diff --git a/plugins/calendar/skins/larry/calendar.css b/plugins/calendar/skins/larry/calendar.css index 50c5910b..f2087fe4 100644 --- a/plugins/calendar/skins/larry/calendar.css +++ b/plugins/calendar/skins/larry/calendar.css @@ -1,2329 +1,2329 @@ /** * Roundcube Calendar plugin styles for skin "Larry" * * Copyright (c) 2012-2014, Kolab Systems AG * Screendesign by FLINT / Büro für Gestaltung, bueroflint.com * * The contents are subject to the Creative Commons Attribution-ShareAlike * License. It is allowed to copy, distribute, transmit and to adapt the work * by keeping credits to the original autors in the README file. * See http://creativecommons.org/licenses/by-sa/3.0/ for details. */ body.calendarmain { overflow: hidden; } body.calendarmain #mainscreen { left: 0; } /* overrides for tablets and mobile phones */ @media screen and (max-device-width: 1024px){ body.calendarmain { overflow: visible; } body.calendarmain #mainscreen { min-width: 1000px !important; min-height: 520px !important; } body.calendarmain #header { min-width: 1020px !important; } } #calendarsidebar { position: absolute; top: 0; left: 10px; bottom: 0; width: 250px; } #datepicker { position: absolute; top: 40px; left: 0; width: 100%; min-height: 190px; } #datepicker .ui-datepicker { width: 100% !important; box-shadow: none; -moz-box-shadow: none; -webkit-box-shadow: none; } #datepicker .ui-datepicker td a { padding: 5px 4px; font-size: 12px; } #datepicker td.ui-datepicker-activerange { border-color: #69a2b6; } #datepicker .ui-datepicker-activerange a { color: #185d7a; background: #d9f1fb; background: -moz-linear-gradient(top, #d9f1fb 0%, #c5e3ee 100%); background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#d9f1fb), color-stop(100%,#c5e3ee)); background: -o-linear-gradient(top, #d9f1fb 0%, #c5e3ee 100%); background: -ms-linear-gradient(top, #d9f1fb 0%, #c5e3ee 100%); background: linear-gradient(top, #d9f1fb 0%, #c5e3ee 100%); filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#d9f1fb', endColorstr='#c5e3ee', GradientType=0); } #datepicker .ui-datepicker-days-cell-over a.ui-state-default { color: #fff; border-color: #2fa0c0; background: rgba(73,180,210,0.6); text-shadow: 0px 1px 1px #666; filter: none; } #datepicker .ui-datepicker-activerange a.ui-state-active { color: #fff; background: #00acd4; background: -moz-linear-gradient(top, #00acd4 0%, #008fc7 100%); background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#00acd4), color-stop(100%,#008fc7)); background: -o-linear-gradient(top, #00acd4 0%, #008fc7 100%); background: -ms-linear-gradient(top, #00acd4 0%, #008fc7 100%); background: linear-gradient(top, #00acd4 0%, #008fc7 100%); filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00acd4', endColorstr='#008fc7', GradientType=0); } #datepicker td.ui-datepicker-week-col { cursor: pointer; } #datepicker .ui-datepicker-title { margin: 2px 2.3em 3px 2.3em; } #datepicker .ui-datepicker .ui-datepicker-prev, #datepicker .ui-datepicker .ui-datepicker-next { top: 4px; } #calsidebarsplitter { position: absolute; left: 264px; width: 6px; top: 40px !important; bottom: 0; background: url(images/toggle.gif) -1px 48% no-repeat transparent; } div.sidebarclosed { background-position: -8px 48% !important; cursor: pointer; } #calsidebarsplitter:hover { background-color: #ddd; } #calendar { position: absolute; top: 0; left: 276px; right: 0; bottom: 0; } .calendarmain #message.statusbar { border: 1px solid #c3c3c3; border-bottom-color: #ababab; } #timezonedisplay { position: absolute; bottom: 5px; right: 12px; font-size: 0.85em; color: #666; } #print { width: 680px; } pre { font-family: "Lucida Grande", Verdana, Arial, Helvetica, sans-serif; } #calendars { position: absolute; top: 276px; left: 0; bottom: 0; right: 0; } #calendars .boxtitle { position: relative; } #calendars .boxtitle a.iconbutton.search { position: absolute; top: 8px; right: 8px; width: 16px; cursor: pointer; background-position: -2px -317px; } #calendars .listsearchbox { display: none; } #calendars .listsearchbox.expanded { display: block; } #calendars .scroller { top: 34px; } #calendars .listsearchbox.expanded + .scroller { top: 68px; } #calendars .treelist li { margin: 0; position: relative; } #calendars .treelist li div.folder, #calendars .treelist li div.calendar { position: relative; height: 28px; } #calendars .treelist li div.virtual { height: 22px; } #calendars .treelist li span.calname { display: block; padding: 0px 18px 2px 2px; position: absolute; top: 7px; left: 38px; right: 45px; cursor: default; background: url(images/calendars.png) right 20px no-repeat; overflow-x: hidden; text-overflow: ellipsis; white-space: nowrap; color: #004458; } .quickview-active #calendars .treelist div input, .quickview-active #calendars .treelist div .calname { opacity: 0.35; } .quickview-active #calendars .treelist div.focusview .calname { opacity: 1.0; background-image: none; } #calendars .treelist li div.virtual > span.calname { color: #aaa; top: 4px; left: 20px; } #calendars .treelist li.x-birthdays span.calname, #calendars .treelist li.x-invitations span.calname { font-style: italic; } #calendars .treelist.flat li span.calname { left: 24px; right: 42px; } #calendars .treelist li span.handle { display: inline-block; position: absolute; top: 8px; right: 6px; padding: 0; width: 10px; height: 10px; border-radius: 7px; font-size: 0.8em; border: 1px solid rgba(0, 0, 0, 0.5); -webkit-box-shadow: inset 0px 0 1px 1px rgba(0, 0, 0, 0.3); -moz-box-shadow: inset 0px 0 1px 1px rgba(0, 0, 0, 0.3); box-shadow: inset 0px 0 1px 1px rgba(0, 0, 0, 0.3); } #calendars .treelist div span.actions { display: inline-block; position: absolute; top: 2px; right: 22px; padding: 5px 20px 0 6px; /* min-width: 40px; */ height: 19px; text-align: right; z-index: 4; } #calendars .treelist div:hover span.actions { top: 1px; right: 21px; border: 1px solid #c6c6c6; border-radius: 4px; background: #f7f7f7; background: -moz-linear-gradient(top, #f9f9f9 0%, #e6e6e6 100%); background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#f9f9f9), color-stop(100%,#e6e6e6)); background: -o-linear-gradient(top, #f9f9f9 0%, #e6e6e6 100%); background: -ms-linear-gradient(top, #f9f9f9 0%, #e6e6e6 100%); background: linear-gradient(top, #f9f9f9 0%, #e6e6e6 100%); filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#f9f9f9', endColorstr='#e6e6e6', GradientType=0); } #calendars .treelist li a.subscribed { display: inline-block; position: absolute; top: 5px; right: 3px; height: 16px; width: 16px; padding: 0; background: url(images/calendars.png) -100px 0 no-repeat; overflow: hidden; text-indent: -5000px; cursor: pointer; } #calendars .treelist div:hover a.subscribed, #calendars .treelist div a.subscribed:focus { background-position: 0 -110px; } #calendars .treelist div.subscribed a.subscribed, #calendars .treelist div.subscribed a.subscribed:focus { background-position: -16px -110px; } #calendars .treelist div.subscribed.partial a.subscribed, #calendars .treelist div.subscribed.partial a.subscribed:focus { background-position: -16px -148px; } #calendars .treelist div a.remove:focus, #calendars .treelist div a.quickview:focus, #calendars .treelist div a.subscribed:focus { border-radius: 3px; outline: 2px solid rgba(30,150,192, 0.5); } #calendars .treelist div a.remove, #calendars .treelist div a.quickview { display: inline-block; width: 16px; height: 16px; margin-right: 4px; padding: 0; background: url(images/calendars.png) -100px 0 no-repeat; overflow: hidden; text-indent: -5000px; cursor: pointer; } #calendars .treelist div a.quickview:focus, #calendars .treelist div:hover a.quickview { background-position: 0 -128px; background-color: transparent !important; } #calendars .treelist div.focusview a.quickview { background-position: -16px -128px; } #calendars .treelist div a.remove:focus, #calendars .treelist div:hover a.remove { background-position: -16px -168px; background-color: transparent !important; } #calendars .searchresults .treelist div a.remove { display: none; } #calendars .treelist li input { position: absolute; top: 5px; left: 18px; } #calendars .treelist li div.treetoggle { top: 8px; } #calendars .treelist li.virtual div.treetoggle { top: 6px; } #calendars .treelist.flat li input { left: 4px; } #calendars .treelist ul li div.folder, #calendars .treelist ul li div.calendar { margin-left: 16px; } #calendars .treelist ul ul li div.folder, #calendars .treelist ul ul li div.calendar { margin-left: 32px; } #calendars .treelist ul ul ul li div.folder, #calendars .treelist ul ul ul li div.calendar { margin-left: 48px; } #calendars .treelist li.selected > div.calendar { background-color: #c7e3ef; } #calendars .treelist li.selected > span.calname { font-weight: bold; } #calendars .treelist div.readonly span.calname { background-position: right -20px; } #calendars .treelist li.user > div > span.calname { background-position: right -38px; } /* #calendars .treelist div.user.readonly span.calname { background-position: right -56px; } #calendars .treelist div.shared span.calname { background-position: right -74px; } #calendars .treelist div.shared.readonly span.calname { background-position: right -92px; } */ #calendars .treelist .calendar .count { position: absolute; display: inline-block; top: 5px; right: 68px; min-width: 1.3em; padding: 2px 4px; background: #005d76; background: -moz-linear-gradient(top, #005d76 0%, #004558 100%); background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#005d76), color-stop(100%,#004558)); background: -o-linear-gradient(top, #005d76 0%, #004558 100%); background: -ms-linear-gradient(top, #005d76 0%, #004558 100%); background: linear-gradient(to bottom, #005d76 0%, #004558 100%); -webkit-box-shadow: inset 0 1px 1px 0 #002635; box-shadow: inset 0 1px 1px 0 #002635; border-radius: 10px; color: #fff; text-align: center; font-style: normal; font-weight: bold; text-shadow: none; z-index: 3; } #calendars .searchresults { background: #b0ccd7; margin-top: 8px; } #calendars .searchresults .boxtitle { background: none; padding: 2px 8px; border-radius: 0; } #calendars .searchresults .listing li { background-color: #c7e3ef; } #calfeedurl, #caldavurl { width: 98%; background: #fbfbfb; padding: 4px; margin-bottom: 1em; resize: none; } #agendalist { width: 100%; margin: 0 auto; margin-top: 60px; border: 1px solid #C1DAD7; display: none; } #agendalist table { width: 100%; } #agendalist td, #agendalist th { border-right: 1px solid #C1DAD7; border-bottom: 1px solid #C1DAD7; background: #fff; padding: 6px 6px 6px 12px; } #agendalist tr { vertical-align: top; } #agendalist th { font-weight: bold; } #calendartoolbar { position: absolute; top: -6px; left: 0; height: 40px; white-space: nowrap; } #calendartoolbar a.button { background-image: url(images/toolbar.png); padding-left: 0; padding-right: 0; min-width: 50px; max-width: 60px; } #calendartoolbar a.button.addevent { background-position: center 1px; max-width: 70px; } #calendartoolbar a.button.export { background-position: center -40px; } #calendartoolbar a.button.import { background-position: center -440px; } #calendartoolbar a.button.print { background-position: center -80px; } body.calendarmain #quicksearchbar { z-index: 20; } body.calendarmain #searchmenulink { width: 15px; } #eventedit.uidialog, .calendarmain div.uidialog { display: none; } #user { position: absolute; top: 10px; right: 100px; left: 100px; text-align: center; } a.morelink { font-size: 90%; color: #0069a6; text-decoration: none; } a.morelink:hover { text-decoration: underline; } a.miniColors-trigger { margin-top: -3px; } .calendar.attachmentwin #attachmenttoolbar { position: relative; top: -6px; height: 40px; } .calendar.attachmentwin #attachmentcontainer { position: absolute; top: 0; left: 232px; right: 0; bottom: 0; } .calendar.attachmentwin #attachmentframe { width: 100%; height: 100%; border: 0; background-color: #fff; border-radius: 4px; } .calendar.attachmentwin #partheader { position: absolute; top: 0; left: 0; width: 220px; bottom: 0; } .calendar.attachmentwin #partheader table { table-layout: fixed; overflow: hidden; } .calendar.attachmentwin #partheader table td { color: #666; padding: 4px 6px; text-overflow: ellipsis; overflow: hidden; } .calendar.attachmentwin #partheader table td.header { font-weight: bold; } .calendar.attachmentwin #partheader table td.title { width: 60px; padding-right: 0; } #edit-attachments { margin: 0.6em 0; } #edit-attachments ul li { display: block; color: #333; font-weight: bold; padding: 4px 4px 3px 30px; text-shadow: 0px 1px 1px #fff; text-decoration: none; white-space: nowrap; line-height: 20px; } #edit-attachments ul li a.file { padding: 0; } #edit-attachments-form { margin-top: 1em; padding-top: 0.8em; border-top: 2px solid #fafafa; } #edit-attachments-form .buttons { margin: 0.5em 0; } #eventedit .droptarget { background-image: url(../../../../skins/larry/images/filedrop.png) !important; background-position: center bottom !important; background-repeat: no-repeat !important; } #eventedit .droptarget.hover, #eventedit .droptarget.active { border-color: #019bc6; box-shadow: 0 0 3px 2px rgba(71,135,177, 0.5); -moz-box-shadow: 0 0 3px 2px rgba(71,135,177, 0.5); -webkit-box-shadow: 0 0 3px 2px rgba(71,135,177, 0.5); -o-box-shadow: 0 0 3px 2px rgba(71,135,177, 0.5); } #eventedit .droptarget.hover { background-color: #d9ecf4; box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); -moz-box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); -webkit-box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); -o-box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); } #event-attachments .attachmentslist li { float: left; margin-right: 1em; } #event-attachments .attachmentslist li a { outline: none; } #event-panel-attachments.disabled .attachmentslist li a.delete { visibility: hidden; } .event-attendees span.attendee { padding-right: 18px; margin-right: 0.5em; background: url(images/attendee-status.png) right 0 no-repeat; } .event-attendees span.attendee a.mailtolink { text-decoration: none; white-space: nowrap; outline: none; } .event-attendees span.attendee a.mailtolink:hover { text-decoration: underline; } .event-attendees span.accepted { background-position: right -20px; } .event-attendees span.declined { background-position: right -40px; } .event-attendees span.tentative { background-position: right -60px; } .event-attendees span.delegated { background-position: right -180px; } .event-attendees span.organizer { background-position: right -80px; } #all-event-attendees span.attendee { display: block; margin-bottom: 0.4em; padding-bottom: 0.3em; border-bottom: 1px solid #ddd; } .calendarmain .fc-view-table td.fc-list-header, #attendees-freebusy-table h3.boxtitle, #schedule-freebusy-times thead th, .edit-attendees-table thead th { color: #69939e; font-size: 11px; font-weight: bold; background: #d6eaf3; background: -moz-linear-gradient(left, #e3f2f6 0, #d6eaf3 14px, #d6eaf3 100%); background: -webkit-gradient(linear, left top, right top, color-stop(0,#e3f2f6), color-stop(8%,#d6eaf3), color-stop(100%,#d6eaf3)); background: -o-linear-gradient(left, #e3f2f6 0, #d6eaf3 14px, #d6eaf3 100%); background: -ms-linear-gradient(left, #e3f2f6 0, #d6eaf3 14px ,#d6eaf3 100%); background: linear-gradient(left, #e3f2f6 0, #d6eaf3 14px, #d6eaf3 100%); border: 0; border-bottom: 1px solid #ccc; height: 18px; line-height: 18px; padding: 8px 7px 3px 7px; } /* jQuery UI overrides */ .calendarmain .eventdialog h1 { font-size: 18px; margin: -0.3em 0 0.4em 0; } .calendarmain .eventdialog label, .calendarmain .eventdialog h5.label { font-weight: normal; font-size: 1em; color: #999; margin: 0 0 0.2em 0; } .calendarmain .eventdialog label span.index, .calendarmain .eventdialog h5.label .index { vertical-align: inherit; margin-left: 0.6em; } .calendarmain .eventdialog { margin: 0 -0.2em; } .calendarmain .eventdialog.status-cancelled { background: url(images/badge_cancelled.png) top right no-repeat; } .calendarmain .eventdialog.sensitivity-private { background: url(images/badge_private.png) top right no-repeat; } .calendarmain .eventdialog.sensitivity-confidential { background: url(images/badge_confidential.png) top right no-repeat; } .calendarmain .sensitivity-private #event-title { margin-right: 50px; } .calendarmain .sensitivity-confidential #event-title { margin-right: 60px; } .calendarmain .eventdialog div.event-line { margin-top: 0.1em; margin-bottom: 0.3em; } .calendarmain .eventdialog div.event-line a.iconbutton { margin-left: 0.5em; line-height: 17px; } .calendarmain .eventdialog div.event-line span.event-text + label { margin-left: 2em; } .calendarmain .eventdialog #event-rsvp-comment, .calendarmain .eventdialog #event-created-changed { margin-top: 0.6em; } .eventdialog .event-text-old, .eventdialog .event-text-new, .eventdialog .event-text-diff { padding: 2px; } .eventdialog .event-text-diff del, .eventdialog .event-text-diff ins { text-decoration: none; color: inherit; } .eventdialog .event-text-old, .eventdialog .event-text-diff del { background-color: #fdd; /* text-decoration: line-through; */ } .eventdialog .event-text-new, .eventdialog .event-text-diff ins { background-color: #dfd; } #eventdiff .attachmentslist li a, #eventdiff .attachmentslist li a:hover { cursor: default; text-decoration: none; } .changelog-table .loading { color: #666; margin: 1em 0; padding: 1px 0 2px 24px; background: url(images/loading_blue.gif) top left no-repeat; } .changelog-dialog .compare-button { margin: 4px 0; } .changelog-table tbody td { padding: 4px 7px; vertical-align: middle; } .changelog-table tbody tr:last-child td { border-bottom: 0; } .changelog-table tbody tr.undisclosed td.date, .changelog-table tbody tr.undisclosed td.user { font-style: italic; } .changelog-table .diff { width: 4em; padding: 2px; } .changelog-table .revision { width: 6em; } .changelog-table .date { width: 11em; } .changelog-table .user { width: auto; } .changelog-table .operation { width: 15%; } .changelog-table .actions { width: 50px; text-align: right; padding: 4px; } .changelog-table td a.iconbutton.restore, .changelog-table td a.iconbutton.preview { width: 16px; margin-right: 2px; background-image: url(images/calendars.png); background-position: -1px -147px; } .changelog-table td a.iconbutton.restore { background-image: url(images/calendars.png); background-position: -1px -167px; } .changelog-table tr.first td a.iconbutton { opacity: 0.3; cursor: default; } #event-partstat .changersvp { cursor: pointer; color: #333; text-decoration: none; } #event-partstat .iconbutton { visibility: hidden; } #event-partstat .changersvp:focus .iconbutton, #event-partstat:hover .iconbutton { visibility: visible; } #eventedit { position: relative; top: -1.5em; padding: 0.5em 0.1em; margin: 0 -0.2em; } #eventedit input.text, #eventedit textarea { width: 97%; } #eventtabs { position: relative; padding: 0; border: 0; border-radius: 0; } div.form-section, .calendarmain .eventdialog div.event-section, #eventtabs div.event-section { margin-top: 0.2em; margin-bottom: 0.6em; } #eventtabs .border-after { padding-bottom: 0.8em; margin-bottom: 0.8em; border-bottom: 2px solid #fafafa; } .calendarmain .eventdialog label, #eventedit label, .form-section label { display: inline-block; min-width: 7em; padding-right: 0.5em; } .calendarmain .eventdialog #event-url .event-text { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } #event-links .attachmentslist { display: inline-block; } #event-links label, #edit-event-links label { float: left; margin-top: 0.3em; padding-right: 0.75em; } #edit-event-links .event-text { margin-left: 8em; min-height: 22px; } #edit-event-links .attachmentslist li.message a.messagelink, #event-links .attachmentslist li.message a.messagelink { padding: 0 0 0 24px; } #edit-event-links .attachmentslist li a.delete { top: 0; background-position: -6px -378px; } #edit-event-links .attachmentslist li.deleted a.messagelink, #edit-event-links .attachmentslist li.deleted a.messagelink:hover { text-decoration: line-through; } #eventedit .formtable td.label { min-width: 6em; } td.topalign { vertical-align: top; } #eventedit label.weekday, #eventedit label.monthday { min-width: 3em; } #eventedit label.month { min-width: 5em; } #eventedit .formtable td { padding: 0.2em 0; } .ui-dialog .event-update-confirm { padding: 0 0.5em 0.5em 0.5em; } .event-dialog-message, .event-update-confirm .message { margin-top: 0.5em; padding: 0.8em; border: 1px solid #ffdf0e; background-color: #fef893; } .event-dialog-message .message, .event-update-confirm .message { margin-bottom: 0.5em; } .edit-recurring-warning .savemode { padding-left: 20px; } .event-update-confirm .savemode { padding-left: 30px; } .event-dialog-message span.ui-icon, .event-update-confirm span.ui-icon { float: left; margin: 0 7px 20px 0; } .event-dialog-message label, .event-update-confirm label { min-width: 3em; padding-right: 1em; } .event-update-confirm a.button { margin: 0 0.5em 0 0.2em; min-width: 5em; text-align: center; } .libcal-rsvp-replymode li a { cursor: default; } #event-rsvp, #edit-attendees-notify { margin: 0.6em 0 0.3em 0; padding: 0.5em; } #event-rsvp .itip-reply-controls { margin-top: 0.5em; } #event-rsvp .itip-reply-controls label { color: #333; } #event-rsvp .itip-reply-controls textarea { width: 95%; } #eventedit .edit-attendees-table { width: 100%; margin-top: 0.5em; } #eventedit .edit-attendees-table th.role, #eventedit .edit-attendees-table td.role { width: 9em; } #eventedit .edit-attendees-table th.availability, #eventedit .edit-attendees-table td.availability, #eventedit .edit-attendees-table th.confirmstate, #eventedit .edit-attendees-table td.confirmstate { width: 4em; } #eventedit .edit-attendees-table th.options, #eventedit .edit-attendees-table td.options { width: 16px; padding: 2px 4px; } #eventedit .edit-attendees-table th.invite, #eventedit .edit-attendees-table td.invite { - width: 44px; + width: 50px; padding: 2px; } #eventedit .edit-attendees-table th.invite label { display: inline-block; position: relative; top: 4px; width: 24px; height: 18px; min-width: 24px; padding: 0; overflow: hidden; text-indent: -5000px; white-space: nowrap; background: url(images/sendinvitation.png) 1px 0 no-repeat; } #eventedit .edit-attendees-table tbody tr:last-child td { border-bottom: 0; } #eventedit .edit-attendees-table th.name, #eventedit .edit-attendees-table td.name { width: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #eventedit .edit-attendees-table td.name select { width: 100%; } #eventedit .edit-attendees-table td.name .attendee-name { display: block; position: relative; overflow: hidden; text-overflow: ellipsis; padding: 5px 7px 6px; margin: -5px -7px -6px; } #eventedit .edit-attendees-table a.deletelink { display: inline-block; width: 17px; height: 17px; padding: 0; overflow: hidden; text-indent: 1000px; } #eventedit .edit-attendees-table a.expandlink { position: absolute; top: 4px; right: 6px; width: 16px; height: 16px; } #edit-attendees-form, #edit-resources-form { position: relative; margin-top: 15px; } #edit-attendees-form .attendees-invitebox { text-align: right; margin: 0; } #edit-attendees-form .attendees-invitebox label { padding-right: 3px; } #edit-resources-form #edit-resource-find { position: absolute; top: 0; right: 0; } #edit-attendees-form #edit-attendee-schedule { position: absolute; right: 0; top: 0; } .edit-attendees-table select.edit-attendee-role { border: 0; padding: 2px; background: white; width: 100%; } .availability img.availabilityicon { margin: 1px; width: 14px; height: 14px; border-radius: 4px; -moz-border-radius: 4px; } .availability img.availabilityicon.loading { background: url(images/loading_blue.gif) center no-repeat; } #schedule-freebusy-times td.unknown, .availability img.availabilityicon.unknown { background: #ddd; } #schedule-freebusy-times td.free, .availability img.availabilityicon.free { background: #abd640; } #schedule-freebusy-times td.busy, .availability img.availabilityicon.busy { background: #e26569; } #schedule-freebusy-times td.tentative, .availability img.availabilityicon.tentative { background: #8383fc; } #schedule-freebusy-times td.out-of-office, .availability img.availabilityicon.out-of-office { background: #fbaa68; } #schedule-freebusy-times td.all-busy, #schedule-freebusy-times td.all-tentative, #schedule-freebusy-times td.all-out-of-office { background-image: url(images/freebusy-colors.png); background-position: top right; background-repeat: no-repeat; } #schedule-freebusy-times td.all-tentative { background-position: right -40px; } #schedule-freebusy-times td.all-out-of-office { background-position: right -80px; } #edit-attendees-legend { margin-top: 3em; margin-bottom: 0.5em; } #edit-attendees-legend .legend { margin-right: 2em; white-space: nowrap; } #edit-attendees-legend img.availabilityicon { vertical-align: middle; } .edit-attendees-table tbody td.confirmstate { overflow: hidden; white-space: nowrap; text-indent: -2000%; } .edit-attendees-table td.confirmstate span { display: block; width: 20px; background: url(images/attendee-status.png) 5px 0 no-repeat; } .edit-attendees-table td.confirmstate span.needs-action { } .edit-attendees-table td.confirmstate span.accepted { background-position: 5px -20px; } .edit-attendees-table td.confirmstate span.declined { background-position: 5px -40px; } .edit-attendees-table td.confirmstate span.tentative { background-position: 5px -60px; } .edit-attendees-table td.confirmstate span.delegated { background-position: 5px -180px; } #attendees-freebusy-table { width: 100%; table-layout: fixed; border: 1px solid #bbd3da; } #attendees-freebusy-table td.attendees { width: 18em; vertical-align: top; overflow: hidden; } #attendees-freebusy-table td.times { width: auto; vertical-align: top; } #attendees-freebusy-table div.scroll { position: relative; overflow: auto; } #attendees-freebusy-table h3.boxtitle { margin: 0; border-color: #ccc; } .attendees-list .attendee { padding: 4px 4px 4px 1px; background: url(images/attendee-status.png) 2px -97px no-repeat; white-space: nowrap; } .attendees-list a.attendee-role-toggle { display: inline-block; width: 16px; margin-right: 3px; cursor: pointer; } .attendees-list div.attendee { border-top: 1px solid #ccc; } .attendees-list span.attendee { padding-left: 20px; margin-right: 2em; } .attendees-list .organizer { background-position: 3px -77px; } .attendees-list .opt-participant { background-position: 2px -117px; } .attendees-list .non-participant { background-position: 2px -137px; } .attendees-list .chair { background-position: 2px -157px; } .attendees-list .loading { background: url(images/loading_blue.gif) 1px 50% no-repeat; } .attendees-list .total { background: none; padding-left: 4px; font-weight: bold; } .attendees-list .spacer, #schedule-freebusy-times tr.spacer td { background: 0; font-size: 50%; } #schedule-freebusy-times { border-collapse: collapse; width: 100%; } #schedule-freebusy-times td { padding: 4px; border: 1px solid #ccc; } #attendees-freebusy-table div.timesheader, #schedule-freebusy-times tr.times td { min-width: 30px; font-size: 9px; padding: 5px 2px 6px 2px; text-align: center; color: #004658; } #schedule-freebusy-times tr.times td.allday { min-width: 60px; } #schedule-freebusy-times tr.times td { cursor: pointer; } #schedule-event-time { position: absolute; border: 2px solid #333; background: #777; background: rgba(60, 60, 60, 0.6); opacity: 0.5; border-radius: 4px; cursor: move; filter: alpha(opacity=40); /* IE8 */ } #eventfreebusy .schedule-options { position: relative; margin-bottom: 1.5em; } #eventfreebusy .schedule-buttons { position: absolute; top: 0.5em; right: 0; margin-right: 0; } #eventfreebusy .schedule-find-buttons { padding-bottom:0.5em; } #eventfreebusy .schedule-find-buttons button { min-width: 9em; text-align: center; } #eventedit .attendees-commentbox label { display: block; } #eventedit .ui-tabs-panel { min-height: 24em; } #rcmKSearchpane ul li.resource i.icon, #rcmKSearchpane ul li.collection i.icon { background-image: url(images/autocomplete.png); background-position: -1px -2px; } #rcmKSearchpane ul li.collection i.icon { background-position: -1px -26px; } a.dropdown-link { font-size: 11px; text-decoration: none; } a.dropdown-link:after { content: ' ▼'; font-size: 10px; color: #666; } .ui-dialog-buttonset a.dropdown-link { position: relative; top: 2px; margin: 0 1em; color: #333; } #calendarsidebar .ui-datepicker-calendar { table-layout: fixed; } .ui-datepicker-calendar .ui-datepicker-week-col { border: 0; color: #999; font-size: 90%; text-align: right; padding-right: 6px; width: 20px; overflow: hidden; } .ui-autocomplete { max-height: 160px; overflow-y: auto; overflow-x: hidden; } .ui-autocomplete .ui-menu-item { white-space: nowrap; } * html .ui-autocomplete { height: 160px; } .calendarmain span.spacer { padding-left: 3em; } #agendaoptions { position: absolute; bottom: 0; left: 0; right: 0; height: auto; z-index: 10; padding: 4px 5px; border: 1px solid #c3c3c3; border-top-color: #ddd; border-bottom-color: #bbb; border-radius: 0 0 4px 4px; background: #ebebeb; background: -moz-linear-gradient(top, #ebebeb 0%, #c6c6c6 100%); background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#ebebeb), color-stop(100%,#c6c6c6)); background: -o-linear-gradient(top, #ebebeb 0%, #c6c6c6 100%); background: -ms-linear-gradient(top, #ebebeb 0%, #c6c6c6 100%); background: linear-gradient(top, #ebebeb 0%, #c6c6c6 100%); } #agendaoptions label { text-shadow: 1px 1px #fff; padding-right: 0.5em; } #calendar-kolabform { position: relative; margin: 0 -8px; min-width: 660px; min-height: 400px; } #calendar-kolabform table td.title { font-weight: bold; white-space: nowrap; color: #666; padding-right: 10px; } #resource-selection { position: absolute; top: 0; left: 8px; right: 0; bottom: 0; } #resource-selection .scroller { top: 34px; } #resource-dialog-left { position: absolute; top: 10px; left: 0; width: 380px; bottom: 10px; } #resource-dialog-right { position: absolute; top: 10px; left: 392px; right: 8px; bottom: 10px; } #resource-info { position: absolute; top: 0; left: 0; right: 0; height: 48%; } #resource-info table { margin: 8px; width: 97%; } #resource-info thead td { background: none; font-weight: bold; font-size: 14px; } #resource-availability { position: absolute; bottom: 0; left: 0; right: 0; height: 49%; } #resource-freebusy-calendar { position: absolute; top: 33px; left: -1px; right: -1px; bottom: -1px; } #resource-freebusy-calendar .fc-content { top: 0; } #resource-freebusy-calendar .fc-content .fc-event-bg { background: 0; } #resource-freebusy-calendar .fc-event.status-busy, #resource-freebusy-calendar .status-busy .fc-event-skin { border-color: #e26569; background-color: #e26569; } #resource-freebusy-calendar .fc-event.status-tentative, #resource-freebusy-calendar .status-tentative .fc-event-skin { border-color: #8383fc; background: #8383fc; } #resource-freebusy-calendar .fc-event.status-outofoffice, #resource-freebusy-calendar .status-outofoffice .fc-event-skin { border-color: #fbaa68; background: #fbaa68; } #resourcequicksearch { padding: 4px; background: #c7e3ef; } #resourcesearchbox { width: 100%; height: 26px; -moz-box-sizing: border-box; box-sizing: border-box; } #resourcequicksearch .iconbutton.searchoptions { position: absolute; top: 5px; left: 6px; width: 16px; } .searchbox .iconbutton.reset { position: absolute; top: 4px; right: 1px; } /* fullcalendar style overrides */ .rcube-fc-content { overflow: hidden; border: 0; border-radius: 4px; box-shadow: 0 0 2px #999; -o-box-shadow: 0 0 2px #999; -webkit-box-shadow: 0 0 2px #999; -moz-box-shadow: 0 0 2px #999; } .calendarmain .fc-content { position: absolute !important; top: 40px; left: 0; right: 0; bottom: 0; background: #fff; } .calendarmain.quickview-active .fc-content { background-image: url('images/focusview.png'); background-position: center; background-repeat: no-repeat; } #fish-eye-view .fc-content { top: 2px; bottom: 2px; } #quickview-calendar { padding: 8px; overflow: hidden; } .calendarmain .fc-button, .calendarmain .fc-button.fc-state-default, .calendarmain .fc-button.fc-state-hover { background-color: #f5f5f5; background-image: -moz-linear-gradient(top, #ffffff, #e6e6e6); background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), to(#e6e6e6)); background-image: -webkit-linear-gradient(top, #ffffff, #e6e6e6); background-image: -o-linear-gradient(top, #ffffff, #e6e6e6); background-image: linear-gradient(to bottom, #ffffff, #e6e6e6); background-position: 0 0; } .calendarmain #calendar .fc-button, .calendarmain #calendar .fc-button.fc-state-default, .calendarmain #calendar .fc-button.fc-state-hover { margin: 0 0 0 0; height: 20px; line-height: 20px; color: #505050; text-shadow: 0px 1px 1px #fff; border: 1px solid #e6e6e6; background: #d8d8d8; background: -moz-linear-gradient(top, #d8d8d8 0%, #bababa 100%); background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#d8d8d8), color-stop(100%,#bababa)); background: -o-linear-gradient(top, #d8d8d8 0%, #bababa 100%); background: -ms-linear-gradient(top, #d8d8d8 0%, #bababa 100%); background: linear-gradient(top, #d8d8d8 0%, #bababa 100%); box-shadow: 0 1px 1px 0 #999; -o-box-shadow: 0 1px 1px 0 #999; -webkit-box-shadow: 0 1px 1px 0 #999; -moz-box-shadow: 0 1px 1px 0 #999; text-decoration: none; } .calendarmain #calendar .fc-button.fc-state-disabled { color: #999; background: #d8d8d8; } .calendarmain .fc-button.fc-state-active, .calendarmain .fc-button.fc-state-down, .calendarmain #calendar .fc-button.fc-state-active, .calendarmain #calendar .fc-button.fc-state-down { color: #333; background: #bababa; background: -moz-linear-gradient(top, #bababa 0%, #d8d8d8 100%); background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#bababa), color-stop(100%,#d8d8d8)); background: -o-linear-gradient(top, #bababa 0%, #d8d8d8 100%); background: -ms-linear-gradient(top, #bababa 0%, #d8d8d8 100%); background: linear-gradient(top, #bababa 0%, #d8d8d8 100%); } .calendarmain #calendar .fc-header .fc-button { margin-left: -1px; margin-right: 0; } .calendarmain #calendar .fc-header-left .fc-button { display: inline-block; margin: 0; text-align: center; font-size: 10px; color: #555; min-width: 50px; max-width: 75px; height: 13px; line-height: 1em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin: -7px 0 0 0; padding: 28px 2px 0 2px; text-shadow: 0px 1px 1px #EEE; border: 0; background: url(images/toolbar.png) center 100px no-repeat; box-shadow: none; -o-box-shadow: none; -webkit-box-shadow: none; -moz-box-shadow: none; outline: none; } .calendarmain #calendar .fc-header-left .fc-button:focus { color: #fff; text-shadow: 0px 1px 1px #666; background-color: rgba(30,150,192, 0.5); border-radius: 3px; } .calendarmain #calendar .fc-header-left .fc-button.fc-state-active { font-weight: bold; color: #222; text-shadow: none; background-color: transparent; } .calendarmain #calendar .fc-header-left .fc-button-agendaDay { background-position: center -120px; } .calendarmain #calendar .fc-header-left .fc-button-agendaDay.fc-state-active { background-position: center -160px; } .calendarmain #calendar .fc-header-left .fc-button-agendaWeek { background-position: center -200px; } .calendarmain #calendar .fc-header-left .fc-button-agendaWeek.fc-state-active { background-position: center -240px; } .calendarmain #calendar .fc-header-left .fc-button-month { background-position: center -280px; } .calendarmain #calendar .fc-header-left .fc-button-month.fc-state-active { background-position: center -320px; } .calendarmain #calendar .fc-header-left .fc-button-table { background-position: center -360px; } .calendarmain #calendar .fc-header-left .fc-button-table.fc-state-active { background-position: center -400px; } .calendarmain #calendar .fc-header-right { padding-right: 252px; padding-top: 4px; } .calendarmain #calendar .fc-header-title { padding-top: 5px; } .fc-event { font-size: 1em !important; } .fc-event-hori.fc-type-freebusy, .fc-event-vert.fc-type-freebusy { opacity: 0.60; /* color: #fff !important; background: rgba(80,80,80,0.85) !important; background: -moz-linear-gradient(top, rgba(80,80,80,0.85) 0%, rgba(48,48,48,0.9) 100%) !important; background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(80,80,80,0.85)), color-stop(100%,rgba(48,48,48,0.9))) !important; background: -webkit-linear-gradient(top, rgba(80,80,80,0.85) 0%, rgba(48,48,48,0.85) 100%) !important; background: -o-linear-gradient(top, rgba(80,80,80,0.85) 0%, rgba(48,48,48,0.85) 100%) !important; background: -ms-linear-gradient(top, rgba(80,80,80,0.85) 0%, rgba(48,48,48,0.85) 100%) !important; background: linear-gradient(to bottom, rgba(80,80,80,0.85) 0%, rgba(48,48,48,0.85) 100%) !important; border-color: #444 !important; cursor: default !important; */ -moz-box-shadow: inset 0px 1px 0 0px #888; -webkit-box-shadow: inset 0px 1px 0 0px #888; -o-box-shadow: inset 0px 1px 0 0px #888; box-shadow: inset 0px 1px 0 0px #888; } .fc-event-row.fc-type-freebusy td { color: #999; } .fc-event-hori.fc-type-freebusy .fc-event-skin, .fc-event-hori.fc-type-freebusy .fc-event-inner, .fc-event-vert.fc-type-freebusy .fc-event-skin, .fc-event-vert.fc-type-freebusy .fc-event-inner { /* background-color: transparent !important; border-color: #444 !important; color: #fff !important; text-shadow: 0 1px 1px #000; */ } .fc-event-hori.fc-type-freebusy .fc-event-title, .fc-event-vert.fc-type-freebusy .fc-event-title { position: absolute; top: -5000px; } .fc-event-vert.fc-invitation-needs-action, .fc-event-hori.fc-invitation-needs-action { border: 1px dashed #5757c7 !important; } .fc-event-vert.fc-invitation-tentative, .fc-event-hori.fc-invitation-tentative { border: 1px dashed #eb8900 !important; } .fc-event-vert.fc-invitation-declined, .fc-event-hori.fc-invitation-declined { border: 1px dashed #c00 !important; } .fc-event-vert.fc-event-ns-other.fc-invitation-declined, .fc-event-hori.fc-event-ns-other.fc-invitation-declined { opacity: 0.7; } .fc-event-ns-other.fc-invitation-declined .fc-event-title { text-decoration: line-through; } .fc-event-vert.fc-invitation-tentative .fc-event-head, .fc-event-vert.fc-invitation-declined .fc-event-head, .fc-event-vert.fc-invitation-needs-action .fc-event-head { /* background-color: transparent !important; */ } .fc-event-vert.fc-invitation-tentative .fc-event-bg { background: url(data:image/gif;base64,R0lGODlhCAAIAPABAOuJAP///yH/C1hNUCBEYXRhWE1QAT8AIfkEBQAAAQAsAAAAAAgACAAAAg4Egmipx+ZaDPCtVPFNBQA7) 0 0 repeat #fff; } .fc-event-vert.fc-invitation-needs-action .fc-event-bg { background: url(data:image/gif;base64,R0lGODlhCAAIAPABAFdXx////yH/C1hNUCBEYXRhWE1QAT8AIfkEBQAAAQAsAAAAAAgACAAAAg4Egmipx+ZaDPCtVPFNBQA7) 0 0 repeat #fff; } .fc-event-vert.fc-invitation-declined .fc-event-bg { background: url(data:image/gif;base64,R0lGODlhCAAIAPABAMwAAP///yH/C1hNUCBEYXRhWE1QAT8AIfkEBQAAAQAsAAAAAAgACAAAAg4Egmipx+ZaDPCtVPFNBQA7) 0 0 repeat #fff; } .fc-view-table tr.fc-invitation-tentative td, .fc-view-table tr.fc-invitation-declined td, .fc-view-table tr.fc-invitation-needs-action td { color: #888; } .fc-view-table tr.fc-invitation-tentative td.fc-event-title, .fc-view-table tr.fc-invitation-declined td.fc-event-title, .fc-view-table tr.fc-invitation-needs-action td.fc-event-title { font-weight: normal; } #quickview-calendar .fc-view-table tr.fc-invitation-tentative td, #quickview-calendar .fc-view-table tr.fc-invitation-declined td, #quickview-calendar .fc-view-table tr.fc-invitation-needs-action td { color: #333; } .calendarmain .fc-event:focus { outline: 1px solid rgba(71,135,177, 0.4); -webkit-box-shadow: 0 0 2px 3px rgba(71,135,177, 0.6); -moz-box-shadow: 0 0 2px 3px rgba(71,135,177, 0.6); -o-box-shadow: 0 0 2px 3px rgba(71,135,177, 0.6); box-shadow: 0 0 2px 3px rgba(71,135,177, 0.6); } .fc-event-title { font-weight: bold; } .cal-event-status-cancelled .fc-event-title { text-decoration: line-through; } .fc-event-hori .fc-event-title { font-weight: normal; white-space: nowrap; } .fc-event-hori .fc-event-time { white-space: nowrap; font-weight: normal !important; font-size: 10px; padding-right: 0.6em; } .fc-grid .fc-event-time { font-weight: normal !important; padding-right: 0.3em; } .calendarmain .fc-event-vert .fc-event-inner { z-index: 0; } .fc-event-cateories { font-style:italic; } div.fc-event-location { font-size: 90%; } .fc-more-link { color: #999; padding-top: 1px; cursor: pointer; } .fc-agenda-slots td div { height: 22px; } .fc-sat, .fc-sun { background-color: rgba(198,198,198, 0.08); } .calendarmain .fc-state-highlight { background-color: rgba(233,198,14, 0.12); } .fc-widget-header { background-color: #d6eaf3; color: #004458; text-shadow: 0px 1px 1px #fff; } .fc-view thead th.fc-widget-header { padding: 8px 0; color: #69939e; } .fc-day-number { color: #578da5; } .fc-icon-alarms, .fc-icon-sensitive, .fc-icon-recurring { display: inline-block; width: 11px; height: 11px; background: url(images/eventicons.png) 0 0 no-repeat; margin-left: 3px; line-height: 10px; } .fc-icon-alarms { background-position: 0 -13px; } .fc-icon-sensitive { background-position: 0 -25px; } .fc-list-section .fc-event { cursor: pointer; } .calendarmain .fc-view-table td.fc-list-header { color: #004458; font-size: 12px; } .calendarmain .fc-view-table tr.fc-event td { border-color: #ddd; padding: 4px 7px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .calendarmain .fc-view-table tr.fc-event td.fc-event-handle { padding: 5px 0 2px 7px; width: 12px; } .calendarmain .fc-view-table .fc-event-handle .fc-event-skin { margin: 0; padding: 0; display: inline-block; width: 10px; height: 10px; font-size: 6px; border-radius: 8px; } .calendarmain .fc-view-table .fc-event-handle .fc-event-inner { display: inline-block; width: 10px; height: 10px; padding: 0; margin: -1px; font-size: 10px; border-radius: 8px; border: 1px solid rgba(0, 0, 0, 0.4); -webkit-box-shadow: inset 0px 0 1px 1px rgba(0, 0, 0, 0.3); -moz-box-shadow: inset 0px 0 1px 1px rgba(0, 0, 0, 0.3); box-shadow: inset 0px 0 1px 1px rgba(0, 0, 0, 0.3); } .calendarmain .fc-view-table col.fc-event-location { width: 25%; } .fc-view-table table.fc-list-smart { /* table-layout: auto; */ } .fc-listappend { text-align: center; margin: 1em 0; } .fc-listappend .message { padding: 0.5em; margin-bottom: 0.5em; font-size: 150%; color: #999; } .fc-listappend .formlinks a { font-size: 12px; padding: 0 0.3em; } .fc-event-temp { opacity: 0.4; filter: alpha(opacity=40); /* IE8 */ } /* Settings section */ fieldset #calendarcategories div { margin-bottom: 0.3em; } /* Invitation UI in mail */ .messagelist tbody .attachment span.ical { display: inline-block; vertical-align: middle; height: 18px; width: 20px; padding: 0; background: url(images/ical-attachment.png) 2px 1px no-repeat; } ul.toolbarmenu li a.calendarlink span.calendar, #attachmentmenu li a.calendarlink span.calendar { background-position: 0px -2197px; } div.calendar-invitebox { min-height: 20px; margin: 5px 8px; padding: 3px 6px 6px 34px; border: 1px solid #ffdf0e; background: url(images/calendar.png) 6px 5px no-repeat #fef893; } div.calendar-invitebox td.ititle { font-weight: bold; padding-right: 0.5em; } div.calendar-invitebox td { padding: 2px; } div.calendar-invitebox td.label { color: #666; padding-right: 1em; } div.calendar-invitebox td.sensitivity { color: #d31400; font-weight: bold; } div.calendar-invitebox td.recurrence-id { text-transform: uppercase; font-style: italic; } div.calendar-invitebox td em { font-weight: bold; } #event-rsvp .rsvp-buttons, div.calendar-invitebox .itip-buttons div { margin-top: 0.5em; } #event-rsvp input.button, div.calendar-invitebox input.button { font-weight: bold; margin-right: 0.5em; } div.calendar-invitebox input.button.preview { margin-left: 1em; margin-right: 0; } div.calendar-invitebox .folder-select { font-weight: 10px; margin-left: 1em; white-space: nowrap; } div.calendar-invitebox .rsvp-status { padding-left: 2px; } div.calendar-invitebox .rsvp-status.loading { color: #666; padding: 1px 0 2px 24px; background: url(images/loading_blue.gif) top left no-repeat; } div.calendar-invitebox .rsvp-status.hint { color: #666; text-shadow: none; font-style: italic; } #event-partstat .changersvp, div.calendar-invitebox .rsvp-status.declined, div.calendar-invitebox .rsvp-status.tentative, div.calendar-invitebox .rsvp-status.accepted, div.calendar-invitebox .rsvp-status.delegated, div.calendar-invitebox .rsvp-status.needs-action { padding: 0 0 1px 22px; background: url(images/attendee-status.png) 2px -20px no-repeat; } #event-partstat .changersvp.declined, div.calendar-invitebox .rsvp-status.declined { background-position: 2px -40px; } #event-partstat .changersvp.tentative, div.calendar-invitebox .rsvp-status.tentative { background-position: 2px -60px; } #event-partstat .changersvp.delegated, div.calendar-invitebox .rsvp-status.delegated { background-position: 2px -180px; } #event-partstat .changersvp.needs-action, div.calendar-invitebox .rsvp-status.needs-action { background-position: 2px 0; } div.calendar-invitebox .calendar-agenda-preview { display: none; border-top: 1px solid #dfdfdf; margin-top: 1em; padding-top: 0.6em; } div.calendar-invitebox .calendar-agenda-preview h3.preview-title { margin: 0 0 0.5em 0; font-size: 12px; color: #333; } div.calendar-invitebox .calendar-agenda-preview .event-row { color: #777; padding: 2px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } div.calendar-invitebox .calendar-agenda-preview .event-row.current { color: #333; font-weight: bold; } div.calendar-invitebox .calendar-agenda-preview .event-row.no-event { font-style: italic; } div.calendar-invitebox .calendar-agenda-preview .event-date { display: inline-block; min-width: 8em; margin-right: 1em; white-space: nowrap; } /* iTIP attend reply page */ .calendaritipattend .centerbox { width: 40em; min-height: 7em; margin: 80px auto 0 auto; padding: 10px 10px 10px 90px; background: url(images/invitation.png) 10px 10px no-repeat #fff; } .calendaritipattend #message { width: 46em; margin: 0 auto; padding: 10px; } .calendaritipattend .calendar-invitebox { background: none; padding-left: 0; border: 0; margin: 0 0 2em 0; } .calendaritipattend .calendar-invitebox .rsvp-status { margin-top: 2.5em; font-size: 110%; font-weight: bold; } .calendaritipattend .calendar-invitebox td.title, .calendaritipattend .calendar-invitebox td.ititle { font-size: 120%; } .calendaritipattend .itip-reply-controls .noreply-toggle, .calendaritipattend .itip-reply-controls #noreply-event-rsvp { display: none; } .calendaritipattend .itip-reply-controls a.reply-comment-toggle { margin-left: 2px; } diff --git a/plugins/kolab_delegation/kolab_delegation.js b/plugins/kolab_delegation/kolab_delegation.js index 01d6d30b..c1bbe9b6 100644 --- a/plugins/kolab_delegation/kolab_delegation.js +++ b/plugins/kolab_delegation/kolab_delegation.js @@ -1,320 +1,320 @@ /** * Client scripts for the Kolab Delegation configuration utitlity * * @author Aleksander Machniak * @author Thomas Bruederli * * @licstart The following is the entire license notice for the * JavaScript code in this file. * * Copyright (C) 2011-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * @licend The above is the entire license notice * for the JavaScript code in this file. */ window.rcmail && rcmail.addEventListener('init', function(evt) { if (rcmail.env.task == 'mail' || rcmail.env.task == 'calendar') { // set delegator context for calendar requests on invitation message rcmail.addEventListener('requestcalendar/event', function(o) { rcmail.event_delegator_request(o); }); rcmail.addEventListener('requestcalendar/mailimportevent', function(o) { rcmail.event_delegator_request(o); }); if (rcmail.env.delegators && window.rcube_calendar_ui) { rcmail.calendar_identity_init(); // delegator context for calendar event form rcmail.addEventListener('calendar-event-init', function(o) { return rcmail.calendar_event_init(o); }); // change organizer identity on calendar folder change $('#edit-calendar').change(function() { rcmail.calendar_change(); }); } } else if (rcmail.env.task != 'settings') return; if (/^plugin.delegation/.test(rcmail.env.action)) { rcmail.addEventListener('plugin.delegate_save_complete', function(e) { rcmail.delegate_save_complete(e); }); if (rcmail.gui_objects.delegatelist) { rcmail.delegatelist = new rcube_list_widget(rcmail.gui_objects.delegatelist, { multiselect:true, draggable:false, keyboard:true }); rcmail.delegatelist.addEventListener('select', function(o) { rcmail.select_delegate(o); }) .init(); rcmail.enable_command('delegate-add', true); } else { rcmail.enable_command('delegate-save', true); var input = $('#delegate'); // delegate autocompletion if (input.length) { rcmail.init_address_input_events(input, {action: 'settings/plugin.delegation-autocomplete'}); rcmail.env.recipients_delimiter = ''; input.focus(); } // folders list $('input.write').change(function(e) { if (this.checked) $('input.read', this.parentNode.parentNode).prop('checked', true); }); $('input.read').change(function(e) { if (!this.checked) $('input.write', this.parentNode.parentNode).prop('checked', false); }); var fn = function(elem) { var classname = elem.className, list = $(elem).closest('table').find('input.' + classname), check = list.not(':checked').length > 0; list.prop('checked', check).change(); }; $('th.read,th.write').click(function() { fn(this); }) .keydown(function(e) { if (e.which == 13 || e.which == 32) fn(this); }); } } }); // delegates list onclick even handler rcube_webmail.prototype.select_delegate = function(list) { this.env.active_delegate = list.get_single_selection(); if (this.env.active_delegate) this.delegate_select(this.env.active_delegate); else if (this.env.contentframe) this.show_contentframe(false); }; // select delegate rcube_webmail.prototype.delegate_select = function(id) { var win, target = window, url = '&_action=plugin.delegation'; if (id) url += '&_id='+urlencode(id); else { this.show_contentframe(false); return; } if (win = this.get_frame_window(this.env.contentframe)) { target = win; url += '&_framed=1'; } if (String(target.location.href).indexOf(url) >= 0) this.show_contentframe(true); else this.location_href(this.env.comm_path+url, target, true); }; // display new delegate form rcube_webmail.prototype.delegate_add = function() { var win, target = window, url = '&_action=plugin.delegation'; this.delegatelist.clear_selection(); this.env.active_delegate = null; this.show_contentframe(false); if (win = this.get_frame_window(this.env.contentframe)) { target = win; url += '&_framed=1'; } this.location_href(this.env.comm_path+url, target, true); }; // handler for delete commands rcube_webmail.prototype.delegate_delete = function() { if (!this.env.active_delegate) return; var $dialog = $("#delegate-delete-dialog").addClass('uidialog'), buttons = {}; buttons[this.gettext('no', 'kolab_delegation')] = function() { $dialog.dialog('close'); }; buttons[this.gettext('yes', 'kolab_delegation')] = function() { $dialog.dialog('close'); var lock = rcmail.set_busy(true, 'kolab_delegation.savingdata'); rcmail.http_post('plugin.delegation-delete', {id: rcmail.env.active_delegate, acl: $("#delegate-delete-dialog input:checked").length}, lock); } // open jquery UI dialog $dialog.dialog({ modal: true, resizable: false, closeOnEscape: true, title: this.gettext('deleteconfirm', 'kolab_delegation'), close: function() { $dialog.dialog('destroy').hide(); }, buttons: buttons, width: 400 }).show(); }; // submit delegate form to the server rcube_webmail.prototype.delegate_save = function() { var data = {id: this.env.active_delegate}, lock = this.set_busy(true, 'kolab_delegation.savingdata'); // new delegate if (!data.id) { data.newid = $('#delegate').val().replace(/(^\s+|[\s,]+$)/, ''); if (data.newid.match(/\s*\(([^)]+)\)$/)) data.newid = RegExp.$1; } data.folders = {}; - $('input.read:checked').each(function(i, elem) { - data.folders[elem.value] = 1; + $('input.read').each(function(i, elem) { + data.folders[elem.value] = this.checked ? 1 : 0; }); $('input.write:checked').each(function(i, elem) { data.folders[elem.value] = 2; }); this.http_post('plugin.delegation-save', data, lock); }; // callback function when saving/deleting has completed successfully rcube_webmail.prototype.delegate_save_complete = function(p) { // delegate created if (p.created) { var input = $('#delegate'), row = $(''), rc = this.is_framed() ? parent.rcmail : this; // remove delegate input input.parent().append($('').text(p.name)); input.remove(); // add delegate row to the list row.attr('id', 'rcmrow'+p.created); $('td', row).text(p.name); rc.delegatelist.insert_row(row.get(0)); rc.delegatelist.highlight_row(p.created); this.env.active_delegate = p.created; rc.env.active_delegate = p.created; rc.enable_command('delegate-delete', true); } // delegate updated else if (p.updated) { // do nothing } // delegate deleted else if (p.deleted) { this.env.active_delegate = null; this.delegate_select(); this.delegatelist.remove_row(p.deleted); this.enable_command('delegate-delete', false); } }; rcube_webmail.prototype.event_delegator_request = function(data) { if (!this.env.delegator_context) return; if (typeof data === 'object') data._context = this.env.delegator_context; else data += '&_context=' + this.env.delegator_context; return data; }; // callback for calendar event form initialization rcube_webmail.prototype.calendar_event_init = function(data) { // set identity for delegator context this.env.calendar_settings.identity = this.calendar_folder_delegator(data.o.calendar); }; // returns delegator's identity data according to selected calendar folder rcube_webmail.prototype.calendar_folder_delegator = function(calendar) { var d, delegator; // derive delegator from the calendar owner property if (this.env.calendars[calendar] && this.env.calendars[calendar].owner) { delegator = this.env.calendars[calendar].owner.replace(/@.+$/, ''); } if (delegator && (d = this.env.delegators[delegator])) { // find delegator's identity id if (!d.identity_id) $.each(this.env.calendar_settings.identities, function(i, v) { if (d.email == v) { d.identity_id = i; return false; } }); d.uid = delegator; } else d = this.env.original_identity; this.env.delegator_context = d.uid; return d; }; // handler for calendar folder change rcube_webmail.prototype.calendar_change = function() { var calendar = $('#edit-calendar').val(), select = $('#edit-identities-list'), old = this.env.calendar_settings.identity; this.env.calendar_settings.identity = this.calendar_folder_delegator(calendar); // change organizer identity in identity selector if (select.length && old != this.env.calendar_settings.identity) { var id = this.env.calendar_settings.identity.identity_id; select.val(id || select.find('option').first().val()).change(); } }; // modify default identity of the user rcube_webmail.prototype.calendar_identity_init = function() { var identity = this.env.calendar_settings.identity, emails = identity.emails.split(';'); // remove delegators' emails from list of emails of the current user emails = $.map(emails, function(v) { for (var n in rcmail.env.delegators) if (rcmail.env.delegators[n].emails.indexOf(';'+v) > -1) return null; return v; }); identity.emails = emails.join(';'); this.env.original_identity = identity; } diff --git a/plugins/kolab_delegation/kolab_delegation_engine.php b/plugins/kolab_delegation/kolab_delegation_engine.php index 88e4563b..e1a62299 100644 --- a/plugins/kolab_delegation/kolab_delegation_engine.php +++ b/plugins/kolab_delegation/kolab_delegation_engine.php @@ -1,934 +1,932 @@ * @author Aleksander Machniak * * Copyright (C) 2011-2012, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_delegation_engine { public $context; private $rc; private $ldap_filter; private $ldap_delegate_field; private $ldap_login_field; private $ldap_name_field; private $ldap_email_field; private $ldap_org_field; private $ldap_dn; private $cache = array(); private $folder_types = array('mail', 'event', 'task'); const ACL_READ = 1; const ACL_WRITE = 2; /** * Class constructor */ public function __construct() { $this->rc = rcube::get_instance(); } /** * Add delegate * * @param string|array $delegate Delegate DN (encoded) or delegate data (result of delegate_get()) * @param array $acl List of folder->right map */ public function delegate_add($delegate, $acl) { if (!is_array($delegate)) { $delegate = $this->delegate_get($delegate); } $dn = $delegate['ID']; if (empty($delegate) || empty($dn)) { return false; } $list = $this->list_delegates(); // add delegate to the list $list = array_keys((array)$list); $list = array_filter($list); if (!in_array($dn, $list)) { $list[] = $dn; } $list = array_map(array('kolab_auth_ldap', 'dn_decode'), $list); // update user record $result = $this->user_update_delegates($list); // Set ACL on folders if ($result && !empty($acl)) { $this->delegate_acl_update($delegate['uid'], $acl); } return $result; } /** * Set/Update ACL on delegator's folders * * @param string $uid Delegate authentication identifier * @param array $acl List of folder->right map * @param bool $update Update (remove) old rights */ public function delegate_acl_update($uid, $acl, $update = false) { $storage = $this->rc->get_storage(); $right_types = $this->right_types(); $folders = $update ? $this->list_folders($uid) : array(); foreach ($acl as $folder_name => $rights) { $r = $right_types[$rights]; if ($r) { $storage->set_acl($folder_name, $uid, $r); } + else { + $storage->delete_acl($folder_name, $uid); + } if (!empty($folders) && isset($folders[$folder_name])) { unset($folders[$folder_name]); } } foreach ($folders as $folder_name => $folder) { if ($folder['rights']) { $storage->delete_acl($folder_name, $uid); } } return true; } /** * Delete delgate * * @param string $dn Delegate DN (encoded) * @param bool $acl_del Enable ACL deletion on delegator folders */ public function delegate_delete($dn, $acl_del = false) { $delegate = $this->delegate_get($dn); $list = $this->list_delegates(); $user = $this->user(); if (empty($delegate) || !isset($list[$dn])) { return false; } // remove delegate from the list unset($list[$dn]); $list = array_keys($list); $list = array_map(array('kolab_auth_ldap', 'dn_decode'), $list); $user[$this->ldap_delegate_field] = $list; // update user record $result = $this->user_update_delegates($list); // remove ACL if ($result && $acl_del) { $this->delegate_acl_update($delegate['uid'], array(), true); } return $result; } /** * Return delegate data * * @param string $dn Delegate DN (encoded) * * @return array Delegate record (ID, name, uid, imap_uid) */ public function delegate_get($dn) { // use internal cache so we not query LDAP more than once per request if (!isset($this->cache[$dn])) { $ldap = $this->ldap(); if (!$ldap || empty($dn)) { return array(); } // Get delegate $user = $ldap->get_record(kolab_auth_ldap::dn_decode($dn)); if (empty($user)) { return array(); } $delegate = $this->parse_ldap_record($user); $delegate['ID'] = $dn; $this->cache[$dn] = $delegate; } return $this->cache[$dn]; } /** * Return delegate data * * @param string $login Delegate name (the 'uid' returned in get_users()) * * @return array Delegate record (ID, name, uid, imap_uid) */ public function delegate_get_by_name($login) { $ldap = $this->ldap(); if (!$ldap || empty($login)) { return array(); } $list = $ldap->dosearch($this->ldap_login_field, $login, 1); if (count($list) == 1) { $dn = key($list); $user = $list[$dn]; return $this->parse_ldap_record($user, $dn); } } /** * LDAP object getter */ private function ldap() { $ldap = kolab_auth::ldap(); if (!$ldap || !$ldap->ready) { return null; } // Default filter of LDAP queries $this->ldap_filter = $this->rc->config->get('kolab_delegation_filter', '(|(objectClass=kolabInetOrgPerson)(&(objectclass=kolabsharedfolder)(kolabFolderType=mail)))'); // Name of the LDAP field for delegates list $this->ldap_delegate_field = $this->rc->config->get('kolab_delegation_delegate_field', 'kolabDelegate'); // Encoded LDAP DN of current user, set on login by kolab_auth plugin $this->ldap_dn = $_SESSION['kolab_dn']; // Name of the LDAP field with authentication ID $this->ldap_login_field = $this->rc->config->get('kolab_auth_login'); // Name of the LDAP field with user name used for identities $this->ldap_name_field = $this->rc->config->get('kolab_auth_name'); // Name of the LDAP field with email addresses used for identities $this->ldap_email_field = $this->rc->config->get('kolab_auth_email'); // Name of the LDAP field with organization name for identities $this->ldap_org_field = $this->rc->config->get('kolab_auth_organization'); $ldap->set_filter($this->ldap_filter); $ldap->extend_fieldmap(array($this->ldap_delegate_field => $this->ldap_delegate_field)); return $ldap; } /** * List current user delegates */ public function list_delegates() { $result = array(); $ldap = $this->ldap(); $user = $this->user(); if (empty($ldap) || empty($user)) { return array(); } // Get delegates of current user $delegates = $user[$this->ldap_delegate_field]; if (!empty($delegates)) { foreach ((array)$delegates as $dn) { $delegate = $ldap->get_record($dn); $data = $this->parse_ldap_record($delegate, $dn); if (!empty($data) && !empty($data['name'])) { $result[$data['ID']] = $data['name']; } } } return $result; } /** * List current user delegators * * @return array List of delegators */ public function list_delegators() { $result = array(); $ldap = $this->ldap(); if (empty($ldap) || empty($this->ldap_dn)) { return array(); } $list = $ldap->dosearch($this->ldap_delegate_field, $this->ldap_dn, 1); foreach ($list as $dn => $delegator) { $delegator = $this->parse_ldap_record($delegator, $dn); $result[$delegator['ID']] = $delegator; } return $result; } /** * List current user delegators in format compatible with Calendar plugin * * @return array List of delegators */ public function list_delegators_js() { $list = $this->list_delegators(); $result = array(); foreach ($list as $delegator) { $name = $delegator['name']; if ($pos = strrpos($name, '(')) { $name = trim(substr($name, 0, $pos)); } $result[$delegator['imap_uid']] = array( 'emails' => ';' . implode(';', $delegator['email']), 'email' => $delegator['email'][0], 'name' => $name, ); } return $result; } /** * Prepare namespace prefixes for JS environment * * @return array List of prefixes */ public function namespace_js() { $storage = $this->rc->get_storage(); $ns = $storage->get_namespace('other'); if ($ns) { foreach ($ns as $idx => $nsval) { $ns[$idx] = kolab_storage::folder_id($nsval[0]); } } return $ns; } /** * Get all folders to which current user has admin access * * @param string $delegate IMAP user identifier * * @return array Folder type/rights */ public function list_folders($delegate = null) { $storage = $this->rc->get_storage(); $folders = $storage->list_folders(); $metadata = kolab_storage::folders_typedata(); $result = array(); if (!is_array($metadata)) { return $result; } // Definition of read and write ACL $right_types = $this->right_types(); foreach ($folders as $folder) { // get only folders in personal namespace if ($storage->folder_namespace($folder) != 'personal') { continue; } $rights = null; $type = $metadata[$folder] ?: 'mail'; list($class, $subclass) = explode('.', $type); if (!in_array($class, $this->folder_types)) { continue; } // in edit mode, get folder ACL if ($delegate) { // @TODO: cache ACL $acl = $storage->get_acl($folder); if ($acl = $acl[$delegate]) { if ($this->acl_compare($acl, $right_types[self::ACL_WRITE])) { $rights = self::ACL_WRITE; } else if ($this->acl_compare($acl, $right_types[self::ACL_READ])) { $rights = self::ACL_READ; } } } else if ($folder == 'INBOX' || $subclass == 'default' || $subclass == 'inbox') { $rights = self::ACL_WRITE; } $result[$folder] = array( 'type' => $class, 'rights' => $rights, ); } return $result; } /** * Returns list of users for autocompletion * * @param string $search Search string * * @return array Users list */ public function list_users($search) { $ldap = $this->ldap(); if (empty($ldap) || $search === '' || $search === null) { return array(); } $max = (int) $this->rc->config->get('autocomplete_max', 15); $mode = (int) $this->rc->config->get('addressbook_search_mode'); $fields = array_unique(array_filter(array_merge((array)$this->ldap_name_field, (array)$this->ldap_login_field))); $users = array(); $keys = array(); $result = $ldap->dosearch($fields, $search, $mode, (array)$this->ldap_login_field, $max); foreach ($result as $record) { // skip self if ($record['dn'] == $_SESSION['kolab_dn']) { continue; } $user = $this->parse_ldap_record($record); if ($user['uid']) { $display = rcube_addressbook::compose_search_name($record); $user = array('name' => $user['uid'], 'display' => $display); $users[] = $user; $keys[] = $display ?: $user['uid']; } } if (count($users)) { // sort users index asort($keys, SORT_LOCALE_STRING); // re-sort users according to index foreach (array_keys($keys) as $idx) { $keys[$idx] = $users[$idx]; } $users = array_values($keys); } return $users; } /** * Extract delegate identifiers and pretty name from LDAP record */ private function parse_ldap_record($data, $dn = null) { $email = array(); $uid = $data[$this->ldap_login_field]; if (is_array($uid)) { $uid = array_filter($uid); $uid = $uid[0]; } // User name for identity foreach ((array)$this->ldap_name_field as $field) { $name = is_array($data[$field]) ? $data[$field][0] : $data[$field]; if (!empty($name)) { break; } } // User email(s) for identity foreach ((array)$this->ldap_email_field as $field) { $user_email = is_array($data[$field]) ? array_filter($data[$field]) : $data[$field]; if (!empty($user_email)) { $email = array_merge((array)$email, (array)$user_email); } } // Organization for identity foreach ((array)$this->ldap_org_field as $field) { $organization = is_array($data[$field]) ? $data[$field][0] : $data[$field]; if (!empty($organization)) { break; } } $realname = $name; if ($uid && $name) { $name .= ' (' . $uid . ')'; } else { $name = $uid; } // get IMAP uid - identifier used in shared folder hierarchy $imap_uid = $uid; if ($pos = strpos($imap_uid, '@')) { $imap_uid = substr($imap_uid, 0, $pos); } return array( 'ID' => kolab_auth_ldap::dn_encode($dn), 'uid' => $uid, 'name' => $name, 'realname' => $realname, 'imap_uid' => $imap_uid, 'email' => $email, 'organization' => $organization, ); } /** * Returns LDAP record of current user * * @return array User data */ public function user($parsed = false) { if (!isset($this->cache['user'])) { $ldap = $this->ldap(); if (!$ldap) { return array(); } // Get current user record $this->cache['user'] = $ldap->get_record($this->ldap_dn); } return $parsed ? $this->parse_ldap_record($this->cache['user']) : $this->cache['user']; } /** * Update LDAP record of current user * * @param array List of delegates */ public function user_update_delegates($list) { $ldap = $this->ldap(); $pass = $this->rc->decrypt($_SESSION['password']); if (!$ldap) { return false; } // need to bind as self for sufficient privilages if (!$ldap->bind($this->ldap_dn, $pass)) { return false; } $user[$this->ldap_delegate_field] = $list; unset($this->cache['user']); // replace delegators list in user record return $ldap->replace($this->ldap_dn, $user); } /** * Manage delegation data on user login */ public function delegation_init() { // Fetch all delegators from LDAP who assigned the // current user as their delegate and create identities // a) if identity with delegator's email exists, continue // b) create identity ($delegate on behalf of $delegator // <$delegator-email>) for new delegators // c) remove all other identities which do not match the user's primary // or alias email if 'kolab_delegation_purge_identities' is set. $delegators = $this->list_delegators(); $use_subs = $this->rc->config->get('kolab_use_subscriptions'); $identities = $this->rc->user->list_emails(); $emails = array(); $uids = array(); if (!empty($delegators)) { $storage = $this->rc->get_storage(); $other_ns = $storage->get_namespace('other'); $folders = $storage->list_folders(); } // convert identities to simpler format for faster access foreach ($identities as $idx => $ident) { // get user name from default identity if (!$idx) { $default = array( 'name' => $ident['name'], ); } $emails[$ident['identity_id']] = $ident['email']; } // for every delegator... foreach ($delegators as $delegator) { $uids[$delegator['imap_uid']] = $email_arr = $delegator['email']; $diff = array_intersect($emails, $email_arr); // identity with delegator's email already exist, do nothing if (count($diff)) { $emails = array_diff($emails, $email_arr); continue; } // create identities for delegator emails foreach ($email_arr as $email) { // @TODO: "Delegatorname" or "Username on behalf of Delegatorname"? $default['name'] = $delegator['realname']; $default['email'] = $email; // Database field for organization is NOT NULL $default['organization'] = empty($delegator['organization']) ? '' : $delegator['organization']; $this->rc->user->insert_identity($default); } // IMAP folders shared by new delegators shall be subscribed on login, // as well as existing subscriptions of previously shared folders shall // be removed. I suppose the latter one is already done in Roundcube. // for every accessible folder... foreach ($folders as $folder) { // for every 'other' namespace root... foreach ($other_ns as $ns) { $prefix = $ns[0] . $delegator['imap_uid']; // subscribe delegator's folder if ($folder === $prefix || strpos($folder, $prefix . substr($ns[0], -1)) === 0) { // Event/Task folders need client-side activation $type = kolab_storage::folder_type($folder); if (preg_match('/^(event|task)/i', $type)) { kolab_storage::folder_activate($folder); } // Subscribe to mail folders and (if system is configured // to display only subscribed folders) to other if ($use_subs || preg_match('/^mail/i', $type)) { $storage->subscribe($folder); } } } } } // remove identities that "do not belong" to user nor delegators if ($this->rc->config->get('kolab_delegation_purge_identities')) { $user = $this->user(true); $emails = array_diff($emails, $user['email']); foreach (array_keys($emails) as $idx) { $this->rc->user->delete_identity($idx); } } $_SESSION['delegators'] = $uids; } /** * Sets delegator context according to email message recipient * * @param rcube_message $message Email message object */ public function delegator_context_from_message($message) { if (empty($_SESSION['delegators'])) { return; } // Match delegators' addresses with message To: address // @TODO: Is this reliable enough? // Roundcube sends invitations to every attendee separately, // but maybe there's a software which sends with CC header or many addresses in To: $emails = $message->get_header('to'); $emails = rcube_mime::decode_address_list($emails, null, false); foreach ($emails as $email) { foreach ($_SESSION['delegators'] as $uid => $addresses) { if (in_array($email['mailto'], $addresses)) { return $this->context = $uid; } } } } /** * Return (set) current delegator context * * @return string Delegator UID */ public function delegator_context() { if (!$this->context && !empty($_SESSION['delegators'])) { $context = rcube_utils::get_input_value('_context', rcube_utils::INPUT_GPC); if ($context && isset($_SESSION['delegators'][$context])) { $this->context = $context; } } return $this->context; } /** * Set user identity according to delegator delegator * * @param array $args Reference to plugin hook arguments */ public function delegator_identity_filter(&$args) { $context = $this->delegator_context(); if (!$context) { return; } $identities = $this->rc->user->list_emails(); $emails = $_SESSION['delegators'][$context]; foreach ($identities as $ident) { if (in_array($ident['email'], $emails)) { $args['identity'] = $ident; return; } } // fallback to default identity $args['identity'] = array_shift($identities); } /** * Filter user emails according to delegator context * * @param array $args Reference to plugin hook arguments */ public function delegator_emails_filter(&$args) { $context = $this->delegator_context(); // try to derive context from the given user email if (!$context && !empty($args['emails'])) { if (($user = preg_replace('/@.+$/', '', $args['emails'][0])) && isset($_SESSION['delegators'][$user])) { $context = $user; } } // return delegator's addresses if ($context) { $args['emails'] = $_SESSION['delegators'][$context]; $args['abort'] = true; } // return only user addresses (exclude all delegators addresses) else if (!empty($_SESSION['delegators'])) { $identities = $this->rc->user->list_emails(); $emails[] = $this->rc->user->get_username(); foreach ($identities as $identity) { $emails[] = $identity['email']; } foreach ($_SESSION['delegators'] as $delegator_emails) { $emails = array_diff($emails, $delegator_emails); } $args['emails'] = array_unique($emails); $args['abort'] = true; } } /** * Filters list of calendars according to delegator context * * @param array $args Reference to plugin hook arguments */ public function delegator_folder_filter(&$args) { $context = $this->delegator_context(); $storage = $this->rc->get_storage(); $other_ns = $storage->get_namespace('other'); $delim = $storage->get_hierarchy_delimiter(); $calendars = array(); // code parts derived from kolab_driver::filter_calendars() foreach ($args['list'] as $cal) { if (!$cal->ready) { continue; } if ($args['writeable'] && $cal->readonly) { continue; } if ($args['active'] && !$cal->storage->is_active()) { continue; } if ($args['personal']) { $ns = $cal->get_namespace(); if (empty($context)) { if ($ns != 'personal') { continue; } } else { if ($ns != 'other') { continue; } foreach ($other_ns as $ns) { $folder = $ns[0] . $context . $delim; if (strpos($cal->name, $folder) !== 0) { continue; } } } } $calendars[$cal->id] = $cal; } $args['calendars'] = $calendars; $args['abort'] = true; } /** * Filters/updates message headers according to delegator context * * @param array $args Reference to plugin hook arguments */ public function delegator_delivery_filter(&$args) { // no context, but message still can be send on behalf of... if (!empty($_SESSION['delegators'])) { $message = $args['message']; $headers = $message->headers(); // get email address from From: header $from = rcube_mime::decode_address_list($headers['From']); $from = array_shift($from); $from = $from['mailto']; foreach ($_SESSION['delegators'] as $uid => $addresses) { if (in_array($from, $addresses)) { $context = $uid; break; } } // add Sender: header with current user default identity if ($context) { $identity = $this->rc->user->get_identity(); $sender = format_email_recipient($identity['email'], $identity['name']); $message->headers(array('Sender' => $sender), false, true); } } } /** * Compares two ACLs (according to supported rights) * - * @todo: this is stolen from acl plugin, move to rcube_storage/rcube_imap - * * @param array $acl1 ACL rights array (or string) * @param array $acl2 ACL rights array (or string) * - * @param int Comparision result, 2 - full match, 1 - partial match, 0 - no match + * @param bool True if $acl1 contains all rights from $acl2 */ function acl_compare($acl1, $acl2) { if (!is_array($acl1)) $acl1 = str_split($acl1); if (!is_array($acl2)) $acl2 = str_split($acl2); $rights = $this->rights_supported(); $acl1 = array_intersect($acl1, $rights); $acl2 = array_intersect($acl2, $rights); $res = array_intersect($acl1, $acl2); $cnt1 = count($res); $cnt2 = count($acl2); - if ($cnt1 == $cnt2) - return 2; - else if ($cnt1) - return 1; - else - return 0; + if ($cnt1 >= $cnt2) { + return true; + } } /** * Get list of supported access rights (according to RIGHTS capability) * * @todo: this is stolen from acl plugin, move to rcube_storage/rcube_imap * * @return array List of supported access rights abbreviations */ public function rights_supported() { if ($this->supported !== null) { return $this->supported; } $storage = $this->rc->get_storage(); $capa = $storage->get_capability('RIGHTS'); if (is_array($capa)) { $rights = strtolower($capa[0]); } else { $rights = 'cd'; } return $this->supported = str_split('lrswi' . $rights . 'pa'); } private function right_types() { // Get supported rights and build column names $supported = $this->rights_supported(); // depending on server capability either use 'te' or 'd' for deleting msgs $deleteright = implode(array_intersect(str_split('ted'), $supported)); return array( self::ACL_READ => 'lrs', self::ACL_WRITE => 'lrswi'.$deleteright, ); } } diff --git a/plugins/kolab_tags/kolab_tags.php b/plugins/kolab_tags/kolab_tags.php index b9be9b86..12fbb01d 100644 --- a/plugins/kolab_tags/kolab_tags.php +++ b/plugins/kolab_tags/kolab_tags.php @@ -1,158 +1,154 @@ * * Copyright (C) 2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_tags extends rcube_plugin { public $task = 'mail'; public $rc; public $home; private $engine; public function init() { $this->rc = rcube::get_instance(); // Register hooks to display tags in message subject $this->add_hook('messages_list', array($this, 'messages_list')); $this->add_hook('message_headers_output', array($this, 'message_headers_output')); // Searching by tags $this->add_hook('imap_search_before', array($this, 'imap_search_before')); // Plugin actions for tag management $this->register_action('plugin.kolab_tags', array($this, 'actions')); // Load UI from startup hook $this->add_hook('startup', array($this, 'startup')); } /** * Creates kolab_files_engine instance */ private function engine() { if ($this->engine === null) { // the files module can be enabled/disabled by the kolab_auth plugin if ($this->rc->config->get('kolab_tags_disabled') || !$this->rc->config->get('kolab_tags_enabled', true)) { return $this->engine = false; } // $this->load_config(); require_once $this->home . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR . 'kolab_tags_engine.php'; $this->engine = new kolab_tags_engine($this); } return $this->engine; } /** * Startup hook handler, initializes/enables Files UI */ public function startup($args) { // call this from startup to give a chance to set // kolab_files_enabled/disabled in kolab_auth plugin if ($this->rc->output->type != 'html') { return; } - if ($this->rc->action == 'print') { - return; - } - if ($engine = $this->engine()) { $engine->ui(); } } /** * Engine actions handler */ public function actions() { if ($engine = $this->engine()) { $engine->actions(); } } /** * Handler for messages list */ public function messages_list($args) { if ($engine = $this->engine()) { $args = $engine->messages_list_handler($args); } return $args; } /** * Handler for message headers */ public function message_headers_output($args) { // this hook can be executed many times if ($this->mail_headers_done) { return $args; } if ($this->rc->action == 'print') { return; } $this->mail_headers_done = true; if ($engine = $this->engine()) { $args = $engine->message_headers_handler($args); } return $args; } /** * Handler for messages searching */ public function imap_search_before($args) { // if search filter contains tag mark if (preg_match('/^(kolab_tags_[0-9]{10,}:([^:]+):)/', $args['search'], $m) && ($engine = $this->engine())) { $this->current_tags = $args['search_tags'] = explode(',', $m[2]); $this->current_filter = $args['search'] = substr($args['search'], strlen($m[1])); // modify search arguments $args = $engine->imap_search_handler($args); unset($args['search_tags']); // send current search properties to the browser $this->rc->output->set_env('search_filter_selected', $this->current_filter); $this->rc->output->set_env('selected_tags', $this->current_tags); } return $args; } } diff --git a/plugins/kolab_tags/lib/kolab_tags_engine.php b/plugins/kolab_tags/lib/kolab_tags_engine.php index 8f881851..4e15069b 100644 --- a/plugins/kolab_tags/lib/kolab_tags_engine.php +++ b/plugins/kolab_tags/lib/kolab_tags_engine.php @@ -1,501 +1,507 @@ * * Copyright (C) 2014, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_tags_engine { private $backend; private $plugin; private $rc; /** * Class constructor */ public function __construct($plugin) { $plugin->require_plugin('libkolab'); require_once $plugin->home . DIRECTORY_SEPARATOR . 'lib' . DIRECTORY_SEPARATOR . 'kolab_tags_backend.php'; $this->backend = new kolab_tags_backend; $this->plugin = $plugin; $this->rc = $plugin->rc; } /** * User interface initialization */ public function ui() { // set templates of Files UI and widgets - if ($this->rc->task == 'mail') { - $this->plugin->add_texts('localization/'); + if ($this->rc->task != 'mail') { + return; + } + + if ($this->rc->action && !in_array($this->rc->action, array('show', 'preview'))) { + return; + } + + $this->plugin->add_texts('localization/'); - $this->plugin->include_stylesheet($this->plugin->local_skin_path().'/style.css'); - $this->plugin->include_script('kolab_tags.js'); - $this->rc->output->add_label('cancel', 'save'); - $this->plugin->add_label('tags', 'add', 'edit', 'delete', 'saving', - 'nameempty', 'nameexists', 'colorinvalid', 'untag', 'tagname', - 'tagcolor', 'tagsearchnew', 'newtag'); + $this->plugin->include_stylesheet($this->plugin->local_skin_path().'/style.css'); + $this->plugin->include_script('kolab_tags.js'); + $this->rc->output->add_label('cancel', 'save'); + $this->plugin->add_label('tags', 'add', 'edit', 'delete', 'saving', + 'nameempty', 'nameexists', 'colorinvalid', 'untag', 'tagname', + 'tagcolor', 'tagsearchnew', 'newtag'); - $this->rc->output->add_handlers(array( - 'plugin.taglist' => array($this, 'taglist'), - )); + $this->rc->output->add_handlers(array( + 'plugin.taglist' => array($this, 'taglist'), + )); - $ui = $this->rc->output->parse('kolab_tags.ui', false, false); - $this->rc->output->add_footer($ui); + $ui = $this->rc->output->parse('kolab_tags.ui', false, false); + $this->rc->output->add_footer($ui); - // load miniColors - jqueryui::miniColors(); - } + // load miniColors + jqueryui::miniColors(); } /** * Engine actions handler (managing tag objects) */ public function actions() { $this->plugin->add_texts('localization/'); $action = rcube_utils::get_input_value('_act', rcube_utils::INPUT_POST); if ($action) { $this->{'action_' . $action}(); } // manage tag objects else { $delete = (array) rcube_utils::get_input_value('delete', rcube_utils::INPUT_POST); $update = (array) rcube_utils::get_input_value('update', rcube_utils::INPUT_POST, true); $add = (array) rcube_utils::get_input_value('add', rcube_utils::INPUT_POST, true); $response = array(); // tags deletion foreach ($delete as $uid) { if ($this->backend->remove($uid)) { $response['delete'][] = $uid; } else { $error = true; } } // tags creation foreach ($add as $tag) { if ($tag = $this->backend->create($tag)) { $response['add'][] = $this->parse_tag($tag); } else { $error = true; } } // tags update foreach ($update as $tag) { if ($this->backend->update($tag)) { $response['update'][] = $this->parse_tag($tag); } else { $error = true; } } if (!empty($error)) { $this->rc->output->show_message($this->plugin->gettext('updateerror'), 'error'); } else { $this->rc->output->show_message($this->plugin->gettext('updatesuccess'), 'confirmation'); } $this->rc->output->command('plugin.kolab_tags', $response); } $this->rc->output->send(); } /** * Remove tag from message(s) */ public function action_remove() { $tag = rcube_utils::get_input_value('_tag', rcube_utils::INPUT_POST); $filter = $tag == '*' ? array() : array(array('uid', '=', explode(',', $tag))); $taglist = $this->backend->list_tags($filter); $filter = array(); $tags = array(); foreach (rcmail::get_uids() as $mbox => $uids) { if ($uids === '*') { $filter[$mbox] = $this->build_member_url(array('folder' => $mbox)); } else { foreach ((array)$uids as $uid) { $filter[$mbox][] = $this->build_member_url(array( 'folder' => $mbox, 'uid' => $uid )); } } } // for every tag... foreach ($taglist as $tag) { $updated = false; // @todo: make sure members list is up-to-date (UIDs are up-to-date) // ...filter members by folder/uid prefix foreach ((array) $tag['members'] as $idx => $member) { foreach ($filter as $members) { // list of prefixes if (is_array($members)) { foreach ($members as $message) { if ($member == $message || strpos($member, $message . '?') === 0) { unset($tag['members'][$idx]); $updated = true; } } } // one prefix (all messages in a folder) else { if (preg_match('/^' . preg_quote($members, '/') . '\/[0-9]+(\?|$)/', $member)) { unset($tag['members'][$idx]); $updated = true; } } } } // update tag object if ($updated) { if (!$this->backend->update($tag)) { $error = true; } } $tags[] = $tag['uid']; } if ($error) { if ($_POST['_from'] != 'show') { $this->rc->output->show_message($this->plugin->gettext('untaggingerror'), 'error'); $this->rc->output->command('list_mailbox'); } } else { $this->rc->output->show_message($this->plugin->gettext('untaggingsuccess'), 'confirmation'); $this->rc->output->command('plugin.kolab_tags', array('mark' => 1, 'delete' => $tags)); } } /** * Add tag to message(s) */ public function action_add() { $tag = rcube_utils::get_input_value('_tag', rcube_utils::INPUT_POST); $storage = $this->rc->get_storage(); $members = array(); // build list of members foreach (rcmail::get_uids() as $mbox => $uids) { if ($uids === '*') { $index = $storage->index($mbox, null, null, true); $uids = $index->get(); $msgs = $storage->fetch_headers($mbox, $uids, false); } else { $msgs = $storage->fetch_headers($mbox, $uids, false); } $members = array_merge($members, $this->build_members($mbox, $msgs)); } // create a new tag? if (!empty($_POST['_new'])) { $object = array( 'name' => $tag, 'members' => $members, ); $object = $this->backend->create($object); $error = $object === false; } // use existing tags (by UID) else { $filter = array(array('uid', '=', explode(',', $tag))); $taglist = $this->backend->list_tags($filter); // for every tag... foreach ($taglist as $tag) { $tag['members'] = array_unique(array_merge((array) $tag['members'], $members)); // update tag object if (!$this->backend->update($tag)) { $error = true; } } } if ($error) { $this->rc->output->show_message($this->plugin->gettext('taggingerror'), 'error'); if ($_POST['_from'] != 'show') { $this->rc->output->command('list_mailbox'); } } else { $this->rc->output->show_message($this->plugin->gettext('taggingsuccess'), 'confirmation'); if (isset($object)) { $this->rc->output->command('plugin.kolab_tags', array('mark' => 1, 'add' => array($this->parse_tag($object)))); } } } /** * Template object building tags list/cloud */ public function taglist($attrib) { $taglist = $this->backend->list_tags(); // Performance: Save the list for later if ($this->rc->action == 'show' || $this->rc->action == 'preview') { $this->taglist = $taglist; } $taglist = array_map(array($this, 'parse_tag'), $taglist); $this->rc->output->set_env('tags', $taglist); $this->rc->output->add_gui_object('taglist', $attrib['id']); return html::tag('ul', $attrib, '', html::$common_attrib); } /** * Handler for messages list (add tag-boxes in subject line on the list) */ public function messages_list_handler($args) { if (empty($args['messages'])) { return; } // get tags list $taglist = $this->backend->list_tags(); // get message UIDs foreach ($args['messages'] as $msg) { $message_tags[$msg->uid . '-' . $msg->folder] = null; } $uids = array_keys($message_tags); foreach ($taglist as $tag) { $tag = $this->parse_tag($tag, true); foreach ((array) $tag['uids'] as $folder => $_uids) { array_walk($_uids, function(&$uid, $key, $folder) { $uid .= '-' . $folder; }, $folder); foreach (array_intersect($uids, $_uids) as $uid) { $message_tags[$uid][] = $tag['uid']; } } } $this->rc->output->set_env('message_tags', array_filter($message_tags)); // @TODO: tag counters for the whole folder (search result) return $args; } /** * Handler for a single message (add tag-boxes in subject line) */ public function message_headers_handler($args) { $taglist = $this->taglist ?: $this->backend->list_tags(); $uid = $args['uid']; $folder = $args['folder']; $tags = array(); foreach ($taglist as $tag) { $tag = $this->parse_tag($tag, true); if (in_array($uid, (array)$tag['uids'][$folder])) { unset($tag['uids']); $tags[] = $tag; } } if (!empty($tags)) { $this->rc->output->set_env('message_tags', $tags); } return $args; } /** * Handler for messages searching requests */ public function imap_search_handler($args) { if (empty($args['search_tags'])) { return $args; } // we'll reset to current folder to fix issues when searching in multi-folder mode $storage = $this->rc->get_storage(); $orig_folder = $storage->get_folder(); // get tags $tags = $this->backend->list_tags(array(array('uid', '=', $args['search_tags']))); // sanity check (that should not happen) if (empty($tags)) { if ($orig_folder) { $storage->set_folder($orig_folder); } return $args; } $search = array(); $folders = (array) $args['folder']; // collect folders and uids foreach ($tags as $tag) { $tag = $this->parse_tag($tag, true); // tag has no members -> empty search result if (empty($tag['uids'])) { goto empty_result; } foreach ($tag['uids'] as $folder => $uid_list) { $search[$folder] = array_merge((array)$search[$folder], $uid_list); } } $search = array_map('array_unique', $search); $criteria = array(); // modify search folders/criteria $args['folder'] = array_intersect($folders, array_keys($search)); foreach ($args['folder'] as $folder) { $criteria[$folder] = ($args['search'] != 'ALL' ? trim($args['search']).' ' : '') . 'UID ' . rcube_imap_generic::compressMessageSet($search[$folder]); } if (!empty($args['folder'])) { $args['search'] = $criteria; } else { // return empty result empty_result: if (count($folders) > 1) { $args['result'] = new rcube_result_multifolder($args['folder']); foreach ($args['folder'] as $folder) { $index = new rcube_result_index($folder, '* SORT'); $args['result']->add($index); } } else { $class = 'rcube_result_' . ($args['threading'] ? 'thread' : 'index'); $result = $args['threading'] ? '* THREAD' : '* SORT'; $args['result'] = new $class($folder, $result); } } if ($orig_folder) { $storage->set_folder($orig_folder); } return $args; } /** * "Convert" tag object to simple array for use in javascript */ private function parse_tag($tag, $list = false) { $result = array( 'uid' => $tag['uid'], 'name' => $tag['name'], 'color' => $tag['color'], ); if ($list) { $result['uids'] = $this->get_tag_messages($tag); } return $result; } /** * Resolve members to folder/UID * * @param array $tag Tag object * * @return array Folder/UID list */ protected function get_tag_messages(&$tag, $force = true) { return kolab_storage_config::resolve_members($tag, $force); } /** * Build array of member URIs from set of messages */ protected function build_members($folder, $messages) { return kolab_storage_config::build_members($folder, $messages); } /** * Parses tag member string * * @param string $url Member URI * * @return array Message folder, UID, Search headers (Message-Id, Date) */ protected function parse_member_url($url) { return kolab_storage_config::parse_member_url($url); } /** * Builds member URI * * @param array Message folder, UID, Search headers (Message-Id, Date) * * @return string $url Member URI */ protected function build_member_url($params) { return kolab_storage_config::build_member_url($params); } } diff --git a/plugins/tasklist/skins/larry/tasklist.css b/plugins/tasklist/skins/larry/tasklist.css index a8748ede..fc7982c8 100644 --- a/plugins/tasklist/skins/larry/tasklist.css +++ b/plugins/tasklist/skins/larry/tasklist.css @@ -1,1511 +1,1511 @@ /** * Roundcube Taklist plugin styles for skin "Larry" * * Copyright (C) 2012, Kolab Systems AG * Screendesign by FLINT / Büro für Gestaltung, bueroflint.com * * The contents are subject to the Creative Commons Attribution-ShareAlike * License. It is allowed to copy, distribute, transmit and to adapt the work * by keeping credits to the original autors in the README file. * See http://creativecommons.org/licenses/by-sa/3.0/ for details. */ #taskbar a.button-tasklist span.button-inner { background-image: url(buttons.png); background-position: 0 0; } #taskbar a.button-tasklist:hover span.button-inner, #taskbar a.button-tasklist.button-selected span.button-inner { background-position: 0 -26px; } ul.toolbarmenu li span.icon.taskadd, #attachmentmenu li a.tasklistlink span.icon.taskadd { background-image: url(buttons.png); background-position: -4px -90px; } #taskedit.uidialog, .tasklistview div.uidialog { display: none; } .tasklistview #mainscreen { min-width: 1000px !important; min-height: 520px !important; } .tasklistview #header { min-width: 1020px !important; } #sidebar { position: absolute; top: 0; left: 0; bottom: 0; width: 240px; } .tasklistview #searchmenulink { width: 15px; } #tagsbox { position: absolute; top: 42px; left: 0; width: 100%; height: 242px; } #tasklistsbox { position: absolute; top: 300px; left: 0; width: 100%; bottom: 0px; } #tasklistsbox .boxtitle a.iconbutton.search { position: absolute; top: 8px; right: 8px; width: 16px; cursor: pointer; background-position: -2px -317px; } #tasklistsbox .listsearchbox { display: none; } #tasklistsbox .listsearchbox.expanded { display: block; } #tasklistsbox .scroller { top: 34px; } #tasklistsbox .listsearchbox.expanded + .scroller { top: 68px; } #taskselector { margin: -1px 40px 0 0; padding: 0; } #taskselector li { display: inline-block; position: relative; font-size: 90%; padding-right: 0.3em; } .tagcloud li, #taskselector li a { display: inline-block; color: #004458; min-width: 3.5em; padding: 0.2em 0.6em 0.2em 0.6em; text-align: center; text-decoration: none; border: 1px solid #eee; border-color: transparent; } .webkit .tagcloud li, .webkit #taskselector li a { padding-bottom: 0.25em; } #taskselector li:first-child { border-top: 0; border-radius: 4px 4px 0 0; } #taskselector li:last-child { border-bottom: 0; border-radius: 0 0 4px 4px; } #taskselector li.overdue a { color: #b72a2a; font-weight: bold; } #taskselector li.inactive a { color: #97b3bf; } .tagcloud li.selected, #taskselector li.selected a { color: #fff; background: #005d76; background: -moz-linear-gradient(top, #005d76 0%, #004558 100%); background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#005d76), color-stop(100%,#004558)); background: -o-linear-gradient(top, #005d76 0%, #004558 100%); background: -ms-linear-gradient(top, #005d76 0%, #004558 100%); background: linear-gradient(top, #005d76 0%, #004558 100%); box-shadow: inset 0 1px 1px 0 #003645; -o-box-shadow: inset 0 1px 1px 0 #003645; -webkit-box-shadow: inset 0 1px 1px 0 #003645; -moz-box-shadow: inset 0 1px 1px 0 #003645; border-color: #003645; border-radius: 10px; text-shadow: none; } #taskselector li .count { display: none; position: absolute; top: -18px; right: 5px; min-width: 1.8em; padding: 2px 4px; background: #004558; background: -moz-linear-gradient(top, #005d76 0%, #004558 100%); background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#005d76), color-stop(100%,#004558)); background: -o-linear-gradient(top, #005d76 0%, #004558 100%); background: -ms-linear-gradient(top, #005d76 0%, #004558 100%); background: linear-gradient(top, #005d76 0%, #004558 100%); box-shadow: 0 1px 2px 0 rgba(24,24,24,0.6); color: #fff; border-radius: 3px; text-align: center; font-weight: bold; font-size: 80%; text-shadow: none; } #taskselector li .count:after { content: ""; position: absolute; bottom: -5px; left: 50%; margin-left: -5px; border-style: solid; border-width: 5px 5px 0; border-color: #004558 transparent; /* reduce the damage in FF3.0 */ display: block; width: 0; } #taskselector li.overdue .count { background: #ff3800; } #taskselector li.overdue .count:after { border-color: #ff3800 transparent; } .tagcloud { padding: 0; margin: 6px; list-style: none; } .tagcloud li { display: inline-block; color: #004458; padding-right: 0.2em; margin-right: 0.3em; margin-bottom: 0.4em; min-width: 1.2em; cursor: pointer; } .tagcloud li.inactive { color: #89b3be; padding-right: 0.6em; font-size: 80%; /* display: none; */ } .tagcloud 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, .tagcloud li.inactive .count { display: none; } #tasklistsbox .treelist li { margin: 0; display: block; position: relative; } #tasklistsbox .treelist li div.tasklist { margin: 0; height: 20px; padding: 6px 8px 2px 6px; position: relative; white-space: nowrap; } #tasklistsbox .treelist li.virtual > div.tasklist { height: 14px; } #tasklistsbox .treelist ul li > div.tasklist { margin-left: 16px; } #tasklistsbox .treelist ul ul li > div.tasklist { margin-left: 32px; } #tasklistsbox .treelist ul ul ul li > div.tasklist { margin-left: 48px; } #tasklistsbox .treelist li label { display: block; } #tasklistsbox .treelist li span.listname { display: block; position: absolute; top: 7px; left: 38px; right: 40px; cursor: default; padding: 0px 30px 2px 2px; color: #004458; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; background: url(sprites.png) right 20px no-repeat; } .quickview-active #tasklistsbox .treelist li input, .quickview-active #tasklistsbox .treelist li span.listname { opacity: 0.35; } .quickview-active #tasklistsbox .treelist div.focusview span.listname { opacity: 1.0; } #tasklistsbox .treelist div span.actions { display: inline-block; position: absolute; top: 2px; right: 2px; padding: 5px 20px 0 6px; min-width: 40px; height: 19px; text-align: right; } #tasklistsbox .treelist div:hover span.actions { top: 1px; right: 1px; border: 1px solid #c6c6c6; border-radius: 4px; background: #f7f7f7; background: -moz-linear-gradient(top, #f9f9f9 0%, #e6e6e6 100%); background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#f9f9f9), color-stop(100%,#e6e6e6)); background: -o-linear-gradient(top, #f9f9f9 0%, #e6e6e6 100%); background: -ms-linear-gradient(top, #f9f9f9 0%, #e6e6e6 100%); background: linear-gradient(top, #f9f9f9 0%, #e6e6e6 100%); filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#f9f9f9', endColorstr='#e6e6e6', GradientType=0); } #tasklistsbox .treelist div a.remove, #tasklistsbox .treelist div a.quickview, #tasklistsbox .treelist div a.subscribed { display: inline-block; width: 16px; height: 16px; padding: 0; margin-right: 4px; background: url(sprites.png) -200px 0 no-repeat; overflow: hidden; text-indent: -5000px; cursor: pointer; } #tasklistsbox .treelist div a.subscribed { position: absolute; top: 5px; right: 4px; margin: 0; } #tasklistsbox .treelist div a.subscribed:focus, #tasklistsbox .treelist div:hover a.subscribed { background-position: -2px -215px; } #tasklistsbox .treelist div.subscribed a.subscribed { background-position: -20px -215px; } #tasklistsbox .treelist div a.quickview:focus, #tasklistsbox .treelist div:hover a.quickview { background-position: -20px -101px; background-color: transparent !important; } #tasklistsbox .treelist div a.remove:focus, #tasklistsbox .treelist div:hover a.remove { background-position: -2px -371px; background-color: transparent !important; } #tasklistsbox .treelist div.focusview a.quickview { background-position: -2px -101px; } #tasklistsbox .searchresults .treelist div a.remove, #tasklistsbox .searchresults .treelist div a.quickview { display: none; } #tasklistsbox .treelist div a.remove:focus, #tasklistsbox .treelist div a.quickview:focus, #tasklistsbox .treelist div a.subscribed:focus { border-radius: 3px; outline: 2px solid rgba(30,150,192, 0.5); } #tasklistsbox .treelist li.selected > div > span.listname { font-weight: bold; } #tasklistsbox .treelist .readonly > span.listname { background-position: right -142px; } #tasklistsbox .treelist .user > span.listname { background-position: right -160px; } #tasklistsbox .treelist .virtual > span.listname { color: #aaa; top: 4px; left: 20px; right: 5px; } #tasklistsbox .treelist.flat li span.calname { left: 24px; right: 22px; } #tasklistsbox .treelist li input { position: absolute; top: 5px; left: 18px; } #tasklistsbox .treelist li .treetoggle { top: 8px; } #tasklistsbox .treelist li.virtual > .treetoggle { top: 6px; } #tasklistsbox .searchresults { background: #b0ccd7; margin-top: 8px; } #tasklistsbox .searchresults .boxtitle { background: none; padding: 2px 8px 2px 8px; } #tasklistsbox .searchresults .listing li { background-color: #c7e3ef; } #mainview-right { position: absolute; top: 0; left: 256px; right: 0; bottom: 0; } #taskstoolbar { position: absolute; top: -6px; left: 0; width: 100%; height: 40px; white-space: nowrap; } #taskstoolbar a.button.newtask { background-image: url(buttons.png); background-position: center -53px; } #quickaddbox { position: absolute; top: 2px; left: 0; width: 60%; height: 32px; white-space: nowrap; } #quickaddinput { width: 85%; margin: 0; padding: 3px 8px; height: 18px; background: #f1f1f1; background: rgba(255, 255, 255, 0.7); border-color: #a3a3a3; font-weight: bold; } #quickaddbox .button { margin-left: 5px; padding: 3px 10px; font-weight: bold; } #tasksview { position: absolute; top: 42px; left: 0; right: 0; bottom: 0; background: rgba(255, 255, 255, 0.2); overflow: visible; } .quickview-active #tasksview { background-image: url('images/focusview.png'); background-position: center; background-repeat: no-repeat; } #message.statusbar { border-top: 1px solid #c3c3c3; } #tasksview .scroller { position: absolute; left: 0; top: 35px; width: 100%; bottom: 0; overflow: auto; } #tasksview .buttonbar { color: #777; background: #eee; background: -moz-linear-gradient(top, #eee 0%, #dfdfdf 100%); background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#eee), color-stop(100%,#dfdfdf)); background: -o-linear-gradient(top, #eee 0%, #dfdfdf 100%); background: -ms-linear-gradient(top, #eee 0%, #dfdfdf 100%); background: linear-gradient(top, #eee 0%, #dfdfdf 100%); border-bottom: 1px solid #ccc; position: relative; line-height: 13px; height: 20px; } #tasksview .buttonbar .buttonbar-right { position: absolute; top: 6px; right: 8px; } .buttonbar-right .listmenu { display: inline-block; cursor: pointer; } .buttonbar-right a.iconbutton { padding: 0; background-image: url(sprites.png); background-position: 0 -238px; } .buttonbar-right a.iconbutton.sorting { background-position: -18px -347px; } #thelist { padding: 0; margin: 1em; list-style: none; } #listmessagebox { display: none; font-size: 14px; color: #666; margin: 1.5em; text-shadow: 0px 1px 1px #fff; text-align:center; } .taskitem { position: relative; display: block; margin-bottom: 3px; } .taskitem.dragging { opacity: 0.5; } .taskitem .childtasks { position: relative; padding: 0; margin: 3px 0 0 20px; list-style: none; } .taskitem .childtoggle { display: none; position: absolute; top: 4px; left: -5px; padding: 2px; font-size: 10px; color: #727272; cursor: pointer; width: 14px; height: 14px; background: url(sprites.png) -2px -80px no-repeat; text-indent: -1000px; overflow: hidden; } .taskitem .childtoggle.collapsed { background-position: -18px -81px; } .taskhead { position: relative; margin-left: 14px; padding: 4px 5px 3px 5px; border: 1px solid #fff; border-radius: 5px; background: #fff; -webkit-box-shadow: 0 1px 1px 0 rgba(50, 50, 50, 0.5); -moz-box-shadow: 0 1px 1px 0 rgba(50, 50, 50, 0.5); box-shadow: 0 1px 1px 0 rgba(50, 50, 50, 0.5); padding-right: 26em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: default; outline: none; } .taskhead:focus, .taskhead.droptarget { border-color: #4787b1; box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); -moz-box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); -webkit-box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); -o-box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); } .taskhead .complete { margin: -1px 1em 0 0; } .taskhead .title { font-size: 12px; } .taskhead .flagged, .taskshow.status-flagged h2:after { display: inline-block; width: 16px; height: 16px; background: url(sprites.png) 1000px -3px no-repeat; margin: -3px 1em 0 0; vertical-align: middle; cursor: pointer; } .taskhead .flagged:focus, .taskhead:hover .flagged { background-position: -2px -3px; } .taskhead.flagged .flagged, .taskshow.status-flagged h2:after { background-position: -2px -23px; } .taskhead .tags { display: block; position: absolute; top: 3px; right: 10em; max-width: 14em; height: 16px; overflow: hidden; padding-top: 1px; text-align: right; } .tag-draghelper .tag, .taskhead .tags .tag { font-size: 85%; background: #d9ecf4; border: 1px solid #c2dae5; border-radius: 4px; padding: 1px 7px; margin-right: 3px; } .tag-draghelper li.tag { list-style: none; font-size: 100%; } .taskhead .date { position: absolute; top: 4px; right: 30px; text-align: right; cursor: pointer; } .taskhead.nodate .date { color: #ddd; } .taskhead.overdue .date { color: #d00; } .taskhead.nodate:hover .date { color: #999; } .taskhead .date input { padding: 1px 2px; border: 1px solid #ddd; -webkit-box-shadow: none; -moz-box-shadow: none; box-shadow: none; outline: none; text-align: right; width: 6em; font-size: 11px; } .taskhead .actions { display: block; position: absolute; top: 3px; right: 6px; width: 18px; height: 18px; background: url(sprites.png) 1000px -80px no-repeat; text-indent: -5000px; overflow: hidden; cursor: pointer; } .taskhead .actions:focus, .taskhead:hover .actions { background-position: 0 -80px; } .taskhead.complete { opacity: 0.6; } .taskhead.complete .title { text-decoration: line-through; } .taskhead .progressbar { position: absolute; bottom: 1px; left: 6px; right: 6px; height: 2px; } .taskhead.complete .progressbar { display: none; } .taskhead .progressvalue { height: 1px; background: rgba(1, 124, 180, 0.2); border-top: 1px solid #219de6; } ul.toolbarmenu li span.add, ul.toolbarmenu li span.expand, ul.toolbarmenu li span.collapse, ul.toolbarmenu li span.history, ul.toolbarmenu.iconized .selected span.icon { background-image: url(sprites.png); } 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 { background-position: 0 -1508px; } ul.toolbarmenu li span.history { background-position: 0 -408px; } ul.toolbarmenu.iconized .selected span.icon { background-position: 0 -324px; } ul.toolbarmenu .sortcol.by-auto a { font-style: italic; } .taskitem-draghelper { /* width: 32px; height: 26px; */ background: #444; border: 1px solid #555; border-radius: 4px; box-shadow: 0 2px 6px 0 #333; -moz-box-shadow: 0 2px 6px 0 #333; -webkit-box-shadow: 0 2px 6px 0 #333; -o-box-shadow: 0 2px 6px 0 #333; z-index: 5000; padding: 2px 10px; font-size: 20px; color: #ccc; opacity: 0.92; filter: alpha(opacity=90); text-shadow: 0px 1px 1px #333; } #rootdroppable { display: none; position: absolute; top: 2px; left: 1em; right: 1em; height: 5px; background: #ddd; border-radius: 3px; } #rootdroppable.droptarget { background: #4787b1; box-shadow: 0 0 2px 1px rgba(71,135,177, 0.9); -moz-box-shadow: 0 0 2px 1px rgba(71,135,177, 0.9); -webkit-box-shadow: 0 0 2px 1px rgba(71,135,177, 0.9); -o-box-shadow: 0 0 2px 1px rgba(71,135,177, 0.9); } /*** task edit form ***/ #taskedit, #taskshow, #taskdiff { display: none; } #taskedit { position: relative; top: -1.5em; padding: 0.5em 0.1em; margin: 0 -0.2em; } .taskshow h2 { margin-top: -0.5em; } #taskdiff h2 { font-size: 18px; margin: -0.3em 0 0.4em 0; } .taskshow.status-completed h2 { text-decoration: line-through; } .taskshow.status-flagged h2:after { content: " "; position: relative; margin-left: 0.6em; top: 1px; cursor: default; } .taskshow label { color: #999; } .taskshow.status-cancelled { background: url(images/badge_cancelled.png) top right no-repeat; } .task-parent-title { position: relative; top: -0.6em; } a.morelink { font-size: 90%; color: #0069a6; text-decoration: none; outline: none; } a.morelink:hover { text-decoration: underline; } #taskedit .ui-tabs-panel { min-height: 24em; } #taskeditform input.text, #taskeditform textarea { width: 97%; } #taskeditform .buttons { margin: 0.5em 0; } #taskedit .border-after { padding-bottom: 0.8em; margin-bottom: 0.8em; border-bottom: 2px solid #fafafa; } #taskedit .edit-attendees-table { width: 100%; margin-top: 0.5em; } #taskedit .edit-attendees-table tbody td { padding: 5px 7px 6px; } #taskedit .edit-attendees-table tbody tr:last-child td { border-bottom: 0; } #taskedit .edit-attendees-table th.role, #taskedit .edit-attendees-table td.role { width: 9em; } #taskedit .edit-attendees-table th.availability, #taskedit .edit-attendees-table td.availability, #taskedit .edit-attendees-table th.confirmstate, #taskedit .edit-attendees-table td.confirmstate { width: 6em; } #taskedit .edit-attendees-table th.options, #taskedit .edit-attendees-table td.options { width: 24px; padding: 2px 4px; text-align: right; } #taskedit .edit-attendees-table th.invite, #taskedit .edit-attendees-table td.invite { - width: 48px; + width: 50px; padding: 2px; } #taskedit .edit-attendees-table th.invite label { display: inline-block; position: relative; top: 4px; width: 24px; height: 18px; min-width: 24px; padding: 0; overflow: hidden; text-indent: -5000px; white-space: nowrap; background: url(images/sendinvitation.png) 1px 0 no-repeat; } #taskedit .edit-attendees-table th.name, #taskedit .edit-attendees-table td.name { width: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #taskedit .edit-attendees-table td.name .attendee-name { display: block; position: relative; overflow: hidden; text-overflow: ellipsis; padding: 5px 7px 6px 4px; margin: -5px -7px -6px; } #taskedit .edit-attendees-table td.name select { width: 100%; } #taskedit .edit-attendees-table a.deletelink { display: inline-block; width: 17px; height: 17px; padding: 0; overflow: hidden; text-indent: 1000px; } #taskedit .edit-attendees-table a.expandlink { position: absolute; top: 4px; right: 6px; width: 16px; height: 16px; } #edit-attendees-form { position: relative; margin-top: 15px; } #edit-attendees-form .attendees-invitebox { text-align: right; margin: 0; } #edit-attendees-form .attendees-invitebox label { padding-right: 3px; } #taskedit-attachments { margin: 0.6em 0; } #taskedit-attachments ul li { display: block; color: #333; font-weight: bold; padding: 3px 4px 3px 30px; text-shadow: 0px 1px 1px #fff; text-decoration: none; white-space: nowrap; line-height: 20px; } #taskedit-attachments ul li a.file { padding: 0; } #taskedit-attachments-form { margin-top: 1em; padding-top: 0.8em; border-top: 2px solid #fafafa; } div.form-section { position: relative; margin-top: 0.2em; margin-bottom: 0.5em; } .form-section label { display: inline-block; min-width: 7em; padding-right: 0.5em; margin-bottom: 0.3em; } .tasklistview div.form-section span.task-text + label { margin-left: 2em; } label.block { display: block; margin-bottom: 0.3em; } .task-description { margin-bottom: 1em; } .taskshow .task-text-old, .taskshow .task-text-new, .taskshow .task-text-diff { padding: 2px; } .taskshow .task-text-diff del, .taskshow .task-text-diff ins { text-decoration: none; color: inherit; } .taskshow .task-text-old, .taskshow .task-text-diff del { background-color: #fdd; /* text-decoration: line-through; */ } .taskshow .task-text-new, .taskshow .task-text-diff ins { background-color: #dfd; } .tasklistview .taskshow label span.index { vertical-align: inherit; margin-left: 0.6em; } #taskedit-completeness-slider { display: inline-block; margin-left: 2em; width: 30em; height: 0.8em; border: 1px solid #ccc; } #taskedit-tagline { width: 97%; } #taskedit .droptarget { background-image: url(../../../../skins/larry/images/filedrop.png) !important; background-position: center bottom !important; background-repeat: no-repeat !important; } #taskedit .droptarget.hover, #taskedit .droptarget.active { border-color: #019bc6; box-shadow: 0 0 3px 2px rgba(71,135,177, 0.5); -moz-box-shadow: 0 0 3px 2px rgba(71,135,177, 0.5); -webkit-box-shadow: 0 0 3px 2px rgba(71,135,177, 0.5); -o-box-shadow: 0 0 3px 2px rgba(71,135,177, 0.5); } #taskedit .droptarget.hover { background-color: #d9ecf4; box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); -moz-box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); -webkit-box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); -o-box-shadow: 0 0 5px 2px rgba(71,135,177, 0.9); } #task-links { margin-top: 0; margin-bottom: 0.2em; } #task-links label { vertical-align: top; margin-top: 0.3em; } #task-links .attachmentslist { display: inline-block; } #task-links .attachmentslist li { display: inline-block; margin-right: 1em; } #taskedit-links .attachmentslist li.message.eml, #task-links .attachmentslist li.message.eml { background-image: url(sprites.png); background-position: -2px -388px; } #taskedit-links .attachmentslist li.message a.messagelink, #task-links .attachmentslist li.message a.messagelink { padding: 0 0 0 24px; } #taskedit-links .attachmentslist li.deleted a.messagelink, #taskedit-links .attachmentslist li.deleted a.messagelink:hover { text-decoration: line-through; } #taskedit-links label { float: left; margin-top: 0.3em; } #taskedit-links .task-text { margin-left: 8em; min-height: 22px; } #taskedit-links .attachmentslist li a.delete { top: 0; background-position: -6px -378px; } #task-attachments .attachmentslist li { float: left; margin-right: 1em; } #task-attachments .attachmentslist li a { outline: none; } .task-attendees span.attendee { padding-right: 18px; margin-right: 0.5em; background: url(images/attendee-status.png) right 0 no-repeat; } .task-attendees span.attendee a.mailtolink { text-decoration: none; white-space: nowrap; outline: none; } .task-attendees span.attendee a.mailtolink:hover { text-decoration: underline; } .task-attendees span.completed { background-position: right -20px; } .task-attendees span.declined { background-position: right -40px; } .task-attendees span.tentative { background-position: right -60px; } .task-attendees span.delegated { background-position: right -180px; } .task-attendees span.in-process { background-position: right -200px; } .task-attendees span.accepted { background-position: right -220px; } .task-attendees span.organizer { background-position: right 100px; } #all-task-attendees span.attendee { display: block; margin-bottom: 0.4em; padding-bottom: 0.3em; border-bottom: 1px solid #ddd; } .tasklistview .uidialog .tabbed { min-width: 600px; } .tasklistview .uidialog .propform fieldset.ui-tabs-panel { min-height: 290px; } .tasklistview .uidialog .propform #taskedit-tasklistame { width: 20em; } .task-dialog-message { margin-top: 0.5em; padding: 0.8em; border: 1px solid #ffdf0e; background-color: #fef893; } .task-dialog-message .message, .task-update-confirm .message { margin-bottom: 0.5em; } /* Invitation UI in mail */ .messagelist tbody .attachment span.ical { display: inline-block; vertical-align: middle; height: 18px; width: 20px; padding: 0; background: url(images/ical-attachment.png) 2px 1px no-repeat; } div.tasklist-invitebox { min-height: 20px; margin: 5px 8px; padding: 3px 6px 6px 34px; border: 1px solid #ffdf0e; background: url(images/tasklist.png) 6px 5px no-repeat #fef893; } div.tasklist-invitebox td { padding: 2px; } div.tasklist-invitebox td.ititle { font-weight: bold; padding-right: 0.5em; } div.tasklist-invitebox td.label { color: #666; padding-right: 1em; } #task-rsvp .rsvp-buttons, #task-rsvp .itip-reply-controls, div.tasklist-invitebox .itip-buttons div { margin-top: 0.5em; } #task-rsvp .itip-reply-controls a, #task-rsvp .itip-reply-controls label { color: #333; } #task-rsvp input.button, div.tasklist-invitebox input.button { font-weight: bold; margin-right: 0.5em; } div.tasklist-invitebox .folder-select { font-weight: 10px; margin-left: 1em; } div.tasklist-invitebox .rsvp-status { padding-left: 2px; } div.tasklist-invitebox .rsvp-status.loading { color: #666; padding: 1px 0 2px 24px; background: url(images/loading_blue.gif) top left no-repeat; } div.tasklist-invitebox .rsvp-status.hint { color: #666; text-shadow: none; font-style: italic; } #task-partstat .changersvp, .tasklistview .edit-attendees-table td.confirmstate span, div.tasklist-invitebox .rsvp-status.declined, div.tasklist-invitebox .rsvp-status.tentative, div.tasklist-invitebox .rsvp-status.accepted, div.tasklist-invitebox .rsvp-status.delegated, div.tasklist-invitebox .rsvp-status.in-process, div.tasklist-invitebox .rsvp-status.completed, div.tasklist-invitebox .rsvp-status.needs-action { padding: 0 0 1px 22px; background: url(images/attendee-status.png) 2px -20px no-repeat; } #task-partstat .changersvp.declined, div.tasklist-invitebox .rsvp-status.declined, .tasklistview .edit-attendees-table td.confirmstate span.declined { background-position: 2px -40px; } #task-partstat .changersvp.tentative, div.tasklist-invitebox .rsvp-status.tentative, .tasklistview .edit-attendees-table td.confirmstate span.tentative { background-position: 2px -60px; } #task-partstat .changersvp.delegated, div.tasklist-invitebox .rsvp-status.delegated, .tasklistview .edit-attendees-table td.confirmstate span.delegated { background-position: 2px -180px; } #task-partstat .changersvp.needs-action, div.tasklist-invitebox .rsvp-status.needs-action, .tasklistview .edit-attendees-table td.confirmstate span.needs-action { background-position: 2px 0; } #task-partstat .changersvp.in-process, div.tasklist-invitebox .rsvp-status.in-process, .tasklistview .edit-attendees-table td.confirmstate span.in-process { background-position: 2px -200px; } #task-partstat .changersvp.accepted, div.tasklist-invitebox .rsvp-status.accepted, .tasklistview .edit-attendees-table td.confirmstate span.accepted { background-position: 2px -220px; } div.messagetasklinks { position: relative; margin: 8px 8px; padding: 4px 8px 4px 30px; border: 1px solid #dfdfdf; background: #fafafa; border-radius: 4px; } div.messagetasklinks::before { content: " "; position: absolute; top: 4px; left: 8px; width: 18px; height: 18px; background: url(buttons.png) -6px -115px no-repeat; } div.messagetasklinks ul.tasklist { margin: 0; padding: 0; list-style: none; } div.messagetasklinks .messagetaskref { display: block; margin-bottom: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } div.messagetasklinks a.messagetasklink { position: relative; display: inline-block; color: #333; font-weight: bold; padding: 3px 0 2px 2px; text-shadow: 0px 1px 1px #fff; text-decoration: none; white-space: nowrap; vertical-align: middle; } div.messagetasklinks .messagetaskref.complete a.messagetasklink { text-decoration: line-through; text-shadow: none; color: #666; } div.messagetasklinks .messagetaskref input.complete { vertical-align: middle; } .tasklist.attachmentwin #attachmenttoolbar { position: relative; top: -6px; height: 40px; } .tasklist.attachmentwin #attachmentcontainer { position: absolute; top: 0; left: 232px; right: 0; bottom: 0; } .tasklist.attachmentwin #attachmentframe { width: 100%; height: 100%; border: 0; background-color: #fff; border-radius: 4px; } .tasklist.attachmentwin #partheader { position: absolute; top: 0; left: 0; width: 220px; bottom: 0; } .tasklist.attachmentwin #partheader table { table-layout: fixed; overflow: hidden; } .tasklist.attachmentwin #partheader table td { color: #666; padding: 4px 6px; text-overflow: ellipsis; overflow: hidden; } .tasklist.attachmentwin #partheader table td.header { font-weight: bold; } .tasklist.attachmentwin #partheader table td.title { width: 60px; padding-right: 0; } /** Special hacks for IE7 **/ /** They need to be in this file to also affect the task-create dialog embedded in mail view **/ html.ie7 #taskedit-completeness-slider { display: inline; } diff --git a/plugins/tasklist/tasklist.php b/plugins/tasklist/tasklist.php index 457f66d6..7c45a3f6 100644 --- a/plugins/tasklist/tasklist.php +++ b/plugins/tasklist/tasklist.php @@ -1,2280 +1,2285 @@ * * Copyright (C) 2012, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class tasklist extends rcube_plugin { const FILTER_MASK_TODAY = 1; const FILTER_MASK_TOMORROW = 2; const FILTER_MASK_WEEK = 4; const FILTER_MASK_LATER = 8; const FILTER_MASK_NODATE = 16; const FILTER_MASK_OVERDUE = 32; const FILTER_MASK_FLAGGED = 64; const FILTER_MASK_COMPLETE = 128; const FILTER_MASK_ASSIGNED = 256; const FILTER_MASK_MYTASKS = 512; const SESSION_KEY = 'tasklist_temp'; public static $filter_masks = array( 'today' => self::FILTER_MASK_TODAY, 'tomorrow' => self::FILTER_MASK_TOMORROW, 'week' => self::FILTER_MASK_WEEK, 'later' => self::FILTER_MASK_LATER, 'nodate' => self::FILTER_MASK_NODATE, 'overdue' => self::FILTER_MASK_OVERDUE, 'flagged' => self::FILTER_MASK_FLAGGED, 'complete' => self::FILTER_MASK_COMPLETE, 'assigned' => self::FILTER_MASK_ASSIGNED, 'mytasks' => self::FILTER_MASK_MYTASKS, ); public $task = '?(?!login|logout).*'; public $allowed_prefs = array('tasklist_sort_col','tasklist_sort_order'); public $rc; public $lib; public $driver; public $timezone; public $ui; public $home; // declare public to be used in other classes private $collapsed_tasks = array(); private $message_tasks = array(); private $itip; private $ical; /** * Plugin initialization. */ function init() { $this->require_plugin('libcalendaring'); $this->require_plugin('jqueryui'); $this->rc = rcube::get_instance(); $this->lib = libcalendaring::get_instance(); $this->register_task('tasks', 'tasklist'); // load plugin configuration $this->load_config(); $this->timezone = $this->lib->timezone; // proceed initialization in startup hook $this->add_hook('startup', array($this, 'startup')); $this->add_hook('user_delete', array($this, 'user_delete')); } /** * Startup hook */ public function startup($args) { // the tasks module can be enabled/disabled by the kolab_auth plugin if ($this->rc->config->get('tasklist_disabled', false) || !$this->rc->config->get('tasklist_enabled', true)) return; // load localizations $this->add_texts('localization/', $args['task'] == 'tasks' && (!$args['action'] || $args['action'] == 'print')); $this->rc->load_language($_SESSION['language'], array('tasks.tasks' => $this->gettext('navtitle'))); // add label for task title if ($args['task'] == 'tasks' && $args['action'] != 'save-pref') { $this->load_driver(); // register calendar actions $this->register_action('index', array($this, 'tasklist_view')); $this->register_action('task', array($this, 'task_action')); $this->register_action('tasklist', array($this, 'tasklist_action')); $this->register_action('counts', array($this, 'fetch_counts')); $this->register_action('fetch', array($this, 'fetch_tasks')); $this->register_action('print', array($this, 'print_tasks')); $this->register_action('inlineui', array($this, 'get_inline_ui')); $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->register_action('mailimportitip', array($this, 'mail_import_itip')); $this->register_action('mailimportattach', array($this, 'mail_import_attachment')); $this->register_action('itip-status', array($this, 'task_itip_status')); $this->register_action('itip-remove', array($this, 'task_itip_remove')); $this->register_action('itip-decline-reply', array($this, 'mail_itip_decline_reply')); $this->register_action('itip-delegate', array($this, 'mail_itip_delegate')); $this->add_hook('refresh', array($this, 'refresh')); $this->collapsed_tasks = array_filter(explode(',', $this->rc->config->get('tasklist_collapsed_tasks', ''))); } else if ($args['task'] == 'mail') { if ($args['action'] == 'show' || $args['action'] == 'preview') { if ($this->rc->config->get('tasklist_mail_embed', true)) { $this->add_hook('message_load', array($this, 'mail_message_load')); } $this->add_hook('template_object_messagebody', array($this, 'mail_messagebody_html')); } // add 'Create event' item to message menu if ($this->api->output->type == 'html') { $this->api->add_content(html::tag('li', null, $this->api->output->button(array( 'command' => 'tasklist-create-from-mail', 'label' => 'tasklist.createfrommail', 'type' => 'link', 'classact' => 'icon taskaddlink active', 'class' => 'icon taskaddlink', 'innerclass' => 'icon taskadd', ))), 'messagemenu'); $this->api->output->add_label('tasklist.createfrommail'); } } if (!$this->rc->output->ajax_call && !$this->rc->output->env['framed']) { $this->load_ui(); $this->ui->init(); } // add hooks for alarms handling $this->add_hook('pending_alarms', array($this, 'pending_alarms')); $this->add_hook('dismiss_alarms', array($this, 'dismiss_alarms')); } /** * */ private function load_ui() { if (!$this->ui) { require_once($this->home . '/tasklist_ui.php'); $this->ui = new tasklist_ui($this); } } /** * Helper method to load the backend driver according to local config */ private function load_driver() { if (is_object($this->driver)) return; $driver_name = $this->rc->config->get('tasklist_driver', 'database'); $driver_class = 'tasklist_' . $driver_name . '_driver'; require_once($this->home . '/drivers/tasklist_driver.php'); require_once($this->home . '/drivers/' . $driver_name . '/' . $driver_class . '.php'); switch ($driver_name) { case "kolab": $this->require_plugin('libkolab'); default: $this->driver = new $driver_class($this); break; } $this->rc->output->set_env('tasklist_driver', $driver_name); } /** * Dispatcher for task-related actions initiated by the client */ public function task_action() { $filter = intval(rcube_utils::get_input_value('filter', rcube_utils::INPUT_GPC)); $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); $rec = rcube_utils::get_input_value('t', rcube_utils::INPUT_POST, true); $oldrec = $rec; $success = $refresh = $got_msg = false; // force notify if hidden + active $itip_send_option = (int)$this->rc->config->get('calendar_itip_send_option', 3); if ($itip_send_option === 1 && empty($rec['_reportpartstat'])) $rec['_notify'] = 1; switch ($action) { case 'new': $oldrec = null; $rec = $this->prepare_task($rec); $rec['uid'] = $this->generate_uid(); $temp_id = $rec['tempid']; if ($success = $this->driver->create_task($rec)) { $refresh = $this->driver->get_task($rec); if ($temp_id) $refresh['tempid'] = $temp_id; $this->cleanup_task($rec); } break; case 'complete': $complete = intval(rcube_utils::get_input_value('complete', rcube_utils::INPUT_POST)); if (!($rec = $this->driver->get_task($rec))) { break; } $oldrec = $rec; $rec['status'] = $complete ? 'COMPLETED' : ($rec['complete'] > 0 ? 'IN-PROCESS' : 'NEEDS-ACTION'); // sent itip notifications if enabled (no user interaction here) if (($itip_send_option & 1)) { if ($this->is_attendee($rec)) { $rec['_reportpartstat'] = $rec['status']; } else if ($this->is_organizer($rec)) { $rec['_notify'] = 1; } } case 'edit': $oldrec = $this->driver->get_task($rec); $rec = $this->prepare_task($rec); $clone = $this->handle_recurrence($rec, $this->driver->get_task($rec)); if ($success = $this->driver->edit_task($rec)) { - $refresh[] = $this->driver->get_task($rec); + $new_task = $this->driver->get_task($rec); + $new_task['tempid'] = $rec['id']; + $refresh[] = $new_task; $this->cleanup_task($rec); // add clone from recurring task if ($clone && $this->driver->create_task($clone)) { - $refresh[] = $this->driver->get_task($clone); + $new_clone = $this->driver->get_task($clone); + $new_clone['tempid'] = $clone['id']; + $refresh[] = $new_clone; $this->driver->clear_alarms($rec['id']); } // move all childs if list assignment was changed if (!empty($rec['_fromlist']) && !empty($rec['list']) && $rec['_fromlist'] != $rec['list']) { foreach ($this->driver->get_childs(array('id' => $rec['id'], 'list' => $rec['_fromlist']), true) as $cid) { $child = array('id' => $cid, 'list' => $rec['list'], '_fromlist' => $rec['_fromlist']); if ($this->driver->move_task($child)) { $r = $this->driver->get_task($child); if ((bool)($filter & self::FILTER_MASK_COMPLETE) == $this->driver->is_complete($r)) { + $r['tempid'] = $cid; $refresh[] = $r; } } } } } break; case 'move': foreach ((array)$rec['id'] as $id) { $r = $rec; $r['id'] = $id; if ($this->driver->move_task($r)) { $new_task = $this->driver->get_task($r); $new_task['tempid'] = $id; $refresh[] = $new_task; $success = true; // move all childs, too foreach ($this->driver->get_childs(array('id' => $id, 'list' => $rec['_fromlist']), true) as $cid) { $child = $rec; $child['id'] = $cid; if ($this->driver->move_task($child)) { $r = $this->driver->get_task($child); if ((bool)($filter & self::FILTER_MASK_COMPLETE) == $this->driver->is_complete($r)) { $r['tempid'] = $cid; $refresh[] = $r; } } } } } break; case 'delete': $mode = intval(rcube_utils::get_input_value('mode', rcube_utils::INPUT_POST)); $oldrec = $this->driver->get_task($rec); if ($success = $this->driver->delete_task($rec, false)) { // delete/modify all childs foreach ($this->driver->get_childs($rec, $mode) as $cid) { $child = array('id' => $cid, 'list' => $rec['list']); if ($mode == 1) { // delete all childs if ($this->driver->delete_task($child, false)) { if ($this->driver->undelete) $_SESSION['tasklist_undelete'][$rec['id']][] = $cid; } else $success = false; } else { $child['parent_id'] = strval($oldrec['parent_id']); $this->driver->edit_task($child); } } // update parent task to adjust list of children if (!empty($oldrec['parent_id'])) { $refresh[] = $this->driver->get_task(array('id' => $oldrec['parent_id'], 'list' => $rec['list'])); } } if (!$success) $this->rc->output->command('plugin.reload_data'); break; case 'undelete': if ($success = $this->driver->undelete_task($rec)) { $refresh[] = $this->driver->get_task($rec); foreach ((array)$_SESSION['tasklist_undelete'][$rec['id']] as $cid) { if ($this->driver->undelete_task($rec)) { $refresh[] = $this->driver->get_task($rec); } } } break; case 'collapse': foreach (explode(',', $rec['id']) as $rec_id) { if (intval(rcube_utils::get_input_value('collapsed', rcube_utils::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)))); return; // avoid further actions case 'rsvp': $status = rcube_utils::get_input_value('status', rcube_utils::INPUT_GPC); $noreply = intval(rcube_utils::get_input_value('noreply', rcube_utils::INPUT_GPC)) || $status == 'needs-action'; $task = $this->driver->get_task($rec); $task['attendees'] = $rec['attendees']; $task['_type'] = 'task'; // send invitation to delegatee + add it as attendee if ($status == 'delegated' && $rec['to']) { $itip = $this->load_itip(); if ($itip->delegate_to($task, $rec['to'], (bool)$rec['rsvp'])) { $this->rc->output->show_message('tasklist.itipsendsuccess', 'confirmation'); $refresh[] = $task; $noreply = false; } } $rec = $task; if ($success = $this->driver->edit_task($rec)) { if (!$noreply) { // let the reply clause further down send the iTip message $rec['_reportpartstat'] = $status; } } break; case 'changelog': $data = $this->driver->get_task_changelog($rec); if (is_array($data) && !empty($data)) { $lib = $this->lib; $dtformat = $this->rc->config->get('date_format') . ' ' . $this->rc->config->get('time_format'); array_walk($data, function(&$change) use ($lib, $dtformat) { if ($change['date']) { $dt = $lib->adjust_timezone($change['date']); if ($dt instanceof DateTime) { $change['date'] = $this->rc->format_date($dt, $dtformat, false); } } }); $this->rc->output->command('plugin.task_render_changelog', $data); } else { $this->rc->output->command('plugin.task_render_changelog', false); } $got_msg = true; break; case 'diff': $data = $this->driver->get_task_diff($rec, $rec['rev1'], $rec['rev2']); if (is_array($data)) { // convert some properties, similar to self::_client_event() $lib = $this->lib; $date_format = $this->rc->config->get('date_format', 'Y-m-d'); $time_format = $this->rc->config->get('time_format', 'H:i'); array_walk($data['changes'], function(&$change, $i) use ($lib, $date_format, $time_format) { // convert date cols if (in_array($change['property'], array('date','start','created','changed'))) { if (!empty($change['old'])) { $dtformat = strlen($change['old']) == 10 ? $date_format : $date_format . ' ' . $time_format; $change['old_'] = $lib->adjust_timezone($change['old'], strlen($change['old']) == 10)->format($dtformat); } if (!empty($change['new'])) { $dtformat = strlen($change['new']) == 10 ? $date_format : $date_format . ' ' . $time_format; $change['new_'] = $lib->adjust_timezone($change['new'], strlen($change['new']) == 10)->format($dtformat); } } // create textual representation for alarms and recurrence if ($change['property'] == 'alarms') { if (is_array($change['old'])) $change['old_'] = libcalendaring::alarm_text($change['old']); if (is_array($change['new'])) $change['new_'] = libcalendaring::alarm_text(array_merge((array)$change['old'], $change['new'])); } if ($change['property'] == 'recurrence') { if (is_array($change['old'])) $change['old_'] = $lib->recurrence_text($change['old']); if (is_array($change['new'])) $change['new_'] = $lib->recurrence_text(array_merge((array)$change['old'], $change['new'])); } if ($change['property'] == 'complete') { $change['old_'] = intval($change['old']) . '%'; $change['new_'] = intval($change['new']) . '%'; } if ($change['property'] == 'attachments') { if (is_array($change['old'])) $change['old']['classname'] = rcube_utils::file2class($change['old']['mimetype'], $change['old']['name']); if (is_array($change['new'])) { $change['new'] = array_merge((array)$change['old'], $change['new']); $change['new']['classname'] = rcube_utils::file2class($change['new']['mimetype'], $change['new']['name']); } } // resolve parent_id to the refered task title for display if ($change['property'] == 'parent_id') { $change['property'] = 'parent-title'; if (!empty($change['old']) && ($old_parent = $this->driver->get_task(array('id' => $change['old'], 'list' => $rec['list'])))) { $change['old_'] = $old_parent['title']; } if (!empty($change['new']) && ($new_parent = $this->driver->get_task(array('id' => $change['new'], 'list' => $rec['list'])))) { $change['new_'] = $new_parent['title']; } } // compute a nice diff of description texts if ($change['property'] == 'description') { $change['diff_'] = libkolab::html_diff($change['old'], $change['new']); } }); $this->rc->output->command('plugin.task_show_diff', $data); } else { $this->rc->output->command('display_message', $this->gettext('objectdiffnotavailable'), 'error'); } $got_msg = true; break; case 'show': if ($rec = $this->driver->get_task_revison($rec, $rec['rev'])) { $this->encode_task($rec); $rec['readonly'] = 1; $this->rc->output->command('plugin.task_show_revision', $rec); } else { $this->rc->output->command('display_message', $this->gettext('objectnotfound'), 'error'); } $got_msg = true; break; case 'restore': if ($success = $this->driver->restore_task_revision($rec, $rec['rev'])) { $refresh = $this->driver->get_task($rec); $this->rc->output->command('display_message', $this->gettext(array('name' => 'objectrestoresuccess', 'vars' => array('rev' => $rec['rev']))), 'confirmation'); $this->rc->output->command('plugin.close_history_dialog'); } else { $this->rc->output->command('display_message', $this->gettext('objectrestoreerror'), 'error'); } $got_msg = true; break; } if ($success) { $this->rc->output->show_message('successfullysaved', 'confirmation'); $this->update_counts($oldrec, $refresh); } else if (!$got_msg) { $this->rc->output->show_message('tasklist.errorsaving', 'error'); } // send out notifications if ($success && $rec['_notify'] && ($rec['attendees'] || $oldrec['attendees'])) { // make sure we have the complete record $task = $action == 'delete' ? $oldrec : $this->driver->get_task($rec); // only notify if data really changed (TODO: do diff check on client already) if (!$oldrec || $action == 'delete' || self::task_diff($task, $oldrec)) { $sent = $this->notify_attendees($task, $oldrec, $action, $rec['_comment']); if ($sent > 0) $this->rc->output->show_message('tasklist.itipsendsuccess', 'confirmation'); else if ($sent < 0) $this->rc->output->show_message('tasklist.errornotifying', 'error'); } } else if ($success && $rec['_reportpartstat'] && $rec['_reportpartstat'] != 'NEEDS-ACTION') { // get the full record after update $task = $this->driver->get_task($rec); // send iTip REPLY with the updated partstat if ($task['organizer'] && ($idx = $this->is_attendee($task)) !== false) { $sender = $task['attendees'][$idx]; $status = strtolower($sender['status']); if (!empty($_POST['comment'])) $task['comment'] = rcube_utils::get_input_value('comment', rcube_utils::INPUT_POST); $itip = $this->load_itip(); $itip->set_sender_email($sender['email']); if ($itip->send_itip_message($this->to_libcal($task), 'REPLY', $task['organizer'], 'itipsubject' . $status, 'itipmailbody' . $status)) $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $task['organizer']['name'] ?: $task['organizer']['email']))), 'confirmation'); else $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); } } // unlock client $this->rc->output->command('plugin.unlock_saving'); if ($refresh) { if ($refresh['id']) { $this->encode_task($refresh); } else if (is_array($refresh)) { foreach ($refresh as $i => $r) $this->encode_task($refresh[$i]); } $this->rc->output->command('plugin.update_task', $refresh); } } /** * Load iTIP functions */ private function load_itip() { if (!$this->itip) { require_once realpath(__DIR__ . '/../libcalendaring/lib/libcalendaring_itip.php'); $this->itip = new libcalendaring_itip($this, 'tasklist'); $this->itip->set_rsvp_actions(array('accepted','declined','delegated')); $this->itip->set_rsvp_status(array('accepted','tentative','declined','delegated','in-process','completed')); } return $this->itip; } /** * repares new/edited task properties before save */ private function prepare_task($rec) { // try to be smart and extract date from raw input if ($rec['raw']) { foreach (array('today','tomorrow','sunday','monday','tuesday','wednesday','thursday','friday','saturday','sun','mon','tue','wed','thu','fri','sat') as $word) { $locwords[] = '/^' . preg_quote(mb_strtolower($this->gettext($word))) . '\b/i'; $normwords[] = $word; $datewords[] = $word; } foreach (array('jan','feb','mar','apr','may','jun','jul','aug','sep','oct','now','dec') as $month) { $locwords[] = '/(' . preg_quote(mb_strtolower($this->gettext('long'.$month))) . '|' . preg_quote(mb_strtolower($this->gettext($month))) . ')\b/i'; $normwords[] = $month; $datewords[] = $month; } foreach (array('on','this','next','at') as $word) { $fillwords[] = preg_quote(mb_strtolower($this->gettext($word))); $fillwords[] = $word; } $raw = trim($rec['raw']); $date_str = ''; // translate localized keywords $raw = preg_replace('/^(' . join('|', $fillwords) . ')\s*/i', '', $raw); $raw = preg_replace($locwords, $normwords, $raw); // find date pattern $date_pattern = '!^(\d+[./-]\s*)?((?:\d+[./-])|' . join('|', $datewords) . ')\.?(\s+\d{4})?[:;,]?\s+!i'; if (preg_match($date_pattern, $raw, $m)) { $date_str .= $m[1] . $m[2] . $m[3]; $raw = preg_replace(array($date_pattern, '/^(' . join('|', $fillwords) . ')\s*/i'), '', $raw); // add year to date string if ($m[1] && !$m[3]) $date_str .= date('Y'); } // find time pattern $time_pattern = '/^(\d+([:.]\d+)?(\s*[hapm.]+)?),?\s+/i'; if (preg_match($time_pattern, $raw, $m)) { $has_time = true; $date_str .= ($date_str ? ' ' : 'today ') . $m[1]; $raw = preg_replace($time_pattern, '', $raw); } // yes, raw input matched a (valid) date if (strlen($date_str) && strtotime($date_str) && ($date = new DateTime($date_str, $this->timezone))) { $rec['date'] = $date->format('Y-m-d'); if ($has_time) $rec['time'] = $date->format('H:i'); $rec['title'] = $raw; } else $rec['title'] = $rec['raw']; } // normalize input from client if (isset($rec['complete'])) { $rec['complete'] = floatval($rec['complete']); if ($rec['complete'] > 1) $rec['complete'] /= 100; } if (isset($rec['flagged'])) $rec['flagged'] = intval($rec['flagged']); // fix for garbage input if ($rec['description'] == 'null') $rec['description'] = ''; foreach ($rec as $key => $val) { if ($val === 'null') $rec[$key] = null; } if (!empty($rec['date'])) { $this->normalize_dates($rec, 'date', 'time'); } if (!empty($rec['startdate'])) { $this->normalize_dates($rec, 'startdate', 'starttime'); } // convert tags to array, filter out empty entries if (isset($rec['tags']) && !is_array($rec['tags'])) { $rec['tags'] = array_filter((array)$rec['tags']); } // convert the submitted alarm values if ($rec['valarms']) { $valarms = array(); foreach (libcalendaring::from_client_alarms($rec['valarms']) as $alarm) { // alarms can only work with a date (either task start, due or absolute alarm date) if (is_a($alarm['trigger'], 'DateTime') || $rec['date'] || $rec['startdate']) $valarms[] = $alarm; } $rec['valarms'] = $valarms; } // convert the submitted recurrence settings if (is_array($rec['recurrence'])) { $refdate = null; if (!empty($rec['date'])) { $refdate = new DateTime($rec['date'] . ' ' . $rec['time'], $this->timezone); } else if (!empty($rec['startdate'])) { $refdate = new DateTime($rec['startdate'] . ' ' . $rec['starttime'], $this->timezone); } if ($refdate) { $rec['recurrence'] = $this->lib->from_client_recurrence($rec['recurrence'], $refdate); // translate count into an absolute end date. // why? because when shifting completed tasks to the next recurrence, // the initial start date to count from gets lost. if ($rec['recurrence']['COUNT']) { $engine = libcalendaring::get_recurrence(); $engine->init($rec['recurrence'], $refdate); if ($until = $engine->end()) { $rec['recurrence']['UNTIL'] = $until; unset($rec['recurrence']['COUNT']); } } } else { // recurrence requires a reference date $rec['recurrence'] = ''; } } $attachments = array(); $taskid = $rec['id']; if (is_array($_SESSION[self::SESSION_KEY]) && $_SESSION[self::SESSION_KEY]['id'] == $taskid) { if (!empty($_SESSION[self::SESSION_KEY]['attachments'])) { foreach ($_SESSION[self::SESSION_KEY]['attachments'] as $id => $attachment) { if (is_array($rec['attachments']) && in_array($id, $rec['attachments'])) { $attachments[$id] = $this->rc->plugins->exec_hook('attachment_get', $attachment); unset($attachments[$id]['abort'], $attachments[$id]['group']); } } } } $rec['attachments'] = $attachments; // convert link references into simple URIs if (array_key_exists('links', $rec)) { $rec['links'] = array_map(function($link) { return is_array($link) ? $link['uri'] : strval($link); }, (array)$rec['links']); } // convert invalid data if (isset($rec['attendees']) && !is_array($rec['attendees'])) $rec['attendees'] = array(); foreach ((array)$rec['attendees'] as $i => $attendee) { if (is_string($attendee['rsvp'])) { $rec['attendees'][$i]['rsvp'] = $attendee['rsvp'] == 'true' || $attendee['rsvp'] == '1'; } } // copy the task status to my attendee partstat if (!empty($rec['_reportpartstat'])) { if (($idx = $this->is_attendee($rec)) !== false) { if (!($rec['_reportpartstat'] == 'NEEDS-ACTION' && $rec['attendees'][$idx]['status'] == 'ACCEPTED')) $rec['attendees'][$idx]['status'] = $rec['_reportpartstat']; else unset($rec['_reportpartstat']); } } // set organizer from identity selector if ((isset($rec['_identity']) || (!empty($rec['attendees']) && empty($rec['organizer']))) && ($identity = $this->rc->user->get_identity($rec['_identity']))) { $rec['organizer'] = array('name' => $identity['name'], 'email' => $identity['email']); } if (is_numeric($rec['id']) && $rec['id'] < 0) unset($rec['id']); return $rec; } /** * Utility method to convert a tasks date/time values into a normalized format */ private function normalize_dates(&$rec, $date_key, $time_key) { try { // parse date from user format (#2801) $date_format = $this->rc->config->get(empty($rec[$time_key]) ? 'date_format' : 'date_long', 'Y-m-d'); $date = DateTime::createFromFormat($date_format, trim($rec[$date_key] . ' ' . $rec[$time_key]), $this->timezone); // fall back to default strtotime logic if (empty($date)) { $date = new DateTime($rec[$date_key] . ' ' . $rec[$time_key], $this->timezone); } $rec[$date_key] = $date->format('Y-m-d'); if (!empty($rec[$time_key])) $rec[$time_key] = $date->format('H:i'); return true; } catch (Exception $e) { $rec[$date_key] = $rec[$time_key] = null; } return false; } /** * Releases some resources after successful save */ private function cleanup_task(&$rec) { // remove temp. attachment files if (!empty($_SESSION[self::SESSION_KEY]) && ($taskid = $_SESSION[self::SESSION_KEY]['id'])) { $this->rc->plugins->exec_hook('attachments_cleanup', array('group' => $taskid)); $this->rc->session->remove(self::SESSION_KEY); } } /** * When flagging a recurring task as complete, * clone it and shift dates to the next occurrence */ private function handle_recurrence(&$rec, $old) { $clone = null; if ($this->driver->is_complete($rec) && $old && !$this->driver->is_complete($old) && is_array($rec['recurrence'])) { $engine = libcalendaring::get_recurrence(); $rrule = $rec['recurrence']; $updates = array(); // compute the next occurrence of date attributes foreach (array('date'=>'time', 'startdate'=>'starttime') as $date_key => $time_key) { if (empty($rec[$date_key])) continue; $date = new DateTime($rec[$date_key] . ' ' . $rec[$time_key], $this->timezone); $engine->init($rrule, $date); if ($next = $engine->next()) { $updates[$date_key] = $next->format('Y-m-d'); if (!empty($rec[$time_key])) $updates[$time_key] = $next->format('H:i'); } } // shift absolute alarm dates if (!empty($updates) && is_array($rec['valarms'])) { $updates['valarms'] = array(); unset($rrule['UNTIL'], $rrule['COUNT']); // make recurrence rule unlimited foreach ($rec['valarms'] as $i => $alarm) { if ($alarm['trigger'] instanceof DateTime) { $engine->init($rrule, $alarm['trigger']); if ($next = $engine->next()) { $alarm['trigger'] = $next; } } $updates['valarms'][$i] = $alarm; } } if (!empty($updates)) { // clone task to save a completed copy $clone = $rec; $clone['uid'] = $this->generate_uid(); $clone['parent_id'] = $rec['id']; unset($clone['id'], $clone['recurrence'], $clone['attachments']); // update the task but unset completed flag $rec = array_merge($rec, $updates); $rec['complete'] = $old['complete']; $rec['status'] = $old['status']; } } return $clone; } /** * Send out an invitation/notification to all task attendees */ private function notify_attendees($task, $old, $action = 'edit', $comment = null) { if ($action == 'delete' || ($task['status'] == 'CANCELLED' && $old['status'] != $task['status'])) { $task['cancelled'] = true; $is_cancelled = true; } $itip = $this->load_itip(); $emails = $this->lib->get_user_emails(); $itip_notify = (int)$this->rc->config->get('calendar_itip_send_option', 3); // add comment to the iTip attachment $task['comment'] = $comment; // needed to generate VTODO instead of VEVENT entry $task['_type'] = 'task'; // compose multipart message using PEAR:Mail_Mime $method = $action == 'delete' ? 'CANCEL' : 'REQUEST'; $object = $this->to_libcal($task); $message = $itip->compose_itip_message($object, $method, $task['sequence'] > $old['sequence']); // list existing attendees from the $old task $old_attendees = array(); foreach ((array)$old['attendees'] as $attendee) { $old_attendees[] = $attendee['email']; } // send to every attendee $sent = 0; $current = array(); foreach ((array)$task['attendees'] as $attendee) { $current[] = strtolower($attendee['email']); // skip myself for obvious reasons if (!$attendee['email'] || in_array(strtolower($attendee['email']), $emails)) { continue; } // skip if notification is disabled for this attendee if ($attendee['noreply'] && $itip_notify & 2) { continue; } // skip if this attendee has delegated and set RSVP=FALSE if ($attendee['status'] == 'DELEGATED' && $attendee['rsvp'] === false) { continue; } // which template to use for mail text $is_new = !in_array($attendee['email'], $old_attendees); $is_rsvp = $is_new || $task['sequence'] > $old['sequence']; $bodytext = $is_cancelled ? 'itipcancelmailbody' : ($is_new ? 'invitationmailbody' : 'itipupdatemailbody'); $subject = $is_cancelled ? 'itipcancelsubject' : ($is_new ? 'invitationsubject' : ($task['title'] ? 'itipupdatesubject' : 'itipupdatesubjectempty')); // finally send the message if ($itip->send_itip_message($object, $method, $attendee, $subject, $bodytext, $message, $is_rsvp)) $sent++; else $sent = -100; } // send CANCEL message to removed attendees foreach ((array)$old['attendees'] as $attendee) { if (!$attendee['email'] || in_array(strtolower($attendee['email']), $current)) { continue; } $vtodo = $this->to_libcal($old); $vtodo['cancelled'] = $is_cancelled; $vtodo['attendees'] = array($attendee); $vtodo['comment'] = $comment; if ($itip->send_itip_message($vtodo, 'CANCEL', $attendee, 'itipcancelsubject', 'itipcancelmailbody')) $sent++; else $sent = -100; } return $sent; } /** * Compare two task objects and return differing properties * * @param array Event A * @param array Event B * @return array List of differing task properties */ public static function task_diff($a, $b) { $diff = array(); $ignore = array('changed' => 1, 'attachments' => 1); foreach (array_unique(array_merge(array_keys($a), array_keys($b))) as $key) { if (!$ignore[$key] && $a[$key] != $b[$key]) $diff[] = $key; } // only compare number of attachments if (count($a['attachments']) != count($b['attachments'])) $diff[] = 'attachments'; return $diff; } /** * Dispatcher for tasklist actions initiated by the client */ public function tasklist_action() { $action = rcube_utils::get_input_value('action', rcube_utils::INPUT_GPC); $list = rcube_utils::get_input_value('l', rcube_utils::INPUT_GPC, true); $success = false; if (isset($list['showalarms'])) $list['showalarms'] = intval($list['showalarms']); switch ($action) { case 'form-new': case 'form-edit': echo $this->ui->tasklist_editform($action, $list); exit; case 'new': $list += array('showalarms' => true, 'active' => true, 'editable' => true); if ($insert_id = $this->driver->create_list($list)) { $list['id'] = $insert_id; if (!$list['_reload']) { $this->load_ui(); $list['html'] = $this->ui->tasklist_list_item($insert_id, $list, $jsenv); $list += (array)$jsenv[$insert_id]; } $this->rc->output->command('plugin.insert_tasklist', $list); $success = true; } break; case 'edit': if ($newid = $this->driver->edit_list($list)) { $list['oldid'] = $list['id']; $list['id'] = $newid; $this->rc->output->command('plugin.update_tasklist', $list); $success = true; } break; case 'subscribe': $success = $this->driver->subscribe_list($list); break; case 'delete': if (($success = $this->driver->delete_list($list))) $this->rc->output->command('plugin.destroy_tasklist', $list); break; case 'search': $this->load_ui(); $results = array(); $query = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); $source = rcube_utils::get_input_value('source', rcube_utils::INPUT_GPC); foreach ((array)$this->driver->search_lists($query, $source) as $id => $prop) { $editname = $prop['editname']; unset($prop['editname']); // force full name to be displayed $prop['active'] = false; // let the UI generate HTML and CSS representation for this calendar $html = $this->ui->tasklist_list_item($id, $prop, $jsenv); $prop += (array)$jsenv[$id]; $prop['editname'] = $editname; $prop['html'] = $html; $results[] = $prop; } // report more results available if ($this->driver->search_more_results) { $this->rc->output->show_message('autocompletemore', 'info'); } $this->rc->output->command('multi_thread_http_response', $results, rcube_utils::get_input_value('_reqid', rcube_utils::INPUT_GPC)); return; } if ($success) $this->rc->output->show_message('successfullysaved', 'confirmation'); else $this->rc->output->show_message('tasklist.errorsaving', 'error'); $this->rc->output->command('plugin.unlock_saving'); } /** * Get counts for active tasks divided into different selectors */ public function fetch_counts() { if (isset($_REQUEST['lists'])) { $lists = rcube_utils::get_input_value('lists', rcube_utils::INPUT_GPC); } else { foreach ($this->driver->get_lists() as $list) { if ($list['active']) $lists[] = $list['id']; } } $counts = $this->driver->count_tasks($lists); $this->rc->output->command('plugin.update_counts', $counts); } /** * Adjust the cached counts after changing a task */ public function update_counts($oldrec, $newrec) { // rebuild counts until this function is finally implemented $this->fetch_counts(); // $this->rc->output->command('plugin.update_counts', $counts); } /** * */ public function fetch_tasks() { $f = intval(rcube_utils::get_input_value('filter', rcube_utils::INPUT_GPC)); $search = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); $lists = rcube_utils::get_input_value('lists', rcube_utils::INPUT_GPC); $filter = array('mask' => $f, 'search' => $search); /* // convert magic date filters into a real date range switch ($f) { case self::FILTER_MASK_TODAY: $today = new DateTime('now', $this->timezone); $filter['from'] = $filter['to'] = $today->format('Y-m-d'); break; case self::FILTER_MASK_TOMORROW: $tomorrow = new DateTime('now + 1 day', $this->timezone); $filter['from'] = $filter['to'] = $tomorrow->format('Y-m-d'); break; case self::FILTER_MASK_OVERDUE: $yesterday = new DateTime('yesterday', $this->timezone); $filter['to'] = $yesterday->format('Y-m-d'); break; case self::FILTER_MASK_WEEK: $today = new DateTime('now', $this->timezone); $filter['from'] = $today->format('Y-m-d'); $weekend = new DateTime('now + 7 days', $this->timezone); $filter['to'] = $weekend->format('Y-m-d'); break; case self::FILTER_MASK_LATER: $date = new DateTime('now + 8 days', $this->timezone); $filter['from'] = $date->format('Y-m-d'); break; } */ $data = $this->tasks_data($this->driver->list_tasks($filter, $lists), $f); $this->rc->output->command('plugin.data_ready', array( 'filter' => $f, 'lists' => $lists, 'search' => $search, 'data' => $data, 'tags' => $this->driver->get_tags(), )); } /** * Handler for printing calendars */ public function print_tasks() { // Add CSS stylesheets to the page header $skin_path = $this->local_skin_path(); $this->include_stylesheet($skin_path . '/print.css'); $this->include_script('tasklist.js'); $this->rc->output->add_handlers(array( 'plugin.tasklist_print' => array($this, 'print_tasks_list'), )); $this->rc->output->set_pagetitle($this->gettext('print')); $this->rc->output->send('tasklist.print'); } /** * Handler for printing calendars */ public function print_tasks_list($attrib) { $f = intval(rcube_utils::get_input_value('filter', rcube_utils::INPUT_GPC)); $search = rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC); $lists = rcube_utils::get_input_value('lists', rcube_utils::INPUT_GPC); $filter = array('mask' => $f, 'search' => $search); $data = $this->tasks_data($this->driver->list_tasks($filter, $lists), $f); // we'll build the tasks table in javascript on page load // where we have sorting methods, etc. $this->rc->output->set_env('tasks', $data); return $this->ui->tasks_resultview($attrib); } /** * Prepare and sort the given task records to be sent to the client */ private function tasks_data($records, $f) { $data = $this->task_tree = $this->task_titles = array(); foreach ($records as $rec) { if ($rec['parent_id']) { $this->task_tree[$rec['id']] = $rec['parent_id']; } $this->encode_task($rec); // apply filter; don't trust the driver on this :-) if ((!$f && !$this->driver->is_complete($rec)) || ($rec['mask'] & $f)) $data[] = $rec; } // assign hierarchy level indicators for later sorting array_walk($data, array($this, 'task_walk_tree')); return $data; } /** * Prepare the given task record before sending it to the client */ private function encode_task(&$rec) { $rec['mask'] = $this->filter_mask($rec); $rec['flagged'] = intval($rec['flagged']); $rec['complete'] = floatval($rec['complete']); if (is_object($rec['created'])) { $rec['created_'] = $this->rc->format_date($rec['created']); $rec['created'] = $rec['created']->format('U'); } if (is_object($rec['changed'])) { $rec['changed_'] = $this->rc->format_date($rec['changed']); $rec['changed'] = $rec['changed']->format('U'); } else { $rec['changed'] = null; } if ($rec['date']) { try { $date = new DateTime($rec['date'] . ' ' . $rec['time'], $this->timezone); $rec['datetime'] = intval($date->format('U')); $rec['date'] = $date->format($this->rc->config->get('date_format', 'Y-m-d')); $rec['_hasdate'] = 1; } catch (Exception $e) { $rec['date'] = $rec['datetime'] = null; } } else { $rec['date'] = $rec['datetime'] = null; $rec['_hasdate'] = 0; } if ($rec['startdate']) { try { $date = new DateTime($rec['startdate'] . ' ' . $rec['starttime'], $this->timezone); $rec['startdatetime'] = intval($date->format('U')); $rec['startdate'] = $date->format($this->rc->config->get('date_format', 'Y-m-d')); } catch (Exception $e) { $rec['startdate'] = $rec['startdatetime'] = null; } } if ($rec['valarms']) { $rec['alarms_text'] = libcalendaring::alarms_text($rec['valarms']); $rec['valarms'] = libcalendaring::to_client_alarms($rec['valarms']); } if ($rec['recurrence']) { $rec['recurrence_text'] = $this->lib->recurrence_text($rec['recurrence']); $rec['recurrence'] = $this->lib->to_client_recurrence($rec['recurrence'], $rec['time'] || $rec['starttime']); } foreach ((array)$rec['attachments'] as $k => $attachment) { $rec['attachments'][$k]['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']); } // convert link URIs references into structs if (array_key_exists('links', $rec)) { foreach ((array) $rec['links'] as $i => $link) { if (strpos($link, 'imap://') === 0 && ($msgref = $this->driver->get_message_reference($link, 'task'))) { $rec['links'][$i] = $msgref; } } } // Convert HTML description into plain text if ($this->is_html($rec)) { $h2t = new rcube_html2text($rec['description'], false, true, 0); $rec['description'] = $h2t->get_text(); } 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; if (empty($rec['parent_id'])) $rec['parent_id'] = null; $this->task_titles[$rec['id']] = $rec['title']; } /** * Determine whether the given task description is HTML formatted */ private function is_html($task) { // check for opening and closing or tags return (preg_match('/<(html|body)(\s+[a-z]|>)/', $task['description'], $m) && strpos($task['description'], '') > 0); } /** * Callback function for array_walk over all tasks. * Sets tree depth and parent titles */ private function task_walk_tree(&$rec) { $rec['_depth'] = 0; $parent_titles = array(); $parent_id = $this->task_tree[$rec['id']]; while ($parent_id) { $rec['_depth']++; array_unshift($parent_titles, $this->task_titles[$parent_id]); $parent_id = $this->task_tree[$parent_id]; } if (count($parent_titles)) { $rec['parent_title'] = join(' » ', array_filter($parent_titles)); } } /** * Compute the filter mask of the given task * * @param array Hash array with Task record properties * @return int Filter mask */ public function filter_mask($rec) { static $today, $tomorrow, $weeklimit; if (!$today) { $today_date = new DateTime('now', $this->timezone); $today = $today_date->format('Y-m-d'); $tomorrow_date = new DateTime('now + 1 day', $this->timezone); $tomorrow = $tomorrow_date->format('Y-m-d'); $week_date = new DateTime('now + 7 days', $this->timezone); $weeklimit = $week_date->format('Y-m-d'); } $mask = 0; $start = $rec['startdate'] ?: '1900-00-00'; $duedate = $rec['date'] ?: '3000-00-00'; if ($rec['flagged']) $mask |= self::FILTER_MASK_FLAGGED; if ($this->driver->is_complete($rec)) $mask |= self::FILTER_MASK_COMPLETE; if (empty($rec['date'])) $mask |= self::FILTER_MASK_NODATE; else if ($rec['date'] < $today) $mask |= self::FILTER_MASK_OVERDUE; if ($duedate <= $today || ($rec['startdate'] && $start <= $today)) $mask |= self::FILTER_MASK_TODAY; if ($duedate <= $tomorrow || ($rec['startdate'] && $start <= $tomorrow)) $mask |= self::FILTER_MASK_TOMORROW; if (($start > $tomorrow && $start <= $weeklimit) || ($duedate > $tomorrow && $duedate <= $weeklimit)) $mask |= self::FILTER_MASK_WEEK; else if ($start > $weeklimit || ($rec['date'] && $duedate > $weeklimit)) $mask |= self::FILTER_MASK_LATER; // add masks for assigned tasks if ($this->is_organizer($rec) && !empty($rec['attendees']) && $this->is_attendee($rec) === false) $mask |= self::FILTER_MASK_ASSIGNED; else if (/*empty($rec['attendees']) ||*/ $this->is_attendee($rec) !== false) $mask |= self::FILTER_MASK_MYTASKS; return $mask; } /** * Determine whether the current user is an attendee of the given task */ public function is_attendee($task) { $emails = $this->lib->get_user_emails(); foreach ((array)$task['attendees'] as $i => $attendee) { if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { return $i; } } return false; } /** * Determine whether the current user is the organizer of the given task */ public function is_organizer($task) { $emails = $this->lib->get_user_emails(); return (empty($task['organizer']) || in_array(strtolower($task['organizer']['email']), $emails)); } /******* UI functions ********/ /** * Render main view of the tasklist task */ public function tasklist_view() { $this->ui->init(); $this->ui->init_templates(); // set autocompletion env $this->rc->output->set_env('autocomplete_threads', (int)$this->rc->config->get('autocomplete_threads', 0)); $this->rc->output->set_env('autocomplete_max', (int)$this->rc->config->get('autocomplete_max', 15)); $this->rc->output->set_env('autocomplete_min_length', $this->rc->config->get('autocomplete_min_length')); $this->rc->output->add_label('autocompletechars', 'autocompletemore', 'delete', 'close', 'libcalendaring.expandattendeegroup', 'libcalendaring.expandattendeegroupnodata'); $this->rc->output->set_pagetitle($this->gettext('navtitle')); $this->rc->output->send('tasklist.mainview'); } /** * */ public function get_inline_ui() { foreach (array('save','cancel','savingdata') as $label) $texts['tasklist.'.$label] = $this->gettext($label); $texts['tasklist.newtask'] = $this->gettext('createfrommail'); $this->ui->init_templates(); $this->ui->tasklists(); // collect env variables $env = array( 'tasklists' => $this->rc->output->get_env('tasklists'), 'tasklist_settings' => $this->ui->load_settings(), ); echo $this->api->output->parse('tasklist.taskedit', false, false); $script_add = ''; foreach ($this->ui->get_gui_objects() as $obj => $id) { $script_add .= rcmail_output::JS_OBJECT_NAME . ".gui_object('$obj', '$id');\n"; } echo html::tag('script', array('type' => 'text/javascript'), rcmail_output::JS_OBJECT_NAME . ".set_env(" . json_encode($env) . ");\n". rcmail_output::JS_OBJECT_NAME . ".add_label(" . json_encode($texts) . ");\n". $script_add ); 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' => rcube_utils::get_input_value('q', rcube_utils::INPUT_GPC), 'mask' => intval(rcube_utils::get_input_value('filter', rcube_utils::INPUT_GPC)) & self::FILTER_MASK_COMPLETE, ); $lists = rcube_utils::get_input_value('lists', rcube_utils::INPUT_GPC);; $updates = $this->driver->list_tasks($filter, $lists); if (!empty($updates)) { $this->rc->output->command('plugin.refresh_tasks', $this->tasks_data($updates, 255), 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. * This will check for pending notifications and pass them to the client */ public function pending_alarms($p) { $this->load_driver(); if ($alarms = $this->driver->pending_alarms($p['time'] ?: time())) { foreach ($alarms as $alarm) { // encode alarm object to suit the expectations of the calendaring code if ($alarm['date']) $alarm['start'] = new DateTime($alarm['date'].' '.$alarm['time'], $this->timezone); $alarm['id'] = 'task:' . $alarm['id']; // prefix ID with task: $alarm['allday'] = empty($alarm['time']) ? 1 : 0; $p['alarms'][] = $alarm; } } return $p; } /** * Handler for alarm dismiss hook triggered by the calendar module */ public function dismiss_alarms($p) { $this->load_driver(); foreach ((array)$p['ids'] as $id) { if (strpos($id, 'task:') === 0) $p['success'] |= $this->driver->dismiss_alarm(substr($id, 5), $p['snooze']); } return $p; } /******* Attachment handling *******/ /** * Handler for attachments upload */ public function attachment_upload() { $this->lib->attachment_upload(self::SESSION_KEY); } /** * Handler for attachments download/displaying */ public function attachment_get() { // show loading page if (!empty($_GET['_preload'])) { return $this->lib->attachment_loading_page(); } $task = rcube_utils::get_input_value('_t', rcube_utils::INPUT_GPC); $list = rcube_utils::get_input_value('_list', rcube_utils::INPUT_GPC); $id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC); $rev = rcube_utils::get_input_value('_rev', rcube_utils::INPUT_GPC); $task = array('id' => $task, 'list' => $list, 'rev' => $rev); $attachment = $this->driver->get_attachment($id, $task); // show part page if (!empty($_GET['_frame'])) { $this->lib->attachment = $attachment; $this->register_handler('plugin.attachmentframe', array($this->lib, 'attachment_frame')); $this->register_handler('plugin.attachmentcontrols', array($this->lib, 'attachment_header')); $this->rc->output->send('tasklist.attachment'); } // deliver attachment content else if ($attachment) { $attachment['body'] = $this->driver->get_attachment_body($id, $task); $this->lib->attachment_get($attachment); } // if we arrive here, the requested part was not found header('HTTP/1.1 404 Not Found'); exit; } /******* Email related function *******/ public function mail_message2task() { $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST); $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST); $task = array(); // establish imap connection $imap = $this->rc->get_storage(); $imap->set_folder($mbox); $message = new rcube_message($uid); if ($message->headers) { $task['title'] = trim($message->subject); $task['description'] = trim($message->first_text_part()); $task['id'] = -$uid; $this->load_driver(); // add a reference to the email message if ($msgref = $this->driver->get_message_reference($message->headers, $mbox)) { $task['links'] = array($msgref); } // copy mail attachments to task else if ($message->attachments && $this->driver->attachments) { if (!is_array($_SESSION[self::SESSION_KEY]) || $_SESSION[self::SESSION_KEY]['id'] != $task['id']) { $_SESSION[self::SESSION_KEY] = array(); $_SESSION[self::SESSION_KEY]['id'] = $task['id']; $_SESSION[self::SESSION_KEY]['attachments'] = array(); } foreach ((array)$message->attachments as $part) { $attachment = array( 'data' => $imap->get_message_part($uid, $part->mime_id, $part), 'size' => $part->size, 'name' => $part->filename, 'mimetype' => $part->mimetype, 'group' => $task['id'], ); $attachment = $this->rc->plugins->exec_hook('attachment_save', $attachment); if ($attachment['status'] && !$attachment['abort']) { $id = $attachment['id']; $attachment['classname'] = rcube_utils::file2class($attachment['mimetype'], $attachment['name']); // store new attachment in session unset($attachment['status'], $attachment['abort'], $attachment['data']); $_SESSION[self::SESSION_KEY]['attachments'][$id] = $attachment; $attachment['id'] = 'rcmfile' . $attachment['id']; // add prefix to consider it 'new' $task['attachments'][] = $attachment; } } } $this->rc->output->command('plugin.mail2taskdialog', $task); } else { $this->rc->output->command('display_message', $this->gettext('messageopenerror'), 'error'); } $this->rc->output->send(); } /** * Add UI element to copy task invitations or updates to the tasklist */ public function mail_messagebody_html($p) { // load iCalendar functions (if necessary) if (!empty($this->lib->ical_parts)) { $this->get_ical(); $this->load_itip(); } $html = ''; $has_tasks = false; $ical_objects = $this->lib->get_mail_ical_objects(); // show a box for every task in the file foreach ($ical_objects as $idx => $task) { if ($task['_type'] != 'task') { continue; } $has_tasks = true; // get prepared inline UI for this event object if ($ical_objects->method) { $html .= html::div('tasklist-invitebox', $this->itip->mail_itip_inline_ui( $task, $ical_objects->method, $ical_objects->mime_id . ':' . $idx, 'tasks', rcube_utils::anytodatetime($ical_objects->message_date) ) ); } // limit listing if ($idx >= 3) { break; } } // list linked tasks $links = array(); foreach ($this->message_tasks as $task) { $checkbox = new html_checkbox(array( 'name' => 'completed', 'class' => 'complete', 'title' => $this->gettext('complete'), 'data-list' => $task['list'], )); $complete = $this->driver->is_complete($task); $links[] = html::tag('li', 'messagetaskref' . ($complete ? ' complete' : ''), $checkbox->show($complete ? $task['id'] : null, array('value' => $task['id'])) . ' ' . html::a(array( 'href' => $this->rc->url(array( 'task' => 'tasks', 'list' => $task['list'], 'id' => $task['id'], )), 'class' => 'messagetasklink', 'rel' => $task['id'] . '@' . $task['list'], 'target' => '_blank', ), rcube::Q($task['title'])) ); } if (count($links)) { $html .= html::div('messagetasklinks', html::tag('ul', 'tasklist', join("\n", $links))); } // prepend iTip/relation boxes to message body if ($html) { $this->load_ui(); $this->ui->init(); $p['content'] = $html . $p['content']; $this->rc->output->add_label('tasklist.savingdata','tasklist.deletetaskconfirm','tasklist.declinedeleteconfirm'); } // add "Save to tasks" button into attachment menu if ($has_tasks) { $this->add_button(array( 'id' => 'attachmentsavetask', 'name' => 'attachmentsavetask', 'type' => 'link', 'wrapper' => 'li', 'command' => 'attachment-save-task', 'class' => 'icon tasklistlink', 'classact' => 'icon tasklistlink active', 'innerclass' => 'icon taskadd', 'label' => 'tasklist.savetotasklist', ), 'attachmentmenu'); } return $p; } /** * Lookup backend storage and find notes associated with the given message */ public function mail_message_load($p) { if (!$p['object']->headers->others['x-kolab-type']) { $this->load_driver(); $this->message_tasks = $this->driver->get_message_related_tasks($p['object']->headers, $p['object']->folder); // sort message tasks by completeness and due date $driver = $this->driver; array_walk($this->message_tasks, array($this, 'encode_task')); usort($this->message_tasks, function($a, $b) use ($driver) { $a_complete = intval($driver->is_complete($a)); $b_complete = intval($driver->is_complete($b)); $d = $a_complete - $b_complete; if (!$d) $d = $b['_hasdate'] - $a['_hasdate']; if (!$d) $d = $a['datetime'] - $b['datetime']; return $d; }); } } /** * Load iCalendar functions */ public function get_ical() { if (!$this->ical) { $this->ical = libcalendaring::get_ical(); } return $this->ical; } /** * Get properties of the tasklist this user has specified as default */ public function get_default_tasklist($sensitivity = null) { $lists = $this->driver->get_lists(); $list = null; foreach ($lists as $l) { if ($sensitivity && $l['subtype'] == $sensitivity) { $list = $l; break; } if ($l['default']) { $list = $l; } if ($l['editable']) { $first = $l; } } return $list ?: $first; } /** * Import the full payload from a mail message attachment */ public function mail_import_attachment() { $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST); $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST); $mime_id = rcube_utils::get_input_value('_part', rcube_utils::INPUT_POST); $charset = RCUBE_CHARSET; // establish imap connection $imap = $this->rc->get_storage(); $imap->set_folder($mbox); if ($uid && $mime_id) { $part = $imap->get_message_part($uid, $mime_id); // $headers = $imap->get_message_headers($uid); if ($part->ctype_parameters['charset']) { $charset = $part->ctype_parameters['charset']; } if ($part) { $tasks = $this->get_ical()->import($part, $charset); } } $success = $existing = 0; if (!empty($tasks)) { // find writeable tasklist to store task $cal_id = !empty($_REQUEST['_list']) ? rcube_utils::get_input_value('_list', rcube_utils::INPUT_POST) : null; $lists = $this->driver->get_lists(); foreach ($tasks as $task) { // save to tasklist $list = $lists[$cal_id] ?: $this->get_default_tasklist($task['sensitivity']); if ($list && $list['editable'] && $task['_type'] == 'task') { $task = $this->from_ical($task); $task['list'] = $list['id']; if (!$this->driver->get_task($task['uid'])) { $success += (bool) $this->driver->create_task($task); } else { $existing++; } } } } if ($success) { $this->rc->output->command('display_message', $this->gettext(array( 'name' => 'importsuccess', 'vars' => array('nr' => $success), )), 'confirmation'); } else if ($existing) { $this->rc->output->command('display_message', $this->gettext('importwarningexists'), 'warning'); } else { $this->rc->output->command('display_message', $this->gettext('errorimportingtask'), 'error'); } } /** * Handler for POST request to import an event attached to a mail message */ public function mail_import_itip() { $uid = rcube_utils::get_input_value('_uid', rcube_utils::INPUT_POST); $mbox = rcube_utils::get_input_value('_mbox', rcube_utils::INPUT_POST); $mime_id = rcube_utils::get_input_value('_part', rcube_utils::INPUT_POST); $status = rcube_utils::get_input_value('_status', rcube_utils::INPUT_POST); $delete = intval(rcube_utils::get_input_value('_del', rcube_utils::INPUT_POST)); $noreply = intval(rcube_utils::get_input_value('_noreply', rcube_utils::INPUT_POST)) || $status == 'needs-action'; $error_msg = $this->gettext('errorimportingtask'); $success = false; $delegate = null; if ($status == 'delegated') { $delegates = rcube_mime::decode_address_list(rcube_utils::get_input_value('_to', rcube_utils::INPUT_POST, true), 1, false); $delegate = reset($delegates); if (empty($delegate) || empty($delegate['mailto'])) { $this->rc->output->command('display_message', $this->gettext('libcalendaring.delegateinvalidaddress'), 'error'); return; } } // successfully parsed tasks? if ($task = $this->lib->mail_get_itip_object($mbox, $uid, $mime_id, 'task')) { $task = $this->from_ical($task); // forward iTip request to delegatee if ($delegate) { $rsvpme = intval(rcube_utils::get_input_value('_rsvp', rcube_utils::INPUT_POST)); $itip = $this->load_itip(); if ($itip->delegate_to($task, $delegate, $rsvpme ? true : false)) { $this->rc->output->show_message('tasklist.itipsendsuccess', 'confirmation'); } else { $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); } } // find writeable list to store the task $list_id = !empty($_REQUEST['_folder']) ? rcube_utils::get_input_value('_folder', rcube_utils::INPUT_POST) : null; $lists = $this->driver->get_lists(); $list = $lists[$list_id]; $dontsave = ($_REQUEST['_folder'] === '' && $task['_method'] == 'REQUEST'); // select default list except user explicitly selected 'none' if (!$list && !$dontsave) { $list = $this->get_default_tasklist($task['sensitivity']); } $metadata = array( 'uid' => $task['uid'], 'changed' => is_object($task['changed']) ? $task['changed']->format('U') : 0, 'sequence' => intval($task['sequence']), 'fallback' => strtoupper($status), 'method' => $task['_method'], 'task' => 'tasks', ); // update my attendee status according to submitted method if (!empty($status)) { $organizer = $task['organizer']; $emails = $this->lib->get_user_emails(); foreach ($task['attendees'] as $i => $attendee) { if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { $metadata['attendee'] = $attendee['email']; $metadata['rsvp'] = $attendee['role'] != 'NON-PARTICIPANT'; $reply_sender = $attendee['email']; $task['attendees'][$i]['status'] = strtoupper($status); if (!in_array($task['attendees'][$i]['status'], array('NEEDS-ACTION','DELEGATED'))) { $task['attendees'][$i]['rsvp'] = false; // unset RSVP attribute } } } // add attendee with this user's default identity if not listed if (!$reply_sender) { $sender_identity = $this->rc->user->list_emails(true); $task['attendees'][] = array( 'name' => $sender_identity['name'], 'email' => $sender_identity['email'], 'role' => 'OPT-PARTICIPANT', 'status' => strtoupper($status), ); $metadata['attendee'] = $sender_identity['email']; } } // save to tasklist if ($list && $list['editable']) { $task['list'] = $list['id']; // check for existing task with the same UID $existing = $this->driver->get_task($task['uid']); if ($existing) { // only update attendee status if ($task['_method'] == 'REPLY') { // try to identify the attendee using the email sender address $existing_attendee = -1; $existing_attendee_emails = array(); foreach ($existing['attendees'] as $i => $attendee) { $existing_attendee_emails[] = $attendee['email']; if ($task['_sender'] && ($attendee['email'] == $task['_sender'] || $attendee['email'] == $task['_sender_utf'])) { $existing_attendee = $i; } } $task_attendee = null; foreach ($task['attendees'] as $attendee) { if ($task['_sender'] && ($attendee['email'] == $task['_sender'] || $attendee['email'] == $task['_sender_utf'])) { $task_attendee = $attendee; $metadata['fallback'] = $attendee['status']; $metadata['attendee'] = $attendee['email']; $metadata['rsvp'] = $attendee['rsvp'] || $attendee['role'] != 'NON-PARTICIPANT'; if ($attendee['status'] != 'DELEGATED') { break; } } // also copy delegate attendee else if (!empty($attendee['delegated-from']) && (stripos($attendee['delegated-from'], $task['_sender']) !== false || stripos($attendee['delegated-from'], $task['_sender_utf']) !== false) && (!in_array($attendee['email'], $existing_attendee_emails))) { $existing['attendees'][] = $attendee; } } // if delegatee has declined, set delegator's RSVP=True if ($task_attendee && $task_attendee['status'] == 'DECLINED' && $task_attendee['delegated-from']) { foreach ($existing['attendees'] as $i => $attendee) { if ($attendee['email'] == $task_attendee['delegated-from']) { $existing['attendees'][$i]['rsvp'] = true; break; } } } // found matching attendee entry in both existing and new events if ($existing_attendee >= 0 && $task_attendee) { $existing['attendees'][$existing_attendee] = $task_attendee; $success = $this->driver->edit_task($existing); } // update the entire attendees block else if (($task['sequence'] >= $existing['sequence'] || $task['changed'] >= $existing['changed']) && $task_attendee) { $existing['attendees'][] = $task_attendee; $success = $this->driver->edit_task($existing); } else { $error_msg = $this->gettext('newerversionexists'); } } // delete the task when declined else if ($status == 'declined' && $delete) { $deleted = $this->driver->delete_task($existing, true); $success = true; } // import the (newer) task else if ($task['sequence'] >= $existing['sequence'] || $task['changed'] >= $existing['changed']) { $task['id'] = $existing['id']; $task['list'] = $existing['list']; // preserve my participant status for regular updates if (empty($status)) { $emails = $this->lib->get_user_emails(); foreach ($task['attendees'] as $i => $attendee) { if ($attendee['email'] && in_array(strtolower($attendee['email']), $emails)) { foreach ($existing['attendees'] as $j => $_attendee) { if ($attendee['email'] == $_attendee['email']) { $task['attendees'][$i] = $existing['attendees'][$j]; break; } } } } } // set status=CANCELLED on CANCEL messages if ($task['_method'] == 'CANCEL') { $task['status'] = 'CANCELLED'; } // show me as free when declined (#1670) if ($status == 'declined' || $task['status'] == 'CANCELLED') { $task['free_busy'] = 'free'; } $success = $this->driver->edit_task($task); } else if (!empty($status)) { $existing['attendees'] = $task['attendees']; if ($status == 'declined') { // show me as free when declined (#1670) $existing['free_busy'] = 'free'; } $success = $this->driver->edit_event($existing); } else { $error_msg = $this->gettext('newerversionexists'); } } else if (!$existing && ($status != 'declined' || $this->rc->config->get('kolab_invitation_tasklists'))) { $success = $this->driver->create_task($task); } else if ($status == 'declined') { $error_msg = null; } } else if ($status == 'declined' || $dontsave) { $error_msg = null; } else { $error_msg = $this->gettext('nowritetasklistfound'); } } if ($success || $dontsave) { if ($success) { $message = $task['_method'] == 'REPLY' ? 'attendeupdateesuccess' : ($deleted ? 'successremoval' : ($existing ? 'updatedsuccessfully' : 'importedsuccessfully')); $this->rc->output->command('display_message', $this->gettext(array('name' => $message, 'vars' => array('list' => $list['name']))), 'confirmation'); } $metadata['rsvp'] = intval($metadata['rsvp']); $metadata['after_action'] = $this->rc->config->get('calendar_itip_after_action', 0); $this->rc->output->command('plugin.itip_message_processed', $metadata); $error_msg = null; } else if ($error_msg) { $this->rc->output->command('display_message', $error_msg, 'error'); } // send iTip reply if ($task['_method'] == 'REQUEST' && $organizer && !$noreply && !in_array(strtolower($organizer['email']), $emails) && !$error_msg) { $task['comment'] = rcube_utils::get_input_value('_comment', rcube_utils::INPUT_POST); $itip = $this->load_itip(); $itip->set_sender_email($reply_sender); if ($itip->send_itip_message($this->to_libcal($task), 'REPLY', $organizer, 'itipsubject' . $status, 'itipmailbody' . $status)) $this->rc->output->command('display_message', $this->gettext(array('name' => 'sentresponseto', 'vars' => array('mailto' => $organizer['name'] ?: $organizer['email']))), 'confirmation'); else $this->rc->output->command('display_message', $this->gettext('itipresponseerror'), 'error'); } $this->rc->output->send(); } /**** Task invitation plugin hooks ****/ /** * Handler for task/itip-delegate requests */ function mail_itip_delegate() { // forward request to mail_import_itip() with the right status $_POST['_status'] = $_REQUEST['_status'] = 'delegated'; $this->mail_import_itip(); } /** * Handler for task/itip-status requests */ public function task_itip_status() { $data = rcube_utils::get_input_value('data', rcube_utils::INPUT_POST, true); // find local copy of the referenced task $existing = $this->driver->get_task($data); $itip = $this->load_itip(); $response = $itip->get_itip_status($data, $existing); // get a list of writeable lists to save new tasks to if (!$existing && $response['action'] == 'rsvp' || $response['action'] == 'import') { $lists = $this->driver->get_lists(); $select = new html_select(array('name' => 'tasklist', 'id' => 'itip-saveto', 'is_escaped' => true)); $select->add('--', ''); foreach ($lists as $list) { if ($list['editable']) { $select->add($list['name'], $list['id']); } } } if ($select) { $default_list = $this->get_default_tasklist($data['sensitivity']); $response['select'] = html::span('folder-select', $this->gettext('saveintasklist') . ' ' . $select->show($default_list['id'])); } $this->rc->output->command('plugin.update_itip_object_status', $response); } /** * Handler for task/itip-remove requests */ public function task_itip_remove() { $success = false; $uid = rcube_utils::get_input_value('uid', rcube_utils::INPUT_POST); // search for event if only UID is given if ($task = $this->driver->get_task($uid)) { $success = $this->driver->delete_task($task, true); } if ($success) { $this->rc->output->show_message('tasklist.successremoval', 'confirmation'); } else { $this->rc->output->show_message('tasklist.errorsaving', 'error'); } } /******* Utility functions *******/ /** * Generate a unique identifier for an event */ public function generate_uid() { return strtoupper(md5(time() . uniqid(rand())) . '-' . substr(md5($this->rc->user->get_username()), 0, 16)); } /** * Map task properties for ical exprort using libcalendaring */ public function to_libcal($task) { $object = $task; $object['_type'] = 'task'; $object['categories'] = (array)$task['tags']; // convert to datetime objects if (!empty($task['date'])) { $object['due'] = rcube_utils::anytodatetime($task['date'].' '.$task['time'], $this->timezone); if (empty($task['time'])) $object['due']->_dateonly = true; unset($object['date']); } if (!empty($task['startdate'])) { $object['start'] = rcube_utils::anytodatetime($task['startdate'].' '.$task['starttime'], $this->timezone); if (empty($task['starttime'])) $object['start']->_dateonly = true; unset($object['startdate']); } $object['complete'] = $task['complete'] * 100; if ($task['complete'] == 1.0 && empty($task['complete'])) { $object['status'] = 'COMPLETED'; } if ($task['flagged']) { $object['priority'] = 1; } else if (!$task['priority']) { $object['priority'] = 0; } return $object; } /** * Convert task properties from ical parser to the internal format */ public function from_ical($vtodo) { $task = $vtodo; $task['tags'] = array_filter((array)$vtodo['categories']); $task['flagged'] = $vtodo['priority'] == 1; $task['complete'] = floatval($vtodo['complete'] / 100); // convert from DateTime to internal date format if (is_a($vtodo['due'], 'DateTime')) { $due = $this->lib->adjust_timezone($vtodo['due']); $task['date'] = $due->format('Y-m-d'); if (!$vtodo['due']->_dateonly) $task['time'] = $due->format('H:i'); } // convert from DateTime to internal date format if (is_a($vtodo['start'], 'DateTime')) { $start = $this->lib->adjust_timezone($vtodo['start']); $task['startdate'] = $start->format('Y-m-d'); if (!$vtodo['start']->_dateonly) $task['starttime'] = $start->format('H:i'); } if (is_a($vtodo['dtstamp'], 'DateTime')) { $task['changed'] = $vtodo['dtstamp']; } unset($task['categories'], $task['due'], $task['start'], $task['dtstamp']); return $task; } /** * Handler for user_delete plugin hook */ public function user_delete($args) { $this->load_driver(); return $this->driver->user_delete($args); } /** * Magic getter for public access to protected members */ public function __get($name) { switch ($name) { case 'ical': return $this->get_ical(); case 'itip': return $this->load_itip(); case 'driver': $this->load_driver(); return $this->driver; } return null; } } diff --git a/plugins/tasklist/tasklist_ui.php b/plugins/tasklist/tasklist_ui.php index c67710b8..17b13e91 100644 --- a/plugins/tasklist/tasklist_ui.php +++ b/plugins/tasklist/tasklist_ui.php @@ -1,541 +1,545 @@ * * Copyright (C) 2012-2015, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class tasklist_ui { private $rc; private $plugin; private $ready = false; private $gui_objects = array(); function __construct($plugin) { $this->plugin = $plugin; $this->rc = $plugin->rc; } /** * Calendar UI initialization and requests handlers */ public function init() { if ($this->ready) { return; } + if ($this->rc->action && !in_array($this->rc->action, array('show', 'preview', 'print', 'index'))) { + return; + } + // add taskbar button $this->plugin->add_button(array( 'command' => 'tasks', 'class' => 'button-tasklist', 'classsel' => 'button-tasklist button-selected', 'innerclass' => 'button-inner', 'label' => 'tasklist.navtitle', ), 'taskbar'); $this->plugin->include_stylesheet($this->plugin->local_skin_path() . '/tasklist.css'); if ($this->rc->task == 'mail' || $this->rc->task == 'tasks') { jqueryui::tagedit(); $this->plugin->include_script('tasklist_base.js'); // copy config to client $this->rc->output->set_env('tasklist_settings', $this->load_settings()); // initialize attendees autocompletion $this->rc->autocomplete_init(); } $this->ready = true; } /** * */ function load_settings() { $settings = array(); $settings['invite_shared'] = (int)$this->rc->config->get('calendar_allow_invite_shared', 0); $settings['itip_notify'] = (int)$this->rc->config->get('calendar_itip_send_option', 3); $settings['sort_col'] = $this->rc->config->get('tasklist_sort_col', ''); $settings['sort_order'] = $this->rc->config->get('tasklist_sort_order', 'asc'); // get user identity to create default attendee foreach ($this->rc->user->list_emails() as $rec) { if (!$identity) $identity = $rec; $identity['emails'][] = $rec['email']; $settings['identities'][$rec['identity_id']] = $rec['email']; } $identity['emails'][] = $this->rc->user->get_username(); $settings['identity'] = array( 'name' => $identity['name'], 'email' => strtolower($identity['email']), 'emails' => ';' . strtolower(join(';', $identity['emails'])) ); if ($list = rcube_utils::get_input_value('_list', rcube_utils::INPUT_GPC)) { $settings['selected_list'] = $list; } if ($list && ($id = rcube_utils::get_input_value('_id', rcube_utils::INPUT_GPC))) { $settings['selected_id'] = $id; // check if the referenced task is completed $task = $this->plugin->driver->get_task(array('id' => $id, 'list' => $list)); if ($task && $this->plugin->driver->is_complete($task)) { $settings['selected_filter'] = 'complete'; } } else if ($filter = rcube_utils::get_input_value('_filter', rcube_utils::INPUT_GPC)) { $settings['selected_filter'] = $filter; } return $settings; } /** * Render a HTML select box for user identity selection */ function identity_select($attrib = array()) { $attrib['name'] = 'identity'; $select = new html_select($attrib); $identities = $this->rc->user->list_emails(); foreach ($identities as $ident) { $select->add(format_email_recipient($ident['email'], $ident['name']), $ident['identity_id']); } return $select->show(null); } /** * Register handler methods for the template engine */ public function init_templates() { $this->plugin->register_handler('plugin.tasklists', array($this, 'tasklists')); $this->plugin->register_handler('plugin.tasklist_select', array($this, 'tasklist_select')); $this->plugin->register_handler('plugin.status_select', array($this, 'status_select')); $this->plugin->register_handler('plugin.searchform', array($this->rc->output, 'search_form')); $this->plugin->register_handler('plugin.quickaddform', array($this, 'quickadd_form')); $this->plugin->register_handler('plugin.tasks', array($this, 'tasks_resultview')); $this->plugin->register_handler('plugin.tagslist', array($this, 'tagslist')); $this->plugin->register_handler('plugin.tags_editline', array($this, 'tags_editline')); $this->plugin->register_handler('plugin.alarm_select', array($this, 'alarm_select')); $this->plugin->register_handler('plugin.recurrence_form', array($this->plugin->lib, 'recurrence_form')); $this->plugin->register_handler('plugin.attachments_form', array($this, 'attachments_form')); $this->plugin->register_handler('plugin.attachments_list', array($this, 'attachments_list')); $this->plugin->register_handler('plugin.filedroparea', array($this, 'file_drop_area')); $this->plugin->register_handler('plugin.attendees_list', array($this, 'attendees_list')); $this->plugin->register_handler('plugin.attendees_form', array($this, 'attendees_form')); $this->plugin->register_handler('plugin.identity_select', array($this, 'identity_select')); $this->plugin->register_handler('plugin.edit_attendees_notify', array($this, 'edit_attendees_notify')); $this->plugin->register_handler('plugin.task_rsvp_buttons', array($this->plugin->itip, 'itip_rsvp_buttons')); $this->plugin->register_handler('plugin.object_changelog_table', array('libkolab', 'object_changelog_table')); jqueryui::tagedit(); $this->plugin->include_script('tasklist.js'); $this->rc->output->include_script('treelist.js'); // include kolab folderlist widget if available if (in_array('libkolab', $this->plugin->api->loaded_plugins())) { $this->plugin->api->include_script('libkolab/js/folderlist.js'); $this->plugin->api->include_script('libkolab/js/audittrail.js'); } } /** * */ public function tasklists($attrib = array()) { $tree = true; $jsenv = array(); $lists = $this->plugin->driver->get_lists($tree); // walk folder tree if (is_object($tree)) { $html = $this->list_tree_html($tree, $lists, $jsenv, $attrib); } else { // fall-back to flat folder listing $attrib['class'] .= ' flat'; $html = ''; foreach ((array)$lists as $id => $prop) { if ($attrib['activeonly'] && !$prop['active']) continue; $html .= html::tag('li', array( 'id' => 'rcmlitasklist' . rcube_utils::html_identifier($id), 'class' => $prop['group'], ), $this->tasklist_list_item($id, $prop, $jsenv, $attrib['activeonly']) ); } } $this->rc->output->set_env('tasklists', $jsenv); $this->register_gui_object('tasklistslist', $attrib['id']); return html::tag('ul', $attrib, $html, html::$common_attrib); } /** * Return html for a structured list
    for the folder tree */ public function list_tree_html($node, $data, &$jsenv, $attrib) { $out = ''; foreach ($node->children as $folder) { $id = $folder->id; $prop = $data[$id]; $is_collapsed = false; // TODO: determine this somehow? $content = $this->tasklist_list_item($id, $prop, $jsenv, $attrib['activeonly']); if (!empty($folder->children)) { $content .= html::tag('ul', array('style' => ($is_collapsed ? "display:none;" : null)), $this->list_tree_html($folder, $data, $jsenv, $attrib)); } if (strlen($content)) { $out .= html::tag('li', array( 'id' => 'rcmlitasklist' . rcube_utils::html_identifier($id), 'class' => $prop['group'] . ($prop['virtual'] ? ' virtual' : ''), ), $content); } } return $out; } /** * Helper method to build a tasklist item (HTML content and js data) */ public function tasklist_list_item($id, $prop, &$jsenv, $activeonly = false) { // enrich list properties with settings from the driver if (!$prop['virtual']) { unset($prop['user_id']); $prop['alarms'] = $this->plugin->driver->alarms; $prop['undelete'] = $this->plugin->driver->undelete; $prop['sortable'] = $this->plugin->driver->sortable; $prop['attachments'] = $this->plugin->driver->attachments; $prop['attendees'] = $this->plugin->driver->attendees; $prop['caldavurl'] = $this->plugin->driver->tasklist_caldav_url($prop); $jsenv[$id] = $prop; } $classes = array('tasklist'); $title = $prop['title'] ?: ($prop['name'] != $prop['listname'] || strlen($prop['name']) > 25 ? html_entity_decode($prop['name'], ENT_COMPAT, RCUBE_CHARSET) : ''); if ($prop['virtual']) $classes[] = 'virtual'; else if (!$prop['editable']) $classes[] = 'readonly'; if ($prop['subscribed']) $classes[] = 'subscribed'; if ($prop['class']) $classes[] = $prop['class']; if (!$activeonly || $prop['active']) { $label_id = 'tl:' . $id; return html::div(join(' ', $classes), html::span(array('class' => 'listname', 'title' => $title, 'id' => $label_id), $prop['listname'] ?: $prop['name']) . ($prop['virtual'] ? '' : html::tag('input', array('type' => 'checkbox', 'name' => '_list[]', 'value' => $id, 'checked' => $prop['active'], 'aria-labelledby' => $label_id)) . html::span('actions', ($prop['removable'] ? html::a(array('href' => '#', 'class' => 'remove', 'title' => $this->plugin->gettext('removelist')), ' ') : '') . html::a(array('href' => '#', 'class' => 'quickview', 'title' => $this->plugin->gettext('focusview'), 'role' => 'checkbox', 'aria-checked' => 'false'), ' ') . (isset($prop['subscribed']) ? html::a(array('href' => '#', 'class' => 'subscribed', 'title' => $this->plugin->gettext('tasklistsubscribe'), 'role' => 'checkbox', 'aria-checked' => $prop['subscribed'] ? 'true' : 'false'), ' ') : '') ) ) ); } return ''; } /** * Render HTML form for task status selector */ function status_select($attrib = array()) { $attrib['name'] = 'status'; $select = new html_select($attrib); $select->add('---', ''); $select->add($this->plugin->gettext('status-needs-action'), 'NEEDS-ACTION'); $select->add($this->plugin->gettext('status-in-process'), 'IN-PROCESS'); $select->add($this->plugin->gettext('status-completed'), 'COMPLETED'); $select->add($this->plugin->gettext('status-cancelled'), 'CANCELLED'); return $select->show(null); } /** * Render a HTML select box for list selection */ function tasklist_select($attrib = array()) { $attrib['name'] = 'list'; $attrib['is_escaped'] = true; $select = new html_select($attrib); $default = null; foreach ((array)$this->plugin->driver->get_lists() as $id => $prop) { if ($prop['editable'] || strpos($prop['rights'], 'i') !== false) { $select->add($prop['name'], $id); if (!$default || $prop['default']) $default = $id; } } return $select->show($default); } function tasklist_editform($action, $list = array()) { $fields = array( 'name' => array( 'id' => 'taskedit-tasklistame', 'label' => $this->plugin->gettext('listname'), 'value' => html::tag('input', array('id' => 'taskedit-tasklistame', 'name' => 'name', 'type' => 'text', 'class' => 'text', 'size' => 40)), ), /* 'color' => array( 'id' => 'taskedit-color', 'label' => $this->plugin->gettext('color'), 'value' => html::tag('input', array('id' => 'taskedit-color', 'name' => 'color', 'type' => 'text', 'class' => 'text colorpicker', 'size' => 6)), ), */ 'showalarms' => array( 'id' => 'taskedit-showalarms', 'label' => $this->plugin->gettext('showalarms'), 'value' => html::tag('input', array('id' => 'taskedit-showalarms', 'name' => 'color', 'type' => 'checkbox')), ), ); return html::tag('form', array('action' => "#", 'method' => "post", 'id' => 'tasklisteditform'), $this->plugin->driver->tasklist_edit_form($action, $list, $fields) ); } /** * Render HTML form for alarm configuration */ function alarm_select($attrib = array()) { $attrib['_type'] = 'task'; return $this->plugin->lib->alarm_select($attrib, $this->plugin->driver->alarm_types, $this->plugin->driver->alarm_absolute); } /** * */ function quickadd_form($attrib) { $attrib += array('action' => $this->rc->url('add'), 'method' => 'post', 'id' => 'quickaddform'); $label = html::label(array('for' => 'quickaddinput', 'class' => 'voice'), $this->plugin->gettext('quickaddinput')); $input = new html_inputfield(array('name' => 'text', 'id' => 'quickaddinput')); $button = html::tag('input', array('type' => 'submit', 'value' => '+', 'title' => $this->plugin->gettext('createtask'), 'class' => 'button mainaction')); $this->register_gui_object('quickaddform', $attrib['id']); return html::tag('form', $attrib, $label . $input->show() . $button); } /** * The result view */ function tasks_resultview($attrib) { $attrib += array('id' => 'rcmtaskslist'); $this->register_gui_object('resultlist', $attrib['id']); unset($attrib['name']); return html::tag('ul', $attrib, ''); } /** * Container for a tags cloud */ function tagslist($attrib) { $attrib += array('id' => 'rcmtasktagslist'); unset($attrib['name']); $this->register_gui_object('tagslist', $attrib['id']); return html::tag('ul', $attrib, ''); } /** * Interactive UI element to add/remove tags */ function tags_editline($attrib) { $attrib += array('id' => 'rcmtasktagsedit'); $this->register_gui_object('edittagline', $attrib['id']); $input = new html_inputfield(array('name' => 'tags[]', 'class' => 'tag', 'size' => $attrib['size'], 'tabindex' => $attrib['tabindex'])); unset($attrib['tabindex']); return html::div($attrib, $input->show('')); } /** * Generate HTML element for attachments list */ function attachments_list($attrib = array()) { if (!$attrib['id']) $attrib['id'] = 'rcmtaskattachmentlist'; $this->register_gui_object('attachmentlist', $attrib['id']); return html::tag('ul', $attrib, '', html::$common_attrib); } /** * Generate the form for event attachments upload */ function attachments_form($attrib = array()) { // add ID if not given if (!$attrib['id']) $attrib['id'] = 'rcmtaskuploadform'; // Get max filesize, enable upload progress bar $max_filesize = $this->rc->upload_init(); $button = new html_inputfield(array('type' => 'button')); $input = new html_inputfield(array( 'type' => 'file', 'name' => '_attachments[]', 'multiple' => 'multiple', 'size' => $attrib['attachmentfieldsize'], )); return html::div($attrib, html::div(null, $input->show()) . html::div('buttons', $button->show($this->rc->gettext('upload'), array('class' => 'button mainaction', 'onclick' => rcmail_output::JS_OBJECT_NAME . ".upload_file(this.form)"))) . html::div('hint', $this->rc->gettext(array('name' => 'maxuploadsize', 'vars' => array('size' => $max_filesize)))) ); } /** * Register UI object for HTML5 drag & drop file upload */ function file_drop_area($attrib = array()) { if ($attrib['id']) { $this->register_gui_object('filedrop', $attrib['id']); $this->rc->output->set_env('filedrop', array('action' => 'upload', 'fieldname' => '_attachments')); } } /** * */ function attendees_list($attrib = array()) { // add "noreply" checkbox to attendees table only $invitations = strpos($attrib['id'], 'attend') !== false; $invite = new html_checkbox(array('value' => 1, 'id' => 'edit-attendees-invite')); $table = new html_table(array('cols' => 4 + intval($invitations), 'border' => 0, 'cellpadding' => 0, 'class' => 'rectable')); // $table->add_header('role', $this->plugin->gettext('role')); $table->add_header('name', $this->plugin->gettext($attrib['coltitle'] ?: 'attendee')); $table->add_header('confirmstate', $this->plugin->gettext('confirmstate')); if ($invitations) { $table->add_header(array('class' => 'invite', 'title' => $this->plugin->gettext('sendinvitations')), $invite->show(1) . html::label('edit-attendees-invite', $this->plugin->gettext('sendinvitations'))); } $table->add_header('options', ''); // hide invite column if disabled by config $itip_notify = (int)$this->rc->config->get('calendar_itip_send_option', 3); if ($invitations && !($itip_notify & 2)) { $css = sprintf('#%s td.invite, #%s th.invite { display:none !important }', $attrib['id'], $attrib['id']); $this->rc->output->add_footer(html::tag('style', array('type' => 'text/css'), $css)); } return $table->show($attrib); } /** * */ function attendees_form($attrib = array()) { $input = new html_inputfield(array('name' => 'participant', 'id' => 'edit-attendee-name', 'size' => 30)); $textarea = new html_textarea(array('name' => 'comment', 'id' => 'edit-attendees-comment', 'rows' => 4, 'cols' => 55, 'title' => $this->plugin->gettext('itipcommenttitle'))); return html::div($attrib, html::div(null, $input->show() . " " . html::tag('input', array('type' => 'button', 'class' => 'button', 'id' => 'edit-attendee-add', 'value' => $this->plugin->gettext('addattendee'))) // . " " . html::tag('input', array('type' => 'button', 'class' => 'button', 'id' => 'edit-attendee-schedule', 'value' => $this->plugin->gettext('scheduletime').'...')) ) . html::p('attendees-commentbox', html::label(null, $this->plugin->gettext('itipcomment') . $textarea->show())) ); } /** * */ function edit_attendees_notify($attrib = array()) { $checkbox = new html_checkbox(array('name' => '_notify', 'id' => 'edit-attendees-donotify', 'value' => 1)); return html::div($attrib, html::label(null, $checkbox->show(1) . ' ' . $this->plugin->gettext('sendnotifications'))); } /** * Wrapper for rcube_output_html::add_gui_object() */ function register_gui_object($name, $id) { $this->gui_objects[$name] = $id; $this->rc->output->add_gui_object($name, $id); } /** * Getter for registered gui objects. * (for manual registration when loading the inline UI) */ function get_gui_objects() { return $this->gui_objects; } }