<?php namespace ProcessWire;

/**
 * ProcessWire Language Translator Process
 *
 * This is the process assigned to the processwire/setup/language-translator/ page.
 *
 * ProcessWire 3.x, Copyright 2023 by Ryan Cramer
 * https://processwire.com
 *
 * 
 * @method string executeList()
 * @method string executeAdd()
 * @method string processAdd(Inputfield $field = null, $sourceFilename = '')
 * @method string executeEdit()
 * @method processEdit($form, $textdomain, $translations)
 * 
 * @property array $extensions Allowed file extensions for translatable files
 * 
 * 
 */

class ProcessLanguageTranslator extends Process implements ConfigurableModule {

	public static function getModuleInfo() {
		return array(
			'title' => __('Language Translator', __FILE__),
			'summary' => __('Provides language translation capabilities for ProcessWire core and modules.', __FILE__),
			'version' => 103,
			'author' => 'Ryan Cramer',
			'requires' => 'LanguageSupport',
			'permission' => 'lang-edit'
		);
	}

	/**
	 * Untranslated versions of the text (in default language en-US) indexed by hash-key
	 * 
	 * @var array
	 *
	 */
	protected $untranslated = array();

	/**
	 * Alternate translations indexed by hash-key
	 * 
	 * @var array
	 * 
	 */
	protected $alternates = array(); 

	/**
	 * Optional comment labels for translations indexed by hash.
	 *
	 * These labels are pulled from a PHP comment in "// comment" format at the end of the same line that the __() or $this->_() call appears on.
	 * 
	 * @var array
	 *
	 */
	protected $comments = array();

	/**
	 * Instance of Language (Page) containing the language we are translating to
	 * 
	 * @var Language
	 *
	 */
	protected $language = null;

	/**
	 * Instance of LanguageTranslator
	 * 
	 * @var LanguageTranslator
	 *
	 */
	protected $translator = null;

	/**
	 * File pointer for search index file
	 * 
	 * @var null
	 * 
	 */
	protected $fp = null;
	
	public function __construct() {
		parent::__construct();
		$this->set('extensions', array('php', 'module', 'inc')); 
	}

	/**
	 * Initialize the module and setup the variables above
	 *
	 */
	public function init() {

		// if language specified as a GET var in the URL, then pick it up and use it (storing in session)
		$id = $this->wire()->input->get('language_id');
		if($id) {
			$this->setLanguage((int) $id);
		} else if($this->wire()->session->get('translateLanguageID')) {
			$this->setLanguage($this->wire()->session->get('translateLanguageID'));
		}
		// else throw new WireException("No language specified"); 
		parent::init();
	}
	
	public function __destruct() {
		if($this->fp) fclose($this->fp);
	}

	/**
	 * Set
	 * 
	 * @param string $key
	 * @param mixed $value
	 * @return self
	 * 
	 */
	public function set($key, $value) {
		if($key === 'extensions') return $this->setExtensions($value);
		return parent::set($key, $value);
	}

	/**
	 * Set allowed extensions
	 * 
	 * @param string|array $extensions
	 * @since 3.0.212
	 * 
	 */
	protected function setExtensions($extensions) {
		if(!is_array($extensions)) {
			$extensions = preg_replace('/[^a-zA-Z0-9 ]/', ' ', (string) $extensions);
			$extensions = explode(' ', strtolower(trim($extensions)));
			foreach($extensions as $k => $v) {
				if(empty($v)) unset($extensions[$k]);
			}
		}
		if(!in_array('php', $extensions)) $extensions[] = 'php';
		if(!in_array('module', $extensions)) $extensions[] = 'module';
		parent::set('extensions', $extensions);
		return $this;
	}

	/**
	 * Set the language used by the translator process and create the new translator for it
	 * 
	 * @param int|Language $language
	 * @throws WireException|WirePermissionException
	 *
	 */
	public function setLanguage($language) {

		$languages = $this->wire()->languages;
		if(!$languages) return;

		if(is_int($language)) $language = $languages->get($language);
		
		if(!$language instanceof Language || !$language->id) {
			throw new WireException($this->_("Unknown/invalid language"));
		}
		
		if(!$language->editable()) {
			throw new WirePermissionException($this->_('You do not have permission to edit this language'));
		}
		
		$this->language = $language;
		$this->session->set('translateLanguageID', $language->id);
		$this->translator = new LanguageTranslator($this->language);
	}

	/**
	 * List the languages
	 *
	 */
	public function ___execute() {
		return $this->executeList();
	}

	/**
	 * List the languages
	 *
	 */
	public function ___executeList() {
		
		$modules = $this->wire()->modules;
		$config = $this->wire()->config;

		/** @var MarkupAdminDataTable $table */
		$table = $modules->get("MarkupAdminDataTable");
		$url = $this->wire()->pages->get("template=admin, name=language-translations")->url;
		$out = '';

		foreach(array('language_files', 'language_files_site') as $fieldName) {
			if(!$this->language || !$this->language->$fieldName) continue; // language_files_site not installed
			if(count($this->language->$fieldName)) {
				$table->headerRow(array(
					$this->_x('file', 'table-header'),
					$this->_x('phrases', 'table-header'),
					$this->_x('last modified', 'table-header'),
				));
				foreach($this->language->$fieldName as $file) {
					$textdomain = basename($file->basename, '.json');
					$data = $this->translator->getTextdomain($textdomain);
					$table->row(array(
						$data['file'] => $url . "edit/?textdomain=$textdomain",
						count($data['translations']),
						date($config->dateFormat, filemtime($file->filename))
					));
					$this->translator->unloadTextdomain($textdomain);
				}
			} else {
				$table->headerRow(array('file'));
				$table->row(array(sprintf($this->_('No files in this language for field %s'), $fieldName)));
			}
			$out .= $table->render();
		}

		/** @var InputfieldButton $btn */
		$btn = $modules->get('InputfieldButton');
		$btn->href = $url . 'add/';
		$btn->icon = 'plane';
		$btn->showInHeader();
		$out .= $btn->render();

		return $out;
	}

