diff --git a/docs/sandbox-cli.md b/docs/sandbox-cli.md index 72f16dbf..436652db 100644 --- a/docs/sandbox-cli.md +++ b/docs/sandbox-cli.md @@ -332,6 +332,49 @@ On Windows, quote external paths containing spaces: .\bin\qi.cmd style:mount demo "C:\Path\To\My Styles\stylename" --allow-external ``` +## Languages + +Put downloaded language packs under `customisations/`: + +```text +customisations/de/iso.txt +``` + +phpBB 4.x packs use `extra.language-iso` in `composer.json`; their source folder name does not need to match the ISO: + +```text +customisations/german/composer.json +``` + +Mount one language pack or recursively discover all language packs: + +```bash +php bin/qi lang:mount demo customisations/de +php bin/qi lang:mount demo customisations --recursive +``` + +QuickInstall bind-mounts the pack using its validated ISO code: + +```text +/var/www/html/language/de +``` + +List and unmount language packs: + +```bash +php bin/qi lang:list demo +php bin/qi lang:unmount demo de +``` + +Unmounting an installed language resets its users to the board default and removes its phpBB database records before removing files. The default language cannot be unmounted. + +Copy and trusted external paths work like extension and style mounts: + +```bash +php bin/qi lang:mount demo customisations/de --copy +php bin/qi lang:mount demo /path/to/de --allow-external +``` + ## Supported phpBB Versions Show supported selectors: @@ -486,7 +529,7 @@ customisations/ - Board web ports bind to `127.0.0.1`, not every network interface. - `board:create` refuses to overwrite an existing board unless `--replace` is used. - `board:create` rejects ports already registered to another board or already in use on the host. -- `ext:mount` and `style:mount` only use `customisations/` unless `--allow-external` is used. +- `ext:mount`, `style:mount`, and `lang:mount` only use `customisations/` unless `--allow-external` is used. - Custom Git source URLs require `--allow-external`; only use trusted forks. - The Dashboard UI server only accepts loopback hosts (`127.0.0.1`, `localhost`, or `::1`) and rejects non-local requests. - `ui:start` refuses ports already in use on the selected loopback host. diff --git a/public/assets/sandbox-ui.css b/public/assets/sandbox-ui.css index f95180ac..31566bea 100644 --- a/public/assets/sandbox-ui.css +++ b/public/assets/sandbox-ui.css @@ -8,6 +8,7 @@ --border: #d0d7de; --border-muted: #d8dee4; --row: #f6f8fa; + --details-bg: #dff3ff; --accent: #009bdf; --accent-hover: #007caf; --success: #1a7f37; @@ -27,8 +28,6 @@ --input-bg: #fff; --toggle-bg: #eaeef2; --chrome: #fff; - --brand-bg: #24292f; - --brand-text: #fff; --sidebar-bg: #161b22; --sidebar-border: #30363d; --sidebar-text: #e6edf3; @@ -60,6 +59,7 @@ --border: #30363d; --border-muted: #30363d; --row: #161b22; + --details-bg: #24292f; --accent: #009bdf; --accent-hover: #26b4f2; --success: #3fb950; @@ -78,8 +78,6 @@ --input-bg: #0d1117; --toggle-bg: #30363d; --chrome: #161b22; - --brand-bg: #0d1117; - --brand-text: #e6edf3; --sidebar-bg: #161b22; --sidebar-border: #30363d; --sidebar-text: #e6edf3; @@ -237,28 +235,25 @@ button:disabled { opacity: .65; cursor: wait; } } .main { min-width: 0; } .topbar { + background: var(--chrome); + border-bottom: 1px solid var(--border-muted); +} +.topbar-inner { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; + width: 100%; + max-width: 1220px; + margin: 0 auto; padding: 24px 32px; - background: var(--chrome); - border-bottom: 1px solid var(--border-muted); } .lede { margin-top: 4px; color: var(--muted); max-width: 680px; } .content { max-width: 1220px; margin: 0 auto; padding-bottom: 40px; } -.eyebrow { - margin-bottom: 3px; - color: var(--subtle); - font-size: 12px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: .05em; -} .status-strip { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; padding: 24px 32px 0; } @@ -318,7 +313,6 @@ button:disabled { opacity: .65; cursor: wait; } transition: opacity .18s ease, transform .18s ease, margin .18s ease, padding .18s ease, border-width .18s ease; } .content > .action-result { margin: 16px 32px 0; } -.board .action-result { margin: 12px 16px 0; } .action-result.is-dismissing { margin-top: 0; margin-bottom: 0; @@ -339,35 +333,105 @@ button:disabled { opacity: .65; cursor: wait; } border-bottom: 1px solid var(--border-muted); margin-bottom: 16px; } -.section-head p:not(.eyebrow) { margin-top: 4px; color: var(--muted); max-width: 760px; } +.section-head p { margin-top: 4px; color: var(--muted); max-width: 760px; } .section-head h2 { display: flex; align-items: center; gap: 8px; } .section-head h2 .icon { color: var(--accent); } -.board-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: 16px; } -.board { - container-name: board-card; +.board-manager { + container-name: board-manager; container-type: inline-size; padding: 0; + overflow: hidden; } -.card-head { +.board-manager-head { display: flex; + align-items: end; justify-content: space-between; gap: 16px; - padding: 16px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-muted); +} +.board-manager-title { + display: flex; + align-items: baseline; + gap: 12px; +} +.board-manager-title span { color: var(--muted); font-size: 12px; } +.field.board-search { + display: flex; + align-items: center; + width: min(360px, 100%); + gap: 8px; +} +.board-search > span { flex: 0 0 auto; } +.board-search > input { flex: 1 1 auto; min-width: 0; } +.board-table-head, +.board-row-main { + display: grid; + grid-template-columns: minmax(190px, 1.1fr) minmax(270px, 1.5fr) 156px 216px; + gap: 12px; + align-items: center; +} +.board-table-head { + padding: 8px 16px; + background: var(--row); border-bottom: 1px solid var(--border-muted); + color: var(--muted); + font-size: 12px; + font-weight: 600; +} +.board-fact-headings { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} +.board-row { + container-name: board-card; + container-type: inline-size; + border-top: 1px solid var(--border-muted); +} +.board-row:first-child { border-top: 0; } +.board-row[hidden], +.board-details[hidden] { display: none; } +.board-row-main { + cursor: pointer; + padding: 12px 16px; + transition: background .12s ease; +} +.board-row-main:hover { background: var(--table-hover); } +.board-row.is-expanded > .board-row-main { + background: var(--table-hover); + box-shadow: inset 3px 0 var(--accent); +} +.board-identity, +.board-facts, +.board-customisations { min-width: 0; } +.board-identity { + display: grid; + grid-template-columns: 40px minmax(0, 1fr); + align-items: center; + gap: 8px; +} +.board-identity-copy { min-width: 0; } +.board-identity h3 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.card-head .url { +.board-identity .url { margin-top: 3px; color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - max-width: 260px; } -.card-head .url a { color: var(--accent); font-weight: 600; } +.board-identity .url a { color: var(--accent); font-weight: 600; } .badge { display: inline-flex; + flex: 0 0 auto; align-items: center; + justify-content: center; + min-width: 68px; height: 22px; padding: 0 8px; border-radius: 999px; @@ -381,25 +445,90 @@ button:disabled { opacity: .65; cursor: wait; } .status-error, .status-missing { background: var(--danger-bg); color: var(--danger); } .status-partial { background: var(--warning-bg); color: var(--warning); } -.facts { +.board-facts, +.board-customisations { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; - padding: 12px 16px; - gap: 0; - border-bottom: 1px solid var(--border-muted); + min-width: 0; } -.facts div { min-width: 0; padding-right: 12px; } -.facts dt { color: var(--subtle); font-size: 12px; } -.facts dd { - margin: 2px 0 0; - font-weight: 600; +.board-facts { + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} +.board-facts div, +.board-customisations div { min-width: 0; } +.board-facts dt, +.board-customisations dt { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; + color: var(--subtle); + font-size: 11px; +} +.board-facts dd, +.board-customisations dd { + margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.board-customisation-counts { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 12px; +} +.board-customisation-counts strong { color: var(--text); } .actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } -.compact { padding: 12px 16px; border-bottom: 1px solid var(--border-muted); } +.board-actions-cell { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: 0; +} +.board-actions { justify-content: flex-end; flex-wrap: nowrap; } +.board-lifecycle-button { min-width: 58px; } +.board-destroy-button { min-width: 72px; } +.board-disclosure { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + border-color: transparent; + background: transparent; + box-shadow: none; +} +.board-disclosure:hover { background: var(--button-hover); } +@media (pointer: coarse) { + .board-identity { grid-template-columns: 44px minmax(0, 1fr); } + .board-disclosure { width: 44px; height: 44px; } +} +.board-toggle-icon { + display: block; + flex: 0 0 auto; + width: 8px; + height: 8px; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(45deg); + transform-origin: center; +} +.board-disclosure[aria-expanded="true"] .board-toggle-icon { transform: rotate(225deg); } +.board-details { + background: var(--details-bg); + border-top: 1px solid var(--border-muted); +} .subsection-title { padding: 12px 16px 0; color: var(--muted); @@ -414,9 +543,9 @@ button:disabled { opacity: .65; cursor: wait; } gap: 10px; align-items: end; padding: 10px 16px 16px; - border-bottom: 1px solid var(--border-muted); } -.mounted-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0; } +.seed-title { border-top: 1px solid var(--border-muted); } +.mounted-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0; } .mounted { padding: 14px 16px; } .mounted + .mounted { border-left: 1px solid var(--border-muted); } .mounted h4 { @@ -455,7 +584,7 @@ button:disabled { opacity: .65; cursor: wait; } background: transparent; text-decoration: underline; } -.row { +.mounted-list .row { display: flex; align-items: center; justify-content: space-between; @@ -463,18 +592,24 @@ button:disabled { opacity: .65; cursor: wait; } padding: 8px 0; border-top: 1px solid var(--border-muted); } -.row:first-of-type { border-top: 0; } -.row span { min-width: 0; } -.row strong, -.row small { +.mounted-list .row:first-of-type { border-top: 0; } +.mounted-list .row span { min-width: 0; } +.mounted-list .row strong, +.mounted-list .row small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.row small { color: var(--subtle); font-size: 12px; } +.mounted-list .row small { color: var(--subtle); font-size: 12px; } +.board-no-results { padding: 24px 16px; color: var(--muted); text-align: center; } + +@container board-card (max-width: 760px) { + .seed-form { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} @container board-card (max-width: 560px) { + .seed-form, .mounted-grid { grid-template-columns: 1fr; } .mounted + .mounted { border-top: 1px solid var(--border-muted); @@ -482,19 +617,50 @@ button:disabled { opacity: .65; cursor: wait; } } } +@container board-manager (max-width: 900px) { + .board-table-head { display: none; } + .board-row-main { grid-template-columns: minmax(0, 1fr) max-content; align-items: start; } + .board-identity { grid-column: 1; grid-row: 1; } + .board-actions-cell { grid-column: 2; grid-row: 1; } + .board-facts { grid-column: 1; grid-row: 2; } + .board-customisations { grid-column: 2; grid-row: 2; } + .board-facts dt, + .board-customisations dt { + position: static; + width: auto; + height: auto; + margin: 0; + overflow: visible; + clip: auto; + clip-path: none; + white-space: normal; + } + .board-facts dd, + .board-customisations dd { margin-top: 2px; } +} + +@container board-manager (max-width: 620px) { + .board-manager-head { align-items: stretch; flex-direction: column; } + .field.board-search { width: 100%; } + .board-row-main { grid-template-columns: 1fr; } + .board-identity, + .board-facts, + .board-customisations, + .board-actions-cell { grid-column: 1; grid-row: auto; } + .board-actions-cell, + .board-actions { justify-content: flex-start; } + .board-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); row-gap: 8px; } +} + .settings-form { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; padding: 16px; } -.settings-form h3 { grid-column: 1 / -1; } -.compact-form { grid-template-columns: repeat(4, minmax(0, 1fr)); } -.stack-form { grid-template-columns: 1fr 1fr; } -.stack-form .source-field { grid-column: 1 / -1; } -.split { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.settings-form > .source-field { grid-column: span 2; } .form-actions { grid-column: 1 / -1; display: flex; justify-content: flex-end; padding-top: 4px; } -.field { display: grid; gap: 6px; color: var(--text); font-weight: 600; } +.field { display: grid; align-content: start; gap: 6px; color: var(--text); font-weight: 600; } .field span { font-size: 13px; } .field small { color: var(--muted); @@ -518,7 +684,7 @@ button:disabled { opacity: .65; cursor: wait; } border-radius: var(--radius); background: var(--input-bg); color: var(--text); - padding: 5px 8px;; + padding: 5px 8px; font-size: 14px; line-height: 20px; font-family: inherit; @@ -565,10 +731,16 @@ button:disabled { opacity: .65; cursor: wait; } .toggle input:focus-visible + span { box-shadow: var(--focus); } .table-wrap { + overflow-x: auto; border: 1px solid var(--border-muted); border-radius: var(--radius); background: var(--panel); } +.table-wrap:focus-visible { + outline: none; + border-color: var(--accent); + box-shadow: var(--focus); +} #sources .joined-form { border-radius: var(--radius) var(--radius) 0 0; } #sources .joined-form + .table-wrap, #sources .joined-form + .empty { @@ -580,6 +752,7 @@ table { table-layout: fixed; border-collapse: collapse; } +#sources table { min-width: 820px; } th, td { padding: 9px 12px; @@ -710,11 +883,10 @@ tr:last-child td { border-bottom: 0; } .sidebar nav { display: flex; overflow: auto; } .sidebar a { white-space: nowrap; } .status-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .split { grid-template-columns: 1fr; } } @media (max-width: 760px) { - .topbar { flex-direction: column; padding: 20px; } + .topbar-inner { flex-direction: column; padding: 20px; } .content { max-width: none; } .section, .status-strip, @@ -730,15 +902,21 @@ tr:last-child td { border-bottom: 0; } margin-left: 20px; margin-right: 20px; } - .board-grid { grid-template-columns: 1fr; } .settings-form, - .compact-form, - .stack-form, .seed-form { grid-template-columns: 1fr; } - .facts { grid-template-columns: repeat(2, minmax(0, 1fr)); row-gap: 10px; } .mounted-grid { grid-template-columns: 1fr; } .mounted + .mounted { border-left: 0; border-top: 1px solid var(--border-muted); } } + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + .action-result, + .board-row-main, + .toggle span, + .toggle span:before { transition: none; } + .progress span, + .spinner { animation: none; } +} diff --git a/public/assets/sandbox-ui.js b/public/assets/sandbox-ui.js index e355577a..339d4b1b 100644 --- a/public/assets/sandbox-ui.js +++ b/public/assets/sandbox-ui.js @@ -3,6 +3,8 @@ const busy = document.getElementById('busy'); const pendingActions = new Map(); const activityEntries = []; const maxActivityEntries = 50; +const expandedBoards = new Set(); +let boardFilter = ''; let nextActionId = 1; function updateProcessingState() { @@ -11,12 +13,133 @@ function updateProcessingState() { document.documentElement.classList.toggle('is-processing', active); } -function sameAction(left, right) { - return left.action === right.action - && left.board === right.board - && left.section === right.section - && left.name === right.name - && left.source === right.source; +function sameForm(left, right) { + return left.formKey === right.formKey; +} + +/** Captures a stable board or section header before dashboard markup changes. */ +function snapshotViewport(context) { + let kind = 'status'; + let anchor = null; + if (context.board) { + anchor = boardHeader(context.board); + kind = 'board'; + } + if (!anchor && context.section) { + const section = document.getElementById(context.section); + anchor = section ? section.querySelector('.section-head') : null; + kind = 'section'; + } + if (!anchor) { + anchor = dashboard.querySelector('.status-strip'); + kind = 'status'; + } + + return { + kind, + top: anchor ? anchor.getBoundingClientRect().top : null, + scrollY: window.scrollY, + }; +} + +/** Keeps the same stable header at the same viewport position after replacement. */ +function restoreViewport(context, snapshot) { + let anchor = null; + if (snapshot.kind === 'board') { + anchor = boardHeader(context.board); + } else if (snapshot.kind === 'section') { + const section = document.getElementById(context.section); + anchor = section ? section.querySelector('.section-head') : null; + } else { + anchor = dashboard.querySelector('.status-strip'); + } + + let target = snapshot.scrollY; + if (anchor && snapshot.top !== null) { + target = window.scrollY + anchor.getBoundingClientRect().top - snapshot.top; + } + + const previousBehavior = document.documentElement.style.scrollBehavior; + document.documentElement.style.scrollBehavior = 'auto'; + window.scrollTo(0, Math.max(0, target)); + document.documentElement.style.scrollBehavior = previousBehavior; +} + +function filterBoards(manager, query) { + const normalized = query.trim().toLocaleLowerCase(); + const rows = Array.from(manager.querySelectorAll('[data-board-row]')); + rows.forEach((row) => { + row.hidden = normalized !== '' && !row.textContent.toLocaleLowerCase().includes(normalized); + }); + + const visible = rows.filter((row) => !row.hidden); + const noResults = manager.querySelector('[data-board-no-results]'); + if (noResults) { + noResults.hidden = visible.length !== 0; + } +} + +function setBoardExpanded(row, toggle, details, expanded) { + toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + details.hidden = !expanded; + row.classList.toggle('is-expanded', expanded); + if (expanded) { + expandedBoards.add(row.dataset.board); + } else { + expandedBoards.delete(row.dataset.board); + } +} + +function bindBoardManager() { + const manager = dashboard.querySelector('[data-board-manager]'); + if (!manager || manager.dataset.bound) { + return; + } + + manager.dataset.bound = '1'; + const rows = Array.from(manager.querySelectorAll('[data-board-row]')); + const filter = manager.querySelector('[data-board-filter]'); + const boardNames = new Set(rows.map((row) => row.dataset.board)); + expandedBoards.forEach((name) => { + if (!boardNames.has(name)) { + expandedBoards.delete(name); + } + }); + + rows.forEach((row) => { + const toggle = row.querySelector('[data-board-toggle]'); + const details = row.querySelector('[data-board-details]'); + const surface = row.querySelector('.board-row-main'); + if (!toggle || !details || !surface) { + return; + } + + setBoardExpanded(row, toggle, details, expandedBoards.has(row.dataset.board)); + toggle.addEventListener('click', () => { + const shouldExpand = toggle.getAttribute('aria-expanded') !== 'true'; + setBoardExpanded(row, toggle, details, shouldExpand); + }); + surface.addEventListener('click', (event) => { + if (event.target.closest('a, button, input, select, textarea, label, form')) { + return; + } + const selection = window.getSelection(); + if (selection && !selection.isCollapsed && selection.toString().trim() !== '') { + return; + } + + setBoardExpanded(row, toggle, details, toggle.getAttribute('aria-expanded') !== 'true'); + }); + }); + + if (filter) { + filter.value = boardFilter; + filter.addEventListener('input', () => { + boardFilter = filter.value; + filterBoards(manager, boardFilter); + }); + filterBoards(manager, boardFilter); + } } /** Captures user-editable controls before dashboard markup is replaced. */ @@ -41,14 +164,8 @@ function snapshotForm(form) { /** Finds the regenerated form without relying on mutable field values. */ function actionForm(context) { - const candidates = Array.from(dashboard.querySelectorAll('form[data-ajax]')).filter((form) => { - const candidate = actionContext(form); - return candidate.action === context.action - && candidate.board === context.board - && candidate.section === context.section; - }); - - return candidates.find((form) => sameAction(actionContext(form), context)) || (candidates.length === 1 ? candidates[0] : null); + return Array.from(dashboard.querySelectorAll('form[data-ajax]')) + .find((form) => sameForm(actionContext(form), context)) || null; } /** Restores submitted values after an error response regenerates the dashboard. */ @@ -79,8 +196,7 @@ function restoreForm(context, snapshot) { /** Reapplies pending state after AJAX replaces dashboard markup. */ function syncPendingActions() { pendingActions.forEach((pending) => { - const form = Array.from(dashboard.querySelectorAll('form[data-ajax]')) - .find((candidate) => sameAction(actionContext(candidate), pending.context)); + const form = actionForm(pending.context); const submitter = form ? form.querySelector('button[type="submit"], button:not([type]), input[type="submit"]') : null; if (!submitter) { return; @@ -97,8 +213,23 @@ function syncPendingActions() { function actionLabel(context) { const action = context.action.replaceAll('_', ' ').replace(/^./, (character) => character.toUpperCase()); - const target = context.board || context.name || context.source; - return target ? action + ' — ' + target : action; + const details = [context.type, context.targetBoard, context.name, context.source] + .filter((value, index, values) => value && values.indexOf(value) === index); + + if (context.action === 'customisation_mount') { + if (context.copy) { + details.push('copy'); + } else if (context.recursive) { + details.push('recursive'); + } else { + details.push('bind'); + } + if (context.allowExternal) { + details.push('external path'); + } + } + + return details.length ? action + ' — ' + details.join(' · ') : action; } function addActivityEntry(actionId, context) { @@ -177,6 +308,7 @@ function renderActivityLog() { } function bindAjax() { + bindBoardManager(); bindUpdateBanner(); bindMountedLists(); @@ -193,6 +325,7 @@ function bindAjax() { } const context = actionContext(form); + const viewportSnapshot = snapshotViewport(context); const formSnapshot = snapshotForm(form); const submitter = event.submitter; const original = submitter ? submitter.innerHTML : ''; @@ -234,10 +367,14 @@ function bindAjax() { syncPendingActions(); } showActionResult(data, context); + restoreViewport(context, viewportSnapshot); scrollLog(); } catch (error) { - completeActivityEntry(actionId, { error: 'Request failed: ' + error.message, output: '' }); - alert('Request failed: ' + error.message); + const message = 'Request failed: ' + error.message; + completeActivityEntry(actionId, { error: message, output: '' }); + showActionResult({ error: message }, context); + restoreViewport(context, viewportSnapshot); + scrollLog(); } finally { const pending = pendingActions.get(actionId); pendingActions.delete(actionId); @@ -316,17 +453,44 @@ function bindUpdateBanner() { }); } +/** Builds an immutable key for locating the same form after dashboard replacement. */ +function actionFormKey(form, formData) { + const action = formData.get('action') || ''; + const section = form.closest('section'); + const board = form.closest('[data-board]'); + let repeatedTarget = ''; + if (['ext_unmount', 'style_unmount', 'lang_unmount'].includes(action)) { + repeatedTarget = formData.get('name') || ''; + } else if (action === 'source_remove') { + repeatedTarget = formData.get('source') || ''; + } + + return JSON.stringify([ + section ? section.id : '', + board ? board.dataset.board : '', + action, + repeatedTarget, + ]); +} + function actionContext(form) { const formData = new FormData(form); const section = form.closest('section'); const board = form.closest('[data-board]'); + const contextualBoard = board ? board.dataset.board : ''; return { action: formData.get('action') || '', - board: board ? board.dataset.board : '', + board: contextualBoard, + targetBoard: formData.get('board') || contextualBoard, name: formData.get('name') || '', section: section ? section.id : '', source: formData.get('source') || '', + type: formData.get('type') || '', + copy: formData.has('copy'), + recursive: formData.has('recursive'), + allowExternal: formData.has('allow_external'), + formKey: actionFormKey(form, formData), }; } @@ -347,10 +511,7 @@ function showActionResult(data, context) { result.textContent = message; let target = null; - if (context.board) { - target = boardHeader(context.board); - } - if (!target && context.section) { + if (context.section) { const section = document.getElementById(context.section); target = section ? section.querySelector('.section-head') : null; } @@ -375,7 +536,7 @@ function boardHeader(name) { const boards = dashboard.querySelectorAll('[data-board]'); for (const board of boards) { if (board.dataset.board === name) { - return board.querySelector('.card-head'); + return board.querySelector('.board-row-main'); } } diff --git a/src/QuickInstall/Sandbox/Application.php b/src/QuickInstall/Sandbox/Application.php index 35111eea..9f9ae522 100644 --- a/src/QuickInstall/Sandbox/Application.php +++ b/src/QuickInstall/Sandbox/Application.php @@ -114,6 +114,15 @@ public function run(array $argv): int case 'style:list': return $this->styleList($argv); + case 'lang:mount': + return $this->langMount($argv); + + case 'lang:unmount': + return $this->langUnmount($argv); + + case 'lang:list': + return $this->langList($argv); + case 'ui:start': return $this->uiStart($argv); @@ -156,7 +165,7 @@ private function mutatesWorkspace(string $command): bool return in_array($command, [ 'init', 'source:fetch', 'source:remove', 'source:prune', 'board:create', 'board:start', 'board:stop', 'board:destroy', 'board:seed', - 'ext:mount', 'ext:unmount', 'style:mount', 'style:unmount', + 'ext:mount', 'ext:unmount', 'style:mount', 'style:unmount', 'lang:mount', 'lang:unmount', 'ui:start', 'ui:stop', 'ui:restart', ], true); } @@ -832,6 +841,65 @@ private function styleList(array $args): int return 0; } + private function langMount(array $args): int + { + $cli = CommandLine::parse($args); + $board = $cli->argument(0); + $source = $cli->argument(1); + if ($board === null || $source === null) + { + throw new InvalidArgumentException('Usage: qi lang:mount [--copy] [--recursive] [--allow-external]'); + } + if ($cli->has('recursive') && $cli->has('copy')) + { + throw new InvalidArgumentException('--recursive cannot be combined with --copy. Mount recursively with bind mode, or copy individual languages.'); + } + + return $this->mountResources('language', new LanguageManager($this->project), $board, $source, $cli->has('copy'), $cli->has('recursive'), $cli->has('allow-external')); + } + + private function langUnmount(array $args): int + { + $cli = CommandLine::parse($args); + $board = $cli->argument(0); + $name = $cli->argument(1); + if ($board === null || $name === null) + { + throw new InvalidArgumentException('Usage: qi lang:unmount '); + } + + $languages = new LanguageManager($this->project); + $target = (new CustomisationUnmountService($this->project))->language($languages, $board, $name); + echo "Unmounted $name from $board\n"; + echo "Removed: $target\n"; + return 0; + } + + private function langList(array $args): int + { + $board = $this->boardName($args, 'Usage: qi lang:list '); + $mounted = (new LanguageManager($this->project))->list($board); + + if (!$mounted) + { + echo "No languages mounted for board: $board\n"; + return 0; + } + + $this->printTable( + ['Language', 'Mode', 'Source'], + array_map(static function ($language) { + return [ + $language['name'], + $language['mode'], + $language['source'], + ]; + }, $mounted) + ); + + return 0; + } + private function printBulkMountResult(string $type, string $board, array $mounted, array $errors): void { if (!$mounted && !$errors) @@ -1169,6 +1237,52 @@ private function helpCommands(): array ], ], ], + 'Language commands' => [ + 'lang:mount' => [ + 'title' => 'lang:mount', + 'usage' => 'lang:mount [--copy] [--recursive] [--allow-external]', + 'summary' => 'Mount one or more language packs into a board.', + 'description' => 'Mounts a phpBB 3.x or 4.x language pack from the customisations drop zone. Running boards are refreshed automatically.', + 'arguments' => [ + '' => 'Required board name.', + '' => 'Language-pack path, or a directory to scan when --recursive is used.', + ], + 'options' => [ + '--copy' => 'Copy one language pack instead of bind-mounting it.', + '--recursive' => 'Find and bind-mount all language packs below . Cannot be combined with --copy.', + '--allow-external' => 'Allow trusted paths outside the customisations drop zone.', + ], + 'examples' => [ + 'lang:mount demo customisations/de', + 'lang:mount demo customisations --recursive', + ], + ], + 'lang:unmount' => [ + 'title' => 'lang:unmount', + 'usage' => 'lang:unmount ', + 'summary' => 'Remove a mounted language pack from a board.', + 'description' => 'Uninstalls the language from phpBB before removing its mount or copied files. Installed boards must be running.', + 'arguments' => [ + '' => 'Required board name.', + '' => 'Language ISO from the folder name or composer.json metadata.', + ], + 'examples' => [ + 'lang:unmount demo de', + ], + ], + 'lang:list' => [ + 'title' => 'lang:list', + 'usage' => 'lang:list ', + 'summary' => 'Show language packs mounted on a board.', + 'description' => 'Lists mounted language packs, mount mode, and source path for one board.', + 'arguments' => [ + '' => 'Required board name.', + ], + 'examples' => [ + 'lang:list demo', + ], + ], + ], 'UI commands' => [ 'ui:start' => [ 'title' => 'ui:start', diff --git a/src/QuickInstall/Sandbox/BoardRefreshService.php b/src/QuickInstall/Sandbox/BoardRefreshService.php index 87452cd5..c03cb493 100644 --- a/src/QuickInstall/Sandbox/BoardRefreshService.php +++ b/src/QuickInstall/Sandbox/BoardRefreshService.php @@ -50,6 +50,7 @@ private function runtimeConfig(array $board): array 'debug' => false, 'extensions' => [], 'styles' => [], + 'languages' => [], ]; } } diff --git a/src/QuickInstall/Sandbox/BoardRunner.php b/src/QuickInstall/Sandbox/BoardRunner.php index aee8613b..2d89a1ad 100644 --- a/src/QuickInstall/Sandbox/BoardRunner.php +++ b/src/QuickInstall/Sandbox/BoardRunner.php @@ -174,6 +174,15 @@ public function uninstallStyle(string $board, string $name): void $this->run(['docker', 'compose', '-f', $compose, 'exec', '-T', 'web', 'php', '/tmp/qi_style_uninstall.php', $name]); } + /** Removes an installed language from phpBB before its files disappear. */ + public function uninstallLanguage(string $board, string $name): void + { + $script = (new LanguageUninstallerWriter($this->project))->write($board); + $compose = $this->project->composePath($board); + $this->run(['docker', 'compose', '-f', $compose, 'cp', $script, 'web:/tmp/qi_language_uninstall.php']); + $this->run(['docker', 'compose', '-f', $compose, 'exec', '-T', 'web', 'php', '/tmp/qi_language_uninstall.php', $name]); + } + public function recreateWeb(string $name): void { $this->project->board($name); diff --git a/src/QuickInstall/Sandbox/BoardService.php b/src/QuickInstall/Sandbox/BoardService.php index 5b775c00..7100f262 100644 --- a/src/QuickInstall/Sandbox/BoardService.php +++ b/src/QuickInstall/Sandbox/BoardService.php @@ -99,6 +99,7 @@ public function create(string $name, string $version = 'latest', string $db = 'm 'board_timezone' => $this->hostTimezone(), 'extensions' => [], 'styles' => [], + 'languages' => [], ]; $backups = []; if ($existingName !== null) @@ -138,6 +139,7 @@ public function create(string $name, string $version = 'latest', string $db = 'm 'board_timezone' => $config['board_timezone'], 'extensions' => [], 'styles' => [], + 'languages' => [], 'created_at' => gmdate('c'), ]; diff --git a/src/QuickInstall/Sandbox/CustomisationMountService.php b/src/QuickInstall/Sandbox/CustomisationMountService.php index bcf0170e..3a69d5a3 100644 --- a/src/QuickInstall/Sandbox/CustomisationMountService.php +++ b/src/QuickInstall/Sandbox/CustomisationMountService.php @@ -13,7 +13,7 @@ use InvalidArgumentException; use RuntimeException; -/** Coordinates single or recursive extension/style mounts and board refreshes. */ +/** Coordinates single or recursive customisation mounts and board refreshes. */ class CustomisationMountService { private Project $project; diff --git a/src/QuickInstall/Sandbox/CustomisationUnmountService.php b/src/QuickInstall/Sandbox/CustomisationUnmountService.php index a1d16072..e3f2c65c 100644 --- a/src/QuickInstall/Sandbox/CustomisationUnmountService.php +++ b/src/QuickInstall/Sandbox/CustomisationUnmountService.php @@ -12,7 +12,7 @@ use RuntimeException; -/** Cleans phpBB state before removing extension or style mounts. */ +/** Cleans phpBB state before removing customisation mounts. */ class CustomisationUnmountService { private Project $project; @@ -44,6 +44,15 @@ public function style(StyleManager $manager, string $board, string $name): strin return $this->remove($manager, $board, $name); } + public function language(LanguageManager $manager, string $board, string $name): string + { + $this->cleanInstalledBoard($board, static function (BoardRunner $runner) use ($board, $name): void { + $runner->uninstallLanguage($board, $name); + }); + + return $this->remove($manager, $board, $name); + } + private function cleanInstalledBoard(string $board, callable $cleanup): void { $this->project->board($board); diff --git a/src/QuickInstall/Sandbox/DockerComposeWriter.php b/src/QuickInstall/Sandbox/DockerComposeWriter.php index f47ee44e..eb971454 100644 --- a/src/QuickInstall/Sandbox/DockerComposeWriter.php +++ b/src/QuickInstall/Sandbox/DockerComposeWriter.php @@ -122,6 +122,7 @@ private function compose(string $name, array $config): string $boardPath = $this->project->boardPath($name); $extensionVolumes = $this->extensionVolumes($config['extensions'] ?? []); $styleVolumes = $this->styleVolumes($config['styles'] ?? []); + $languageVolumes = $this->languageVolumes($config['languages'] ?? []); $cookiePath = $config['cookie_path'] ?? '/'; $rewriteBase = rtrim($config['script_path'] ?? '/', '/') . '/'; $dbPath = $this->project->workspacePath('db/' . $name); @@ -142,7 +143,7 @@ private function compose(string $name, array $config): string # Boards are intentionally unavailable to other network devices. - "127.0.0.1:{$config['port']}:80" volumes: -{$this->bindVolume($sourcePath, '/opt/phpbb-source', true)}{$this->bindVolume($boardPath, '/var/www/html')}{$extensionVolumes}{$styleVolumes}{$this->bindVolume('./install-config.yml', '/opt/quickinstall/install-config.yml', true)}{$this->bindVolume('./entrypoint.sh', '/opt/quickinstall/entrypoint.sh', true)}{$this->bindVolume('./apache.conf', '/etc/apache2/conf-enabled/quickinstall.conf', true)} +{$this->bindVolume($sourcePath, '/opt/phpbb-source', true)}{$this->bindVolume($boardPath, '/var/www/html')}{$extensionVolumes}{$styleVolumes}{$languageVolumes}{$this->bindVolume('./install-config.yml', '/opt/quickinstall/install-config.yml', true)}{$this->bindVolume('./entrypoint.sh', '/opt/quickinstall/entrypoint.sh', true)}{$this->bindVolume('./apache.conf', '/etc/apache2/conf-enabled/quickinstall.conf', true)} entrypoint: ["/bin/sh", "/opt/quickinstall/entrypoint.sh"] depends_on: db: @@ -226,6 +227,28 @@ private function styleVolumes(array $styles): string return $volumes; } + private function languageVolumes(array $languages): string + { + $volumes = ''; + foreach ($languages as $name => $language) + { + if (($language['mode'] ?? '') !== 'bind') + { + continue; + } + + $source = $language['source'] ?? ''; + if ($source === '') + { + continue; + } + + $volumes .= $this->bindVolume($source, '/var/www/html/language/' . $name); + } + + return $volumes; + } + private function bindVolume(string $source, string $target, bool $readOnly = false): string { $volume = " - type: bind\n"; @@ -346,6 +369,13 @@ private function ownershipCommand(array $config): string $mountTargets[] = '/var/www/html/styles/' . $name; } } + foreach (($config['languages'] ?? []) as $name => $language) + { + if (($language['mode'] ?? '') === 'bind' && ($language['source'] ?? '') !== '') + { + $mountTargets[] = '/var/www/html/language/' . $name; + } + } $command = 'find /var/www/html'; foreach ($mountTargets as $target) diff --git a/src/QuickInstall/Sandbox/LanguageManager.php b/src/QuickInstall/Sandbox/LanguageManager.php new file mode 100644 index 00000000..d4406a68 --- /dev/null +++ b/src/QuickInstall/Sandbox/LanguageManager.php @@ -0,0 +1,238 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstall\Sandbox; + +use InvalidArgumentException; +use JsonException; +use RuntimeException; + +/** Discovers, copies, binds, lists, and removes phpBB language packs. */ +class LanguageManager implements CustomisationManagerInterface +{ + private Project $project; + + public function __construct(Project $project) + { + $this->project = $project; + } + + /** Mounts one 3.x or 4.x language pack using its validated ISO code. */ + public function mount(string $board, string $source, bool $copy = false, bool $allowExternal = false): array + { + $boardConfig = $this->project->board($board); + $sourcePath = $this->resolvePath($source, $allowExternal); + if (!is_dir($sourcePath)) + { + throw new InvalidArgumentException("Language source is not a directory: $source"); + } + + $name = $this->languageName($sourcePath); + $target = $this->project->boardPath($board) . '/language/' . $name; + $languages = $boardConfig['languages'] ?? []; + + if (file_exists($target) || is_link($target)) + { + if (!$copy && isset($languages[$name]) && ($languages[$name]['mode'] ?? '') === 'bind') + { + $languages[$name] = ['mode' => 'bind', 'source' => $sourcePath]; + $boardConfig['languages'] = $languages; + $this->project->appendBoard($boardConfig); + + return ['name' => $name, 'source' => $sourcePath, 'target' => '/var/www/html/language/' . $name, 'mode' => 'bind']; + } + + if (!$copy && (is_link($target) || !$this->isLanguagePath($target))) + { + $this->project->deleteTree($target); + } + else + { + throw new RuntimeException("Language target already exists: $target"); + } + } + + if ($copy) + { + $this->project->copyTree($sourcePath, $target); + $mode = 'copy'; + $languages[$name] = ['mode' => 'copy', 'source' => $target]; + } + else + { + $mode = 'bind'; + $languages[$name] = ['mode' => 'bind', 'source' => $sourcePath]; + $target = '/var/www/html/language/' . $name; + } + + $boardConfig['languages'] = $languages; + $this->project->appendBoard($boardConfig); + + return ['name' => $name, 'source' => $sourcePath, 'target' => $target, 'mode' => $mode]; + } + + public function discover(string $source, bool $allowExternal = false): array + { + $sourcePath = $this->resolvePath($source, $allowExternal); + if (!is_dir($sourcePath)) + { + throw new InvalidArgumentException("Language search path is not a directory: $source"); + } + + $found = []; + $this->discoverLanguages($sourcePath, $found); + sort($found); + + return $found; + } + + /** Removes copied files or registry state for a bind mount. */ + public function unmount(string $board, string $name): string + { + $boardConfig = $this->project->board($board); + $this->assertLanguageName($name); + $target = $this->project->boardPath($board) . '/language/' . $name; + $languages = $boardConfig['languages'] ?? []; + $isBind = isset($languages[$name]) && ($languages[$name]['mode'] ?? '') === 'bind'; + + if (!isset($languages[$name]) && !file_exists($target) && !is_link($target)) + { + throw new InvalidArgumentException("Language is not mounted: $name"); + } + + if (!$isBind) + { + $this->project->deleteTree($target); + } + + unset($languages[$name]); + $boardConfig['languages'] = $languages; + $this->project->appendBoard($boardConfig); + + return $isBind ? '/var/www/html/language/' . $name : $target; + } + + public function cleanupStaleTarget(string $board, string $name): void + { + $this->assertLanguageName($name); + $this->project->deleteTree($this->project->boardPath($board) . '/language/' . $name); + } + + public function list(string $board): array + { + $boardConfig = $this->project->board($board); + $mounted = []; + foreach (($boardConfig['languages'] ?? []) as $name => $language) + { + $mounted[] = [ + 'name' => $name, + 'mode' => $language['mode'] ?? 'bind', + 'target' => '/var/www/html/language/' . $name, + 'source' => $language['source'] ?? '', + ]; + } + + usort($mounted, static function (array $left, array $right): int { + return strcmp((string) $left['name'], (string) $right['name']); + }); + + return $mounted; + } + + private function resolvePath(string $path, bool $allowExternal): string + { + return $this->project->resolveDropZonePath( + $path, + $this->project->customisationsPath(), + $allowExternal, + "Language path must be under customisations/. Use --allow-external only for trusted local paths." + ); + } + + private function languageName(string $sourcePath): string + { + if (is_file($sourcePath . '/iso.txt')) + { + $name = basename($sourcePath); + $this->assertLanguageName($name); + + return $name; + } + + $composer = $sourcePath . '/composer.json'; + if (!is_file($composer)) + { + throw new InvalidArgumentException("Language source must contain iso.txt or composer.json: $sourcePath"); + } + + try + { + $data = json_decode((string) file_get_contents($composer), true, 512, JSON_THROW_ON_ERROR); + } + catch (JsonException $e) + { + throw new InvalidArgumentException("Language composer.json contains invalid JSON: $composer", 0, $e); + } + + $name = $data['extra']['language-iso'] ?? null; + if (!is_string($name) || $name === '') + { + throw new InvalidArgumentException("Language composer.json must contain extra.language-iso: $composer"); + } + + $this->assertLanguageName($name); + + return $name; + } + + private function isLanguagePath(string $path): bool + { + try + { + $this->languageName($path); + return true; + } + catch (InvalidArgumentException $e) + { + return false; + } + } + + private function assertLanguageName(string $name): void + { + if (!preg_match('/^[a-z]{2,3}(?:_[a-z0-9]+)*$/', $name)) + { + throw new InvalidArgumentException("Invalid language ISO: $name"); + } + } + + private function discoverLanguages(string $path, array &$found): void + { + if ($this->isLanguagePath($path)) + { + $found[] = realpath($path) ?: $path; + return; + } + + foreach (scandir($path) ?: [] as $item) + { + if ($item === '.' || $item === '..') + { + continue; + } + + $child = $path . '/' . $item; + if (is_dir($child) && !is_link($child)) + { + $this->discoverLanguages($child, $found); + } + } + } +} diff --git a/src/QuickInstall/Sandbox/LanguageUninstallerWriter.php b/src/QuickInstall/Sandbox/LanguageUninstallerWriter.php new file mode 100644 index 00000000..be2314f0 --- /dev/null +++ b/src/QuickInstall/Sandbox/LanguageUninstallerWriter.php @@ -0,0 +1,84 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace QuickInstall\Sandbox; + +use RuntimeException; + +/** Writes a phpBB-bootstrapped script that safely removes an installed language. */ +class LanguageUninstallerWriter +{ + private Project $project; + + public function __construct(Project $project) + { + $this->project = $project; + } + + public function write(string $board): string + { + $path = $this->project->runtimePath($board) . '/language-uninstall.php'; + $directory = dirname($path); + if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) + { + throw new RuntimeException("Unable to create runtime directory: $directory"); + } + + if (file_put_contents($path, $this->script(), LOCK_EX) === false) + { + throw new RuntimeException("Unable to write language uninstaller: $path"); + } + + return $path; + } + + private function script(): string + { + return <<<'PHP' +sql_escape($iso) . "'"; +$result = $db->sql_query($sql); +$language = $db->sql_fetchrow($result); +$db->sql_freeresult($result); +if (!$language) +{ + exit(0); +} + +if ($language['lang_iso'] === $config['default_lang']) +{ + fwrite(STDERR, "Cannot uninstall the default language.\n"); + exit(1); +} + +$id = (int) $language['lang_id']; +$default = $db->sql_escape($config['default_lang']); +$db->sql_transaction('begin'); +$db->sql_query('DELETE FROM ' . LANG_TABLE . ' WHERE lang_id = ' . $id); +$db->sql_query("UPDATE " . USERS_TABLE . " SET user_lang = '$default' WHERE user_lang = '" . $db->sql_escape($iso) . "'"); +$db->sql_query('DELETE FROM ' . PROFILE_LANG_TABLE . ' WHERE lang_id = ' . $id); +$db->sql_query('DELETE FROM ' . PROFILE_FIELDS_LANG_TABLE . ' WHERE lang_id = ' . $id); +$db->sql_transaction('commit'); +$cache->purge(); +PHP; + } +} diff --git a/src/QuickInstall/Sandbox/Web/Application.php b/src/QuickInstall/Sandbox/Web/Application.php index cb289d00..f8698a53 100644 --- a/src/QuickInstall/Sandbox/Web/Application.php +++ b/src/QuickInstall/Sandbox/Web/Application.php @@ -20,6 +20,7 @@ use QuickInstall\Sandbox\CustomisationUnmountService; use QuickInstall\Sandbox\DoctorService; use QuickInstall\Sandbox\ExtensionManager; +use QuickInstall\Sandbox\LanguageManager; use QuickInstall\Sandbox\Project; use QuickInstall\Sandbox\SeedPresetCatalog; use QuickInstall\Sandbox\SourceService; @@ -264,6 +265,10 @@ private function handlePost(): void $this->mountCustomisation('extension', new ExtensionManager($this->project)); break; + case 'customisation_mount': + $this->mountSelectedCustomisation(); + break; + case 'ext_unmount': $board = $this->required('board'); $name = $this->required('name'); @@ -284,6 +289,18 @@ private function handlePost(): void $this->notice = "Unmounted style: $name"; break; + case 'lang_mount': + $this->mountCustomisation('language', new LanguageManager($this->project)); + break; + + case 'lang_unmount': + $board = $this->required('board'); + $name = $this->required('name'); + $languages = new LanguageManager($this->project); + (new CustomisationUnmountService($this->project, $this->output))->language($languages, $board, $name); + $this->notice = "Unmounted language: $name"; + break; + default: throw new InvalidArgumentException('Unknown action.'); } @@ -381,6 +398,27 @@ private function mountCustomisation(string $type, CustomisationManagerInterface $this->notice = "Mounted {$type}: {$mounted['name']}"; } + private function mountSelectedCustomisation(): void + { + $type = $this->required('type'); + switch ($type) + { + case 'extension': + $this->mountCustomisation($type, new ExtensionManager($this->project)); + return; + + case 'style': + $this->mountCustomisation($type, new StyleManager($this->project)); + return; + + case 'language': + $this->mountCustomisation($type, new LanguageManager($this->project)); + return; + } + + throw new InvalidArgumentException('type must be one of: extension, style, language.'); + } + private function required(string $name): string { $value = trim((string) ($_POST[$name] ?? '')); @@ -501,16 +539,19 @@ private function viewData(?UpdateService $updates = null): array $versions = (new SourceService($this->project))->supportedVersions(); $extensions = new ExtensionManager($this->project); $styles = new StyleManager($this->project); + $languages = new LanguageManager($this->project); $running = count(array_filter($boards, static function ($board) { return ($board['status'] ?? '') === 'running'; })); $mountedExtensions = 0; $mountedStyles = 0; + $mountedLanguages = 0; foreach ($boards as $board) { $mountedExtensions += count($board['extensions'] ?? []); $mountedStyles += count($board['styles'] ?? []); + $mountedLanguages += count($board['languages'] ?? []); } $viewBoards = []; @@ -519,6 +560,7 @@ private function viewData(?UpdateService $updates = null): array $name = (string) $board['name']; $board['mounted_extensions'] = $extensions->list($name); $board['mounted_styles'] = $styles->list($name); + $board['mounted_languages'] = $languages->list($name); $viewBoards[] = $board; } @@ -542,6 +584,7 @@ private function viewData(?UpdateService $updates = null): array })) . ' downloaded', 'description' => 'phpBB cache'], ['label' => 'Extensions', 'value' => (string) $mountedExtensions, 'detail' => 'mounted', 'description' => 'Board mounts'], ['label' => 'Styles', 'value' => (string) $mountedStyles, 'detail' => 'mounted', 'description' => 'Board mounts'], + ['label' => 'Languages', 'value' => (string) $mountedLanguages, 'detail' => 'mounted', 'description' => 'Board mounts'], ], 'boards' => $viewBoards, 'sources' => $viewSources, diff --git a/src/QuickInstall/Sandbox/Web/templates/dashboard.php b/src/QuickInstall/Sandbox/Web/templates/dashboard.php index 0dbacd29..dccf5c00 100644 --- a/src/QuickInstall/Sandbox/Web/templates/dashboard.php +++ b/src/QuickInstall/Sandbox/Web/templates/dashboard.php @@ -66,66 +66,88 @@ Create boards below. When they are ready, they will appear here. -
+ +
+
+

All boards

total
+ +
+ +
-
-
-
-

escape($name) ?>

-

- - escape($url) ?> - - escape($url) ?> - -

+
+
+
+ +
+

escape($name) ?>

+

+ + escape($url) ?> + + escape($url) ?> + + No URL + +

+
+
+
+
phpBB
escape($board['phpbb'] ?? '') ?>
+
PHP
escape($board['php'] ?? '') ?>
+
DB
escape($board['db'] ?? '') ?>
+
Populate
escape($board['populate'] ?? 'none') ?>
+
+
+
+
Customisations
+
+ Ext + Style + Lang +
+
+
+
+
+
+ + + + +
+
+ + + + +
+
+ escape($status) ?>
- escape($status) ?> -
-
-
phpBB
escape($board['phpbb'] ?? '') ?>
-
PHP
escape($board['php'] ?? '') ?>
-
Database
escape($board['db'] ?? '') ?>
-
Populate
escape($board['populate'] ?? 'none') ?>
-
-
- Open -
- - - - -
-
- - - - -
-
- - - - -
-
Seed content
-
- - - - - - - -
-
- +
-
+ +
+
+
@@ -181,28 +217,24 @@

Customisations

-

Add extensions and styles from your local projects.

+

Add extensions, styles, and language packs from your local projects.

-
- -
- -

escape($mountForm[1]) ?>

- - - - - - -
-
- -
+
+ + + + + + + + +
+
@@ -212,7 +244,7 @@

Manage the phpBB versions available for your boards.

-
+ @@ -224,7 +256,7 @@
No sources registeredFetch a phpBB source above. When it is downloaded, it will appear here.
-
+
diff --git a/src/QuickInstall/Sandbox/Web/templates/layout.php b/src/QuickInstall/Sandbox/Web/templates/layout.php index 75ccd98d..c42d0845 100644 --- a/src/QuickInstall/Sandbox/Web/templates/layout.php +++ b/src/QuickInstall/Sandbox/Web/templates/layout.php @@ -44,21 +44,23 @@
-
-

QuickInstall Dashboard

-

Create and manage local phpBB boards for development and testing.

-
-
- - - - - -
- - - - +
+
+

QuickInstall Dashboard

+

Create and manage local phpBB boards for development and testing.

+
+
+
+ + + + +
+ + + + +
diff --git a/src/QuickInstall/Sandbox/bootstrap.php b/src/QuickInstall/Sandbox/bootstrap.php index c21b6487..110f3e7d 100644 --- a/src/QuickInstall/Sandbox/bootstrap.php +++ b/src/QuickInstall/Sandbox/bootstrap.php @@ -25,12 +25,14 @@ require_once __DIR__ . '/BoardService.php'; require_once __DIR__ . '/DockerComposeWriter.php'; require_once __DIR__ . '/StyleUninstallerWriter.php'; +require_once __DIR__ . '/LanguageUninstallerWriter.php'; require_once __DIR__ . '/SeedPresetCatalog.php'; require_once __DIR__ . '/SeederWriter.php'; require_once __DIR__ . '/BoardRunner.php'; require_once __DIR__ . '/CustomisationManagerInterface.php'; require_once __DIR__ . '/ExtensionManager.php'; require_once __DIR__ . '/StyleManager.php'; +require_once __DIR__ . '/LanguageManager.php'; require_once __DIR__ . '/BoardRefreshService.php'; require_once __DIR__ . '/CustomisationMountService.php'; require_once __DIR__ . '/CustomisationUnmountService.php'; diff --git a/tests/Integration/ApplicationTest.php b/tests/Integration/ApplicationTest.php index ba6395e8..0158f281 100644 --- a/tests/Integration/ApplicationTest.php +++ b/tests/Integration/ApplicationTest.php @@ -44,6 +44,15 @@ public function testSeedHelpShowsDevelopmentAndHidesDeprecatedPreset(): void self::assertStringNotContainsString('extension-dev', $result['output']); } + public function testLanguageCommandsAreDocumented(): void + { + $result = $this->runApplication($this->createTempProjectRoot(), ['qi', 'lang:mount', '--help']); + + self::assertSame(0, $result['exit_code']); + self::assertStringContainsString('qi lang:mount ', $result['output']); + self::assertStringContainsString('phpBB 3.x or 4.x language pack', $result['output']); + } + public function testInitCreatesWorkspace(): void { $root = $this->createTempProjectRoot(); diff --git a/tests/Integration/BinQiTest.php b/tests/Integration/BinQiTest.php index 47d6529f..c20f26d2 100644 --- a/tests/Integration/BinQiTest.php +++ b/tests/Integration/BinQiTest.php @@ -52,4 +52,13 @@ public function testExtMountSmokeTestLoadsCustomisationMountService(): void self::assertSame('', $result['stdout']); self::assertStringContainsString('Unknown board: missing-board', $result['stderr']); } + + public function testLangMountSmokeTestLoadsLanguageManager(): void + { + $result = $this->runCli(['lang:mount', 'missing-board', 'customisations/missing-language']); + + self::assertSame(1, $result['exit_code']); + self::assertSame('', $result['stdout']); + self::assertStringContainsString('Unknown board: missing-board', $result['stderr']); + } } diff --git a/tests/Integration/WebApplicationTest.php b/tests/Integration/WebApplicationTest.php index f144fb21..03baea3c 100644 --- a/tests/Integration/WebApplicationTest.php +++ b/tests/Integration/WebApplicationTest.php @@ -33,7 +33,7 @@ protected function tearDown(): void $this->cleanupTempPaths(); } - public function testRenderShowsCoreSandboxWorkflows(): void + public function testRenderShowsProjectState(): void { $root = $this->createTempProjectRoot(); $project = new Project($root); @@ -58,39 +58,94 @@ public function testRenderShowsCoreSandboxWorkflows(): void 'vendor/four' => ['mode' => 'bind', 'source' => '/tmp/four'], ], 'styles' => [], + 'languages' => [ + 'de' => ['mode' => 'bind', 'source' => '/tmp/de'], + ], ]); $html = $this->runWebApplication($root); + $text = $this->visibleText($html); + + self::assertStringContainsString('QuickInstall Dashboard', $text); + self::assertStringContainsString('demo', $text); + self::assertStringContainsString('http://localhost:8081/', $text); + self::assertStringContainsString('3.3.14', $text); + self::assertStringContainsString('vendor/one', $text); + self::assertStringContainsString('vendor/four', $text); + } + + public function testRenderListsEveryBoard(): void + { + $root = $this->createTempProjectRoot(); + $project = new Project($root); + $project->init(); + for ($index = 1; $index <= 10; $index++) + { + $project->appendBoard([ + 'name' => 'board-' . $index, + 'phpbb' => '3.3.17', + 'phpbb_source' => '3.3.17', + 'php' => '8.1', + 'db' => 'mariadb', + 'port' => 8100 + $index, + 'url' => 'http://localhost:' . (8100 + $index) . '/', + 'populate' => 'none', + 'extensions' => [], + 'styles' => [], + 'languages' => [], + ]); + } + + $html = $this->runWebApplication($root); + $text = $this->visibleText($html); + + for ($index = 1; $index <= 10; $index++) + { + self::assertStringContainsString('board-' . $index, $text); + self::assertStringContainsString('http://localhost:' . (8100 + $index) . '/', $text); + } + } + + public function testRunningBoardRendersOneStopAction(): void + { + $html = $this->renderDashboard([[ + 'name' => 'demo', + 'phpbb' => '3.3.17', + 'php' => '8.1', + 'db' => 'mariadb', + 'url' => 'http://localhost:8080/', + 'populate' => 'none', + 'status' => 'running', + 'mounted_extensions' => [], + 'mounted_styles' => [], + 'mounted_languages' => [], + ]]); + + $text = $this->visibleText($html); + self::assertStringContainsString('running', $text); + self::assertStringContainsString('Stop', $text); + self::assertStringNotContainsString('Start', $text); + } + + public function testBoardUrlIsLinkedOnlyWhileRunning(): void + { + $board = [ + 'name' => 'demo', + 'phpbb' => '3.3.17', + 'php' => '8.1', + 'db' => 'mariadb', + 'url' => 'http://localhost:8080/', + 'populate' => 'none', + 'mounted_extensions' => [], + 'mounted_styles' => [], + 'mounted_languages' => [], + ]; + + $stoppedHtml = $this->renderDashboard([$board + ['status' => 'stopped']]); + $runningHtml = $this->renderDashboard([$board + ['status' => 'running']]); - self::assertStringContainsString('QuickInstall Dashboard', $html); - self::assertStringContainsString('QuickInstall + Docker', $html); - self::assertStringContainsString('Create board', $html); - self::assertStringContainsString('Run Doctor', $html); - self::assertStringContainsString('id="icon-boards"', $html); - self::assertStringContainsString('href="#icon-activity"', $html); - self::assertStringContainsString('class="icon" aria-hidden="true"', $html); - self::assertStringContainsString('Sources', $html); - self::assertStringContainsString('Mount extension', $html); - self::assertStringContainsString('Mount style', $html); - self::assertStringContainsString('4', $html); - self::assertSame(1, substr_count($html, 'data-mounted-extra hidden')); - self::assertStringContainsString('data-more-label="Show 1 more"', $html); - self::assertStringContainsString('board_start', $html); - self::assertStringContainsString('board_seed', $html); - self::assertStringContainsString('source_remove', $html); - self::assertStringContainsString('data-ajax', $html); - self::assertStringContainsString('activity-log', $html); - self::assertStringContainsString('/assets/sandbox-ui.css', $html); - self::assertStringContainsString('/assets/sandbox-ui.js', $html); - self::assertStringContainsString('', $html); - self::assertStringNotContainsString('
SourceVersionStatusDownloadedUsed byPathActions