···11+defmodule ElixirBlonk.BlipTags.BlipTag do
22+ use Ecto.Schema
33+ import Ecto.Changeset
44+55+ @primary_key {:id, :binary_id, autogenerate: true}
66+ @foreign_key_type :binary_id
77+ schema "blip_tags" do
88+ field :uri, :string
99+ field :cid, :string
1010+ field :author_did, :string
1111+1212+ belongs_to :blip, ElixirBlonk.Blips.Blip
1313+ belongs_to :tag, ElixirBlonk.Tags.Tag
1414+1515+ timestamps(type: :utc_datetime)
1616+ end
1717+1818+ @doc false
1919+ def changeset(blip_tag, attrs) do
2020+ blip_tag
2121+ |> cast(attrs, [:uri, :cid, :author_did, :blip_id, :tag_id])
2222+ |> validate_required([:author_did, :blip_id, :tag_id])
2323+ |> unique_constraint(:uri)
2424+ |> unique_constraint([:blip_id, :tag_id], message: "tag already associated with this blip")
2525+ end
2626+end
+150
elixir_blonk/lib/elixir_blonk/tags.ex
···11+defmodule ElixirBlonk.Tags do
22+ @moduledoc """
33+ The Tags context for managing tag records in ATProto.
44+ """
55+66+ import Ecto.Query, warn: false
77+ require Logger
88+ alias ElixirBlonk.Repo
99+1010+ alias ElixirBlonk.Tags.Tag
1111+1212+ @doc """
1313+ Returns the list of tags.
1414+ """
1515+ def list_tags do
1616+ Tag
1717+ |> order_by([t], desc: t.usage_count)
1818+ |> Repo.all()
1919+ end
2020+2121+ @doc """
2222+ Returns the list of tags by author.
2323+ """
2424+ def list_tags_by_author(author_did) do
2525+ Tag
2626+ |> where([t], t.author_did == ^author_did)
2727+ |> order_by([t], desc: t.usage_count)
2828+ |> Repo.all()
2929+ end
3030+3131+ @doc """
3232+ Gets a single tag by ID.
3333+ """
3434+ def get_tag!(id), do: Repo.get!(Tag, id)
3535+3636+ @doc """
3737+ Gets a tag by name and author.
3838+ """
3939+ def get_tag_by_name_and_author(name, author_did) do
4040+ Tag
4141+ |> where([t], t.name == ^name and t.author_did == ^author_did)
4242+ |> Repo.one()
4343+ end
4444+4545+ @doc """
4646+ Gets a tag by URI.
4747+ """
4848+ def get_tag_by_uri(uri) do
4949+ Repo.get_by(Tag, uri: uri)
5050+ end
5151+5252+ @doc """
5353+ Searches for tags by name.
5454+ """
5555+ def search_tags(query) do
5656+ search_term = "%#{query}%"
5757+5858+ Tag
5959+ |> where([t], ilike(t.name, ^search_term) or ilike(t.description, ^search_term))
6060+ |> order_by([t], desc: t.usage_count)
6161+ |> Repo.all()
6262+ end
6363+6464+ @doc """
6565+ Creates a tag.
6666+ """
6767+ def create_tag(attrs \\ %{}) do
6868+ # First create in local database
6969+ with {:ok, tag} <- %Tag{}
7070+ |> Tag.changeset(attrs)
7171+ |> Repo.insert() do
7272+7373+ # Then try to create in ATProto if enabled
7474+ if Application.get_env(:elixir_blonk, :atproto_enabled, true) do
7575+ Task.Supervisor.start_child(ElixirBlonk.TaskSupervisor, fn ->
7676+ create_tag_in_atproto(tag)
7777+ end)
7878+ end
7979+8080+ {:ok, tag}
8181+ end
8282+ end
8383+8484+ @doc """
8585+ Finds or creates a tag by name and author.
8686+ """
8787+ def find_or_create_tag(name, author_did, description \\ nil) do
8888+ case get_tag_by_name_and_author(name, author_did) do
8989+ nil ->
9090+ create_tag(%{
9191+ name: name,
9292+ author_did: author_did,
9393+ description: description,
9494+ indexed_at: DateTime.utc_now()
9595+ })
9696+9797+ existing_tag ->
9898+ {:ok, existing_tag}
9999+ end
100100+ end
101101+102102+ @doc """
103103+ Updates a tag.
104104+ """
105105+ def update_tag(%Tag{} = tag, attrs) do
106106+ tag
107107+ |> Tag.changeset(attrs)
108108+ |> Repo.update()
109109+ end
110110+111111+ @doc """
112112+ Increments the usage count for a tag.
113113+ """
114114+ def increment_usage_count(%Tag{} = tag) do
115115+ update_tag(tag, %{usage_count: tag.usage_count + 1})
116116+ end
117117+118118+ @doc """
119119+ Deletes a tag.
120120+ """
121121+ def delete_tag(%Tag{} = tag) do
122122+ Repo.delete(tag)
123123+ end
124124+125125+ @doc """
126126+ Gets the most popular tags.
127127+ """
128128+ def get_popular_tags(limit \\ 20) do
129129+ Tag
130130+ |> where([t], t.usage_count > 0)
131131+ |> order_by([t], desc: t.usage_count)
132132+ |> limit(^limit)
133133+ |> Repo.all()
134134+ end
135135+136136+ # Private functions
137137+138138+ defp create_tag_in_atproto(tag) do
139139+ with {:ok, client} <- ElixirBlonk.ATProto.SessionManager.get_client(),
140140+ {:ok, %{uri: uri, cid: cid}} <- ElixirBlonk.ATProto.Client.create_tag(client, tag) do
141141+142142+ # Update local record with ATProto URI and CID
143143+ update_tag(tag, %{uri: uri, cid: cid})
144144+ Logger.info("Created tag in ATProto: #{uri}")
145145+ else
146146+ {:error, reason} ->
147147+ Logger.error("Failed to create tag in ATProto: #{inspect(reason)}")
148148+ end
149149+ end
150150+end
+32
elixir_blonk/lib/elixir_blonk/tags/tag.ex
···11+defmodule ElixirBlonk.Tags.Tag do
22+ use Ecto.Schema
33+ import Ecto.Changeset
44+55+ @primary_key {:id, :binary_id, autogenerate: true}
66+ @foreign_key_type :binary_id
77+ schema "tags" do
88+ field :uri, :string
99+ field :cid, :string
1010+ field :name, :string
1111+ field :description, :string
1212+ field :author_did, :string
1313+ field :usage_count, :integer, default: 0
1414+ field :indexed_at, :utc_datetime
1515+1616+ many_to_many :blips, ElixirBlonk.Blips.Blip, join_through: "blip_tags"
1717+1818+ timestamps(type: :utc_datetime)
1919+ end
2020+2121+ @doc false
2222+ def changeset(tag, attrs) do
2323+ tag
2424+ |> cast(attrs, [:uri, :cid, :name, :description, :author_did, :usage_count, :indexed_at])
2525+ |> validate_required([:name, :author_did])
2626+ |> validate_length(:name, min: 1, max: 50)
2727+ |> validate_length(:description, max: 280)
2828+ |> validate_format(:name, ~r/^[a-zA-Z0-9_]+$/, message: "can only contain letters, numbers, and underscores")
2929+ |> unique_constraint(:uri)
3030+ |> unique_constraint([:name, :author_did], message: "tag name already exists for this author")
3131+ end
3232+end