diff --git a/README.md b/README.md index 09e8c1e..1e4b478 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,19 @@ Adds a button to the performers page to check for duplicate performer urls. Task ![Performers page](images/Stash%20Performer%20Audit%20Task%20Button/performers-page.png?raw=true "Performers page") +### Stash Performer Custom Fields + +Adds custom fields to performers that are stored in performer details as JSON. + +**[VIEW DOCUMENTATION](plugins/stashPerformerCustomFields/README.md)** + +**Note: Make sure you fully understand how this plugin will affect your performer details data before attempting to use it.** + +#### Requirements + +* Python 3.9+ +* PyStashLib (https://pypi.org/project/pystashlib/) + ### Stash Performer Image Cropper Adds ability to crop performer image from performer page diff --git a/plugins/stashPerformerCustomFields/README.md b/plugins/stashPerformerCustomFields/README.md new file mode 100644 index 0000000..15465b8 --- /dev/null +++ b/plugins/stashPerformerCustomFields/README.md @@ -0,0 +1,110 @@ +# Stash Performer Custom Fields + +Adds custom fields to performers that are stored in performer details as JSON. + +**Note: Make sure you fully understand how this plugin will affect your performer details data before attempting to use it.** + +## Overview + +This plugin provides the following: + +* A task for migrating existing performer detail data to JSON format +* A performer creation hook that adds custom fields to performer details data +* UI updates to performer pages for viewing and editing performer details and custom fields + +## Consequences + +Since this plugin combines existing performer details data and custom field data in JSON format for storage as performer details in the stash database, this has some consequences and limitations to consider: + +* Custom fields cannot be filtered on directly + +* Filtering on performer details will include custom fields data. + +* Direct editing of performer details is discouraged + * However, an alternative is provided by the updated UI. + +* The raw performer details data is less human-readable due to it being JSON instead of just unformatted text. + * This shouldn't matter if you just use the updated UI for editing details and custom fields data. + +## Performer Details Migration Task + +The first thing the task does is run the backup task. Then it goes through all performers and converts details data to JSON with custom fields. Existing details data is preserved within the JSON. + +A performer without any existing details will end up with `{"custom": {"notes": null, "paths": null, "urls": null}}` + +A performer with existing details of `"This is a performer bio"` will have their details changed to `{"custom": {"notes": null, "paths": null, "urls": null}, "details": "This is a performer bio"}`. + +No existing details data is lost, because it is still embedded within the JSON. + +*Examples assume the default fields setting `notes,paths,urls`* + +## Performer Creation Hook + +Whenever a new performer is created, the plugin hook with the default field setting `notes,paths,urls` will set the performer details to `{"custom": {"notes": null, "paths": null, "urls": null}}`. + +## Performer Page UI Changes + +The plugin displays custom fields just as the normal performer fields are displayed. Data in the `urls` and `paths` custom fields will display as links. Clicking a url link will open the link in a new tab and clicking a path will open File Explorer at that location. + +A toggle button is added to the Details section that allows you to switch to the editing mode. The editing mode allows adding and remove custom field data entries. + +The editing mode also provides a textbox for editing the normal Details section. This is a substitute for editing performer details the normal way. + +Manually editing performer details data the normal way is no longer recommended unless you are familiar with JSON and understand the data format used by the plugin. + +## Plugin Settings + +### Fields + +The fields setting defines the custom fields that will be used. The value should be a comma-separated list of custom field names. The default value is `notes,paths,urls`. Only the `paths` and `urls` custom field names have special UI behavior. + +Field names should not contain spaces. You can use underscores instead and they will be replaced with spaces in the field name labels on the performers page. + +### Show Edit + +This setting is tied to the toggle button that is added to the performer page details that switches between view and edit mode. + +## JSON Format + +Custom field data along with performer details data is saved to the existing performer details field as JSON. + +The performer details JSON is an object with a `custom` key and an optional `details` key. The `custom` value is an object and the `details` value is a string. + +The field names in the field setting are used as keys in the `custom` object. The key values are either null or an array of strings. + +*Examples assume the default fields setting `notes,paths,urls`* + +Without performer details and no urls, paths, or notes: +``` +{ + "custom": { + "notes": null, + "paths": null, + "urls": null + } +} +``` + +With performer details and no urls, paths, or notes: +``` +{ + "custom": { + "notes": null, + "paths": null, + "urls": null + }, + "details": "Performer details go here" +} +``` + +With performer details, urls, paths, and notes: +``` +{ + "custom": { + "notes": ["Note 1","Note 2"], + "paths": ["C:\Videos\Alice","C:\Videos\Alice 2"], + "urls": ["https://github.com/stashapp/stash","https://github.com/7dJx1qP/stash-plugins"] + }, + "details": "Performer details go here" +} +``` \ No newline at end of file diff --git a/plugins/stashPerformerCustomFields/performer_details.py b/plugins/stashPerformerCustomFields/performer_details.py new file mode 100644 index 0000000..893692d --- /dev/null +++ b/plugins/stashPerformerCustomFields/performer_details.py @@ -0,0 +1,82 @@ +import json +import math +import yaml +from tqdm import tqdm +from stashlib.logger import logger as log +from stashlib.stash_database import StashDatabase +from stashlib.stash_models import PerformersRow + +def update_performer_details(db: StashDatabase, performer_id: int, details, commit=True): + encoded_details = json.dumps(details, ensure_ascii=False) + db.performers.update_details_by_id(performer_id, encoded_details, commit) + +def fields_to_dict(fields: list[str]): + result = {} + for field in fields: + result[field] = None + return result + +def init_performer_details(db: StashDatabase, fields: list[str], performer: PerformersRow, commit=True): + log.LogDebug(f"Initializing performer... {performer.id} {performer.name}") + if not performer.details: + log.LogTrace(f"No details. Updating performer...") + details = { + 'custom': fields_to_dict(fields) + } + encoded_details = json.dumps(details, ensure_ascii=False) + db.performers.update_details_by_id(performer.id, encoded_details, commit) + else: + log.LogTrace(f"Checking for performer details JSON...") + details = { + 'custom': fields_to_dict(fields) + } + needs_update = False + try: + docs = json.loads(performer.details) + if type(docs) is dict: + details = docs + log.LogTrace(f"JSON is dict, details: {'details' in details}, custom: {'custom' in details}") + if 'details' in details and type(details['details']) is not str: + log.LogWarning(f"Malformed details key value. Expected str, got {type(details['details']).__name__}. Skipping... {performer.id} {performer.name}") + return + if 'custom' not in details: + details['custom'] = fields_to_dict(fields) + needs_update = True + log.LogTrace(f"Added missing custom dict") + elif type(details['custom']) is not dict: + log.LogWarning(f"Malformed custom key value. Expected dict, got {type(details['custom']).__name__}. Skipping... {performer.id} {performer.name}") + return + else: + log.LogTrace(f"Has custom dict") + for field in fields: + if field not in details['custom']: + details['custom'][field] = None + needs_update = True + log.LogTrace(f"Added missing {field} field to custom dict") + else: + log.LogWarning(f"JSON detected but expected dict, got {type(docs).__name__}... {performer.id} {performer.name}") + details['details'] = performer.details + needs_update = True + except: + log.LogTrace(f"Invalid JSON") + details['details'] = performer.details + needs_update = True + + if needs_update: + log.LogTrace(f"Updating performer details... {performer.id} {performer.name}") + encoded_details = json.dumps(details, ensure_ascii=False) + db.performers.update_details_by_id(performer.id, encoded_details, commit) + else: + log.LogTrace(f"No update needed... {performer.id} {performer.name}") + +def init_performer_details_by_id(db: StashDatabase, fields: list[str], performer_id, commit=True): + performer = db.performers.selectone_id(performer_id) + if performer: + init_performer_details(db, fields, performer, commit) + +def init_all_performer_details(db: StashDatabase, fields: list[str]): + performers = [PerformersRow().from_sqliterow(row) for row in db.performers.select()] + log.LogInfo(f"Migrating {len(performers)} performer details...") + for performer in performers: + init_performer_details(db, fields, performer, commit=False) + db.commit() \ No newline at end of file diff --git a/plugins/stashPerformerCustomFields/requirements.txt b/plugins/stashPerformerCustomFields/requirements.txt new file mode 100644 index 0000000..e9c9420 --- /dev/null +++ b/plugins/stashPerformerCustomFields/requirements.txt @@ -0,0 +1 @@ +pystashlib==0.4.2 diff --git a/plugins/stashPerformerCustomFields/stashPerformerCustomFields.js b/plugins/stashPerformerCustomFields/stashPerformerCustomFields.js new file mode 100644 index 0000000..c1fcef5 --- /dev/null +++ b/plugins/stashPerformerCustomFields/stashPerformerCustomFields.js @@ -0,0 +1,289 @@ +(function () { + 'use strict'; + + const { + stash, + Stash, + waitForElementId, + waitForElementClass, + waitForElementByXpath, + getElementByXpath, + insertAfter, + createElementFromHTML, + } = window.stash7dJx1qP; + + document.body.appendChild(document.createElement('style')).textContent = ` + .detail-header.collapsed .detail-item.custom { display: none; } + `; + + function openExplorerTask(path) { + stash.runPluginTask("stashPerformerCustomFields", "Open in File Explorer", {"key":"path", "value":{"str": path}}); + } + + async function getPerformer() { + const performerId = window.location.pathname.split('/').find((o, i, arr) => i > 1 && arr[i - 1] == 'performers'); + const reqData = { + "operationName": "FindPerformer", + "variables": { + "id": performerId + }, + "query": `query FindPerformer($id: ID!) { + findPerformer(id: $id) { + id + details + } + }` + }; + const result = await stash.callGQL(reqData); + return result?.data?.findPerformer; + } + + async function updatePerformerDetails(details) { + const performerId = window.location.pathname.split('/').find((o, i, arr) => i > 1 && arr[i - 1] == 'performers'); + const reqData = { + "operationName": "PerformerUpdate", + "variables": { + "input": { + "details": details, + "id": performerId + } + }, + "query": `mutation PerformerUpdate($input: PerformerUpdateInput!) { + performerUpdate(input: $input) { + id + details + } + }` + }; + const result = await stash.callGQL(reqData); + return result?.data?.findPerformer; + } + + function toUrl(string) { + let url; + + try { + url = new URL(string); + } catch (_) { + return null; + } + + if (url.protocol === "http:" || url.protocol === "https:") return url; + return null; + } + + function createDeleteButtonHTML(itemType, itemIndex) { + return ``; + } + + async function deleteDetailItem(itemType, itemIndex) { + const performer = await getPerformer(); + const docs = JSON.parse(performer.details); + const doc = docs.custom; + doc[itemType].splice(itemIndex, 1); + if (!doc[itemType].length) doc[itemType] = null; + const details = JSON.stringify(docs); + await updatePerformerDetails(details); + window.location.reload(); + } + + function createDetailsItem(field, doc) { + const detailsItem = createElementFromHTML(`
+ ${field.replaceAll('_', ' ')}: + +
`); + const detailsValue = detailsItem.querySelector('.detail-item-value'); + if (doc[field]) { + if (field === 'urls') { + doc.urls = doc.urls.map((url, i) => `
  • ${createDeleteButtonHTML('urls', i)}${url}
  • `); + } + else if (field === 'paths') { + doc.paths = doc.paths.map((path, i) => `
  • ${createDeleteButtonHTML('paths', i)}${path}
  • `); + } + else { + doc[field] = doc[field].map((value, i) => `
  • ${createDeleteButtonHTML(field, i)}${value}
  • `); + } + detailsValue.appendChild(createElementFromHTML(``)); + for (const a of detailsValue.querySelectorAll('a.filepath')) { + a.style.cursor = 'pointer'; + a.addEventListener('click', function () { + openExplorerTask(a.innerText); + }); + } + for (const btn of detailsValue.querySelectorAll('.btn-details-item-delete')) { + btn.style.display = document.getElementById('toggle-details-edit').checked ? 'block' : 'none'; + btn.addEventListener('click', async () => deleteDetailItem(btn.dataset.itemType, btn.dataset.itemIndex)); + } + } + else { + detailsValue.appendChild(createElementFromHTML(``)); + } + + const fieldInput = createElementFromHTML(``); + fieldInput.addEventListener('change', async () => { + const value = field === 'urls' ? toUrl(fieldInput.value) : fieldInput.value.trim(); + if (value) { + const performer = await getPerformer(); + const docs = JSON.parse(performer.details); + const doc = docs.custom; + if (!doc?.[field]) { + doc[field] = []; + } + let updated = false; + if (field === 'urls') { + if (doc.urls.indexOf(value.href) === -1) { + doc.urls.push(value.href); + updated = true; + } + } + else if (doc[field].indexOf(value) === -1) { + doc[field].push(value); + updated = true; + } + if (updated) { + const details = JSON.stringify(docs); + await updatePerformerDetails(details); + window.location.reload(); + } + } + fieldInput.value = ''; + }); + detailsValue.appendChild(fieldInput); + + return detailsItem; + } + + function createDetailsItems(detailsEl, details) { + const docs = JSON.parse(details); + const doc = docs.custom; + for (const field in doc) { + let detailsItem = document.getElementById(`custom-details-${field}`); + if (!detailsItem) { + detailsItem = createDetailsItem(field, doc); + } + insertAfter(detailsItem, detailsEl.parentElement); + } + return docs; + } + + function updateDetailElementVisibility(visible) { + if (document.getElementById('detail-field-inputs')) document.getElementById('detail-field-inputs').style.display = visible ? 'block' : 'none'; + if (document.getElementById('details-string')) document.getElementById('details-string').style.display = visible ? 'none' : 'block'; + for (const btn of document.querySelectorAll('.btn-details-item-delete')) { + btn.style.display = visible ? 'inline-block' : 'none'; + } + for (const el of document.querySelectorAll('.add-custom-details')) { + el.style.display = visible ? 'inline-block' : 'none'; + } + } + + function performerPageHandler() { + waitForElementClass('detail-container', async function (className, [detailContainerEl]) { + const performer = await getPerformer(); + try { + const docs = JSON.parse(performer.details); + if (!Object.hasOwn(docs, 'custom')) { + return; + } + } + catch (e) { + return; + } + + const settings = (await stash.getPluginConfig('stashPerformerCustomFields')) || {}; + const fields = (settings.fields || 'notes,paths,urls').split(',').map(field => field.trim().toLowerCase()); + if (!settings.fields) { + settings.fields = 'notes,paths,urls'; + await stash.updatePluginConfig('stashPerformerCustomFields', settings); + } + + const detailItemValueEl = detailContainerEl.querySelector(".detail-item.details > span.detail-item-value.details"); + if (detailItemValueEl) { + detailItemValueEl.parentElement.style.display = 'none'; + + let detailsStringItem = document.getElementById('custom-details'); + let cbToggleEdit = document.getElementById('toggle-details-edit'); + let detailsStringEl = document.getElementById('details-string'); + let detailsInput = document.getElementById('add-performer-details'); + if (!detailsStringItem) { + detailsStringItem = createElementFromHTML(`
    + Details: + +
    + +
    +
    + +
    +
    + +
    `); + insertAfter(detailsStringItem, detailItemValueEl.parentElement); + + cbToggleEdit = document.getElementById('toggle-details-edit'); + detailsStringEl = document.getElementById('details-string'); + detailsInput = document.getElementById('add-performer-details'); + + cbToggleEdit.addEventListener('change', async evt => { + updateDetailElementVisibility(cbToggleEdit.checked); + settings.showEdit = cbToggleEdit.checked; + const detailHeaderEl = document.querySelector('.detail-header'); + if (cbToggleEdit.checked) { + // never compact in edit mode + if (!detailHeaderEl.classList.contains('full-width') && !detailHeaderEl.classList.contains('collapsed')) { // required otherwise userscript library mutation observer will fire infinitely + detailHeaderEl.classList.add('full-width'); + } + } + else { + // respect compact setting out of edit mode + const uiConfig = await stash.getUIConfig(); + if (uiConfig?.data.configuration.ui.compactExpandedDetails && detailHeaderEl.classList.contains('full-width')) { // required otherwise userscript library mutation observer will fire infinitely + detailHeaderEl.classList.remove('full-width'); + } + } + await stash.updatePluginConfig('stashPerformerCustomFields', settings); + }); + + detailsInput.addEventListener('change', async () => { + const performer = await getPerformer(); + const docs = JSON.parse(performer.details); + const detailsValue = detailsInput.value.trim(); + if (detailsValue) { + docs.details = detailsValue; + } + else { + delete docs.details; + } + const details = JSON.stringify(docs); + await updatePerformerDetails(details); + window.location.reload(); + }); + } + else { + detailsStringItem.style.display = null; + } + insertAfter(detailsStringItem, detailItemValueEl.parentElement); + + const docs = createDetailsItems(detailItemValueEl, performer.details); + + if (docs.details) { + detailsStringEl.innerHTML = docs.details; + detailsInput.value = docs.details; + } + + cbToggleEdit.checked = settings?.showEdit; + cbToggleEdit.dispatchEvent(new Event('change')); + } + else { + const uiConfig = await stash.getUIConfig(); + const detailsStringItem = document.getElementById('custom-details'); + if (detailsStringItem) { + detailsStringItem.style.display = uiConfig?.data.configuration.ui.compactExpandedDetails ? null : 'none'; + } + updateDetailElementVisibility(false); // never edit mode when collapsed + } + }); + } + stash.addEventListener('page:performer:any', performerPageHandler); + stash.addEventListener('page:performer:details', performerPageHandler); +})(); \ No newline at end of file diff --git a/plugins/stashPerformerCustomFields/stashPerformerCustomFields.py b/plugins/stashPerformerCustomFields/stashPerformerCustomFields.py new file mode 100644 index 0000000..162c99c --- /dev/null +++ b/plugins/stashPerformerCustomFields/stashPerformerCustomFields.py @@ -0,0 +1,48 @@ +import json +import subprocess +import sys +from performer_details import init_all_performer_details, init_performer_details_by_id +try: + from stashlib.logger import logger as log + from stashlib.stash_database import StashDatabase + from stashlib.stash_interface import StashInterface +except ModuleNotFoundError: + print("If you have pip (normally installed with python), run this command in a terminal (cmd): pip install pystashlib)", file=sys.stderr) + sys.exit() + +json_input = json.loads(sys.stdin.read()) + +name = json_input.get('args', {}).get('name') +hook_type = json_input.get("args", {}).get("hookContext", {}).get("type") +performer_id = json_input.get("args", {}).get("hookContext", {}).get("id") + +client = StashInterface(json_input["server_connection"]) +result = client.callGraphQL("""query Configuration { configuration { general { databasePath } } }""") +database_path = result["configuration"]["general"]["databasePath"] +log.LogDebug(f"databasePath: {database_path}") + +def get_fields(): + settings = client.callGraphQL("""query Configuration { configuration { plugins } }""")['configuration']['plugins'] + if settings and 'stashPerformerCustomFields' in settings and 'fields' in settings['stashPerformerCustomFields']: + fields = settings['stashPerformerCustomFields']['fields'] + fields = (fields or 'notes,paths,urls').split(',') + log.LogDebug(f'fields: {json.dumps(fields)}') + return fields + +try: + with StashDatabase(database_path, None, None) as db: + if name == 'explorer': + path = json_input['args']['path'] + log.LogDebug(f"{name}: {path}") + subprocess.Popen(f'explorer "{path}"') + elif name == 'init_all_performer_details': + fields = get_fields() + client.callGraphQL("""mutation BackupDatabase($input: BackupDatabaseInput!) { backupDatabase(input: $input) }""", { 'input': {} }) + init_all_performer_details(db, fields) + log.LogInfo("Performer details migration done.") + elif hook_type == 'Performer.Create.Post': + fields = get_fields() + init_performer_details_by_id(db, fields, performer_id) +except Exception as e: + log.LogError(str(e)) + sys.exit(0) \ No newline at end of file diff --git a/plugins/stashPerformerCustomFields/stashPerformerCustomFields.yml b/plugins/stashPerformerCustomFields/stashPerformerCustomFields.yml new file mode 100644 index 0000000..a4b4590 --- /dev/null +++ b/plugins/stashPerformerCustomFields/stashPerformerCustomFields.yml @@ -0,0 +1,35 @@ +name: Stash Performer Custom Fields +# requires: stashUserscriptLibrary +description: Adds custom fields to performers that are stored in performer details as JSON. +version: 0.2.0 +ui: + requires: + - stashUserscriptLibrary7dJx1qP + javascript: + - stashPerformerCustomFields.js +exec: + - python + - "{pluginDir}/stashPerformerCustomFields.py" +interface: raw +tasks: + - name: Open in File Explorer + description: Open file explorer + defaultArgs: + name: explorer + path: null + - name: Migrate Performer Details + description: Runs backup task then converts all performer details data to JSON. This task modifies your performer details data. + defaultArgs: + name: init_all_performer_details +hooks: + - name: Inititialize Performer On Create + description: Add custom fields to performer details data on create + triggeredBy: + - Performer.Create.Post +settings: + fields: + displayName: Fields + type: STRING + showEdit: + displayName: Show Edit + type: BOOLEAN \ No newline at end of file