Begin with event attendees and free/busy implementation
This commit is contained in:
parent
6994d3d38f
commit
d210f1c023
10 changed files with 356 additions and 9 deletions
|
@ -108,6 +108,8 @@ class calendar extends rcube_plugin
|
|||
$this->register_action('export_events', array($this, 'export_events'));
|
||||
$this->register_action('upload', array($this, 'attachment_upload'));
|
||||
$this->register_action('get-attachment', array($this, 'attachment_get'));
|
||||
$this->register_action('freebusy-status', array($this, 'freebusy_status'));
|
||||
$this->register_action('freebusy-times', array($this, 'freebusy_times'));
|
||||
$this->register_action('randomdata', array($this, 'generate_randomdata'));
|
||||
}
|
||||
else if ($this->rc->task == 'settings') {
|
||||
|
@ -169,10 +171,12 @@ class calendar extends rcube_plugin
|
|||
$this->register_handler('plugin.recurrence_form', array($this->ui, 'recurrence_form'));
|
||||
$this->register_handler('plugin.attachments_form', array($this->ui, 'attachments_form'));
|
||||
$this->register_handler('plugin.attachments_list', array($this->ui, 'attachments_list'));
|
||||
$this->register_handler('plugin.attendees_list', array($this->ui, 'attendees_list'));
|
||||
$this->register_handler('plugin.attendees_form', array($this->ui, 'attendees_form'));
|
||||
$this->register_handler('plugin.edit_recurring_warning', array($this->ui, 'recurring_event_warning'));
|
||||
$this->register_handler('plugin.searchform', array($this->rc->output, 'search_form')); // use generic method from rcube_template
|
||||
|
||||
$this->rc->output->add_label('low','normal','high','delete','cancel','uploading');
|
||||
$this->rc->output->add_label('low','normal','high','delete','cancel','uploading','noemailwarning');
|
||||
|
||||
$this->rc->output->send("calendar.calendar");
|
||||
}
|
||||
|
@ -442,29 +446,41 @@ class calendar extends rcube_plugin
|
|||
case "new":
|
||||
// create UID for new event
|
||||
$event['uid'] = $this->generate_uid();
|
||||
|
||||
// set current user as organizer
|
||||
if (!$event['attendees']) {
|
||||
$identity = $this->rc->user->get_identity();
|
||||
$event['attendees'][] = array('role' => 'OWNER', 'name' => $identity['name'], 'email' => $identity['email']);
|
||||
}
|
||||
|
||||
$this->prepare_event($event);
|
||||
if ($success = $this->driver->new_event($event))
|
||||
$this->cleanup_event($event);
|
||||
$reload = true;
|
||||
break;
|
||||
|
||||
case "edit":
|
||||
$this->prepare_event($event);
|
||||
if ($success = $this->driver->edit_event($event))
|
||||
$this->cleanup_event($event);
|
||||
$reload = true;
|
||||
break;
|
||||
|
||||
case "resize":
|
||||
$success = $this->driver->resize_event($event);
|
||||
$reload = true;
|
||||
break;
|
||||
|
||||
case "move":
|
||||
$success = $this->driver->move_event($event);
|
||||
$reload = true;
|
||||
break;
|
||||
|
||||
case "remove":
|
||||
$removed = $this->driver->remove_event($event);
|
||||
$reload = true;
|
||||
break;
|
||||
|
||||
case "dismiss":
|
||||
foreach (explode(',', $event['id']) as $id)
|
||||
$success |= $this->driver->dismiss_alarm($id, $event['snooze']);
|
||||
|
@ -606,6 +622,10 @@ class calendar extends rcube_plugin
|
|||
|
||||
// user prefs
|
||||
$settings['hidden_calendars'] = array_filter(explode(',', $this->rc->config->get('hidden_calendars', '')));
|
||||
|
||||
// get user identity to create default attendee
|
||||
$identity = $this->rc->user->get_identity();
|
||||
$settings['event_owner'] = array('name' => $identity['name'], 'email' => $identity['email']);
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
@ -1113,5 +1133,59 @@ class calendar extends rcube_plugin
|
|||
unset($_SESSION['event_session']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Echo simple free/busy status text for the given user and time range
|
||||
*/
|
||||
public function freebusy_status()
|
||||
{
|
||||
$email = get_input_value('email', RCUBE_INPUT_GPC);
|
||||
$start = get_input_value('start', RCUBE_INPUT_GET);
|
||||
$end = get_input_value('end', RCUBE_INPUT_GET);
|
||||
|
||||
if (!$start) $start = time();
|
||||
if (!$end) $end = $start + 3600;
|
||||
|
||||
$status = 'UNKNOWN';
|
||||
|
||||
// if the backend has free-busy information
|
||||
$fblist = $this->driver->get_freebusy_list($email, $start, $end);
|
||||
if (is_array($fblist)) {
|
||||
$status = 'FREE';
|
||||
|
||||
foreach ($fblist as $slot) {
|
||||
list($from, $to) = $slot;
|
||||
if ($from <= $end && $to >= $start) {
|
||||
$status = 'BUSY';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo $status;
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a list of free/busy time slots within the given period
|
||||
* Echo data in JSON encoding
|
||||
*/
|
||||
public function freebusy_times()
|
||||
{
|
||||
$email = get_input_value('email', RCUBE_INPUT_GPC);
|
||||
$start = get_input_value('start', RCUBE_INPUT_GET);
|
||||
$end = get_input_value('end', RCUBE_INPUT_GET);
|
||||
|
||||
if (!$start) $start = time();
|
||||
if (!$end) $end = $start + 86400 * 30;
|
||||
|
||||
$fblist = $this->driver->get_freebusy_list($email, $start, $end);
|
||||
$result = array();
|
||||
|
||||
// TODO: build a list from $start till $end with blocks representing the fb-status
|
||||
|
||||
echo json_encode($result);
|
||||
exit;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -42,6 +42,8 @@ function rcube_calendar_ui(settings)
|
|||
var gmt_offset = (new Date().getTimezoneOffset() / -60) - (settings.timezone || 0);
|
||||
var day_clicked = day_clicked_ts = 0;
|
||||
var ignore_click = false;
|
||||
var event_attendees = null;
|
||||
var attendees_list;
|
||||
|
||||
// general datepicker settings
|
||||
var datepicker_settings = {
|
||||
|
@ -66,6 +68,27 @@ function rcube_calendar_ui(settings)
|
|||
{
|
||||
return String(str).replace(/\n/g, "<br/>");
|
||||
};
|
||||
|
||||
//
|
||||
var explode_quoted_string = function(str, delimiter)
|
||||
{
|
||||
var result = [],
|
||||
strlen = str.length,
|
||||
q, p, i;
|
||||
|
||||
for (q = p = i = 0; i < strlen; i++) {
|
||||
if (str[i] == '"' && str[i-1] != '\\') {
|
||||
q = !q;
|
||||
}
|
||||
else if (!q && str[i] == delimiter) {
|
||||
result.push(str.substring(p, i));
|
||||
p = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
result.push(str.substr(p));
|
||||
return result;
|
||||
};
|
||||
|
||||
// from time and date strings to a real date object
|
||||
var parse_datetime = function(time, date)
|
||||
|
@ -390,6 +413,14 @@ function rcube_calendar_ui(settings)
|
|||
}
|
||||
else
|
||||
$('#edit-recurring-warning').hide();
|
||||
|
||||
// attendees
|
||||
event_attendees = [];
|
||||
attendees_list = $('#edit-attendees-table > tbody').html('');
|
||||
if (calendar.attendees && event.attendees) {
|
||||
for (var j=0; j < event.attendees.length; j++)
|
||||
add_attendee(event.attendees[j]);
|
||||
}
|
||||
|
||||
// attachments
|
||||
if (calendar.attachments) {
|
||||
|
@ -436,6 +467,7 @@ function rcube_calendar_ui(settings)
|
|||
sensitivity: sensitivity.val(),
|
||||
recurrence: '',
|
||||
alarms: '',
|
||||
attendees: event_attendees,
|
||||
deleted_attachments: rcmail.env.deleted_attachments
|
||||
};
|
||||
|
||||
|
@ -531,18 +563,87 @@ function rcube_calendar_ui(settings)
|
|||
$dialog.dialog({
|
||||
modal: true,
|
||||
resizable: true,
|
||||
closeOnEscape: false,
|
||||
title: rcmail.gettext((action == 'edit' ? 'edit_event' : 'new_event'), 'calendar'),
|
||||
close: function() {
|
||||
$dialog.dialog("destroy").hide();
|
||||
},
|
||||
buttons: buttons,
|
||||
minWidth: 440,
|
||||
width: 480
|
||||
minWidth: 500,
|
||||
width: 580
|
||||
}).show();
|
||||
|
||||
title.select();
|
||||
};
|
||||
|
||||
// add the given list of participants
|
||||
var add_attendees = function(names)
|
||||
{
|
||||
names = explode_quoted_string(names.replace(/,\s*$/, ''), ',');
|
||||
|
||||
// parse name/email pairs
|
||||
var item, email, name, success = false;
|
||||
for (var i=0; i < names.length; i++) {
|
||||
email = name = null;
|
||||
item = $.trim(names[i]);
|
||||
|
||||
if (!item.length) {
|
||||
continue;
|
||||
} // address in brackets without name (do nothing)
|
||||
else if (item.match(/^<[^@]+@[^>]+>$/)) {
|
||||
email = item.replace(/[<>]/g, '');
|
||||
} // address without brackets and without name (add brackets)
|
||||
else if (rcube_check_email(item)) {
|
||||
email = item;
|
||||
} // address with name
|
||||
else if (item.match(/([^\s<@]+@[^>]+)>*$/)) {
|
||||
email = RegExp.$1;
|
||||
name = item.replace(email, '').replace(/^["\s<>]+/, '').replace(/["\s<>]+$/, '');
|
||||
}
|
||||
|
||||
if (email) {
|
||||
add_attendee({ email:email, name:name, role:'REQUIRED', status:'unknown' });
|
||||
success = true;
|
||||
}
|
||||
else {
|
||||
alert(rcmail.gettext('noemailwarning'));
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
};
|
||||
|
||||
// add the given attendee to the list
|
||||
var add_attendee = function(data)
|
||||
{
|
||||
var dispname = (data.email && data.name) ? data.name + ' <' + data.email + '>' : (data.email || data.name);
|
||||
|
||||
// delete icon
|
||||
var icon = rcmail.env.deleteicon ? '<img src="' + rcmail.env.deleteicon + '" alt="" />' : rcmail.gettext('delete');
|
||||
var dellink = '<a href="#delete" class="deletelink" title="' + Q(rcmail.gettext('delete')) + '">' + icon + '</a>';
|
||||
|
||||
var html = '<td class="role"></td>' +
|
||||
'<td class="name">' + Q(dispname) + '</td>' +
|
||||
'<td class="availability">' + '' + '</td>' +
|
||||
'<td class="confirmstate">' + Q(data.status) + '</td>' +
|
||||
'<td class="options">' + (data.role != 'OWNER' ? dellink : '') + '</td>';
|
||||
|
||||
$('<tr>')
|
||||
.addClass(String(data.role).toLowerCase())
|
||||
.html(html)
|
||||
.appendTo(attendees_list)
|
||||
.find('a.deletelink').click({ id:(data.email || data.name) }, function(e) { remove_attendee(this, e.data.id); return false; });
|
||||
|
||||
event_attendees.push(data);
|
||||
};
|
||||
|
||||
// remove an attendee from the list
|
||||
var remove_attendee = function(elem, id)
|
||||
{
|
||||
$(elem).closest('tr').hide();
|
||||
event_attendees = $.grep(event_attendees, function(data){ return (data.name != id && data.email != id) });
|
||||
};
|
||||
|
||||
// post the given event data to server
|
||||
var update_event = function(action, data)
|
||||
{
|
||||
|
@ -1098,7 +1199,12 @@ function rcube_calendar_ui(settings)
|
|||
};
|
||||
|
||||
// init event dialog
|
||||
$('#eventtabs').tabs();
|
||||
$('#eventtabs').tabs({
|
||||
show: function(event, ui) {
|
||||
if (ui.panel.id == 'event-tab-3')
|
||||
$('#edit-attendee-name').select();
|
||||
}
|
||||
});
|
||||
$('#edit-enddate, input.edit-alarm-date').datepicker(datepicker_settings);
|
||||
$('#edit-startdate').datepicker(datepicker_settings).datepicker('option', 'onSelect', shift_enddate).change(function(){ shift_enddate(this.value); });
|
||||
$('#edit-allday').click(function(){ $('#edit-starttime, #edit-endtime')[(this.checked?'hide':'show')](); });
|
||||
|
@ -1175,6 +1281,16 @@ function rcube_calendar_ui(settings)
|
|||
$('#recurrence-form-'+freq+', #recurrence-form-until').show();
|
||||
});
|
||||
$('#edit-recurrence-enddate').datepicker(datepicker_settings).click(function(){ $("#edit-recurrence-repeat-until").prop('checked', true) });
|
||||
|
||||
// init attendees autocompletion
|
||||
rcmail.init_address_input_events($('#edit-attendee-name'));
|
||||
rcmail.addEventListener('autocomplete_insert', function(e){ $('#edit-attendee-add').click(); });
|
||||
|
||||
$('#edit-attendee-add').click(function(){
|
||||
var input = $('#edit-attendee-name');
|
||||
if (add_attendees(input.val()))
|
||||
input.val('');
|
||||
});
|
||||
|
||||
// add proprietary css styles if not IE
|
||||
if (!bw.ie)
|
||||
|
@ -1208,7 +1324,6 @@ window.rcmail && rcmail.addEventListener('init', function(evt) {
|
|||
rcmail.addEventListener('plugin.destroy_source', function(p){ cal.calendar_destroy_source(p.id); });
|
||||
rcmail.addEventListener('plugin.unlock_saving', function(p){ rcmail.set_busy(false, null, cal.saving_lock); });
|
||||
|
||||
|
||||
// let's go
|
||||
var cal = new rcube_calendar_ui(rcmail.env.calendar_settings);
|
||||
|
||||
|
|
|
@ -274,10 +274,15 @@ abstract class calendar_driver
|
|||
|
||||
/**
|
||||
* Fetch free/busy information from a person within the given range
|
||||
*
|
||||
* @param string E-mail address of attendee
|
||||
* @param integer Requested period start date/time as unix timestamp
|
||||
* @param integer Requested period end date/time as unix timestamp
|
||||
* @return array List of busy timeslots within the requested range
|
||||
*/
|
||||
public function get_freebusy_list($email, $start, $end)
|
||||
{
|
||||
return array();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -441,6 +441,24 @@ class kolab_calendar
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($rec['organizer']) {
|
||||
$attendees[] = array(
|
||||
'role' => 'OWNER',
|
||||
'name' => $rec['organizer']['display-name'],
|
||||
'email' => $rec['organizer']['smtp-address'],
|
||||
'status' => 'accepted',
|
||||
);
|
||||
}
|
||||
|
||||
foreach ((array)$rec['attendee'] as $attendee) {
|
||||
$attendees[] = array(
|
||||
'role' => strtoupper($attendee['role']),
|
||||
'name' => $attendee['display-name'],
|
||||
'email' => $attendee['smtp-address'],
|
||||
'status' => $attendee['status'],
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'id' => $rec['uid'],
|
||||
|
@ -455,6 +473,7 @@ class kolab_calendar
|
|||
'alarms' => $alarm_value . $alarm_unit,
|
||||
'categories' => $rec['categories'],
|
||||
'attachments' => $attachments,
|
||||
'attendees' => $attendees,
|
||||
'free_busy' => $rec['show-time-as'],
|
||||
'priority' => isset($priority_map[$rec['priority']]) ? $priority_map[$rec['priority']] : 1,
|
||||
'sensitivity' => $sensitivity_map[$rec['sensitivity']],
|
||||
|
@ -601,6 +620,25 @@ class kolab_calendar
|
|||
unset($event['attachments'][$idx]);
|
||||
}
|
||||
}
|
||||
|
||||
// process event attendees
|
||||
foreach ((array)$event['attendees'] as $attendee) {
|
||||
$role = $attendee['role'];
|
||||
if ($role == 'OWNER') {
|
||||
$object['organizer'] = array(
|
||||
'display-name' => $attendee['name'],
|
||||
'smtp-address' => $attendee['email'],
|
||||
);
|
||||
}
|
||||
else {
|
||||
$object['attendee'][] = array(
|
||||
'display-name' => $attendee['name'],
|
||||
'smtp-address' => $attendee['email'],
|
||||
'status' => $attendee['status'],
|
||||
'role' => strtolower($role),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $object;
|
||||
}
|
||||
|
|
|
@ -645,7 +645,29 @@ class kolab_driver extends calendar_driver
|
|||
*/
|
||||
public function get_freebusy_list($email, $start, $end)
|
||||
{
|
||||
return array();
|
||||
require_once('Horde/iCalendar.php');
|
||||
|
||||
if (empty($email) || $end < time())
|
||||
return false;
|
||||
|
||||
// load and parse free-busy information using Horde classes
|
||||
$fburl = rcube_kolab::get_freebusy_url($email);
|
||||
if ($fbdata = file_get_contents($fburl)) {
|
||||
$fbcal = new Horde_iCalendar;
|
||||
$fbcal->parsevCalendar($fbdata);
|
||||
if ($fb = $fbcal->findComponent('vfreebusy')) {
|
||||
$result = array();
|
||||
foreach ($fb->getBusyPeriods() as $from => $to) {
|
||||
if ($to == null) // no information, assume free
|
||||
break;
|
||||
$result[] = array($from, $to);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -560,5 +560,31 @@ class calendar_ui
|
|||
|
||||
return html::tag('form', array('action' => "#", 'method' => "get"), $html);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function attendees_list($attrib = array())
|
||||
{
|
||||
$table = new html_table(array('cols' => 5, 'border' => 0, 'cellpadding' => 0, 'class' => 'rectable'));
|
||||
$table->add_header('role', $this->calendar->gettext('role'));
|
||||
$table->add_header('name', $this->calendar->gettext('attendee'));
|
||||
$table->add_header('availability', $this->calendar->gettext('availability'));
|
||||
$table->add_header('confirmstate', $this->calendar->gettext('confirmstate'));
|
||||
$table->add_header('options', '');
|
||||
|
||||
return $table->show($attrib);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function attendees_form($attrib = array())
|
||||
{
|
||||
$input = new html_inputfield(array('name' => 'participant', 'id' => 'edit-attendee-name', 'size' => 30));
|
||||
|
||||
return html::div($attrib, $input->show() . " " .
|
||||
html::tag('input', array('type' => 'button', 'class' => 'button', 'id' => 'edit-attendee-add', 'value' => $this->calendar->gettext('addattendee'))));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -86,6 +86,13 @@ $labels['repeattomorrow'] = 'Repeat tomorrow';
|
|||
$labels['repeatinweek'] = 'Repeat in a week';
|
||||
$labels['alarmtitle'] = 'Upcoming events';
|
||||
|
||||
// attendees
|
||||
$labels['attendee'] = 'Participant';
|
||||
$labels['role'] = 'Role';
|
||||
$labels['availability'] = 'Avail.';
|
||||
$labels['confirmstate'] = 'Confirmed';
|
||||
$labels['addattendee'] = 'Add participant';
|
||||
|
||||
// event dialog tabs
|
||||
$labels['tabsummary'] = 'Summary';
|
||||
$labels['tabrecurrence'] = 'Recurrence';
|
||||
|
|
|
@ -467,6 +467,53 @@ td.topalign {
|
|||
min-width: 5em;
|
||||
}
|
||||
|
||||
#edit-attendees-table {
|
||||
width: 100%;
|
||||
display: table;
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
#edit-attendees-table td {
|
||||
padding: 3px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
#edit-attendees-table tr.owner td {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
#edit-attendees-table td.role,
|
||||
#edit-attendees-table td.availability {
|
||||
width: 4em;
|
||||
}
|
||||
|
||||
#edit-attendees-table td.confirmstate {
|
||||
width: 6em;
|
||||
}
|
||||
|
||||
#edit-attendees-table td.options {
|
||||
width: 3em;
|
||||
text-align: right;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
#edit-attendees-table td.name {
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#edit-attendees-table thead td {
|
||||
background: url(images/listheader.gif) top left repeat-x #CCC;
|
||||
}
|
||||
|
||||
#edit-attendees-form {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
span.edit-alarm-set {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
@ -623,7 +670,8 @@ div.fc-event-location {
|
|||
}
|
||||
|
||||
.fc-view-list div.fc-list-header,
|
||||
.fc-view-table td.fc-list-header {
|
||||
.fc-view-table td.fc-list-header,
|
||||
#edit-attendees-table thead td {
|
||||
padding: 3px;
|
||||
background: #dddddd;
|
||||
background-image: -moz-linear-gradient(center top, #f4f4f4, #d2d2d2);
|
||||
|
|
|
@ -166,7 +166,8 @@
|
|||
</div>
|
||||
<!-- attendees list -->
|
||||
<div id="event-tab-3">
|
||||
|
||||
<roundcube:object name="plugin.attendees_list" id="edit-attendees-table" cellspacing="0" cellpadding="0" border="0" />
|
||||
<roundcube:object name="plugin.attendees_form" id="edit-attendees-form" />
|
||||
</div>
|
||||
<!-- attachments list (with upload form) -->
|
||||
<div id="event-tab-4">
|
||||
|
|
|
@ -27,6 +27,7 @@ require_once 'Horde/Perms.php';
|
|||
class rcube_kolab
|
||||
{
|
||||
private static $horde_auth;
|
||||
private static $config;
|
||||
private static $ready = false;
|
||||
|
||||
|
||||
|
@ -74,6 +75,7 @@ class rcube_kolab
|
|||
|
||||
$conf['kolab']['ldap'] = array_merge($ldap, (array)$conf['kolab']['ldap']);
|
||||
$conf['kolab']['imap'] = array_merge($imap, (array)$conf['kolab']['imap']);
|
||||
self::$config = &$conf;
|
||||
|
||||
// pass the current IMAP authentication credentials to the Horde auth system
|
||||
self::$horde_auth = Auth::singleton('kolab');
|
||||
|
@ -146,6 +148,15 @@ class rcube_kolab
|
|||
$kolab = Kolab_List::singleton();
|
||||
return self::$ready ? $kolab->getFolder($folder)->getData($data_type) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose an URL to query the free/busy status for the given user
|
||||
*/
|
||||
public static function get_freebusy_url($email)
|
||||
{
|
||||
$host = self::$config['kolab']['freebusy']['server'] ? self::$config['kolab']['freebusy']['server'] : self::$config['kolab']['imap']['server'];
|
||||
return 'https://' . $host . '/freebusy/' . $email . '.ifb';
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup session data when done
|
||||
|
|
Loading…
Add table
Reference in a new issue