defmodule ReqToggl do @moduledoc """ A Req plugin for the Toggl Track API. ## Usage You can attach ReqToggl to a Req request to interact with the Toggl Track API: req = Req.new(base_url: "https://api.track.toggl.com/api/v9") |> ReqToggl.attach(api_token: "your-api-token") Or use the convenience function: req = ReqToggl.new(api_token: "your-api-token") ## Authentication ReqToggl uses HTTP Basic Authentication with your Toggl API token. Get your token from: https://track.toggl.com/profile The plugin automatically handles the authentication by setting the Authorization header with your token in the format `:api_token`. ## Examples # Create a client client = ReqToggl.new(api_token: System.fetch_env!("TOGGL_API_TOKEN")) # Get current user info and workspace {:ok, %{body: user}} = Req.get(client, url: "/me") # List projects for a workspace {:ok, %{body: projects}} = ReqToggl.list_projects(client, workspace_id) # Create a time entry {:ok, %{body: entry}} = ReqToggl.create_time_entry(client, workspace_id: workspace_id, project_id: project_id, start: ~U[2024-01-15 09:00:00Z], stop: ~U[2024-01-15 17:00:00Z], description: "Working on features" ) """ @base_url "https://api.track.toggl.com/api/v9" @doc """ Attaches ReqToggl plugin to a Req request. ## Options * `:api_token` - (required) Your Toggl API token from https://track.toggl.com/profile """ def attach(%Req.Request{} = request, options) do request |> Req.Request.register_options([:api_token]) |> Req.Request.merge_options(options) |> Req.Request.append_request_steps(toggl_auth: &auth_step/1) |> Req.Request.append_request_steps(toggl_headers: &headers_step/1) end @doc """ Creates a new Req request configured for the Toggl API. ## Options * `:api_token` - (required) Your Toggl API token ## Examples client = ReqToggl.new(api_token: "your-api-token") """ def new(options \\ []) do options = Keyword.validate!(options, [:api_token]) unless options[:api_token] do raise ArgumentError, "api_token is required" end Req.new(base_url: @base_url) |> attach(options) end # Request steps defp auth_step(%Req.Request{options: options} = request) do case options[:api_token] do nil -> request token -> # Toggl uses Basic Auth with format: :api_token credentials = Base.encode64("#{token}:api_token") Req.Request.put_header(request, "authorization", "Basic #{credentials}") end end defp headers_step(request) do request |> Req.Request.put_header("content-type", "application/json") end # API Methods @doc """ Get the current authenticated user's information. Returns the user profile including workspace details and default workspace ID. ## Options * `:with_related_data` - Include workspaces and organizations (boolean, default: false) ## Examples {:ok, %{body: user}} = ReqToggl.get_me(client) default_workspace_id = user["default_workspace_id"] # With workspaces and organizations {:ok, %{body: user}} = ReqToggl.get_me(client, with_related_data: true) workspaces = user["workspaces"] """ def get_me(client, opts \\ []) do Req.get(client, url: "/me", params: opts) end @doc """ List all workspaces for the current user. This is a convenience function that calls get_me/2 with `with_related_data: true` and returns just the workspaces array. ## Examples {:ok, %{body: workspaces}} = ReqToggl.list_workspaces(client) Enum.each(workspaces, fn workspace -> IO.puts("Workspace: \#{workspace["name"]} (ID: \#{workspace["id"]})") end) """ def list_workspaces(client) do case get_me(client, with_related_data: true) do {:ok, %{body: user} = response} -> {:ok, %{response | body: user["workspaces"] || []}} error -> error end end @doc """ List all projects for a workspace. ## Parameters * `client` - The ReqToggl client * `workspace_id` - The workspace ID (integer) * `opts` - Optional parameters: * `:active` - Filter by active/inactive status (boolean) * `:name` - Filter by project name (string) - required by API * `:per_page` - Number of results per page (default 151, max 200) ## Examples {:ok, %{body: projects}} = ReqToggl.list_projects(client, 12345, name: "") {:ok, %{body: projects}} = ReqToggl.list_projects(client, 12345, name: "Marketing") """ def list_projects(client, workspace_id, opts \\ []) do # The API requires a 'name' query parameter, use empty string to list all query = Keyword.put_new(opts, :name, "") Req.get(client, url: "/workspaces/#{workspace_id}/projects", params: query) end @doc """ Create a time entry. ## Parameters * `client` - The ReqToggl client * `opts` - Time entry options: * `:workspace_id` - (required) The workspace ID * `:start` - (required) Start time as DateTime or ISO8601 string * `:stop` - End time as DateTime or ISO8601 string (either :stop or :duration required) * `:duration` - Duration in seconds (either :stop or :duration required) * `:project_id` - Project ID to associate with * `:description` - Description of the time entry * `:billable` - Whether the entry is billable (default false) * `:tags` - List of tag names ## Examples # Using start and stop times {:ok, %{body: entry}} = ReqToggl.create_time_entry(client, workspace_id: 12345, project_id: 67890, start: ~U[2024-01-15 09:00:00Z], stop: ~U[2024-01-15 17:00:00Z], description: "Working on features" ) # Using start time and duration {:ok, %{body: entry}} = ReqToggl.create_time_entry(client, workspace_id: 12345, project_id: 67890, start: ~U[2024-01-15 09:00:00Z], duration: 28800, # 8 hours in seconds description: "Working on features" ) # Using date and time strings {:ok, %{body: entry}} = ReqToggl.create_time_entry(client, workspace_id: 12345, project_id: 67890, date: "2024-01-15", start_time: "09:00", end_time: "17:00", description: "Working on features" ) """ def create_time_entry(client, opts) do workspace_id = Keyword.fetch!(opts, :workspace_id) body = build_time_entry_body(opts) Req.post(client, url: "/workspaces/#{workspace_id}/time_entries", json: body) end @doc """ Update a time entry. ## Parameters * `client` - The ReqToggl client * `workspace_id` - The workspace ID (integer) * `time_entry_id` - The time entry ID to update (integer) * `opts` - Time entry options (same as create_time_entry/2): * `:start` - Start time as DateTime or ISO8601 string * `:stop` - End time as DateTime or ISO8601 string * `:duration` - Duration in seconds * `:project_id` - Project ID to associate with * `:description` - Description of the time entry * `:billable` - Whether the entry is billable * `:tags` - List of tag names ## Examples {:ok, %{body: entry}} = ReqToggl.update_time_entry(client, 12345, 98765, description: "Updated description", stop: ~U[2024-01-15 18:00:00Z] ) """ def update_time_entry(client, workspace_id, time_entry_id, opts \\ []) do # Build update body - workspace_id is not included in update payload body = build_update_body(opts) Req.put(client, url: "/workspaces/#{workspace_id}/time_entries/#{time_entry_id}", json: body ) end @doc """ Delete a time entry. ## Parameters * `client` - The ReqToggl client * `workspace_id` - The workspace ID (integer) * `time_entry_id` - The time entry ID to delete (integer) ## Examples {:ok, response} = ReqToggl.delete_time_entry(client, 12345, 98765) """ def delete_time_entry(client, workspace_id, time_entry_id) do Req.delete(client, url: "/workspaces/#{workspace_id}/time_entries/#{time_entry_id}") end defp build_time_entry_body(opts) do workspace_id = Keyword.fetch!(opts, :workspace_id) # Handle different time input formats {start_dt, stop_dt, duration} = parse_time_params(opts) body = %{ "workspace_id" => workspace_id, "start" => format_datetime(start_dt), "created_with" => "req_toggl" } # Add stop time or duration body = cond do stop_dt -> Map.put(body, "stop", format_datetime(stop_dt)) duration -> Map.put(body, "duration", duration) true -> raise ArgumentError, "Either :stop or :duration is required" end # Add optional fields body |> maybe_put("project_id", opts[:project_id]) |> maybe_put("description", opts[:description]) |> maybe_put("billable", opts[:billable]) |> maybe_put("tags", opts[:tags]) end defp build_update_body(opts) do body = %{} # Handle time params if provided (all optional for updates) body = if opts[:start] || opts[:date] do {start_dt, stop_dt, duration} = parse_time_params(opts) body |> Map.put("start", format_datetime(start_dt)) |> then(fn b -> cond do stop_dt -> Map.put(b, "stop", format_datetime(stop_dt)) duration -> Map.put(b, "duration", duration) true -> b end end) else body end # Add optional fields that can be updated body |> maybe_put("project_id", opts[:project_id]) |> maybe_put("description", opts[:description]) |> maybe_put("billable", opts[:billable]) |> maybe_put("tags", opts[:tags]) end defp parse_time_params(opts) do cond do # If date, start_time, and end_time are provided opts[:date] && opts[:start_time] && opts[:end_time] -> date = opts[:date] start_dt = parse_datetime_from_parts(date, opts[:start_time]) stop_dt = parse_datetime_from_parts(date, opts[:end_time]) {start_dt, stop_dt, nil} # If date, start_time, and duration are provided opts[:date] && opts[:start_time] && opts[:duration] -> date = opts[:date] start_dt = parse_datetime_from_parts(date, opts[:start_time]) {start_dt, nil, opts[:duration]} # If start DateTime and stop DateTime are provided opts[:start] && opts[:stop] -> {ensure_datetime(opts[:start]), ensure_datetime(opts[:stop]), nil} # If start DateTime and duration are provided opts[:start] && opts[:duration] -> {ensure_datetime(opts[:start]), nil, opts[:duration]} # If only start is provided, calculate duration from stop if present opts[:start] -> start_dt = ensure_datetime(opts[:start]) stop_dt = if opts[:stop], do: ensure_datetime(opts[:stop]), else: nil {start_dt, stop_dt, opts[:duration]} true -> raise ArgumentError, "Either :start or (:date and :start_time) is required" end end defp parse_datetime_from_parts(date, time) do # Parse "2024-01-15" and "09:00" into a DateTime datetime_string = "#{date}T#{time}:00Z" case DateTime.from_iso8601(datetime_string) do {:ok, dt, 0} -> dt {:error, _} -> raise ArgumentError, "Invalid date/time format: #{datetime_string}" end end defp ensure_datetime(%DateTime{} = dt), do: dt defp ensure_datetime(string) when is_binary(string) do case DateTime.from_iso8601(string) do {:ok, dt, 0} -> dt {:error, _} -> raise ArgumentError, "Invalid datetime string: #{string}" end end defp format_datetime(%DateTime{} = dt) do DateTime.to_iso8601(dt) end defp maybe_put(map, _key, nil), do: map defp maybe_put(map, key, value), do: Map.put(map, key, value) end