Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions plugins/cf-tag-filter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Custom Field Tag Filter

Hide "meta-tags" from manual tag entry (when editing scenes) and from scraper results. A tag is treated as a meta-tag when it has a specific **custom field** set on it.

This is a self-contained alternative to the [`tag-filter`](https://github.com/feederbox826/plugins) plugin. The big difference: `tag-filter` stores the list of hidden tags in the **browser's localStorage**, so the list is lost when you clear your browser cache or open StashApp in a different browser. This plugin instead stores the marking **on the tag itself in the stash database** (via a custom field), so it is shared across browsers and survives cache clears.

It is also **fully self-contained** — it does not require any helper plugins (`0gql-intercept`, `forbiddenConfig`, `wfke`, `fontawesome-js`).

## What it does

Hides marked tags from:

- the tag dropdown when editing a scene (`FindTagsForSelect`)
- the scraper's automatic tag search
- scraper results (optional, see settings)

It does **not** hide a tag from:

- the `/tags` page or its search box
- scenes that already have the tag
- the backend / GraphQL

## Setup

1. Install the plugin and enable it under **Settings → Plugins**.
2. Configure the settings (see below). Set **Custom field name** to the field you want to use, e.g. `foobar`.
3. Mark a tag: open the tag's **Edit** page, add a custom field with the name you configured (e.g. `foobar`) and any non-empty value (e.g. `1`), and save.
4. Reload the UI. The tag is now hidden from scene tag entry.

## Settings

| Setting | Description |
| --- | --- |
| **Custom field name** | The name of the custom field that marks a tag as hidden. Required — leave empty to disable filtering. |
| **Custom field value (optional)** | If set, only tags whose custom field equals this value are hidden. Leave empty to hide any tag that has the field set (to any non-empty value). |
| **Also hide marked tags from scraper results** | When enabled, marked tags are also stripped from scene scrape results. |

## How it works

The plugin intercepts GraphQL responses in the browser and removes hidden tags from the scene tag dropdown and (optionally) scraper results. It asks the stash backend for the list of marked tags **lazily and only once per page load** — the first time a tag dropdown or scrape result is shown — then reuses that list for the rest of the session. It does no background polling, so simply viewing or browsing items generates no extra traffic.

This mirrors Stash's own cache-first behavior: if you mark or change a tag in another tab, reload the page to pick it up (the same way newly added tags/groups require a reload in the stock UI).

Because the source of truth is the tag's custom field in the stash database, your hidden-tag list is the same in every browser and is never lost when clearing the browser cache.
243 changes: 243 additions & 0 deletions plugins/cf-tag-filter/cf-tag-filter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
// cf-tag-filter
// =============
// Hide tags that are marked via a custom field from manual tag entry (scene
// editing) and from scraper results.
//
// Unlike the original "tag-filter" plugin, the list of hidden tags is NOT stored
// in the browser's localStorage. Instead the marking lives on the tag in the
// stash backend (a custom field), so it is shared across browsers and survives
// clearing the browser cache.
//
// This plugin is fully self-contained: it does not depend on any helper plugins
// (0gql-intercept, forbiddenConfig, wfke, fontawesome-js). It inlines a minimal
// GraphQL fetch interceptor and reads its own settings via GraphQL.
//
// It does no background polling: the hidden-tag list is fetched lazily once —
// the first time a tag-select dropdown or scrape result is shown — and then
// reused for the rest of the page session. This mirrors Stash's own cache-first
// behavior: just viewing or browsing generates no traffic, and changes made
// elsewhere (e.g. in another tab) are picked up after a page reload, same as the
// rest of the UI.

(function () {
"use strict";

const PLUGIN_ID = "cf-tag-filter";

// IDs (as strings) of all tags currently considered "hidden".
let hiddenTagIds = new Set();
// Cached settings.
let settings = { fieldName: "", fieldValue: "", hideScrape: false };
// Cached API key (fallback auth; same-origin requests usually use the cookie).
let apiKey = null;

// --- GraphQL helper -----------------------------------------------------

// Note: we keep a reference to the *original* fetch so our own requests are
// not re-processed by our interceptor (and to avoid any chance of recursion).
const originalFetch = window.fetch.bind(window);

async function gqlRequest(query, variables, operationName) {
const headers = { "Content-Type": "application/json" };
if (apiKey) headers["ApiKey"] = apiKey;

const res = await originalFetch("/graphql", {
method: "POST",
headers,
body: JSON.stringify({ query, variables, operationName }),
});
return res.json();
}

// --- settings + hidden tag refresh --------------------------------------

async function loadSettings() {
const query = `query CfTagFilterConfig {
configuration {
general { apiKey }
plugins(include: ["${PLUGIN_ID}"])
}
}`;
try {
const json = await gqlRequest(query, {}, "CfTagFilterConfig");
const cfg = json?.data?.configuration;
apiKey = cfg?.general?.apiKey || apiKey;
const s = cfg?.plugins?.[PLUGIN_ID] || {};
settings = {
fieldName: (s.customFieldName || "").trim(),
fieldValue: (s.customFieldValue || "").trim(),
hideScrape: !!s.hideScrape,
};
} catch (e) {
console.error("[cf-tag-filter] failed to load settings", e);
}
}

// A tag is hidden when it has the configured custom field set (NOT_NULL). If a
// value is also configured, only tags whose field equals that value are hidden.
async function refreshHiddenTags() {
const field = settings.fieldName;
if (!field) {
hiddenTagIds = new Set();
return;
}

const query = `query CfTagFilterFindTags($filter: FindFilterType, $tag_filter: TagFilterType) {
findTags(filter: $filter, tag_filter: $tag_filter) {
tags { id custom_fields }
}
}`;
const variables = {
filter: { per_page: -1 },
tag_filter: { custom_fields: [{ field, modifier: "NOT_NULL" }] },
};

try {
const json = await gqlRequest(query, variables, "CfTagFilterFindTags");
let tags = json?.data?.findTags?.tags ?? [];

// optional exact value match (compared as string to avoid number/string
// coercion surprises)
const wanted = settings.fieldValue;
if (wanted) {
tags = tags.filter((tag) => {
const v = tag.custom_fields?.[field];
return v !== undefined && v !== null && String(v) === wanted;
});
}

hiddenTagIds = new Set(tags.map((tag) => String(tag.id)));
} catch (e) {
console.error("[cf-tag-filter] failed to refresh hidden tags", e);
}
}

// --- GraphQL response interceptors --------------------------------------

// hide tags from the scene tag select dropdown / scraper search
function tagSelectInterceptor(data, query) {
if (!data?.data?.findTags) return data;
if (query?.operationName !== "FindTagsForSelect") return data;
// never hide tags while browsing the /tags pages themselves
if (location.pathname.startsWith("/tags")) return data;

const filtered = data.data.findTags.tags.filter(
(tag) => !hiddenTagIds.has(String(tag.id))
);
data.data.findTags.tags = filtered;
data.data.findTags.count = filtered.length;
return data;
}

function stripSceneTags(scene) {
if (scene && Array.isArray(scene.tags)) {
scene.tags = scene.tags.filter(
(tag) => !hiddenTagIds.has(String(tag.stored_id))
);
}
return scene;
}

// hide tags from scraper results.
// scrapeSingleScene -> [ScrapedScene]
// scrapeMultiScenes -> [[ScrapedScene]] (note the nested arrays)
function scraperInterceptor(data, query) {
if (!settings.hideScrape) return data;
if (!data?.data) return data;

const op = query?.operationName;
if (op !== "ScrapeSingleScene" && op !== "ScrapeMultiScenes") return data;

const key = op === "ScrapeSingleScene" ? "scrapeSingleScene" : "scrapeMultiScenes";
const result = data.data[key];
if (!Array.isArray(result)) return data;

data.data[key] = result.map((entry) =>
Array.isArray(entry) ? entry.map(stripSceneTags) : stripSceneTags(entry)
);
return data;
}

// --- fetch monkeypatch --------------------------------------------------

function isGraphqlJson(response) {
const contentType = response.headers.get("Content-Type") || "";
return (
(contentType.includes("application/json") ||
contentType.includes("application/graphql-response+json")) &&
response.url.endsWith("/graphql")
);
}

async function interceptResponse(response, request) {
if (!response.ok || !isGraphqlJson(response)) return response;

const query = request?.body ? JSON.parse(request.body) : undefined;
const op = query?.operationName;

// We only ever touch the tag-select and scene-scrape operations. Anything
// else (including simply viewing or browsing scenes) is passed straight
// through untouched, so the plugin does no work and makes no requests while
// you browse.
const relevant =
op === "FindTagsForSelect" ||
op === "ScrapeSingleScene" ||
op === "ScrapeMultiScenes";
if (!relevant) return response;

// Lazily load our hidden-tag list once, the first time it actually matters.
await ensureLoaded();

let data = await response.clone().json();
data = tagSelectInterceptor(data, query);
data = scraperInterceptor(data, query);

return new Response(JSON.stringify(data), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}

function installFetchPatch() {
if (window.__cfTagFilterPatched) return;
window.__cfTagFilterPatched = true;

window.fetch = async (...args) => {
const [, config] = args;
const response = await originalFetch(...args);
try {
return await interceptResponse(response, config);
} catch (e) {
return response;
}
};
}

// --- one-time load ------------------------------------------------------

// The hidden-tag list is loaded lazily the first time it is actually needed
// (a tag-select dropdown or scrape result), then reused for the rest of the
// page session. There is no background polling and no periodic refresh, which
// mirrors Stash's own cache-first behavior: changes made elsewhere (e.g. in
// another tab) show up after a page reload, just like the rest of the UI.
let loadPromise = null;

async function refreshAll() {
// loadSettings/refreshHiddenTags swallow their own errors, so this never
// rejects; a failed attempt simply leaves the (empty) cache in place.
await loadSettings();
await refreshHiddenTags();
}

function ensureLoaded() {
// cache the promise so concurrent callers share a single load, and so the
// queries run only once per page session
if (!loadPromise) loadPromise = refreshAll();
return loadPromise;
}

// --- bootstrap ----------------------------------------------------------

installFetchPatch();
})();
18 changes: 18 additions & 0 deletions plugins/cf-tag-filter/cf-tag-filter.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Custom Field Tag Filter
description: Hide tags marked via a custom field from scene tag entry and scraping.
version: 1.0.0
ui:
javascript:
- cf-tag-filter.js
settings:
customFieldName:
displayName: Custom field name
description: Tags that have this custom field set are hidden from tag selection.
type: STRING
customFieldValue:
displayName: Custom field value (optional)
description: If set, only tags whose custom field equals this value are hidden. Leave empty to hide any tag that has the field set.
type: STRING
hideScrape:
displayName: Also hide marked tags from scraper results
type: BOOLEAN
Loading