egroupware/api/src/Framework/Bundle.php

393 lines
14 KiB
PHP
Raw Normal View History

<?php
/**
* EGroupware API - Bundle JS includes
*
* @link http://www.egroupware.org
* @author Ralf Becker <RalfBecker-AT-outdoor-training.de>
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
* @package api
* @subpackage framework
* @access public
* @version $Id$
*/
namespace EGroupware\Api\Framework;
use EGroupware\Api\Cache;
use EGroupware\Api\Header\UserAgent;
/**
* Bundle JS includes
*/
class Bundle
{
/**
* Url of minified version of bundle
*
* @var array
*/
static $bundle2minurl = array(
'api' => '/api/js/jsapi.min.js',
'et2' => '/api/js/etemplate/etemplate2.min.js',
'et21'=> '/api/js/etemplate/etemplate2.min.js',
'pixelegg' => '/pixelegg/js/fw_pixelegg.min.js',
'jdots' => '/jdots/js/fw_jdots.min.js',
'mobile' => '/pixelegg/js/fw_mobile.min.js',
);
/**
* Devide js-includes in bundles of javascript files to include eg. api or etemplate2, if minifying is enabled
*
* @param array $js_includes files to include with egw relative url
2021-06-05 20:39:39 +02:00
* @param array& $to_include on return map file => bundle
* @return array egw relative urls to include incl. bundels/minify urls, if enabled
*/
2021-06-05 20:39:39 +02:00
public static function js_includes(array $js_includes, array &$to_include=null)
{
$file2bundle = array();
if ($GLOBALS['egw_info']['server']['debug_minify'] !== 'True')
{
// get used bundles and cache them on tree-level for 2h
//$bundles = self::all(); Cache::setTree(__CLASS__, 'bundles', $bundles, 7200);
$bundles = Cache::getTree(__CLASS__, 'bundles', array(__CLASS__, 'all'), array(), 7200);
$bundles_ts = $bundles['.ts'];
unset($bundles['.ts']);
foreach($bundles as $name => $files)
{
// to facilitate move to new api/et2 location, can be removed after 16.1 release
if ($name == 'et21' && !in_array('/api/js/etemplate/etemplate2.js', $files) ||
2016-06-06 17:38:20 +02:00
$name == 'api' && !in_array('/vendor/bower-asset/jquery/dist/jquery.js', $files))
{
Cache::unsetTree(__CLASS__, 'bundles');
return self::js_includes($js_includes);
}
// ignore bundles of not used templates, as they can contain identical files
if (in_array($name, array('api', 'et2', 'et21')) ||
$name == (UserAgent::mobile() ? 'mobile' : $GLOBALS['egw_info']['server']['template_set']) ||
isset($GLOBALS['egw_info']['apps'][$name]))
{
$file2bundle += array_combine($files, array_fill(0, count($files), $name));
}
}
//error_log(__METHOD__."() file2bundle=".array2string($file2bundle));
}
$to_include = $included_bundles = array();
$query = null;
foreach($js_includes as $file)
{
if ($file == '/api/js/jsapi/egw.js') continue; // loaded via own tag, and we must not load it twice!
if (!isset($to_include[$file]))
{
if (($bundle = $file2bundle[$file]))
{
//error_log(__METHOD__."() requiring bundle $bundle for $file");
if (!in_array($bundle, $included_bundles))
{
$included_bundles[] = $bundle;
$minurl = self::$bundle2minurl[$bundle];
if (!isset($minurl) && isset($GLOBALS['egw_info']['apps'][$bundle]))
{
$minurl = '/'.$bundle.'/js/app.min.js';
}
$max_modified = 0;
$to_include = array_merge($to_include, self::urls($bundles[$bundle], $max_modified, $minurl));
// check if bundle-config is more recent then
if ($max_modified > $bundles_ts)
{
// force new bundle Config by deleting cached one and call ourself again
Cache::unsetTree(__CLASS__, 'bundles');
return self::js_includes($js_includes);
}
}
}
else
{
unset($query);
list($path, $query) = explode('?', $file, 2);
$mod = filemtime(EGW_SERVER_ROOT.$path);
// check if we have a more recent minified version of the file and use it
if ($GLOBALS['egw_info']['server']['debug_minify'] !== 'True' &&
substr($path, -3) == '.js' && file_exists(EGW_SERVER_ROOT.($min_path = substr($path, 0, -3).'.min.js')) &&
(($min_mod = filemtime(EGW_SERVER_ROOT.$min_path)) >= $mod))
{
$path = $min_path;
$mod = $min_mod;
}
$to_include[$file] = $path.'?'.$mod.($query ? '&'.$query : '');
}
}
}
//error_log(__METHOD__."(".array2string($js_includes).') debug_minify='.array2string($GLOBALS['egw_info']['server']['debug_minify']).', include_bundels='.array2string($included_bundles).' returning '.array2string(array_values(array_unique($to_include))));
return array_values(array_unique($to_include));
}
/**
* Generate bundle url(s) for given js files
*
* @param array $js_includes
* @param int& $max_modified =null on return maximum modification time of bundle
* @param string $minurl =null url of minified bundle, to be used, if existing and recent
* @return array js-files (can be more then one, if one of given files can not be bundeled)
*/
protected static function urls(array $js_includes, &$max_modified=null, $minurl=null)
{
$debug_minify = $GLOBALS['egw_info']['server']['debug_minify'] === 'True';
// ignore not existing minurl
2016-04-30 16:30:13 +02:00
if (!empty($minurl) && !file_exists(EGW_SERVER_ROOT.$minurl)) $minurl = null;
$to_include_first = $to_include = $to_minify = array();
$max_modified = 0;
$query = null;
foreach($js_includes as $path)
{
if ($path == '/api/js/jsapi/egw.js') continue; // Leave egw.js out of bundle
unset($query);
list($path,$query) = explode('?',$path,2);
$mod = filemtime(EGW_SERVER_ROOT.$path);
if ($mod > $max_modified) $max_modified = $mod;
// TinyMCE must be included before bundled files, as it depends on it!
if (strpos($path, '/tinymce/tinymce.min.js') !== false)
{
$to_include_first[] = $path . '?' . $mod;
}
// for now minify does NOT support query parameters, nor php files generating javascript
2016-04-30 16:30:13 +02:00
elseif ($debug_minify || $query || substr($path, -3) != '.js' || empty($minurl))
{
$path .= '?'. $mod.($query ? '&'.$query : '');
$to_include[] = $path;
}
else
{
$to_minify[] = substr($path,1);
}
}
if (!$debug_minify && $to_minify)
{
2016-04-30 16:30:13 +02:00
$path = $minurl.'?'.filemtime(EGW_SERVER_ROOT.$minurl);
/* no more dynamic minifying
if (!empty($minurl) && file_exists(EGW_SERVER_ROOT.$minurl) &&
($mod=filemtime(EGW_SERVER_ROOT.$minurl)) >= $max_modified)
{
$path = $minurl.'?'.$mod;
}
else
{
$base_path = $GLOBALS['egw_info']['server']['webserver_url'];
if ($base_path[0] != '/') $base_path = parse_url($base_path, PHP_URL_PATH);
$path = '/phpgwapi/inc/min/?'.($base_path && $base_path != '/' ? 'b='.substr($base_path, 1).'&' : '').
'f='.implode(',', $to_minify) .
($GLOBALS['egw_info']['server']['debug_minify'] === 'debug' ? '&debug' : '').
'&'.$max_modified;
2016-04-30 16:30:13 +02:00
}*/
// need to include minified javascript before not minified stuff like jscalendar-setup, as it might depend on it
array_unshift($to_include, $path);
}
if ($to_include_first) $to_include = array_merge($to_include_first, $to_include);
//error_log(__METHOD__."("./*array2string($js_includes).*/", $max_modified, $minurl) returning ".array2string($to_include));
return $to_include;
}
/**
* Maximum number of files in a bundle
*
* We split bundles, if they contain more then these number of files,
* because IE silently stops caching them, if Content-Length get's too big.
*
* IE11 cached 142kb compressed api bundle, but not 190kb et2 bundle.
* Splitting et2 bundle in max 50 files chunks, got IE11 to cache both bundles.
*/
const MAX_BUNDLE_FILES = 50;
/**
* Apps which should be their own bundle:
* - own eT2 widgets
* - not just an app.js or a huge one
*/
const BUNDLE_APPS = ['calendar', 'mail', 'projectmanager', 'smallpart'];
/**
* Return all bundels we use:
* - api stuff phpgwapi/js/jsapi/* and it's dependencies incl. jquery
* - etemplate2 stuff not including api bundle, but jquery-ui
*
* @param bool $all_apps=false true: return bundle for every app with an app.js/ts
* @return array bundle-url => array of contained files
*/
public static function all(bool $all_apps = false)
{
$inc_mgr = new IncludeMgr();
$bundles = array();
$max_mod = array();
// generate api bundle
2016-06-06 17:38:20 +02:00
$inc_mgr->include_js_file('/vendor/bower-asset/jquery/dist/jquery.js');
$inc_mgr->include_js_file('/api/js/jquery/jquery.noconflict.js');
2016-06-06 17:38:20 +02:00
$inc_mgr->include_js_file('/vendor/bower-asset/jquery-ui/jquery-ui.js');
$inc_mgr->include_js_file('/api/js/jsapi/jsapi.js');
$inc_mgr->include_js_file('/api/js/egw_json.js');
$inc_mgr->include_js_file('/api/js/jsapi/egw.js');
// dhtmlxTree (dhtmlxMenu get loaded via dependency in egw_menu_dhtmlx.js)
$inc_mgr->include_js_file('/api/js/dhtmlxtree/codebase/dhtmlxcommon.js');
$inc_mgr->include_js_file('/api/js/dhtmlxtree/sources/dhtmlxtree.js');
$inc_mgr->include_js_file('/api/js/dhtmlxtree/sources/ext/dhtmlxtree_json.js');
// actions
$inc_mgr->include_js_file('/api/js/egw_action/egw_action.js');
$inc_mgr->include_js_file('/api/js/egw_action/egw_keymanager.js');
$inc_mgr->include_js_file('/api/js/egw_action/egw_action_popup.js');
$inc_mgr->include_js_file('/api/js/egw_action/egw_action_dragdrop.js');
$inc_mgr->include_js_file('/api/js/egw_action/egw_dragdrop_dhtmlx_tree.js');
$inc_mgr->include_js_file('/api/js/egw_action/egw_menu.js');
$inc_mgr->include_js_file('/api/js/egw_action/egw_menu_dhtmlx.js');
// include choosen in api, as old eTemplate uses it and fail if it pulls in half of et2
$inc_mgr->include_js_file('/api/js/jquery/chosen/chosen.jquery.js');
$bundles['api'] = $inc_mgr->get_included_files();
self::urls($bundles['api'], $max_mod['api']);
// generate et2 bundle (excluding files in api bundle)
$inc_mgr->include_js_file('/api/js/etemplate/etemplate2.js');
$bundles['et2'] = array_diff($inc_mgr->get_included_files(), $bundles['api']);
self::urls($bundles['et2'], $max_mod['et2']);
$stock_files = array_merge(...array_values($bundles));
// generate template and app bundles, if installed
foreach([
'jdots' => '/jdots/js/fw_jdots.js',
'mobile' => '/pixelegg/js/fw_mobile.js',
'pixelegg' => '/pixelegg/js/fw_pixelegg.js',
]+($all_apps ? scandir(EGW_SERVER_ROOT) : self::BUNDLE_APPS) as $bundle => $file)
{
if (is_int($bundle))
{
$bundle = $file;
$file = "/$bundle/js/app.js";
}
if (@file_exists(EGW_SERVER_ROOT.$file))
{
$inc_mgr = new IncludeMgr($stock_files); // reset loaded files to stock files
$inc_mgr->include_js_file($file);
$bundles[$bundle] = array_diff($inc_mgr->get_included_files(), $stock_files);
self::urls($bundles[$bundle], $max_mod[$bundle]);
}
}
// automatic split bundles with more then MAX_BUNDLE_FILES (=50) files
foreach($bundles as $name => $files)
{
$n = '';
while (count($files) > self::MAX_BUNDLE_FILES*(int)$n)
{
$files80 = array_slice($files, self::MAX_BUNDLE_FILES*(int)$n, self::MAX_BUNDLE_FILES, true);
$bundles[$name.$n++] = $files80;
}
}
// store max modification time of all files in all bundles
$bundles['.ts'] = max($max_mod);
//error_log(__METHOD__."() returning ".array2string($bundles));
return $bundles;
}
/**
* some files are not in a bundle, because loaded otherwise or are big enough themselves
*
* @var array
*/
static public $exclude = [
// api/js/jsapi/egw.js loaded via own tag, and we must not load it twice!
'api/js/jsapi/egw.js',
// TinyMCE is loaded separate before the bundle
'vendor/tinymce/tinymce/tinymce.min.js',
// CRM.js from addressbook is also used in infolog, so it can't be bundled with either!
'addressbook/js/CRM.js',
];
/**
* Generate importmap for whole instance
*
* It need to be for the whole instance incl. all app.js, as it does not get reloaded, when we execute
* apps via ajax!
*
* @ToDo new-js-loader: use static file in filesystem updated when js-files get minified (for minified only!)
*
* @return array
*/
public static function getImportMap()
{
$minified = empty($GLOBALS['egw_info']['server']['debug_minify']);
// cache map for the whole tree to use
return Cache::getTree('api', 'importmap'.($minified?'-minified':''), static function()
{
$gruntfile = EGW_SERVER_ROOT . '/Gruntfile.js';
if (!($content = @file_get_contents($gruntfile)))
{
die("\nFile '$gruntfile' not found!\n\n");
}
if (!preg_match('/grunt\.initConfig\(({.+})\);/s', $content, $matches) ||
!($json = preg_replace('/^(\s*)([a-z0-9_-]+):/mi', '$1"$2":', $matches[1])) ||
!($config = json_decode($json, true)))
{
die("\nCan't parse $gruntfile!\n\n");
}
if (($prefix = parse_url($GLOBALS['egw_info']['server']['webserver_url'], PHP_URL_PATH)) === '/') $prefix = '';
$uglify = $config['terser'];
unset($config, $uglify['options']);
$map = [];
foreach (self::all(true) as $name => $files)
{
if ($name == '.ts') continue; // ignore timestamp
// some files are not in a bundle, because they are big enough themselves or otherwise excluded
foreach (self::$exclude as $file)
{
if (($key = array_search($file, $files)))
{
$map[$prefix . $file] = $prefix . $file . '?' . filemtime(EGW_SERVER_ROOT . $file);
unset($files[$key]);
}
}
if (isset($uglify[$name]))
{
$target = key($uglify[$name]['files']);
$uglify[$name]['files'][$target] = array_values($files);
}
elseif (isset($uglify[$append = substr($name, 0, -1)]))
{
reset($uglify[$append]['files']);
$target = key($uglify[$append]['files']);
$uglify[$append]['files'][$target] = array_merge($uglify[$append]['files'][$target], array_values($files));
}
else // create new bundle using last file as target
{
$target = str_replace('.js', '.min.js', end($files));
$uglify[$name]['files'][$target] = array_values($files);
}
if ($target[0] !== '/') $target = '/' . $target;
$use_bundle = in_array($name, array_merge(['api', 'et2'], Bundle::BUNDLE_APPS)) &&
empty($GLOBALS['egw_info']['server']['debug_minify']);
foreach ($files as $file)
{
// use bundle / minified url as target or not
if (!$use_bundle) $target = $file;
$map[$prefix . $file] = $prefix.$target.'?'.filemtime(EGW_SERVER_ROOT.$target);
// typescript unfortunately has currently no option to add ".js" to it's es6 import statements
// therefore we add extra entries without .js extension to the map if (file_exists(EGW_SERVER_ROOT.substr($file, 0, -3) . '.ts'))
{
$map[$prefix . substr($file, 0, -3)] = $prefix.$target.'?'.filemtime(EGW_SERVER_ROOT.$target);
}
}
}
return $map;
}, [], 30);
}
}