feat: datasheet upload and auto-retrieve
- store datasheet PDFs on the server - download PDF automatically when given a link
This commit is contained in:
137
lib/components_elixir/datasheet_downloader.ex
Normal file
137
lib/components_elixir/datasheet_downloader.ex
Normal file
@@ -0,0 +1,137 @@
|
||||
defmodule ComponentsElixir.DatasheetDownloader do
|
||||
@moduledoc """
|
||||
Module for downloading datasheet PDFs from URLs.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Downloads a PDF from the given URL and saves it to the datasheets folder.
|
||||
Returns {:ok, filename} on success or {:error, reason} on failure.
|
||||
"""
|
||||
def download_pdf_from_url(url) when is_binary(url) do
|
||||
with {:ok, %URI{scheme: scheme}} when scheme in ["http", "https"] <- validate_url(url),
|
||||
{:ok, filename} <- generate_filename(url),
|
||||
{:ok, response} <- fetch_pdf(url),
|
||||
:ok <- validate_pdf_content(response.body),
|
||||
:ok <- save_file(filename, response.body) do
|
||||
{:ok, filename}
|
||||
else
|
||||
{:error, reason} -> {:error, reason}
|
||||
error -> {:error, "Unexpected error: #{inspect(error)}"}
|
||||
end
|
||||
end
|
||||
|
||||
def download_pdf_from_url(_), do: {:error, "Invalid URL"}
|
||||
|
||||
defp validate_url(url) do
|
||||
case URI.parse(url) do
|
||||
%URI{scheme: scheme} when scheme in ["http", "https"] ->
|
||||
{:ok, URI.parse(url)}
|
||||
_ ->
|
||||
{:error, "Invalid URL scheme. Only HTTP and HTTPS are supported."}
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_filename(url) do
|
||||
# Try to extract a meaningful filename from the URL
|
||||
uri = URI.parse(url)
|
||||
original_filename =
|
||||
case Path.basename(uri.path || "") do
|
||||
"" -> "datasheet"
|
||||
basename ->
|
||||
# Remove extension and sanitize
|
||||
basename
|
||||
|> Path.rootname()
|
||||
|> sanitize_filename()
|
||||
end
|
||||
|
||||
# Create a unique filename with timestamp
|
||||
timestamp = System.unique_integer([:positive])
|
||||
filename = "#{timestamp}_#{original_filename}.pdf"
|
||||
{:ok, filename}
|
||||
end
|
||||
|
||||
defp sanitize_filename(filename) do
|
||||
filename
|
||||
|> String.replace(~r/[^\w\-_]/, "_") # Replace non-word chars with underscores
|
||||
|> String.replace(~r/_+/, "_") # Replace multiple underscores with single
|
||||
|> String.trim("_") # Remove leading/trailing underscores
|
||||
|> String.slice(0, 50) # Limit length
|
||||
|> case do
|
||||
"" -> "datasheet"
|
||||
name -> name
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_pdf(url) do
|
||||
case Req.get(url,
|
||||
redirect: true,
|
||||
max_redirects: 5,
|
||||
receive_timeout: 30_000,
|
||||
headers: [
|
||||
{"User-Agent", "ComponentSystem/1.0 DatasheetDownloader"}
|
||||
]
|
||||
) do
|
||||
{:ok, %Req.Response{status: 200} = response} ->
|
||||
{:ok, response}
|
||||
{:ok, %Req.Response{status: status}} ->
|
||||
{:error, "HTTP error: #{status}"}
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to download PDF from #{url}: #{inspect(reason)}")
|
||||
{:error, "Download failed: #{inspect(reason)}"}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_pdf_content(body) do
|
||||
# Check if the response body looks like a PDF (starts with %PDF)
|
||||
case body do
|
||||
<<"%PDF", _rest::binary>> ->
|
||||
:ok
|
||||
_ ->
|
||||
{:error, "Downloaded content is not a valid PDF file"}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_file(filename, content) do
|
||||
uploads_dir = Application.get_env(:components_elixir, :uploads_dir)
|
||||
datasheets_dir = Path.join([uploads_dir, "datasheets"])
|
||||
file_path = Path.join(datasheets_dir, filename)
|
||||
|
||||
# Ensure the datasheets directory exists
|
||||
case File.mkdir_p(datasheets_dir) do
|
||||
:ok ->
|
||||
case File.write(file_path, content) do
|
||||
:ok ->
|
||||
Logger.info("Successfully saved datasheet: #{filename}")
|
||||
:ok
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to save datasheet file: #{inspect(reason)}")
|
||||
{:error, "Failed to save file: #{inspect(reason)}"}
|
||||
end
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to create datasheets directory: #{inspect(reason)}")
|
||||
{:error, "Failed to create directory: #{inspect(reason)}"}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a datasheet file from the filesystem.
|
||||
"""
|
||||
def delete_datasheet_file(nil), do: :ok
|
||||
def delete_datasheet_file(""), do: :ok
|
||||
|
||||
def delete_datasheet_file(filename) do
|
||||
uploads_dir = Application.get_env(:components_elixir, :uploads_dir)
|
||||
file_path = Path.join([uploads_dir, "datasheets", filename])
|
||||
|
||||
case File.rm(file_path) do
|
||||
:ok ->
|
||||
Logger.info("Deleted datasheet file: #{filename}")
|
||||
:ok
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to delete datasheet file #{filename}: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -320,6 +320,30 @@ defmodule ComponentsElixir.Inventory do
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a component and downloads datasheet from URL if provided.
|
||||
"""
|
||||
def create_component_with_datasheet(attrs \\ %{}) do
|
||||
# If a datasheet_url is provided, download it
|
||||
updated_attrs =
|
||||
case Map.get(attrs, "datasheet_url") do
|
||||
url when is_binary(url) and url != "" ->
|
||||
case ComponentsElixir.DatasheetDownloader.download_pdf_from_url(url) do
|
||||
{:ok, filename} ->
|
||||
Map.put(attrs, "datasheet_filename", filename)
|
||||
{:error, _reason} ->
|
||||
# Continue without datasheet file if download fails
|
||||
attrs
|
||||
end
|
||||
_ ->
|
||||
attrs
|
||||
end
|
||||
|
||||
%Component{}
|
||||
|> Component.changeset(updated_attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a component.
|
||||
"""
|
||||
@@ -329,6 +353,34 @@ defmodule ComponentsElixir.Inventory do
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a component and downloads datasheet from URL if provided.
|
||||
"""
|
||||
def update_component_with_datasheet(%Component{} = component, attrs) do
|
||||
# If a datasheet_url is provided and changed, download it
|
||||
updated_attrs =
|
||||
case Map.get(attrs, "datasheet_url") do
|
||||
url when is_binary(url) and url != "" and url != component.datasheet_url ->
|
||||
case ComponentsElixir.DatasheetDownloader.download_pdf_from_url(url) do
|
||||
{:ok, filename} ->
|
||||
# Delete old datasheet file if it exists
|
||||
if component.datasheet_filename do
|
||||
ComponentsElixir.DatasheetDownloader.delete_datasheet_file(component.datasheet_filename)
|
||||
end
|
||||
Map.put(attrs, "datasheet_filename", filename)
|
||||
{:error, _reason} ->
|
||||
# Keep existing filename if download fails
|
||||
attrs
|
||||
end
|
||||
_ ->
|
||||
attrs
|
||||
end
|
||||
|
||||
component
|
||||
|> Component.changeset(updated_attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a component.
|
||||
"""
|
||||
|
||||
@@ -18,6 +18,7 @@ defmodule ComponentsElixir.Inventory.Component do
|
||||
field :legacy_position, :string
|
||||
field :count, :integer, default: 0
|
||||
field :datasheet_url, :string
|
||||
field :datasheet_filename, :string
|
||||
field :image_filename, :string
|
||||
|
||||
belongs_to :category, Category
|
||||
@@ -29,7 +30,7 @@ defmodule ComponentsElixir.Inventory.Component do
|
||||
@doc false
|
||||
def changeset(component, attrs) do
|
||||
component
|
||||
|> cast(attrs, [:name, :description, :keywords, :position, :count, :datasheet_url, :image_filename, :category_id, :storage_location_id])
|
||||
|> cast(attrs, [:name, :description, :keywords, :position, :count, :datasheet_url, :datasheet_filename, :image_filename, :category_id, :storage_location_id])
|
||||
|> validate_required([:name, :category_id])
|
||||
|> validate_length(:name, min: 1, max: 255)
|
||||
|> validate_length(:description, max: 2000)
|
||||
@@ -59,6 +60,14 @@ defmodule ComponentsElixir.Inventory.Component do
|
||||
|> cast(attrs, [:image_filename])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changeset for updating component datasheet.
|
||||
"""
|
||||
def datasheet_changeset(component, attrs) do
|
||||
component
|
||||
|> cast(attrs, [:datasheet_filename])
|
||||
end
|
||||
|
||||
defp validate_url(changeset, field) do
|
||||
validate_change(changeset, field, fn ^field, url ->
|
||||
if url && url != "" do
|
||||
@@ -78,6 +87,12 @@ defmodule ComponentsElixir.Inventory.Component do
|
||||
def has_image?(%__MODULE__{image_filename: filename}) when is_binary(filename), do: true
|
||||
def has_image?(_), do: false
|
||||
|
||||
@doc """
|
||||
Returns true if the component has a datasheet file.
|
||||
"""
|
||||
def has_datasheet?(%__MODULE__{datasheet_filename: filename}) when is_binary(filename), do: true
|
||||
def has_datasheet?(_), do: false
|
||||
|
||||
@doc """
|
||||
Returns the search text for this component.
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user