From d6e07b83163b60eb21225c4eb8a18ac12606e11c Mon Sep 17 00:00:00 2001 From: "byte[]" Date: Mon, 18 Nov 2019 22:38:22 -0500 Subject: [PATCH] topics/polls forms, relative dates --- assets/js/poll.js | 18 ++-- assets/js/ujs.js | 6 +- assets/js/utils/events.js | 4 + lib/philomena/poll_options/poll_option.ex | 15 ++++ lib/philomena/polls/poll.ex | 34 ++++++++ lib/philomena/posts/post.ex | 12 +++ lib/philomena/repo.ex | 23 +++-- lib/philomena/slug.ex | 35 ++++++++ lib/philomena/topics.ex | 72 +++++++++++---- lib/philomena/topics/topic.ex | 36 +++++++- .../controllers/topic_controller.ex | 39 ++++++++- lib/philomena_web/router.ex | 2 +- .../templates/forum/show.html.slime | 5 +- .../templates/topic/new.html.slime | 85 ++++++++++++++++++ .../templates/topic/poll/_results.html.slime | 35 ++++---- lib/philomena_web/views/topic/poll_view.ex | 4 +- lib/relative_date/parser.ex | 87 +++++++++++++++++++ 17 files changed, 449 insertions(+), 63 deletions(-) create mode 100644 lib/philomena_web/templates/topic/new.html.slime create mode 100644 lib/relative_date/parser.ex diff --git a/assets/js/poll.js b/assets/js/poll.js index 6786412a..9698de99 100644 --- a/assets/js/poll.js +++ b/assets/js/poll.js @@ -1,8 +1,17 @@ import { $, $$, clearEl, removeEl, insertBefore } from './utils/dom'; +import { delegate, leftClick } from './utils/events'; + +function pollOptionRemover(_event, target) { + removeEl(target.closest('.js-poll-option')); +} function pollOptionCreator() { const addPollOptionButton = $('.js-poll-add-option'); + delegate(document, 'click', { + '.js-option-remove': leftClick(pollOptionRemover) + }); + if (!addPollOptionButton) { return; } @@ -16,13 +25,10 @@ function pollOptionCreator() { if (existingOptionCount < maxOptionCount) { // The element right before the add button will always be the last field, make a copy const prevFieldCopy = addPollOptionButton.previousElementSibling.cloneNode(true); - // Clear its value and increment the N in "Option N" in the placeholder attribute - clearEl($$('.js-option-id', prevFieldCopy)); - const input = $('.js-option-label', prevFieldCopy); - input.value = ''; - input.setAttribute('placeholder', input.getAttribute('placeholder').replace(/\d+$/, m => parseInt(m, 10) + 1)); + const newHtml = prevFieldCopy.outerHTML.replace(/(\d+)/g, `${existingOptionCount}`); + // Insert copy before the button - insertBefore(addPollOptionButton, prevFieldCopy); + addPollOptionButton.insertAdjacentHTML("beforebegin", newHtml); existingOptionCount++; } diff --git a/assets/js/ujs.js b/assets/js/ujs.js index 23073061..12c8ba89 100644 --- a/assets/js/ujs.js +++ b/assets/js/ujs.js @@ -1,5 +1,5 @@ import { $$, makeEl, findFirstTextNode } from './utils/dom'; -import { fire, delegate } from './utils/events'; +import { fire, delegate, leftClick } from './utils/events'; const headers = () => ({ 'x-csrf-token': window.booru.csrfToken @@ -85,10 +85,6 @@ function linkRemote(event, target) { ); } -function leftClick(func) { - return (event, target) => { if (event.button === 0) return func(event, target); }; -} - delegate(document, 'click', { 'a[data-confirm],button[data-confirm],input[data-confirm]': leftClick(confirm), 'a[data-disable-with],button[data-disable-with],input[data-disable-with]': leftClick(disable), diff --git a/assets/js/utils/events.js b/assets/js/utils/events.js index f5685053..51c46a13 100644 --- a/assets/js/utils/events.js +++ b/assets/js/utils/events.js @@ -10,6 +10,10 @@ export function on(node, event, selector, func) { delegate(node, event, { [selector]: func }); } +export function leftClick(func) { + return (event, target) => { if (event.button === 0) return func(event, target); }; +} + export function delegate(node, event, selectors) { node.addEventListener(event, e => { for (const selector in selectors) { diff --git a/lib/philomena/poll_options/poll_option.ex b/lib/philomena/poll_options/poll_option.ex index 3c6e55d4..49b64291 100644 --- a/lib/philomena/poll_options/poll_option.ex +++ b/lib/philomena/poll_options/poll_option.ex @@ -17,4 +17,19 @@ defmodule Philomena.PollOptions.PollOption do |> cast(attrs, []) |> validate_required([]) end + + @doc false + def creation_changeset(poll_option, attrs) do + poll_option + |> cast(attrs, [:label]) + |> validate_required([:label]) + |> validate_length(:label, max: 80, count: :bytes) + |> unique_constraint(:label, name: :index_poll_options_on_poll_id_and_label) + |> ignore_if_blank() + end + + defp ignore_if_blank(%{valid?: false, changes: changes} = changeset) when changes == %{}, + do: %{changeset | action: :ignore} + defp ignore_if_blank(changeset), + do: changeset end diff --git a/lib/philomena/polls/poll.ex b/lib/philomena/polls/poll.ex index d5b7f39d..1d1d3ee6 100644 --- a/lib/philomena/polls/poll.ex +++ b/lib/philomena/polls/poll.ex @@ -17,6 +17,7 @@ defmodule Philomena.Polls.Poll do field :total_votes, :integer, default: 0 field :hidden_from_users, :boolean, default: false field :deletion_reason, :string, default: "" + field :until, :string, virtual: true timestamps(inserted_at: :created_at) end @@ -27,4 +28,37 @@ defmodule Philomena.Polls.Poll do |> cast(attrs, []) |> validate_required([]) end + + @doc false + def creation_changeset(poll, attrs) do + poll + |> cast(attrs, [:title, :until, :vote_method]) + |> put_active_until() + |> validate_required([:title, :active_until, :vote_method]) + |> validate_length(:title, max: 140, count: :bytes) + |> validate_inclusion(:vote_method, ["single", "multiple"]) + |> cast_assoc(:options, with: &PollOption.creation_changeset/2) + |> validate_length(:options, min: 2, max: 20) + |> ignore_if_blank() + end + + defp ignore_if_blank(%{valid?: false, changes: changes} = changeset) when changes == %{}, + do: %{changeset | action: :ignore} + defp ignore_if_blank(changeset), + do: changeset + + defp put_active_until(changeset) do + changeset + |> get_field(:until) + |> RelativeDate.Parser.parse() + |> case do + {:ok, until} -> + changeset + |> change(active_until: until) + + _error -> + changeset + |> add_error(:active_until, "invalid date format") + end + end end diff --git a/lib/philomena/posts/post.ex b/lib/philomena/posts/post.ex index 45ba5247..c140fbe5 100644 --- a/lib/philomena/posts/post.ex +++ b/lib/philomena/posts/post.ex @@ -49,6 +49,18 @@ defmodule Philomena.Posts.Post do |> put_name_at_post_time() end + @doc false + def topic_creation_changeset(post, attrs, attribution, anonymous?) do + post + |> change(anonymous: anonymous?) + |> cast(attrs, [:body]) + |> validate_required([:body]) + |> validate_length(:body, min: 1, max: 300_000, count: :bytes) + |> change(attribution) + |> change(topic_position: 0) + |> put_name_at_post_time() + end + defp put_name_at_post_time(%{changes: %{user: %{data: %{name: name}}}} = changeset), do: change(changeset, name_at_post_time: name) defp put_name_at_post_time(changeset), diff --git a/lib/philomena/repo.ex b/lib/philomena/repo.ex index dd6cf89f..68ab4b49 100644 --- a/lib/philomena/repo.ex +++ b/lib/philomena/repo.ex @@ -1,4 +1,6 @@ defmodule Philomena.Repo do + alias Ecto.Multi + use Ecto.Repo, otp_app: :philomena, adapter: Ecto.Adapters.Postgres @@ -11,17 +13,14 @@ defmodule Philomena.Repo do serializable: "SERIALIZABLE" } - def isolated_transaction(f, level) do - Philomena.Repo.transaction(fn -> - Philomena.Repo.query!("SET TRANSACTION ISOLATION LEVEL #{@levels[level]}") - Philomena.Repo.transaction(f) - end) - |> case do - {:ok, value} -> - value - - error -> - error - end + def isolated_transaction(%Multi{} = multi, level) do + Multi.append( + Multi.new |> Multi.run(:isolate, fn repo, _chg -> + repo.query!("SET TRANSACTION ISOLATION LEVEL #{@levels[level]}") + {:ok, nil} + end), + multi + ) + |> Philomena.Repo.transaction() end end diff --git a/lib/philomena/slug.ex b/lib/philomena/slug.ex index 57ed96e1..fbbcef5e 100644 --- a/lib/philomena/slug.ex +++ b/lib/philomena/slug.ex @@ -1,4 +1,39 @@ defmodule Philomena.Slug do + # Generates a URL-safe slug from a string by removing nonessential + # information from it. + # + # The process for this is as follows: + # + # 1. Remove non-ASCII or non-printable characters. + # + # 2. Replace any runs of non-alphanumeric characters that were allowed + # through previously with hyphens. + # + # 3. Remove any starting or ending hyphens. + # + # 4. Convert all characters to their lowercase equivalents. + # + # This method makes no guarantee of creating unique slugs for unique inputs. + # In addition, for certain inputs, it will return empty strings. + # + # Example + # + # destructive_slug("Time-Wasting Thread 3.0 (SFW - No Explicit/Grimdark)") + # #=> "time-wasting-thread-3-0-sfw-no-explicit-grimdark" + # + # destructive_slug("~`!@#$%^&*()-_=+[]{};:'\" <>,./?") + # #=> "" + # + @spec destructive_slug(String.t()) :: String.t() + def destructive_slug(input) when is_binary(input) do + input + |> String.replace(~r/[^ -~]/, "") # 1 + |> String.replace(~r/[^a-zA-Z0-9]+/, "-") # 2 + |> String.replace(~r/\A-|-\z/, "") # 3 + |> String.downcase() # 4 + end + def destructive_slug(_input), do: "" + def slug(string) when is_binary(string) do string |> String.replace("-", "-dash-") diff --git a/lib/philomena/topics.ex b/lib/philomena/topics.ex index 868a2e42..4c757596 100644 --- a/lib/philomena/topics.ex +++ b/lib/philomena/topics.ex @@ -4,22 +4,12 @@ defmodule Philomena.Topics do """ import Ecto.Query, warn: false + alias Ecto.Multi alias Philomena.Repo alias Philomena.Topics.Topic - - @doc """ - Returns the list of topics. - - ## Examples - - iex> list_topics() - [%Topic{}, ...] - - """ - def list_topics do - Repo.all(Topic) - end + alias Philomena.Forums.Forum + alias Philomena.Notifications @doc """ Gets a single topic. @@ -49,10 +39,58 @@ defmodule Philomena.Topics do {:error, %Ecto.Changeset{}} """ - def create_topic(attrs \\ %{}) do - %Topic{} - |> Topic.changeset(attrs) - |> Repo.insert() + def create_topic(forum, attribution, attrs \\ %{}) do + topic = + %Topic{} + |> Topic.creation_changeset(attrs, forum, attribution) + + Multi.new + |> Multi.insert(:topic, topic) + |> Multi.run(:update_topic, fn repo, %{topic: topic} -> + {count, nil} = + Topic + |> where(id: ^topic.id) + |> repo.update_all(set: [last_post_id: hd(topic.posts).id]) + + {:ok, count} + end) + |> Multi.run(:update_forum, fn repo, %{topic: topic} -> + {count, nil} = + Forum + |> where(id: ^topic.forum_id) + |> repo.update_all(inc: [post_count: 1], set: [last_post_id: hd(topic.posts).id]) + + {:ok, count} + end) + |> Repo.isolated_transaction(:serializable) + end + + def notify_topic(topic) do + spawn fn -> + forum = + topic + |> Repo.preload(:forum) + |> Map.fetch!(:forum) + + subscriptions = + forum + |> Repo.preload(:subscriptions) + |> Map.fetch!(:subscriptions) + + Notifications.notify( + topic, + subscriptions, + %{ + actor_id: forum.id, + actor_type: "Forum", + actor_child_id: topic.id, + actor_child_type: "Topic", + action: "posted a new topic in" + } + ) + end + + topic end @doc """ diff --git a/lib/philomena/topics/topic.ex b/lib/philomena/topics/topic.ex index 47059906..333edd39 100644 --- a/lib/philomena/topics/topic.ex +++ b/lib/philomena/topics/topic.ex @@ -7,6 +7,7 @@ defmodule Philomena.Topics.Topic do alias Philomena.Polls.Poll alias Philomena.Posts.Post alias Philomena.Topics.Subscription + alias Philomena.Slug @derive {Phoenix.Param, key: :slug} schema "topics" do @@ -20,7 +21,7 @@ defmodule Philomena.Topics.Topic do has_many :subscriptions, Subscription field :title, :string - field :post_count, :integer, default: 0 + field :post_count, :integer, default: 1 field :view_count, :integer, default: 0 field :sticky, :boolean, default: false field :last_replied_to_at, :naive_datetime @@ -40,4 +41,37 @@ defmodule Philomena.Topics.Topic do |> cast(attrs, []) |> validate_required([]) end + + @doc false + def creation_changeset(topic, attrs, forum, attribution) do + changes = + topic + |> cast(attrs, [:title, :anonymous]) + |> validate_required([:title, :anonymous]) + + anonymous? = + changes + |> get_field(:anonymous) + + changes + |> validate_length(:title, min: 4, max: 96, count: :bytes) + |> put_slug() + |> change(forum: forum, user: attribution[:user]) + |> validate_required(:forum) + |> cast_assoc(:poll, with: &Poll.creation_changeset/2) + |> cast_assoc(:posts, with: {Post, :topic_creation_changeset, [attribution, anonymous?]}) + |> validate_length(:posts, is: 1) + |> unique_constraint(:slug, name: :index_topics_on_forum_id_and_slug) + end + + def put_slug(changeset) do + slug = + changeset + |> get_field(:title) + |> Slug.destructive_slug() + + changeset + |> put_change(:slug, slug) + |> validate_required(:slug, message: "must be printable") + end end diff --git a/lib/philomena_web/controllers/topic_controller.ex b/lib/philomena_web/controllers/topic_controller.ex index 8279bf54..bed611b4 100644 --- a/lib/philomena_web/controllers/topic_controller.ex +++ b/lib/philomena_web/controllers/topic_controller.ex @@ -1,10 +1,14 @@ defmodule PhilomenaWeb.TopicController do use PhilomenaWeb, :controller - alias Philomena.{Forums.Forum, Topics, Topics.Topic, Posts, Posts.Post, Textile.Renderer} + alias Philomena.{Forums.Forum, Topics.Topic, Posts.Post, Polls.Poll, PollOptions.PollOption} + alias Philomena.{Topics, Posts} + alias Philomena.Textile.Renderer alias Philomena.Repo import Ecto.Query + plug PhilomenaWeb.FilterBannedUsersPlug when action in [:new, :create] + plug PhilomenaWeb.UserAttributionPlug when action in [:new, :create] plug :load_and_authorize_resource, model: Forum, id_name: "forum_id", id_field: "short_name", persisted: true def show(conn, %{"id" => slug} = params) do @@ -60,4 +64,37 @@ defmodule PhilomenaWeb.TopicController do render(conn, "show.html", posts: posts, changeset: changeset, watching: watching) end + + def new(conn, _params) do + changeset = + %Topic{poll: %Poll{options: [%PollOption{}, %PollOption{}]}, posts: [%Post{}]} + |> Topics.change_topic() + + render(conn, "new.html", changeset: changeset) + end + + def create(conn, %{"topic" => topic_params}) do + attributes = conn.assigns.attributes + forum = conn.assigns.forum + + case Topics.create_topic(forum, attributes, topic_params) do + {:ok, %{topic: topic}} -> + post = hd(topic.posts) + Posts.reindex_post(post) + Topics.notify_topic(topic) + + conn + |> put_flash(:info, "Successfully posted topic.") + |> redirect(to: Routes.forum_topic_path(conn, :show, forum, topic)) + + {:error, :topic, changeset, _} -> + conn + |> render("new.html", changeset: changeset) + + _error -> + conn + |> put_flash(:error, "There was an error with your submission. Please try again.") + |> redirect(to: Routes.forum_topic_path(conn, :new, forum)) + end + end end diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index b7f2e008..7b22c813 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -67,7 +67,7 @@ defmodule PhilomenaWeb.Router do end resources "/forums", ForumController, only: [] do - resources "/topics", TopicController, only: [] do + resources "/topics", TopicController, only: [:new, :create] do resources "/subscription", Topic.SubscriptionController, only: [:create, :delete], singleton: true end end diff --git a/lib/philomena_web/templates/forum/show.html.slime b/lib/philomena_web/templates/forum/show.html.slime index 6687fdc6..c0a43064 100644 --- a/lib/philomena_web/templates/forum/show.html.slime +++ b/lib/philomena_web/templates/forum/show.html.slime @@ -6,7 +6,10 @@ h1 = @forum.name => link("Forums", to: Routes.forum_path(@conn, :index)) ' » => link(@forum.name, to: Routes.forum_path(@conn, :show, @forum)) - /= icon_link 'New Topic', 'fas fa-fw fa-pen-square', new_forum_topic_path(@forum) + a href=Routes.forum_topic_path(@conn, :new, @forum) + i.fa.fa-fw.fa-pencil> + ' New Topic + /= icon_link 'Search Posts', 'fa fa-fw fa-search', posts_path(forum_id: @forum.id) span.spacing-left => @forum.topic_count diff --git a/lib/philomena_web/templates/topic/new.html.slime b/lib/philomena_web/templates/topic/new.html.slime new file mode 100644 index 00000000..52451e0f --- /dev/null +++ b/lib/philomena_web/templates/topic/new.html.slime @@ -0,0 +1,85 @@ += form_for @changeset, Routes.forum_topic_path(@conn, :create, @forum), fn f -> + = if @changeset.action do + .alert.alert-danger + p Oops, something went wrong! Please check the errors below. + + .block + .block__header.block__header--js-tabbed + a.selected href="#" data-click-tab="write" + i.fa.fa-pencil> + ' Create a Topic + + a href="#" data-click-tab="preview" + i.fa.fa-eye> + ' Preview + + .block__tab.communication-edit__tab.selected data-tab="write" + .field + = text_input f, :title, class: "input input--wide", placeholder: "Title" + = error_tag f, :title + = error_tag f, :slug + + = inputs_for f, :posts, fn fp -> + .field + = textarea fp, :body, class: "input input--wide input--text js-preview-input js-toolbar-input", placeholder: "Please read the site rules before posting and use [spoiler][/spoiler] for NSFW stuff in SFW forums.", required: true + = error_tag fp, :body + + .field + => checkbox f, :anonymous + = label f, :anonymous, "Post anonymously" + + = inputs_for f, :poll, fn fp -> + #add-poll + input.toggle-box id="add_poll" name="add_poll" type="checkbox" + label for="add_poll" Add a poll + .toggle-box-container + p + ' Polls may have a maximum of + span.js-max-option-count> 20 + ' options. Leave any options you don't want to use blank. + ' Only registered users will be able to vote. + + .field.field--block + = text_input fp, :title, class: "input input--wide", placeholder: "Poll title", maxlength: 140 + = error_tag fp, :title + + p.fieldlabel + ' End date + + .field.field--block + = text_input fp, :until, class: "input input--wide", placeholder: "2 weeks from now", maxlength: 255 + = error_tag fp, :until + = error_tag fp, :active_until + + p.fieldlabel + ' Specify when the poll should end. Once the poll ends, no more + ' votes can be cast and the final results will be displayed. Good + ' values to try are "1 week from now" and "24 hours from now". Polls + ' must last for at least 24 hours. + + p.fieldlabel + ' Voting method: + + .field.field--block + = select fp, :vote_method, ["-": "", "Single option": :single, "Multiple options": :multiple], class: "input" + = error_tag fp, :vote_method + + = inputs_for fp, :options, fn fo -> + .field.js-poll-option.field--inline.flex--no-wrap.flex--centered + = text_input fo, :label, class: "input flex__grow js-option-label", placeholder: "Option" + = error_tag fo, :label + + label.input--separate-left.flex__fixed.flex--centered + a.js-option-remove href="#" + i.fa.fa-trash> + ' Delete + + button.button.js-poll-add-option type="button" + i.fa.fa-plus> + ' Add option + + .block__tab.communication-edit__tab.hidden data-tab="preview" + ' [Loading preview...] + + .block__content.communication-edit__actions + = submit "Post", class: "button" \ No newline at end of file diff --git a/lib/philomena_web/templates/topic/poll/_results.html.slime b/lib/philomena_web/templates/topic/poll/_results.html.slime index df0e2560..9690c607 100644 --- a/lib/philomena_web/templates/topic/poll/_results.html.slime +++ b/lib/philomena_web/templates/topic/poll/_results.html.slime @@ -23,22 +23,23 @@ svg.poll-bar__image width=percent_of_total(option, @poll) height="100%" viewBox="0 0 1 1" preserveAspectRatio="none" rect class=poll_bar_class(option, winning, winners?) width="1" height="1" - = if active?(@poll) do - ' Poll ends - = pretty_time(@poll.active_until) - ' . + p + = if active?(@poll) do + ' Poll ends + = pretty_time(@poll.active_until) + ' . + + = if @poll.total_votes > 0 do + => @poll.total_votes + => pluralize("vote", "votes", @poll.total_votes) + - else + ' No votes have been + ' cast so far. - = if @poll.total_votes > 0 do - => @poll.total_votes - => pluralize("vote", "votes", @poll.total_votes) - else - ' No votes have been - ' cast so far. - - - else - ' Poll ended - => pretty_time(@poll.active_until) - ' with - => @poll.total_votes - = pluralize("vote", "votes", @poll.total_votes) - ' . \ No newline at end of file + ' Poll ended + => pretty_time(@poll.active_until) + ' with + => @poll.total_votes + = pluralize("vote", "votes", @poll.total_votes) + ' . \ No newline at end of file diff --git a/lib/philomena_web/views/topic/poll_view.ex b/lib/philomena_web/views/topic/poll_view.ex index 29a8dc2f..504088ee 100644 --- a/lib/philomena_web/views/topic/poll_view.ex +++ b/lib/philomena_web/views/topic/poll_view.ex @@ -13,10 +13,10 @@ defmodule PhilomenaWeb.Topic.PollView do end def active?(poll) do - not poll.hidden_from_users and poll.active_until > DateTime.utc_now() + not poll.hidden_from_users and DateTime.diff(poll.active_until, DateTime.utc_now()) > 0 end - def percent_of_total(_option, %{total_votes: 0}), do: 0 + def percent_of_total(_option, %{total_votes: 0}), do: "0%" def percent_of_total(%{vote_count: vote_count}, %{total_votes: total_votes}) do :io_lib.format("~.2f%", [(vote_count / total_votes * 100)]) end diff --git a/lib/relative_date/parser.ex b/lib/relative_date/parser.ex new file mode 100644 index 00000000..748f24c3 --- /dev/null +++ b/lib/relative_date/parser.ex @@ -0,0 +1,87 @@ +defmodule RelativeDate.Parser do + import NimbleParsec + + time_specifier = + choice([ + string("second") |> replace(1), + string("minute") |> replace(60), + string("hour") |> replace(3_600), + string("day") |> replace(86_400), + string("week") |> replace(604_800), + string("month") |> replace(2_592_000), + string("year") |> replace(31_536_000) + ]) + |> ignore(optional(string("s"))) + + direction_specifier = + choice([ + string("ago") |> replace(-1), + string("from now") |> replace(1) + ]) + + space = ignore(repeat(string(" "))) + + moon = + space + |> string("moon") + |> concat(space) + |> eos() + |> unwrap_and_tag(:moon) + + date = + space + |> integer(min: 1) + |> concat(space) + |> concat(time_specifier) + |> concat(space) + |> concat(direction_specifier) + |> concat(space) + |> eos() + |> tag(:relative_date) + + relative_date = + choice([ + moon, + date + ]) + + defparsecp :relative_date, relative_date + + def parse(input) do + input = + input + |> to_string() + |> String.trim() + + case parse_absolute(input) do + {:ok, datetime} -> + {:ok, datetime} + + _error -> + parse_relative(input) + end + end + + def parse_absolute(input) do + case DateTime.from_iso8601(input) do + {:ok, datetime, _offset} -> + {:ok, datetime |> DateTime.truncate(:second)} + + _error -> + {:error, "Parse error"} + end + end + + def parse_relative(input) do + case relative_date(input) do + {:ok, [moon: _moon], _1, _2, _3, _4} -> + {:ok, DateTime.utc_now() |> DateTime.add(31_536_000_000, :second) |> DateTime.truncate(:second)} + + {:ok, [relative_date: [amount, scale, direction]], _1, _2, _3, _4} -> + {:ok, DateTime.utc_now() |> DateTime.add(amount * scale * direction, :second) |> DateTime.truncate(:second)} + + _error -> + {:error, "Parse error"} + end + end +end