diff --git a/public/scripts/RossAscends-mods.js b/public/scripts/RossAscends-mods.js index 5179cad62..d7f4025e0 100644 --- a/public/scripts/RossAscends-mods.js +++ b/public/scripts/RossAscends-mods.js @@ -1120,7 +1120,7 @@ export function initRossMods() { const result = await Popup.show.confirm('Regenerate Message', 'Are you sure you want to regenerate the latest message?', { customInputs: [{ id: 'regenerateWithCtrlEnter', label: 'Don\'t ask again' }], onClose: (popup) => { - regenerateWithCtrlEnter = popup.inputResults.get('regenerateWithCtrlEnter') ?? false; + regenerateWithCtrlEnter = Boolean(popup.inputResults.get('regenerateWithCtrlEnter') ?? false); }, }); if (!result) { diff --git a/public/scripts/extensions.js b/public/scripts/extensions.js index 5c22da7a0..df6e8b898 100644 --- a/public/scripts/extensions.js +++ b/public/scripts/extensions.js @@ -661,6 +661,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, let deleteButton = isExternal ? `` : ''; let updateButton = isExternal ? `` : ''; let moveButton = isExternal && isUserAdmin ? `` : ''; + let branchButton = isExternal && isUserAdmin ? `` : ''; let modulesInfo = ''; if (isActive && Array.isArray(manifest.optional)) { @@ -701,6 +702,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
${updateButton} + ${branchButton} ${moveButton} ${deleteButton}
@@ -944,6 +946,44 @@ async function onDeleteClick() { } } +async function onBranchClick() { + const extensionName = $(this).data('name'); + const isCurrentUserAdmin = isAdmin(); + const isGlobal = getExtensionType(extensionName) === 'global'; + if (isGlobal && !isCurrentUserAdmin) { + toastr.error(t`You don't have permission to switch branch.`); + return; + } + + let newBranch = ''; + + const branches = await getExtensionBranches(extensionName, isGlobal); + const selectElement = document.createElement('select'); + selectElement.classList.add('text_pole', 'wide100p'); + selectElement.addEventListener('change', function () { + newBranch = this.value; + }); + for (const branch of branches) { + const option = document.createElement('option'); + option.value = branch.name; + option.textContent = `${branch.name} (${branch.commit}) [${branch.label}]`; + option.selected = branch.current; + selectElement.appendChild(option); + } + + const popup = new Popup(selectElement, POPUP_TYPE.CONFIRM, '', { + okButton: t`Switch`, + cancelButton: t`Cancel`, + }); + const popupResult = await popup.show(); + + if (!popupResult || !newBranch) { + return; + } + + await switchExtensionBranch(extensionName, isGlobal, newBranch); +} + async function onMoveClick() { const extensionName = $(this).data('name'); const isCurrentUserAdmin = isAdmin(); @@ -1055,13 +1095,83 @@ async function getExtensionVersion(extensionName, abortSignal) { } } +/** + * Gets the list of branches for a specific extension. + * @param {string} extensionName The name of the extension + * @param {boolean} isGlobal Whether the extension is global or not + * @returns {Promise} List of branches for the extension + * @typedef {object} ExtensionBranch + * @property {string} name The name of the branch + * @property {string} commit The commit hash of the branch + * @property {boolean} current Whether this branch is the current one + * @property {string} label The commit label of the branch + */ +async function getExtensionBranches(extensionName, isGlobal) { + try { + const response = await fetch('/api/extensions/branches', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ + extensionName, + global: isGlobal, + }), + }); + + if (!response.ok) { + const text = await response.text(); + toastr.error(text || response.statusText, t`Extension branches fetch failed`); + console.error('Extension branches fetch failed', response.status, response.statusText, text); + return []; + } + + return await response.json(); + } catch (error) { + console.error('Error:', error); + return []; + } +} + +/** + * Switches the branch of an extension. + * @param {string} extensionName The name of the extension + * @param {boolean} isGlobal If the extension is global + * @param {string} branch Branch name to switch to + * @returns {Promise} + */ +async function switchExtensionBranch(extensionName, isGlobal, branch) { + try { + const response = await fetch('/api/extensions/switch', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ + extensionName, + branch, + global: isGlobal, + }), + }); + + if (!response.ok) { + const text = await response.text(); + toastr.error(text || response.statusText, t`Extension branch switch failed`); + console.error('Extension branch switch failed', response.status, response.statusText, text); + return; + } + + toastr.success(t`Extension ${extensionName} switched to ${branch}`); + await loadExtensionSettings({}, false, false); + void showExtensionsDetails(); + } catch (error) { + console.error('Error:', error); + } +} + /** * Installs a third-party extension via the API. * @param {string} url Extension repository URL * @param {boolean} global Is the extension global? * @returns {Promise} */ -export async function installExtension(url, global) { +export async function installExtension(url, global, branch = '') { console.debug('Extension installation started', url); toastr.info(t`Please wait...`, t`Installing extension`); @@ -1072,6 +1182,7 @@ export async function installExtension(url, global) { body: JSON.stringify({ url, global, + branch, }), }); @@ -1406,9 +1517,17 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') { await popup.complete(POPUP_RESULT.AFFIRMATIVE); }, }; + /** @type {import('./popup.js').CustomPopupInput} */ + const branchNameInput = { + id: 'extension_branch_name', + label: t`Branch or tag name (optional)`, + type: 'text', + tooltip: 'e.g. main, dev, v1.0.0', + }; const customButtons = isCurrentUserAdmin ? [installForAllButton] : []; - const popup = new Popup(html, POPUP_TYPE.INPUT, suggestUrl ?? '', { okButton, customButtons }); + const customInputs = [branchNameInput]; + const popup = new Popup(html, POPUP_TYPE.INPUT, suggestUrl ?? '', { okButton, customButtons, customInputs }); const input = await popup.show(); if (!input) { @@ -1417,7 +1536,8 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') { } const url = String(input).trim(); - await installExtension(url, global); + const branchName = String(popup.inputResults.get('extension_branch_name') ?? '').trim(); + await installExtension(url, global, branchName); } export async function initExtensions() { @@ -1433,6 +1553,7 @@ export async function initExtensions() { $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick); $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick); $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick); + $(document).on('click', '.extensions_info .extension_block .btn_branch', onBranchClick); /** * Handles the click event for the third-party extension import button. diff --git a/public/scripts/extensions/assets/index.js b/public/scripts/extensions/assets/index.js index 014db82cd..c3e14fda4 100644 --- a/public/scripts/extensions/assets/index.js +++ b/public/scripts/extensions/assets/index.js @@ -291,7 +291,7 @@ async function installAsset(url, assetType, filename) { try { if (category === 'extension') { console.debug(DEBUG_PREFIX, 'Installing extension ', url); - await installExtension(url); + await installExtension(url, false); console.debug(DEBUG_PREFIX, 'Extension installed.'); return; } @@ -309,7 +309,7 @@ async function installAsset(url, assetType, filename) { console.debug(DEBUG_PREFIX, 'Importing character ', filename); const blob = await result.blob(); const file = new File([blob], filename, { type: blob.type }); - await processDroppedFiles([file], true); + await processDroppedFiles([file]); console.debug(DEBUG_PREFIX, 'Character downloaded.'); } } diff --git a/public/scripts/popup.js b/public/scripts/popup.js index aaadce1b1..3a1b03877 100644 --- a/public/scripts/popup.js +++ b/public/scripts/popup.js @@ -71,7 +71,8 @@ export const POPUP_RESULT = { * @property {string} id - The id for the html element * @property {string} label - The label text for the input * @property {string?} [tooltip=null] - Optional tooltip icon displayed behind the label - * @property {boolean?} [defaultState=false] - The default state when opening the popup (false if not set) + * @property {boolean|string|undefined} [defaultState=false] - The default state when opening the popup (false if not set) + * @property {string?} [type='checkbox'] - The type of the input (default is checkbox) */ /** @@ -157,7 +158,7 @@ export class Popup { /** @type {POPUP_RESULT|number} */ result; /** @type {any} */ value; - /** @type {Map?} */ inputResults; + /** @type {Map?} */ inputResults; /** @type {any} */ cropData; /** @type {HTMLElement} */ lastFocus; @@ -260,28 +261,53 @@ export class Popup { return; } - const label = document.createElement('label'); - label.classList.add('checkbox_label', 'justifyCenter'); - label.setAttribute('for', input.id); - const inputElement = document.createElement('input'); - inputElement.type = 'checkbox'; - inputElement.id = input.id; - inputElement.checked = input.defaultState ?? false; - label.appendChild(inputElement); - const labelText = document.createElement('span'); - labelText.innerText = input.label; - labelText.dataset.i18n = input.label; - label.appendChild(labelText); + if (!input.type || input.type === 'checkbox') { + const label = document.createElement('label'); + label.classList.add('checkbox_label', 'justifyCenter'); + label.setAttribute('for', input.id); + const inputElement = document.createElement('input'); + inputElement.type = 'checkbox'; + inputElement.id = input.id; + inputElement.checked = Boolean(input.defaultState ?? false); + label.appendChild(inputElement); + const labelText = document.createElement('span'); + labelText.innerText = input.label; + labelText.dataset.i18n = input.label; + label.appendChild(labelText); - if (input.tooltip) { - const tooltip = document.createElement('div'); - tooltip.classList.add('fa-solid', 'fa-circle-info', 'opacity50p'); - tooltip.title = input.tooltip; - tooltip.dataset.i18n = '[title]' + input.tooltip; - label.appendChild(tooltip); + if (input.tooltip) { + const tooltip = document.createElement('div'); + tooltip.classList.add('fa-solid', 'fa-circle-info', 'opacity50p'); + tooltip.title = input.tooltip; + tooltip.dataset.i18n = '[title]' + input.tooltip; + label.appendChild(tooltip); + } + + this.inputControls.appendChild(label); + } else if (input.type === 'text') { + const label = document.createElement('label'); + label.classList.add('text_label', 'justifyCenter'); + label.setAttribute('for', input.id); + + const inputElement = document.createElement('input'); + inputElement.classList.add('text_pole'); + inputElement.type = 'text'; + inputElement.id = input.id; + inputElement.value = String(input.defaultState ?? ''); + inputElement.placeholder = input.tooltip ?? ''; + + const labelText = document.createElement('span'); + labelText.innerText = input.label; + labelText.dataset.i18n = input.label; + + label.appendChild(labelText); + label.appendChild(inputElement); + + this.inputControls.appendChild(label); + } else { + console.warn('Unknown custom input type. Only checkbox and text are supported.', input); + return; } - - this.inputControls.appendChild(label); }); // Set the default button class @@ -529,7 +555,8 @@ export class Popup { this.inputResults = new Map(this.customInputs.map(input => { /** @type {HTMLInputElement} */ const inputControl = this.dlg.querySelector(`#${input.id}`); - return [inputControl.id, inputControl.checked]; + const value = input.type === 'text' ? inputControl.value : inputControl.checked; + return [inputControl.id, value]; })); } @@ -619,7 +646,7 @@ export class Popup { /** @readonly @type {Popup[]} Remember all popups */ popups: [], - /** @type {{value: any, result: POPUP_RESULT|number?, inputResults: Map?}?} Last popup result */ + /** @type {{value: any, result: POPUP_RESULT|number?, inputResults: Map?}?} Last popup result */ lastResult: null, /** @returns {boolean} Checks if any modal popup dialog is open */ diff --git a/src/endpoints/extensions.js b/src/endpoints/extensions.js index 2827d8058..6ac291b8e 100644 --- a/src/endpoints/extensions.js +++ b/src/endpoints/extensions.js @@ -30,17 +30,23 @@ async function getManifest(extensionPath) { * @returns {Promise} - Returns the extension information as an object */ async function checkIfRepoIsUpToDate(extensionPath) { - const git = simpleGit(); - await git.cwd(extensionPath).fetch('origin'); - const currentBranch = await git.cwd(extensionPath).branch(); - const currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']); - const log = await git.cwd(extensionPath).log({ + const git = simpleGit({ baseDir: extensionPath }); + await git.fetch('origin'); + const currentBranch = await git.branch(); + const currentCommitHash = await git.revparse(['HEAD']); + const log = await git.log({ from: currentCommitHash, to: `origin/${currentBranch.current}`, }); // Fetch remote repository information - const remotes = await git.cwd(extensionPath).getRemotes(true); + const remotes = await git.getRemotes(true); + if (remotes.length === 0) { + return { + isUpToDate: true, + remoteUrl: '', + }; + } return { isUpToDate: log.total === 0, @@ -76,7 +82,7 @@ router.post('/install', async (request, response) => { fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions); } - const { url, global } = request.body; + const { url, global, branch } = request.body; if (global && !request.user.profile.admin) { console.error(`User ${request.user.profile.handle} does not have permission to install global extensions.`); @@ -90,8 +96,12 @@ router.post('/install', async (request, response) => { return response.status(409).send(`Directory already exists at ${extensionPath}`); } - await git.clone(url, extensionPath, { '--depth': 1 }); - console.info(`Extension has been cloned at ${extensionPath}`); + const cloneOptions = { '--depth': 1 }; + if (branch) { + cloneOptions['--branch'] = branch; + } + await git.clone(url, extensionPath, cloneOptions); + console.info(`Extension has been cloned to ${extensionPath} from ${url} at ${branch || '(default)'} branch`); const { version, author, display_name } = await getManifest(extensionPath); @@ -114,7 +124,6 @@ router.post('/install', async (request, response) => { * @returns {void} */ router.post('/update', async (request, response) => { - const git = simpleGit(); if (!request.body.extensionName) { return response.status(400).send('Bad Request: extensionName is required in the request body.'); } @@ -128,22 +137,23 @@ router.post('/update', async (request, response) => { } const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions; - const extensionPath = path.join(basePath, extensionName); + const extensionPath = path.join(basePath, sanitize(extensionName)); if (!fs.existsSync(extensionPath)) { return response.status(404).send(`Directory does not exist at ${extensionPath}`); } const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath); - const currentBranch = await git.cwd(extensionPath).branch(); + const git = simpleGit({ baseDir: extensionPath }); + const currentBranch = await git.branch(); if (!isUpToDate) { - await git.cwd(extensionPath).pull('origin', currentBranch.current); + await git.pull('origin', currentBranch.current); console.info(`Extension has been updated at ${extensionPath}`); } else { console.info(`Extension is up to date at ${extensionPath}`); } - await git.cwd(extensionPath).fetch('origin'); - const fullCommitHash = await git.cwd(extensionPath).revparse(['HEAD']); + await git.fetch('origin'); + const fullCommitHash = await git.revparse(['HEAD']); const shortCommitHash = fullCommitHash.slice(0, 7); return response.send({ shortCommitHash, extensionPath, isUpToDate, remoteUrl }); @@ -154,6 +164,110 @@ router.post('/update', async (request, response) => { } }); +router.post('/branches', async (request, response) => { + try { + const { extensionName, global } = request.body; + + if (!extensionName) { + return response.status(400).send('Bad Request: extensionName is required in the request body.'); + } + + if (global && !request.user.profile.admin) { + console.error(`User ${request.user.profile.handle} does not have permission to list branches of global extensions.`); + return response.status(403).send('Forbidden: No permission to list branches of global extensions.'); + } + + const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions; + const extensionPath = path.join(basePath, sanitize(extensionName)); + + if (!fs.existsSync(extensionPath)) { + return response.status(404).send(`Directory does not exist at ${extensionPath}`); + } + + const git = simpleGit({ baseDir: extensionPath }); + // Unshallow the repository if it is shallow + const isShallow = await git.revparse(['--is-shallow-repository']) === 'true'; + if (isShallow) { + console.info(`Unshallowing the repository at ${extensionPath}`); + await git.fetch('origin', ['--unshallow']); + } + + // Fetch all branches + await git.remote(['set-branches', 'origin', '*']); + await git.fetch('origin'); + const localBranches = await git.branchLocal(); + const remoteBranches = await git.branch(['-r', '--list', 'origin/*']); + const result = [ + ...Object.values(localBranches.branches), + ...Object.values(remoteBranches.branches), + ].map(b => ({ current: b.current, commit: b.commit, name: b.name, label: b.label })); + + return response.send(result); + } catch (error) { + console.error('Getting branches failed', error); + return response.status(500).send('Internal Server Error. Check the server logs for more details.'); + } +}); + +router.post('/switch', async (request, response) => { + try { + const { extensionName, branch, global } = request.body; + + if (!extensionName || !branch) { + return response.status(400).send('Bad Request: extensionName and branch are required in the request body.'); + } + + if (global && !request.user.profile.admin) { + console.error(`User ${request.user.profile.handle} does not have permission to switch branches of global extensions.`); + return response.status(403).send('Forbidden: No permission to switch branches of global extensions.'); + } + + const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions; + const extensionPath = path.join(basePath, sanitize(extensionName)); + + if (!fs.existsSync(extensionPath)) { + return response.status(404).send(`Directory does not exist at ${extensionPath}`); + } + + const git = simpleGit({ baseDir: extensionPath }); + const branches = await git.branchLocal(); + + if (String(branch).startsWith('origin/')) { + const localBranch = branch.replace('origin/', ''); + if (branches.all.includes(localBranch)) { + console.info(`Branch ${localBranch} already exists locally, checking it out`); + await git.checkout(localBranch); + return response.sendStatus(204); + } + + console.info(`Branch ${localBranch} does not exist locally, creating it from ${branch}`); + await git.checkoutBranch(localBranch, branch); + return response.sendStatus(204); + } + + if (!branches.all.includes(branch)) { + console.error(`Branch ${branch} does not exist locally`); + return response.status(404).send(`Branch ${branch} does not exist locally`); + } + + // Check if the branch is already checked out + const currentBranch = await git.branch(); + if (currentBranch.current === branch) { + console.info(`Branch ${branch} is already checked out`); + return response.sendStatus(204); + } + + // Checkout the branch + await git.checkout(branch); + console.info(`Checked out branch ${branch} at ${extensionPath}`); + + return response.sendStatus(204); + } catch (error) { + console.error('Switching branches failed', error); + return response.status(500).send('Internal Server Error. Check the server logs for more details.'); + } +}); + router.post('/move', async (request, response) => { try { const { extensionName, source, destination } = request.body; @@ -194,7 +308,7 @@ router.post('/move', async (request, response) => { return response.sendStatus(204); } catch (error) { console.error('Moving extension failed', error); - return response.status(500).send('Internal Server Error. Try again later.'); + return response.status(500).send('Internal Server Error. Check the server logs for more details.'); } }); @@ -209,7 +323,6 @@ router.post('/move', async (request, response) => { * @returns {void} */ router.post('/version', async (request, response) => { - const git = simpleGit(); if (!request.body.extensionName) { return response.status(400).send('Bad Request: extensionName is required in the request body.'); } @@ -223,19 +336,20 @@ router.post('/version', async (request, response) => { return response.status(404).send(`Directory does not exist at ${extensionPath}`); } + const git = simpleGit({ baseDir: extensionPath }); let currentCommitHash; try { - currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']); + currentCommitHash = await git.revparse(['HEAD']); } catch (error) { // it is not a git repo, or has no commits yet, or is a bare repo // not possible to update it, most likely can't get the branch name either return response.send({ currentBranchName: '', currentCommitHash: '', isUpToDate: true, remoteUrl: '' }); } - const currentBranch = await git.cwd(extensionPath).branch(); + const currentBranch = await git.branch(); // get only the working branch const currentBranchName = currentBranch.current; - await git.cwd(extensionPath).fetch('origin'); + await git.fetch('origin'); console.debug(extensionName, currentBranchName, currentCommitHash); const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath);