View Javadoc
1   /*
2    * DavMail POP/IMAP/SMTP/CalDav/LDAP Exchange Gateway
3    * Copyright (C) 2010  Mickael Guessant
4    *
5    * This program is free software; you can redistribute it and/or
6    * modify it under the terms of the GNU General Public License
7    * as published by the Free Software Foundation; either version 2
8    * of the License, or (at your option) any later version.
9    *
10   * This program is distributed in the hope that it will be useful,
11   * but WITHOUT ANY WARRANTY; without even the implied warranty of
12   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13   * GNU General Public License for more details.
14   *
15   * You should have received a copy of the GNU General Public License
16   * along with this program; if not, write to the Free Software
17   * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
18   */
19  
20  package davmail.exchange.graph;
21  
22  import davmail.exception.DavMailException;
23  import davmail.exchange.VCalendar;
24  import davmail.exchange.VObject;
25  import davmail.exchange.VProperty;
26  import davmail.util.DateUtil;
27  import davmail.util.StringUtil;
28  import org.apache.log4j.Logger;
29  import org.codehaus.jettison.json.JSONArray;
30  import org.codehaus.jettison.json.JSONException;
31  import org.codehaus.jettison.json.JSONObject;
32  
33  import java.text.ParseException;
34  import java.text.SimpleDateFormat;
35  import java.util.Date;
36  import java.util.HashMap;
37  import java.util.HashSet;
38  import java.util.Map;
39  import java.util.ResourceBundle;
40  import java.util.TimeZone;
41  
42  import static davmail.util.DateUtil.CALDAV_DATE_TIME;
43  import static davmail.util.DateUtil.GRAPH_DATE_TIME;
44  
45  /**
46   * Wrapper for Graph API JsonObject
47   */
48  public class GraphObject {
49      protected static final Logger LOGGER = Logger.getLogger(GraphObject.class);
50  
51      protected final JSONObject jsonObject;
52      protected int statusCode;
53  
54      public GraphObject() {
55          this.jsonObject = new JSONObject();
56      }
57  
58      public GraphObject(JSONObject jsonObject) {
59          this.jsonObject = jsonObject;
60      }
61  
62      public String optString(String key) {
63          // use field mapping to get value
64          return optString(GraphField.get(key));
65      }
66  
67      public String optString(GraphField field) {
68          String key = field.getGraphId();
69          if (key == null) {
70              return null;
71          }
72          String value = null;
73          // special case for keywords/categories
74          if ("keywords".equals(key) || "categories".equals(key)) {
75              JSONArray categoriesArray = jsonObject.optJSONArray("categories");
76              HashSet<String> keywords = new HashSet<>();
77              // Collects keywords from the categories array, joins into comma‑separated string
78              if (categoriesArray != null) {
79                  for (int j = 0; j < categoriesArray.length(); j++) {
80                      keywords.add(categoriesArray.optString(j));
81                  }
82                  value = StringUtil.join(keywords, ",");
83              }
84          } else if ("from".equals(key)) {
85              value = formatEmailAddress(jsonObject.optJSONObject(key));
86          } else if ("@odata.etag".equals(key)) {
87              // tasks don't have an etag field, use @odata.etag
88              String odataEtag = jsonObject.optString("@odata.etag");
89              if (odataEtag != null && odataEtag.startsWith("W/\"") && odataEtag.endsWith("\"")) {
90                  value = odataEtag.substring(3, odataEtag.length() - 1);
91              }
92          } else if (!field.isExtended()) {
93              // grab value by key
94              value = jsonObject.optString(key, null);
95          } else {
96              JSONArray singleValueExtendedProperties = jsonObject.optJSONArray("singleValueExtendedProperties");
97              if (singleValueExtendedProperties != null) {
98                  // Iterates extended properties to find a matching value
99                  for (int i = 0; i < singleValueExtendedProperties.length(); i++) {
100                     JSONObject singleValueObject = singleValueExtendedProperties.optJSONObject(i);
101                     if (singleValueObject != null && key.equals(singleValueObject.optString("id"))) {
102                         value = singleValueObject.optString("value");
103                     }
104                 }
105             }
106         }
107         if (field.isDate()) {
108             try {
109                 value = GraphExchangeSession.convertDateFromExchange(value);
110             } catch (DavMailException e) {
111                 LOGGER.warn("Invalid date " + value + " on field " + key);
112             }
113         }
114         return value;
115     }
116 
117     protected String formatEmailAddress(JSONObject jsonObject) {
118         String value = null;
119         if (jsonObject != null) {
120             JSONObject jsonEmailAddress = jsonObject.optJSONObject("emailAddress");
121             if (jsonEmailAddress != null) {
122                 value = jsonEmailAddress.optString("name", "") + " <" + jsonEmailAddress.optString("address", "") + ">";
123             }
124         }
125         return value;
126     }
127 
128     public JSONArray optJSONArray(String key) {
129         return jsonObject.optJSONArray(key);
130     }
131 
132     /**
133      * Set value for alias.
134      * First map property alias to a field to determine graph id, then set graph property or singleValueExtendedProperty
135      * @param alias field alias
136      * @param value property value
137      * @throws JSONException on error
138      */
139     public GraphObject put(String alias, String value) throws JSONException {
140         GraphField field = GraphField.get(alias);
141         String key = field.getGraphId();
142         // handle MAPI extended fields
143         if (field.isExtended()) {
144             // force number attributes value
145             if (field.isNumber() && value == null) {
146                 value = "0";
147             }
148             if (field.isBoolean() && value == null) {
149                 value = "false";
150             }
151             getSingleValueExtendedProperties().put(new JSONObject().put("id", key).put("value", value == null ? JSONObject.NULL : value));
152         } else {
153             jsonObject.put(key, value == null ? JSONObject.NULL : value);
154         }
155         return this;
156     }
157 
158     /**
159      * Set the boolean value on the field defined by alias.
160      * First map property alias to a field to determine graph id, then set graph property or singleValueExtendedProperty
161      * @param alias field alias
162      * @param value property value
163      * @throws JSONException on error
164      */
165     public GraphObject put(String alias, boolean value) throws JSONException {
166         GraphField field = GraphField.get(alias);
167         String key = field.getGraphId();
168         // extended field values go under singleValueExtendedProperties
169         if (field.isExtended()) {
170             getSingleValueExtendedProperties().put(new JSONObject().put("id", key).put("value", value));
171         } else {
172             jsonObject.put(key, value);
173         }
174         return this;
175     }
176 
177      public GraphObject put(String key, JSONArray value) throws JSONException {
178         jsonObject.put(key, value);
179         return this;
180     }
181 
182     public GraphObject put(String key, JSONObject value) throws JSONException {
183         jsonObject.put(key, value);
184         return this;
185     }
186 
187     /**
188      * Set categories from comma separated values.
189      * @param values comma separated values
190      * @throws JSONException on error
191      */
192     public void setCategories(String values) throws JSONException {
193         if (values != null) {
194             setCategories(values.split(","));
195         } else {
196             jsonObject.put("categories", new JSONArray());
197         }
198     }
199 
200     public void setCategories(String[] values) throws JSONException {
201         // assume all expanded properties have a space
202         JSONArray jsonValues = new JSONArray();
203         for (String singleValue : values) {
204             jsonValues.put(singleValue);
205         }
206         jsonObject.put("categories", jsonValues);
207     }
208 
209 
210     /**
211      * Get a singleValueExtendedProperties JSON array.
212      * Create an empty JSON array on the first call.
213      * @return singleValueExtendedProperties array
214      * @throws JSONException on error
215      */
216     protected JSONArray getSingleValueExtendedProperties() throws JSONException {
217         JSONArray singleValueExtendedProperties = jsonObject.optJSONArray("singleValueExtendedProperties");
218         if (singleValueExtendedProperties == null) {
219             singleValueExtendedProperties = new JSONArray();
220             jsonObject.put("singleValueExtendedProperties", singleValueExtendedProperties);
221         }
222         return singleValueExtendedProperties;
223     }
224 
225 
226     /**
227      * Get mandatory property value.
228      * @param key property name
229      * @return value
230      * @throws JSONException on missing property
231      */
232     public String getString(String key) throws JSONException {
233         String value = optString(key);
234         if (value == null) {
235             throw new JSONException("JSONObject[" + key + "] not found.");
236         }
237         return value;
238     }
239 
240     /**
241      * Get optional parameter from JSON property.
242      * @param section JSON property name
243      * @param key internal property name
244      * @return value or null
245      */
246     public String optString(String section, String key) {
247         JSONObject sectionObject = jsonObject.optJSONObject(section);
248         if (sectionObject != null) {
249             return sectionObject.optString(key, null);
250         }
251         return null;
252     }
253 
254     public boolean getBoolean(String key) throws JSONException {
255         return getBoolean(GraphField.get(key));
256     }
257 
258     public boolean optBoolean(String key) {
259         return optBoolean(GraphField.get(key));
260     }
261 
262     public boolean getBoolean(GraphField field) throws JSONException {
263         String key = field.getGraphId();
264         if (key == null) {
265             return false;
266         }
267         return jsonObject.getBoolean(key);
268     }
269 
270     public boolean optBoolean(GraphField field) {
271         String key = field.getGraphId();
272         if (key == null) {
273             return false;
274         }
275         return jsonObject.optBoolean(key);
276     }
277 
278     public JSONObject optJSONObject(String key) {
279         return jsonObject.optJSONObject(key);
280     }
281 
282     /**
283      * Convert datetimetimezone to java date.
284      * Graph API returns some dates as json object with dateTime and timeZone, see
285      * <a href="https://learn.microsoft.com/en-us/graph/api/resources/datetimetimezone">datetimetimezone</a>
286      * @param key property key, e.g. dueDateTime
287      * @return java date
288      * @throws DavMailException on error
289      */
290     public Date optDateTimeTimeZone(String key) throws DavMailException {
291         JSONObject sectionObject = jsonObject.optJSONObject(key);
292         if (sectionObject != null) {
293             String timeZone = sectionObject.optString("timeZone");
294             String dateTime = sectionObject.optString("dateTime");
295             if (timeZone != null && dateTime != null) {
296                 try {
297                     SimpleDateFormat parser = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSSS");
298                     parser.setTimeZone(TimeZone.getTimeZone(convertTimezoneFromExchange(timeZone)));
299                     return parser.parse(dateTime);
300                 } catch (ParseException e) {
301                     throw new DavMailException("EXCEPTION_INVALID_DATE", dateTime);
302                 }
303             }
304         }
305         return null;
306     }
307 
308     /**
309      * Compute recurrenceId property based on the original start and timezone.
310      * @return recurrenceId property
311      * @throws DavMailException on error
312      */
313     public VProperty getRecurrenceId() throws DavMailException {
314         // get the unmodified start date and timezone of occurrence
315         String originalStartTimeZone = optString("originalStartTimeZone");
316         // originalStart is always in UTC, see https://learn.microsoft.com/en-us/graph/api/resources/event
317         String originalStart = optString("originalStart");
318 
319         if (originalStartTimeZone != null && originalStart != null && originalStart.length() >= 19) {
320             String convertedOriginalStart = originalStart;
321             // Per https://learn.microsoft.com/en-us/graph/api/resources/recurrencerange?view=graph-rest-1.0,
322             // originalStart is always in ISO8601 format with offset or Zulu
323             SimpleDateFormat parser = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX");
324             parser.setTimeZone(TimeZone.getTimeZone("UTC"));
325 
326             String standardTimeZoneId = DateUtil.getStandardTimeZone(originalStartTimeZone);
327             SimpleDateFormat formatter;
328             if (standardTimeZoneId == null) {
329                 // format to zulu
330                 formatter = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'");
331                 formatter.setTimeZone(TimeZone.getTimeZone("UTC"));
332             } else {
333                 // format to original timezone
334                 formatter = new SimpleDateFormat("yyyyMMdd'T'HHmmss");
335                 formatter.setTimeZone(DateUtil.getTimeZone(standardTimeZoneId));
336             }
337             try {
338                 convertedOriginalStart = formatter.format(parser.parse(originalStart));
339             } catch (ParseException e) {
340                 LOGGER.warn("Unable to convert to original timezone: " + originalStart + ", " + originalStartTimeZone);
341             }
342 
343             // Convert date from graph to caldav format, keep timezone information
344             VProperty recurrenceId = new VProperty("RECURRENCE-ID", convertedOriginalStart);
345             if (standardTimeZoneId != null) {
346                 recurrenceId.setParam("TZID", originalStartTimeZone);
347             }
348             return recurrenceId;
349         } else {
350             throw new DavMailException("LOG_MESSAGE", "Missing original start date and timezone");
351         }
352     }
353 
354     /**
355      * Convert Exchange timezone id to standard timezone id.
356      * Standard timezones use the area/location format.
357      * @param exchangeTimezone Exchange / O365 timezone id
358      * @return standard timezone
359      */
360     public static String convertTimezoneFromExchange(String exchangeTimezone) {
361         ResourceBundle tzidsBundle = ResourceBundle.getBundle("stdtimezones");
362         if (tzidsBundle.containsKey(exchangeTimezone)) {
363             return tzidsBundle.getString(exchangeTimezone);
364         } else {
365             return exchangeTimezone;
366         }
367     }
368 
369 
370     public int optInt(String key) {
371         return jsonObject.optInt(key);
372     }
373 
374     public String toString(int indentFactor) throws JSONException {
375         return jsonObject.toString(indentFactor);
376     }
377 
378     protected static final Map<String, String> vTodoToTaskStatusMap = new HashMap<>();
379     protected static final Map<String, String> taskTovTodoStatusMap = new HashMap<>();
380 
381     static {
382         // The possible values are: notStarted, inProgress, completed, waitingOnOthers, deferred
383         //taskTovTodoStatusMap.put("notStarted", null);
384         taskTovTodoStatusMap.put("inProgress", "IN-PROCESS");
385         taskTovTodoStatusMap.put("completed", "COMPLETED");
386         taskTovTodoStatusMap.put("waitingOnOthers", "NEEDS-ACTION");
387         taskTovTodoStatusMap.put("deferred", "CANCELLED");
388 
389         //vTodoToTaskStatusMap.put(null, "NotStarted");
390         vTodoToTaskStatusMap.put("IN-PROCESS", "inProgress");
391         vTodoToTaskStatusMap.put("COMPLETED", "completed");
392         vTodoToTaskStatusMap.put("NEEDS-ACTION", "waitingOnOthers");
393         vTodoToTaskStatusMap.put("CANCELLED", "deferred");
394     }
395 
396     public void setTaskStatusFromVTodo(VObject vEvent) throws JSONException {
397         String taskStatus = vTodoToTaskStatusMap.get(vEvent.getPropertyValue("STATUS"));
398         if (taskStatus == null) {
399             taskStatus = "notStarted";
400         }
401         put("status", taskStatus);
402     }
403 
404     public String getVTodoStatusFromTask() {
405         return taskTovTodoStatusMap.get(optString("status"));
406     }
407 
408     protected static final Map<String, String> importanceToPriorityMap = new HashMap<>();
409 
410     static {
411         importanceToPriorityMap.put("high", "1");
412         importanceToPriorityMap.put("normal", "5");
413         importanceToPriorityMap.put("low", "9");
414     }
415 
416     protected static final Map<String, String> priorityToImportanceMap = new HashMap<>();
417 
418     static {
419         // 0 means undefined, map it to normal
420         priorityToImportanceMap.put("0", "normal");
421 
422         priorityToImportanceMap.put("1", "high");
423         priorityToImportanceMap.put("2", "high");
424         priorityToImportanceMap.put("3", "high");
425         priorityToImportanceMap.put("4", "normal");
426         priorityToImportanceMap.put("5", "normal");
427         priorityToImportanceMap.put("6", "normal");
428         priorityToImportanceMap.put("7", "low");
429         priorityToImportanceMap.put("8", "low");
430         priorityToImportanceMap.put("9", "low");
431     }
432 
433     protected String getTaskPriority() {
434         String taskImportance = optString("importance");
435         String taskPriority = null;
436         if (taskImportance != null) {
437             taskPriority = importanceToPriorityMap.get(taskImportance);
438         }
439         return taskPriority;
440     }
441 
442     public void setTaskImportanceFromVTodo(VObject vEvent) throws JSONException {
443         String taskImportance = priorityToImportanceMap.get(vEvent.getPropertyValue("PRIORITY"));
444         if (taskImportance == null) {
445             taskImportance = "normal";
446         }
447         put("importance", taskImportance);
448     }
449 
450 }
451