PWA Mobile App für Schaltschrank-Dokumentation vor Ort: - Token-basierte Authentifizierung (15 Tage gültig) - Kundensuche mit Offline-Cache - Anlagen-Auswahl und Offline-Laden - Felder/Hutschienen/Automaten erfassen - Automatische Synchronisierung wenn wieder online - Installierbar auf dem Smartphone Home Screen - Touch-optimiertes Dark Mode Design - Quick-Select für Automaten-Werte (B16, C32, etc.) Schaltplan-Editor Verbesserungen: - Block Hover-Tooltip mit show_in_hover Feldern - Produktinfo mit Icon im Tooltip - Position und Breite in TE Neue Dateien: - pwa.php, pwa_auth.php - PWA Einstieg & Auth - ajax/pwa_api.php - PWA AJAX API - js/pwa.js, css/pwa.css - PWA App & Styles - sw.js, manifest.json - Service Worker & Manifest - img/pwa-icon-192.png, img/pwa-icon-512.png Version: 5.2.0 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
558 lines
13 KiB
PHP
Executable file
558 lines
13 KiB
PHP
Executable file
<?php
|
|
/* Copyright (C) 2026 Alles Watt lauft
|
|
*
|
|
* This program is free software; you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation; either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*/
|
|
|
|
/**
|
|
* Class AnlageBackup
|
|
* Handles backup and restore of all installation data
|
|
*/
|
|
class AnlageBackup
|
|
{
|
|
public $db;
|
|
public $error;
|
|
public $errors = array();
|
|
|
|
// Tables to backup (in order for foreign key constraints)
|
|
private $tables = array(
|
|
'kundenkarte_anlage_system',
|
|
'kundenkarte_anlage_type',
|
|
'kundenkarte_anlage_type_field',
|
|
'kundenkarte_customer_system',
|
|
'kundenkarte_anlage',
|
|
'kundenkarte_anlage_files',
|
|
'kundenkarte_anlage_connection',
|
|
'kundenkarte_equipment_panel',
|
|
'kundenkarte_equipment_carrier',
|
|
'kundenkarte_equipment',
|
|
'kundenkarte_medium_type',
|
|
'kundenkarte_busbar_type',
|
|
'kundenkarte_building_type',
|
|
);
|
|
|
|
/**
|
|
* Constructor
|
|
*
|
|
* @param DoliDB $db Database handler
|
|
*/
|
|
public function __construct($db)
|
|
{
|
|
$this->db = $db;
|
|
}
|
|
|
|
/**
|
|
* Create a full backup
|
|
*
|
|
* @param bool $includeFiles Include uploaded files in backup
|
|
* @return string|false Path to backup file or false on error
|
|
*/
|
|
public function createBackup($includeFiles = true)
|
|
{
|
|
global $conf;
|
|
|
|
$backupDir = $conf->kundenkarte->dir_output.'/backups';
|
|
if (!is_dir($backupDir)) {
|
|
dol_mkdir($backupDir);
|
|
}
|
|
|
|
$timestamp = date('Y-m-d_H-i-s');
|
|
$backupName = 'kundenkarte_backup_'.$timestamp;
|
|
$tempDir = $backupDir.'/'.$backupName;
|
|
|
|
if (!dol_mkdir($tempDir)) {
|
|
$this->error = 'Cannot create backup directory';
|
|
return false;
|
|
}
|
|
|
|
// Export database tables
|
|
$dbData = $this->exportDatabaseTables();
|
|
if ($dbData === false) {
|
|
return false;
|
|
}
|
|
|
|
// Save database export as JSON
|
|
$dbFile = $tempDir.'/database.json';
|
|
if (file_put_contents($dbFile, json_encode($dbData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) === false) {
|
|
$this->error = 'Cannot write database file';
|
|
return false;
|
|
}
|
|
|
|
// Create metadata file
|
|
$metadata = array(
|
|
'version' => '3.6.0',
|
|
'created' => date('Y-m-d H:i:s'),
|
|
'tables' => array_keys($dbData),
|
|
'record_counts' => array(),
|
|
'includes_files' => $includeFiles,
|
|
);
|
|
foreach ($dbData as $table => $records) {
|
|
$metadata['record_counts'][$table] = count($records);
|
|
}
|
|
file_put_contents($tempDir.'/metadata.json', json_encode($metadata, JSON_PRETTY_PRINT));
|
|
|
|
// Copy uploaded files if requested
|
|
if ($includeFiles) {
|
|
$filesDir = $conf->kundenkarte->dir_output.'/anlagen';
|
|
if (is_dir($filesDir)) {
|
|
$this->copyDirectory($filesDir, $tempDir.'/files');
|
|
}
|
|
}
|
|
|
|
// Create ZIP archive
|
|
$zipFile = $backupDir.'/'.$backupName.'.zip';
|
|
if (!$this->createZipArchive($tempDir, $zipFile)) {
|
|
$this->error = 'Cannot create ZIP archive';
|
|
return false;
|
|
}
|
|
|
|
// Clean up temp directory
|
|
$this->deleteDirectory($tempDir);
|
|
|
|
return $zipFile;
|
|
}
|
|
|
|
/**
|
|
* Export all database tables
|
|
*
|
|
* @return array|false Array of table data or false on error
|
|
*/
|
|
private function exportDatabaseTables()
|
|
{
|
|
global $conf;
|
|
|
|
$data = array();
|
|
|
|
foreach ($this->tables as $table) {
|
|
$fullTable = MAIN_DB_PREFIX.$table;
|
|
|
|
// Check if table exists
|
|
$sql = "SHOW TABLES LIKE '".$this->db->escape($fullTable)."'";
|
|
$resql = $this->db->query($sql);
|
|
if (!$resql || $this->db->num_rows($resql) == 0) {
|
|
continue; // Skip non-existent tables
|
|
}
|
|
|
|
$records = array();
|
|
$sql = "SELECT * FROM ".$fullTable;
|
|
$sql .= " WHERE entity = ".((int) $conf->entity);
|
|
|
|
$resql = $this->db->query($sql);
|
|
if ($resql) {
|
|
while ($obj = $this->db->fetch_array($resql)) {
|
|
$records[] = $obj;
|
|
}
|
|
$this->db->free($resql);
|
|
}
|
|
|
|
$data[$table] = $records;
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Restore from a backup file
|
|
*
|
|
* @param string $backupFile Path to backup ZIP file
|
|
* @param bool $clearExisting Clear existing data before restore
|
|
* @return bool True on success, false on error
|
|
*/
|
|
public function restoreBackup($backupFile, $clearExisting = false)
|
|
{
|
|
global $conf, $user;
|
|
|
|
if (!file_exists($backupFile)) {
|
|
$this->error = 'Backup file not found';
|
|
return false;
|
|
}
|
|
|
|
// Create temp directory for extraction
|
|
$tempDir = $conf->kundenkarte->dir_output.'/backups/restore_'.uniqid();
|
|
if (!dol_mkdir($tempDir)) {
|
|
$this->error = 'Cannot create temp directory';
|
|
return false;
|
|
}
|
|
|
|
// Extract ZIP
|
|
$zip = new ZipArchive();
|
|
if ($zip->open($backupFile) !== true) {
|
|
$this->error = 'Cannot open backup file';
|
|
return false;
|
|
}
|
|
$zip->extractTo($tempDir);
|
|
$zip->close();
|
|
|
|
// Read metadata
|
|
$metadataFile = $tempDir.'/metadata.json';
|
|
if (!file_exists($metadataFile)) {
|
|
$this->error = 'Invalid backup: metadata.json not found';
|
|
$this->deleteDirectory($tempDir);
|
|
return false;
|
|
}
|
|
$metadata = json_decode(file_get_contents($metadataFile), true);
|
|
|
|
// Read database data
|
|
$dbFile = $tempDir.'/database.json';
|
|
if (!file_exists($dbFile)) {
|
|
$this->error = 'Invalid backup: database.json not found';
|
|
$this->deleteDirectory($tempDir);
|
|
return false;
|
|
}
|
|
$dbData = json_decode(file_get_contents($dbFile), true);
|
|
|
|
$this->db->begin();
|
|
|
|
try {
|
|
// Clear existing data if requested
|
|
if ($clearExisting) {
|
|
$this->clearExistingData();
|
|
}
|
|
|
|
// Import database tables (in correct order)
|
|
foreach ($this->tables as $table) {
|
|
if (isset($dbData[$table])) {
|
|
$this->importTable($table, $dbData[$table]);
|
|
}
|
|
}
|
|
|
|
// Restore files if included
|
|
if (!empty($metadata['includes_files']) && is_dir($tempDir.'/files')) {
|
|
$filesDir = $conf->kundenkarte->dir_output.'/anlagen';
|
|
if (!is_dir($filesDir)) {
|
|
dol_mkdir($filesDir);
|
|
}
|
|
$this->copyDirectory($tempDir.'/files', $filesDir);
|
|
}
|
|
|
|
$this->db->commit();
|
|
} catch (Exception $e) {
|
|
$this->db->rollback();
|
|
$this->error = $e->getMessage();
|
|
$this->deleteDirectory($tempDir);
|
|
return false;
|
|
}
|
|
|
|
// Clean up
|
|
$this->deleteDirectory($tempDir);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Clear existing data for this entity
|
|
*/
|
|
private function clearExistingData()
|
|
{
|
|
global $conf;
|
|
|
|
// Delete in reverse order to respect foreign keys
|
|
$reverseTables = array_reverse($this->tables);
|
|
|
|
foreach ($reverseTables as $table) {
|
|
$fullTable = MAIN_DB_PREFIX.$table;
|
|
|
|
// Check if table exists
|
|
$sql = "SHOW TABLES LIKE '".$this->db->escape($fullTable)."'";
|
|
$resql = $this->db->query($sql);
|
|
if (!$resql || $this->db->num_rows($resql) == 0) {
|
|
continue;
|
|
}
|
|
|
|
$sql = "DELETE FROM ".$fullTable." WHERE entity = ".((int) $conf->entity);
|
|
$this->db->query($sql);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Import data into a table
|
|
*
|
|
* @param string $table Table name (without prefix)
|
|
* @param array $records Array of records
|
|
*/
|
|
private function importTable($table, $records)
|
|
{
|
|
global $conf;
|
|
|
|
if (empty($records)) {
|
|
return;
|
|
}
|
|
|
|
$fullTable = MAIN_DB_PREFIX.$table;
|
|
|
|
// Check if table exists
|
|
$sql = "SHOW TABLES LIKE '".$this->db->escape($fullTable)."'";
|
|
$resql = $this->db->query($sql);
|
|
if (!$resql || $this->db->num_rows($resql) == 0) {
|
|
return;
|
|
}
|
|
|
|
// Get column info
|
|
$columns = array();
|
|
$sql = "SHOW COLUMNS FROM ".$fullTable;
|
|
$resql = $this->db->query($sql);
|
|
while ($obj = $this->db->fetch_object($resql)) {
|
|
$columns[] = $obj->Field;
|
|
}
|
|
|
|
foreach ($records as $record) {
|
|
// Build insert statement
|
|
$fields = array();
|
|
$values = array();
|
|
|
|
foreach ($record as $field => $value) {
|
|
if (!in_array($field, $columns)) {
|
|
continue; // Skip unknown columns
|
|
}
|
|
|
|
$fields[] = $field;
|
|
|
|
if ($value === null) {
|
|
$values[] = 'NULL';
|
|
} elseif (is_numeric($value)) {
|
|
$values[] = $value;
|
|
} else {
|
|
$values[] = "'".$this->db->escape($value)."'";
|
|
}
|
|
}
|
|
|
|
if (empty($fields)) {
|
|
continue;
|
|
}
|
|
|
|
$sql = "INSERT INTO ".$fullTable." (".implode(', ', $fields).") VALUES (".implode(', ', $values).")";
|
|
$sql .= " ON DUPLICATE KEY UPDATE ";
|
|
|
|
$updates = array();
|
|
foreach ($fields as $i => $field) {
|
|
if ($field != 'rowid') {
|
|
$updates[] = $field." = ".$values[$i];
|
|
}
|
|
}
|
|
$sql .= implode(', ', $updates);
|
|
|
|
if (!$this->db->query($sql)) {
|
|
throw new Exception('Error importing '.$table.': '.$this->db->lasterror());
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get list of available backups
|
|
*
|
|
* @return array Array of backup info
|
|
*/
|
|
public function getBackupList()
|
|
{
|
|
global $conf;
|
|
|
|
$backups = array();
|
|
$backupDir = $conf->kundenkarte->dir_output.'/backups';
|
|
|
|
if (!is_dir($backupDir)) {
|
|
return $backups;
|
|
}
|
|
|
|
$files = glob($backupDir.'/kundenkarte_backup_*.zip');
|
|
if ($files) {
|
|
foreach ($files as $file) {
|
|
$filename = basename($file);
|
|
// Extract date from filename
|
|
if (preg_match('/kundenkarte_backup_(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})\.zip/', $filename, $matches)) {
|
|
$date = str_replace('_', ' ', $matches[1]);
|
|
$date = str_replace('-', ':', substr($date, 11));
|
|
$date = substr($matches[1], 0, 10).' '.$date;
|
|
|
|
$backups[] = array(
|
|
'file' => $file,
|
|
'filename' => $filename,
|
|
'date' => $date,
|
|
'size' => filesize($file),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort by date descending
|
|
usort($backups, function ($a, $b) {
|
|
return strcmp($b['date'], $a['date']);
|
|
});
|
|
|
|
return $backups;
|
|
}
|
|
|
|
/**
|
|
* Delete a backup file
|
|
*
|
|
* @param string $filename Backup filename
|
|
* @return bool
|
|
*/
|
|
public function deleteBackup($filename)
|
|
{
|
|
global $conf;
|
|
|
|
$file = $conf->kundenkarte->dir_output.'/backups/'.basename($filename);
|
|
if (file_exists($file) && strpos($filename, 'kundenkarte_backup_') === 0) {
|
|
return unlink($file);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Copy directory recursively
|
|
*
|
|
* @param string $src Source directory
|
|
* @param string $dst Destination directory
|
|
*/
|
|
private function copyDirectory($src, $dst)
|
|
{
|
|
if (!is_dir($dst)) {
|
|
dol_mkdir($dst);
|
|
}
|
|
|
|
$dir = opendir($src);
|
|
while (($file = readdir($dir)) !== false) {
|
|
if ($file == '.' || $file == '..') {
|
|
continue;
|
|
}
|
|
|
|
$srcFile = $src.'/'.$file;
|
|
$dstFile = $dst.'/'.$file;
|
|
|
|
if (is_dir($srcFile)) {
|
|
$this->copyDirectory($srcFile, $dstFile);
|
|
} else {
|
|
copy($srcFile, $dstFile);
|
|
}
|
|
}
|
|
closedir($dir);
|
|
}
|
|
|
|
/**
|
|
* Delete directory recursively
|
|
*
|
|
* @param string $dir Directory path
|
|
*/
|
|
private function deleteDirectory($dir)
|
|
{
|
|
if (!is_dir($dir)) {
|
|
return;
|
|
}
|
|
|
|
$files = array_diff(scandir($dir), array('.', '..'));
|
|
foreach ($files as $file) {
|
|
$path = $dir.'/'.$file;
|
|
if (is_dir($path)) {
|
|
$this->deleteDirectory($path);
|
|
} else {
|
|
unlink($path);
|
|
}
|
|
}
|
|
rmdir($dir);
|
|
}
|
|
|
|
/**
|
|
* Create ZIP archive from directory
|
|
*
|
|
* @param string $sourceDir Source directory
|
|
* @param string $zipFile Target ZIP file
|
|
* @return bool
|
|
*/
|
|
private function createZipArchive($sourceDir, $zipFile)
|
|
{
|
|
$zip = new ZipArchive();
|
|
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
|
return false;
|
|
}
|
|
|
|
$sourceDir = realpath($sourceDir);
|
|
$files = new RecursiveIteratorIterator(
|
|
new RecursiveDirectoryIterator($sourceDir),
|
|
RecursiveIteratorIterator::LEAVES_ONLY
|
|
);
|
|
|
|
foreach ($files as $file) {
|
|
if (!$file->isDir()) {
|
|
$filePath = $file->getRealPath();
|
|
$relativePath = substr($filePath, strlen($sourceDir) + 1);
|
|
$zip->addFile($filePath, $relativePath);
|
|
}
|
|
}
|
|
|
|
return $zip->close();
|
|
}
|
|
|
|
/**
|
|
* Get backup statistics
|
|
*
|
|
* @return array Statistics array
|
|
*/
|
|
public function getStatistics()
|
|
{
|
|
global $conf;
|
|
|
|
$stats = array(
|
|
'total_anlagen' => 0,
|
|
'total_files' => 0,
|
|
'total_connections' => 0,
|
|
'total_customers' => 0,
|
|
'files_size' => 0,
|
|
);
|
|
|
|
// Count anlagen
|
|
$sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."kundenkarte_anlage WHERE entity = ".((int) $conf->entity);
|
|
$resql = $this->db->query($sql);
|
|
if ($resql && $obj = $this->db->fetch_object($resql)) {
|
|
$stats['total_anlagen'] = $obj->cnt;
|
|
}
|
|
|
|
// Count files
|
|
$sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_files WHERE entity = ".((int) $conf->entity);
|
|
$resql = $this->db->query($sql);
|
|
if ($resql && $obj = $this->db->fetch_object($resql)) {
|
|
$stats['total_files'] = $obj->cnt;
|
|
}
|
|
|
|
// Count connections
|
|
$sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."kundenkarte_anlage_connection WHERE entity = ".((int) $conf->entity);
|
|
$resql = $this->db->query($sql);
|
|
if ($resql && $obj = $this->db->fetch_object($resql)) {
|
|
$stats['total_connections'] = $obj->cnt;
|
|
}
|
|
|
|
// Count customers with anlagen
|
|
$sql = "SELECT COUNT(DISTINCT fk_soc) as cnt FROM ".MAIN_DB_PREFIX."kundenkarte_anlage WHERE entity = ".((int) $conf->entity);
|
|
$resql = $this->db->query($sql);
|
|
if ($resql && $obj = $this->db->fetch_object($resql)) {
|
|
$stats['total_customers'] = $obj->cnt;
|
|
}
|
|
|
|
// Calculate files size
|
|
$filesDir = $conf->kundenkarte->dir_output.'/anlagen';
|
|
if (is_dir($filesDir)) {
|
|
$stats['files_size'] = $this->getDirectorySize($filesDir);
|
|
}
|
|
|
|
return $stats;
|
|
}
|
|
|
|
/**
|
|
* Get directory size recursively
|
|
*
|
|
* @param string $dir Directory path
|
|
* @return int Size in bytes
|
|
*/
|
|
private function getDirectorySize($dir)
|
|
{
|
|
$size = 0;
|
|
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir)) as $file) {
|
|
if ($file->isFile()) {
|
|
$size += $file->getSize();
|
|
}
|
|
}
|
|
return $size;
|
|
}
|
|
}
|