Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

Initial module load

  • Loading branch information...
commit b74cedb6bd049006635ff3023d7b1923a12524b3 1 parent fdd62c4
Mark Stephens authored May 13, 2010
3  _config.php
... ...
@@ -0,0 +1,3 @@
  1
+<?php
  2
+
  3
+i18n::register_plugin("customTranslations", array("CustomLanguageTranslation", "custom_translations"));
134  code/CustomLanguageTranslation.php
... ...
@@ -0,0 +1,134 @@
  1
+<?php
  2
+
  3
+/**
  4
+ * Provides configurable overrides for language translations.
  5
+ */
  6
+class CustomLanguageTranslation extends DataObject {
  7
+	static $db = array(
  8
+		"Locale" => "Varchar(6)",
  9
+
  10
+		// Entity of this override, a dot-separated identifier for the string we're looking up, that is
  11
+		// passed into _t.
  12
+		// passed into _t.
  13
+		"Entity" => "Varchar(255)",
  14
+
  15
+		// What it translates to in this locale
  16
+		"Translation" => "Text",
  17
+
  18
+		// Priority defined in lang. If zero, uses PR_MEDIUM
  19
+		"Priority" => "Int",
  20
+
  21
+		// Explanatory comments
  22
+		"Comment" => "Text"
  23
+	);
  24
+
  25
+	static $summary_fields = array('Locale', 'Entity', 'Translation');
  26
+	static $searchable_fields = array('Locale', 'Entity', 'Translation');
  27
+
  28
+
  29
+ 	public function getCMSFields() {
  30
+		$fields = parent::getCMSFields();
  31
+		$fields->addFieldToTab('Root.Main', new HeaderField('_newDesc', 'Custom Translation', 4));
  32
+		$fields->addFieldToTab('Root.Main', new TextField('Locale'));
  33
+		$fields->addFieldToTab('Root.Main', new TextField('Entity'));
  34
+		$fields->addFieldToTab('Root.Main', new TextField('Translation'));
  35
+		$fields->addFieldToTab('Root.Main', new TextField('Priority'));
  36
+		$fields->addFieldToTab('Root.Main', new TextField('Comment'));
  37
+		if (isset($_REQUEST['OriginalTranslation']) || isset($_REQUEST['OriginalPriority']) || isset($_REQUEST['OriginalComment']))
  38
+			$fields->addFieldToTab('Root.Main', new HeaderField('_origDesc', 'Values in the lang entry being overridden:', 4));
  39
+		if (isset($_REQUEST['OriginalTranslation']))
  40
+			$fields->addFieldToTab(
  41
+				'Root.Main',
  42
+				new ReadonlyField('OriginalTranslation', 'Translation in lang file', $_REQUEST['OriginalTranslation'])
  43
+			);
  44
+		if (isset($_REQUEST['OriginalPriority']))
  45
+			$fields->addFieldToTab(
  46
+				'Root.Main',
  47
+				new ReadonlyField('OriginalPriority', 'Priority in lang file', $_REQUEST['OriginalPriority'])
  48
+			);
  49
+		if (isset($_REQUEST['OriginalComment']))
  50
+			$fields->addFieldToTab(
  51
+				'Root.Main',
  52
+				new ReadonlyField('OriginalComment', 'Comment in lang file', $_REQUEST['OriginalTranslation'])
  53
+			);
  54
+		return $fields;
  55
+	}
  56
+
  57
+	/**
  58
+	 * Return a validator.
  59
+	 * @return void
  60
+	 */
  61
+	public function getCMSValidator() {
  62
+		return new CustomTranslationValidator(array('Translation'));
  63
+	}
  64
+
  65
+	/**
  66
+	 * A callback passed into i18n::register_translation_provider, which is called the first time a
  67
+	 * locale is loaded. Basically it retrieves all the translations from CustomLanguageTranslation
  68
+	 * that are in that locale, pulling apart the entities to create the correctly structured array.
  69
+	 * @static
  70
+	 * @param  $locale
  71
+	 * @return array		Returns an array that can be merged with $lang.
  72
+	 */
  73
+	static function custom_translations($locale) {
  74
+		$result = array();
  75
+		$trans = DataObject::get("CustomLanguageTranslation", "\"Locale\"='$locale'");
  76
+		if ($trans) foreach ($trans as $t) {
  77
+			$parts = explode(".", $t->Entity);
  78
+			if (count($parts) != 2) continue;
  79
+			$class = $parts[0];
  80
+			$entity = $parts[1];
  81
+			$result[$locale][$class][$entity] = array($t->Translation, $t->Priority ? $t->Priority : PR_MEDIUM, $t->Comment); 
  82
+		}
  83
+		return $result;
  84
+	}
  85
+
  86
+}
  87
