<?php
/**
* A bundle may use one or more text domains, and may or may not physically house them.
* Essentially a bundle "uses" a text domain.
* Types are "theme", "plugin" and "core"
*/
abstract class Loco_package_Bundle extends ArrayObject implements JsonSerializable {
/**
* Internal handle for targeting in WordPress, e.g. "twentyfifteen" or "loco-translate/loco.php"
* @var string
*/
private $handle;
/**
* Short name, e.g. "twentyfifteen" or "loco-translate"
* @var string
*/
private $slug;
/**
* Friendly name, e.g. "Twenty Fifteen
* @var string
*/
private $name;
/**
* Full path to root directory of bundle
* @var Loco_fs_Directory
*/
private $root;
/**
* Directory paths to exclude from all projects
* @var Loco_fs_FileList
*/
private $xpaths;
/**
* Full path to PHP bootstrap file
* @var string
*/
private $boot;
/**
* Whether bundle is a single file, as opposed to in its own directory
* @var bool
*/
protected $solo;
/**
* Method with which bundle has been configured
* @var string|false (file|db|meta|internal)
*/
private $saved = false;
/**
* Get system (i.e. "global") target locations for all projects of this type.
* These are always append to configs, and always excluded from serialization
* @return string[] absolute directory paths
*/
abstract public function getSystemTargets();
/**
* Get canonical info registered with WordPress, i.e. plugin or theme headers
* @return Loco_package_Header
*/
abstract public function getHeaderInfo();
/**
* Get built-in translatable values mapped to annotation for translators
* @return array
*/
abstract public function getMetaTranslatable();
/**
* Get type of Bundle (title case)
* @return string
*/
abstract public function getType();
/**
* Get absolute URL to bundle root, with trailing slash
* @return string
*/
abstract public function getDirectoryUrl();
/**
* Construct bundle from unique ID containing type and handle
* @return self
*/
public static function fromId( $id ){
$r = explode( '.', $id, 2 );
return self::createType( $r[0], isset($r[1]) ? $r[1] : '' );
}
/**
* @param string $type
* @param string $handle
* @return self
* @throws Loco_error_Exception
*/
public static function createType( $type, $handle ){
$func = [ 'Loco_package_'.ucfirst($type), 'create' ];
if( is_callable($func) ){
$bundle = call_user_func( $func, $handle );
}
else {
throw new Loco_error_Exception('Unexpected bundle type: '.$type );
}
return $bundle;
}
/**
* Resolve a file path to a plugin, theme or the core
* @return self|null
*/
public static function fromFile( Loco_fs_File $file ){
if( $file->underThemeDirectory() ){
return Loco_package_Theme::fromFile($file);
}
else if( $file->underPluginDirectory() ){
return Loco_package_Plugin::fromFile($file);
}
else if( $file->underWordPressDirectory() && ! $file->underContentDirectory() ){
return Loco_package_Core::create();
}
else {
return null;
}
}
/**
* Construct from WordPress handle and friendly name
* @param string $handle
* @param string $name
*/
public function __construct( $handle, $name ){
parent::__construct();
$this->setHandle($handle)->setName($name);
$this->xpaths = new Loco_fs_FileList;
}
/**
* Re-fetch this bundle from its currently saved location
* @return self
*/
public function reload(){
return call_user_func( [ get_class($this), 'create' ], $this->getSlug() );
}
/**
* Get ID that uniquely identifies bundle by its type and handle
* @return string
*/
public function getId(){
$type = strtolower( $this->getType() );
return $type.'.'.$this->getHandle();
}
/**
* @return string
*/
public function __toString(){
return $this->name;
}
/**
* @return bool
*/
public function isTheme(){
return false;
}
/**
* Get parent bundle if possible. This can only be a theme.
* @codeCoverageIgnore
* @return Loco_package_Theme|null
*/
public function getParent(){
trigger_error( $this->getType().' bundles cannot have parents. Check isTheme first');
return null;
}
/**
* @return bool
*/
public function isPlugin(){
return false;
}
/**
* Get handle of bundle unique for its type, e.g. "twentyfifteen" or "loco-translate/loco.php"
* @return string
*/
public function getHandle(){
return $this->handle;
}
/**
* Attempt to get the vendor-specific slug, which may or may not be the same as the internal handle
* @return string
*/
public function getSlug(){
if( $slug = $this->slug ){
return $slug;
}
// fall back to runtime handle
return $this->getHandle();
}
/**
* Set friendly name of bundle
* @return self
*/
public function setName( $name ){
$this->name = (string) $name;
return $this;
}
/**
* Set short name of bundle which may or may not match unique handle
* @return self
*/
public function setSlug( $slug ){
$this->slug = (string) $slug;
return $this;
}
/**
* Set internal handle registered with WordPress for this bundle type
* @return self
*/
public function setHandle( $handle ){
$this->handle = (string) $handle;
return $this;
}
/**
* Get friendly name of bundle, e.g. "Twenty Fifteen" or "Loco Translate"
* @return string
*/
public function getName(){
return $this->name;
}
/**
* Whether bundle root is currently known
* @return bool
*/
public function hasDirectoryPath(){
return (bool) $this->root;
}
/**
* Set root directory for bundle. e.g. theme or plugin directory
* @return self
*/
public function setDirectoryPath( $path ){
$this->root = new Loco_fs_Directory( $path );
$this->root->normalize();
return $this;
}
/**
* Get absolute path to root directory for bundle. e.g. theme or plugin directory
* @return string
*/
public function getDirectoryPath(){
if( $this->root ){
return $this->root->getPath();
}
// without a root directory return WordPress root
return untrailingslashit(ABSPATH);
}
/**
* @return string[]
*/
public function getVendorRoots(){
$dirs = [];
$base = $this->getDirectoryPath();
foreach( ['node_modules','vendor'] as $f ){
$path = $base.'/'.$f;
if( Loco_fs_File::is_readable($path) && is_dir($path) ){
$dirs[] = $path;
}
}
return $dirs;
}
/**
* Get file locations to exclude from all projects in bundle. These are effectively "hidden"
* @return Loco_fs_FileList
*/
public function getExcludedLocations(){
return $this->xpaths;
}
/**
* Add a path for excluding from all projects
* @param Loco_fs_File|string $path
* @return Loco_package_Bundle
*/
public function excludeLocation( $path ){
$this->xpaths->add( new Loco_fs_File($path) );
return $this;
}
/**
* Create a file searcher from root location, excluding that which is excluded
* @return Loco_fs_FileFinder
*/
public function getFileFinder(){
$root = $this->getDirectoryPath();
/*/ if bundle is symlinked it's resource files won't be matched properly
if( is_link($root) && ( $real = realpath($root) ) ){
$root = $real;
}*/
$finder = new Loco_fs_FileFinder( $root );
foreach( $this->xpaths as $path ){
$finder->exclude( (string) $path );
}
return $finder;
}
/**
* Get primary PHP source file containing bundle bootstrap code, if applicable
* @return string
*/
public function getBootstrapPath(){
return $this->boot;
}
/**
* Set primary PHP source file containing bundle bootstrap code, if applicable.
* @param string $path to PHP file
* @return Loco_package_Bundle
*/
public function setBootstrapPath( $path ){
$path = (string) $path;
// sanity check this is a PHP file even if it doesn't exist
if( '.php' !== substr($path,-4) ){
throw new Loco_error_Exception('Bootstrap file should end .php, got '.$path );
}
$this->boot = $path;
// base directory can be inferred from bootstrap path
if( ! $this->hasDirectoryPath() ){
$this->setDirectoryPath( dirname($path) );
}
return $this;
}
/**
* Test whether bundle consists of a single file
*/
public function isSingleFile(){
return $this->solo;
}
/**
* Add all projects defined in a TextDomain
* @return self
*/
public function addDomain( Loco_package_TextDomain $domain ){
/* @var Loco_package_Project $proj */
foreach( $domain as $proj ){
$this->addProject($proj);
}
return $this;
}
/**
* Add a translation project to bundle.
* Note that this always adds without checking uniqueness. Call hasProject first if it could be a duplicate
* @return self
*/
public function addProject( Loco_package_Project $project ){
// add global targets
foreach( $this->getSystemTargets() as $path ){
$project->addSystemTargetDirectory( $path );
}
// add global exclusions affecting source and target locations
foreach( $this->xpaths as $path ){
$project->excludeLocation( $path );
}
// projects must be unique by Text Domain and "slug" (used to prefix files)
// however, I am not indexing them here on purpose so domain and slug may be added at any time.
$this[] = $project;
return $this;
}
/**
* Export projects grouped by domain
* @return array indexed by Text Domain name
*/
public function exportGrouped(){
$domains = [];
/* @var $proj Loco_package_Project */
foreach( $this as $proj ){
$domain = $proj->getDomain();
$key = $domain->getName();
$domains[$key][] = $proj;
}
return $domains;
}
/**
* Create a suitable Text Domain from bundle's name.
* Note that internal handle may be a directory name differing entirely from the author's intention, hence the configured bundle name is slugged instead
* @return Loco_package_TextDomain
*/
public function createDomain(){
$slug = sanitize_title( $this->name, $this->slug );
return new Loco_package_TextDomain( $slug );
}
/**
* Generate default configuration.
* Adds a simple one domain, one project config
* @param string|null $domainName optional Text Domain to use
* @return Loco_package_Project
*/
public function createDefault( $domainName = null ){
if( is_null($domainName) ){
$domain = $this->createDomain();
}
else {
$domain = new Loco_package_TextDomain($domainName);
}
$project = $domain->createProject( $this, $this->name );
if( $this->solo ){
$project->addSourceFile( $this->getBootstrapPath() );
}
else {
$project->addSourceDirectory( $this->getDirectoryPath() );
}
$this->addProject( $project );
return $project;
}
/**
* Configure from custom saved option
* @return bool whether configured via database option
*/
public function configureDb(){
$option = $this->getCustomConfig();
if( $option instanceof Loco_config_CustomSaved ){
$option->configure();
$this->saved = 'db';
return true;
}
return false;
}
/**
* Configure from XML config
* @return bool whether configured via static XML file
*/
public function configureXml(){
$xmlfile = $this->getConfigFile();
if( $xmlfile instanceof Loco_fs_File ){
$reader = new Loco_config_BundleReader($this);
$reader->loadXml( $xmlfile );
$this->saved = 'file';
return true;
}
return false;
}
/**
* Get XML configuration file used to define this bundle
* @return Loco_fs_File
*/
public function getConfigFile(){
$base = $this->getDirectoryPath();
$file = new Loco_fs_File( $base.'/loco.xml' );
if( ! $file->exists() || ! loco_check_extension('dom') ){
return null;
}
return $file;
}
/**
* Check whether bundle is manually configured, as opposed to guessed
* @return string|false (file|db|meta|internal)
*/
public function isConfigured(){
return $this->saved;
}
/**
* Do basic configuration from bundle meta data (file headers)
* @param array $header tags from theme or plugin bootstrap file
* @return bool whether configured via header tags
*/
public function configureMeta( array $header ){
if( isset($header['Name']) ){
$this->setName( $header['Name'] );
}
if( isset($header['TextDomain']) && ( $slug = $header['TextDomain'] ) ){
$domain = new Loco_package_TextDomain($slug);
$domain->setCanonical( true );
// use domain as bundle handle and slug if not set when constructed
if( ! $this->handle ){
$this->handle = $slug;
}
if( ! $this->getSlug() ){
$this->setSlug( $slug );
}
$project = $domain->createProject( $this, $this->name );
// May have declared DomainPath
$base = $this->getDirectoryPath();
if( isset($header['DomainPath']) && ( $path = trim($header['DomainPath'],'/') ) ){
$project->addTargetDirectory( $base.'/'.$path );
}
// else use standard language path if it exists
else if( ! $this->solo ){
if( is_dir($base.'/languages') ) {
$project->addTargetDirectory($base.'/languages');
}
// else add bundle root by default
else {
$project->addTargetDirectory($base);
}
}
// single file bundles can have only one source file
if( $this->solo ){
$project->addSourceFile( $this->getBootstrapPath() );
}
// else add bundle root as default source file location
else {
$project->addSourceDirectory( $base );
}
// automatically block common vendor locations
foreach( $this->getVendorRoots() as $root ){
$this->excludeLocation($root);
}
// default domain added
$this->addProject($project);
$this->saved = 'meta';
return true;
}
return false;
}
/**
* Configure bundle from canonical sources.
* Source order is "db","file","meta" where meta is the auto-config fallback.
* No deep scanning is performed at this point
* @param string $base
* @param string[] $header tags from theme or plugin bootstrap file
* @return self
*/
public function configure( $base, array $header ){
$this->setDirectoryPath( $base );
$this->configureDb() || $this->configureXml() || $this->configureMeta($header);
do_action('loco_bundle_configured',$this);
return $this;
}
/**
* Get the custom config saved in WordPress DB for this bundle
* @return Loco_config_CustomSaved|null
*/
public function getCustomConfig(){
$custom = new Loco_config_CustomSaved;
if( $custom->setBundle($this)->fetch() ){
return $custom;
}
return null;
}
/**
* Inherit another bundle. Used for child themes to display parent translations
* @return self
*/
public function inherit( Loco_package_Bundle $parent ){
foreach( $parent as $project ){
if( ! $this->hasProject($project) ){
$this->addProject( $project );
}
}
return $this;
}
/**
* Get unique translation project by text domain (and optionally slug)
* TODO would prefer to avoid iteration, but slug can be changed at any time
* @param string $domain
* @param string|null $slug
* @return Loco_package_Project
*/
public function getProject( $domain, $slug = null ){
if( is_null($slug) ){
$slug = $domain;
}
/* @var $project Loco_package_Project */
foreach( $this as $project ){
if( $project->getSlug() === $slug && $project->getDomain()->getName() === $domain ){
return $project;
}
}
return null;
}
/**
* @return Loco_package_Project|null
*/
public function getDefaultProject(){
$i = 0;
/* @var Loco_package_Project $project */
foreach( $this as $project ){
if( $project->isDomainDefault() ){
return $project;
}
$i++;
}
// nothing is domain default, but if we only have one, then duh
if( 1 === $i ){
return $project;
}
return null;
}
/**
* Test if project already exists in bundle
* @return bool
*/
public function hasProject( Loco_package_Project $project ){
return (bool) $this->getProject( $project->getDomain()->getName(), $project->getSlug() );
}
/**
* @return Loco_package_TextDomain[]
*/
public function getDomains(){
$domains = [];
/* @var $project Loco_package_Project */
foreach( $this as $project ){
if( $domain = $project->getDomain() ){
$d = (string) $domain;
if( ! isset($domains[$d]) ){
$domains[$d] = $domain;
}
}
}
return $domains;
}
/**
* Get newest timestamp of all translation files (includes template, but exclude source files)
* @return int
*/
public function getLastUpdated(){
// recent items is a convenient cache for checking last modified times
$t = Loco_data_RecentItems::get()->hasBundle( $this->getId() );
// else have to scan targets across all projects
if( 0 === $t ){
/* @var Loco_package_Project $project */
foreach( $this as $project ){
$t = max( $t, $project->getLastUpdated() );
}
}
return $t;
}
/**
* Get project by ID
* @param string $id identifier of the form <domain>[.<slug>]
* @return Loco_package_Project
*/
public function getProjectById( $id ){
list( $domain, $slug ) = Loco_package_Project::splitId($id);
return $this->getProject( $domain, $slug );
}
/**
* Reset bundle configuration, but keep metadata like name and slug.
* Call this before applying a saved config, otherwise values will just be added on top.
* @return self
*/
public function clear(){
$this->exchangeArray( [] );
$this->xpaths = new Loco_fs_FileList;
$this->saved = false;
return $this;
}
/**
* @return array
*/
#[ReturnTypeWillChange]
public function jsonSerialize(){
$writer = new Loco_config_BundleWriter( $this );
return $writer->toArray();
}
/**
* Create a copy of this bundle containing any files found that aren't currently configured
* @return self
*/
public function invert(){
return Loco_package_Inverter::compile( $this );
}
}