""" Plugin Distribution System Utilities Helper functions for plugin management, ZIP creation, and versioning. """ import os import hashlib import zipfile import shutil import logging from pathlib import Path from typing import Optional, Tuple from django.conf import settings logger = logging.getLogger(__name__) # Base paths for plugin storage PLUGINS_ROOT = Path('/data/app/igny8/plugins') WORDPRESS_SOURCE = PLUGINS_ROOT / 'wordpress' / 'source' WORDPRESS_DIST = PLUGINS_ROOT / 'wordpress' / 'dist' SHOPIFY_SOURCE = PLUGINS_ROOT / 'shopify' / 'source' SHOPIFY_DIST = PLUGINS_ROOT / 'shopify' / 'dist' CUSTOM_SOURCE = PLUGINS_ROOT / 'custom-site' / 'source' CUSTOM_DIST = PLUGINS_ROOT / 'custom-site' / 'dist' PLATFORM_PATHS = { 'wordpress': { 'source': WORDPRESS_SOURCE, 'dist': WORDPRESS_DIST, }, 'shopify': { 'source': SHOPIFY_SOURCE, 'dist': SHOPIFY_DIST, }, 'custom': { 'source': CUSTOM_SOURCE, 'dist': CUSTOM_DIST, }, } def get_source_path(platform: str, plugin_slug: str) -> Path: """Get the source directory path for a plugin.""" paths = PLATFORM_PATHS.get(platform) if not paths: raise ValueError(f"Unknown platform: {platform}") return paths['source'] / plugin_slug def get_dist_path(platform: str) -> Path: """Get the distribution directory path for a platform.""" paths = PLATFORM_PATHS.get(platform) if not paths: raise ValueError(f"Unknown platform: {platform}") return paths['dist'] def calculate_checksum(file_path: str) -> str: """Calculate SHA256 checksum of a file.""" sha256_hash = hashlib.sha256() with open(file_path, "rb") as f: for chunk in iter(lambda: f.read(8192), b""): sha256_hash.update(chunk) return sha256_hash.hexdigest() def parse_version_to_code(version_string: str) -> int: """ Convert version string to numeric code for comparison. '1.0.0' -> 10000 '1.0.1' -> 10001 '1.2.3' -> 10203 '2.0.0' -> 20000 """ try: parts = version_string.split('.') major = int(parts[0]) if len(parts) > 0 else 0 minor = int(parts[1]) if len(parts) > 1 else 0 patch = int(parts[2]) if len(parts) > 2 else 0 return major * 10000 + minor * 100 + patch except (ValueError, IndexError): return 0 def code_to_version(version_code: int) -> str: """Convert version code back to version string.""" major = version_code // 10000 minor = (version_code % 10000) // 100 patch = version_code % 100 return f"{major}.{minor}.{patch}" def update_php_version(file_path: str, new_version: str) -> bool: """ Update version numbers in a PHP plugin file. Updates both the plugin header and any $version variables. """ try: with open(file_path, 'r', encoding='utf-8') as f: content = f.read() import re # Update "Version: X.X.X" in plugin header content = re.sub( r'(Version:\s*)\d+\.\d+\.\d+', f'\\g<1>{new_version}', content ) # Update IGNY8_BRIDGE_VERSION constant content = re.sub( r"(define\s*\(\s*'IGNY8_BRIDGE_VERSION'\s*,\s*')[^']+(')", f"\\g<1>{new_version}\\g<2>", content ) # Update $version variable content = re.sub( r"(\$version\s*=\s*')[^']+(')", f"\\g<1>{new_version}\\g<2>", content ) with open(file_path, 'w', encoding='utf-8') as f: f.write(content) return True except Exception as e: logger.error(f"Failed to update PHP version: {e}") return False def create_plugin_zip( platform: str, plugin_slug: str, version: str, update_version: bool = True ) -> Tuple[Optional[str], Optional[str], Optional[int]]: """ Create a ZIP file for plugin distribution. Args: platform: Target platform ('wordpress', 'shopify', 'custom') plugin_slug: Plugin slug (e.g., 'igny8-wp-bridge') version: Version string (e.g., '1.0.1') update_version: Whether to update version numbers in source files Returns: Tuple of (file_path, checksum, file_size) or (None, None, None) on error """ try: source_path = get_source_path(platform, plugin_slug) dist_path = get_dist_path(platform) if not source_path.exists(): logger.error(f"Source path does not exist: {source_path}") return None, None, None # Ensure dist directory exists dist_path.mkdir(parents=True, exist_ok=True) # Create temp directory for packaging import tempfile temp_dir = tempfile.mkdtemp() package_dir = Path(temp_dir) / plugin_slug try: # Copy source files to temp directory shutil.copytree(source_path, package_dir) # Update version in main plugin file if requested if update_version and platform == 'wordpress': main_file = package_dir / f"{plugin_slug}.php" # Handle renamed plugin file if not main_file.exists(): main_file = package_dir / "igny8-bridge.php" if main_file.exists(): update_php_version(str(main_file), version) # Remove unwanted files patterns_to_remove = [ '**/__pycache__', '**/*.pyc', '**/.git', '**/.gitignore', '**/tests', '**/tester', '**/.DS_Store', '**/*.bak', '**/*.tmp', '**/*.log', ] for pattern in patterns_to_remove: for path in package_dir.glob(pattern): if path.is_dir(): shutil.rmtree(path) else: path.unlink() # Create ZIP file zip_filename = f"{plugin_slug}-v{version}.zip" zip_path = dist_path / zip_filename with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: for root, dirs, files in os.walk(package_dir): for file in files: file_path = Path(root) / file arcname = file_path.relative_to(temp_dir) zipf.write(file_path, arcname) # Create/update latest symlink latest_link = dist_path / f"{plugin_slug}-latest.zip" if latest_link.exists() or latest_link.is_symlink(): latest_link.unlink() latest_link.symlink_to(zip_filename) # Calculate checksum and file size checksum = calculate_checksum(str(zip_path)) file_size = zip_path.stat().st_size logger.info(f"Created plugin ZIP: {zip_path} ({file_size} bytes)") return zip_filename, checksum, file_size finally: # Clean up temp directory shutil.rmtree(temp_dir, ignore_errors=True) except Exception as e: logger.exception(f"Failed to create plugin ZIP: {e}") return None, None, None def get_plugin_file_path(platform: str, file_name: str) -> Optional[Path]: """Get the full path to a plugin ZIP file.""" dist_path = get_dist_path(platform) file_path = dist_path / file_name if file_path.exists(): return file_path return None def verify_checksum(file_path: str, expected_checksum: str) -> bool: """Verify a file's checksum matches expected value.""" actual_checksum = calculate_checksum(file_path) return actual_checksum == expected_checksum def get_installed_version_from_header(file_path: str) -> Optional[str]: """Extract version from a PHP plugin file header.""" try: with open(file_path, 'r', encoding='utf-8') as f: # Read first 8KB which should contain the header content = f.read(8192) import re match = re.search(r'Version:\s*(\d+\.\d+\.\d+)', content) if match: return match.group(1) return None except Exception as e: logger.error(f"Failed to extract version from {file_path}: {e}") return None def list_available_versions(platform: str, plugin_slug: str) -> list: """List all available versions for a plugin.""" dist_path = get_dist_path(platform) versions = [] import re pattern = re.compile(rf'^{re.escape(plugin_slug)}-v(\d+\.\d+\.\d+)\.zip$') for file in dist_path.iterdir(): if file.is_file(): match = pattern.match(file.name) if match: versions.append({ 'version': match.group(1), 'version_code': parse_version_to_code(match.group(1)), 'file_name': file.name, 'file_size': file.stat().st_size, }) # Sort by version_code descending versions.sort(key=lambda x: x['version_code'], reverse=True) return versions def cleanup_old_versions(platform: str, plugin_slug: str, keep_count: int = 5) -> int: """ Remove old version ZIP files, keeping the most recent ones. Args: platform: Target platform plugin_slug: Plugin slug keep_count: Number of recent versions to keep Returns: Number of versions removed """ versions = list_available_versions(platform, plugin_slug) removed = 0 if len(versions) > keep_count: versions_to_remove = versions[keep_count:] dist_path = get_dist_path(platform) for version_info in versions_to_remove: file_path = dist_path / version_info['file_name'] try: file_path.unlink() removed += 1 logger.info(f"Removed old version: {file_path}") except Exception as e: logger.error(f"Failed to remove {file_path}: {e}") return removed