+
  88
+/**
  89
+ * A custom validator for translations. It implements validation logic in PHP for matching signatures in the translation
  90
+ * text. It parses the original translation (from the language file) for occurence of %s, %d and the like, and does
  91
+ * the same for the new validation text. The validation only succeeds if there are the same number of tokens, and
  92
+ * of the same type in the same order. Failure to enforce this could mean arbitrary run-time errors in the site,
  93
+ * where replacements don't work.
  94
+ */
  95
+class CustomTranslationValidator extends RequiredFields {
  96
+	function php($data) {
  97
+		if (!parent::php($data)) return false;  // required fields failed.
  98
+
  99
+		$origTokens = $this->getTokens($data['OriginalTranslation']);
  100
+		$newTokens = $this->getTokens($data['Translation']);
  101
+
  102
+		// determine if they are the same
  103
+		$valid = count($origTokens) == count($newTokens);
  104
+
  105
+		if ($valid && count($origTokens) > 0) {
  106
+			// Counts are the same, so iterate over both arrays and ensure they are compatible.
  107
+			for ($i = 0; $i < count($origTokens); $i++) {
  108
+				// @todo This requires an exact match. For example, %10d and %d won't match, but should.
  109
+				if ($origTokens[$i] != $newTokens[$i]) $valid = false;
  110
+			}
  111
+		}
  112
+
  113
+		if (!$valid) {
  114
+			$this->validationError(
  115
+				'Translation',
  116
+				_t('Form.FIELDSIGNATUREMISMATCH', "The value substitution tokens in the new translation must match the tokens in the original translation string"),
  117
+				"invalid"
  118
+			);
  119
+		}
  120
+
  121
+		return $valid;
  122
+	}
  123
+
  124
+	/**
  125
+	 * Scan a string for sprintf %-style tokens. Return an array with the tokens. If there are no tokens, return
  126
+	 * an empty array. It parses %% as well, but doesn't return them, as they are treated as literal by sprintf.
  127
+	 * @param  $s
  128
+	 * @return void
  129
+	 */
  130
+	protected function getTokens($s) {
  131
+		preg_match_all("/\%[sd%]/", $s, $matches, PREG_PATTERN_ORDER);
  132
+		return array_filter($matches[0], create_function('$s', 'return $s!="%%";'));
  133
+	}
  134
+}
302  code/CustomTranslationAdmin.php
... ...
@@ -0,0 +1,302 @@
  1
+<?php
  2
+
  3
+class CustomTranslationAdmin extends ModelAdmin {
  4
+	static $url_segment = 'translations';
  5
+
  6
+	static $managed_models = "CustomLanguageTranslation";
  7
+
  8
+	static $menu_title = 'Translations';
  9
+
  10
+	public static $collection_controller_class = "CustomTranslationAdmin_CollectionController";
  11
+}
  12
+
  13
