|
// ==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 |
|
|
|
})(); |
gist has some unfortunate features for
updateURL
anddownloadURL
.The
acaa4c8b7548ede02262bcd50af0212c26d1ef01
is the revision hash of this particular version. So you really want the URLs to be onlyhttps://gist.github.com/boecko/e2d0effe7c61976c22e6bc0a8ee645c7/raw/photoprismbulkeditor.user.js
The other issue is github does not send no-cache header with the raw URL. Github cache, your browser, http proxy, etc will cache it. This will prevent quick updates. For something like this unlikely to change very often, it's probably not a huge deal, but something to keep in mind when working with it.
To manually work around that (in your browser, curl, etc, not in the updateURL's) you can use the above URL and add a
?someRandomString
eghttps://gist.github.com/boecko/e2d0effe7c61976c22e6bc0a8ee645c7/raw/photoprismbulkeditor.user.js?afdshakhfdka
to the end which will force a fresh GET of the URL.