Fix builder page layout: make new chat and history buttons sticky at top on desktop

- Moved sticky positioning from mobile-only to global CSS for .top-left-actions

- Buttons now stay fixed at top above content on all screen sizes

- Includes various other app updates (version management, server improvements)
This commit is contained in:
southseact-3d
2026-02-11 19:23:23 +00:00
parent e2a2dff301
commit 0513000a9e
9 changed files with 742 additions and 28 deletions

View File

@@ -0,0 +1,309 @@
/**
* Version Manager Utility
* Handles WordPress plugin version parsing, bumping, and detection
*/
const path = require('path');
const fs = require('fs').promises;
/**
* Valid semantic version regex (X.Y.Z format)
*/
const VERSION_REGEX = /^\d+\.\d+\.\d+$/;
/**
* WordPress plugin header version regex
* Matches: Version: 1.2.3 or Version:1.2.3
*/
const WP_HEADER_VERSION_REGEX = /^\s*\*?\s*Version:\s*(\d+\.\d+\.\d+)/im;
/**
* PHP define version regex
* Matches: define('CONSTANT_NAME', '1.2.3') or define( "CONSTANT_NAME", "1.2.3" )
*/
const PHP_DEFINE_VERSION_REGEX = /define\s*\(\s*['"][A-Z_]*VERSION['"]\s*,\s*['"](\d+\.\d+\.\d+)['"]\s*\)/gi;
/**
* PHP constant assignment regex (alternative format)
* Matches: const VERSION = '1.2.3';
*/
const PHP_CONST_VERSION_REGEX = /const\s+[A-Z_]*VERSION\s*=\s*['"](\d+\.\d+\.\d+)['"]/gi;
/**
* Parse a version string into components
* @param {string} version - Version string (e.g., "1.2.3")
* @returns {object|null} - { major, minor, patch } or null if invalid
*/
function parseVersion(version) {
if (!version || typeof version !== 'string') return null;
const cleanVersion = version.trim();
if (!VERSION_REGEX.test(cleanVersion)) return null;
const [major, minor, patch] = cleanVersion.split('.').map(Number);
return { major, minor, patch, raw: cleanVersion };
}
/**
* Format version components into a string
* @param {number} major
* @param {number} minor
* @param {number} patch
* @returns {string} - Formatted version (e.g., "1.2.3")
*/
function formatVersion(major, minor, patch) {
return `${major}.${minor}.${patch}`;
}
/**
* Bump a version number
* @param {string} currentVersion - Current version (e.g., "1.2.3")
* @param {string} bumpType - 'major', 'minor', 'patch', or 'keep'
* @returns {string|null} - New version or null if invalid input
*/
function bumpVersion(currentVersion, bumpType = 'patch') {
const parsed = parseVersion(currentVersion);
if (!parsed) return null;
const { major, minor, patch } = parsed;
switch (bumpType.toLowerCase()) {
case 'major':
return formatVersion(major + 1, 0, 0);
case 'minor':
return formatVersion(major, minor + 1, 0);
case 'patch':
return formatVersion(major, minor, patch + 1);
case 'keep':
return currentVersion;
default:
// Default to patch bump
return formatVersion(major, minor, patch + 1);
}
}
/**
* Extract version from WordPress plugin header
* @param {string} content - PHP file content
* @returns {string|null} - Version string or null
*/
function extractHeaderVersion(content) {
if (!content) return null;
const match = content.match(WP_HEADER_VERSION_REGEX);
return match ? match[1] : null;
}
/**
* Extract version from PHP define() constants
* @param {string} content - PHP file content
* @returns {string|null} - Version string or null
*/
function extractDefineVersion(content) {
if (!content) return null;
// Use non-global version of regex to properly capture groups
const regex = /define\s*\(\s*['"][A-Z_]*VERSION['"]\s*,\s*['"](\d+\.\d+\.\d+)['"]\s*\)/i;
const match = content.match(regex);
return match ? match[1] : null;
}
/**
* Extract version from PHP const assignments
* @param {string} content - PHP file content
* @returns {string|null} - Version string or null
*/
function extractConstVersion(content) {
if (!content) return null;
const regex = new RegExp(PHP_CONST_VERSION_REGEX);
const match = regex.exec(content);
return match ? match[1] : null;
}
/**
* Find the main plugin file in a directory
* Searches for PHP files with WordPress plugin headers
* @param {string} dirPath - Directory to search
* @param {Array} validFiles - Optional array of file objects from collectValidFiles
* @returns {Promise<object|null>} - { fullPath, relativePath, content } or null
*/
async function findMainPluginFile(dirPath, validFiles = null) {
// If validFiles not provided, collect them
if (!validFiles) {
validFiles = [];
await collectPhpFiles(dirPath, dirPath, validFiles);
}
// Sort by likelihood of being main file (root level first, shorter names preferred)
const phpFiles = validFiles
.filter(f => f.fullPath.endsWith('.php'))
.sort((a, b) => {
const aDepth = a.relativePath.split(path.sep).length;
const bDepth = b.relativePath.split(path.sep).length;
if (aDepth !== bDepth) return aDepth - bDepth;
return a.relativePath.length - b.relativePath.length;
});
for (const fileInfo of phpFiles) {
try {
const content = await fs.readFile(fileInfo.fullPath, 'utf-8');
// Check for WordPress plugin header
if (content.includes('Plugin Name:')) {
return {
fullPath: fileInfo.fullPath,
relativePath: fileInfo.relativePath,
content
};
}
} catch (err) {
// Continue to next file
}
}
return null;
}
/**
* Helper to collect PHP files recursively
*/
async function collectPhpFiles(rootDir, currentDir, files) {
try {
const entries = await fs.readdir(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
const relativePath = path.relative(rootDir, fullPath);
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
await collectPhpFiles(rootDir, fullPath, files);
} else if (entry.isFile() && entry.name.endsWith('.php')) {
files.push({ fullPath, relativePath });
}
}
} catch (err) {
// Ignore errors
}
}
/**
* Extract all version information from a plugin file
* @param {string} content - PHP file content
* @returns {object} - { headerVersion, defineVersion, constVersion, detectedVersion }
*/
function extractAllVersions(content) {
const headerVersion = extractHeaderVersion(content);
const defineVersion = extractDefineVersion(content);
const constVersion = extractConstVersion(content);
// Use first non-null version found (priority: header > define > const)
const detectedVersion = headerVersion || defineVersion || constVersion;
return {
headerVersion,
defineVersion,
constVersion,
detectedVersion
};
}
/**
* Update version in plugin file content
* @param {string} content - Original file content
* @param {string} oldVersion - Current version
* @param {string} newVersion - New version to set
* @returns {string|null} - Updated content or null if no changes needed
*/
function updateVersionInContent(content, oldVersion, newVersion) {
if (!content || !oldVersion || !newVersion || oldVersion === newVersion) {
return null;
}
let updated = content;
let changesMade = false;
// Update WordPress plugin header version
const headerRegex = new RegExp(
`^(\\s*\\*?\\s*Version:)\\s*${oldVersion.replace(/\./g, '\\.')}`,
'gim'
);
if (headerRegex.test(updated)) {
updated = updated.replace(headerRegex, `$1 ${newVersion}`);
changesMade = true;
}
// Update PHP define() version constants
const defineRegex = new RegExp(
`(define\\s*\\(\\s*['"][A-Z_]*VERSION['"]\\s*,\\s*['"])${oldVersion.replace(/\./g, '\\.')}(['"]\\s*\\))`,
'gi'
);
if (defineRegex.test(updated)) {
updated = updated.replace(defineRegex, `$1${newVersion}$2`);
changesMade = true;
}
// Update PHP const version assignments
const constRegex = new RegExp(
`(const\\s+[A-Z_]*VERSION\\s*=\\s*['"])${oldVersion.replace(/\./g, '\\.')}(['"])`,
'gi'
);
if (constRegex.test(updated)) {
updated = updated.replace(constRegex, `$1${newVersion}$2`);
changesMade = true;
}
return changesMade ? updated : null;
}
/**
* Detect if a file is a WordPress plugin file
* @param {string} content - File content
* @returns {boolean}
*/
function isWordPressPluginFile(content) {
if (!content) return false;
return content.includes('Plugin Name:') && content.includes('Version:');
}
/**
* Get the plugin slug from folder name or file name
* @param {string} filePath - Path to main plugin file
* @returns {string}
*/
function getPluginSlugFromPath(filePath) {
const parsed = path.parse(filePath);
const parentDir = path.basename(parsed.dir);
const fileName = parsed.name;
// If parent directory name matches file name (typical WordPress plugin structure: my-plugin/my-plugin.php)
// or if there's a meaningful parent directory name (not generic like 'to', 'path', 'src')
// use the directory name, otherwise use the file name
const genericDirNames = ['.', '..', 'src', 'lib', 'includes', 'dist', 'build', 'to', 'path'];
if (parentDir === fileName) {
// Perfect match: folder/my-plugin/my-plugin.php
return parentDir;
} else if (!genericDirNames.includes(parentDir.toLowerCase())) {
// Parent directory has a meaningful name: folder/my-custom-plugin/plugin.php
return parentDir;
} else {
// Use file name as fallback: folder/plugin-name.php
return fileName;
}
}
module.exports = {
parseVersion,
formatVersion,
bumpVersion,
extractHeaderVersion,
extractDefineVersion,
extractConstVersion,
extractAllVersions,
findMainPluginFile,
updateVersionInContent,
isWordPressPluginFile,
getPluginSlugFromPath,
// Regex exports for testing
VERSION_REGEX,
WP_HEADER_VERSION_REGEX,
PHP_DEFINE_VERSION_REGEX,
PHP_CONST_VERSION_REGEX
};

View File

@@ -0,0 +1,115 @@
/**
* Version Manager Tests
* Run with: node versionManager.test.js
*/
const versionManager = require('./versionManager');
const assert = require('assert');
console.log('Running version manager tests...\n');
// Test parseVersion
console.log('Test 1: parseVersion');
assert.deepStrictEqual(versionManager.parseVersion('1.2.3'), { major: 1, minor: 2, patch: 3, raw: '1.2.3' });
assert.strictEqual(versionManager.parseVersion('invalid'), null);
assert.strictEqual(versionManager.parseVersion('1.2'), null);
assert.strictEqual(versionManager.parseVersion(''), null);
console.log('✓ parseVersion tests passed\n');
// Test formatVersion
console.log('Test 2: formatVersion');
assert.strictEqual(versionManager.formatVersion(1, 2, 3), '1.2.3');
assert.strictEqual(versionManager.formatVersion(10, 0, 0), '10.0.0');
console.log('✓ formatVersion tests passed\n');
// Test bumpVersion
console.log('Test 3: bumpVersion');
assert.strictEqual(versionManager.bumpVersion('1.2.3', 'patch'), '1.2.4');
assert.strictEqual(versionManager.bumpVersion('1.2.3', 'minor'), '1.3.0');
assert.strictEqual(versionManager.bumpVersion('1.2.3', 'major'), '2.0.0');
assert.strictEqual(versionManager.bumpVersion('1.2.3', 'keep'), '1.2.3');
assert.strictEqual(versionManager.bumpVersion('1.2.3', 'unknown'), '1.2.4'); // defaults to patch
assert.strictEqual(versionManager.bumpVersion('invalid', 'patch'), null);
console.log('✓ bumpVersion tests passed\n');
// Test extractHeaderVersion
console.log('Test 4: extractHeaderVersion');
const headerContent = `<?php
/**
* Plugin Name: Test Plugin
* Version: 2.5.1
* Author: Test
*/`;
assert.strictEqual(versionManager.extractHeaderVersion(headerContent), '2.5.1');
const headerContentNoSpace = `<?php
/**
* Plugin Name: Test Plugin
* Version:3.0.0
*/`;
assert.strictEqual(versionManager.extractHeaderVersion(headerContentNoSpace), '3.0.0');
assert.strictEqual(versionManager.extractHeaderVersion('no version here'), null);
console.log('✓ extractHeaderVersion tests passed\n');
// Test extractDefineVersion
console.log('Test 5: extractDefineVersion');
const defineContent = `<?php
define('TEST_PLUGIN_VERSION', '1.2.3');
define("ANOTHER_VERSION", "2.0.0");`;
assert.strictEqual(versionManager.extractDefineVersion(defineContent), '1.2.3');
const defineContentSpaces = `<?php
define( 'MY_VERSION' , '3.4.5' );`;
assert.strictEqual(versionManager.extractDefineVersion(defineContentSpaces), '3.4.5');
assert.strictEqual(versionManager.extractDefineVersion('no version here'), null);
console.log('✓ extractDefineVersion tests passed\n');
// Test extractAllVersions
console.log('Test 6: extractAllVersions');
const allVersionsContent = `<?php
/**
* Plugin Name: Test
* Version: 1.2.3
*/
define('PLUGIN_VERSION', '1.2.3');`;
const allVersions = versionManager.extractAllVersions(allVersionsContent);
assert.strictEqual(allVersions.headerVersion, '1.2.3');
assert.strictEqual(allVersions.defineVersion, '1.2.3');
assert.strictEqual(allVersions.detectedVersion, '1.2.3');
console.log('✓ extractAllVersions tests passed\n');
// Test updateVersionInContent
console.log('Test 7: updateVersionInContent');
const originalContent = `<?php
/**
* Plugin Name: Test Plugin
* Version: 1.0.0
* Author: Test
*/
define('PLUGIN_VERSION', '1.0.0');`;
const updatedContent = versionManager.updateVersionInContent(originalContent, '1.0.0', '1.1.0');
assert(updatedContent.includes('Version: 1.1.0'));
assert(updatedContent.includes("define('PLUGIN_VERSION', '1.1.0')"));
assert(!updatedContent.includes('Version: 1.0.0'));
assert(!updatedContent.includes("define('PLUGIN_VERSION', '1.0.0')"));
console.log('✓ updateVersionInContent tests passed\n');
// Test isWordPressPluginFile
console.log('Test 8: isWordPressPluginFile');
assert.strictEqual(versionManager.isWordPressPluginFile(headerContent), true);
assert.strictEqual(versionManager.isWordPressPluginFile('<?php echo "hello";'), false);
assert.strictEqual(versionManager.isWordPressPluginFile(''), false);
console.log('✓ isWordPressPluginFile tests passed\n');
// Test getPluginSlugFromPath
console.log('Test 9: getPluginSlugFromPath');
assert.strictEqual(versionManager.getPluginSlugFromPath('/path/to/my-plugin/my-plugin.php'), 'my-plugin');
assert.strictEqual(versionManager.getPluginSlugFromPath('/path/to/plugin-name.php'), 'plugin-name');
console.log('✓ getPluginSlugFromPath tests passed\n');
console.log('========================================');
console.log('All version manager tests passed! ✓');
console.log('========================================');