+class CustomTranslationAdmin_CollectionController extends ModelAdmin_CollectionController {
  14
+	/**
  15
+	 * Creates and returns the result table field for resultsForm.
  16
+	 * Uses {@link resultsTableClassName()} to initialise the formfield.
  17
+	 * Method is called from {@link ResultsForm}.
  18
+	 *
  19
+	 * @param array $searchCriteria passed through from ResultsForm
  20
+	 *
  21
+	 * @return TableListField
  22
+	 */
  23
+	function getResultsTable($searchCriteria) {
  24
+		$summaryFields = $this->getResultColumns($searchCriteria);
  25
+
  26
+		$className = $this->parentController->resultsTableClassName();
  27
+		$tf = new $className(
  28
+			$this->modelClass,
  29
+			$this->modelClass,
  30
+			$summaryFields
  31
+		);
  32
+
  33
+		// Force TableListField js before custom translation js
  34
+		Requirements::javascript(SAPPHIRE_DIR . '/javascript/TableListField.js');
  35
+		Requirements::javascript("customtranslations/javascript/CustomTranslationAdmin.js");
  36
+
  37
+		$tf->setCustomSourceItems($this->itemsWithFiltering($searchCriteria));
  38
+		$tf->setPageSize($this->parentController->stat('page_length'));
  39
+		$tf->setShowPagination(true);
  40
+		$tf->itemClass = "CustomTranslationTableListField_Item";
  41
+		$tf->actions = array(
  42
+			// Delete an existing mapping
  43
+			'delete' => array(
  44
+				'label' => 'Delete',
  45
+				'icon' => 'cms/images/delete.gif',
  46
+				'icon_disabled' => 'cms/images/delete_disabled.gif',
  47
+				'class' => 'deletelink'
  48
+			),
  49
+			// Add a new mapping
  50
+			'add' => array(
  51
+				'label' => 'Add',
  52
+				'icon' => 'customtranslations/images/add.gif',
  53
+				'icon_disabled' => 'customtranslations/images/add_disabled.gif',
  54
+				'class' => 'addlink'
  55
+			)
  56
+		);
  57
+
  58
+
  59
+		$tf->setPermissions(array_merge(array('view','export'), TableListField::permissions_for_object($this->modelClass)));
  60
+
  61
+		// csv export settings (select all columns regardless of user checkbox settings in 'ResultsAssembly')
  62
+		$exportFields = $this->getResultColumns($searchCriteria, false);
  63
+		$tf->setFieldListCsv($exportFields);
  64
+
  65
+		return $tf;
  66
+	}
  67
+
  68
+	/**
  69
+	 * Return a DataObjectSet that contains all the items that apply to the search criteria. This is basically
  70
+	 * a list of all language settings from $lang that match the search Criteria, with overrides loaded from the
  71
+	 * database and merged together. Sowe get a list of all matching items, and showing where they have been overridden.
  72
+	 * @return DataObjectSet
  73
+	 */
  74
+	function itemsWithFiltering($searchCriteria) {
  75
+		global $lang;
  76
+
  77
+		// Keep the base lang
  78
+		$langOld = $lang;
  79
+
  80
+		// Reload lang with all the common locals
  81
+		$lang = null;
  82
+
  83
+		// Load this first, others depend on it.
  84
+		i18n::include_by_locale('en_US', true, true);
  85
+		foreach (i18n::$common_locales as $locale => $name) {
  86
+			i18n::include_by_locale($locale, false, true);
  87
+		}
  88
+
  89
+		// Iterate over $lang and add anything to the dataset that matches the searchCriteria.
  90
+		$result = new DataObjectSet();
  91
+		foreach ($lang as $locale => $classes) {
  92
+			if (isset($searchCriteria["Locale"]) &&
  93
+				$searchCriteria["Locale"] &&
  94
+				strpos($locale, $searchCriteria["Locale"]) === false) continue;
  95
+			foreach ($classes as $class => $entities) {
  96
+				foreach ($entities as $entity => $translation) {
  97
+					$combined = $class . "." . $entity;
  98
+					if (isset($searchCriteria["Entity"]) &&
  99
+						$searchCriteria["Entity"] &&
  100
+						strpos($combined, $searchCriteria["Entity"]) === false) continue;
  101
+
  102
+					if (is_array($translation)) {
  103
+						$trans = array_shift($translation);
  104
+						$priority = array_shift($translation);
  105
+						$comment = array_shift($translation);
  106
+					}
  107
+					else {
  108
+						$trans = $translation;
  109
+						$priority = PR_MEDIUM;
  110
+						$comment = null;
  111
+					}
  112
+
  113
+					if (isset($searchCriteria["Translation"]) &&
  114
+						$searchCriteria["Translation"] &&
  115
+						strpos($trans, $searchCriteria["Translation"]) === false) continue;
  116
+
  117
+
  118
+
  119
+					$item = new CustomLanguageTranslation(array(
  120
+						'ID' => 0,
  121
+						'ClassName' => "CustomTranslationAdmin",
  122
+						'RecordClassName' => "CustomTranslationAdmin",
  123
+						'Locale' => $locale,
  124
+						'Entity' => $combined,
  125
+						'Translation' => $trans,
  126
+						'Priority' => $priority,
  127
+						'Comment' => $comment,
  128
+						'Link' => $this->Link()
  129
+					));
  130
+
  131
+					$result->push($item);
  132
+				}
  133
+			}
  134
+		}
  135
+
  136
+		// Get the query that will give us the overrides to merge in.
  137
+		$query = $this->getSearchQuery($searchCriteria);
  138
+		$records = $query->execute();
  139
+		$dataobject = new CustomLanguageTranslation();
  140
+		$items = $dataobject->buildDataObjectSet($records, 'DataObjectSet');
  141
+
  142
+		if ($items) {
  143
+			// there are overrides that match, so merge these into $result. In general we should always find the
  144
+			// record already there, and we just update it, otherwise we add it.
  145
+			foreach ($items as $item) {
  146
+				$found = false;
  147
+				foreach ($result as $r) {
  148
+					if ($r->Locale == $item->Locale &&
  149
+						$r->Entity == $item->Entity) {
  150
+						$found = true;
  151
+						break;
  152
+					}
  153
+				}
  154
+
  155
+				if ($found) {
  156
+					$r->OriginalTranslation = $r->Translation;
  157
+					$r->OriginalPriority = $r->Priority;
  158
+					$r->OriginalComment = $r->Comment;
  159
+					$r->Translation = $item->Translation;
  160
+					$r->Priority = $item->Priority;
  161
+					$r->Comment = $item->Comment;
  162
+					$r->ID = $item->ID;
  163
+				}
  164
+				else
  165
+					$result->push($item);
  166
+			}
  167
+		}
  168
+
  169
+		$lang = $langOld;
  170
+
  171
+		return $result;
  172
+	}
  173
+
  174
+	public function AddForm() {
  175
+		$newRecord = new $this->modelClass();
  176
+
  177
+		foreach (array("Locale", "Entity", "Translation", "Priority", "Comment", "OriginalTranslation", "OriginalPriority", "OriginalComment") as $field) {
  178
+			if (isset($_REQUEST[$field])) $newRecord->$field = $_REQUEST[$field];
  179
+		}
  180
+
  181
+		if($newRecord->canCreate()){
  182
+			if($newRecord->hasMethod('getCMSAddFormFields')) {
  183
+				$fields = $newRecord->getCMSAddFormFields();
  184
+			} else {
  185
+				$fields = $newRecord->getCMSFields();
  186
+			}
  187
+
  188
+			$validator = ($newRecord->hasMethod('getCMSValidator')) ? $newRecord->getCMSValidator() : null;
  189
+			if(!$validator) $validator = new RequiredFields();
  190
+			$validator->setJavascriptValidationHandler('none');
  191
+
  192
+			$actions = new FieldSet (
  193
+				new FormAction("doCreate", _t('ModelAdmin.ADDBUTTON', "Add"))
  194
+			);
  195
+
  196
+			$form = new Form($this, "AddForm", $fields, $actions, $validator);
  197
+			$form->loadDataFrom($newRecord);
  198
+
  199
+			return $form;
  200
+		}
  201
+	}
  202
+}
  203
