diff --git a/GUIDE.md b/GUIDE.md index f1c846cc1..253969c4e 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -58,6 +58,10 @@ python3 server.py On the device these values are injected by the `mod-ui.service` in `pi-gen-pistomp` (see `debpkgs/mod-ui/debian/mod-ui.mod-ui.service`). +## Testing a branch on a pi-Stomp + +`./deploy.sh` copies this working tree over the packaged mod-ui on the device and restarts it; `./deploy.sh restore` puts the packaged version back. See [`docs/deploying-to-pistomp.md`](docs/deploying-to-pistomp.md) for the recipe and its limits. + ## Repo layout | Path | Purpose | diff --git a/deploy.sh b/deploy.sh index f77670356..ef011ba0c 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,42 +1,156 @@ #!/usr/bin/env bash -# Deploy mod-ui source to the running device via rsync and restart the service. +# Deploy this mod-ui working tree onto a pi-Stomp over the packaged install, and +# put the packaged version back when you are done. See docs/deploying-to-pistomp.md. # Override PISTOMP_HOST / PISTOMP_USER if your device differs from the default. # # Usage: -# ./deploy.sh # sync Python + HTML, restart service +# ./deploy.sh # sync Python + HTML, restart service, wait until it serves # ./deploy.sh --build-utils # also rsync utils/ source, build on device, install .so +# ./deploy.sh status # packaged version, what is deployed over it, service state +# ./deploy.sh logs [args] # follow the mod-ui journal (args go to journalctl) +# ./deploy.sh snapshot # save the packaged install (done automatically on first deploy) +# ./deploy.sh restore # put the snapshot back and restart set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" + HOST="${PISTOMP_HOST:-pistomp.local}" USER="${PISTOMP_USER:-pistomp}" TARGET="${USER}@${HOST}" VENV="/opt/pistomp/venvs/mod-ui" HTML_DIR="/opt/pistomp/mod-ui/html" +MARKER="/opt/pistomp/mod-ui/DEV-DEPLOY" +SNAPSHOT_DIR="/home/${USER}/mod-ui-snapshots" + +# One SSH connection for the whole run: one password prompt at most, and faster. +SSH_OPTS=(-o ControlMaster=auto -o "ControlPath=/tmp/mod-ui-deploy-%r@%h:%p" -o ControlPersist=60 -o ConnectTimeout=10) +export RSYNC_RSH="ssh ${SSH_OPTS[*]}" + +remote() { ssh "${SSH_OPTS[@]}" "${TARGET}" "$@"; } + +connect() { + if ! remote true; then + echo "error: cannot reach ${TARGET}. Is the pi-Stomp on the network? Set PISTOMP_HOST to its IP if mDNS fails." >&2 + exit 1 + fi + PKG_VERSION="$(remote "dpkg-query -W -f='\${Version}' mod-ui")" + PYTHON_VERSION="$(remote "ls ${VENV}/lib/" | grep -oE 'python[0-9]+\.[0-9]+' | head -1 | grep -oE '[0-9]+\.[0-9]+')" + SITE_PACKAGES="${VENV}/lib/python${PYTHON_VERSION}/site-packages" + SNAPSHOT="${SNAPSHOT_DIR}/mod-ui-${PKG_VERSION}.tar.gz" +} + +# Must only ever capture the packaged files: refuses while a dev deploy is in place. +snapshot() { + if remote "test -f ${SNAPSHOT}"; then + echo "==> Snapshot of packaged mod-ui ${PKG_VERSION} already at ${SNAPSHOT}" + return + fi + # A marker from an older package version means an upgrade already replaced the dev files. + if remote "grep -qx 'package=${PKG_VERSION}' ${MARKER} 2>/dev/null"; then + echo "error: a dev build is deployed and there is no snapshot of mod-ui ${PKG_VERSION}." >&2 + echo " Reinstall the package first (sudo apt install --reinstall mod-ui), then retry." >&2 + exit 1 + fi + echo "==> Saving packaged mod-ui ${PKG_VERSION} to ${SNAPSHOT}" + remote "mkdir -p ${SNAPSHOT_DIR} && tar -C / -czf ${SNAPSHOT}.tmp \ + ${SITE_PACKAGES#/}/mod ${SITE_PACKAGES#/}/modtools ${HTML_DIR#/} && mv ${SNAPSHOT}.tmp ${SNAPSHOT}" +} + +wait_until_serving() { + echo "==> Waiting for mod-ui to answer on port 80" + for _ in $(seq 1 45); do + if remote "${VENV}/bin/python -c \"import urllib.request; urllib.request.urlopen('http://localhost/', timeout=3)\"" 2>/dev/null; then + echo "==> mod-ui is up: http://${HOST}/" + return + fi + sleep 2 + done + echo "error: mod-ui did not come up within 90s. Last journal lines:" >&2 + remote "sudo journalctl -u mod-ui -n 40 --no-pager" >&2 || true + echo "Put the packaged version back with: ./deploy.sh restore" >&2 + exit 1 +} + +deploy() { + local build_utils="${1:-}" + + echo "==> Checking Python syntax locally" + python3 -W ignore -m py_compile mod/*.py modtools/*.py + + connect + remote "command -v rsync >/dev/null" || { echo "error: rsync is not installed on the device (sudo apt install rsync)" >&2; exit 1; } + snapshot + + local sha branch dirty="" + sha="$(git rev-parse --short HEAD)" + branch="$(git rev-parse --abbrev-ref HEAD)" + [[ -n "$(git status --porcelain)" ]] && dirty=" + uncommitted changes" + echo "==> Deploying ${branch} @ ${sha}${dirty} to ${TARGET} (Python ${PYTHON_VERSION})" + + rsync -az --delete --exclude='__pycache__' --exclude='*.pyc' \ + mod/ "${TARGET}:${SITE_PACKAGES}/mod/" -PYTHON_VERSION="$(ssh "${TARGET}" "ls ${VENV}/lib/" | grep -oE 'python[0-9]+\.[0-9]+' | head -1 | grep -oE '[0-9]+\.[0-9]+')" -SITE_PACKAGES="${VENV}/lib/python${PYTHON_VERSION}/site-packages" + # Exclude libmod_utils.so: it is ARM64 and must be built on the device (see --build-utils). + rsync -az --delete --exclude='__pycache__' --exclude='*.pyc' --exclude='libmod_utils.so' \ + modtools/ "${TARGET}:${SITE_PACKAGES}/modtools/" -echo "==> Deploying to ${TARGET} (Python ${PYTHON_VERSION})" + rsync -az --delete --exclude='node_modules' --exclude='.DS_Store' \ + html/ "${TARGET}:${HTML_DIR}/" -rsync -az --delete --exclude='__pycache__' --exclude='*.pyc' \ - mod/ "${TARGET}:${SITE_PACKAGES}/mod/" + if [[ "${build_utils}" == "--build-utils" ]]; then + echo "==> Building utils on device" + rsync -az --delete --exclude='*.o' --exclude='*.so' \ + utils/ "${TARGET}:/tmp/mod-ui-utils/" + remote "make -C /tmp/mod-ui-utils && cp /tmp/mod-ui-utils/libmod_utils.so ${SITE_PACKAGES}/modtools/" + fi -# Exclude libmod_utils.so: it is ARM64 and must be built on the device (see --build-utils). -rsync -az --delete --exclude='__pycache__' --exclude='*.pyc' --exclude='libmod_utils.so' \ - modtools/ "${TARGET}:${SITE_PACKAGES}/modtools/" + remote "printf '%s\n' 'branch=${branch}' 'commit=${sha}${dirty}' 'package=${PKG_VERSION}' \ + 'deployed=$(date '+%Y-%m-%d %H:%M:%S %z')' 'from=$(whoami)@$(hostname -s)' > ${MARKER}" -rsync -az --delete \ - html/ "${TARGET}:${HTML_DIR}/" + echo "==> Restarting mod-ui" + remote "sudo systemctl restart mod-ui" + wait_until_serving +} -if [[ "${1:-}" == "--build-utils" ]]; then - echo "==> Building utils on device" - rsync -az --delete --exclude='*.o' --exclude='*.so' \ - utils/ "${TARGET}:/tmp/mod-ui-utils/" - ssh "${TARGET}" "make -C /tmp/mod-ui-utils && cp /tmp/mod-ui-utils/libmod_utils.so ${SITE_PACKAGES}/modtools/" -fi +restore() { + connect + if ! remote "test -f ${SNAPSHOT}"; then + echo "error: no snapshot of mod-ui ${PKG_VERSION} at ${SNAPSHOT}." >&2 + echo " Fall back to: ssh ${TARGET} sudo apt install --reinstall mod-ui" >&2 + exit 1 + fi + echo "==> Restoring packaged mod-ui ${PKG_VERSION} from ${SNAPSHOT}" + remote "rm -rf ${SITE_PACKAGES}/mod ${SITE_PACKAGES}/modtools ${HTML_DIR} \ + && tar -C / -xzf ${SNAPSHOT} && rm -f ${MARKER}" + echo "==> Restarting mod-ui" + remote "sudo systemctl restart mod-ui" + wait_until_serving +} -echo "==> Restarting mod-ui" -ssh "${TARGET}" "sudo systemctl restart mod-ui" +status() { + connect + echo "Device: ${TARGET}" + echo "Package: mod-ui ${PKG_VERSION}" + if remote "test -f ${MARKER}"; then + echo "Dev deploy: yes" + remote "sed 's/^/ /' ${MARKER}" + if ! remote "grep -qx 'package=${PKG_VERSION}' ${MARKER}"; then + echo " (the package was upgraded after this deploy, so its files have replaced the dev build)" + fi + else + echo "Dev deploy: no (packaged files)" + fi + echo "Snapshots: $(remote "ls ${SNAPSHOT_DIR} 2>/dev/null | tr '\n' ' '")" + echo "Service: $(remote "systemctl is-active mod-ui" || true)" +} -echo "==> Done" +case "${1:-}" in + ""|--build-utils) deploy "${1:-}" ;; + status) status ;; + logs) shift; ssh "${SSH_OPTS[@]}" -t "${TARGET}" "sudo journalctl -u mod-ui ${*:--f}" ;; + snapshot) connect; snapshot ;; + restore) restore ;; + -h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//' ;; + *) echo "unknown command: $1 (try --help)" >&2; exit 1 ;; +esac diff --git a/docs/deploying-to-pistomp.md b/docs/deploying-to-pistomp.md new file mode 100644 index 000000000..341a303ca --- /dev/null +++ b/docs/deploying-to-pistomp.md @@ -0,0 +1,76 @@ +# Testing a mod-ui branch on a pi-Stomp + +`deploy.sh` copies this working tree over the mod-ui that the `mod-ui` Debian +package installed on the device, restarts the service, and can put the packaged +version back afterwards. Nothing is rebuilt or repackaged, so a round trip takes +seconds. + +## What gets replaced + +The package (built by `pi-gen-pistomp/debpkgs/mod-ui`) installs mod-ui in three +places. `deploy.sh` overwrites the first two and leaves the third alone: + +| On the device | From this repo | Deployed | +|---------------|----------------|----------| +| `/opt/pistomp/venvs/mod-ui/lib/python3.11/site-packages/{mod,modtools}` | `mod/`, `modtools/` | always | +| `/opt/pistomp/mod-ui/html` (`MOD_HTML_DIR`) | `html/` | always | +| `.../site-packages/modtools/libmod_utils.so` | `utils/` (C++) | only with `--build-utils` | + +The service unit, its environment and the Python dependencies in the venv stay +as packaged. + +## The recipe + +```bash +git switch my-branch +./deploy.sh # snapshot the packaged files (first time only), copy, restart, wait for port 80 +# test at http://pistomp.local/ — a normal reload picks up new JS/CSS (the ?v= key changes on each deploy) +./deploy.sh logs # follow the mod-ui journal while you test +# edit, then ./deploy.sh again; repeat +./deploy.sh status # what is on the device right now +./deploy.sh restore # back to the packaged mod-ui when you are done +``` + +Uncommitted changes are deployed too; `status` says so. The device is +`pistomp@pistomp.local` unless you set `PISTOMP_HOST` / `PISTOMP_USER` +(use the IP address if mDNS does not resolve). + +## Snapshots and markers + +- **Snapshot.** Before the first deploy, the packaged `mod`, `modtools` and `html` + are saved to `~pistomp/mod-ui-snapshots/mod-ui-.tar.gz`. + Later deploys reuse it. `restore` puts it back. It is keyed by package version, + so after an `apt upgrade` of mod-ui the next deploy takes a fresh one. +- **Marker.** Each deploy writes `/opt/pistomp/mod-ui/DEV-DEPLOY` (branch, commit, + time, package version). `restore` removes it. `deploy.sh` will not snapshot + while a dev build sits on top of the installed package version, so a dev build + is never saved as "packaged". + If you hit that error, run `sudo apt install --reinstall mod-ui` on the device + and deploy again. + +## Things to know + +- **A package upgrade overwrites a dev deploy.** `status` notices and says so. +- **Crash loops start recovery.** The unit has `OnFailure=pistomp-recovery.service` + and `StartLimitBurst=3` in 60s. If the deployed code dies on startup three + times, the device drops into the recovery UI. `deploy.sh` waits 90s for port + 80, prints the journal if it never answers, and you run `./deploy.sh restore`. +- **Edited `.less`? Rebuild `main.css` before deploying.** The device serves + the compiled `html/css/main.css` and never compiles `.less` itself. This + reproduces the committed file exactly: + + ```bash + cd html/css + npm install --no-save bootstrap@3.4.1 + npx -y -p less@2 lessc --compress less/main.less main.css + ``` + +- **C++ changes (`utils/`) need `--build-utils`.** The `.so` must be built for + ARM64 on the device, which needs `make`, `g++`, `liblilv-dev` and + `libjack-jackd2-dev` there. Without the flag the packaged `.so` is kept, which + is right as long as your branch does not touch `utils/` beyond what the + installed package was built from. +- **Dependency changes are not deployed.** If a branch needs a new Python + package or service environment variable, test it by building the package + instead (set `MOD_UI_BRANCH` in `pi-gen-pistomp/config.sh` and run + `debpkgs/mod-ui/build.sh`). diff --git a/html/allguis.html b/html/allguis.html index 662d7d0eb..4f6ecedb2 100644 --- a/html/allguis.html +++ b/html/allguis.html @@ -25,7 +25,7 @@ + + @@ -918,11 +928,41 @@