	/**
	 * Add a new class file to translate (creating a new textdomain file)
	 *
	 * URL: setup/language-translator/add/
	 *
	 */
	public function ___executeAdd() {
		
		$modules = $this->wire()->modules;
		$input = $this->wire()->input;
		$config = $this->wire()->config;
		$session = $this->wire()->session;

		$this->addBreadcrumbs();
		$this->headline($this->_('Select File(s)')); // Headline when adding new translation files

		/** @var InputfieldForm $form */
		$form = $modules->get("InputfieldForm");
		$form->attr('method', 'post');
		$form->attr('action', "./?language_id={$this->language->id}");
		//$form->description = sprintf("Select file(s) for translation to %s", $this->language->get('title|name'));
		$languageTitle = $this->language->get('title|name');
		$form->description = sprintf($this->_('Select a file (or multiple files) and click Submit to create new %s translation files.'), $languageTitle);
		$useCache = $input->post('submit_refresh') || $input->get('refresh') ? false : true;

		$files = array(
			'site' => $this->findTranslatableFiles($config->paths->site, $useCache),
			'wire' => $this->findTranslatableFiles($config->paths->wire, $useCache)
		);
		
		if($input->get('refresh')) {
			$session->redirect("../../languages/edit/?id={$this->language->id}"); 
			return '';
		}

		$textdomains = array();
		foreach(array('language_files', 'language_files_site') as $fieldName) {
			if(!$this->language->$fieldName) continue;
			foreach($this->language->$fieldName as $file) {
				$textdomain = basename($file->basename, '.json');
				$textdomains[$textdomain] = $textdomain;
			}
		}

		foreach(array_keys($files) as $key) {
			/** @var InputfieldSelectMultiple $field */
			$field = $modules->get('InputfieldSelectMultiple');
			$field->attr('name', 'file_' . $key);
			$field->label = sprintf($this->_('Translatable files in %s'), "/$key/");
			$field->addClass('TranslationFileSelect');
			$field->icon = 'plane';
			$field->attr('size', 20);
			$value = $files[$key];

			$translated = $this->_('Translation files exist');
			$untranslated = $this->_('No translation files exist');
			$optgroups = array(
				$translated   => array(),
				$untranslated => array(),
			);
			$maxLength = 0;
			sort($value);
			foreach($value as $file) {
				$textdomain = $this->translator->filenameToTextdomain($file);
				$label = substr($file, 5);
				if(strlen($file) > $maxLength) $maxLength = strlen($file);
				$basename = basename($file);
				if(strpos($file, '.module')) {
					$basename = basename($basename, '.php');
					$basename = basename($basename, '.module');
					if(strpos($basename, '.module') === false) {
						$moduleInfo = $this->modules->getModuleInfo($basename);
						if($moduleInfo['title']) $label .= "\t" . $moduleInfo['title'];
					}
				}

				if(isset($textdomains[$textdomain])) {
					$optgroups[$translated][$file] = $label;
				} else {
					$optgroups[$untranslated][$file] = $label;
				}
			}

			foreach($optgroups as $name => $_files) {
				foreach($_files as $file => $label) {
					if(strpos($label, "\t") === false) continue;
					list($filename, $moduleName) = explode("\t", $label);
					$label = str_pad("$filename ", $maxLength, '.', STR_PAD_RIGHT) . " " . $moduleName;
					$optgroups[$name][$file] = $label;
				}
			}

			if(count($optgroups[$translated]) && count($optgroups[$untranslated])) {
				$field->addOptions($optgroups);
			} else if(count($optgroups[$translated])) {
				$field->addOptions($optgroups[$translated]);
			} else if(count($optgroups[$untranslated])) {
				$field->addOptions($optgroups[$untranslated]);
			}

			$form->add($field);
		}

		/** @var InputfieldText $field */
		$field = $modules->get('InputfieldText');
		$field->attr('name', 'filename');
		$field->label = $this->_('Enter file to translate');
		$field->icon = 'code';
		$field->description = $this->_('Enter the path and filename to translate. This should be entered relative to the site root installation.');
		$field->notes = $this->_('Example:') . " /wire/modules/Process/ProcessPageList/ProcessPageList.module";
		$field->collapsed = Inputfield::collapsedYes;
		$form->add($field);

		/** @var InputfieldSubmit $submit */
		$submit = $modules->get("InputfieldSubmit");
		$submit->attr('id+name', 'submit_add');
		$submit->icon = 'plane';
		$submit->showInHeader();
		$form->add($submit);

		/** @var InputfieldSubmit $submit */
		$submit = $modules->get("InputfieldSubmit");
		$submit->attr('name', 'submit_refresh');
		$submit->attr('value', $this->_('Refresh File List'));
		$submit->setSecondary();
		$submit->icon = 'refresh';
		$form->add($submit);

		if($form->isSubmitted('submit_add')) {
			if($input->post('filename')) {
				$this->processAdd($field);

			} else {
				$newTextdomains = array();
				foreach(array('site' => 'file_site', 'wire' => 'file_wire') as $key => $name) {
					$postFiles = $input->post->$name;
					if(!wireCount($postFiles)) continue;
					foreach($postFiles as $file) {
						if(!isset($files[$key][$file])) continue;
						$textdomain = $this->translator->filenameToTextdomain($file);
						if(!isset($textdomains[$textdomain])) {
							$textdomain = $this->translator->addFileToTranslate($file);
							if($textdomain) $this->message(sprintf($this->_('Added %s'), $file)); // Added [filename]
						}
						if($textdomain) $newTextdomains[] = $textdomain;
					}
				}
				if(count($newTextdomains) == 1) {
					$session->redirect("../edit/?language_id={$this->language->id}&textdomain=" . reset($newTextdomains));
					return '';
				} else if(count($newTextdomains) > 1) {
					// render form again
					$session->redirect("../../languages/edit/?id={$this->language->id}");
					return '';
				}
			}

		}
		return $form->render();
	}

	/**
	 * Process the 'add' form: file input manually
	 * 
	 * @param Inputfield $field
	 * @param string $sourceFilename Optional
	 * @return bool
	 *
	 */
	protected function ___processAdd($field = null, $sourceFilename = '') {
		
		$session = $this->wire()->session;

		if($sourceFilename) {
			$filename = $sourceFilename;
		} else {
			$filename = $this->wire()->input->post('filename');
		}
		
		$filename = str_replace(array('\\', '..'), array('/', ''), $filename);
		if($field) $field->attr('value', $filename);
		$pathname = $this->wire()->config->paths->root . ltrim($filename, '/');

		if(is_file($pathname)) {

			if(!$sourceFilename) $session->get('CSRF')->validate();
			$this->message(sprintf($this->_('Found %s'), $filename)); // Found [filename]

			if($this->parseTranslatableFile($pathname)) {

				$textdomain = $this->translator->addFileToTranslate($filename);
				if($textdomain) {
					$session->redirect("../edit/?language_id={$this->language->id}&textdomain=$textdomain");
				}

				$this->error($this->_('That file is already in the system'));

			} else {
				$this->error($this->_('That file has no translatable phrases'));
			}
			
		} else {
			$this->error($this->_('File does not exist'));
		}

		return false;
	}

