diff --git a/config/config.exs b/config/config.exs index 80feaece3..ef90323d5 100644 --- a/config/config.exs +++ b/config/config.exs @@ -66,6 +66,8 @@ config :groupher_server, :customization, sidebar_communities_index: %{} config :groupher_server, :article, + min_length: 10, + max_length: 20_000, # NOTE: do not change unless you know what you are doing threads: [:post, :job, :repo, :blog], # in this period, paged articles will sort front if non-article-author commented diff --git a/lib/groupher_server/cms/cms.ex b/lib/groupher_server/cms/cms.ex index f4a7a98b5..32d6ac2c6 100644 --- a/lib/groupher_server/cms/cms.ex +++ b/lib/groupher_server/cms/cms.ex @@ -92,6 +92,8 @@ defmodule GroupherServer.CMS do defdelegate mark_delete_article(thread, id), to: ArticleCURD defdelegate undo_mark_delete_article(thread, id), to: ArticleCURD + defdelegate remove_article(thread, id), to: ArticleCURD + defdelegate remove_article(thread, id, reason), to: ArticleCURD defdelegate update_active_timestamp(thread, article), to: ArticleCURD defdelegate sink_article(thread, id), to: ArticleCURD diff --git a/lib/groupher_server/cms/delegates/article_curd.ex b/lib/groupher_server/cms/delegates/article_curd.ex index 86fc2cb93..8e99317f7 100644 --- a/lib/groupher_server/cms/delegates/article_curd.ex +++ b/lib/groupher_server/cms/delegates/article_curd.ex @@ -18,12 +18,14 @@ defmodule GroupherServer.CMS.Delegate.ArticleCURD do alias Accounts.Model.User alias CMS.Model.{Author, Community, PinnedArticle, Embeds} + alias CMS.Model.Repo, as: CMSRepo alias CMS.Delegate.{ ArticleCommunity, CommentCurd, ArticleTag, CommunityCURD, + Document, Hooks } @@ -32,6 +34,7 @@ defmodule GroupherServer.CMS.Delegate.ArticleCURD do @active_period get_config(:article, :active_period_days) @default_emotions Embeds.ArticleEmotion.default_emotions() @default_article_meta Embeds.ArticleMeta.default_meta() + @remove_article_hint "The content does not comply with the community norms" @doc """ read articles for un-logined user @@ -40,7 +43,10 @@ defmodule GroupherServer.CMS.Delegate.ArticleCURD do with {:ok, info} <- match(thread) do Multi.new() |> Multi.run(:inc_views, fn _, _ -> ORM.read(info.model, id, inc: :views) end) - |> Multi.run(:update_article_meta, fn _, %{inc_views: article} -> + |> Multi.run(:load_html, fn _, %{inc_views: article} -> + article |> Repo.preload(:document) |> done + end) + |> Multi.run(:update_article_meta, fn _, %{load_html: article} -> article_meta = ensure(article.meta, @default_article_meta) meta = Map.merge(article_meta, %{can_undo_sink: in_active_period?(thread, article)}) @@ -57,17 +63,11 @@ defmodule GroupherServer.CMS.Delegate.ArticleCURD do def read_article(thread, id, %User{id: user_id}) do with {:ok, info} <- match(thread) do Multi.new() - |> Multi.run(:inc_views, fn _, _ -> ORM.read(info.model, id, inc: :views) end) - |> Multi.run(:update_article_meta, fn _, %{inc_views: article} -> - article_meta = ensure(article.meta, @default_article_meta) - meta = Map.merge(article_meta, %{can_undo_sink: in_active_period?(thread, article)}) - - ORM.update_meta(article, meta) - end) - |> Multi.run(:add_viewed_user, fn _, %{inc_views: article} -> + |> Multi.run(:normal_read, fn _, _ -> read_article(thread, id) end) + |> Multi.run(:add_viewed_user, fn _, %{normal_read: article} -> update_viewed_user_list(article, user_id) end) - |> Multi.run(:set_viewer_has_states, fn _, %{inc_views: article} -> + |> Multi.run(:set_viewer_has_states, fn _, %{normal_read: article} -> article_meta = if is_nil(article.meta), do: @default_article_meta, else: article.meta viewer_has_states = %{ @@ -76,7 +76,7 @@ defmodule GroupherServer.CMS.Delegate.ArticleCURD do viewer_has_reported: user_id in article_meta.reported_user_ids } - {:ok, Map.merge(article, viewer_has_states)} + article |> Map.merge(viewer_has_states) |> done end) |> Repo.transaction() |> result() @@ -156,6 +156,9 @@ defmodule GroupherServer.CMS.Delegate.ArticleCURD do |> Multi.run(:create_article, fn _, _ -> do_create_article(info.model, attrs, author, community) end) + |> Multi.run(:create_document, fn _, %{create_article: article} -> + Document.create(article, attrs) + end) |> Multi.run(:mirror_article, fn _, %{create_article: article} -> ArticleCommunity.mirror_article(thread, article.id, community.id) end) @@ -211,14 +214,17 @@ defmodule GroupherServer.CMS.Delegate.ArticleCURD do @doc """ update a article(post/job ...) """ - def update_article(article, args) do + def update_article(article, attrs) do Multi.new() |> Multi.run(:update_article, fn _, _ -> - do_update_article(article, args) + do_update_article(article, attrs) + end) + |> Multi.run(:update_document, fn _, %{update_article: update_article} -> + Document.update(update_article, attrs) end) |> Multi.run(:update_comment_question_flag_if_need, fn _, %{update_article: update_article} -> # 如果帖子的类型变了,那么 update 所有的 flag - case Map.has_key?(args, :is_question) do + case Map.has_key?(attrs, :is_question) do true -> CommentCurd.batch_update_question_flag(update_article) false -> {:ok, :pass} end @@ -319,6 +325,31 @@ defmodule GroupherServer.CMS.Delegate.ArticleCURD do end end + @doc """ + remove article forever + """ + def remove_article(thread, id, reason \\ @remove_article_hint) do + with {:ok, info} <- match(thread), + {:ok, article} <- ORM.find(info.model, id, preload: [:communities, [author: :user]]) do + Multi.new() + |> Multi.run(:remove_article, fn _, _ -> + article |> ORM.delete() + end) + |> Multi.run(:update_community_article_count, fn _, _ -> + CommunityCURD.update_community_count_field(article.communities, thread) + end) + |> Multi.run(:update_user_published_meta, fn _, _ -> + Accounts.update_published_states(article.author.user.id, thread) + end) + |> Multi.run(:delete_document, fn _, _ -> + Document.remove(thread, id) + end) + # TODO: notify author + |> Repo.transaction() + |> result() + end + end + @spec ensure_author_exists(User.t()) :: {:ok, User.t()} def ensure_author_exists(%User{} = user) do # unique_constraint: avoid race conditions, make sure user_id unique @@ -392,13 +423,12 @@ defmodule GroupherServer.CMS.Delegate.ArticleCURD do end # for create artilce step in Multi.new - defp do_create_article(model, attrs, %Author{id: author_id}, %Community{id: community_id}) do - # special article like Repo do not have :body, assign it with default-empty rich text - body = Map.get(attrs, :body, Converter.Article.default_rich_text()) + defp do_create_article(model, %{body: _body} = attrs, %Author{id: author_id}, %Community{ + id: community_id + }) do meta = @default_article_meta |> Map.merge(%{thread: module_to_upcase(model)}) - attrs = attrs |> Map.merge(%{body: body}) - with {:ok, attrs} <- add_rich_text_attrs(attrs) do + with {:ok, attrs} <- add_digest_attrs(attrs) do model.__struct__ |> model.changeset(attrs) |> Ecto.Changeset.put_change(:emotions, @default_emotions) @@ -409,8 +439,18 @@ defmodule GroupherServer.CMS.Delegate.ArticleCURD do end end + # Github Repo 没有传统的 body, 需要特殊处理 + # 赋值一个空的 body, 后续在 document 中处理 + # 注意:digest 那里也要特殊处理 + defp do_create_article(CMSRepo, attrs, author, community) do + body = Map.get(attrs, :body, Converter.Article.default_rich_text()) + attrs = Map.put(attrs, :body, body) + + do_create_article(CMSRepo, attrs, author, community) + end + defp do_update_article(article, %{body: _} = attrs) do - with {:ok, attrs} <- add_rich_text_attrs(attrs) do + with {:ok, attrs} <- add_digest_attrs(attrs) do ORM.update(article, attrs) end end @@ -418,17 +458,16 @@ defmodule GroupherServer.CMS.Delegate.ArticleCURD do defp do_update_article(article, attrs), do: ORM.update(article, attrs) # is update or create article with body field, parsed and extand it into attrs - defp add_rich_text_attrs(%{body: body} = attrs) when not is_nil(body) do + defp add_digest_attrs(%{body: body} = attrs) when not is_nil(body) do with {:ok, parsed} <- Converter.Article.parse_body(body), {:ok, digest} <- Converter.Article.parse_digest(parsed.body_map) do attrs - |> Map.merge(Map.take(parsed, [:body, :body_html])) |> Map.merge(%{digest: digest}) |> done end end - defp add_rich_text_attrs(attrs), do: attrs + defp add_digest_attrs(attrs), do: attrs defp update_viewed_user_list(%{meta: nil} = article, user_id) do new_ids = Enum.uniq([user_id] ++ @default_article_meta.viewed_user_ids) @@ -458,6 +497,7 @@ defmodule GroupherServer.CMS.Delegate.ArticleCURD do defp result({:ok, %{update_edit_status: result}}), do: {:ok, result} defp result({:ok, %{update_article: result}}), do: {:ok, result} + defp result({:ok, %{remove_article: result}}), do: {:ok, result} # NOTE: for read article, order is import defp result({:ok, %{set_viewer_has_states: result}}), do: result |> done() defp result({:ok, %{update_article_meta: result}}), do: {:ok, result} diff --git a/lib/groupher_server/cms/delegates/document.ex b/lib/groupher_server/cms/delegates/document.ex new file mode 100644 index 000000000..feada3a07 --- /dev/null +++ b/lib/groupher_server/cms/delegates/document.ex @@ -0,0 +1,124 @@ +defmodule GroupherServer.CMS.Delegate.Document do + @moduledoc """ + CURD operation on post/job ... + """ + import Ecto.Query, warn: false + import Helper.Utils, only: [done: 1, thread_of_article: 2, get_config: 2] + + import Helper.ErrorCode + import ShortMaps + + alias Helper.{ORM, Converter} + alias GroupherServer.{CMS, Repo} + + alias CMS.Model.ArticleDocument + alias Ecto.Multi + + # alias Helper.Converter.MdToEditor + alias GroupherServer.Support.Factory + + # TODO: spec repo logic + def create(article, %{readme: readme} = attrs) do + # .parse(markdown) + # body = MdToEditor.mock_rich_text(readme) + body = Factory.mock_rich_text(readme) + attrs = attrs |> Map.drop([:readme]) |> Map.put(:body, body) + create(article, attrs) + end + + # for create artilce step in Multi.new + def create(article, %{body: body} = attrs) do + with {:ok, article_thread} <- thread_of_article(article, :upcase), + {:ok, parsed} <- Converter.Article.parse_body(body) do + attrs = Map.take(parsed, [:body, :body_html]) + + Multi.new() + |> Multi.run(:create_article_document, fn _, _ -> + document_attrs = + Map.merge(attrs, %{ + thread: article_thread, + article_id: article.id, + title: article.title + }) + + ArticleDocument |> ORM.create(document_attrs) + end) + |> Multi.run(:create_thread_document, fn _, _ -> + attrs = attrs |> Map.put(foreign_key(article_thread), article.id) + + CMS.Model + |> Module.concat("#{Recase.to_title(article_thread)}Document") + |> ORM.create(attrs) + end) + |> Repo.transaction() + |> result() + end + end + + @doc """ + update both article and thread document + """ + def update(article, %{body: body} = attrs) when not is_nil(body) do + with {:ok, article_thread} <- thread_of_article(article, :upcase), + {:ok, article_doc} <- find_article_document(article_thread, article), + {:ok, thread_doc} <- find_thread_document(article_thread, article), + {:ok, parsed} <- Converter.Article.parse_body(body) do + attrs = Map.take(parsed, [:body, :body_html]) + + Multi.new() + |> Multi.run(:update_article_document, fn _, _ -> + case Map.has_key?(attrs, :title) do + true -> article_doc |> ORM.update(Map.merge(attrs, %{title: attrs.title})) + false -> article_doc |> ORM.update(attrs) + end + end) + |> Multi.run(:update_thread_document, fn _, _ -> + thread_doc |> ORM.update(attrs) + end) + |> Repo.transaction() + |> result() + end + end + + # 只更新 title 的情况 + def update(article, %{title: title} = attrs) do + with {:ok, article_thread} <- thread_of_article(article, :upcase), + {:ok, article_doc} <- find_article_document(article_thread, article) do + article_doc |> ORM.update(%{title: attrs.title}) + end + end + + def update(article, _), do: {:ok, article} + + defp find_article_document(article_thread, article) do + ORM.find_by(ArticleDocument, %{article_id: article.id, thread: article_thread}) + end + + defp find_thread_document(article_thread, article) do + CMS.Model + |> Module.concat("#{Recase.to_title(article_thread)}Document") + |> ORM.find_by(Map.put(%{}, foreign_key(article_thread), article.id)) + end + + @doc """ + remove article document foever + """ + def remove(thread, id) do + thread = thread |> to_string |> String.upcase() + + ArticleDocument |> ORM.findby_delete!(%{thread: thread, article_id: id}) + end + + defp foreign_key(article_thread) do + thread_atom = article_thread |> String.downcase() |> String.to_atom() + + :"#{thread_atom}_id" + end + + defp result({:ok, %{create_thread_document: result}}), do: {:ok, result} + defp result({:ok, %{update_article_document: result}}), do: {:ok, result} + + defp result({:error, _, _result, _steps}) do + {:error, [message: "create document", code: ecode(:create_fails)]} + end +end diff --git a/lib/groupher_server/cms/delegates/hooks/cite.ex b/lib/groupher_server/cms/delegates/hooks/cite.ex index 7f97b8375..d836c45bd 100644 --- a/lib/groupher_server/cms/delegates/hooks/cite.ex +++ b/lib/groupher_server/cms/delegates/hooks/cite.ex @@ -65,6 +65,12 @@ defmodule GroupherServer.CMS.Delegate.Hooks.Cite do end end + def handle(%{document: document} = article) do + body = Repo.preload(article, :document) |> get_in([:document, :body]) + article = article |> Map.put(:body, body) + handle(article) + end + @doc """ return fmt like: [ diff --git a/lib/groupher_server/cms/delegates/hooks/mention.ex b/lib/groupher_server/cms/delegates/hooks/mention.ex index f48d66fa5..5b49dea64 100644 --- a/lib/groupher_server/cms/delegates/hooks/mention.ex +++ b/lib/groupher_server/cms/delegates/hooks/mention.ex @@ -27,6 +27,12 @@ defmodule GroupherServer.CMS.Delegate.Hooks.Mention do end end + def handle(%{document: document} = article) do + body = Repo.preload(article, :document) |> get_in([:document, :body]) + article = article |> Map.put(:body, body) + handle(article) + end + defp handle_mentions(mentions, artiment) do with {:ok, author} <- author_of(artiment) do Delivery.send(:mention, artiment, mentions, author) diff --git a/lib/groupher_server/cms/helper/macros.ex b/lib/groupher_server/cms/helper/macros.ex index 61a8e95d0..03788d089 100644 --- a/lib/groupher_server/cms/helper/macros.ex +++ b/lib/groupher_server/cms/helper/macros.ex @@ -114,10 +114,8 @@ defmodule GroupherServer.CMS.Helper.Macros do common casting fields for general_article_fields """ - def general_article_fields(:cast) do + def general_article_cast_fields() do [ - :body, - :body_html, :digest, :original_community_id, :comments_count, @@ -176,19 +174,22 @@ defmodule GroupherServer.CMS.Helper.Macros do add(:[article]_id, references(:cms_[article]s, on_delete: :delete_all)) """ - defmacro general_article_fields do + defmacro general_article_fields(thread) do quote do field(:title, :string) - field(:body, :string) - field(:body_html, :string) field(:digest, :string) - belongs_to(:author, Author) - field(:views, :integer, default: 0) field(:is_pinned, :boolean, default: false, virtual: true) field(:mark_delete, :boolean, default: false) + belongs_to(:author, Author) + + has_one( + :document, + unquote(Module.concat(CMS.Model, "#{Recase.to_title(to_string(thread))}Document")) + ) + embeds_one(:meta, Embeds.ArticleMeta, on_replace: :update) embeds_one(:emotions, Embeds.ArticleEmotion, on_replace: :update) diff --git a/lib/groupher_server/cms/models/article_document.ex b/lib/groupher_server/cms/models/article_document.ex new file mode 100644 index 000000000..167c6bd15 --- /dev/null +++ b/lib/groupher_server/cms/models/article_document.ex @@ -0,0 +1,53 @@ +defmodule GroupherServer.CMS.Model.ArticleDocument do + @moduledoc """ + mainly for full-text search + """ + alias __MODULE__ + + use Ecto.Schema + use Accessible + + import Ecto.Changeset + import GroupherServer.CMS.Helper.Macros + import Helper.Utils, only: [get_config: 2] + + alias GroupherServer.CMS + alias CMS.Model.Embeds + + alias Helper.HTML + + @timestamps_opts [type: :utc_datetime_usec] + + @max_body_length get_config(:article, :max_length) + @min_body_length get_config(:article, :min_length) + + @required_fields ~w(thread title article_id body body_html)a + @optional_fields [] + + @type t :: %ArticleDocument{} + schema "article_documents" do + field(:thread, :string) + field(:title, :string) + field(:article_id, :id) + field(:body, :string) + field(:body_html, :string) + # TODO: 分词数据 + + timestamps() + end + + @doc false + def changeset(%ArticleDocument{} = doc, attrs) do + doc + |> cast(attrs, @optional_fields ++ @required_fields) + |> validate_required(@required_fields) + |> validate_length(:body, min: @min_body_length, max: @max_body_length) + end + + @doc false + def update_changeset(%ArticleDocument{} = doc, attrs) do + doc + |> cast(attrs, @optional_fields ++ @required_fields) + |> validate_length(:body, min: @min_body_length, max: @max_body_length) + end +end diff --git a/lib/groupher_server/cms/models/blog.ex b/lib/groupher_server/cms/models/blog.ex index 442fc5ac2..e7520eb26 100644 --- a/lib/groupher_server/cms/models/blog.ex +++ b/lib/groupher_server/cms/models/blog.ex @@ -14,7 +14,7 @@ defmodule GroupherServer.CMS.Model.Blog do @timestamps_opts [type: :utc_datetime_usec] @required_fields ~w(title digest)a - @article_cast_fields general_article_fields(:cast) + @article_cast_fields general_article_cast_fields() @optional_fields ~w(link_addr digest length)a ++ @article_cast_fields @type t :: %Blog{} @@ -24,7 +24,7 @@ defmodule GroupherServer.CMS.Model.Blog do article_tags_field(:blog) article_communities_field(:blog) - general_article_fields() + general_article_fields(:blog) end @doc false diff --git a/lib/groupher_server/cms/models/blog_document.ex b/lib/groupher_server/cms/models/blog_document.ex new file mode 100644 index 000000000..6e3bdb356 --- /dev/null +++ b/lib/groupher_server/cms/models/blog_document.ex @@ -0,0 +1,47 @@ +defmodule GroupherServer.CMS.Model.BlogDocument do + @moduledoc """ + mainly for full-text search + """ + alias __MODULE__ + + use Ecto.Schema + use Accessible + + import Ecto.Changeset + import Helper.Utils, only: [get_config: 2] + + alias GroupherServer.CMS + alias CMS.Model.{Embeds, Blog} + + @timestamps_opts [type: :utc_datetime_usec] + + @max_body_length get_config(:article, :max_length) + @min_body_length get_config(:article, :min_length) + + @required_fields ~w(body body_html blog_id)a + @optional_fields [] + + @type t :: %BlogDocument{} + schema "blog_documents" do + belongs_to(:blog, Blog, foreign_key: :blog_id) + + field(:body, :string) + field(:body_html, :string) + field(:toc, :map) + end + + @doc false + def changeset(%BlogDocument{} = blog, attrs) do + blog + |> cast(attrs, @optional_fields ++ @required_fields) + |> validate_required(@required_fields) + |> validate_length(:body, min: @min_body_length, max: @max_body_length) + end + + @doc false + def update_changeset(%BlogDocument{} = blog, attrs) do + blog + |> cast(attrs, @optional_fields ++ @required_fields) + |> validate_length(:body, min: @min_body_length, max: @max_body_length) + end +end diff --git a/lib/groupher_server/cms/models/cited_artiment.ex b/lib/groupher_server/cms/models/cited_artiment.ex index 9d11232bb..85422a665 100644 --- a/lib/groupher_server/cms/models/cited_artiment.ex +++ b/lib/groupher_server/cms/models/cited_artiment.ex @@ -16,7 +16,7 @@ defmodule GroupherServer.CMS.Model.CitedArtiment do @timestamps_opts [type: :utc_datetime] @required_fields ~w(cited_by_type cited_by_id user_id)a - @article_cast_fields general_article_fields(:cast) + @article_cast_fields general_article_cast_fields() @optional_fields ~w(comment_id block_linker)a ++ @article_cast_fields @type t :: %CitedArtiment{} diff --git a/lib/groupher_server/cms/models/job.ex b/lib/groupher_server/cms/models/job.ex index 20ab33218..142a3ffb9 100644 --- a/lib/groupher_server/cms/models/job.ex +++ b/lib/groupher_server/cms/models/job.ex @@ -10,11 +10,10 @@ defmodule GroupherServer.CMS.Model.Job do alias GroupherServer.CMS alias CMS.Model.Embeds - alias Helper.HTML @timestamps_opts [type: :utc_datetime_usec] - @required_fields ~w(title company body digest length)a - @article_cast_fields general_article_fields(:cast) + @required_fields ~w(title company digest length)a + @article_cast_fields general_article_cast_fields() @optional_fields @article_cast_fields ++ ~w(desc company_link link_addr copy_right)a @type t :: %Job{} @@ -29,7 +28,7 @@ defmodule GroupherServer.CMS.Model.Job do article_tags_field(:job) article_communities_field(:job) - general_article_fields() + general_article_fields(:job) end @doc false @@ -52,8 +51,5 @@ defmodule GroupherServer.CMS.Model.Job do defp generl_changeset(content) do content |> validate_length(:title, min: 3, max: 50) - |> validate_length(:body, min: 3, max: 10_000) - # |> cast_embed(:emotions, with: &Embeds.ArticleEmotion.changeset/2) - |> HTML.safe_string(:body) end end diff --git a/lib/groupher_server/cms/models/job_document.ex b/lib/groupher_server/cms/models/job_document.ex new file mode 100644 index 000000000..7f1fab644 --- /dev/null +++ b/lib/groupher_server/cms/models/job_document.ex @@ -0,0 +1,47 @@ +defmodule GroupherServer.CMS.Model.JobDocument do + @moduledoc """ + mainly for full-text search + """ + alias __MODULE__ + + use Ecto.Schema + use Accessible + + import Ecto.Changeset + import Helper.Utils, only: [get_config: 2] + + alias GroupherServer.CMS + alias CMS.Model.{Embeds, Job} + + @timestamps_opts [type: :utc_datetime_usec] + + @max_body_length get_config(:article, :max_length) + @min_body_length get_config(:article, :min_length) + + @required_fields ~w(body body_html job_id)a + @optional_fields [] + + @type t :: %JobDocument{} + schema "job_documents" do + belongs_to(:job, Job, foreign_key: :job_id) + + field(:body, :string) + field(:body_html, :string) + field(:toc, :map) + end + + @doc false + def changeset(%JobDocument{} = job, attrs) do + job + |> cast(attrs, @optional_fields ++ @required_fields) + |> validate_required(@required_fields) + |> validate_length(:body, min: @min_body_length, max: @max_body_length) + end + + @doc false + def update_changeset(%JobDocument{} = job, attrs) do + job + |> cast(attrs, @optional_fields ++ @required_fields) + |> validate_length(:body, min: @min_body_length, max: @max_body_length) + end +end diff --git a/lib/groupher_server/cms/models/post.ex b/lib/groupher_server/cms/models/post.ex index ff92b9c96..6bdd15a07 100644 --- a/lib/groupher_server/cms/models/post.ex +++ b/lib/groupher_server/cms/models/post.ex @@ -15,8 +15,8 @@ defmodule GroupherServer.CMS.Model.Post do @timestamps_opts [type: :utc_datetime_usec] - @required_fields ~w(title body digest length)a - @article_cast_fields general_article_fields(:cast) + @required_fields ~w(title digest length)a + @article_cast_fields general_article_cast_fields() @optional_fields ~w(link_addr copy_right is_question is_solved solution_digest)a ++ @article_cast_fields @@ -30,13 +30,9 @@ defmodule GroupherServer.CMS.Model.Post do field(:is_solved, :boolean, default: false) field(:solution_digest, :string) - # TODO: move to general_article_fields - # embeds_one(:block_task_runner, Embeds.BlockTaskRunner, on_replace: :update) - # embeds_many(:citing_contents, CMS.CitedArtiment, on_replace: :delete) - article_tags_field(:post) article_communities_field(:post) - general_article_fields() + general_article_fields(:post) end @doc false @@ -59,7 +55,6 @@ defmodule GroupherServer.CMS.Model.Post do changeset |> validate_length(:title, min: 3, max: 50) |> cast_embed(:emotions, with: &Embeds.ArticleEmotion.changeset/2) - |> validate_length(:body, min: 3, max: 10_000) |> validate_length(:link_addr, min: 5, max: 400) |> HTML.safe_string(:body) end diff --git a/lib/groupher_server/cms/models/post_document.ex b/lib/groupher_server/cms/models/post_document.ex new file mode 100644 index 000000000..b7e7bec9b --- /dev/null +++ b/lib/groupher_server/cms/models/post_document.ex @@ -0,0 +1,47 @@ +defmodule GroupherServer.CMS.Model.PostDocument do + @moduledoc """ + mainly for full-text search + """ + alias __MODULE__ + + use Ecto.Schema + use Accessible + + import Ecto.Changeset + import Helper.Utils, only: [get_config: 2] + + alias GroupherServer.CMS + alias CMS.Model.{Embeds, Post} + + @timestamps_opts [type: :utc_datetime_usec] + + @max_body_length get_config(:article, :max_length) + @min_body_length get_config(:article, :min_length) + + @required_fields ~w(body body_html post_id)a + @optional_fields [] + + @type t :: %PostDocument{} + schema "post_documents" do + belongs_to(:post, Post, foreign_key: :post_id) + + field(:body, :string) + field(:body_html, :string) + field(:toc, :map) + end + + @doc false + def changeset(%PostDocument{} = post, attrs) do + post + |> cast(attrs, @optional_fields ++ @required_fields) + |> validate_required(@required_fields) + |> validate_length(:body, min: @min_body_length, max: @max_body_length) + end + + @doc false + def update_changeset(%PostDocument{} = post, attrs) do + post + |> cast(attrs, @optional_fields ++ @required_fields) + |> validate_length(:body, min: @min_body_length, max: @max_body_length) + end +end diff --git a/lib/groupher_server/cms/models/repo.ex b/lib/groupher_server/cms/models/repo.ex index 8d992ebd8..8333d2562 100644 --- a/lib/groupher_server/cms/models/repo.ex +++ b/lib/groupher_server/cms/models/repo.ex @@ -15,7 +15,7 @@ defmodule GroupherServer.CMS.Model.Repo do @timestamps_opts [type: :utc_datetime_usec] @required_fields ~w(title owner_name owner_url repo_url desc readme star_count issues_count prs_count fork_count watch_count)a - @article_cast_fields general_article_fields(:cast) + @article_cast_fields general_article_cast_fields() @optional_fields @article_cast_fields ++ ~w(last_sync homepage_url release_tag license)a @type t :: %Repo{} @@ -42,7 +42,7 @@ defmodule GroupherServer.CMS.Model.Repo do article_tags_field(:repo) article_communities_field(:repo) - general_article_fields() + general_article_fields(:repo) end @doc false diff --git a/lib/groupher_server/cms/models/repo_document.ex b/lib/groupher_server/cms/models/repo_document.ex new file mode 100644 index 000000000..5d08129e9 --- /dev/null +++ b/lib/groupher_server/cms/models/repo_document.ex @@ -0,0 +1,41 @@ +defmodule GroupherServer.CMS.Model.RepoDocument do + @moduledoc """ + mainly for full-text search + """ + alias __MODULE__ + + use Ecto.Schema + use Accessible + + import Ecto.Changeset + + alias GroupherServer.CMS + alias CMS.Model.{Embeds, Repo} + + @timestamps_opts [type: :utc_datetime_usec] + + @required_fields ~w(body body_html repo_id)a + @optional_fields [] + + @type t :: %RepoDocument{} + schema "repo_documents" do + belongs_to(:repo, Repo, foreign_key: :repo_id) + + field(:body, :string) + field(:body_html, :string) + field(:toc, :map) + end + + @doc false + def changeset(%RepoDocument{} = repo, attrs) do + repo + |> cast(attrs, @optional_fields ++ @required_fields) + |> validate_required(@required_fields) + end + + @doc false + def update_changeset(%RepoDocument{} = repo, attrs) do + repo + |> cast(attrs, @optional_fields ++ @required_fields) + end +end diff --git a/lib/groupher_server_web/schema/Helper/fields.ex b/lib/groupher_server_web/schema/Helper/fields.ex index e36af5264..1ba8b9da8 100644 --- a/lib/groupher_server_web/schema/Helper/fields.ex +++ b/lib/groupher_server_web/schema/Helper/fields.ex @@ -1,6 +1,6 @@ defmodule GroupherServerWeb.Schema.Helper.Fields do @moduledoc """ - general fields used in schema definition + general fields used in GraphQL schema definition """ import Helper.Utils, only: [get_config: 2] import Absinthe.Resolution.Helpers, only: [dataloader: 2] @@ -18,8 +18,7 @@ defmodule GroupherServerWeb.Schema.Helper.Fields do quote do field(:id, :id) field(:title, :string) - field(:body, :string) - field(:body_html, :string) + field(:document, :thread_document, resolve: dataloader(CMS, :document)) field(:digest, :string) field(:views, :integer) field(:is_pinned, :boolean) @@ -112,7 +111,7 @@ defmodule GroupherServerWeb.Schema.Helper.Fields do "e do field(unquote(:"#{&1}_count"), :integer) field(unquote(:"viewer_has_#{&1}ed"), :boolean) - field(unquote(:"latest_#{&1}_users"), list_of(:simple_user)) + field(unquote(:"latest_#{&1}_users"), list_of(:common_user)) end ) end @@ -123,7 +122,7 @@ defmodule GroupherServerWeb.Schema.Helper.Fields do "e do field(unquote(:"#{&1}_count"), :integer) field(unquote(:"viewer_has_#{&1}ed"), :boolean) - field(unquote(:"latest_#{&1}_users"), list_of(:simple_user)) + field(unquote(:"latest_#{&1}_users"), list_of(:common_user)) end ) end diff --git a/lib/groupher_server_web/schema/cms/cms_metrics.ex b/lib/groupher_server_web/schema/cms/cms_metrics.ex index 5257fc809..1d072749e 100644 --- a/lib/groupher_server_web/schema/cms/cms_metrics.ex +++ b/lib/groupher_server_web/schema/cms/cms_metrics.ex @@ -191,6 +191,12 @@ defmodule GroupherServerWeb.Schema.CMS.Metrics do field(:sort, :sort_enum) end + input_object :paged_blogs_filter do + pagination_args() + article_filter_fields() + field(:sort, :sort_enum) + end + @desc "article_filter doc" input_object :paged_repos_filter do @desc "limit of records (default 20), if first > 30, only return 30 at most" diff --git a/lib/groupher_server_web/schema/cms/cms_queries.ex b/lib/groupher_server_web/schema/cms/cms_queries.ex index c45be857a..394c440bd 100644 --- a/lib/groupher_server_web/schema/cms/cms_queries.ex +++ b/lib/groupher_server_web/schema/cms/cms_queries.ex @@ -147,6 +147,7 @@ defmodule GroupherServerWeb.Schema.CMS.Queries do article_queries(:post) article_queries(:job) + article_queries(:blog) article_queries(:repo) end end diff --git a/lib/groupher_server_web/schema/cms/cms_types.ex b/lib/groupher_server_web/schema/cms/cms_types.ex index 4e774fdfd..350c91b47 100644 --- a/lib/groupher_server_web/schema/cms/cms_types.ex +++ b/lib/groupher_server_web/schema/cms/cms_types.ex @@ -46,9 +46,9 @@ defmodule GroupherServerWeb.Schema.CMS.Types do field(:id, :id) end - object :simple_user do - field(:login, :string) - field(:nickname, :string) + object :thread_document do + field(:body, :string) + field(:body_html, :string) end object :post do diff --git a/priv/repo/migrations/20210623093412_creaet_article_documents.exs b/priv/repo/migrations/20210623093412_creaet_article_documents.exs new file mode 100644 index 000000000..a53b23047 --- /dev/null +++ b/priv/repo/migrations/20210623093412_creaet_article_documents.exs @@ -0,0 +1,15 @@ +defmodule GroupherServer.Repo.Migrations.CreaetArticleDocuments do + use Ecto.Migration + + def change do + create table(:article_documents) do + add(:thread, :string) + add(:article_id, :id) + add(:title, :string) + add(:body, :text) + add(:body_html, :text) + + timestamps() + end + end +end diff --git a/priv/repo/migrations/20210623094741_creaet_domain_article_documents.exs b/priv/repo/migrations/20210623094741_creaet_domain_article_documents.exs new file mode 100644 index 000000000..ec9c8df14 --- /dev/null +++ b/priv/repo/migrations/20210623094741_creaet_domain_article_documents.exs @@ -0,0 +1,37 @@ +defmodule GroupherServer.Repo.Migrations.CreaetDomainArticleDocuments do + use Ecto.Migration + + def change do + create table(:post_documents) do + add(:post_id, references(:cms_posts, on_delete: :delete_all), null: false) + add(:body, :text) + add(:body_html, :text) + add(:markdown, :text) + add(:toc, :map) + end + + create table(:job_documents) do + add(:job_id, references(:cms_jobs, on_delete: :delete_all), null: false) + add(:body, :text) + add(:body_html, :text) + add(:markdown, :text) + add(:toc, :map) + end + + create table(:repo_documents) do + add(:repo_id, references(:cms_repos, on_delete: :delete_all), null: false) + add(:body, :text) + add(:body_html, :text) + add(:markdown, :text) + add(:toc, :map) + end + + create table(:blog_documents) do + add(:blog_id, references(:cms_blogs, on_delete: :delete_all), null: false) + add(:body, :text) + add(:body_html, :text) + add(:markdown, :text) + add(:toc, :map) + end + end +end diff --git a/priv/repo/migrations/20210623123527_remove_body_html_in_articles.exs b/priv/repo/migrations/20210623123527_remove_body_html_in_articles.exs new file mode 100644 index 000000000..48c37ec2c --- /dev/null +++ b/priv/repo/migrations/20210623123527_remove_body_html_in_articles.exs @@ -0,0 +1,10 @@ +defmodule GroupherServer.Repo.Migrations.RemoveBodyHtmlInArticles do + use Ecto.Migration + + def change do + alter(table(:cms_posts), do: remove(:body_html)) + alter(table(:cms_jobs), do: remove(:body_html)) + alter(table(:cms_blogs), do: remove(:body_html)) + alter(table(:cms_repos), do: remove(:body_html)) + end +end diff --git a/test/groupher_server/cms/articles/blog_test.exs b/test/groupher_server/cms/articles/blog_test.exs index 2e1e00deb..83f97a162 100644 --- a/test/groupher_server/cms/articles/blog_test.exs +++ b/test/groupher_server/cms/articles/blog_test.exs @@ -1,11 +1,11 @@ defmodule GroupherServer.Test.Articles.Blog do use GroupherServer.TestTools - alias GroupherServer.CMS + alias GroupherServer.{CMS, Repo} alias Helper.Converter.{EditorToHTML, HtmlSanitizer} alias EditorToHTML.{Class, Validator} - alias CMS.Model.{Author, Blog, Community} + alias CMS.Model.{Author, Blog, Community, ArticleDocument, BlogDocument} alias Helper.ORM @root_class Class.article() @@ -25,15 +25,19 @@ defmodule GroupherServer.Test.Articles.Blog do test "can create blog with valid attrs", ~m(user community blog_attrs)a do assert {:error, _} = ORM.find_by(Author, user_id: user.id) {:ok, blog} = CMS.create_article(community, :blog, blog_attrs, user) + blog = Repo.preload(blog, :document) - body_map = Jason.decode!(blog.body) + body_map = Jason.decode!(blog.document.body) assert blog.meta.thread == "BLOG" assert blog.title == blog_attrs.title assert body_map |> Validator.is_valid() - assert blog.body_html |> String.contains?(~s(
)) + + assert blog.document.body_html |> String.contains?(~s(
List.first() |> get_in(["data", "text"]) assert blog.digest == paragraph_text |> HtmlSanitizer.strip_all_tags() @@ -148,4 +152,45 @@ defmodule GroupherServer.Test.Articles.Blog do is_error?(reason, :undo_sink_old_article) end end + + describe "[cms blog document]" do + test "will create related document after create", ~m(user community blog_attrs)a do + {:ok, blog} = CMS.create_article(community, :blog, blog_attrs, user) + {:ok, blog} = CMS.read_article(:blog, blog.id) + assert not is_nil(blog.document.body_html) + {:ok, blog} = CMS.read_article(:blog, blog.id, user) + assert not is_nil(blog.document.body_html) + + {:ok, article_doc} = ORM.find_by(ArticleDocument, %{article_id: blog.id, thread: "BLOG"}) + {:ok, blog_doc} = ORM.find_by(BlogDocument, %{blog_id: blog.id}) + + assert blog.document.body == blog_doc.body + assert article_doc.body == blog_doc.body + end + + test "delete blog should also delete related document", ~m(user community blog_attrs)a do + {:ok, blog} = CMS.create_article(community, :blog, blog_attrs, user) + {:ok, _article_doc} = ORM.find_by(ArticleDocument, %{article_id: blog.id, thread: "BLOG"}) + {:ok, _blog_doc} = ORM.find_by(BlogDocument, %{blog_id: blog.id}) + + CMS.remove_article(:blog, blog.id) + + {:error, _} = ORM.find(Blog, blog.id) + {:error, _} = ORM.find_by(ArticleDocument, %{article_id: blog.id, thread: "BLOG"}) + {:error, _} = ORM.find_by(BlogDocument, %{blog_id: blog.id}) + end + + test "update blog should also update related document", ~m(user community blog_attrs)a do + {:ok, blog} = CMS.create_article(community, :blog, blog_attrs, user) + + body = mock_rich_text(~s(new content)) + {:ok, blog} = CMS.update_article(blog, %{body: body}) + + {:ok, article_doc} = ORM.find_by(ArticleDocument, %{article_id: blog.id, thread: "BLOG"}) + {:ok, blog_doc} = ORM.find_by(BlogDocument, %{blog_id: blog.id}) + + assert String.contains?(blog_doc.body, "new content") + assert String.contains?(article_doc.body, "new content") + end + end end diff --git a/test/groupher_server/cms/articles/job_test.exs b/test/groupher_server/cms/articles/job_test.exs index d653a0644..8769c6cd9 100644 --- a/test/groupher_server/cms/articles/job_test.exs +++ b/test/groupher_server/cms/articles/job_test.exs @@ -1,11 +1,11 @@ defmodule GroupherServer.Test.Articles.Job do use GroupherServer.TestTools - alias GroupherServer.CMS + alias GroupherServer.{CMS, Repo} alias Helper.Converter.{EditorToHTML, HtmlSanitizer} alias EditorToHTML.{Class, Validator} - alias CMS.Model.{Author, Job, Community} + alias CMS.Model.{Author, Job, Community, ArticleDocument, JobDocument} alias Helper.ORM @@ -26,15 +26,19 @@ defmodule GroupherServer.Test.Articles.Job do test "can create job with valid attrs", ~m(user community job_attrs)a do assert {:error, _} = ORM.find_by(Author, user_id: user.id) {:ok, job} = CMS.create_article(community, :job, job_attrs, user) + job = Repo.preload(job, :document) - body_map = Jason.decode!(job.body) + body_map = Jason.decode!(job.document.body) assert job.meta.thread == "JOB" assert job.title == job_attrs.title assert body_map |> Validator.is_valid() - assert job.body_html |> String.contains?(~s(
)) + + assert job.document.body_html |> String.contains?(~s(
List.first() |> get_in(["data", "text"]) assert job.digest == paragraph_text |> HtmlSanitizer.strip_all_tags() @@ -149,4 +153,45 @@ defmodule GroupherServer.Test.Articles.Job do is_error?(reason, :undo_sink_old_article) end end + + describe "[cms job document]" do + test "will create related document after create", ~m(user community job_attrs)a do + {:ok, job} = CMS.create_article(community, :job, job_attrs, user) + {:ok, job} = CMS.read_article(:job, job.id) + assert not is_nil(job.document.body_html) + {:ok, job} = CMS.read_article(:job, job.id, user) + assert not is_nil(job.document.body_html) + + {:ok, article_doc} = ORM.find_by(ArticleDocument, %{article_id: job.id, thread: "JOB"}) + {:ok, job_doc} = ORM.find_by(JobDocument, %{job_id: job.id}) + + assert job.document.body == job_doc.body + assert article_doc.body == job_doc.body + end + + test "delete job should also delete related document", ~m(user community job_attrs)a do + {:ok, job} = CMS.create_article(community, :job, job_attrs, user) + {:ok, _article_doc} = ORM.find_by(ArticleDocument, %{article_id: job.id, thread: "JOB"}) + {:ok, _job_doc} = ORM.find_by(JobDocument, %{job_id: job.id}) + + CMS.remove_article(:job, job.id) + + {:error, _} = ORM.find(Job, job.id) + {:error, _} = ORM.find_by(ArticleDocument, %{article_id: job.id, thread: "JOB"}) + {:error, _} = ORM.find_by(JobDocument, %{job_id: job.id}) + end + + test "update job should also update related document", ~m(user community job_attrs)a do + {:ok, job} = CMS.create_article(community, :job, job_attrs, user) + + body = mock_rich_text(~s(new content)) + {:ok, job} = CMS.update_article(job, %{body: body}) + + {:ok, article_doc} = ORM.find_by(ArticleDocument, %{article_id: job.id, thread: "JOB"}) + {:ok, job_doc} = ORM.find_by(JobDocument, %{job_id: job.id}) + + assert String.contains?(job_doc.body, "new content") + assert String.contains?(article_doc.body, "new content") + end + end end diff --git a/test/groupher_server/cms/articles/post_test.exs b/test/groupher_server/cms/articles/post_test.exs index 2e71f92c5..9d98efc3a 100644 --- a/test/groupher_server/cms/articles/post_test.exs +++ b/test/groupher_server/cms/articles/post_test.exs @@ -2,11 +2,11 @@ defmodule GroupherServer.Test.CMS.Articles.Post do use GroupherServer.TestTools alias Helper.ORM - alias GroupherServer.CMS + alias GroupherServer.{CMS, Repo} alias Helper.Converter.{EditorToHTML, HtmlSanitizer} alias EditorToHTML.{Class, Validator} - alias CMS.Model.{Author, Community, Post} + alias CMS.Model.{Author, ArticleDocument, Community, Post, PostDocument} @root_class Class.article() @last_year Timex.shift(Timex.beginning_of_year(Timex.now()), days: -3, seconds: -1) @@ -26,15 +26,19 @@ defmodule GroupherServer.Test.CMS.Articles.Post do test "can create post with valid attrs", ~m(user community post_attrs)a do assert {:error, _} = ORM.find_by(Author, user_id: user.id) {:ok, post} = CMS.create_article(community, :post, post_attrs, user) + post = Repo.preload(post, :document) - body_map = Jason.decode!(post.body) + body_map = Jason.decode!(post.document.body) assert post.meta.thread == "POST" assert post.title == post_attrs.title assert body_map |> Validator.is_valid() - assert post.body_html |> String.contains?(~s(
)) + + assert post.document.body_html |> String.contains?(~s(
List.first() |> get_in(["data", "text"]) assert post.digest == paragraph_text |> HtmlSanitizer.strip_all_tags() @@ -183,4 +187,45 @@ defmodule GroupherServer.Test.CMS.Articles.Post do assert not post.is_question end end + + describe "[cms post document]" do + test "will create related document after create", ~m(user community post_attrs)a do + {:ok, post} = CMS.create_article(community, :post, post_attrs, user) + {:ok, post} = CMS.read_article(:post, post.id) + assert not is_nil(post.document.body_html) + {:ok, post} = CMS.read_article(:post, post.id, user) + assert not is_nil(post.document.body_html) + + {:ok, article_doc} = ORM.find_by(ArticleDocument, %{article_id: post.id, thread: "POST"}) + {:ok, post_doc} = ORM.find_by(PostDocument, %{post_id: post.id}) + + assert post.document.body == post_doc.body + assert article_doc.body == post_doc.body + end + + test "delete post should also delete related document", ~m(user community post_attrs)a do + {:ok, post} = CMS.create_article(community, :post, post_attrs, user) + {:ok, _article_doc} = ORM.find_by(ArticleDocument, %{article_id: post.id, thread: "POST"}) + {:ok, _post_doc} = ORM.find_by(PostDocument, %{post_id: post.id}) + + CMS.remove_article(:post, post.id) + + {:error, _} = ORM.find(Post, post.id) + {:error, _} = ORM.find_by(ArticleDocument, %{article_id: post.id, thread: "POST"}) + {:error, _} = ORM.find_by(PostDocument, %{post_id: post.id}) + end + + test "update post should also update related document", ~m(user community post_attrs)a do + {:ok, post} = CMS.create_article(community, :post, post_attrs, user) + + body = mock_rich_text(~s(new content)) + {:ok, post} = CMS.update_article(post, %{body: body}) + + {:ok, article_doc} = ORM.find_by(ArticleDocument, %{article_id: post.id, thread: "POST"}) + {:ok, post_doc} = ORM.find_by(PostDocument, %{post_id: post.id}) + + assert String.contains?(post_doc.body, "new content") + assert String.contains?(article_doc.body, "new content") + end + end end diff --git a/test/groupher_server/cms/articles/repo_test.exs b/test/groupher_server/cms/articles/repo_test.exs index fb5f006ff..e2439c285 100644 --- a/test/groupher_server/cms/articles/repo_test.exs +++ b/test/groupher_server/cms/articles/repo_test.exs @@ -2,6 +2,7 @@ defmodule GroupherServer.Test.Articles.Repo do use GroupherServer.TestTools alias GroupherServer.CMS + alias GroupherServer.Repo, as: GlabelRepo alias Helper.Converter.{EditorToHTML} alias EditorToHTML.{Class, Validator} @@ -26,14 +27,18 @@ defmodule GroupherServer.Test.Articles.Repo do test "can create repo with valid attrs", ~m(user community repo_attrs)a do assert {:error, _} = ORM.find_by(Author, user_id: user.id) {:ok, repo} = CMS.create_article(community, :repo, repo_attrs, user) + repo = GlabelRepo.preload(repo, :document) - body_map = Jason.decode!(repo.body) + body_map = Jason.decode!(repo.document.body) assert repo.meta.thread == "REPO" assert repo.title == repo_attrs.title assert body_map |> Validator.is_valid() - assert repo.body_html |> String.contains?(~s(