--- a/calendar/base/content/calendar-item-editing.js
+++ b/calendar/base/content/calendar-item-editing.js
@@ -264,17 +264,17 @@ function createEventWithDialog(calendar,
event = cal.createEvent();
let refDate = currentView().initialized && currentView().selectedDay.clone();
setDefaultItemValues(event, calendar, startDate, endDate, refDate, aForceAllday);
if (summary) {
event.title = summary;
}
}
- openEventDialog(event, event.calendar, "new", onNewEvent, null);
+ openEventDialog(event, event.calendar, "new", onNewEvent);
}
/**
* Creates a task with the calendar event dialog.
*
* @param calendar (optional) The calendar to create the task in
* @param dueDate (optional) The task's due date.
* @param summary (optional) The task's title.
@@ -321,18 +321,31 @@ function createTodoWithDialog(calendar,
* Modifies the passed event in the event dialog.
*
* @param aItem The item to modify.
* @param job (optional) The job object that controls this
* modification.
* @param aPromptOccurrence If the user should be prompted to select if the
* parent item or occurrence should be modified.
* @param initialDate (optional) The initial date for new task datepickers
+ * @param aCounterProposal (optional) An object representing the counterproposal
+ * {
+ * {JsObject} result: {
+ * type: {String} "OK"|"OUTDATED"|"NOTLATESTUPDATE"|"ERROR"|"NODIFF"
+ * descr: {String} a technical description of the problem if type is ERROR or NODIFF,
+ * otherwise an empty string
+ * },
+ * (empty if result.type = "ERROR"|"NODIFF"){Array} differences: [{
+ * property: {String} a property that is subject to the proposal
+ * proposed: {String} the proposed value
+ * original: {String} the original value
+ * }]
+ * }
*/
-function modifyEventWithDialog(aItem, job, aPromptOccurrence, initialDate) {
+function modifyEventWithDialog(aItem, job=null, aPromptOccurrence, initialDate=null, aCounterProposal) {
let dlg = cal.findItemWindow(aItem);
if (dlg) {
dlg.focus();
disposeJob(job);
return;
}
let onModifyItem = function(item, calendar, originalItem, listener) {
@@ -341,33 +354,36 @@ function modifyEventWithDialog(aItem, jo
let item = aItem;
let response;
if (aPromptOccurrence !== false) {
[item, , response] = promptOccurrenceModification(aItem, true, "edit");
}
if (item && (response || response === undefined)) {
- openEventDialog(item, item.calendar, "modify", onModifyItem, job, initialDate);
+ openEventDialog(item, item.calendar, "modify", onModifyItem, job, initialDate,
+ aCounterProposal);
} else {
disposeJob(job);
}
}
/**
* Opens the event dialog with the given item (task OR event)
*
* @param calendarItem The item to open the dialog with
* @param calendar The calendar to open the dialog with.
* @param mode The operation the dialog should do ("new", "modify")
* @param callback The callback to call when the dialog has completed.
* @param job (optional) The job object for the modification.
* @param initialDate (optional) The initial date for new task datepickers
+ * @param counterProposal (optional) An object representing the counterproposal - see
+ * description for modifyEventWithDialog()
*/
-function openEventDialog(calendarItem, calendar, mode, callback, job, initialDate) {
+function openEventDialog(calendarItem, calendar, mode, callback, job=null, initialDate=null, counterProposal) {
let dlg = cal.findItemWindow(calendarItem);
if (dlg) {
dlg.focus();
disposeJob(job);
return;
}
// Set up some defaults
@@ -433,16 +449,17 @@ function openEventDialog(calendarItem, c
// Setup the window arguments
let args = {};
args.calendarEvent = calendarItem;
args.calendar = calendar;
args.mode = mode;
args.onOk = callback;
args.job = job;
args.initialStartDateValue = initialDate || getDefaultStartDate();
+ args.counterProposal = counterProposal;
args.inTab = Preferences.get("calendar.item.editInTab", false);
args.useNewItemUI = Preferences.get("calendar.item.useNewItemUI", false);
// this will be called if file->new has been selected from within the dialog
args.onNewEvent = function(opcalendar) {
createEventWithDialog(opcalendar, null, null);
};
args.onNewTodo = function(opcalendar) {
--- a/calendar/base/modules/calItipUtils.jsm
+++ b/calendar/base/modules/calItipUtils.jsm
@@ -75,37 +75,67 @@ cal.itip = {
dtstamp = item.stampTime;
}
}
return dtstamp;
},
/**
- * Compares sequences and/or stamps of two parties; returns -1, 0, +1.
+ * Compares sequences and/or stamps of two items
+ *
+ * @param {calIEvent|calIToDo|calIAttendee} aItem1
+ * @param {calIEvent|calIToDo|calIAttendee} aItem2
+ * @return {Integer} +1 if item2 is newer, -1 if item1 is newer or 0 if both are equal
*/
- compare: function(item1, item2) {
- let seq1 = cal.itip.getSequence(item1);
- let seq2 = cal.itip.getSequence(item2);
+ compare: function(aItem1, aItem2) {
+ let comp = cal.itip.compareSequence(aItem1, aItem2);
+ if (comp == 0) {
+ comp = cal.itip.compareStamp(aItem1, aItem2);
+ }
+ return comp;
+ },
+
+ /**
+ * Compares sequences of two items
+ *
+ * @param {calIEvent|calIToDo|calIAttendee} aItem1
+ * @param {calIEvent|calIToDo|calIAttendee} aItem2
+ * @return {Integer} +1 if item2 is newer, -1 if item1 is newer or 0 if both are equal
+ */
+ compareSequence: function(aItem1, aItem2) {
+ let seq1 = cal.itip.getSequence(aItem1);
+ let seq2 = cal.itip.getSequence(aItem2);
if (seq1 > seq2) {
return 1;
} else if (seq1 < seq2) {
return -1;
} else {
- let st1 = cal.itip.getStamp(item1);
- let st2 = cal.itip.getStamp(item2);
- if (st1 && st2) {
- return st1.compare(st2);
- } else if (!st1 && st2) {
- return -1;
- } else if (st1 && !st2) {
- return 1;
- } else {
- return 0;
- }
+ return 0;
+ }
+ },
+
+ /**
+ * Compares stamp of two items
+ *
+ * @param {calIEvent|calIToDo|calIAttendee} aItem1
+ * @param {calIEvent|calIToDo|calIAttendee} aItem2
+ * @return {Integer} +1 if item2 is newer, -1 if item1 is newer or 0 if both are equal
+ */
+ compareStamp: function(aItem1, aItem2) {
+ let st1 = cal.itip.getStamp(aItem1);
+ let st2 = cal.itip.getStamp(aItem2);
+ if (st1 && st2) {
+ return st1.compare(st2);
+ } else if (!st1 && st2) {
+ return -1;
+ } else if (st1 && !st2) {
+ return 1;
+ } else {
+ return 0;
}
},
/**
* Checks if the given calendar is a scheduling calendar. This means it
* needs an organizer id and an itip transport. It should also be writable.
*
* @param calendar The calendar to check
@@ -122,16 +152,19 @@ cal.itip = {
*
* Given an nsIMsgDBHdr and an imipMethod, set up the given itip item.
*
* @param itipItem The item to set up
* @param imipMethod The received imip method
* @param aMsgHdr Information about the received email
*/
initItemFromMsgData: function(itipItem, imipMethod, aMsgHdr) {
+ // set the sender of the itip message
+ itipItem.sender = cal.itip.getMessageSender(aMsgHdr);
+
// Get the recipient identity and save it with the itip item.
itipItem.identity = cal.itip.getMessageRecipient(aMsgHdr);
// We are only called upon receipt of an invite, so ensure that isSend
// is false.
itipItem.isSend = false;
// XXX Get these from preferences
@@ -204,16 +237,18 @@ cal.itip = {
}
switch (method) {
case "REFRESH": return _gs("imipBarRefreshText");
case "REQUEST": return _gs("imipBarRequestText");
case "PUBLISH": return _gs("imipBarPublishText");
case "CANCEL": return _gs("imipBarCancelText");
case "REPLY": return _gs("imipBarReplyText");
+ case "COUNTER": return _gs("imipBarCounterText");
+ case "DECLINECOUNTER": return _gs("imipBarDeclineCounterText");
default:
cal.ERROR("Unknown iTIP method: " + method);
return _gs("imipBarUnsupportedText");
}
},
/**
* Scope: iTIP message receiver
@@ -237,41 +272,70 @@ cal.itip = {
return cal.calGetString("lightning", strName, null, "lightning");
}
let imipLabel = null;
if (itipItem.receivedMethod) {
imipLabel = cal.itip.getMethodText(itipItem.receivedMethod);
}
let data = { label: imipLabel, buttons: [], hideMenuItems: [] };
+ let disallowedCounter = false;
+ if (foundItems && foundItems.length) {
+ let disallow = foundItems[0].getProperty("X-MICROSOFT-DISALLOW-COUNTER");
+ disallowedCounter = disallow && disallow == "TRUE";
+ }
if (rc == Components.interfaces.calIErrors.CAL_IS_READONLY) {
// No writable calendars, tell the user about it
data.label = _gs("imipBarNotWritable");
} else if (Components.isSuccessCode(rc) && !actionFunc) {
// This case, they clicked on an old message that has already been
// added/updated, we want to tell them that.
data.label = _gs("imipBarAlreadyProcessedText");
if (foundItems && foundItems.length) {
- // Not a real method, but helps us decide
data.buttons.push("imipDetailsButton");
+ if (itipItem.receivedMethod == "COUNTER" && itipItem.sender) {
+ if (disallowedCounter) {
+ data.label = _gs("imipBarDisallowedCounterText");
+ } else {
+ let comparison;
+ for (let item of itipItem.getItemList({})) {
+ let attendees = cal.getAttendeesBySender(
+ item.getAttendees({}),
+ itipItem.sender
+ );
+ if (attendees.length == 1) {
+ let replyer = foundItems[0].getAttendeeById(attendees[0].id);
+ comparison = cal.itip.compareSequence(item, foundItems[0]);
+ if (comparison == 1) {
+ data.label = _gs("imipBarCounterErrorText");
+ break;
+ } else if (comparison == -1) {
+ data.label = _gs("imipBarCounterPreviousVersionText");
+ }
+ }
+ }
+ }
+ }
} else if (itipItem.receivedMethod == "REPLY") {
// The item has been previously removed from the available calendars or the calendar
// containing the item is not available
let delmgr = Components.classes["@mozilla.org/calendar/deleted-items-manager;1"]
.getService(Components.interfaces.calIDeletedItems);
let delTime = null;
let items = itipItem.getItemList({});
if (items && items.length) {
delTime = delmgr.getDeletedDate(items[0].id);
}
if (delTime) {
data.label = _gs("imipBarReplyToRecentlyRemovedItem", [delTime.toString()]);
} else {
data.label = _gs("imipBarReplyToNotExistingItem");
}
+ } else if (itipItem.receivedMethod == "DECLINECOUNTER") {
+ data.label = _gs("imipBarDeclineCounterText");
}
} else if (Components.isSuccessCode(rc)) {
cal.LOG("iTIP options on: " + actionFunc.method);
switch (actionFunc.method) {
case "PUBLISH:UPDATE":
case "REQUEST:UPDATE-MINOR":
data.label = _gs("imipBarUpdateText");
// falls through
@@ -314,29 +378,55 @@ cal.itip = {
case "CANCEL": {
data.buttons.push("imipDeleteButton");
break;
}
case "REFRESH": {
data.buttons.push("imipReconfirmButton");
break;
}
+ case "COUNTER": {
+ if (disallowedCounter) {
+ data.label = _gs("imipBarDisallowedCounterText");
+ }
+ data.buttons.push("imipDeclineCounterButton");
+ data.buttons.push("imipRescheduleButton");
+ break;
+ }
default:
data.label = _gs("imipBarUnsupportedText");
break;
}
} else {
data.label = _gs("imipBarUnsupportedText");
}
return data;
},
/**
* Scope: iTIP message receiver
+ * Retrieves the message sender.
+ *
+ * @param {nsIMsgHdr} aMsgHdr The message header to check.
+ * @return The email address of the intended recipient.
+ */
+ getMessageSender: function(aMsgHdr) {
+ let author = (aMsgHdr && aMsgHdr.author) || "";
+ let compFields = Components.classes["@mozilla.org/messengercompose/composefields;1"]
+ .createInstance(Components.interfaces.nsIMsgCompFields);
+ let addresses = compFields.splitRecipients(author, true, {});
+ if (addresses.length != 1) {
+ cal.LOG("No unique email address for lookup in message.\r\n" + cal.STACK(20));
+ }
+ return addresses[0] || null;
+ },
+
+ /**
+ * Scope: iTIP message receiver
*
* Retrieves the intended recipient for this message.
*
* @param aMsgHdr The message to check.
* @return The email of the intended recipient.
*/
getMessageRecipient: function(aMsgHdr) {
if (!aMsgHdr) {
@@ -420,16 +510,18 @@ cal.itip = {
switch (aMethod) {
// methods that don't require the calendar chooser:
case "REFRESH":
case "REQUEST:UPDATE":
case "REQUEST:UPDATE-MINOR":
case "PUBLISH:UPDATE":
case "REPLY":
case "CANCEL":
+ case "COUNTER":
+ case "DECLINECOUNTER":
needsCalendar = false;
break;
default:
needsCalendar = true;
break;
}
if (needsCalendar) {
@@ -500,23 +592,27 @@ cal.itip = {
* * REFRESH -- send the latest item (sent by attendee(s))
* * PUBLISH -- initial publish, no reply (sent by organizer)
* * PUBLISH:UPDATE -- update of a published item (sent by organizer)
* * REQUEST -- initial invitation (sent by organizer)
* * REQUEST:UPDATE -- rescheduling invitation, has major change (sent by organizer)
* * REQUEST:UPDATE-MINOR -- update of invitation, minor change (sent by organizer)
* * REPLY -- invitation reply (sent by attendee(s))
* * CANCEL -- invitation cancel (sent by organizer)
+ * * COUNTER -- counterproposal (sent by attendee)
+ * * DECLINECOUNTER -- denial of a counterproposal (sent by organizer)
*/
processItipItem: function(itipItem, optionsFunc) {
switch (itipItem.receivedMethod.toUpperCase()) {
case "REFRESH":
case "PUBLISH":
case "REQUEST":
case "CANCEL":
+ case "COUNTER":
+ case "DECLINECOUNTER":
case "REPLY": {
// Per iTIP spec (new Draft 4), multiple items in an iTIP message MUST have
// same ID, this simplifies our searching, we can just look for Item[0].id
let itemList = itipItem.getItemList({});
if (!itipItem.targetCalendar) {
optionsFunc(itipItem, Components.interfaces.calIErrors.CAL_IS_READONLY);
} else if (itemList.length > 0) {
ItipItemFinderFactory.findItem(itemList[0].id, itipItem, optionsFunc);
@@ -826,19 +922,20 @@ cal.itip = {
return newItem;
},
/**
* Returns a copy of an itipItem with modified properties and items build from scratch
* Use itipItem.clone() instead if only a simple copy is required
*
- * @param aItipItem ItipItem to derive a new one from
- * @param aItems List of items to be contained in the new itipItem
- * @param aProps List of properties to be different in the new itipItem
+ * @param {calIItipItem} aItipItem ItipItem to derive a new one from
+ * @param {Array} aItems calIEvent or calITodo items to be contained in the new itipItem
+ * @param {JsObject} aProps Properties to be different in the new itipItem
+ * @return {calIItipItem}
*/
getModifiedItipItem: function(aItipItem, aItems=[], aProps={}) {
let itipItem = Components.classes["@mozilla.org/calendar/itip-item;1"]
.createInstance(Components.interfaces.calIItipItem);
let serializedItems = "";
for (let item of aItems) {
serializedItems += cal.getSerializedItem(item);
}
@@ -848,16 +945,30 @@ cal.itip = {
itipItem.identity = ("identity" in aProps) ? aProps.identity : aItipItem.identity;
itipItem.isSend = ("isSend" in aProps) ? aProps.isSend : aItipItem.isSend;
itipItem.localStatus = ("localStatus" in aProps) ? aProps.localStatus : aItipItem.localStatus;
itipItem.receivedMethod = ("receivedMethod" in aProps) ? aProps.receivedMethod : aItipItem.receivedMethod;
itipItem.responseMethod = ("responseMethod" in aProps) ? aProps.responseMethod : aItipItem.responseMethod;
itipItem.targetCalendar = ("targetCalendar" in aProps) ? aProps.targetCalendar : aItipItem.targetCalendar;
return itipItem;
+ },
+
+ /**
+ * A shortcut to send DECLINECOUNTER messages - for everything else use cal.itip.checkAndSend
+ *
+ * @param aItem iTIP item to be sent
+ * @param aMethod iTIP method
+ * @param aRecipientsList an array of calIAttendee objects the message should be sent to
+ * @param aAutoResponse an inout object whether the transport should ask before sending
+ */
+ sendDeclineCounterMessage: function(aItem, aMethod, aRecipientsList, aAutoResponse) {
+ if (aMethod == "DECLINECOUNTER") {
+ return sendMessage(aItem, aMethod, aRecipientsList, aAutoResponse);
+ }
}
};
/** local to this module file
* Sets the received info either on the passed attendee or item object.
*
* @param item either calIAttendee or calIItemBase
* @param itipItemItem received iTIP item
@@ -999,17 +1110,17 @@ function createOrganizer(aCalendar) {
*/
function sendMessage(aItem, aMethod, aRecipientsList, autoResponse) {
if (aRecipientsList.length == 0) {
return false;
}
let calendar = cal.wrapInstance(aItem.calendar, Components.interfaces.calISchedulingSupport);
if (calendar) {
if (calendar.QueryInterface(Components.interfaces.calISchedulingSupport)
- .canNotify(aMethod, aItem)) {
+ .canNotify(aMethod, aItem)) {
// provider will handle that, so we return - we leave it also to the provider to
// deal with user canceled notifications (if possible), so set the return value
// to true as false would prevent any further notification within this cycle
return true;
}
}
let aTransport = aItem.calendar.getProperty("itip.transport");
@@ -1208,17 +1319,17 @@ ItipItemFinder.prototype = {
} else {
this.mFoundItems.splice(idx, 1);
}
found = true;
break;
}
}
- // If it hasn't been found and there isto add a item, add it to the end
+ // If it hasn't been found and there is to add a item, add it to the end
if (!found && aNewItem) {
this.mFoundItems.push(aNewItem);
}
this.processFoundItems();
}
},
onAddItem: function(aItem) {
@@ -1258,16 +1369,18 @@ ItipItemFinder.prototype = {
// implicitly on the parent (ics/memory/storage calls modifyException),
// the generation check will fail. We should really consider to allow
// deletion/modification/addition of occurrences directly on the providers,
// which would ease client code a lot.
case "REFRESH":
case "PUBLISH":
case "REQUEST":
case "REPLY":
+ case "COUNTER":
+ case "DECLINECOUNTER":
for (let itipItemItem of this.mItipItem.getItemList({})) {
for (let item of this.mFoundItems) {
let rid = itipItemItem.recurrenceId; // XXX todo support multiple
if (rid) { // actually applies to individual occurrence(s)
if (item.recurrenceInfo) {
item = item.recurrenceInfo.getOccurrenceFor(rid);
if (!item) {
continue;
@@ -1275,17 +1388,18 @@ ItipItemFinder.prototype = {
} else { // the item has been rescheduled with master:
itipItemItem = itipItemItem.parentItem;
}
}
switch (method) {
case "REFRESH": { // xxx todo test
let attendees = itipItemItem.getAttendees({});
- cal.ASSERT(attendees.length == 1, "invalid number of attendees in REFRESH!");
+ cal.ASSERT(attendees.length == 1,
+ "invalid number of attendees in REFRESH!");
if (attendees.length > 0) {
let action = function(opListener) {
if (!item.organizer) {
let org = createOrganizer(item.calendar);
if (org) {
item = item.clone();
item.organizer = org;
}
@@ -1363,35 +1477,75 @@ ItipItemFinder.prototype = {
newItem.addAttendee(att);
return newItem.calendar.modifyItem(
newItem, item, new ItipOpListener(opListener, item));
});
}
}
break;
}
+ case "DECLINECOUNTER":
+ // nothing to do right now, but once countering is implemented,
+ // we probably need some action here to remove the proposal from
+ // the countering attendee's calendar
+ break;
+ case "COUNTER":
case "REPLY": {
let attendees = itipItemItem.getAttendees({});
- cal.ASSERT(attendees.length == 1, "invalid number of attendees in REPLY!");
- if (attendees.length > 0 &&
- (item.calendar.getProperty("itip.disableRevisionChecks") ||
- (cal.itip.compare(itipItemItem, item.getAttendeeById(attendees[0].id)) > 0))) {
- // accepts REPLYs from previously uninvited attendees:
+ if (method == "REPLY") {
+ cal.ASSERT(
+ attendees.length == 1,
+ "invalid number of attendees in REPLY!"
+ );
+ } else {
+ attendees = cal.getAttendeesBySender(
+ attendees,
+ this.mItipItem.sender
+ );
+ cal.ASSERT(
+ attendees.length == 1,
+ "ambiguous resolution of replying attendee in COUNTER!"
+ );
+ }
+ // we get the attendee from the event stored in the calendar
+ let replyer = item.getAttendeeById(attendees[0].id);
+ if (!replyer && method == "REPLY") {
+ // We accepts REPLYs also from previously uninvited
+ // attendees, so we always have one for REPLY
+ replyer = attendees[0];
+ }
+ let noCheck = item.calendar.getProperty(
+ "itip.disableRevisionChecks");
+ let revCheck = false;
+ if (replyer && !noCheck) {
+ revCheck = cal.itip.compare(itipItemItem, replyer) > 0;
+ if (revCheck && method == "COUNTER") {
+ revCheck = cal.itip.compareSequence(itipItemItem, item) == 0;
+ }
+ }
+
+ if (replyer && (noCheck || revCheck)) {
let newItem = item.clone();
- let att = item.getAttendeeById(attendees[0].id) || attendees[0];
- newItem.removeAttendee(att);
- att = att.clone();
- setReceivedInfo(att, itipItemItem);
- att.participationStatus = attendees[0].participationStatus;
- newItem.addAttendee(att);
+ newItem.removeAttendee(replyer);
+ replyer = replyer.clone();
+ setReceivedInfo(replyer, itipItemItem);
+ let newPS = itipItemItem.getAttendeeById(replyer.id)
+ .participationStatus;
+ replyer.participationStatus = newPS;
+ newItem.addAttendee(replyer);
// Make sure the provider-specified properties are copied over
copyProviderProperties(this.mItipItem, itipItemItem, newItem);
let action = function(opListener) {
+ // n.b.: this will only be processed in case of reply or
+ // declining the counter request - of sending the
+ // appropriate reply will be taken care within the
+ // opListener (defined in imip-bar.js)
+ // TODO: move that from imip-bar.js to here
return newItem.calendar.modifyItem(
newItem, item,
newItem.calendar.getProperty("itip.notify-replies")
? new ItipOpListener(opListener, item)
: opListener);
};
operations.push(action);
}
@@ -1479,16 +1633,17 @@ ItipItemFinder.prototype = {
? new ItipOpListener(opListener, null)
: opListener);
};
operations.push(action);
break;
}
case "CANCEL": // has already been processed
case "REPLY": // item has been previously removed from the calendar
+ case "COUNTER": // the item has been previously removed form the calendar
break;
default:
rc = Components.results.NS_ERROR_NOT_IMPLEMENTED;
break;
}
}
}
--- a/calendar/base/modules/calUtils.jsm
+++ b/calendar/base/modules/calUtils.jsm
@@ -390,16 +390,43 @@ var cal = {
let calendar = cal.wrapInstance(aCalendar, Components.interfaces.calISchedulingSupport);
if (calendar) {
invitedAttendee = calendar.getInvitedAttendee(aItem);
}
return invitedAttendee;
},
/**
+ * Returns all attendees from given set of attendees matching based on the attendee id
+ * or a sent-by parameter compared to the specified email address
+ *
+ * @param {Array} aAttendees An array of calIAttendee objects
+ * @param {String} aEmailAddress A string containing the email address for lookup
+ * @return {Array} Returns an array of matching attendees
+ */
+ getAttendeesBySender: function(aAttendees, aEmailAddress) {
+ let attendees = [];
+ // we extract the email address to make it work also for a raw header value
+ let compFields = Components.classes["@mozilla.org/messengercompose/composefields;1"]
+ .createInstance(Components.interfaces.nsIMsgCompFields);
+ let addresses = compFields.splitRecipients(aEmailAddress, true, {});
+ if (addresses.length == 1) {
+ let searchFor = cal.prependMailTo(addresses[0]);
+ aAttendees.forEach(aAttendee => {
+ if ([aAttendee.id, aAttendee.getProperty("SENT-BY")].includes(searchFor)) {
+ attendees.push(aAttendee);
+ }
+ });
+ } else {
+ cal.WARN("No unique email address for lookup!");
+ }
+ return attendees;
+ },
+
+ /**
* Returns a wellformed email string like 'attendee@example.net',
* 'Common Name <attendee@example.net>' or '"Name, Common" <attendee@example.net>'
*
* @param {calIAttendee} aAttendee - the attendee to check
* @param {boolean} aIncludeCn - whether or not to return also the CN if available
* @return {string} valid email string or an empty string in case of error
*/
getAttendeeEmail: function(aAttendee, aIncludeCn) {
--- a/calendar/base/public/calIItipItem.idl
+++ b/calendar/base/public/calIItipItem.idl
@@ -9,17 +9,17 @@ interface calIItemBase;
interface calICalendar;
interface nsISimpleEnumerator;
/**
* calIItipItem is an interface used to carry information between the mime
* parser, the imip-bar UI, and the iTIP processor. It encapsulates a list of
* calIItemBase objects and provides specialized iTIP methods for those items.
*/
-[scriptable, uuid(f41392ab-dcad-4bad-818f-b3d1631c4d93)]
+[scriptable, uuid(7539c158-c30d-41d0-90e9-41d315ac3eb1)]
interface calIItipItem : nsISupports
{
/**
* Initializes the item with an ics string
* @param - in parameter - AString of ical Data
*/
void init(in AUTF8String icalData);
@@ -35,16 +35,22 @@ interface calIItipItem : nsISupports
* the item directly to the email subsystem so that communication can be
* initiated. For example, if you are Sending a REQUEST, you would set
* this flag, and send the iTIP Item into the iTIP processor, which would
* handle everything else.
*/
attribute boolean isSend;
/**
+ * Attribute: sender - set to the email address of the sender if part of an
+ * iMIP communication.
+ */
+ attribute AUTF8String sender;
+
+ /**
* Attribute: receivedMethod - method the iTIP item had upon reciept
*/
attribute AUTF8String receivedMethod;
/**
* Attribute: responseMethod - method that the protocol handler (or the
* user) decides to use to respond to the iTIP item (could be COUNTER,
* REPLY, DECLINECOUNTER, etc)
--- a/calendar/base/src/calItipItem.js
+++ b/calendar/base/src/calItipItem.js
@@ -23,16 +23,24 @@ calItipItem.prototype = {
QueryInterface: XPCOMUtils.generateQI(calItipItemInterfaces),
classInfo: XPCOMUtils.generateCI({
classID: calItipItemClassID,
contractID: "@mozilla.org/calendar/itip-item;1",
classDescription: "Calendar iTIP item",
interfaces: calItipItemInterfaces
}),
+ mSender: null,
+ get sender() {
+ return this.mSender;
+ },
+ set sender(aValue) {
+ return (this.mSender = aValue);
+ },
+
mIsSend: false,
get isSend() {
return this.mIsSend;
},
set isSend(aValue) {
return (this.mIsSend = aValue);
},
@@ -159,16 +167,17 @@ calItipItem.prototype = {
let newItem = new calItipItem();
newItem.mItemList = this.mItemList.map(item => item.clone());
newItem.mReceivedMethod = this.mReceivedMethod;
newItem.mResponseMethod = this.mResponseMethod;
newItem.mAutoResponse = this.mAutoResponse;
newItem.mTargetCalendar = this.mTargetCalendar;
newItem.mIdentity = this.mIdentity;
newItem.mLocalStatus = this.mLocalStatus;
+ newItem.mSender = this.mSender;
newItem.mIsSend = this.mIsSend;
newItem.mIsInitialized = this.mIsInitialized;
return newItem;
},
/**
* This returns both the array and the number of items. An easy way to
* call it is: let itemArray = itipItem.getItemList({ });
--- a/calendar/base/src/calUtils.js
+++ b/calendar/base/src/calUtils.js
@@ -1693,17 +1693,17 @@ function calIterateEmailIdentities(func)
* @param aSecondItem The item to compare to.
* @param aIgnoreProps (optional) An array of parameters to ignore.
* @param aIgnoreParams (optional) An object describing which parameters to
* ignore.
* @return True, if items match.
*/
function compareItemContent(aFirstItem, aSecondItem, aIgnoreProps, aIgnoreParams) {
let ignoreProps = arr2hash(aIgnoreProps ||
- ["SEQUENCE", "DTSTAMP", "LAST-MODIFIED", "X-MOZ-GENERATION",
+ ["SEQUENCE", "DTSTAMP", "LAST-MODIFIED", "X-MOZ-GENERATION", "X-MICROSOFT-DISALLOW-COUNTER",
"X-MOZ-SEND-INVITATIONS", "X-MOZ-SEND-INVITATIONS-UNDISCLOSED"]);
let ignoreParams = aIgnoreParams ||
{ ATTENDEE: ["CN"], ORGANIZER: ["CN"] };
for (let x in ignoreParams) {
ignoreParams[x] = arr2hash(ignoreParams[x]);
}
--- a/calendar/base/themes/common/dialogs/calendar-event-dialog.css
+++ b/calendar/base/themes/common/dialogs/calendar-event-dialog.css
@@ -29,16 +29,37 @@ dialog[systemcolors] {
#calendar-event-dialog .todo-only,
#calendar-task-dialog .event-only,
#calendar-event-dialog-inner .todo-only,
#calendar-task-dialog-inner .event-only {
display: none;
}
+/*--------------------------------------------------------------------
+ * Event dialog counter box section
+ *-------------------------------------------------------------------*/
+
+#counter-proposal-box {
+ background-color: rgb(186, 238, 255);
+ border-bottom: 1px solid #444444;
+}
+
+#counter-proposal-property-values > description {
+ margin-bottom: 2px;
+}
+
+#counter-proposal-summary {
+ font-weight: bold;
+}
+
+.counter-buttons {
+ max-height: 25px;
+}
+
#yearly-period-of-label,
label.label {
text-align: right;
}
#item-calendar,
#item-categories,
#item-repeat,
--- a/calendar/itip/calItipEmailTransport.js
+++ b/calendar/itip/calItipEmailTransport.js
@@ -107,21 +107,36 @@ calItipEmailTransport.prototype = {
body = cal.calGetString(
"lightning",
"itipCancelBody",
[item.organizer ? item.organizer.toString() : "", summary],
"lightning"
);
break;
}
+ case "DECLINECOUNTER": {
+ subject = cal.calGetString(
+ "lightning",
+ "itipDeclineCounterSubject",
+ [summary],
+ "lightning"
+ );
+ body = cal.calGetString(
+ "lightning",
+ "itipDeclineCounterBody",
+ [item.organizer ? item.organizer.toString() : "", summary],
+ "lightning"
+ );
+ break;
+ }
case "REPLY": {
// Get my participation status
let att = cal.getInvitedAttendee(item, aItipItem.targetCalendar);
if (!att && aItipItem.identity) {
- att = item.getAttendeeById("mailto:" + aItipItem.identity);
+ att = item.getAttendeeById(cal.prependMailTo(aItipItem.identity));
}
if (!att) { // should not happen anymore
return false;
}
// work around BUG 351589, the below just removes RSVP:
aItipItem.setAttendeeStatus(att.id, att.participationStatus);
let myPartStat = att.participationStatus;
@@ -303,17 +318,17 @@ calItipEmailTransport.prototype = {
.createInstance(Components.interfaces.nsIMsgSend);
msgSend.sendMessageFile(identity,
account.key,
composeFields,
mailFile,
true /* deleteSendFileOnCompletion */,
false /* digest_p */,
(Services.io.offline ? Components.interfaces.nsIMsgSend.nsMsgQueueForLater
- : Components.interfaces.nsIMsgSend.nsMsgDeliverNow),
+ : Components.interfaces.nsIMsgSend.nsMsgDeliverNow),
null /* nsIMsgDBHdr msgToReplace */,
null /* nsIMsgSendListener aListener */,
null /* nsIMsgStatusFeedback aStatusFeedback */,
"" /* password */);
return true;
}
break;
}
--- a/calendar/lightning/components/lightningTextCalendarConverter.js
+++ b/calendar/lightning/components/lightningTextCalendarConverter.js
@@ -48,47 +48,43 @@ ltnMimeConverter.prototype = {
}
}
if (!event) {
return "";
}
let itipItem = null;
let msgOverlay = "";
+ let msgWindow = null;
- try {
- itipItem = Components.classes["@mozilla.org/calendar/itip-item;1"]
- .createInstance(Components.interfaces.calIItipItem);
- itipItem.init(data);
+ itipItem = Components.classes["@mozilla.org/calendar/itip-item;1"]
+ .createInstance(Components.interfaces.calIItipItem);
+ itipItem.init(data);
+
+ // this.uri is the message URL that we are processing.
+ // We use it to get the nsMsgHeaderSink to store the calItipItem.
+ if (this.uri) {
+ try {
+ let msgUrl = this.uri.QueryInterface(Components.interfaces.nsIMsgMailNewsUrl);
+ msgWindow = msgUrl.msgWindow;
+ itipItem.sender = msgUrl.mimeHeaders.extractHeader("From", false);
+ } catch (exc) {
+ // msgWindow is optional in some scenarios
+ // (e.g. gloda in action, throws NS_ERROR_INVALID_POINTER then)
+ }
+ }
+
+ if (msgWindow) {
let dom = ltn.invitation.createInvitationOverlay(event, itipItem);
msgOverlay = cal.xml.serializeDOM(dom);
- // this.uri is the message URL that we are processing.
- // We use it to get the nsMsgHeaderSink to store the calItipItem.
- if (this.uri) {
- let msgWindow = null;
- try {
- let msgUrl = this.uri.QueryInterface(Components.interfaces.nsIMsgMailNewsUrl);
- msgWindow = msgUrl.msgWindow;
- } catch (exc) {
- // msgWindow is optional in some scenarios
- // (e.g. gloda in action, throws NS_ERROR_INVALID_POINTER then)
- }
+ let sinkProps = msgWindow.msgHeaderSink.properties;
+ sinkProps.setPropertyAsInterface("itipItem", itipItem);
+ sinkProps.setPropertyAsAUTF8String("msgOverlay", msgOverlay);
- if (msgWindow) {
- let sinkProps = msgWindow.msgHeaderSink.properties;
- sinkProps.setPropertyAsInterface("itipItem", itipItem);
- sinkProps.setPropertyAsAUTF8String("msgOverlay", msgOverlay);
-
- // Notify the observer that the itipItem is available
- Services.obs.notifyObservers(null, "onItipItemCreation", 0);
- }
- }
- } catch (e) {
- cal.ERROR("[ltnMimeConverter] convertToHTML: " + e);
+ // Notify the observer that the itipItem is available
+ Services.obs.notifyObservers(null, "onItipItemCreation", 0);
}
-
- // Create the HTML string for display
return msgOverlay;
}
};
this.NSGetFactory = XPCOMUtils.generateNSGetFactory([ltnMimeConverter]);
--- a/calendar/lightning/content/imip-bar-overlay.xul
+++ b/calendar/lightning/content/imip-bar-overlay.xul
@@ -78,16 +78,32 @@
<!-- show event/invitation details -->
<toolbarbutton id="imipDetailsButton"
label="&lightning.imipbar.btnDetails.label;"
tooltiptext="&lightning.imipbar.btnDetails.tooltiptext;"
class="toolbarbutton-1 msgHeaderView-button imipDetailsButton"
oncommand="ltnImipBar.executeAction('X-SHOWDETAILS')"
hidden="true"/>
+ <!-- decline counter -->
+ <toolbarbutton id="imipDeclineCounterButton"
+ label="&lightning.imipbar.btnDeclineCounter.label;"
+ tooltiptext="&lightning.imipbar.btnDeclineCounter.tooltiptext;"
+ class="toolbarbutton-1 msgHeaderView-button imipDeclineCounterButton"
+ oncommand="ltnImipBar.executeAction('X-DECLINECOUNTER')"
+ hidden="true"/>
+
+ <!-- reschedule -->
+ <toolbarbutton id="imipRescheduleButton"
+ label="&lightning.imipbar.btnReschedule.label;"
+ tooltiptext="&lightning.imipbar.btnReschedule.tooltiptext;"
+ class="toolbarbutton-1 msgHeaderView-button imipRescheduleButton"
+ oncommand="ltnImipBar.executeAction('X-RESCHEDULE')"
+ hidden="true"/>
+
<!-- add published events -->
<toolbarbutton id="imipAddButton"
label="&lightning.imipbar.btnAdd.label;"
tooltiptext="&lightning.imipbar.btnAdd.tooltiptext;"
class="toolbarbutton-1 msgHeaderView-button imipAddButton"
oncommand="ltnImipBar.executeAction()"
hidden="true"/>
--- a/calendar/lightning/content/imip-bar.js
+++ b/calendar/lightning/content/imip-bar.js
@@ -205,17 +205,17 @@ var ltnImipBar = {
return false;
}
let author = aMsgHdr.mime2DecodedAuthor;
let isSentFolder = aMsgHdr.folder.flags & nsMsgFolderFlags.SentMail;
if (author && isSentFolder) {
let accounts = MailServices.accounts;
for (let identity in fixIterator(accounts.allIdentities,
Components.interfaces.nsIMsgIdentity)) {
- if (author.includes(identity.email) && !identity.fccReplyFollowParent) {
+ if (author.includes(identity.email) && !identity.fccReplyFollowsParent) {
return true;
}
}
}
return false;
};
// We override the bar label for sent out invitations and in case the event does not exist
@@ -274,30 +274,71 @@ var ltnImipBar = {
}
}
msgWindow.displayHTMLInMessagePane("", msgOverlay, false);
},
executeAction: function(partStat, extendResponse) {
function _execAction(aActionFunc, aItipItem, aWindow, aPartStat) {
if (cal.itip.promptCalendar(aActionFunc.method, aItipItem, aWindow)) {
+ let isDeclineCounter = aPartStat == "X-DECLINECOUNTER";
// filter out fake partstats
if (aPartStat.startsWith("X-")) {
- partstat = "";
+ partStat = "";
}
// hide the buttons now, to disable pressing them twice...
if (aPartStat == partStat) {
ltnImipBar.resetButtons();
}
let opListener = {
QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]),
onOperationComplete: function(aCalendar, aStatus, aOperationType, aId, aDetail) {
+ if (Components.isSuccessCode(aStatus) && isDeclineCounter) {
+ // TODO: move the DECLINECOUNTER stuff to actionFunc
+ aItipItem.getItemList({}).forEach(aItem => {
+ // we can rely on the received itipItem to reply at this stage
+ // already, the checks have been done in cal.itip.processFoundItems
+ // when setting up the respective aActionFunc
+ let attendees = cal.getAttendeesBySender(
+ aItem.getAttendees({}),
+ aItipItem.sender
+ );
+ let status = true;
+ if (attendees.length == 1 && ltnImipBar.foundItems &&
+ ltnImipBar.foundItems.length) {
+ // we must return a message with the same sequence number as the
+ // counterproposal - to make it easy, we simply use the received
+ // item and just remove a comment, if any
+ try {
+ let item = aItem.clone();
+ item.calendar = ltnImipBar.foundItems[0].calendar;
+ item.deleteProperty("COMMENT");
+ // once we have full support to deal with for multiple items
+ // in a received invitation message, we should send this
+ // from outside outside of the forEach context
+ status = cal.itip.sendDeclineCounterMessage(
+ item,
+ "DECLINECOUNTER",
+ attendees,
+ { value: false }
+ );
+ } catch (e) {
+ cal.ERROR(e);
+ status = false;
+ }
+ } else {
+ status = false;
+ }
+ if (!status) {
+ cal.ERROR("Failed to send DECLINECOUNTER reply!");
+ }
+ });
+ }
// For now, we just state the status for the user something very simple
- let imipBar = document.getElementById("imip-bar");
let label = cal.itip.getCompleteText(aStatus, aOperationType);
imipBar.setAttribute("label", label);
if (!Components.isSuccessCode(aStatus)) {
showError(label);
}
},
onGetResult: function(aCalendar, aStatus, aItemType, aDetail, aCount, aItems) {
@@ -309,29 +350,80 @@ var ltnImipBar = {
} catch (exc) {
Components.utils.reportError(exc);
}
return true;
}
return false;
}
+ let imipBar = document.getElementById("imip-bar");
if (partStat == null) {
partStat = "";
}
- if (partStat == "X-SHOWDETAILS") {
+ if (partStat == "X-SHOWDETAILS" || partStat == "X-RESCHEDULE") {
+ let counterProposal;
let items = ltnImipBar.foundItems;
if (items && items.length) {
let item = items[0].isMutable ? items[0] : items[0].clone();
- modifyEventWithDialog(item);
+
+ if (partStat == "X-RESCHEDULE") {
+ // TODO most of the following should be moved to the actionFunc defined in
+ // calItipUtils
+ let proposedItem = ltnImipBar.itipItem.getItemList({})[0];
+ let proposedRID = proposedItem.getProperty("RECURRENCE-ID");
+ if (proposedRID) {
+ // if this is a counterproposal for a specific occurrence, we use
+ // that to compare with
+ item = item.recurrenceInfo.getOccurrenceFor(proposedRID).clone();
+ }
+ let parsedProposal = ltn.invitation.parseCounter(proposedItem, item);
+ let potentialProposers = cal.getAttendeesBySender(
+ proposedItem.getAttendees({}),
+ ltnImipBar.itipItem.sender
+ );
+ let proposingAttendee = potentialProposers.length == 1 ?
+ potentialProposers[0] : null;
+ if (proposingAttendee &&
+ ["OK", "OUTDATED", "NOTLATESTUPDATE"].includes(parsedProposal.result.type)) {
+ counterProposal = {
+ attendee: proposingAttendee,
+ proposal: parsedProposal.differences,
+ oldVersion: parsedProposal.result == "OLDVERSION" ||
+ parsedProposal.result == "NOTLATESTUPDATE",
+ onReschedule: () => {
+ imipBar.setAttribute(
+ "label",
+ ltn.getString("lightning", "imipBarCounterPreviousVersionText")
+ );
+ // TODO: should we hide the buttons in this case, too?
+ }
+ };
+ } else {
+ imipBar.setAttribute(
+ "label",
+ ltn.getString("lightning", "imipBarCounterErrorText")
+ );
+ ltnImipBar.resetButtons();
+ if (proposingAttendee) {
+ cal.LOG(parsedProposal.result.descr);
+ } else {
+ cal.LOG("Failed to identify the sending attendee of the counterproposal.");
+ }
+
+ return false;
+ }
+ }
+ // if this a rescheduling operation, we suppress the occurrence prompt here
+ modifyEventWithDialog(item, null, partStat != "X-RESCHEDULE", null, counterProposal);
}
} else {
if (extendResponse) {
// Open an extended response dialog to enable the user to add a comment, make a
- // counter proposal, delegate the event or interact in another way.
+ // counterproposal, delegate the event or interact in another way.
// Instead of a dialog, this might be implemented as a separate container inside the
// imip-overlay as proposed in bug 458578
//
// If implemented as a dialog, the OL compatibility decision should be incorporated
// therein too and the itipItems's autoResponse set to auto subsequently
// to prevent a second popup during imip transport processing.
}
let delmgr = Components.classes["@mozilla.org/calendar/deleted-items-manager;1"]
--- a/calendar/lightning/content/lightning-item-iframe.js
+++ b/calendar/lightning/content/lightning-item-iframe.js
@@ -368,16 +368,21 @@ function onLoad() {
if (!gNewItemUI) {
setElementValue("completed-date-picker", initialDatesValue);
setElementValue("todo-entrydate", initialDatesValue);
setElementValue("todo-duedate", initialDatesValue);
}
}
loadDialog(window.calendarItem);
+ if (args.counterProposal) {
+ window.counterProposal = args.counterProposal;
+ displayCounterProposal();
+ }
+
gMainWindow.setCursor("auto");
if (typeof ToolbarIconColor !== "undefined") {
ToolbarIconColor.init();
}
if (!gNewItemUI) {
document.getElementById("item-title").focus();
@@ -730,34 +735,41 @@ function loadDialog(aItem) {
// figure out what the title of the dialog should be and set it
// tabs already have their title set
if (!gInTab) {
updateTitle();
}
let notifyCheckbox = document.getElementById("notify-attendees-checkbox");
let undiscloseCheckbox = document.getElementById("undisclose-attendees-checkbox");
+ let disallowcounterCheckbox = document.getElementById("disallow-counter-checkbox");
if (canNotifyAttendees(aItem.calendar, aItem)) {
// visualize that the server will send out mail:
notifyCheckbox.checked = true;
- // hide undisclosure control as this a client only feature
+ // hide these controls as this a client only feature
undiscloseCheckbox.disabled = true;
} else {
let itemProp = aItem.getProperty("X-MOZ-SEND-INVITATIONS");
notifyCheckbox.checked = (aItem.calendar.getProperty("imip.identity") &&
((itemProp === null)
? Preferences.get("calendar.itip.notify", true)
: (itemProp == "TRUE")));
let undiscloseProp = aItem.getProperty("X-MOZ-SEND-INVITATIONS-UNDISCLOSED");
undiscloseCheckbox.checked = (undiscloseProp === null)
? false // default value as most common within organizations
: (undiscloseProp == "TRUE");
// disable checkbox, if notifyCheckbox is not checked
undiscloseCheckbox.disabled = (notifyCheckbox.checked == false);
}
+ // this may also be a server exposed calendar property from exchange servers - if so, this
+ // probably should overrule the client-side config option
+ let disallowCounterProp = aItem.getProperty("X-MICROSOFT-DISALLOW-COUNTER");
+ disallowcounterCheckbox.checked = disallowCounterProp == "TRUE";
+ // if we're in reschedule mode, it's pointless to enable the control
+ disallowcounterCheckbox.disabled = !!window.counterProposal;
updateAttendees();
updateRepeat(true);
updateReminder(true);
}
// Status
if (cal.isEvent(aItem)) {
@@ -2923,17 +2935,30 @@ function saveItem() {
item.deleteProperty("X-MOZ-SEND-INVITATIONS");
} else {
item.setProperty("X-MOZ-SEND-INVITATIONS", notifyCheckbox.checked ? "TRUE" : "FALSE");
}
let undiscloseCheckbox = document.getElementById("undisclose-attendees-checkbox");
if (undiscloseCheckbox.disabled) {
item.deleteProperty("X-MOZ-SEND-INVITATIONS-UNDISCLOSED");
} else {
- item.setProperty("X-MOZ-SEND-INVITATIONS-UNDISCLOSED", undiscloseCheckbox.checked ? "TRUE" : "FALSE");
+ item.setProperty("X-MOZ-SEND-INVITATIONS-UNDISCLOSED",
+ undiscloseCheckbox.checked ? "TRUE" : "FALSE");
+ }
+ let disallowcounterCheckbox = document.getElementById("disallow-counter-checkbox");
+ let xProp = window.calendarItem.getProperty("X-MICROSOFT-DISALLOW-COUNTER");
+ // we want to leave an existing x-prop in case the checkbox is disabled as we need to
+ // roundtrip x-props that are not exclusively under our control
+ if (!disallowcounterCheckbox.disabled) {
+ // we only set the prop if we need to
+ if (disallowcounterCheckbox.checked) {
+ item.setProperty("X-MICROSOFT-DISALLOW-COUNTER", "TRUE");
+ } else if (xProp) {
+ item.setProperty("X-MICROSOFT-DISALLOW-COUNTER", "FALSE");
+ }
}
}
// We check if the organizerID is different from our
// calendar-user-address-set. The organzerID is the owner of the calendar.
// If it's different, that is because someone is acting on behalf of
// the organizer.
if (item.organizer && item.calendar.aclEntry) {
@@ -2988,17 +3013,17 @@ function onCommandSave(aIsClosing) {
// the call is complete? This might help when the user tries to save twice
// before the call is complete. In that case, we do need a progress bar and
// the ability to cancel the operation though.
let listener = {
QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]),
onOperationComplete: function(aCalendar, aStatus, aOpType, aId, aItem) {
// Check if the current window has a calendarItem first, because in case of undo
// window refers to the main window and we would get a 'calendarItem is undefined' warning.
- if ("calendarItem" in window) {
+ if (!aIsClosing && "calendarItem" in window) {
// If we changed the calendar of the item, onOperationComplete will be called multiple
// times. We need to make sure we're receiving the update on the right calendar.
if ((!window.calendarItem.id ||aId == window.calendarItem.id) &&
(aCalendar.id == window.calendarItem.calendar.id) &&
Components.isSuccessCode(aStatus)) {
if (window.calendarItem.recurrenceId) {
// TODO This workaround needs to be removed in bug 396182
// We are editing an occurrence. Make sure that the returned
@@ -3012,24 +3037,24 @@ function onCommandSave(aIsClosing) {
}
// We now have an item, so we must change to an edit.
window.mode = "modify";
updateTitle();
eventDialogCalendarObserver.observe(window.calendarItem.calendar);
}
}
+ // this triggers the update of the imipbar in case this is a rescheduling case
+ if (window.counterProposal && window.counterProposal.onReschedule) {
+ window.counterProposal.onReschedule();
+ }
},
onGetResult: function() {}
};
-
- // Let the caller decide how to handle the modified/added item. Only pass
- // the above item if we are not closing, otherwise the listener will be
- // missing its window afterwards.
- window.onAcceptCallback(item, calendar, originalItem, !aIsClosing && listener);
+ window.onAcceptCallback(item, calendar, originalItem, listener);
}
/**
* This function is called when the user chooses to delete an Item
* from the Event/Task dialog
*
*/
function onCommandDeleteItem() {
@@ -3814,17 +3839,17 @@ function capSupported(aCap) {
* @return The values for this capability
*/
function capValues(aCap, aDefault) {
let calendar = getCurrentCalendar();
let vals = calendar.getProperty("capabilities." + aCap + ".values");
return (vals === null ? aDefault : vals);
}
- /**
+/**
* Checks the until date just entered in the datepicker in order to avoid
* setting a date earlier than the start date.
* Restores the previous correct date; sets the warning flag to prevent closing
* the dialog when the user enters a wrong until date.
*/
function checkUntilDate() {
let repeatUntilDate = getElementValue("repeat-until-datepicker");
if (repeatUntilDate == "forever") {
@@ -3858,8 +3883,203 @@ function checkUntilDate() {
};
setTimeout(callback, 1);
} else {
// Valid date: set the time equal to start date time.
gUntilDate = untilDate;
updateUntildateRecRule();
}
}
+
+/**
+ * Displays a counterproposal if any
+ */
+function displayCounterProposal() {
+ if (!window.counterProposal || !window.counterProposal.attendee ||
+ !window.counterProposal.proposal) {
+ return;
+ }
+
+ let propLabels = document.getElementById("counter-proposal-property-labels");
+ let propValues = document.getElementById("counter-proposal-property-values");
+ let idCounter = 0;
+ let comment;
+
+ for (let proposal of window.counterProposal.proposal) {
+ if (proposal.property == "COMMENT") {
+ if (proposal.proposed && !proposal.original) {
+ comment = proposal.proposed;
+ }
+ } else {
+ let label = lookupCounterLabel(proposal);
+ let value = formatCounterValue(proposal);
+ if (label && value) {
+ // setup label node
+ let propLabel = propLabels.firstChild.cloneNode(false);
+ propLabel.id = propLabel.id + "-" + idCounter;
+ propLabel.control = propLabel.control + "-" + idCounter;
+ propLabel.removeAttribute("collapsed");
+ propLabel.value = label;
+ // setup value node
+ let propValue = propValues.firstChild.cloneNode(false);
+ propValue.id = propLabel.control;
+ propValue.removeAttribute("collapsed");
+ propValue.value = value;
+ // append nodes
+ propLabels.appendChild(propLabel);
+ propValues.appendChild(propValue);
+ idCounter++;
+ }
+ }
+ }
+
+ let attendeeId = window.counterProposal.attendee.CN ||
+ cal.removeMailTo(window.counterProposal.attendee.id || "");
+ let partStat = window.counterProposal.attendee.participationStatus;
+ if (partStat == "DECLINED") {
+ partStat = "counterSummaryDeclined";
+ } else if (partStat == "TENTATIVE") {
+ partStat = "counterSummaryTentative";
+ } else if (partStat == "ACCEPTED") {
+ partStat = "counterSummaryAccepted";
+ } else if (partStat == "DELEGATED") {
+ partStat = "counterSummaryDelegated";
+ } else if (partStat == "NEEDS-ACTION") {
+ partStat = "counterSummaryNeedsAction";
+ } else {
+ cal.LOG("Unexpected partstat " + partStat + " detected.");
+ // we simply reset partStat not display the summary text of the counter box
+ // to avoid the window of death
+ partStat = null;
+ }
+
+ if (idCounter > 0) {
+ if (partStat && attendeeId.length) {
+ document.getElementById("counter-proposal-summary").value = cal.calGetString(
+ "calendar-event-dialog",
+ partStat,
+ [attendeeId]
+ );
+ document.getElementById("counter-proposal-summary").removeAttribute("collapsed");
+ }
+ if (comment) {
+ document.getElementById("counter-proposal-comment").value = comment;
+ document.getElementById("counter-proposal-box").removeAttribute("collapsed");
+ }
+ document.getElementById("counter-proposal-box").removeAttribute("collapsed");
+
+ if (window.counterProposal.oldVersion) {
+ // this is a counterproposal to a previous version of the event - we should notify the
+ // user accordingly
+ notifyUser(
+ "counterProposalOnPreviousVersion",
+ cal.calGetString("calendar-event-dialog", "counterOnPreviousVersionNotification"),
+ "warn"
+ );
+ }
+ if (window.calendarItem.getProperty("X-MICROSOFT-DISALLOW-COUNTER") == "TRUE") {
+ // this is a counterproposal although the user disallowed countering when sending the
+ // invitation, so we notify the user accordingly
+ notifyUser(
+ "counterProposalOnCounteringDisallowed",
+ cal.calGetString("calendar-event-dialog", "counterOnCounterDisallowedNotification"),
+ "warn"
+ );
+ }
+ }
+}
+
+/**
+ * Get the property label to display for a counterproposal based on the respective label used in
+ * the dialog
+ *
+ * @param {JSObject} aProperty The property to check for a label
+ * @returns {String|null} The label to display or null if no such label
+ */
+function lookupCounterLabel(aProperty) {
+ let nodeIds = getPropertyMap();
+ let labels = nodeIds.has(aProperty.property) &&
+ document.getElementsByAttribute("control", nodeIds.get(aProperty.property));
+ let labelValue;
+ if (labels && labels.length) {
+ // as label control assignment should be unique, we can just take the first result
+ labelValue = labels[0].value;
+ } else {
+ cal.LOG("Unsupported property " + aProperty.property + " detected when setting up counter " +
+ "box labels.");
+ }
+ return labelValue;
+}
+
+/**
+ * Get the property value to display for a counterproposal as currently supported
+ *
+ * @param {JSObject} aProperty The property to check for a label
+ * @returns {String|null} The value to display or null if the property is not supported
+ */
+function formatCounterValue(aProperty) {
+ const dateProps = ["DTSTART", "DTEND"];
+ const stringProps = ["SUMMARY", "LOCATION"];
+
+ let val;
+ if (dateProps.includes(aProperty.property)) {
+ let localTime = aProperty.proposed.getInTimezone(cal.calendarDefaultTimezone());
+ let formatter = getDateFormatter();
+ val = formatter.formatDateTime(localTime);
+ if (gTimezonesEnabled) {
+ let tzone = localTime.timezone.displayName || localTime.timezone.tzid;
+ val += " " + tzone;
+ }
+ } else if (stringProps.includes(aProperty.property)) {
+ val = aProperty.proposed;
+ } else {
+ cal.LOG("Unsupported property " + aProperty.property +
+ " detected when setting up counter box values.");
+ }
+ return val;
+}
+
+/**
+ * Get a map of porperty names and labels of currently supported properties
+ *
+ * @returns {Map}
+ */
+function getPropertyMap() {
+ let map = new Map();
+ map.set("SUMMARY", "item-title");
+ map.set("LOCATION", "item-location");
+ map.set("DTSTART", "event-starttime");
+ map.set("DTEND", "event-endtime");
+ return map;
+}
+
+/**
+ * Applies the proposal or original data to the respective dialog fields
+ *
+ * @param {String} aType Either 'proposed' or 'original'
+ */
+function applyValues(aType) {
+ if (!window.counterProposal || (aType != "proposed" && aType != "original")) {
+ return;
+ }
+ let originalBtn = document.getElementById("counter-original-btn");
+ if (originalBtn.disabled) {
+ // The button is disbled when opening the dialog/tab, which makes it more obvious to the
+ // user that he/she needs to apply the proposal values prior to saving & sending.
+ // Once that happened, we leave both options to the user without toogling the button states
+ // to avoid needing to listen to manual changes to do that correctly
+ originalBtn.removeAttribute("disabled");
+ }
+ let nodeIds = getPropertyMap();
+ window.counterProposal.proposal.forEach(aProperty => {
+ if (aProperty.property != "COMMENT") {
+ let valueNode = nodeIds.has(aProperty.property) &&
+ document.getElementById(nodeIds.get(aProperty.property));
+ if (valueNode) {
+ if (["DTSTART", "DTEND"].includes(aProperty.property)) {
+ valueNode.value = cal.dateTimeToJsDate(aProperty[aType]);
+ } else {
+ valueNode.value = aProperty[aType];
+ }
+ }
+ }
+ });
+}
--- a/calendar/lightning/content/lightning-item-iframe.xul
+++ b/calendar/lightning/content/lightning-item-iframe.xul
@@ -74,18 +74,71 @@
<command id="cmd_copyAttachment"
oncommand="copyAttachment()"/>
<command id="cmd_deleteAttachment"
disable-on-readonly="true"
oncommand="deleteAttachment()"/>
<command id="cmd_deleteAllAttachments"
disable-on-readonly="true"
oncommand="deleteAllAttachments()"/>
+ <command id="cmd_applyProposal"
+ disable-on-readonly="true"
+ oncommand="applyValues('proposed')"/>
+ <command id="cmd_applyOriginal"
+ disable-on-readonly="true"
+ oncommand="applyValues('original')"/>
</commandset>
+ <!-- Counter information section -->
+ <hbox id="counter-proposal-box"
+ collapsed="true">
+ <vbox>
+ <description id="counter-proposal-summary"
+ collapsed="true"
+ crop="end" />
+ <hbox id="counter-proposal">
+ <vbox id="counter-proposal-property-labels">
+ <label id="counter-proposal-property-label"
+ control="counter-proposal-property-value"
+ collapsed="true"
+ value="" />
+ </vbox>
+ <vbox id="counter-proposal-property-values">
+ <description id="counter-proposal-property-value"
+ crop="end"
+ collapsed="true"
+ value="" />
+ </vbox>
+ </hbox>
+ <description id="counter-proposal-comment"
+ collapsed="true"
+ crop="end" />
+ </vbox>
+ <spacer flex="1" />
+ <vbox id ="counter-buttons">
+ <button id="counter-proposal-btn"
+ label="&counter.button.proposal.label;"
+ crop="end"
+ command="cmd_applyProposal"
+ orient="horizontal"
+ class="counter-buttons"
+ accesskey="&counter.button.proposal.accesskey;"
+ tooltip="&counter.button.proposal.tooltip2;" />
+ <button id="counter-original-btn"
+ label="&counter.button.original.label;"
+ crop="end"
+ command="cmd_applyOriginal"
+ orient="horizontal"
+ disabled="true"
+ class="counter-buttons"
+ accesskey="&counter.button.original.accesskey;"
+ tooltip="&counter.button.original.tooltip2;" />
+ </vbox>
+ </hbox>
+
<notificationbox id="event-dialog-notifications" notificationside="top"/>
<grid id="event-grid"
flex="1"
style="padding-top: 8px; padding-bottom: 10px; padding-inline-start: 8px; padding-inline-end: 10px;">
<columns id="event-grid-columns">
<column id="event-description-column"/>
<column id="event-controls-column" flex="1"/>
@@ -567,16 +620,21 @@
accesskey="&event.attendees.notify.accesskey;"
oncommand="changeUndiscloseCheckboxStatus();"
pack="start"/>
<checkbox id="undisclose-attendees-checkbox"
label="&event.attendees.notifyundisclosed.label;"
accesskey="&event.attendees.notifyundisclosed.accesskey;"
tooltiptext="&event.attendees.notifyundisclosed.tooltip;"
pack="start"/>
+ <checkbox id="disallow-counter-checkbox"
+ label="&event.attendees.disallowcounter.label;"
+ accesskey="&event.attendees.disallowcounter.accesskey;"
+ tooltiptext="&event.attendees.disallowcounter.tooltip;"
+ pack="start"/>
</hbox>
</vbox>
</tabpanel>
</tabpanels>
</tabbox>
<separator id="event-grid-link-separator"
class="groove"
--- a/calendar/lightning/modules/ltnInvitationUtils.jsm
+++ b/calendar/lightning/modules/ltnInvitationUtils.jsm
@@ -32,34 +32,44 @@ ltn.invitation = {
"itipRequestBody",
[organizerString, summary]);
break;
case "CANCEL":
header = ltn.getString("lightning",
"itipCancelBody",
[organizerString, summary]);
break;
- case "REPLY": {
- // This is a reply received from someone else, there should
- // be just one attendee, the attendee that replied. If
- // there is more than one attendee, just take the first so
- // code doesn't break here.
+ case "COUNTER":
+ // falls through
+ case "REPLY":
let attendees = item.getAttendees({});
- if (attendees && attendees.length >= 1) {
- let sender = attendees[0];
- let statusString = sender.participationStatus == "DECLINED"
- ? "itipReplyBodyDecline"
- : "itipReplyBodyAccept";
-
- header = ltn.getString("lightning", statusString, [sender.toString()]);
+ let sender = cal.getAttendeesBySender(attendees, aItipItem.sender);
+ if (sender.length == 1) {
+ if (aItipItem.responseMethod == "COUNTER") {
+ header = cal.calGetString("lightning",
+ "itipCounterBody",
+ [sender[0].toString(), summary],
+ "lightning");
+ } else {
+ let statusString = (sender[0].participationStatus == "DECLINED" ?
+ "itipReplyBodyDecline" : "itipReplyBodyAccept");
+ header = cal.calGetString("lightning",
+ statusString,
+ [sender[0].toString()],
+ "lightning");
+ }
} else {
header = "";
}
break;
- }
+ case "DECLINECOUNTER":
+ header = ltn.getString("lightning",
+ "itipDeclineCounterBody",
+ [organizerString, summary]);
+ break;
}
}
if (!header) {
header = ltn.getString("lightning", "imipHtml.header", null);
}
return header;
@@ -499,10 +509,80 @@ ltn.invitation = {
return MailServices.mimeConverter
.encodeMimePartIIStr_UTF8(aHeader,
aIsEmail,
"UTF-8",
fieldNameLen,
Components.interfaces
.nsIMimeConverter
.MIME_ENCODED_WORD_SIZE);
+ },
+
+ /**
+ * Parses a counterproposal to extract differences to the existing event
+ * @param {calIEvent|calITodo} aProposedItem The counterproposal
+ * @param {calIEvent|calITodo} aExistingItem The item to compare with
+ * @return {JSObject} Objcet of result and differences of parsing
+ * @return {String} JsObject.result.type Parsing result: OK|OLDVERSION|ERROR|NODIFF
+ * @return {String} JsObject.result.descr Parsing result description
+ * @return {Array} JsObject.differences Array of objects consisting of property, proposed
+ * and original properties.
+ * @return {String} JsObject.comment A comment of the attendee, if any
+ */
+ parseCounter: function(aProposedItem, aExistingItem) {
+ let isEvent = cal.isEvent(aProposedItem);
+ // atm we only support a subset of properties, for a full list see RfC 5546 section 3.2.7
+ let properties = ["SUMMARY", "LOCATION", "DTSTART", "DTEND", "COMMENT"];
+ if (!isEvent) {
+ cal.LOG("Parsing of counterproposals is currently only supported for events.");
+ properties = [];
+ }
+
+ let diff = [];
+ let status = { descr: "", type: "OK" };
+ // As required in https://tools.ietf.org/html/rfc5546#section-3.2.7 a valid counterproposal
+ // is referring to as existing UID and must include the same sequence number and organizer as
+ // the original request being countered
+ if (aProposedItem.id == aExistingItem.id &&
+ aProposedItem.organizer && aExistingItem.organizer &&
+ aProposedItem.organizer.id == aExistingItem.organizer.id) {
+ let proposedSequence = aProposedItem.getProperty("SEQUENCE") || 0;
+ let existingSequence = aExistingItem.getProperty("SEQUENCE") || 0;
+ if (existingSequence >= proposedSequence) {
+ if (existingSequence > proposedSequence) {
+ // in this case we prompt the organizer with the additional information that the
+ // received proposal refers to an outdated version of the event
+ status.descr = "This is a counterproposal to an already rescheduled event.";
+ status.type = "OUTDATED";
+ } else if (aProposedItem.stampTime.compare(aExistingItem.stampTime) == -1) {
+ // now this is the same sequence but the proposal is not based on the latest
+ // update of the event - updated events may have minor changes, while for major
+ // ones there has been a rescheduling
+ status.descr = "This is a counterproposal not based on the latest event update.";
+ status.type = "NOTLATESTUPDATE";
+ }
+ for (let prop of properties) {
+ let newValue = aProposedItem.getProperty(prop) || null;
+ let oldValue = aExistingItem.getProperty(prop) || null;
+ if ((["DTSTART", "DTEND"].includes(prop) && newValue.toString() != oldValue.toString()) ||
+ (!["DTSTART", "DTEND"].includes(prop) && newValue != oldValue)) {
+ diff.push({
+ property: prop,
+ proposed: newValue,
+ original: oldValue
+ });
+ }
+ }
+ } else {
+ status.descr = "Invalid sequence number in counterproposal.";
+ status.type = "ERROR";
+ }
+ } else {
+ status.descr = "Mismatch of uid or organizer in counterproposal.";
+ status.type = "ERROR";
+ }
+ if (status.type != "ERROR" && !diff.length) {
+ status.descr = "No difference in counterproposal detected.";
+ status.type = "NODIFF";
+ }
+ return { result: status, differences: diff };
}
};
--- a/calendar/lightning/themes/linux/lightning.css
+++ b/calendar/lightning/themes/linux/lightning.css
@@ -185,16 +185,17 @@ radio[pane=paneLightning] {
}
/* ::: imip button icons ::: */
.imipAcceptRecurrencesButton,
.imipAcceptButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#complete);
}
+.imipDeclineCounterButton,
.imipDeclineRecurrencesButton,
.imipDeclineButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#decline);
}
.imipTentativeRecurrencesButton,
.imipTentativeButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#tentative);
@@ -203,16 +204,17 @@ radio[pane=paneLightning] {
.imipDetailsButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#find);
}
.imipAddButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#newevent);
}
+.imipRescheduleButton,
.imipUpdateButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#synchronize);
}
.imipDeleteButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#delete);
}
--- a/calendar/lightning/themes/osx/lightning.css
+++ b/calendar/lightning/themes/osx/lightning.css
@@ -261,16 +261,17 @@ toolbar[bighttext] #task-tab-button {
/* ::: imip button icons ::: */
@media not all and (-moz-mac-yosemite-theme) {
.imipAcceptRecurrencesButton,
.imipAcceptButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar-osxlion.svg#complete);
}
+ .imipDeclineCounterButton,
.imipDeclineRecurrencesButton,
.imipDeclineButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar-osxlion.svg#decline);
}
.imipTentativeRecurrencesButton,
.imipTentativeButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar-osxlion.svg#tentative);
@@ -279,16 +280,17 @@ toolbar[bighttext] #task-tab-button {
.imipDetailsButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar-osxlion.svg#find);
}
.imipAddButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar-osxlion.svg#newevent);
}
+ .imipRescheduleButton,
.imipUpdateButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar-osxlion.svg#synchronize);
}
.imipDeleteButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar-osxlion.svg#delete);
}
@@ -298,16 +300,17 @@ toolbar[bighttext] #task-tab-button {
}
@media (-moz-mac-yosemite-theme) {
.imipAcceptRecurrencesButton,
.imipAcceptButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#complete-flat);
}
+ .imipDeclineCounterButton,
.imipDeclineRecurrencesButton,
.imipDeclineButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#decline-flat);
}
.imipTentativeRecurrencesButton,
.imipTentativeButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#tentative-flat);
@@ -316,16 +319,17 @@ toolbar[bighttext] #task-tab-button {
.imipDetailsButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#find-flat);
}
.imipAddButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#newevent-flat);
}
+ .imipRescheduleButton,
.imipUpdateButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#synchronize-flat);
}
.imipDeleteButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#delete);
}
--- a/calendar/lightning/themes/windows/lightning.css
+++ b/calendar/lightning/themes/windows/lightning.css
@@ -239,30 +239,32 @@ radio[pane=paneLightning] {
}
/* ::: imip button icons ::: */
.imipAcceptButton,
.imipAcceptRecurrencesButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#complete);
}
+ .imipDeclineCounterButton,
.imipDeclineButton,
.imipDeclineRecurrencesButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#decline);
}
.imipTentativeButton,
.imipTentativeRecurrencesButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#tentative);
}
.imipAddButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#newevent);
}
+ .imipRescheduleButton,
.imipUpdateButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#synchronize);
}
.imipDetailsButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#find);
}
@@ -356,30 +358,32 @@ radio[pane=paneLightning] {
}
/* ::: imip button icons ::: */
.imipAcceptButton,
.imipAcceptRecurrencesButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#complete-flat);
}
+ .imipDeclineCounterButton,
.imipDeclineButton,
.imipDeclineRecurrencesButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#decline-flat);
}
.imipTentativeButton,
.imipTentativeRecurrencesButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#tentative-flat);
}
.imipAddButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#newevent-flat);
}
+ .imipRescheduleButton,
.imipUpdateButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#synchronize-flat);
}
.imipDetailsButton {
list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#find-flat);
}
--- a/calendar/locales/en-US/chrome/calendar/calendar-event-dialog.dtd
+++ b/calendar/locales/en-US/chrome/calendar/calendar-event-dialog.dtd
@@ -35,17 +35,17 @@
- they still fit in. -->
<!ENTITY event.attendees.notify.label "Notify attendees">
<!ENTITY event.attendees.notify.accesskey "f">
<!ENTITY event.attendees.notifyundisclosed.label "Separate invitation per attendee">
<!ENTITY event.attendees.notifyundisclosed.accesskey "x">
<!ENTITY event.attendees.notifyundisclosed.tooltip "This option sends one invitation email per attendee. Each invitation only contains the recipient attendee so that other attendee identities are not disclosed.">
<!ENTITY event.attendees.disallowcounter.label "Disallow counter">
<!ENTITY event.attendees.disallowcounter.accesskey "a">
-<!ENTITY event.attendees.disallowcounter.tooltip "Indicates that you will not accept counter proposals">
+<!ENTITY event.attendees.disallowcounter.tooltip "Indicates that you will not accept counterproposals">
<!-- Keyboard Shortcuts -->
<!ENTITY event.dialog.new.event.key2 "I">
<!ENTITY event.dialog.new.task.key2 "D">
<!ENTITY event.dialog.new.message.key2 "N">
<!ENTITY event.dialog.close.key "W">
<!ENTITY event.dialog.save.key "S">
<!ENTITY event.dialog.saveandclose.key "L">
@@ -160,22 +160,28 @@
<!ENTITY event.toolbar.attendees.tooltip "Invite Attendees">
<!ENTITY event.toolbar.attachments.tooltip "Add Attachments">
<!ENTITY event.toolbar.privacy.tooltip "Change Privacy">
<!ENTITY event.toolbar.priority.tooltip "Change Priority">
<!ENTITY event.toolbar.status.tooltip "Change Status">
<!ENTITY event.toolbar.freebusy.tooltip "Change Free/Busy time">
<!-- Counter box -->
+<!-- LOCALIZATON NOTE(counter.button.*)
+ - This is only visible in the UI if you have received a counterproposal before and are going to
+ - reschedule the event from the imipbar in the email view. Clicking on the buttons will only
+ - populate the form fields in the dialog, there's no other immediate action on clicking like with
+ - the imip bar. Rescheduling will happen after clicking on save&close as usual. This screenshot
+ - illustrates how it might look like: https://bugzilla.mozilla.org/attachment.cgi?id=8810121 -->
<!ENTITY counter.button.proposal.label "Apply proposal">
<!ENTITY counter.button.proposal.accesskey "p">
-<!ENTITY counter.button.proposal.tooltip "Apply the proposal data to the form.">
+<!ENTITY counter.button.proposal.tooltip2 "Event fields will be filled in using the values from the counterproposal, only saving with or without additional changes will notify all attendees accordingly">
<!ENTITY counter.button.original.label "Apply original data">
<!ENTITY counter.button.original.accesskey "r">
-<!ENTITY counter.button.original.tooltip "Apply the original data to the form.">
+<!ENTITY counter.button.original.tooltip2 "The fields will be set to the values from the original event, before the counterproposal was made">
<!-- Main page -->
<!ENTITY event.title.textbox.label "Title:" >
<!ENTITY event.title.textbox.accesskey "I">
<!ENTITY event.location.label "Location:" >
<!ENTITY event.location.accesskey "L">
<!ENTITY event.categories.label "Category:">
<!ENTITY event.categories.accesskey "y">
--- a/calendar/locales/en-US/chrome/lightning/lightning.properties
+++ b/calendar/locales/en-US/chrome/lightning/lightning.properties
@@ -113,21 +113,21 @@ imipHtml.attendeeUserType2.ROOM=%1$S (ro
# imipHtml.attendeeRole2.*
# %1$S - email address or common name <email address> representing an attendee of unknown type
imipHtml.attendeeUserType2.UNKNOWN=%1$S
imipAddedItemToCal2=The event has been added to your calendar.
imipCanceledItem2=The event has been deleted from your calendar.
imipUpdatedItem2=The event has been updated.
imipBarCancelText=This message contains an event cancellation.
-imipBarCounterErrorText=This message contains a counter proposal to an invitation that cannot be processed.
-imipBarCounterPreviousVersionText=This message contains a counter proposal to a previous version of an invitation.
-imipBarCounterText=This message contains a counter proposal to an invitation.
-imipBarDisallowedCounterText=This message contains a counter proposal although you disallowed countering for this event.
-imipBarDeclineCounterText=This message contains a reply to your counter proposal.
+imipBarCounterErrorText=This message contains a counterproposal to an invitation that cannot be processed.
+imipBarCounterPreviousVersionText=This message contains a counterproposal to a previous version of an invitation.
+imipBarCounterText=This message contains a counterproposal to an invitation.
+imipBarDisallowedCounterText=This message contains a counterproposal although you disallowed countering for this event.
+imipBarDeclineCounterText=This message contains a reply to your counterproposal.
imipBarRefreshText=This message asks for an event update.
imipBarPublishText=This message contains an event.
imipBarRequestText=This message contains an invitation to an event.
imipBarSentText=This message contains a sent event.
imipBarSentButRemovedText=This message contains a sent out event that is not in your calendar anymore.
imipBarUpdateText=This message contains an update to an existing event.
imipBarAlreadyProcessedText=This message contains an event that has already been processed.
imipBarProcessedNeedsAction=This message contains an event that you have not yet responded to.
@@ -150,20 +150,20 @@ itipReplyBodyAccept=%1$S has accepted yo
itipReplyBodyDecline=%1$S has declined your event invitation.
itipReplySubjectAccept=Event Invitation Reply (Accepted): %1$S
itipReplySubjectDecline=Event Invitation Reply (Declined): %1$S
itipReplySubjectTentative=Event Invitation Reply (Tentative): %1$S
itipRequestSubject=Event Invitation: %1$S
itipRequestUpdatedSubject=Updated Event Invitation: %1$S
itipRequestBody=%1$S has invited you to %2$S
itipCancelSubject=Event Canceled: %1$S
-itipCancelBody=%1$S has canceled this event: « %2$S »
-itipCounterBody=%1$S has made a counter proposal for « %2$S »:
-itipDeclineCounterBody=%1$S has declined your counter proposal for « %2$S ».
-itipDeclineCounterSubject=Counter Proposal Declined: %1$S
+itipCancelBody=%1$S has canceled this event: %2$S
+itipCounterBody=%1$S has made a counterproposal for "%2$S":
+itipDeclineCounterBody=%1$S has declined your counterproposal for "%2$S".
+itipDeclineCounterSubject=Counterproposal Declined: %1$S
confirmProcessInvitation=You have recently deleted this item, are you sure you want to process this invitation?
confirmProcessInvitationTitle=Process Invitation?
invitationsLink.label=Invitations: %1$S
# LOCALIZATION_NOTE(binaryComponentKnown): This is shown when Lightning is
# missing the binary component and knows how to calculate the expected version
new file mode 100644
--- /dev/null
+++ b/calendar/test/unit/test_calitiputils.js
@@ -0,0 +1,398 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+Components.utils.import("resource://calendar/modules/calUtils.jsm");
+Components.utils.import("resource://calendar/modules/calItipUtils.jsm");
+Components.utils.import("resource://testing-common/mailnews/mailTestUtils.js");
+
+// tests for calItipUtils.jsm
+
+function run_test() {
+ getMessageSender_test();
+ getSequence_test();
+ getStamp_test();
+ compareSequence_test();
+ compareStamp_test();
+ compare_test();
+}
+
+/*
+ * Helper function to get an ics for testing sequence and stamp comparison
+ *
+ * @param {String} aAttendee A serialized ATTENDEE property
+ * @param {String} aSequence A serialized SEQUENCE property
+ * @param {String} aDtStamp A serialized DTSTAMP property
+ * @param {String} aXMozReceivedSequence A serialized X-MOZ-RECEIVED-SEQUENCE property
+ * @param {String} aXMozReceivedDtStamp A serialized X-MOZ-RECEIVED-STAMP property
+ */
+function getSeqStampTestIcs(aProperties) {
+ // we make sure to have a dtstamp property to get a valid ics
+ let dtStamp = "20150909T181048Z";
+ let additionalProperties = "";
+ aProperties.forEach((aProp) => {
+ if (aProp.startsWith("DTSTAMP:")) {
+ dtStamp = aProp;
+ } else {
+ additionalProperties += "\r\n" + aProp;
+ }
+ });
+
+ return [
+ "BEGIN:VCALENDAR",
+ "PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN",
+ "VERSION:2.0",
+ "METHOD:REQUEST",
+ "BEGIN:VTIMEZONE",
+ "TZID:Europe/Berlin",
+ "BEGIN:DAYLIGHT",
+ "TZOFFSETFROM:+0100",
+ "TZOFFSETTO:+0200",
+ "TZNAME:CEST",
+ "DTSTART:19700329T020000",
+ "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU",
+ "END:DAYLIGHT",
+ "BEGIN:STANDARD",
+ "TZOFFSETFROM:+0200",
+ "TZOFFSETTO:+0100",
+ "TZNAME:CET",
+ "DTSTART:19701025T030000",
+ "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU",
+ "END:STANDARD",
+ "END:VTIMEZONE",
+ "BEGIN:VEVENT",
+ "CREATED:20150909T180909Z",
+ "LAST-MODIFIED:20150909T181048Z",
+ dtStamp,
+ "UID:cb189fdc-ed47-4db6-a8d7-31a08802249d",
+ "SUMMARY:Test Event",
+ "ORGANIZER;RSVP=TRUE;CN=Organizer;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:organizer@example.net",
+ "ATTENDEE;RSVP=TRUE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:mailto:attende" +
+ "e@example.net" + additionalProperties,
+ "DTSTART;TZID=Europe/Berlin:20150909T210000",
+ "DTEND;TZID=Europe/Berlin:20150909T220000",
+ "TRANSP:OPAQUE",
+ "LOCATION:Room 1",
+ "DESCRIPTION:Let us get together",
+ "URL:http://www.example.com",
+ "ATTACH:http://www.example.com",
+ "END:VEVENT",
+ "END:VCALENDAR"].join("\r\n");
+}
+
+function getSeqStampTestItems(aTest) {
+ let items = [];
+ for (let input of aTest.input) {
+ if (input.item) {
+ // in this case, we need to return an event
+ let attendee = "";
+ if ("attendee" in input.item && input.item.attendee != {}) {
+ let att = cal.createAttendee();
+ att.id = input.item.attendee.id || "mailto:otherattendee@example.net";
+ if ("receivedSeq" in input.item.attendee && input.item.attendee.receivedSeq.length) {
+ att.setProperty("RECEIVED-SEQUENCE", input.item.attendee.receivedSeq);
+ }
+ if ("receivedStamp" in input.item.attendee && input.item.attendee.receivedStamp.length) {
+ att.setProperty("RECEIVED-DTSTAMP", input.item.attendee.receivedStamp);
+ }
+ }
+ let sequence = "";
+ if ("sequence" in input.item && input.item.sequence.length) {
+ sequence = "SEQUENCE:" + input.item.sequence;
+ }
+ let dtStamp = "DTSTAMP:20150909T181048Z";
+ if ("dtStamp" in input.item && input.item.dtStamp) {
+ dtStamp = "DTSTAMP:" + input.item.dtStamp;
+ }
+ let xMozReceivedSeq = "";
+ if ("xMozReceivedSeq" in input.item && input.item.xMozReceivedSeq.length) {
+ xMozReceivedSeq = "X-MOZ-RECEIVED-SEQUENCE:" + input.item.xMozReceivedSeq;
+ }
+ let xMozReceivedStamp = "";
+ if ("xMozReceivedStamp" in input.item && input.item.xMozReceivedStamp.length) {
+ xMozReceivedStamp = "X-MOZ-RECEIVED-DTSTAMP:" + input.item.xMozReceivedStamp;
+ }
+ let xMsAptSeq = "";
+ if ("xMsAptSeq" in input.item && input.item.xMsAptSeq.length) {
+ xMsAptSeq = "X-MICROSOFT-CDO-APPT-SEQUENCE:" + input.item.xMsAptSeq;
+ }
+ let testItem = cal.createEvent();
+ testItem.icalString = getSeqStampTestIcs([attendee, sequence, dtStamp, xMozReceivedSeq,
+ xMozReceivedStamp, xMsAptSeq]);
+ items.push(testItem);
+ } else {
+ // in this case, we need to return an attendee
+ let att = cal.createAttendee();
+ att.id = input.attendee.id || "mailto:otherattendee@example.net";
+ if (input.attendee.receivedSeq && input.attendee.receivedSeq.length) {
+ att.setProperty("RECEIVED-SEQUENCE", input.attendee.receivedSeq);
+ }
+ if (input.attendee.receivedStamp && input.attendee.receivedStamp.length) {
+ att.setProperty("RECEIVED-DTSTAMP", input.attendee.receivedStamp);
+ }
+ items.push(att);
+ }
+ }
+ return items;
+}
+
+function getMessageSender_test() {
+ let data = [{
+ input: null,
+ expected: null
+ }, {
+ input: { },
+ expected: null
+ }, {
+ input: { author: "Sender 1 <sender1@example.net>" },
+ expected: "sender1@example.net"
+ }];
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ equal(cal.itip.getMessageSender(test.input), test.expected, "(test #" + i + ")");
+ }
+}
+
+function getSequence_test() {
+ // assigning an empty string results in not having the property in the ics here
+ let data = [{
+ input: [{ item: { sequence: "", xMozReceivedSeq: "" } }],
+ expected: 0
+ }, {
+ input: [{ item: { sequence: "0", xMozReceivedSeq: "" } }],
+ expected: 0
+ }, {
+ input: [{ item: { sequence: "", xMozReceivedSeq: "0" } }],
+ expected: 0
+ }, {
+ input: [{ item: { sequence: "1", xMozReceivedSeq: "" } }],
+ expected: 1
+ }, {
+ input: [{ item: { sequence: "", xMozReceivedSeq: "1" } }],
+ expected: 1
+ }, {
+ input: [{ attendee: { receivedSeq: "" } }],
+ expected: 0
+ }, {
+ input: [{ attendee: { receivedSeq: "0" } }],
+ expected: 0
+ }, {
+ input: [{ attendee: { receivedSeq: "1" } }],
+ expected: 1
+ }];
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ testItems = getSeqStampTestItems(test);
+ equal(cal.itip.getSequence(testItems[0], testItems[1]), test.expected, "(test #" + i + ")");
+ }
+}
+
+function getStamp_test() {
+ // assigning an empty string results in not having the property in the ics here. However, there
+ // must be always an dtStamp for item - if it's missing it will be set by the test code to make
+ // sure we get a valid ics
+ let data = [{
+ // !dtStamp && !xMozReceivedStamp => test default value
+ input: [{ item: { dtStamp: "", xMozReceivedStamp: "" } }],
+ expected: "20150909T181048Z"
+ }, {
+ // dtStamp && !xMozReceivedStamp => dtStamp
+ input: [{ item: { dtStamp: "20150910T181048Z", xMozReceivedStamp: "" } }],
+ expected: "20150910T181048Z"
+ }, {
+ // dtStamp && xMozReceivedStamp => xMozReceivedStamp
+ input: [{ item: { dtStamp: "20150909T181048Z", xMozReceivedStamp: "20150910T181048Z" } }],
+ expected: "20150910T181048Z"
+ }, {
+ input: [{ attendee: { receivedStamp: "" } }],
+ expected: null
+ }, {
+ input: [{ attendee: { receivedStamp: "20150910T181048Z" } }],
+ expected: "20150910T181048Z"
+ }];
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ let result = cal.itip.getStamp(getSeqStampTestItems(test)[0]);
+ if (result) {
+ result = result.icalString;
+ }
+ equal(result, test.expected, "(test #" + i + ")");
+ }
+}
+
+function compareSequence_test() {
+ // it is sufficient to test here with sequence for items - full test coverage for
+ // x-moz-received-sequence is already provided by compareSequence_test
+ let data = [{
+ // item1.seq == item2.seq
+ input: [{ item: { sequence: "2" } },
+ { item: { sequence: "2" } }],
+ expected: 0
+ }, {
+ // item1.seq > item2.seq
+ input: [{ item: { sequence: "3" } },
+ { item: { sequence: "2" } }],
+ expected: 1
+ }, {
+ // item1.seq < item2.seq
+ input: [{ item: { sequence: "2" } },
+ { item: { sequence: "3" } }],
+ expected: -1
+ }, {
+ // attendee1.seq == attendee2.seq
+ input: [{ attendee: { receivedSeq: "2" } },
+ { attendee: { receivedSeq: "2" } }],
+ expected: 0
+ }, {
+ // attendee1.seq > attendee2.seq
+ input: [{ attendee: { receivedSeq: "3" } },
+ { attendee: { receivedSeq: "2" } }],
+ expected: 1
+ }, {
+ // attendee1.seq < attendee2.seq
+ input: [{ attendee: { receivedSeq: "2" } },
+ { attendee: { receivedSeq: "3" } }],
+ expected: -1
+ }, {
+ // item.seq == attendee.seq
+ input: [{ item: { sequence: "2" } },
+ { attendee: { receivedSeq: "2" } }],
+ expected: 0
+ }, {
+ // item.seq > attendee.seq
+ input: [{ item: { sequence: "3" } },
+ { attendee: { receivedSeq: "2" } }],
+ expected: 1
+ }, {
+ // item.seq < attendee.seq
+ input: [{ item: { sequence: "2" } },
+ { attendee: { receivedSeq: "3" } }],
+ expected: -1
+ }];
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ testItems = getSeqStampTestItems(test);
+ equal(cal.itip.compareSequence(testItems[0], testItems[1]),
+ test.expected,
+ "(test #" + i + ")"
+ );
+ }
+}
+
+function compareStamp_test() {
+ // it is sufficient to test here with dtstamp for items - full test coverage for
+ // x-moz-received-stamp is already provided by compareStamp_test
+ let data = [{
+ // item1.stamp == item2.stamp
+ input: [{ item: { dtStamp: "20150910T181048Z" } },
+ { item: { dtStamp: "20150910T181048Z" } }],
+ expected: 0
+ }, {
+ // item1.stamp > item2.stamp
+ input: [{ item: { dtStamp: "20150911T181048Z" } },
+ { item: { dtStamp: "20150910T181048Z" } }],
+ expected: 1
+ }, {
+ // item1.stamp < item2.stamp
+ input: [{ item: { dtStamp: "20150910T181048Z" } },
+ { item: { dtStamp: "20150911T181048Z" } }],
+ expected: -1
+ }, {
+ // attendee1.stamp == attendee2.stamp
+ input: [{ attendee: { receivedStamp: "20150910T181048Z" } },
+ { attendee: { receivedStamp: "20150910T181048Z" } }],
+ expected: 0
+ }, {
+ // attendee1.stamp > attendee2.stamp
+ input: [{ attendee: { receivedStamp: "20150911T181048Z" } },
+ { attendee: { receivedStamp: "20150910T181048Z" } }],
+ expected: 1
+ }, {
+ // attendee1.stamp < attendee2.stamp
+ input: [{ attendee: { receivedStamp: "20150910T181048Z" } },
+ { attendee: { receivedStamp: "20150911T181048Z" } }],
+ expected: -1
+ }, {
+ // item.stamp == attendee.stamp
+ input: [{ item: { dtStamp: "20150910T181048Z" } },
+ { attendee: { receivedStamp: "20150910T181048Z" } }],
+ expected: 0
+ }, {
+ // item.stamp > attendee.stamp
+ input: [{ item: { dtStamp: "20150911T181048Z" } },
+ { attendee: { receivedStamp: "20150910T181048Z" } }],
+ expected: 1
+ }, {
+ // item.stamp < attendee.stamp
+ input: [{ item: { dtStamp: "20150910T181048Z" } },
+ { attendee: { receivedStamp: "20150911T181048Z" } }],
+ expected: -1
+ }];
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ testItems = getSeqStampTestItems(test);
+ equal(cal.itip.compareStamp(testItems[0], testItems[1]),
+ test.expected,
+ "(test #" + i + ")"
+ );
+ }
+}
+
+function compare_test() {
+ // it is sufficient to test here with items only - full test coverage for attendees or
+ // item/attendee is already provided by compareSequence_test and compareStamp_test
+ let data = [{
+ // item1.seq == item2.seq && item1.stamp == item2.stamp
+ input: [{ item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+ { item: { sequence: "2", dtStamp: "20150910T181048Z" } }],
+ expected: 0
+ }, {
+ // item1.seq == item2.seq && item1.stamp > item2.stamp
+ input: [{ item: { sequence: "2", dtStamp: "20150911T181048Z" } },
+ { item: { sequence: "2", dtStamp: "20150910T181048Z" } }],
+ expected: 1
+ }, {
+ // item1.seq == item2.seq && item1.stamp < item2.stamp
+ input: [{ item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+ { item: { sequence: "2", dtStamp: "20150911T181048Z" } }],
+ expected: -1
+ }, {
+ // item1.seq > item2.seq && item1.stamp == item2.stamp
+ input: [{ item: { sequence: "3", dtStamp: "20150910T181048Z" } },
+ { item: { sequence: "2", dtStamp: "20150910T181048Z" } }],
+ expected: 1
+ }, {
+ // item1.seq > item2.seq && item1.stamp > item2.stamp
+ input: [{ item: { sequence: "3", dtStamp: "20150911T181048Z" } },
+ { item: { sequence: "2", dtStamp: "20150910T181048Z" } }],
+ expected: 1
+ }, {
+ // item1.seq > item2.seq && item1.stamp < item2.stamp
+ input: [{ item: { sequence: "3", dtStamp: "20150910T181048Z" } },
+ { item: { sequence: "2", dtStamp: "20150911T181048Z" } }],
+ expected: 1
+ }, {
+ // item1.seq < item2.seq && item1.stamp == item2.stamp
+ input: [{ item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+ { item: { sequence: "3", dtStamp: "20150910T181048Z" } }],
+ expected: -1
+ }, {
+ // item1.seq < item2.seq && item1.stamp > item2.stamp
+ input: [{ item: { sequence: "2", dtStamp: "20150911T181048Z" } },
+ { item: { sequence: "3", dtStamp: "20150910T181048Z" } }],
+ expected: -1
+ }, {
+ // item1.seq < item2.seq && item1.stamp < item2.stamp
+ input: [{ item: { sequence: "2", dtStamp: "20150910T181048Z" } },
+ { item: { sequence: "3", dtStamp: "20150911T181048Z" } }],
+ expected: -1
+ }];
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ testItems = getSeqStampTestItems(test);
+ equal(cal.itip.compare(testItems[0], testItems[1]),
+ test.expected,
+ "(test #" + i + ")"
+ );
+ }
+}
--- a/calendar/test/unit/test_calutils.js
+++ b/calendar/test/unit/test_calutils.js
@@ -1,25 +1,26 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
Components.utils.import("resource://calendar/modules/calUtils.jsm");
+// tests for calUtils.jsm
+
function run_test() {
getAttendeeEmail_test();
+ getAttendeesBySender_test();
getRecipientList_test();
prependMailTo_test();
removeMailTo_test();
resolveDelegation_test();
validateRecipientList_test();
}
-// tests for calUtils.jsm
-
function getAttendeeEmail_test() {
let data = [{
input: { id: "mailto:first.last@example.net", cname: "Last, First", email: null, useCn: true },
expected: "\"Last, First\" <first.last@example.net>"
}, {
input: { id: "mailto:first.last@example.net", cname: "Last; First", email: null, useCn: true },
expected: "\"Last; First\" <first.last@example.net>"
}, {
@@ -54,16 +55,84 @@ function getAttendeeEmail_test() {
}
if (test.input.email) {
attendee.setProperty("EMAIL", test.input.email);
}
equal(cal.getAttendeeEmail(attendee, test.input.useCn), test.expected, "(test #" + i + ")");
}
}
+function getAttendeesBySender_test() {
+ let data = [{
+ input: {
+ attendees: [{ id: "mailto:user1@example.net", sentBy: null },
+ { id: "mailto:user2@example.net", sentBy: null }],
+ sender: "user1@example.net"
+ },
+ expected: ["mailto:user1@example.net"]
+ }, {
+ input: {
+ attendees: [{ id: "mailto:user1@example.net", sentBy: null },
+ { id: "mailto:user2@example.net", sentBy: null }],
+ sender: "user3@example.net"
+ },
+ expected: []
+ }, {
+ input: {
+ attendees: [{ id: "mailto:user1@example.net", sentBy: "mailto:user3@example.net" },
+ { id: "mailto:user2@example.net", sentBy: null }],
+ sender: "user3@example.net"
+ },
+ expected: ["mailto:user1@example.net"]
+ }, {
+ input: {
+ attendees: [{ id: "mailto:user1@example.net", sentBy: null },
+ { id: "mailto:user2@example.net", sentBy: "mailto:user1@example.net" }],
+ sender: "user1@example.net"
+ },
+ expected: ["mailto:user1@example.net", "mailto:user2@example.net"]
+ }, {
+ input: { attendees: [], sender: "user1@example.net" },
+ expected: []
+ }, {
+ input: {
+ attendees: [{ id: "mailto:user1@example.net", sentBy: null },
+ { id: "mailto:user2@example.net", sentBy: null }],
+ sender: ""
+ },
+ expected: []
+ }, {
+ input: {
+ attendees: [{ id: "mailto:user1@example.net", sentBy: null },
+ { id: "mailto:user2@example.net", sentBy: null }],
+ sender: null
+ },
+ expected: []
+ }];
+
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ let attendees = [];
+ for (let att of test.input.attendees) {
+ let attendee = cal.createAttendee();
+ attendee.id = att.id;
+ if (att.sentBy) {
+ attendee.setProperty("SENT-BY", att.sentBy);
+ }
+ attendees.push(attendee);
+ }
+ let detected = [];
+ cal.getAttendeesBySender(attendees, test.input.sender).forEach(att => {
+ detected.push(att.id);
+ });
+ ok(detected.every(aId => test.expected.includes(aId)), "(test #" + i + " ok1)");
+ ok(test.expected.every(aId => detected.includes(aId)), "(test #" + i + " ok2)");
+ }
+}
+
function getRecipientList_test() {
let data = [{
input: [{ id: "mailto:first@example.net", cname: null },
{ id: "mailto:second@example.net", cname: null },
{ id: "mailto:third@example.net", cname: null }],
expected: "first@example.net, second@example.net, third@example.net"
}, {
input: [{ id: "mailto:first@example.net", cname: "first example" },
--- a/calendar/test/unit/test_ltninvitationutils.js
+++ b/calendar/test/unit/test_ltninvitationutils.js
@@ -8,19 +8,19 @@ Components.utils.import("resource:///mod
Components.utils.import("resource://gre/modules/Preferences.jsm");
function run_test() {
do_calendar_startup(run_next_test);
}
// tests for ltnInvitationUtils.jsm
-function getIcs() {
+function getIcs(aAsArray=false) {
// we use an unfolded ics blueprint here to make replacing of properties easier
- return [
+ let item = [
"BEGIN:VCALENDAR",
"PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN",
"VERSION:2.0",
"METHOD:REQUEST",
"BEGIN:VTIMEZONE",
"TZID:Europe/Berlin",
"BEGIN:DAYLIGHT",
"TZOFFSETFROM:+0100",
@@ -43,96 +43,123 @@ function getIcs() {
"DTSTAMP:20150909T181048Z",
"UID:cb189fdc-ed47-4db6-a8d7-31a08802249d",
"SUMMARY:Test Event",
"ORGANIZER;RSVP=TRUE;CN=Organizer;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:organizer@example.net",
"ATTENDEE;RSVP=TRUE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:mailto:attende" +
"e@example.net",
"DTSTART;TZID=Europe/Berlin:20150909T210000",
"DTEND;TZID=Europe/Berlin:20150909T220000",
+ "SEQUENCE:1",
"TRANSP:OPAQUE",
"LOCATION:Room 1",
"DESCRIPTION:Let us get together",
"URL:http://www.example.com",
"ATTACH:http://www.example.com",
"END:VEVENT",
- "END:VCALENDAR"].join("\r\n");
+ "END:VCALENDAR"];
+ if (!aAsArray) {
+ item = item.join("\r\n");
+ }
+ return item;
}
add_task(function* getItipHeader_test() {
let data = [{
input: {
method: "METHOD:REQUEST\r\n",
- attendee: null
+ attendees: [null]
},
expected: "Organizer has invited you to Test Event"
}, {
input: {
method: "METHOD:CANCEL\r\n",
- attendee: null
+ attendees: [null]
+ },
+ expected: "Organizer has canceled this event: Test Event"
+ }, {
+ input: {
+ method: "METHOD:DECLINECOUNTER\r\n",
+ attendees: ["ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=ACCEPTED;" +
+ "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net"]
},
- expected: "Organizer has canceled this event: « Test Event »"
+ expected: "Organizer has declined your counterproposal for \"Test Event\"."
+ }, {
+ input: {
+ method: "METHOD:COUNTER\r\n",
+ attendees: ["ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=DECLINED;" +
+ "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net"]
+ },
+ expected: "Attendee1 <attendee1@example.net> has made a counterproposal for \"Test Event\":"
}, {
input: {
method: "METHOD:REPLY\r\n",
- attendee: "ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=ACCEPTED;" +
- "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net"
+ attendees: ["ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=ACCEPTED;" +
+ "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net"]
},
expected: "Attendee1 <attendee1@example.net> has accepted your event invitation."
}, {
input: {
method: "METHOD:REPLY\r\n",
- attendee: "ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=TENTATIVE;" +
- "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net"
+ attendees: ["ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=TENTATIVE;" +
+ "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net"]
},
expected: "Attendee1 <attendee1@example.net> has accepted your event invitation."
}, {
input: {
method: "METHOD:REPLY\r\n",
- attendee: "ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=DECLINED;" +
- "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net"
+ attendees: ["ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=DECLINED;" +
+ "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net"]
},
expected: "Attendee1 <attendee1@example.net> has declined your event invitation."
}, {
input: {
method: "METHOD:REPLY\r\n",
- attendee: ["ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=ACCEPTED;" +
- "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net",
- "ATTENDEE;RSVP=TRUE;CN=Attendee2;PARTSTAT=DECLINED;" +
- "ROLE=REQ-PARTICIPANT:mailto:attendee2@example.net"].join("\r\n")
+ attendees: ["ATTENDEE;RSVP=TRUE;CN=Attendee1;PARTSTAT=ACCEPTED;" +
+ "ROLE=REQ-PARTICIPANT:mailto:attendee1@example.net",
+ "ATTENDEE;RSVP=TRUE;CN=Attendee2;PARTSTAT=DECLINED;" +
+ "ROLE=REQ-PARTICIPANT:mailto:attendee2@example.net"]
},
expected: "Attendee1 <attendee1@example.net> has accepted your event invitation."
}, {
input: {
method: "METHOD:UNSUPPORTED\r\n",
- attendee: null
+ attendees: [null]
},
expected: "Event Invitation"
}, {
input: {
method: "",
- attendee: ""
+ attendees: [""]
},
expected: "Event Invitation"
}];
let i = 0;
for (let test of data) {
i++;
let itipItem = Components.classes["@mozilla.org/calendar/itip-item;1"]
.createInstance(Components.interfaces.calIItipItem);
let item = getIcs();
+ let sender;
if (test.input.method || test.input.method == "") {
item = item.replace(/METHOD:REQUEST\r\n/, test.input.method);
}
- if (test.input.attendee || test.input.attendee == "") {
- item = item.replace(/(ATTENDEE.+(?:\r\n))/, test.input.attendee + "\r\n");
+ if (test.input.attendees.length) {
+ let attendees = test.input.attendees.filter(aAtt => !!aAtt).join("\r\n");
+ item = item.replace(/(ATTENDEE.+(?:\r\n))/, attendees + "\r\n");
+ if (test.input.attendees[0]) {
+ sender = cal.createAttendee();
+ sender.icalString = test.input.attendees[0];
+ }
}
itipItem.init(item);
- equal(ltn.invitation.getItipHeader(itipItem), test.expected,
- "(test #" + i + ")");
+ if (sender) {
+ itipItem.sender = sender.id;
+ }
+ equal(ltn.invitation.getItipHeader(itipItem), test.expected, "(test #" + i + ")");
}
});
add_task(function* createInvitationOverlay_test() {
let data = [{
input: { description: "DESCRIPTION:Go to https://www.example.net if you can.\r\n" },
expected: {
node: "imipHtml-description-content",
@@ -767,8 +794,340 @@ add_task(function* getRfc5322FormattedDa
Preferences.reset("calendar.timezone.local");
}
let date = test.date ? new Date(test.date) : null;
let re = new RegExp(data.expected);
ok(re.test(ltn.invitation.getRfc5322FormattedDate(date)), "(test #" + i + ")");
}
Preferences.set("calendar.timezone.local", timezone);
});
+
+add_task(function* parseCounter_test() {
+ let data = [{
+ // #1: basic test to check all currently supported properties
+ input: {
+ existing: [],
+ proposed: [
+ {
+ method: "METHOD:COUNTER"
+ }, {
+ dtStart: "DTSTART;TZID=Europe/Berlin:20150910T210000"
+ }, {
+ dtEnd: "DTEND;TZID=Europe/Berlin:20150910T220000"
+ }, {
+ location: "LOCATION:Room 2"
+ }, {
+ summary: "SUMMARY:Test Event 2"
+ }, {
+ attendee: "ATTENDEE;CN=Attendee;PARTSTAT=DECLINED;ROLE=REQ-PARTICIPANT:" +
+ "mailto:attendee@example.net"
+ }, {
+ dtStamp: "DTSTAMP:20150909T182048Z"
+ }, {
+ attach: "COMMENT:Sorry\, I cannot make it that time."
+ }
+ ]
+ },
+ expected: {
+ result: { descr: "", type: "OK" },
+ differences: [{
+ property: "SUMMARY",
+ proposed: "Test Event 2",
+ original: "Test Event"
+ }, {
+ property: "LOCATION",
+ proposed: "Room 2",
+ original: "Room 1"
+ }, {
+ property: "DTSTART",
+ proposed: ["Thursday, September 10, 2015 9:00 PM Europe/Berlin", // Automation Win
+ "Thu 10 Sep 2015 9:00 PM Europe/Berlin", // Automation OSX
+ "Thu 10 Sep 2015 09:00 PM Europe/Berlin", // Automation Linux
+ "Thu 10 Sep 2015 21:00 Europe/Berlin"], // Local Win 24h
+ original: ["Wednesday, September 09, 2015 9:00 PM Europe/Berlin",
+ "Wed 9 Sep 2015 9:00 PM Europe/Berlin",
+ "Wed 9 Sep 2015 09:00 PM Europe/Berlin",
+ "Wed 9 Sep 2015 21:00 Europe/Berlin"]
+ }, {
+ property: "DTEND",
+ proposed: ["Thursday, September 10, 2015 10:00 PM Europe/Berlin",
+ "Thu 10 Sep 2015 10:00 PM Europe/Berlin",
+ "Thu 10 Sep 2015 22:00 Europe/Berlin"],
+ original: ["Wednesday, September 09, 2015 10:00 PM Europe/Berlin",
+ "Wed 9 Sep 2015 10:00 PM Europe/Berlin",
+ "Wed 9 Sep 2015 22:00 Europe/Berlin"]
+ }, {
+ property: "COMMENT",
+ proposed: "Sorry\, I cannot make it that time.",
+ original: null
+ }]
+ }
+ }, {
+ // #2: test with an unsupported property has been changed
+ input: {
+ existing: [],
+ proposed: [
+ {
+ method: "METHOD:COUNTER"
+ }, {
+ attendee: "ATTENDEE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:" +
+ "mailto:attendee@example.net"
+ }, {
+ location: "LOCATION:Room 2"
+ }, {
+ attach: "ATTACH:http://www.example2.com"
+ } ,{
+ dtStamp: "DTSTAMP:20150909T182048Z"
+ }]
+ },
+ expected: {
+ result: { descr: "", type: "OK" },
+ differences: [{ property: "LOCATION", proposed: "Room 2", original: "Room 1" }]
+ }
+ }, {
+ // #3: proposed change not based on the latest update of the invitation
+ input: {
+ existing: [],
+ proposed: [
+ {
+ method: "METHOD:COUNTER"
+ }, {
+ attendee: "ATTENDEE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:" +
+ "mailto:attendee@example.net"
+ }, {
+ location: "LOCATION:Room 2"
+ }, {
+ dtStamp: "DTSTAMP:20150909T171048Z"
+ }
+ ]
+ },
+ expected: {
+ result: {
+ descr: "This is a counterproposal not based on the latest event update.",
+ type: "NOTLATESTUPDATE"
+ },
+ differences: [{ property: "LOCATION", proposed: "Room 2", original: "Room 1" }]
+ }
+ }, {
+ // #4: proposed change based on a meanwhile reschuled invitation
+ input: {
+ existing: [],
+ proposed: [
+ {
+ method: "METHOD:COUNTER"
+ }, {
+ attendee: "ATTENDEE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:" +
+ "mailto:attendee@example.net"
+ }, {
+ location: "LOCATION:Room 2"
+ }, {
+ sequence: "SEQUENCE:0"
+ }, {
+ dtStamp: "DTSTAMP:20150909T182048Z"
+ }
+ ]
+ },
+ expected: {
+ result: {
+ descr: "This is a counterproposal to an already rescheduled event.",
+ type: "OUTDATED"
+ },
+ differences: [{ property: "LOCATION", proposed: "Room 2", original: "Room 1" }]
+ }
+ }, {
+ // #5: proposed change for an later sequence of the event
+ input: {
+ existing: [],
+ proposed: [
+ {
+ method: "METHOD:COUNTER"
+ }, {
+ attendee: "ATTENDEE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:" +
+ "mailto:attendee@example.net"
+ }, {
+ location: "LOCATION:Room 2"
+ }, {
+ sequence: "SEQUENCE:2"
+ }, {
+ dtStamp: "DTSTAMP:20150909T182048Z"
+ }
+ ]
+ },
+ expected: {
+ result: {
+ descr: "Invalid sequence number in counterproposal.",
+ type: "ERROR"
+ },
+ differences: []
+ }
+ }, {
+ // #6: proposal to a different event
+ input: {
+ existing: [],
+ proposed: [
+ {
+ method: "METHOD:COUNTER"
+ }, {
+ uid: "UID:cb189fdc-0000-0000-0000-31a08802249d"
+ }, {
+ attendee: "ATTENDEE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:" +
+ "mailto:attendee@example.net"
+ }, {
+ location: "LOCATION:Room 2"
+ }, {
+ dtStamp: "DTSTAMP:20150909T182048Z"
+ }
+ ]
+ },
+ expected: {
+ result: {
+ descr: "Mismatch of uid or organizer in counterproposal.",
+ type: "ERROR"
+ },
+ differences: []
+ }
+ }, {
+ // #7: proposal with a different organizer
+ input: {
+ existing: [],
+ proposed: [
+ {
+ method: "METHOD:COUNTER"
+ }, {
+ organizer: "ORGANIZER;RSVP=TRUE;CN=Organizer;PARTSTAT=ACCEPTED;ROLE=CHAI" +
+ "R:mailto:organizer2@example.net"
+ }, {
+ attendee: "ATTENDEE;CN=Attendee;PARTSTAT=NEEDS-ACTION;ROLE=REQ-PARTICIPANT:" +
+ "mailto:attendee@example.net"
+ }, {
+ dtStamp: "DTSTAMP:20150909T182048Z"
+ }
+ ]
+ },
+ expected: {
+ result: {
+ descr: "Mismatch of uid or organizer in counterproposal.",
+ type: "ERROR"
+ },
+ differences: []
+ }
+ }, {
+ // #8:counterproposal without any difference
+ input: {
+ existing: [],
+ proposed: [{ method: "METHOD:COUNTER" }] },
+ expected: {
+ result: {
+ descr: "No difference in counterproposal detected.",
+ type: "NODIFF"
+ },
+ differences: []
+ }
+ }];
+
+ let getItem = function(aProperties) {
+ let item = getIcs(true);
+
+ let modifyProperty = function(aRegex, aReplacement, aInVevent) {
+ let inVevent = false;
+ let i = 0;
+ item.forEach(aProp => {
+ if (aProp == "BEGIN:VEVENT" && !inVevent) {
+ inVevent = true;
+ } else if (aProp == "END:VEVENT" && inVevent) {
+ inVevent = false;
+ }
+ if ((aInVevent && inVevent) || !aInVevent) {
+ item[i] = aProp.replace(aRegex, aReplacement);
+ }
+ i++;
+ });
+ };
+
+ if (aProperties) {
+ aProperties.forEach(aProp => {
+ if ("method" in aProp && aProp.method) {
+ modifyProperty(/(METHOD.+)/, aProp.method, false);
+ } else if ("attendee" in aProp && aProp.attendee) {
+ modifyProperty(/(ATTENDEE.+)/, aProp.attendee, true);
+ } else if ("attach" in aProp && aProp.attach) {
+ modifyProperty(/(ATTACH.+)/, aProp.attach, true);
+ } else if ("summary" in aProp && aProp.summary) {
+ modifyProperty(/(SUMMARY.+)/, aProp.summary, true);
+ } else if ("location" in aProp && aProp.location) {
+ modifyProperty(/(LOCATION.+)/, aProp.location, true);
+ } else if ("dtStart" in aProp && aProp.dtStart) {
+ modifyProperty(/(DTSTART.+)/, aProp.dtStart, true);
+ } else if ("dtEnd" in aProp && aProp.dtEnd) {
+ modifyProperty(/(DTEND.+)/, aProp.dtEnd, true);
+ } else if ("sequence" in aProp && aProp.sequence) {
+ modifyProperty(/(SEQUENCE.+)/, aProp.sequence, true);
+ } else if ("dtStamp" in aProp && aProp.dtStamp) {
+ modifyProperty(/(DTSTAMP.+)/, aProp.dtStamp, true);
+ } else if ("organizer" in aProp && aProp.organizer) {
+ modifyProperty(/(ORGANIZER.+)/, aProp.organizer, true);
+ } else if ("uid" in aProp && aProp.uid) {
+ modifyProperty(/(UID.+)/, aProp.uid, true);
+ }
+ });
+ }
+ item = item.join("\r\n");
+ return createEventFromIcalString(item);
+ };
+
+ let formatDt = function (aDateTime) {
+ let datetime = getDateFormatter().formatDateTime(aDateTime);
+ return datetime += " " + aDateTime.timezone.displayName;
+ };
+
+ for (let i = 1; i <= data.length; i++) {
+ let test = data[i - 1];
+ let existingItem = getItem(test.input.existing);
+ let proposedItem = getItem(test.input.proposed);
+ let parsed = ltn.invitation.parseCounter(proposedItem, existingItem);
+
+ equal(parsed.result.type, test.expected.result.type, "(test #" + i + ": result.type)");
+ equal(parsed.result.descr, test.expected.result.descr, "(test #" + i + ": result.descr)");
+ let parsedProps = [];
+ let additionalProps = [];
+ let missingProps = [];
+ parsed.differences.forEach(aDiff => {
+ let expected = test.expected.differences.filter(bDiff => bDiff.property == aDiff.property);
+ if (expected.length == 1) {
+ if (["DTSTART", "DTEND"].includes(aDiff.property)) {
+ let prop = aDiff.proposed ? formatDt(aDiff.proposed) : null;
+ ok(
+ prop && expected[0].proposed.includes(prop),
+ "(test #" + i + ": difference " + aDiff.property + ": proposed '" + prop + "')"
+ );
+ prop = aDiff.original ? formatDt(aDiff.original) : null
+ ok(
+ prop && expected[0].original.includes(prop),
+ "(test #" + i + ": difference " + aDiff.property + ": original '" + prop + "')"
+ );
+ } else {
+ equal(
+ aDiff.proposed,
+ expected[0].proposed,
+ "(test #" + i + ": difference " + aDiff.property + ": proposed)"
+ );
+ equal(
+ aDiff.original,
+ expected[0].original,
+ "(test #" + i + ": difference " + aDiff.property + ": original)"
+ );
+ }
+ parsedProps.push(aDiff.property);
+ } else if (expected.length == 0) {
+ additionalProps.push(aDiff.property);
+ }
+ });
+ test.expected.differences.forEach(aDiff => {
+ if (!parsedProps.includes(aDiff.property)) {
+ missingProps.push(aDiff.property);
+ }
+ });
+ ok(additionalProps.length == 0, "(test #" + i + ": differences: check for unexpectedly "+
+ "occurring additional properties " + additionalProps + ")");
+ ok(missingProps.length == 0, "(test #" + i + ": differences: check for unexpectedly " +
+ "missing properties " + missingProps + ")");
+ }
+});
--- a/calendar/test/unit/xpcshell-shared.ini
+++ b/calendar/test/unit/xpcshell-shared.ini
@@ -14,16 +14,17 @@
[test_bug486186.js]
[test_bug494140.js]
[test_bug523860.js]
[test_bug653924.js]
[test_bug668222.js]
[test_bug759324.js]
[test_calmgr.js]
[test_calutils.js]
+[test_calitiputils.js]
[test_datetime.js]
[test_datetime_before_1970.js]
[test_deleted_items.js]
[test_duration.js]
[test_extract.js]
[test_freebusy.js]
[test_freebusy_service.js]
[test_gdata_provider.js]