+
  204
+/**
  205
+ * Provide custom behaviour of list items.
  206
+ */
  207
+class CustomTranslationTableListField_Item extends TableListField_Item {
  208
+	// Basically a version that doesn't use default item formatting.
  209
+	function Fields($xmlSafe = true) {
  210
+		$list = $this->parent->FieldList();
  211
+		foreach($list as $fieldName => $fieldTitle) {
  212
+			$value = "";
  213
+
  214
+			// This supports simple FieldName syntax
  215
+			if(strpos($fieldName,'.') === false) {
  216
+				$value = ($this->item->XML_val($fieldName) && $xmlSafe) ? $this->item->XML_val($fieldName) : $this->item->RAW_val($fieldName);
  217
+			// This support the syntax fieldName = Relation.RelatedField
  218
+			} else {
  219
+				$fieldNameParts = explode('.', $fieldName)	;
  220
+				$tmpItem = $this->item;
  221
+				for($j=0;$j<sizeof($fieldNameParts);$j++) {
  222
+					$relationMethod = $fieldNameParts[$j];
  223
+					$idField = $relationMethod . 'ID';
  224
+					if($j == sizeof($fieldNameParts)-1) {
  225
+						if($tmpItem) $value = $tmpItem->$relationMethod;
  226
+					} else {
  227
+						if($tmpItem) $tmpItem = $tmpItem->$relationMethod();
  228
+					}
  229
+				}
  230
+			}
  231
+
  232
+			// casting
  233
+			if(array_key_exists($fieldName, $this->parent->fieldCasting)) {
  234
+				$value = $this->parent->getCastedValue($value, $this->parent->fieldCasting[$fieldName]);
  235
+			} elseif(is_object($value) && method_exists($value, 'Nice')) {
  236
+				$value = $value->Nice();
  237
+			}
  238
+
  239
+			// formatting. If there is a override here, make all fields hyperlinked. We also include in the URL
  240
+			// the original fields so we can display them too.
  241
+			if ($this->item->ID) {
  242
+				$orig = "?OriginalTranslation="	. urlencode($this->item->OriginalTranslation) .
  243
+						"&OriginalPriority=" . urlencode($this->item->OriginalPriority) .
  244
+						"&OriginalComment=" . urlencode($this->item->OriginalComment);
  245
+				$value = "<a href=\"" . $this->item->Link . "/{$this->item->ID}/edit/$orig\">$value</a>";
  246
+			}
  247
+
  248
+			//escape
  249
+			if($escape = $this->parent->fieldEscape){
  250
+				foreach($escape as $search => $replace){
  251
+					$value = str_replace($search, $replace, $value);
  252
+				}
  253
+			}
  254
+
  255
+			$fields[] = new ArrayData(array(
  256
+				"Name" => $fieldName,
  257
+				"Title" => $fieldTitle,
  258
+				"Value" => $value,
  259
+				"CsvSeparator" => $this->parent->getCsvSeparator(),
  260
+			));
  261
+		}
  262
+		return new DataObjectSet($fields);
  263
+	}
  264
+
  265
+	function Actions() {
  266
+		$allowedActions = new DataObjectSet();
  267
+		foreach($this->parent->actions as $actionName => $actionSettings) {
  268
+			$can = $this->Can($actionName);
  269
+			if ((!$this->item->ID && $actionName == "delete") ||
  270
+				($this->item->ID && $actionName == "add")) $can = false;
  271
+			if($this->parent->Can($actionName)) {
  272
+				$allowedActions->push(new ArrayData(array(
  273
+					'Name' => $actionName,
  274
+					'Link' => $this->{ucfirst($actionName).'Link'}(),
  275
+					'Icon' => $actionSettings['icon'],
  276
+					'IconDisabled' => $actionSettings['icon_disabled'],
  277
+					'Label' => $actionSettings['label'],
  278
+					'Class' => $actionSettings['class'],
  279
+					'Default' => ($actionName == $this->parent->defaultAction),
  280
+					'IsAllowed' => $can,
  281
+				)));
  282
+			}
  283
+		}
  284
+
  285
+		return $allowedActions;
  286
+	}
  287
+
  288
+	function AddLink() {
  289
+		$link = $this->item->Link . "/add";
  290
+		foreach (array(
  291
+			"?Locale" => $this->item->Locale,
  292
+			"&Entity" => $this->item->Entity,
  293
+			"&Translation" => $this->item->Translation,
  294
+			"&Priority" => $this->item->Priority,
  295
+			"&Comment" => $this->item->Comment,
  296
+			"&OriginalTranslation" => $this->item->Translation,
  297
+			"&OriginalPriority" => $this->item->Priority,
  298
+			"&OriginalComment" => $this->item->Comment
  299
+		) as $formField => $value) $link .= "{$formField}=" . urlencode($value);
  300
+		return $link;
  301
+	}
  302
+}
47  docs/README.md
Source Rendered
... ...
@@ -0,0 +1,47 @@
  1
