|
// ==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 |
|
|
|
})(); |
@Moosbueffel It's just for gps coordinates at the moment, as noted in the first line of the readme.
You should have checkboxes on "Höhe", "Breitengrad" and "Längengrad". Which version of photoprismn do you run ?