	protected function executeEditField($hash, $untranslated, $translated, $alternates) {

		/** @var InputfieldText|InputfieldTextarea $field */
		
		$comment = isset($this->comments[$hash]) ? $this->comments[$hash] : '';
		$type = '';
		
		if(stripos($comment, 'type=') !== false && preg_match('!type=(\w+)!i', $comment, $matches)) {
			// if type=value appears in comment then use value as Inputfield module name
			$type = ucfirst($matches[1]);
			$comment = str_replace($matches[0], '', $comment);
		}
		
		if(stripos($comment, 'rows=') !== false && preg_match('!rows=(\d+)!i', $comment, $matches)) {
			// if rows=3 appears in comment then accept it as rows attribute for input
			$rows = (int) $matches[1]; 
			$comment = str_replace($matches[0], '', $comment);
		} else if(strpos($untranslated, "\n") !== false) {
			$qty1 = substr_count($untranslated, "\n")+1;
			$qty2 = substr_count($translated, "\n")+1; 
			$rows = max(2, $qty1, $qty2);
		} else if(strlen($untranslated) < 128) {
			$rows = 1;
		} else {
			$rows = 3;
		}
		
		if(stripos($comment, 'options=[') !== false && preg_match('!\boptions=\[([^\]]+)\]!', $comment, $matches)) {
			// Options for select or radios
			// i.e. "options=[r,b,g]" or "options[r:Red,b:Blue,g:Green]"
			$c = strpos($matches[1], '|') ? '|' : ',';
			$options = explode($c, $matches[1]); 
			$comment = str_replace($matches[0], '', $comment);
			if(empty($type)) $type = 'Radios';
		} else {
			$options = array();
		}
		
		if(empty($type)) {
			$type = $rows > 1 ? 'InputfieldTextarea' : 'InputfieldText';
		} else if(strpos($type, 'Inputfield') !== 0) {
			$type = "Inputfield$type";
		}
		
		$field = $this->wire()->modules->get($type);
		if(!$field) $field = $this->wire()->modules->get('InputfieldText');
		if($rows > 1 && $field instanceof InputfieldTextarea) $field->attr('rows', $rows);
		
		if(count($options) && $field instanceof InputfieldSelect) {
			foreach($options as $option) {
				if(strpos($option, ':') !== false) {
					// separate "value:label"
					list($option, $label) = explode(':', $option, 2);
					$field->addOption(trim($option), trim($label));
				} else {
					// just "value"
					$field->addOption(trim($option));
				}
			}
		}

		/** @var InputfieldText|InputfieldSelect $field */
		$field->attr('id+name', $hash);
		$field->set('textFormat', Inputfield::textFormatNone);
		if($field instanceof InputfieldSelect) {
			$v = strlen($translated) ? $translated : $untranslated;
			$field->val($v);
		} else {
			$field->val($translated);
		}
		$field->label = $untranslated;
		$field->addClass(strlen($translated) ? 'translated' : 'untranslated', 'wrapClass');
		$field->addClass('translatable');

		if($comment) {
			if($field instanceof InputfieldSelect) {
				if(strpos($comment, '//')) {
					list($label, $notes) = explode('//', $comment, 2);
				} else {
					$label = $comment;
					$notes = '';
				}
				$field->description = $label;
				$field->notes = $notes;
			} else {
				if(preg_match('{^(.*?)//(.*)$}', $comment, $m)) {
					if(trim($m[1]) != trim($untranslated)) {
						$field->notes = "$m[1]\n$m[2]";
					} else {
						$field->notes = $m[2];
					}
				} else if(trim($comment) != trim($untranslated)) {
					$field->notes = $comment;
				}
			}
		}

		if($this->language && !$this->language->isDefault()) {
			foreach($alternates as $altText => $altTranslation) {
				if(empty($altText) || empty($altTranslation)) continue;
				$field->placeholder = $altTranslation;
				$field->notes = trim(
					"$field->notes\n" .
					sprintf(
						$this->_('Fallback: when untranslated, the text “%1$s” is used, which is translated from “%2$s”.'),
						$altTranslation, $altText
					)
				);
				break;
			}
		}

		if((!strlen($translated) || $translated === '+') && !$field instanceof InputfieldTextarea) {
			$languages = $this->wire()->languages;
			$languages->setLanguage($this->language);
			$this->wire()->user->language = $this->language;
			$test = $this->translator->commonTranslation($untranslated);
			$field->textFormat = Inputfield::textFormatBasic;
			if(strlen($test) && $test !== $untranslated) {
				$field->notes = trim($field->notes . "\n" .
					sprintf($this->_('If no text is provided, the common translation “**%s**” will be used.'), $test)) . " " . 
					$this->_('Enter a single plus sign “**+**” above to also mark this field as translated.');
				
				$field->attr('placeholder', $test);
			}
			$languages->unsetLanguage();
		}

		$field->skipLabel = Inputfield::skipLabelHeader;
		if(!strlen($field->description)) $field->description = $untranslated;

		return $field;
	}

