├── .credo.exs ├── .dockerignore ├── .formatter.exs ├── .gitignore ├── .tool-versions ├── .travis.yml ├── CHANGELOG.md ├── LICENSE.md ├── Makefile ├── README.md ├── bench └── bench.exs ├── coveralls.json ├── install_libical.sh ├── lib ├── excal.ex └── excal │ ├── interface │ └── recurrence │ │ └── iterator.ex │ └── recurrence │ ├── iterator.ex │ └── stream.ex ├── mix.exs ├── mix.lock ├── priv ├── plts │ └── .gitkeep └── recurrence │ └── .gitkeep ├── src └── recurrence │ └── iterator.c └── test ├── excal ├── interface │ └── recurrence │ │ └── iterator_test.exs └── recurrence │ ├── iterator_test.exs │ └── stream_test.exs └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any exec using `mix credo -C `. If no exec name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: ["lib/", "test/", "bench/"], 25 | excluded: [~r"/_build/", ~r"/deps/"] 26 | }, 27 | # 28 | # If you create your own checks, you must specify the source files for 29 | # them here, so they can be loaded by Credo before running the analysis. 30 | # 31 | requires: [], 32 | # 33 | # If you want to enforce a style guide and need a more traditional linting 34 | # experience, you can change `strict` to `true` below: 35 | # 36 | strict: false, 37 | # 38 | # If you want to use uncolored output by default, you can change `color` 39 | # to `false` below: 40 | # 41 | color: true, 42 | # 43 | # You can customize the parameters of any check by adding a second element 44 | # to the tuple. 45 | # 46 | # To disable a check put `false` as second element: 47 | # 48 | # {Credo.Check.Design.DuplicatedCode, false} 49 | # 50 | checks: [ 51 | # 52 | ## Consistency Checks 53 | # 54 | {Credo.Check.Consistency.ExceptionNames, []}, 55 | {Credo.Check.Consistency.LineEndings, []}, 56 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 57 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 58 | {Credo.Check.Consistency.SpaceInParentheses, []}, 59 | {Credo.Check.Consistency.TabsOrSpaces, []}, 60 | 61 | # 62 | ## Design Checks 63 | # 64 | # You can customize the priority of any check 65 | # Priority values are: `low, normal, high, higher` 66 | # 67 | {Credo.Check.Design.AliasUsage, [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 68 | # You can also customize the exit_status of each check. 69 | # If you don't want TODO comments to cause `mix credo` to fail, just 70 | # set this value to 0 (zero). 71 | # 72 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 73 | {Credo.Check.Design.TagFIXME, []}, 74 | 75 | # 76 | ## Readability Checks 77 | # 78 | {Credo.Check.Readability.AliasOrder, []}, 79 | {Credo.Check.Readability.FunctionNames, []}, 80 | {Credo.Check.Readability.LargeNumbers, []}, 81 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 82 | {Credo.Check.Readability.ModuleAttributeNames, []}, 83 | {Credo.Check.Readability.ModuleDoc, []}, 84 | {Credo.Check.Readability.ModuleNames, []}, 85 | {Credo.Check.Readability.ParenthesesInCondition, []}, 86 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 87 | {Credo.Check.Readability.PredicateFunctionNames, []}, 88 | {Credo.Check.Readability.PreferImplicitTry, []}, 89 | {Credo.Check.Readability.RedundantBlankLines, []}, 90 | {Credo.Check.Readability.Semicolons, []}, 91 | {Credo.Check.Readability.SpaceAfterCommas, []}, 92 | {Credo.Check.Readability.StringSigils, []}, 93 | {Credo.Check.Readability.TrailingBlankLine, []}, 94 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 95 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 96 | {Credo.Check.Readability.VariableNames, []}, 97 | 98 | # 99 | ## Refactoring Opportunities 100 | # 101 | {Credo.Check.Refactor.CondStatements, []}, 102 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 103 | {Credo.Check.Refactor.FunctionArity, []}, 104 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 105 | {Credo.Check.Refactor.MapInto, []}, 106 | {Credo.Check.Refactor.MatchInCondition, []}, 107 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 108 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 109 | {Credo.Check.Refactor.Nesting, []}, 110 | {Credo.Check.Refactor.PipeChainStart, 111 | [ 112 | excluded_argument_types: [:atom, :binary, :fn, :keyword, :number], 113 | excluded_functions: [] 114 | ]}, 115 | {Credo.Check.Refactor.UnlessWithElse, []}, 116 | 117 | # 118 | ## Warnings 119 | # 120 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 121 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 122 | {Credo.Check.Warning.IExPry, []}, 123 | {Credo.Check.Warning.IoInspect, []}, 124 | {Credo.Check.Warning.LazyLogging, []}, 125 | {Credo.Check.Warning.OperationOnSameValues, []}, 126 | {Credo.Check.Warning.OperationWithConstantResult, []}, 127 | {Credo.Check.Warning.RaiseInsideRescue, []}, 128 | {Credo.Check.Warning.UnusedEnumOperation, []}, 129 | {Credo.Check.Warning.UnusedFileOperation, []}, 130 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 131 | {Credo.Check.Warning.UnusedListOperation, []}, 132 | {Credo.Check.Warning.UnusedPathOperation, []}, 133 | {Credo.Check.Warning.UnusedRegexOperation, []}, 134 | {Credo.Check.Warning.UnusedStringOperation, []}, 135 | {Credo.Check.Warning.UnusedTupleOperation, []}, 136 | 137 | # 138 | # Controversial and experimental checks (opt-in, just replace `false` with `[]`) 139 | # 140 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 141 | {Credo.Check.Design.DuplicatedCode, false}, 142 | {Credo.Check.Readability.Specs, false}, 143 | {Credo.Check.Refactor.ABCSize, false}, 144 | {Credo.Check.Refactor.AppendSingleItem, false}, 145 | {Credo.Check.Refactor.DoubleBooleanNegation, false}, 146 | {Credo.Check.Refactor.ModuleDependencies, false}, 147 | {Credo.Check.Refactor.VariableRebinding, false}, 148 | {Credo.Check.Warning.MapGetUnsafePass, false}, 149 | {Credo.Check.Warning.UnsafeToAtom, false} 150 | 151 | # 152 | # Custom checks can be created using `mix credo.gen.check`. 153 | # 154 | ] 155 | } 156 | ] 157 | } 158 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !lib 3 | !src 4 | !test 5 | !.formatter.exs 6 | !coveralls.json 7 | !Makefile 8 | !mix.exs 9 | !mix.lock 10 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | line_length: 120 5 | ] 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | excal-*.tar 24 | 25 | # ignore build shared objects 26 | /priv/**/*.so 27 | 28 | # ignore local plts 29 | /priv/plts/*.plt 30 | /priv/plts/*.plt.hash 31 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.9.4-otp-22 2 | erlang 22.1.8 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | 3 | language: elixir 4 | 5 | otp_release: 6 | - 21.3 7 | - 22.2 8 | 9 | elixir: 10 | - "1.8" 11 | - "1.9" 12 | 13 | cache: 14 | directories: 15 | - priv/plts 16 | 17 | install: 18 | - mix local.hex --force 19 | - mix local.rebar --force 20 | - mix deps.get 21 | - ./install_libical.sh 22 | 23 | script: 24 | - mix compile --warnings-as-errors 25 | - mix format --check-formatted 26 | - mix dialyzer --halt-exit-status 27 | - mix credo --strict 28 | - mix coveralls.json 29 | 30 | after_success: 31 | - mix inch.report 32 | - bash <(curl -s https://codecov.io/bash) 33 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased][] 9 | 10 | ## [0.3.2][] - 2019-06-15 11 | 12 | ### Updated 13 | 14 | - Updated dependencies 15 | 16 | ## [0.3.1][] - 2019-03-18 17 | 18 | ### Fixed 19 | 20 | - Fixed typespec for Stream.new - Thanks [@bismark](https://github.com/bismark) - [#29](https://github.com/peek-travel/excal/pull/29) 21 | - Fixed Makefile not recompiling when C source changes - [0a5081bb](https://github.com/peek-travel/excal/commit/0a5081bb865b712cc1e573cce423b320719aa9c3) 22 | - Fixed dialyzer error on `Excal.Interface.Recurrence.Iterator.load_nifs/0` - [#30](https://github.com/peek-travel/excal/pull/30) 23 | 24 | ## [0.3.0][] - 2019-02-12 25 | 26 | Thanks [@bismark](https://github.com/bismark) for the following fixes in 27 | PR [#23](https://github.com/peek-travel/excal/pull/23) 28 | 29 | ### Fixed 30 | 31 | - Fix NIF path issue when including excal as a dependency 32 | 33 | ### Changed 34 | 35 | - Use elixir_make for a more robust build process 36 | - Update Makefile to not recompile every time 37 | 38 | ## [0.2.0][] - 2019-02-02 39 | 40 | ### Added 41 | 42 | - Documentation and typespecs to all public functions. 43 | 44 | ### Fixed 45 | 46 | - Removed usage of unreleased libical features that were only available in libical master. Excal now compiles with libical >= 3.0.0 47 | 48 | ## 0.1.0 - 2018-07-04 49 | 50 | ### Initial release 51 | 52 | [Unreleased]: https://github.com/peek-travel/excal/compare/0.3.2...HEAD 53 | [0.3.2]: https://github.com/peek-travel/excal/compare/0.3.1...0.3.2 54 | [0.3.1]: https://github.com/peek-travel/excal/compare/0.3.0...0.3.1 55 | [0.3.0]: https://github.com/peek-travel/excal/compare/0.2.0...0.3.0 56 | [0.2.0]: https://github.com/peek-travel/excal/compare/0.1.0...0.2.0 57 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2018 Peek Travel, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | CFLAGS = -O3 -Wall 2 | 3 | ERLANG_PATH = $(shell erl -eval 'io:format("~s", [lists:concat([code:root_dir(), "/erts-", erlang:system_info(version), "/include"])])' -s init stop -noshell) 4 | CFLAGS += -I$(ERLANG_PATH) 5 | 6 | ifneq ($(OS),Windows_NT) 7 | CFLAGS += -fPIC 8 | 9 | ifeq ($(shell uname),Darwin) 10 | LDFLAGS += -dynamiclib -undefined dynamic_lookup 11 | endif 12 | endif 13 | 14 | SOURCE_FILES = src/recurrence/iterator.c 15 | LIBRARY_DIRECTORY = priv/recurrence 16 | LIBRARY = $(LIBRARY_DIRECTORY)/iterator.so 17 | 18 | all: $(SOURCE_FILES) $(LIBRARY) 19 | 20 | $(LIBRARY): $(LIBRARY_DIRECTORY) $(SOURCE_FILES) 21 | $(CC) $(CFLAGS) -shared -o $(LIBRARY) $(SOURCE_FILES) -lical $(LDFLAGS) 22 | 23 | $(LIBRARY_DIRECTORY): 24 | mkdir -p priv/recurrence 25 | 26 | clean: 27 | rm -r "priv/recurrence/iterator.so" 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Excal 2 | 3 | [![Build Status](https://travis-ci.com/peek-travel/excal.svg?branch=master)](https://travis-ci.org/peek-travel/excal) 4 | [![codecov](https://codecov.io/gh/peek-travel/excal/branch/master/graph/badge.svg)](https://codecov.io/gh/peek-travel/excal) 5 | [![SourceLevel](https://app.sourcelevel.io/github/peek-travel/excal.svg)](https://app.sourcelevel.io/github/peek-travel/excal) 6 | [![Hex.pm Version](https://img.shields.io/hexpm/v/excal.svg?style=flat)](https://hex.pm/packages/excal) 7 | [![License](https://img.shields.io/hexpm/l/excal.svg)](LICENSE.md) 8 | [![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=peek-travel/excal)](https://dependabot.com) 9 | 10 | NIF bindings to [libical](https://libical.github.io/libical/) for Elixir. 11 | 12 | This library is still a **WIP**, but works well for basic calls to libical's recurrence iterators. 13 | 14 | ## Requirements 15 | 16 | Excal requires that libical (and its development headers) be present on your system, and that it's at least version 3.0.0. 17 | 18 | ### macOS 19 | 20 | You can easily install `libical` using [Homebrew](https://brew.sh/) on macOS: 21 | 22 | ```sh 23 | brew install libical 24 | ``` 25 | 26 | Homebrew provides the latest version, as of this writing, which is `3.0.7`. 27 | 28 | ### linux 29 | 30 | Use favorite package manager to install `libical` (may be named slightly differently depending on distro), or maybe `libical-dev` if you're using a Debian based distro like Ubuntu. 31 | 32 | NOTE: Make sure you're getting at least version `3.0.0`. Anything below will prevent Excal from compiling. 33 | 34 | ### Windows 35 | 36 | I'm not currently aware of how to get this working on Windows, but if someone wants to try and let me know how, I will add instructions to this readme. 37 | 38 | ## Installation 39 | 40 | The package can be installed by adding `excal` to your list of dependencies in `mix.exs`: 41 | 42 | ```elixir 43 | def deps do 44 | [ 45 | {:excal, "~> 0.3.2"} 46 | ] 47 | end 48 | ``` 49 | 50 | Documentation can be found at [https://hexdocs.pm/excal](https://hexdocs.pm/excal). 51 | -------------------------------------------------------------------------------- /bench/bench.exs: -------------------------------------------------------------------------------- 1 | defmodule Excal.Benchmarks do 2 | def test_daily_date do 3 | {:ok, stream} = Excal.Recurrence.Stream.new("FREQ=DAILY", ~D[2018-09-09]) 4 | Enum.take(stream, 1000) 5 | end 6 | 7 | def test_daily_date_with_end do 8 | {:ok, stream} = Excal.Recurrence.Stream.new("FREQ=DAILY", ~D[2018-09-09], until: ~D[2030-09-09]) 9 | Enum.take(stream, 1000) 10 | end 11 | 12 | def test_daily_datetime do 13 | {:ok, stream} = Excal.Recurrence.Stream.new("FREQ=DAILY", ~N[2018-09-09 12:30:00]) 14 | Enum.take(stream, 1000) 15 | end 16 | 17 | def test_weekly_date do 18 | {:ok, stream} = Excal.Recurrence.Stream.new("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR", ~D[2018-09-09]) 19 | Enum.take(stream, 1000) 20 | end 21 | 22 | def test_weekly_datetime do 23 | {:ok, stream} = 24 | Excal.Recurrence.Stream.new( 25 | "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;BYHOUR=12,14;BYMINUTE=0,30", 26 | ~N[2018-09-09 12:30:00] 27 | ) 28 | 29 | Enum.take(stream, 1000) 30 | end 31 | end 32 | 33 | alias Excal.Benchmarks 34 | 35 | Benchee.run( 36 | %{ 37 | "test_daily_date" => fn -> Benchmarks.test_daily_date() end, 38 | "test_daily_date_with_end" => fn -> Benchmarks.test_daily_date_with_end() end, 39 | "test_daily_datetime" => fn -> Benchmarks.test_daily_datetime() end, 40 | "test_weekly_date" => fn -> Benchmarks.test_weekly_date() end, 41 | "test_weekly_datetime" => fn -> Benchmarks.test_weekly_datetime() end 42 | }, 43 | memory_time: 2 44 | ) 45 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "lib/excal/interface" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /install_libical.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # This script is intended only to be used in CI environments, don't use it to install libical on your system! 4 | 5 | LIBICAL_VERSION="v3.0.7" 6 | 7 | git clone --branch $LIBICAL_VERSION https://github.com/libical/libical.git 8 | mkdir libical/build 9 | 10 | cd libical/build 11 | 12 | cmake -DWITH_CXX_BINDINGS=false \ 13 | -DICAL_BUILD_DOCS=false \ 14 | -DICAL_GLIB=false \ 15 | -DCMAKE_RELEASE_TYPE=Release \ 16 | -DCMAKE_INSTALL_PREFIX=/usr \ 17 | -DCMAKE_INSTALL_LIBDIR=/usr/lib \ 18 | -DSHARED_ONLY=true \ 19 | .. 20 | 21 | make 22 | sudo make install 23 | -------------------------------------------------------------------------------- /lib/excal.ex: -------------------------------------------------------------------------------- 1 | defmodule Excal do 2 | @moduledoc """ 3 | Excal provides basic Elixir bindings to libical, the reference implementation of the iCalendar spec written in C. 4 | 5 | There are currently two possible ways to use Excal: 6 | 7 | * `Excal.Recurrence.Iterator` 8 | * A simple Elixir wrapper around a libical recurrence iterator. 9 | * `Excal.Recurrence.Stream` 10 | * An Elixir `Stream` wrapper for the above iterator. 11 | 12 | Refer to either of the two modules above for documentation on how to use them. 13 | """ 14 | 15 | @typedoc """ 16 | Recurrence iterators and streams can operate using either Date or NaiveDateTime structs. 17 | """ 18 | @type date_or_datetime :: Date.t() | NaiveDateTime.t() 19 | end 20 | -------------------------------------------------------------------------------- /lib/excal/interface/recurrence/iterator.ex: -------------------------------------------------------------------------------- 1 | defmodule Excal.Interface.Recurrence.Iterator do 2 | @moduledoc false 3 | 4 | @on_load :load_nifs 5 | 6 | @type initialization_error :: :invalid_dtstart | :invalid_rrule | :bad_iterator 7 | @type iterator_start_error :: :invalid_start | :start_invalid_for_rule 8 | 9 | @doc false 10 | def load_nifs, do: [:code.priv_dir(:excal), 'recurrence/iterator'] |> :filename.join() |> :erlang.load_nif(0) 11 | 12 | @spec new(String.t(), String.t()) :: {:ok, reference()} | {:error, initialization_error()} 13 | def new(_rrule, _dtstart), do: :erlang.nif_error("NIF new/2 not implemented") 14 | 15 | @spec set_start(reference(), String.t()) :: :ok | {:error, iterator_start_error()} 16 | def set_start(_iterator, _start), do: :erlang.nif_error("NIF set_start/2 not implemented") 17 | 18 | @spec next(reference()) :: nil | :calendar.date() | :calendar.datetime() 19 | def next(_iterator), do: :erlang.nif_error("NIF next/1 not implemented") 20 | end 21 | -------------------------------------------------------------------------------- /lib/excal/recurrence/iterator.ex: -------------------------------------------------------------------------------- 1 | defmodule Excal.Recurrence.Iterator do 2 | @moduledoc """ 3 | Elixir wrapper around a libical recurrence iterator. 4 | 5 | The iterator is fundamentally a mutable resource, so it acts more like a stateful reference, rather than an immutable 6 | data structure. To create one, you will need a iCalendar recurrence rule string and a start date or datetime. 7 | """ 8 | 9 | alias __MODULE__ 10 | alias Excal.Interface.Recurrence.Iterator, as: Interface 11 | 12 | @enforce_keys [:iterator, :type, :rrule, :dtstart] 13 | defstruct iterator: nil, type: nil, rrule: nil, dtstart: nil, from: nil, until: nil, finished: false 14 | 15 | @typedoc """ 16 | A struct that represents a recurrence iterator. Consider all the fields to be internal implementation detail at this 17 | time, as they may change without notice. 18 | """ 19 | @type t :: %Iterator{ 20 | iterator: reference(), 21 | type: Date | NaiveDateTime, 22 | rrule: String.t(), 23 | dtstart: Excal.date_or_datetime(), 24 | from: nil | Excal.date_or_datetime(), 25 | until: nil | Excal.date_or_datetime(), 26 | finished: boolean() 27 | } 28 | 29 | @typedoc """ 30 | Possible errors returned from iterator initialization. 31 | """ 32 | @type initialization_error :: :unsupported_datetime_type | Interface.initialization_error() 33 | 34 | @typedoc """ 35 | Possible errors returned from setting the start date or datetime of an iterator. 36 | """ 37 | @type iterator_start_error :: :unsupported_datetime_type | :datetime_type_mismatch | Interface.iterator_start_error() 38 | 39 | @doc """ 40 | Creates a new recurrence iterator from an iCalendar recurrence rule (RRULE) string and a start date or datetime. 41 | 42 | ## Examples 43 | 44 | A daily schedule starting on January 1st 2019: 45 | 46 | iex> {:ok, iter} = Iterator.new("FREQ=DAILY", ~D[2019-01-01]) 47 | ...> {_occurrence, iter} = Iterator.next(iter) 48 | ...> {_occurrence, iter} = Iterator.next(iter) 49 | ...> {occurrence, _iter} = Iterator.next(iter) 50 | ...> occurrence 51 | ~D[2019-01-03] 52 | 53 | A bi-weekly schedule every Monday, Wednesday and Friday: 54 | 55 | iex> {:ok, iter} = Iterator.new("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR", ~D[2019-01-01]) 56 | ...> {occurrence, _iter} = Iterator.next(iter) 57 | ...> occurrence 58 | ~D[2019-01-02] 59 | """ 60 | @spec new(String.t(), Excal.date_or_datetime()) :: {:ok, t()} | {:error, initialization_error()} 61 | def new(rrule, date_or_datetime) do 62 | with {:ok, type, dtstart} <- to_ical_time_string(date_or_datetime), 63 | {:ok, iterator} <- Interface.new(rrule, dtstart) do 64 | {:ok, %Iterator{iterator: iterator, type: type, rrule: rrule, dtstart: date_or_datetime}} 65 | end 66 | end 67 | 68 | @doc """ 69 | Sets the start date or datetime for an existing iterator. 70 | 71 | The iterator's start time is not the same thing as the schedule's start time. At creation time, an iterator is given a 72 | recurrence rule string and a schedule start date or datetime, but the iterator's start can be some time farther in the 73 | future than the schedules start time. 74 | 75 | This can also be used to reset an existing iterator to a new starting time. 76 | 77 | NOTE: You cannot call `set_start/2` on an iterator whose RRULE contains a COUNT clause. 78 | 79 | ## Example 80 | 81 | Consider: an RRULE for Friday on every 3rd week starting January 1st 2016 might look like this: 82 | 83 | iex> {:ok, iter} = Iterator.new("FREQ=WEEKLY;INTERVAL=3", ~D[2016-01-01]) 84 | ...> {next_occurrence, _iter} = Iterator.next(iter) 85 | ...> next_occurrence 86 | ~D[2016-01-01] 87 | 88 | ...but if you only cared about the instances starting in 2019, you can't change the start date because that would 89 | affect the cadence of the "every 3rd week" part of the schedule. Instead, just tell the iterator to skip ahead until 90 | 2019: 91 | 92 | iex> {:ok, iter} = Iterator.new("FREQ=WEEKLY;INTERVAL=3", ~D[2016-01-01]) 93 | ...> {:ok, iter} = Iterator.set_start(iter, ~D[2019-01-01]) 94 | ...> {next_occurrence, _iter} = Iterator.next(iter) 95 | ...> next_occurrence 96 | ~D[2019-01-18] 97 | """ 98 | @spec set_start(t(), Excal.date_or_datetime()) :: {:ok, t()} | {:error, iterator_start_error()} 99 | def set_start(%Iterator{iterator: iterator_ref, type: type} = iterator, %type{} = date_or_datetime) do 100 | with {:ok, _, time_string} <- to_ical_time_string(date_or_datetime), 101 | :ok <- Interface.set_start(iterator_ref, time_string) do 102 | {:ok, %{iterator | from: date_or_datetime}} 103 | end 104 | end 105 | 106 | def set_start(%Iterator{}, _), do: {:error, :datetime_type_mismatch} 107 | def set_start(iterator, _), do: raise(ArgumentError, "invalid iterator: #{inspect(iterator)}") 108 | 109 | @doc """ 110 | Sets the end date or datetime for an existing iterator. 111 | 112 | Once an end time is set for an iterator, the iterator will return `nil` once it has reached the specified end. 113 | 114 | ## Example 115 | 116 | iex> {:ok, iter} = Iterator.new("FREQ=DAILY", ~D[2019-01-01]) 117 | ...> {:ok, iter} = Iterator.set_end(iter, ~D[2019-01-03]) 118 | ...> {_occurrence, iter} = Iterator.next(iter) 119 | ...> {_occurrence, iter} = Iterator.next(iter) 120 | ...> {occurrence, _iter} = Iterator.next(iter) 121 | ...> occurrence 122 | nil 123 | 124 | """ 125 | @spec set_end(t(), Excal.date_or_datetime()) :: {:ok, t()} | {:error, :datetime_type_mismatch} 126 | def set_end(%Iterator{type: type} = iterator, %type{} = date_or_datetime) do 127 | {:ok, %{iterator | until: date_or_datetime}} 128 | end 129 | 130 | def set_end(%Iterator{}, _), do: {:error, :datetime_type_mismatch} 131 | def set_end(iterator, _), do: raise(ArgumentError, "invalid iterator: #{inspect(iterator)}") 132 | 133 | @doc """ 134 | Returns the next date or datetime occurrence of an existing iterator. 135 | 136 | If the iterator has reached the end of the set described by the RRULE, or has reached the end time specified by 137 | `set_end/2`, it will return `nil`. 138 | 139 | ## Example 140 | 141 | iex> {:ok, iter} = Iterator.new("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR", ~D[2019-01-01]) 142 | ...> {occurrence, _iter} = Iterator.next(iter) 143 | ...> occurrence 144 | ~D[2019-01-02] 145 | """ 146 | @spec next(t()) :: {Excal.date_or_datetime(), t()} | {nil, t()} 147 | def next(%Iterator{finished: true} = iterator), do: {nil, iterator} 148 | 149 | def next(%Iterator{iterator: iterator_ref, type: type, until: until} = iterator) do 150 | occurrence = iterator_ref |> Interface.next() |> from_tuple(type) 151 | 152 | cond do 153 | is_nil(occurrence) -> 154 | {nil, %{iterator | finished: true}} 155 | 156 | is_nil(until) -> 157 | {occurrence, iterator} 158 | 159 | type.compare(occurrence, until) == :lt -> 160 | {occurrence, iterator} 161 | 162 | true -> 163 | {nil, %{iterator | finished: true}} 164 | end 165 | end 166 | 167 | defp to_ical_time_string(%Date{} = date), do: {:ok, Date, Date.to_iso8601(date, :basic)} 168 | 169 | defp to_ical_time_string(%NaiveDateTime{} = datetime), 170 | do: {:ok, NaiveDateTime, NaiveDateTime.to_iso8601(datetime, :basic)} 171 | 172 | defp to_ical_time_string(_), do: {:error, :unsupported_datetime_type} 173 | 174 | # NOTE: 175 | # Native Elixir Date and NaiveDateTime are heavy to initialize with `new` or `from_erl!` because it checks validity. 176 | # We're bypassing the validity check here, assuming that libical is giving us valid dates and times. 177 | 178 | defp from_tuple(nil, _), do: nil 179 | 180 | defp from_tuple({year, month, day}, Date), 181 | do: %Date{year: year, month: month, day: day, calendar: Calendar.ISO} 182 | 183 | defp from_tuple({{year, month, day}, {hour, minute, second}}, NaiveDateTime), 184 | do: %NaiveDateTime{ 185 | year: year, 186 | month: month, 187 | day: day, 188 | hour: hour, 189 | minute: minute, 190 | second: second, 191 | calendar: Calendar.ISO 192 | } 193 | end 194 | -------------------------------------------------------------------------------- /lib/excal/recurrence/stream.ex: -------------------------------------------------------------------------------- 1 | defmodule Excal.Recurrence.Stream do 2 | @moduledoc """ 3 | Generates Elixir streams from iCalendar recurrence rules (RRULE). 4 | 5 | This is the most idiomatic way of interacting with iCalendar recurrence rules in Elixir. The streams created here act 6 | like any other Elixir stream would act. 7 | """ 8 | 9 | alias Excal.Recurrence.Iterator 10 | 11 | @typedoc """ 12 | Valid options for `new/3`. 13 | """ 14 | @type option :: {:from, Excal.date_or_datetime()} | {:until, Excal.date_or_datetime()} 15 | 16 | @doc """ 17 | Creates a stream of date or datetime instances from the given recurrence rule string and schedule start time. 18 | 19 | It's also possible to set the start and end time of the stream using the `:from` and `:until` options. 20 | 21 | ## Options 22 | 23 | * `:from` - specifies the start date or datetime of the stream. 24 | * `:until` - specifies the end date or datetime of the stream. 25 | 26 | ## Examples 27 | 28 | An infinite stream of `Date` structs for every Monday, Wednesday and Friday: 29 | 30 | iex> {:ok, stream} = Stream.new("FREQ=WEEKLY;BYDAY=MO,WE,FR", ~D[2019-01-01]) 31 | ...> Enum.take(stream, 5) 32 | [ 33 | ~D[2019-01-02], 34 | ~D[2019-01-04], 35 | ~D[2019-01-07], 36 | ~D[2019-01-09], 37 | ~D[2019-01-11] 38 | ] 39 | 40 | A finite stream of `NaiveDateTime` using the `:from` and `:until` options: 41 | 42 | iex> opts = [from: ~N[2020-01-01 10:00:00], until: ~N[2020-06-01 10:00:00]] 43 | ...> {:ok, stream} = Stream.new("FREQ=MONTHLY;BYMONTHDAY=1", ~N[2019-01-01 10:00:00], opts) 44 | ...> Enum.to_list(stream) 45 | [ 46 | ~N[2020-01-01 10:00:00], 47 | ~N[2020-02-01 10:00:00], 48 | ~N[2020-03-01 10:00:00], 49 | ~N[2020-04-01 10:00:00], 50 | ~N[2020-05-01 10:00:00] 51 | ] 52 | """ 53 | @spec new(String.t(), Excal.date_or_datetime(), [option()]) :: 54 | {:ok, Enumerable.t()} | {:error, Iterator.initialization_error() | Iterator.iterator_start_error()} 55 | def new(rrule, dtstart, opts \\ []) do 56 | # The below call to make_stream will not return any errors until the stream is used, 57 | # so we initialize an iterator first to ensure it can be, to return any possible errors. 58 | # This iterator is not actually used though. 59 | with {:ok, _} <- make_iterator(rrule, dtstart, opts) do 60 | {:ok, make_stream(rrule, dtstart, opts)} 61 | end 62 | end 63 | 64 | defp make_iterator(rrule, dtstart, opts) do 65 | with {:ok, iterator} <- Iterator.new(rrule, dtstart) do 66 | process_options(iterator, opts) 67 | end 68 | end 69 | 70 | defp process_options(iterator, []), do: {:ok, iterator} 71 | 72 | defp process_options(iterator, [{:from, time} | rest]) do 73 | with {:ok, iterator} <- Iterator.set_start(iterator, time) do 74 | process_options(iterator, rest) 75 | end 76 | end 77 | 78 | defp process_options(iterator, [{:until, time} | rest]) do 79 | with {:ok, iterator} <- Iterator.set_end(iterator, time) do 80 | process_options(iterator, rest) 81 | end 82 | end 83 | 84 | defp make_stream(rrule, dtstart, opts) do 85 | Elixir.Stream.resource( 86 | fn -> 87 | {:ok, iterator} = make_iterator(rrule, dtstart, opts) 88 | iterator 89 | end, 90 | fn iterator -> 91 | case Iterator.next(iterator) do 92 | {nil, iterator} -> {:halt, iterator} 93 | {occurrence, iterator} -> {[occurrence], iterator} 94 | end 95 | end, 96 | fn _ -> nil end 97 | ) 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Excal.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.3.2" 5 | @source_url "https://github.com/peek-travel/excal" 6 | 7 | def project do 8 | [ 9 | app: :excal, 10 | compilers: [:elixir_make] ++ Mix.compilers(), 11 | version: @version, 12 | elixir: "~> 1.8", 13 | start_permanent: Mix.env() == :prod, 14 | description: description(), 15 | package: package(), 16 | deps: deps(), 17 | docs: docs(), 18 | source_url: @source_url, 19 | test_coverage: [tool: ExCoveralls], 20 | preferred_cli_env: preferred_cli_env(), 21 | make_clean: ["clean"], 22 | dialyzer: dialyzer() 23 | ] 24 | end 25 | 26 | defp dialyzer do 27 | [ 28 | plt_add_deps: :app_tree, 29 | plt_file: {:no_warn, "priv/plts/excal.plt"} 30 | ] 31 | end 32 | 33 | defp preferred_cli_env do 34 | [ 35 | coveralls: :test, 36 | "coveralls.html": :test, 37 | "coveralls.json": :test 38 | ] 39 | end 40 | 41 | def application do 42 | [ 43 | extra_applications: [:logger] 44 | ] 45 | end 46 | 47 | defp description do 48 | """ 49 | NIF bindings to libical providing icalendar rrule expansion. 50 | """ 51 | end 52 | 53 | defp package do 54 | [ 55 | files: ["lib", ".formatter.exs", "mix.exs", "README.md", "LICENSE.md", "CHANGELOG.md", "src", "Makefile"], 56 | maintainers: ["Chris Dosé "], 57 | licenses: ["MIT"], 58 | links: %{ 59 | "GitHub" => @source_url, 60 | "Readme" => "#{@source_url}/blob/#{@version}/README.md", 61 | "Changelog" => "#{@source_url}/blob/#{@version}/CHANGELOG.md" 62 | } 63 | ] 64 | end 65 | 66 | defp docs do 67 | [ 68 | main: "Excal", 69 | source_ref: @version, 70 | source_url: @source_url, 71 | extras: ["README.md", "LICENSE.md"], 72 | groups_for_modules: [ 73 | Recurrence: [Excal.Recurrence.Iterator, Excal.Recurrence.Stream] 74 | ] 75 | ] 76 | end 77 | 78 | defp deps do 79 | [ 80 | {:benchee, "~> 1.0", only: :dev, runtime: false}, 81 | {:credo, "~> 1.1", only: [:dev, :test], runtime: false}, 82 | {:dialyxir, "~> 1.1.0", only: [:dev, :test], runtime: false}, 83 | {:elixir_make, "~> 0.6", runtime: false}, 84 | {:ex_doc, "~> 0.21", only: :dev, runtime: false}, 85 | {:excoveralls, "~> 0.12", only: :test, runtime: false} 86 | ] 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "benchee": {:hex, :benchee, "1.0.1", "66b211f9bfd84bd97e6d1beaddf8fc2312aaabe192f776e8931cb0c16f53a521", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm", "3ad58ae787e9c7c94dd7ceda3b587ec2c64604563e049b2a0e8baafae832addb"}, 3 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 4 | "certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"}, 5 | "credo": {:hex, :credo, "1.5.5", "e8f422026f553bc3bebb81c8e8bf1932f498ca03339856c7fec63d3faac8424b", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dd8623ab7091956a855dc9f3062486add9c52d310dfd62748779c4315d8247de"}, 6 | "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, 7 | "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, 8 | "earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm", "1f93aba7340574847c0f609da787f0d79efcab51b044bb6e242cae5aca9d264d"}, 9 | "earmark_parser": {:hex, :earmark_parser, "1.4.10", "6603d7a603b9c18d3d20db69921527f82ef09990885ed7525003c7fe7dc86c56", [:mix], [], "hexpm", "8e2d5370b732385db2c9b22215c3f59c84ac7dda7ed7e544d7c459496ae519c0"}, 10 | "elixir_make": {:hex, :elixir_make, "0.6.2", "7dffacd77dec4c37b39af867cedaabb0b59f6a871f89722c25b28fcd4bd70530", [:mix], [], "hexpm", "03e49eadda22526a7e5279d53321d1cced6552f344ba4e03e619063de75348d9"}, 11 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 12 | "ex_doc": {:hex, :ex_doc, "0.23.0", "a069bc9b0bf8efe323ecde8c0d62afc13d308b1fa3d228b65bca5cf8703a529d", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f5e2c4702468b2fd11b10d39416ddadd2fcdd173ba2a0285ebd92c39827a5a16"}, 13 | "excoveralls": {:hex, :excoveralls, "0.13.3", "edc5f69218f84c2bf61b3609a22ddf1cec0fbf7d1ba79e59f4c16d42ea4347ed", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cc26f48d2f68666380b83d8aafda0fffc65dafcc8d8650358e0b61f6a99b1154"}, 14 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 15 | "hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"}, 16 | "idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"}, 17 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 18 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 19 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.0", "98312c9f0d3730fde4049985a1105da5155bfe5c11e47bdc7406d88e01e4219b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "75ffa34ab1056b7e24844c90bfc62aaf6f3a37a15faa76b07bc5eba27e4a8b4a"}, 20 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 21 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 22 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 23 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, 24 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 25 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"}, 26 | } 27 | -------------------------------------------------------------------------------- /priv/plts/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peek-travel/excal/d748836d46816b63d8e4a159b66fe9f411b52df4/priv/plts/.gitkeep -------------------------------------------------------------------------------- /priv/recurrence/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peek-travel/excal/d748836d46816b63d8e4a159b66fe9f411b52df4/priv/recurrence/.gitkeep -------------------------------------------------------------------------------- /src/recurrence/iterator.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include "libical/ical.h" 3 | #include "erl_nif.h" 4 | 5 | typedef struct 6 | { 7 | icalrecur_iterator *iterator; 8 | } IteratorResource; 9 | 10 | ErlNifResourceType *EXCAL_RECUR_ITERATOR_RES_TYPE; 11 | 12 | void recurrence_iterator_free(ErlNifEnv *env, void *res) 13 | { 14 | icalrecur_iterator *iterator = ((IteratorResource *)res)->iterator; 15 | icalrecur_iterator_free(iterator); 16 | } 17 | 18 | int load(ErlNifEnv *env, void **priv_data, ERL_NIF_TERM load_info) 19 | { 20 | int flags = ERL_NIF_RT_CREATE | ERL_NIF_RT_TAKEOVER; 21 | EXCAL_RECUR_ITERATOR_RES_TYPE = enif_open_resource_type(env, NULL, "IteratorResource", recurrence_iterator_free, flags, NULL); 22 | 23 | return 0; 24 | } 25 | 26 | static ERL_NIF_TERM 27 | icaltime_to_erl_datetime(ErlNifEnv *env, struct icaltimetype datetime) 28 | { 29 | return enif_make_tuple2( 30 | env, 31 | enif_make_tuple3( 32 | env, 33 | enif_make_int(env, datetime.year), 34 | enif_make_int(env, datetime.month), 35 | enif_make_int(env, datetime.day)), 36 | enif_make_tuple3( 37 | env, 38 | enif_make_int(env, datetime.hour), 39 | enif_make_int(env, datetime.minute), 40 | enif_make_int(env, datetime.second))); 41 | } 42 | 43 | static ERL_NIF_TERM 44 | icaltime_to_erl_date(ErlNifEnv *env, struct icaltimetype date) 45 | { 46 | return enif_make_tuple3( 47 | env, 48 | enif_make_int(env, date.year), 49 | enif_make_int(env, date.month), 50 | enif_make_int(env, date.day)); 51 | } 52 | 53 | static ERL_NIF_TERM 54 | make_error_tuple(ErlNifEnv *env, const char *error) 55 | { 56 | return enif_make_tuple2(env, enif_make_atom(env, "error"), enif_make_atom(env, error)); 57 | } 58 | 59 | static ERL_NIF_TERM 60 | make_ok_tuple(ErlNifEnv *env, ERL_NIF_TERM output) 61 | { 62 | return enif_make_tuple2(env, enif_make_atom(env, "ok"), output); 63 | } 64 | 65 | static ERL_NIF_TERM 66 | make_nil(ErlNifEnv *env) 67 | { 68 | return enif_make_atom(env, "nil"); 69 | } 70 | 71 | int get_iterator(ErlNifEnv *env, ERL_NIF_TERM arg, icalrecur_iterator **iterator) 72 | { 73 | // read arg as iterator resource 74 | IteratorResource *iterator_resource; 75 | if (!enif_get_resource(env, arg, EXCAL_RECUR_ITERATOR_RES_TYPE, (void **)&iterator_resource)) 76 | { 77 | return 0; 78 | } 79 | 80 | // get the ical iterator from the resource struct 81 | (*iterator) = iterator_resource->iterator; 82 | 83 | return 1; 84 | } 85 | 86 | int get_string_from_binary(ErlNifEnv *env, ERL_NIF_TERM arg, char **output) 87 | { 88 | // read arg as binary 89 | ErlNifBinary binary; 90 | if (!enif_inspect_iolist_as_binary(env, arg, &binary)) 91 | { 92 | // argument was not a binary 93 | return 0; 94 | } 95 | 96 | // copy binary to local string 97 | (*output) = strndup((char *)binary.data, binary.size); 98 | 99 | return 1; 100 | } 101 | 102 | static ERL_NIF_TERM 103 | recurrence_iterator_new(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) 104 | { 105 | char *rrule_string; 106 | if (argc < 1 || !get_string_from_binary(env, argv[0], &rrule_string)) 107 | { 108 | return enif_make_badarg(env); 109 | } 110 | 111 | char *dtstart_string; 112 | if (argc < 2 || !get_string_from_binary(env, argv[1], &dtstart_string)) 113 | { 114 | return enif_make_badarg(env); 115 | } 116 | 117 | // make the start time struct from the dtstart string 118 | // TODO: timezones? 119 | struct icaltimetype dtstart = icaltime_from_string(dtstart_string); 120 | if (icaltime_is_null_time(dtstart)) 121 | { 122 | return make_error_tuple(env, "invalid_dtstart"); 123 | } 124 | 125 | // make the recurrence struct from the rrule string 126 | icalerror_set_errno(ICAL_NO_ERROR); // reset error first 127 | struct icalrecurrencetype recur = icalrecurrencetype_from_string(rrule_string); 128 | if (icalerrno == ICAL_MALFORMEDDATA_ERROR || icalerrno == ICAL_NEWFAILED_ERROR) 129 | { 130 | return make_error_tuple(env, "invalid_rrule"); 131 | } 132 | 133 | // initialize the iterator 134 | icalrecur_iterator *iterator = icalrecur_iterator_new(recur, dtstart); 135 | if (iterator == 0) 136 | { 137 | // not sure if this is even possible 138 | return make_error_tuple(env, "bad_iterator"); 139 | } 140 | 141 | // wrap the iterator in a resource struct 142 | IteratorResource *iterator_resource = enif_alloc_resource(EXCAL_RECUR_ITERATOR_RES_TYPE, sizeof(IteratorResource)); 143 | iterator_resource->iterator = iterator; 144 | 145 | // convert to erlang resource term and release to erlang memory management 146 | ERL_NIF_TERM iterator_term = enif_make_resource(env, iterator_resource); 147 | enif_release_resource(iterator_resource); 148 | 149 | // return {:ok, iterator} 150 | return make_ok_tuple(env, iterator_term); 151 | } 152 | 153 | static ERL_NIF_TERM 154 | recurrence_iterator_set_start(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) 155 | { 156 | // get iterator arg 157 | icalrecur_iterator *iterator; 158 | if (argc < 1 || !get_iterator(env, argv[0], &iterator)) 159 | { 160 | return enif_make_badarg(env); 161 | } 162 | 163 | // get start string arg 164 | char *start_string; 165 | if (argc < 2 || !get_string_from_binary(env, argv[1], &start_string)) 166 | { 167 | return enif_make_badarg(env); 168 | } 169 | 170 | // build start from start_string 171 | // TODO: timezones? 172 | struct icaltimetype start = icaltime_from_string(start_string); 173 | if (icaltime_is_null_time(start)) 174 | { 175 | return make_error_tuple(env, "invalid_start"); 176 | } 177 | 178 | // set iterator start 179 | if (icalrecur_iterator_set_start(iterator, start)) 180 | { 181 | // return :ok 182 | return enif_make_atom(env, "ok"); 183 | } 184 | else 185 | { 186 | // you can't set start on rules that use COUNT 187 | // return {:error, :invalid_start} 188 | return make_error_tuple(env, "start_invalid_for_rule"); 189 | } 190 | } 191 | 192 | static ERL_NIF_TERM 193 | recurrence_iterator_next(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) 194 | { 195 | // get iterator arg 196 | icalrecur_iterator *iterator; 197 | if (argc < 1 || !get_iterator(env, argv[0], &iterator)) 198 | { 199 | return enif_make_badarg(env); 200 | } 201 | 202 | // get next occurrence 203 | icaltimetype occurrence = icalrecur_iterator_next(iterator); 204 | 205 | // make response, either nil, a date, or a datetime 206 | ERL_NIF_TERM occurrence_term; 207 | if (icaltime_is_null_time(occurrence)) 208 | { 209 | occurrence_term = make_nil(env); 210 | } 211 | else if (occurrence.is_date) 212 | { 213 | occurrence_term = icaltime_to_erl_date(env, occurrence); 214 | } 215 | else 216 | { 217 | occurrence_term = icaltime_to_erl_datetime(env, occurrence); 218 | } 219 | 220 | // return occurrence 221 | return occurrence_term; 222 | } 223 | 224 | static ErlNifFunc nif_funcs[] = { 225 | {"new", 2, recurrence_iterator_new}, 226 | {"set_start", 2, recurrence_iterator_set_start}, 227 | {"next", 1, recurrence_iterator_next}}; 228 | 229 | ERL_NIF_INIT(Elixir.Excal.Interface.Recurrence.Iterator, nif_funcs, &load, NULL, NULL, NULL) 230 | -------------------------------------------------------------------------------- /test/excal/interface/recurrence/iterator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Excal.Interface.Recurrence.IteratorTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Excal.Interface.Recurrence.Iterator 5 | 6 | describe "Iterator.new/3" do 7 | test "returns an iterator reference when valid inputs are given" do 8 | assert {:ok, iterator} = Iterator.new("FREQ=DAILY", "20180909") 9 | assert is_reference(iterator) 10 | assert {:ok, iterator} = Iterator.new("FREQ=DAILY", "20180909T123000") 11 | assert is_reference(iterator) 12 | end 13 | 14 | test "raises ArgumentError when not given a string for rrule" do 15 | assert_raise ArgumentError, fn -> Iterator.new(:invalid, "20180909T123000") end 16 | end 17 | 18 | test "raises ArgumentError when not given a string for dtstart" do 19 | assert_raise ArgumentError, fn -> Iterator.new("FREQ=DAILY", :invalid) end 20 | end 21 | 22 | test "returns an error when an invalid rrule string is given" do 23 | assert {:error, :invalid_rrule} = Iterator.new("INVALID", "20180909T123000") 24 | end 25 | 26 | test "returns an error when an invalid dtstart string is given" do 27 | assert {:error, :invalid_dtstart} = Iterator.new("FREQ=DAILY", "INVALID") 28 | end 29 | end 30 | 31 | describe "Iterator.set_start/2" do 32 | setup [:iterator] 33 | 34 | test "returns :ok if the start time is valid", %{iterator: iterator} do 35 | assert :ok = Iterator.set_start(iterator, "20190909") 36 | end 37 | 38 | test "raises ArgumentError when not given a string for start", %{iterator: iterator} do 39 | assert_raise ArgumentError, fn -> Iterator.set_start(iterator, :invalid) end 40 | end 41 | 42 | test "raises ArgumentError when not given a valid iterator" do 43 | assert_raise ArgumentError, fn -> Iterator.set_start(:invalid, "20190909") end 44 | end 45 | 46 | @tag rrule: "FREQ=DAILY;COUNT=2" 47 | test "returns an error when setting start for rrule that has a COUNT", %{iterator: iterator} do 48 | assert {:error, :start_invalid_for_rule} = Iterator.set_start(iterator, "20190909") 49 | end 50 | end 51 | 52 | describe "Iterator.next/1" do 53 | setup [:iterator] 54 | 55 | test "returns the next occurrence of the given iterator", %{iterator: iterator} do 56 | assert {2018, 9, 9} = Iterator.next(iterator) 57 | end 58 | 59 | @tag dtstart: "20180909T123000" 60 | test "returns datetimes when iterator was initialized with datetime dtstart", %{iterator: iterator} do 61 | assert {{2018, 9, 9}, {12, 30, 0}} = Iterator.next(iterator) 62 | end 63 | 64 | @tag rrule: "FREQ=DAILY;COUNT=2" 65 | test "return nil once the iterator has reached the end of the set described by the rrule", %{iterator: iterator} do 66 | assert {2018, 9, 9} = Iterator.next(iterator) 67 | assert {2018, 9, 10} = Iterator.next(iterator) 68 | assert is_nil(Iterator.next(iterator)) 69 | end 70 | 71 | @tag start: "20190909" 72 | test "respects the given start time", %{iterator: iterator} do 73 | assert {2019, 9, 9} = Iterator.next(iterator) 74 | end 75 | 76 | test "raises ArgumentError when not given an iterator" do 77 | assert_raise ArgumentError, fn -> Iterator.next(:invalid) end 78 | end 79 | end 80 | 81 | defp iterator(context) do 82 | rrule = Map.get(context, :rrule, "FREQ=DAILY") 83 | dtstart = Map.get(context, :dtstart, "20180909") 84 | 85 | {:ok, iterator} = Iterator.new(rrule, dtstart) 86 | 87 | if start_time = Map.get(context, :start) do 88 | Iterator.set_start(iterator, start_time) 89 | end 90 | 91 | [iterator: iterator] 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /test/excal/recurrence/iterator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Excal.Recurrence.IteratorTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Excal.Recurrence.Iterator 5 | 6 | doctest Iterator 7 | 8 | describe "Iterator.new/3" do 9 | test "returns an iterator struct when valid inputs are given" do 10 | assert {:ok, %Iterator{}} = Iterator.new("FREQ=DAILY", ~D[2018-09-09]) 11 | assert {:ok, %Iterator{}} = Iterator.new("FREQ=DAILY", ~N[2018-09-09 12:30:00]) 12 | end 13 | 14 | test "raises ArgumentError when not given a string for rrule" do 15 | assert_raise ArgumentError, fn -> Iterator.new(:invalid, ~D[2018-09-09]) end 16 | end 17 | 18 | test "returns an error when an invalid rrule string is given" do 19 | assert {:error, :invalid_rrule} = Iterator.new("INVALID", ~D[2018-09-09]) 20 | end 21 | 22 | test "returns an error when an invalid dtstart is given" do 23 | assert {:error, :unsupported_datetime_type} = Iterator.new("FREQ=DAILY", :invalid) 24 | end 25 | end 26 | 27 | describe "Iterator.set_start/2" do 28 | setup [:iterator] 29 | 30 | test "returns :ok if the start time is valid", %{iterator: iterator} do 31 | assert {:ok, %Iterator{}} = Iterator.set_start(iterator, ~D[2019-09-09]) 32 | end 33 | 34 | test "returns an error when the start type doesn't match the iterator's dtstart type", %{iterator: iterator} do 35 | assert {:error, :datetime_type_mismatch} = Iterator.set_start(iterator, ~N[2018-09-09 12:30:00]) 36 | end 37 | 38 | test "raises if not given an iterator" do 39 | assert_raise ArgumentError, fn -> Iterator.set_start(:foo, ~N[2018-09-09 12:30:00]) end 40 | end 41 | end 42 | 43 | describe "Iterator.set_end/2" do 44 | setup [:iterator] 45 | 46 | test "returns :ok if the end time is valid", %{iterator: iterator} do 47 | assert {:ok, %Iterator{}} = Iterator.set_end(iterator, ~D[2019-09-09]) 48 | end 49 | 50 | test "returns an error when the end type doesn't match the iterator's dtstart type", %{iterator: iterator} do 51 | assert {:error, :datetime_type_mismatch} = Iterator.set_end(iterator, ~N[2018-09-09 12:30:00]) 52 | end 53 | 54 | test "raises if not given an iterator" do 55 | assert_raise ArgumentError, fn -> Iterator.set_end(:foo, ~N[2018-09-09 12:30:00]) end 56 | end 57 | end 58 | 59 | describe "Iterator.next/1" do 60 | setup [:iterator] 61 | 62 | test "returns the next occurrence of the given iterator", %{iterator: iterator} do 63 | assert {~D[2018-09-09], %Iterator{}} = Iterator.next(iterator) 64 | end 65 | 66 | @tag dtstart: ~N[2018-09-09 12:30:00] 67 | test "returns datetimes when iterator was initialized with datetime dtstart", %{iterator: iterator} do 68 | assert {~N[2018-09-09 12:30:00], %Iterator{}} = Iterator.next(iterator) 69 | end 70 | 71 | @tag rrule: "FREQ=DAILY;COUNT=2" 72 | test "return nil once the iterator has reached the end of the set described by the rrule", %{iterator: iterator} do 73 | assert {~D[2018-09-09], %Iterator{} = iterator} = Iterator.next(iterator) 74 | assert {~D[2018-09-10], %Iterator{} = iterator} = Iterator.next(iterator) 75 | assert {nil, %Iterator{}} = Iterator.next(iterator) 76 | end 77 | 78 | @tag start: ~D[2019-09-09] 79 | test "respects the given start time", %{iterator: iterator} do 80 | assert {~D[2019-09-09], %Iterator{}} = Iterator.next(iterator) 81 | end 82 | 83 | @tag end: ~D[2018-09-11] 84 | test "respects the given end time", %{iterator: iterator} do 85 | assert {~D[2018-09-09], %Iterator{} = iterator} = Iterator.next(iterator) 86 | assert {~D[2018-09-10], %Iterator{} = iterator} = Iterator.next(iterator) 87 | assert {nil, %Iterator{}} = Iterator.next(iterator) 88 | end 89 | end 90 | 91 | defp iterator(context) do 92 | rrule = Map.get(context, :rrule, "FREQ=DAILY") 93 | dtstart = Map.get(context, :dtstart, ~D[2018-09-09]) 94 | 95 | {:ok, iterator} = Iterator.new(rrule, dtstart) 96 | iterator = add_start(Map.get(context, :start), iterator) 97 | iterator = add_end(Map.get(context, :end), iterator) 98 | 99 | [iterator: iterator] 100 | end 101 | 102 | defp add_start(nil, iterator), do: iterator 103 | 104 | defp add_start(start_time, iterator) do 105 | {:ok, iterator} = Iterator.set_start(iterator, start_time) 106 | iterator 107 | end 108 | 109 | defp add_end(nil, iterator), do: iterator 110 | 111 | defp add_end(end_time, iterator) do 112 | {:ok, iterator} = Iterator.set_end(iterator, end_time) 113 | iterator 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /test/excal/recurrence/stream_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Excal.Recurrence.StreamTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Excal.Recurrence.Stream 5 | 6 | doctest Stream 7 | 8 | describe "Stream.new/3" do 9 | test "returns a stream when valid inputs are given" do 10 | assert {:ok, stream} = Stream.new("FREQ=DAILY", ~D[2018-09-09]) 11 | assert is_function(stream) 12 | assert {:ok, stream} = Stream.new("FREQ=DAILY", ~N[2018-09-09 12:30:00]) 13 | assert is_function(stream) 14 | end 15 | 16 | test "raises ArgumentError when not given a string for rrule" do 17 | assert_raise ArgumentError, fn -> Stream.new(:invalid, ~D[2018-09-09]) end 18 | end 19 | 20 | test "returns an error when an invalid rrule string is given" do 21 | assert {:error, :invalid_rrule} = Stream.new("INVALID", ~D[2018-09-09]) 22 | end 23 | 24 | test "returns an error when an invalid datetime type is given" do 25 | assert {:error, :unsupported_datetime_type} = Stream.new("FREQ=DAILY", :invalid) 26 | end 27 | 28 | test "accepts an option for start time" do 29 | assert {:ok, stream} = Stream.new("FREQ=DAILY", ~D[2018-09-09], from: ~D[2019-09-09]) 30 | assert is_function(stream) 31 | end 32 | 33 | test "accepts an option for end time" do 34 | assert {:ok, stream} = Stream.new("FREQ=DAILY", ~D[2018-09-09], until: ~D[2019-09-09]) 35 | assert is_function(stream) 36 | end 37 | end 38 | 39 | describe "Taking occurrences from the stream" do 40 | setup [:stream] 41 | 42 | @tag rrule: "FREQ=DAILY" 43 | @tag dtstart: ~D[2018-09-09] 44 | test "returns date occurrences if dtstart is given as a Date", %{stream: stream} do 45 | times = Enum.take(stream, 3) 46 | 47 | assert times == [ 48 | ~D[2018-09-09], 49 | ~D[2018-09-10], 50 | ~D[2018-09-11] 51 | ] 52 | end 53 | 54 | @tag rrule: "FREQ=DAILY" 55 | @tag dtstart: ~N[2018-09-09 12:30:00] 56 | test "returns datetime occurrences if dtstart is given as a NaiveDateTime", %{stream: stream} do 57 | times = Enum.take(stream, 3) 58 | 59 | assert times == [ 60 | ~N[2018-09-09 12:30:00], 61 | ~N[2018-09-10 12:30:00], 62 | ~N[2018-09-11 12:30:00] 63 | ] 64 | end 65 | 66 | @tag rrule: "FREQ=DAILY;COUNT=3" 67 | @tag dtstart: ~D[2018-09-09] 68 | test "finishes the stream when the set of occurrences described by rrule runs out", %{stream: stream} do 69 | times = Enum.to_list(stream) 70 | 71 | assert times == [ 72 | ~D[2018-09-09], 73 | ~D[2018-09-10], 74 | ~D[2018-09-11] 75 | ] 76 | end 77 | 78 | @tag rrule: "FREQ=WEEKLY" 79 | @tag dtstart: ~D[2018-09-09] 80 | @tag from: ~D[2019-09-09] 81 | test "respects the configured start time", %{stream: stream} do 82 | times = Enum.take(stream, 3) 83 | 84 | assert times == [ 85 | ~D[2019-09-15], 86 | ~D[2019-09-22], 87 | ~D[2019-09-29] 88 | ] 89 | end 90 | 91 | @tag rrule: "FREQ=WEEKLY" 92 | @tag dtstart: ~D[2018-09-09] 93 | @tag until: ~D[2018-09-24] 94 | test "respects the configured end time", %{stream: stream} do 95 | times = Enum.to_list(stream) 96 | 97 | assert times == [ 98 | ~D[2018-09-09], 99 | ~D[2018-09-16], 100 | ~D[2018-09-23] 101 | ] 102 | end 103 | end 104 | 105 | defp stream(context) do 106 | rrule = context[:rrule] 107 | dtstart = context[:dtstart] 108 | 109 | opts = 110 | Enum.reduce(context, [], fn 111 | {:from, from}, opts -> [{:from, from} | opts] 112 | {:until, until}, opts -> [{:until, until} | opts] 113 | {_, _}, opts -> opts 114 | end) 115 | 116 | {:ok, stream} = Stream.new(rrule, dtstart, opts) 117 | 118 | [stream: stream] 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------