Filtered results

+ +
+
+
+

PATCHSTORAGE

+
+
+

Choose an asset type to browse and install from Patchstorage.

+
+ + +
+
+
+
+ +
-

PATCHSTORAGE (Beta)

+ +

PATCHSTORAGE PLUGINS

@@ -993,6 +1033,124 @@

Filtered results

+ +
+
+
+ +

PATCHSTORAGE PEDALBOARDS

+ +
+ +
+

Filter

+
+ + +
+
+ + +
+
+ + +
+
+ +
+
    +
  • All
  • +
+
+ +
+
+

Filtered results

+
+
+
+ +
+
+ + + +
+
+ × +

Share on Patchstorage

+ +
+

+
+ + + +
+ +
+ + +
+

This pedalboard came from on Patchstorage, + so it will be shared as a new pedalboard under your name.

+ + +
+ + +
+ + + + +
+ +
+ Artwork * + + + +
+
+ +

* required

+

+
+ + +
+
+ +
+

Published revision on Patchstorage.

+ +
+
+
+ +
diff --git a/html/js/desktop.js b/html/js/desktop.js index 198e86a62..1b04b4a54 100644 --- a/html/js/desktop.js +++ b/html/js/desktop.js @@ -36,6 +36,9 @@ function Desktop(elements) { cloudPluginBoxTrigger: $('
'), patchstorageBox: $('
'), patchstorageBoxTrigger: $('
'), + patchstorageHub: $('
'), + patchstoragePedalboardsBox: $('
'), + patchstorageShareWindow: $('
'), pedalboardTrigger: $('
'), fileManagerBox: $('
'), fileManagerBoxTrigger: $('
'), @@ -369,6 +372,8 @@ function Desktop(elements) { $('#plugins-library').css('z-index', -1) $('#cloud-plugins-library').css('z-index', -1) $('#patchstorage-library').css('z-index', -1) + $('#patchstorage-pedalboards-library').css('z-index', -1) + $('#patchstorage-hub').css('z-index', -1) $('#pedalboards-library').css('z-index', -1) $('#bank-library').css('z-index', -1) $('#main-menu').css('z-index', -1) @@ -730,8 +735,14 @@ function Desktop(elements) { elements.effectBoxTrigger) this.cloudPluginBox = self.makeCloudPluginBox(elements.cloudPluginBox, elements.cloudPluginBoxTrigger) + this.patchstorageHub = self.makePatchstorageHub(elements.patchstorageHub, + elements.patchstorageBoxTrigger) this.patchstorageBox = self.makePatchstorageBox(elements.patchstorageBox, elements.patchstorageBoxTrigger) + this.patchstoragePedalboardsBox = self.makePatchstoragePedalboardsBox( + elements.patchstoragePedalboardsBox, + elements.patchstorageBoxTrigger) + this.patchstorageShareWindow = elements.patchstorageShareWindow.patchstorageShareBox() this.pedalboardBox = self.makePedalboardBox(elements.pedalboardBox, elements.pedalboardBoxTrigger) this.bankBox = self.makeBankBox(elements.bankBox, @@ -1162,9 +1173,7 @@ function Desktop(elements) { return new Notification('warn', 'No plugins loaded, cannot share', 1500) } - self.validatePlugins(uris, function (stable) { - elements.shareWindow.shareBox('open', self.pedalboardBundle, self.title, stable) - }) + self.sharePedalboardOnPatchstorage(self.pedalboardBundle) } if (self.pedalboardModified || ! (self.pedalboardEmpty || self.pedalboardBundle)) { @@ -1735,10 +1744,45 @@ Desktop.prototype.makeCloudPluginBox = function (el, trigger) { }) } -Desktop.prototype.makePatchstorageBox = function (el, trigger) { +Desktop.prototype.makePatchstorageHub = function (el, menuTrigger) { var self = this - return el.patchstorageBox({ - trigger: trigger, + if (!el || !el.length) { + console.error('[patchstorage] #patchstorage-hub not found in DOM') + return el + } + + el.window({ + trigger: menuTrigger, + windowManager: this.windowManager, + windowName: "Patchstorage", + isMainWindow: true + }) + + el.find('.ps-hub-asset').on('click', function () { + var asset = $(this).data('asset') + el.window('close') + if (asset === 'plugins') { + self.patchstorageBox.removeClass('mod-hidden') + self.patchstorageBox.window('open') + } else if (asset === 'pedalboards') { + self.patchstoragePedalboardsBox.removeClass('mod-hidden') + self.patchstoragePedalboardsBox.window('open') + } + // Keep the blue P selected while a child page is open + if (menuTrigger && menuTrigger.length) { + menuTrigger.addClass('selected') + $('#mod-plugins').removeClass('selected') + } + }) + + return el +} + +Desktop.prototype.makePatchstorageBox = function (el, menuTrigger) { + var self = this + var box = el.patchstorageBox({ + // Hub owns the menu trigger; this page is opened from the hub + trigger: null, windowManager: this.windowManager, removePluginBundles: function (bundles, callback) { if (!confirm('You are about to remove this plugin and any other in the same bundle. This may break pedalboards that depend on them.')) @@ -1771,6 +1815,88 @@ Desktop.prototype.makePatchstorageBox = function (el, trigger) { self.installationQueue.installUsingURI(uri, usingLabs, callback) } }) + + el.data('trigger', menuTrigger) + + el.find('.js-ps-back').on('click', function () { + el.window('close') + self.patchstorageHub.window('open') + return false + }) + + return box +} + +Desktop.prototype.sharePedalboardOnPatchstorage = function (bundlepath, callback) { + var self = this + self.patchstorageShareWindow.patchstorageShareBox('open', bundlepath, function (result) { + self.previousPedalboardList = null + self.patchstoragePedalboardsBox.data('localPedalboards', null) + self.patchstoragePedalboardsBox.data('cloudPedalboards', null) + self.patchstoragePedalboardsBox.data('mergedPedalboards', null) + if (callback) { + callback(result) + } + }) +} + +Desktop.prototype.makePatchstoragePedalboardsBox = function (el, menuTrigger) { + var self = this + + if (!$.fn.patchstoragePedalboardsBox) { + console.error('[patchstorage] patchstorage_pedalboards.js did not load ($.fn.patchstoragePedalboardsBox missing)') + return el + } + + if (!el || !el.length) { + console.error('[patchstorage] #patchstorage-pedalboards-library not found in DOM') + return el + } + + var box = el.patchstoragePedalboardsBox({ + trigger: null, + windowManager: this.windowManager, + removePedalboard: function (bundle, callback) { + if (!confirm('Remove this pedalboard from the device?')) + return + $.ajax({ + url: '/pedalboard/remove/', + data: { + bundlepath: bundle + }, + success: function (ok) { + self.previousPedalboardList = null + if (ok) { + new Notification("info", "Pedalboard removed", 2000) + } + callback(ok) + }, + error: function () { + new Bug("Couldn't remove pedalboard") + }, + cache: false, + dataType: 'json' + }) + }, + loadPedalboard: function (bundle, callback) { + self.loadPedalboard(bundle, function () { + callback(true) + }) + }, + sharePedalboard: function (bundle, callback) { + self.sharePedalboardOnPatchstorage(bundle, callback) + } + }) + + el.data('trigger', menuTrigger) + + el.find('.js-ps-back').on('click', function () { + el.window('close') + self.patchstorageHub.window('open') + return false + }) + + return box } Desktop.prototype.makeBankBox = function (el, trigger) { diff --git a/html/js/patchstorage_pedalboards.js b/html/js/patchstorage_pedalboards.js new file mode 100644 index 000000000..c3de1a189 --- /dev/null +++ b/html/js/patchstorage_pedalboards.js @@ -0,0 +1,732 @@ +function objectEmpty(obj) { + for (var _ in obj) return false + return true +} + +JqueryClass('patchstoragePedalboardsBox', { + init: function (options) { + var self = $(this) + + options = $.extend({ + resultCanvas: self.find('.js-patchstorage-pedalboards'), + removePedalboard: function (bundle, callback) { + callback(false) + }, + loadPedalboard: function (bundle, callback) { + callback(false) + }, + sharePedalboard: function (bundle, callback) { + callback(null) + }, + windowName: "Patchstorage Pedalboards", + info: null, + isMainWindow: true, + localPedalboards: null, + cloudPedalboards: null, + cloudPedalboardsLoaded: false, + localPedalboardsLoaded: false, + mergedPedalboards: null, + xhrs: [] + }, options) + + self.data(options) + + var searchbox = self.find('input[type=search]') + searchbox.val("") + self.data('searchbox', searchbox) + searchbox.cleanableInput() + + var lastKeyTimeout = null + function scheduleSearch() { + if (lastKeyTimeout != null) { + clearTimeout(lastKeyTimeout) + } + lastKeyTimeout = setTimeout(function () { + self.patchstoragePedalboardsBox('search') + }, 400); + } + + searchbox.keydown(function (e) { + if (e.keyCode == 13) { + if (lastKeyTimeout != null) { + clearTimeout(lastKeyTimeout) + lastKeyTimeout = null + } + self.patchstoragePedalboardsBox('search') + return false + } + else if (e.keyCode == 8 || e.keyCode == 46) { + scheduleSearch() + } + }) + searchbox.keypress(function (e) { + if (e.which == 13) + return + scheduleSearch() + }) + searchbox.on('paste', function () { + scheduleSearch() + }) + + self.find('input:checkbox[name=installed]').click(function () { + self.find('input:checkbox[name=non-installed]').prop('checked', false) + self.find('input:checkbox[name=outdated]').prop('checked', false) + self.patchstoragePedalboardsBox('search') + }) + self.find('input:checkbox[name=non-installed]').click(function () { + self.find('input:checkbox[name=installed]').prop('checked', false) + self.find('input:checkbox[name=outdated]').prop('checked', false) + self.patchstoragePedalboardsBox('search') + }) + self.find('input:checkbox[name=outdated]').click(function () { + self.find('input:checkbox[name=non-installed]').prop('checked', false) + self.find('input:checkbox[name=installed]').prop('checked', false) + self.patchstoragePedalboardsBox('search') + }) + + self.find('#patchstorage_pedalboards_update_all').click(function () { + self.data('cloudPedalboards', null) + self.data('localPedalboards', null) + self.data('mergedPedalboards', null) + self.patchstoragePedalboardsBox('search') + }) + + options.open = function () { + self.removeClass('mod-hidden') + self.css({ display: 'block', opacity: 1, 'z-index': 1000 }) + self.patchstoragePedalboardsBox('search') + return false + } + + self.removeClass('mod-hidden') + self.window(options) + return self + }, + + cleanResults: function () { + var self = $(this) + self.find('.plugins-wrapper').html('') + }, + + transformCloudPedalboard: function (p) { + p.psid = p.id.toString() + p.cloud_revision = p.revision + p.name = unescape(p.title) + p.label = unescape(p.title) + p.comment = (p.content) ? unescape(p.content) : unescape(p.excerpt || '') + p.thumbnail_href = (p.artwork && p.artwork.thumbnail_url) ? p.artwork.thumbnail_url : '' + p.screenshot_href = (p.artwork && p.artwork.url) ? p.artwork.url : p.thumbnail_href + p.state = (p.state && p.state.slug) ? p.state.slug : null + p.uploader = (p.author && p.author.slug) ? p.author.slug : '' + p.status = 'available' + p.uri = 'ps-pedalboard:' + p.psid + p.supported = true + + // Prefer a .pedalboard.zip file; otherwise first file + p.file = null + if (p.files && p.files.length > 0) { + p.files.forEach(function (f) { + if (!p.file && f.filename && f.filename.indexOf('.pedalboard') >= 0) { + p.file = f + } + }) + if (!p.file) { + p.file = p.files[0] + } + } + + var tags = [] + if (p.categories) { + p.categories.forEach(function (item) { + var name = item.slug.replace(/-/g, '').toLowerCase() + if (!tags.includes(name)) tags.push(name) + }) + } + if (p.tags) { + p.tags.forEach(function (item) { + var name = item.slug.replace(/-/g, '').toLowerCase() + if (!tags.includes(name)) tags.push(name) + }) + } + p.category = tags.length ? [tags[0]] : ['Other'] + p.tags = tags.join(', ') + + delete p.id + delete p.artwork + delete p.author + delete p.content + delete p.categories + delete p.code + delete p.created_at + delete p.preview_url + delete p.title + delete p.updated_at + delete p.slug + delete p.platform + delete p.excerpt + + return p + }, + + transformLocalPedalboard: function (p) { + p.psid = (p.patchstorage && p.patchstorage.id) ? String(p.patchstorage.id) : null + p.local_revision = (p.patchstorage && p.patchstorage.revision) ? p.patchstorage.revision : null + p.name = p.title + p.label = p.title + p.uri = p.bundle + p.comment = '' + p.status = 'installed' + + var ver = p.version || 0 + var hasThumb = true + p.thumbnail_href = "/pedalboard/image/thumbnail.png?bundlepath=" + escape(p.bundle) + "&v=" + ver + p.screenshot_href = "/pedalboard/image/screenshot.png?bundlepath=" + escape(p.bundle) + "&v=" + ver + // Prefer local images; cloud artwork filled in on merge if needed + p._hasLocalImages = hasThumb + + return p + }, + + mergePedalboardsData: function (lPb, cPb) { + if (!cPb && !lPb) { + return {} + } + + if (lPb == null || lPb == undefined || objectEmpty(lPb)) { + return $.extend(true, {}, cPb) + } + + if (cPb == null || cPb == undefined || objectEmpty(cPb)) { + return $.extend(true, {}, lPb) + } + + lPb.psid = cPb.psid + lPb.files = cPb.files + lPb.file = cPb.file + lPb.cloud_revision = cPb.cloud_revision + lPb.download_count = cPb.download_count + lPb.state = cPb.state + lPb.url = cPb.url + lPb.uploader = cPb.uploader + lPb.tags = cPb.tags + lPb.donate_url = cPb.donate_url + lPb.comment = cPb.comment || lPb.comment + lPb.name = cPb.name || lPb.name + lPb.label = cPb.label || lPb.label + lPb.uri = 'ps-pedalboard:' + cPb.psid + + // Prefer cloud artwork for list cards when available (more reliable than missing thumbs) + if (cPb.thumbnail_href) { + lPb.cloud_thumbnail_href = cPb.thumbnail_href + lPb.cloud_screenshot_href = cPb.screenshot_href + } + + if (lPb.local_revision && lPb.cloud_revision && lPb.cloud_revision != lPb.local_revision) { + lPb.status = 'outdated' + } else { + lPb.status = 'installed' + } + + return lPb + }, + + getCloudPedalboards: function (callback) { + var self = $(this) + var base = PATCHSTORAGE_API_URL + var platform_id = PATCHSTORAGE_PEDALBOARD_PLATFORM_ID + var tag_id = PATCHSTORAGE_PEDALBOARD_TAG_ID + var url = `${base}?per_page=100&platforms=${platform_id}` + if (tag_id) { + url += `&tags=${tag_id}` + } + var page = 1 + var pb_map = {} + var cloudPedalboards = self.data('cloudPedalboards') + + self.data('cloudPedalboardsLoaded', false) + + if (cloudPedalboards != null) { + self.data('cloudPedalboardsLoaded', true) + callback() + return + } + + var loading = new Notification('info', 'Fetching pedalboards from Patchstorage...', 7000) + + function getNextPage() { + var xhr = $.ajax({ + url: url + `&page=${page}`, + method: 'GET', + async: true, + cache: false, + dataType: 'json', + success: function (data, status, xhr) { + if (!data || data.length < 1) { + self.data('cloudPedalboards', pb_map) + self.data('cloudPedalboardsLoaded', true) + loading.close() + callback() + return + } + + var pages = xhr.getResponseHeader('x-wp-totalpages') + + for (var i = 0; i < data.length; i++) { + var pb = self.patchstoragePedalboardsBox('transformCloudPedalboard', data[i]) + if (pb.supported) { + pb_map[pb.psid] = pb + } + } + + if (pages && pages > page) { + page++ + getNextPage() + } else { + self.data('cloudPedalboards', pb_map) + self.data('cloudPedalboardsLoaded', true) + loading.close() + callback() + } + }, + error: function (xhr, status) { + if (status == 'abort') return + loading.close() + new Notification('error', "Connection to Patchstorage failed!", 5000) + self.data('cloudPedalboards', {}) + self.data('cloudPedalboardsLoaded', true) + callback() + } + }); + self.data('xhrs').push(xhr) + } + + getNextPage() + }, + + getLocalPedalboards: function (callback) { + var self = $(this) + var pb_map = {} + var localPedalboards = self.data('localPedalboards') + + self.data('localPedalboardsLoaded', false) + + if (localPedalboards != null) { + self.data('localPedalboardsLoaded', true) + callback() + return + } + + var xhr = $.ajax({ + method: 'GET', + url: '/pedalboard/list', + success: function (pedals) { + for (var i in pedals) { + var transformed = self.patchstoragePedalboardsBox('transformLocalPedalboard', pedals[i]) + if (transformed.psid) { + pb_map[transformed.psid] = transformed + } + } + self.data('localPedalboards', pb_map) + self.data('localPedalboardsLoaded', true) + callback() + }, + error: function (xhr, status) { + if (status == 'abort') return + self.data('localPedalboards', {}) + self.data('localPedalboardsLoaded', true) + callback() + }, + cache: false, + dataType: 'json' + }) + self.data('xhrs').push(xhr) + }, + + mergePedalboards: function (lPbs, cPbs) { + var self = $(this) + + if (self.data('mergedPedalboards') != null) { + return self.data('mergedPedalboards') + } + + var mPbs = {} + var lMap = $.extend(true, {}, lPbs) + var cMap = $.extend(true, {}, cPbs) + + function buildIndex(data) { + data.index = `${data.comment} ${data.name} ${data.label} ${data.uploader} ${data.tags}`.toLowerCase() + return data + } + + for (var psid in cMap) { + var cPb = cMap[psid] + if (psid in lMap) { + var merged = self.patchstoragePedalboardsBox('mergePedalboardsData', lMap[psid], cPb) + mPbs[psid] = buildIndex(merged) + delete lMap[psid] + } else { + cPb.status = 'available' + mPbs[psid] = buildIndex(cPb) + } + } + + // Local pedalboards previously installed from Patchstorage but no longer listed + for (var psid in lMap) { + var lPb = lMap[psid] + lPb.status = 'unavailable' + mPbs[psid] = buildIndex(lPb) + } + + self.data('mergedPedalboards', mPbs) + return mPbs + }, + + search: function () { + var self = $(this) + var query = { + text: self.data('searchbox').val() + } + + var xhrs = self.data('xhrs') + for (var i in xhrs) { + if (xhrs[i].abort) xhrs[i].abort() + delete xhrs[i] + } + self.data('xhrs', []) + + var filterAvailable = function (pb) { + return pb.status != 'available' + } + var filterInstalled = function (pb) { + return !(pb.status == 'installed' || pb.status == 'outdated') + } + var filterOutdated = function (pb) { + return pb.status != 'outdated' + } + var filterAll = function () { + return false + } + + if (self.find('input:checkbox[name=non-installed]:checked').length) + return self.patchstoragePedalboardsBox('searchPedalboards', query, filterAvailable) + + if (self.find('input:checkbox[name=installed]:checked').length) + return self.patchstoragePedalboardsBox('searchPedalboards', query, filterInstalled) + + if (self.find('input:checkbox[name=outdated]:checked').length) + return self.patchstoragePedalboardsBox('searchPedalboards', query, filterOutdated) + + return self.patchstoragePedalboardsBox('searchPedalboards', query, filterAll) + }, + + searchPedalboards: function (query, shouldSkip) { + var self = $(this) + + var renderResultsCallback = function () { + if (self.data('localPedalboardsLoaded') == false || self.data('cloudPedalboardsLoaded') == false) { + return + } + + var pedalboards = [] + var mPbs = self.patchstoragePedalboardsBox('mergePedalboards', + self.data('localPedalboards'), self.data('cloudPedalboards')) + + for (var id in mPbs) { + var pb = mPbs[id] + if (shouldSkip(pb)) { + continue + } + if (query && query.text && !pb.index.includes(query.text.toLowerCase())) { + continue + } + pedalboards.push(pb) + } + + self.patchstoragePedalboardsBox('renderPedalboards', pedalboards) + } + + self.data('localPedalboardsLoaded', false) + self.data('cloudPedalboardsLoaded', false) + self.patchstoragePedalboardsBox('getCloudPedalboards', renderResultsCallback) + self.patchstoragePedalboardsBox('getLocalPedalboards', renderResultsCallback) + }, + + renderPedalboards: function (pedalboards) { + var self = $(this) + self.patchstoragePedalboardsBox('cleanResults') + + pedalboards.sort(function (a, b) { + a = a.label.toLowerCase() + b = b.label.toLowerCase() + if (a > b) return 1 + if (a < b) return -1 + return 0 + }) + + var canvas = self.find('#patch-pedalboard-content-All') + for (var i in pedalboards) { + var render = self.patchstoragePedalboardsBox('renderPedalboardCard', pedalboards[i]) + render.appendTo(canvas) + } + + self.find('#patchstorage-pedalboards-tab-All').html( + 'All (' + pedalboards.length + ')') + }, + + renderPedalboardCard: function (pb) { + var self = $(this) + var data = self.patchstoragePedalboardsBox('getPedalboardCardData', pb, false) + var rendered = $(Mustache.render(TEMPLATES.patchstorage_pedalboard, data)) + rendered.click(function () { + self.patchstoragePedalboardsBox('showPedalboardInfo', pb) + }) + return rendered + }, + + getPedalboardCardData: function (pb, full) { + var data = $.extend(true, {}, pb) + + // Prefer Patchstorage artwork for cards — local pedalboard screenshots are full + // dashboard captures that look wrong in the small plugin-style thumb slot, and + // may show default "tuna can" icons until a fresh screenshot is generated. + var thumb = data.cloud_thumbnail_href || data.thumbnail_href + var shot = data.cloud_screenshot_href || data.screenshot_href || thumb + + // Detail modal: prefer local screenshot when installed (actual board capture) + if (full && data.bundle && (data.status == 'installed' || data.status == 'outdated')) { + shot = data.screenshot_href || shot + thumb = data.thumbnail_href || thumb + } + + var basic = { + uri: data.uri, + escaped_uri: escape(data.uri), + comment: (data.comment) ? data.comment.trim() : "No description available", + has_comment: (data.comment) ? null : "no_description", + screenshot_href: shot, + thumbnail_href: thumb, + status: data.status, + brand: data.uploader, + label: data.label, + download_count: data.download_count, + state: data.state, + tags: data.tags + } + + if (basic.status == 'available') { + delete basic.status + } + + if (full === false) { + return basic + } + + return $.extend(true, basic, { + name: data.name, + url: data.url, + category: (data.category && data.category[0]) || "None", + local_revision: data.local_revision, + cloud_revision: data.cloud_revision, + uploader: data.uploader, + donate_url: data.donate_url + }) + }, + + ensureCloudFile: function (pb, callback) { + var self = $(this) + + if (pb.file && pb.file.url) { + callback(pb) + return + } + + var xhr = $.ajax({ + url: `${PATCHSTORAGE_API_URL}/${pb.psid}`, + method: 'GET', + async: true, + cache: false, + dataType: 'json', + success: function (data) { + var detail = self.patchstoragePedalboardsBox('transformCloudPedalboard', data) + pb.files = detail.files + pb.file = detail.file + callback(pb) + }, + error: function () { + callback(null) + } + }) + self.data('xhrs').push(xhr) + }, + + installPedalboard: function (pb, callback) { + var self = $(this) + + self.patchstoragePedalboardsBox('ensureCloudFile', pb, function (ready) { + if (!ready || !ready.file || !ready.file.url) { + alert('No downloadable pedalboard file found on Patchstorage.') + return + } + + var file = ready.file + var notification = new Notification('warning') + var installationMsg = 'Downloading: ' + file.filename + + notification.open() + notification.html(installationMsg) + notification.type('warning') + notification.bar(1) + + var trans = new SimpleTransference(file.url, '/pedalboard/install', + { to_args: { headers: + { 'Patchstorage-Item' : pb.psid, 'Patchstorage-Item-Version' : pb.cloud_revision } + }}) + + trans.reportPercentageStatus = function (percentage) { + notification.bar(percentage * 100) + if (percentage == 1) { + installationMsg = installationMsg.replace("Downloading", "Installing") + notification.html(installationMsg) + } + } + + trans.reportError = function (reason) { + notification.close() + new Notification('error', "Could not install pedalboard: " + reason, 5000) + } + + trans.reportFinished = function (resp) { + var result = resp.result || resp + if (result.ok) { + notification.html(installationMsg.replace("Installing:", "Done! Installed:")) + notification.bar(0) + notification.type('success') + notification.closeAfter(3000) + if (desktop.previousPedalboardList !== undefined) { + desktop.previousPedalboardList = null + } + } else { + notification.closeAfter(1000) + new Notification('error', "Could not install pedalboard: " + (result.error || 'unknown error'), 5000) + } + callback(result) + } + + trans.start() + }) + }, + + showPedalboardInfo: function (mPb) { + var self = $(this) + + // Refresh cloud detail for files / latest metadata + var xhr = $.ajax({ + url: `${PATCHSTORAGE_API_URL}/${mPb.psid}`, + method: 'GET', + async: true, + cache: false, + dataType: 'json', + success: function (data) { + var cloud = self.patchstoragePedalboardsBox('transformCloudPedalboard', data) + var local = self.data('localPedalboards')[mPb.psid] || {} + var pb = self.patchstoragePedalboardsBox('mergePedalboardsData', local, cloud) + // Preserve installed status if local missing (available) + if (!local || objectEmpty(local)) { + pb.status = 'available' + } + + var metadata = self.patchstoragePedalboardsBox('getPedalboardCardData', pb, true) + var info = self.data('info') + if (info) { + info.remove() + self.data('info', null) + } + + info = $(Mustache.render(TEMPLATES.patchstorage_pedalboard_info, metadata)) + + info.find('.js-install').hide() + info.find('.js-upgrade').hide() + info.find('.js-remove').hide() + info.find('.js-load').hide() + info.find('.js-share').hide() + + if (pb.status == 'available') { + info.find('.js-install').show().click(function () { + self.patchstoragePedalboardsBox('installPedalboard', pb, function () { + info.window('close') + self.data('localPedalboards', null) + self.data('mergedPedalboards', null) + self.patchstoragePedalboardsBox('search') + }) + }) + } + + if (pb.status == 'outdated') { + info.find('.js-upgrade').show().click(function () { + self.patchstoragePedalboardsBox('installPedalboard', pb, function () { + info.window('close') + self.data('localPedalboards', null) + self.data('mergedPedalboards', null) + self.patchstoragePedalboardsBox('search') + }) + }) + } + + if (pb.status == 'installed' || pb.status == 'outdated' || pb.status == 'unavailable') { + info.find('.js-remove').show().click(function () { + var bundle = pb.bundle + if (!bundle) { + alert('Cannot determine local pedalboard path') + return + } + self.data('removePedalboard')(bundle, function (ok) { + info.window('close') + self.data('localPedalboards', null) + self.data('mergedPedalboards', null) + self.patchstoragePedalboardsBox('search') + }) + }) + info.find('.js-load').show().click(function () { + if (!pb.bundle) return + self.data('loadPedalboard')(pb.bundle, function () { + info.window('close') + }) + }) + } + + // Only an up-to-date copy may become the next revision, or an older copy + // would replace newer work. Without a login, ownership is checked after it. + if (pb.status == 'installed' && pb.bundle) { + $.ajax({ + url: '/patchstorage/share/info', + data: { bundlepath: pb.bundle }, + cache: false, + dataType: 'json', + success: function (resp) { + if (!resp.ok || !resp.patch || (resp.auth.logged_in && !resp.patch.owned)) { + return + } + info.find('.js-share').show().click(function () { + self.data('sharePedalboard')(pb.bundle, function () { + info.window('close') + self.patchstoragePedalboardsBox('search') + }) + }) + }, + }) + } + + info.appendTo($('body')) + info.window({ + windowName: "Patchstorage Pedalboard Info" + }) + info.window('open') + self.data('info', info) + }, + error: function () { + new Notification('error', 'Failed to load pedalboard details', 4000) + } + }) + self.data('xhrs').push(xhr) + } +}) diff --git a/html/js/patchstorage_share.js b/html/js/patchstorage_share.js new file mode 100644 index 000000000..3265be6ff --- /dev/null +++ b/html/js/patchstorage_share.js @@ -0,0 +1,427 @@ +// SPDX-FileCopyrightText: 2026 pi-Stomp +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Share a user pedalboard on Patchstorage. The mod-ui server does the login and the +// upload (see mod/patchstorage_share.py); this window collects what to publish. +// +// $('#patchstorage-share-window').patchstorageShareBox('open', bundlepath, onShared) + +JqueryClass('patchstorageShareBox', { + init: function () { + var self = $(this) + + self.data('taxonomies', null) + + self.find('.js-close').click(function () { + if (!self.data('busy')) { + self.patchstorageShareBox('close') + } + return false + }) + self.keydown(function (e) { + if (e.keyCode == 27 && !self.data('busy')) { + self.patchstorageShareBox('close') + return false + } + }) + + self.find('.js-step-login').submit(function () { + self.patchstorageShareBox('login') + return false + }) + self.find('.js-step-form').submit(function () { + self.patchstorageShareBox('publish') + return false + }) + self.find('.js-logout').click(function () { + $.ajax({ + url: '/patchstorage/logout', + method: 'POST', + dataType: 'json', + complete: function () { + self.patchstorageShareBox('showLogin', '') + }, + }) + return false + }) + self.find('input[name=mode]').change(function () { + self.patchstorageShareBox('applyMode') + }) + self.find('.js-artwork-choose').click(function () { + self.find('input[name=artwork]').click() + return false + }) + self.find('input[name=artwork]').change(function () { + self.patchstorageShareBox('chooseArtwork', this.files[0]) + }) + + return self + }, + + open: function (bundlepath, onShared) { + var self = $(this) + self.data('bundlepath', bundlepath) + self.data('onShared', onShared || function () {}) + self.data('info', null) + self.find('.js-error').text('') + self.patchstorageShareBox('showStep', 'loading', 'Preparing pedalboard...') + self.removeClass('mod-hidden').show() + self.focus() + + // the artwork is the pedalboard screenshot; let any pending one finish first + desktop.waitForScreenshot(false, bundlepath, function () { + self.patchstorageShareBox('loadInfo', true) + }) + }, + + close: function () { + var self = $(this) + self.data('busy', false) + self.find('input[type=password]').val('') + self.patchstorageShareBox('clearArtwork') + self.addClass('mod-hidden').hide() + }, + + showStep: function (step, message) { + var self = $(this) + self.find('.ps-share-step').hide() + self.find('.js-step-' + step).show() + if (message !== undefined) { + self.find('.js-step-' + step + ' .js-message').text(message) + } + }, + + fail: function (message) { + var self = $(this) + self.patchstorageShareBox('close') + new Notification('error', message, 6000) + }, + + loadInfo: function (generateIfMissing) { + var self = $(this) + var bundlepath = self.data('bundlepath') + + $.ajax({ + url: '/patchstorage/share/info', + data: { bundlepath: bundlepath }, + cache: false, + dataType: 'json', + success: function (info) { + if (!info.ok) { + self.patchstorageShareBox('fail', info.error) + return + } + if (!info.has_screenshot && generateIfMissing) { + self.patchstorageShareBox('showStep', 'loading', 'Taking a screenshot of the pedalboard...') + desktop.waitForScreenshot(true, bundlepath, function () { + self.patchstorageShareBox('loadInfo', false) + }) + return + } + self.data('info', info) + if (info.auth.logged_in) { + self.patchstorageShareBox('showForm') + } else { + self.patchstorageShareBox('showLogin', '') + } + }, + error: function () { + self.patchstorageShareBox('fail', 'Could not read the pedalboard') + }, + }) + }, + + showLogin: function (error) { + var self = $(this) + self.find('.js-step-login .js-error').text(error || '') + self.patchstorageShareBox('showStep', 'login') + self.find('.js-step-login input[name=username]').focus() + }, + + login: function () { + var self = $(this) + var form = self.find('.js-step-login') + form.find('.js-error').text('') + form.find('button').prop('disabled', true) + + $.ajax({ + url: '/patchstorage/login', + method: 'POST', + data: { + username: form.find('input[name=username]').val(), + password: form.find('input[name=password]').val(), + }, + dataType: 'json', + success: function (resp) { + form.find('input[name=password]').val('') + if (!resp.ok) { + form.find('.js-error').text(resp.error) + return + } + // ownership of an earlier upload depends on who is logged in + self.patchstorageShareBox('showStep', 'loading', 'Loading...') + self.patchstorageShareBox('loadInfo', false) + }, + error: function () { + form.find('.js-error').text('Could not reach the pi-Stomp') + }, + complete: function () { + form.find('button').prop('disabled', false) + }, + }) + }, + + loadTaxonomies: function (callback) { + var self = $(this) + if (self.data('taxonomies')) { + callback(self.data('taxonomies')) + return + } + + var base = PATCHSTORAGE_API_URL.replace(/\/patches\/?$/, '') + var names = ['licenses', 'states'] + var result = {} + var failed = false + + names.forEach(function (name) { + $.ajax({ + url: base + '/' + name + '?per_page=100', + cache: true, + dataType: 'json', + success: function (items) { + result[name] = items + }, + error: function () { + failed = true + }, + complete: function () { + if (Object.keys(result).length + (failed ? 1 : 0) < names.length) { + return + } + if (failed) { + callback(null) + return + } + self.data('taxonomies', result) + callback(result) + }, + }) + }) + }, + + showForm: function () { + var self = $(this) + var info = self.data('info') + var form = self.find('.js-step-form') + + self.patchstorageShareBox('showStep', 'loading', 'Loading Patchstorage options...') + + self.patchstorageShareBox('loadTaxonomies', function (taxonomies) { + if (!taxonomies) { + self.patchstorageShareBox('fail', 'Could not reach Patchstorage') + return + } + + function fillSelect(name, items, placeholder) { + var select = form.find('select[name=' + name + ']').empty() + if (placeholder) { + select.append($('