	/**
	 * 
	 * @param array $translations
	 * @param Inputfieldform $form
	 * 
	 */
	protected function executeEditAbandoned(&$translations, $form) {
		
		$modules = $this->wire()->modules;

		/** @var InputfieldFieldset $fieldset */
		$fieldset = $modules->get("InputfieldFieldset");
		$fieldset->attr('id+name', 'abandoned_fieldset');
		$fieldset->description = $this->_('The following unused translations were found. This means that the original untranslated text was either changed or deleted. It is recommended that you delete abandoned translations unless you need to keep them to copy/paste to a new translation.');
		$fieldset->collapsed = Inputfield::collapsedYes;
		$n = 0;
		
		foreach($translations as $hash => $translation) {

			// if the hash still exists in the untranslated phrases, then it is not abandoned
			if(isset($this->untranslated[$hash])) continue;
			if(!isset($translation['text'])) $translation['text'] = '';
			if(!$this->isAbandonedHash($hash)) continue;
			
			$n++;
			/** @var InputfieldCheckbox $field */
			$field = $modules->get("InputfieldCheckbox");
			$field->attr('name', "abandoned$n");
			$field->attr('value', $hash);
			$field->description = !strlen($translation['text']) ? $this->_('[empty]') : $translation['text'];
			$field->label = $this->_('Delete?'); // Checkbox label
			$field->icon = 'trash-o';
			
			$fieldset->add($field);
		}

		if($n) {
			$fieldset->label = sprintf($this->_n('%d abandoned translation', '%d abandoned translations', $n), $n);
			$form->prepend($fieldset);
		}
	}

