|
// ==UserScript== |
|
// @name photoprism bulkeditor |
|
// @version 0.1 |
|
// @description bulkeditor |
|
// @author [email protected] |
|
// @match https://*/library/browse* |
|
// @match https://*/library/all* |
|
// @match https://*/library/albums/*/view* |
|
// @match https://*/library/favorites* |
|
// @namespace https://boeckler.org/ |
|
// @updateURL https://gist.github.com/boecko/e2d0effe7c61976c22e6bc0a8ee645c7/raw/photoprismbulkeditor.user.js |
|
// @downloadURL https://gist.github.com/boecko/e2d0effe7c61976c22e6bc0a8ee645c7/raw/photoprismbulkeditor.user.js |
|
// @grant none |
|
// ==/UserScript== |
|
|
|
(function() { |
|
'use strict'; |
|
// from https://gist.github.com/stephenchew/b73ecc75b77a84a92fa350048d5ca84f |
|
//------- START |
|
const isDefined = (value) => typeof value !== 'undefined' && value !== null; |
|
|
|
/** |
|
* |
|
* @returns `true` if the field is set, `false` otherwise |
|
*/ |
|
const updateField = (data, field) => { |
|
const type = data[field].type; |
|
const value = data[field].content; |
|
|
|
if (!value) { |
|
return false; |
|
} |
|
|
|
const element = document.forms[0].__vue__._data.inputs.find((element) => |
|
element.$el.className.includes(`input-${field}`) |
|
); |
|
|
|
switch (type.toLowerCase()) { |
|
case 'prepend': |
|
element.internalValue = value + ' ' + element.internalValue; |
|
break; |
|
case 'append': |
|
element.internalValue += ' ' + value; |
|
break; |
|
case 'replace': |
|
element.internalValue = value; |
|
break; |
|
default: |
|
console.error(`'${type}' is not a valid way of updating a field.`); |
|
return false; |
|
} |
|
|
|
return true; |
|
}; |
|
|
|
const runBulk = async (data) => { |
|
const validation = validateData(data); |
|
|
|
if (validation) { |
|
console.error('There is an error in the data:\n\n' + validation); |
|
return; |
|
} |
|
|
|
console.time('bulk-edit'); |
|
|
|
try { |
|
const pause = async (seconds) => new Promise((r) => setTimeout(r, seconds * 500)); |
|
|
|
let count = 0; |
|
|
|
do { |
|
let dirty = false; |
|
|
|
if (window.interrupt) { |
|
alert('Execution interrupted by user.'); |
|
delete window.interrupt; |
|
return; |
|
} |
|
|
|
for (let field of Object.keys(data)) { |
|
dirty |= updateField(data, field); |
|
} |
|
|
|
if (!dirty) { |
|
console.warn('No field was set. Nothing has changed.'); |
|
return; |
|
} |
|
|
|
const applyButton = document.querySelector('button.action-apply'); |
|
applyButton.click(); |
|
count++; |
|
|
|
const rightButton = document.querySelector('.v-toolbar__items .action-next'); |
|
if (rightButton.disabled) { |
|
break; |
|
} |
|
|
|
await pause(1); |
|
rightButton.click(); |
|
await pause(1); |
|
} while (true); |
|
|
|
const doneButton = document.querySelector('button.action-done'); |
|
doneButton.click(); |
|
|
|
console.info(`Bulk edited ${count} photos.`); |
|
} finally { |
|
console.timeEnd('bulk-edit'); |
|
} |
|
}; |
|
|
|
/** |
|
* Return LF delimited error message, or `null` if all is good. |
|
*/ |
|
const validateData = (data) => { |
|
if (!data) { |
|
return 'No data provided.'; |
|
} |
|
|
|
const error = []; |
|
|
|
if (isDefined(data.day?.content)) { |
|
const day = parseInt(data.day.content, 10); |
|
if (isNaN(day) || day < -1 || day > 31 || day === 0) { |
|
error.push('Day must be between 1 and 31. Set to -1 for "Unknown".'); |
|
} |
|
data.day.type = 'replace'; |
|
} |
|
|
|
if (isDefined(data.month?.content)) { |
|
const month = parseInt(data.month.content, 10); |
|
if (isNaN(month) || month < -1 || month > 12 || month === 0) { |
|
error.push('Month must be between 1 and 12. Set to -1 for "Unknown".'); |
|
} |
|
data.month.type = 'replace'; |
|
} |
|
|
|
if (isDefined(data.year?.content)) { |
|
const year = parseInt(data.year.content, 10); |
|
const currentYear = new Date().getFullYear(); |
|
if ((isNaN(year) || year < 1750 || year > currentYear) && year !== -1) { |
|
// 1750 is Photoprism defined year |
|
error.push('Year must be between 1750 and ' + currentYear + '. Set to -1 for "Unknown".'); |
|
} |
|
data.year.type = 'replace'; |
|
} |
|
|
|
return error.length > 0 ? error.join('\n') : null; |
|
}; |
|
//------- END |
|
|
|
const runGpsBulkUpdate = async (url) => { |
|
if(!url) return |
|
let m = url.match(/@(.*)z/) |
|
if( !m[1] ) { |
|
console.warn("URL ist falsch", url) |
|
return |
|
} |
|
let gpsCoords = m[1].split(',') |
|
let data = { |
|
latitude: { |
|
content: gpsCoords[0], |
|
type: 'replace' |
|
}, |
|
longitude: { |
|
content: gpsCoords[1], |
|
type: 'replace' |
|
}, |
|
altitude: { |
|
content: 0, |
|
type: 'replace' |
|
} |
|
} |
|
if(gpsCoords[2] && gpsCoords[2].match(/^\d+$/)) { |
|
data.altitude = { |
|
content: gpsCoords[2], |
|
type: 'replace' |
|
} |
|
} |
|
console.log('runBulk', data); |
|
return await runBulk(data); |
|
// return true; |
|
} |
|
|
|
const runKeywordBulkUpdate = async () => { |
|
let keywords = prompt("Keywords?") |
|
if(!keywords) return |
|
let data = { |
|
keywords: { |
|
content: keywords, |
|
type: 'append', |
|
} |
|
} |
|
return await runBulk(data); |
|
// return true; |
|
} |
|
|
|
//------- greasmonkey code |
|
const BTN_STYLE_1 = 'cursor: pointer; border: solid white' |
|
const BTN_STYLE_2 = 'cursor: pointer; border: solid white; opacity:0.5' |
|
const checkBoxes = {} |
|
const inputs = {} |
|
let submitNode = null |
|
let bulkRunning = false |
|
async function submitHandler(e) { |
|
e.preventDefault() |
|
let bulkData = {} |
|
for(let name in checkBoxes) { |
|
if(!checkBoxes[name].checked) continue |
|
bulkData[name] = { |
|
content: inputs[name].value, |
|
type: 'replace' |
|
} |
|
} |
|
if(Object.keys(bulkData).length == 0) return; |
|
submitNode.disabled = true |
|
submitNode.setAttribute('style', BTN_STYLE_2); |
|
bulkRunning = true |
|
await runBulk(bulkData); |
|
bulkRunning = false |
|
} |
|
|
|
function addSubmitIfMissing() { |
|
const selector = '.input-title input[type=text]' |
|
let inputNode = document.querySelector(selector) |
|
if( inputNode==null || inputNode.offsetParent == null) return |
|
if(inputNode.nextSibling) return |
|
|
|
let newSubmit = document.createElement('input'); |
|
newSubmit.setAttribute('type', 'submit'); |
|
newSubmit.setAttribute('value', 'Bulkchange'); |
|
newSubmit.setAttribute('style', BTN_STYLE_1); |
|
inputNode.parentNode.append(newSubmit); |
|
newSubmit.onclick = submitHandler |
|
submitNode = newSubmit |
|
} |
|
function addCheckBoxIfMissing(name) { |
|
const selector = '.input-' + name + ' input[type=text]' |
|
let inputNode = document.querySelector(selector) |
|
// wenn da und nicht unsichtbar |
|
if( inputNode==null || inputNode.offsetParent == null) return |
|
if(inputNode.nextSibling) return |
|
|
|
let newCheckBox = document.createElement('input'); |
|
newCheckBox.setAttribute('type', 'checkbox'); |
|
inputNode.parentNode.append(newCheckBox); |
|
checkBoxes[name] = newCheckBox |
|
inputs[name] = inputNode |
|
} |
|
|
|
var checkExistTimer = setInterval(function () { |
|
if(bulkRunning) return |
|
addCheckBoxIfMissing('latitude') |
|
addCheckBoxIfMissing('longitude') |
|
addCheckBoxIfMissing('altitude') |
|
addSubmitIfMissing() |
|
},1000); |
|
|
|
window.runBulk = runBulk |
|
window.runGpsBulkUpdate = runGpsBulkUpdate |
|
window.runKeywordBulkUpdate = runKeywordBulkUpdate |
|
|
|
})(); |
I would not recommend using
?uf1Ahngo
at the end of theupdateURL
ordownloadURL
in the gist file. It is only for cache busting, as it must be changed each time to force a new GET. It should only be used on the command line to manually force a GET from the server.