src/output/Buffer.php000066600000006651152141417370010636 0ustar00output; } /** * @return Loco_output_Buffer */ public static function start(){ $buffer = new Loco_output_Buffer; return $buffer->open(); } /** * @internal * Ensure buffers closed if something terminates before we close gracefully */ public function __destruct(){ $this->close(); } /** * @return Loco_output_Buffer */ public function open(){ self::check(); if( ! ob_start() ){ throw new Loco_error_Exception('Failed to start output buffering'); } $this->ob_level = ob_get_level(); return $this; } /** * @return Loco_output_Buffer */ public function close(){ if( is_int($this->ob_level) ){ // collect output from our nested buffers $this->output = self::collect( $this->ob_level ); $this->ob_level = null; } return $this; } /** * Trash all open buffers, logging any junk output collected * @return void */ public function discard(){ $this->close(); if( '' !== $this->output ){ self::log_junk( $this->output ); $this->output = ''; } } /** * Collect output buffered to a given level * @param int highest buffer to flush, 0 being the root * @return string */ public static function collect( $min ){ $last = 0; $output = ''; while( $level = ob_get_level() ){ // @codeCoverageIgnoreStart if( $level === $last ){ throw new Loco_error_Exception('Failed to close output buffer'); } // @codeCoverageIgnoreEnd if( $level < $min ){ break; } // output is appended inside out: $output = ob_get_clean().$output; $last = $level; } return $output; } /** * Forcefully destroy all open buffers and log any bytes already buffered. * @return void */ public static function clear(){ $junk = self::collect(0); if( '' !== $junk ){ self::log_junk($junk); } } /** * Check output has not already been flushed. * @throws Loco_error_Exception */ public static function check(){ if( headers_sent($file,$line) && 'cli' !== PHP_SAPI ){ $file = str_replace( trailingslashit( loco_constant('ABSPATH') ), '', $file ); throw new Loco_error_Exception( sprintf( __('Loco interrupted by output from %s:%u','loco-translate'), $file, $line ) ); } } /** * Debug collection of junk output * @param string */ private static function log_junk( $junk ){ $bytes = strlen($junk); $message = sprintf("Cleared %s of buffered output", Loco_mvc_FileParams::renderBytes($bytes) ); Loco_error_AdminNotices::debug( $message ); do_action( 'loco_buffer_cleared', $junk ); } } src/output/DiffRenderer.php000066600000003654152141417370011764 0ustar00 true, 'leading_context_lines' => 1, 'trailing_context_lines' => 1, ) ); } /** * Render diff of two files, presumed to be PO or POT * @return string HTML table */ public function renderFiles( Loco_fs_File $lhs, Loco_fs_File $rhs ){ loco_require_lib('compiled/gettext.php'); // attempt to raise memory limit to WP_MAX_MEMORY_LIMIT if( function_exists('wp_raise_memory_limit') ){ wp_raise_memory_limit('loco'); } // like wp_text_diff but avoiding whitespace normalization // uses deprecated signature for 'auto' in case of old WordPress return $this->render( new Text_Diff ( preg_split( '/(?:\\n|\\r\\n?)/', Loco_gettext_Data::ensureUtf8( $lhs->getContents() ) ), preg_split( '/(?:\\n|\\r\\n?)/', Loco_gettext_Data::ensureUtf8( $rhs->getContents() ) ) ) ); } /** * {@inheritdoc} */ public function _startDiff() { return "\n"; } /** * {@inheritdoc} */ public function _endDiff() { return "
\n"; } /** * {@inheritdoc} */ public function _startBlock( $header ) { return '\n"; } /** * {@inheritdoc} */ public function _endBlock() { return "\n"; } } src/package/Plugin.php000066600000020416152141417370010711 0ustar00 'get_plugins', 'WPMU_PLUGIN_DIR' => 'get_mu_plugins', ); foreach( $search as $const => $getter ){ if( $list = call_user_func($getter) ){ $base = loco_constant($const); foreach( $list as $handle => $data ){ if( isset($cached[$handle]) ){ Loco_error_AdminNotices::debug( sprintf('Plugin conflict on %s', $handle) ); continue; } // WordPress 4.6 introduced TextDomain header fallback @37562 see https://core.trac.wordpress.org/changeset/37562/ // if we don't force the original text domain header we can't know if a bundle is misconfigured. This leads to silent errors. // this has a performance overhead, and also results in "unconfigured" messages that users may not have had in previous releases. /*/ TODO perhaps implement a plugin setting that forces original headers $file = new Loco_fs_File($base.'/'.$handle); if( $file->exists() ){ $map = array( 'TextDomain' => 'Text Domain' ); $raw = get_file_data( $file->getPath(), $map, 'plugin' ); $data['TextDomain'] = $raw['TextDomain']; }*/ // set resolved base directory before caching our copy of plugin data $data['basedir'] = $base; $cached[$handle] = $data; } } } $cached = apply_filters('loco_plugins_data', $cached ); uasort( $cached, '_sort_uname_callback' ); // Intended as in-memory cache so adding short expiry for object caching plugins that may persist it. // All actions that invoke `wp_clean_plugins_cache` should purge this. See Loco_hooks_AdminHooks wp_cache_set('plugins', $cached, 'loco', 3600 ); } return $cached; } /** * Get raw plugin data from WordPress registry, plus additional "basedir" field for resolving handle to actual file. * @return array */ public static function get_plugin( $handle ){ $search = self::get_plugins(); // plugin must be registered with WordPress if( isset($search[$handle]) ){ $data = $search[$handle]; } // else plugin is not known to WordPress else { $data = apply_filters( 'loco_missing_plugin', array(), $handle ); } // plugin not valid if name absent from raw data if( empty($data['Name']) ){ return null; } // basedir is added by our get_plugins function, but filtered arrays could be broken if( ! array_key_exists('basedir',$data) ){ Loco_error_AdminNotices::debug( sprintf('"basedir" property required to resolve %s',$handle) ); return null; } return $data; } /** * {@inheritdoc} */ public function getHeaderInfo(){ $handle = $this->getHandle(); $data = self::get_plugin($handle); if( ! is_array($data) ){ // permitting direct file access if file exists (tests) $path = $this->getBootstrapPath(); if( $path && file_exists($path) ){ $data = get_plugin_data( $path, false, false ); } else { $data = array(); } } return new Loco_package_Header( $data ); } /** * {@inheritdoc} */ public function getMetaTranslatable(){ return array ( 'Name' => 'Name of the plugin', 'Description' => 'Description of the plugin', 'PluginURI' => 'URI of the plugin', 'Author' => 'Author of the plugin', 'AuthorURI' => 'Author URI of the plugin', // 'Tags' => 'Tags of the plugin', ); } /** * {@inheritdoc} */ public function setHandle( $slug ){ // plugin handles are relative paths from plugin directory to bootstrap file // so plugin is single file if its handle has no directory prefix if( basename($slug) === $slug ){ $this->solo = true; } else { $this->solo = false; } return parent::setHandle( $slug ); } /** * {@inheritdoc} */ public function setDirectoryPath( $path ){ parent::setDirectoryPath($path); // plugin bootstrap file can be inferred from base directory + handle // e.g. if base is "/path/to/foo" and handle is "foo/bar.php" we can derive "/path/to/foo/bar.php" if( ! $this->getBootstrapPath() ){ $handle = $this->getHandle(); if( '' !== $handle ) { $file = new Loco_fs_File( basename($handle) ); $file->normalize( $path ); $this->setBootstrapPath( $file->getPath() ); } } return $this; } /** * Create plugin bundle definition from WordPress plugin data * * @param string plugin handle relative to plugin directory * @return Loco_package_Plugin */ public static function create( $handle ){ // plugin must be registered with at least a name and "basedir" $data = self::get_plugin($handle); if( ! $data ){ throw new Loco_error_Exception( sprintf( __('Plugin not found: %s','loco-translate'),$handle) ); } // lazy resolve of base directory from "basedir" property that we added $file = new Loco_fs_File( $handle ); $file->normalize( $data['basedir'] ); $base = $file->dirname(); // handle and name is enough data to construct empty bundle $bundle = new Loco_package_Plugin( $handle, $data['Name'] ); // check if listener heard the real text domain, but only use when none declared // This will not longer happen since WP 4.6 header fallback, but we could warn about it $listener = Loco_package_Listener::singleton(); if( $domain = $listener->getDomain($handle) ){ if( empty($data['TextDomain']) ){ $data['TextDomain'] = $domain; if( empty($data['DomainPath']) ){ $data['DomainPath'] = $listener->getDomainPath($domain); } } // ideally would only warn on certain pages, but unsure where to place this logic other than here // TODO possibly allow bundle to hold errors/warnings as part of its config. else if( $data['TextDomain'] !== $domain ){ Loco_error_AdminNotices::debug( sprintf("Plugin loaded text domain '%s' but WordPress knows it as '%s'",$domain, $data['TextDomain']) ); } } // do initial configuration of bundle from metadata $bundle->configure( $base, $data ); return $bundle; } }src/package/Project.php000066600000055670152141417370011073 0ustar00.pot" * @var Loco_fs_File */ private $pot; /** * Whether POT file is protected from end-user update and sync operations. * @var bool */ private $potlock; /** * Construct project from its domain and a descriptive name * @param Loco_package_Bundle * @param Loco_package_TextDomain * @param string */ public function __construct( Loco_package_Bundle $bundle, Loco_package_TextDomain $domain, $name ){ $this->name = $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 [.] * @return string[] [ , ] */ public static function splitId( $id ){ $r = preg_split('/(?getSlug(); $domain = (string) $this->getDomain(); if( $slug === $domain ){ return $slug; } return addcslashes($domain,'.').'.'.addcslashes($slug,'.'); } /** * @return string */ public function __toString(){ return (string) $this->name; } /** * Set friendly name of project * @param string * @return Loco_package_Project */ 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 * @return Loco_package_Project */ 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 * @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 * @param Loco_fs_FileFinder * @return Loco_fs_FileFinder */ 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 ); } } return $finder; } /** * Check if target file or directory is excluded * @param Loco_fs_File PO or POT file * @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 * @return Loco_package_Project */ 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; } /** * Lazy create all searchable source paths * @return Loco_fs_FileFinder */ private function getSourceFinder(){ if( ! $this->source ){ $source = new Loco_fs_FileFinder; // .php extensions configured in plugin options $conf = Loco_data_Settings::get(); $exts = $conf->php_alias or $exts = array('php'); // Only add .js extensions if enabled $exts = array_merge( $exts, (array) $conf->jsx_alias ); $source->setRecursive(true)->groupBy($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; } /** * Utility excludes current exclude paths from target finder * @param Loco_fs_FileFinder * @return Loco_fs_FileFinder */ private function excludeSources( Loco_fs_FileFinder $finder ){ foreach( $this->xspaths as $file ){ if( $path = realpath( (string) $file ) ){ $finder->exclude( $path ); } } foreach( $this->xgpaths as $file ){ if( $path = realpath( (string) $file ) ){ $finder->exclude( $path ); } } return $finder; } /** * Add a root path where source files may live under for this project * @param string | Loco_fs_File * @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 * @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 * @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 * @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 * @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 (bool) $this->potlock; } /** * Lock POT file to prevent end-user updates0 * @param bool * @return Loco_package_Project */ public function setPotLock( $locked ){ $this->potlock = (bool) $locked; return $this; } /** * Get full path to template POT (file) * @return Loco_fs_File */ public function getPot(){ if( ! $this->pot ){ $name = $this->getSlug().'.pot'; if( '.pot' !== $name ){ // find 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( array('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; } } } } return $this->pot; } /** * Force the use of a known POT file. This could be a PO file if necessary * @param Loco_fs_File 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 */ 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( array('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 -en_US. foreach( array('-en_US'=>6, '-en'=>3 ) as $tail => $len ){ if( '-en_US' === 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 = array(); /* @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 if( ! $source->isCached() ){ $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; } $this->sfiles->add($file); } } } return $this->sfiles; } /** * Get all translation files matching project prefix across target directories * @param string 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(); /* @var $file Loco_fs_File */ foreach( $files[$ext] as $file ){ $file = new Loco_fs_LocaleFile( $file ); // 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 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 $dir Loco_fs_Directory */ foreach( $this->getConfiguredTargets() as $dir ){ // theme files under their own directory normally have no file prefix if( $default && $dir->underThemeDirectory() ){ $path = $dir->getPath().'/'.$suffix; } // plugin files are prefixed even in their own directory, so empty prefix here implies incorrect bundle configuration //else if( $default && ! $prefix && $dir->underPluginDirectory() ){ // $path = $dir->getPath().'/'.$domain.'-'.$suffix; //} // all other paths use configured prefix, which may be empty else { $path = $dir->getPath().'/'.$prefix.$suffix; } $choice->add( new Loco_fs_LocaleFile($path) ); } /* @var $dir Loco_fs_Directory */ 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) ){ $path .= '/'.$domain.'-'.$suffix; } // all other paths (probably core) use configured prefix, which may be empty else { $path .= '/'.$prefix.$suffix; } $choice->add( new Loco_fs_LocaleFile($path) ); } return $choice; } /** * Get newest timestamp of all translation files (includes template, but exclude source files) * @return int */ public function getLastUpdated(){ $t = 0; $file = $this->getPot(); if( $file && $file->exists() ){ $t = $file->modified(); } /* @var $file Loco_fs_File */ foreach( $this->findLocaleFiles('po') as $file ){ $t = max( $t, $file->modified() ); } return $t; } }src/package/Inverter.php000066600000015242152141417370011252 0ustar00getFileFinder(); /* @var $project Loco_package_Project */ foreach( $bundle as $project ){ if( $file = $project->getPot() ){ // excluding all extensions in case POT is actually a PO/MO pair foreach( array('pot','po','mo') as $ext ){ $file = $file->cloneExtension($ext); if( $path = realpath( $file->getPath() ) ){ $finder->exclude( $path ); } } } foreach( $project->findLocaleFiles('po') as $file ){ if( $path = realpath( $file->getPath() ) ){ $finder->exclude( $path ); } } foreach( $project->findLocaleFiles('mo') as $file ){ if( $path = realpath( $file->getPath() ) ){ $finder->exclude( $path ); } } } // Do a deep scan of all files that haven't been seen, or been excluded: // This will include files in global directories and inside the bundle. return $finder->setRecursive(true)->followLinks(false)->group('po','mo','pot')->exportGroups(); } /** * Compile anything found under bundle root that isn't configured in $known * @return Loco_package_Bundle */ public static function compile( Loco_package_Bundle $bundle ){ $found = self::export($bundle); // done with original bundle now $bundle = clone $bundle; $bundle->clear(); // first iteration groups found files into common locations that should hopefully indicate translation sets $groups = array(); $templates = array(); $localised = array(); $root = $bundle->getDirectoryPath(); /* @var $list Loco_fs_FileList */ foreach( $found as $ext => $list ){ /* @var $file Loco_fs_LocaleFile */ foreach( $list as $file ){ // printf("Found: %s
\n", $file ); // This file is NOT known to be part of a configured project $dir = $file->getParent(); $key = $dir->getRelativePath( $root ); // if( ! isset($groups[$key]) ){ $groups[$key] = $dir; $templates[$key] = array(); $localised[$key] = array(); } // template should define single set of translations unique by directory and file prefix if( 'pot' === $ext ){ $slug = $file->filename(); $templates[$key][$slug] = true; } // else ideally PO/MO files will correspond to a template by common prefix else { $file = new Loco_fs_LocaleFile( $file ); $slug = $file->getPrefix(); if( $file->getLocale()->isValid() ){ $localised[$key][$slug] = true; } // else could be some kind of non-standard template else { $slug = $file->filename(); $templates[$key][$slug] = true; } } } } unset($found); // next iteration matches collected files together into likely project sets $unique = array(); /* @var $list Loco_fs_Directory */ foreach( $groups as $key => $dir ){ // pair up all projects that match templates neatly to prefixed files foreach( $templates[$key] as $slug => $bool ){ if( isset($localised[$key][$slug]) ){ //printf("Perfect match on domain '%s' in %s
\n", $slug, $key ); $unique[$key][$slug] = $dir; // done with this prefectly matched set $templates[$key][$slug] = null; $localised[$key][$slug] = null; } } // pair up any unprefixed localised files if( isset($localised[$key]['']) ){ $slug = 'unknown'; // Match to first (hopefully only) template to establish a slug foreach( $templates[$key] as $_slug => $bool ){ if( $bool ){ $slug = $_slug; $templates[$key][$slug] = null; break; // <- not possible to know how multiple POTs might be paired up } } //printf("Pairing unprefixed files in %s to '%s'
\n", $key, $slug ); $unique[$key][$slug] = $dir; // done with unprefixed localised files in this directory $localised[$key][''] = null; } // add any orphaned translations (those with no template matched) foreach( $localised[$key] as $slug => $bool ){ if( $bool ){ // printf("Picked up orphoned locales in %s as '%s'
\n", $key, $slug ); $unique[$key][$slug] = $dir; } } // add any orphaned templates (those with no localised files matched) foreach( $templates[$key] as $slug => $bool ){ if( $bool ){ //printf("Picked up orphoned template in %s as '%s'
\n", $key, $slug ); $unique[$key][$slug] = $dir; } } } unset( $groups, $localised, $templates ); // final iteration adds unique projects to bundle foreach( $unique as $key => $sets ){ foreach( $sets as $slug => $dir ){ $name = ucfirst( strtr( $slug, '-_', ' ' ) ); $domain = new Loco_package_TextDomain( $slug ); $project = $domain->createProject( $bundle, $name ); $project->addTargetDirectory($dir); $bundle->addProject($project); } // TODO how to prevent overlapping sets by adding each other's files to exclude lists } return $bundle; } }src/package/TextDomain.php000066600000002744152141417370011533 0ustar00name = $name; } /** * @internal */ public function __toString(){ return (string) $this->name; } /** * Get name of Text Domain, e.g. "twentyfifteen" * @return string */ public function getName(){ return $this->name; } /** * Create a named project in a given bundle for this Text Domain * @return Loco_package_Project */ public function createProject( Loco_package_Bundle $bundle, $name ){ $proj = new Loco_package_Project( $bundle, $this, $name ); $this[] = $proj; return $proj; } /** * @return Loco_package_TextDomain */ public function setCanonical( $bool ){ $this->canonical = (bool) $bool; return $this; } /** * @return bool */ public function isCanonical(){ return $this->canonical; } } src/package/Theme.php000066600000007051152141417370010515 0ustar00getDirectoryPath() ); $theme = new WP_Theme( $this->getSlug(), $root ); return new Loco_package_Header( $theme ); } /** * {@inheritdoc} */ public function getMetaTranslatable(){ return array ( 'Name' => 'Name of the theme', 'Description' => 'Description of the theme', 'ThemeURI' => 'URI of the theme', 'Author' => 'Author of the theme', 'AuthorURI' => 'Author URI of the theme', // 'Tags' => 'Tags of the theme', ); } /** * Get parent bundle if theme is a child * @return Loco_package_Theme */ public function getParent(){ return $this->parent; } /** * Create theme bundle definition from WordPress theme handle * * @param string short name of theme, e.g. "twentyfifteen" * @return Loco_package_Plugin */ public static function create( $slug, $root = null ){ return self::createFromTheme( wp_get_theme( $slug, $root ) ); } /** * Create theme bundle definition from WordPress theme data */ public static function createFromTheme( WP_Theme $theme ){ $slug = $theme->get_stylesheet(); $base = $theme->get_stylesheet_directory(); $name = $theme->get('Name') or $name = $slug; if( ! $theme->exists() ){ throw new Loco_error_Exception('Theme not found: '.$name ); } $bundle = new Loco_package_Theme( $slug, $name ); // ideally theme has declared its TextDomain $domain = $theme->get('TextDomain') or // if not, we can see if the Domain listener has picked it up $domain = Loco_package_Listener::singleton()->getDomain($slug); // otherwise we won't try to guess as it results in silent problems when guess is wrong // ideally theme has declared its DomainPath $target = $theme->get('DomainPath') or // if not, we can see if the Domain listener has picked it up $target = Loco_package_Listener::singleton()->getDomainPath($domain); // otherwise project will use theme root by default $bundle->configure( $base, array ( 'Name' => $name, 'TextDomain' => $domain, 'DomainPath' => $target, ) ); // parent theme inheritance: if( $parent = $theme->parent() ){ try { $bundle->parent = self::createFromTheme($parent); $bundle->inherit( $bundle->parent ); } catch( Loco_error_Exception $e ){ Loco_error_AdminNotices::add($e); } } // TODO provide hook to modify bundle? // do_action( 'loco_bundle_configured', $bundle ); return $bundle; } }src/package/Listener.php000066600000027506152141417370011247 0ustar00unhook(); self::$singleton = null; } } /** * Create a singleton listener that we can query from anywhere * @return Loco_package_Listener */ public static function create(){ self::destroy(); self::$singleton = new Loco_package_Listener; return self::$singleton->clear(); } /** * @return Loco_package_Listener */ public function clear(){ $this->buffer = array(); $this->themes = array(); $this->plugins = array(); $this->domains = array(); $this->domainPaths = array(); $this->pluginHandles = null; $this->buffered = false; $this->globalPaths = array(); foreach( array('WP_LANG_DIR') as $name ){ if( $value = loco_constant($name) ){ $this->globalPaths[$value] = strlen($value); } } return $this; } /** * Early hook listening for active bundles loading their own text domains. */ public function on_load_textdomain( $domain, $mofile ){ // echo '
Debug:',esc_html( json_encode(compact('domain','mofile'),JSON_UNESCAPED_SLASHES)),'
'; $this->buffered = true; $this->buffer[$domain][] = $mofile; } /** * Get primary Text Domain that's uniquely assigned to a bundle * @param string theme or plugin relative path */ public function getDomain( $handle ){ $this->flush(); return isset($this->domains[$handle]) ? $this->domains[$handle] : ''; } /** * Get the default directory path where captured files of a given domain are held * @param string TextDomain * @return string relative path */ public function getDomainPath( $domain ){ $this->flush(); return isset($this->domainPaths[$domain]) ? $this->domainPaths[$domain] : ''; } /** * Utility: checks if a file path is under a given root * @return string subpath relative to given root */ private static function relative( $path, $root ){ $root = trailingslashit($root); $snip = strlen($root); // attempt unaltered path if( substr($path,0,$snip) === $root ){ return substr( $path, $snip ); } // attempt resolved in case symlinks along path $real = realpath($path); if( $real && $real !== $path && substr($real,0,$snip) === $root ){ return substr( $real, $snip ); } // path not under root return null; } /** * Check if given relative directory path the root of a known plugin * @param string relative plugin directory name, e.g. "foo/bar" * @return string relative plugin file handle, e.g. "foo/bar/baz.php" */ private function isPlugin( $check ){ if( ! $this->pluginHandles ){ $this->pluginHandles = array(); foreach( Loco_package_Plugin::get_plugins() as $handle => $data ){ $this->pluginHandles[ dirname($handle) ] = $handle; // set default text domain because additional domains could be discovered before the canonical one if( isset($data['TextDomain']) && ( $domain = $data['TextDomain'] ) ){ $this->domains[$handle] = $domain; } } } if( ! array_key_exists($check, $this->pluginHandles) ){ return null; } return $this->pluginHandles[$check]; } /** * Convert a file path to a theme or plugin bundle * @return Loco_package_Bundle */ private function resolve( $path, $domain ){ $file = new Loco_fs_LocaleFile( $path ); // ignore suffix-only files when locale is invalid as locale code would be taken wrongly as slug, e.g. if you tried to load "english.po" if( $file->hasPrefixOnly() ){ return; } // no point looking at files in global directory as they tell us only the domain which we already know foreach( $this->globalPaths as $prefix => $length ){ if( substr($path,0,$length) === $prefix ){ return; } } // avoid infinite loops during bundle resolution $wasBuffered = $this->buffered; $this->buffered = false; // file prefix is *probably* the Text Domain, but can differ if load_textdomain called directly from bundle code $slug = $file->getPrefix() or $slug = $domain; $path = dirname($path); $bundle = null; while( true ){ // check if MO file lives inside a theme foreach( $GLOBALS['wp_theme_directories'] as $root ){ $relative = self::relative($path, $root); if( is_null($relative) ){ continue; } // theme's "stylesheet directory" must be immediately under this root // passed path could root of theme, or any directory below it, but we only need the top level $chunks = explode( '/', $relative, 2 ); $handle = current( $chunks ); if( ! $handle ){ continue; } $theme = new WP_Theme( $handle, $root ); if( ! $theme->exists() ){ continue; } $abspath = $root.'/'.$handle; // theme may have officially declared text domain if( $default = $theme->get('TextDomain') ){ $this->domains[$handle] = $default; } // else set current domain as default if not already set else if ( ! isset($this->domains[$handle]) ){ $this->domains[$handle] = $domain; } if( ! isset($this->domainPaths[$domain]) ){ $this->domainPaths[$domain] = self::relative( $path, $abspath ); } // theme bundle may already exist if( isset($this->themes[$handle]) ){ $bundle = $this->themes[$handle]; } // create default project for theme bundle else { $bundle = Loco_package_Theme::createFromTheme($theme); $this->themes[$handle] = $bundle; } // possibility that additional text domains are being added $project = $bundle->getProject($slug); if( ! $project ){ $project = new Loco_package_Project( $bundle, new Loco_package_TextDomain($domain), $slug ); $bundle->addProject( $project ); } // bundle was a theme, even if we couldn't configure it, so no point checking plugins break 2; } // check if MO file lives inside a plugin foreach( array( 'WP_PLUGIN_DIR', 'WPMU_PLUGIN_DIR' ) as $const ){ $root = loco_constant( $const ); $relative = self::relative($path, $root); if( is_null($relative) ){ continue; } // plugin *might* live directly under root $stack = array(); foreach( explode( '/', dirname($relative) ) as $next ){ $stack[] = $next; $relbase = implode('/', $stack ); if( $handle = $this->isPlugin($relbase) ){ $abspath = $root.'/'.$handle; // set this as default domain if not already cached if( ! isset($this->domains[$handle]) ){ $this->domains[$handle] = $domain; } if( ! isset($this->domainPaths[$domain]) ){ $target = self::relative( $path, dirname($abspath) ); $this->domainPaths[$domain] = $target; } // plugin bundle may already exist if( isset($this->plugins[$handle]) ){ $bundle = $this->plugins[$handle]; } // create default project for plugin bundle (not necessarily the current text domain) else { $bundle = Loco_package_Plugin::create($handle); $this->plugins[$handle] = $bundle; } // add current domain as translation project if not already set // this avoids extra domains getting set before the default one if( ! $bundle->getProject($slug) ){ $project = new Loco_package_Project( $bundle, new Loco_package_TextDomain($domain), $slug ); $bundle->addProject( $project ); } break; } } } // failed to establish a bundle break; } $this->buffered = $wasBuffered; return $bundle; } /** * @internal * Resolve all currently buffered text domain paths */ private function flush(){ if( $this->buffered ){ foreach( $this->buffer as $domain => $paths ){ foreach( $paths as $path ){ try { if( $bundle = $this->resolve($path,$domain) ){ continue 2; } } catch( Loco_error_Exception $e ){ // silent errors for non-critical function } } } $this->buffer = array(); $this->buffered = false; } } /** * @return array */ public function getThemes(){ $this->flush(); return $this->themes; } /** * @return array */ public function getPlugins(){ $this->flush(); return $this->plugins; } }src/package/Core.php000066600000012376152141417370010351 0ustar00 'default', 'DomainPath' => '/wp-content/languages/', // dummy author info for core components 'Name' => __('WordPress core','loco-translate'), 'Version' => $GLOBALS['wp_version'], 'Author' => __('The WordPress Team','default'), 'AuthorURI' => __('https://wordpress.org/','default'), ) ); } /** * {@inheritdoc} */ public function getMetaTranslatable(){ return array(); } /** * {@inheritdoc} */ public function getType(){ return 'Core'; } /** * {@inheritdoc} * Core bundle doesn't need a handle, there is only one. */ public function getId(){ return 'core'; } /** * {@inheritdoc} * Core bundle is always configured */ public function isConfigured(){ $saved = parent::isConfigured() or $saved = 'internal'; return $saved; } /** * Manually define the core WordPress translations as a single bundle * Projects are those included in standard WordPress downloads: [default], "admin", "admin-network" and "continents-cities" * @return Loco_package_Core */ public static function create(){ $rootDir = loco_constant('ABSPATH'); $langDir = loco_constant('WP_LANG_DIR'); $bundle = new Loco_package_Core('core', __('WordPress Core','loco-translate') ); $bundle->setDirectoryPath( $rootDir ); // Core config may be saved in DB, but not supporting bundled XML if( $bundle->configureDb() ){ return $bundle; } // front end, admin and network admin packages are all part of the "default" domain $domain = new Loco_package_TextDomain('default'); $domain->setCanonical( true ); // front end subset, has empty name in WP // full title is like "4.9.x - Development" but we don't know what version at this point list($x,$y) = explode('.',$GLOBALS['wp_version'],3); $project = $domain->createProject( $bundle, sprintf('%u.%u.x - Development',$x,$y) ); $project->setSlug('') ->setPot( new Loco_fs_File($langDir.'/wordpress.pot') ) ->addSourceDirectory( $rootDir) ->excludeSourcePath( $rootDir.'/wp-admin') ->excludeSourcePath( $rootDir.'/wp-content') ->excludeSourcePath( $rootDir.'/wp-includes/class-pop3.php') ->excludeSourcePath( $rootDir.'/wp-includes/js/codemirror') ->excludeSourcePath( $rootDir.'/wp-includes/js/crop') ->excludeSourcePath( $rootDir.'/wp-includes/js/imgareaselect') ->excludeSourcePath( $rootDir.'/wp-includes/js/jcrop') ->excludeSourcePath( $rootDir.'/wp-includes/js/jquery') ->excludeSourcePath( $rootDir.'/wp-includes/js/mediaelement') ->excludeSourcePath( $rootDir.'/wp-includes/js/plupload') ->excludeSourcePath( $rootDir.'/wp-includes/js/swfupload') ->excludeSourcePath( $rootDir.'/wp-includes/js/thickbox') ->excludeSourcePath( $rootDir.'/wp-includes/js/tw-sack.js') ; // "Administration" project (admin subset) $project = $domain->createProject( $bundle, 'Administration'); $project->setSlug('admin') ->setPot( new Loco_fs_File($langDir.'/admin.pot') ) ->addSourceDirectory( $rootDir.'/wp-admin' ) ->excludeSourcePath( $rootDir.'/wp-admin/js') ->excludeSourcePath( $rootDir.'/wp-admin/css') ->excludeSourcePath( $rootDir.'/wp-admin/network') ->excludeSourcePath( $rootDir.'/wp-admin/network.php') ->excludeSourcePath( $rootDir.'/wp-admin/includes/continents-cities.php') ; // "Network Admin" package (admin-network subset) $project = $domain->createProject($bundle, 'Network Admin'); $project->setSlug('admin-network') ->setPot( new Loco_fs_File($langDir.'/admin-network.pot') ) ->addSourceDirectory( $rootDir.'/wp-admin/network' ) ->addSourceFile( $rootDir.'/wp-admin/network.php' ) ; // end of "default" domain projects $bundle->addDomain( $domain ); // Continents & Cities is its own text domain) $domain = new Loco_package_TextDomain('continents-cities'); $project = $domain->createProject( $bundle, 'Continents & Cities'); $project->setPot( new Loco_fs_File( $langDir.'/continents-cities.pot') ) ->addSourceFile( $rootDir.'/wp-admin/includes/continents-cities.php' ) ; $bundle->addDomain( $domain ); return $bundle; } }src/package/Header.php000066600000005722152141417370010646 0ustar00wp = $header; } /** * @return string */ public function __get( $prop ){ $wp = $this->wp; // prefer require "get" method to access raw properties (WP_Theme) if( method_exists($wp, 'get') && ( $value = $wp->get($prop) ) ){ return $value; } // may have key directly, e.g. TextDomain in plugin array if( isset($wp[$prop]) ){ return $wp[$prop]; } // else header not defined, which is probably fine return ''; } /** * @codeCoverageIgnore */ public function __set( $prop, $value ){ throw new RuntimeException('Read only'); } /** * Get bundle author as linked text, just like the WordPress plugin list does * @return string escaped HTML */ public function getAuthorLink(){ if( ( $link = $this->AuthorURI ) || ( $link = $this->PluginURI ) || ( $link = $this->ThemeURI ) ){ $author = $this->Author or $author = $link; return ''.esc_html($author).''; } return ''; } /** * Get "name" by credit * @return string escaped HTML */ public function getAuthorCredit(){ if( $author = $this->Author ){ $author = esc_html( strip_tags($author) ); if( $link = $this->AuthorURI ){ $author = ''.$author.''; } } else { $author = __('Unknown author','loco-translate'); } // translators: Author credit: "" by $html = sprintf( __('"%s" %s by %s','default'), esc_html($this->Name), $this->Version, $author ); if( ( $link = $this->PluginURI ) || ( $link = $this->ThemeURI ) ){ $html .= sprintf( ' — %s', esc_url($link), __('Visit official site','loco-translate') ); } return $html; } /** * Get hostname of vendor that hosts theme/plugin * @return string e.g. "wordpress.org" */ public function getVendorHost(){ $host = ''; if( ( $url = $this->PluginURI ) || ( $url = $this->ThemeURI ) ){ if( $host = parse_url($url,PHP_URL_HOST) ){ $bits = explode( '.', $host ); $host = implode( '.', array_slice($bits,-2) ); } } return $host; } }src/package/Debugger.php000066600000031577152141417370011211 0ustar00messages = array(); $this->counts = array( 'success' => 0, 'warning' => 0, 'debug' => 0, 'info' => 0, ); // config storage type switch( $bundle->isConfigured() ){ case 'db': $this->info("Custom configuration saved in database"); break; case 'meta': $this->good("Configuration auto-detected from file headers"); break; case 'file': $this->good("Official configuration provided by author"); break; case 'internal': $this->info("Configuration built-in to Loco"); break; case '': $this->warn("Cannot auto-detect configuration"); break; default: throw new Exception('Unexpected isConfigured() return value'); } $base = $bundle->getDirectoryPath(); // $this->devel('Bundle root is %s',$base); // self-declarations provided by author in file headers $native = $bundle->getHeaderInfo(); if( $value = $native->TextDomain ){ $this->info('WordPress says primary text domain is "%s"', $value); // WordPress 4.6 changes mean this header could be a fallback and not actually declared by author if( $bundle->isPlugin() ){ $map = array ( 'TextDomain' => 'Text Domain' ); $raw = get_file_data( $bundle->getBootstrapPath(), $map, 'plugin' ); if( empty($raw['TextDomain']) ){ $this->warn('Author doesn\'t define the TextDomain header, WordPress guessed it'); } } // Warn if WordPress-assumed text domain is not configured. plugin/theme headers won't be translated $domains = $bundle->getDomains(); if( ! isset($domains[$value]) && ! isset($domains['*']) ){ $this->warn('Expected text domain "%s" is not configured', $value ); } } else { $this->warn("Author doesn't define the TextDomain header"); } if( $value = $native->DomainPath ){ $this->good('Primary domain path declared by author as "%s"', $value ); } else if( is_dir($base.'/languages') ){ $this->info('Standard "languages" folder found, although DomainPath not declared'); } else { $this->warn("Author doesn't define the DomainPath header"); } // check validity of single-file plugins if( $bundle->isSingleFile() && ! $bundle->getBootstrapPath() ){ $this->warn('Plugin is a single file, but bootstrap file is unknown'); } // collecting only configured domains to match against source code $domains = array(); $templates = array(); // show each known subset if( $count = count($bundle) ){ /* @var $project Loco_package_Project */ foreach( $bundle as $project ){ $id = $project->getId(); $domain = (string) $project->getDomain(); if( '*' === $domain ){ $this->devel('Wildcard text domain configured for %s', $project ); $domain = ''; } $domains[$domain] = true; // Domain path[s] within bundle directory $targets = array(); /* @var $dir Loco_fs_Directory */ foreach( $project->getConfiguredTargets() as $dir ){ $targets[] = $dir->getRelativePath($base); } if( $targets ){ $this->info('%u domain path[s] configured for "%s" -> %s', count($targets), $id, json_encode($targets,JSON_UNESCAPED_SLASHES) ); } else { $this->warn('No domain paths configured for "%s"', $id ); } // POT template file if( $potfile = $project->getPot() ){ if( $potfile->exists() ){ $this->good('Template file for "%s" exists at "%s"', $id, $potfile->getRelativePath($base) ); try { $data = Loco_gettext_Data::load($potfile); $templates[$domain][] = $data; } catch( Exception $e ){ $this->warn('Template file for "%s" is invalid format', $id ); } } else { $this->warn('Template file for "%s" does not exist (%s)', $id, $potfile->getRelativePath($base) ); } } else { $this->warn('No template file configured for "%s"', $domain ); if( $potfile = $project->guessPot() ){ $this->devel('Possible non-standard name for "%s" template at "%s"', $id, $potfile->getRelativePath($base) ); $project->setPot( $potfile ); // <- adding so that invert ignores it } } } $default = $bundle->getDefaultProject(); if( ! $default ){ $this->warn('%u subsets configured, but failed to establish the default/primary', $count ); } } else { $default = $bundle->createDefault(); $domain = (string) $default->getDomain(); $this->devel( 'Suggested text domain: "%s"', $domain ); } // files picked up with no context as to what they're for if( $bundle->isTheme() || ( $bundle->isPlugin() && ! $bundle->isSingleFile() ) ){ $unknown = $bundle->invert(); if( $n = count($unknown) ){ /* @var $project Loco_package_Project */ foreach( $unknown as $project ){ $domain = (string) $project->getDomain(); // should only have one target due the way the inverter groups results /* @var $dir Loco_fs_Directory */ foreach( $project->getConfiguredTargets() as $dir ){ $reldir = $dir->getRelativePath($base) or $stub = '.'; $this->warn('Unconfigured files found in "%s", possible domain name: "%s"', $reldir, $domain ); } } } } // source code extraction across entire bundle $tmp = clone $bundle; $tmp->exchangeArray( array() ); $project = $tmp->createDefault( (string) $default->getDomain() ); $extr = new Loco_gettext_Extraction( $tmp ); $extr->addProject( $project ); if( $total = $extr->getTotal() ){ // real count excludes additional metadata $realCounts = $extr->getDomainCounts(); $counts = $extr->includeMeta()->getDomainCounts(); // $this->good("%u string[s] can be extracted from source code for %s", $total, $this->implodeKeys($counts) ); foreach( array_intersect_key($counts, $domains) as $domain => $count ){ if( isset($realCounts[$domain]) ){ $count = $counts[$domain]; $realCount = $realCounts[$domain]; $str = _n( 'One string extracted from source code for "%2$s"', '%s strings extracted from source code for "%s"', $realCount, 'loco-translate' ); $this->good( $str.' (%s including metadata)', number_format($realCount), $domain?$domain:'*', number_format($count) ); } else { $this->warn('No strings extracted from source code for "%s"', $domain?$domain:'*' ); } // check POT agrees with extracted count, but only if domain has single POT (i.e. not split across files on purpose) if( isset($templates[$domain]) && 1 === count($templates[$domain]) ){ $data = current( $templates[$domain] ); if( ! $extr->getTemplate($domain)->equalSource($data) ){ $meta = Loco_gettext_Metadata::create( new Loco_fs_DummyFile(''), $data ); $this->devel('Template is not in sync with source code (%s in file)', $meta->getTotalSummary() ); } } } // with extracted strings we can check for domain mismatches if( $missing = array_diff_key($domains, $realCounts) ){ $num = count($missing); $str = _n( 'Configured domain has no extractable strings', '%u configured domains have no extractable strings', $num, 'loco-translate' ); $this->warn( $str.': %2$s', $num, $this->implodeKeys($missing) ); } if( $extra = array_diff_key($realCounts,$domains) ){ $this->info('%u unconfigured domain[s] found in source code: %s', count($extra), $this->implodeKeys($extra) ); /*/ debug other domains extracted foreach( $extra as $name => $count ){ $this->devel(' > %s (%u)', $name, $count ); }*/ // extracted domains could prove that declared domain is wrong if( $missing ){ foreach( array_keys($extra) as $name ){ $flat = preg_replace('/[^a-z0-9]/','', strtolower($name) ); foreach( array_keys($missing) as $decl ){ if( preg_replace('/[^a-z0-9]/','', strtolower($decl) ) === $flat ){ $this->devel('"%s" might be a mistake. Should it be "%s"?', $decl, $name ); } } } } } } else { $this->warn("No strings can be extracted from source code"); } } /** * @internal * Implements IteratorAggregate for looping over messages * @return ArrayIterator */ public function getIterator(){ return new ArrayIterator( $this->messages ); } /** * Add a success notice * @return Loco_package_Debugger */ private function good( $text ){ $args = func_get_args(); $text = call_user_func_array('sprintf', $args ); return $this->add( new Loco_error_Success($text) ); } /** * Add a warning notice * @return Loco_package_Debugger */ private function warn( $text ){ $args = func_get_args(); $text = call_user_func_array('sprintf', $args ); return $this->add( new Loco_error_Warning($text) ); } /** * Add an information notice (not good, or bad) * @return Loco_package_Debugger */ private function info( $text ){ $args = func_get_args(); $text = call_user_func_array('sprintf', $args ); return $this->add( new Loco_error_Notice($text) ); } /** * Add a developer notice. probably something helpful for fixing a problem * @return Loco_package_Debugger */ private function devel( $text ){ $args = func_get_args(); $text = call_user_func_array('sprintf', $args ); return $this->add( new Loco_error_Debug($text) ); } /** * @return Loco_package_Debugger */ private function add( Loco_error_Exception $error ){ $this->counts[ $error->getType() ]++; $this->messages[] = $error; return $this; } /** * Print all diagnostic messages suitable for CLI * @codeCoverageIgnore */ public function dump( $prefix = '' ){ /* @var $notice Loco_error_Exception */ foreach( $this as $notice ){ printf("%s[%s] %s\n", $prefix, $notice->getType(), $notice->getMessage() ); } } /** * Get number of bad things discovered * @return int */ public function countWarnings(){ return $this->counts['warning']; } /** * Utility for printing "x", "y" & "z" * @return string */ private function implodeNames( array $names ){ $last = array_pop($names); if( $names ){ return '"'.implode('", "',$names).'" & "'.$last.'"'; } if( is_string($last) ){ return '"'.$last.'"'; } return ''; } /** * @internal * @return string */ private function implodeKeys( array $assoc ){ return $this->implodeNames( array_keys($assoc) ); } }src/package/Locale.php000066600000007226152141417370010656 0ustar00index = new ArrayObject; $this->match = array(); if( $locale ){ $this->addLocale( $locale ); } } /** * Add another locale to serarch on * @return Loco_package_Locale */ public function addLocale( Loco_Locale $locale ){ if( $locale->isValid() ){ $sufx = (string) $locale.'.po'; $this->match[$sufx] = - strlen($sufx); } return $this; } /** * @return Loco_package_Project */ public function getProject( Loco_fs_File $file ){ $path = $file->getPath(); if( isset($this->index[$path]) ){ return $this->index[$path]; } } /** * @return array */ public function getBundles(){ $bundles = $this->bundles; if( ! $bundles ){ $bundles = array ( Loco_package_Core::create() ); foreach( Loco_package_Plugin::get_plugins() as $handle => $data ){ try { $bundles[] = Loco_package_Plugin::create( $handle ); } catch( Exception $e ){ // @codeCoverageIgnore } } /* @var $theme WP_Theme */ foreach( wp_get_themes() as $theme ){ try { $bundles[] = Loco_package_Theme::create( $theme->get_stylesheet() ); } catch( Exception $e ){ // @codeCoverageIgnore } } $this->bundles = $bundles; } return $bundles; } /** * @return loco_fs_FileList */ public function findLocaleFiles(){ $index = $this->index; $suffixes = $this->match; $list = new Loco_fs_FileList; /* @var $bundle Loco_package_Bundle */ foreach( $this->getBundles() as $bundle ){ /* @var $project Loco_package_Project */ foreach( $bundle as $project ){ /* @var $file Loco_fs_File */ foreach( $project->findLocaleFiles('po') as $file ){ $path = $file->getPath(); foreach( $suffixes as $sufx => $snip ){ if( substr($path,$snip) === $sufx ){ $list->add( $file ); $index[$path] = $project; break; } } } } } return $list; } /** * @return loco_fs_FileList */ public function findTemplateFiles(){ $index = $this->index; $list = new Loco_fs_FileList; /* @var $bundle Loco_package_Bundle */ foreach( $this->getBundles() as $bundle ){ /* @var $project Loco_package_Project */ foreach( $bundle as $project ){ $file = $project->getPot(); if( $file && $file->exists() ){ $list->add( $file ); $path = $file->getPath(); $index[$path] = $project; } } } return $list; } } src/package/Bundle.php000066600000047176152141417370010700 0ustar00 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(); /** * Construct bundle from unique ID containing type and handle * @param string * @return Loco_package_Bundle */ public static function fromId( $id ){ $r = explode( '.', $id, 2 ); return self::createType( $r[0], isset($r[1]) ? $r[1] : '' ); } /** * @param string * @param string * @return Loco_package_Bundle * @throws Loco_error_Exception */ public static function createType( $type, $handle ){ $func = array( '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; } /** * Construct from WordPress handle and friendly name * @param string * @param string */ public function __construct( $handle, $name ){ parent::__construct( array() ); $this->setHandle($handle)->setName($name); $this->xpaths = new Loco_fs_FileList; } /** * Re-fetch this bundle from its currently saved location * @return Loco_package_Bundle */ public function reload(){ return call_user_func( array( 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 (string) $this->name; } /** * @return bool */ public function isTheme(){ return false; } /** * Get parent bundle if possible * @codeCoverageIgnore * @return Loco_package_Bundle|null */ public function getParent(){ trigger_error( $this->getType().' bundles cannot have parents. Check isTheme first', E_USER_NOTICE ); 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 * @param string * @return Loco_package_Bundle */ public function setName( $name ){ $this->name = $name; return $this; } /** * Set short name of bundle which may or may not match unique handle * @param string * @return Loco_package_Bundle */ public function setSlug( $slug ){ $this->slug = $slug; return $this; } /** * Set internal handle registered with WordPress for this bundle type * @param string * @return Loco_package_Bundle */ public function setHandle( $handle ){ $this->handle = $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 * @param string * @return Loco_package_Bundle */ 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 = array(); $base = (string) $this->getDirectoryPath(); foreach( array('node_modules','vendor') as $f ){ $path = $base.'/'.$f; if( 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 * @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'.$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 (bool) $this->solo; } /** * Add all projects defined in a TextDomain * @param Loco_package_TextDomain * @return Loco_package_Bundle */ 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 * @param Loco_package_Project * @return Loco_package_Bundle */ 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 = array(); /* @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 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 */ public function configureDb(){ if( $option = $this->getCustomConfig() ){ $option->configure(); $this->saved = 'db'; return true; } return false; } /** * Configure from XML config * @return bool whether configured */ public function configureXml(){ if( $xmlfile = $this->getConfigFile() ){ $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 * TODO will we also support JSON for when dom extension is loaded? * TODO support custom location for user-saved XML? * @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 (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 bootstrapper * @return bool whether configured */ 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 if( $this->solo ){ // skip } // else use standard language path if it exists else 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 * @return Loco_package_Bundle */ public function configure( $base, array $header ){ $this->setDirectoryPath( $base ); $this->configureDb() || $this->configureXml() || $this->configureMeta($header); return $this; } /** * Get the custom config saved in WordPress DB for this bundle * @return Loco_config_CustomSaved */ public function getCustomConfig(){ $custom = new Loco_config_CustomSaved; if( $custom->setBundle($this)->fetch() ){ return $custom; } } /** * Inherit another bundle. Used for child themes to display parent translations * @return Loco_package_Bundle */ 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 * @param string | null * @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 */ public function getDefaultProject(){ $i = 0; /* @var $project Loco_package_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; } } /** * Test if project already exists in bundle * @param Loco_package_Project * @return bool */ public function hasProject( Loco_package_Project $project ){ return (bool) $this->getProject( $project->getDomain()->getName(), $project->getSlug() ); } /** * @return array */ public function getDomains(){ $domains = array(); /* @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 $project Loco_package_Project */ foreach( $this as $project ){ $t = max( $t, $project->getLastUpdated() ); } } return $t; } /** * Get project by ID * @param string [.] * @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 Loco_package_Bundle */ public function clear(){ $this->exchangeArray( array() ); $this->xpaths = new Loco_fs_FileList; $this->saved = false; return $this; } /** * @return array */ 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 Loco_package_Bundle */ public function invert(){ return Loco_package_Inverter::compile( $this ); } }src/gettext/WordCount.php000066600000005477152141417370011502 0ustar00po = $po; } /** * @internal */ private function countField( $f ){ $n = 0; foreach( $this->po as $r ){ $n += self::simpleCount( $r[$f] ); } return $n; } /** * Default count function returns source words (msgid) in current file. * @return int */ public function count(){ $n = $this->sw; if( is_null($n) ){ $n = $this->countField('source'); $this->sw = $n; } return $n; } /** * Very simple word count, only suitable for latin characters, and biased toward English. * @param string * @return int */ public static function simpleCount( $str ){ $n = 0; if( is_string($str) && '' !== $str ){ // TODO should we strip PHP string formatting? // e.g. "Hello %s" currently counts as 2 words. // $str = preg_replace('/%(?:\\d+\\$)?(?:\'.|[-+0 ])*\\d*(?:\\.\\d+)?[suxXbcdeEfFgGo%]/', '', $str ); // Strip HTML (but only if open and close tags detected, else "< foo" would be stripped to nothing if( false !== strpos($str,'<') && false !== strpos($str,'>') ){ $str = strip_tags($str); } // always html-decode, else escaped punctuation will be counted as words $str = html_entity_decode( $str, ENT_QUOTES, 'UTF-8'); // Collapsing apostrophe'd words into single units: // Simplest way to handle ambiguity of "It's Tim's" (technically three words in English) $str = preg_replace('/(\\w+)\'(\\w)(\\W|$)/u', '\\1\\2\\3', $str ); // Combining floating numbers into single units // e.g. "£1.50" and "€1,50" should be one word each $str = preg_replace('/\\d[\\d,\\.]+/', '0', $str ); // count words by standard Unicode word boundaries $words = preg_split( '/\\W+/u', $str, -1, PREG_SPLIT_NO_EMPTY ); $n += count($words); /*/ TODO should we exclude some words (like numbers)? foreach( $words as $word ){ if( ! ctype_digit($word) ){ $n++; } }*/ } return $n; } } src/gettext/Extraction.php000066600000012661152141417370011667 0ustar00bundle = $bundle; $this->extracted = new LocoExtracted; $this->extracted->setDomain('default'); $this->extras = array(); if( $default = $bundle->getDefaultProject() ){ $domain = (string) $default->getDomain(); // wildcard stands in for empty text domain, meaning unspecified or dynamic domains will be included. // note that strings intended to be in "default" domain must specify explicitly, or be included here too. if( '*' === $domain ){ $domain = ''; $this->extracted->setDomain(''); } // pull bundle's default metadata. these are translations that may not be encountered in files $extras = array(); $header = $bundle->getHeaderInfo(); foreach( $bundle->getMetaTranslatable() as $prop => $notes ){ if( $source = $header->__get($prop) ){ if( is_string($source) ){ $extras[] = array( $source, $notes ); } } } if( $extras ){ $this->extras[$domain] = $extras; } } } /** * @param Loco_package_Project * @return Loco_gettext_Extraction */ public function addProject( Loco_package_Project $project ){ $base = $this->bundle->getDirectoryPath(); // skip files larger than configured maximum $opts = Loco_data_Settings::get(); $max = wp_convert_hr_to_bytes( $opts->max_php_size ); // *attempt* to raise memory limit to WP_MAX_MEMORY_LIMIT if( function_exists('wp_raise_memory_limit') ){ wp_raise_memory_limit('loco'); } /* @var $file Loco_fs_File */ foreach( $project->findSourceFiles() as $file ){ $type = $opts->ext2type( $file->extension() ); $extr = loco_wp_extractor($type); if( 'js' !== $type ) { // skip large files for PHP, because token_get_all is hungry $size = $file->size(); $this->maxbytes = max( $this->maxbytes, $size ); if( $size > $max ){ $list = $this->skipped or $list = ( $this->skipped = new Loco_fs_FileList() ); $list->add( $file ); continue; } // extract headers from theme PHP files in if( $project->getBundle()->isTheme() ){ $extr->headerize( array ( 'Template Name' => 'Name of the template', ), (string) $project->getDomain() ); } } $this->extracted->extractSource( $extr, $file->getContents(), $file->getRelativePath( $base ) ); } return $this; } /** * Add metadata strings deferred from construction. Note this will alter domain counts * @return Loco_gettext_Extraction */ public function includeMeta(){ foreach( $this->extras as $domain => $extras ){ foreach( $extras as $args ){ $this->extracted->pushMeta( $args[0], $args[1], $domain ); } } $this->extras = array(); return $this; } /** * Get number of unique strings across all domains extracted (excluding additional metadata) * @return array { default: x, myDomain: y } */ public function getDomainCounts(){ return $this->extracted->getDomainCounts(); } /** * Pull extracted data into POT, filtering out any unwanted domains * @param string * @return Loco_gettext_Data */ public function getTemplate( $domain ){ $data = new Loco_gettext_Data( $this->extracted->filter($domain) ); return $data->templatize(); } /** * Get total number of strings extracted from all domains, excluding additional metadata * @return int */ public function getTotal(){ return $this->extracted->count(); } /** * Get list of files skipped, or null if none were skipped * @return Loco_fs_FileList | null */ public function getSkipped(){ return $this->skipped; } /** * Get size in bytes of largest file encountered, even if skipped. * This is the value required of the max_php_size plugin setting to extract all files * @return int */ public function getMaxPhpSize(){ return $this->maxbytes; } } src/gettext/Data.php000066600000030751152141417370010420 0ustar00extension() ), '~' ); if( 'po' === $ext || 'pot' === $ext || 'mo' === $ext ){ return $ext; } // translators: Error thrown when attempting to parse a file that is not PO, POT or MO throw new Loco_error_Exception( sprintf( __('%s is not a Gettext file'), $file->basename() ) ); } /** * @param Loco_fs_File * @return Loco_gettext_Data */ public static function load( Loco_fs_File $file ){ $type = strtoupper( self::ext($file) ); // catch parse errors so we can inform user of which file is bad try { if( 'MO' === $type ){ return self::fromBinary( $file->getContents() ); } else { return self::fromSource( $file->getContents() ); } } catch( Loco_error_ParseException $e ){ $path = $file->getRelativePath( loco_constant('WP_CONTENT_DIR') ); Loco_error_AdminNotices::debug( sprintf('Failed to parse %s as a %s file',$path,$type) ); throw new Loco_error_ParseException( sprintf('Invalid %s file: %s',$type,basename($path)) ); } } /** * Like load but just pulls header, saving a full parse. PO only * @param Loco_fs_File * @return Loco_gettext_Data * @throws InvalidArgumentException */ public static function head( Loco_fs_File $file ){ if( 'mo' === self::ext($file) ){ throw new InvalidArgumentException('PO only'); } $po = new LocoPoParser( $file->getContents() ); return new Loco_gettext_Data( $po->parse(1) ); } /** * @param string assumed PO source * @return Loco_gettext_Data */ public static function fromSource( $src ){ $p = new LocoPoParser($src); return new Loco_gettext_Data( $p->parse() ); } /** * @param string assumed MO bytes * @return Loco_gettext_Data */ public static function fromBinary( $bin ){ $p = new LocoMoParser($bin); return new Loco_gettext_Data( $p->parse() ); } /** * Create a dummy/empty instance with minimum content to be a valid PO file. * @return Loco_gettext_Data */ public static function dummy(){ return new Loco_gettext_Data( array( array('source'=>'','target'=>'Language:') ) ); } /** * Ensure PO source is UTF-8. * Required if we want PO code when we're not parsing it. e.g. source view * @param string * @return string */ public static function ensureUtf8( $src ){ loco_check_extension('mbstring'); $src = loco_remove_bom($src,$cs); if( ! $cs ){ // read PO header, requiring partial parse try { $cs = LocoPoHeaders::fromSource($src)->getCharset(); } catch( Loco_error_ParseException $e ){ Loco_error_AdminNotices::debug( $e->getMessage() ); } // fall back on detection which will only work for latin1 if( ! $cs ){ $cs = mb_detect_encoding($src,array('UTF-8','ISO-8859-1'),true); } } if( $cs && 'UTF-8' !== $cs ){ $src = mb_convert_encoding($src,'UTF-8',array($cs) ); } return $src; } /** * Compile messages to binary MO format * @return string MO file source * @throws Loco_error_Exception */ public function msgfmt(){ if( 2 !== strlen("\xC2\xA3") ){ throw new Loco_error_Exception('Refusing to compile MO file. Please disable mbstring.func_overload'); // @codeCoverageIgnore } $mo = new LocoMo( $this, $this->getHeaders() ); $opts = Loco_data_Settings::get(); if( $opts->gen_hash ){ $mo->enableHash(); } if( $opts->use_fuzzy ){ $mo->useFuzzy(); } return $mo->compile(); } /** * Get final UTF-8 string for writing to file * @param bool whether to sort output, generally only for extracting strings * @return string */ public function msgcat( $sort = false ){ // set maximum line width, zero or >= 15 $this->wrap( Loco_data_Settings::get()->po_width ); // concat with default text sorting if specified $po = $this->render( $sort ? array( 'LocoPoIterator', 'compare' ) : null ); // Prepend byte order mark only if configured if( Loco_data_Settings::get()->po_utf8_bom ){ $po = "\xEF\xBB\xBF".$po; } return $po; } /** * Split JavaScript messages out of document, based on file reference mapping * @return array */ public function splitJs(){ // TODO take file extension from config $messages = $this->splitRefs( array('js'=>'js','jsx'=>'js') ); return isset($messages['js']) ? $messages['js'] : array(); } /** * Compile JED flavour JSON * @param string text domain for JED metadata * @param LocoPoMessage[] pre-compiled messages * @return string */ public function jedize( $domain, array $po ){ $head = $this->getHeaders(); // start locale_data with JED header $data = array( '' => array ( 'domain' => $domain, 'lang' => $head['language'], 'plural-forms' => $head['plural-forms'], ) ); /* @var LocoPoMessage $msg */ foreach( $po as $msg ){ $data[ $msg->getKey() ] = $msg->getMsgstrs(); } // pretty formatting for debugging $json_options = 0; if( Loco_data_Settings::get()->jed_pretty ){ $json_options |= loco_constant('JSON_PRETTY_PRINT') | loco_constant('JSON_UNESCAPED_SLASHES') | loco_constant('JSON_UNESCAPED_UNICODE'); } return json_encode( array ( 'translation-revision-date' => $head['po-revision-date'], 'generator' => $head['x-generator'], 'domain' => $domain, 'locale_data' => array ( $domain => $data, ), ), $json_options ); } /** * @return array */ public function jsonSerialize(){ $po = $this->getArrayCopy(); // exporting headers non-scalar so js doesn't have to parse them try { $headers = $this->getHeaders(); if( count($headers) && '' === $po[0]['source'] ){ $po[0]['target'] = $headers->getArrayCopy(); } } // suppress header errors when serializing // @codeCoverageIgnoreStart catch( Exception $e ){ } // @codeCoverageIgnoreEnd return $po; } /** * Export to JSON for JavaScript editor * @return string */ public function exportJson(){ return json_encode( $this->jsonSerialize() ); } /** * Create a signature for use in comparing source strings between documents * @return string */ public function getSourceDigest(){ $data = $this->getHashes(); return md5( implode("\1",$data) ); } /** * @param Loco_Locale * @param array custom headers * @return Loco_gettext_Data */ public function localize( Loco_Locale $locale, array $custom = null ){ $date = gmdate('Y-m-d H:i').'+0000'; // <- forcing UCT $headers = $this->getHeaders(); // headers that must always be set if absent $defaults = array ( 'Project-Id-Version' => '', 'Report-Msgid-Bugs-To' => '', 'POT-Creation-Date' => $date, ); // headers that must always override when localizing $required = array ( 'PO-Revision-Date' => $date, 'Last-Translator' => '', 'Language-Team' => $locale->getName(), 'Language' => (string) $locale, 'Plural-Forms' => $locale->getPluralFormsHeader(), 'MIME-Version' => '1.0', 'Content-Type' => 'text/plain; charset=UTF-8', 'Content-Transfer-Encoding' => '8bit', 'X-Generator' => 'Loco https://localise.biz/', 'X-Loco-Version' => sprintf('%s; wp-%s', loco_plugin_version(), $GLOBALS['wp_version'] ), ); // set user's preferred Last-Translator credit if configured if( function_exists('get_current_user_id') && get_current_user_id() ){ $prefs = Loco_data_Preferences::get(); $credit = (string) $prefs->credit; if( '' === $credit ){ $credit = $prefs->default_credit(); } // filter credit with current user name and email $user = wp_get_current_user(); $credit = apply_filters( 'loco_current_translator', $credit, $user->get('display_name'), $user->get('email') ); if( '' !== $credit ){ $required['Last-Translator'] = $credit; } } // only set absent or empty headers from default list foreach( $defaults as $key => $value ){ if( ! $headers[$key] ){ $headers[$key] = $value; } } // add required headers with custom ones overriding if( is_array($custom) ){ $required = array_merge( $required, $custom ); } foreach( $required as $key => $value ){ $headers[$key] = $value; } // avoid non-empty POT placeholders that won't have been set from $defaults if( 'PACKAGE VERSION' === $headers['Project-Id-Version'] ){ $headers['Project-Id-Version'] = ''; } // header message must be un-fuzzied if it was formerly a POT file return $this->initPo(); } /** * @return Loco_gettext_Data */ public function templatize(){ $date = gmdate('Y-m-d H:i').'+0000'; // <- forcing UCT $headers = $this->getHeaders(); $required = array ( 'Project-Id-Version' => 'PACKAGE VERSION', 'Report-Msgid-Bugs-To' => '', 'POT-Creation-Date' => $date, 'PO-Revision-Date' => 'YEAR-MO-DA HO:MI+ZONE', 'Last-Translator' => 'FULL NAME ', 'Language-Team' => '', 'Language' => '', 'Plural-Forms' => 'nplurals=INTEGER; plural=EXPRESSION;', 'MIME-Version' => '1.0', 'Content-Type' => 'text/plain; charset=UTF-8', 'Content-Transfer-Encoding' => '8bit', 'X-Generator' => 'Loco https://localise.biz/', 'X-Loco-Version' => sprintf('%s; wp-%s', loco_plugin_version(), $GLOBALS['wp_version'] ), ); foreach( $required as $key => $value ){ $headers[$key] = $value; } return $this->initPot(); } /** * Remap proprietary base path when PO file is moving to another location. * * @param Loco_fs_File the file that was originally extracted to (POT) * @param Loco_fs_File the file that must now target references relative to itself * @param string vendor name used in header keys * @return bool whether base header was alterered */ public function rebaseHeader( Loco_fs_File $origin, Loco_fs_File $target, $vendor ){ $base = $target->getParent(); $head = $this->getHeaders(); $key = 'X-'.$vendor.'-Basepath'; if( $key = $head->normalize($key) ){ $oldRelBase = $head[$key]; $oldAbsBase = new Loco_fs_Directory($oldRelBase); $oldAbsBase->normalize( $origin->getParent() ); $newRelBase = $oldAbsBase->getRelativePath($base); // new base path is relative to $target location $head[$key] = $newRelBase; return true; } return false; } /** * @param string date format as Gettext states "YEAR-MO-DA HO:MI+ZONE" * @return int */ public static function parseDate( $podate ){ if( method_exists('DateTime', 'createFromFormat') ){ $objdate = DateTime::createFromFormat('Y-m-d H:iO', $podate); if( $objdate instanceof DateTime ){ return $objdate->getTimestamp(); } } return strtotime($podate); } } src/gettext/Metadata.php000066600000015307152141417370011267 0ustar00 total, 'p' => progress, 'f' => fuzzy ]; */ public static function stats( array $po ){ $t = $p = $f = 0; /* @var $r array */ foreach( $po as $i => $r ){ // skip header if( 0 === $i && empty($r['source']) && empty($r['context']) ){ continue; } // plural form // TODO how should plural forms affect stats? should all forms be complete before 100% can be achieved? should offsets add to total?? if( isset($r['parent']) && is_int($r['parent']) ){ continue; } // singular form $t++; if( '' !== $r['target'] ){ $p++; if( isset($r['flag']) /*&& LOCO_FLAG_FUZZY === $r['flag']*/ ){ $f++; } } } return compact('t','p','f'); } /** * {@inheritdoc} */ public function getKey(){ return 'po_'.md5( $this['rpath'] ); } /** * Load metadata from file, using cache if enabled. * Note that this does not throw exception, check "valid" key * @param Loco_fs_File * @param bool * @return Loco_gettext_Metadata */ public static function load( Loco_fs_File $po, $nocache = false ){ $bytes = $po->size(); $mtime = $po->modified(); // quick construct of new meta object. enough to query and validate cache  $meta = new Loco_gettext_Metadata( array( 'rpath' => $po->getRelativePath( loco_constant('WP_CONTENT_DIR') ), ) ); // pull from cache if exists and has not been modified if( $nocache || ! $meta->fetch() || $bytes !== $meta['bytes'] || $mtime !== $meta['mtime'] ){ // not available from cache, or cache is invalidated $meta['bytes'] = $bytes; $meta['mtime'] = $mtime; // parse what is hopefully a PO file to get stats try { $data = Loco_gettext_Data::load($po)->getArrayCopy(); $meta['valid'] = true; $meta['stats'] = self::stats($data); } catch( Exception $e ){ $meta['valid'] = false; $meta['error'] = $e->getMessage(); } } // show cached debug notice as if file was being parsed else if( $meta->offsetExists('error') ){ Loco_error_AdminNotices::debug($meta['error'].': '.$meta['rpath']); } // persist on shutdown with a useful TTL and keepalive // Maximum lifespan: 10 days. Refreshed if accessed a day after being cached. $meta->setLifespan(864000)->keepAlive(86400)->persistLazily(); return $meta; } /** * Construct metadata from previously parsed PO data * @param Loco_fs_File * @param Loco_gettext_Data * @return Loco_gettext_Metadata */ public static function create( Loco_fs_File $file, Loco_gettext_Data $data ){ return new Loco_gettext_Metadata( array ( 'valid' => true, 'bytes' => $file->size(), 'mtime' => $file->modified(), 'stats' => self::stats( $data->getArrayCopy() ), ) ); } /** * Get progress stats as simple array with keys, t=total, p=progress, f:flagged. * Note that untranslated strings are never flagged, hence "f" includes all in "p" * @return array in form ['t' => total, 'p' => progress, 'f' => fuzzy ]; */ public function getStats(){ if( isset($this['stats']) ){ return $this['stats']; } // fallback to empty stats return array( 't' => 0, 'p' => 0, 'f' => 0 ); } /** * Get total number of messages, not including header and excluding plural forms * @return int */ public function getTotal(){ $stats = $this->getStats(); return $stats['t']; } /** * Get number of fuzzy messages, not including header * @return int */ public function countFuzzy(){ $stats = $this->getStats(); return $stats['f']; } /** * Get progress as a string percentage (minus % symbol) * @return string */ public function getPercent(){ $stats = $this->getStats(); $n = max( 0, $stats['p'] - $stats['f'] ); $t = max( $n, $stats['t'] ); return loco_string_percent( $n, $t ); } /** * Get number of strings either untranslated or fuzzy. * @return int */ public function countIncomplete(){ $stats = $this->getStats(); return max( 0, $stats['t'] - ( $stats['p'] - $stats['f'] ) ); } /** * Get number of strings completely untranslated (excludes fuzzy). * @return int */ public function countUntranslated(){ $stats = $this->getStats(); return max( 0, $stats['t'] - $stats['p'] ); } /** * Echo progress bar using compiled function * @return void */ public function printProgress(){ $stats = $this->getStats(); $flagged = $stats['f']; $translated = $stats['p']; $untranslated = $stats['t'] - $translated; loco_print_progress( $translated, $untranslated, $flagged ); } /** * Get wordy summary of total strings */ public function getTotalSummary(){ $total = $this->getTotal(); return sprintf( _n('1 string','%s strings',$total,'loco-translate'), number_format($total) ); } /** * Get wordy summary including translation stats */ public function getProgressSummary(){ $extra = array(); $stext = sprintf( __('%s%% translated','loco-translate'), $this->getPercent() ).', '.$this->getTotalSummary(); if( $num = $this->countFuzzy() ){ $extra[] = sprintf( __('%s fuzzy','loco-translate'), number_format($num) ); } if( $num = $this->countUntranslated() ){ $extra[] = sprintf( __('%s untranslated','loco-translate'), number_format($num) ); } if( $extra ){ $stext .= ' ('.implode(', ', $extra).')'; } return $stext; } public function getPath( $absolute ){ $path = $this['rpath']; if( $absolute && ! Loco_fs_File::abs($path) ){ $path = trailingslashit( loco_constant('WP_CONTENT_DIR') ).$path; } return $path; } } src/gettext/SearchPaths.php000066600000005710152141417370011751 0ustar00getExcluded() ); /* @var Loco_fs_Directory */ foreach( $this->getRootDirectories() as $base ){ $file = new Loco_fs_File($ref); $path = $file->normalize( (string) $base ); if( $file->exists() && ! $excluded->check($path) ){ return $file; } } } /** * Build search paths from a given PO/POT file that references other files * @return Loco_gettext_SearchPaths */ public function init( Loco_fs_File $pofile, LocoHeaders $head = null ){ if( is_null($head) ){ loco_require_lib('compiled/gettext.php'); $head = LocoPoHeaders::fromSource( $pofile->getContents() ); } $ninc = 0; foreach( array('Poedit') as $vendor ){ $key = 'X-'.$vendor.'-Basepath'; if( ! $head->has($key) ){ continue; } $dir = new Loco_fs_Directory( $head[$key] ); $base = $dir->normalize( $pofile->dirname() ); // base should be absolute, with the following search paths relative to it $i = 0; while( true ){ $key = sprintf('X-%s-SearchPath-%u', $vendor, $i++); if( ! $head->has($key) ){ break; } // map search path to given base $include = new Loco_fs_File( $head[$key] ); $include->normalize( $base ); if( $include->exists() ){ if( $include->isDirectory() ){ $this->addRoot( (string) $include ); $ninc++; } /*else { TODO force specific file in Loco_fs_FileFinder }*/ } } // exclude from search paths $i = 0; while( true ){ $key = sprintf('X-%s-SearchPathExcluded-%u', $vendor, $i++); if( ! $head->has($key) ){ break; } // map excluded path to given base $exclude = new Loco_fs_File( $head[$key] ); $exclude->normalize($base); if( $exclude->exists() ){ $this->exclude( (string) $exclude ); } // TODO implement wildcard exclusion } } // Add po file location if no proprietary headers used if( ! $ninc ){ $this->addRoot( $pofile->dirname() ); } return $this; } }src/compat/TokenizerExtension.php000066600000001047152141417370013211 0ustar00

Error: '.implode('. ',$texts).'

'; } } src/compat/JsonExtension.php000066600000001454152141417370012152 0ustar00jsonSerialize() ) * Note that this shim is also present in WordPress >= 4.4.0 */ if( ! interface_exists('JsonSerializable') ){ interface JsonSerializable { public function jsonSerialize(); } } // @codeCoverageIgnoreEnd /** * Redundant interface so this file will autoload when JsonSerializable is referenced * @internal */ interface Loco_compat_JsonSerializable extends JsonSerializable { } src/compat/MbstringExtension.php000066600000003635152141417370013031 0ustar00 */ private $cookies_set; /** * Drop all Loco data from the options table (including transients) * @return void */ protected static function dropOptions(){ global $wpdb; $query = $wpdb->prepare( "SELECT option_name FROM $wpdb->options WHERE option_name LIKE '%s' OR option_name LIKE '%s'", array('loco_%','_%_loco_%') ); if( $results = $wpdb->get_results($query,ARRAY_N) ){ foreach( $results as $row ){ list( $option_name ) = $row; delete_option( $option_name ); } } } /** * @internal */ public static function setUpBeforeClass(){ parent::setUpBeforeClass(); Loco_data_Settings::clear(); Loco_data_Session::destroy(); Loco_data_RecentItems::destroy(); self::dropOptions(); // start with default permissions as if fresh install remove_role('translator'); Loco_data_Permissions::init(); } /** * @internal */ public static function tearDownAfterClass(){ parent::tearDownAfterClass(); Loco_data_Settings::clear(); Loco_data_Session::destroy(); Loco_data_RecentItems::destroy(); wp_cache_flush(); self::dropOptions(); } /** * {@inheritdoc} */ public function setUp(){ parent::setUp(); Loco_mvc_PostParams::destroy(); Loco_error_AdminNotices::destroy(); Loco_package_Listener::destroy(); wp_cache_flush(); // text domains should be unloaded at start of all tests, and locale reset unset( $GLOBALS['locale'] ); $GLOBALS['l10n'] = array(); $this->enable_locale('en_US'); $this->assertSame( 'en_US', get_locale(), 'Ensure test site is English to start'); $this->assertSame( 'en_US', get_user_locale(),'Ensure test site is English to start'); // ensure test themes are registered and WordPress's cache is valid register_theme_directory( LOCO_TEST_DATA_ROOT.'/themes' ); $sniff = get_theme_roots(); if( ! isset($sniff['empty-theme']) ){ delete_site_transient( 'theme_roots' ); } // test plugins require a filter as multiple roots not supported in wp remove_all_filters('loco_missing_plugin'); add_filter( 'loco_missing_plugin', array(__CLASS__,'filter_allows_fake_plugins_to_exist'), 10, 2 ); // avoid WordPress missing index notices $GLOBALS['_SERVER'] += array ( 'HTTP_HOST' => 'localhost', 'SERVER_PROTOCOL' => 'HTTP/1.0', 'HTTP_USER_AGENT' => 'Loco/'.get_class($this), ); // remove all filters before adding remove_all_filters('filesystem_method'); remove_all_filters('loco_constant_DISALLOW_FILE_MODS'); remove_all_filters('file_mod_allowed'); remove_all_filters('loco_file_mod_allowed_context'); remove_all_filters('loco_setcookie'); // tests should always dictate the file system method, which defaults to direct add_filter('filesystem_method', array($this,'filter_fs_method') ); add_filter('loco_constant_DISALLOW_FILE_MODS', array($this,'filter_fs_disallow') ); add_filter('file_mod_allowed', array($this,'filter_fs_allow'), 10, 2 ); // <- wp 4.8 add_filter('loco_file_mod_allowed_context', array($this,'filter_fs_allow_context'),10,2); // <- used with file_mod_allowed // capture cookies so we can test what is set add_filter('loco_setcookie', array($this,'captureCookie'), 10, 1 ); $this->cookies_set = array(); $this->enable_network(); } /** * {@inheritdoc} */ public function clean_up_global_scope(){ parent::clean_up_global_scope(); $_COOKIE = array(); $_REQUEST = array(); } /** * Capture cookie and prevent actual http sending */ public function captureCookie( Loco_data_Cookie $cookie ){ $this->cookies_set[ $cookie->getName() ] = $cookie; return false; } /** * @return Loco_data_Cookie */ public function assertCookieSet( $name, $message = '' ){ $this->assertArrayHasKey( $name, $this->cookies_set, $message ); $cookie = $this->cookies_set[ $name ]; $this->assertInstanceOf( 'Loco_data_Cookie', $cookie, $message ); return $cookie; } /** * Invoke admin page controller without full hook set up * @return string HTML */ public static function renderPage(){ $router = new Loco_mvc_AdminRouter; $router->on_admin_menu(); $screen = get_current_screen(); $action = isset($_GET['action']) ? $_GET['action'] : null; $router->initPage( $screen, $action ); return get_echo( array($router,'renderPage') ); } /** * Invoke Ajax controller without full hook set up. * @return string JSON */ protected function renderAjax(){ wp_magic_quotes(); // <- I hate this, but it's what WP does! $router = new Loco_mvc_AjaxRouter; $router->on_init(); return $router->renderAjax(); } /** * @internal */ public function filter_fs_method( $method = '' ){ return is_null($this->fs_method) ? $method : $this->fs_method; } /** * @return Loco_test_WordPressTestCase */ public function set_fs_method( $method ){ $GLOBALS['wp_filesystem'] = null; $this->fs_method = $method; $ping = class_exists('Loco_test_DummyFtpConnect'); return $this; } /** * @return Loco_test_WordPressTestCase */ public function disable_file_mods(){ $this->fs_allow = false; return $this; } /** * Filters wp_is_file_mod_allowed for WP >= 4.8 * @internal */ public function filter_fs_allow( $bool, $context = '' ){ if( 'loco_test' === $context ){ $bool = $this->fs_allow; } return $bool; } /** * Filters DISALLOW_FILE_MODS for WP < 4.8 * @internal */ public function filter_fs_disallow(){ return ! $this->fs_allow; } /** * Filters context passed to filter_fs_allow * @internal */ public function filter_fs_allow_context( $context, Loco_fs_File $file = null ){ return 'loco_test'; } /** * Remove files created under tmp * @return void */ protected function clearTmp(){ $root = new Loco_fs_Directory( LOCO_TEST_DATA_ROOT.'/tmp' ); $dir = new Loco_fs_FileFinder( $root ); $dir->setRecursive( true ); $dirs = array(); /* @var $file Loco_fs_File */ foreach( $dir as $file ){ $dirs[ $file->dirname() ] = true; $file->unlink(); } // Be warned only directories found above will be removed foreach( array_keys($dirs) as $path ){ $dir = new Loco_fs_Directory($path); while( $dir->exists() && ! $dir->equal($root) ){ $dir->unlink(); $dir = $dir->getParent(); } } } /** * Log a mock user into WordPress * @return void */ protected function login( $role = 'administrator' ){ $wpRole = get_role($role); if( ! $wpRole ){ throw new Exception('No such role, '.$role ); } else if( ! $wpRole->capabilities ){ throw new Exception( $role.' role has no capabilities' ); } $user = self::factory()->user->create( array( 'role' => $role ) ); if( $user instanceof WP_Error ){ foreach( $user->get_error_messages() as $message ){ trigger_error( $message ); } throw new Exception('Failed to login'); } // setting user required to have proper user object $user = wp_set_current_user( $user ); // simulate default permissions used in admin menu hookage if( $user->has_cap('manage_options') ){ $user->add_cap('loco_admin'); } // simulate wp_set_auth_cookie. Can't actually set cookie cos headers $_COOKIE[LOGGED_IN_COOKIE] = wp_generate_auth_cookie( $user->ID, time()+60, 'logged_in' ); // $debug = array( 'name' => $this->getName(), 'token' => wp_get_session_token() ,'uid' => $user->ID ); // forcing new session instance new Loco_data_Session; } /** * Log out current WordPress user * @return void */ protected function logout(){ Loco_data_Session::destroy(); wp_destroy_current_session(); unset( $_COOKIE[LOGGED_IN_COOKIE] ); wp_set_current_user( 0 ); $GLOBALS['current_user'] = null; } /** * Disallow network access * @return void */ protected function disable_network(){ remove_all_filters('loco_allow_remote'); add_filter('loco_allow_remote', '__return_false' ); } /** * Enable network access * @return void */ protected function enable_network(){ remove_all_filters('loco_allow_remote'); } /** * Switch loco_debugging on * @return void */ protected function enable_debug(){ remove_all_filters('loco_debug'); add_filter('loco_debug', '__return_true' ); } /** * Switch loco_debugging off * @return void */ protected function disable_debug(){ remove_all_filters('loco_debug'); add_filter('loco_debug', '__return_false' ); } /** * Temporarily enable the "en_GB_debug" test locale * @return void */ protected function enable_debug_locale(){ return $this->enable_locale('en_GB_debug'); } /** * Temporarily enable a specific locale * @return void */ protected function enable_locale( $tag ){ $locale = Loco_Locale::parse($tag); $this->locale = (string) $locale; remove_all_filters('locale'); add_filter('locale', array($this,'_filter_locale') ); } /** * @internal */ public function _filter_locale(){ return $this->locale; } /** * Temporarily set test data root to content directory * @return void */ public function enable_test_content_dir(){ remove_all_filters('loco_constant_WP_CONTENT_DIR'); add_filter('loco_constant_WP_CONTENT_DIR', array($this,'_filter_wp_content_dir'), 10, 0 ); } /** * @internal */ public function _filter_wp_content_dir(){ return LOCO_TEST_DATA_ROOT; } /** * @internal */ public function capture_redirects(){ remove_all_filters('wp_redirect'); add_filter('wp_redirect', array($this,'filter_wp_redirect'), 10, 2 ); } /** * @internal */ public function filter_wp_redirect( $location, $status ){ $this->redirect = func_get_args(); return false; } public static function filter_allows_fake_plugins_to_exist( array $data, $handle ){ $file = LOCO_TEST_DATA_ROOT.'/plugins/'.$handle; if( file_exists($file) ) { $data = get_plugin_data($file); $snip = -strlen($handle); $data['basedir'] = substr($file,0,--$snip); } return $data; } /** * @return string location */ public function assertRedirected( $status = 302, $message = 'Failed to redirect' ){ $raw = $this->redirect; $this->assertInternalType('array', $raw, $message ); $this->assertSame( $status, $raw[1], $message ); return $raw[0]; } /** * Set $_POST * @return void */ public function setPostArray( array $post ){ $_POST = $post; $_REQUEST = array_merge( $_GET, $_POST, $_COOKIE ); $_SERVER['REQUEST_METHOD'] = 'POST'; Loco_mvc_PostParams::destroy(); } /** * Augment $_POST * @return void */ public function addPostArray( array $post ){ $this->setPostArray( $post + $_POST ); } /** * Set $_GET * @return void */ public function setGetArray( array $get ){ $_GET = $get; $_REQUEST = array_merge( $_GET, $_POST, $_COOKIE ); $_SERVER['REQUEST_METHOD'] = 'GET'; } /** * Augment $_GET * @return void */ public function addGetArray( array $get ){ $this->setGetArray( $get + $_GET ); } }src/test/DummyFtpConnect.php000066600000007630152141417400012113 0ustar00getWriteContext()->connect( new WP_Filesystem_Debug($creds) )` */ class Loco_test_DummyFtpConnect extends Loco_hooks_Hookable { public function filter_filesystem_method(){ return 'debug'; } } /** * Dummy FTP file system. * WARNING: this actually modifies files - it just does it while simulating a remote connection * - All operations performed "direct" when authorized, else they fail. */ class WP_Filesystem_Debug extends WP_Filesystem_Base { private $authed; /** * @var WP_Error */ public $errors; public function __construct( array $opt ) { $this->options = $opt; $this->method = 'ftp'; } /** * Dummy FTP connect: requires username=foo password=xxx */ public function connect() { $this->authed = false; $this->errors = new WP_Error; // @codeCoverageIgnoreStart if( empty($this->options['hostname']) ){ $this->errors->add( 'bad_hostname', 'Debug: empty hostname'); return false; } if( empty($this->options['username']) ){ $this->errors->add( 'bad_username', 'Debug: empty username'); return false; } if( $this->options['username'] !== 'foo' ) { $this->errors->add( 'bad_username', 'Debug: username expected to be "foo"'); return false; } if( empty($this->options['password']) ){ $this->errors->add( 'bad_username', 'Debug: empty password'); return false; } if( $this->options['password'] !== 'xxx' ) { $this->errors->add( 'bad_password', 'Debug: password expected to be "xxx"' ); return false; } // @codeCoverageIgnoreEnd $this->authed = true; return true; } /** * @return WP_Filesystem_Debug */ public function disconnect(){ $this->authed = false; $this->options = array(); return $this; } /** * {@inheritdoc} * Dummy function allows exact path to be returned, subject to debugging filters */ public function find_folder( $path ){ if( WP_CONTENT_DIR === $path ){ return loco_constant('WP_CONTENT_DIR'); } return false; } /** * @internal * Proxies supposed remote call to *real* direct call, as long as instance is authorized. * Deliberately not extending WP_Filesystem_Direct for safety. */ private function _call( $method, array $args ){ if( $this->authed ){ $real = Loco_api_WordPressFileSystem::direct(); return call_user_func_array( array($real,$method), $args ); } return false; } /** * {@inheritdoc} */ public function is_writable( $file ){ return $this->_call( __FUNCTION__, func_get_args() ); } /** * {@inheritdoc} */ public function chmod( $file, $mode = false, $recursive = false ){ return $this->_call( __FUNCTION__, func_get_args() ); } /** * {@inheritdoc} */ public function copy( $source, $destination, $overwrite = false, $mode = false ){ return $this->_call( __FUNCTION__, func_get_args() ); } /** * {@inheritdoc} */ public function put_contents( $path, $data, $mode = false ){ return $this->_call( __FUNCTION__, func_get_args() ); } /** * {@inheritdoc} */ public function delete( $file, $recursive = false, $type = false ){ return $this->_call( __FUNCTION__, func_get_args() ); } /** * {@inheritdoc} */ public function mkdir( $path, $chmod = false, $chown = false, $chgrp = false ){ return $this->_call( __FUNCTION__, func_get_args() ); } } src/test/TestFilters.php000066600000001015152141417400011273 0ustar00preserveWhitespace = false; $dom->formatOutput = false; $dom->loadXML( ''.$src.'' ); $dom->normalizeDocument(); $src = $dom->saveXML(); return trim( preg_replace( '/>\s+<', $src ) ); } public function assertSameHtml( $expect, $actual, $message = null ){ return $this->assertSame( $this->normalizeHtml($expect), $this->normalizeHtml($actual), $message ); } }src/test/TransientObject.php000066600000000247152141417400012127 0ustar00form; } /** * Pre-auth checks for superficial file system blocks and disconnects any active remotes * @param Loco_fs_File * @throws Loco_error_WriteException * @return bool always true */ public function preAuthorize( Loco_fs_File $file ){ if( ! $this->fs_allowed ){ $file->getWriteContext()->authorize(); $this->fs_allowed = true; } // Disconnecting remote file system ensures the auth functions always start with direct file access $file->getWriteContext()->disconnect(); return true; } /** * Authorize for the creation of a file that does not exist * @param Loco_fs_File * @return bool whether file system is authorized NOT necessarily whether file is creatable */ public function authorizeCreate( Loco_fs_File $file ){ $this->preAuthorize($file); if( $file->exists() ){ throw new Loco_error_WriteException( sprintf( __('%s already exists in this folder','loco-translate'), $file->basename() ) ); } return $file->creatable() || $this->authorize($file); } /** * Authorize for the update of a file that does exist * @param Loco_fs_File * @return bool whether file system is authorized NOT necessarily whether file is updatable */ public function authorizeUpdate( Loco_fs_File $file ){ $this->preAuthorize($file); if( ! $file->exists() ){ throw new Loco_error_WriteException("File doesn't exist, try authorizeCreate"); } return $file->writable() || $this->authorize($file); } /** * Authorize for update or creation, depending whether file exists * @param Loco_fs_File * @return bool */ public function authorizeSave( Loco_fs_File $file ){ $this->preAuthorize($file); return ( $file->exists() ? $file->writable() : $file->creatable() ) || $this->authorize($file); } /** * Authorize for copy (to same directory), meaning source file must exist and directory be writable * @param Loco_fs_File * @return bool */ public function authorizeCopy( Loco_fs_File $file ){ $this->preAuthorize($file); if( ! $file->exists() ){ throw new Loco_error_WriteException("Can't copy a file that doesn't exist"); } return $file->creatable() || $this->authorize($file); } /** * Authorize for move (to another path if given). * @param Loco_fs_File file being moved (must exist) * @param Loco_fs_File target path (should not exist) * @return bool */ public function authorizeMove( Loco_fs_File $source, Loco_fs_File $target = null ){ // source is in charge of its own deletion $result = $this->authorizeDelete($source); // target is in charge of copying original which it must also be able to read. if( $target && ! $this->authorizeCreate($target) ){ $result = false; } // value returned will be false if at least one file requires we add credentials return $result; } /** * Authorize for the removal of an existing file * @param Loco_fs_File * @return bool whether file system is authorized NOT necessarily whether file is removable */ public function authorizeDelete( Loco_fs_File $file ){ $this->preAuthorize($file); if( ! $file->exists() ){ throw new Loco_error_WriteException("Can't delete a file that doesn't exist"); } return $file->deletable() || $this->authorize($file); } /** * Connect file to credentials in posted data. Used when established in advance what connection is needed * @param Loco_fs_File * @return bool whether file system is authorized */ public function authorizeConnect( Loco_fs_File $file ){ $this->preAuthorize($file); // front end may have posted that "direct" connection will work $post = Loco_mvc_PostParams::get(); if( 'direct' === $post->connection_type ){ return true; } return $this->authorize($file); } /** * Wraps `request_filesystem_credentials` negotiation to obtain a remote connection and buffer WordPress form output * Call before output started, because buffers. * @param Loco_fs_File * @return bool */ private function authorize( Loco_fs_File $file ){ // may already have authorized successfully if( $fs = $this->fs ){ $file->getWriteContext()->connect( $fs, false ); return true; } // may have already failed authorization if( $this->form ){ return false; } // network access may be disabled if( ! apply_filters('loco_allow_remote', true ) ){ throw new Loco_error_WriteException('Remote connection required, but network access is disabled'); } // else begin new auth $this->fs = null; $this->form = ''; $this->creds_out = array(); // observe settings held temporarily in session try { $session = Loco_data_Session::get(); if( isset($session['loco-fs']) ){ $creds = $session['loco-fs']; if( is_array($creds) && $this->tryCredentials($creds,$file) ){ $this->creds_in = array(); return true; } } } catch( Exception $e ){ // tolerate session failure } $post = Loco_mvc_PostParams::get(); $dflt = array( 'hostname' => '', 'username' => '', 'password' => '', 'public_key' => '', 'private_key' => '', 'connection_type' => '', '_fs_nonce' => '' ); $this->creds_in = array_intersect_key( $post->getArrayCopy(), $dflt ); // deliberately circumventing call to `get_filesystem_method` // risk of WordPress version compatibility issues, but only sane way to force a remote connection // @codeCoverageIgnoreStart if( defined('FS_METHOD') && FS_METHOD ){ $type = FS_METHOD; // forcing direct access means request_filesystem_credentials will never give us a form :( if( 'direct' === $type ){ return false; } } // direct filesystem if ok if front end already posted it else if( 'direct' === $post->connection_type ){ return true; } // else perform same logic as request_filesystem_credentials does to establish type else if( 'ssh' === $post->connection_type && extension_loaded('ssh2') && function_exists('stream_get_contents') ){ $type = 'ssh2'; } else if( extension_loaded('ftp') ){ $type = 'ftpext'; } else if( extension_loaded('sockets') || function_exists('fsockopen') ){ $type = 'ftpsockets'; } // @codeCoverageIgnoreEnd else { $type = ''; } // context is nonsense here as the system doesn't know what operation we're performing // testing directory write-permission when we're updating a file, for example. $context = '/ignore/this'; $type = apply_filters( 'filesystem_method', $type, $post->getArrayCopy(), $context, true ); // the only params we'll pass into form will be those used by the ajax fsConnect end point $extra = array( 'loco-nonce', 'path', 'auth', 'dest' ); // capture WordPress output during negotiation. $buffer = Loco_output_Buffer::start(); $creds = request_filesystem_credentials( '', $type, false, $context, $extra ); if( is_array($creds) ){ // credentials passed through, should allow connect if they are correct if( $this->tryCredentials($creds,$file) ){ $this->persistCredentials(); return true; } // else there must be an error with the credentials $error = true; // pull more useful connection error for display in form if( isset($GLOBALS['wp_filesystem']) ){ $fs = $GLOBALS['wp_filesystem']; $GLOBALS['wp_filesystem'] = null; if( $fs && $fs->errors && $fs->errors->get_error_code() ){ $error = $fs->errors; } } // annoyingly WordPress moves the error notice above the navigation tabs :-/ request_filesystem_credentials( '', $type, $error, $context, $extra ); } // now have unauthorized remote connection $this->form = (string) $buffer->close(); return false; } /** * @param array credentials returned from request_filesystem_credentials * @param Loco_fs_File file to authorize write context * @return bool when credentials connected ok */ private function tryCredentials( array $creds, Loco_fs_File $file ){ // lazy construct the file system from current credentials if possible // in typical WordPress style, after success the object will be held in a global. if( WP_Filesystem( $creds, '/ignore/this/' ) ){ $this->fs = $GLOBALS['wp_filesystem']; // hook new file system into write context (specifying that connect has already been performed) $file->getWriteContext()->connect( $this->fs, false ); $this->creds_out = $creds; return true; } return false; } /** * Set current credentials in session if settings allow * @return bool whether credentials persisted */ private function persistCredentials(){ try { $settings = Loco_data_Settings::get(); if( $settings['fs_persist'] ){ $session = Loco_data_Session::get(); $session['loco-fs'] = $this->creds_out; $session->persist(); return true; } } catch( Exception $e ){ // tolerate session failure Loco_error_AdminNotices::debug( $e->getMessage() ); } return false; } /** * Get working credentials that resulted in connection * @return array */ public function getOutputCredentials(){ return $this->creds_out; } /** * Get input credentials from original post. * this is not the same as getCredentials. It is designed for replay only, regardless of success * Note that input to request_filesystem_credentials is not the same as the output (specifically how hostname:port is handled) */ public function getInputCredentials(){ return $this->creds_in; } /** * Get currently configured filesystem API * @return WP_Filesystem_Direct */ public function getFileSystem(){ if( ! $this->fs ){ return self::direct(); } return $this->fs; } /** * Check if a file is safe from WordPress automatic updates * @param Loco_fs_File * @return bool */ public function isAutoUpdatable( Loco_fs_File $file ){ // all paths safe from auto-updates if auto-updates are completely disabled if( $this->isAutoUpdateDenied() ){ return false; } if( apply_filters( 'automatic_updater_disabled', loco_constant('AUTOMATIC_UPDATER_DISABLED') ) ) { return false; } // Auto-updates aren't denied, so ascertain location "type" and run through the same filters as should_update() if( $type = $file->getUpdateType() ){ // TODO provide a useful context for the update offer passed to filters // WordPress updater will have taken this from remote API data which we don't have here. $item = new stdClass; return apply_filters( 'auto_update_'.$type, true, $item ); } // else safe (not auto-updatable) return false; } /** * Check if system is configured to deny auto-updates * @return bool */ public function isAutoUpdateDenied(){ // WordPress >= 4.8 can disable auto updates completely with "automatic_updater" context if( function_exists('wp_is_file_mod_allowed') && ! wp_is_file_mod_allowed('automatic_updater') ){ return true; } // else simply observe AUTOMATIC_UPDATER_DISABLED constant if( apply_filters( 'automatic_updater_disabled', loco_constant('AUTOMATIC_UPDATER_DISABLED') ) ) { return true; } // else nothing explicitly denying updates return false; } }src/api/WordPressTranslations.php000066600000010046152141417400013153 0ustar00 */ public function getAvailableCore(){ $locales = $this->locales; if( is_null($locales) ){ $locales = array(); // get official locales from API if we have network if( $cached = $this->wp_get_available_translations() ){ $english_name = 'english_name'; $native_name = 'native_name'; } // else fall back to bundled data cached else { $english_name = 0; $native_name = 1; $cached = Loco_data_CompiledData::get('locales'); // debug so we can see on front end that data was offline // $locales['en-debug'] = ( new Loco_Locale('en','','debug') )->setName('OFFLINE DATA'); } foreach( $cached as $tag => $raw ){ $locale = Loco_Locale::parse($tag); if( $locale->isValid() ){ $locale->setName( $raw[$english_name], $raw[$native_name] ); $locales[ (string) $tag ] = $locale; } /* Skip invalid language tags, e.g. "pt_PT_ao90" should be "pt_PT_ao1990" * No point fixing invalid tags, because core translation files won't match. else { Loco_error_AdminNotices::debug( sprintf('Invalid locale: %s', $tag) ); }*/ } $this->locales = $locales; } return $locales; } /** * Wrap get_available_languages * @return array */ public function getInstalledCore(){ // wp-includes/l10n.php should always be included at runtime if( ! is_array($this->installed) ){ $this->installed = get_available_languages(); // en_US is implicitly installed if( ! in_array('en_US',$this->installed) ){ array_unshift( $this->installed, 'en_US' ); } } return $this->installed; } /** * @return array */ private function getInstalledHash(){ if( ! is_array($this->installed_hash) ){ $this->installed_hash = array_flip( $this->getInstalledCore() ); } return $this->installed_hash; } /** * Check if a given locale is installed * @return bool */ public function isInstalled( $locale ){ return array_key_exists( (string) $locale, $this->getInstalledHash() ); } /** * Get WordPress locale data by strictly well-formed language tag * @return Loco_Locale */ public function getLocale( $tag ){ $all = $this->getAvailableCore(); return isset($all[$tag]) ? $all[$tag] : null; } /** * Check whether remote API may be disabled for whatever reason, usually debugging. * @return bool */ public function hasNetwork(){ if( is_null($this->enabled) ){ $this->enabled = (bool) apply_filters('loco_allow_remote', true ); } return $this->enabled; } } src/config/XMLModel.php000066600000010513152141417400010735 0ustar00formatOutput = true; $dom->registerNodeClass('DOMElement','LocoConfig_DOMElement'); $this->xpath = new DOMXPath($dom); return $dom; } /** * {@inheritdoc} * @return LocoConfigNodeListIterator */ public function query( $query, $context = null ){ $list = $this->xpath->query( $query, $context ); return new LocoConfigNodeListIterator( $list ); } /** * @return void */ public function loadXml( $source ){ if( ! $source ){ throw new Loco_error_XmlParseException( __('XML supplied is empty','loco-translate') ); } $dom = $this->getDom(); // parse with silent errors, clearing after $used_errors = libxml_use_internal_errors(true); $result = $dom->loadXML( $source, LIBXML_NONET ); unset( $source ); // fetch errors and ensure clean for next run. $errors = libxml_get_errors(); $used_errors || libxml_use_internal_errors(false); libxml_clear_errors(); // Throw exception if error level exceeds current tolerance if( $errors ){ /* @var $error LibXMLError */ foreach( $errors as $error ){ if( $error->level >= LIBXML_ERR_FATAL ){ $e = new Loco_error_XmlParseException( trim($error->message) ); //$e->setContext( $error->line, $error->column, $source ); throw $e; } // @codeCoverageIgnoreStart } } // @codeCoverageIgnoreEnd // Not currently validating against a DTD, but may as well pre-empt generic model loading errors if( ! $dom->documentElement || 'bundle' !== $dom->documentElement->nodeName ){ throw new Loco_error_XmlParseException('Expected document element'); } $this->xpath = new DOMXPath($dom); } /** * {@inheritdoc} * Overridden to avoid empty text nodes in XML files, preferring . to */ protected function setFileElementPath( $node, $path ){ if( ! $path && '0' !== $path ){ $path = '.'; } return parent::setFileElementPath( $node, $path ); } } /** * @internal */ class LocoConfig_DOMElement extends DOMElement implements IteratorAggregate, Countable { public function getIterator(){ return new LocoConfigNodeListIterator( $this->childNodes ); } public function count(){ return $this->childNodes->length; } } /** * @internal * Cos NodeList doesn't iterate */ class LocoConfigNodeListIterator implements Iterator, Countable, ArrayAccess { /** * @var DOMNodeList */ private $nodes; /** * @var int */ private $i; /** * @var int */ private $n; public function __construct( DOMNodeList $nodes ){ $this->nodes = $nodes; $this->n = $nodes->length; } public function count(){ return $this->n; } public function rewind(){ $this->i = -1; $this->next(); } public function key(){ return $this->i; } public function current(){ return $this->nodes->item( $this->i ); } public function valid(){ return is_int($this->i); } public function next(){ while( true ){ $this->i++; if( $child = $this->nodes->item($this->i) ){ break; } $this->i = null; break; } } public function offsetExists( $i ){ return $i >= 0 && $i < $this->n; } public function offsetGet( $i ){ return $this->nodes->item($i); } /** * @codeCoverageIgnore */ public function offsetSet( $i, $value ){ throw new Exception('Read only'); } /** * @codeCoverageIgnore */ public function offsetUnset( $i ){ throw new Exception('Read only'); } } src/config/Model.php000066600000011431152141417400010354 0ustar00dirs = array(); $this->dom = $this->createDom(); $this->setDirectoryPath( loco_constant('ABSPATH') ); } /** * @return void */ public function setDirectoryPath( $path, $key = null ){ $path = untrailingslashit($path); if( is_null($key) ){ $this->base = $path; } else { $this->dirs[$key] = $path; } } /** * @return LocoConfigDocument */ public function getDom(){ return $this->dom; } /** * Evaluate a name constant pointing to a file location * @param string one of 'LOCO_LANG_DIR', 'WP_LANG_DIR', 'WP_PLUGIN_DIR', 'WPMU_PLUGIN_DIR', 'WP_CONTENT_DIR', or 'ABSPATH' */ public function getDirectoryPath( $key = null ){ if( is_null($key) ){ $value = $this->base; } else if( isset($this->dirs[$key]) ){ $value = $this->dirs[$key]; } else { $value = untrailingslashit( loco_constant($key) ); } return $value; } /** * @return LocoConfigElement */ public function createFileElement( Loco_fs_File $file ){ $node = $this->dom->createElement( $file->isDirectory() ? 'directory' : 'file' ); if( $path = $file->getPath() ) { // Calculate relative path to the config file itself $relpath = $file->getRelativePath( $this->base ); // Map to a configured base path if target is not under our root. This makes XML more portable // matching order is most specific first, resulting in shortest path if( $relpath && ( Loco_fs_File::abs($relpath) || '..' === substr($relpath,0,2) || $this->base === $this->getDirectoryPath('ABSPATH') ) ){ $bases = array( 'LOCO_LANG_DIR', 'WP_LANG_DIR', 'WP_PLUGIN_DIR', 'WPMU_PLUGIN_DIR', 'WP_CONTENT_DIR', 'ABSPATH' ); foreach( $bases as $key ){ if( ( $base = $this->getDirectoryPath($key) ) && $base !== $this->base ){ $base .= '/'; $len = strlen($base); if( substr($path,0,$len) === $base ){ $node->setAttribute('base',$key); $relpath = substr( $path, $len ); break; } } // @codeCoverageIgnore } } $path = $relpath; } $this->setFileElementPath( $node, $path ); return $node; } /** * @param LocoConfigElement * @param string * @return LocoConfigText */ protected function setFileElementPath( $node, $path ){ return $node->appendChild( $this->dom->createTextNode($path) ); } /** * @param LocoConfigElement * @return Loco_fs_File */ public function evaluateFileElement( $el ){ $path = $el->textContent; switch( $el->nodeName ){ case 'directory': $file = new Loco_fs_Directory($path); break; case 'file': $file = new Loco_fs_File($path); break; case 'path': $file = new Loco_fs_File($path); if( $file->isDirectory() ){ $file = new Loco_fs_Directory($path); } break; default: throw new InvalidArgumentException('Cannot evaluate file element from <'.$el->nodeName.'>'); } if( $el->hasAttribute('base') ){ $key = $el->getAttribute('base'); $base = $this->getDirectoryPath($key); $file->normalize( $base ); } else { $file->normalize( $this->base ); } return $file; } /** * @param LocoConfigElement * @return bool */ public function evaulateBooleanAttribute( $el, $attr ){ if( ! $el->hasAttribute($attr) ){ return false; } $value = (string) $el->getAttribute($attr); return 'false' !== $value && 'no' !== $value && '' !== $value; } } src/config/FormModel.php000066600000020035152141417400011200 0ustar00getDom(); $root = $dom->documentElement; $post = new Loco_mvc_PostParams( array ( 'name' => $root->getAttribute('name'), 'exclude' => array ( 'path' => '', ), 'conf' => array(), ) ); /* @var LocoConfigElement $domain */ foreach( $this->query('domain',$root) as $domain ){ $domainName = $domain->getAttribute('name'); /* @var LocoConfigElement $project */ foreach( $domain as $project ){ $tree = array ( 'name' => $project->getAttribute('name'), 'slug' => $project->getAttribute('slug'), 'domain' => $domainName, 'source' => array ( 'path' => '', 'exclude' => array( 'path' => '' ), ), 'target' => array ( 'path' => '', 'exclude' => array( 'path' => '' ), ), 'template' => array( 'path' => '', 'locked' => false ), ); $post['conf'][] = $this->collectPaths( $project, $tree ); } } /* @var LocoConfigElement $paths */ foreach( $this->query('exclude',$root) as $paths ){ $post['exclude'] = $this->collectPaths( $paths, $post['exclude'] ); } return $post; } private function collectPaths( LocoConfigElement $parent, array $branch ){ $texts = array(); foreach( $parent as $child ){ $name = $child->nodeName; // all file types as "path" in form model if( 'file' === $name || 'directory' === $name ){ $name = 'path'; } if( isset($branch[$name]) ){ // collect text if child is a node if( 'path' === $name ){ $file = $this->evaluateFileElement($child); $path = $file->getRelativePath( $this->getDirectoryPath() ); if( '' === $path ){ $path = '.'; } $texts[] = $path; } // else could be simple key to next depth else if( is_array($branch[$name]) ){ $branch[$name] = $this->collectPaths( $child, $branch[$name] ); } } // @codeCoverageIgnoreStart else { throw new Exception('Unexpected structure: '.$name.' not in '.json_encode($branch) ); } // @codeCoverageIgnoreEnd } // parent may have attributes we can set in branch data foreach( $branch as $name => $default ){ if( $parent->hasAttribute($name) ){ if( is_bool($default) ){ $branch[$name] = $this->evaulateBooleanAttribute($parent, $name); } else { $branch[$name] = $parent->getAttribute($name); } } } // set compiled path values if any collected if( $texts ){ $value = implode("\n", $texts ); // display single root path as empty, but not when additional paths defined if( '.' === $value ){ $branch['path'] = ''; } else { $branch['path'] = $value; } } return $branch; } /** * Construct model from posted form data. * @return void */ public function loadForm( Loco_mvc_PostParams $post ){ // basic validation unlikely to fail when posted from UI $name = $post->name; if( ! $name ){ throw new InvalidArgumentException('Bundle must have a name'); } $confs = $post->conf; if( ! $confs || ! is_array($confs) ){ throw new InvalidArgumentException('Bundle must have at least one definition'); } // transform posted data into internal model: // deliberately not configuring bundle object at this point. simply converting data for storage. $dom = $this->getDom(); $root = $dom->appendChild( $dom->createElement('bundle') ); $root->setAttribute( 'name', $name ); // bundle level excluded paths if( $nodes = array_intersect_key( $post->getArrayCopy(), array( 'exclude' => '' ) ) ) { $this->loadStruct( $root, $nodes ); } // collect all projects grouped by domain $domains = array(); foreach( $confs as $i => $conf ){ if( ! empty($conf['removed']) ){ continue; } if( empty($conf['domain']) ){ throw new InvalidArgumentException( __('Text Domain cannot be empty','loco-translate') ); } $domains[ $conf['domain'] ][] = $project = $dom->createElement('project'); // project attributes foreach( array('name','slug') as $attr ){ if( isset($conf[$attr]) ){ $project->setAttribute( $attr, $conf[$attr] ); } } // project children if( $nodes = array_intersect_key( $conf, array( 'source' => '', 'target' => '', 'template' => '' ) ) ) { $this->loadStruct( $project, $nodes ); } } // add all domains and their projects foreach( $domains as $name => $projects ){ $parent = $root->appendChild( $dom->createElement('domain') ); $parent->setAttribute( 'name', $name ); /* @var $project LocoConfigElement */ foreach( $projects as $project ){ $parent->appendChild( $project ); } } } /** * Recursively add array structure into model. * - Text nodes are split into one parent element per line. * - Elements added here cannot have attributes, but are not expected to as they came from form fields */ private function loadStruct( LocoConfigElement $parent, array $nodes ){ $dom = $this->getDom(); foreach( $nodes as $name => $data ){ if( is_string($data) ){ // support common path containing elements if( 'file' === $name || 'directory' === $name || 'path' === $name ){ // form model has multiline "path" nodes which we'll expand from non-empty lines // resolving empty paths to "." must be done elsewhere. here empty means ignore. foreach( preg_split('/\\R/', trim( $data,"\n\r"), -1, PREG_SPLIT_NO_EMPTY ) as $path ){ $ext = pathinfo( $path, PATHINFO_EXTENSION ); $child = $parent->appendChild( $dom->createElement( $ext ? 'file' : 'directory' ) ); $child->appendChild( $dom->createTextNode($path) ); } } // else assume valud is an attribute else { $parent->setAttribute( $name, $data ); } } else if( is_bool($data) ){ $data ? $parent->setAttribute($name,'true') : $parent->removeAttribute($name); } else if( ! is_array($data) ){ throw new InvalidArgumentException('Invalid datatype'); } else { $child = $parent->appendChild( $dom->createElement($name) ); $this->loadStruct( $child, $data ); } } } } src/config/ArrayModel.php000066600000020530152141417400011353 0ustar00loadArray( $root ); } /** * Construct model from exported array * @return void */ public function loadArray( array $root ){ $dom = $this->getDom(); $dom->load( array('#document', array(), array($root) ) ); } /** * {@inheritdoc} * Emulates *very limited* XPath queries used by the XML DOM. */ public function query( $query, $context = null ){ $match = new LocoConfigNodeList; $query = explode('/', $query ); // absolute path always starts in document if( $absolute = empty($query[0]) ){ $match->append( $this->getDom() ); } // else start with base for relative path else if( $context instanceof LocoConfigNode ){ $match->append( $context ); } while( $query ){ $name = array_shift($query); // self references do nothing if( ! $name || '.' === $name ){ continue; } // match all current branches to produce new set of parents $next = new LocoConfigNodeList; foreach( $match as $parent ){ foreach( $parent->childNodes as $child ){ if( $name === $child->nodeName || ( '*' === $name && $child instanceof LocoConfigElement ) || ( 'text()' === $name && $child instanceof LocoConfigText) ){ $next->append( $child ); } } } $match = $next; } return $match; } } // The following classes are "private" to this file: // They partially implement the same interfaces as the core DOM classes and are used for code hints. // Interfaces are deliberately not used as the real DOM classes would not be able to implement them. /** * Node */ abstract class LocoConfigNode implements IteratorAggregate { /** * Raw data of internal format * @var array */ protected $data; /** * Child nodes once cast to node objects * @var LocoConfigNodeList */ protected $children; /** * @return mixed */ abstract public function export(); final public function __construct( $data ){ $this->data = $data; } protected function get_nodeName(){ return $this->data[0]; } /*protected function get_attributes(){ return $this->data[1]; }*/ protected function get_childNodes(){ return $this->getIterator(); } public function __get( $prop ){ $method = array( $this, 'get_'.$prop ); if( is_callable($method) ){ return call_user_func( $method ); } } /** @return LocoConfigNode */ public function appendChild( LocoConfigNode $child ){ $children = $this->getIterator(); $children->append( $child ); return $child; } /** @return bool */ public function hasChildNodes(){ return (bool) count( $this->getIterator() ); } /** * @return LocoConfigNodeList */ public function getIterator(){ if( ! $this->children ){ $raw = isset($this->data[2]) ? $this->data[2] : array(); $this->children = new LocoConfigNodeList( $this->data[2] ); } return $this->children; } public function get_textContent(){ $s = ''; foreach( $this as $child ){ $s .= $child->get_textContent(); } return $s; } } /** * NodeList */ class LocoConfigNodeList implements Iterator, Countable, ArrayAccess { private $nodes; private $i; private $n; public function __construct( array $nodes = array() ){ $this->nodes = $nodes; $this->n = count( $nodes ); } public function count(){ return $this->n; } public function rewind(){ $this->i = -1; $this->next(); } public function key(){ return $this->i; } public function current(){ return $this[ $this->i ]; } public function valid(){ return is_int($this->i); } public function next(){ if( ++$this->i === $this->n ){ $this->i = null; } } public function offsetExists( $i ){ return $i >= 0 && $i < $this->n; } public function offsetGet( $i ){ $node = $this->nodes[$i]; if( ! $node instanceof LocoConfigNode ){ if( is_array($node) ){ $node = new LocoConfigElement( $node ); } else { $node = new LocoConfigText( $node ); } $this->nodes[$i] = $node; } return $node; } /** * @codeCoverageIgnore */ public function offsetSet( $i, $value ){ throw new Exception('Use append'); } /** * @codeCoverageIgnore */ public function offsetUnset( $i ){ throw new Exception('Read only'); } public function append( LocoConfigNode $node ){ $this->nodes[] = $node; $this->n++; } /** * Revert nodes back to raw array form and return for exporting * @return array */ public function normalize(){ foreach( $this->nodes as $i => $node ){ if( $node instanceof LocoConfigNode ){ $this->nodes[$i] = $node->export(); } } return $this->nodes; } } /** * Document */ class LocoConfigDocument extends LocoConfigNode { /** * Rapidly set new data for document */ public function load( $data ){ $this->data = $data; $this->children = null; } /** * @return LocoConfigElement */ public function createElement( $name ){ return new LocoConfigElement( array( $name, array(), array() ) ); } /** * @return LocoConfigText */ public function createTextNode( $text ){ return new LocoConfigText( $text ); } /** * @return LocoConfigElement */ public function get_documentElement(){ $child = null; foreach( $this as $child ){ break; } return $child; } /** * {@inheritdoc} * Override to keep single element root */ public function export(){ if( $root = $this->get_documentElement() ){ return $root->export(); } } } /** * Element */ class LocoConfigElement extends LocoConfigNode { public function setAttribute( $prop, $value ){ $this->data[1][$prop] = $value; } public function removeAttribute( $prop ){ unset( $this->data[1][$prop] ); } public function getAttribute( $prop ){ if( isset($this->data[1][$prop]) ){ return $this->data[1][$prop]; } return ''; } public function hasAttribute( $prop ){ return isset($this->data[1][$prop]); } /** * {@inheritdoc} */ public function export(){ $raw = $this->data; // return any cast elements back to raw data if( $this->children ){ $raw[2] = $this->children->normalize(); } return $raw; } } /** * Text */ class LocoConfigText extends LocoConfigNode { protected function get_nodeName(){ return '#text'; } public function hasChildNodes(){ return false; } public function getIterator(){ return new ArrayIterator; } public function export(){ return (string) $this->data; } public function get_nodeValue(){ return (string) $this->data; } public function get_textContent(){ return (string) $this->data; } } src/config/BundleReader.php000066600000015273152141417400011660 0ustar00bundle = $bundle; } /** * @param Loco_fs_File loco.xml file * @return Loco_package_Bundle */ public function loadXml( Loco_fs_File $file ){ $this->bundle->setDirectoryPath( $file->dirname() ); $model = new Loco_config_XMLModel; $model->loadXml( $file->getContents() ); return $this->loadModel( $model ); } /** * @return Loco_package_Bundle */ public function loadJson( Loco_fs_File $file ){ $this->bundle->setDirectoryPath( $file->dirname() ); return $this->loadArray( json_decode( $file->getContents(), true ) ); } /** * @return Loco_package_Bundle */ public function loadArray( array $raw ){ $model = new Loco_config_ArrayModel; $model->loadArray( $raw ); return $this->loadModel( $model ); } /** * Agnostic construction of Bundle from any configuration format * @return Loco_package_Bundle */ public function loadModel( Loco_config_Model $model ){ // Base directory required to resolve relative paths $bundle = $this->bundle; $model->setDirectoryPath( $bundle->getDirectoryPath() ); $dom = $model->getDom(); $bundleElement = $dom->documentElement; if( ! $bundleElement || 'bundle' !== $bundleElement->nodeName ){ throw new InvalidArgumentException('Expected root bundle element'); } // Set bundle meta data if configured // note that bundles have no inherent slug as it can change according to plugin/theme directory naming if( $bundleElement->hasAttribute('name') ){ $bundle->setName( $bundleElement->getAttribute('name') ); } // Bundle-level path exclusions foreach( $model->query('exclude/*',$bundleElement) as $fileElement ){ $bundle->excludeLocation( $model->evaluateFileElement($fileElement) ); } /* @var $domainElement LocoConfigElement */ foreach( $model->query('domain',$bundleElement) as $domainElement ){ $slug = $domainElement->getAttribute('name') or $slug = $bundle->getSlug(); // bundle may not have a handle set (most likely only in tests) if( ! $bundle->getHandle() ){ $bundle->setHandle( $slug ); } // Text Domain may also be declared by bundle author $domain = new Loco_package_TextDomain( $slug ); $declared = $bundle->getHeaderInfo(); if( $declared && $declared->TextDomain === $slug ){ $domain->setCanonical( true ); } /* @var $projectElement LocoConfigElement */ foreach( $model->query('project',$domainElement) as $projectElement ){ $name = $projectElement->getAttribute('name') or $name = $bundle->getName(); $project = new Loco_package_Project( $bundle, $domain, $name ); if( $projectElement->hasAttribute('slug') ){ $project->setSlug( $projectElement->getAttribute('slug') ); } // foreach( $model->query('source',$projectElement) as $sourceElement ){ // sources may be , or pass in special if it could be either foreach( $model->query('file',$sourceElement) as $fileElement ){ $project->addSourceFile( $model->evaluateFileElement($fileElement) ); } foreach( $model->query('directory',$sourceElement) as $fileElement ){ $project->addSourceDirectory( $model->evaluateFileElement($fileElement) ); } foreach( $model->query('path',$sourceElement) as $fileElement ){ $project->addSourceLocation( $model->evaluateFileElement($fileElement) ); } foreach( $model->query('exclude/*', $sourceElement) as $fileElement ){ $project->excludeSourcePath( $model->evaluateFileElement($fileElement) ); } } // Avoid having no source locations if( ! $project->hasSourceFiles() ){ if( $bundle->isSingleFile() ){ $project->addSourceFile( $bundle->getBootstrapPath() ); } else { $project->addSourceDirectory( $bundle->getDirectoryPath() ); } } // foreach( $model->query('target',$projectElement) as $targetElement ){ // targets support only directory paths: foreach( $model->query('directory',$targetElement) as $fileElement ){ $project->addTargetDirectory( $model->evaluateFileElement($fileElement) ); } foreach( $model->query('exclude/*', $targetElement) as $fileElement ){ $project->excludeTargetPath( $model->evaluateFileElement($fileElement) ); } } // Avoid having no target locations .. if( 0 === count($project->getConfiguredTargets() ) ){ // .. unless the inherited root is a global location if( $bundle->isTheme() || ( $bundle->isPlugin() && ! $bundle->isSingleFile() ) ){ $project->addTargetDirectory( $bundle->getDirectoryPath() ); } } //