<?php
/**
* A project is a set of translations within a Text Domain.
* Often a text domain will have just one set, but this allows domains to be split into multiple POT files.
*/
class Loco_package_Project {
/**
* Text Domain in which project lives
* @var Loco_package_TextDomain
*/
private $domain;
/**
* Bundle in which project lives
* @var Loco_package_Bundle
*/
private $bundle;
/**
* Friendly project name, e.g. "Network Admin"
* @var string
*/
private $name;
/**
* Short name used for naming files, e.g "admin"
* @var string
*/
private $slug;
/**
* Configured domain path[s] not including global search paths
* @var Loco_fs_FileList
*/
private $dpaths;
/**
* Additional system domain path[s] added separately from bundle config
* @var Loco_fs_FileList
*/
private $gpaths;
/**
* Directory paths to exclude during target scanning
* @var Loco_fs_FileList
*/
private $xdpaths;
/**
* Locations where POT, PO and MO files may be saved, including standard global paths
* @var Loco_fs_FileFinder
*/
private $target;
/**
* Configured source path[s] not including global search paths
* @var Loco_fs_FileList
*/
private $spaths;
/**
* File and directory paths to exclude from source file extraction
* @var Loco_fs_FileList
*/
private $xspaths;
/**
* Locations where extractable source files may be found
* @var Loco_fs_FileFinder
*/
private $source;
/**
* Explicitly added individual PHP source files
* @var Loco_fs_FileList
*/
private $sfiles;
/**
* Paths globally excluded by bundle-level configuration
* @var Loco_fs_FileList
*/
private $xgpaths;
/**
* POT template file, ideally named "<name>.pot"
* @var Loco_fs_File
*/
private $pot;
/**
* Whether POT file is protected from end-user update and sync operations.
* @var bool
*/
private $potlock = false;
/**
* Construct project from its domain and a descriptive name
* @param Loco_package_Bundle $bundle
* @param Loco_package_TextDomain $domain
* @param string $name
*/
public function __construct( Loco_package_Bundle $bundle, Loco_package_TextDomain $domain, $name ){
$this->setName($name);
$this->bundle = $bundle;
$this->domain = $domain;
// take default slug from domain, avoiding wildcard
$slug = $domain->getName();
if( '*' === $slug ){
$slug = '';
}
$this->slug = $slug;
// sources
$this->sfiles = new Loco_fs_FileList;
$this->spaths = new Loco_fs_FileList;
$this->xspaths = new Loco_fs_FileList;
// targets
$this->dpaths = new Loco_fs_FileList;
$this->gpaths = new Loco_fs_FileList;
$this->xdpaths = new Loco_fs_FileList;
// global
$this->xgpaths = new Loco_fs_FileList;
}
/**
* Split project ID into domain and slug.
* null and "" are meaningfully different. "" means deliberately empty slug, whereas null means default
* @param string <domain>[.<slug>]
* @return string[] [ <domain>, <slug> ]
*/
public static function splitId( $id ){
$r = preg_split('/(?<!\\\\)\\./', $id, 2 );
$domain = stripcslashes($r[0]);
$slug = isset($r[1]) ? stripcslashes($r[1]) : $domain;
return [ $domain, $slug ];
}
/**
* Get ID identifying project uniquely within a bundle
* @return string
*/
public function getId(){
$slug = $this->getSlug();
$domain = (string) $this->getDomain();
if( $slug === $domain ){
return $slug;
}
return addcslashes($domain,'.').'.'.addcslashes($slug,'.');
}
/**
* @return string
*/
public function __toString(){
return $this->name;
}
/**
* Set friendly name of project
* @param string $name
* @return self
*/
public function setName( $name ){
$this->name = (string) $name;
return $this;
}
/**
* Set short name of project
* @param string
* @return Loco_package_Project
*/
public function setSlug( $slug ){
$this->slug = (string) $slug;
return $this;
}
/**
* Get friendly name of project, e.g. "Network Admin"
* @return string
*/
public function getName(){
return $this->name;
}
/**
* Get short name of project, e.g. "admin"
* @return string
*/
public function getSlug(){
return $this->slug;
}
/**
* @return Loco_package_TextDomain
*/
public function getDomain(){
return $this->domain;
}
/**
* @return Loco_package_Bundle
*/
public function getBundle(){
return $this->bundle;
}
/**
* Whether project is the default for its domain.
* @return bool
*/
public function isDomainDefault(){
$slug = $this->getSlug();
$name = $this->getDomain()->getName();
// default if slug matches text domain.
// else special case for Core "default" domain which has empty slug
return $slug === $name || ( 'default' === $name && '' === $slug ) || 1 === count($this->bundle);
}
/**
* Add a root path where translation files may live
* @param string|Loco_fs_File $location
* @return self
*/
public function addTargetDirectory( $location ){
$this->target = null;
$this->dpaths->add( new Loco_fs_Directory($location) );
return $this;
}
/**
* Add a global search path where translation files may live
* @param string|Loco_fs_Directory $location
* @return Loco_package_Project
*/
public function addSystemTargetDirectory( $location ){
$this->target = null;
$this->gpaths->add( new Loco_fs_Directory($location) );
return $this;
}
/**
* Get domain paths configured in project
* @return Loco_fs_FileList
*/
public function getConfiguredTargets(){
return $this->dpaths;
}
/**
* Get system paths added to project after configuration
* @return Loco_fs_FileList
*/
public function getSystemTargets(){
return $this->gpaths;
}
/**
* Get all target directory roots including global search paths
* @return Loco_fs_FileList
*/
public function getDomainTargets(){
return $this->getTargetFinder()->getRootDirectories();
}
/**
* Lazy create all searchable domain paths including global directories
* @return Loco_fs_FileFinder
*/
private function getTargetFinder(){
if( ! $this->target ){
$target = new Loco_fs_FileFinder;
$target->setRecursive(false)->group('pot','po','mo');
foreach( $this->dpaths as $path ){
// TODO search need not be recursive if it was the configured DomainPath
// currently no way to know at this point, so recursing by default.
$target->addRoot( (string) $path, true );
}
foreach( $this->gpaths as $path ){
$target->addRoot( (string) $path, false );
}
$this->excludeTargets( $target );
$this->target = $target;
}
return $this->target;
}
/**
* Utility excludes current exclude paths from target finder
* @return void
*/
private function excludeTargets( Loco_fs_FileFinder $finder ){
foreach( $this->xdpaths as $file ){
if( $path = realpath( (string) $file ) ){
$finder->exclude( $path );
}
}
foreach( $this->xgpaths as $file ){
if( $path = realpath( (string) $file ) ){
$finder->exclude( $path );
}
}
}
/**
* Check if target file or directory is excluded
* @return bool
*/
private function isTargetExcluded( Loco_fs_File $file ){
return $this->xgpaths->has($file) || $this->xdpaths->has($file);
}
/**
* Add a path for excluding in a recursive target file search
* @param string|Loco_fs_File $path
* @return self
*/
public function excludeTargetPath( $path ){
$this->target = null;
$this->xdpaths->add( new Loco_fs_File($path) );
return $this;
}
/**
* Get all paths excluded when searching for targets
* @return Loco_fs_FileList
*/
public function getConfiguredTargetsExcluded(){
return $this->xdpaths;
}
/**
* Get first valid domain path
* @return Loco_fs_Directory
*/
private function getSafeDomainPath(){
// use first configured domain path that exists
foreach( $this->getConfiguredTargets() as $d ){
if( $d->exists() ){
return $d;
}
}
// fallback to unconfigured, but possibly existent folders
$base = $this->getBundle()->getDirectoryPath();
foreach( ['languages','language','lang','l10n','i18n'] as $d ){
$d = new Loco_fs_Directory($d);
$d->normalize($base);
if( $this->isTargetExcluded($d) ){
continue;
}
if( $d->exists() ){
return $d;
}
}
// Give up and place in root
return new Loco_fs_Directory($base);
}
/**
* Lazy create all searchable source paths
* @return Loco_fs_FileFinder
*/
public function getSourceFinder(){
if( ! $this->source ){
$source = new Loco_fs_FileFinder;
$exts = $this->getSourceExtensions();
$source->setRecursive(true)->filterExtensions($exts);
/* @var $file Loco_fs_File */
foreach( $this->spaths as $file ){
$path = realpath( (string) $file );
if( $path && is_dir($path) ){
$source->addRoot( $path, true );
}
}
$this->excludeSources( $source );
$this->source = $source;
}
return $this->source;
}
/**
* @return string[]
*/
public function getSourceExtensions(){
// TODO source extensions should be moved from plugin settings to project settings
$conf = Loco_data_Settings::get();
$exts = $conf->php_alias;
$exts = array_merge( $exts, $conf->jsx_alias );
// ensure we always scan *.php and block.json files
return array_merge( $exts, ['php','json'] );
}
/**
* Utility excludes current exclude paths from passed target finder
* @return void
*/
private function excludeSources( Loco_fs_FileFinder $finder ){
foreach( [$this->xspaths,$this->xgpaths] as $list ){
foreach( $list as $file ){
$real = realpath( (string) $file );
if( is_string($real) && '' !== $real ){
$finder->exclude($real);
}
}
}
}
/**
* Add a root path where source files may live under for this project
* @param string|Loco_fs_File $location
* @return Loco_package_Project
*/
public function addSourceDirectory( $location ){
$this->source = null;
$this->spaths->add( new Loco_fs_File($location) );
return $this;
}
/**
* Add Explicit source file to project config
* @param string|Loco_fs_File $path
* @return Loco_package_Project
*/
public function addSourceFile( $path ){
$this->source = null;
$this->sfiles->add( new Loco_fs_File($path) );
return $this;
}
/**
* Add a file or directory as a source location
* @param string|Loco_fs_File $path
* @return Loco_package_Project
*/
public function addSourceLocation( $path ){
$file = new Loco_fs_File( $path );
if( $file->isDirectory() ){
$this->addSourceDirectory( $file );
}
else {
$this->addSourceFile( $file );
}
return $this;
}
/**
* Get all source directories and files defined in project
* @return Loco_fs_FileList
*/
public function getConfiguredSources(){
$dynamic = $this->spaths->getArrayCopy();
$statics = $this->sfiles->getArrayCopy();
return new Loco_fs_FileList( array_merge( $dynamic, $statics ) );
}
/**
* Test if bundle has configured source files (even if they're excluded by other rules)
* @return bool
*/
public function hasSourceFiles(){
return count( $this->sfiles ) || count( $this->spaths );
}
/**
* Add a path for excluding in source file search
* @param string|Loco_fs_File $path
* @return Loco_package_Project
*/
public function excludeSourcePath( $path ){
$this->source = null;
$this->xspaths->add( new Loco_fs_File($path) );
return $this;
}
/**
* Get all paths excluded when searching for sources
* @return Loco_fs_FileList
*/
public function getConfiguredSourcesExcluded(){
return $this->xspaths;
}
/**
* Add a globally excluded location affecting sources and targets
* @param string|Loco_fs_File $path
* @return Loco_package_Project
*/
public function excludeLocation( $path ){
$this->source = null;
$this->target = null;
$this->xgpaths->add( new Loco_fs_File($path) );
return $this;
}
/**
* Check whether POT file is protected from end-user update and sync operations.
* @return bool
*/
public function isPotLocked(){
return $this->potlock;
}
/**
* Lock POT file to prevent end-user updates0
* @param bool $locked
* @return Loco_package_Project
*/
public function setPotLock( $locked ){
$this->potlock = (bool) $locked;
return $this;
}
/**
* Get full path to template POT (file) whether it exists or nor
* @return Loco_fs_File
*/
public function getPot(){
if( ! $this->pot ){
$slug = $this->getSlug();
$name = ( $slug ?: $this->getDomain()->getName() ).'.pot';
if( '.pot' !== $name ){
// find actual file under configured domain paths
$targets = $this->getConfiguredTargets()->copy();
// always permit POT file in the bundle root (i.e. outside domain path)
if( $this->isDomainDefault() && $this->bundle->hasDirectoryPath() ){
$root = $this->bundle->getDirectoryPath();
$targets->add( new Loco_fs_Directory($root) );
// look in alternative language directories if only root is configured
if( 1 === count($targets) ){
foreach( ['languages','language','lang','l10n','i18n'] as $d ) {
$alt = new Loco_fs_Directory($root.'/'.$d);
if( ! $this->isTargetExcluded($alt) ){
$targets->add($alt);
}
}
}
}
// pot check is for exact name and not recursive
foreach( $targets as $dir ){
$file = new Loco_fs_File($name);
$file->normalize( $dir->getPath() );
if( $file->exists() && ! $this->isTargetExcluded($file) ){
$this->pot = $file;
break;
}
}
}
// fall back to a directory that exists, but where the POT may not
if( ! $this->pot ){
$this->pot = new Loco_fs_File($name);
$this->pot->normalize( (string) $this->getSafeDomainPath() );
}
}
return $this->pot;
}
/**
* Force the use of a known POT file. This could be a PO file if necessary
* @param Loco_fs_File $pot template POT file
* @return Loco_package_Project
*/
public function setPot( Loco_fs_File $pot ){
$this->pot = $pot;
return $this;
}
/**
* Take a guess at most likely POT file under target locations
* @return Loco_fs_File|null Existent file, or null
*/
public function guessPot(){
$slug = $this->getSlug();
if( ! is_string($slug) || '' === $slug ){
$slug = (string) $this->getDomain();
if( '' === $slug ){
$slug = 'default';
}
}
// search only inside bundle for template
$finder = new Loco_fs_FileFinder;
foreach( $this->dpaths as $path ){
$finder->addRoot( (string) $path, true );
}
$this->excludeTargets($finder);
$files = $finder->group('pot','po','mo')->exportGroups();
foreach( ['pot','po'] as $ext ){
/* @var $pot Loco_fs_File */
foreach( $files[$ext] as $pot ){
$name = $pot->filename();
// use exact match on project slug if found
if( $slug === $name ){
return $pot;
}
// support unconventional "{slug}-en_US.{ext}"
foreach( ['-en_US'=>6, '-en'=>3 ] as $tail => $len ){
if( $tail === substr($name,-$len) && $slug === substr($name,0,-$len) ){
return $pot;
}
}
}
}
// Failed to find correctly named POT file,
// but if a single POT file is found we'll use it.
if( 1 === count($files['pot']) ){
return $files['pot'][0];
}
// Either no POT files are found, or multiple are found.
// if the project is the default in its domain, we can try aliases which may be PO
if( $this->isDomainDefault() ){
$options = Loco_data_Settings::get();
if( $aliases = $options->pot_alias ){
$found = [];
/* @var $pot Loco_fs_File */
foreach( $finder as $pot ){
$priority = array_search( $pot->basename(), $aliases, true );
if( false !== $priority ){
$found[$priority] = $pot;
}
}
if( $found ){
ksort( $found );
return current($found);
}
}
}
// failed to guess POT file
return null;
}
/**
* Get all extractable PHP source files found under all source paths
* @return Loco_fs_FileList
*/
public function findSourceFiles(){
$source = $this->getSourceFinder();
// augment file list from directories unless already done so
$list = $this->sfiles->copy();
$crawled = $source->exportGroups();
foreach( $crawled as $ext => $files ){
/* @var Loco_fs_File $file */
foreach( $files as $file ){
$name = $file->filename();
// skip "{name}.min.{ext}" but only if "{name}.{ext}" exists
if( '.min' === substr($name,-4) && file_exists( $file->dirname().'/'.substr($name,0,-4).'.'.$ext ) ){
continue;
}
// .json source files like block.json theme.json etc..
if( 'json' === $ext && 'block' !== $name && 'theme' !== $name ){
// arbitrarily named theme jsons, like onyx.json (twentytwentyfour)
if( ! $this->getBundle()->isTheme() ){
continue;
}
// Skip JED. We will merge these in separately as needed
if( preg_match('/-[0-9a-f]{32}]$/',$name ) ){
continue;
}
// Ok, treat as json schema file. May fail later...
}
$list->add($file);
}
}
return $list;
}
/**
* Get all translation files matching project prefix across target directories
* @param string $ext File extension, usually "po" or "mo"
* @return Loco_fs_LocaleFileList
*/
public function findLocaleFiles( $ext ){
$finder = $this->getTargetFinder();
$list = new Loco_fs_LocaleFileList;
$files = $finder->exportGroups();
$prefix = $this->getSlug();
$domain = $this->domain->getName();
$default = $this->isDomainDefault();
$prefs = Loco_data_Preferences::get();
/* @var $file Loco_fs_File */
foreach( $files[$ext] as $file ){
$file = new Loco_fs_LocaleFile( $file );
// restrict locale by user preference
if( $prefs && ! $prefs->has_locale( $file->getLocale() ) ){
continue;
}
// add file if prefix matches and has a suffix. locale will be validated later
if( $file->getPrefix() === $prefix && $file->getSuffix() ){
$list->addLocalized( $file );
}
// else in some cases a suffix-only file like "el.po" can match
else if( $default && $file->hasSuffixOnly() ){
// theme files under their own directory
if( $file->underThemeDirectory() ){
$list->addLocalized( $file );
}
// check followed links if they were originally under theme dir
else if( ( $link = $finder->getFollowed($file) ) && $link->underThemeDirectory() ){
$list->addLocalized( $file );
}
// WordPress core "default" domain, default project
else if( 'default' === $domain ){
$list->addLocalized( $file );
}
}
}
return $list;
}
/**
* @param string $ext File extension
* @return Loco_fs_FileList
*/
public function findNotLocaleFiles( $ext ){
$list = new Loco_fs_LocaleFileList;
$files = $this->getTargetFinder()->exportGroups();
/* @var $file Loco_fs_LocaleFile */
foreach( $files[$ext] as $file ){
$file = new Loco_fs_LocaleFile( $file );
// add file if it has no locale suffix and is inside the bundle
if( $file->hasPrefixOnly() && ! $file->underGlobalDirectory() ){
$list->add( $file );
}
}
return $list;
}
/**
* Initialize choice of PO file paths for a given locale
* @param Loco_Locale $locale to initialize translation files for
* @return Loco_fs_FileList
*/
public function initLocaleFiles( Loco_Locale $locale ){
$slug = $this->getSlug();
$domain = $this->domain->getName();
$default = $this->isDomainDefault();
$suffix = sprintf( '%s.po', $locale );
$prefix = $slug ? sprintf('%s-',$slug) : '';
$choice = new Loco_fs_FileList;
/* @var Loco_fs_Directory $dir */
foreach( $this->getConfiguredTargets() as $dir ){
// theme files under their own directory normally have no file prefix
if( $default && $dir->underThemeDirectory() ){
$path = $dir->getPath().'/'.$suffix;
}
// all other paths use configured prefix, which may be empty
else {
$path = $dir->getPath().'/'.$prefix.$suffix;
}
$choice->add( new Loco_fs_LocaleFile($path) );
}
if( 'default' === $domain || '*' === $domain ){
$domain = '';
}
/* @var Loco_fs_Directory $dir */
foreach( $this->getSystemTargets() as $dir ){
$path = $dir->getPath();
// themes and plugins under global locations will be loaded by domain, regardless of prefix
if( ( '/themes' === substr($path,-7) || '/plugins' === substr($path,-8) ) && '' !== $domain ){
$path .= '/'.$domain.'-'.$suffix;
}
// all other paths (probably core) use the configured prefix, which may be empty
else {
$path .= '/'.$prefix.$suffix;
}
$choice->add( new Loco_fs_LocaleFile($path) );
}
return $choice;
}
/**
* Initialize a PO file path from required location
* @return Loco_fs_LocaleFile
* @throws Loco_error_Exception
*/
public function initLocaleFile( Loco_fs_Directory $dir, Loco_Locale $locale ){
$choice = $this->initLocaleFiles($locale);
$pattern = '!^'.preg_quote($dir->getPath(),'!').'/[^/.]+\\.po$!';
/* @var Loco_fs_LocaleFile $file */
foreach( $choice as $file ){
if( preg_match($pattern,$file->getPath()) ){
return $file;
}
}
throw new Loco_error_Exception('Unexpected file location: '.$dir );
}
/**
* Get newest timestamp of all translation files (includes template, but excludes source files)
* @return int
*/
public function getLastUpdated(){
$t = 0;
$file = $this->getPot();
if( $file && $file->exists() ){
$t = $file->modified();
}
/* @var Loco_fs_File $file */
foreach( $this->findLocaleFiles('po') as $file ){
$t = max( $t, $file->modified() );
}
return $t;
}
}