Сохранённые поисковые запросы под rule34.xxx и aibooru.online: кнопки Media/Sort работают скрытно (в строку поиска ничего не пишут), фильтры вкл/выкл/исключить, строка и бейджи пропорций, готовые пресеты, бейджи NEW у отслеживаемых авторов. Форк «Save Your Searches» от php.
// ==UserScript== // @name Save Your Searches (custom) // @name:ru Save Your Searches (custom) — кнопки фильтров для rule34.xxx и aibooru.online // @namespace local.rdss.save-your-searches // @version 3.19.1 // @description Save your favourite search queries for easy retrieval, tuned for rule34.xxx and aibooru.online: Media/Sort buttons work silently (nothing is written into the search bar), tri-state on/off/negate filters, aspect-ratio line and badges, ready presets, NEW badges for tracked authors. Fork of "Save Your Searches" by php. // @description:ru Сохранённые поисковые запросы под rule34.xxx и aibooru.online: кнопки Media/Sort работают скрытно (в строку поиска ничего не пишут), фильтры вкл/выкл/исключить, строка и бейджи пропорций, готовые пресеты, бейджи NEW у отслеживаемых авторов. Форк «Save Your Searches» от php. // @author php (original script) / local custom build // @homepageURL https://greasyfork.org/ru/scripts/577306-save-your-searches // @supportURL https://greasyfork.org/ru/scripts/577306-save-your-searches // @license MIT // @match *://rule34.xxx/* // @match *://aibooru.online/* // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant unsafeWindow // @run-at document-start // @require https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js // NOTE: @downloadURL/@updateURL апстрима убраны, чтобы обновление с Greasyfork не затирало локальный форк. // ==/UserScript== (function() { 'use strict'; const Sortable = window.Sortable; // ========================================== // [Module 1] EventBus // ========================================== const EventBus = { events: {}, on(event, listener) { if (!this.events[event]) this.events[event] = []; this.events[event].push(listener); }, emit(event, data) { if (this.events[event]) { // [FIX] один упавший подписчик не должен ломать остальные this.events[event].forEach(listener => { try { listener(data); } catch (err) { console.warn('[SYS] listener error on "' + event + '":', err); } }); } } }; // ========================================== // [Module 2] Helper (The Single Source of Truth) // ========================================== const Helper = { // Helper function for YYYY-MM-DD_HH-mm-ss getFormattedDate() { const d = new Date(); const pad = (n) => n.toString().padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`; }, // [NEW] Порядок индексов кнопок для отображения. // group.alphabetize — по алфавиту (регистр не важен), при этом избранные всегда // в начале списка; внутри избранного алфавитный порядок тоже соблюдается. // Возвращает индексы реального массива group.queries. orderedIndexes(group) { const queries = Array.isArray(group.queries) ? group.queries : []; const indexes = queries.map((q, i) => i); if (!group.alphabetize) return indexes; const labels = group.labels || {}; const favorites = Array.isArray(group.favorite) ? group.favorite : []; // сортируем по тому, что видно на кнопке (у "user:ник" — только ник) const captionOf = (i) => labels[queries[i]] || queries[i]; const byName = (i1, i2) => String(captionOf(i1)).localeCompare(String(captionOf(i2)), undefined, { sensitivity: 'base', numeric: true }); indexes.sort(byName); const favoriteIdx = indexes.filter(i => favorites.indexOf(queries[i]) !== -1); const restIdx = indexes.filter(i => favorites.indexOf(queries[i]) === -1); return favoriteIdx.concat(restIdx); }, // [NEW] Косметические метки кнопок (показываются перед звёздочкой избранного, // на сортировку НЕ влияют — только отображение). Хранятся в group.marks[key]. // Символы юникода: ♂ (male), ⚧ (trans). При желании можно заменить на <img> с data:URI. MARKERS: [ { key: 'male', symbol: '♂', color: '#4a90d9', title: 'Метка ♂' }, { key: 'trans', symbol: '⚧', color: '#d16ba5', title: 'Метка ⚧' }, ], // [NEW] Задержка автоскрытия панели настроек после ухода указателя (мс) HOVER_CLOSE_MS: 600, // --- 2.1 Configuration Data --- Config: { // [NEW] Поддерживаемые сайты: на них есть профиль по умолчанию, чужие профили вычищаются SUPPORTED_HOSTS: ["rule34.xxx", "aibooru.online"], DEFAULT_DATA: { _meta: { scriptVersion: typeof GM_info !== "undefined" ? GM_info.script.version : "Unknown", exportDate: "" // This gets populated dynamically on save/export }, profiles: { "rule34.xxx": { name: "Rule34", selectors: { searchBar: "input[name='tags']", insertPoint: "div.tag-search" }, cheatsheetUrl: "https://rule34.xxx/index.php?page=help&topic=cheatsheet", showCheatsheet: false, wikiUrl: "https://rule34.xxx/index.php?page=wiki&s=list", showWiki: false, autoHideQueryBox: true, // [NEW] автоскрытие панели тегов при уходе указателя (по умолчанию вкл) showSettingsBtn: true, showRatioBadges: false, // [NEW] соотношения на миниатюрах (по умолчанию выключены) delimiter: " ", splitRegex: "\\s+", // [NEW] Селекторы элементов сайта, которые скрипт прячет (кнопка "Filter AI posts") hideSelectors: ["#displayOptions"], groups: [ { // [NEW] Отдельная группа сверху: video + ai, обе трёхсостоянийные id: "g0", label: "Media", managed: true, // [NEW] правится только из данных/кода, в настройках не показывается silent: true, // [NEW] работает «под капотом»: в строку поиска ничего не пишет queries: ["( video ~ animated )", "( ai_generated ~ ai_assisted )"], lock: [], labels: { "( ai_generated ~ ai_assisted )": "ai" }, tristate: { "( video ~ animated )": "-video -animated", "( ai_generated ~ ai_assisted )": "-ai_generated -ai_assisted" } }, { id: "g1", label: "Sort", managed: true, // [NEW] в настройках не показывается silent: true, // [NEW] работает «под капотом» queries: ["sort:score:desc", "sort:id:desc"], // Заблокированные кнопки: нельзя переименовать / удалить / перетащить lock: ["sort:score:desc", "sort:id:desc"], // [NEW] Взаимоисключающие: активен только один, либо ни одного radio: ["sort:score:desc", "sort:id:desc"], // Подписи на кнопках (сам запрос не меняется) labels: { "sort:score:desc": "score", "sort:id:desc": "id" } }, { // [NEW] Авторы: пустой блок сразу под Sort, заполняется в настройках. // alphabetize — алфавитный порядок, favorite — избранные в начале списка id: "g_authors", label: "Authors", queries: [], favorite: [], alphabetize: true }, { // [NEW] Блоки Meta: подписи кнопок — just girl и futa / femboy id: "g2", label: "Meta", queries: [ "-oral -1boy -penis* -sex -blowjob -sperm -cum -cum_* -testicles -large_penis -deep_penetration -penises_touching -yaoi -big_dildo -rape* -futanari* -futa_* -femboy* -feminine_male -dildo -big_penis", "( futanari* ~ otoko_no_ko ~ girly_boy ~ feminine_male ~ trap ~ dickgirl ~ femboy ~ male_on_futa ~ futa_* ) -futa_on_male -gay*" ], labels: { "-oral -1boy -penis* -sex -blowjob -sperm -cum -cum_* -testicles -large_penis -deep_penetration -penises_touching -yaoi -big_dildo -rape* -futanari* -futa_* -femboy* -feminine_male -dildo -big_penis": "just girl", "( futanari* ~ otoko_no_ko ~ girly_boy ~ feminine_male ~ trap ~ dickgirl ~ femboy ~ male_on_futa ~ futa_* ) -futa_on_male -gay*": "futa / femboy" } }, { // [NEW] Блоки пропорций: подписи кнопок задаёт Storage.ASPECT_LABELS id: "g_aspect", label: "Aspect Ratio", queries: [ "( aspectratio:21:9 ~ aspectratio:20:9 ~ aspectratio:19.5:9 ~ aspectratio:43:18 ~ aspectratio:64:27 ~ aspectratio:12:5 ~ aspectratio:2.39:1 ~ aspectratio:2.35:1 ~ aspectratio:32:9 )", "( aspectratio:1:1 ~ aspectratio:5:4 ~ aspectratio:4:3 ~ aspectratio:11:9 )", "( aspectratio:16:9 ~ aspectratio:16:10 ~ aspectratio:1.85:1 ~ aspectratio:2:1 ~ aspectratio:17:9 ~ aspectratio:14:9 ~ aspectratio:1.78:1 )", "( aspectratio:9:19.5 ~ aspectratio:9:20 ~ aspectratio:9:21 ~ aspectratio:9:22 ~ aspectratio:1:2 ~ aspectratio:1:3 )", "( aspectratio:9:16 ~ aspectratio:10:16 ~ aspectratio:3:4 ~ aspectratio:4:5 ~ aspectratio:9:18 ~ aspectratio:13:19 ~ aspectratio:2:3 ~ aspectratio:10:15 )" ] } ] }, // [NEW] AIBooru (aibooru.online) — Danbooru-подобный AI-борд. Ключевые отличия: // поиск — GET-форма /posts (input[name=tags]), вставка — после section#search-box; // превью — <article id="post_N" data-id data-tags> (не span.thumb); // инфо-лист — section#post-information (не #stats); тег ai не нужен — весь сайт про AI. "aibooru.online": { name: "AIBooru", selectors: { searchBar: "input[name='tags']", insertPoint: "section#search-box" }, cheatsheetUrl: "https://aibooru.online/wiki_pages/help%3Acheatsheet", showCheatsheet: false, // [NEW] кнопку НЕ включаем — только ссылка wikiUrl: "https://aibooru.online/wiki_pages/help:home", showWiki: false, autoHideQueryBox: true, showSettingsBtn: true, showRatioBadges: false, delimiter: " ", splitRegex: "\\s+", hideSelectors: [], aibVer: 2, // версия штатного состава профиля (см. migrateAibooruProfile) groups: [ { // [NEW] Media без "ai" (весь сайт — ai-арт). video — дочерний тег // от animated (где есть video, там всегда есть animated), поэтому // остаётся ОДИН тег: экономит 2 из 4 тегов лимита поиска. id: "g0", label: "Media", managed: true, silent: true, queries: ["animated"], lock: [], tristate: { "animated": "-animated" } }, { // [NEW] Сортировка aibooru: только подтверждённые метатеги // (order:rank/ views — из ссылок сайта, score/ favcount — из справки) id: "g1", label: "Sort", managed: true, silent: true, queries: ["order:rank", "order:views", "order:score", "order:favcount"], lock: ["order:rank", "order:views", "order:score", "order:favcount"], radio: ["order:rank", "order:views", "order:score", "order:favcount"], labels: { "order:rank": "rank", "order:views": "views", "order:score": "score", "order:favcount": "fav" } }, { // [NEW] Авторы: сайт поддерживает user:<ник> (ссылка » у Uploader) id: "g_authors", label: "Authors", queries: [], favorite: [], alphabetize: true }, { // [NEW] Meta под авторами: те же плашки, что на rule34, но под теги // aibooru (без ~ и *). Порядок и подписи — как у rule34. id: "g2", label: "Meta", queries: ["-sex -penis", "otoko_no_ko or futanari"], labels: { "-sex -penis": "just girl", "otoko_no_ko or futanari": "futa / femboy" } }, { // [NEW] Пропорции на aibooru: те же 5 категорий, что на rule34 // (Ultrawide / Square / Landscape / Tall Portrait / Portrait), но // вместо тегов aspectratio: — ЧИСЛОВЫЕ диапазоны ratio: (метатег // бесплатный). Границы — min..max значений категорий rule34, // подписи берутся из Storage.ASPECT_LABELS_RANGE. id: "g_aspect", label: "Aspect Ratio", silent: true, queries: [ "ratio:2.15..", "ratio:0.88..1.39", "ratio:1.4..2.1", "ratio:..0.5", "ratio:0.51..0.87" ], labels: { "ratio:2.15..": "Ultrawide", "ratio:0.88..1.39": "Square", "ratio:1.4..2.1": "Landscape", "ratio:..0.5": "Tall Portrait", "ratio:0.51..0.87": "Portrait" } }, { // [NEW] Статусы: status: тоже бесплатный метатег, одинаковый // метатег между собой не комбинируется → взаимоисключающая группа id: "g_status", label: "Status", silent: true, queries: ["status:active", "status:pending", "status:flagged", "status:deleted"], radio: ["status:active", "status:pending", "status:flagged", "status:deleted"], labels: { "status:active": "active", "status:pending": "pending", "status:flagged": "flagged", "status:deleted": "deleted" } }, { // [NEW] Рейтинг в самом низу списка: SFW/NSFW. is: входит в список // БЕСПЛАТНЫХ метатегов (не идёт в счётчик тегов), а is:sfw = g+s, // is:nsfw = q+e — две взаимоисключающие кнопки вместо четырёх. id: "g_rating", label: "Rating", silent: true, queries: ["is:sfw", "is:nsfw"], radio: ["is:sfw", "is:nsfw"], labels: { "is:sfw": "SFW", "is:nsfw": "NSFW" } } ] }, } } }, // --- 2.2 Styles Definition --- Styles: { PANEL_CSS: ` .panel { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 95%; max-width: 400px; /* Caps the size for desktop */ max-height: 90vh; background: rgba(255,255,255,0.90); backdrop-filter: blur(15px); border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); z-index: 20000; display: none; flex-direction: column; font-family: system-ui, sans-serif; border: 1px solid rgba(0,0,0,0.1); } .header { padding: 15px; background: rgba(0,0,0,0.05); cursor: move; display: flex; justify-content: center; align-items: center; border-bottom: 1px solid rgba(0,0,0,0.1); font-weight: bold; } .content { padding: 15px; overflow-y: auto; flex: 1; } .footer { padding: 12px; display: flex; flex-direction: column; gap: 8px; background: #f9f9f9; border-top: 1px solid #eee; border-radius: 0 0 12px 12px; } .footer-row { display: flex; gap: 8px; width: 100%; } .section-title { font-size: 11px; font-weight: bold; color: #888; text-transform: uppercase; margin: 15px 0 8px 0; } .config-field { margin-bottom: 10px; } .config-field label { display: block; font-size: 11px; margin-bottom: 4px; color: #666; font-weight: 600; } .config-field input[type="text"], .config-field select { width: 100%; box-sizing: border-box; padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; outline: none; } .checkbox-group { display: flex; flex-direction: column; gap: 8px; margin-top: 5px; } .checkbox-field { display: flex; align-items: center; gap: 6px; font-size: 12px; cursor: pointer; user-select: none; color: #444; } .card { background: white; padding: 12px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #eee; position: relative; } .sub-card { background: #f8f9fa; padding: 12px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #eaeced; position: relative; } .sub-card:last-child { margin-bottom: 0; } /* Removes extra space at the bottom of the container */ .card-title { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } /* 幽靈輸入框樣式:平時像文字,互動時顯現 */ .group-label-input { background: transparent; border: none; /* Show a very faint line by default for mobile users */ border-bottom: 1px dashed rgba(0,0,0,0.1); font-weight: bold; font-size: 13px; color: #444; width: 100%; outline: none; transition: all 0.2s; cursor: text; padding: 2px 0; } .group-label-input:focus { border-bottom: 1px solid #2196f3; background: rgba(33, 150, 243, 0.05); } /* 排序模式下讓輸入框看起來像標籤,且不可點擊 */ .group-label-input:disabled { cursor: default; border: none; } .tag-chip { display: inline-flex; align-items: center; background: #f0f0f0; padding: 4px 10px; border-radius: 6px; margin: 3px; font-size: 12px; border: 1px solid #e0e0e0; color: #000;} .tag-del { margin-left: 6px; color: #f44336; cursor: pointer; font-weight: bold; } /* [NEW] Кнопка избранного (стоит перед удалением) */ .tag-fav { margin-left: 6px; color: #ffb300; cursor: pointer; font-size: 13px; line-height: 1; } .tag-fav:hover { color: #ff8f00; } .tag-fav.on { color: #ffc107; text-shadow: 0 0 4px rgba(255,193,7,0.6); } /* [NEW] Косметические метки (♂ / ⚧) — перед звёздочкой, на сортировку не влияют */ .tag-mark { margin-left: 6px; cursor: pointer; font-size: 13px; line-height: 1; color: #bdbdbd; transition: color 0.15s; } .tag-mark:hover { opacity: 0.7; } .tag-mark.on { text-shadow: 0 0 3px rgba(0,0,0,0.15); } .tag-text { cursor: pointer; transition: color 0.15s; } .tag-text:hover { color: #2196f3; } /* Заблокированная кнопка: выглядит как тег, но не редактируется и не удаляется */ .tag-fixed { background: #eceff1; border-style: dashed; } .tag-fixed-text { cursor: default; color: #546e7a; font-weight: 600; } .tag-fixed-text:hover { color: #546e7a; } .sortable-ghost { opacity: 0.4; border: 2px dashed #2196f3 !important; background: #e3f2fd !important; } .drag-handle-group { cursor: grab; color: #bbb; font-size: 22px; padding: 15px; margin: -15px 0 -15px -15px; /* Negative margin offsets the padding */ display: inline-flex; align-items: center; justify-content: center; } .drag-handle-tag { cursor: grab; color: #ccc; font-size: 18px; padding: 10px; margin: -10px 0 -10px -10px; /* Negative margin offsets the padding */ display: inline-flex; align-items: center; justify-content: center; } /* Change color when dragging to show it's "active" */ .drag-handle-group:active, .drag-handle-tag:active { color: #2196f3; } .btn { padding: 8px 16px; border-radius: 6px; cursor: pointer; border: none; font-size: 13px; font-weight: 500; transition: all 0.2s; display: inline-flex; align-items: center; justify-content: center; } .btn-add { background: #4caf50; color: white; margin-top: 5px; width: 100%; } .btn-current { background: #ff9800; color: white; } .btn:hover, .btn:active { filter: brightness(0.9); } .btn-save { background: #2196f3; color: white; } /* [Modified] Rearrange button style adjusted for inline title placement */ .btn-rearrange { background: #90a4ae; color: white; padding: 4px 10px; font-size: 11px; border-radius: 4px; } .btn-rearrange.active { background: #e91e63; } .btn-reset { background: #f44336; color: white; margin-right: auto; } .btn-data { background: #607d8b; color: white; flex: 1; font-size: 12px; padding: 6px; } .modal-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: none; align-items: center; justify-content: center; z-index: 20001; border-radius: 12px; } .modal-box { background: white; padding: 20px; border-radius: 10px; width: 280px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); text-align: center; } .modal-title { font-size: 14px; font-weight: bold; margin-bottom: 15px; color: #333; } .modal-buttons { display: flex; flex-direction: column; gap: 10px; } .btn-modal { padding: 10px; border-radius: 6px; cursor: pointer; border: none; font-size: 13px; font-weight: bold; text-align: center; transition: opacity 0.2s; } .btn-modal:hover { opacity: 0.8; } .btn-modal-current { background: #2196f3; color: white; } .btn-modal-all { background: #4caf50; color: white; } .btn-modal-cancel { background: #f44336; color: white; } .warning-box { background: #fff3cd; color: #856404; padding: 10px; border-radius: 8px; border: 1px solid #ffeeba; margin-bottom: 15px; font-size: 12px; line-height: 1.4; } `, INJECTOR_WRAPPER: "margin: 10px 0; font-family: sans-serif; display: flex; gap: 8px; flex-wrap: wrap; justify-content: center;", TRIGGER_BTN: "padding: 6px; background: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 500;", SETTINGS_BTN: "padding: 6px; background: #3f51b5; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 500;", CHEATSHEET_BTN: "padding: 6px; background: #009688; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; text-decoration: none; font-weight: 500;", WIKI_BTN: "padding: 6px; background: #ff5722; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; text-decoration: none; font-weight: 500;", QUERY_BOX: "width: 100%; margin-top: 5px; display: flex; flex-direction: column; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.05);", GROUP_CARD: (isLast) => `padding: 12px; border-bottom: ${isLast ? 'none' : '2px solid #eee'}`, GROUP_TITLE: "font-size: 11px; font-weight: bold; color: #888; text-transform: uppercase; margin-bottom: 8px;", TAG_ITEM: "background: white; color: #000; border: 1px solid #ced4da; padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 14px; transition: all 0.15s ease;", TAG_ITEM_ACTIVE: "background: #e3f2fd; border: 1px solid #2196f3; color: #0d47a1; padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 14px; transition: all 0.15s ease;", // [NEW] Состояния трёхсостоянийной кнопки: зелёный = запрос, красный = -запрос TAG_ITEM_ACTIVE_POS: "background: #c8e6c9; border: 1px solid #4caf50; color: #1b5e20; padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.15s ease;", TAG_ITEM_ACTIVE_NEG: "background: #ffcdd2; border: 1px solid #f44336; color: #b71c1c; padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.15s ease;", // [NEW] Строка соотношения сторон на странице поста (под "Size:") RATIO_LINE: "display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 4px 0; font-size: 13px; font-family: sans-serif;", RATIO_NAME: (known) => `font-weight: 600; padding: 2px 6px; border-radius: 4px; ${known ? "background: #c8e6c9; color: #1b5e20; border: 1px solid #4caf50;" : "background: #ffcdd2; color: #b71c1c; border: 1px solid #f44336;"}`, RATIO_CHIP: "background: white; color: #000; border: 1px solid #ced4da; padding: 2px 6px; border-radius: 4px; cursor: pointer; font-size: 12px;", RATIO_CHIP_ACTIVE: "background: #c8e6c9; color: #1b5e20; border: 1px solid #4caf50; padding: 2px 6px; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 600;", // [NEW] Бейдж на превьюшке в списке: зелёный — значение есть в базе, красный — новое THUMB_BADGE: (known) => `position: absolute; left: 8px; top: 8px; z-index: 5; pointer-events: none; font: 600 11px/1.5 sans-serif; padding: 1px 5px; border-radius: 4px; color: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.35); ${known ? "background: #4caf50;" : "background: #f44336;"}`, // [NEW] Бейдж NEW у новых постов отслеживаемых авторов (правый верхний угол) NEW_BADGE: "position: absolute; right: 8px; top: 8px; z-index: 6; pointer-events: none; font: 700 11px/1.5 sans-serif; padding: 1px 6px; border-radius: 4px; color: #fff; background: #d32f2f; box-shadow: 0 1px 3px rgba(0,0,0,0.4);", // [NEW] Красная лампочка «данных нет» у автора, чью страницу ещё ни разу не открывали NO_DATA_DOT: "display: inline-block; margin-left: 5px; font-size: 12px; line-height: 1; color: #e53935;", }, // --- 2.3 HTML UI Components --- Templates: { PANEL_SHELL: (contentHTML) => ` <style>${Helper.Styles.PANEL_CSS}</style> <div class="panel" id="main-panel"> <div class="header" id="drag-handle">Save Your Searches</div> <div class="content" id="settings-content">${contentHTML}</div> <div class="footer"> <div class="footer-row"> <button class="btn btn-data" id="btn-export-trigger">📤 Export</button> <button class="btn btn-data" id="btn-import-trigger">📥 Import</button> <input type="file" id="sys-import-file" style="display:none" accept=".json"> </div> <div class="footer-row"> <button class="btn btn-reset" id="btn-reset">Reset</button> <button class="btn" id="btn-cancel">Cancel</button> <button class="btn btn-save" id="btn-save">Save & Apply</button> </div> </div> <div class="modal-overlay" id="action-modal"> <div class="modal-box"> <div class="modal-title" id="modal-title">Select Scope</div> <div class="modal-buttons"> <button class="btn-modal btn-modal-current" id="modal-btn-current">Only Current Website</button> <button class="btn-modal btn-modal-all" id="modal-btn-all">All Websites</button> <button class="btn-modal btn-modal-cancel" id="modal-btn-cancel">Cancel</button> </div> </div> </div> </div> `, SITE_SELECTORS_SECTION: (profile, isRearranging) => ` <div class="section-title">Site Selectors & Tag Delimiter</div> <div class="card"> <div class="config-field"><label>Search Bar Selector</label><input type="text" value="${profile.selectors.searchBar}" id="input-sel-search" ${isRearranging?'disabled':''}></div> <div class="config-field"><label>Insert Point Selector</label><input type="text" value="${profile.selectors.insertPoint}" id="input-sel-insert" ${isRearranging?'disabled':''}></div> <hr style="border: 0; border-top: 1px solid #eee; margin: 12px 0;"> <div style="display:flex; gap:10px;"> <div class="config-field" style="flex:1;"><label>Tag Delimiter Character</label><input type="text" value="${profile.delimiter}" id="input-delimiter" ${isRearranging?'disabled':''}></div> <div class="config-field" style="flex:1;"><label>Tag Delimiter Regex</label><input type="text" value="${profile.splitRegex}" id="input-split-regex" ${isRearranging?'disabled':''}></div> </div> </div> `, SITE_RESOURCES_SECTION: (profile, isRearranging) => ` <div class="section-title">Site Resources & Buttons</div> <div class="card"> <div class="config-field"><label>Cheatsheet URL</label><input type="text" value="${profile.cheatsheetUrl || ''}" id="input-cheatsheet-url" ${isRearranging?'disabled':''}></div> <div class="config-field"><label>Wiki URL</label><input type="text" value="${profile.wikiUrl || ''}" id="input-wiki-url" ${isRearranging?'disabled':''}></div> <hr style="border: 0; border-top: 1px solid #eee; margin: 12px 0;"> <div class="checkbox-group"> <label class="checkbox-field"><input type="checkbox" id="check-show-ratios" ${profile.showRatioBadges ? 'checked' : ''} ${isRearranging?'disabled':''}> Show Ratio Badges on Thumbnails</label> <label class="checkbox-field"><input type="checkbox" id="check-show-cheatsheet" ${profile.showCheatsheet ? 'checked' : ''} ${isRearranging?'disabled':''}> Show Cheatsheet</label> <label class="checkbox-field"><input type="checkbox" id="check-show-wiki" ${profile.showWiki ? 'checked' : ''} ${isRearranging?'disabled':''}> Show Wiki</label> <label class="checkbox-field"><input type="checkbox" id="check-show-settings" ${profile.showSettingsBtn !== false ? 'checked' : ''} ${isRearranging?'disabled':''}> Show Settings Button</label> <label class="checkbox-field"><input type="checkbox" id="check-auto-hide" ${profile.autoHideQueryBox !== false ? 'checked' : ''} ${isRearranging?'disabled':''}> Auto-hide Saved Searches Box (on mouse leave)</label> </div> </div> `, // [MODIFIED] Заблокированные кнопки (group.lock) не удаляются, не переименовываются и не перетаскиваются. // group.labels даёт подпись на кнопке, group.tristate включает трёхсостоянийный режим (query <-> alt). TAG_GROUP_HTML: (group, gIdx, isRearranging) => { const lockList = Array.isArray(group.lock) ? group.lock : []; const favoriteList = Array.isArray(group.favorite) ? group.favorite : []; const marks = group.marks || {}; const labels = group.labels || {}; const tristate = group.tristate || {}; // [NEW] значки гендеров и звёздочка — только у авторов и Meta const marksOK = Storage.allowsMarks(group); return ` <div class="sub-card sortable-group-item" data-gidx="${gIdx}"> <div class="card-title"> <div style="display:flex; align-items:center; flex:1;"> ${isRearranging ? `<span class="drag-handle-group">☰</span>` : ''} <input type="text" value="${group.label}" class="group-label-input" style="margin-left: 10px;" data-gidx="${gIdx}" ${isRearranging ? 'disabled' : ''} placeholder="Group Title"> </div> ${!isRearranging ? `<span class="tag-del delete-group" data-gidx="${gIdx}">✖</span>` : ''} </div> <div style="display:flex; flex-wrap:wrap; min-height: 20px;" class="tags-sortable-container" data-gidx="${gIdx}"> ${Helper.orderedIndexes(group).map((qIdx) => { const q = group.queries[qIdx]; const isLocked = lockList.indexOf(q) !== -1; const isFavorite = favoriteList.indexOf(q) !== -1; const tristateAlt = tristate[q] || ''; const caption = labels[q] || q; const hint = isLocked ? `Locked button: ${q}` : (tristateAlt ? `${q} → ${tristateAlt} → off` : (caption === q ? (isRearranging ? '' : 'Click to edit') : `${q}${isRearranging ? '' : ' — Click to edit'}`)); return ` <span class="tag-chip ${isLocked ? 'tag-fixed' : 'sortable-tag-item'}" data-qidx="${qIdx}"> ${isRearranging && !isLocked ? `<span class="drag-handle-tag">⋮</span>` : ''} <span class="tag-text ${isLocked ? 'tag-fixed-text' : 'edit-tag'}" data-gidx="${gIdx}" data-qidx="${qIdx}" title="${hint}">${caption}</span> ${!isRearranging && !isLocked ? ` ${marksOK ? Helper.MARKERS.map(m => { const on = (marks[m.key] || []).indexOf(q) !== -1; return `<span class="tag-mark toggle-mark ${on ? 'on' : ''}" data-gidx="${gIdx}" data-qidx="${qIdx}" data-mark="${m.key}" style="${on ? 'color:' + m.color : ''}" title="${on ? 'Убрать метку ' + m.symbol : m.title}">${m.symbol}</span>`; }).join('') : ''} ${marksOK ? `<span class="tag-fav toggle-fav ${isFavorite ? 'on' : ''}" data-gidx="${gIdx}" data-qidx="${qIdx}" title="${isFavorite ? 'Убрать из избранного' : 'В избранное (в начало списка)'}">${isFavorite ? '★' : '☆'}</span>` : ''} <span class="tag-del delete-tag" data-gidx="${gIdx}" data-qidx="${qIdx}">×</span> ` : ''} </span> `; }).join('')} </div> ${!isRearranging ? ` <div style="display:flex; gap:5px; margin-top:10px;"> <input type="text" placeholder="Add tag..." class="new-tag-input" data-gidx="${gIdx}" style="flex:1; padding:4px;"> <button class="btn btn-save add-tag-btn" data-gidx="${gIdx}" style="padding:4px 12px;">+</button> <button class="btn btn-current add-current-query-btn" data-gidx="${gIdx}" title="Add current search bar content" style="padding:4px 8px;">+ Current</button> </div> ` : ''} </div> `; }, NEW_PROFILE_WARNING: ` <div class="warning-box"> <strong>⚠️ New Website Detected!</strong><br> This website doesn't have a profile yet. Please click <b>"Save & Apply"</b> to save settings for this site. </div> `, SAVED_SEARCHES_HEADER: (isRearranging) => ` <div class="section-title" style="display:flex; justify-content:space-between; align-items:center;"> <span>Saved Searches ${isRearranging ? '(Rearrange Mode)' : ''}</span> <button class="btn btn-rearrange" id="btn-rearrange">⇅ Rearrange</button> </div> <div id="cards-container" class="card"></div> ` } }; // ========================================== // [Module 3] Storage // ========================================== const Storage = { STORAGE_KEY: "SaveYourSearches_Data", // [NEW] Встроенная группа "Media" (rule34): кнопки video и ai, обе трёхсостоянийные MEDIA_GROUP: { id: "g0", label: "Media", silent: true, // «под капотом»: кнопки не пишут в строку поиска video: { query: "( video ~ animated )", alt: "-video -animated", caption: "video" }, ai: { query: "( ai_generated ~ ai_assisted )", alt: "-ai_generated -ai_assisted", caption: "ai" } }, // [NEW] Блоки группы Meta: запрос + подпись на кнопке META_BLOCKS: [ { query: "-oral -1boy -penis* -sex -blowjob -sperm -cum -cum_* -testicles -large_penis -deep_penetration -penises_touching -yaoi -big_dildo -rape* -futanari* -futa_* -femboy* -feminine_male -dildo -big_penis", caption: "just girl" }, { query: "( futanari* ~ otoko_no_ko ~ girly_boy ~ feminine_male ~ trap ~ dickgirl ~ femboy ~ male_on_futa ~ futa_* ) -futa_on_male -gay*", caption: "futa / femboy" } ], META_PRESET_VERSION: 5, // [NEW] Разовая замена набора Meta: ставим блоки из META_BLOCKS applyMetaPreset(profile) { if (!profile || !Array.isArray(profile.groups)) return null; if (profile.metaPreset === this.META_PRESET_VERSION) return null; const group = profile.groups.find(g => String(g.label || '').trim().toLowerCase() === 'meta') || profile.groups.find(g => g.id === 'g2'); if (!group) return null; const labels = {}; this.META_BLOCKS.forEach(b => { labels[b.query] = b.caption; }); group.queries = this.META_BLOCKS.map(b => b.query); group.labels = labels; group.active = []; group.activeNeg = []; group.favorite = []; group.tristate = {}; profile.metaPreset = this.META_PRESET_VERSION; console.log('[SYS] meta: блоков загружено — ' + group.queries.length); return group; }, // [NEW] Понятные подписи для группы Aspect Ratio: длинные OR-блоки // на кнопках заменяются коротким названием (сам запрос не меняется). // RATIO_MATCH_TOLERANCE — допуск 2% при сравнении соотношений по величине // (8:5 и 16:10 = одно значение). Размеры превьюшек округлены до целых // пикселей, но ошибка там ≤0.5%, а минимальный зазор между разными // значениями базы — 2.3%, так что 2% ничего лишнего не склеивает. RATIO_MATCH_TOLERANCE: 0.02, ASPECT_LABELS: { "( aspectratio:21:9 ~ aspectratio:20:9 ~ aspectratio:19.5:9 ~ aspectratio:43:18 ~ aspectratio:64:27 ~ aspectratio:12:5 ~ aspectratio:2.39:1 ~ aspectratio:2.35:1 ~ aspectratio:32:9 ~ aspectratio:18:7 )": "Ultrawide", "( aspectratio:1:1 ~ aspectratio:5:4 ~ aspectratio:4:3 ~ aspectratio:11:9 ~ aspectratio:9:7 ~ aspectratio:21:20 ~ aspectratio:11:8 ~ aspectratio:26:29 ~ aspectratio:21:22 ~ aspectratio:20:17 ~ aspectratio:10:9 ~ aspectratio:31:27 ~ aspectratio:29:27 ~ aspectratio:25:27 )": "Square", "( aspectratio:16:9 ~ aspectratio:16:10 ~ aspectratio:1.85:1 ~ aspectratio:2:1 ~ aspectratio:17:9 ~ aspectratio:14:9 ~ aspectratio:1.78:1 ~ aspectratio:17:12 ~ aspectratio:39:23 ~ aspectratio:40:23 ~ aspectratio:53:32 ~ aspectratio:47:24 ~ aspectratio:3:2 ~ aspectratio:19:13 )": "Landscape", "( aspectratio:9:19.5 ~ aspectratio:9:20 ~ aspectratio:9:21 ~ aspectratio:9:22 ~ aspectratio:1:2 ~ aspectratio:1:3 ~ aspectratio:9:25 ~ aspectratio:15:31 ~ aspectratio:3:22 ~ aspectratio:3:20 ~ aspectratio:2:29 ~ aspectratio:10:31 ~ aspectratio:3:16 ~ aspectratio:3:47 ~ aspectratio:1:10 ~ aspectratio:1:5 ~ aspectratio:3:10 ~ aspectratio:2:5 )": "Tall Portrait", "( aspectratio:9:16 ~ aspectratio:10:16 ~ aspectratio:3:4 ~ aspectratio:4:5 ~ aspectratio:9:18 ~ aspectratio:13:19 ~ aspectratio:2:3 ~ aspectratio:10:15 ~ aspectratio:17:20 ~ aspectratio:7:9 ~ aspectratio:17:28 ~ aspectratio:19:26 ~ aspectratio:12:17 ~ aspectratio:15:23 ~ aspectratio:20:23 ~ aspectratio:19:23 ~ aspectratio:17:31 ~ aspectratio:13:25 ~ aspectratio:10:19 ~ aspectratio:10:17 )": "Portrait" }, // [NEW] Группа с соотношениями сторон: подписи вместо длинных запросов. // Подпись подбирается по СОДЕРЖИМОМУ (набору значений aspectratio:), а не по точной // строке — иначе после дописывания нового соотношения в OR-блок подпись бы терялась. ensureAspectLabels(profile) { const group = this.aspectGroup(profile); if (!group) return null; group.labels = Object.assign({}, group.labels || {}); // [NEW] штатные подписи навешиваем разово (как Meta): переименование кнопки // пользователем больше не откатывается при каждой загрузке const applyDefaults = group.labelsVersion !== this.ASPECT_LABELS_VERSION; const known = Object.keys(this.ASPECT_LABELS).map(query => ({ caption: this.ASPECT_LABELS[query], values: (query.match(/aspectratio:[0-9.]+:[0-9.]+/gi) || []).map(v => v.toLowerCase()) })); group.queries.forEach(query => { const exact = this.ASPECT_LABELS[query] || this.ASPECT_LABELS_RANGE[query]; if (exact) { if (applyDefaults || !group.labels[query]) group.labels[query] = exact; return; } const values = (String(query).match(/aspectratio:[0-9.]+:[0-9.]+/gi) || []).map(v => v.toLowerCase()); if (!values.length) return; let best = null; known.forEach(k => { if (!k.values.length) return; const all = k.values.every(v => values.indexOf(v) !== -1); if (all && (!best || k.values.length > best.values.length)) best = k; }); if (!best) { // запрос может быть «урезанным» вариантом штатного блока (или наоборот // штатный блок дополнен): тогда берём категорию с наибольшим перекрытием let hits = 0; known.forEach(k => { if (!k.values.length) return; const h = k.values.filter(v => values.indexOf(v) !== -1).length; if (h > hits) { hits = h; best = k; } }); if (best && hits / values.length < 0.6) best = null; } if (best && (applyDefaults || !group.labels[query])) group.labels[query] = best.caption; }); if (applyDefaults) group.labelsVersion = this.ASPECT_LABELS_VERSION; return group; }, ASPECT_LABELS_VERSION: 1, // [NEW] Те же 5 категорий, но для сайтов с метатегом ratio (aibooru.online): // запрос — ЧИСЛОВЫЙ диапазон как у id (`ratio:1.4..2.1`, `ratio:..0.5`, `ratio:2.15..`). // Границы подобраны по значениям ASPECT_LABELS (min..max каждой категории rule34), // зазоры между категориями сохранены, чтобы диапазоны не накладывались. ASPECT_LABELS_RANGE: { "ratio:2.15..": "Ultrawide", // 19.5:9 (2.17) … 32:9 (3.56) "ratio:0.88..1.39": "Square", // 26:29 (0.90) … 11:8 (1.375) "ratio:1.4..2.1": "Landscape", // 17:12 (1.42) … 2:1 (2.00) "ratio:..0.5": "Tall Portrait", // 3:47 (0.064) … 1:2 (0.50) "ratio:0.51..0.87": "Portrait" // 13:25 (0.52) … 20:23 (0.87) }, // [NEW] Группа с соотношениями сторон. Ищем по названию/ид, а если не нашли — // по содержимому (любой запрос с aspectratio:), чтобы блок находился даже после // переименования или сброса данных. aspectGroup(profile) { if (!profile || !Array.isArray(profile.groups)) return null; return profile.groups.find(g => /aspect/i.test(String(g.label || '')) || g.id === "g_aspect") || profile.groups.find(g => (Array.isArray(g.queries) ? g.queries : []).some(q => /aspectratio:/i.test(String(q)))) || null; }, // [NEW] Где живёт группа соотношений: { host, group } в актуальных данных findAspectGroup(data, host) { const profiles = (data && data.profiles) || {}; const own = profiles[host] ? this.aspectGroup(profiles[host]) : null; if (own) return { host, group: own }; const other = Object.keys(profiles).find(h => this.aspectGroup(profiles[h])); return other ? { host: other, group: this.aspectGroup(profiles[other]) } : null; }, // [NEW] Постоянный набор блоков пропорций для rule34 (на случай сброса данных) ASPECT_GROUP: { id: "g_aspect", label: "Aspect Ratio", silent: true, // «под капотом»: кнопки не пишут в строку поиска queries: [ "( aspectratio:21:9 ~ aspectratio:20:9 ~ aspectratio:19.5:9 ~ aspectratio:43:18 ~ aspectratio:64:27 ~ aspectratio:12:5 ~ aspectratio:2.39:1 ~ aspectratio:2.35:1 ~ aspectratio:32:9 ~ aspectratio:18:7 )", "( aspectratio:1:1 ~ aspectratio:5:4 ~ aspectratio:4:3 ~ aspectratio:11:9 ~ aspectratio:9:7 ~ aspectratio:21:20 ~ aspectratio:11:8 ~ aspectratio:26:29 ~ aspectratio:21:22 ~ aspectratio:20:17 ~ aspectratio:10:9 ~ aspectratio:31:27 ~ aspectratio:29:27 ~ aspectratio:25:27 )", "( aspectratio:16:9 ~ aspectratio:16:10 ~ aspectratio:1.85:1 ~ aspectratio:2:1 ~ aspectratio:17:9 ~ aspectratio:14:9 ~ aspectratio:1.78:1 ~ aspectratio:17:12 ~ aspectratio:39:23 ~ aspectratio:40:23 ~ aspectratio:53:32 ~ aspectratio:47:24 ~ aspectratio:3:2 ~ aspectratio:19:13 )", "( aspectratio:9:19.5 ~ aspectratio:9:20 ~ aspectratio:9:21 ~ aspectratio:9:22 ~ aspectratio:1:2 ~ aspectratio:1:3 ~ aspectratio:9:25 ~ aspectratio:15:31 ~ aspectratio:3:22 ~ aspectratio:3:20 ~ aspectratio:2:29 ~ aspectratio:10:31 ~ aspectratio:3:16 ~ aspectratio:3:47 ~ aspectratio:1:10 ~ aspectratio:1:5 ~ aspectratio:3:10 ~ aspectratio:2:5 )", "( aspectratio:9:16 ~ aspectratio:10:16 ~ aspectratio:3:4 ~ aspectratio:4:5 ~ aspectratio:9:18 ~ aspectratio:13:19 ~ aspectratio:2:3 ~ aspectratio:10:15 ~ aspectratio:17:20 ~ aspectratio:7:9 ~ aspectratio:17:28 ~ aspectratio:19:26 ~ aspectratio:12:17 ~ aspectratio:15:23 ~ aspectratio:20:23 ~ aspectratio:19:23 ~ aspectratio:17:31 ~ aspectratio:13:25 ~ aspectratio:10:19 ~ aspectratio:10:17 )" ] }, // [NEW] Группа соотношений существует всегда: если её нет (сброс/новый профиль), // добавляем в конец со штатными блоками; если есть, но пустая — заполняем. ensureAspectGroup(profile) { let group = this.aspectGroup(profile); if (!group) { group = { id: this.ASPECT_GROUP.id, label: this.ASPECT_GROUP.label, silent: true, queries: this.ASPECT_GROUP.queries.slice() }; profile.groups.push(group); return group; } if (!Array.isArray(group.queries)) group.queries = []; if (!group.queries.length) group.queries = this.ASPECT_GROUP.queries.slice(); return group; }, // [NEW] Разовая чистка группы пропорций: теги, дописанные кликами по постам, // переезжают в group.hidden, а сами запросы возвращаются к 5 штатным OR-блокам. // Так строка поиска перестаёт пухнуть, а прежние отметки не теряются. sanitizeAspectGroup(profile) { const group = this.aspectGroup(profile); if (!group || group.sanitized === this.ASPECT_SANITIZE_VERSION) return group; if (!Array.isArray(group.queries) || !group.queries.length) return group; const defaults = this.ASPECT_GROUP.queries; const labels = Object.assign({}, group.labels || {}); const hidden = Object.assign({}, group.hidden || {}); const tagsOf = q => (String(q || '').match(/aspectratio:\s*[0-9.]+\s*:\s*[0-9.]+/gi) || []) .map(t => t.toLowerCase().replace(/\s+/g, '')); const allowed = {}; defaults.forEach(q => tagsOf(q).forEach(t => { allowed[t] = true; })); const custom = []; const active = Array.isArray(group.active) ? group.active : []; const activeNext = []; // узнать штатный блок по содержимому: у него больше всего совпадающих тегов const bestByTags = (tags) => { let best = null, bestScore = 0; defaults.forEach(q => { const set = tagsOf(q); const hits = set.filter(t => tags.indexOf(t) !== -1).length; const score = tags.length ? hits / tags.length : 0; // доля тегов запроса, найденных в штатном блоке if (score > bestScore || (score === bestScore && best && set.length < tagsOf(best).length)) { bestScore = score; best = q; } }); return bestScore >= 0.6 ? best : null; }; group.queries.forEach(query => { const caption = labels[query]; const tags = tagsOf(query); const def = defaults.find(q => this.ASPECT_LABELS[q] === caption) || (defaults.indexOf(query) !== -1 ? query : null) || bestByTags(tags); // всё, чего нет в штатных блоках — это старые отметки кликов: переносим в hidden const target = def ? (this.ASPECT_LABELS[def] || def) : (caption || query); tags.forEach(t => { if (!allowed[t]) hidden[t] = target; }); if (def) { if (caption) labels[def] = caption; } else if (custom.indexOf(query) === -1) custom.push(query); // включённые фильтры переводим на штатный текст блока if (active.indexOf(query) !== -1) { const on = def || query; if (activeNext.indexOf(on) === -1) activeNext.push(on); } }); // [NEW v2] теги, которые теперь входят в штатные блоки, больше не считаются // переопределением категории (иначе старые отметки уводят бейдж не туда) Object.keys(hidden).forEach(t => { if (allowed[t]) delete hidden[t]; }); const queries = defaults.slice(); custom.forEach(query => { let cleaned = query; tagsOf(query).forEach(t => { if (!allowed[t]) cleaned = this.removeRatioFromQuery(cleaned, t); }); if (cleaned && queries.indexOf(cleaned) === -1) queries.push(cleaned); }); group.queries = queries; group.labels = labels; group.hidden = hidden; // включённые фильтры должны существовать в списке категорий group.active = activeNext.filter(q => queries.indexOf(q) !== -1); group.sanitized = this.ASPECT_SANITIZE_VERSION; const moved = Object.keys(hidden).length; console.log('[SYS] aspect: запросы очищены — лишние теги убраны из строки поиска, под капотом записей:', moved); return group; }, ASPECT_SANITIZE_VERSION: 2, // [NEW] Тег соотношения: { w, h } -> "aspectratio:12:5" ratioTag(ratio) { return `aspectratio:${ratio.w}:${ratio.h}`; }, // [NEW] Числовое значение тега: "aspectratio:9:19.5" -> 0.4615 ratioValue(text) { const m = String(text || '').match(/aspectratio:\s*([0-9.]+)\s*:\s*([0-9.]+)/i); if (!m) return null; const w = parseFloat(m[1]), h = parseFloat(m[2]); if (!w || !h) return null; return w / h; }, // [NEW] Попадает ли значение в запрос вида `ratio:a..b` / `ratio:..b` / `ratio:a..` // (метатег ratio на aibooru использует ту же нотацию диапазонов, что id:) ratioRangeCovers(query, target) { const s = String(query || ''); const rangeRe = /\bratio:\s*([0-9.]+)?\s*\.\.\s*([0-9.]+)?/gi; let m; while ((m = rangeRe.exec(s))) { const lo = m[1] !== undefined ? parseFloat(m[1]) : -Infinity; const hi = m[2] !== undefined ? parseFloat(m[2]) : Infinity; if (isFinite(lo) || isFinite(hi)) { if (target >= lo && target <= hi) return true; } } // одиночное значение: ratio:4:3 или ratio:4/3 (та же нотация, что у id, но со скобкой) const singleRe = /\bratio:\s*([0-9.]+)\s*(?::|\/)\s*([0-9.]+)/gi; while ((m = singleRe.exec(s))) { const w = parseFloat(m[1]), h = parseFloat(m[2]); if (w && h) { const v = w / h; if (Math.abs(v - target) / target <= this.RATIO_MATCH_TOLERANCE) return true; } } // простое число (ratio:1.33) — только если в запросе нет диапазонов if (!/\bratio:\s*[0-9.]+\s*\.\./i.test(s)) { const dec = /\bratio:\s*([0-9.]+)(?![.:\/])/i.exec(s); if (dec) { const v = parseFloat(dec[1]); if (v && Math.abs(v - target) / target <= this.RATIO_MATCH_TOLERANCE) return true; } } return false; }, // [NEW] Значение запроса равно искомому соотношению (по величине, допуск 2%). // Понимает обе нотации: теги aspectratio:W:H (rule34) и диапазоны ratio: (aibooru). queryCoversRatio(query, ratio) { const target = ratio.value !== undefined ? ratio.value : (ratio.w / ratio.h); const tokens = String(query || '').match(/aspectratio:\s*[0-9.]+\s*:\s*[0-9.]+/gi) || []; const byTag = tokens.some(t => { const v = this.ratioValue(t); return v !== null && Math.abs(v - target) / target <= this.RATIO_MATCH_TOLERANCE; }); if (byTag) return true; return this.ratioRangeCovers(query, target); }, // [NEW] Есть ли такое соотношение в запросе буквально (с границами чисел) hasRatioTag(query, ratioTag) { const esc = String(ratioTag).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return new RegExp(`(^|[^0-9.])${esc}(?![0-9.])`, 'i').test(String(query || '')); }, // [NEW] Где это соотношение уже встречается — во всех профилях всех сайтов findRatio(data, ratio) { const hits = []; const profiles = (data && data.profiles) || {}; Object.keys(profiles).forEach(host => { const prof = profiles[host]; ((prof && prof.groups) || []).forEach((group, gIdx) => { (group.queries || []).forEach((query, qIdx) => { if (this.queryCoversRatio(query, ratio)) hits.push({ host, groupIdx: gIdx, queryIdx: qIdx, group, query }); }); }); }); return hits; }, // [NEW] «Под капотом»: категория, к которой пользователь отнёс это соотношение. // Строка запроса при этом НЕ меняется — выбор живёт во внутреннем поле group.hidden. choiceForRatio(group, ratio) { if (!group || !group.hidden) return ''; const tag = this.ratioTag(ratio); return group.hidden[tag] || ''; }, // Категория «под капотом» для этого сайта (по данным хранилища) hiddenChoice(data, host, ratio) { const found = this.findAspectGroup(data, host); return found ? this.choiceForRatio(found.group, ratio) : ''; }, // Ключ категории (подпись, если есть, иначе сам запрос) categoryKey(group, query) { const labels = (group && group.labels) || {}; return labels[query] !== undefined ? labels[query] : query; }, // ============================================================ // [NEW] Группы «под капотом» (Media, Sort, Aspect Ratio). // Их кнопки ничего не пишут в строку поиска: состояние живёт в // group.active (включено) и group.activeNeg (отрицание для трёхсостоянийных), // а фильтр подставляется в запрос только в момент отправки поиска. // ============================================================ SILENT_GROUPS: [ { id: "g0", label: "media" }, { id: "g1", label: "sort" }, { id: "g_aspect", label: "aspect ratio" } ], isSilent(group) { return !!(group && group.silent === true); }, // [NEW] Это группа авторов? isAuthorsGroup(group) { if (!group) return false; return group.id === "g_authors" || String(group.label || '').trim().toLowerCase() === "authors"; }, // [NEW] Метки ♂/⚧ и избранное (звёздочка / золотая рамка) — только у авторов и Meta. // Для остальных групп (Aspect Ratio, Status, Rating, Media, Sort) эти значки не нужны, // поэтому и не показываются — ни в виджете, ни в настройках. allowsMarks(group) { if (!group) return false; return this.isAuthorsGroup(group) || group.id === "g2" || String(group.label || '').trim().toLowerCase() === "meta"; }, silentGroups(profile) { return ((profile && profile.groups) || []).filter(g => this.isSilent(g)); }, // Состояние кнопки этой группы: '' | 'on' | 'neg' silentStateOf(group, query) { if (!group) return ''; const neg = Array.isArray(group.activeNeg) ? group.activeNeg : []; if (neg.indexOf(query) !== -1 && ((group.tristate || {})[query] || '')) return 'neg'; const on = Array.isArray(group.active) ? group.active : []; return on.indexOf(query) !== -1 ? 'on' : ''; }, // Текст, который уйдёт в поиск вместо кнопки silentText(group, query) { if (!group) return query; if (this.silentStateOf(group, query) === 'neg') return (group.tristate || {})[query] || query; return query; }, // Включённые кнопки группы пропорций (для бейджей и подписей) aspectActive(profile) { const group = this.aspectGroup(profile); return (group && Array.isArray(group.active)) ? group.active.slice() : []; }, // Готовый фрагмент запроса: всё, что включено «под капотом» silentSearchBlock(profile) { const parts = []; this.silentGroups(profile).forEach(group => { const queries = Array.isArray(group.queries) ? group.queries : []; const list = (Array.isArray(group.active) ? group.active : []) .concat(Array.isArray(group.activeNeg) ? group.activeNeg : []); list.forEach(q => { if (queries.indexOf(q) === -1) return; const text = this.silentText(group, q); if (parts.indexOf(text) === -1) parts.push(text); }); }); return parts.join(' '); }, // Клик по кнопке «под капотом»: строку поиска не трогаем, гоним только состояние. // Трёхсостоянийные: off -> on -> neg -> off. Радио: либо один, либо никто. toggleSilent(host, groupId, query) { const data = this.load(); const profile = (data.profiles || {})[host]; if (!profile || !Array.isArray(profile.groups)) return null; const group = profile.groups.find(g => g.id === groupId || g.label === groupId); if (!group || !this.isSilent(group)) return null; if (!Array.isArray(group.queries) || group.queries.indexOf(query) === -1) return null; const active = Array.isArray(group.active) ? group.active.slice() : []; const neg = (Array.isArray(group.activeNeg) ? group.activeNeg : []).filter(q => q !== query); const state = this.silentStateOf(group, query); const alt = (group.tristate || {})[query] || ''; const radio = Array.isArray(group.radio) ? group.radio : []; let nextActive; if (radio.indexOf(query) !== -1) { // взаимоисключающие: снимаем весь набор, включаем выбранную (повторный клик — только снять) const kept = active.filter(q => radio.indexOf(q) === -1); nextActive = state === 'on' ? kept : kept.concat([query]); } else if (state === 'on' && alt) { nextActive = active.filter(q => q !== query); neg.push(query); } else if (state === '') { nextActive = active.concat([query]); } else { nextActive = active.filter(q => q !== query); } group.active = nextActive.filter((q, i) => nextActive.indexOf(q) === i); group.activeNeg = (alt ? neg : []).filter((q, i) => neg.indexOf(q) === i); this.save(data); return { active: group.active, neg: group.activeNeg }; }, // Новый поиск (клик по тегу из списка, переход по ссылке) — прежние // скрытые фильтры больше не действуют, кнопки гаснут. resetSilentState(host) { const data = this.load(); const profile = (data.profiles || {})[host]; if (!profile) return false; let changed = false; (profile.groups || []).forEach(group => { if (!this.isSilent(group)) return; const had = (Array.isArray(group.active) && group.active.length) || (Array.isArray(group.activeNeg) && group.activeNeg.length); if (!had) return; group.active = []; group.activeNeg = []; changed = true; }); if (changed) { this.save(data); console.log('[SYS] скрытые фильтры сброшены: начался новый поиск'); } return changed; }, // [NEW] Подпись категории для этого соотношения (для подсказок) ratioCaption(data, host, ratio) { const found = this.findAspectGroup(data, host); if (!found) return ''; const queries = found.group.queries || []; const labels = found.group.labels || {}; const tag = this.ratioTag(ratio); // «под капотом» — категория, выбранная пользователем вручную const choice = this.choiceForRatio(found.group, ratio); if (choice) return choice; // сперва категория, где лежит ровно этот тег, потом — «похожее» значение let query = queries.find(q => this.hasRatioTag(q, tag)); if (!query) query = queries.find(q => this.queryCoversRatio(q, ratio)); if (!query) return ''; if (labels[query]) return labels[query]; // подпись могла остаться от исходной строки без этого тега: // берём категорию с наибольшим совпадением по набору значений пропорций const ratioSet = q => (String(q).match(/aspectratio:\s*[0-9.]+\s*:\s*[0-9.]+/gi) || []) .map(t => t.toLowerCase().replace(/\s+/g, '')); const current = ratioSet(query); let bestKey = '', bestScore = 0; Object.keys(labels).forEach(k => { const other = ratioSet(k); if (!other.length) return; const score = other.filter(t => current.indexOf(t) !== -1).length / Math.max(other.length, current.length); if (score > bestScore) { bestScore = score; bestKey = k; } }); return (bestKey && bestScore > 0.5) ? labels[bestKey] : ''; }, // [NEW] Дописать тег в OR-блок: "( a ~ b )" -> "( a ~ b ~ aspectratio:12:5 )" addRatioToQuery(query, ratioTag) { const q = String(query); const close = q.lastIndexOf(")"); if (close === -1) return `${q} ${ratioTag}`; return `${q.slice(0, close).trimEnd()} ~ ${ratioTag} )`; }, // [NEW] Убрать тег из OR-блока (для перевыбора категории) removeRatioFromQuery(query, ratioTag) { const esc = String(ratioTag).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return String(query) .replace(new RegExp(`\\s*~\\s*${esc}(?![0-9.])`, 'gi'), '') // "... ~ tag" .replace(new RegExp(`(^|\\()\\s*${esc}(?![0-9.])\\s*(~\\s*)?`, 'gi'), '$1') // "tag ~ ..." / "( tag" .replace(/\(\s*~\s*/g, '( ') // если тег был первым .replace(/\s+\)/g, ' )') .replace(/\(\s+\)/g, '') .trim(); }, // [NEW] Группа Authors: пустой блок сразу под Sort. Авторы в алфавитном порядке, // "user:ник" в запросе остаётся как есть, но на кнопке показывается только ник. ensureAuthorsGroup(profile) { const isSort = (g) => g.id === "g1" || String(g.label || '').trim().toLowerCase() === "sort"; const isAuthors = (g) => g.id === "g_authors" || String(g.label || '').trim().toLowerCase() === "authors"; let group = profile.groups.find(isAuthors); if (!group) { group = { id: "g_authors", label: "Authors", queries: [], favorite: [] }; profile.groups.push(group); } // авторы всегда идут сразу под Sort profile.groups.splice(profile.groups.indexOf(group), 1); const sortIdx = profile.groups.findIndex(isSort); profile.groups.splice(sortIdx === -1 ? 0 : sortIdx + 1, 0, group); if (!Array.isArray(group.queries)) group.queries = []; if (!Array.isArray(group.favorite)) group.favorite = []; group.alphabetize = true; group.labels = Object.assign({}, group.labels || {}); group.marks = group.marks || {}; Helper.MARKERS.forEach(m => { if (!Array.isArray(group.marks[m.key])) group.marks[m.key] = []; }); // чистка: пустые записи и точные дубликаты const cleaned = []; group.queries.forEach(q => { const query = String(q).trim(); if (query && cleaned.indexOf(query) === -1) cleaned.push(query); }); group.queries = cleaned; // подпись на кнопке: если пользователь её не задал, для "user:ник" показываем ник, // остальные — сам запрос. Заданные (в т.ч. вручную) подписи не трогаем. group.queries.forEach(query => { if (group.labels[query]) return; const m = /^user:\s*(.+)$/i.exec(query); if (m && m[1].trim()) group.labels[query] = m[1].trim(); }); // из избранного убираем то, чего больше нет в списке group.favorite = group.favorite.filter(f => cleaned.indexOf(f) !== -1); Helper.MARKERS.forEach(m => { group.marks[m.key] = group.marks[m.key].filter(f => cleaned.indexOf(f) !== -1); }); // [NEW] состояние бейджа NEW на автора: { m — «знак», s — показанные id > m }. // Храним только для авторов из списка; старые форматы (число/массив) приводим к объекту. if (!profile.authorSeen || typeof profile.authorSeen !== 'object' || Array.isArray(profile.authorSeen)) { profile.authorSeen = {}; } Object.keys(profile.authorSeen).forEach(k => { if (cleaned.indexOf(k) === -1) { delete profile.authorSeen[k]; return; } const v = profile.authorSeen[k]; if (Array.isArray(v)) { profile.authorSeen[k] = { m: v.reduce((x, y) => Math.max(x, Number(y) || 0), 0), s: [] }; } else if (typeof v === 'number') { profile.authorSeen[k] = { m: v > 0 ? v : 0, s: [] }; } else if (!v || typeof v !== 'object') { delete profile.authorSeen[k]; } else { if (typeof v.m !== 'number' || !(v.m >= 0)) v.m = 0; if (!Array.isArray(v.s)) v.s = []; } }); return group; }, // [NEW] Приводит профиль к актуальному виду. Идемпотентно. // [NEW] Скрипт живёт только на поддерживаемых сайтах (Helper.Config.SUPPORTED_HOSTS): // чужие профили (danbooru и пр.) вычищаем. keep — хост или список хостов. pruneProfiles(data, keep) { if (!data || !data.profiles) return data; const list = Array.isArray(keep) ? keep : [keep]; Object.keys(data.profiles).forEach(host => { if (list.indexOf(host) === -1) delete data.profiles[host]; }); return data; }, // [NEW] Разовая миграция профиля aibooru.online (помечается profile.aibVer): // v1: Media → animated; Sort → 4 порядка; Rating → is:sfw/is:nsfw; порядок групп; // cheatsheet URL. // v2: Meta — плашки как на rule34, но с тегами aibooru; cheatsheet-кнопка НЕ включается. // Запросы авторов, Meta, метки и избранное пользователя НЕ трогаем (Meta только дополняем). // ВАЖНО: меняешь дефолты Media/Sort/Rating/Meta — поднимай aibVer, иначе уже установленные // профили не пересоберутся (новые значения всё равно уедут в DEFAULT_DATA). migrateAibooruProfile(profile) { if (!profile || profile.aibVer >= 2) return profile; const groups = Array.isArray(profile.groups) ? profile.groups : []; const byLabel = l => groups.find(g => String(g.label || '').trim().toLowerCase() === l); const byId = id => groups.find(g => g.id === id); // 1) Media: оставляем единственный тег animated const media = byId("g0") || byLabel("media"); if (media) { media.id = "g0"; media.label = "Media"; media.silent = true; media.managed = true; media.lock = []; media.queries = ["animated"]; media.tristate = { "animated": "-animated" }; media.labels = {}; } // 2) Sort: четыре подтверждённых порядка const SORT = ["order:rank", "order:views", "order:score", "order:favcount"]; const sort = byId("g1") || byLabel("sort"); if (sort) { sort.id = "g1"; sort.label = "Sort"; sort.silent = true; sort.managed = true; sort.queries = SORT.slice(); sort.radio = SORT.slice(); sort.lock = SORT.slice(); sort.labels = { "order:rank": "rank", "order:views": "views", "order:score": "score", "order:favcount": "fav" }; } // 3) Rating → SFW / NSFW const QS = ["is:sfw", "is:nsfw"]; let rating = byId("g_rating") || byLabel("rating"); if (!rating) { rating = { id: "g_rating", label: "Rating" }; groups.push(rating); } rating.id = "g_rating"; rating.label = "Rating"; rating.silent = true; rating.managed = false; rating.lock = []; rating.queries = QS.slice(); rating.radio = QS.slice(); rating.labels = { "is:sfw": "SFW", "is:nsfw": "NSFW" }; if (Array.isArray(rating.favorite)) rating.favorite = rating.favorite.filter(f => QS.indexOf(f) !== -1); if (rating.marks) Object.keys(rating.marks).forEach(k => { rating.marks[k] = (rating.marks[k] || []).filter(x => QS.indexOf(x) !== -1); }); // 4) порядок групп const order = ["g0", "g1", "g_authors", "g2", "g_aspect", "g_status", "g_rating"]; const known = order.map(id => groups.find(g => g.id === id)).filter(Boolean); const rest = groups.filter(g => order.indexOf(g.id) === -1); profile.groups = known.concat(rest); // 5) Meta: те же плашки, что на rule34, но с тегами aibooru (без ~ и *). // Дополняем, не затирая то, что пользователь добавил сам. const meta = byId("g2") || byLabel("meta"); if (meta) { if (!Array.isArray(meta.queries)) meta.queries = []; meta.labels = Object.assign({}, meta.labels || {}); [ { query: "-sex -penis", caption: "just girl" }, { query: "otoko_no_ko or futanari", caption: "futa / femboy" } ].forEach(p => { if (meta.queries.indexOf(p.query) === -1) meta.queries.push(p.query); if (!meta.labels[p.query]) meta.labels[p.query] = p.caption; }); } // 6) ресурсы: ссылка обновляется, но кнопка Cheatsheet НЕ включается profile.cheatsheetUrl = "https://aibooru.online/wiki_pages/help%3Acheatsheet"; profile.showCheatsheet = false; profile.aibVer = 2; return profile; }, normalizeProfile(profile, host) { if (!profile || !Array.isArray(profile.groups)) return profile; const LOCKED_QUERIES = ["sort:score:desc", "sort:id:desc"]; const LOCKED_LABELS = { "sort:score:desc": "score", "sort:id:desc": "id" }; profile.groups.forEach(group => { if (!Array.isArray(group.queries)) group.queries = []; }); profile.groups.forEach(group => { const isSortGroup = group.id === "g1" || String(group.label || '').trim().toLowerCase() === "sort"; if (!isSortGroup) return; // "video" больше не живёт внутри Sort — он переехал в отдельную группу Media const videoIdx = group.queries.indexOf("video"); if (videoIdx !== -1) group.queries.splice(videoIdx, 1); const presentLocked = LOCKED_QUERIES.filter(q => group.queries.indexOf(q) !== -1); if (presentLocked.length) { group.lock = presentLocked; // [NEW] взаимоисключающие кнопки (активна только одна или ни одной) group.radio = presentLocked; group.labels = Object.assign({}, LOCKED_LABELS, group.labels || {}); } }); // [NEW] сайт-специфичные миграции: у каждого сайта свой набор групп и свой синтаксис const isRule34 = host === "rule34.xxx"; // [NEW] aibooru: до ensure* — порядок групп и состав Rating должны быть верными if (host === "aibooru.online") this.migrateAibooruProfile(profile); if (isRule34) { // на случай импорта одиночного профиля (merge полей тогда не срабатывает) if (profile.hideSelectors === undefined) profile.hideSelectors = ["#displayOptions"]; } else if (profile.hideSelectors === undefined) { profile.hideSelectors = []; // на других сайтах своих скрытий по умолчанию нет } // [NEW] бейджи соотношений по умолчанию выключены: разовая установка для старых данных if (profile.showRatioBadges === undefined || profile.ratioBadgesOff !== 1) { profile.showRatioBadges = false; profile.ratioBadgesOff = 1; } // порядок важен: Media всегда на верху (unshift), затем Authors, затем Aspect if (isRule34) this.ensureMediaGroup(profile); this.ensureAuthorsGroup(profile); // [NEW] группа авторов нужна на любом сайте if (isRule34) this.ensureAspectGroup(profile); // блоки пропорций: если данных нет — вернём штатные this.ensureAspectLabels(profile); // подписи для группы Aspect Ratio — на любом сайте if (isRule34) this.sanitizeAspectGroup(profile); // чистка запросов от старых кликов if (isRule34) this.applyMetaPreset(profile); // разовая замена набора Meta // Media и Sort правятся только из данных: в настройках их не показываем profile.groups.forEach(group => { const label = String(group.label || '').trim().toLowerCase(); if (group.id === "g0" || group.id === "g1" || label === "media" || label === "sort") { group.managed = true; } }); // [NEW] Media / Sort / Aspect Ratio работают «под капотом»: в строку поиска не пишут profile.groups.forEach(group => { const label = String(group.label || '').trim().toLowerCase(); if (this.SILENT_GROUPS.some(s => s.id === group.id || s.label === label)) group.silent = true; if (!Array.isArray(group.active)) group.active = []; if (!Array.isArray(group.activeNeg)) group.activeNeg = []; }); // подписи/состояния/избранное для запросов, которых уже нет, выкидываем profile.groups.forEach(group => { const has = (q) => group.queries.indexOf(q) !== -1; if (group.labels) Object.keys(group.labels).forEach(k => { if (!has(k)) delete group.labels[k]; }); if (group.tristate) Object.keys(group.tristate).forEach(k => { if (!has(k)) delete group.tristate[k]; }); if (Array.isArray(group.favorite)) group.favorite = group.favorite.filter(f => has(f)); if (group.marks) Object.keys(group.marks).forEach(k => { if (Array.isArray(group.marks[k])) group.marks[k] = group.marks[k].filter(has); }); if (Array.isArray(group.active)) group.active = group.active.filter(has); if (Array.isArray(group.activeNeg)) { group.activeNeg = group.activeNeg.filter(q => has(q) && (group.tristate || {})[q] !== undefined); } }); return profile; }, // [NEW] Группа Media (video + ai) всегда существует и стоит первой. ensureMediaGroup(profile) { const M = this.MEDIA_GROUP; let group = profile.groups.find(g => g.id === M.id); if (!group) { group = { id: M.id, label: M.label, managed: true, silent: true, queries: [], lock: [], labels: {}, tristate: {} }; profile.groups.unshift(group); } else if (profile.groups.indexOf(group) !== 0) { profile.groups.splice(profile.groups.indexOf(group), 1); profile.groups.unshift(group); } group.tristate = Object.assign({}, group.tristate || {}); group.labels = Object.assign({}, group.labels || {}); [M.video, M.ai].forEach(def => { if (group.queries.indexOf(def.query) === -1) group.queries.push(def.query); group.tristate[def.query] = def.alt; group.labels[def.query] = def.caption; }); // порядок внутри группы: video, затем ai, затем всё остальное group.queries = [M.video.query, M.ai.query].concat( group.queries.filter(q => q !== M.video.query && q !== M.ai.query) ); return group; }, load() { const rawData = GM_getValue(this.STORAGE_KEY, null); // [FIX] на пустом хранилище пишем дефолт напрямую, без emit: иначе // refreshSettings() падает на tempData === null и виджет не появляется. if (!rawData) { const defaults = JSON.parse(JSON.stringify(Helper.Config.DEFAULT_DATA)); Object.keys(defaults.profiles).forEach(k => this.normalizeProfile(defaults.profiles[k], k)); GM_setValue(this.STORAGE_KEY, JSON.stringify(defaults)); return defaults; } try { const data = JSON.parse(rawData); Object.keys(Helper.Config.DEFAULT_DATA.profiles).forEach(key => { if (!data.profiles[key]) { data.profiles[key] = Helper.Config.DEFAULT_DATA.profiles[key]; } else { const defaultProf = Helper.Config.DEFAULT_DATA.profiles[key]; ["cheatsheetUrl", "showCheatsheet", "wikiUrl", "showWiki", "autoHideQueryBox", "delimiter", "splitRegex", "showSettingsBtn", "hideSelectors"].forEach(field => { if (data.profiles[key][field] === undefined) { // глубокая копия, чтобы массивы не шарили ссылку с дефолтом data.profiles[key][field] = JSON.parse(JSON.stringify(defaultProf[field])); } }); } }); // [NEW] нормализация всех профилей (в т.ч. добавленных вручную сайтов) this.pruneProfiles(data, Helper.Config.SUPPORTED_HOSTS); Object.keys(data.profiles).forEach(key => this.normalizeProfile(data.profiles[key], key)); // [NEW] тихо фиксируем результат нормализации (миграции/чистки) без события, // иначе они повторялись бы при каждой загрузке страницы const json = JSON.stringify(data); if (json !== rawData) GM_setValue(this.STORAGE_KEY, json); return data; } catch (e) { return Helper.Config.DEFAULT_DATA; } }, save(data) { // Apply dynamic version and date to _meta, and clean up the old 'settings' key if (!data._meta) data._meta = {}; data._meta.scriptVersion = typeof GM_info !== "undefined" ? GM_info.script.version : "Unknown"; data._meta.exportDate = Helper.getFormattedDate(); if (data.settings) delete data.settings; // [NEW] нормализация перед записью (импорт, ручные правки) if (data.profiles) { this.pruneProfiles(data, Helper.Config.SUPPORTED_HOSTS); Object.keys(data.profiles).forEach(key => this.normalizeProfile(data.profiles[key], key)); } GM_setValue(this.STORAGE_KEY, JSON.stringify(data)); EventBus.emit('storage:updated', data); } }; // ========================================== // [Module 4] UIEngine // ========================================== const UIEngine = { container: null, shadowRoot: null, panel: null, tempData: null, editingHost: null, isRearranging: false, sortableInstances: [], init() { this.container = document.createElement('div'); this.container.id = 'sys-ui-root'; document.body.appendChild(this.container); this.shadowRoot = this.container.attachShadow({ mode: 'open' }); this.shadowRoot.innerHTML = Helper.Templates.PANEL_SHELL(""); this.panel = this.shadowRoot.getElementById('main-panel'); this.setupPanelDragging(); this.bindBaseButtons(); // Binds static footer buttons EventBus.on('storage:updated', () => this.refreshSettings()); }, isNewProfile: false, toggle() { if (getComputedStyle(this.panel).display === 'none') { this.tempData = JSON.parse(JSON.stringify(Storage.load())); // [NEW] редактируем профиль текущего сайта; для неподдерживаемого — первый поддерживаемый const host = window.location.host; this.editingHost = (this.tempData.profiles && this.tempData.profiles[host]) ? host : Helper.Config.SUPPORTED_HOSTS[0]; this.isRearranging = false; if (!this.tempData.profiles[this.editingHost]) { this.tempData.profiles[this.editingHost] = { name: this.editingHost, selectors: { searchBar: "", insertPoint: "" }, cheatsheetUrl: "", showCheatsheet: false, wikiUrl: "", showWiki: false, autoHideQueryBox: true, delimiter: " ", splitRegex: "\\s+", groups: [] }; } this.refreshSettings(); this.panel.style.display = 'flex'; } else { this.panel.style.display = 'none'; } }, refreshSettings() { // [FIX] панель ещё ни разу не открывали — обновлять нечего if (!this.tempData || !this.editingHost) return; this.sortableInstances.forEach(i => i.destroy()); this.sortableInstances = []; const storedData = Storage.load(); this.isNewProfile = !storedData.profiles[this.editingHost]; const content = this.shadowRoot.getElementById('settings-content'); const profile = this.tempData.profiles[this.editingHost]; if (!profile) return; let html = this.isNewProfile ? Helper.Templates.NEW_PROFILE_WARNING : ""; html += Helper.Templates.SITE_SELECTORS_SECTION(profile, this.isRearranging); html += Helper.Templates.SITE_RESOURCES_SECTION(profile, this.isRearranging); html += Helper.Templates.SAVED_SEARCHES_HEADER(this.isRearranging); if (!this.isRearranging) html += `<button class="btn btn-add" id="btn-add-group">+ Add New Group</button>`; content.innerHTML = html; const container = this.shadowRoot.getElementById('cards-container'); profile.groups.forEach((group, gIdx) => { // [NEW] Media и Sort правятся только из данных — в настройках их не показываем if (group.managed) return; const cardDiv = document.createElement('div'); cardDiv.innerHTML = Helper.Templates.TAG_GROUP_HTML(group, gIdx, this.isRearranging); container.appendChild(cardDiv.firstElementChild); }); this.bindDynamicEvents(); if (this.isRearranging) this.setupSortable(); }, setupSortable() { const profile = this.tempData.profiles[this.editingHost]; const groupContainer = this.shadowRoot.getElementById('cards-container'); this.sortableInstances.push(new Sortable(groupContainer, { animation: 180, handle: '.drag-handle-group', ghostClass: 'sortable-ghost', onEnd: (evt) => { const movedGroup = profile.groups.splice(evt.oldIndex, 1)[0]; profile.groups.splice(evt.newIndex, 0, movedGroup); } })); this.shadowRoot.querySelectorAll('.tags-sortable-container').forEach(el => { this.sortableInstances.push(new Sortable(el, { animation: 180, handle: '.drag-handle-tag', draggable: '.sortable-tag-item', // [NEW] заблокированные кнопки не таскаются filter: '.tag-fixed', // [NEW] страховка на случай drag за сам чип ghostClass: 'sortable-ghost', group: 'shared-tags', // [Changed] Common group name allows cross-list dragging onEnd: (evt) => { // [Changed] Identify source and destination group indices const fromGidx = evt.from.dataset.gidx; const toGidx = evt.to.dataset.gidx; // [Changed] Target the specific arrays const fromQueries = profile.groups[fromGidx].queries; const toQueries = profile.groups[toGidx].queries; // [Changed] Move the item from the old array to the new array const movedQuery = fromQueries.splice(evt.oldIndex, 1)[0]; toQueries.splice(evt.newIndex, 0, movedQuery); // [Changed] Refresh UI if moved to a new group to update HTML indices if (fromGidx !== toGidx) { this.refreshSettings(); } } })); }); }, showActionModal(titleText, onCurrent, onAll) { const modal = this.shadowRoot.getElementById('action-modal'); const title = this.shadowRoot.getElementById('modal-title'); const btnCurrent = this.shadowRoot.getElementById('modal-btn-current'); const btnAll = this.shadowRoot.getElementById('modal-btn-all'); const btnCancel = this.shadowRoot.getElementById('modal-btn-cancel'); title.innerText = titleText; modal.style.display = 'flex'; const closeModal = () => { modal.style.display = 'none'; btnCurrent.onclick = null; btnAll.onclick = null; btnCancel.onclick = null; }; btnCurrent.onclick = () => { onCurrent(); closeModal(); }; btnAll.onclick = () => { onAll(); closeModal(); }; btnCancel.onclick = closeModal; }, handleExport() { this.showActionModal( "Select Export Scope", () => { const dataToExport = this.tempData.profiles[this.editingHost]; this.executeExport(dataToExport, 'SINGLE'); }, () => { const dataToExport = this.tempData; this.executeExport(dataToExport, 'ALL'); } ); }, executeExport(dataToExport, mode) { // 1. Create a clean copy and remove old keys to reset the insertion order const dataCopy = JSON.parse(JSON.stringify(dataToExport)); delete dataCopy._meta; delete dataCopy.settings; // 2. Construct the final object with _meta explicitly at the very top const exportObject = { _meta: { scriptVersion: typeof GM_info !== "undefined" ? GM_info.script.version : "Unknown", exportDate: Helper.getFormattedDate() }, ...dataCopy // 3. Spread the rest of the data (profiles, etc.) underneath }; const blob = new Blob([JSON.stringify(exportObject, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const fileNamePrefix = mode === 'SINGLE' ? `Save_Your_Searches_backup_${this.editingHost}` : `Save_Your_Searches_backup_ALL`; // Use the formatted date for the file name a.download = `${fileNamePrefix}_${exportObject._meta.exportDate}.json`; a.click(); URL.revokeObjectURL(url); window.alert("Export complete!"); }, handleImport(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { try { const imported = JSON.parse(e.target.result); this.showActionModal( "Import Data (Will overwrite existing)", () => { const profileToImport = imported.profiles ? imported.profiles[this.editingHost] : imported; if (!profileToImport || !profileToImport.groups) throw new Error("Invalid profile format"); this.tempData.profiles[this.editingHost] = profileToImport; window.alert("Import success! Click 'Save & Apply' to take effect."); this.refreshSettings(); }, () => { if (!imported.profiles) throw new Error("Invalid format for full backup"); this.tempData = imported; window.alert("Import success! Click 'Save & Apply' to take effect."); this.refreshSettings(); } ); } catch (err) { window.alert("Import Failed: " + err.message); } }; reader.readAsText(file); event.target.value = ''; }, bindDynamicEvents() { const profile = this.tempData.profiles[this.editingHost]; // [Modified] Rearrange button is now generated inside settings-content dynamically const btnRearrange = this.shadowRoot.getElementById('btn-rearrange'); if (btnRearrange) { btnRearrange.classList.toggle('active', this.isRearranging); btnRearrange.onclick = () => { this.isRearranging = !this.isRearranging; this.refreshSettings(); }; } if (this.isRearranging) return; this.shadowRoot.getElementById('input-sel-search').oninput = (e) => { profile.selectors.searchBar = e.target.value; }; this.shadowRoot.getElementById('input-sel-insert').oninput = (e) => { profile.selectors.insertPoint = e.target.value; }; this.shadowRoot.getElementById('input-delimiter').oninput = (e) => { profile.delimiter = e.target.value; }; this.shadowRoot.getElementById('input-split-regex').oninput = (e) => { profile.splitRegex = e.target.value; }; this.shadowRoot.getElementById('input-cheatsheet-url').oninput = (e) => { profile.cheatsheetUrl = e.target.value; }; this.shadowRoot.getElementById('check-show-cheatsheet').onchange = (e) => { profile.showCheatsheet = e.target.checked; }; // [NEW] Бейджи соотношений на превьюшках: включение/выключение прямо из настроек const chkRatios = this.shadowRoot.getElementById('check-show-ratios'); if (chkRatios) { chkRatios.onchange = (e) => { profile.showRatioBadges = e.target.checked; // сразу применяем: рисуем бейджи заново или убираем их с превьюшек SiteInjector.badgeAllThumbs(this.tempData, true); }; } this.shadowRoot.getElementById('input-wiki-url').oninput = (e) => { profile.wikiUrl = e.target.value; }; this.shadowRoot.getElementById('check-show-wiki').onchange = (e) => { profile.showWiki = e.target.checked; }; this.shadowRoot.getElementById('check-show-settings').onchange = (e) => { profile.showSettingsBtn = e.target.checked; }; // [NEW] this.shadowRoot.getElementById('check-auto-hide').onchange = (e) => { profile.autoHideQueryBox = e.target.checked; }; // [NEW] Edit tag on click (заблокированные кнопки класса .edit-tag не имеют) this.shadowRoot.querySelectorAll('.edit-tag').forEach(span => { span.onclick = (e) => { if (this.isRearranging) return; // Disable editing while rearranging const { gidx, qidx } = e.target.dataset; const group = profile.groups[gidx]; const currentTag = group.queries[qidx]; // [NEW] Первое окно — сам запрос (то, что подставляется в поиск)… const newTag = prompt("Edit your saved search:", currentTag); if (newTag === null) return; const trimmedTag = newTag.trim(); if (!trimmedTag) return; // …второе окно — название кнопки (подпись). Пустое поле = показывать сам запрос, // отмена окна = меняем только запрос, подпись остаётся прежней. const labels = group.labels = Object.assign({}, group.labels || {}); const currentCaption = labels[currentTag] || currentTag; const answer = prompt("Название кнопки (подпись):", currentCaption); const caption = answer === null ? null : answer.trim(); if (trimmedTag !== currentTag) { group.queries[qidx] = trimmedTag; if (labels[currentTag] !== undefined) delete labels[currentTag]; } if (caption !== null) { if (caption && caption !== trimmedTag) labels[trimmedTag] = caption; else delete labels[trimmedTag]; } this.refreshSettings(); }; }); this.shadowRoot.querySelectorAll('.delete-tag').forEach(btn => { btn.onclick = (e) => { const { gidx, qidx } = e.target.dataset; profile.groups[gidx].queries.splice(qidx, 1); this.refreshSettings(); }; }); // [NEW] Избранное: звёздочка перед крестиком, избранные поднимаются в начало списка this.shadowRoot.querySelectorAll('.toggle-fav').forEach(btn => { btn.onclick = (e) => { const { gidx, qidx } = e.target.dataset; const group = profile.groups[gidx]; const query = group.queries[qidx]; if (!Array.isArray(group.favorite)) group.favorite = []; const i = group.favorite.indexOf(query); if (i === -1) group.favorite.push(query); else group.favorite.splice(i, 1); this.refreshSettings(); }; }); // [NEW] Косметические метки (♂ / ⚧): как избранное, но сортировку не меняют this.shadowRoot.querySelectorAll('.toggle-mark').forEach(btn => { btn.onclick = (e) => { const { gidx, qidx, mark } = e.target.dataset; const group = profile.groups[gidx]; const query = group.queries[qidx]; if (!group.marks) group.marks = {}; if (!Array.isArray(group.marks[mark])) group.marks[mark] = []; const i = group.marks[mark].indexOf(query); if (i === -1) group.marks[mark].push(query); else group.marks[mark].splice(i, 1); this.refreshSettings(); }; }); this.shadowRoot.querySelectorAll('.add-tag-btn').forEach(btn => { btn.onclick = (e) => { const gidx = e.target.dataset.gidx; const val = this.shadowRoot.querySelectorAll('.new-tag-input')[gidx].value.trim(); if (val) { profile.groups[gidx].queries.push(val); this.refreshSettings(); } }; }); this.shadowRoot.querySelectorAll('.add-current-query-btn').forEach(btn => { btn.onclick = (e) => { const gidx = e.target.dataset.gidx; const bar = document.querySelector(profile.selectors.searchBar); if (bar && bar.value.trim()) { const fullQuery = bar.value.trim(); // Grab the entire search bar content if (!profile.groups[gidx].queries.includes(fullQuery)) { profile.groups[gidx].queries.push(fullQuery); this.refreshSettings(); } } }; }); this.shadowRoot.querySelectorAll('.group-label-input').forEach(input => { input.onchange = (e) => { profile.groups[e.target.dataset.gidx].label = e.target.value; }; }); this.shadowRoot.querySelectorAll('.delete-group').forEach(btn => { btn.onclick = (e) => { if(confirm("Delete this group?")) { profile.groups.splice(e.target.dataset.gidx, 1); this.refreshSettings(); } }; }); this.shadowRoot.getElementById('btn-add-group').onclick = () => { profile.groups.push({ id: "g_"+Date.now(), label: "New Group", queries: [] }); this.refreshSettings(); }; }, // [Modified] Binds static elements located in the base PANEL_SHELL template bindBaseButtons() { // Data row buttons this.shadowRoot.getElementById('btn-export-trigger').onclick = () => this.handleExport(); this.shadowRoot.getElementById('btn-import-trigger').onclick = () => this.shadowRoot.getElementById('sys-import-file').click(); this.shadowRoot.getElementById('sys-import-file').onchange = (e) => this.handleImport(e); // Action row buttons this.shadowRoot.getElementById('btn-cancel').onclick = () => this.toggle(); this.shadowRoot.getElementById('btn-save').onclick = () => { Storage.save(this.tempData); this.toggle(); }; this.shadowRoot.getElementById('btn-reset').onclick = () => { if(confirm("Reset ALL data?")) { Storage.save(Helper.Config.DEFAULT_DATA); this.toggle(); } }; }, setupPanelDragging() { const handle = this.shadowRoot.getElementById('drag-handle'); let startX, startY, initialLeft, initialTop; const onMouseMove = (e) => { this.panel.style.left = `${initialLeft + (e.clientX - startX)}px`; this.panel.style.top = `${initialTop + (e.clientY - startY)}px`; }; const onMouseUp = () => { document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }; handle.onmousedown = (e) => { if (e.button !== 0) return; startX = e.clientX; startY = e.clientY; initialLeft = this.panel.offsetLeft; initialTop = this.panel.offsetTop; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }; } }; // ========================================== // [Module 5] SiteInjector // ========================================== const SiteInjector = { async init() { const host = window.location.host; let data = Storage.load(); if (data.profiles[host]) this.injectUI(data.profiles[host]); EventBus.on('storage:updated', (newData) => { if (!newData.profiles[host]) return; this.injectUI(newData.profiles[host]); this.badgeAllThumbs(newData, true); // бейджи на превьюшках пересчитываются }); if (data.profiles[host]) { // [NEW] страница поста: под строкой "Size:" показываем соотношение сторон this.watchRatioLine(data.profiles[host], data); // [NEW] список постов: бейдж соотношения на каждой превьюшке this.watchThumbBadges(data); // [NEW] бейдж NEW у новых постов отслеживаемых авторов this.watchNewBadges(data); // [NEW] кнопки формы: скрытая подстановка фильтров при поиске this.watchSilentFilters(data.profiles[host]); } }, // [NEW] Прячет элементы сайта, перечисленные в profile.hideSelectors applyHideRules(profile) { const selectors = Array.isArray(profile.hideSelectors) ? profile.hideSelectors.filter(Boolean) : []; let styleEl = document.getElementById('sys-hide-css'); if (!selectors.length) { if (styleEl) styleEl.remove(); return; } if (!styleEl) { styleEl = document.createElement('style'); styleEl.id = 'sys-hide-css'; styleEl.type = 'text/css'; document.head.appendChild(styleEl); } styleEl.textContent = selectors.map(sel => `${sel} { display: none !important; }`).join('\n'); }, // ========================================== // [NEW] Соотношение сторон на странице поста (rule34) // ========================================== RATIO_MAX_DEN: 32, // максимальный знаменатель при округлении W:H к целым RATIO_TOLERANCE: 0.02, // общий допуск 2% RATIO_STRICT: 0.01, // сначала ищем «красивое» целое с ошибкой до 1% // Округление пропорции к целым числам: 2236x932 -> 12:5 integerRatio(width, height) { const w0 = Number(width), h0 = Number(height); if (!w0 || !h0 || !isFinite(w0) || !isFinite(h0)) return null; if (w0 < 1 || h0 < 1 || Math.max(w0, h0) > 20000000) return null; const target = w0 / h0; const candidates = []; const maxDen = Math.max(this.RATIO_MAX_DEN, 64); for (let h = 1; h <= maxDen; h++) { const w = Math.round(target * h); if (w < 1 || w > 199) continue; const err = Math.abs((w / h) - target) / target; if (err <= this.RATIO_TOLERANCE + 0.01) candidates.push({ w, h, err }); } if (!candidates.length) { for (let h = 1; h <= 96; h++) { const w = Math.round(target * h); if (w < 1 || w > 199) continue; const err = Math.abs((w / h) - target) / target; if (err <= this.RATIO_TOLERANCE + 0.03) candidates.push({ w, h, err }); } } // сначала самое простое целое с малой ошибкой, иначе — самое точное // prefer the one with smaller denominator first for readability, also prefer closer const preferredKey = (x) => { const k = x.w + ':' + x.h; if (k === '12:5' || k === '9:7' || k === '16:9' || k === '6:5' || k === '4:3' || k === '3:2' || k === '5:4' || k === '1:1' || k === '11:9' || k === '9:16' || k === '16:10' || k === '2:3') return 0; return 1; }; const isNice = (x) => { if (x.h > this.RATIO_MAX_DEN) return false; if (x.w < 1 || x.w > 199) return false; const err = Math.abs((x.w / x.h) - target) / target; return err <= this.RATIO_STRICT; }; const canonical = (x) => { const k = x.w + ':' + x.h; if (k === '12:5' || k === '9:7' || k === '16:9' || k === '6:5' || k === '8:5' || k === '9:4' || k === '3:4' || k === '5:12' || k === '1:1' || k === '9:16') return 0; return 1; }; const key = (x) => [canonical(x), isNice(x) ? 0 : 1, x.err, Math.min(x.w, x.h), x.w + x.h, x.h, x.w]; const strict = candidates.filter(c => c.err <= this.RATIO_STRICT) .sort((a, b) => { const ka = key(a), kb = key(b); for (let i = 0; i < ka.length; i++) { if (ka[i] !== kb[i]) return ka[i] - kb[i]; } return 0; })[0]; if (strict) return strict; const closest = candidates.sort((a, b) => { const ka = key(a), kb = key(b); for (let i = 0; i < ka.length; i++) { if (ka[i] !== kb[i]) return ka[i] - kb[i]; } return 0; })[0]; if (closest) return closest; // запасной вариант — точное сокращение const gcd = (a, b) => (b ? gcd(b, a % b) : a); const div = gcd(w0, h0) || 1; const w2 = Math.round(w0 / div); const h2 = Math.round(h0 / div); if (w2 >= 1 && h2 >= 1 && w2 <= 199 && h2 <= 199) { return { w: w2, h: h2, err: Math.abs(w2 / h2 - target) / target }; } // совсем аномалия — покажем как есть, но обрежем return { w: Math.min(199, w2 || Math.round(w0)), h: h2 || Math.round(h0), err: 0 }; }, // Размер оригинала со страницы поста: "#stats" -> "Size: 2236x932" // Если в статистике нет размеров (или они битые) — берём их у картинки/видео поста. postMedia() { const candidates = [ document.getElementById('image'), document.querySelector('#image-container img'), document.querySelector('#gelcomVideoContainer video'), document.querySelector('#post-view img'), document.querySelector('video'), document.querySelector('#content img') ]; for (let i = 0; i < candidates.length; i++) { if (candidates[i]) return candidates[i]; } return null; }, mediaSize(el) { if (!el) return null; const attrW = parseFloat((el.getAttribute && el.getAttribute('width')) || 0) || 0; const attrH = parseFloat((el.getAttribute && el.getAttribute('height')) || 0) || 0; const width = el.naturalWidth || el.videoWidth || el.width || attrW || 0; const height = el.naturalHeight || el.videoHeight || el.height || attrH || 0; return (width > 0 && height > 0) ? { width, height } : null; }, // [NEW] Список характеристик поста: rule34 — #stats ul, aibooru — section#post-information ul statsList() { const a = document.querySelector('#stats ul'); if (a) return a; return document.querySelector('#post-information ul') || null; }, parsePostSize() { // 1) Строка Size в статистике const stats = this.statsList(); if (stats) { const lis = Array.prototype.slice.call(stats.querySelectorAll('li')); const sizeLi = lis.find(el => /^\s*Size\s*:/i.test((el.textContent || '').replace(/\u00A0/g, ' '))); const text = ((sizeLi ? sizeLi.textContent : stats.textContent) || '').replace(/\u00A0/g, ' '); const m = String(text).match(/Size\s*:?\s*(\d{2,7})\s*[×xX*]\s*(\d{2,7})/i) || String(text).match(/\b(\d{2,7})\s*[×xX*]\s*(\d{2,7})\b(?!\d)/); if (m) { const width = parseInt(m[1], 10); const height = parseInt(m[2], 10); if (isFinite(width) && isFinite(height) && width > 0 && height > 0 && Math.max(width, height) <= 20000000) { return { width, height, source: 'stats' }; } } } // 2) Картинка или видео поста const media = this.mediaSize(this.postMedia()); if (media) return { width: media.width, height: media.height, source: 'media' }; return null; }, // Диагностика: что именно нашлось на странице (один раз на страницу) debugRatioSources() { const info = { url: location.href, stats: (this.statsList() || {}).innerText || null, media: null, images: [] }; const media = this.postMedia(); if (media) { info.media = { tag: media.tagName, id: media.id, cls: media.className, natural: `${media.naturalWidth || media.videoWidth || 0}x${media.naturalHeight || media.videoHeight || 0}`, shown: `${media.width || 0}x${media.height || 0}`, src: (media.currentSrc || media.src || '').slice(0, 100) }; } Array.prototype.slice.call(document.querySelectorAll('img, video')).slice(0, 15).forEach(el => { info.images.push({ tag: el.tagName, id: el.id, cls: String(el.className || '').slice(0, 40), natural: `${el.naturalWidth || el.videoWidth || 0}x${el.naturalHeight || el.videoHeight || 0}`, shown: `${el.width || 0}x${el.height || 0}` }); }); console.log('[SYS] ratio sources:', info); return info; }, // Строка под "Size:": соотношение + кнопки категорий. // Зелёный — такое соотношение уже есть в сохранённых запросах, красный — новое. renderRatioLine(profile, data) { const size = this.parsePostSize(); if (!size) { // картинка/видео могли ещё не загрузиться — сообщит watchRatioLine, если не найдём вовсе return false; } const ratio = this.integerRatio(size.width, size.height); if (!ratio) { console.warn('[SYS] ratio: integerRatio вернул null для', size); return false; } // Предупредим, если в итоге получилась совсем аномальная дробь по сравнению с исходной try { const src = size.width / size.height; const r = ratio.w / ratio.h; if (Math.abs(src - r) / src > 0.05) { console.warn('[SYS] ratio: сильное расхождение округления', size, '→', ratio, '(src-r)/(src)=', ((Math.abs(src - r) / src).toFixed(3))); } } catch (e) {} const tag = Storage.ratioTag(ratio); const found = Storage.findAspectGroup(data, window.location.host); const aspect = found ? found.group : null; const choice = aspect ? Storage.choiceForRatio(aspect, ratio) : ''; const known = !!choice || Storage.findRatio(data, ratio).length > 0 || !!(aspect && (Array.isArray(aspect.queries) ? aspect.queries : []).some(q => Storage.queryCoversRatio(q, ratio))); let line = document.getElementById('sys-ratio-line'); if (!line) { const statsUl = this.statsList(); if (!statsUl) return false; line = document.createElement('li'); line.id = 'sys-ratio-line'; const sizeLi = Array.prototype.slice.call(statsUl.children) .find(el => /^\s*Size:/i.test(el.textContent || el.innerText || '')); if (sizeLi) statsUl.insertBefore(line, sizeLi.nextSibling); else statsUl.appendChild(line); } line.textContent = ''; line.style = Helper.Styles.RATIO_LINE; const name = document.createElement('span'); name.innerText = `${ratio.w}:${ratio.h} (${(size.width / size.height).toFixed(2)})`; name.title = `Размер: ${size.width}x${size.height} → ${tag}. ` + (choice ? `Отнесено к категории «${choice}» (под капотом).` : (known ? 'Такое соотношение уже есть в сохранённых запросах.' : 'Новое соотношение — выбери, куда отнести (кнопки ниже).')); name.style = Helper.Styles.RATIO_NAME(known); line.appendChild(name); // кнопки остаются всегда, чтобы можно было перевыбрать категорию const categories = (aspect && Array.isArray(aspect.queries)) ? aspect.queries : []; if (categories.length) { const labels = aspect.labels || {}; categories.forEach((query, qIdx) => { const chip = document.createElement('span'); const caption = labels[query] || query; chip.innerText = caption.length > 22 ? caption.slice(0, 21) + '…' : caption; chip.title = `${query}\n\nКлик — отнести ${tag} к этой категории (в запрос не пишется)`; const isChosen = choice ? choice === Storage.categoryKey(aspect, query) : Storage.queryCoversRatio(query, ratio); chip.style = isChosen ? Helper.Styles.RATIO_CHIP_ACTIVE : Helper.Styles.RATIO_CHIP; chip.onclick = (e) => { if (e && e.preventDefault) e.preventDefault(); this.assignRatio(ratio, qIdx); }; line.appendChild(chip); }); } console.log('[SYS] ratio', `${size.width}x${size.height}`, '→', tag, choice ? `(под капотом: ${choice})` : (known ? '(значение уже есть)' : '(новое)'), 'категорий:', categories.length, '| профили:', Object.keys((data && data.profiles) || {}).join(', ')); return true; }, // «Под капотом»: запомнить, что соотношение относится к выбранной категории. // Строка поиска и сохранённые запросы остаются чистыми — выбор лежит в aspect.hidden. assignRatio(ratio, targetQueryIdx) { const host = window.location.host; const data = Storage.load(); const found = Storage.findAspectGroup(data, host); if (!found) return; const aspect = found.group; if (!Array.isArray(aspect.queries) || aspect.queries[targetQueryIdx] === undefined) return; const tag = Storage.ratioTag(ratio); const category = Storage.categoryKey(aspect, aspect.queries[targetQueryIdx]); const hidden = Object.assign({}, aspect.hidden || {}); if (category) hidden[tag] = category; else delete hidden[tag]; aspect.hidden = hidden; Storage.save(data); // пересоберёт виджет (storage:updated) и нормализует подписи this.renderRatioLine(data.profiles[found.host], data); }, // Ждём появления #stats (страница поста) и рисуем строку watchRatioLine(profile, data) { let tries = 0; let hooked = false; const tick = () => { if (this.renderRatioLine(profile, data)) return; // размеры могут появиться позже: картинка/видео ещё грузится const media = this.postMedia(); if (media && !hooked) { hooked = true; const retry = () => this.renderRatioLine(profile, data); ['load', 'loadedmetadata', 'loadeddata', 'error'].forEach(ev => { try { media.addEventListener(ev, retry, { once: true }); } catch (e) {} }); } if (++tries > 20) { console.warn('[SYS] ratio: размеры поста не найдены — вот что есть на странице:'); this.debugRatioSources(); return; } setTimeout(tick, 250); }; tick(); }, // ========================================== // [NEW] Бейдж соотношения на превьюшках в списке постов // ========================================== // Превьюшки rule34 сохраняют пропорции (масштаб до 250px), поэтому // соотношение считается по naturalWidth/naturalHeight картинки превью. thumbElements() { const seen = []; ['div.image-list > span.thumb', '#post-list span.thumb', 'span.thumb', // [NEW] aibooru.online: превью — <article id="post_N" class="post-preview"> '#posts article.post-preview', 'article.post-preview'].forEach(sel => { document.querySelectorAll(sel).forEach(el => { if (seen.indexOf(el) === -1) seen.push(el); }); }); return seen; }, badgeThumb(thumb, data) { const profile = ((data || {}).profiles || {})[window.location.host]; if (profile && profile.showRatioBadges === false) return; // [NEW] отключено в настройках if (thumb.getAttribute('data-sys-badge')) return; const img = thumb.querySelector('img'); if (!img) return; const width = img.naturalWidth || img.width || 0; const height = img.naturalHeight || img.height || 0; // картинка ещё не загрузилась — подождём её load if (!width || !height) { if (!img.getAttribute('data-sys-badge-wait')) { img.setAttribute('data-sys-badge-wait', '1'); img.addEventListener('load', () => this.badgeThumb(thumb, data)); } return; } const ratio = this.integerRatio(width, height); if (!ratio) return; const tag = Storage.ratioTag(ratio); const caption = Storage.ratioCaption(data, window.location.host, ratio); const known = !!Storage.hiddenChoice(data, window.location.host, ratio) || Storage.findRatio(data, ratio).length > 0; if (getComputedStyle(thumb).position === 'static') thumb.style.position = 'relative'; const badge = document.createElement('span'); badge.innerText = `${ratio.w}:${ratio.h}`; badge.title = `${tag} (превью ${width}x${height})` + (known ? `\nЗначение уже есть в базе${caption ? ': ' + caption : ''}` : '\nНовое соотношение — открой пост и выбери категорию'); badge.style = Helper.Styles.THUMB_BADGE(known); badge.setAttribute('data-sys-badge', '1'); thumb.setAttribute('data-sys-badge', '1'); thumb.appendChild(badge); }, badgeAllThumbs(data, force) { const thumbs = this.thumbElements(); const profile = ((data || {}).profiles || {})[window.location.host]; // [NEW] настройка «показывать соотношения на миниатюрах» if (profile && profile.showRatioBadges === false) { thumbs.forEach(thumb => { Array.prototype.slice.call(thumb.children || []).forEach(child => { if (child.getAttribute && child.getAttribute('data-sys-badge')) thumb.removeChild(child); }); thumb.removeAttribute('data-sys-badge'); }); return 0; } thumbs.forEach(thumb => { if (force) { // пересчёт после правки данных: снимаем старый бейдж и рисуем заново Array.prototype.slice.call(thumb.children || []).forEach(child => { if (child.getAttribute && child.getAttribute('data-sys-badge')) thumb.removeChild(child); }); thumb.removeAttribute('data-sys-badge'); } this.badgeThumb(thumb, data); }); return thumbs.length; }, // первые секунды после загрузки страницы превьюшки могут догружаться watchThumbBadges(data) { let tries = 0; const tick = () => { this.badgeAllThumbs(data); if (++tries > 12) return; setTimeout(tick, 500); }; tick(); }, // ========================================== // [NEW] Бейдж NEW у новых постов отслеживаемых авторов // ========================================== // «Следим» = ВСЕ авторы группы Authors. // Состояние на автора — profile.authorSeen[запрос] = { m, s } (см. _authorState/badgeNewPosts): // m — «знак» (id <= m старые), s — показанные id > m, чтобы не помечать NEW повторно. // id на rule34 растут со временем: страницы идут по убыванию, старые страницы не бейджатся; // серия новых постов на несколько страниц помечается целиком, пока не дойдём до известного id. // Авторы, за которыми следим — ВСЕ запросы группы Authors (у кого есть «простой» тег). // (favorite ⊆ queries в обычном UI; на всякий случай объединяем.) followedAuthors(profile) { const group = (profile && profile.groups || []).find(g => g.id === 'g_authors' || String(g.label || '').trim().toLowerCase() === 'authors'); if (!group) return []; const all = []; (Array.isArray(group.queries) ? group.queries : []).forEach(q => { if (all.indexOf(q) === -1) all.push(q); }); (Array.isArray(group.favorite) ? group.favorite : []).forEach(q => { if (all.indexOf(q) === -1) all.push(q); }); return all.filter(q => this.authorTagsOf(q).length > 0); }, // Простые теги из запроса автора: "user:ник" → ["user:ник"], // "( a ~ b )" → ["a","b"], "user:alpha -loli" → ["user:alpha"]. // Минусы, звёздочки и служебные знаки отбрасываются (по ним матчить нельзя). authorTagsOf(query) { const q = String(query || '').trim().replace(/^\((.*)\)$/, '$1'); return q.split(/[~\s]+/).map(s => s.trim()).filter(t => /^[\w:]+$/.test(t)); }, // Входит ли тег в строку тегов поста (по границам токена, без учёта регистра). hasToken(tagsStr, token) { if (!tagsStr || !token) return false; const t = String(token).toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return new RegExp('(^|\\s)' + t + '(?=\\s|$)').test(String(tagsStr).toLowerCase()); }, // id поста из ссылки превьюшки (?...&id=NNN или a#pNNN) thumbPostId(thumb) { if (!thumb) return null; // [NEW] aibooru.online: id у самого превью — <article data-id="181952"> const did = (thumb.getAttribute && thumb.getAttribute('data-id')) || ''; if (/^\d+$/.test(did)) return did; const a = thumb.querySelector('a[href]'); if (!a) return null; const href = a.getAttribute('href') || ''; const m = /(?:[?&]id=)(\d+)/i.exec(href); // rule34: ...&id=18195106 if (m) return m[1]; const pm = /\/posts\/(\d+)/i.exec(href); // aibooru: /posts/181952 if (pm) return pm[1]; const am = /^p(\d+)$/i.exec(a.getAttribute('id') || ''); return am ? am[1] : null; }, // Строка тегов поста: rule34 держит её в alt, aibooru — в data-tags / title // (его alt выглядит как «post #181952» — это НЕ теги). thumbTags(thumb) { if (!thumb) return ''; const dt = (thumb.getAttribute && thumb.getAttribute('data-tags')) || ''; if (dt) return String(dt).trim(); const img = thumb.querySelector('img'); const a = thumb.querySelector('a[href]'); let t = ''; if (img) { const alt = img.getAttribute('alt') || ''; const title = img.getAttribute('title') || ''; t = (/^\s*post\s*#\d+\s*$/i.test(alt) || !alt) ? (title || alt) : alt; } if (!t) t = (a && a.getAttribute('title')) || ''; return String(t).trim(); }, addNewBadge(thumb) { if (!thumb || thumb.getAttribute('data-sys-new')) return; if (getComputedStyle(thumb).position === 'static') thumb.style.position = 'relative'; const badge = document.createElement('span'); badge.innerText = 'NEW'; badge.title = 'Новый пост у отслеживаемого автора'; badge.style = Helper.Styles.NEW_BADGE; badge.setAttribute('data-sys-new', '1'); thumb.setAttribute('data-sys-new', '1'); thumb.appendChild(badge); }, // Состояние отслеживания автора: { m, s }. // m — «знак»: все id <= m считаются старыми (бейджа не бывает); // s — уже показанные id, большие m (чтобы не помечать NEW повторно на этой же серии страниц). // Старые форматы (число / массив id) приводятся к объекту. _authorState(seen, a) { let st = seen[a]; if (Array.isArray(st)) st = { m: st.reduce((x, y) => Math.max(x, Number(y) || 0), 0), s: [] }; else if (typeof st === 'number') st = { m: st > 0 ? st : 0, s: [] }; else if (!st || typeof st !== 'object') st = { m: 0, s: [] }; if (typeof st.m !== 'number' || !(st.m >= 0)) st.m = 0; if (!Array.isArray(st.s)) st.s = []; seen[a] = st; return st; }, // Разметка превьюшек бейджем NEW. // Идея: id постов на rule34 растут со временем. Бейдж — только если id БОЛЬШЕ знака m // (страницы идут по убыванию id, поэтому старые страницы бейджей не получают). // Когда впервые доходим до страницы, где есть уже известный id (min <= m), «знак» // поднимается до максимума показанного — так серия новых постов, растянутая на // несколько страниц, помечается целиком, а повторный показ бейджа не даёт. // baseline — авторы, которых видим впервые: молча ставим знак = максимум страницы. badgeNewPosts(data, authors, baseline) { const profile = (data.profiles || {})[window.location.host]; if (!profile) return true; const seen = profile.authorSeen || (profile.authorSeen = {}); const thumbs = this.thumbElements(); let dirty = false, needRetry = false; const pageIds = {}; // автор -> все его id постов на текущей странице authors.forEach(a => { pageIds[a] = []; }); thumbs.forEach(thumb => { const postId = this.thumbPostId(thumb); if (!postId) return; const tags = this.thumbTags(thumb); if (!tags) { needRetry = true; return; } // img ещё не отдал теги const id = Number(postId); if (!id) return; let hit = authors.filter(a => this.authorTagsOf(a).some(t => this.hasToken(tags, t))); // [NEW] user:-авторы: в alt превьюшек тега `user:ник` нет (это системный тег), // поэтому по тегам пост к автору не привязывается. Но страница уже отфильтрована // этим автором (см. trackedAuthorsHere) — если на странице он один, все посты его. if (!hit.length && authors.length === 1) hit = [authors[0]]; if (!hit.length) return; hit.forEach(a => { if (pageIds[a].indexOf(id) === -1) pageIds[a].push(id); }); if (!thumb.getAttribute('data-sys-new')) { // на этой странице уже помечено — не дублируем hit.forEach(a => { if (baseline[a]) return; // первый показ автора — только затравка const st = this._authorState(seen, a); if (id > st.m && st.s.indexOf(id) === -1) { this.addNewBadge(thumb); st.s.push(id); dirty = true; } }); } }); if (!needRetry) { authors.forEach(a => { const ids = pageIds[a]; if (!ids.length) return; const st = this._authorState(seen, a); const min = Math.min.apply(null, ids); const max = Math.max.apply(null, ids); if (baseline[a]) { // первый показ: знак = максимум if (max > st.m) { st.m = max; dirty = true; } st.s = []; } else if (min <= st.m) { // дошли до известных постов — фиксируем знак const top = Math.max(st.m, max, st.s.length ? Math.max.apply(null, st.s) : 0); if (top !== st.m || st.s.length) { st.m = top; st.s = []; dirty = true; } } if (st.s.length > 500) { // «показанные» не растут безгранично st.m = Math.max(st.m, Math.max.apply(null, st.s)); st.s = []; dirty = true; } }); } if (dirty) Storage.save(data); return !needRetry; }, // Теги/запрос текущей страницы: из URL (?tags=…) или из строки поиска. currentSearchTags() { let tags = ''; const href = (window.location && window.location.href) || ''; const m = /[?&]tags=([^&]*)/i.exec(href); if (m) { try { tags = decodeURIComponent(m[1].replace(/\+/g, ' ')); } catch (e) { tags = m[1]; } } if (!tags) { const bar = document.querySelector("input[name='tags']"); if (bar && bar.value) tags = bar.value; } return String(tags).toLowerCase().trim(); }, // Отслеживаем бейджи ТОЛЬКО на странице самого автора: текущий поиск должен содержать // тег одного из авторов группы. В обычной ленте/главной (без такого фильтра) — ничего // не помечаем и НЕ пишем в историю. trackedAuthorsHere(profile) { const pageTags = this.currentSearchTags(); if (!pageTags) return []; return this.followedAuthors(profile) .filter(a => this.authorTagsOf(a).some(t => this.hasToken(pageTags, t))); }, watchNewBadges(data) { const profile = (data.profiles || {})[window.location.host]; if (!profile) return; const authors = this.trackedAuthorsHere(profile); if (!authors.length) return; // не страница отслеживаемого автора — не вмешиваемся const seen = profile.authorSeen || (profile.authorSeen = {}); // база один раз на страницу: кого видим впервые — «затравка» без бейджей const baseline = {}; authors.forEach(a => { baseline[a] = !Object.prototype.hasOwnProperty.call(seen, a); }); let tries = 0; const tick = () => { const done = this.badgeNewPosts(data, authors, baseline); if (done || ++tries > 12) return; setTimeout(tick, 500); }; tick(); }, async injectUI(profile) { // [NEW] запоминаем, была ли панель тегов открыта: перестройка виджета (любое сохранение // настроек, клик по кнопке «под капотом») не должна сворачивать панель const prevBox = document.getElementById('sys-query-box'); const wasOpen = !!(prevBox && prevBox.style && prevBox.style.display === 'block'); // [NEW] защита от дублей: старые шапки убираем СИНХРОННО ПОСЛЕ await, а не до него. // До await два параллельных вызова (init + storage:updated) успевали оба вставить // обёртку и на странице появлялось два «Saved Searches» + «⚙️». Удаление+вставка // идут одной синхронной порцией, поэтому в итоге всегда ровно одна шапка. const dropWrappers = () => { const list = document.querySelectorAll('#sys-wrapper'); for (let i = 0; i < list.length; i++) list[i].remove(); }; dropWrappers(); this.applyHideRules(profile); // [NEW] прячем ненужные элементы сайта if (!profile.selectors.insertPoint) return; const target = await this.waitForElement(profile.selectors.insertPoint); if (!target) return; dropWrappers(); // снимаем всё, что успел вставить параллельный/повторный вызов const wrapper = document.createElement('div'); wrapper.id = 'sys-wrapper'; wrapper.style = Helper.Styles.INJECTOR_WRAPPER; const triggerBtn = document.createElement('button'); triggerBtn.innerText = "🔍 Saved Searches"; triggerBtn.style = Helper.Styles.TRIGGER_BTN; // Settings Button Logic let settingsBtn = null; if (profile.showSettingsBtn !== false) { // Defaults to true if undefined settingsBtn = document.createElement('button'); settingsBtn.innerText = "⚙️"; settingsBtn.style = Helper.Styles.SETTINGS_BTN; settingsBtn.onclick = (e) => { e.preventDefault(); UIEngine.toggle(); // Opens the settings panel }; } let cheatsheetBtn = null; if (profile.showCheatsheet && profile.cheatsheetUrl) { cheatsheetBtn = document.createElement('a'); cheatsheetBtn.innerText = "📄 Cheatsheet"; cheatsheetBtn.href = profile.cheatsheetUrl; cheatsheetBtn.target = "_blank"; cheatsheetBtn.style = Helper.Styles.CHEATSHEET_BTN; } let wikiBtn = null; if (profile.showWiki && profile.wikiUrl) { wikiBtn = document.createElement('a'); wikiBtn.innerText = "🌐 Wiki"; wikiBtn.href = profile.wikiUrl; wikiBtn.target = "_blank"; wikiBtn.style = Helper.Styles.WIKI_BTN; } const queryBox = this.createBox(profile); // [NEW] единая точка открытия/закрытия тегов (кнопка и её подсветка синхронно) const setBoxOpen = (open) => { queryBox.style.display = open ? "block" : "none"; triggerBtn.style.background = open ? "#2196f3" : "#6c757d"; }; setBoxOpen(wasOpen); // закрыта при первом показе; при перестройке остаётся как была triggerBtn.onclick = (e) => { e.preventDefault(); setBoxOpen(queryBox.style.display === "none"); }; // [NEW] автоскрытие/автооткрытие по наведению — только если включено в настройках if (profile.autoHideQueryBox !== false) { let closeTimer = null; const cancelClose = () => { if (closeTimer) { clearTimeout(closeTimer); closeTimer = null; } }; const scheduleClose = () => { cancelClose(); closeTimer = setTimeout(() => setBoxOpen(false), Helper.HOVER_CLOSE_MS); }; triggerBtn.addEventListener('mouseenter', () => { cancelClose(); setBoxOpen(true); }); triggerBtn.addEventListener('mouseleave', () => scheduleClose()); queryBox.addEventListener('mouseenter', () => cancelClose()); queryBox.addEventListener('mouseleave', () => scheduleClose()); } wrapper.appendChild(triggerBtn); if (settingsBtn) wrapper.appendChild(settingsBtn); if (cheatsheetBtn) wrapper.appendChild(cheatsheetBtn); if (wikiBtn) wrapper.appendChild(wikiBtn); wrapper.appendChild(queryBox); target.parentNode.insertBefore(wrapper, target.nextSibling); const searchBar = document.querySelector(profile.selectors.searchBar); if (searchBar) { searchBar.addEventListener('input', () => this.updateHighlights(profile)); searchBar.addEventListener('change', () => this.updateHighlights(profile)); this.updateHighlights(profile); } }, createBox(profile) { const box = document.createElement('div'); box.id = 'sys-query-box'; box.style = Helper.Styles.QUERY_BOX; profile.groups.forEach((group, index) => { const card = document.createElement('div'); card.style = Helper.Styles.GROUP_CARD(index === profile.groups.length - 1); const title = document.createElement('div'); title.innerText = group.label; title.style = Helper.Styles.GROUP_TITLE; const tagsCont = document.createElement('div'); tagsCont.style = "display: flex; flex-wrap: wrap; gap: 6px;"; const labels = group.labels || {}; const tristate = group.tristate || {}; const radio = Array.isArray(group.radio) ? group.radio : []; const favorites = Array.isArray(group.favorite) ? group.favorite : []; const marks = group.marks || {}; // [NEW] группы «под капотом» (Media / Sort / Aspect): кнопки не пишут в строку поиска const isSilent = Storage.isSilent(group); // [NEW] порядок как в настройках: алфавит + избранные в начале (Helper.orderedIndexes) Helper.orderedIndexes(group).forEach(qIdx => { const query = group.queries[qIdx]; // [NEW] подпись кнопки (score/id) и трёхсостоянийный режим (video/-video) const tristateAlt = tristate[query] || ''; const caption = labels[query] || query; const tag = document.createElement('span'); const captionEl = document.createElement('span'); captionEl.innerText = caption; tag.appendChild(captionEl); // [NEW] косметические метки (♂ / ⚧) — после подписи (nick♂), сортировку не меняют. // Показываем их только у авторов и Meta (см. Storage.allowsMarks) if (Storage.allowsMarks(group)) Helper.MARKERS.forEach(m => { if ((marks[m.key] || []).indexOf(query) === -1) return; const mark = document.createElement('span'); mark.innerText = m.symbol; mark.style = `color: ${m.color}; margin-left: 5px; font-size: 15px; line-height: 1;`; tag.appendChild(mark); }); // [NEW] Красная лампочка у автора, чью страницу ещё не открывали: пока нет записи // в authorSeen, бейджам NEW не от чего считаться. Открыл страницу один раз — // запоминается «знак», и лампочка пропадает. if (Storage.isAuthorsGroup(group)) { const seenState = (profile.authorSeen || {})[query]; if (!(seenState && seenState.m)) { const lamp = document.createElement('span'); lamp.innerText = '●'; lamp.title = 'Нет данных об авторе: открой его страницу хотя бы раз — тогда включится подсчёт бейджей NEW'; lamp.style = Helper.Styles.NO_DATA_DOT; lamp.setAttribute('data-sys-no-data', '1'); tag.appendChild(lamp); } } tag.title = radio.indexOf(query) !== -1 ? `${query} (взаимоисключающая: активна только одна)` : (tristateAlt ? `${query} → ${tristateAlt} → off` : query); if (isSilent) { // [NEW] «под капотом»: фильтр живёт внутри скрипта, в строку поиска не пишется tag.title = `${caption}\n${tristateAlt ? `${query} → ${tristateAlt}` : query}\n\n` + 'Фильтр применяется скрытно при поиске; в строке поиска ничего не появляется'; } // [NEW] избранные — золотая рамка вместо звёздочки (только у авторов и Meta) if (Storage.allowsMarks(group) && favorites.indexOf(query) !== -1) tag.setAttribute('data-sys-fav', '1'); this.applyTagStyle(tag, Helper.Styles.TAG_ITEM); tag.setAttribute('data-sys-query', query); if (isSilent) { tag.setAttribute('data-sys-silent', '1'); tag.setAttribute('data-sys-gid', group.id); } if (tristateAlt) tag.setAttribute('data-sys-alt', tristateAlt); tag.onclick = () => { // [NEW] «под капотом»: только вкл/выкл внутри скрипта, строку поиска не трогаем if (isSilent) { Storage.toggleSilent(window.location.host, group.id, query); return; } // [NEW] score/id: либо один, либо никто if (radio.indexOf(query) !== -1) { this.selectExclusive(profile, radio, query); return; } if (tristateAlt) this.cycleTag(profile, query, tristateAlt); else this.toggleQuery(profile, query); }; tagsCont.appendChild(tag); }); // [NEW] пустой блок виден в виджете, даже если кнопок ещё нет if (group.queries.length === 0) { const empty = document.createElement('span'); empty.innerText = "пусто — добавьте в настройках"; empty.style = "font-size: 12px; font-style: italic; color: #adb5bd;"; tagsCont.appendChild(empty); } card.appendChild(title); card.appendChild(tagsCont); box.appendChild(card); }); return box; }, _escapeRegex(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }, // [NEW] Проверка наличия запроса в строке поиска по границам разделителя _hasQuery(value, query, delimiterRegexStr) { const delimiterRe = delimiterRegexStr || "\\s+"; // [NEW] без учёта регистра: сайт может вернуть тег в другом регистре // (кнопка "InfWaifus" → запрос "user:InfWaifus", на результатах "user:infwaifus") return new RegExp(`(^|${delimiterRe})${this._escapeRegex(query)}(?=${delimiterRe}|$)`, 'i').test(value); }, // [NEW] Взаимоисключающий выбор: активна только одна кнопка из набора, либо ни одной. // Повторный клик по активной — снимает её. selectExclusive(profile, members, query) { const bar = document.querySelector(profile.selectors.searchBar); if (!bar) return; const delimiterStr = profile.delimiter !== undefined ? profile.delimiter : " "; const delimiterRegexStr = profile.splitRegex || "\\s+"; // активной считаем кнопку только если она единственная включённая: // если в строке оба сразу (грязное состояние) — клик нормализует к выбранной const wasActive = this._hasQuery(bar.value, query, delimiterRegexStr) && !members.some(member => member !== query && this._hasQuery(bar.value, member, delimiterRegexStr)); // снять все кнопки набора, включая выбранную members.forEach(member => { if (!this._hasQuery(bar.value, member, delimiterRegexStr)) return; const re = new RegExp(`(^|${delimiterRegexStr})${this._escapeRegex(member)}(?=${delimiterRegexStr}|$)`, 'i'); bar.value = bar.value.replace(re, (m, boundary) => boundary); }); bar.value = bar.value.replace(new RegExp(`(${delimiterRegexStr})+`, 'g'), delimiterStr).trim(); if (bar.value.length > 0) bar.value += delimiterStr; // и включить выбранную, если она не была активна (тогда это было выключение) if (!wasActive) { const currentVal = bar.value.trim(); bar.value = (currentVal ? currentVal + delimiterStr : "") + query + delimiterStr; } bar.dispatchEvent(new Event('input', { bubbles: true })); }, // [NEW] Трёхсостоянийная кнопка: none -> query (зелёный) -> alt (красный) -> none cycleTag(profile, query, altQuery) { const bar = document.querySelector(profile.selectors.searchBar); if (!bar) return; const delimiterStr = profile.delimiter !== undefined ? profile.delimiter : " "; const delimiterRegexStr = profile.splitRegex || "\\s+"; const hasMain = this._hasQuery(bar.value, query, delimiterRegexStr); const hasAlt = this._hasQuery(bar.value, altQuery, delimiterRegexStr); if (hasMain) { // video -> -video const re = new RegExp(`(^|${delimiterRegexStr})${this._escapeRegex(query)}(?=${delimiterRegexStr}|$)`, 'i'); bar.value = bar.value.replace(re, (m, boundary) => boundary + altQuery); } else if (hasAlt) { // -video -> off const re = new RegExp(`(^|${delimiterRegexStr})${this._escapeRegex(altQuery)}(?=${delimiterRegexStr}|$)`, 'i'); bar.value = bar.value.replace(re, (m, boundary) => boundary) .replace(new RegExp(`(${delimiterRegexStr})+`, 'g'), delimiterStr) .trim(); if (bar.value.length > 0) bar.value += delimiterStr; } else { // off -> video this.toggleQuery(profile, query); return; // toggleQuery already dispatched the input event } bar.dispatchEvent(new Event('input', { bubbles: true })); }, // 替換原本的 toggleQuery 函式 toggleQuery(profile, query) { const bar = document.querySelector(profile.selectors.searchBar); if (!bar) return; const delimiterStr = profile.delimiter !== undefined ? profile.delimiter : " "; const delimiterRegexStr = profile.splitRegex || "\\s+"; // [NEW] регистр не важен: "user:InfWaifus" и "user:infwaifus" — один и тот же тег const matchRegex = new RegExp(`(^|${delimiterRegexStr})${this._escapeRegex(query)}(?=${delimiterRegexStr}|$)`, 'gi'); if (matchRegex.test(bar.value)) { // 如果標籤存在,將其移除。 // 將標籤替換為它前面的邊界符號 ($1),接著清理多餘的連續分隔符號 bar.value = bar.value.replace(matchRegex, (m, boundary) => boundary) .replace(new RegExp(`(${delimiterRegexStr})+`, 'g'), delimiterStr).trim(); if (bar.value.length > 0) bar.value += delimiterStr; // 保持結尾有空格的習慣 } else { // 如果標籤不存在,將其加到最後 let currentVal = bar.value.trim(); bar.value = (currentVal ? currentVal + delimiterStr : "") + query + delimiterStr; } bar.dispatchEvent(new Event('input', { bubbles: true })); }, // [NEW] Актуальный профиль из хранилища: объект в обработчиках устаревает после сохранений freshProfile(profile) { try { const data = Storage.load(); const host = window.location.host; if (data.profiles && data.profiles[host]) return data.profiles[host]; } catch (e) {} return profile; }, // [NEW] Фильтры «под капотом» (Media / Sort / Aspect) одним фрагментом запроса silentBlock(profile) { return Storage.silentSearchBlock(this.freshProfile(profile)); }, // Подставить скрытые фильтры в момент отправки поиска, НЕ трогая видимое поле. // ВАЖНО: браузер снимает данные формы не в момент события submit, а когда уже планирует // переход, — «подменить value и вернуть» ненадёжно (фильтры могли не доехать до сервера, // а кнопки горели). Поэтому добавляем в форму скрытый `<input name="tags" value="<всё // вместе>">` и на время отправки снимаем имя у видимого поля: сервер получает ровно одно // значение — с фильтрами, а поле пользователя вообще не меняется (нет ни мелькания, // ни потери текста). Имя поля возвращается через 800 мс: если отправку не отменили — // страница к тому моменту уже ушла и таймер умирает вместе с ней. // (Сайтовый слайдер «Filter AI posts» сделан иначе: cookie `filter_ai` читает сам сервер. // Свои теги серверу нечем принять, кроме как в запросе, поэтому добавляем их сами.) injectSilentQuery(profile) { const block = this.silentBlock(profile); if (!block) return false; const bar = document.querySelector(profile.selectors.searchBar); if (!bar) return false; const delim = profile.splitRegex || "\\s+"; const name = bar.getAttribute('name') || 'tags'; const form = bar.form; const saved = bar.value; if (this._hasQuery(saved, block, delim)) return false; const full = (saved.trim() ? saved.trim() + ' ' : '') + block + ' '; // резерв: поле вне формы — подставляем значение и возвращаем его в том же тике if (!form || !form.appendChild) { bar.value = full; const back = () => { if (bar.value === full) bar.value = saved; }; if (typeof queueMicrotask === 'function') queueMicrotask(back); else Promise.resolve().then(back); return true; } const prev = form.querySelector('input[data-sys-silent-input]'); if (prev && prev.remove) prev.remove(); // повторный submit на той же странице const hidden = document.createElement('input'); hidden.setAttribute('type', 'hidden'); hidden.setAttribute('name', name); hidden.setAttribute('data-sys-silent-input', '1'); hidden.value = full; form.appendChild(hidden); bar.removeAttribute('name'); // иначе сервер получил бы два «tags» setTimeout(() => { if (bar.getAttribute && bar.getAttribute('name') === null) bar.setAttribute('name', name); }, 800); console.log('[SYS] скрытые фильтры подставлены скрытым полем (видимое поле не менялось)'); return true; }, // Убрать такие фильтры из строки поиска (после перехода на результаты) // и запомнить их как включённые, чтобы кнопки горели и фильтр не потерялся. stripSilentQuery(profile) { const bar = document.querySelector(profile.selectors.searchBar); if (!bar || !bar.value) return false; const fresh = this.freshProfile(profile); const delim = profile.splitRegex || "\\s+"; const marks = []; // { group, query, state } Storage.silentGroups(fresh).forEach(group => { (Array.isArray(group.queries) ? group.queries : []).forEach(query => { if (this._hasQuery(bar.value, query, delim)) { marks.push({ group, query, state: 'on' }); return; } const alt = (group.tristate || {})[query] || ''; if (alt && this._hasQuery(bar.value, alt, delim)) marks.push({ group, query, state: 'neg' }); }); }); if (!marks.length) return false; let value = bar.value; marks.forEach(({ group, query, state }) => { const text = state === 'neg' ? ((group.tristate || {})[query] || query) : query; const re = new RegExp(`(^|${delim})${this._escapeRegex(text)}(?=${delim}|$)`, 'gi'); value = value.replace(re, (m, boundary) => boundary); }); bar.value = value.replace(new RegExp(`(${delim})+`, 'g'), " ").trim(); try { bar.setAttribute('value', bar.value); } catch (e) {} // атрибут тоже чистим — он переживает перерисовки const data = Storage.load(); const prof = (data.profiles || {})[window.location.host]; if (prof && Array.isArray(prof.groups)) { marks.forEach(({ group, query, state }) => { const g = prof.groups.find(x => x.id === group.id || x.label === group.label); if (!g) return; const active = (Array.isArray(g.active) ? g.active : []).filter(q => q !== query); const neg = (Array.isArray(g.activeNeg) ? g.activeNeg : []).filter(q => q !== query); if (state === 'neg') neg.push(query); else active.push(query); g.active = active; g.activeNeg = neg; }); Storage.save(data); } console.log('[SYS] скрытые фильтры убраны из строки поиска (под капотом активны):', marks.length); return true; }, // [NEW] Ранняя чистка поля поиска — ДО первой отрисовки страницы. // Сайт кладёт в <input name="tags"> все теги из URL, включая наши скрытые фильтры. // Скрипт стартует на document-start и ловит поле через MutationObserver, как только парсер // его создаст: колбэк выполняется в микрозадаче, т.е. до отрисовки — фильтры не видны ни кадра. // Чистка идёт через stripSilentQuery: убрать теги из поля И запомнить их активными под капотом. earlyStrip() { const run = () => { const data = Storage.load(); const profile = (data.profiles || {})[window.location.host]; if (!profile || !profile.selectors || !profile.selectors.searchBar) return false; if (!document.querySelector(profile.selectors.searchBar)) return false; try { this.stripSilentQuery(profile); } catch (e) { console.error('[SYS] ранняя чистка поля: ', e); } return true; }; if (run()) return; // поле уже есть на момент запуска const root = document.documentElement; if (!root || typeof MutationObserver !== 'function') return; // дождётся обычный старт const obs = new MutationObserver(() => { if (run()) obs.disconnect(); }); obs.observe(root, { childList: true, subtree: true }); }, // Подстановка фильтров вокруг отправки поиска + сброс при новом поиске по тегу watchSilentFilters(profile) { const attach = () => { const bar = document.querySelector(profile.selectors.searchBar); if (!bar) return false; if (bar.getAttribute('data-sys-silent-hook')) return true; bar.setAttribute('data-sys-silent-hook', '1'); // Подстановка ТОЛЬКО в момент submit (capture — раньше обработчиков сайта). // injectSilentQuery ставит блок в value и возвращает поле в тот же тик, // до отрисовки — поэтому в поле фильтры НИКОГДА не мелькают. Enter в поле и // кнопка Search дают одно и то же событие submit, отдельные обработчики не нужны. const inject = () => { try { this.injectSilentQuery(profile); } catch (e) {} }; document.addEventListener('submit', (e) => { if (!e.target || !bar.form || e.target !== bar.form) return; inject(); }, true); document.addEventListener('click', (e) => { const t = e.target; // клик внутри формы (в т.ч. в самое поле) фильтры НЕ подставляет — это не отправка if (t && bar.form && t.closest && t.closest('form') === bar.form) return; // [NEW] клик по ссылке-тегу (список тегов, теги поста, поиск по тегу) — // это новый поиск: прежние скрытые фильтры больше не действуют const link = (t && t.closest) ? t.closest('a[href]') : null; if (!link || !link.getAttribute) return; if (link.closest && link.closest('#sys-query-box')) return; // свои кнопки не трогаем const href = link.getAttribute('href') || ''; if (!/([?&]tags=|\/post\/|\/posts?\/|page=post)/i.test(href)) return; // листалка результатов: фильтры должны пережить переход (они есть в URL). // rule34 листает по pid=NNN, aibooru — по page=NNN (не путать с page=post) if (/[?&](?:pid=\d|page=\d)/i.test(href) && (prof => Storage.silentSearchBlock(prof))(this.freshProfile(profile))) return; try { Storage.resetSilentState(window.location.host); } catch (err) {} }, true); // на загруженной странице прячем фильтры из строки поиска // (сразу при attach — сайт уже положил теги в поле, убираем без ожидания) [0, 150, 600, 1500, 3000].forEach(delay => setTimeout(() => { try { this.stripSilentQuery(profile); } catch (e) {} this.updateHighlights(profile); }, delay)); return true; }; if (attach()) return; const wait = () => { if (!attach()) setTimeout(wait, 300); }; wait(); }, // [NEW] базовый стиль кнопки + золотая рамка у избранных (без звёздочки) applyTagStyle(tagEl, css) { if (tagEl.getAttribute('data-sys-fav') === '1') css = css.replace(/;+\s*$/, '') + '; border: 2px solid #ffb300;'; tagEl.style.cssText = css; }, // 替換原本的 updateHighlights 函式 updateHighlights(profile) { const bar = document.querySelector(profile.selectors.searchBar); if (!bar) return; const box = document.getElementById('sys-query-box'); if (!box) return; const delimiterRegexStr = profile.splitRegex || "\\s+"; const barValue = bar.value; const fresh = this.freshProfile(profile); box.querySelectorAll('span[data-sys-query]').forEach(tagEl => { const query = tagEl.getAttribute('data-sys-query'); const altQuery = tagEl.getAttribute('data-sys-alt'); // [NEW] кнопки «под капотом» (Media / Sort / Aspect): горят по внутреннему // состоянию из хранилища, а не по содержимому строки поиска if (tagEl.getAttribute('data-sys-silent') === '1') { const gid = tagEl.getAttribute('data-sys-gid'); const group = ((fresh && fresh.groups) || []).find(g => g.id === gid || g.label === gid); const state = Storage.silentStateOf(group, query); this.applyTagStyle(tagEl, state === 'neg' ? Helper.Styles.TAG_ITEM_ACTIVE_NEG : (state === 'on' ? Helper.Styles.TAG_ITEM_ACTIVE : Helper.Styles.TAG_ITEM)); return; } const isMatched = this._hasQuery(barValue, query, delimiterRegexStr); // [NEW] Трёхсостоянийная кнопка: зелёный = query, красный = alt-query if (altQuery) { this.applyTagStyle(tagEl, isMatched ? Helper.Styles.TAG_ITEM_ACTIVE_POS : (this._hasQuery(barValue, altQuery, delimiterRegexStr) ? Helper.Styles.TAG_ITEM_ACTIVE_NEG : Helper.Styles.TAG_ITEM)); return; } this.applyTagStyle(tagEl, isMatched ? Helper.Styles.TAG_ITEM_ACTIVE : Helper.Styles.TAG_ITEM); }); }, waitForElement(selector) { return new Promise(resolve => { if (document.querySelector(selector)) return resolve(document.querySelector(selector)); const obs = new MutationObserver(() => { if (document.querySelector(selector)) { obs.disconnect(); resolve(document.querySelector(selector)); } }); obs.observe(document.body, { childList: true, subtree: true }); }); } }; // ========================================== // Boot Initialization // ========================================== // [NEW] Скрипт стартует на document-start: сначала чистим поле поиска (до первой отрисовки), // остальное — когда DOM готов (document.body ещё нет). try { SiteInjector.earlyStrip(); } catch (e) { console.error('[SYS] ранняя чистка поля: ', e); } const boot = () => { UIEngine.init(); SiteInjector.init(); GM_registerMenuCommand("Open Settings Panel", () => UIEngine.toggle()); const targetWindow = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; targetWindow.toggleQM = () => UIEngine.toggle(); console.log('[SYS] Save Your Searches (custom) v' + (typeof GM_info !== 'undefined' ? GM_info.script.version : '?') + ' loaded'); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, true); else boot(); })();