diff --git a/userscript/README.md b/userscript/README.md
new file mode 100644
index 00000000..f1c35f4e
--- /dev/null
+++ b/userscript/README.md
@@ -0,0 +1,5 @@
+# User Scripts
+
+The scripts may be installed in one's browser (using Greasemonkey or Tampermonkey) to provide additional features using OBS via the web.
+
+- [Staging Move Drag-n-Drop](https://github.com/openSUSE/openSUSE-release-tools/raw/master/userscript/staging-move-drag-n-drop.user.js)
diff --git a/userscript/staging-move-drag-n-drop.user.js b/userscript/staging-move-drag-n-drop.user.js
new file mode 100644
index 00000000..87295e9d
--- /dev/null
+++ b/userscript/staging-move-drag-n-drop.user.js
@@ -0,0 +1,620 @@
+// ==UserScript==
+// @name OSRT Staging Move Drag-n-Drop
+// @namespace openSUSE/openSUSE-release-tools
+// @version 0.1.0
+// @description Provide staging request moving interface on staging dashboard.
+// @author Jimmy Berry
+// @match */project/staging_projects/*
+// @require https://code.jquery.com/jquery-3.3.1.min.js
+// @require https://raw.githubusercontent.com/p34eu/selectables/master/selectables.js
+// @grant none
+// ==/UserScript==
+
+// Uses a combination of two sources:
+// - https://www.sitepoint.com/accessible-drag-drop/ (modified slightly)
+// - https://github.com/p34eu/selectables (used directly with abuse of modifier key option)
+
+(function()
+{
+ // Add explanation of trigger shortcut to legend box.
+ var explanation = document.createElement('div');
+ explanation.id = 'osrt-explanation';
+ explanation.innerText = 'press ctrl+m to move requests';
+ document.querySelector('#legends').appendChild(explanation);
+
+ window.onkeyup = function(e) {
+ if (e.keyCode == 77 && e.ctrlKey) {
+ initMoveInterface();
+ }
+ }
+
+ // Include CSS immediately for explanation.
+ $('head').append(``);
+
+})();
+
+var initMoveInterface = function(){
+ //exclude older browsers by the features we need them to support
+ //and legacy opera explicitly so we don't waste time on a dead browser
+ if
+ (
+ !document.querySelectorAll
+ ||
+ !('draggable' in document.createElement('span'))
+ ||
+ window.opera
+ )
+ { return; }
+
+ // Update explanation text and add new legend entries.
+ function addLegend(type)
+ {
+ var listItem = document.createElement('li');
+ var span = document.createElement('span');
+ span.classList.add(type.toLowerCase());
+ listItem.appendChild(span);
+ listItem.appendChild(document.createTextNode(type));
+ document.querySelector('ul.color-legend').appendChild(listItem);
+ }
+
+ addLegend('Moved');
+ addLegend('Selected');
+
+ document.querySelector('#osrt-explanation').innerText = 'move mode activated: drag box around requests or ctrl/shift+click requests to select and drag a request to another staging.';
+
+ // @resource will not work since served without proper MIME type.
+ $.get('https://raw.githubusercontent.com/p34eu/selectables/master/selectables.css', function(data, status) {
+ $('head').append('');
+ });
+
+ // Mark the drag targets and draggable items.
+ // Preferable to use the tr element as target, but mouse events not handled properly.
+ // Avoid making expand/collapse links selectable and avoid forcing them to be expanded.
+ // The pointer-events changes only seem to work properly from script.
+ $('table.staging-dashboard td').attr('data-draggable', 'target');
+ $('table.staging-dashboard ul.packages-list li.request:not(:has(a.staging_expand, a.staging_collapse))').attr('data-draggable', 'item').css('pointer-events', 'all');
+
+ // Disable mouse events on the request links as that makes it nearly impossible to drag them.
+ $('table.staging-dashboard ul.packages-list li.request:not(:has(a.staging_expand, a.staging_collapse)) a').css('pointer-events', 'none');
+
+ // Configure selectables to play nice with drag-n-drop code.
+ new Selectables({
+ elements: 'ul.packages-list li[data-draggable="item"]',
+ zone: 'body',
+ start: function (e) {
+ e.osrtContinue = (e.target.getAttribute('data-draggable') != 'item' &&
+ e.target.tagName != 'A' &&
+ e.target.tagName != 'LABEL' &&
+ e.target.id != 'osrt-summary');
+ },
+ // Abuse key option by setting the value in start callback whic is run
+ // first and the value determines if drag selection is started.
+ key: 'osrtContinue',
+ onSelect: function (e) {
+ addSelection(e);
+ },
+ onDeselect: function (e) {
+ removeSelection(e);
+ }
+ });
+
+ function getStaging(item)
+ {
+ var parent;
+ if (item.tagName == 'TD') {
+ parent = item.parentElement;
+ } else {
+ parent = item.parentElement.parentElement.parentElement;
+ }
+ if (item.parentElement.classList.contains('staging_collapsible')) {
+ // An additional layer since in hidden container.
+ parent = parent.parentElement;
+ }
+ return parent.querySelector('div.letter a').innerText;
+ }
+
+ function updateSummary()
+ {
+ var summaryElement = document.querySelector('div#osrt-summary');
+ if (!summaryElement) {
+ summaryElement = document.createElement('div');
+ summaryElement.id = 'osrt-summary';
+ document.body.appendChild(summaryElement);
+ }
+
+ var elements = document.querySelectorAll('.osrt-moved');
+ var summary = {};
+ var staging;
+ for (var i = 0; i < elements.length; i++) {
+ staging = getStaging(elements[i]);
+ if (!(staging in summary)) {
+ summary[staging] = [];
+ }
+ summary[staging].push(elements[i].children[0].innerText.trim());
+ }
+
+ var summaryText = '';
+ var pathParts = window.location.pathname.split('/');
+ var project = pathParts[pathParts.length - 1];
+ for (var key in summary) {
+ staging = key;
+ if (!isNaN(key)) {
+ staging = 'adi:' + key;
+ }
+ summaryText += 'osc staging -p ' + project + ' select --move ' + staging + ' ' + summary[key].join(' ') + "\n";
+ }
+
+ summaryElement.innerText = summaryText;
+ }
+
+ //get the collection of draggable targets and add their draggable attribute
+ for(var
+ targets = document.querySelectorAll('[data-draggable="target"]'),
+ len = targets.length,
+ i = 0; i < len; i ++)
+ {
+ targets[i].setAttribute('aria-dropeffect', 'none');
+ }
+
+ //get the collection of draggable items and add their draggable attributes
+ for(var
+ items = document.querySelectorAll('[data-draggable="item"]'),
+ len = items.length,
+ i = 0; i < len; i ++)
+ {
+ items[i].setAttribute('draggable', 'true');
+ items[i].setAttribute('aria-grabbed', 'false');
+ items[i].setAttribute('tabindex', '0');
+
+ // OSRT modification: keep track of original staging.
+ items[i].setAttribute('data-staging-origin', getStaging(items[i]));
+ }
+
+ //dictionary for storing the selections data
+ //comprising an array of the currently selected items
+ //a reference to the selected items' owning container
+ //and a refernce to the current drop target container
+ var selections =
+ {
+ items : [],
+ owner : null,
+ droptarget : null
+ };
+
+ //function for selecting an item
+ function addSelection(item)
+ {
+ //if the owner reference is still null, set it to this item's parent
+ //so that further selection is only allowed within the same container
+ if(!selections.owner)
+ {
+ selections.owner = item.parentNode;
+ }
+
+ //or if that's already happened then compare it with this item's parent
+ //and if they're not the same container, return to prevent selection
+ else if(selections.owner != item.parentNode)
+ {
+ return;
+ }
+
+ //set this item's grabbed state
+ item.setAttribute('aria-grabbed', 'true');
+
+ //add it to the items array
+ selections.items.push(item);
+ }
+
+ //function for unselecting an item
+ function removeSelection(item)
+ {
+ //reset this item's grabbed state
+ item.setAttribute('aria-grabbed', 'false');
+
+ //then find and remove this item from the existing items array
+ for(var len = selections.items.length, i = 0; i < len; i ++)
+ {
+ if(selections.items[i] == item)
+ {
+ selections.items.splice(i, 1);
+ break;
+ }
+ }
+ }
+
+ //function for resetting all selections
+ function clearSelections()
+ {
+ //if we have any selected items
+ if(selections.items.length)
+ {
+ //reset the owner reference
+ selections.owner = null;
+
+ //reset the grabbed state on every selected item
+ for(var len = selections.items.length, i = 0; i < len; i ++)
+ {
+ selections.items[i].setAttribute('aria-grabbed', 'false');
+ }
+
+ //then reset the items array
+ selections.items = [];
+ }
+ }
+
+ //shorctut function for testing whether a selection modifier is pressed
+ function hasModifier(e)
+ {
+ return (e.ctrlKey || e.metaKey || e.shiftKey);
+ }
+
+ //function for applying dropeffect to the target containers
+ function addDropeffects()
+ {
+ //apply aria-dropeffect and tabindex to all targets apart from the owner
+ for(var len = targets.length, i = 0; i < len; i ++)
+ {
+ if
+ (
+ targets[i] != selections.owner
+ &&
+ targets[i].getAttribute('aria-dropeffect') == 'none'
+ )
+ {
+ targets[i].setAttribute('aria-dropeffect', 'move');
+ targets[i].setAttribute('tabindex', '0');
+ }
+ }
+
+ //remove aria-grabbed and tabindex from all items inside those containers
+ for(var len = items.length, i = 0; i < len; i ++)
+ {
+ if
+ (
+ items[i].parentNode != selections.owner
+ &&
+ items[i].getAttribute('aria-grabbed')
+ )
+ {
+ items[i].removeAttribute('aria-grabbed');
+ items[i].removeAttribute('tabindex');
+ }
+ }
+ }
+
+ //function for removing dropeffect from the target containers
+ function clearDropeffects()
+ {
+ //if we have any selected items
+ if(selections.items.length)
+ {
+ //reset aria-dropeffect and remove tabindex from all targets
+ for(var len = targets.length, i = 0; i < len; i ++)
+ {
+ if(targets[i].getAttribute('aria-dropeffect') != 'none')
+ {
+ targets[i].setAttribute('aria-dropeffect', 'none');
+ targets[i].removeAttribute('tabindex');
+ }
+ }
+
+ //restore aria-grabbed and tabindex to all selectable items
+ //without changing the grabbed value of any existing selected items
+ for(var len = items.length, i = 0; i < len; i ++)
+ {
+ if(!items[i].getAttribute('aria-grabbed'))
+ {
+ items[i].setAttribute('aria-grabbed', 'false');
+ items[i].setAttribute('tabindex', '0');
+ }
+ else if(items[i].getAttribute('aria-grabbed') == 'true')
+ {
+ items[i].setAttribute('tabindex', '0');
+ }
+ }
+ }
+ }
+
+ //shortcut function for identifying an event element's target container
+ function getContainer(element)
+ {
+ do
+ {
+ if(element.nodeType == 1 && element.getAttribute('aria-dropeffect'))
+ {
+ return element;
+ }
+ }
+ while(element = element.parentNode);
+
+ return null;
+ }
+
+ //mousedown event to implement single selection
+ document.addEventListener('mousedown', function(e)
+ {
+ //if the element is a draggable item
+ if(e.target.getAttribute('draggable'))
+ {
+ //clear dropeffect from the target containers
+ clearDropeffects();
+
+ //if the multiple selection modifier is not pressed
+ //and the item's grabbed state is currently false
+ if
+ (
+ !hasModifier(e)
+ &&
+ e.target.getAttribute('aria-grabbed') == 'false'
+ )
+ {
+ //clear all existing selections
+ clearSelections();
+
+ //then add this new selection
+ addSelection(e.target);
+ }
+ }
+
+ //else [if the element is anything else]
+ //and the selection modifier is not pressed
+ else if(!hasModifier(e))
+ {
+ //clear dropeffect from the target containers
+ clearDropeffects();
+
+ //clear all existing selections
+ clearSelections();
+ }
+
+ //else [if the element is anything else and the modifier is pressed]
+ else
+ {
+ //clear dropeffect from the target containers
+ clearDropeffects();
+ }
+
+ }, false);
+
+ //mouseup event to implement multiple selection
+ document.addEventListener('mouseup', function(e)
+ {
+ //if the element is a draggable item
+ //and the multipler selection modifier is pressed
+ if(e.target.getAttribute('draggable') && hasModifier(e))
+ {
+ //if the item's grabbed state is currently true
+ if(e.target.getAttribute('aria-grabbed') == 'true')
+ {
+ //unselect this item
+ removeSelection(e.target);
+
+ //if that was the only selected item
+ //then reset the owner container reference
+ if(!selections.items.length)
+ {
+ selections.owner = null;
+ }
+ }
+
+ //else [if the item's grabbed state is false]
+ else
+ {
+ //add this additional selection
+ addSelection(e.target);
+ }
+ }
+
+ }, false);
+
+ //dragstart event to initiate mouse dragging
+ document.addEventListener('dragstart', function(e)
+ {
+ //if the element's parent is not the owner, then block this event
+ if(selections.owner != e.target.parentNode)
+ {
+ e.preventDefault();
+ return;
+ }
+
+ //[else] if the multiple selection modifier is pressed
+ //and the item's grabbed state is currently false
+ if
+ (
+ hasModifier(e)
+ &&
+ e.target.getAttribute('aria-grabbed') == 'false'
+ )
+ {
+ //add this additional selection
+ addSelection(e.target);
+ }
+
+ //we don't need the transfer data, but we have to define something
+ //otherwise the drop action won't work at all in firefox
+ //most browsers support the proper mime-type syntax, eg. "text/plain"
+ //but we have to use this incorrect syntax for the benefit of IE10+
+ e.dataTransfer.setData('text', '');
+
+ //apply dropeffect to the target containers
+ addDropeffects();
+
+ }, false);
+
+ //related variable is needed to maintain a reference to the
+ //dragleave's relatedTarget, since it doesn't have e.relatedTarget
+ var related = null;
+
+ //dragenter event to set that variable
+ document.addEventListener('dragenter', function(e)
+ {
+ related = e.target;
+
+ }, false);
+
+ //dragleave event to maintain target highlighting using that variable
+ document.addEventListener('dragleave', function(e)
+ {
+ //get a drop target reference from the relatedTarget
+ var droptarget = getContainer(related);
+
+ //if the target is the owner then it's not a valid drop target
+ if(droptarget == selections.owner)
+ {
+ droptarget = null;
+ }
+
+ //if the drop target is different from the last stored reference
+ //(or we have one of those references but not the other one)
+ if(droptarget != selections.droptarget)
+ {
+ //if we have a saved reference, clear its existing dragover class
+ if(selections.droptarget)
+ {
+ selections.droptarget.className =
+ selections.droptarget.className.replace(/ dragover/g, '');
+ }
+
+ //apply the dragover class to the new drop target reference
+ if(droptarget)
+ {
+ droptarget.className += ' dragover';
+ }
+
+ //then save that reference for next time
+ selections.droptarget = droptarget;
+ }
+
+ }, false);
+
+ //dragover event to allow the drag by preventing its default
+ document.addEventListener('dragover', function(e)
+ {
+ //if we have any selected items, allow them to be dragged
+ if(selections.items.length)
+ {
+ e.preventDefault();
+ }
+
+ }, false);
+
+ //dragend event to implement items being validly dropped into targets,
+ //or invalidly dropped elsewhere, and to clean-up the interface either way
+ document.addEventListener('dragend', function(e)
+ {
+ //if we have a valid drop target reference
+ //(which implies that we have some selected items)
+ if(selections.droptarget)
+ {
+ // OSRT modification: only move if location is changing.
+ if (getStaging(selections.droptarget) == getStaging(selections.items[0])) {
+ e.preventDefault();
+ return;
+ }
+
+ // OSRT modification: place requests back in package list.
+ var target = selections.droptarget.parentElement.querySelector('ul.packages-list');
+
+ //append the selected items to the end of the target container
+ for(var len = selections.items.length, i = 0; i < len; i ++)
+ {
+ // OSRT modification: place in package list and determine if moved from origin.
+ // selections.droptarget.appendChild(selections.items[i]);
+ target.appendChild(selections.items[i]);
+ if (getStaging(selections.items[i]) != selections.items[i].getAttribute('data-staging-origin'))
+ {
+ selections.items[i].classList.add('osrt-moved');
+ }
+ else {
+ selections.items[i].classList.remove('osrt-moved');
+ }
+ }
+
+ // OSRT modification: after drag update overall summary of moves.
+ updateSummary();
+
+ //prevent default to allow the action
+ e.preventDefault();
+ }
+
+ //if we have any selected items
+ if(selections.items.length)
+ {
+ //clear dropeffect from the target containers
+ clearDropeffects();
+
+ //if we have a valid drop target reference
+ if(selections.droptarget)
+ {
+ //reset the selections array
+ clearSelections();
+
+ //reset the target's dragover class
+ selections.droptarget.className =
+ selections.droptarget.className.replace(/ dragover/g, '');
+
+ //reset the target reference
+ selections.droptarget = null;
+ }
+ }
+
+ }, false);
+};