diff --git a/.github/workflows/tests-workflow.yml b/.github/workflows/tests-workflow.yml new file mode 100644 index 0000000..70a9145 --- /dev/null +++ b/.github/workflows/tests-workflow.yml @@ -0,0 +1,47 @@ +name: qdate tests and dialyzer +on: push + +jobs: + linux: + name: OTP ${{ matrix.otp_version }} + runs-on: ${{ matrix.os }} + continue-on-error: true + + strategy: + matrix: + include: + - os: ubuntu-22.04 + otp_version: '27.x' + rebar3_version: "3.24.0" + - os: ubuntu-22.04 + otp_version: '27.x' + rebar3_version: "3.23.0" + - os: ubuntu-22.04 + otp_version: '26.x' + rebar3_version: "3.22.1" + - os: ubuntu-22.04 + otp_version: '25.x' + rebar3_version: "3.22.1" + - os: ubuntu-22.04 + otp_version: '24.x' + rebar3_version: "3.22.1" + - os: ubuntu-20.04 + otp_version: '23.x' + rebar3_version: "3.19.0" + + steps: + - name: Install OTP ${{matrix.otp_version}} + uses: erlef/setup-beam@v1 + with: + version-type: loose + otp-version: ${{ matrix.otp_version}} + rebar3-version: ${{ matrix.rebar3_version}} + + - name: Checkout qdate + uses: actions/checkout@v4 + + - name: Run Tests + run: make test + + - name: Run Dialyzer + run: make dialyzer diff --git a/.gitignore b/.gitignore index 05f183f..6ff84d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ *~ *.beam *.sw? +*.iml deps/ ebin/ .eunit/ +.idea/ +_build +doc/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9251b0c..0000000 --- a/.travis.yml +++ /dev/null @@ -1,10 +0,0 @@ -language: erlang -script: "make test" -otp_release: - - R16B - - R15B02 - - R15B01 - - R15B - - R14B03 - - R14B02 -before_script: "sudo apt-get --yes --force-yes install libpam0g-dev" diff --git a/CHANGELOG.markdown b/CHANGELOG.markdown new file mode 100644 index 0000000..f44c11c --- /dev/null +++ b/CHANGELOG.markdown @@ -0,0 +1,87 @@ +## 0.7.3 + +* Remove the `?else` macro. + +## 0.7.2 + +* Update the error message when qdate is not started with some better instructions. +* Some minor updates for hex.pm +* Removed the `erlnow()` warning +* Fix some typos in the documentation +* Update the makefile to use [rebar3.mk](https://rebar3.mk) +* Skipped 0.7.1 only because there was a partially tagged 0.7.1 for a while, + but was never published to hex. Just to ensure upgrades are easier, I just skipped + a proper 0.7.1 release + +## 0.7.0 + +* Re-introduce the qdate server for storing qdate timezones, formats, and parsers, + rather than overloading the `application` env vars (since the `application` + module only wants keys to be atoms). +* Convert to using `qdate_localtime` 1.2.0 (which passes dialyzer checks) +* `qdate` is passing dialyzer again + +## 0.6.0 + +* Add `age` and `age_days` functions +* Add option to preserve millisecond accuracy in date parsing and formatting (@Leonardb) + + +## 0.5.0 + +* Add `range_X` functions for getting a list of dates/times within a range + (such as `range_day/3` to get a range of days between a start and end date. +* Add `beginning_X` functions to return the beginning of the provided precision + (minute, hour, day, week, month, or year) ++ Add `end_X` functions to return the last second of each time period (this is + just the opposite of `beginning_X`) +* Add `between/[2,3,5]` functions for computing whether a date/time is between + two others. +* Update to rebar3 and add hex compatibility. (@Licenser) +* Properly add dependent apps to .app.src (@Licenser) +* Add an optional "relative date/time parser". +* Fix: Ensure `get_timezone()` returns the default timezone (from config) if it + hasn't been set by `get_timezone()` +* Fix UTC/GMT bug (@loudferret) +* Fix Erlang 21 Stacktrace changes (@tnt-dev) +* Set a better rebar2 version of erlware commons (@tnt-dev) + +## 0.4.2 + +* Add partial support for `ec_date`'s 4-tuple subsecond accuracy time format. +* Fix `erlware_commons` dependency to a rebar2-compatible version. + +## 0.4.1 + +* Remove unnecessary `io:format` call. + +## 0.4.0 + +* Remove dependency on a running server for tracking application state. + Instead, parsers and formats are registered to the application environment + vars (e.g. `application:get_env`), and timezones are registered to the + application environment or the process dictionary. A side-effect of this + change is that you can no longer query another process's timezone. +* Add basic date arithmetic (e.g. `qdate:add_hours/[1-2]`, etc). +* Add `get_formats()` and `get_parsers()` to see list of registered formats and + parsers. +* Fix bug related to relying on the application environment variable + `default_timezone` + +## 0.3.0 + +* Add Timezone/Daylight Saving Disambiguation +* Add the `auto` timezone shortcut +* Fix rebar.config to allow for compilation on Erlang 17 + +## 0.2.1 + +* Fix allowing timezone names to be binary + +## 0.2.0 + +* Adding `qdate:compare/2,3` for easily comparing dates + +## 0.1.0 + +* Initial Release diff --git a/LICENSE b/LICENSE.md similarity index 100% rename from LICENSE rename to LICENSE.md diff --git a/Makefile b/Makefile index a0058ac..de4d762 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,41 @@ -all: get-deps compile +all: compile -get-deps: - ./rebar get-deps +# Check if rebar3.mk exists, and if not, download it +ifeq ("$(wildcard rebar3.mk)","") +$(shell curl -O https://raw.githubusercontent.com/choptastic/rebar3.mk/master/rebar3.mk) +endif -compile: - ./rebar compile +# rebar3.mk adds a new rebar3 rule to your Makefile +# (see https://github.com/choptastic/rebar3.mk) for full info +include rebar3.mk -test: get-deps compile - ./rebar skip_deps=true eunit +compile: rebar3 + $(REBAR) compile + +update: rebar3 + $(REBAR) update + +test: + EUNIT=1 $(REBAR) compile + EUNIT=1 $(REBAR) eunit + +dialyzer: compile + DIALYZER=1 $(REBAR) dialyzer + +dev: + mkdir -p _checkouts + cd _checkouts; git clone https://github.com/choptastic/qdate_localtime + + +run: rebar3 + $(REBAR) shell + +push_tags: + git push --tag + +pull_tags: + git pull --tag + +publish: rebar3 pull_tags + $(REBAR) hex publish -run: - erl -pa ebin/ deps/*/ebin/ -eval "application:start(qdate)" diff --git a/README.markdown b/README.md similarity index 57% rename from README.markdown rename to README.md index 662640e..a462a9b 100644 --- a/README.markdown +++ b/README.md @@ -1,6 +1,6 @@ -# qdate - A Wrapper for Erlang Date and Timezone Management +# qdate - Erlang Date and Timezone Library -[![Build Status](https://travis-ci.org/choptastic/qdate.png?branch=master)](https://travis-ci.org/choptastic/qdate) +[![qdate tests and dialyzer](https://github.com/choptastic/qdate/actions/workflows/tests-workflow.yml/badge.svg)](https://github.com/choptastic/qdate/actions/workflows/tests-workflow.yml) ## Purpose @@ -21,12 +21,15 @@ benefits of `ec_date` and `erlang_localtime`, as well as extending the capabilities of both to provide for other needed tools found in a single module. -`qdate` will provide, under the roof of a single module date and time formatting -and parsing from and into: +`qdate` provides date and time formatting and parsing from and into: + Formatting Strings + Erlang Date Format + Erlang Now Format + Unixtime integers + + Timezones + +And all this while dealing with timezone parsing, formatting, conversion +and overall management. #### Acceptable Date Formats @@ -65,10 +68,15 @@ T, Z, r, and c), `qdate` will handle them for us. + `to_now(Date)` - converts any date/time format to Erlang now format. + `to_unixtime(Date)` - converts any date/time format to a unixtime integer +A **ToTimezone** value of the atom `auto` will automatically determine the +timezone. For example, `to_date(Date, auto)` is exactly the same as +`to_date(Date)` + **A Note About Argument Order**: In all cases, `ToTimezone` is optional and if omitted, will be determined as described below in "Understanding Timezone Determining and Conversion". If `ToTimezone` is specified, it will always be -immediately left of the `Date` argument. `Date` will always be the last +immediately left of the `Disambiguate` argument (if it's specified), which is +always immediately left of `Date` argument. `Date` will always be the last argument to any of the conversion and formatting functions. #### Understanding Timezone Determining and Conversions @@ -90,9 +98,88 @@ will infer the timezone in the following order. `set_timezone/1` only applies to that *specific* process. If none is specified. + If no timezone is specified for the process, `qdate` looks at the `qdate` - application variable `default_timezone`. + application variable `default_timezone`. `default_timezone` can be either a + hard-specified timezone, or a `{Module, Function}` tuple. The tuple format + should return either a timezone or the atom `undefined`. + If no timezone is specified by either of the above, `qdate` assumes "GMT" for all dates. + + A timezone value of `auto` will act as if no timezone is specified. + +#### Disambiguating Ambiguous Timezone Conversions + +Sometimes, when you're converting a datetime from one timezone to another, there +are potentially two different results if the conversion happens to land on in a +timezone that's in the middle of a Daylight Saving conversion. For example, +converting "11-Nov-2013 1:00:am" in "America/New York" to "GMT" could be both +"5am" and "6am" in GMT, since "1am EST". This is a side effect of the +"intelligence" of `qdate` - `qdate` would notice that 1am in New York is EST, +and should be converted to "1am EST", and then do the conversion from "1am EST" +to "GMT". This can lead to confusion. + +Further, since `qdate` attempts to be "smart" about mistakenly entered +timezones (ie, if you entered "2013-01-01 EDT", `qdate` knows that "EDT" +(Eastern Daylight Time) doesn't apply to January first, so it *assumes* you +meant "EST". + +**THE SOLUTION** to this tangled mess that we call Daylight Saving Time is to +provide an option to disambiguate if you so desire. By default disambiguation +is disabled, and `qdate` will just guess as to it's best choice. But if you so +desire, you can make sure qdate does *both* conversions, and returns both. + +You can do this by passing a `Disambiguation` argument to `to_string`, +`to_date`, `to_unixtime`, and `to_now`. `Disambiguation` can be an atom of the +values: + + + `prefer_standard` *(Default Behavior)*: If an ambiguous result occurs, + qdate will return the date in standard time rather than daylight time. + + `prefer_daylight`: If an ambiguous result occurs, qdate will return the + preferred daylight time. + + `both`: If an ambiguous result occurs, `qdate` will return the tuple: + `{ambiguous, DateStandard, DateDaylight}`, where `DateStandard` is the date + in Standard Time, and `DateDaylight` is the date in Daylight Saving Time. + +So the expanded conversions functions are: + + + `to_date(ToTimezone, Disambiguate, Date)` + + `to_string(FormatString, ToTimezone, Disambiguate, Date)` + + `to_unixtime(Disambiguate, Date)` + + `to_now(Disambiguate, Date)` + +Examples: + +```erlang +1> qdate:set_timezone("GMT"). +ok + +%% Here, converting GMT 2013-11-03 6AM to America/New York yields an ambiguous +%% result +2> qdate:to_date("America/New York", both, {{2013,11,3},{6,0,0}}). +{ambiguous,{{2013,11,3},{1,0,0}},{{2013,11,3},{2,0,0}}} + +%% Let's just use daylight time +3> qdate:to_date("America/New York", prefer_daylight, {{2013,11,3},{6,0,0}}). +{{2013,11,3},{2,0,0}} + +%% Let's just use standard time (the default behavior) +4> qdate:to_date("America/New York", prefer_standard, {{2013,11,3},{6,0,0}}). +{{2013,11,3},{1,0,0}} + +5> qdate:set_timezone("America/New York"). +ok + +%% Switching from 1AM Eastern Time to GMT yields a potentially ambiguous result +6> qdate:to_date("GMT", both, {{2013,11,3},{1,0,0}}). +{ambiguous,{{2013,11,3},{6,0,0}},{{2013,11,3},{5,0,0}}} + +%% Use daylight time for conversion +7> qdate:to_date("GMT", prefer_daylight, {{2013,11,3},{1,0,0}}). +{{2013,11,3},{5,0,0}} + +%% Here we demonstrated that even if we ask for "both", if there is no +%% ambiguity, the plain date is returned +8> qdate:to_date("GMT", both, {{2013,11,3},{5,0,0}}). +{{2013,11,3},{10,0,0}} +``` #### Conversion Functions provided for API compatibility with `ec_date` @@ -101,6 +188,76 @@ will infer the timezone in the following order. + `format/1` - Same as `to_string/1` + `format/2` - Same as `to_string/2` +### Date and Time Comparison + +`qdate` provides a few convenience functions for performing date comparisons. + + + `compare(A, B) -> -1|0|1` - Like C's `strcmp`, returns: + + `0`: `A` and `B` are exactly the same. + + `-1`: `A` is less than (before) `B`. + + `1`: `A` is greater than (after) `B`. + + `compare(A, Operator, B) -> true|false` - Operator is an infix comparison operator, and + the function will return a boolean. Will return `true` if: + + `'='`, or `'=='` - `A` is the same time as `B` + + `'/='`, or `'=/='` or `'!='` - `A` is not the same time as `B` + + `'<'` - `A` is before `B` + + `'>'` - `A` is after `B` + + `'=<'` or `'<='` - `A` is before or equal to `B` + + `'>='` or `'=>'` - `A` is after or equal to `B` + + `between(A, Date, B) -> true|false` - The provided `Date` is (inclusively) + between `A` and `B`. That is, `A =< Date =< B`. + + `between(A, B) -> true|false` - shortcut for `between(A, now(), B)` + + `between(A, Op1, Date, Op2, B) -> true|false` - the fully verbose option of + comparing between. `Op1` and `Op2` are custom operators. For example, if + you wanted to do an exclusive `between`, you can do: + `between(A, '<', Date, '<', B)` + +**Note 1:** `Operator` must be an atom. + +**Note 2:** These functions will properly compare times with different timezones +(for example: `compare("12am CST",'==',"1am EST")` will properly return true) + +### Sorting + +`qdate` also provides a convenience functions for sorting lists of dates/times: + + + `sort(List)` - Sort the list in ascending order of earliest to latest. + + `sort(Op, List)` - Sort the list where `Op` is one of the following: + + `'<'` or `'=<'` or `'<='` - Sort ascending + + `'>'` or `'>='` or `'=>'` - Sort descending + + `sort(Op, List, Opts)` - Sort the list according to the `Op`, with options provided in `Opts`. `Opts` is a proplist of the following options: + + `{non_dates, NonDates}` - Tells it how to handle non-dates. `NonDates` can be any of the following: + + `back` **(default)** - put any non-dates at the end (the back) of the list + + `front` - put any non-dates at the beginning of the list + + `crash` - if there are any non-dates, crash. + +Example: + +```erlang + 1> Dates = ["non date string", <<"garbage">>, + 1466200861, "2011-01-01", "7pm", + {{1999,6,21},{5,30,0}}, non_date_atom, {some_tuple,123}]. + 2> qdate:sort('>=', Dates, [{non_dates, front}]). + [<<"garbage">>,"non date string", + {some_tuple,123}, + non_date_atom,1466200861,"2011-01-01", + {{1999,6,21},{5,30,0}}, + "7pm"] +``` + +**Note 1:** This sorting is optimized to be much faster than using a home-grown +sort using the `compare` functions, as this normalizes the items in the list +before comparing (so it's only really comparing integers, which is quite fast). + +**Note 2:** This is one of the few qdate functions that don't have the "Date" +as the last argument. This follows the pattern in Erlang/OTP to put options as +the last argument (for example, `re:run/3`) + +**Note 3:** You'll notice that qdate's sorting retains the original terms (in +the example above, we compared a datetime tuple, unix timestamp, and two +strings (along with a number of non-dates, which were just prepended to the +front of the list). + ### Timezone Functions + `set_timezone(Key, TZ)` - Set the timezone to TZ for the key `Key` @@ -136,6 +293,7 @@ be attempted before engaging the `ec_date` parser. able to parse the string, then it should return `undefined`. + `deregister_parser(Key)` - If you previously registered a parser with the `qdate` server, you can deregister it by its `Key`. + + `get_parsers()` - Get the list of all registered parsers and their keys. ### Registering and Deregistering Formatters + `register_format(Key, FormatString)` - Register a formatting string with @@ -143,6 +301,7 @@ be attempted before engaging the `ec_date` parser. formatting string. + `deregister_format(Key)` - Deregister the formatting string from the `qdate` server. + + `get_formats()` - Get the list of all registered formats and their keys. ### About backwards compatibility with `ec_date` and deterministic parsing @@ -339,8 +498,31 @@ the crash. **Another Note:** Custom parsers are expected to return either: + A `datetime()` tuple. (ie {{2012,12,21},{14,45,23}}). + + An integer, which represents the Unix timestamp. + The atom `undefined` if this parser is not a match for the supplied value +#### Included Parser: Relative Times + +`qdate` ships with an optional relative time parser. To speed up performance +(since this parser uses regular expressions), this parser is disabled by +default. But if you wish to use it, make sure you call +`qdate:register_parser(parse_relative, fun qdate:parse_relative/1)`. + +Doing this allows you to parse relative time strings of the following formats: + + + "1 hour ago" + + "-15 minutes" + + "in 45 days" + + "+2 years" + +And doing so allows you to construct slightly more readable comparison calls +for sometimes common comparisons. For example, the following two calls are identical: + +```erlang +qdate:between("-15 minutes", Date, "+15 minutes"). + +qdate:between(qdate:add_minutes(-15), Date, qdate:add_minutes(15)). +``` ### Registering Custom Formats @@ -354,7 +536,7 @@ the crash. %% But, you don't have to: if that's a common format you use in your %% application, you can register your format with the `qdate` server, and then -%% easiy refer to that format by its key. +%% easily refer to that format by its key. %% So let's take that format and register it 16> qdate:register_format(longdate, "l, F jS, Y g:i A T"). @@ -510,6 +692,175 @@ ok %% that timezone to our intended timezone. ``` +## Beginning or Ending of time periods (hours, days, years, weeks, etc) + +qdate can determine beginnings and endings of time periods, like "beginning of the month" + +This is abstracted to `beginning_X` functions, which return a date/time format +with the dates and times truncated to the specified level. + + + `beginning_minute(Date)` + + `beginning_hour(Date)` + + `beginning_day(Date)` + + `beginning_month(Date)` + + `beginning_year(Date)` + +There are also 0-arity versions of the above, in which `Date` is assumed to be +"right now". For example, calling `qdate:beginning_month()` would return +midnight on the first day of the current month. + +#### Beginning of Week + +qdate can also do a special "beginning" case, particularly the "beginning of +the week" calculation. This has three forms, specifically: + + + `beginning_week()` - Returns first day of the current week. + + `beginning_week(Date)` - Assumes the beginning of the week is Monday + (chosen because Erlang's calendar:day_of_the_week uses 1=Monday and + 7=Sunday). + + `beginning_week(DayOfWeek, Date)` - Calculates the beginning of the week + based on the provided `DayOfWeek`. Valid values for DayOfWeek are the + integers 1-7 or the atom versions of the days of the week. Specifically: + + * Monday: `1 | monday | mon` + * Tuesday: `2 | tuesday | tue` + * Wednesday: `3 | wednesday | wed` + * Thursday: `4 | thursday | thu` + * Friday: `5 | friday | fri` + * Saturday: `6 | saturday | sat` + * Sunday: `7 | sunday | sun` + +These all return 12am on the day that is the first day of the week of the +provided date. + +(My apologies to non-English speakers. I'm a lazy American who only speaks +English, hence the Anglocentric day names). + +### End of time period + +There are also the related `end_X` functions available, using the same +conventions, except return the last second of that time period. + +So `end_month("2016-01-05")` will return the unix timestamp representing +"2016-01-31 11:59:59pm" + + +## Date Arithmetic + +The current implementation of qdate's date arithmetic returns Unixtimes. + +There are 8 main functions for date arithmetic: + + + `add_seconds(Seconds, Date)` + + `add_minutes(Minutes, Date)` + + `add_hours(Hours, Date)` + + `add_days(Days, Date)` + + `add_weeks(Weeks, Date)` + + `add_months(Months, Date)` + + `add_years(Years, Date)` + + `add_date(DateToAdd, Date)` - `DateToAdd` is a shortcut way of adding + numerous options. For example. `qdate:add_date({{1, 2, -3}, {-500, 20, 0}})` + will add 1 year, add 2 months, subtract 3 days, subtract 500 hours, add 20 + minutes, and not make any changes to seconds. + +For the date arithmetic functions, `Date`, like all `qdate` functions, can be any +format. + +### Date Arithmetic from "now" + +There are 7 other arithmetic functions that take a single argument, and these do arithmetic from "now." For example, `add_years(4)` is a shortcut for `add_years(4, os:timestamp())`. + + + `add_seconds(Seconds)` + + `add_minutes(Minutes)` + + `add_hours(Hours)` + + `add_days(Days)` + + `add_weeks(Weeks)` + + `add_months(Months)` + + `add_years(Years)` + +## Date and Time Ranges + +qdate provides a number of `range` functions that give applicable dates/times +within a start and end time. For example, "All days from 2015-01-01 to today", +"every 3rd month from 2000-01-01 to 2009-12-31", or "every 15 minutes from +midnight to 11:59pm on 2015-04-15". + +The functions are as follows: + + + `range_seconds(Interval, Start, End)` + + `range_minutes(Interval, Start, End)` + + `range_hours(Interval, Start, End)` + + `range_days(Interval, Start, End)` + + `range_weeks(Interval, Start, End)` + + `range_months(Interval, Start, End)` + + `range_years(Interval, Start, End)` + +Where `Interval` is the number of seconds/days/years/etc. + +So for example: + +```erlang +%% Get every 15th minute from "2015-04-15 12:00am to 2015-04-15 11:59am" +> qdate:range_minutes(15, "2015-04-15 12:00am", "2015-04-15 11:59am"). +[1429056000,1429056900,1429057800,1429058700,1429059600, + 1429060500,1429061400,1429062300,1429063200,1429064100, + 1429065000,1429065900,1429066800,1429067700,1429068600, + 1429069500,1429070400,1429071300,1429072200,1429073100, + 1429074000,1429074900,1429075800,1429076700,1429077600, + 1429078500,1429079400,1429080300,1429081200|...] + +%% Get every day of April, 2014 +> qdate:range_days(1, "2014-04-01", "2014-04-30"). +[1396310400,1396396800,1396483200,1396569600,1396656000, + 1396742400,1396828800,1396915200,1397001600,1397088000, + 1397174400,1397260800,1397347200,1397433600,1397520000, + 1397606400,1397692800,1397779200,1397865600,1397952000, + 1398038400,1398124800,1398211200,1398297600,1398384000, + 1398470400,1398556800,1398643200,1398729600|...] +``` + +Note, that the return value (just like qdate's arithmetic functions) is a list +of integers. These integers are unix timestamps and can be easily formatted +with qdate: + +```erlang +> Mins = qdate:range_minutes(15, "2015-04-15 12:00am", "2015-04-15 11:59am"), +> [qdate:to_string("Y-m-d h:ia", M) || M <- Mins]. +["2015-04-15 00:00am","2015-04-15 00:15am", + "2015-04-15 00:30am","2015-04-15 00:45am", + "2015-04-15 01:00am","2015-04-15 01:15am", + "2015-04-15 01:30am","2015-04-15 01:45am", + "2015-04-15 02:00am","2015-04-15 02:15am", + "2015-04-15 02:30am","2015-04-15 02:45am", + "2015-04-15 03:00am","2015-04-15 03:15am", + "2015-04-15 03:30am","2015-04-15 03:45am", + "2015-04-15 04:00am","2015-04-15 04:15am", + "2015-04-15 04:30am","2015-04-15 04:45am", + "2015-04-15 05:00am","2015-04-15 05:15am", + "2015-04-15 05:30am","2015-04-15 05:45am", + "2015-04-15 06:00am","2015-04-15 06:15am", + "2015-04-15 06:30am","2015-04-15 06:45am", + [...]|...] +``` + +Also note that the range functions are *inclusive*. + + +## Age Comparison + +There are two main comparisons right now, age in years, and age in days. + + + `age(Date)` - Number of years since `Date` + + `age(FromDate, ToDate)` - Number of years between `FromDate` to `ToDate` + + `age_days(Date)` - Number of full days since `Date` (for example from `2pm` yesterday to `1:59pm` today is still 0. + + `age_days(FromDate, ToDate)` - Number of full days between `FromDate` and `ToDate`. + +## Configuration Sample + +There is a sample configuration file can be found in the root of the qdate +directory. Or you can just [look at it +here](https://github.com/choptastic/qdate/blob/master/qdate.config). + ## Thanks A few shoutouts to [Dale Harvey](http://github.com/daleharvey) and the @@ -518,12 +869,24 @@ A few shoutouts to [Dale Harvey](http://github.com/daleharvey) and the package. Without the hard work of all involved in those projects, `qdate` would not exist. +### Thanks to Additional Contributors + ++ [Mark Allen](https://github.com/mrallen1) ++ [Christopher Phillips](https://github.com/lostcolony) ++ [Nicholas Lundgaard](https://github.com/nlundgaard-al) ++ [Alejandro Ramallo](https://github.com/aramallo) ++ [Heinz Gies](https://github.com/Licenser) + + +## Changelog + +See [CHANGELOG.markdown](https://github.com/choptastic/qdate/blob/master/CHANGELOG.markdown) + ## TODO + Make `qdate` backend-agnostic (allow specifying either ec_date or dh_date as the backend) + Add `-spec` and `-type` info for dialyzer -+ Add date and time arithmetic. + Research the viability of [ezic](https://github.com/drfloob/ezic) for a timezone backend replacement for `erlang_localtime`. diff --git a/qdate.config b/qdate.config new file mode 100644 index 0000000..a2dd0e2 --- /dev/null +++ b/qdate.config @@ -0,0 +1,20 @@ +%% vim: ts=4 sw=4 et ft=erlang +[{qdate, [ + %% default_timezone can be one of two things: + %% 1) An actual timezone. Either short-form like "GMT", "UTC", or a + %% longer-form (but more likely to pick the correct daylight saving + %% config timezone like "America/Chicago" + %% 2) A 2-tuple of {Module, Function}, which will be called as + %% Module:Function() to determine the timezone (say you wanted to + %% determine timezone based on some kind of environmental conditions) + {default_timezone, "GMT"}, + + %% See readme section here: + %% https://github.com/choptastic/qdate#about-backwards-compatibility-with-ec_date-and-deterministic-parsing + {deterministic_parsing, {zero, zero}}, + + %% Preserve Milliseconds in parsing + %% Changes the return for qdate:to_date to include any Ms value + %% and also changes the return of to_string to include the Ms value + {preserve_ms, false} +]}]. diff --git a/rebar b/rebar deleted file mode 100755 index 288059a..0000000 Binary files a/rebar and /dev/null differ diff --git a/rebar.config b/rebar.config index f4c8cf0..9dc3672 100644 --- a/rebar.config +++ b/rebar.config @@ -1,13 +1,24 @@ -% vim:ts=4 sw=4 et ft=erlang -{require_otp_vsn, "R13B04|R14|R15|R16"}. - +%% -*- erlang -*- +%% vim:ts=4 sw=4 et ft=erlang {cover_enabled, true}. -%{erl_opts, [debug_info,{i,"site/include"}]}. - -{deps_dir, ["deps"]}. - -{deps, [ - {erlware_commons, ".*", {git, "git://github.com/erlware/erlware_commons.git", "HEAD"}}, - {erlang_localtime, ".*", {git, "git://github.com/choptastic/erlang_localtime.git", "HEAD"}} +{dialyzer, [ + {exclude_apps, []}, + {warnings, []} ]}. + +{deps, + [ + erlware_commons, + {qdate_localtime, "~> 1.2.0"} + ]}. + +{project_plugins, [rebar3_ex_doc]}. + +{hex, [{doc, ex_doc}]}. + +{ex_doc, [ + {source_url, <<"https://github.com/choptastic/qdate">>}, + {extras, [<<"README.md">>, <<"LICENSE.md">>]}, + {main, <<"readme">>}]}. + diff --git a/rebar.config.script b/rebar.config.script new file mode 100644 index 0000000..61542d6 --- /dev/null +++ b/rebar.config.script @@ -0,0 +1,15 @@ +%% -*- mode: erlang -*- +%% -*- tab-width: 4;erlang-indent-level: 4;indent-tabs-mode: nil -*- +%% ex: ts=4 sw=4 sts ft=erlang et + +case erlang:function_exported(rebar3, main, 1) of + true -> % rebar3 + CONFIG; + false -> % rebar 2.x or older + %% Rebuild deps, possibly including those that have been moved to + %% profiles + [{deps, [ + {erlware_commons, "", {git, "https://github.com/erlware/erlware_commons", {tag, "v1.5.0"}}}, %% this is the version of erlware_commons that works until erlware tags a new version + {qdate_localtime, "", {git, "https://github.com/choptastic/qdate_localtime", {tag, "1.1.0"}}} + ]} | lists:keydelete(deps, 1, CONFIG)] +end. diff --git a/rebar.lock b/rebar.lock new file mode 100644 index 0000000..30b2fae --- /dev/null +++ b/rebar.lock @@ -0,0 +1,14 @@ +{"1.2.0", +[{<<"cf">>,{pkg,<<"cf">>,<<"0.3.1">>},1}, + {<<"erlware_commons">>,{pkg,<<"erlware_commons">>,<<"1.6.0">>},0}, + {<<"qdate_localtime">>,{pkg,<<"qdate_localtime">>,<<"1.2.1">>},0}]}. +[ +{pkg_hash,[ + {<<"cf">>, <<"5CB902239476E141EA70A740340233782D363A31EEA8AD37049561542E6CD641">>}, + {<<"erlware_commons">>, <<"E0DA62F91E65DEFAE28BB450DDC3C24E85ECB99B926F857CDC6ED8E7DD7076B4">>}, + {<<"qdate_localtime">>, <<"72E1034DC6B7FEE8F588281EDDD0BD0DC5260005D758052F50634D265D382C18">>}]}, +{pkg_hash_ext,[ + {<<"cf">>, <<"315E8D447D3A4B02BCDBFA397AD03BBB988A6E0AA6F44D3ADD0F4E3C3BF97672">>}, + {<<"erlware_commons">>, <<"B57C299C39A2992A8C413F9D2C1B8A20061310FB4432B0EEE5E4C4DF7AAA0AA3">>}, + {<<"qdate_localtime">>, <<"1109958D205C65C595C8C5694CB83EBAF2DBE770CF902E4DCE8AFB2C4123764D">>}]} +]. diff --git a/src/qdate.app.src b/src/qdate.app.src index 6578dfd..2479739 100644 --- a/src/qdate.app.src +++ b/src/qdate.app.src @@ -1,12 +1,17 @@ {application, qdate, [ {description, "Simple Date and Timezone handling for Erlang"}, - {vsn, "0.1.0"}, + {vsn, git}, {registered, []}, {applications, [ kernel, - stdlib + stdlib, + qdate_localtime, + erlware_commons ]}, - {mod, { qdate_app, []}}, - {env, []} + {modules, [qdate, qdate_srv, qdate_sup, qdate_app]}, + {env, []}, + {licenses, ["MIT"]}, + {mod, {qdate_app, []}}, + {links, [{"Github", "https://github.com/choptastic/qdate"}]} ]}. diff --git a/src/qdate.erl b/src/qdate.erl index 432260d..7027b27 100644 --- a/src/qdate.erl +++ b/src/qdate.erl @@ -1,9 +1,11 @@ % vim: ts=4 sw=4 et -% Copyright (c) 2013 Jesse Gumm +% Copyright (c) 2013-2023 Jesse Gumm % See LICENSE for licensing information. % -module(qdate). +%-compile(export_all). + -export([ start/0, stop/0 @@ -13,21 +15,111 @@ to_string/1, to_string/2, to_string/3, + to_string/4, to_date/1, to_date/2, + to_date/3, to_now/1, + to_now/2, to_unixtime/1, + to_unixtime/2, unixtime/0 ]). +-export([ + beginning_minute/1, + beginning_minute/0, + beginning_hour/1, + beginning_hour/0, + beginning_day/1, + beginning_day/0, + beginning_week/0, + beginning_week/1, + beginning_week/2, + beginning_month/1, + beginning_month/0, + beginning_year/1, + beginning_year/0 +]). + +-export([ + end_minute/1, + end_minute/0, + end_hour/1, + end_hour/0, + end_day/1, + end_day/0, + end_week/0, + end_week/1, + end_week/2, + end_month/1, + end_month/0, + end_year/1, + end_year/0 +]). + +-export([ + compare/2, + compare/3, + between/2, + between/3, + between/5 +]). + +-export([ + sort/1, + sort/2, + sort/3 +]). + +-export([ + add_seconds/2, + add_seconds/1, + add_minutes/2, + add_minutes/1, + add_hours/2, + add_hours/1, + add_days/2, + add_days/1, + add_weeks/2, + add_weeks/1, + add_months/2, + add_months/1, + add_years/2, + add_years/1, + add_unit/2, + add_unit/3, + add_date/2 +]). + +-export([ + range/4, + range_seconds/3, + range_minutes/3, + range_hours/3, + range_days/3, + range_weeks/3, + range_months/3, + range_years/3 +]). + +-export([ + age/1, + age/2, + age_days/1, + age_days/2 +]). + -export([ register_parser/2, register_parser/1, deregister_parser/1, deregister_parsers/0, + get_parsers/0, register_format/2, deregister_format/1, + get_formats/0, set_timezone/1, set_timezone/2, @@ -37,6 +129,9 @@ clear_timezone/1 ]). +-export([ + parse_relative/1 +]). %% Exported for API compatibility with ec_date -export([ @@ -45,45 +140,55 @@ parse/1 ]). +-type qdate() :: any(). +-type datetime() :: {{integer(), integer(), integer()}, {integer(), integer(), integer()}} | + {{integer(), integer(), integer()}, {integer(), integer(), integer(), integer()}}. +-type erlnow() :: {integer(), integer(), integer()}. +-type binary_or_string() :: binary() | string(). +-type disambiguate() :: prefer_standard | prefer_daylight | both. + +%% erlang:get_stacktrace/0 is deprecated in OTP 21 +-ifndef(OTP_RELEASE). +-define(WITH_STACKTRACE(T, R, S), T:R -> S = erlang:get_stacktrace(), ). +-else. +-define(WITH_STACKTRACE(T, R, S), T:R:S ->). +-endif. + %% This the value in gregorian seconds for jan 1st 1970, 12am %% It's used to convert to and from unixtime, since unixtime starts %% 1970-01-01 12:00am -define(UNIXTIME_BASE,62167219200). -%% This is the timezone only if the qdate application variable -%% "default_timezone" isn't set or is set to undefined. -%% It's recommended that your app sets the var in a config, or at least using -%% -%% application:set_env(qdate, default_timezone, "GMT"). -%% --define(DEFAULT_TZ, case application:get_env(qdate, default_timezone) of - undefined -> "GMT"; - TZ -> TZ - end). -define(DETERMINE_TZ, determine_timezone()). +-define(DEFAULT_DISAMBIG, prefer_standard). + start() -> - application:start(qdate). + application:ensure_all_started(qdate). stop() -> - application:stop(qdate). + ok. to_string(Format) -> - to_string(Format, now()). + to_string(Format, os:timestamp()). to_string(Format, Date) -> to_string(Format, ?DETERMINE_TZ, Date). -to_string(FormatKey, ToTZ, Date) when is_atom(FormatKey) orelse is_tuple(FormatKey) -> +to_string(Format, ToTZ, Date) -> + to_string(Format, ToTZ, ?DEFAULT_DISAMBIG, Date). + +-spec to_string(Format :: any(), ToTZ :: any(), Disambiguate :: disambiguate(), Date :: qdate()) -> binary_or_string() | {ambiguous, binary_or_string() , binary_or_string()}. +to_string(FormatKey, ToTZ, Disambiguate, Date) when is_atom(FormatKey) orelse is_tuple(FormatKey) -> Format = case qdate_srv:get_format(FormatKey) of undefined -> throw({undefined_format_key,FormatKey}); F -> F end, - to_string(Format, ToTZ, Date); -to_string(Format, ToTZ, Date) when is_binary(Format) -> - list_to_binary(to_string(binary_to_list(Format), ToTZ, Date)); -to_string(Format, ToTZ, Date) when is_list(Format) -> + to_string(Format, ToTZ, Disambiguate, Date); +to_string(Format, ToTZ, Disambiguate, Date) when is_binary(Format) -> + list_to_binary(to_string(binary_to_list(Format), ToTZ, Disambiguate, Date)); +to_string(Format, ToTZ, Disambiguate, Date) when is_list(Format) -> %% it may seem odd that we're ensuring it here, and then again %% as one of the last steps of the to_date process, but we need %% the actual name for the strings for the PHP "T" and "e", so @@ -91,46 +196,81 @@ to_string(Format, ToTZ, Date) when is_list(Format) -> %% Then we can pass it on to to_date as well. That way we don't have %% to do it twice, since it's already ensured. ActualToTZ = ensure_timezone(ToTZ), - to_string_worker(Format, ActualToTZ, to_date(ActualToTZ, Date)). + case to_date(ActualToTZ, Disambiguate, Date) of + {ambiguous, Standard, Daylight} -> + {ambiguous, + to_string_worker(Format, ActualToTZ, prefer_standard, Standard), + to_string_worker(Format, ActualToTZ, prefer_daylight, Daylight)}; + ActualDate -> + case tz_name(ActualDate,Disambiguate, ActualToTZ) of + {ambiguous,_,_} -> + {ambiguous, + to_string_worker(Format, ActualToTZ, prefer_standard, ActualDate), + to_string_worker(Format, ActualToTZ, prefer_daylight, ActualDate)}; + _ -> + to_string_worker(Format, ActualToTZ, Disambiguate, ActualDate) + end + end. + + -to_string_worker([], _, _) -> +to_string_worker([], _, _, _) -> ""; -to_string_worker([$e|RestFormat], ToTZ, Date) -> - ToTZ ++ to_string_worker(RestFormat, ToTZ, Date); -to_string_worker([$I|RestFormat], ToTZ, Date) -> +to_string_worker([$\\,H|RestFormat], ToTZ, Disamb, Date) -> + [H|to_string_worker(RestFormat, ToTZ, Disamb, Date)]; +to_string_worker([$e|RestFormat], ToTZ, Disamb, Date) -> + ToTZ ++ to_string_worker(RestFormat, ToTZ, Disamb, Date); +to_string_worker([$I|RestFormat], ToTZ, Disamb, Date) -> I = case localtime_dst:check(Date, ToTZ) of is_in_dst -> "1"; is_not_in_dst -> "0"; ambiguous_time -> "?" end, - I ++ to_string_worker(RestFormat, ToTZ, Date); -to_string_worker([H | RestFormat], ToTZ, Date) when H==$O orelse H==$P -> - Shift = get_timezone_shift(ToTZ, Date), + I ++ to_string_worker(RestFormat, ToTZ, Disamb, Date); +to_string_worker([H | RestFormat], ToTZ, Disamb, Date) when H==$O orelse H==$P -> + Shift = get_timezone_shift(ToTZ, Disamb, Date), Separator = case H of $O -> ""; $P -> ":" end, - format_shift(Shift,Separator) ++ to_string_worker(RestFormat, ToTZ, Date); -to_string_worker([$T | RestFormat], ToTZ, Date) -> - {ShortName,_} = localtime:tz_name(Date, ToTZ), - ShortName ++ to_string_worker(RestFormat, ToTZ, Date); -to_string_worker([$Z | RestFormat], ToTZ, Date) -> - {Sign, Hours, Mins} = get_timezone_shift(ToTZ, Date), + format_shift(Shift,Separator) ++ to_string_worker(RestFormat, ToTZ, Disamb, Date); +to_string_worker([$T | RestFormat], ToTZ, Disamb, Date) -> + ShortName = tz_name(Date, Disamb, ToTZ), + ShortName ++ to_string_worker(RestFormat, ToTZ, Disamb, Date); +to_string_worker([$Z | RestFormat], ToTZ, Disamb, Date) -> + {Sign, Hours, Mins} = get_timezone_shift(ToTZ, Disamb, Date), Seconds = (Hours * 3600) + (Mins * 60), - atom_to_list(Sign) ++ integer_to_list(Seconds) ++ to_string_worker(RestFormat, ToTZ, Date); -to_string_worker([$r | RestFormat], ToTZ, Date) -> + atom_to_list(Sign) ++ integer_to_list(Seconds) ++ to_string_worker(RestFormat, ToTZ, Disamb, Date); +to_string_worker([$r | RestFormat], ToTZ, Disamb, Date) -> NewFormat = "D, d M Y H:i:s O", - to_string_worker(NewFormat, ToTZ, Date) ++ to_string_worker(RestFormat, ToTZ, Date); -to_string_worker([$c | RestFormat], ToTZ, Date) -> + to_string_worker(NewFormat, ToTZ, Disamb, Date) ++ to_string_worker(RestFormat, ToTZ, Disamb, Date); +to_string_worker([$c | RestFormat], ToTZ, Disamb, Date) -> Format1 = "Y-m-d", - Format2 = "H:i:sP", - to_string_worker(Format1, ToTZ, Date) + Format2 = case Date of + {_, {_,_,_,_}} -> + %% Have milliseconds + "H:i:s.fP"; + _ -> + "H:i:sP" + end, + to_string_worker(Format1, ToTZ, Disamb, Date) ++ "T" - ++ to_string_worker(Format2, ToTZ, Date) - ++ to_string_worker(RestFormat, ToTZ, Date); -to_string_worker([H | RestFormat], ToTZ, Date) -> - ec_date:format([H], Date) ++ to_string_worker(RestFormat, ToTZ, Date). + ++ to_string_worker(Format2, ToTZ, Disamb, Date) + ++ to_string_worker(RestFormat, ToTZ, Disamb, Date); +to_string_worker([H | RestFormat], ToTZ, Disamb, Date) -> + ec_date:format([H], Date) ++ to_string_worker(RestFormat, ToTZ, Disamb, Date). +tz_name(Date, Disambiguate, ToTZ) -> + case localtime:tz_name(Date, ToTZ) of + {ShortName, _} when is_list(ShortName) -> + ShortName; + {{ShortStandard,_},{ShortDST,_}} -> + case Disambiguate of + prefer_standard -> ShortStandard; + prefer_daylight -> ShortDST; + both -> {ambiguous, ShortStandard, ShortDST} + end + end. format_shift({Sign,Hours,Mins},Separator) -> SignStr = atom_to_list(Sign), @@ -143,14 +283,6 @@ leading_zero(I) when I < 10 -> leading_zero(I) -> integer_to_list(I). -get_timezone_shift(TZ, Date) -> - case localtime:tz_shift(Date, TZ) of - unable_to_detect -> {error,unable_to_detect}; - {error,T} -> {error,T}; - {Sh, _DstSh} -> Sh; - Sh -> Sh - end. - format(Format) -> to_string(Format). @@ -168,11 +300,15 @@ nparse(String) -> to_date(RawDate) -> to_date(?DETERMINE_TZ, RawDate). -to_date(ToTZ, RawDate) when is_binary(RawDate) -> - to_date(ToTZ, binary_to_list(RawDate)); -to_date(ToTZ, RawDate) when is_binary(ToTZ) -> - to_date(binary_to_list(ToTZ), RawDate); -to_date(ToTZ, RawDate) -> +to_date(ToTZ, RawDate) -> + to_date(ToTZ, ?DEFAULT_DISAMBIG, RawDate). + +-spec to_date(ToTZ :: any(), Disambiguate :: disambiguate(), RawDate :: any()) -> {ambiguous, datetime(), datetime()} | datetime(). +to_date(ToTZ, Disambiguate, RawDate) when is_binary(RawDate) -> + to_date(ToTZ, Disambiguate, binary_to_list(RawDate)); +to_date(ToTZ, Disambiguate, RawDate) when is_binary(ToTZ) -> + to_date(binary_to_list(ToTZ), Disambiguate, RawDate); +to_date(ToTZ, Disambiguate, RawDate) -> {ExtractedDate, ExtractedTZ} = extract_timezone(RawDate), {RawDate3, FromTZ} = case try_registered_parsers(RawDate) of undefined -> @@ -181,18 +317,677 @@ to_date(ToTZ, RawDate) -> {ParsedDate,ExtractedTZ}; {ParsedDate,ParsedTZ} -> {ParsedDate,ParsedTZ} - end, + end, + PreserveMs = preserve_ms(), try raw_to_date(RawDate3) of D={{_,_,_},{_,_,_}} -> - date_tz_to_tz(D, FromTZ, ToTZ) + date_tz_to_tz(D, Disambiguate, FromTZ, ToTZ); + {{Year, Month, Date},{Hour,Minute,Second,Millis}} when PreserveMs -> + {ODate, {OHour,OMinute,OSecond}} = date_tz_to_tz({{Year, Month, Date},{Hour,Minute,Second}}, Disambiguate, FromTZ, ToTZ), + {ODate, {OHour,OMinute,OSecond, Millis}}; + {{Year, Month, Date},{Hour,Minute,Second,_Millis}} -> + date_tz_to_tz({{Year, Month, Date},{Hour,Minute,Second}}, Disambiguate, FromTZ, ToTZ) catch _:_ -> case raw_to_date(RawDate) of D2={{_,_,_},{_,_,_}} -> - date_tz_to_tz(D2, ?DETERMINE_TZ, ToTZ) + date_tz_to_tz(D2, Disambiguate, ?DETERMINE_TZ, ToTZ) end end. +%% This converts dates without regard to timezone. +%% Unixtime just goes to UTC +raw_to_date(Unixtime) when is_integer(Unixtime) -> + unixtime_to_date(Unixtime); +raw_to_date(DateString) when is_list(DateString) -> + ec_date:parse(DateString, get_deterministic_datetime()); +raw_to_date(Now = {_,_,_}) -> + calendar:now_to_datetime(Now); +raw_to_date(Date = {{_,_,_},{_,_,_}}) -> + Date. + +get_deterministic_datetime() -> + DateZero = {1970,1,1}, + TimeZero = {0,0,0}, + case application:get_env(qdate, deterministic_parsing) of + {ok, {zero, zero}} -> {DateZero, TimeZero}; + {ok, {zero, now}} -> {DateZero, time()}; + {ok, {now, zero}} -> {date(), TimeZero}; + {ok, {now, now}} -> {date(), time()}; + undefined -> {DateZero, TimeZero}; + {ok, Val} -> throw({invalid_env_var, {qdate, deterministic_parsing, Val}}) + end. + +to_unixtime(Date) -> + to_unixtime(?DEFAULT_DISAMBIG, Date). + +-spec to_unixtime(Disamb :: disambiguate(), qdate()) -> {ambiguous, integer(), integer()} | integer(). +to_unixtime(_, Unixtime) when is_integer(Unixtime) -> + Unixtime; +to_unixtime(_, {MegaSecs,Secs,_}) when is_integer(MegaSecs), is_integer(Secs) -> + MegaSecs*1000000 + Secs; +to_unixtime(Disamb, ToParse) -> + %% We want to treat all unixtimes as GMT + case to_date("GMT", Disamb, ToParse) of + {ambiguous, Standard, Daylight} -> + {ambiguous, + calendar:datetime_to_gregorian_seconds(Standard) - ?UNIXTIME_BASE, + calendar:datetime_to_gregorian_seconds(Daylight) - ?UNIXTIME_BASE}; + Date -> + calendar:datetime_to_gregorian_seconds(Date) - ?UNIXTIME_BASE + end. + +unixtime() -> + to_unixtime(os:timestamp()). + +to_now(Date) -> + to_now(?DEFAULT_DISAMBIG, Date). + +-spec to_now(Disamb :: disambiguate(), qdate()) -> erlnow() | {ambiguous, erlnow(), erlnow()}. +to_now(_, Now = {_,_,_}) -> + Now; +to_now(Disamb, ToParse) -> + case to_unixtime(Disamb, ToParse) of + {ambiguous, Standard, Daylight} when is_integer(Standard), is_integer(Daylight) -> + {ambiguous, + unixtime_to_now(Standard), + unixtime_to_now(Daylight)}; + Unixtime -> + unixtime_to_now(Unixtime) + end. + + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%% Beginning/Truncation %%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +beginning_minute() -> + beginning_minute({date(),time()}). + +beginning_minute(Date) -> + {{Y,M,D},{H,I,_}} = to_date(Date), + {{Y,M,D},{H,I,0}}. + +beginning_hour() -> + beginning_hour({date(),time()}). + +beginning_hour(Date) -> + {{Y,M,D},{H,_,_}} = to_date(Date), + {{Y,M,D},{H,0,0}}. + +beginning_day() -> + beginning_day(unixtime()). + +beginning_day(Date) -> + {{Y,M,D},{_,_,_}} = to_date(Date), + {{Y,M,D},{0,0,0}}. + +beginning_month() -> + beginning_month({date(),time()}). + +beginning_month(Date) -> + {{Y,M,_},{_,_,_}} = to_date(Date), + {{Y,M,1},{0,0,0}}. + +beginning_year() -> + beginning_year({date(),time()}). + +beginning_year(Date) -> + {{Y,_,_},{_,_,_}} = to_date(Date), + {{Y,1,1},{0,0,0}}. + +beginning_week() -> + beginning_week({date(), time()}). + +%% 1 = Monday, 7 = Sunday +beginning_week(Date) -> + beginning_week(1, Date). + +beginning_week(BeginningDayOfWeek, Date) when is_atom(BeginningDayOfWeek) -> + DOW = weekday_map(BeginningDayOfWeek), + beginning_week(DOW, Date); +beginning_week(BeginningDayOfWeek, Date0) when + BeginningDayOfWeek >= 1, + BeginningDayOfWeek =< 7, + is_integer(BeginningDayOfWeek) -> + {DateOnly, _} = Date = to_date(Date0), + CurDOW = calendar:day_of_the_week(DateOnly), + if + CurDOW==BeginningDayOfWeek -> + {DateOnly, {0,0,0}}; + CurDOW > BeginningDayOfWeek-> + Diff = CurDOW - BeginningDayOfWeek, + beginning_day(add_days(-Diff, Date)); + CurDOW < BeginningDayOfWeek -> + Diff = 7 - (BeginningDayOfWeek - CurDOW), + beginning_day(add_days(-Diff, Date)) + end. + +weekday_map(mon) -> 1; +weekday_map(tue) -> 2; +weekday_map(wed) -> 3; +weekday_map(thu) -> 4; +weekday_map(fri) -> 5; +weekday_map(sat) -> 6; +weekday_map(sun) -> 7; + +weekday_map(monday) -> 1; +weekday_map(tuesday) -> 2; +weekday_map(wednesday) -> 3; +weekday_map(thursday) -> 4; +weekday_map(friday) -> 5; +weekday_map(saturday) -> 6; +weekday_map(sunday) -> 7. + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%% End of Period (day/hour, etc) %%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + + +end_minute() -> + end_minute({date(),time()}). + +end_minute(Date) -> + {{Y,M,D},{H,I,_}} = to_date(Date), + {{Y,M,D},{H,I,59}}. + +end_hour() -> + end_hour({date(),time()}). + +end_hour(Date) -> + {{Y,M,D},{H,_,_}} = to_date(Date), + {{Y,M,D},{H,59,59}}. + +end_day() -> + end_day({date(),time()}). + +end_day(Date) -> + {{Y,M,D},_} = to_date(Date), + {{Y,M,D},{23,59,59}}. + +end_month() -> + end_month({date(), time()}). + +end_month(Date) -> + Beginning = beginning_month(Date), + add_seconds(-1, add_months(1, Beginning)). + +end_year() -> + end_year({date(),time()}). + +end_year(Date) -> + {{Y,_,_},_} = to_date(Date), + {{Y,12,31},{23,59,59}}. + +end_week() -> + end_week({date(), time()}). + +end_week(Date) -> + end_week(1, Date). + +end_week(BeginningDayOfWeek, Date) -> + Beginning = beginning_week(BeginningDayOfWeek, Date), + PlusWeek = add_weeks(1, Beginning), + add_seconds(-1, PlusWeek). + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%% Comparisons %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +-spec compare(A :: qdate(), B :: qdate()) -> integer(). +compare(A, B) -> + NowA = to_now(A), + NowB = to_now(B), + if + NowA == NowB -> 0; + NowA < NowB -> -1; + NowA > NowB -> 1 + end. + +-spec compare(A :: qdate(), Op :: atom(), B :: qdate()) -> boolean(). +compare(A, Op, B) -> + Comp = compare(A, B), + case Op of + '==' -> Comp =:= 0; + '=' -> Comp =:= 0; + + '!=' -> Comp =/= 0; + '=/=' -> Comp =/= 0; + '/=' -> Comp =/= 0; + + 'before'-> Comp =:= -1; + '<' -> Comp =:= -1; + '<=' -> Comp =:= -1 orelse Comp =:= 0; + '=<' -> Comp =:= -1 orelse Comp =:= 0; + + 'after' -> Comp =:= 1; + '>' -> Comp =:= 1; + '>=' -> Comp =:= 1 orelse Comp =:= 0; + '=>' -> Comp =:= 1 orelse Comp =:= 0 + end. + +between(A, B) -> + between(A, unixtime(), B). + +between(A, Date, B) -> + between(A, '=<', Date, '=<', B). + +between(A, Op1, Date, Op2, B) -> + compare(A, Op1, Date) andalso compare(Date, Op2, B). + + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%% Sorting %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +sort(List) -> + sort('=<', List). + +sort(Op, List) -> + sort(Op, List, [{non_dates, back}]). + +sort(Op, List, Opts) -> + NonDateOpt = proplists:get_value(non_dates, Opts, back), + WithNorm = add_sort_normalization(List, NonDateOpt), + SortFun = make_sort_fun(Op, NonDateOpt), + Sorted = lists:sort(SortFun, WithNorm), + strip_sort_normalization(Sorted). + +%% Normalization pre-processes the dates (converting them to unixtimes for easy +%% comparison, and also tags non-dates (dates that crashed during parsing) as such +add_sort_normalization(List, NonDateOpt) -> + lists:map(fun(Date) -> + Sortable = try to_unixtime(Date) + catch _:_ when NonDateOpt=/=crash -> + {non_date, Date} + end, + {Sortable, Date} + end, List). + +%% Remove the normalization tag to return the original term +strip_sort_normalization(List) -> + [Date || {_, Date} <- List]. + +-spec make_sort_fun(Op :: atom(), NonDateOpt :: front | back) -> fun(). +make_sort_fun(Op, NonDateOpt) -> + DateComp = sort_op_comp_fun(Op), + + fun({{non_date, A}, _}, {{non_date, B},_}) -> + DateComp(A,B); + ({{non_date, _}, _}, _) when NonDateOpt == front -> + true; + ({{non_date, _}, _}, _) when NonDateOpt == back -> + false; + (_, {{non_date, _}, _}) when NonDateOpt == front -> + false; + (_, {{non_date, _}, _}) when NonDateOpt == back -> + true; + (A, B) -> + DateComp(A, B) + end. + +sort_op_comp_fun(Op) -> + fun(A, B) -> + case Op of + 'before'-> A < B; + '<' -> A < B; + '<=' -> A =< B; + '=<' -> A =< B; + + 'after' -> A > B; + '>' -> A > B; + '>=' -> A >= B; + '=>' -> A >= B + end + end. + + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%% Date Math %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +add_seconds(Seconds, Date) -> + to_unixtime(Date) + Seconds. + +add_seconds(Seconds) -> + add_seconds(Seconds, os:timestamp()). + +add_minutes(Minutes, Date) -> + add_seconds(Minutes * 60, Date). + +add_minutes(Minutes) -> + add_minutes(Minutes, os:timestamp()). + +add_hours(Hours, Date) -> + add_seconds(Hours * 3600, Date). + +add_hours(Hours) -> + add_hours(Hours, os:timestamp()). + +add_days(Days, Date0) -> + {{Y,M,D},Time} = to_date(Date0), + to_unixtime(fix_maybe_improper_date({{Y, M, D+Days}, Time})). + +add_days(Days) -> + add_days(Days, os:timestamp()). + +add_weeks(Weeks, Date) -> + add_days(Weeks * 7, Date). + +add_weeks(Weeks) -> + add_weeks(Weeks, os:timestamp()). + +add_months(Months, Date) -> + {{Y,M,D}, Time} = to_date(Date), + {TargetYear, TargetMonth} = fix_year_month({Y,M+Months}), + DaysInMonth = calendar:last_day_of_the_month(TargetYear, TargetMonth), + NewD = lists:min([DaysInMonth, D]), + to_unixtime(fix_maybe_improper_date({{Y, M+Months, NewD}, Time})). + +add_months(Months) -> + add_months(Months, os:timestamp()). + +add_years(Years, Date) -> + {{Y,M,D}, Time} = to_date(Date), + TargetYear = Y+Years, + NewD = case M of + 2 -> + DaysInMonth = calendar:last_day_of_the_month(TargetYear, M), + lists:min([DaysInMonth, D]); + _ -> + D + end, + to_unixtime({{Y+Years, M, NewD}, Time}). + +add_years(Years) -> + add_years(Years, os:timestamp()). + +-type unit() :: second | seconds | + minute | minutes | + hour | hours | + day | days | + week | weeks | + month | months | + year | years. + +-spec add_unit(Unit :: unit(), Value :: integer(), Date :: qdate()) -> qdate(). +add_unit(second, Value, Date) -> + add_unit(seconds, Value, Date); +add_unit(seconds, Value, Date) -> + add_seconds(Value, Date); +add_unit(minute, Value, Date) -> + add_unit(minutes, Value, Date); +add_unit(minutes, Value, Date) -> + add_minutes(Value, Date); +add_unit(hour, Value, Date) -> + add_unit(hours, Value, Date); +add_unit(hours, Value, Date) -> + add_hours(Value, Date); +add_unit(day, Value, Date) -> + add_unit(days, Value, Date); +add_unit(days, Value, Date) -> + add_days(Value, Date); +add_unit(week, Value, Date) -> + add_unit(weeks, Value, Date); +add_unit(weeks, Value, Date) -> + add_weeks(Value, Date); +add_unit(month, Value, Date) -> + add_unit(months, Value, Date); +add_unit(months, Value, Date) -> + add_months(Value, Date); +add_unit(year, Value, Date) -> + add_unit(years, Value, Date); +add_unit(years, Value, Date) -> + add_years(Value, Date). + +add_unit(Unit, Value) -> + add_unit(Unit, Value, os:timestamp()). + + +add_date({{AddY, AddM, AddD}, {AddH, AddI, AddS}}, Date) -> + {{Y, M, D}, {H, I, S}} = to_date(Date), + Date1 = fix_maybe_improper_date({{Y+AddY, M+AddM, D+AddD}, {H, I, S}}), + Date2 = to_unixtime(Date1), + Date2 + AddS + (AddI*60) + (AddH*3600). + + +-define(IS_LEAP_YEAR(Y), (Y rem 4 =:= 0 andalso + (Y rem 100 =/= 0 + orelse Y rem 400 =:= 0))). + +fix_maybe_improper_date({Date0, Time}) -> + Date = fmid(Date0), + {Date, Time}. + + +%% Originally, this function didn't recurse. Here's the story. Some numbers, +%% like M = 12 (December) or M = -11 (January) would trigger an overflow or +%% underflow, resulting in fix_year_month returning something nonsensical like +%% {2018, 13}. I added some extra clauses to special treat those "overflow but +%% shouldn't" situations, but realized it was just cleaner to recurse, calling +%% fix_year_month on the calculated result, knowing that the numbers will +%% normalize on their own. So for all the clauses of fix_year_month, we recurse +%% as a sanity check, eventually only returning the result of the "Everything +%% Looks good" clause at the bottom. +fix_year_month({Y, M}) when M > 12 -> + YearsOver = M div 12, + fix_year_month({Y + YearsOver, M-(YearsOver*12)}); +fix_year_month({Y, M}) when M < 1 -> + YearsUnder = (abs(M-1) div 12) + 1, + fix_year_month({Y - YearsUnder, M+(YearsUnder*12)}); +fix_year_month({Y, M}) -> + {Y, M}. + + +fmid({Y, M, D}) when M > 12; + M < 1 -> + {NewY, NewM} = fix_year_month({Y, M}), + fmid({NewY, NewM, D}); + +fmid({Y, M, D}) when (D > 30 andalso ( + M=:=4 orelse + M=:=6 orelse + M=:=9 orelse + M=:=11)) -> + fmid({Y, M+1, D-30}); +fmid({Y, M, D}) when M=:=2 andalso D > 29 andalso ?IS_LEAP_YEAR(Y) -> + fmid({Y, M+1, D-29}); +fmid({Y, M, D}) when M =:= 2 andalso D > 28 andalso not(?IS_LEAP_YEAR(Y)) -> + fmid({Y, M+1, D-28}); +fmid({Y, M, D}) when D > 31 -> + fmid({Y, M+1, D-31}); + +fmid({Y, M, D}) when D < 1 -> + TargetMonth = case M-1 of + 0 -> 12; + X -> X + end, + DaysInTargetMonth = calendar:last_day_of_the_month(Y, TargetMonth), + fmid({Y, M-1, D+DaysInTargetMonth}); + + +fmid(Date) -> + Date. + +age(Birth) -> + age(Birth, os:timestamp()). + +age(Birth, Now) -> + %% B=Birth + {{BY, BM, BD}, _} = to_date(Birth), + %% C=Current + {{CY, CM, CD}, _} = to_date(Now), + if + (CM > BM); + (CM == BM andalso CD >= BD) + -> CY - BY; + true -> + (CY - BY) - 1 + end. + +age_days(Birth) -> + age_days(Birth, os:timestamp()). + +age_days(Birth, Now) -> + case {to_date(Birth), to_date(Now)} of + {{SameDay, _}, {SameDay, _}} -> + 0; + {{BirthDate, BirthTime}, {NowDate, NowTime}} -> + BirthDays = calendar:date_to_gregorian_days(BirthDate), + NowDays = calendar:date_to_gregorian_days(NowDate), + DiffDays = NowDays - BirthDays, + if + NowTime >= BirthTime -> + DiffDays; + true -> + DiffDays-1 + end + end. + + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%% Ranges %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +range(seconds, Interval, Start, Finish) -> + range_inner(fun add_seconds/2, Interval, Start, Finish); +range(minutes, Interval, Start, Finish) -> + range_inner(fun add_minutes/2, Interval, Start, Finish); +range(hours, Interval, Start, Finish) -> + range_inner(fun add_hours/2, Interval, Start, Finish); +range(days, Interval, Start, Finish) -> + range_inner(fun add_days/2, Interval, Start, Finish); +range(weeks, Interval, Start, Finish) -> + range_inner(fun add_weeks/2, Interval, Start, Finish); +range(months, Interval, Start, Finish) -> + range_inner(fun add_months/2, Interval, Start, Finish); +range(years, Interval, Start, Finish) -> + range_inner(fun add_years/2, Interval, Start, Finish). + +range_inner(IntervalFun, Interval, Start, Finish) when Interval > 0 -> + %% If Interval>0, then we're ascending, and we want to compare start/end + %% dates normally + CompareFun = fun(S, F) -> compare(S, F) end, + range_normalizer(IntervalFun, Interval, CompareFun, Start, Finish); +range_inner(IntervalFun, Interval, Start, Finish) when Interval < 0 -> + %% If Interval<0, then we're descending, and we want to compare start/end + %% dates backwards (we want to end when the Start Date is Lower than + %% Finish) + CompareFun = fun(S, F) -> compare(F, S) end, + range_normalizer(IntervalFun, Interval, CompareFun, Start, Finish); +range_inner(_, Interval, _, _) when Interval==0 -> + throw(interval_cannot_be_zero). + +range_normalizer(IntervalFun, Interval, CompareFun, Start0, Finish0) -> + %% Convert dates to unixtime for speed's sake + Start = to_unixtime(Start0), + Finish = to_unixtime(Finish0), + %% Prepare the incrementer, so we just need to pass the date to the incrementer. + Incrementer = fun(D) -> IntervalFun(Interval, D) end, + range_worker(Incrementer, CompareFun, Start, Finish). + +range_worker(Incrementer, CompareFun, Start, Finish) -> + case CompareFun(Start, Finish) of + 0 -> [Finish]; %% Equal, so we add our Finish value + 1 -> []; %% Start is after Finish, so we add nothing + -1 -> %% Start is before Finish, so we include it, and recurse + NextDay = Incrementer(Start), + [Start | range_worker(Incrementer, CompareFun, NextDay, Finish)] + end. + +range_seconds(Interval, Start, Finish) -> + range(seconds, Interval, Start, Finish). + +range_minutes(Interval, Start, Finish) -> + range(minutes, Interval, Start, Finish). + +range_hours(Interval, Start, Finish) -> + range(hours, Interval, Start, Finish). + +range_days(Interval, Start, Finish) -> + range(days, Interval, Start, Finish). + +range_weeks(Interval, Start, Finish) -> + range(weeks, Interval, Start, Finish). + +range_months(Interval, Start, Finish) -> + range(months, Interval, Start, Finish). + +range_years(Interval, Start, Finish) -> + range(years, Interval, Start, Finish). + + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%% Relative Date Parsing %%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +parse_relative({relative, Date, Relation}) when is_atom(Relation) -> + parse_relative({relative, Date, atom_to_list(Relation)}); +parse_relative({relative, Date, Relation}) when is_list(Relation); is_binary(Relation) -> + case parse_actual_relation(Relation) of + undefined -> undefined; + {OpStr, NumStr, UnitStr} -> + {Num, Unit} = normalize_relative_matches(OpStr, NumStr, UnitStr), + add_unit(Unit, Num, Date) + end; +parse_relative(now) -> + unixtime(); +parse_relative("now") -> + unixtime(); +parse_relative(<<"now">>) -> + unixtime(); +parse_relative(Relation) when is_list(Relation); is_binary(Relation) -> + parse_relative({relative, unixtime(), Relation}); +parse_relative(_) -> + undefined. + + +%% I would do this function recursively, but the return order of arguments +%% inconsistent, so I just leave it like this. It's a little nasty to have the +%% nested case expressions, but I can deal with it. +parse_actual_relation(Relation) -> + PrefixRE = "^(\\-|\\+|in)\\s?(\\d+) (second|minute|hour|day|week|month|year)s?$", + SuffixRE = "^(\\d+) (second|minute|hour|day|week|month|year)s?\\s?(ago|from now)?$", + case re:run(Relation, PrefixRE, [{capture, all_but_first, list}]) of + nomatch -> + case re:run(Relation, SuffixRE, [{capture, all_but_first, list}]) of + nomatch -> undefined; + {match, [NumStr, UnitStr, OpStr]} -> + {OpStr, NumStr, UnitStr} + end; + {match, [OpStr, NumStr, UnitStr]} -> + {OpStr, NumStr, UnitStr} + end. + +normalize_relative_matches(OpStr, NumStr, UnitStr) -> + Op = normalize_relative_op(OpStr), + Num = list_to_integer(Op ++ NumStr), + Unit = list_to_existing_atom(UnitStr), + {Num, Unit}. + +normalize_relative_op(Op) -> + case Op of + "+" -> "+"; + "-" -> "-"; + "ago" -> "-"; + "from now" -> "+"; + "in" -> "+" + end. + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%% Timezone Stuff %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +get_timezone_shift(TZ, Disambiguate, Date) -> + case localtime:tz_shift(Date, TZ) of + unable_to_detect -> {error,unable_to_detect}; + {error,T} -> {error,T}; + {Sh, _} when Disambiguate==prefer_standard -> Sh; + {_, Sh} when Disambiguate==prefer_daylight -> Sh; + 0 -> {'+', 0, 0}; + Sh -> Sh + end. + extract_timezone(Unixtime) when is_integer(Unixtime) -> {Unixtime, "GMT"}; @@ -207,6 +1002,8 @@ extract_timezone(DateString) when is_list(DateString) -> end; extract_timezone(Date={{_,_,_},{_,_,_}}) -> {Date, ?DETERMINE_TZ}; +extract_timezone(Rel={relative, _, _}) -> + {Rel, "GMT"}; extract_timezone(Now={_,_,_}) -> {Now, "GMT"}; extract_timezone({MiscDate,TZ}) -> @@ -242,79 +1039,77 @@ extract_timezone_helper(RevDate, [TZ | TZs]) when length(RevDate) >= length(TZ) extract_timezone_helper(RevDate, [_TZ | TZs]) -> extract_timezone_helper(RevDate, TZs). -determine_timezone() -> - case qdate_srv:get_timezone() of - undefined -> ?DEFAULT_TZ; - TZ -> TZ +preserve_ms() -> + application:get_env(qdate, preserve_ms, false). + +%% This is the timezone only if the qdate application variable +%% "default_timezone" isn't set or is set to undefined. +%% It's recommended that your app sets the var in a config, or at least using +%% +%% application:set_env(qdate, default_timezone, "GMT"). +%% +default_timezone() -> + case application:get_env(qdate, default_timezone) of + undefined -> "GMT"; + {ok, {Mod, Fun}} -> Mod:Fun(); + {ok, TZ} -> TZ end. -%% This converts dates without regard to timezone. -%% Unixtime just goes to UTC -raw_to_date(Unixtime) when is_integer(Unixtime) -> - unixtime_to_date(Unixtime); -raw_to_date(DateString) when is_list(DateString) -> - ec_date:parse(DateString, get_deterministic_datetime()); -raw_to_date(Now = {_,_,_}) -> - calendar:now_to_datetime(Now); -raw_to_date(Date = {{_,_,_},{_,_,_}}) -> - Date. - -get_deterministic_datetime() -> - DateZero = {1970,1,1}, - TimeZero = {0,0,0}, - case application:get_env(qdate, deterministic_parsing) of - {ok, {zero, zero}} -> {DateZero, TimeZero}; - {ok, {zero, now}} -> {DateZero, time()}; - {ok, {now, zero}} -> {date(), TimeZero}; - {ok, {now, now}} -> {date(), time()}; - undefined -> {DateZero, TimeZero}; - {ok, Val} -> throw({invalid_env_var, {qdate, deterministic_parsing, Val}}) +determine_timezone() -> + case qdate_srv:get_timezone() of + undefined -> default_timezone(); + TZ -> TZ end. %% If FromTZ is an integer, then it's an integer that represents the number of minutes %% relative to GMT. So we convert the date to GMT based on that number, then we can %% do the other timezone conversion. -date_tz_to_tz(Date, FromTZ, ToTZ) when is_integer(FromTZ) -> +-spec date_tz_to_tz(Date :: datetime(), Disambiguate :: disambiguate(), FromTZ :: any(), ToTZ :: any()) -> + datetime() | {ambiguous, datetime(), datetime()}. +date_tz_to_tz(Date, Disambiguate, FromTZ, ToTZ) when is_integer(FromTZ) -> NewDate = localtime:adjust_datetime(Date, FromTZ), - date_tz_to_tz(NewDate, "GMT", ToTZ); -date_tz_to_tz(Date, FromTZ, ToTZ) -> + date_tz_to_tz(NewDate, Disambiguate, "GMT", ToTZ); +date_tz_to_tz(Date, Disambiguate, FromTZ, ToTZ) -> ActualToTZ = ensure_timezone(ToTZ), - localtime:local_to_local(Date,FromTZ,ActualToTZ). - -try_registered_parsers(RawDate) -> - Parsers = qdate_srv:get_parsers(), - try_parsers(RawDate,Parsers). - -try_parsers(_RawDate,[]) -> - undefined; -try_parsers(RawDate,[{ParserKey,Parser}|Parsers]) -> - try Parser(RawDate) of - {{_,_,_},{_,_,_}} = DateTime -> - {DateTime,undefined}; - {DateTime={{_,_,_},{_,_,_}},Timezone} -> - {DateTime,Timezone}; - undefined -> - try_parsers(RawDate, Parsers); - Other -> - throw({invalid_parser_return_value,[{parser_key,ParserKey},{return,Other}]}) - catch - Error:Reason -> - throw({error_in_parser,[{error,{Error,Reason}},{parser_key,ParserKey}]}) + case Disambiguate of + prefer_standard -> + localtime:local_to_local(Date, FromTZ, ActualToTZ); + prefer_daylight -> + localtime:local_to_local_dst(Date, FromTZ, ActualToTZ); + both -> + date_tz_to_tz_both(Date, FromTZ, ToTZ) end. +-spec date_tz_to_tz_both(Date :: datetime(), FromTZ :: string(), ToTZ :: string()) -> datetime() | {ambiguous, datetime(), datetime()}. +date_tz_to_tz_both(Date, FromTZ, ToTZ) -> + Standard = localtime:local_to_local(Date, FromTZ, ToTZ), + Daylight = localtime:local_to_local_dst(Date, FromTZ, ToTZ), + if + Standard=:=Daylight -> + Standard; + true -> + {ambiguous, Standard, Daylight} + end. + +set_timezone(TZ) when is_binary(TZ) -> + set_timezone(binary_to_list(TZ)); set_timezone(TZ) -> qdate_srv:set_timezone(TZ). + +set_timezone(Key,TZ) when is_binary(TZ) -> + set_timezone(Key, binary_to_list(TZ)); set_timezone(Key,TZ) -> qdate_srv:set_timezone(Key, TZ). get_timezone() -> - qdate_srv:get_timezone(). + ?DETERMINE_TZ. get_timezone(Key) -> qdate_srv:get_timezone(Key). - +ensure_timezone(auto) -> + ?DETERMINE_TZ; ensure_timezone(Key) when is_atom(Key) orelse is_tuple(Key) -> case get_timezone(Key) of undefined -> throw({timezone_key_not_found,Key}); @@ -325,7 +1120,6 @@ ensure_timezone(TZ) when is_binary(TZ) -> ensure_timezone(TZ) when is_list(TZ) -> TZ. - clear_timezone() -> qdate_srv:clear_timezone(). @@ -333,24 +1127,12 @@ clear_timezone(Key) -> qdate_srv:clear_timezone(Key). -to_unixtime(Unixtime) when is_integer(Unixtime) -> - Unixtime; -to_unixtime({MegaSecs,Secs,_}) -> - MegaSecs*1000000 + Secs; -to_unixtime(ToParse) -> - %% We want to treat all unixtimes as GMT - Date = to_date("GMT", ToParse), - calendar:datetime_to_gregorian_seconds(Date) - ?UNIXTIME_BASE. - -unixtime() -> - to_unixtime(now()). - -to_now(Now = {_,_,_}) -> - Now; -to_now(ToParse) -> - Unixtime = to_unixtime(ToParse), - unixtime_to_now(Unixtime). +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%% Register Parsers %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +get_parsers() -> + qdate_srv:get_parsers(). register_parser(Key, Parser) when is_function(Parser,1) -> qdate_srv:register_parser(Key,Parser). @@ -364,6 +1146,36 @@ deregister_parser(Key) -> deregister_parsers() -> qdate_srv:deregister_parsers(). +try_registered_parsers(RawDate) -> + Parsers = qdate_srv:get_parsers(), + try_parsers(RawDate,Parsers). + +try_parsers(_RawDate,[]) -> + undefined; +try_parsers(RawDate,[{ParserKey,Parser}|Parsers]) -> + try Parser(RawDate) of + Timestamp when is_integer(Timestamp) -> + {Timestamp, "GMT"}; + {{_,_,_},{_,_,_}} = DateTime -> + {DateTime,undefined}; + {DateTime={{_,_,_},{_,_,_}},Timezone} -> + {DateTime,Timezone}; + undefined -> + try_parsers(RawDate, Parsers); + Other -> + throw({invalid_parser_return_value,[{parser_key,ParserKey},{return,Other}]}) + catch + ?WITH_STACKTRACE(Error, Reason, Stacktrace) + throw({error_in_parser,[{error,{Error,Reason}},{parser_key,ParserKey}, {stacktrace, Stacktrace}]}) + end. + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%% Register Formats %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +get_formats() -> + qdate_srv:get_formats(). + register_format(Key, Format) -> qdate_srv:register_format(Key, Format). @@ -373,7 +1185,7 @@ deregister_format(Key) -> unixtime_to_now(T) when is_integer(T) -> - MegaSec = floor(T/1000000), + MegaSec = flooring(T/1000000), Secs = T - MegaSec*1000000, {MegaSec,Secs,0}. @@ -381,22 +1193,26 @@ unixtime_to_date(T) -> Now = unixtime_to_now(T), calendar:now_to_datetime(Now). -floor(N) when N >= 0 -> - trunc(N); -floor(N) when N < 0 -> - Int = trunc(N), +flooring(N) when N >= 0 -> + erlang:trunc(N); +flooring(N) when N < 0 -> + Int = erlang:trunc(N), if Int==N -> Int; true -> Int-1 end. +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% TESTS %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +-ifdef(EUNIT). -%% TESTS -include_lib("eunit/include/eunit.hrl"). %% emulates as if a forum-type website has a Site tz, and a user-specified tz -define(SITE_TZ,"PST"). --define(USER_TZ,"CST"). +-define(USER_TZ,<<"CST">>). -define(SELF_TZ,"EST"). %% Self will be the pid of the current running process -define(SITE_KEY,test_site_key). -define(USER_KEY,test_user_key). @@ -409,10 +1225,36 @@ tz_test_() -> fun(SetupData) -> {inorder,[ simple_test(SetupData), + compare_test(SetupData), tz_tests(SetupData), - test_process_die(SetupData), parser_format_test(SetupData), - test_deterministic_parser(SetupData) + test_deterministic_parser(SetupData), + test_disambiguation(SetupData), + arith_tests(SetupData) + ]} + end + }. + +tz_preserve_ms_true_test_() -> + { + setup, + fun start_test/0, + fun stop_test/1, + fun(SetupData) -> + {inorder,[ + preserve_ms_true_tests(SetupData) + ]} + end + }. + +tz_preserve_ms_false_test_() -> + { + setup, + fun start_test/0, + fun stop_test/1, + fun(SetupData) -> + {inorder,[ + preserve_ms_false_tests(SetupData) ]} end }. @@ -432,11 +1274,28 @@ test_deterministic_parser(_) -> ?_assertEqual({{2012,5,10}, {0,0,0}}, qdate:to_date("2012-5-10")) ]}. +test_disambiguation(_) -> + {inorder, [ + ?_assertEqual(ok, set_timezone("America/New York")), + ?_assertEqual({ambiguous, {{2013,11,3},{6,0,0}}, {{2013,11,3},{5,0,0}}}, qdate:to_date("GMT",both,{{2013,11,3},{1,0,0}})), + ?_assertEqual({{2013,11,3},{6,0,0}}, qdate:to_date("GMT",prefer_standard,{{2013,11,3},{1,0,0}})), + ?_assertEqual({{2013,11,3},{5,0,0}}, qdate:to_date("GMT",prefer_daylight,{{2013,11,3},{1,0,0}})), + ?_assertEqual({ambiguous, "GMT","GMT"}, qdate:to_string("T", "GMT", both, {{2013,11,3},{1,0,0}})), + ?_assertEqual({ambiguous, "EST","EDT"}, qdate:to_string("T", auto, both, {{2013,11,3},{1,0,0}})), + ?_assertEqual(ok, set_timezone("GMT")), + ?_assertEqual({ambiguous, {{2013,11,3},{1,0,0}}, {{2013,11,3},{2,0,0}}}, qdate:to_date("America/New York", both, {{2013,11,3},{6,0,0}})), + ?_assertEqual({{2013,11,3},{2,0,0}}, qdate:to_date("America/New York", prefer_daylight, {{2013,11,3},{6,0,0}})), + ?_assertEqual({{2013,11,3},{1,0,0}}, qdate:to_date("America/New York", prefer_standard, {{2013,11,3},{6,0,0}})) + ]}. + + tz_tests(_) -> {inorder,[ + ?_assertEqual(ok,set_timezone(<<"Europe/Moscow">>)), + ?_assertEqual("Europe/Moscow", get_timezone()), ?_assertEqual(ok,set_timezone(?SELF_TZ)), ?_assertEqual(?SELF_TZ,get_timezone()), - ?_assertEqual(?USER_TZ,get_timezone(?USER_KEY)), + ?_assertEqual("CST",get_timezone(?USER_KEY)), ?_assertEqual(?SITE_TZ,get_timezone(?SITE_KEY)), ?_assertEqual({{2013,3,7},{0,0,0}}, to_date(?USER_KEY,"3/7/2013 1:00am EST")), ?_assertEqual({{2013,3,7},{0,0,0}}, to_date(?SITE_KEY,"3/7/2013 3:00am EST")), @@ -458,6 +1317,20 @@ tz_tests(_) -> ?_assertEqual({{2013,3,6},{18,0,0}}, to_date("GMT","3/7/2013 12:00am +0600")), ?_assertEqual({{2013,3,6},{12,0,0}}, to_date("CST","3/7/2013 12:00am +0600")), + %% These next two test check to make sure that the tz database properly + %% interprets GMT+/-X timezones (an earlier issue with + %% erlang_localtime's tz database had it incrementing/decrementing the + %% minute field rather than hours. + %% + %% It also ensures that GMT+/-X handling is interpreted the way you'd + %% intuitively expect, rather than the POSIX way, which is, quite + %% frankly, broken. + ?_assertEqual({{2013,3,7},{10,0,0}}, to_date("GMT-0","3/7/2013 10:00am GMT")), + ?_assertEqual({{2013,3,7},{10,0,0}}, to_date("GMT+0","3/7/2013 10:00am GMT")), + ?_assertEqual({{2013,3,7},{9,0,0}}, to_date("GMT-1","3/7/2013 10:00am GMT")), + ?_assertEqual({{2013,3,7},{11,0,0}}, to_date("GMT+1","3/7/2013 10:00am GMT")), + + %% parsing, then reformatting the same time with a different timezone using the php "r" (rfc2822) ?_assertEqual("Thu, 07 Mar 2013 12:15:00 -0600", to_string("r","CST",to_string("r","EST",{{2013,3,7},{13,15,0}}))), @@ -472,7 +1345,14 @@ tz_tests(_) -> ?_assertEqual(ok, set_timezone("EST")), ?_assertEqual(555555555,to_unixtime("1987-08-10 00:59:15 GMT")), ?_assertEqual({555,555555,0},to_now("1987-08-10 00:59:15 GMT")), - ?_assertEqual(ok, set_timezone("GMT")) + ?_assertEqual(ok, set_timezone("GMT")), + ?_assertEqual({{1970, 1, 1}, {1, 0, 0}}, to_date("CET", "1970-01-01T00:00:00Z")), + ?_assertEqual(ok, set_timezone("UTC")), + ?_assertEqual(1521945120, to_unixtime("2018-3-25T2:32:00")), + ?_assertEqual(true, between("-1 seconds", os:timestamp(), "+1 seconds")), + ?_assertEqual(true, between("60 hours ago", unixtime(), "in 15 days")), + ?_assertEqual(false, between("+1 seconds", qdate:to_string("n/j/Y g:ia"), "+2 seconds")), + ?_assertEqual(false, between("5 seconds ago","1 second ago")) ]}. @@ -497,6 +1377,24 @@ simple_test(_) -> ?_assertEqual({{2013,1,1},{0,15,15}},to_date("GMT", "December 31, 2012 6:15:15pm CST")) ]}. + +compare_test(_) -> + {inorder,[ + ?_assertEqual(true, compare({{2013,9,10},{0,0,0}},'=',"Sep 10th, 2013 12:00am")), + ?_assertEqual(true, compare("9/10/2013 1am EDT",'==',"Sep 10th, 2013 12:00:00am CDT")), + ?_assertEqual(true, compare({{2013,9,10},{0,0,0}},'=<',"Sep 10th, 2013 12:00am")), + ?_assertEqual(false, compare({{2013,9,10},{0,0,1}},'=',"Sep 10th, 2013 12:00am")), + ?_assertEqual(true, compare({{2013,9,10},{0,0,1}},'=/=',"Sep 10th, 2013 12:00am")), + ?_assertEqual(true, compare({{2013,9,10},{0,0,1}},'>',"Sep 10th, 2013 12:00am")), + ?_assertEqual(false, compare({{2013,9,10},{0,0,1}},'<',"Sep 10th, 2013 12:00am")), + ?_assertEqual(true, compare({{2013,9,10},{0,0,1}},'<',"Sep 10th, 2013 12:00:02am")), + ?_assertEqual(true, compare({{2013,9,10},{0,0,1}},'<',"Sep 10th, 2013 12:02am")), + ?_assertEqual(true, compare({{2013,9,10},{0,0,1}},'<',"Sep 10th, 2013 1am")), + ?_assertEqual(true, compare({{2013,9,9},{23,59,59}},'<',"Sep 10th, 2013 12am")), + ?_assertEqual(false, compare({{2013,9,9},{23,59,59}},'>',"Sep 10th, 2013 12am")), + ?_assertEqual(true, compare("11am EST",'==',"10am CST")) + ]}. + parser_format_test(_) -> {inorder,[ ?_assertEqual({{2008,2,8},{0,0,0}},to_date("20080208")), @@ -506,47 +1404,95 @@ parser_format_test(_) -> ?_assertEqual("2/8/2008 12:00am",to_string(longdate,"2008-02-08 12:00am")), ?_assertEqual("2/8/2008 12:00am",to_string(longdate,"20080208")) ]}. - -test_process_die(_) -> - TZ = "MST", - Caller = self(), - Pid = spawn(fun() -> - set_timezone(TZ), - Caller ! tz_set, - receive tz_set_ack -> ok end, - Caller ! get_timezone() - end), - - PidTZFromOtherProc = receive - tz_set -> - T = get_timezone(Pid), - Pid ! tz_set_ack, - T - after 1000 -> fail - end, - ReceivedTZ = receive - TZ -> TZ - after 2000 -> - fail - end, - - [ - %% Verify we can read the spawned process's TZ from another proc - ?_assertEqual(TZ,PidTZFromOtherProc), - %% Verify the spawned process properly set the TZ - ?_assertEqual(TZ,ReceivedTZ), - %% Verify the now-dead spawned process's TZ is cleared - ?_assertEqual(undefined,get_timezone(Pid)) - ]. +arith_tests(_) -> + {inorder,[ + ?_assertEqual({{2012,2,29},{23,59,59}}, to_date(add_seconds(-1, {{2012,3,1},{0,0,0}}))), + ?_assertEqual({{2013,2,28},{23,59,59}}, to_date(add_seconds(-1, {{2013,3,1},{0,0,0}}))), + ?_assertEqual({{2015,1,1},{0,0,0}}, to_date(add_years(1, {{2014,1,1},{0,0,0}}))), + ?_assertEqual({{2015,1,1},{0,0,0}}, to_date(add_seconds(1, {{2014,12,31},{23,59,59}}))), + ?_assertEqual({{2015,1,1},{0,0,59}}, to_date(add_minutes(1, {{2014,12,31},{23,59,59}}))), + ?_assertEqual({{2015,1,1},{0,59,59}}, to_date(add_hours(1, {{2014,12,31},{23,59,59}}))), + ?_assertEqual({{2015,1,1},{23,59,59}}, to_date(add_days(1, {{2014,12,31},{23,59,59}}))), + ?_assertEqual({{2015,1,7},{23,59,59}}, to_date(add_weeks(1, {{2014,12,31},{23,59,59}}))), + ?_assertEqual({{2015,1,31},{23,59,59}}, to_date(add_months(1, {{2014,12,31},{23,59,59}}))), + ?_assertEqual({{2015,2,28},{0,0,0}}, to_date(add_months(2, {{2014,12,31},{0,0,0}}))), + ?_assertEqual({{2016,2,28},{0,0,0}}, to_date(add_years(1, {{2015,2,28},{0,0,0}}))), + + ?_assertEqual({{2017,2,1},{0,0,0}}, to_date(add_months(-11, {{2018,1,1},{0,0,0}}))), + ?_assertEqual({{2017,1,1},{0,0,0}}, to_date(add_months(-12, {{2018,1,1},{0,0,0}}))), + ?_assertEqual({{2016,12,1},{0,0,0}}, to_date(add_months(-13, {{2018,1,1},{0,0,0}}))), + + ?_assertEqual({{2018,12,1},{0,0,0}}, to_date(add_months(11, {{2018,1,1},{0,0,0}}))), + ?_assertEqual({{2019,1,1},{0,0,0}}, to_date(add_months(12, {{2018,1,1},{0,0,0}}))), + ?_assertEqual({{2019,2,1},{0,0,0}}, to_date(add_months(13, {{2018,1,1},{0,0,0}}))), + + ?_assertEqual({{2018,1,1},{0,0,0}}, to_date(add_months(-11, {{2018,12,1},{0,0,0}}))), + ?_assertEqual({{2017,12,1},{0,0,0}}, to_date(add_months(-12, {{2018,12,1},{0,0,0}}))), + ?_assertEqual({{2017,11,1},{0,0,0}}, to_date(add_months(-13, {{2018,12,1},{0,0,0}}))), + + ?_assertEqual({{2019,11,1},{0,0,0}}, to_date(add_months(11, {{2018,12,1},{0,0,0}}))), + ?_assertEqual({{2019,12,1},{0,0,0}}, to_date(add_months(12, {{2018,12,1},{0,0,0}}))), + ?_assertEqual({{2020,1,1},{0,0,0}}, to_date(add_months(13, {{2018,12,1},{0,0,0}}))), + + + ?_assertEqual({{2014,2,28},{0,0,0}}, to_date(add_months(-24, {{2016,2,29},{0,0,0}}))), + ?_assertEqual({{2018,12,15},{0,0,0}}, to_date(add_months(24, {{2016,12,15},{0,0,0}}))), + ?_assertEqual({{2012,2,29},{0,0,0}}, to_date(add_months(-48, {{2016,2,29},{0,0,0}}))), + ?_assertEqual({{2016,2,29},{0,0,0}}, to_date(add_months(-1, {{2016,3,31},{0,0,0}}))), + ?_assertEqual({{2017,2,28},{0,0,0}}, to_date(add_years(1, {{2016,2,29},{0,0,0}}))), + ?_assertEqual({{2015,3,1},{0,0,0}}, to_date(add_days(1, {{2015,2,28},{0,0,0}}))), + ?_assertEqual({{2015,3,3},{0,0,0}}, to_date(add_days(3, {{2015,2,28},{0,0,0}}))), + ?_assertEqual({{2017,1,2},{0,0,0}}, beginning_week({{2017,1,2},{0,0,0}})), + ?_assertEqual({{2017,1,2},{0,0,0}}, beginning_week({{2017,1,3},{0,0,0}})), + ?_assertEqual({{2017,1,3},{0,0,0}}, beginning_week(2, {{2017,1,4},{0,0,0}})), + ?_assertEqual({{2016,12,29},{0,0,0}}, beginning_week(4, {{2017,1,4},{0,0,0}})), + ?_assertEqual({{2016,12,31},{0,0,0}}, beginning_week(6, {{2017,1,6},{0,0,0}})), + ?_assertEqual({{2017,1,1},{0,0,0}}, beginning_week(7, {{2017,1,6},{0,0,0}})), + + ?_assertEqual(0, age("1981-01-15", "1981-12-31")), + ?_assertEqual(39, age("1981-01-15", "2020-01-15")), + ?_assertEqual(39, age("1981-01-15", "2020-01-15 12am")), + ?_assertEqual(38, age("1981-01-15", "2020-01-14")), + ?_assertEqual(38, age("1981-01-15", "2020-01-14 11:59pm")), + + %% checking pre-unix-epoch + ?_assertEqual(100, age("1901-01-01","2001-01-01")), + ?_assertEqual(20, age("1900-01-01", "1920-01-01")), + ?_assertEqual(0, age_days("2020-11-20 12am","2020-11-20 11:59:59pm")), + ?_assertEqual(1, age_days("2020-11-19 11:59:59pm","2020-11-20 11:59:59pm")), + ?_assertEqual(7, age_days("2020-11-20","2020-11-27")), + ?_assertEqual(7, age_days("2020-11-27","2020-12-04")) + + ]}. + +preserve_ms_true_tests(_) -> + application:set_env(qdate, preserve_ms, true), + {inorder, [ + ?_assertEqual({{2021,5,8},{23,0,16,472000}}, qdate:to_date(<<"UTC">>,<<"2021-5-09 0:0:16.472+01:00">>)), + ?_assertEqual(<<"2021-05-08T23:00:16.472000+00:00">>, qdate:to_string(<<"Y-m-d\\TH:i:s.fP">>, <<"UTC">>,<<"2021-5-09 0:0:16.472+01:00">>)), + ?_assertEqual(<<"2021-05-08T23:00:16+00:00">>, qdate:to_string(<<"Y-m-d\\TH:i:sP">>, <<"UTC">>,<<"2021-5-09 0:0:16.472+01:00">>)), + ?_assertEqual(<<"2021-05-08T23:00:16.472000+00:00">>, qdate:to_string(<<"c">>, <<"UTC">>,<<"2021-5-09 0:0:16.472+01:00">>)) + ]}. + +preserve_ms_false_tests(_) -> + application:set_env(qdate, preserve_ms, false), + {inorder, [ + ?_assertEqual({{2021,5,8},{23,0,16}}, qdate:to_date(<<"UTC">>,<<"2021-5-09 0:0:16.472+01:00">>)), + ?_assertEqual(<<"2021-05-08T23:00:16.0+00:00">>, qdate:to_string(<<"Y-m-d\\TH:i:s.fP">>, <<"UTC">>,<<"2021-5-09 0:0:16.472+01:00">>)), + ?_assertEqual(<<"2021-05-08T23:00:16+00:00">>, qdate:to_string(<<"Y-m-d\\TH:i:sP">>, <<"UTC">>,<<"2021-5-09 0:0:16.472+01:00">>)), + ?_assertEqual(<<"2021-05-08T23:00:16+00:00">>, qdate:to_string(<<"c">>, <<"UTC">>,<<"2021-5-09 0:0:16.472+01:00">>)) + ]}. + start_test() -> - application:start(qdate), + qdate:start(), set_timezone(?SELF_TZ), set_timezone(?SITE_KEY,?SITE_TZ), set_timezone(?USER_KEY,?USER_TZ), register_parser(compressed,fun compressed_parser/1), register_parser(microsoft_date,fun microsoft_parser/1), + register_parser(parse_relative, fun parse_relative/1), register_format(shortdate,"n/j/Y"), register_format(longdate,"n/j/Y g:ia"). @@ -568,7 +1514,7 @@ compressed_parser(_) -> microsoft_parser(FloatDate) when is_float(FloatDate) -> try - DaysSince1900 = floor(FloatDate), + DaysSince1900 = flooring(FloatDate), Days0to1900 = calendar:date_to_gregorian_days(1900,1,1), GregorianDays = Days0to1900 + DaysSince1900, Date = calendar:gregorian_days_to_date(GregorianDays), @@ -585,3 +1531,5 @@ microsoft_parser(_) -> stop_test(_) -> ok. + +-endif. diff --git a/src/qdate_app.erl b/src/qdate_app.erl index 757de7e..36a8289 100644 --- a/src/qdate_app.erl +++ b/src/qdate_app.erl @@ -1,7 +1,3 @@ -% vim: ts=4 sw=4 et -% Copyright (c) 2013 Jesse Gumm -% See LICENSE for licensing information. - -module(qdate_app). -behaviour(application). @@ -9,12 +5,11 @@ %% Application callbacks -export([start/2, stop/1]). -%% =================================================================== -%% Application callbacks -%% =================================================================== - start(_StartType, _StartArgs) -> qdate_sup:start_link(). + stop(_State) -> ok. + + diff --git a/src/qdate_srv.erl b/src/qdate_srv.erl index cdf74af..731b8b1 100644 --- a/src/qdate_srv.erl +++ b/src/qdate_srv.erl @@ -1,22 +1,10 @@ % vim: ts=4 sw=4 et -% Copyright (c) 2013 Jesse Gumm +% Copyright (c) 2013-2021 Jesse Gumm % See LICENSE for licensing information. -module(qdate_srv). -behaviour(gen_server). --define(SRV, ?MODULE). - --export([ - start_link/0, - init/1, - handle_call/3, - handle_cast/2, - handle_info/2, - code_change/3, - terminate/2 -]). - -export([ set_timezone/1, set_timezone/2, @@ -33,133 +21,172 @@ register_format/2, get_format/1, - deregister_format/1 + deregister_format/1, + get_formats/0 ]). + +%% API +-export([start_link/0]). + +%% gen_server callbacks +-export([init/1, + handle_call/3, + handle_cast/2, + handle_info/2, + terminate/2, + code_change/3]). + + +%% Simple wrappers for unique keys +-define(BASETAG, qdate_var). +-define(KEY(Name), {?BASETAG, Name}). + +-define(TZTAG, qdate_tz). +-define(TZKEY(Name), {?TZTAG, Name}). +-define(PARSERTAG, qdate_parser). +-define(PARSERKEY(Name), {?PARSERTAG, Name}). +-define(FORMATTAG, qdate_format). +-define(FORMATKEY(Name), {?FORMATTAG, Name}). + +-define(SERVER, ?MODULE). +-define(TABLE, ?MODULE). + +-record(state, {}). + +start_link() -> + gen_server:start_link({local, ?SERVER}, ?MODULE, [], []). + +init([]) -> + error_logger:info_msg("Creating qdate ETS Table: ~p",[?TABLE]), + ?TABLE = ets:new(?TABLE, [public, {read_concurrency, true}, named_table]), + {ok, #state{}}. + +handle_call({set, Key, Val}, _From, State) -> + ets:insert(?TABLE, {Key, Val}), + {reply, ok, State}; +handle_call({unset, Key}, _From, State) -> + ets:delete(?TABLE, Key), + {reply, ok, State}; +handle_call(_, _From, State) -> + {reply, invalid_request, State}. + +handle_cast(_Msg, State) -> + {noreply, State}. + +handle_info(_Info, State) -> + {noreply, State}. + +terminate(_Reason, _State) -> + ok. + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + + + %% PUBLIC API FUNCTIONS -start_link() -> - gen_server:start_link({local, ?SRV}, ?MODULE, [], []). - set_timezone(TZ) -> - set_timezone(self(),TZ). + put_pd(?TZTAG, TZ). -set_timezone(Key,TZ) -> - ok = gen_server:call(?SRV,{set_timezone,Key,TZ}). +set_timezone(Key, TZ) -> + set_env(?TZKEY(Key), TZ). get_timezone() -> - get_timezone(self()). + get_pd(?TZTAG). get_timezone(Key) -> - gen_server:call(?SRV,{get_timezone,Key}). + get_env(?TZKEY(Key)). clear_timezone() -> - clear_timezone(self()). + unset_pd(?TZTAG). clear_timezone(Key) -> - ok = gen_server:call(?SRV, {clear_timezone, Key}). + unset_env(?TZKEY(Key)). register_parser(Parser) when is_function(Parser,1) -> register_parser(erlang:make_ref(),Parser). register_parser(Key,Parser) when is_function(Parser,1) -> - Key = gen_server:call(?SRV,{register_parser,Key,Parser}). + set_env(?PARSERKEY(Key), Parser). deregister_parser(Key) -> - ok = gen_server:call(?SRV,{deregister_parser,Key}). + unset_env(?PARSERKEY(Key)). deregister_parsers() -> - ok = gen_server:call(?SRV,{deregister_parsers}). + [deregister_parser(Key) || {Key, _} <- get_parsers()]. get_parsers() -> - gen_server:call(?SRV,{get_parsers}). + get_all_env(?PARSERTAG). -register_format(Key,Format) -> - ok = gen_server:call(?SRV,{register_format,Key,Format}). +register_format(Key, Format) -> + set_env(?FORMATKEY(Key), Format). get_format(Key) -> - gen_server:call(?SRV,{get_format,Key}). + get_env(?FORMATKEY(Key)). deregister_format(Key) -> - ok = gen_server:call(?SRV,{deregister_format,Key}). - - -%% SERVER FUNCTIONS - --record(state, {tz, parsers, formats}). - -init(_) -> - State = #state{tz=dict:new(),parsers=dict:new(),formats=dict:new()}, - {ok, State}. - -handle_cast(_,State) -> - {noreply, State}. - -handle_info({'DOWN', MonitorRef, process, Pid, _Reason}, State) -> - erlang:demonitor(MonitorRef), - NewTZ = dict:erase(Pid, State#state.tz), - NewParsers = dict:erase(Pid, State#state.parsers), - NewFormats = dict:erase(Pid, State#state.formats), - NewState = State#state{tz=NewTZ, parsers=NewParsers, formats=NewFormats}, - {noreply, NewState }; -handle_info(_, State) -> - {noreply, State}. - -handle_call({set_timezone,Key,TZ}, _From, State) -> - monitor_if_pid(Key), - NewTZ = dict:store(Key, TZ, State#state.tz), - NewState = State#state{tz=NewTZ}, - {reply, ok, NewState}; -handle_call({clear_timezone,Key},_From, State) -> - NewTZ = dict:erase(Key, State#state.tz), - NewState = State#state{tz=NewTZ}, - {reply, ok, NewState}; -handle_call({get_timezone,Key},_From, State) -> - Reply = case dict:find(Key, State#state.tz) of - error -> undefined; - {ok,TZ} -> TZ - end, - {reply, Reply, State}; - -handle_call({register_parser,Key,Parser},_From,State) -> - NewParsers = dict:store(Key, Parser, State#state.parsers), - NewState = State#state{parsers=NewParsers}, - {reply, Key, NewState}; -handle_call({get_parsers},_From,State) -> - Reply = dict:to_list(State#state.parsers), - {reply, Reply, State}; -handle_call({deregister_parser,Key},_From,State) -> - NewParsers = dict:erase(Key, State#state.parsers), - NewState = State#state{parsers=NewParsers}, - {reply, ok, NewState}; -handle_call({deregister_parsers},_From,State) -> - NewState = State#state{parsers=dict:new()}, - {reply, ok, NewState}; - -handle_call({register_format,Key,Format},_From,State) -> - NewFormats = dict:store(Key, Format, State#state.formats), - NewState = State#state{formats=NewFormats}, - {reply, ok, NewState}; -handle_call({get_format,Key},_From,State) -> - Reply = case dict:find(Key, State#state.formats) of - error -> undefined; - {ok, Format} -> Format - end, - {reply, Reply,State}; -handle_call({deregister_format,Key},_From,State) -> - NewFormats = dict:erase(Key, State#state.formats), - NewState = State#state{formats=NewFormats}, - {reply, ok, NewState}. - -terminate(_Reason, _State) -> - ok. - -code_change(_OldVersion, State, _Extra) -> - {ok, State}. + unset_env(?FORMATKEY(Key)). + +get_formats() -> + get_all_env(?FORMATTAG). %% PRIVATE TOOLS -monitor_if_pid(Key) when is_pid(Key) -> - erlang:monitor(process,Key); -monitor_if_pid(_) -> - do_nothing. +%% App Vars + +set_env(Key, Val) -> + gen_server:call(?SERVER, {set, ?KEY(Key), Val}). + +get_env(Key) -> + get_env(Key, undefined). + +get_env(Key, Default) -> + case ets:lookup(?TABLE, ?KEY(Key)) of + [{__Key, Val}] -> Val; + [] -> Default + end. + +unset_env(Key) -> + gen_server:call(?SERVER, {unset, ?KEY(Key)}). + +get_all_env(FilterTag) -> + try ets:tab2list(?TABLE) of + All -> + [{Key, V} || {{?BASETAG, {Tag, Key}}, V} <- All, Tag==FilterTag] + catch + error:badarg:S -> + Msg = maybe_start_msg(), + logger:error(Msg), + + error({qdate_get_all_env_failed, #{ + original_error => {error, badarg, S} + }}) + end. + +maybe_start_msg() -> + "Attempting to read qdate environment failed (qdate_srv:get_all_env/1).\n" + "qdate may not have been started properly.\n" + "Please ensure qdate is started properly by either:\n" + "* Putting qdate in the 'applications' key in your .app.src or .app file, or\n" + "* Starting it manually with application:ensure_all_started(qdate) or qdate:start().". + + +%% ProcDic Vars + +get_pd(Key) -> + erlang:get(?KEY(Key)). + +put_pd(Key, Val) -> + erlang:put(?KEY(Key), Val), + ok. + +unset_pd(Key) -> + put_pd(Key, undefined). + + + + + + diff --git a/src/qdate_sup.erl b/src/qdate_sup.erl index 2f47585..492dd8e 100644 --- a/src/qdate_sup.erl +++ b/src/qdate_sup.erl @@ -1,7 +1,3 @@ -% vim: ts=4 sw=4 et -% Copyright (c) 2013 Jesse Gumm -% See LICENSE for licensing information. - -module(qdate_sup). -behaviour(supervisor). @@ -12,21 +8,25 @@ %% Supervisor callbacks -export([init/1]). -%% Helper macro for declaring children of supervisor --define(CHILD(I, Type), {I, {I, start_link, []}, permanent, 5000, Type, [I]}). - -%% =================================================================== -%% API functions -%% =================================================================== +-define(SERVER, ?MODULE). start_link() -> - supervisor:start_link({local, ?MODULE}, ?MODULE, []). - -%% =================================================================== -%% Supervisor callbacks -%% =================================================================== + supervisor:start_link({local, ?SERVER}, ?MODULE, []). init([]) -> - Server = ?CHILD(qdate_srv, worker), - {ok, { {one_for_one, 5, 10}, [Server]} }. + + SupFlags = #{}, + + ChildSpec = #{ + id=>qdate_srv, + start=>{qdate_srv, start_link, []} + }, + + {ok, {SupFlags, [ChildSpec]}}. + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== + +