Projects
Kolab:3.4
roundcubemail-plugins-kolab
roundcubemail-plugins-kolab-3.1.6-task-enhancem...
Log In
Username
Password
Overview
Repositories
Revisions
Requests
Users
Attributes
Meta
File roundcubemail-plugins-kolab-3.1.6-task-enhancements.patch of Package roundcubemail-plugins-kolab (Revision 25)
Currently displaying revision
25
,
Show latest
diff --git a/plugins/calendar/calendar.php b/plugins/calendar/calendar.php index f038caf..685745d 100644 --- a/plugins/calendar/calendar.php +++ b/plugins/calendar/calendar.php @@ -126,6 +126,7 @@ class calendar extends rcube_plugin $this->register_action('mailtoevent', array($this, 'mail_message2event')); $this->register_action('inlineui', array($this, 'get_inline_ui')); $this->register_action('check-recent', array($this, 'check_recent')); + $this->add_hook('refresh', array($this, 'refresh')); // remove undo information... if ($undo = $_SESSION['calendar_event_undo']) { @@ -162,6 +163,8 @@ class calendar extends rcube_plugin 'innerclass' => 'icon calendar', ))), 'messagemenu'); + + $this->api->output->add_label('calendar.createfrommail'); } } @@ -919,6 +922,38 @@ class calendar extends rcube_plugin } /** + * Handler for keep-alive requests + * This will check for updated data in active calendars and sync them to the client + */ + public function refresh($attr) + { + // refresh the entire calendar every 10th time to also sync deleted events + $refetch = rand(0,10) == 10; + + foreach ($this->driver->list_calendars(true) as $cal) { + if ($refetch) { + $this->rc->output->command('plugin.refresh_calendar', + array('source' => $cal['id'], 'refetch' => true)); + } + else { + $events = $this->driver->load_events( + get_input_value('start', RCUBE_INPUT_GET), + get_input_value('end', RCUBE_INPUT_GET), + get_input_value('q', RCUBE_INPUT_GET), + $cal['id'], + 1, + $attr['last'] + ); + + foreach ($events as $event) { + $this->rc->output->command('plugin.refresh_calendar', + array('source' => $cal['id'], 'update' => $this->_client_event($event))); + } + } + } + } + + /** * Handler for pending_alarms plugin hook triggered by the calendar module on keep-alive requests. * This will check for pending notifications and pass them to the client */ @@ -975,10 +1010,18 @@ class calendar extends rcube_plugin if (!$err && $_FILES['_data']['tmp_name']) { $calendar = get_input_value('calendar', RCUBE_INPUT_GPC); - $events = $this->get_ical()->import_from_file($_FILES['_data']['tmp_name']); - - $count = $errors = 0; $rangestart = $_REQUEST['_range'] ? date_create("now -" . intval($_REQUEST['_range']) . " months") : 0; + $count = $errors = 0; + + try { + $events = $this->get_ical()->import_from_file($_FILES['_data']['tmp_name'], 'UTF-8', true); + } + catch (Exception $e) { + $errors = 1; + $msg = $e->getMessage(); + $events = array(); + } + foreach ($events as $event) { // TODO: correctly handle recurring events which start before $rangestart if ($event['end'] < $rangestart && (!$event['recurrence'] || ($event['recurrence']['until'] && $event['recurrence']['until'] < $rangestart))) @@ -1000,8 +1043,9 @@ class calendar extends rcube_plugin $this->rc->output->command('display_message', $this->gettext('importnone'), 'notice'); $this->rc->output->command('plugin.import_success', array('source' => $calendar)); } - else - $this->rc->output->command('display_message', $this->gettext('importerror'), 'error'); + else { + $this->rc->output->command('plugin.import_error', array('message' => $this->gettext('importerror') . ($msg ? ': ' . $msg : ''))); + } } else { if ($err == UPLOAD_ERR_INI_SIZE || $err == UPLOAD_ERR_FORM_SIZE) { @@ -1012,7 +1056,7 @@ class calendar extends rcube_plugin $msg = rcube_label('fileuploaderror'); } - $this->rc->output->command('display_message', $msg, 'error'); + $this->rc->output->command('plugin.import_error', array('message' => $msg)); $this->rc->output->command('plugin.unlock_saving', false); } diff --git a/plugins/calendar/calendar_base.js b/plugins/calendar/calendar_base.js index c60c89a..33fe9e4 100644 --- a/plugins/calendar/calendar_base.js +++ b/plugins/calendar/calendar_base.js @@ -6,7 +6,7 @@ * @author Thomas Bruederli <bruederli@kolabsys.com> * * Copyright (C) 2010, Lazlo Westerhof <hello@lazlo.me> - * Copyright (C) 2012, Kolab Systems AG <contact@kolabsys.com> + * Copyright (C) 2013, Kolab Systems AG <contact@kolabsys.com> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -36,10 +36,9 @@ function rcube_calendar(settings) var me = this; // create new event from current mail message - this.create_from_mail = function() + this.create_from_mail = function(uid) { - var uid; - if ((uid = rcmail.get_single_uid())) { + if (uid || (uid = rcmail.get_single_uid())) { // load calendar UI (scripts and edit dialog template) if (!this.ui_loaded) { $.when( @@ -53,7 +52,7 @@ function rcube_calendar(settings) me.ui_loaded = true; me.ui = new rcube_calendar_ui(me.settings); - me.create_from_mail(); // start over + me.create_from_mail(uid); // start over }); return; } @@ -156,9 +155,6 @@ window.rcmail && rcmail.addEventListener('init', function(evt) { // register create-from-mail command to message_commands array if (rcmail.env.task == 'mail') { - // place link above 'view source' - $('#messagemenu a.calendarlink').parent().insertBefore($('#messagemenu a.sourcelink').parent()); - rcmail.register_command('calendar-create-from-mail', function() { cal.create_from_mail() }); rcmail.addEventListener('plugin.mail2event_dialog', function(p){ cal.mail2event_dialog(p) }); rcmail.addEventListener('plugin.unlock_saving', function(p){ cal.ui && cal.ui.unlock_saving(); }); @@ -169,6 +165,15 @@ window.rcmail && rcmail.addEventListener('init', function(evt) { } else rcmail.enable_command('calendar-create-from-mail', true); + + // add contextmenu item + if (window.rcm_contextmenu_register_command) { + rcm_contextmenu_register_command( + 'calendar-create-from-mail', + function(cmd,el){ cal.create_from_mail() }, + 'calendar.createfrommail', + 'moveto'); + } } } diff --git a/plugins/calendar/calendar_ui.js b/plugins/calendar/calendar_ui.js index 986113f..d5a4308 100644 --- a/plugins/calendar/calendar_ui.js +++ b/plugins/calendar/calendar_ui.js @@ -1981,10 +1981,17 @@ function rcube_calendar_ui(settings) if (form && form.elements._data.value) { rcmail.async_upload_form(form, 'import_events', function(e) { rcmail.set_busy(false, null, me.saving_lock); + $('.ui-dialog-buttonpane button', $dialog.parent()).button('enable'); + + // display error message if no sophisticated response from server arrived (e.g. iframe load error) + if (me.import_succeeded === null) + rcmail.display_message(rcmail.get_label('importerror', 'calendar'), 'error'); }); // display upload indicator + me.import_succeeded = null; me.saving_lock = rcmail.set_busy(true, 'uploading'); + $('.ui-dialog-buttonpane button', $dialog.parent()).button('disable'); } }; @@ -1999,6 +2006,7 @@ function rcube_calendar_ui(settings) closeOnEscape: false, title: rcmail.gettext('importevents', 'calendar'), close: function() { + $('.ui-dialog-buttonpane button', $dialog.parent()).button('enable'); $dialog.dialog("destroy").hide(); }, buttons: buttons, @@ -2010,6 +2018,7 @@ function rcube_calendar_ui(settings) // callback from server if import succeeded this.import_success = function(p) { + this.import_succeeded = true; $("#eventsimport:ui-dialog").dialog('close'); rcmail.set_busy(false, null, me.saving_lock); rcmail.gui_objects.importform.reset(); @@ -2018,6 +2027,13 @@ function rcube_calendar_ui(settings) this.refresh(p); }; + // callback from server to report errors on import + this.import_error = function(p) + { + this.import_succeeded = false; + rcmail.display_message(p.message || rcmail.get_label('importerror', 'calendar'), 'error'); + } + // show URL of the given calendar in a dialog box this.showurl = function(calendar) { @@ -2087,6 +2103,20 @@ function rcube_calendar_ui(settings) fc.fullCalendar('removeEvents', function(e){ return e.temp; }); }; + // modify query parameters for refresh requests + this.before_refresh = function(query) + { + var view = fc.fullCalendar('getView'); + + query.start = date2unixtime(view.visStart); + query.end = date2unixtime(view.visEnd); + + if (this.search_query) + query.q = this.search_query; + + return query; + }; + /*** event searching ***/ @@ -2780,6 +2810,8 @@ window.rcmail && rcmail.addEventListener('init', function(evt) { rcmail.addEventListener('plugin.unlock_saving', function(p){ cal.unlock_saving(); }); rcmail.addEventListener('plugin.refresh_calendar', function(p){ cal.refresh(p); }); rcmail.addEventListener('plugin.import_success', function(p){ cal.import_success(p); }); + rcmail.addEventListener('plugin.import_error', function(p){ cal.import_error(p); }); + rcmail.addEventListener('requestrefresh', function(q){ return cal.before_refresh(q); }); // let's go var cal = new rcube_calendar_ui($.extend(rcmail.env.calendar_settings, rcmail.env.libcal_settings)); diff --git a/plugins/calendar/drivers/calendar_driver.php b/plugins/calendar/drivers/calendar_driver.php index 52de901..c09d8b9 100644 --- a/plugins/calendar/drivers/calendar_driver.php +++ b/plugins/calendar/drivers/calendar_driver.php @@ -236,9 +236,11 @@ abstract class calendar_driver * @param integer Event's new end (unix timestamp) * @param string Search query (optional) * @param mixed List of calendar IDs to load events from (either as array or comma-separated string) + * @param boolean Include virtual/recurring events (optional) + * @param integer Only list events modified since this time (unix timestamp) * @return array A list of event objects (see header of this file for struct of an event) */ - abstract function load_events($start, $end, $query = null, $calendars = null); + abstract function load_events($start, $end, $query = null, $calendars = null, $virtual = 1, $modifiedsince = null); /** * Get a list of pending alarms to be displayed to the user diff --git a/plugins/calendar/drivers/database/database_driver.php b/plugins/calendar/drivers/database/database_driver.php index 8cd363c..a2cb903 100644 --- a/plugins/calendar/drivers/database/database_driver.php +++ b/plugins/calendar/drivers/database/database_driver.php @@ -724,7 +724,7 @@ class database_driver extends calendar_driver * * @see calendar_driver::load_events() */ - public function load_events($start, $end, $query = null, $calendars = null) + public function load_events($start, $end, $query = null, $calendars = null, $virtual = 1, $modifiedsince = null) { if (empty($calendars)) $calendars = array_keys($this->calendars); @@ -742,6 +742,12 @@ class database_driver extends calendar_driver $sql_add = 'AND (' . join(' OR ', $sql_query) . ')'; } + if (!$virtual) + $sql_arr .= ' AND e.recurrence_id = 0'; + + if ($modifiedsince) + $sql_add .= ' AND e.changed >= ' . $this->rc->db->quote(date('Y-m-d H:i:s', $modifiedsince)); + $events = array(); if (!empty($calendar_ids)) { $result = $this->rc->db->query(sprintf( diff --git a/plugins/calendar/drivers/kolab/kolab_driver.php b/plugins/calendar/drivers/kolab/kolab_driver.php index b8171b7..0722d23 100644 --- a/plugins/calendar/drivers/kolab/kolab_driver.php +++ b/plugins/calendar/drivers/kolab/kolab_driver.php @@ -733,20 +733,25 @@ class kolab_driver extends calendar_driver * @param integer Event's new end (unix timestamp) * @param string Search query (optional) * @param mixed List of calendar IDs to load events from (either as array or comma-separated string) - * @param boolean Strip virtual events (optional) + * @param boolean Include virtual events (optional) + * @param integer Only list events modified since this time (unix timestamp) * @return array A list of event records */ - public function load_events($start, $end, $search = null, $calendars = null, $virtual = 1) + public function load_events($start, $end, $search = null, $calendars = null, $virtual = 1, $modifiedsince = null) { if ($calendars && is_string($calendars)) $calendars = explode(',', $calendars); + $query = array(); + if ($modifiedsince) + $query[] = array('changed', '>=', $modifiedsince); + $events = $categories = array(); foreach (array_keys($this->calendars) as $cid) { if ($calendars && !in_array($cid, $calendars)) continue; - $events = array_merge($events, $this->calendars[$cid]->list_events($start, $end, $search, $virtual)); + $events = array_merge($events, $this->calendars[$cid]->list_events($start, $end, $search, $virtual, $query)); $categories += $this->calendars[$cid]->categories; } diff --git a/plugins/tasklist/drivers/database/tasklist_database_driver.php b/plugins/tasklist/drivers/database/tasklist_database_driver.php index 8ad776a..3c5ad38 100644 --- a/plugins/tasklist/drivers/database/tasklist_database_driver.php +++ b/plugins/tasklist/drivers/database/tasklist_database_driver.php @@ -286,7 +286,7 @@ class tasklist_database_driver extends tasklist_driver if ($filter['mask'] & tasklist::FILTER_MASK_COMPLETE) $sql_add .= ' AND complete=1'; - else // don't show complete tasks by default + else if (empty($filter['since'])) // don't show complete tasks by default $sql_add .= ' AND complete<1'; if ($filter['mask'] & tasklist::FILTER_MASK_FLAGGED) @@ -301,6 +301,10 @@ class tasklist_database_driver extends tasklist_driver $sql_add = 'AND (' . join(' OR ', $sql_query) . ')'; } + if ($filter['since'] && is_numeric($filter['since'])) { + $sql_add .= ' AND changed >= ' . $this->rc->db->quote(date('Y-m-d H:i:s', $filter['since'])); + } + $tasks = array(); if (!empty($list_ids)) { $result = $this->rc->db->query(sprintf( diff --git a/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php b/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php index c630a41..fd3e5c3 100644 --- a/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php +++ b/plugins/tasklist/drivers/kolab/tasklist_kolab_driver.php @@ -371,7 +371,7 @@ class tasklist_kolab_driver extends tasklist_driver $query = array(); if ($filter['mask'] & tasklist::FILTER_MASK_COMPLETE) $query[] = array('tags','~','x-complete'); - else + else if (empty($filter['since'])) $query[] = array('tags','!~','x-complete'); // full text search (only works with cache enabled) @@ -382,6 +382,10 @@ class tasklist_kolab_driver extends tasklist_driver } } + if ($filter['since']) { + $query[] = array('changed', '>=', $filter['since']); + } + foreach ($lists as $list_id) { $folder = $this->folders[$list_id]; foreach ((array)$folder->select($query) as $record) { diff --git a/plugins/tasklist/localization/en_US.inc b/plugins/tasklist/localization/en_US.inc index 57d6c3c..ba331be 100644 --- a/plugins/tasklist/localization/en_US.inc +++ b/plugins/tasklist/localization/en_US.inc @@ -51,6 +51,8 @@ $labels['listactions'] = 'List options...'; $labels['listname'] = 'Name'; $labels['showalarms'] = 'Show alarms'; $labels['import'] = 'Import'; +$labels['viewoptions'] = 'View options'; +$labels['focusview'] = 'View only this list'; // date words $labels['on'] = 'on'; diff --git a/plugins/tasklist/skins/larry/sprites.png b/plugins/tasklist/skins/larry/sprites.png index 5224f6f..5c6b9fd 100644 Binary files a/plugins/tasklist/skins/larry/sprites.png and b/plugins/tasklist/skins/larry/sprites.png differ diff --git a/plugins/tasklist/skins/larry/tasklist.css b/plugins/tasklist/skins/larry/tasklist.css index 173704d..7a1df99 100644 --- a/plugins/tasklist/skins/larry/tasklist.css +++ b/plugins/tasklist/skins/larry/tasklist.css @@ -66,7 +66,7 @@ body.attachmentwin #topnav .topright { } #taskselector { - margin: -4px 0 0; + margin: -4px 40px 0 0; padding: 0; } @@ -180,16 +180,46 @@ body.attachmentwin #topnav .topright { #tagslist li { display: inline-block; color: #004458; - margin-right: 0.5em; + padding-right: 0.2em; + margin-right: 0.3em; margin-bottom: 0.4em; min-width: 1.2em; cursor: pointer; } +#tagslist li.inactive { + color: #89b3be; + padding-right: 0.6em; + font-size: 80%; +/* display: none; */ +} + +#tagslist li .count { + position: relative; + top: -1px; + margin-left: 5px; + padding: 0.15em 0.5em; + font-size: 80%; + font-weight: bold; + color: #59838e; + background: #c7e3ef; + box-shadow: inset 0 1px 1px 0 #b0ccd7; + -o-box-shadow: inset 0 1px 1px 0 #b0ccd7; + -webkit-box-shadow: inset 0 1px 1px 0 #b0ccd7; + -moz-box-shadow: inset 0 1px 1px 0 #b0ccd7; + border-color: #b0ccd7; + border-radius: 8px; +} + +.tag-draghelper .tag .count, +#tagslist li.inactive .count { + display: none; +} + #tasklists li { margin: 0; height: 20px; - padding: 6px 8px 2px; + padding: 6px 8px 2px 6px; display: block; position: relative; white-space: nowrap; @@ -206,10 +236,13 @@ body.attachmentwin #topnav .topright { #tasklists li span.listname { display: block; + position: absolute; + top: 7px; + left: 26px; + right: 26px; cursor: default; padding-bottom: 2px; padding-right: 30px; - margin-right: 20px; color: #004458; overflow: hidden; text-overflow: ellipsis; @@ -218,7 +251,20 @@ body.attachmentwin #topnav .topright { } #tasklists li span.handle { - display: none; + display: inline-block; + width: 16px; + height: 16px; + margin-right: 4px; + background: url(sprites.png) -200px 0 no-repeat; + cursor: pointer; +} + +#tasklists li:hover span.handle { + background-position: -20px -101px; +} + +#tasklists li.focusview span.handle { + background-position: -2px -101px; } #tasklists li.selected span.listname { @@ -249,6 +295,11 @@ body.attachmentwin #topnav .topright { color: #aaa; } +#tasklists li.virtual span.handle { + background: none; + cursor: default; +} + #tasklists li input { position: absolute; top: 5px; @@ -336,6 +387,27 @@ body.attachmentwin #topnav .topright { background: -ms-linear-gradient(top, #eee 0%, #dfdfdf 100%); background: linear-gradient(top, #eee 0%, #dfdfdf 100%); border-bottom: 1px solid #ccc; + position: relative; +} + +#tasksview .buttonbar .buttonbar-right { + position: absolute; + top: 6px; + right: 8px; +} + +.buttonbar-right .listmenu { + display: inline-block; + cursor: pointer; +} + +.buttonbar-right .listmenu .inner { + display: inline-block; + height: 18px; + width: 20px; + padding: 0; + background: url(sprites.png) 0 -237px no-repeat; + text-indent: -5000px; } #thelist { @@ -456,6 +528,7 @@ body.attachmentwin #topnav .topright { text-align: right; } +.tag-draghelper .tag, .taskhead .tags .tag { font-size: 85%; background: #d9ecf4; @@ -465,6 +538,11 @@ body.attachmentwin #topnav .topright { margin-right: 3px; } +.tag-draghelper li.tag { + list-style: none; + font-size: 100%; +} + .taskhead .date { position: absolute; top: 4px; @@ -547,9 +625,22 @@ body.attachmentwin #topnav .topright { border-top: 1px solid #219de6; } -ul.toolbarmenu li span.add { +ul.toolbarmenu li span.add, +ul.toolbarmenu li span.expand, +ul.toolbarmenu li span.collapse { background-image: url(sprites.png); - background-position: 0 -100px; +} + +ul.toolbarmenu li span.add { + background-position: 0 -302px; +} + +ul.toolbarmenu li span.expand { + background-position: 0 -258px; +} + +ul.toolbarmenu li span.collapse { + background-position: 0 -280px; } ul.toolbarmenu li span.delete { @@ -806,6 +897,12 @@ label.block { /* cursor: pointer; */ } +.form-section span.tag-element.inherit { + color: #666; + background: #f2f2f2; + border-color: #ddd; +} + .tagedit-list li.tagedit-listelement-old a.tagedit-close, .tagedit-list li.tagedit-listelement-old a.tagedit-break, .tagedit-list li.tagedit-listelement-old a.tagedit-delete, diff --git a/plugins/tasklist/skins/larry/templates/mainview.html b/plugins/tasklist/skins/larry/templates/mainview.html index 9196d02..52463e0 100644 --- a/plugins/tasklist/skins/larry/templates/mainview.html +++ b/plugins/tasklist/skins/larry/templates/mainview.html @@ -59,6 +59,10 @@ <li class="nodate"><a href="#nodate"><roundcube:label name="tasklist.nodate" ucfirst="true" /></a></li> <li class="complete"><a href="#complete"><roundcube:label name="tasklist.complete" /><span class="count"></span></a></li> </ul> + + <div class="buttonbar-right"> + <roundcube:button name="taskviewmenulink" id="taskviewmenulink" type="link" title="tasklist.viewoptions" class="listmenu viewoptions" onclick="UI.show_popup('taskviewmenu');return false" innerClass="inner" content="⚙" /> + </div> </div> <div class="scroller"> @@ -92,6 +96,13 @@ </ul> </div> +<div id="taskviewmenu" class="popupmenu"> + <ul class="toolbarmenu"> + <li><roundcube:button command="expand-all" label="expand-all" class="icon" classAct="icon active" innerclass="icon expand" /></li> + <li><roundcube:button command="collapse-all" label="collapse-all" class="icon" classAct="icon active" innerclass="icon collapse" /></li> + </ul> +</div> + <div id="taskshow"> <div class="form-section" id="task-parent-title"></div> <div class="form-section"> @@ -146,6 +157,8 @@ $(document).ready(function(e){ UI.init(); new rcube_splitter({ id:'taskviewsplitter', p1:'#sidebar', p2:'#mainview-right', orientation:'v', relative:true, start:240, min:180, size:16, offset:2 }).init(); + new rcube_splitter({ id:'taskviewsplitterv', p1:'#tagsbox', p2:'#tasklistsbox', + orientation:'h', relative:true, start:242, min:120, size:16, offset:6 }).init(); }); </script> diff --git a/plugins/tasklist/tasklist.js b/plugins/tasklist/tasklist.js index d76c9d1..25ab68d 100644 --- a/plugins/tasklist/tasklist.js +++ b/plugins/tasklist/tasklist.js @@ -54,6 +54,7 @@ function rcube_tasklist_ui(settings) var filtermask = FILTER_MASK_ALL; var loadstate = { filter:-1, lists:'', search:null }; var idcount = 0; + var focusview; var saving_lock; var ui_loading; var taskcounts = {}; @@ -64,6 +65,8 @@ function rcube_tasklist_ui(settings) var search_request; var search_query; var completeness_slider; + var task_draghelper; + var tag_draghelper; var me = this; // general datepicker settings @@ -92,6 +95,7 @@ function rcube_tasklist_ui(settings) this.add_childtask = add_childtask; this.quicksearch = quicksearch; this.reset_search = reset_search; + this.expand_collapse = expand_collapse; this.list_remove = list_remove; this.list_edit_dialog = list_edit_dialog; this.unlock_saving = unlock_saving; @@ -116,22 +120,30 @@ function rcube_tasklist_ui(settings) init_tasklist_li(li, id); } - if (me.tasklists[id].editable && !me.selected_list) { + if (me.tasklists[id].editable && (!me.selected_list || (me.tasklists[id].active && !me.tasklists[me.selected_list].active))) { me.selected_list = id; - rcmail.enable_command('addtask', true); - $(li).click(); } } + if (me.selected_list) { + rcmail.enable_command('addtask', true); + $(rcmail.get_folder_li(me.selected_list, 'rcmlitasklist')).click(); + } + // register server callbacks rcmail.addEventListener('plugin.data_ready', data_ready); - rcmail.addEventListener('plugin.refresh_task', update_taskitem); + rcmail.addEventListener('plugin.update_task', update_taskitem); + rcmail.addEventListener('plugin.refresh_tasks', function(p) { update_taskitem(p, true); }); rcmail.addEventListener('plugin.update_counts', update_counts); rcmail.addEventListener('plugin.insert_tasklist', insert_list); rcmail.addEventListener('plugin.update_tasklist', update_list); rcmail.addEventListener('plugin.destroy_tasklist', destroy_list); - rcmail.addEventListener('plugin.reload_data', function(){ list_tasks(null); }); rcmail.addEventListener('plugin.unlock_saving', unlock_saving); + rcmail.addEventListener('requestrefresh', before_refresh); + rcmail.addEventListener('plugin.reload_data', function(){ + list_tasks(null, true); + setTimeout(fetch_counts, 200); + }); // start loading tasks fetch_counts(); @@ -335,7 +347,8 @@ function rcube_tasklist_ui(settings) $(input).datepicker('widget').find('button.ui-datepicker-close') .html(rcmail.gettext('nodate','tasklist')) .attr('onclick', '') - .click(function(e){ + .unbind('click') + .bind('click', function(e){ $(input).datepicker('setDate', null).datepicker('hide'); }); }, 1); @@ -393,7 +406,7 @@ function rcube_tasklist_ui(settings) /** * List tasks matching the given selector */ - function list_tasks(sel) + function list_tasks(sel, force) { if (rcmail.busy) return; @@ -405,7 +418,7 @@ function rcube_tasklist_ui(settings) var active = active_lists(), basefilter = filtermask == FILTER_MASK_COMPLETE ? FILTER_MASK_COMPLETE : FILTER_MASK_ALL, - reload = active.join(',') != loadstate.lists || basefilter != loadstate.filter || loadstate.search != search_query; + reload = force || active.join(',') != loadstate.lists || basefilter != loadstate.filter || loadstate.search != search_query; if (active.length && reload) { ui_loading = rcmail.set_busy(true, 'loading'); @@ -439,6 +452,19 @@ function rcube_tasklist_ui(settings) } /** + * Modify query parameters for refresh requests + */ + function before_refresh(query) + { + query.filter = filtermask == FILTER_MASK_COMPLETE ? FILTER_MASK_COMPLETE : FILTER_MASK_ALL; + query.lists = active_lists().join(','); + if (search_query) + query.q = search_query; + + return query; + } + + /** * Callback if task data from server is ready */ function data_ready(response) @@ -459,8 +485,9 @@ function rcube_tasklist_ui(settings) listdata[listdata[id].parent_id].children.push(id); } - render_tasklist(); append_tags(response.tags || []); + render_tasklist(); + rcmail.set_busy(false, 'loading', ui_loading); } @@ -473,6 +500,7 @@ function rcube_tasklist_ui(settings) var id, rec, count = 0, cache = {}, + activetags = {}, msgbox = $('#listmessagebox').hide(), list = $(rcmail.gui_objects.resultlist).html(''); @@ -482,10 +510,19 @@ function rcube_tasklist_ui(settings) if (match_filter(rec, cache)) { render_task(rec); count++; + + // keep a list of tags from all visible tasks + for (var t, j=0; rec.tags && j < rec.tags.length; j++) { + t = rec.tags[j]; + if (typeof activetags[t] == 'undefined') + activetags[t] = 0; + activetags[t]++; + } } } fix_tree_toggles(); + update_tagcloud(activetags); if (!count) msgbox.html(rcmail.gettext('notasksfound','tasklist')).show(); @@ -506,6 +543,30 @@ function rcube_tasklist_ui(settings) } /** + * Expand/collapse all task items with childs + */ + function expand_collapse(expand) + { + var collapsed = !expand; + + $('.taskitem .childtasks')[(collapsed ? 'hide' : 'show')](); + $('.taskitem .childtoggle') + .removeClass(collapsed ? 'expanded' : 'collapsed') + .addClass(collapsed ? 'collapsed' : 'expanded') + .html(collapsed ? '▶' : '▼'); + + // store new toggle collapse states + var ids = []; + for (var id in listdata) { + if (listdata[id].children && listdata[id].children.length) + ids.push(id); + } + if (ids.length) { + rcmail.http_post('tasks/task', { action:'collapse', t:{ id:ids.join(',') }, collapsed:collapsed?1:0 }); + } + } + + /** * */ function append_tags(taglist) @@ -520,8 +581,19 @@ function rcube_tasklist_ui(settings) // append new tags to tag cloud $.each(newtags, function(i, tag){ - $('<li>').attr('rel', tag).data('value', tag).html(Q(tag)).appendTo(rcmail.gui_objects.tagslist); - }); + $('<li>').attr('rel', tag).data('value', tag) + .html(Q(tag) + '<span class="count"></span>') + .appendTo(rcmail.gui_objects.tagslist) + .draggable({ + addClasses: false, + revert: 'invalid', + revertDuration: 300, + helper: tag_draggable_helper, + start: tag_draggable_start, + appendTo: 'body', + cursor: 'pointer' + }); + }); // re-sort tags list $(rcmail.gui_objects.tagslist).children('li').sortElements(function(a,b){ @@ -530,6 +602,90 @@ function rcube_tasklist_ui(settings) } /** + * Display the given counts to each tag and set those inactive which don't + * have any matching tasks in the current view. + */ + function update_tagcloud(counts) + { + // compute counts first by iterating over all visible task items + if (typeof counts == 'undefined') { + counts = {}; + $('li.taskitem', rcmail.gui_objects.resultlist).each(function(i,li){ + var t, id = $(li).attr('rel'), + rec = listdata[id]; + for (var j=0; rec && rec.tags && j < rec.tags.length; j++) { + t = rec.tags[j]; + if (typeof counts[t] == 'undefined') + counts[t] = 0; + counts[t]++; + } + }); + } + + $(rcmail.gui_objects.tagslist).children('li').each(function(i,li){ + var elem = $(li), tag = elem.attr('rel'), + count = counts[tag] || 0; + + elem.children('.count').html(count+''); + if (count == 0) elem.addClass('inactive'); + else elem.removeClass('inactive'); + }); + } + + /* Helper functions for drag & drop functionality of tags */ + + function tag_draggable_helper() + { + if (!tag_draghelper) + tag_draghelper = $('<div class="tag-draghelper"></div>'); + else + tag_draghelper.html(''); + + $(this).clone().addClass('tag').appendTo(tag_draghelper); + return tag_draghelper; + } + + function tag_draggable_start(event, ui) + { + $('.taskhead').droppable({ + hoverClass: 'droptarget', + accept: tag_droppable_accept, + drop: tag_draggable_dropped, + addClasses: false + }); + } + + function tag_droppable_accept(draggable) + { + if (rcmail.busy) + return false; + + var tag = draggable.data('value'), + drop_id = $(this).data('id'), + drop_rec = listdata[drop_id]; + + // target already has this tag assigned + if (!drop_rec || (drop_rec.tags && $.inArray(tag, drop_rec.tags) >= 0)) { + return false; + } + + return true; + } + + function tag_draggable_dropped(event, ui) + { + var drop_id = $(this).data('id'), + tag = ui.draggable.data('value'), + rec = listdata[drop_id]; + + if (rec && rec.id) { + if (!rec.tags) rec.tags = []; + rec.tags.push(tag); + save_task(rec, 'edit'); + } + } + + /** * */ function update_counts(counts) @@ -553,11 +709,11 @@ function rcube_tasklist_ui(settings) /** * Callback from server to update a single task item */ - function update_taskitem(rec) + function update_taskitem(rec, filter) { // handle a list of task records if ($.isArray(rec)) { - $.each(rec, function(i,r){ update_taskitem(r); }); + $.each(rec, function(i,r){ update_taskitem(r, filter); }); return; } @@ -597,12 +753,16 @@ function rcube_tasklist_ui(settings) } } - if (list.active) - render_task(rec, oldid); - else + if (list.active || rec.tempid) { + if (!filter || match_filter(rec, {})) + render_task(rec, oldid); + } + else { $('li[rel="'+id+'"]', rcmail.gui_objects.resultlist).remove(); + } append_tags(rec.tags || []); + update_tagcloud(); fix_tree_toggles(); } @@ -655,10 +815,10 @@ function rcube_tasklist_ui(settings) revert: 'invalid', addClasses: false, cursorAt: { left:-10, top:12 }, - helper: draggable_helper, + helper: task_draggable_helper, appendTo: 'body', - start: draggable_start, - stop: draggable_stop, + start: task_draggable_start, + stop: task_draggable_stop, revertDuration: 300 }); @@ -703,7 +863,7 @@ function rcube_tasklist_ui(settings) */ function resort_task(rec, li, animated) { - var dir = 0, index, slice, next_li, next_id, next_rec; + var dir = 0, index, slice, cmp, next_li, next_id, next_rec, insert_after, past_myself; // animated moving var insert_animated = function(li, before, after) { @@ -729,33 +889,36 @@ function rcube_tasklist_ui(settings) } // find the right place to insert the task item - li.siblings().each(function(i, elem){ + li.parent().children('.taskitem').each(function(i, elem){ next_li = $(elem); next_id = next_li.attr('rel'); next_rec = listdata[next_id]; if (next_id == rec.id) { - next_li = null; + past_myself = true; return 1; // continue } - if (next_rec && task_cmp(rec, next_rec) > 0) { + cmp = next_rec ? task_cmp(rec, next_rec) : 0; + + if (cmp > 0 || (cmp == 0 && !past_myself)) { + insert_after = next_li; return 1; // continue; } - else if (next_rec && next_li && task_cmp(rec, next_rec) < 0) { + else if (next_li && cmp < 0) { if (animated) insert_animated(li, next_li); else li.insertBefore(next_li); - next_li = null; - return false; + index = $.inArray(next_id, listindex); + return false; // break } }); - index = $.inArray(next_id, listindex); + if (insert_after) { + if (animated) insert_animated(li, null, insert_after); + else li.insertAfter(insert_after); - if (next_li) { - if (animated) insert_animated(li, null, next_li); - else li.insertAfter(next_li); - index++; + next_id = insert_after.attr('rel'); + index = $.inArray(next_id, listindex); } // insert into list index @@ -799,20 +962,20 @@ function rcube_tasklist_ui(settings) /* Helper functions for drag & drop functionality */ - function draggable_helper() + function task_draggable_helper() { - if (!draghelper) - draghelper = $('<div class="taskitem-draghelper">✔</div>'); + if (!task_draghelper) + task_draghelper = $('<div class="taskitem-draghelper">✔</div>'); - return draghelper; + return task_draghelper; } - function draggable_start(event, ui) + function task_draggable_start(event, ui) { $('.taskhead, #rootdroppable, #'+rcmail.gui_objects.folderlist.id+' li').droppable({ hoverClass: 'droptarget', - accept: droppable_accept, - drop: draggable_dropped, + accept: task_droppable_accept, + drop: task_draggable_dropped, addClasses: false }); @@ -820,13 +983,13 @@ function rcube_tasklist_ui(settings) $('#rootdroppable').show(); } - function draggable_stop(event, ui) + function task_draggable_stop(event, ui) { $(this).parent().removeClass('dragging'); $('#rootdroppable').hide(); } - function droppable_accept(draggable) + function task_droppable_accept(draggable) { if (rcmail.busy) return false; @@ -858,7 +1021,7 @@ function rcube_tasklist_ui(settings) return true; } - function draggable_dropped(event, ui) + function task_draggable_dropped(event, ui) { var drop_id = $(this).data('id'), task_id = ui.draggable.data('id'), @@ -921,10 +1084,23 @@ function rcube_tasklist_ui(settings) $('#task-completeness .task-text').html(((rec.complete || 0) * 100) + '%'); $('#task-list .task-text').html(Q(me.tasklists[rec.list] ? me.tasklists[rec.list].name : '')); - var taglist = $('#task-tags')[(rec.tags && rec.tags.length ? 'show' : 'hide')]().children('.task-text').empty(); + var itags = get_inherited_tags(rec); + var taglist = $('#task-tags')[(rec.tags && rec.tags.length || itags.length ? 'show' : 'hide')]().children('.task-text').empty(); if (rec.tags && rec.tags.length) { $.each(rec.tags, function(i,val){ - $('<span>').addClass('tag-element').html(Q(val)).data('value', val).appendTo(taglist); + $('<span>').addClass('tag-element').html(Q(val)).appendTo(taglist); + }); + } + + // append inherited tags + if (itags.length) { + $.each(itags, function(i,val){ + if (!rec.tags || $.inArray(val, rec.tags) < 0) + $('<span>').addClass('tag-element inherit').html(Q(val)).appendTo(taglist); + }); + // re-sort tags list + $(taglist).children().sortElements(function(a,b){ + return $.text([a]).toLowerCase() > $.text([b]).toLowerCase() ? 1 : -1; }); } @@ -1012,7 +1188,7 @@ function rcube_tasklist_ui(settings) var recstarttime = $('#taskedit-starttime').val(rec.starttime || ''); var complete = $('#taskedit-completeness').val((rec.complete || 0) * 100); completeness_slider.slider('value', complete.val()); - var tasklist = $('#taskedit-tasklist').val(rec.list || 0).prop('disabled', rec.parent_id ? true : false); + var tasklist = $('#taskedit-tasklist').val(rec.list || me.selected_list).prop('disabled', rec.parent_id ? true : false); // tag-edit line var tagline = $(rcmail.gui_objects.edittagline).empty(); @@ -1102,10 +1278,16 @@ function rcube_tasklist_ui(settings) } } + // collect tags $('input[type="hidden"]', rcmail.gui_objects.edittagline).each(function(i,elem){ if (elem.value) me.selected_task.tags.push(elem.value); }); + // including the "pending" one in the text box + var newtag = $('#tagedit-input').val(); + if (newtag != '') { + me.selected_task.tags.push(newtag); + } // serialize alarm settings var alarm = $('#taskedit select.edit-alarm-type').val(); @@ -1124,7 +1306,7 @@ function rcube_tasklist_ui(settings) } // task assigned to a new list - if (me.selected_task.list && me.selected_task.list != rec.list) { + if (me.selected_task.list && listdata[id] && me.selected_task.list != listdata[id].list) { me.selected_task._fromlist = rec.list; } @@ -1135,6 +1317,9 @@ function rcube_tasklist_ui(settings) if (!me.selected_task.list && list.id) me.selected_task.list = list.id; + if (!me.selected_task.tags.length) + me.selected_task.tags = ''; + if (save_task(me.selected_task, action)) $dialog.dialog('close'); }; @@ -1367,12 +1552,17 @@ function rcube_tasklist_ui(settings) return cache[rec.id]; } - var match = !filtermask || (filtermask & rec.mask) > 0 + var match = !filtermask || (filtermask & rec.mask) > 0; + + // in focusview mode, only tasks from the selected list are allowed + if (focusview && rec.list != focusview) + match = false; if (match && tagsfilter.length) { match = rec.tags && rec.tags.length; + var alltags = get_inherited_tags(rec).concat(rec.tags || []); for (var i=0; match && i < tagsfilter.length; i++) { - if ($.inArray(tagsfilter[i], rec.tags) < 0) + if ($.inArray(tagsfilter[i], alltags) < 0) match = false; } } @@ -1402,6 +1592,23 @@ function rcube_tasklist_ui(settings) /** * */ + function get_inherited_tags(rec) + { + var parent_id, itags = []; + + if ((parent_id = rec.parent_id)) { + while (parent_id && listdata[parent_id]) { + itags = itags.concat(listdata[parent_id].tags || []); + parent_id = listdata[parent_id].parent_id; + } + } + + return $.unqiqueStrings(itags); + } + + /** + * + */ function list_edit_dialog(id) { var list = me.tasklists[id], @@ -1671,6 +1878,11 @@ function rcube_tasklist_ui(settings) if (!this.checked) remove_tasks(id); else list_tasks(null); rcmail.http_post('tasklist', { action:'subscribe', l:{ id:id, active:me.tasklists[id].active?1:0 } }); + + // disable focusview + if (!this.checked && focusview == id) { + set_focusview(null); + } } }).data('id', id).get(0).checked = me.tasklists[id].active || false; @@ -1679,6 +1891,15 @@ function rcube_tasklist_ui(settings) rcmail.select_folder(id, 'rcmlitasklist'); rcmail.enable_command('list-edit', 'list-remove', 'list-import', me.tasklists[id].editable); me.selected_list = id; + + // click on handle icon toggles focusview + if (e.target.className == 'handle') { + set_focusview(focusview == id ? null : id) + } + // disable focusview when selecting another list + else if (focusview && id != focusview) { + set_focusview(null); + } }) .dblclick(function(e){ list_edit_dialog($(this).data('id')); @@ -1688,6 +1909,31 @@ function rcube_tasklist_ui(settings) .addClass(me.tasklists[id].editable ? null : 'readonly'); } + /** + * Enable/disable focusview mode for the given list + */ + function set_focusview(id) + { + if (focusview && focusview != id) + $(rcmail.get_folder_li(focusview, 'rcmlitasklist')).removeClass('focusview'); + + focusview = id; + + // activate list if necessary + if (focusview && !me.tasklists[id].active) { + $('input', rcmail.get_folder_li(id, 'rcmlitasklist')).get(0).checked = true; + me.tasklists[id].active = true; + fetch_counts(); + } + + // update list + list_tasks(null); + + if (focusview) { + $(rcmail.get_folder_li(focusview, 'rcmlitasklist')).addClass('focusview'); + } + } + // init dialog by default init_taskedit(); @@ -1714,6 +1960,22 @@ jQuery.fn.sortElements = (function(){ }; })(); +// equivalent to $.unique() but working on arrays of strings +jQuery.unqiqueStrings = (function() { + return function(arr) { + var hash = {}, out = []; + + for (var i = 0; i < arr.length; i++) { + hash[arr[i]] = 0; + } + for (var val in hash) { + out.push(val); + } + + return out; + }; +})(); + /* tasklist plugin UI initialization */ var rctasks; @@ -1731,6 +1993,8 @@ window.rcmail && rcmail.addEventListener('init', function(evt) { rcmail.register_command('search', function(){ rctasks.quicksearch(); }, true); rcmail.register_command('reset-search', function(){ rctasks.reset_search(); }, true); + rcmail.register_command('expand-all', function(){ rctasks.expand_collapse(true); }, true); + rcmail.register_command('collapse-all', function(){ rctasks.expand_collapse(false); }, true); rctasks.init(); }); diff --git a/plugins/tasklist/tasklist.php b/plugins/tasklist/tasklist.php index 6ebddc4..e77bccc 100644 --- a/plugins/tasklist/tasklist.php +++ b/plugins/tasklist/tasklist.php @@ -90,6 +90,7 @@ class tasklist extends rcube_plugin $this->register_action('mail2task', array($this, 'mail_message2task')); $this->register_action('get-attachment', array($this, 'attachment_get')); $this->register_action('upload', array($this, 'attachment_upload')); + $this->add_hook('refresh', array($this, 'refresh')); $this->collapsed_tasks = array_filter(explode(',', $this->rc->config->get('tasklist_collapsed_tasks', ''))); } @@ -112,6 +113,8 @@ class tasklist extends rcube_plugin 'innerclass' => 'icon taskadd', ))), 'messagemenu'); + + $this->api->output->add_label('tasklist.createfrommail'); } } @@ -247,13 +250,15 @@ class tasklist extends rcube_plugin break; case 'collapse': - if (intval(get_input_value('collapsed', RCUBE_INPUT_GPC))) { - $this->collapsed_tasks[] = $rec['id']; - } - else { - $i = array_search($rec['id'], $this->collapsed_tasks); - if ($i !== false) - unset($this->collapsed_tasks[$i]); + foreach (explode(',', $rec['id']) as $rec_id) { + if (intval(get_input_value('collapsed', RCUBE_INPUT_GPC))) { + $this->collapsed_tasks[] = $rec_id; + } + else { + $i = array_search($rec_id, $this->collapsed_tasks); + if ($i !== false) + unset($this->collapsed_tasks[$i]); + } } $this->rc->user->save_prefs(array('tasklist_collapsed_tasks' => join(',', array_unique($this->collapsed_tasks)))); @@ -278,7 +283,7 @@ class tasklist extends rcube_plugin foreach ($refresh as $i => $r) $this->encode_task($refresh[$i]); } - $this->rc->output->command('plugin.refresh_task', $refresh); + $this->rc->output->command('plugin.update_task', $refresh); } } @@ -540,8 +545,17 @@ class tasklist extends rcube_plugin } */ + $data = $this->tasks_data($this->driver->list_tasks($filter, $lists), $f, $tags); + $this->rc->output->command('plugin.data_ready', array('filter' => $f, 'lists' => $lists, 'search' => $search, 'data' => $data, 'tags' => array_values(array_unique($tags)))); + } + + /** + * Prepare and sort the given task records to be sent to the client + */ + private function tasks_data($records, $f, &$tags) + { $data = $tags = $this->task_tree = $this->task_titles = array(); - foreach ($this->driver->list_tasks($filter, $lists) as $rec) { + foreach ($records as $rec) { if ($rec['parent_id']) { $this->task_tree[$rec['id']] = $rec['parent_id']; } @@ -558,7 +572,7 @@ class tasklist extends rcube_plugin array_walk($data, array($this, 'task_walk_tree')); usort($data, array($this, 'task_sort_cmp')); - $this->rc->output->command('plugin.data_ready', array('filter' => $f, 'lists' => $lists, 'search' => $search, 'data' => $data, 'tags' => array_values(array_unique($tags)))); + return $data; } /** @@ -605,6 +619,10 @@ class tasklist extends rcube_plugin $rec['attachments'][$k]['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']); } + if (!is_array($rec['tags'])) + $rec['tags'] = (array)$rec['tags']; + sort($rec['tags'], SORT_LOCALE_STRING); + if (in_array($rec['id'], $this->collapsed_tasks)) $rec['collapsed'] = true; @@ -720,6 +738,34 @@ class tasklist extends rcube_plugin exit; } + /** + * Handler for keep-alive requests + * This will check for updated data in active lists and sync them to the client + */ + public function refresh($attr) + { + // refresh the entire list every 10th time to also sync deleted items + if (rand(0,10) == 10) { + $this->rc->output->command('plugin.reload_data'); + return; + } + + $filter = array( + 'since' => $attr['last'], + 'search' => get_input_value('q', RCUBE_INPUT_GPC), + 'mask' => intval(get_input_value('filter', RCUBE_INPUT_GPC)) & self::FILTER_MASK_COMPLETE, + ); + $lists = get_input_value('lists', RCUBE_INPUT_GPC);; + + $updates = $this->driver->list_tasks($filter, $lists); + if (!empty($updates)) { + $this->rc->output->command('plugin.refresh_tasks', $this->tasks_data($updates, 255, $tags), true); + + // update counts + $counts = $this->driver->count_tasks($lists); + $this->rc->output->command('plugin.update_counts', $counts); + } + } /** * Handler for pending_alarms plugin hook triggered by the calendar module on keep-alive requests. diff --git a/plugins/tasklist/tasklist_base.js b/plugins/tasklist/tasklist_base.js index e3a889c..f804c34 100644 --- a/plugins/tasklist/tasklist_base.js +++ b/plugins/tasklist/tasklist_base.js @@ -4,7 +4,7 @@ * @version @package_version@ * @author Thomas Bruederli <bruederli@kolabsys.com> * - * Copyright (C) 2012, Kolab Systems AG <contact@kolabsys.com> + * Copyright (C) 2013, Kolab Systems AG <contact@kolabsys.com> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -37,10 +37,9 @@ function rcube_tasklist(settings) /** * Open a new task dialog prefilled with contents from the currently selected mail message */ - function create_from_mail() + function create_from_mail(uid) { - var uid; - if ((uid = rcmail.get_single_uid())) { + if (uid || (uid = rcmail.get_single_uid())) { // load calendar UI (scripts and edit dialog template) if (!ui_loaded) { $.when( @@ -53,7 +52,7 @@ function rcube_tasklist(settings) ui_loaded = true; me.ui = new rcube_tasklist_ui(settings); - create_from_mail(); // start over + create_from_mail(uid); // start over }); return; } @@ -90,5 +89,14 @@ window.rcmail && rcmail.env.task == 'mail' && rcmail.addEventListener('init', fu rcmail.env.message_commands.push('tasklist-create-from-mail'); else rcmail.enable_command('tasklist-create-from-mail', true); + + // add contextmenu item + if (window.rcm_contextmenu_register_command) { + rcm_contextmenu_register_command( + 'tasklist-create-from-mail', + function(cmd,el){ tasks.create_from_mail() }, + 'tasklist.createfrommail', + 'moveto'); + } }); diff --git a/plugins/tasklist/tasklist_ui.php b/plugins/tasklist/tasklist_ui.php index dab9b12..21faba3 100644 --- a/plugins/tasklist/tasklist_ui.php +++ b/plugins/tasklist/tasklist_ui.php @@ -117,7 +117,7 @@ class tasklist_ui $li .= html::tag('li', array('id' => 'rcmlitasklist' . $html_id, 'class' => $class), ($prop['virtual'] ? '' : html::tag('input', array('type' => 'checkbox', 'name' => '_list[]', 'value' => $id, 'checked' => $prop['active']))) . - html::span('handle', ' ') . + html::span(array('class' => 'handle', 'title' => $this->plugin->gettext('focusview')), ' ') . html::span(array('class' => 'listname', 'title' => $title), $prop['listname'])); }
Locations
Projects
Search
Status Monitor
Help
Open Build Service
OBS Manuals
API Documentation
OBS Portal
Reporting a Bug
Contact
Mailing List
Forums
Chat (IRC)
Twitter
Open Build Service (OBS)
is an
openSUSE project
.