	/**
	 * Edit all translations in a textdomain
	 *
	 * URL: setup/language-translator/edit/?language_id=$id&textdomain=$textdomain
	 *
	 */
	public function ___executeEdit() {
		
		$sanitizer = $this->wire()->sanitizer;
		$input = $this->wire()->input;
		$config = $this->wire()->config;
		$modules = $this->wire()->modules;
		$session = $this->wire()->session;

		$this->addBreadcrumbs();
		$this->breadcrumb('../add/', $this->_x('Select File(s)', 'breadcrumb'));

		$textdomain = $sanitizer->textdomain($input->get('textdomain'));
		if(empty($textdomain)) throw new WireException("Missing textdomain");
		
		$fileUrl = $this->translator->textdomainToFilename($textdomain);
		$fragment = strpos($textdomain, 'site') === 0 ? 'find-language_files_site' : 'find-language_files';
		$csvUploadUrl = "../../languages/edit/?id={$this->language->id}#$fragment";
		$csvDownloadUrl = "../../languages/download/?language_id={$this->language->id}&csv=1&textdomain=$textdomain";
		$csvViewUrl = $csvDownloadUrl . "&view=1";
		
		if(!$fileUrl) {
			$filename = $input->get('filename');
			$test = $filename ? $this->translator->filenameToTextdomain($filename) : '';
			if($test === $textdomain) {
				$this->processAdd(null, $filename); 
			} else {
				throw new WireException($this->_('Unable to load textdomain'));
			}
		}
		
		$file = $config->paths->root . $fileUrl;

		if(!file_exists($file)) {
			$this->error(
				$this->_('File does not exist:') . " $fileUrl " .
				sprintf($this->_('(translation file not needed? textdomain: %s)'), $textdomain)
			);
			$session->redirect('../add/');
		}

		$this->parseTranslatableFile($file);
		$this->headline(sprintf($this->_('Translate %1$s to %2$s'), basename($file), $this->language->get('title|name')));

		// get links to other languages
		$links = array();
		foreach($this->wire()->languages as $lang) {
			$url = "./?textdomain=$textdomain&amp;language_id=$lang&amp;filename=" . urlencode($fileUrl);
			$label = $sanitizer->entities1($lang->get('title|name'));
			$links[] = "$lang" === "$this->language" ? $label : "<a href='$url'>$label</a>"; 
		}

		/** @var InputfieldForm $form */
		$form = $modules->get('InputfieldForm');
		$form->addClass('InputfieldFormConfirm');
		$form->attr('action', "./?textdomain=$textdomain&language_id={$this->language->id}");
		$form->attr('method', 'post');
		$form->value = 
			(count($links) > 1 ? "<p>" . implode(' &nbsp;|&nbsp; ', $links) . "</p>" : "") . 
			"<p>" .
			$this->_('Each of the inputs below represents a block of text to translate.') . ' ' .
			sprintf($this->_('The text shown immediately above each input is the text that should be translated to %s.'), $this->language->title) . ' ' .
			$this->_('If you leave an input blank, the non-translated text will be used.') . ' ' .
			$this->_('If the translation will be identical to the original, you may also enter a single "=" (equals sign) for the translation and it will be marked as translated.') . ' ' . 
			$sanitizer->entitiesMarkdown(
				sprintf(
					$this->_('You may also [download](%1$s) or [view](%2$s) a CSV file with these translations, edit them, and then [upload them here](%3$s).'), 
					$csvDownloadUrl, $csvViewUrl, $csvUploadUrl
				),
				array('linkMarkup' => "<a href='{url}'>{text}</a>")
			) . 
			"</p>";
		$form->appendMarkup .= '<p class="description">' . sprintf($this->_('The textdomain for this file is: %s'), "<code>$textdomain</code>") . '</p>';

		$translations = $this->translator->getTranslations($textdomain);

		foreach($this->untranslated as $hash => $untranslated) {
			$translated = isset($translations[$hash]) ? $translations[$hash]['text'] : '';
			$alternates = array();
			if(isset($this->alternates[$hash])) {
				foreach($this->alternates[$hash] as $altHash => $altText) {
					if(!isset($translations[$altHash])) continue;
					$alternates[$altText] = $translations[$altHash]['text'];
				}
			}
			$form->add($this->executeEditField($hash, $untranslated, $translated, $alternates));
		}

		$this->executeEditAbandoned($translations, $form);

		/** @var InputfieldCheckbox $field */
		$field = $modules->get('InputfieldCheckbox');
		$field->attr('id+name', 'untranslated');
		$field->label = $this->_('Only show blocks that are not yet translated');
		if($session->getFor($this, 'untranslated')) $field->attr('checked', 'checked');
		$form->prepend($field);

		/** @var InputfieldSubmit $submit */
		$submit = $modules->get("InputfieldSubmit");
		$submit->attr('id+name', 'save_translations');
		$submit->value = $this->_x('Save', 'button');
		$submit->showInHeader();
		$submit->addActionValue('exit', $this->_('Save + Exit'), 'times');
		$submit->addActionValue('next', $this->_('Save + Next'), 'edit');
		$form->add($submit);

		if($input->post('save_translations')) $this->processEdit($form, $textdomain, $translations);

		return $form->render();
	}

