Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion docs/sandbox-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions public/assets/sandbox-ui.css
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ button:disabled { opacity: .65; cursor: wait; }

.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;
}
Expand Down Expand Up @@ -416,7 +416,7 @@ button:disabled { opacity: .65; cursor: wait; }
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; }
.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 {
Expand Down Expand Up @@ -489,12 +489,13 @@ button:disabled { opacity: .65; cursor: wait; }
padding: 16px;
}
.settings-form h3 { grid-column: 1 / -1; }
.settings-form > .source-field { grid-column: span 2; }
.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; }
.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);
Expand Down
50 changes: 50 additions & 0 deletions public/assets/sandbox-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,54 @@ function sameAction(left, right) {
&& left.source === right.source;
}

/** 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;
}

/** Captures user-editable controls before dashboard markup is replaced. */
function snapshotForm(form) {
const occurrences = new Map();
Expand Down Expand Up @@ -193,6 +241,7 @@ function bindAjax() {
}

const context = actionContext(form);
const viewportSnapshot = snapshotViewport(context);
const formSnapshot = snapshotForm(form);
const submitter = event.submitter;
const original = submitter ? submitter.innerHTML : '';
Expand Down Expand Up @@ -234,6 +283,7 @@ function bindAjax() {
syncPendingActions();
}
showActionResult(data, context);
restoreViewport(context, viewportSnapshot);
scrollLog();
} catch (error) {
completeActivityEntry(actionId, { error: 'Request failed: ' + error.message, output: '' });
Expand Down
116 changes: 115 additions & 1 deletion src/QuickInstall/Sandbox/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 <board> <path> [--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 <board> <iso>');
}

$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 <board>');
$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)
Expand Down Expand Up @@ -1169,6 +1237,52 @@ private function helpCommands(): array
],
],
],
'Language commands' => [
'lang:mount' => [
'title' => 'lang:mount',
'usage' => 'lang:mount <board> <path> [--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' => [
'<board>' => 'Required board name.',
'<path>' => '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 <path>. 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 <board> <iso>',
'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' => [
'<board>' => 'Required board name.',
'<iso>' => 'Language ISO from the folder name or composer.json metadata.',
],
'examples' => [
'lang:unmount demo de',
],
],
'lang:list' => [
'title' => 'lang:list',
'usage' => 'lang:list <board>',
'summary' => 'Show language packs mounted on a board.',
'description' => 'Lists mounted language packs, mount mode, and source path for one board.',
'arguments' => [
'<board>' => 'Required board name.',
],
'examples' => [
'lang:list demo',
],
],
],
'UI commands' => [
'ui:start' => [
'title' => 'ui:start',
Expand Down
1 change: 1 addition & 0 deletions src/QuickInstall/Sandbox/BoardRefreshService.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ private function runtimeConfig(array $board): array
'debug' => false,
'extensions' => [],
'styles' => [],
'languages' => [],
];
}
}
9 changes: 9 additions & 0 deletions src/QuickInstall/Sandbox/BoardRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/QuickInstall/Sandbox/BoardService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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'),
];

Expand Down
2 changes: 1 addition & 1 deletion src/QuickInstall/Sandbox/CustomisationMountService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 10 additions & 1 deletion src/QuickInstall/Sandbox/CustomisationUnmountService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading