diff --git a/apps/content/.gitignore b/apps/content/.gitignore new file mode 100644 index 00000000..2ebe6e95 --- /dev/null +++ b/apps/content/.gitignore @@ -0,0 +1,34 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +auth_web-*.tar + +# If NPM crashes, it generates a log, let's ignore it too. +npm-debug.log + +# The directory NPM downloads your dependencies sources to. +/assets/node_modules/ + +# Since we are building assets from assets/, +# we ignore priv/static. You may want to comment +# this depending on your deployment strategy. +/priv/static/ diff --git a/apps/content/config/config.exs b/apps/content/config/config.exs new file mode 100644 index 00000000..06eedc42 --- /dev/null +++ b/apps/content/config/config.exs @@ -0,0 +1,26 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Mix.Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. +use Mix.Config + +# General application configuration +config :content, + ecto_repos: [Content.Repo] + +config :phoenix, :json_library, Jason + +# Configures Elixir's Logger +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:user_id] + +config :content, Content.Scheduler, + jobs: [ + {"@hourly", {ContentWeb.Sitemaps, :generate, []}} + ] + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{Mix.env}.exs" diff --git a/apps/content/config/dev.exs b/apps/content/config/dev.exs new file mode 100644 index 00000000..0655a1fc --- /dev/null +++ b/apps/content/config/dev.exs @@ -0,0 +1,76 @@ +use Mix.Config + +# Configure your database +config :content, Content.Repo, + username: "postgres", + password: "postgres", + database: "content_dev", + hostname: "localhost", + show_sensitive_data_on_connection_error: true, + pool_size: 10 + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we use it +# with webpack to recompile .js and .css sources. +config :content, ContentWeb.Endpoint, + http: [port: 4000], + debug_errors: true, + code_reloader: true, + check_origin: false, + watchers: [ + node: [ + "node_modules/webpack/bin/webpack.js", + "--mode", + "development", + "--watch-stdin", + cd: Path.expand("../assets", __DIR__) + ] + ] + +# ## SSL Support +# +# In order to use HTTPS in development, a self-signed +# certificate can be generated by running the following +# Mix task: +# +# mix phx.gen.cert +# +# Note that this task requires Erlang/OTP 20 or later. +# Run `mix help phx.gen.cert` for more information. +# +# The `http:` config above can be replaced with: +# +# https: [ +# port: 4001, +# cipher_suite: :strong, +# keyfile: "priv/cert/selfsigned_key.pem", +# certfile: "priv/cert/selfsigned.pem" +# ], +# +# If desired, both `http:` and `https:` keys can be +# configured to run both http and https servers on +# different ports. + +# Watch static and templates for browser reloading. +config :content, ContentWeb.Endpoint, + live_reload: [ + patterns: [ + ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", + ~r"priv/gettext/.*(po)$", + ~r"lib/content_web/(live|views)/.*(ex)$", + ~r"lib/content_web/templates/.*(eex)$" + ] + ] + +# Do not include metadata nor timestamps in development logs +config :logger, :console, format: "[$level] $message\n" + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 + +# Initialize plugs at runtime for faster development compilation +config :phoenix, :plug_init_mode, :runtime diff --git a/apps/content/config/test.exs b/apps/content/config/test.exs new file mode 100644 index 00000000..39a41d15 --- /dev/null +++ b/apps/content/config/test.exs @@ -0,0 +1,12 @@ +use Mix.Config + +# Print only warnings and errors during test +config :logger, level: :warn + +config :content, Content.Repo, + username: "postgres", + password: "postgres", + database: "content_test#{System.get_env("MIX_TEST_PARTITION")}", + hostname: System.get_env("DATABASE_URL") || "localhost", + show_sensitive_data_on_connection_error: true, + pool: Ecto.Adapters.SQL.Sandbox diff --git a/apps/content/lib/application.ex b/apps/content/lib/application.ex new file mode 100644 index 00000000..0f71c2ef --- /dev/null +++ b/apps/content/lib/application.ex @@ -0,0 +1,34 @@ +defmodule Content.Application do + @moduledoc """ + The base module of the CMS application. + """ + use Application + + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + def start(_type, _args) do + import Supervisor.Spec + + # Define workers and child supervisors to be supervised + children = [ + # Start the Ecto repository + supervisor(Content.Repo, []), + # Start the endpoint when the application starts + # Start your own worker by calling: Content.Worker.start_link(arg1, arg2, arg3) + # worker(Content.Worker, [arg1, arg2, arg3]), + worker(Content.Scheduler, []), + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: Content.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + def config_change(changed, _new, removed) do + Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/apps/content/lib/attachment.ex b/apps/content/lib/attachment.ex new file mode 100644 index 00000000..db1b8d50 --- /dev/null +++ b/apps/content/lib/attachment.ex @@ -0,0 +1,40 @@ +defmodule Content.Attachment do + @moduledoc """ + Helpers for dealing with "attachment"-type posts, which are generally media + uploaded to the site e.g. images. + """ + alias Content.Post + + def dimensions(attachment) do + meta = + attachment + |> Post.metas_map + + deserialization_results = + meta["_wp_attachment_metadata"] + |> PhpSerializer.unserialize + + case deserialization_results do + {:ok, info} -> + %{ + width: info |> Enum.find(fn {key, _} -> key == "width" end) |> elem(1), + height: info |> Enum.find(fn {key, _} -> key == "height" end) |> elem(1) + } + {:error, _} -> + nil + end + end + + def vertical?(attachment) do + case dimensions(attachment) do + %{width: width, height: height} -> + if width < height do + true + else + false + end + _ -> + false + end + end +end diff --git a/apps/content/lib/commands/update_menu.ex b/apps/content/lib/commands/update_menu.ex new file mode 100644 index 00000000..0613f8b1 --- /dev/null +++ b/apps/content/lib/commands/update_menu.ex @@ -0,0 +1,178 @@ +defmodule Content.UpdateMenu do + alias Content.{Menu, Post, Postmeta, Repo, TermRelationship} + alias Ecto.Multi + + import Ecto.Query + + def run(id, new_menu_params) do + current_posts = Menu.nav_menu_items_for_id(id) + post_ids_in_new_menu = recursive_post_ids(new_menu_params) + deleted_post_ids = + current_posts + |> Enum.reject(& &1."ID" in post_ids_in_new_menu) + |> Enum.map(& &1."ID") + + Multi.new() + |> process_nodes(id, 0, new_menu_params |> add_order()) + |> Multi.delete_all(:stale_nodes, from(p in Post, where: p."ID" in ^deleted_post_ids)) + |> Repo.transaction() + end + + def add_order(tree) do + {_next_order, nodes} = add_order_starting_at(tree, 1) + + nodes + end + + def add_order_starting_at(tree, starting_at) do + tree + |> Enum.reduce({starting_at, []}, fn node, {order, new_nodes} -> + {next_order, new_child_nodes} = add_order_starting_at(node["children"], order + 1) + new_node = + node + |> Map.merge(%{ + "order" => order, + "children" => new_child_nodes, + }) + {next_order, new_nodes ++ [new_node]} + end) + end + + defp process_nodes(multi, menu_id, parent_id, nodes) do + nodes + |> Enum.reduce(multi, fn node, m -> + case node["post_id"] do + nil -> + create_node(m, menu_id, parent_id, node) + _id -> + update_node(m, menu_id, parent_id, node) + end + end) + end + + defp create_node(multi, menu_id, parent_id, node) do + post = + Post.changeset( + %Post{}, + %{ + post_author: 1, + post_title: node["title"], + post_status: "publish", + comment_status: "closed", + ping_status: "closed", + menu_order: node["order"], + post_type: "nav_menu_item", + comment_count: 0, + } + ) + + step_name = "#{parent_id}.create_node.#{node["order"]}" + + multi + |> Multi.insert(step_name, post) + |> Multi.run("#{step_name}.term_relationship", fn _repo, %{^step_name => post} -> + tr = + TermRelationship.changeset( + %TermRelationship{}, + %{ + object_id: post."ID", + term_taxonomy_id: menu_id, + term_order: 0, + } + ) + + Repo.insert(tr) + end) + |> Multi.merge(fn %{^step_name => post} -> + Multi.new() + |> insert_metas(type_of_node(node), post, parent_id, node) + end) + |> Multi.merge(fn %{^step_name => post} -> + Multi.new() + |> process_nodes(menu_id, post."ID", node["children"]) + end) + end + + defp insert_metas(multi, "post", post, parent_id, node) do + multi + |> update_meta(post."ID", "_menu_item_type", "post_type") + |> update_meta(post."ID", "_menu_item_object", "page") + |> update_meta(post."ID", "_menu_item_object_id", node["target_id"]) + |> update_meta(post."ID", "_menu_item_menu_item_parent", parent_id) + end + + defp insert_metas(multi, "category", post, parent_id, node) do + multi + |> update_meta(post."ID", "_menu_item_type", "taxonomy") + |> update_meta(post."ID", "_menu_item_object", "category") + |> update_meta(post."ID", "_menu_item_object_id", node["target_id"]) + |> update_meta(post."ID", "_menu_item_menu_item_parent", parent_id) + end + + defp insert_metas(multi, "link", post, parent_id, node) do + multi + |> update_meta(post."ID", "_menu_item_type", "custom") + |> update_meta(post."ID", "_menu_item_object", "custom") + |> update_meta(post."ID", "_menu_item_object_id", post."ID") + |> update_meta(post."ID", "_menu_item_url", node["url"]) + |> update_meta(post."ID", "_menu_item_menu_item_parent", parent_id) + end + + defp type_of_node(%{"url" => url}) when url != nil, do: "link" + defp type_of_node(%{"related_item" => %{"resource" => "posts"}}), do: "post" + defp type_of_node(%{"related_item" => %{"resource" => "category"}}), do: "category" + + defp update_node(multi, menu_id, parent_id, node) do + multi + |> update_meta(node["post_id"], "_menu_item_menu_item_parent", parent_id) + |> update_order(node["post_id"], node["order"]) + |> process_nodes(menu_id, node["post_id"], node["children"]) + end + + defp update_meta(multi, post_id, meta_key, new_value) when is_integer(new_value), + do: update_meta(multi, post_id, meta_key, new_value |> Integer.to_string()) + + defp update_meta(multi, post_id, meta_key, new_value) do + step_name = "#{post_id}.update_meta.#{meta_key}" + type = Postmeta.__schema__(:type, :meta_value) + cast_value = Ecto.Type.cast(type, new_value) + + Postmeta + |> where([pm], pm.meta_key == ^meta_key) + |> where([pm], pm.post_id == ^post_id) + |> Repo.one() + |> case do + nil -> + multi + |> Multi.insert( + step_name, + Postmeta.changeset( + %Postmeta{}, + %{ + post_id: post_id, + meta_key: meta_key, + meta_value: new_value + } + ) + ) + %{meta_value: ^cast_value} -> + # No change needed + multi + meta -> + multi + |> Multi.update(step_name, Postmeta.changeset(meta, %{meta_value: new_value})) + end + end + + defp update_order(multi, post_id, new_order) do + step_name = "#{post_id}.update_order" + + multi + |> Multi.update_all(step_name, from(p in Post, where: p."ID" == ^post_id), [set: [menu_order: new_order]]) + end + + defp recursive_post_ids(params) do + params + |> Enum.flat_map(& [&1["post_id"]|recursive_post_ids(&1["children"])]) + end +end diff --git a/apps/content/lib/comment.ex b/apps/content/lib/comment.ex new file mode 100644 index 00000000..8784b642 --- /dev/null +++ b/apps/content/lib/comment.ex @@ -0,0 +1,54 @@ +defmodule Content.Comment do + @moduledoc """ + A comment on the site. + """ + use Ecto.Schema + import Ecto.Changeset + alias Content.{Post} + + @primary_key {:comment_ID, :id, autogenerate: true} + @derive {Phoenix.Param, key: :comment_ID} + schema "wp_comments" do + belongs_to :post, Post, foreign_key: :comment_post_ID, references: :ID + field :comment_author, :string + field :comment_author_email, :string + field :comment_author_url, :string + field :comment_author_IP, :string + field :comment_date, :naive_datetime + field :comment_date_gmt, :naive_datetime + field :comment_content, :string + field :comment_karma, :integer + field :comment_approved, :string + field :comment_agent, :string + field :comment_type, :string + field :comment_parent, :integer, default: 0 + field :user_id, :integer + end + + def changeset(struct, params \\ %{}) do + struct + |> Map.merge(%{ + comment_date: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second), + comment_date_gmt: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second), + comment_approved: "1" + }) + |> cast(params, [ + :comment_ID, + :comment_post_ID, + :comment_author, + :comment_author_email, + :comment_author_url, + :comment_author_IP, + :comment_date, + :comment_date_gmt, + :comment_content, + :comment_karma, + :comment_approved, + :comment_agent, + :comment_type, + :comment_parent, + :user_id + ]) + |> validate_required([:comment_content]) + end +end diff --git a/apps/content/lib/commentmeta.ex b/apps/content/lib/commentmeta.ex new file mode 100644 index 00000000..cad8e1cc --- /dev/null +++ b/apps/content/lib/commentmeta.ex @@ -0,0 +1,19 @@ +defmodule Content.Commentmeta do + @moduledoc """ + A piece of metadata about a comment on the site. + """ + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:meta_id, :id, autogenerate: true} + schema "wp_commentmeta" do + field :comment_id, :integer + field :meta_key, :string + field :meta_value, :string + end + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [:meta_id, :comment_id, :meta_key, :meta_value]) + end +end diff --git a/apps/content/lib/comments.ex b/apps/content/lib/comments.ex new file mode 100644 index 00000000..76782f76 --- /dev/null +++ b/apps/content/lib/comments.ex @@ -0,0 +1,107 @@ +defmodule Content.Comments do + @moduledoc """ + Functions for presenting comments on the site. + """ + import Ecto.Query, warn: false + alias Content.Comment + alias Content.Repo + + def children(parent_comment_id, array_of_comments) do + array_of_comments + |> Enum.filter(&(&1.comment_parent == parent_comment_id)) + end + + @doc """ + Returns the list of comments. + + ## Examples + + iex> list_comments() + [%Comment{}, ...] + + """ + def list_comments do + Repo.all(Comment) + end + + @doc """ + Gets a single comment. + + Raises `Ecto.NoResultsError` if the comment does not exist. + + ## Examples + + iex> get_comment!(123) + %Comment{} + + iex> get_comment!(456) + ** (Ecto.NoResultsError) + + """ + def get_comment!(id), do: Repo.get!(Comment, id) + + @doc """ + Creates a comment. + + ## Examples + + iex> create_comment(%{field: value}) + {:ok, %Comment{}} + + iex> create_comment(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_comment(attrs \\ %{}) do + %Comment{} + |> Comment.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a comment. + + ## Examples + + iex> update_comment(comment, %{field: new_value}) + {:ok, %Comment{}} + + iex> update_comment(comment, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_comment(%Comment{} = comment, attrs) do + comment + |> Comment.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a Comment. + + ## Examples + + iex> delete_comment(comment) + {:ok, %Comment{}} + + iex> delete_comment(comment) + {:error, %Ecto.Changeset{}} + + """ + def delete_comment(%Comment{} = comment) do + Repo.delete(comment) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking comment changes. + + ## Examples + + iex> change_comment(comment) + %Ecto.Changeset{source: %Comment{}} + + """ + def change_comment(%Comment{} = comment) do + Comment.changeset(comment, %{}) + end +end diff --git a/apps/content/lib/link.ex b/apps/content/lib/link.ex new file mode 100644 index 00000000..4baa64bd --- /dev/null +++ b/apps/content/lib/link.ex @@ -0,0 +1,42 @@ +defmodule Content.Link do + @moduledoc """ + A link for the (deprecated) link roll feature. + """ + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:link_id, :id, autogenerate: true} + schema "wp_links" do + field :link_url, :string + field :link_name, :string + field :link_image, :string + field :link_target, :string + field :link_description, :string + field :link_visible, :string + field :link_owner, :integer + field :link_rating, :integer + field :link_updated, :naive_datetime + field :link_rel, :string + field :link_notes, :string + field :link_rss, :string + end + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [ + :link_id, + :link_url, + :link_name, + :link_image, + :link_target, + :link_description, + :link_visible, + :link_owner, + :link_rating, + :link_updated, + :link_rel, + :link_notes, + :link_rss + ]) + end +end diff --git a/apps/content/lib/menu.ex b/apps/content/lib/menu.ex new file mode 100644 index 00000000..2437c8e8 --- /dev/null +++ b/apps/content/lib/menu.ex @@ -0,0 +1,167 @@ +defmodule Content.Menu do + @moduledoc """ + Module for retrieving, manipulating, and processing navigation menus. + """ + alias Content.{Option, Post, Repo, Term, TermRelationship} + import Ecto.Query + + def put_menu_option(option_name, location_name, menu_id) do + option = + Option + |> where(option_name: ^option_name) + |> Repo.one() + |> Kernel.||(%Option{option_name: option_name, option_value: "a:0:{}"}) + + value = + option + |> Option.parse_option_value + + nav_menu_locations = + value + |> Enum.find(fn {key, _value} -> key == "nav_menu_locations" end) + |> Kernel.||({"nav_menu_locations", []}) + |> elem(1) + + new_nav_menu_locations = + nav_menu_locations + |> Enum.filter(fn {key, _value} -> key != location_name end) + |> Kernel.++([{location_name, menu_id}]) + + new_value = + value + |> Enum.filter(fn {key, _value} -> key != "nav_menu_locations" end) + |> Kernel.++([{"nav_menu_locations", new_nav_menu_locations}]) + + option + |> Option.put_new_value(new_value) + end + + def get_menu_from_option_and_location(option_name, location_name) do + option = + Option + |> where(option_name: ^option_name) + |> Repo.one() + |> Kernel.||(%Option{option_name: option_name, option_value: "a:0:{}"}) + + value = + option + |> Option.parse_option_value + + menu_pair = + value + |> Enum.find(fn {key, _value} -> key == "nav_menu_locations" end) + |> Kernel.||({"nav_menu_locations", []}) + |> elem(1) + |> Enum.find(fn {key, _value} -> key == location_name end) + + case menu_pair do + {^location_name, menu_id} -> + menu_id |> get_menu_from_id() + nil -> + nil + end + end + + def get_menu_from_id(menu_id) do + menu_id + |> nav_menu_items_for_id() + |> arrange_menu_item_posts() + end + + def nav_menu_items_for_id(menu_id) do + Post + |> join( + :inner, + [p], + tr in TermRelationship, + on: p."ID" == tr.object_id + ) + |> order_by(:menu_order) + |> preload(:metas) + |> where([p, tr], tr.term_taxonomy_id == ^menu_id) + |> Repo.all() + end + + defp arrange_menu_item_posts(nav_posts, parent_id \\ "0", nav_to_post_map \\ nil) do + nav_to_post_map = nav_to_post_map || make_nav_to_post_map(nav_posts) + + nav_posts + |> Enum.filter(fn post -> + meta_map = post |> Post.metas_map + meta_map["_menu_item_menu_item_parent"] == parent_id + end) + |> Enum.map(fn post -> + meta_map = post |> Post.metas_map + related_item = + if meta_map["_menu_item_object"] == "category" do + item = nav_to_post_map["category/#{meta_map["_menu_item_object_id"]}"] || %Term{} + + %{ + title: item.name, + slug: item.slug, + resource: "category", + } + else + item = nav_to_post_map["post/#{meta_map["_menu_item_object_id"]}"] || %Post{} + + %{ + title: item.post_title, + slug: item.post_name, + resource: "posts", + } + end + + %{ + post_id: post."ID", + type: meta_map["_menu_item_object"], + target_id: meta_map["_menu_item_object_id"], + parent_id: meta_map["_menu_item_menu_item_parent"], + url: meta_map["_menu_item_url"], + related_item: related_item, + children: arrange_menu_item_posts(nav_posts, Integer.to_string(post."ID"), nav_to_post_map), + } + end) + end + + defp make_nav_to_post_map(nav_posts) do + nav_post_meta_map = nav_posts |> Post.metas_map() + + linked_post_ids = + nav_post_meta_map + |> Enum.filter(fn {_key, value} -> + value["_menu_item_object"] != "category" + end) + |> Enum.map(fn {_key, value} -> + value["_menu_item_object_id"] + end) + + nav_to_post_map = + Post + |> where([p], p."ID" in ^linked_post_ids) + |> Repo.all() + |> Enum.map(fn post -> + {"post/#{post."ID"}", post} + end) + |> Map.new + + linked_category_ids = + nav_post_meta_map + |> Enum.filter(fn {_key, value} -> + value["_menu_item_object"] == "category" + end) + |> Enum.map(fn {_key, value} -> + value["_menu_item_object_id"] + end) + + nav_to_category_map = + Term + |> where([t], t.term_id in ^linked_category_ids) + |> Repo.all() + |> Enum.map(fn category -> + {"category/#{category.term_id}", category} + end) + |> Map.new + + nav_to_post_map |> Map.merge(nav_to_category_map) + end +end diff --git a/apps/content/lib/option.ex b/apps/content/lib/option.ex new file mode 100644 index 00000000..c9ee60bd --- /dev/null +++ b/apps/content/lib/option.ex @@ -0,0 +1,31 @@ +defmodule Content.Option do + @moduledoc """ + A configuration option for the site. + """ + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:option_id, :id, autogenerate: true} + schema "wp_options" do + field :option_name, :string + field :autoload, :string + field :option_value, :string + end + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [:option_id, :option_name, :option_value, :autoload]) + end + + def parse_option_value(struct) do + case PhpSerializer.unserialize(struct.option_value) do + {:ok, values} -> + values + end + end + + def put_new_value(struct, value) do + struct + |> change(%{option_value: PhpSerializer.serialize(value)}) + end +end diff --git a/apps/content/lib/options.ex b/apps/content/lib/options.ex new file mode 100644 index 00000000..6f998816 --- /dev/null +++ b/apps/content/lib/options.ex @@ -0,0 +1,29 @@ +defmodule Content.Options do + @moduledoc """ + Query the option key-value pairs for the site. + """ + alias Content.Option + alias Content.Repo + + def get(key), do: Option |> Repo.get_by(option_name: key) + + def get_value(key) do + case get(key) do + nil -> + nil + opt -> + opt + |> (&(&1.option_value)).() + end + end + + def get_value_as_int(key) do + case get_value(key) do + nil -> + {nil, nil} + opt -> + opt + |> Integer.parse() + end + end +end diff --git a/apps/content/lib/post.ex b/apps/content/lib/post.ex new file mode 100644 index 00000000..3576a352 --- /dev/null +++ b/apps/content/lib/post.ex @@ -0,0 +1,136 @@ +defmodule Content.Post do + @moduledoc """ + One "post" i.e. a blog post, page, attachment, or item of a custom post type. + """ + use Ecto.Schema + import Ecto.Changeset + alias Content.Slugs + + @primary_key {:ID, :id, autogenerate: true} + @derive {Phoenix.Param, key: :post_name} + schema "wp_posts" do + field :post_date, :naive_datetime + field :post_date_gmt, :naive_datetime + field :post_content, :string, default: "" + field :post_title, :string + field :post_excerpt, :string + field :post_status, :string + field :comment_status, :string + field :ping_status, :string + field :post_password, :string, default: "" + field :post_name, :string + field :to_ping, :string, default: "" + field :pinged, :string, default: "" + field :post_modified, :naive_datetime + field :post_modified_gmt, :naive_datetime + field :post_content_filtered, :string, default: "" + field :post_parent, :integer + field :guid, :string + field :menu_order, :integer + field :post_type, :string + field :post_mime_type, :string + field :comment_count, :integer + field :sticky, :boolean, [virtual: true, default: false] + has_many :metas, Content.Postmeta, foreign_key: :post_id + has_many :comments, Content.Comment, foreign_key: :comment_post_ID + has_many :term_relationships, Content.TermRelationship, foreign_key: :object_id + has_many :categories, through: [:term_relationships, :category, :term] + has_many :tags, through: [:term_relationships, :tag, :term] + has_one :post_format, through: [:term_relationships, :post_format, :term] + belongs_to :author, Content.User, foreign_key: :post_author, references: :ID + end + + def changeset(struct, params \\ %{}) do + struct + |> cast( + params, + [ + :ID, + :post_author, + :post_date, + :post_date_gmt, + :post_content, + :post_title, + :post_excerpt, + :post_status, + :comment_status, + :ping_status, + :post_password, + :post_name, + :to_ping, + :pinged, + :post_content_filtered, + :post_parent, + :menu_order, + :post_type, + :post_mime_type, + :comment_count, + :sticky, + ]) + |> put_default(:post_date, NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)) + |> put_default(:post_date_gmt, NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)) + |> put_change(:post_modified, NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)) + |> put_change(:post_modified_gmt, NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)) + |> Slugs.ensure_post_has_slug() + |> maybe_put_guid() + |> validate_required([:post_name, :post_status]) + |> validate_inclusion(:post_status, ["publish", "future", "draft", "pending", "private", "trash", "auto-draft", "inherit"]) + end + + def put_default(changeset, key, value) do + if is_nil(changeset |> get_field(key)) do + changeset + |> put_change(key, value) + else + changeset + end + end + + def before_more(string) do + string + |> String.split("") + |> Enum.at(0) + end + + def content_page(struct, page) do + (struct.post_content || "") + |> String.split("") + |> Enum.at(page - 1) + |> Kernel.||("") + end + + def content_page_count(struct) do + (struct.post_content || "") + |> String.split("") + |> Enum.count + end + + def paginated_post?(struct) do + content_page_count(struct) > 1 + end + + def metas_map(list) when is_list(list) do + list + |> Enum.map(fn post -> + {post."ID", metas_map(post)} + end) + |> Map.new + end + def metas_map(%Content.Post{} = struct) do + struct.metas + |> Enum.map(&({&1.meta_key, &1.meta_value})) + |> Map.new + end + + def maybe_put_guid(changeset) do + import ContentWeb.Router.Helpers, only: [posts_url: 3] + slug = changeset |> get_field(:post_name) + + case slug do + nil -> changeset + _ -> + changeset + |> put_default(:guid, posts_url(ContentWeb.Endpoint, :show, slug)) + end + end +end diff --git a/apps/content/lib/postmeta.ex b/apps/content/lib/postmeta.ex new file mode 100644 index 00000000..0f6a96b0 --- /dev/null +++ b/apps/content/lib/postmeta.ex @@ -0,0 +1,19 @@ +defmodule Content.Postmeta do + @moduledoc """ + An item of metadata about a post. + """ + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:meta_id, :id, autogenerate: true} + schema "wp_postmeta" do + belongs_to :post, Content.Post, references: :ID + field :meta_key, :string + field :meta_value, :string + end + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [:meta_id, :post_id, :meta_key, :meta_value]) + end +end diff --git a/apps/content/lib/posts.ex b/apps/content/lib/posts.ex new file mode 100644 index 00000000..f418515b --- /dev/null +++ b/apps/content/lib/posts.ex @@ -0,0 +1,286 @@ +defmodule Content.Posts do + @page_size 3 + + @moduledoc """ + The Content context. + """ + + import Ecto.Query, warn: false + alias Content.Repo + + alias Content.Option + alias Content.Post + alias Ecto.Changeset + + @preloads [:metas, :author, :categories, :tags, :comments, :post_format] + + @doc """ + Returns the lisdpt of posts for admin interface. + + ## Examples + + iex> list_admin_posts() + [%Post{}, ...] + + """ + def list_admin_posts(page, post_type \\ "post") do + post_type = post_type || "post" + Repo.all( + from p in Post, + where: p.post_type == ^post_type, + where: p.post_status in ["publish", "future", "draft", "pending", "private", "inherit"], + preload: ^@preloads, + order_by: [desc: p.post_date], + limit: @page_size, + offset: ^(@page_size * (String.to_integer(page) - 1)) + ) + end + + @doc """ + Returns the list of posts. + + ## Examples + + iex> list_posts() + [%Post{}, ...] + + """ + def list_posts(params \\ %{}) do + page = params |> Map.get("page", "1") + + normal_posts = + params + |> post_scope_for_params() + |> limit(@page_size) + |> offset(^(@page_size * (String.to_integer(page) - 1))) + |> Repo.all() + + sticky_posts_for_page(params) ++ normal_posts + end + + def post_scope_for_params(params) do + post_type = params |> Map.get("post_type", "post") + category = params |> Map.get("category") + + query = + post_scope() + |> where([p], p.post_type == ^post_type) + + if category do + query |> join(:inner, [p], term in assoc(p, :categories), on: term.slug == ^category) + else + query + end + end + + def post_scope do + from p in Post, + where: p.post_status == "publish", + preload: ^@preloads, + order_by: [desc: p.post_date] + end + + def post_scope_with_drafts do + from p in Post, + preload: ^@preloads, + order_by: [desc: p.post_date] + end + + def sticky_posts_for_page(%{"page" => "1"} = params) do + sticky_posts = + params + |> post_scope_for_params() + |> where([p], p.'ID' in ^sticky_ids()) + |> Repo.all() + + sticky_posts + |> Enum.map(fn post -> + post + |> Changeset.change(%{sticky: true}) + |> Changeset.apply_changes() + end) + end + def sticky_posts_for_page(_), do: [] + + defp sticky_ids do + case Repo.one(from opt in Option, where: opt.option_name == "sticky_posts") do + nil -> + [] + option -> + option + |> Option.parse_option_value + |> Enum.map(&(elem(&1, 1))) + end + end + + def last_page(params \\ %{}) do + post_count = + params + |> post_scope_for_params() + |> Repo.aggregate(:count, :ID) + + post_count + |> (&(&1 / @page_size)).() + |> Float.ceil + |> trunc + end + + def thumbs_for_posts(posts) do + post_to_thumbnail_id = + posts + |> Enum.map(fn post -> {post.'ID', (post |> Post.metas_map)["_thumbnail_id"]} end) + |> Enum.reject(&(elem(&1, 1) == nil)) + + thumbs = + Post + |> preload(:metas) + |> where([thumb], thumb.'ID' in ^Enum.map(post_to_thumbnail_id, &(elem(&1, 1)))) + |> Repo.all() + |> Enum.map(fn thumb -> {thumb.'ID', thumb} end) + |> Map.new + + post_to_thumbnail_id + |> Enum.map(fn {key, value} -> {key, thumbs[String.to_integer(value)]} end) + |> Map.new + end + + @doc """ + Gets a single posts. + + Raises `Ecto.NoResultsError` if the Wp posts does not exist. + + ## Examples + + iex> get_posts!(123) + %Post{} + + iex> get_posts!(456) + ** (Ecto.NoResultsError) + + """ + def get_posts!(slug) do + id_filter = fn scope, id -> + + case Integer.parse(id, 10) do + :error -> + scope |> where([p], p.post_name == ^id) + {int_id, _} -> + scope |> where([p], p.'ID' == ^int_id) + end + end + + post_scope() + |> where([p], p.post_type != "nav_menu_item") + |> id_filter.(slug) + |> Repo.one!() + end + + @doc """ + Gets a single post that may or may not be in draft status. + + Raises `Ecto.NoResultsError` if the Wp posts does not exist. + + ## Examples + + iex> get_post_with_drafts!(123) + %Post{} + + iex> get_post_with_drafts!(456) + ** (Ecto.NoResultsError) + + """ + def get_post_with_drafts!(slug) do + id_filter = fn scope, id -> + + case Integer.parse(id, 10) do + :error -> + scope |> where([p], p.post_name == ^id) + {int_id, ""} -> + scope |> where([p], p.'ID' == ^int_id) + {_int_id, _} -> + scope |> where([p], p.post_name == ^id) + end + end + + post_scope_with_drafts() + |> where([p], p.post_type != "nav_menu_item") + |> id_filter.(slug) + |> Repo.one!() + end + + @doc """ + Creates a posts. + + ## Examples + + iex> create_posts(%{field: value}) + {:ok, %Post{}} + + iex> create_posts(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_posts(attrs \\ %{}) do + %Post{} + |> Post.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Builds a post for preview, but does not save it. + """ + def preview_post(attrs \\ %{}) do + %Post{} + |> Repo.preload(@preloads) + |> Post.changeset(attrs) + |> Changeset.put_change(:post_name, "preview") + |> Changeset.apply_changes() + end + + @doc """ + Updates a posts. + + ## Examples + + iex> update_posts(posts, %{field: new_value}) + {:ok, %Post{}} + + iex> update_posts(posts, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_posts(%Post{} = posts, attrs) do + posts + |> Post.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a Post. + + ## Examples + + iex> delete_posts(posts) + {:ok, %Post{}} + + iex> delete_posts(posts) + {:error, %Ecto.Changeset{}} + + """ + def delete_posts(%Post{} = posts) do + Repo.delete(posts) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking posts changes. + + ## Examples + + iex> change_posts(posts) + %Ecto.Changeset{source: %Post{}} + + """ + def change_posts(%Post{} = posts) do + Post.changeset(posts, %{}) + end +end diff --git a/apps/content/lib/repo.ex b/apps/content/lib/repo.ex new file mode 100644 index 00000000..97b1e861 --- /dev/null +++ b/apps/content/lib/repo.ex @@ -0,0 +1,5 @@ +defmodule Content.Repo do + use Ecto.Repo, + otp_app: :content, + adapter: Ecto.Adapters.Postgres +end diff --git a/apps/content/lib/scheduler.ex b/apps/content/lib/scheduler.ex new file mode 100644 index 00000000..88d07146 --- /dev/null +++ b/apps/content/lib/scheduler.ex @@ -0,0 +1,9 @@ +defmodule Content.Scheduler do + @moduledoc """ + The Quantum cron-like scheduler for this application. See config.exs for + configured jobs. + """ + + use Quantum.Scheduler, + otp_app: :content +end diff --git a/apps/content/lib/shortcodes.ex b/apps/content/lib/shortcodes.ex new file mode 100644 index 00000000..9423e1bb --- /dev/null +++ b/apps/content/lib/shortcodes.ex @@ -0,0 +1,97 @@ +defmodule Content.ShortcodeParser do + use Neotomex.ExGrammar + + @root true + define :document, "(comment / shortcode / notcode)*" do + tail -> + tail |> Enum.join + end + + define :notcode, "not_open_bracket+" do + content -> + content |> Enum.join + end + + define :comment, " ([^\\]]+ close_bracket?)+ " do + [inner] -> + inner + |> Enum.map(fn [chars, nil] -> "#{chars |> Enum.join}]" end) + |> Enum.join + |> (&("[#{&1}")).() + end + + define :shortcode, " name attribute* (notcode? <'/'> name )?" do + [name, attributes, nil] -> + Content.Shortcodes.dispatch(name, attributes) + [name, attributes, [content, closing_name]] when closing_name == name -> + Content.Shortcodes.dispatch(name, attributes, content || "") + end + + define :attribute, "name " + define :name, "(namechar+)", do: (chars -> Enum.join(chars)) + define :namechar, "[A-Za-z0-9] / dash / underscore" + define :dash, "<'-'>" + define :underscore, "<'_'>" + define :open_bracket, "<'['>", do: ["["] + define :not_open_bracket, "[^\\[]" + define :close_bracket, "<']'>", do: ["]"] + define :close_comment, "close_bracket close_bracket" + define :spaces, "[\s\\r\\n]*" + + @_neotomex_definitions Map.put(@_neotomex_definitions, + :not_open_bracket, + {{:terminal, ~r/^[^[]/u}, nil}) +end + +defmodule Content.Shortcodes do + @moduledoc """ + For handling wordpress style shortcodes in strings. + """ + def expand_shortcodes(frag) do + {:ok, tree} = Floki.parse_fragment(frag) + + case tree do + [text] when is_binary(text) -> + {:ok, result} = processed_text(text) |> Floki.parse_fragment + result + _ -> + tree + |> Floki.traverse_and_update(fn + tag -> + tag |> transform_text_nodes + end) + end + |> Floki.raw_html(encode: false) + end + + defp transform_text_nodes({tag_name, attrs, children}) do + new_children = + children + |> Enum.map(fn + text when is_binary(text) -> + {:ok, [result]} = processed_text(text) |> Floki.parse_fragment + result + other -> other + end) + {tag_name, attrs, new_children} + end + + defp processed_text(text) do + text = + text + |> String.replace("\r", "") + + case Content.ShortcodeParser.parse(text) do + {:ok, result, remainder} -> + [result, remainder] |> Enum.join + {:ok, result} -> + result + end + end + + def dispatch(tag, _attrs), do: String.upcase(String.reverse(tag)) + + def dispatch(_tag, _attrs, content) do + String.upcase(String.reverse(content)) + end +end diff --git a/apps/content/lib/slugs.ex b/apps/content/lib/slugs.ex new file mode 100644 index 00000000..a6ce4017 --- /dev/null +++ b/apps/content/lib/slugs.ex @@ -0,0 +1,68 @@ +defmodule Content.Slugs do + @moduledoc """ + Provides functions for working with post slugs and ensuring that they are unique. + """ + import Ecto.{Changeset, Query} + alias Content.{Post, Repo} + + def ensure_post_has_slug(changeset) do + cond do + !is_nil(changeset |> get_field(:post_name)) -> + changeset + is_nil(changeset |> get_field(:post_title)) -> + changeset + |> put_change( + :post_name, + changeset + |> get_field(:post_date) + |> Kernel.||(NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)) + |> Timex.format!("%F", :strftime) + |> Slugger.slugify_downcase() + |> unique_slug(changeset |> get_field(:ID)) + ) + true -> + changeset + |> put_change( + :post_name, + changeset + |> get_field(:post_title) + |> Slugger.slugify_downcase() + |> unique_slug(changeset |> get_field(:ID)) + ) + end + end + + defp unique_slug(proposed_slug, post_id, postfix_number \\ 0) do + proposed_slug_with_postfix = + if postfix_number == 0 do + proposed_slug + else + "#{proposed_slug}-#{postfix_number}" + end + + competition_count = + Repo.aggregate( + ( + Post + |> where([post], post.post_name == ^proposed_slug_with_postfix) + |> post_id_match(post_id) + ), + :count, + :ID + ) + + if competition_count == 0 do + proposed_slug_with_postfix + else + unique_slug(proposed_slug, post_id, postfix_number + 1) + end + end + + defp post_id_match(query, nil) do + query + end + + defp post_id_match(query, id) when is_number(id) do + from p in query, where: p.'ID' != ^id + end +end diff --git a/apps/content/lib/term.ex b/apps/content/lib/term.ex new file mode 100644 index 00000000..44a2221f --- /dev/null +++ b/apps/content/lib/term.ex @@ -0,0 +1,19 @@ +defmodule Content.Term do + @moduledoc """ + Represents one 'term', i.e. a grouping under a taxonomy. + """ + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:term_id, :id, autogenerate: true} + schema "wp_terms" do + field :name, :string + field :slug, :string + field :term_group, :integer + end + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [:term_id, :name, :slug, :term_group]) + end +end diff --git a/apps/content/lib/term_relationship.ex b/apps/content/lib/term_relationship.ex new file mode 100644 index 00000000..71ab281d --- /dev/null +++ b/apps/content/lib/term_relationship.ex @@ -0,0 +1,43 @@ +defmodule Content.TermRelationship do + @moduledoc """ + Maintains the relationship between a term_taxonomy and a post / page / or object. + """ + use Ecto.Schema + import Ecto.Changeset + alias Content.{Post} + + @primary_key {:object_id, :integer, []} + @primary_key {:term_taxonomy_id, :integer, []} + schema "wp_term_relationships" do + field :term_order, :integer + belongs_to :post, Post, foreign_key: :object_id, references: :ID + belongs_to :term_taxonomy, + Content.TermTaxonomy, + foreign_key: :term_taxonomy_id, + references: :term_taxonomy_id, + define_field: false + belongs_to :category, + Content.TermTaxonomy, + foreign_key: :term_taxonomy_id, + references: :term_taxonomy_id, + define_field: false, + where: [taxonomy: "category"] + belongs_to :tag, + Content.TermTaxonomy, + foreign_key: :term_taxonomy_id, + references: :term_taxonomy_id, + define_field: false, + where: [taxonomy: "post_tag"] + belongs_to :post_format, + Content.TermTaxonomy, + foreign_key: :term_taxonomy_id, + references: :term_taxonomy_id, + define_field: false, + where: [taxonomy: "post_format"] + end + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [:object_id, :term_taxonomy_id, :term_order]) + end +end diff --git a/apps/content/lib/term_taxonomy.ex b/apps/content/lib/term_taxonomy.ex new file mode 100644 index 00000000..cf642ff8 --- /dev/null +++ b/apps/content/lib/term_taxonomy.ex @@ -0,0 +1,21 @@ +defmodule Content.TermTaxonomy do + @moduledoc """ + A record in a taxonomy which organizes terms and posts in the system. + """ + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:term_taxonomy_id, :id, autogenerate: true} + schema "wp_term_taxonomy" do + field :taxonomy, :string + field :description, :string + field :parent, :integer + field :count, :integer + belongs_to :term, Content.Term, foreign_key: :term_id, references: :term_id + end + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [:term_taxonomy_id, :term_id, :taxonomy, :description, :parent, :count]) + end +end diff --git a/apps/content/lib/termmeta.ex b/apps/content/lib/termmeta.ex new file mode 100644 index 00000000..1d50e3dd --- /dev/null +++ b/apps/content/lib/termmeta.ex @@ -0,0 +1,19 @@ +defmodule Content.Termmeta do + @moduledoc """ + Represents one piece of metadata around one "term" (a grouping under a taxonomy). + """ + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:meta_id, :id, autogenerate: true} + schema "wp_termmeta" do + field :term_id, :integer + field :meta_key, :string + field :meta_value, :string + end + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [:meta_id, :term_id, :meta_key, :meta_value]) + end +end diff --git a/apps/content/lib/terms.ex b/apps/content/lib/terms.ex new file mode 100644 index 00000000..b53f0bfd --- /dev/null +++ b/apps/content/lib/terms.ex @@ -0,0 +1,15 @@ +defmodule Content.Terms do + @moduledoc """ + This module contains functions for retrieving, manipulating, and saving + Terms. + """ + + import Ecto.Query + + def categories do + from t in Content.Term, + join: tt in Content.TermTaxonomy, + on: t.term_id == tt.term_id, + where: tt.taxonomy == "category" + end +end diff --git a/apps/content/mix.exs b/apps/content/mix.exs new file mode 100644 index 00000000..64d1554c --- /dev/null +++ b/apps/content/mix.exs @@ -0,0 +1,84 @@ +defmodule Content.Mixfile do + use Mix.Project + + def project do + [ + app: :content, + version: "0.0.1", + build_path: "../../_build", + config_path: "../../config/config.exs", + deps_path: "../../deps", + lockfile: "../../mix.lock", + elixir: "~> 1.8", + elixirc_paths: elixirc_paths(Mix.env), + compilers: [:phoenix, :gettext] ++ Mix.compilers, + start_permanent: Mix.env == :prod, + aliases: aliases(), + deps: deps(), + test_coverage: [tool: ExCoveralls], + preferred_cli_env: [coveralls: :test, "coveralls.detail": :test, "coveralls.post": :test, "coveralls.html": :test] + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {Content.Application, []}, + extra_applications: [:logger, :runtime_tools, :sitemap] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:content_web, in_umbrella: true}, + {:phoenix, "~> 1.5.3"}, + {:phoenix_pubsub, "~> 2.0"}, + {:ecto_sql, "~> 3.4"}, + {:postgrex, "~> 0.15.0"}, + {:plug_cowboy, "~> 2.0"}, + {:phoenix_ecto, "~> 4.0"}, + {:phoenix_html, "~> 2.10"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:gettext, "~> 0.11"}, + {:cowboy, "~> 2.7"}, + {:php_serializer, "~> 0.9.0"}, + {:quantum, "~> 2.3"}, + {:timex, "~> 3.1"}, + {:excoveralls, "~> 0.10", only: [:dev, :test]}, + {:phoenix_html_sanitizer, "~> 1.0.0"}, + {:bcrypt_elixir, "~> 1.0"}, + {:comeonin, "~> 4.0"}, + {:earmark, "~> 1.4.2" }, + {:slugger, "~> 0.3"}, + {:ecto, "~> 3.4.3"}, + {:floki, "~> 0.25.0"}, + {:mock, "~> 0.3.0", only: :test}, + {:meck, "~> 0.8.13", only: :test}, + {:sitemap, "~> 1.1"}, + {:neotomex, "~> 0.1.7"}, + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to create, migrate and run the seeds file at once: + # + # $ mix ecto.setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate", "test"] + ] + end +end diff --git a/apps/content/priv/gettext/en/LC_MESSAGES/errors.po b/apps/content/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 00000000..a589998c --- /dev/null +++ b/apps/content/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,97 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" + +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/apps/content/priv/gettext/errors.pot b/apps/content/priv/gettext/errors.pot new file mode 100644 index 00000000..7b2d5ca2 --- /dev/null +++ b/apps/content/priv/gettext/errors.pot @@ -0,0 +1,95 @@ +## This file is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here as no +## effect: edit them in PO (`.po`) files instead. + +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_acceptance/3 +msgid "must be accepted" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" +msgstr[1] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" +msgstr[1] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" diff --git a/apps/content/priv/repo/migrations/20180825175607_create_wp_schema.exs b/apps/content/priv/repo/migrations/20180825175607_create_wp_schema.exs new file mode 100644 index 00000000..d5af16e0 --- /dev/null +++ b/apps/content/priv/repo/migrations/20180825175607_create_wp_schema.exs @@ -0,0 +1,133 @@ +defmodule Content.Repo.Migrations.CreateWpSchema do + use Ecto.Migration + + def change do + create table("wp_commentmeta", primary_key: false) do + add :meta_id, :serial, primary_key: true + add :comment_id, :integer + add :meta_key, :text + end + + create table("wp_comments", primary_key: false) do + add :comment_ID, :serial, primary_key: true + add :comment_post_ID, :integer + add :comment_author, :text + add :comment_author_email, :text + add :comment_author_url, :text + add :comment_author_IP, :text + add :comment_date, :naive_datetime + add :comment_date_gmt, :naive_datetime + add :comment_content, :string + add :comment_karma, :integer + add :comment_approved, :text + add :comment_agent, :text + add :comment_type, :text + add :comment_parent, :integer + add :user_id, :integer + end + + create table("wp_links", primary_key: false) do + add :link_id, :serial, primary_key: true + add :link_url, :text + add :link_name, :text + add :link_image, :text + add :link_target, :text + add :link_description, :text + add :link_visible, :text + add :link_owner, :integer + add :link_rating, :integer + add :link_updated, :naive_datetime + add :link_rel, :text + add :link_rss, :text + end + + create table("wp_options", primary_key: false) do + add :option_id, :serial, primary_key: true + add :option_name, :text + add :autoload, :text + add :option_value, :text + end + + create table("wp_postmeta", primary_key: false) do + add :meta_id, :serial, primary_key: true + add :post_id, :integer + add :meta_key, :text + add :meta_value, :text + end + + create table("wp_posts", primary_key: false) do + add :ID, :integer, [:primary_key] + add :post_author, :integer + add :post_date, :naive_datetime + add :post_date_gmt, :naive_datetime + add :post_content, :text + add :post_title, :string + add :post_excerpt, :string + add :post_status, :text + add :comment_status, :text + add :ping_status, :text + add :post_password, :text + add :post_name, :text + add :to_ping, :string + add :pinged, :string + add :post_modified, :naive_datetime + add :post_modified_gmt, :naive_datetime + add :post_content_filtered, :text + add :post_parent, :integer + add :guid, :text + add :menu_order, :integer + add :post_type, :text + add :post_mime_type, :text + add :comment_count, :integer + end + + create table("wp_term_relationships", primary_key: false) do + add :object_id, :serial, primary_key: true + add :term_taxonomy_id, :integer, [:primary_key] + add :term_order, :integer + end + + create table("wp_term_taxonomy", primary_key: false) do + add :term_taxonomy_id, :serial, primary_key: true + add :term_id, :integer + add :taxonomy, :text + add :description, :text + add :parent, :integer + add :count, :integer + end + + create table("wp_termmeta", primary_key: false) do + add :meta_id, :serial, primary_key: true + add :term_id, :integer + add :meta_key, :text + add :meta_value, :text + end + + create table("wp_terms", primary_key: false) do + add :term_id, :serial, primary_key: true + add :name, :text + add :slug, :text + add :term_group, :integer + end + + create table("wp_usermeta", primary_key: false) do + add :umeta_id, :serial, primary_key: true + add :user_id, :integer + add :meta_key, :text + add :meta_value, :text + end + + create table("wp_users", primary_key: false) do + add :ID, :integer, [:primary_key] + add :user_login, :text + add :user_pass, :text + add :user_nicename, :text + add :user_email, :text + add :user_url, :text + add :user_registered, :naive_datetime + add :user_activation_key, :text + add :user_status, :integer + add :display_name, :text + end + end +end diff --git a/apps/content/priv/repo/migrations/20180910143353_change_comment_content_to_text.exs b/apps/content/priv/repo/migrations/20180910143353_change_comment_content_to_text.exs new file mode 100644 index 00000000..ce51b9bc --- /dev/null +++ b/apps/content/priv/repo/migrations/20180910143353_change_comment_content_to_text.exs @@ -0,0 +1,9 @@ +defmodule Content.Repo.Migrations.ChangeCommentContentToText do + use Ecto.Migration + + def change do + alter table("wp_comments") do + modify :comment_content, :text + end + end +end diff --git a/apps/content/priv/repo/migrations/20181105030245_add_default_to_comment_count.exs b/apps/content/priv/repo/migrations/20181105030245_add_default_to_comment_count.exs new file mode 100644 index 00000000..904554a8 --- /dev/null +++ b/apps/content/priv/repo/migrations/20181105030245_add_default_to_comment_count.exs @@ -0,0 +1,9 @@ +defmodule Content.Repo.Migrations.AddDefaultToCommentCount do + use Ecto.Migration + + def change do + alter table("wp_posts") do + modify :comment_count, :integer, default: 0 + end + end +end diff --git a/apps/content/priv/repo/migrations/20181117185826_add_meta_value_to_wp_commentmeta.exs b/apps/content/priv/repo/migrations/20181117185826_add_meta_value_to_wp_commentmeta.exs new file mode 100644 index 00000000..ee8a7f72 --- /dev/null +++ b/apps/content/priv/repo/migrations/20181117185826_add_meta_value_to_wp_commentmeta.exs @@ -0,0 +1,9 @@ +defmodule Content.Repo.Migrations.AddMetaValueToWpCommentmetas do + use Ecto.Migration + + def change do + alter table("wp_commentmeta") do + add :meta_value, :string + end + end +end diff --git a/apps/content/priv/repo/migrations/20181117191925_add_link_notes_to_wp_links.exs b/apps/content/priv/repo/migrations/20181117191925_add_link_notes_to_wp_links.exs new file mode 100644 index 00000000..d08465ce --- /dev/null +++ b/apps/content/priv/repo/migrations/20181117191925_add_link_notes_to_wp_links.exs @@ -0,0 +1,9 @@ +defmodule Content.Repo.Migrations.AddLinkNotesToWpLinks do + use Ecto.Migration + + def change do + alter table("wp_links") do + add :link_notes, :string + end + end +end diff --git a/apps/content/priv/repo/migrations/20190221142633_add_unique_index_to_post_name.exs b/apps/content/priv/repo/migrations/20190221142633_add_unique_index_to_post_name.exs new file mode 100644 index 00000000..d1e69a64 --- /dev/null +++ b/apps/content/priv/repo/migrations/20190221142633_add_unique_index_to_post_name.exs @@ -0,0 +1,7 @@ +defmodule Content.Repo.Migrations.AddUniqueIndexToPostName do + use Ecto.Migration + + def change do + create unique_index(:wp_posts, ["post_name"]) + end +end diff --git a/apps/content/priv/repo/seeds.exs b/apps/content/priv/repo/seeds.exs new file mode 100644 index 00000000..f67a3785 --- /dev/null +++ b/apps/content/priv/repo/seeds.exs @@ -0,0 +1,11 @@ +# Script for populating the database. You can run it as: +# +# mix run priv/repo/seeds.exs +# +# Inside the script, you can read and write to any of your +# repositories directly: +# +# Content.Repo.insert!(%Content.SomeSchema{}) +# +# We recommend using the bang functions (`insert!`, `update!` +# and so on) as they will fail if something goes wrong. diff --git a/apps/content/test/content/attachment_test.exs b/apps/content/test/content/attachment_test.exs new file mode 100644 index 00000000..3759449c --- /dev/null +++ b/apps/content/test/content/attachment_test.exs @@ -0,0 +1,69 @@ +defmodule Content.AttachmentTest do + use Content.DataCase + + alias Content.{Attachment, Postmeta, Posts, Repo} + + @create_attrs %{ + ID: 123, + post_name: "my-attachment", + post_title: "My Attachment", + post_content: "", + post_status: "publish", + post_type: "attachment", + post_date: "2018-01-01T00:00:00Z" + } + + def fixture(:wide_attachment) do + {:ok, attachment} = Posts.create_posts(@create_attrs) + {:ok, _meta} = + %Postmeta{ + post_id: attachment."ID", + meta_key: "_wp_attachment_metadata", + meta_value: "a:2:{s:5:\"width\";i:640;s:6:\"height\";i:480;}" + } |> Repo.insert() + + Content.Post + |> preload([:metas]) + |> Repo.get!(attachment."ID") + end + + def fixture(:tall_attachment) do + {:ok, attachment} = Posts.create_posts(@create_attrs) + {:ok, _meta} = + %Postmeta{ + post_id: attachment."ID", + meta_key: "_wp_attachment_metadata", + meta_value: "a:2:{s:5:\"width\";i:480;s:6:\"height\";i:640;}" + } |> Repo.insert() + Content.Post + |> preload([:metas]) + |> Repo.get!(attachment."ID") + end + + def fixture(:unknown_dimensions) do + {:ok, attachment} = Posts.create_posts(@create_attrs) + Content.Post + |> preload([:metas]) + |> Repo.get!(attachment."ID") + end + + describe "dimensions" do + test "can get dimensions" do + assert Attachment.dimensions(fixture(:wide_attachment)) == %{width: 640, height: 480} + end + + test "returns nil if dimensions are missing" do + assert is_nil(Attachment.dimensions(fixture(:unknown_dimensions))) + end + end + + describe "vertical?" do + test "returns true if vertical image" do + assert Attachment.vertical?(fixture(:tall_attachment)) + end + + test "returns false if not vertical image" do + refute Attachment.vertical?(fixture(:wide_attachment)) + end + end +end diff --git a/apps/content/test/content/commentmeta_test.exs b/apps/content/test/content/commentmeta_test.exs new file mode 100644 index 00000000..55772764 --- /dev/null +++ b/apps/content/test/content/commentmeta_test.exs @@ -0,0 +1,15 @@ +defmodule Content.CommentmetaTest do + use Content.DataCase + + alias Content.{Commentmeta, Repo} + + test "can save a new commentmeta" do + %Commentmeta{} + |> Commentmeta.changeset(%{ + comment_id: 123, + meta_key: "testcommentmeta", + meta_value: "some value", + }) + |> Repo.insert!() + end +end diff --git a/apps/content/test/content/comments_test.exs b/apps/content/test/content/comments_test.exs new file mode 100644 index 00000000..24f54f4b --- /dev/null +++ b/apps/content/test/content/comments_test.exs @@ -0,0 +1,53 @@ +defmodule Content.CommentsTest do + use Content.DataCase + + alias Content.{Comment, Comments, Repo} + alias Ecto.Changeset + + def fixture(:parent_comment) do + %Comment{ + comment_ID: 123, + comment_content: "Hello world", + comment_post_ID: 456, + } + |> Repo.insert!() + end + + def fixture(:child_comment) do + %Comment{ + comment_ID: 456, + comment_parent: 123, + comment_content: "Hello back", + comment_post_ID: 456, + } + |> Repo.insert!() + end + + describe "children" do + test "can get children of a comment that has them" do + parent = fixture(:parent_comment) + kid = fixture(:child_comment) + + kids = Comments.children(parent.comment_ID, Comments.list_comments) + assert kids == [kid] + end + + test "returns an empty list if the comment has no children " do + parent = fixture(:parent_comment) + + kids = Comments.children(parent.comment_ID, Comments.list_comments) + assert kids == [] + end + end + + describe "change comment" do + test "gives a changeset" do + changeset = fixture(:parent_comment) |> Comments.change_comment + changed_value = + changeset + |> Changeset.put_change(:comment_content, "woops") + |> Changeset.get_change(:comment_content) + assert changed_value == "woops" + end + end +end diff --git a/apps/content/test/content/link_test.exs b/apps/content/test/content/link_test.exs new file mode 100644 index 00000000..7ecb960d --- /dev/null +++ b/apps/content/test/content/link_test.exs @@ -0,0 +1,13 @@ +defmodule Content.LinkTest do + use Content.DataCase + + alias Content.{Link, Repo} + + test "can save a new link" do + %Link{} + |> Link.changeset(%{ + link_url: "https://example.com" + }) + |> Repo.insert!() + end +end diff --git a/apps/content/test/content/menu_test.exs b/apps/content/test/content/menu_test.exs new file mode 100644 index 00000000..c8dd4138 --- /dev/null +++ b/apps/content/test/content/menu_test.exs @@ -0,0 +1,141 @@ +defmodule Content.MenuTest do + use Content.DataCase + + alias Content.{Menu, Option, Post, Postmeta, Repo, Term, TermRelationship} + + @theme_option %Option{ + option_name: "test_theme", + option_value: "a:1:{s:18:\"nav_menu_locations\";a:1:{s:3:\"top\";i:13;}}" + } + + @term_relationship %TermRelationship{ + term_taxonomy_id: 13, + object_id: 123, + } + + @top_nav_item %Post{ + ID: 123, + post_name: "home", + post_title: "Home", + post_content: "", + post_status: "publish", + post_type: "nav_item", + post_date: ~N"2018-01-01T00:00:00", + comment_status: "open", + } + + @top_nav_metas [ + %{ + post_id: 123, + meta_key: "_menu_item_object_id", + meta_value: "456", + }, + %{ + post_id: 123, + meta_key: "_menu_item_object", + meta_value: "post", + }, + %{ + post_id: 123, + meta_key: "_menu_item_menu_item_parent", + meta_value: "0", + }, + ] + + @category_nav_metas [ + %{ + post_id: 123, + meta_key: "_menu_item_object_id", + meta_value: "42", + }, + %{ + post_id: 123, + meta_key: "_menu_item_object", + meta_value: "category", + }, + %{ + post_id: 123, + meta_key: "_menu_item_menu_item_parent", + meta_value: "0", + }, + ] + + @related_page %Post { + ID: 456, + post_title: "Test Nav Home", + } + + @related_category %Term{ + term_id: 42, + name: "Test Category", + slug: "test-category", + } + + def fixture(:option) do + @theme_option |> Repo.insert() + end + + def fixture(:menu) do + {:ok, option} = fixture(:option) + {:ok, _term_relationship} = @term_relationship |> Repo.insert() + {:ok, _nav_item} = @top_nav_item |> Repo.insert() + {3, nil} = Repo.insert_all(Postmeta, @top_nav_metas) + {:ok, _post} = @related_page |> Repo.insert() + option + end + + def fixture(:category_menu) do + {:ok, option} = fixture(:option) + {:ok, _term_relationship} = @term_relationship |> Repo.insert() + {:ok, _nav_item} = @top_nav_item |> Repo.insert() + {3, nil} = Repo.insert_all(Postmeta, @category_nav_metas) + {:ok, _category} = @related_category |> Repo.insert() + option + end + + describe "get_menu_from_option_and_location" do + test "returns an empty if the menu is not present" do + fixture(:option) + assert Menu.get_menu_from_option_and_location("test_theme", "top") == [] + end + + test "returns items if the menu is present" do + fixture(:menu) + menu = Menu.get_menu_from_option_and_location("test_theme", "top") + refute menu == [] + assert (menu |> Enum.at(0)) == %{ + children: [], + parent_id: "0", + post_id: 123, + related_item: %{resource: "posts", slug: nil, title: "Test Nav Home"}, + target_id: "456", + type: "post", + url: nil, + } + end + + test "returns items if the menu has a category" do + fixture(:category_menu) + menu = Menu.get_menu_from_option_and_location("test_theme", "top") + refute menu == [] + assert (menu |> Enum.at(0)) == %{ + children: [], + parent_id: "0", + post_id: 123, + related_item: %{resource: "category", slug: "test-category", title: "Test Category"}, + target_id: "42", + type: "category", + url: nil, + } + end + end + + describe "put_menu_option" do + test "it can change the active menu in a position" do + fixture(:menu) + + {:ok, _option} = Menu.put_menu_option("test_theme", "top", 7) |> Repo.update() + assert Menu.get_menu_from_option_and_location("test_theme", "top") == [] + end + end +end diff --git a/apps/content/test/content/option_test.exs b/apps/content/test/content/option_test.exs new file mode 100644 index 00000000..b7679c26 --- /dev/null +++ b/apps/content/test/content/option_test.exs @@ -0,0 +1,14 @@ +defmodule Content.OptionTest do + use Content.DataCase + + alias Content.{Option, Repo} + + test "can save a new link" do + %Option{} + |> Option.changeset(%{ + option_name: "test_up", + option_value: "1", + }) + |> Repo.insert!() + end +end diff --git a/apps/content/test/content/options_test.exs b/apps/content/test/content/options_test.exs new file mode 100644 index 00000000..55436c32 --- /dev/null +++ b/apps/content/test/content/options_test.exs @@ -0,0 +1,26 @@ +defmodule Content.OptionsTest do + use Content.DataCase + + alias Content.{Option, Options, Repo} + + def fixture(:option) do + %Option{} + |> Option.changeset(%{ + option_name: "test_up", + option_value: "1", + }) + |> Repo.insert!() + end + + test "can get an option by name" do + fixture(:option) + + assert Options.get_value("test_up") == "1" + end + + test "can get an option by name as an int" do + fixture(:option) + + assert Options.get_value_as_int("test_up") == {1, ""} + end +end diff --git a/apps/content/test/content/postmeta_test.exs b/apps/content/test/content/postmeta_test.exs new file mode 100644 index 00000000..7014fcaa --- /dev/null +++ b/apps/content/test/content/postmeta_test.exs @@ -0,0 +1,15 @@ +defmodule Content.PostmetaTest do + use Content.DataCase + + alias Content.{Postmeta, Repo} + + test "can save a new postmeta" do + %Postmeta{} + |> Postmeta.changeset(%{ + post_id: 123, + meta_key: "testpostmeta", + meta_value: "some value", + }) + |> Repo.insert!() + end +end diff --git a/apps/content/test/content/shortcode_test.exs b/apps/content/test/content/shortcode_test.exs new file mode 100644 index 00000000..2154578d --- /dev/null +++ b/apps/content/test/content/shortcode_test.exs @@ -0,0 +1,71 @@ +defmodule Content.ShortcodesTest do + use ExUnit.Case + + import Content.Shortcodes + + describe "shortcodes" do + test "no shortcodes, no problem" do + assert expand_shortcodes("test") == "test" + end + + test "double bracket escapes work" do + assert expand_shortcodes("[[test]]") == "[test]" + end + + test "escapes enclosing shortcodes" do + assert expand_shortcodes("[[test] abc [/test]]") == "[test] abc [/test]" + end + + test "escapes shortcodes with args" do + assert expand_shortcodes("[[test with-args][/test]]") == "[test with-args][/test]" + end + + test "expands shortcodes" do + assert expand_shortcodes("[test]") == "TSET" + end + + test "expands shortcodes in the middle" do + assert expand_shortcodes("this is a [test] of the shortcode system") == "this is a TSET of the shortcode system" + end + + test "expands shortcodes at the end" do + assert expand_shortcodes("this is a [test]") == "this is a TSET" + end + + test "expands shortcodes at the beginning" do + assert expand_shortcodes("[test] it up") == "TSET it up" + end + + test "handles shortcodes with args" do + assert expand_shortcodes("[test with-args]") == "TSET" + end + + test "handles enclosing shortcodes" do + assert expand_shortcodes("[test]Content[/test]") == "TNETNOC" + end + + test "handles enclosing shortcodes with args" do + assert expand_shortcodes("[test with-args]Content[/test]") == "TNETNOC" + end + + test "handles enclosing shortcodes with no content" do + assert expand_shortcodes("[test with-args][/test]") == "" + end + + test "handles strings with carriage returns" do + assert expand_shortcodes(" | \r\n ") == " | \n " + end + + test "handles strings with high unicode characters" do + assert expand_shortcodes("—") == "—" + end + + test "handles shortcodes within tags" do + assert expand_shortcodes("

[test]chacha

") == "

TSETchacha

" + end + + test "handles mangled shortcodes gracefully" do + assert expand_shortcodes("[[unclosed shortcode") == "[[unclosed shortcode" + end + end +end diff --git a/apps/content/test/content/slugs_test.exs b/apps/content/test/content/slugs_test.exs new file mode 100644 index 00000000..09426ada --- /dev/null +++ b/apps/content/test/content/slugs_test.exs @@ -0,0 +1,91 @@ +defmodule Content.SlugsTest do + use Content.DataCase + + alias Content.{Post, Posts, Repo, Slugs} + alias Ecto.Changeset + + @create_attrs %{ + ID: 123, + post_name: "my-post", + post_title: "My Post", + post_content: "", + post_status: "publish", + post_type: "post", + post_date: "2018-01-01T00:00:00Z" + } + + @dupe_title_attrs %{ + ID: 456, + post_title: "My Post", + post_content: "", + post_status: "publish", + post_type: "post", + post_date: "2018-01-01T00:00:00Z" + } + + describe "ensure_post_has_slug" do + test "doesn't overwrite a set slug" do + new_post = + %Post{ + post_name: "a-set-slug" + } + |> Post.changeset() + |> Slugs.ensure_post_has_slug() + |> Changeset.apply_changes() + + assert new_post.post_name == "a-set-slug" + end + + test "works even if the title is nil" do + new_post = + %Post{} + |> Changeset.change(%{}) + |> Slugs.ensure_post_has_slug() + |> Changeset.apply_changes() + + assert new_post.post_name |> String.length() > 0 + end + + test "sets a slug if the title is there" do + new_post = + %Post{ + post_title: "My NEW Post" + } + |> Changeset.change(%{}) + |> Slugs.ensure_post_has_slug() + |> Changeset.apply_changes() + + assert new_post.post_name == "my-new-post" + end + + test "ensures uniqueness of the slug" do + {:ok, og_post} = Posts.create_posts(@create_attrs) + assert Post |> Repo.aggregate(:count, :ID) == 1 + + new_post = + %Post{ + post_title: "MY POST" + } + |> Changeset.change(%{}) + |> Slugs.ensure_post_has_slug() + |> Changeset.apply_changes() + + assert new_post.post_name != og_post.post_name + assert new_post.post_name == "my-post-1" + end + + test "ensures uniqueness of the slug on update" do + {:ok, og_post} = Posts.create_posts(@create_attrs) + assert Post |> Repo.aggregate(:count, :ID) == 1 + + new_post = + %Post{} + |> Changeset.change(@dupe_title_attrs) + |> Slugs.ensure_post_has_slug() + |> Changeset.apply_changes() + + assert new_post.post_name != og_post.post_name + assert new_post.post_name == "my-post-1" + end + end +end diff --git a/apps/content/test/content/term_relationship_test.exs b/apps/content/test/content/term_relationship_test.exs new file mode 100644 index 00000000..6031d737 --- /dev/null +++ b/apps/content/test/content/term_relationship_test.exs @@ -0,0 +1,14 @@ +defmodule Content.TermRelationshipTest do + use Content.DataCase + + alias Content.{Repo, TermRelationship} + + test "can save a new term relationship" do + %TermRelationship{} + |> TermRelationship.changeset(%{ + object_id: 123, + term_taxonomy_id: 456, + }) + |> Repo.insert!() + end +end diff --git a/apps/content/test/content/term_taxonomy_test.exs b/apps/content/test/content/term_taxonomy_test.exs new file mode 100644 index 00000000..2939da71 --- /dev/null +++ b/apps/content/test/content/term_taxonomy_test.exs @@ -0,0 +1,13 @@ +defmodule Content.TermTaxonomyTest do + use Content.DataCase + + alias Content.{Repo, TermTaxonomy} + + test "can save a new term taxonomy" do + %TermTaxonomy{} + |> TermTaxonomy.changeset(%{ + taxonomy: "post_tag", + }) + |> Repo.insert!() + end +end diff --git a/apps/content/test/content/term_test.exs b/apps/content/test/content/term_test.exs new file mode 100644 index 00000000..10cb8b35 --- /dev/null +++ b/apps/content/test/content/term_test.exs @@ -0,0 +1,14 @@ +defmodule Content.TermTest do + use Content.DataCase + + alias Content.{Repo, Term} + + test "can save a new term" do + %Term{} + |> Term.changeset(%{ + slug: "testterm", + name: "Test Term", + }) + |> Repo.insert!() + end +end diff --git a/apps/content/test/content/termmeta_test.exs b/apps/content/test/content/termmeta_test.exs new file mode 100644 index 00000000..ba437b91 --- /dev/null +++ b/apps/content/test/content/termmeta_test.exs @@ -0,0 +1,15 @@ +defmodule Content.TermmetaTest do + use Content.DataCase + + alias Content.{Repo, Termmeta} + + test "can save a new termmeta" do + %Termmeta{} + |> Termmeta.changeset(%{ + term_id: 123, + meta_key: "testtermmeta", + meta_value: "some value", + }) + |> Repo.insert!() + end +end diff --git a/apps/content/test/support/data_case.ex b/apps/content/test/support/data_case.ex new file mode 100644 index 00000000..c1b16489 --- /dev/null +++ b/apps/content/test/support/data_case.ex @@ -0,0 +1,56 @@ +defmodule Content.DataCase do + @moduledoc """ + This module defines the setup for tests requiring + access to the application's data layer. + + You may define functions here to be used as helpers in + your tests. + + Finally, if the test case interacts with the database, + it cannot be async. For this reason, every test runs + inside a transaction which is reset at the beginning + of the test unless the test case is marked as async. + """ + + use ExUnit.CaseTemplate + + alias Ecto.Adapters.SQL.Sandbox + alias Ecto.Changeset + + using do + quote do + alias Content.Repo + + import Ecto + + import Ecto.Query + import Content.DataCase + end + end + + setup tags do + :ok = Sandbox.checkout(Content.Repo) + + unless tags[:async] do + Sandbox.mode(Content.Repo, {:shared, self()}) + end + + :ok + end + + @doc """ + A helper that transform changeset errors to a map of messages. + + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) + assert "password is too short" in errors_on(changeset).password + assert %{password: ["password is too short"]} = errors_on(changeset) + + """ + def errors_on(changeset) do + Changeset.traverse_errors(changeset, fn {message, opts} -> + Enum.reduce(opts, message, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end +end diff --git a/apps/content/test/test_helper.exs b/apps/content/test/test_helper.exs new file mode 100644 index 00000000..95a6f8e0 --- /dev/null +++ b/apps/content/test/test_helper.exs @@ -0,0 +1,3 @@ +ExUnit.start() + +Ecto.Adapters.SQL.Sandbox.mode(Content.Repo, :manual) diff --git a/apps/content_web/lib/content_web/router.ex b/apps/content_web/lib/content_web/router.ex index 2acb24da..20b4d2a3 100644 --- a/apps/content_web/lib/content_web/router.ex +++ b/apps/content_web/lib/content_web/router.ex @@ -16,6 +16,7 @@ defmodule ContentWeb.Router do scope "/", ContentWeb do pipe_through :browser + get "/posts/:id", PostsController, :show get "/:id", PageController, :show end end diff --git a/mix.lock b/mix.lock index 418cdee5..6b2039e9 100644 --- a/mix.lock +++ b/mix.lock @@ -1,47 +1,73 @@ %{ "bamboo": {:hex, :bamboo, "1.5.0", "1926107d58adba6620450f254dfe8a3686637a291851fba125686fa8574842af", [:mix], [{:hackney, ">= 1.13.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d5f3d04d154e80176fd685e2531e73870d8700679f14d25a567e448abce6298d"}, + "bcrypt_elixir": {:hex, :bcrypt_elixir, "1.1.1", "6b5560e47a02196ce5f0ab3f1d8265db79a23868c137e973b27afef928ed8006", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "10f658be786bd2daaadcd45cc5b598da01d5bbc313da4d0e3efb2d6a511d896d"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, "certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"}, "cldr_utils": {:hex, :cldr_utils, "2.9.1", "be714403abe1a7abed5ee4f7dd3823a9067f96ab4b0613a454177b51ca204236", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "6cba0a485f57feb773291ca1816469ddd887e22d73d9b12a1b207d82a67a4e71"}, + "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, + "comeonin": {:hex, :comeonin, "4.1.2", "3eb5620fd8e35508991664b4c2b04dd41e52f1620b36957be837c1d7784b7592", [:mix], [{:argon2_elixir, "~> 1.2", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:bcrypt_elixir, "~> 0.12.1 or ~> 1.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: true]}, {:pbkdf2_elixir, "~> 0.12", [hex: :pbkdf2_elixir, repo: "hexpm", optional: true]}], "hexpm", "d8700a0ca4dbb616c22c9b3f6dd539d88deaafec3efe66869d6370c9a559b3e9"}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, "cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"}, "cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"}, "credo": {:hex, :credo, "1.4.0", "92339d4cbadd1e88b5ee43d427b639b68a11071b6f73854e33638e30a0ea11f5", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1fd3b70dce216574ce3c18bdf510b57e7c4c85c2ec9cad4bff854abaf7e58658"}, + "crontab": {:hex, :crontab, "1.1.10", "dc9bb1f4299138d47bce38341f5dcbee0aa6c205e864fba7bc847f3b5cb48241", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "1347d889d1a0eda997990876b4894359e34bfbbd688acbb0ba28a2795ca40685"}, "db_connection": {:hex, :db_connection, "2.2.2", "3bbca41b199e1598245b716248964926303b5d4609ff065125ce98bcd368939e", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "642af240d8a8affb93b4ba5a6fcd2bbcbdc327e1a524b825d383711536f8070c"}, "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, + "earmark": {:hex, :earmark, "1.4.10", "bddce5e8ea37712a5bfb01541be8ba57d3b171d3fa4f80a0be9bcf1db417bcaf", [:mix], [{:earmark_parser, ">= 1.4.10", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "12dbfa80810478e521d3ffb941ad9fbfcbbd7debe94e1341b4c4a1b2411c1c27"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.10", "6603d7a603b9c18d3d20db69921527f82ef09990885ed7525003c7fe7dc86c56", [:mix], [], "hexpm", "8e2d5370b732385db2c9b22215c3f59c84ac7dda7ed7e544d7c459496ae519c0"}, "ecto": {:hex, :ecto, "3.4.5", "2bcd262f57b2c888b0bd7f7a28c8a48aa11dc1a2c6a858e45dd8f8426d504265", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8c6d1d4d524559e9b7a062f0498e2c206122552d63eacff0a6567ffe7a8e8691"}, "ecto_sql": {:hex, :ecto_sql, "3.4.4", "d28bac2d420f708993baed522054870086fd45016a9d09bb2cd521b9c48d32ea", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "edb49af715dd72f213b66adfd0f668a43c17ed510b5d9ac7528569b23af57fe8"}, + "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"}, "ex_cldr": {:hex, :ex_cldr, "2.13.0", "742f14a4afcfea61a190d603d8e555d2c91d71e4e8fc2520d5dc35616969e225", [:mix], [{:cldr_utils, "~> 2.3", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.13", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "5e4cf3e945ee60156a3342e2a762f69036ffbe1f80520cc88592d68f12c5db55"}, "ex_prompt": {:hex, :ex_prompt, "0.1.5", "b136642d0962f8ea37b3c9fa185ad1f42c71c3b9c6c3950f0358d7f3d2db2970", [:mix], [], "hexpm", "ad19a404708c9c7b05d36090b2d074ceafbed248a8de1a22d45a05ebe6994b83"}, "excoveralls": {:hex, :excoveralls, "0.13.0", "4e1b7cc4e0351d8d16e9be21b0345a7e165798ee5319c7800b9138ce17e0b38e", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "fe2a56c8909564e2e6764765878d7d5e141f2af3bc8ff3b018a68ee2a218fced"}, "file_system": {:hex, :file_system, "0.2.8", "f632bd287927a1eed2b718f22af727c5aeaccc9a98d8c2bd7bff709e851dc986", [:mix], [], "hexpm", "97a3b6f8d63ef53bd0113070102db2ce05352ecf0d25390eb8d747c2bde98bca"}, + "floki": {:hex, :floki, "0.25.0", "b1c9ddf5f32a3a90b43b76f3386ca054325dc2478af020e87b5111c19f2284ac", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "631f4e627c46d5ecd347df5a2accdaf0621c77c3693c5b75a8ad58e84c61f242"}, + "gen_stage": {:hex, :gen_stage, "1.0.0", "51c8ae56ff54f9a2a604ca583798c210ad245f415115453b773b621c49776df5", [:mix], [], "hexpm", "1d9fc978db5305ac54e6f5fec7adf80cd893b1000cf78271564c516aa2af7706"}, + "gen_state_machine": {:hex, :gen_state_machine, "2.1.0", "a38b0e53fad812d29ec149f0d354da5d1bc0d7222c3711f3a0bd5aa608b42992", [:mix], [], "hexpm", "ae367038808db25cee2f2c4b8d0531522ea587c4995eb6f96ee73410a60fa06b"}, "gettext": {:hex, :gettext, "0.18.0", "406d6b9e0e3278162c2ae1de0a60270452c553536772167e2d701f028116f870", [:mix], [], "hexpm", "c3f850be6367ebe1a08616c2158affe4a23231c70391050bf359d5f92f66a571"}, "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"}, + "html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"}, + "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.0.1", "2572e7122c78ab7e57b613e7c7f5e42bf9b3c25e430e32f23f1413d86db8a0af", [:mix], [{:mochiweb, "~> 2.12.2", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "c334e2835e094fb9c04658bd4cfc7533fa51a8f56f11343c57ab9cb2a01d8613"}, "idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"}, "jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b659b8571deedf60f79c5a608e15414085fa141344e2716fbd6988a084b5f993"}, + "libring": {:hex, :libring, "1.5.0", "44313eb6862f5c9168594a061e9d5f556a9819da7c6444706a9e2da533396d70", [:mix], [], "hexpm", "04e843d4fdcff49a62d8e03778d17c6cb2a03fe2d14020d3825a1761b55bd6cc"}, "linguist": {:hex, :linguist, "0.3.0", "2984dfce6720d1212ddd7bba82496f92627a39aecd4d32c7016ec00393e1f925", [:mix], [{:ex_cldr, "~> 2.0", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.0", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "1923876545db22b63334c9d203ef56397a2946daa018117767b068f856be41e4"}, + "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "mochiweb": {:hex, :mochiweb, "2.12.2", "80804ad342afa3d7f3524040d4eed66ce74b17a555de454ac85b07c479928e46", [:make, :rebar], [], "hexpm", "d3e681d4054b74a96cf2efcd09e94157ab83a5f55ddc4ce69f90b8144673bd7a"}, + "mock": {:hex, :mock, "0.3.5", "feb81f52b8dcf0a0d65001d2fec459f6b6a8c22562d94a965862f6cc066b5431", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "6fae404799408300f863550392635d8f7e3da6b71abdd5c393faf41b131c8728"}, + "neotomex": {:hex, :neotomex, "0.1.7", "64f76513653aa87ea7abdde0fd600e56955d838020a13d88f2bf334c88ac3e7a", [:mix], [], "hexpm", "4b87b8f614d1cd89dc8ba80ba0e559bedb3ebf6f6d74cd774fcfdd215e861445"}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, "phoenix": {:hex, :phoenix, "1.5.3", "bfe0404e48ea03dfe17f141eff34e1e058a23f15f109885bbdcf62be303b49ff", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8e16febeb9640d8b33895a691a56481464b82836d338bb3a23125cd7b6157c25"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.1.0", "a044d0756d0464c5a541b4a0bf4bcaf89bffcaf92468862408290682c73ae50d", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c5e666a341ff104d0399d8f0e4ff094559b2fde13a5985d4cb5023b2c2ac558b"}, "phoenix_html": {:hex, :phoenix_html, "2.14.2", "b8a3899a72050f3f48a36430da507dd99caf0ac2d06c77529b1646964f3d563e", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "58061c8dfd25da5df1ea0ca47c972f161beb6c875cd293917045b92ffe1bf617"}, + "phoenix_html_sanitizer": {:hex, :phoenix_html_sanitizer, "1.0.2", "e2c8cfbc83660e362753de127cc957bec3442a8aecdf271fb65a684a906fccf5", [:mix], [{:html_sanitize_ex, "~> 1.0.0", [hex: :html_sanitize_ex, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}], "hexpm", "47aebb08fa954b7ad95f295fb701df9800ee3a489212119c9c6074a65e1e5a10"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.2.6", "1b4e1b7d797386b7f9d70d2af931dc9843a5f2f2423609d22cef1eec4e4dba7d", [:mix], [{:phoenix_html, "~> 2.14.1 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.13.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.4.0 or ~> 0.5.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "b20dcad98c4ca63d38a7f5e7a40936e1e8e9da983d3d722b88ae33afb866c9ca"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.2.4", "940c0344b1d66a2e46eef02af3a70e0c5bb45a4db0bf47917add271b76cd3914", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "38f9308357dea4cc77f247e216da99fcb0224e05ada1469167520bed4cb8cccd"}, "phoenix_live_view": {:hex, :phoenix_live_view, "0.13.3", "2186c55cc7c54ca45b97c6f28cfd267d1c61b5f205f3c83533704cd991bdfdec", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.4.17 or ~> 1.5.2", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}], "hexpm", "c6309a7da2e779cb9cdf2fb603d75f38f49ef324bedc7a81825998bd1744ff8a"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, + "php_serializer": {:hex, :php_serializer, "0.9.2", "59c5fd6bd3096671fd89358fb8229341ac7423b50ad8d45a15213b02ea2edab2", [:mix], [], "hexpm", "34eb835a460944f7fc216773b363c02e7dcf8ac0390c9e9ccdbd92b31a7ca59a"}, "plug": {:hex, :plug, "1.10.3", "c9cebe917637d8db0e759039cc106adca069874e1a9034fd6e3fdd427fd3c283", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "01f9037a2a1de1d633b5a881101e6a444bcabb1d386ca1e00bb273a1f1d9d939"}, "plug_cowboy": {:hex, :plug_cowboy, "2.3.0", "149a50e05cb73c12aad6506a371cd75750c0b19a32f81866e1a323dda9e0e99d", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bc595a1870cef13f9c1e03df56d96804db7f702175e4ccacdb8fc75c02a7b97e"}, "plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"}, "postgrex": {:hex, :postgrex, "0.15.5", "aec40306a622d459b01bff890fa42f1430dac61593b122754144ad9033a2152f", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "ed90c81e1525f65a2ba2279dbcebf030d6d13328daa2f8088b9661eb9143af7f"}, "pow": {:hex, :pow, "1.0.20", "b99993811af5233681bfc521e81ca706d25a56f2be54bad6424db327ce840ab9", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.3.0 and < 1.6.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 2.0.0 and <= 3.0.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, ">= 1.5.0 and < 2.0.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b6bd271399ccb353abbdbdc316199fe7fd7ae36bbf47059d53e366831c34fc8"}, + "quantum": {:hex, :quantum, "2.4.0", "f2ad4b20988f848455d35ed0e884ba0c7629a27ee86cbec6a6e0fc214b6e69cf", [:mix], [{:calendar, "~> 0.17", [hex: :calendar, repo: "hexpm", optional: true]}, {:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.12 or ~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:swarm, "~> 3.3", [hex: :swarm, repo: "hexpm", optional: false]}, {:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: true]}, {:tzdata, "~> 1.0", [hex: :tzdata, repo: "hexpm", optional: true]}], "hexpm", "a125a9e65a5af740a1198f3b05c1a736fce3942f5e0dc2901e0f9be5745bea99"}, "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, + "sitemap": {:hex, :sitemap, "1.1.0", "23a019cccef7c17090d0b493354ee47a94549db64fd1cf39bda7eb41c567729c", [:mix], [{:xml_builder, ">= 0.0.0", [hex: :xml_builder, repo: "hexpm", optional: false]}], "hexpm", "d21f2c3ac65567fbdbe231f9faaf802a48405aa487d24052964d3d818a3d8c22"}, + "slugger": {:hex, :slugger, "0.3.0", "efc667ab99eee19a48913ccf3d038b1fb9f165fa4fbf093be898b8099e61b6ed", [:mix], [], "hexpm", "20d0ded0e712605d1eae6c5b4889581c3460d92623a930ddda91e0e609b5afba"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, + "swarm": {:hex, :swarm, "3.4.0", "64f8b30055d74640d2186c66354b33b999438692a91be275bb89cdc7e401f448", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:libring, "~> 1.0", [hex: :libring, repo: "hexpm", optional: false]}], "hexpm", "94884f84783fc1ba027aba8fe8a7dae4aad78c98e9f9c76667ec3471585c08c6"}, "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.5.0", "1b796e74add83abf844e808564275dfb342bcc930b04c7577ab780e262b0d998", [:mix], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "31225e6ce7a37a421a0a96ec55244386aec1c190b22578bd245188a4a33298fd"}, "telemetry_poller": {:hex, :telemetry_poller, "0.5.1", "21071cc2e536810bac5628b935521ff3e28f0303e770951158c73eaaa01e962a", [:rebar3], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4cab72069210bc6e7a080cec9afffad1b33370149ed5d379b81c7c5f0c663fd4"}, + "timex": {:hex, :timex, "3.6.2", "845cdeb6119e2fef10751c0b247b6c59d86d78554c83f78db612e3290f819bc2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "26030b46199d02a590be61c2394b37ea25a3664c02fafbeca0b24c972025d47a"}, + "tzdata": {:hex, :tzdata, "1.0.3", "73470ad29dde46e350c60a66e6b360d3b99d2d18b74c4c349dbebbc27a09a3eb", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a6e1ee7003c4d04ecbd21dd3ec690d4c6662db5d3bbdd7262d53cdf5e7c746c1"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"}, + "xml_builder": {:hex, :xml_builder, "2.1.2", "90cb9ad382958934c78c6ddfbe6d385a8ce147d84b61cbfa83ec93a169d0feab", [:mix], [], "hexpm", "b89046041da2fbc1d51d31493ba31b9d5fc6223c93384bf513a1a9e1df9ec081"}, "yamerl": {:hex, :yamerl, "0.8.0", "8214cfe16bbabe5d1d6c14a14aea11c784b9a21903dd6a7c74f8ce180adae5c7", [:rebar3], [], "hexpm", "010634477bf9c208a0767dcca89116c2442cf0b5e87f9c870f85cd1c3e0c2aab"}, "yaml_elixir": {:hex, :yaml_elixir, "2.4.0", "2f444abc3c994c902851fde56b6a9cb82895c291c05a0490a289035c2e62ae71", [:mix], [{:yamerl, "~> 0.7", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "4e25a6d5c873e393689c6f1062c5ec90f6cd1be2527b073178ae37eae4c78bee"}, }