1/* Copyright 2016 Software Freedom Conservancy Inc.
2 *
3 * This software is licensed under the GNU Lesser General Public License
4 * (version 2.1 or later).  See the COPYING file in this distribution.
5 */
6
7/**
8 * A representations of IMAP's INTERNALDATE field.
9 *
10 * INTERNALDATE's format is
11 *
12 * dd-Mon-yyyy hh:mm:ss +hhmm
13 *
14 * Note that Mon is the standard ''English'' three-letter abbreviation.
15 *
16 * See [[http://tools.ietf.org/html/rfc3501#section-2.3.3]]
17 */
18
19public class Geary.Imap.InternalDate : Geary.MessageData.AbstractMessageData, Geary.Imap.MessageData,
20    Gee.Hashable<InternalDate>, Gee.Comparable<InternalDate> {
21    // see get_en_us_mon() for explanation
22    private const string[] EN_US_MON = {
23        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
24    };
25
26    private const string[] EN_US_MON_DOWN = {
27        "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"
28    };
29
30    public DateTime value { get; private set; }
31    public string? original { get; private set; default = null; }
32
33    private InternalDate(string original, DateTime datetime) {
34        this.original = original;
35        value = datetime;
36    }
37
38    public InternalDate.from_date_time(DateTime datetime) throws ImapError {
39        value = datetime;
40    }
41
42    public static InternalDate decode(string internaldate) throws ImapError {
43        if (String.is_empty(internaldate))
44            throw new ImapError.PARSE_ERROR("Invalid INTERNALDATE: empty string");
45
46        if (internaldate.length > 64)
47            throw new ImapError.PARSE_ERROR("Invalid INTERNALDATE: too long (%d)", internaldate.length);
48
49        // Alas, GMime.utils_header_decode_date() is too forgiving for our needs, so do it manually
50        int day, year, hour, min, sec;
51        char mon[4] = { 0 };
52        char tz[6] = { 0 };
53        int count = internaldate.scanf("%d-%3s-%d %d:%d:%d %5s", out day, mon, out year, out hour,
54            out min, out sec, tz);
55        if (count != 6 && count != 7)
56            throw new ImapError.PARSE_ERROR("Invalid INTERNALDATE \"%s\": too few fields (%d)", internaldate, count);
57
58        // check numerical ranges; this does not verify this is an actual date, DateTime will do
59        // that (and round upward, which has to be accepted)
60        if (!Numeric.int_in_range_inclusive(day, 1, 31)
61            || !Numeric.int_in_range_inclusive(hour, 0, 23)
62            || !Numeric.int_in_range_inclusive(min, 0, 59)
63            || !Numeric.int_in_range_inclusive(sec, 0, 59)
64            || year < 1970) {
65            throw new ImapError.PARSE_ERROR("Invalid INTERNALDATE \"%s\": bad numerical range", internaldate);
66        }
67
68        // check month (this catches localization problems)
69        int month = -1;
70        string mon_down = Ascii.strdown(((string) mon));
71        for (int ctr = 0; ctr < EN_US_MON_DOWN.length; ctr++) {
72            if (mon_down == EN_US_MON_DOWN[ctr]) {
73                month = ctr;
74
75                break;
76            }
77        }
78
79        if (month < 0)
80            throw new ImapError.PARSE_ERROR("Invalid INTERNALDATE \"%s\": bad month", internaldate);
81
82        // TODO: verify timezone
83
84        // if no timezone listed, ISO 8601 says to use local time
85        TimeZone timezone = (tz[0] != '\0') ? new TimeZone((string) tz) : new TimeZone.local();
86
87        // assemble into DateTime, which validates the time as well (this is why we want to keep
88        // original around, for other reasons) ... month is 1-based in DateTime
89        DateTime datetime = new DateTime(timezone, year, month + 1, day, hour, min, sec);
90
91        return new InternalDate(internaldate, datetime);
92    }
93
94    /**
95     * Returns the {@link InternalDate} as a {@link Parameter}.
96     */
97    public Parameter to_parameter() {
98        return Parameter.get_for_string(serialize());
99    }
100
101    /**
102     * Returns the {@link InternalDate} as a {@link Parameter} for a {@link SearchCriterion}.
103     *
104     * @see serialize_for_search
105     */
106    public Parameter to_search_parameter() {
107        return Parameter.get_for_string(serialize_for_search());
108    }
109
110    /**
111     * Returns the {@link InternalDate}'s string representation.
112     *
113     * @see serialize_for_search
114     */
115    public string serialize() {
116        return original ?? value.format("%d-%%s-%Y %H:%M:%S %z").printf(get_en_us_mon());
117    }
118
119    /**
120     * Returns the {@link InternalDate}'s string representation for a SEARCH command.
121     *
122     * SEARCH does not respect time or timezone, so drop when sending it.  See
123     * [[http://tools.ietf.org/html/rfc3501#section-6.4.4]]
124     *
125     * @see serialize
126     * @see SearchCommand
127     */
128    public string serialize_for_search() {
129        return value.format("%d-%%s-%Y").printf(get_en_us_mon());
130    }
131
132    /**
133     * Because IMAP's INTERNALDATE strings are ''never'' localized (as best as I can gather), so
134     * need to use en_US appreviated month names, as that's the only value in INTERNALDATE that is
135     * in a language and not a numeric value.
136     */
137    private string get_en_us_mon() {
138        // month is 1-based inside of DateTime
139        int mon = (value.get_month() - 1).clamp(0, EN_US_MON.length - 1);
140
141        return EN_US_MON[mon];
142    }
143
144    public uint hash() {
145        return value.hash();
146    }
147
148    public bool equal_to(InternalDate other) {
149        return value.equal(other.value);
150    }
151
152    public int compare_to(InternalDate other) {
153        return value.compare(other.value);
154    }
155
156    public override string to_string() {
157        return serialize();
158    }
159}
160
161