+Introduction
  2
+============
  3
+
  4
+The `customtranslations` module lets you override the default translations for strings provided by SilverStripe. It
  5
+provides an admin interface titled "Translations", and lets you add and manage language overrides.
  6
+
  7
+The translation overrides are stored in the database. The core translations are in PHP and are not changed.
  8
+
  9
+Installation
  10
+============
  11
+
  12
+Dependencies
  13
+------------
  14
+This depends on a minimum of Sapphire 2.4.1.
  15
+
  16
+Installation Instructions
  17
+=========================
  18
+* Install the module directory into the top-level directory of your SilverStripe project.
  19
+* Perform a dev/build?flush=all
  20
+
  21
+
  22
+How to Use
  23
+==========
  24
+The module installs a new Admin interface called Translations. This lets you search the translations, and will search
  25
+through all common locales unless you provide an explicit locale.
  26
+
  27
+The admin interface lets you override individual translations strings. When you override a translation, it will show
  28
+you the values overridden.
  29
+
  30
+If you delete a custom translation, the system will revert back to the built-in translation.
  31
+
  32
+When you create or edit a custom translation, it must have the same % tokens that the original has, in the same order.
  33
+e.g. if the original translation is "%s owes you %d pieces of gold", any custom translation of this string must
  34
+contain %s and %d, in that order. Validation rules enforce this.
  35