	/**
	 * Process the 'edit' form and save the changes
	 * 
	 * @param InputfieldForm $form
	 * @param string $textdomain
	 * @param array $translations
	 *
	 */
	protected function ___processEdit($form, $textdomain, $translations) {
		
		$input = $this->wire()->input;
		$session = $this->wire()->session;
		
		$numChanges = 0;
		$numRemoved = 0;

		$form->processInput($input->post);

		foreach($this->untranslated as $hash => $text) {
			$translation = isset($translations[$hash]) ? $translations[$hash] : array('text' => '');
			$field = $form->getChildByName($hash);
			if($field->value != $translation['text']) {
				$numChanges++;
				$this->translator->setTranslationFromHash($textdomain, $hash, $field->value);
			}
		}

		foreach($input->post as $key => $hash) {
			if(strpos($key, 'abandoned') !== 0) continue;
			if(!$form->getChildByName($key)) continue;
			$this->translator->removeTranslation($textdomain, $hash);
			$numRemoved++;
		}

		// show only untranslated toggle, remember setting
		$untranslated = (int) $input->post('untranslated');
		$session->setFor($this, 'untranslated', $untranslated);

		if($numChanges) $this->message(sprintf($this->_n('%d translation changed', '%d translations changed', $numChanges), $numChanges));
		if($numRemoved) $this->message(sprintf($this->_n('%d abandoned translation removed', '%d abandoned translations removed', $numRemoved), $numRemoved));

		$this->translator->saveTextdomain($textdomain);
		$this->message(sprintf($this->_('Saved %s'), $textdomain));
	
		// button actions
		$action = $input->post('_action_value'); 	
		if($action == 'exit') {
			// save and exit
			$session->redirect("../../languages/edit/?id={$this->language->id}");
		} else if($action == 'next') {
			// save and edit next file
			$nextTextdomain = false;
			foreach(array('language_files_site', 'language_files') as $fieldName) {
				foreach($this->language->get($fieldName) as $pagefile) {
					if($nextTextdomain === true) {
						$nextTextdomain = basename($pagefile->name, '.json');	
						break;
					} else {
						if(basename($pagefile->name, '.json') == $textdomain) $nextTextdomain = true;
					}
				}
				if(is_string($nextTextdomain)) break;
			}
			if(is_bool($nextTextdomain)) {
				$nextTextdomain = $textdomain;
				$this->warning($this->_('There is no next file to edit.'));
			}
			$session->redirect("./?textdomain=$nextTextdomain&language_id={$this->language->id}");
		} else {
			// save and continue editing (default)
			$session->redirect("./?textdomain=$textdomain&language_id={$this->language->id}");
		}
	}

	/**
	 * Given a full path to a file, locate all the translatable phrases, populating $this->untranslated array and $this->comments array
	 *
	 * @param string $file
	 *
	 * @return int Number of translatable phrases found
	 *
	 */
	protected function parseTranslatableFile($file) {
		require_once(dirname(__FILE__) . '/LanguageParser.php');
		$parser = new LanguageParser($this->translator, $file);
		$this->comments = $parser->getComments();
		$this->untranslated = $parser->getUntranslated();
		$this->alternates = $parser->getAlternates();
		return $parser->getNumFound();
	}

	/**
	 * Manage the breadcrumb trail for PW admin
	 *
	 */
	protected function addBreadcrumbs() {
		/** @var LanguageSupport $languageSupport */
		$languageSupport = $this->wire()->modules->get('LanguageSupport');
		$languagesPage = $this->pages->get($languageSupport->languagesPageID);
		$url = $languagesPage->url;
		$breadcrumbs = $this->wire()->breadcrumbs;
		$breadcrumbs->add(new Breadcrumb($url, $languagesPage->title));
		$breadcrumbs->add(new Breadcrumb($url . "edit/?id={$this->language->id}", $this->language->get('title|name')));
	}

