Files
api/web/scripts/sync-webgui-repo.js
Zack Spear df168224ea refactor: webgui sync script to copy uui build (#1318)
- enhances `pnpm sync-webgui-repo` to include Unraid UI build + sync
options
- changed the dev intended ThemeSwitcher to use local / session storage

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- The theme switcher now respects your saved preferences, ensuring a
more personalized interface.
- The component build and synchronization process has been enhanced with
new options for UI components and clearer feedback during operations.
- New computed properties added for better state management in account
and purchase functionalities.

- **Bug Fixes**
- Improved error handling and logging during the component build
process.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-04-03 09:17:21 -04:00

697 lines
21 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const fs = require('fs');
const path = require('path');
const ignore = require('ignore');
const readline = require('readline');
const diff = require('diff');
const crypto = require('crypto');
let chalk;
(async () => {
chalk = await import('chalk');
})();
const CONSTANTS = {
PATHS: {
IGNORE_LIST: path.join(__dirname, '.sync-webgui-repo-ignored-files.json'),
NEW_FILES: path.join(__dirname, '.sync-webgui-repo-new-files.json'),
STATE: path.join(__dirname, '.sync-webgui-repo-state.json'),
},
IGNORE_PATTERNS: [/\.md$/i, /\.ico$/i, /\.cfg$/i, /\.json$/i, /^banner\.png$/i],
PLUGIN_PATHS: {
API: 'plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins',
WEBGUI: 'emhttp/plugins',
},
WEB_COMPONENTS: {
API_WEB_BUILD_PATH: 'web/.nuxt/nuxt-custom-elements/dist/unraid-components',
API_UI_BUILD_PATH: 'unraid-ui/dist-wc',
WEBGUI_BASE_PATH: 'emhttp/plugins/dynamix.my.servers/unraid-components',
WEBGUI_WEB_SUBPATH: 'nuxt',
WEBGUI_UI_SUBPATH: 'uui',
},
};
const FileSystem = {
readJsonFile(path, defaultValue = {}) {
try {
return fs.existsSync(path) ? JSON.parse(fs.readFileSync(path, 'utf8')) : defaultValue;
} catch {
return defaultValue;
}
},
writeJsonFile(path, data) {
fs.writeFileSync(path, JSON.stringify(data, null, 2));
},
ensureDir(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
},
copyFile(source, dest) {
try {
const destDir = path.dirname(dest);
this.ensureDir(destDir);
fs.copyFileSync(source, dest);
return true;
} catch (err) {
UI.log(`Failed to copy ${source} to ${dest}: ${err.message}`, 'error');
return false;
}
},
getFileHash(filePath) {
const fileBuffer = fs.readFileSync(filePath);
const hashSum = crypto.createHash('sha256');
hashSum.update(fileBuffer);
return hashSum.digest('hex');
},
copyDirectory(source, destination) {
this.ensureDir(destination);
fs.readdirSync(source).forEach((file) => {
const sourcePath = path.join(source, file);
const destPath = path.join(destination, file);
const stats = fs.statSync(sourcePath);
if (stats.isDirectory()) {
this.copyDirectory(sourcePath, destPath);
} else {
fs.copyFileSync(sourcePath, destPath);
}
});
},
};
const UI = {
rl: readline.createInterface({
input: process.stdin,
output: process.stdout,
}),
async question(query) {
return new Promise((resolve) => this.rl.question(query, resolve));
},
async confirm(query, defaultYes = true) {
const answer = await this.question(`${query} (${defaultYes ? 'Y/n' : 'y/N'}) `);
return defaultYes ? answer.toLowerCase() !== 'n' : answer.toLowerCase() === 'y';
},
log(message, type) {
const icons = {
success: '✅',
error: '❌',
warning: '⚠️',
info: '',
skip: '⏭️',
new: '✨',
};
console.log(`${icons[type] || ''} ${message}`);
},
playSound() {
const sounds = {
darwin: 'afplay /System/Library/Sounds/Glass.aiff',
linux: 'paplay /usr/share/sounds/freedesktop/stereo/complete.oga',
win32:
'powershell.exe -c "(New-Object Media.SoundPlayer \'C:\\Windows\\Media\\Windows Default.wav\').PlaySync()"',
};
const sound = sounds[process.platform];
if (sound) require('child_process').exec(sound);
},
};
const State = {
loadIgnoredFiles() {
return FileSystem.readJsonFile(CONSTANTS.PATHS.IGNORE_LIST, []);
},
saveIgnoredFiles(files) {
FileSystem.writeJsonFile(CONSTANTS.PATHS.IGNORE_LIST, files);
},
loadPaths() {
return FileSystem.readJsonFile(CONSTANTS.PATHS.STATE);
},
savePaths(paths) {
Object.keys(paths).forEach((key) => {
if (typeof paths[key] === 'string') {
paths[key] = paths[key].endsWith('/') ? paths[key] : paths[key] + '/';
}
});
FileSystem.writeJsonFile(CONSTANTS.PATHS.STATE, paths);
},
getNewFiles() {
const data = FileSystem.readJsonFile(CONSTANTS.PATHS.NEW_FILES, { newFiles: {} });
return data.newFiles;
},
saveNewFiles(files) {
FileSystem.writeJsonFile(CONSTANTS.PATHS.NEW_FILES, {
timestamp: new Date().toISOString(),
newFiles: files,
});
},
};
const FileOps = {
loadGitignore(dirPath) {
const gitignorePath = path.join(dirPath, '.gitignore');
if (fs.existsSync(gitignorePath)) {
const ig = ignore();
ig.add(fs.readFileSync(gitignorePath, 'utf8'));
return ig;
}
return null;
},
showDiff(apiFile, webguiFile) {
const content1 = fs.readFileSync(apiFile, 'utf8');
const content2 = fs.readFileSync(webguiFile, 'utf8');
const differences = diff.createPatch(
path.basename(apiFile),
content2,
content1,
'webgui version',
'api version'
);
if (differences.split('\n').length > 5) {
console.log('\nDiff for', chalk.default.cyan(path.basename(apiFile)));
console.log(chalk.default.red('--- webgui:'), webguiFile);
console.log(chalk.default.green('+++ api: '), apiFile);
differences
.split('\n')
.slice(5)
.forEach((line) => {
if (line.startsWith('+')) console.log(chalk.default.green(line));
else if (line.startsWith('-')) console.log(chalk.default.red(line));
else if (line.startsWith('@')) console.log(chalk.default.cyan(line));
else console.log(line);
});
return true;
}
return false;
},
async handleFileDiff(apiFile, webguiFile) {
if (!this.showDiff(apiFile, webguiFile)) return 'identical';
const answer = await UI.question(
'What should I do fam? (w=copy to webgui/a=copy to API/s=skip) '
);
switch (answer.toLowerCase()) {
case 'w':
return FileSystem.copyFile(apiFile, webguiFile) ? 'webgui' : 'error';
case 'a':
return FileSystem.copyFile(webguiFile, apiFile) ? 'api' : 'error';
default:
return 'skip';
}
},
walkDirectory(currentPath, baseDir, projectFiles, gitignoreRules) {
const files = fs.readdirSync(currentPath);
files.forEach((file) => {
if (file.startsWith('.')) return;
const fullPath = path.join(currentPath, file);
const relativePath = path.relative(baseDir, fullPath);
if (relativePath.includes('test') || relativePath.includes('tests')) return;
if (CONSTANTS.IGNORE_PATTERNS.some((pattern) => pattern.test(file))) return;
if (gitignoreRules?.ignores(relativePath)) return;
const lstat = fs.lstatSync(fullPath);
if (lstat.isSymbolicLink()) return;
const stats = fs.statSync(fullPath);
if (stats.isDirectory()) {
this.walkDirectory(fullPath, baseDir, projectFiles, gitignoreRules);
} else {
if (!projectFiles.has(file)) {
projectFiles.set(file, []);
}
projectFiles.get(file).push(fullPath);
}
});
},
};
const Features = {
async setupPaths() {
const paths = State.loadPaths();
let changed = false;
if (
!paths.apiProjectDir ||
!(await UI.confirm(`Use last API repo path (${paths.apiProjectDir})?`))
) {
paths.apiProjectDir = await UI.question('Enter the path to your API repo: ');
changed = true;
}
if (
!paths.webguiProjectDir ||
!(await UI.confirm(`Use last webgui repo path (${paths.webguiProjectDir})?`))
) {
paths.webguiProjectDir = await UI.question('Enter the path to your webgui repo: ');
changed = true;
}
if (changed) {
State.savePaths(paths);
}
return paths;
},
async handleWebComponentBuild() {
const webDir = path.join(global.apiProjectDir, 'web');
if (!fs.existsSync(webDir)) {
UI.log('Web directory not found in API repo!', 'error');
return;
}
UI.log('Building web components...', 'info');
const { exec } = require('child_process');
try {
await new Promise((resolve, reject) => {
const buildProcess = exec('pnpm run build', { cwd: webDir });
buildProcess.stdout.on('data', (data) => process.stdout.write(data));
buildProcess.stderr.on('data', (data) => process.stderr.write(data));
buildProcess.on('exit', (code) => {
if (code === 0) {
UI.log('Web components build completed successfully!', 'success');
resolve();
} else {
reject(new Error(`Web components build failed with code ${code}`));
}
});
});
} catch (err) {
UI.log(`Error during build: ${err.message}`, 'error');
}
},
async handleUiComponentBuild() {
const uiDir = path.join(global.apiProjectDir, 'unraid-ui');
if (!fs.existsSync(uiDir)) {
UI.log('Unraid UI directory not found in API repo!', 'error');
return;
}
UI.log('Building UI components...', 'info');
const { exec } = require('child_process');
try {
await new Promise((resolve, reject) => {
const buildProcess = exec('pnpm run build && pnpm run build:wc', { cwd: uiDir });
buildProcess.stdout.on('data', (data) => process.stdout.write(data));
buildProcess.stderr.on('data', (data) => process.stderr.write(data));
buildProcess.on('exit', (code) => {
if (code === 0) {
UI.log('UI build completed successfully!', 'success');
resolve();
} else {
reject(new Error(`UI build failed with code ${code}`));
}
});
});
} catch (err) {
UI.log(`Error during build: ${err.message}`, 'error');
}
},
async handleWebComponentSync() {
const apiWebPath = path.join(global.apiProjectDir, CONSTANTS.WEB_COMPONENTS.API_WEB_BUILD_PATH);
const webguiBasePath = path.join(global.webguiProjectDir, CONSTANTS.WEB_COMPONENTS.WEBGUI_BASE_PATH);
const webguiWebPath = path.join(webguiBasePath, CONSTANTS.WEB_COMPONENTS.WEBGUI_WEB_SUBPATH);
try {
if (!fs.existsSync(apiWebPath)) {
UI.log('Web components source directory not found! Did you build the web components?', 'error');
return;
}
UI.log('Removing old web components...', 'info');
fs.rmSync(webguiWebPath, { recursive: true, force: true });
UI.log('Copying new web components...', 'info');
FileSystem.copyDirectory(apiWebPath, webguiWebPath);
const indexPath = path.join(webguiWebPath, 'index.html');
if (fs.existsSync(indexPath)) {
UI.log('Removing irrelevant index.html...', 'info');
fs.unlinkSync(indexPath);
}
UI.playSound();
UI.log('Web components copied successfully!', 'success');
} catch (err) {
UI.log(`Error during sync: ${err.message}`, 'error');
}
},
async handleUiComponentSync() {
const apiUiPath = path.join(global.apiProjectDir, CONSTANTS.WEB_COMPONENTS.API_UI_BUILD_PATH);
const webguiBasePath = path.join(global.webguiProjectDir, CONSTANTS.WEB_COMPONENTS.WEBGUI_BASE_PATH);
const webguiUiPath = path.join(webguiBasePath, CONSTANTS.WEB_COMPONENTS.WEBGUI_UI_SUBPATH);
try {
if (!fs.existsSync(apiUiPath)) {
UI.log('Unraid UI source directory not found!', 'error');
return;
}
UI.log('Removing old UI components...', 'info');
fs.rmSync(webguiUiPath, { recursive: true, force: true });
UI.log('Copying new UI components...', 'info');
FileSystem.copyDirectory(apiUiPath, webguiUiPath);
UI.playSound();
UI.log('UI components copied successfully!', 'success');
} catch (err) {
UI.log(`Error during sync: ${err.message}`, 'error');
}
},
findMatchingFiles(apiProjectDir, webguiProjectDir) {
const matches = new Map();
const apiFiles = new Map();
const webguiFiles = new Map();
const gitignore1 = FileOps.loadGitignore(apiProjectDir);
const gitignore2 = FileOps.loadGitignore(webguiProjectDir);
FileOps.walkDirectory(apiProjectDir, apiProjectDir, apiFiles, gitignore1);
FileOps.walkDirectory(webguiProjectDir, webguiProjectDir, webguiFiles, gitignore2);
apiFiles.forEach((paths1, filename) => {
if (webguiFiles.has(filename)) {
matches.set(filename, [...paths1, ...webguiFiles.get(filename)]);
}
});
return matches;
},
findMissingPluginFiles(apiProjectDir, webguiProjectDir, ignoredFiles) {
const missingFiles = new Map();
if (!apiProjectDir || !webguiProjectDir) {
UI.log('API project and webgui project directories are required!', 'error');
return missingFiles;
}
const apiPluginsPath = path.join(apiProjectDir, CONSTANTS.PLUGIN_PATHS.API);
const webguiPluginsPath = path.join(webguiProjectDir, CONSTANTS.PLUGIN_PATHS.WEBGUI);
if (!fs.existsSync(apiPluginsPath)) {
UI.log('API plugins directory not found: ' + apiPluginsPath, 'error');
return missingFiles;
}
const gitignore1 = FileOps.loadGitignore(apiProjectDir);
function walkDir(currentPath, baseDir) {
if (!fs.existsSync(currentPath)) {
UI.log(`Directory doesn't exist: ${currentPath}`, 'warning');
return;
}
UI.log(`Checking directory: ${path.relative(apiProjectDir, currentPath)}`, 'info');
fs.readdirSync(currentPath).forEach((file) => {
if (file.startsWith('.')) {
UI.log(`Skipping dot file/dir: ${file}`, 'skip');
return;
}
const fullPath = path.join(currentPath, file);
const relativePath = path.relative(apiPluginsPath, fullPath);
if (CONSTANTS.IGNORE_PATTERNS.some((pattern) => pattern.test(file))) {
UI.log(`Skipping ignored pattern: ${file}`, 'skip');
return;
}
if (gitignore1?.ignores(relativePath)) {
UI.log(`Skipping gitignored file: ${file}`, 'skip');
return;
}
const lstat = fs.lstatSync(fullPath);
if (lstat.isSymbolicLink()) {
UI.log(`Skipping symlink: ${file}`, 'skip');
return;
}
const stats = fs.statSync(fullPath);
if (stats.isDirectory()) {
UI.log(`Found subdirectory: ${file}`, 'info');
walkDir(fullPath, baseDir);
return;
}
if (ignoredFiles.includes(file)) {
UI.log(`Skipping manually ignored file: ${file}`, 'skip');
return;
}
const webguiPath = path.join(webguiPluginsPath, relativePath);
if (!fs.existsSync(webguiPath)) {
UI.log(`Found new file: ${relativePath}`, 'new');
missingFiles.set(relativePath, {
source: fullPath,
destinationPath: webguiPath,
relativePath,
});
} else {
UI.log(`File exists in both: ${relativePath}`, 'success');
}
});
}
UI.log('\nStarting directory scan...', 'info');
UI.log(`API plugins path: ${apiPluginsPath}`, 'info');
UI.log(`Webgui plugins path: ${webguiPluginsPath}\n`, 'info');
try {
walkDir(apiPluginsPath, apiPluginsPath);
if (missingFiles.size > 0) {
State.saveNewFiles(
Object.fromEntries(
Array.from(missingFiles).map(([relativePath, info]) => [relativePath, info])
)
);
}
} catch (err) {
UI.log(`Error while scanning directories: ${err.message}`, 'error');
}
return missingFiles;
},
async handleNewFiles() {
const newFiles = State.getNewFiles();
const fileCount = Object.keys(newFiles).length;
if (fileCount === 0) {
UI.log('No new files to copy bruv!', 'info');
return;
}
UI.log(`Found ${fileCount} files to review:`, 'info');
const handledFiles = new Set();
const ignoredFiles = State.loadIgnoredFiles();
for (const [relativePath, info] of Object.entries(newFiles)) {
console.log(`\nFile: ${relativePath}`);
console.log(`From: ${info.source}`);
console.log(`To: ${info.destinationPath}`);
const answer = await UI.question(
'What should I do fam? (w=copy to webgui/i=ignore forever/s=skip/q=quit) '
);
switch (answer.toLowerCase()) {
case 'w':
if (FileSystem.copyFile(info.source, info.destinationPath)) {
UI.log(`Copied: ${relativePath}`, 'success');
handledFiles.add(relativePath);
}
break;
case 'i':
ignoredFiles.push(path.basename(relativePath));
State.saveIgnoredFiles(ignoredFiles);
UI.log(`Added ${path.basename(relativePath)} to ignore list`, 'info');
handledFiles.add(relativePath);
break;
case 'q':
UI.log('Stopping here fam!', 'info');
break;
default:
UI.log(`Skipped: ${relativePath}`, 'skip');
handledFiles.add(relativePath);
break;
}
if (answer.toLowerCase() === 'q') break;
}
const updatedNewFiles = Object.fromEntries(
Object.entries(newFiles).filter(([relativePath]) => !handledFiles.has(relativePath))
);
State.saveNewFiles(updatedNewFiles);
const remainingCount = Object.keys(updatedNewFiles).length;
UI.log('All done for now bruv! 🔥', 'success');
if (remainingCount > 0) {
UI.log(`${remainingCount} files left to handle next time.`, 'info');
}
},
};
const Menu = {
async show() {
while (true) {
try {
console.log('\nWhat you trying to do fam?');
console.log('1. Find new plugin files in API project');
console.log('2. Handle new plugin files in API project');
console.log('3. Sync shared files between API and webgui');
console.log('4. Build UI components');
console.log('5. Sync UI components');
console.log('6. Build web components');
console.log('7. Sync web components');
console.log('8. Exit\n');
const answer = await UI.question('Choose an option (1-8): ');
switch (answer) {
case '1': {
UI.log('Checking plugin directories for missing files bruv...', 'info');
const ignoredFiles = State.loadIgnoredFiles();
const missingFiles = Features.findMissingPluginFiles(
global.apiProjectDir,
global.webguiProjectDir,
ignoredFiles
);
if (missingFiles.size > 0) {
UI.log(`Found ${missingFiles.size} new files! 🔍`, 'info');
if (await UI.confirm('Want to handle these new files now fam?', false)) {
await Features.handleNewFiles();
} else {
UI.log('Safe, you can handle them later with option 2!', 'info');
}
} else {
UI.log('\n');
UI.log('No new files found bruv! 👌', 'success');
}
break;
}
case '2':
await Features.handleNewFiles();
break;
case '3': {
UI.log('Checking for matching files bruv...', 'info');
const matchingFiles = Features.findMatchingFiles(
global.apiProjectDir,
global.webguiProjectDir
);
if (matchingFiles.size === 0) {
UI.log('No matching files found fam!', 'info');
} else {
UI.log(`Found ${matchingFiles.size} matching files:\n`, 'info');
for (const [filename, paths] of matchingFiles) {
const [apiPath, webguiPath] = paths;
console.log(`File: ${filename}`);
const apiHash = FileSystem.getFileHash(apiPath);
const webguiHash = FileSystem.getFileHash(webguiPath);
if (apiHash !== webguiHash) {
await FileOps.handleFileDiff(apiPath, webguiPath);
} else {
UI.log('Files are identical', 'success');
}
}
}
break;
}
case '4':
await Features.handleUiComponentBuild();
break;
case '5':
await Features.handleUiComponentSync();
break;
case '6':
await Features.handleWebComponentBuild();
break;
case '7':
await Features.handleWebComponentSync();
break;
case '8':
UI.log('Safe bruv, catch you later! 👋', 'success');
UI.rl.close();
process.exit(0);
return;
default:
UI.log("Nah fam, that's not a valid option!", 'error');
break;
}
} catch (error) {
UI.log(error.message, 'error');
UI.rl.close();
process.exit(1);
}
}
}
};
const App = {
async init() {
try {
const paths = await Features.setupPaths();
global.apiProjectDir = paths.apiProjectDir;
global.webguiProjectDir = paths.webguiProjectDir;
await Menu.show();
} catch (error) {
UI.log(error.message, 'error');
UI.rl.close();
process.exit(1);
}
},
};
App.init().catch((error) => {
UI.log(`Something went wrong fam: ${error.message}`, 'error');
UI.rl.close();
process.exit(1);
});