+
  36
+Known Issues
  37
+============
  38
+
  39
+* Doesn't load translations outside the 'common' ones defined in i18n.
  40
+
  41
+* If _t is called before customtranslations/_config.php, the translations won't be loaded for the locales used. Generally
  42
+  calling _t before Director is called is a bad idea anyway.
  43
+
  44
+* After deleting an override, the list doesn't refresh properly.
  45
+
  46
+* Validation of % tokens in custom translations only understands %s and %d, and doesn't handle cases like %5d. It does
  47
+  handle %%, which is treated as a literal, and doesn't have to match.
BIN  images/add.gif
BIN  images/add_disabled.gif
32  javascript/CustomTranslationAdmin.js
... ...
@@ -0,0 +1,32 @@
  1
+// Override the default delete behaviour. We don't want to delete the row client side, but rather refresh
  2
+// the RHS
  3
+TableListField.prototype.deleteRecord = function(e) {
  4
+		var img = Event.element(e);
  5
+		var link = Event.findElement(e,"a");
  6
+		var row = Event.findElement(e,"tr");
  7
+
  8
+		// TODO ajaxErrorHandler and loading-image are dependent on cms, but formfield is in sapphire
  9
+		var confirmed = confirm(ss.i18n._t('TABLEFIELD.DELETECONFIRMMESSAGE', 'Are you sure you want to delete this record?'));
  10
+		if(confirmed)
  11
+		{
  12
+			img.setAttribute("src",'cms/images/network-save.gif'); // TODO doesn't work
  13
+			new Ajax.Request(
  14
+				link.getAttribute("href"),
  15
+				{
  16
+					method: 'post',
  17
+					postBody: 'forceajax=1' + ($('SecurityID') ? '&SecurityID=' + $('SecurityID').value : ''),
  18
+					onComplete: function(){
  19
+						$('Form_ResultsForm_CustomLanguageTranslation').refresh();
  20
+//						$('Form_ResultsForm_CustomLanguageTranslation').refresh();
  21
+					}.bind(this),
  22
+					onFailure: this.ajaxErrorHandler
  23
+				}
  24
+			);
  25
+		}
  26
+		Event.stop(e);
  27
+	}
  28
+
  29
+/*
  30
+// This goes to the next page
  31
+	http://localhost/wf/admin/translations/CustomLanguageTranslation/ResultsForm/field/CustomLanguageTranslation?Locale=en&Entity=&Translation=%25&ResultAssembly%5BLocale%5D=Locale&ResultAssembly%5BTranslation%5D=Translation&ResultAssembly%5BEntity%5D=Entity&ctf[CustomLanguageTranslation][start]=30
  32
+*/

0 notes on commit b74cedb

Please sign in to comment.
Something went wrong with that request. Please try again.