source: sipes/modules_contrib/workflow_extensions/workflow_extensions.module @ 6e81fb4

stableversion-3.0
Last change on this file since 6e81fb4 was 78ed022, checked in by root <root@…>, 8 años ago

se agrego el modulo para editar los comentarios del flujo del trabajo

  • Propiedad mode establecida a 100755
File size: 24.2 KB
Línea 
1<?php
2
3/**
4 *  @file
5 *  UI-related improvements to the Workflow module and tokens for Rules.
6 *
7 *  1a) Replaces the traditional workflow radio-buttons by either a drop-down or
8 *  single-action buttons featuring context-sensitive labels (using replacement
9 *  tokens) for a more intuitive user experience.
10 *  See also the discussion on intelligent buttons at drupal.org/node/282122.
11 *  All three UI styles (radio, dropw-down or single-action) are available as a
12 *  block.
13 *  1b) Suppresses the workflow schedule transition form if not selected by the
14 *  user.
15 *  2) The module also defines tokens which when used with Rules allow you to
16 *  more easily invoke actions when something did NOT happen for some time.
17 *
18 *  Re 1a)
19 *  Let's say we have a basic workflow with states "draft", "review" and "live".
20 *  Traditionally authors and moderators must select the next state by pressing
21 *  the correct radio-button and clicking submit. Experience from the field
22 *  suggests that not everybody finds this intuitive. Rather than having to
23 *  think in terms of state transitions, users prefer to press a button with a
24 *  an explanatory label that clearly expresses what is going to happen.
25 *  Using this module authors will find on the edit form a couple of clearly
26 *  labeled buttons: "Save as draft, don't submit" and "Submit for publication".
27 *  In old workflow-speak the latter action is represented by radio buttons plus
28 *  a submit button and read less intuitively as: transition workflow state
29 *  from "draft" to "review".
30 *  Similarly, with this module a moderator will see on their edit form buttons
31 *  like "Reject and return to author John" (i.e. "review -> draft") and
32 *  "Publish this" ("review -> live").
33 *  The alternative buttons appear on:
34 *  a) the node edit form (node/%/edit)
35 *  b) the comment edit form, if enabled
36 *  c) the workflow tab, if enabled (node/%/workflow)
37 *
38 *  Re 2)
39 *  This module defines a replacement token [node:workflow-state-age], which
40 *  when used in a scheduled rule set, make it easier to invoke actions when
41 *  a workflow state was NOT changed after a specified elapsed time.
42 *  See drupal.org/project/workflow_extensions for full instructions on
43 *  how to do this using Rules.
44 */
45
46define('WORKFLOW_EXTENSIONS_UI_RADIOS',   1); // the original Workflow style
47define('WORKFLOW_EXTENSIONS_UI_BUTTONS',  2); // single-action buttons
48define('WORKFLOW_EXTENSIONS_UI_DROPDOWN', 3); // dropdown selector + update
49
50/**
51 * Implementation of hook_perm().
52 */
53function workflow_extensions_perm() {
54  return array(
55    'change workflow state via node edit form',
56    'edit workflow log',
57    'view workflow state change block even when state cannot be changed');
58}
59
60/**
61 * Implementation of hook_menu().
62 */
63function workflow_extensions_menu() {
64  $items = array();
65  $items['workflow-log/%workflow_state_transition_record'] = array( // maps to function workflow_state_transition_record_load()
66    'title' => 'Edit workflow log comment',
67    'description' => 'Edit workflow state transition comment.',
68    'page callback' => 'drupal_get_form',
69    'page arguments' => array('workflow_extensions_workflow_comment_edit_form', 1),
70    'access arguments' => array('edit workflow log')
71  );
72  $items['admin/settings/workflow_extensions'] = array(
73    'title' => 'Workflow extensions',
74    'description' => 'Configure workflow form style (buttons and labels).',
75    'page callback' => 'drupal_get_form',
76    'page arguments' => array('workflow_extensions_admin_configure'),
77    'access arguments' => array('administer site configuration'),
78    'file' => 'workflow_extensions.admin.inc'
79  );
80  return $items;
81}
82
83/**
84 * Load function belonging to the above menu option 'workflow-log/%workflow-state-transition-record'.
85 * Maps to this function just like 'node/%node' maps to node_load().
86 *
87 * @param $hid
88 *   The ID of the workflow state transition record to load.
89 * @return
90 *   object representing one row from the {workflow_node_history} table
91 */
92function workflow_state_transition_record_load($hid) {
93  $workflow_state_transition_record = db_fetch_object(db_query('SELECT * FROM {workflow_node_history} WHERE hid = %d', $hid));
94  return $workflow_state_transition_record;
95}
96
97/**
98 * Display a text area populated with the selected workflow log comment and
99 * allow the user to modify and save it.
100 */
101function workflow_extensions_workflow_comment_edit_form($form_state, $workflow_state_transition_record) {
102  $form = array();
103  $form['hid'] = array('#type' => 'value', '#value' => $workflow_state_transition_record->hid);
104  $form['nid'] = array('#type' => 'value', '#value' => $workflow_state_transition_record->nid);
105  $form['workflow']['workflow_comment'] = array(
106    '#type' => 'textarea',
107    '#title' => t('Comment'),
108    '#description' => t('Modify this workflow state transition comment and press submit.'),
109    '#default_value' => $workflow_state_transition_record->comment,
110    '#rows' => 2,
111  );
112  $form['submit'] = array(
113    '#type' => 'submit',
114    '#value' => t('Submit')
115  );
116  return $form;
117}
118
119/**
120 * Submit handler for the workflow transition comment edit form.
121 *
122 * @see workflow_extensions_workflow_comment_edit_form()
123 */
124function workflow_extensions_workflow_comment_edit_form_submit($form, &$form_state) {
125  $hid = $form_state['values']['hid'];
126  $comment = $form_state['values']['workflow_comment'];
127
128  db_query("UPDATE {workflow_node_history} SET comment = '%s' WHERE hid = %d", $comment, $hid);
129
130  $nid = $form_state['values']['nid'];
131
132  // Whatever is set here, is overriden by the "?destination=..." parameter, if present
133  $form_state['redirect'] = module_exists('views') && views_get_view('workflow_history')
134    ? "workflow-history/$nid"
135    : (workflow_node_tab_access(node_load($nid)) ? "node/$nid/workflow" : "node/$nid");
136}
137
138/**
139 * Implementation of hook_form_alter().
140 *
141 * 1) Remove the Workflow radio buttons and replace each state transition by a
142 * submit button with a configurable, explanatory label.
143 * To allow saving of edits to the node without a state transition, display
144 * an additional button "Save, don't submit" (or similar).
145 * 2) If on the create/edit form suppress the workflow fieldset if the user
146 * does not have the relevant permission
147 * 3) If the schedule state transition form is enabled, suppress it until the
148 * user clicks the radio button to reveal it.
149 * 4) Add an (extra) form validator
150 */
151function workflow_extensions_form_alter(&$form, &$form_state, $form_id) {
152
153  // If there's no Workflow fieldset, then we have nothing further to do.
154  if (!isset($form['#wf'])) { // was $form['workflow'] but need to exclude content type edit form
155    return;
156  }
157
158  // 4) Add form validator
159  if (empty($form['#validate'])) {
160    $form['#validate'] = array();
161  }
162  elseif (!is_array($form['#validate'])) {
163    $form['#validate'] = array($form['#validate']);
164  }
165  $form['#validate'][] = 'workflow_extensions_workflow_form_validate';
166
167  // 2) Suppress Workflow fieldset on the create/edit form if not permitted.
168  if (isset($form['#id']) && $form['#id'] == 'node-form' && !user_access('change workflow state via node edit form')) {
169    unset($form['workflow']);
170    return;
171  }
172
173  // The next if-statement implements point 3) above
174  if (variable_get('workflow_extensions_display_schedule_toggle', TRUE)) {
175    $path = drupal_get_path('module', 'workflow_extensions');
176    drupal_add_js($path . '/workflow_schedule.js', 'module');
177  }
178
179  // The rest of this function implements point 1)
180  $workflow_name = workflow_extensions_extract_workflow_name($form);
181  $workflow_radios = $form['workflow'][$workflow_name];
182
183  if (is_array($workflow_radios) && isset($workflow_radios['#options'])) {
184    // Use the form to work out the potential state transitions for this user.
185    $states = $workflow_radios['#options'];
186    // $states will be empty when creating node
187    if (!empty($states)) {
188
189      $title = variable_get('workflow_extensions_change_state_form_title', '');
190      if (!empty($title)) {
191        if (trim($title) == '<none>') {
192          unset($form['workflow'][$workflow_name]['#title']);
193        }
194        else {
195          $form['workflow'][$workflow_name]['#title'] = workflow_extensions_replace_tokens_raw($title);
196        }
197      }
198
199      if (empty($form['#submit'])) {
200        // This seems to happen as a side-effect of using hook_forms(), i.e
201        // the case where workflow_extensions_change_state_form() is called,
202        // for instance when the state change form appears in a block or View.
203        // In the latter case, we also create ourselves an option to redirect
204        // to page different from the default (which is node/%), as set in
205        // workflow_tab_form_submit().
206        $form['#submit'] = array('workflow_tab_form_submit', 'workflow_extensions_form_redirect');
207      }
208
209      switch (variable_get('workflow_extensions_ui_style', WORKFLOW_EXTENSIONS_UI_BUTTONS)) {
210
211        case WORKFLOW_EXTENSIONS_UI_BUTTONS:
212          if (count($states) > 1) {
213            _workflow_extensions_replace_with_buttons($form, $workflow_name);
214          }
215          break;
216
217        case WORKFLOW_EXTENSIONS_UI_DROPDOWN:
218          _workflow_extensions_replace_with_dropdown($form, $workflow_name);
219          // no break;
220
221        default: // radios
222          if (isset($form['submit'])) {
223            $submit_label = variable_get('workflow_extensions_change_state_button_label', '');
224            if (!empty($submit_label)) {
225              $form['submit']['#value'] = workflow_extensions_replace_tokens_raw($submit_label);
226            }
227          }
228          break;
229      }
230    }
231  }
232}
233
234/**
235 * Implementation of hook_forms();
236 *
237 * Called as a result of the fact that there are no form handlers for the
238 * unique form_id's generated in workflow_extensions_change_state_form().
239 * Here we map these form_id's back to the same 'workflow_tab_form'. This
240 * allows us to have multiple copies of the same form on the same page.
241 * Note: first of the args is typically the node object.
242 */
243function workflow_extensions_forms($form_id, $args) {
244  if (strpos($form_id, 'workflow_tab_form_nid') === 0) {
245    $form = array(
246      $form_id => array(
247        'callback' => 'workflow_tab_form',
248        'callback arguments' => array()
249      )
250    );
251    return $form;
252  }
253}
254
255/**
256 * Validate the workflow form, in particular the state transition comment and
257 * the scheduled state transition time (the date is a drop-down so requires no
258 * validation).
259 */
260function workflow_extensions_workflow_form_validate($form, &$form_state) {
261  $nid = substr($form['#id'], strrpos($form['#id'], '-') + 1);
262  $title = isset($form_state['values']['title']) ? $form_state['values']['title'] : $form_state['values']['node']->title;
263
264  if (!empty($form_state['values']['workflow_scheduled_hour'])) {
265    if (!strtotime($form_state['values']['workflow_scheduled_hour'])) {
266      form_set_error(is_numeric($nid) ? "workflow_scheduled_hour_$nid" : 'workflow_scheduled_hour', t('%title: scheduled time is not in the correct format.', array('%title' => $title)));
267    }
268  }
269  if (!variable_get('workflow_extensions_allow_blank_comments', TRUE)) {
270    if (!isset($form_state['values']['nid']) && !isset($form_state['values']['node'])) {
271      // Bypass node creation
272      return;
273    }
274    $comment = $form_state['values']['workflow_comment'];
275    if (!trim($comment)) {
276      form_set_error(is_numeric($nid) ? "workflow_comment_$nid" : 'workflow_comment', t('%title: please enter a non-blank comment for the workflow log.', array('%title' => $title)));
277    }
278  }
279}
280
281/**
282 * Handler for the single-action submit buttons on the edit form.
283 * Does NOT get called when radio buttons or drop-down are used.
284 */
285function workflow_extensions_form_submit($form, &$form_state) {
286  // In the original form_submit handler that we pass control to next, the
287  // selected workflow state is taken from form_state['values']['workflow']. So
288  // that's the entry we need to set here in accordance with the clicked button.
289  // See node_form_submit() -> node_submit() for the edit form.
290  // See workflow_tab_form_submit() for the Workflow tab.
291  $form_state['values']['workflow'] = $form_state['clicked_button']['#to_state'];
292  // ... now proceed to next handler, e.g. node_form_submit for a normal save
293}
294
295/**
296 * If the workflow state was not transitioned via either the node edit form or
297 * the Workflow tab, then redirect to a configurable page, e.g. some View.
298 */
299function workflow_extensions_form_redirect($form, &$form_state) {
300  if (arg(0) != 'node') {
301    $path = variable_get('workflow_extensions_redirect_page', ''); // @todo: make this a UI config option
302    $form_state['redirect'] = empty($path) ? $_GET['q'] : $path;   // @todo: save query-string too (Views pager)
303  }
304}
305
306function _workflow_extensions_replace_with_buttons(&$form, $workflow_name) {
307  $current_sid = $form['workflow'][$workflow_name]['#default_value'];
308  if (function_exists('workflow_get_state_name')) {
309    $current_state_name = workflow_get_state_name($current_sid);
310  }
311  else {
312    $current_state = workflow_get_state($current_sid);
313    $current_state_name = $current_state['state'];
314  }
315  // We need a node-context for token replacement. When on the Workflow tab
316  // form, the node object will already have been loaded on the form.
317  // When creating content (node/add/<type>) we only have limited data. In
318  // the remaining cases we load the node from the cache based on the nid
319  // found on the form.
320  $form_id = $form['form_id']['#value'];
321  if (strpos($form_id, 'workflow_tab_form') === 0) {
322    $node = $form['node']['#value'];
323  }
324  elseif (is_numeric($nid = $form['nid']['#value'])) {
325    $node = node_load($nid);
326  }
327  else { // Creating new content, nid not yet known
328    $node = $form['#node'];
329  }
330  $states = $form['workflow'][$workflow_name]['#options'];
331  $submit_handlers = _workflow_extensions_assign_handlers($form);
332  foreach ($states as $sid => $to_state_name) {
333    if ($sid != $current_sid) {
334      // Create button for transition from current_sid to destination state.
335      $button = array();
336      $button['#value'] = workflow_extensions_get_transition_label($form['#wf']->wid, $current_state_name, workflow_get_state_name($sid), $node);
337      $button['#type'] = 'submit';
338      $button['#to_state'] = $sid;
339      if (isset($form['buttons']['submit']['#weight'])) { // node form
340        $button['#weight'] = $form['buttons']['submit']['#weight'] + 1;
341      }
342      elseif (isset($form['submit']['#weight'])) { // comment form
343        $button['#weight'] = $form['submit']['#weight'];
344      }
345      $button['#submit'] = $submit_handlers;
346      $form['buttons']["submit_to_$to_state_name"] = $button;
347    }
348  }
349  // Get rid of workflow radio buttons that live inside the fieldset
350  unset($form['workflow'][$workflow_name]);
351  // If after this the fieldset is empty, remove it altogher
352  if (!isset($form['workflow']['workflow_scheduled']) &&
353    (!isset($form['workflow']['workflow_comment']) || $form['workflow']['workflow_comment']['#type'] == 'hidden')) {
354    unset($form['workflow']);
355  }
356  // With the existing Save button now impotent to submit a workflow
357  // transition, we can re-purpose it for saving all other edits to the
358  // node without changing the workflow state.
359  // This does not make sense for the Workflow tab form though, as there is
360  // nothing to save but a state change. In this case we simply remove the
361  // Save button.
362  if ($form_id == 'comment_form') {
363    $form['buttons']['submit'] = $form['submit'];
364    $form['buttons']['submit']['#submit'] = $form['#submit'];
365    $form['buttons']['submit']['#weight']--; // left-most
366  }
367  if (strpos($form_id, 'workflow_tab_form') !== 0 && ($label = variable_get('workflow_extensions_default_save_button_label', ''))) {
368    $form['buttons']['submit']['#value'] = workflow_extensions_replace_state_name_tokens($label, $current_state_name);
369  }
370  unset($form['submit']);  // the button
371//unset($form['#submit']); // don't remove handler, [#1097328]
372}
373
374function _workflow_extensions_replace_with_dropdown(&$form, $workflow_name) {
375  $form['workflow'][$workflow_name]['#type'] = 'select';
376  $form['workflow'][$workflow_name]['#name'] = 'workflow';
377}
378
379/**
380 * Implementation of hook_block().
381 */
382function workflow_extensions_block($op = 'list', $delta = 0) {
383  if (!module_exists('workflow')) {
384    return;
385  }
386  if ($op == 'list') {
387    $block[0]['info'] = t('Workflow state change form');
388    return $block;
389  }
390  elseif ($op == 'view' && arg(0) == 'node') {
391    $node = node_load(arg(1));
392    $block['content'] = workflow_extensions_change_state_form($node);
393    $block['subject'] = '';
394    return $block;
395  }
396}
397
398/**
399 * Use this function in a code-snippet to output a workflow state change form
400 * on any page. May be used multiple times on the same page (for different
401 * nodes), e.g. using the 'Views PHP' module, as a unique form_id is generated
402 * for each occurrence of the form.
403 *
404 * @param $node
405 */
406function workflow_extensions_change_state_form($node) {
407
408  if (!$node || !($wid = workflow_get_workflow_for_type($node->type))) {
409    return '';
410  }
411
412  $choices = workflow_field_choices($node);
413  if (count($choices) == 1) {
414    if (user_access('view workflow state change block even when state cannot be changed')) {
415      // Generate single-option form without Submit button
416      return drupal_get_form('workflow_extensions_single_state_form', workflow_get_name($wid), $choices);
417    }
418    return ''; // not allowed to view state
419  }
420
421  $result = db_query("SELECT sid, state FROM {workflow_states} WHERE status = 1 ORDER BY sid");
422  while ($row = db_fetch_object($result)) {
423    $workflow_states[$row->sid] = check_plain(t($row->state));
424  }
425  $sid_current = workflow_node_current_state($node);
426  require_once drupal_get_path('module', 'workflow') . '/workflow.pages.inc';
427  $output = drupal_get_form('workflow_tab_form_nid_'. $node->nid, $node, $wid, $workflow_states, $sid_current);
428  return $output;
429}
430
431/**
432 * Form used to display the current state, without a submit button to change it.
433 * @param $form_state
434 * @param $workflow_name
435 * @param $current_state, an array of one element, indexed by its sid
436 */
437function workflow_extensions_single_state_form($form_state, $workflow_name, $current_state) {
438  $sids = array_keys($current_state);
439  $form['workflow']['#title'] = $workflow_name;
440  $form['workflow'][$workflow_name] = array(
441    '#type' => 'radios', // may be overridden by workflow_extensions_form_alter()
442    '#options' => $current_state,
443    '#default_value' => $sids[0]
444  );
445  return $form;
446}
447
448function workflow_extensions_extract_workflow_name($form) {
449  // Current and allowed next states for this user and node live in
450  // $form['workflow'][$workflow_name]['#options].
451  // At the time of writing, the workflow.module (6.x.1-4) contained a bug that
452  // resulted in the $workflow_name being passed as blank. Luckily we can work
453  // around this and also be compatible with later versions that don't have the
454  // bug.
455  if (isset($form['workflow'][''])) {
456    return '';
457  }
458  return isset($form['#wf']->name) ? $form['#wf']->name : $form['workflow']['#title'];
459}
460
461function _workflow_extensions_assign_handlers($form) {
462  $original_handlers = $form['#submit']; // e.g. 'workflow_tab_form_submit' or 'menu_node_form_submit'
463  return ($form['#id'] == 'node-form') // 'menu_node_form_submit', 'upload_node_form_submit'
464    ? array('workflow_extensions_form_submit', 'node_form_submit') // node_form_submit() will add original handlers
465    : array_merge(array('workflow_extensions_form_submit'), $original_handlers);
466
467  // [#1346078] ?
468  //$original_handlers = array_merge($original_handlers, $form['buttons']['submit']['#submit']);
469  //  return array_merge(array('workflow_extensions_form_submit'), $original_handlers);
470}
471
472/**
473 * Implementation of hook_token_list().
474 *
475 * Note: [workflow-new-state-name] is in fact a pseudo-token, but the user
476 * doesn't have to know that!
477 */
478function workflow_extensions_token_list($context = 'all') {
479  if (module_exists('workflow') && in_array($context, array('workflow', 'node', 'all'))) {
480    $tokens['workflow']['workflow-new-state-name'] = 'New state of content';
481    $tokens['workflow']['workflow-state-age'] = 'Seconds elapsed since last state change';
482  }
483  if ($context == 'node'|| $context == 'all') {
484    $tokens['node']['mod-since-seconds'] = 'Seconds elapsed since last modification';
485  }
486  return $tokens;
487}
488
489/**
490 * Implementation of hook_token_values().
491 *
492 * Returning [workflow-state-age] for both node and workflow contexts as there
493 * seems to be an issue with using Workflow state as the data type argument in
494 * a Rule set. Such a Rule set won't show as available in a scheduled triggered
495 * rule. The Content (ie node) data type must be used instead.
496 */
497function workflow_extensions_token_values($context, $object = NULL) {
498  $values = array();
499  switch ($context) {
500    case 'node':
501    case 'workflow':
502      if (isset($object)) {
503        $node = (object)$object;
504        if (module_exists('workflow')) {
505          $stamp = db_result(db_query_range("SELECT stamp FROM {workflow_node_history} WHERE nid = %d ORDER BY stamp DESC", $node->nid, 0, 1));
506          $values['workflow-state-age'] = $stamp ? (time() - $stamp) : 0;
507        }
508        $values['mod-since-seconds'] = $node->changed ? (time() - $node->changed) : 0;
509      }
510      break;
511  }
512  return $values;
513}
514
515/**
516 * Return the name for the workflow transition identified by the supplied
517 * from-state and to-state names.
518 *
519 * @param int $wid, workflow identifier, maybe NULL (but then the combination
520 *  of $from_state_name and $to_state_name must be unique across all workflows)
521 * @param string $from_state_name
522 * @param string $to_state_name
523 * @param object $node, context for token replacement; if omitted an attempt
524 *   will be made to load the node based on the nid in the URL. This will fail
525 *   when creating new content, in which case a partial node must be supplied.
526 */
527function workflow_extensions_get_transition_label($wid, $from_state_name, $to_state_name, $node = NULL) {
528  if (module_exists('workflow_named_transitions')) {
529    $transitions = workflow_named_transitions_get_transitions($wid);
530    foreach ($transitions as $transition) {
531      if ($transition['from_state'] == $from_state_name && $transition['to_state'] == $to_state_name) {
532        return workflow_extensions_replace_state_name_tokens($transition['label'], $to_state_name, $node);
533      }
534    }
535    // No label defined, fall through as if module 'workflow_named_transitions'
536    // wasn't installed
537  }
538  $tokenized_label = variable_get('workflow_extensions_change_state_button_label', '');
539  // Don't think we need to check_markup(). Only users with 'administer site
540  // configuration' permission can set the label pattern, so it's up to them to
541  // use or not use HTML and/or javascript.
542  return workflow_extensions_replace_state_name_tokens($tokenized_label, $to_state_name, $node);
543}
544
545function workflow_extensions_replace_state_name_tokens($tokenized_label, $to_state_name = NULL, $node = NULL) {
546  if (empty($tokenized_label)) {
547    return t('Move to "@state_name"', array('@state_name' => $to_state_name));
548  }
549  $label = workflow_extensions_replace_tokens_raw($tokenized_label, $node);
550  if (!empty($to_state_name)) {
551    // Once the real tokens have been replaced, replace the pseudo-token
552    // [workflow-new-state-name]
553    $label = str_replace('[workflow-new-state-name]', $to_state_name, $label);
554  }
555  return $label;
556}
557
558function workflow_extensions_replace_tokens_raw($tokenized_label, $node = NULL) {
559  if (module_exists('token')) {
560    global $user;
561    $objects['global'] = NULL;
562    $objects['user'] = $user;
563    if ($node == NULL && arg(0) == 'node' && is_numeric(arg(1))) {
564      $node = node_load(arg(1)); // node/%
565    }
566    $objects['node'] = $objects['workflow'] = $node;
567    return token_replace_multiple($tokenized_label, $objects);
568  }
569  return $tokenized_label;
570}
571
572/**
573 * Implementation of hook_views_api().
574 */
575function workflow_extensions_views_api() {
576  return array(
577    'api' => views_api_version(),
578    'path' => drupal_get_path('module', 'workflow_extensions') .'/views'
579  );
580}
Nota: Vea TracBrowser para ayuda de uso del navegador del repositorio.