	/**
	 * Find all translation files recursively, starting from $path
	 *
	 * To prevent a file from being identified as translatable, place this text somewhere in it a PHP comment:
	 * __(file-not-translatable)
	 * 
	 * @param string $path
	 * @param bool $useCache
	 * @return array
	 * @throws WireException
	 *
	 */
	public function findTranslatableFiles($path, $useCache = true) {

		if(!is_dir($path)) throw new WireException(sprintf($this->_('%s does not exist or is not a directory'), $path));

		static $level = 0;
		$extensions = $this->extensions;

		if(!$level) {
			$cacheKey = "files_" . md5($path);
			if($useCache) {
				$files = $this->wire()->session->get($this, $cacheKey);
				if($files !== null) return $files;
			}
		} else {
			$cacheKey = '';
		}
		
		if(is_null($this->fp)) {
			$f = $this->language->filesManager()->path() . '.phrase-index.txt'; 
			if(is_file($f)) $this->wire()->files->unlink($f);
			$this->fp = fopen($f, "a");
		}

		$files = array();
		$dirs = array();
		$root = $this->wire()->config->paths->root;
		$assetsDir = '/site/assets/';
		if(DIRECTORY_SEPARATOR != '/') $assetsDir = str_replace('/', DIRECTORY_SEPARATOR, $assetsDir);

		$find1 = array('$this->_(', '$this->_n(', '$this->_x(');
		$find2 = array('__(', '_n(', '_x(');
	
		try {
			$dirIterator = new \DirectoryIterator($path);
		} catch(\Exception $e) {
			$this->warning($e->getMessage());
			$dirIterator = false;
		}
		
		if($dirIterator === false) return array();

		foreach($dirIterator as $file) {

			if($file->isDot()) continue;
			$c = substr($file->getBasename(), 0, 1);
			if($c === '.' || $c === '-' || $c === '\\') continue; // skip hidden
			if($file->isDir()) {
				$pathname = $file->getPathname();
				if(strpos($pathname, $assetsDir) !== false) continue; // avoid descending into /site/assets/
				$dirs[] = $pathname;
				continue;
			}

			$ext = $file->getExtension();
			if(!in_array(strtolower($ext), $extensions)) continue;
			$pathname = $file->getPathname();
			$text = file_get_contents($pathname);
			$found = false;

			foreach($find1 as $s) {
				if(strpos($text, $s) !== false) {
					$found = true;
					break;
				}
			}
			if(!$found) foreach($find2 as $s) {
				$pos = strpos($text, $s);
				if($pos === false) continue;
				$c = substr($text, $pos - 1, 1); // character before __(
				if(!ctype_alnum($c) && $c != '_') {
					// not a character that would appear in a variable function definition, so we'll take it
					$found = true;
					break;
				}
			}

			// files containing __(file-not-translatable) anywhere are non-translatable
			if($found && $pathname !== __FILE__ && strpos($text, '__(file-not-translatable)') !== false) $found = false;

			if($found) {
				$pathname = str_replace($root, '/', $pathname);
				$files[$pathname] = $pathname;
				if(preg_match_all('/\b(?:_[_nx]\(|->_[nx]?\()[\'"](.+?)[\'"]\)/', $text, $matches)) {
					foreach($matches[1] as $phrase) {
						$phrase = str_replace(array('|', '^', "\n", "\r", "\t", "&lt;", "&gt;"), ' ', strip_tags($phrase));
						fwrite($this->fp, "|$phrase");
					}
					$textdomain = $this->translator->filenameToTextdomain($pathname);
					fwrite($this->fp, "|^$textdomain:$pathname\n");
				}
			}
		}

		$level++;
		if($level <= 20) { // 20=max directory nesting level
			foreach($dirs as $dir) {
				$_files = $this->findTranslatableFiles($dir, $useCache);
				$files = array_merge($files, $_files);
			}
		}
		$level--;
		
		if($cacheKey) {
			$this->wire()->session->set($this, $cacheKey, $files);
		}
		
		if($this->fp && !$level) {
			$this->message($this->_('Built search phrase index') . " - $path"); 
		}

		return $files;
	}

	/**
	 * Is the given translation hash abandoned?
	 * 
	 * @param string $hash
	 * @return bool
	 * @since 3.0.151
	 * 
	 */
	protected function isAbandonedHash($hash) {
		// if the hash still exists in the untranslated phrases, then it is not abandoned
		if(isset($this->untranslated[$hash])) return false;
		if($this->language && $this->language->isDefault()) return true;
		$abandoned = true;

		foreach($this->alternates as $srcHash => $values) {
			if(isset($values[$hash]) && isset($this->untranslated[$srcHash])) {
				// $text = $this->untranslated[$srcHash];
				// $notes = "Currently used as fallback translation for: " . $text;
				$abandoned = false;
				break;
			}
		}
		
		return $abandoned;
	}

	/**
	 * Module config
	 * 
	 * @param InputfieldWrapper $inputfields
	 * 
	 */
	public function getModuleConfigInputfields(InputfieldWrapper $inputfields) {
		$f = $inputfields->InputfieldText;
		$f->attr('name', 'extensions');
		$f->label = $this->_('Supported file extensions for translatable files'); 
		$f->description = $this->_('Enter a space-separated list of file extensions (`php` and `module` are required).'); 
		$f->val(implode(' ', $this->extensions));
		$inputfields->add($f);
	}
}
