Added a selectbox not a file path for XSL. Added upload for managing your XSL. tweake...
[project/xsl_formatter.git] / xsl_formatter.module
1 <?php
2 /**
3 * @file a formatter that runs given XML content through a defined
4 * XSL stylesheet before rendering.
5 *
6 * When enabled, this provides an additional field formatter available
7 * through 'manage display' for use entity displays or views.
8 * The data source should be a textarea witch contains the raw XML
9 * and configuration options on the display widget will allow you to
10 * define the XSL that should be run over it.
11 *
12 * The textarea should contain valid XML, and the XSL should produce an HTML
13 * snippet, as the result will be displayed inline in the page.
14 *
15 * This process can ALSO run identically over a link field.
16 * Add a 'link' field to your page, and choose 'Transformed by XSL' as the
17 * renderer.
18 * On each request, that file will be fetched, parsed, transformed and
19 * rendered.
20 *
21 * Other projects
22 * ==============
23 *
24 * Built with *some* comparison to
25 *
26 * http://drupal.org/project/cck_xslt D6 unreleased
27 * - which made its own field and data storage instead of re-using a text field.
28 * - Has the XSL selection a per-node choice, not a field formatter.
29 * - did demonstrate how easy it was to extend to reading an URL instead of text.
30 *
31 * http://drupal.org/project/feeds_xsltparser D7 dev
32 * - Not incredibly similar, but nice to see!
33 *
34 * http://drupal.org/node/1476774 "XML Transform" Never released
35 * - Uses the Drupal text filter system to process a textarea with XSL.
36 * - Actually the first way I started thinking about it, as I've done multiple
37 * XML-based text filters already.
38 *
39 *
40 * @author Dan Morrison (dman) dan@coders.co.nz
41 * @version 2012-11-27 (1:00AM -3:30AM)
42 */
43
44
45 /**
46 * Implements hook_field_formatter_info().
47 *
48 * Declares the existance of this formatter.
49 * We can do similar things to local textareas, remote URLs, or uploaded files!
50 */
51 function xsl_formatter_field_formatter_info() {
52 return array(
53 'xsl_formatter' => array(
54 'label' => t('Transformed by XSL'),
55 'field types' => array('text_long', 'link_field', 'file'),
56 'settings' => array(
57 'xsl_path' => 'xsl/xmlverbatim.xsl',
58 'xsl_params' => '',
59 'debug' => FALSE,
60 ),
61 ),
62 );
63 }
64
65 /**
66 * Implements hook_field_formatter_settings_summary().
67 *
68 * Summarizes the settings for display on the UI.
69 */
70 function xsl_formatter_field_formatter_settings_summary($field, $instance, $view_mode) {
71 $display = $instance['display'][$view_mode];
72 $settings = $display['settings'];
73 return t('XSL process, using : @xsl_filename', array('@xsl_filename' => basename($settings['xsl_path'])));
74 }
75
76 /**
77 * Implements hook_field_formatter_settings_form().
78 *
79 * Settings for the display options.
80 */
81 function xsl_formatter_field_formatter_settings_form($field, $instance, $view_mode, $form, &$form_state) {
82 $display = $instance['display'][$view_mode];
83 $settings = $display['settings'];
84
85 // Originally text, try a drop-down instead
86 /*
87 $element['xsl_path'] = array(
88 '#title' => t('XSL path'),
89 '#type' => 'textfield',
90 '#default_value' => $settings['xsl_path'],
91 '#element_validate' => array('xsl_formatter_xsl_path_validate'),
92 '#description' => t("Path to the location of the XSL file. Search will be made relative to the module directory, the site directory and the file directory. eg <code>xsl/xmlverbatimwrapper.xsl</code>, <code>xsl/prettyprint.xsl</code>"),
93 '#autocomplete_path' => 'admin/xsl_path'
94 );
95 */
96 $xsls = xsl_formatter_enumerate_xsls();
97 $element['xsl_path'] = array(
98 '#title' => t('XSL path'),
99 '#type' => 'select',
100 '#default_value' => $settings['xsl_path'],
101 '#element_validate' => array('xsl_formatter_xsl_path_validate'),
102 '#description' => t("Path to the location of the XSL file. Search will be made relative to the files/xsl directory, then the module directory."),
103 '#options' => $xsls,
104 );
105
106 // file upload needs an explicit name. This is horrid sorry
107 $upload_field_id = 'files[' . drupal_clean_css_identifier("files[fields][{$instance['field_name']}][settings_edit_form][settings][xsl_upload]") . ']';
108 $element['xsl_upload'] = array(
109 '#type' => 'file',
110 '#title' => t('Upload XSL file'),
111 '#maxlength' => 40,
112 '#description' => t("This will be placed in your files/xsl folder where it can be found and re-used."),
113 '#element_validate' => array('xsl_formatter_xsl_upload_validate'),
114 '#name' => $upload_field_id,
115 );
116 $module_path = drupal_get_path('module', 'xsl_formatter');
117 $element['xsl_params'] = array(
118 '#title' => t('Additional params'),
119 '#type' => 'textarea',
120 '#rows' => 2,
121 '#cols' => 24,
122 '#description' => t("Additional parameters that the Transformation stylesheet may expect. Use JSON format, eg <pre>{\"indent-elements\":true, \"css-stylesheet\":\"$module_path/xsl/xmlverbatim.css\"}</pre>"),
123 '#default_value' => $settings['xsl_params'],
124 '#element_validate' => array('xsl_formatter_xsl_params_validate'),
125 );
126 $element['debug'] = array(
127 '#title' => t('Show XML parsing warnings'),
128 '#type' => 'checkbox',
129 '#description' => t("Bad XML data input will trigger warnings that may show on screen. Disable this for a public site."),
130 '#default_value' => $settings['debug'],
131 );
132 return $element;
133 }
134
135 /**
136 * Ensure the named path exists. This includes a small search lookup.
137 */
138 function xsl_formatter_xsl_path_validate($element, &$form_state, $form) {
139 try {
140 $xsl_doc = xsl_formatter_get_xml_doc($element['#value']);
141 } catch (Exception $e) {
142 $element_id = join('][', $element['#parents']);
143 form_set_error($element_id, $e->getMessage());
144 }
145 }
146
147 /**
148 * Ensure the params are valid. Checks that the JSON parses into something.
149 */
150 function xsl_formatter_xsl_params_validate($element, &$form_state, $form) {
151 // json_decode doesn't throw many parse errors, so look at the results.
152 $value = trim($element['#value']);
153 $params = json_decode($value);
154 if (!empty($value) && $params == NULL) {
155 // This means some sort of failure
156 $element_id = join('][', $element['#parents']);
157 form_set_error($element_id, 'Failed to parse the JSON. Check your syntax. You must quote all strings with double-quotes.');
158 }
159 }
160
161 /**
162 * If we upload our own xsl, Make sure it gets saved.
163 *
164 * Place it in the public xsl foilder and refer to it.
165 */
166 function xsl_formatter_xsl_upload_validate($element, &$form_state, $form) {
167 // Check for a new uploaded xsl.
168 // Figure out what the big ID was. This is wierd.
169 $upload_field_id = 'files-' . substr($element['#id'], strlen('edit-'));
170
171 // Get it. Temporary at first.
172 $validators = array('file_validate_extensions' => array('xsl','xslt'));
173 $file = file_save_upload($upload_field_id, $validators);
174
175 if (!empty($file)) {
176 // File upload was attempted.
177 if ($file) {
178 $save_dir = "public://xsl";
179 file_prepare_directory($save_dir, FILE_CREATE_DIRECTORY);
180 $save_filepath = $save_dir . '/' . $file->filename;
181 $filename = file_unmanaged_copy($file->uri, $save_filepath, FILE_EXISTS_REPLACE);
182
183 // Set xsl_path to the newly uploaded value.
184 // The #parents array is important.
185 // Find the nearby xsl_path element with the same ancestry as me.
186 $parents = $element['#parents'];
187 array_pop($parents);
188 array_push($parents, 'xsl_path');
189 $xsl_path_element = array('#parents' => $parents);
190 form_set_value($xsl_path_element, $save_filepath, $form_state);
191 }
192 else {
193 // File upload failed.
194 form_set_error('xsl_upload', t('The xsl could not be uploaded.'));
195 }
196 }
197 }
198
199
200 /**
201 * Implements hook_field_formatter_view().
202 *
203 * Does the process here, to generate the result.
204 * Delegates the final layout to the theme func
205 */
206 function xsl_formatter_field_formatter_view($entity_type, $entity, $field, $instance, $langcode, $items, $display) {
207 $element = array();
208 foreach ($items as $delta => $item) {
209
210 $result = "Can't parse the XML input";
211 $data = @$item['value'];
212 $xml_doc = new domdocument;
213
214 // Alternate field types. Local data (valus) is easiest, but
215 // If the field type is a link, go get that data now
216 // DESPERATELY need caching or something here.
217 if ($field['type'] == 'link_field') {
218 $url = url($item['url'], $item);
219 $data = file_get_contents($url);
220 }
221 // Or files, why not?
222 if ($field['type'] == 'file') {
223 dpm($item);
224 # $url = url($item['url'], $item);
225 $data = file_get_contents($item['uri']);
226 }
227
228 try {
229 // Tricky to catch errors.
230 // Do this to toggle debug mode.
231 if (!empty($display['settings']['debug'])) {
232 // Warnings may go to the screen.
233 $xml_doc->loadXML($data);
234 }
235 else {
236 // Suppress warnings.
237 @$xml_doc->loadXML($data);
238 }
239
240 // XML Loaded OK. Now load the stylesheet.
241 $xsl_path = $display['settings']['xsl_path'];
242 $xsl_doc = xsl_formatter_get_xml_doc($xsl_path);
243 // Pass through any params that the XSLT may want.
244 $params = (array)json_decode($display['settings']['xsl_params']);
245 // 'base' can be used for supporting relative css links.
246 $params['base'] = url(dirname($xsl_doc->documenturi));
247 // Transform!
248 $result = xsl_formatter_xmldoc_plus_xsldoc($xml_doc, $xsl_doc, $params);
249 }
250 catch (Exception $e) {
251 throw new Exception("Unable to parse the data. Probably invalid XML.", E_USER_ERROR);
252 }
253
254 $element[$delta] = array(
255 '#theme' => 'xsl_formatter',
256 '#item' => $item,
257 '#settings' => $display['settings'],
258 '#result' => $result,
259 );
260 }
261 return $element;
262 }
263
264
265 /**
266 * Implements hook_theme().
267 *
268 * Advertises our theme function.
269 */
270 function xsl_formatter_theme() {
271 return array(
272 'xsl_formatter' => array(
273 'variables' => array(
274 'item' => NULL,
275 'settings' => NULL,
276 'result' => NULL,
277 ),
278 ),
279 );
280 }
281
282 /**
283 * Returns HTML from passing the input through the XSL process
284 *
285 * @param $variables
286 * An associative array containing:
287 * - item: An array of field data.
288 * - settings: used to do the transform, including the xsl path
289 * - result: the rendered result.
290 *
291 * @ingroup themeable
292 */
293 function theme_xsl_formatter($variables) {
294 // The data is already cooked, just use this theme func to stick a
295 // wrapper around it if you want.
296 return $variables['result'];
297 }
298
299 /**
300 * Return a list of xsl files, as found in the search locations
301 * Over-engineerd for now, anticipating 'module:// as a file scheme
302 * and keeping the UI simpler (?)
303 */
304 function xsl_formatter_enumerate_xsls() {
305 $paths = array('public://xsl', 'module://xsl_formatter/xsl');
306 $found = array();
307 foreach ($paths as $base) {
308 $label = $base;
309
310 // Just me being cute here...
311 if (file_uri_scheme($base) == 'module') {
312 $target_path = file_uri_target($base);
313 $split_path = explode('/', $target_path);
314 $module_name = array_shift($split_path);
315 $base = drupal_get_path('module' , $module_name) .'/'. join('/', $split_path);
316 }
317
318 $files = file_scan_directory($base, '/.*\.xsl[t]?/');
319 foreach ($files as $file) {
320 $found[$file->uri] = $label .'/'. $file->filename;
321 }
322 }
323 return $found;
324 }
325
326 /////////////////////////
327 // XML utilities below.
328 // Ultra-paranoid and layered with pessimism.
329 // Because XML always goes wrong.
330
331 /**
332 * Find and initialize the transformation template.
333 *
334 * LOTS of error checking.
335 *
336 * Allows you to define the path relative to the module, the site,
337 * or the files dir.
338 *
339 * Includes caching retrieval for a bit of speed-up over bulks.
340 * XSL is expensive, so if we find ourselves doing it more than once,
341 * the parsed file is retained for next time.
342 *
343 * Throws an exception if anything goes wrong.
344 *
345 * @return XML Document
346 */
347 function xsl_formatter_get_xml_doc($xml_file, $description = "XML file") {
348 // Check cache first-off.
349 static $xmldocs;
350 if (isset($xmldocs[$xml_file])) {
351 return $xmldocs[$xml_file];
352 }
353 if (empty($xml_file)) {
354 throw new Exception("Null $description? Cannot proceed", E_USER_ERROR);
355 }
356
357 // Check if and where filepath can be found.
358 // Search first under full path, then under files dir, then module dir.
359
360 # TODO - check if this could be used as an attack vector?
361 # Sanitize the fetch path.
362
363 $xml_filepath = $xml_file;
364 if (!is_file($xml_filepath)) {
365 $xml_filepath = 'public://' . $xml_file;
366 }
367 if (!is_file($xml_filepath)) {
368 $xml_filepath = drupal_get_path('module', 'xsl_formatter') . "/$xml_file";
369 }
370
371 if (is_file($xml_filepath)) {
372 #watchdog(__FUNCTION__, "Loading $description from $xml_filepath", array(), WATCHDOG_DEBUG);
373 $xml_doc = new domdocument;
374 if ($xml_doc->load($xml_filepath) ) {
375 // Loaded OK.
376 // .yay.
377 }
378 else {
379 $xmldocs[$xml_file] = FALSE;
380 throw new Exception("Unable to parse the $description '$xml_filepath' ", E_USER_ERROR);
381 }
382
383 $xml_docs[$xml_file] = $xml_doc;
384 }
385 else {
386 $xmldocs[$xml_file] = FALSE;
387 throw new Exception("Unable to locate the $description '$xml_file' ", E_USER_ERROR);
388 }
389
390 // This is required/helpful to support relative includes
391 $xml_doc->documenturi = $xml_filepath;
392
393 return $xml_doc;
394 }
395
396 /**
397 * Do the actual conversion between XML+XSL
398 *
399 * Input and output are full DOM objects in PHP5
400 * We return the result STRING, as that's what
401 * the process gives us :-/
402 * Need to parse it back in again for pipelining.
403 *
404 * Support for PHP4 XSL removed.
405 *
406 * @param domdocument or string $xmldoc
407 *
408 * @param domdocument or string $xsldoc . If it uses includes, the xsl must have
409 * had its documenturi set correctly prior to this, but it can be set in the
410 * parameters also.
411 *
412 * @param array $parameters To be passed into the xslt_process()
413 *
414 * @returns string The result.
415 */
416 function xsl_formatter_xmldoc_plus_xsldoc($xml_doc, $xsl_doc, $parameters = array()) {
417 $xsltproc = new XSLTProcessor;
418
419 // Attach the xsl rules.
420 $xsltproc->importStyleSheet($xsl_doc);
421 // Set any processing parameters and flags.
422 if ($parameters) {
423 foreach ($parameters as $param => $value) {
424 $xsltproc->setParameter("", $param, $value);
425 }
426 }
427
428 $out = $xsltproc->transformToXml($xml_doc);
429
430 if (function_exists('charset_decode_utf_8')) {
431 // I just CAN'T trust XML not to have squashed the entities into bytecodes.
432 // Expand them before returning or I can never trust that my result here
433 // is actually valid to put in anywhere else again.
434 return charset_decode_utf_8($out);
435 }
436
437 return $out;
438 }
439