Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/fuzzy_catalog/import_export.ex
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ defmodule FuzzyCatalog.ImportExport do
"""
def create_export_job(%User{} = user, attrs \\ %{}) do
%Job{}
|> Job.export_changeset(Map.put(attrs, :user_id, user.id))
|> Job.export_changeset(Map.put(attrs, "user_id", user.id))
|> Repo.insert()
end

Expand All @@ -113,7 +113,7 @@ defmodule FuzzyCatalog.ImportExport do
"""
def create_import_job(%User{} = user, attrs \\ %{}) do
%Job{}
|> Job.import_changeset(Map.put(attrs, :user_id, user.id))
|> Job.import_changeset(Map.put(attrs, "user_id", user.id))
|> Repo.insert()
end

Expand Down
70 changes: 45 additions & 25 deletions lib/fuzzy_catalog/import_export/exporter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,51 +13,71 @@ defmodule FuzzyCatalog.ImportExport.Exporter do

@supported_formats ["json", "csv"]

require Logger

@doc """
Performs an export job asynchronously.
"""
def perform_export(%Job{} = job) do
Logger.info("Starting export for job ##{job.id}")

case update_job_status(job, "processing") do
{:ok, job} ->
try do
export_data(job)
result = export_data(job)
Logger.info("Export completed for job ##{job.id}: #{inspect(result)}")
result
rescue
error ->
Logger.error("Export failed for job ##{job.id}: #{Exception.message(error)}")
Logger.error("Stacktrace: #{Exception.format_stacktrace(__STACKTRACE__)}")
ImportExport.fail_job(job, "Export failed: #{Exception.message(error)}")
end

{:error, _changeset} ->
{:error, changeset} ->
Logger.error(
"Failed to update job status for job ##{job.id}: #{inspect(changeset.errors)}"
)

{:error, "Failed to update job status"}
end
end

defp export_data(%Job{} = job) do
filters = job.filters || %{}
format = Map.get(filters, "format", "json")
# Handle empty string format
format = if format == "", do: "json", else: format

Logger.info("Export format: #{format}, filters: #{inspect(filters)}")

unless format in @supported_formats do
Logger.error("Unsupported format: #{format}")
ImportExport.fail_job(job, "Unsupported format: #{format}")
end

# Get filtered data
query_result = build_export_query(filters)
collection_items = Repo.all(query_result.query)
total_items = length(collection_items)

# Update job with total count
ImportExport.update_job_progress(job, %{
total_items: total_items,
processed_items: 0,
progress: 0
})

# Process data in chunks for large exports
chunk_size = 100
chunks = Enum.chunk_every(collection_items, chunk_size)

case format do
"json" -> export_to_json(job, chunks, total_items)
"csv" -> export_to_csv(job, chunks, total_items)
{:error, "Unsupported format"}
else
# Get filtered data
query_result = build_export_query(filters)
collection_items = Repo.all(query_result.query)
total_items = length(collection_items)

Logger.info("Found #{total_items} collection items to export")

# Update job with total count
ImportExport.update_job_progress(job, %{
total_items: total_items,
processed_items: 0,
progress: 0
})

# Process data in chunks for large exports
chunk_size = 100
chunks = Enum.chunk_every(collection_items, chunk_size)

case format do
"json" -> export_to_json(job, chunks, total_items)
"csv" -> export_to_csv(job, chunks, total_items)
end
end
end

Expand Down Expand Up @@ -201,7 +221,7 @@ defmodule FuzzyCatalog.ImportExport.Exporter do
end

new_processed = processed_count + length(chunk)
progress = round(new_processed / total_items * 100)
progress = if total_items > 0, do: round(new_processed / total_items * 100), else: 100

ImportExport.update_job_progress(job, %{
processed_items: new_processed,
Expand Down Expand Up @@ -289,7 +309,7 @@ defmodule FuzzyCatalog.ImportExport.Exporter do
IO.write(file, Enum.join(csv_rows, "\n") <> "\n")

new_processed = processed_count + length(chunk)
progress = round(new_processed / total_items * 100)
progress = if total_items > 0, do: round(new_processed / total_items * 100), else: 100

ImportExport.update_job_progress(job, %{
processed_items: new_processed,
Expand Down
50 changes: 44 additions & 6 deletions lib/fuzzy_catalog/import_export/job.ex
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,57 @@ defmodule FuzzyCatalog.ImportExport.Job do
"""
def export_changeset(job, attrs) do
job
|> changeset(attrs)
|> cast(attrs, [
:file_path,
:file_name,
:file_size,
:progress,
:total_items,
:processed_items,
:error_message,
:filters,
:user_id
])
|> put_change(:type, "export")
|> put_change(:expires_at, DateTime.utc_now() |> DateTime.add(7, :day))
|> put_change(:status, "pending")
|> put_change(
:expires_at,
DateTime.utc_now() |> DateTime.add(7, :day) |> DateTime.truncate(:second)
)
|> validate_required([:type, :status, :user_id])
|> validate_inclusion(:type, @type_values)
|> validate_inclusion(:status, @status_values)
|> validate_number(:progress, greater_than_or_equal_to: 0, less_than_or_equal_to: 100)
|> validate_number(:processed_items, greater_than_or_equal_to: 0)
|> validate_number(:total_items, greater_than_or_equal_to: 0)
|> foreign_key_constraint(:user_id)
end

@doc """
Creates a changeset for import job creation
"""
def import_changeset(job, attrs) do
job
|> changeset(attrs)
|> cast(attrs, [
:file_path,
:file_name,
:file_size,
:progress,
:total_items,
:processed_items,
:error_message,
:filters,
:user_id
])
|> put_change(:type, "import")
|> validate_required([:file_path, :file_name])
|> put_change(:status, "pending")
|> validate_required([:type, :status, :user_id, :file_path, :file_name])
|> validate_inclusion(:type, @type_values)
|> validate_inclusion(:status, @status_values)
|> validate_number(:progress, greater_than_or_equal_to: 0, less_than_or_equal_to: 100)
|> validate_number(:processed_items, greater_than_or_equal_to: 0)
|> validate_number(:total_items, greater_than_or_equal_to: 0)
|> foreign_key_constraint(:user_id)
end

@doc """
Expand All @@ -90,7 +128,7 @@ defmodule FuzzyCatalog.ImportExport.Job do
job
|> cast(attrs, [:file_path, :file_name, :file_size, :error_message])
|> put_change(:status, "completed")
|> put_change(:completed_at, DateTime.utc_now())
|> put_change(:completed_at, DateTime.utc_now() |> DateTime.truncate(:second))
|> put_change(:progress, 100)
end

Expand All @@ -101,7 +139,7 @@ defmodule FuzzyCatalog.ImportExport.Job do
job
|> cast(%{error_message: error_message}, [:error_message])
|> put_change(:status, "failed")
|> put_change(:completed_at, DateTime.utc_now())
|> put_change(:completed_at, DateTime.utc_now() |> DateTime.truncate(:second))
end

@doc """
Expand Down
56 changes: 29 additions & 27 deletions lib/fuzzy_catalog/title_utils.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,37 @@ defmodule FuzzyCatalog.TitleUtils do
Provides normalization and matching functions for book titles.
"""

@common_postfixes [
# Audio-specific postfixes
"(Unabridged)",
"(Abridged)",
"(Audio CD)",
"(Audiobook)",
defp common_postfixes do
[
# Audio-specific postfixes
"(Unabridged)",
"(Abridged)",
"(Audio CD)",
"(Audiobook)",

# Format-specific postfixes
"(Paperback)",
"(Hardcover)",
"(Kindle Edition)",
"(eBook)",
"(Digital)",
# Format-specific postfixes
"(Paperback)",
"(Hardcover)",
"(Kindle Edition)",
"(eBook)",
"(Digital)",

# Edition-specific postfixes
"(1st Edition)",
"(2nd Edition)",
"(3rd Edition)",
"(First Edition)",
"(Second Edition)",
"(Third Edition)",
"(Revised Edition)",
"(Updated Edition)",
"(Anniversary Edition)",
# Edition-specific postfixes
"(1st Edition)",
"(2nd Edition)",
"(3rd Edition)",
"(First Edition)",
"(Second Edition)",
"(Third Edition)",
"(Revised Edition)",
"(Updated Edition)",
"(Anniversary Edition)",

# Generic edition markers
~r/\(\d+(?:st|nd|rd|th) Edition\)/,
~r/\(.*? Edition\)/
]
# Generic edition markers
~r/\(\d+(?:st|nd|rd|th) Edition\)/,
~r/\(.*? Edition\)/
]
end

@doc """
Normalizes a title by removing common postfixes that don't affect the core title.
Expand Down Expand Up @@ -66,7 +68,7 @@ defmodule FuzzyCatalog.TitleUtils do
def normalize_title(nil), do: nil

defp remove_common_postfixes(title) do
Enum.reduce(@common_postfixes, title, fn postfix, acc ->
Enum.reduce(common_postfixes(), title, fn postfix, acc ->
case postfix do
%Regex{} = regex ->
String.replace(acc, regex, "")
Expand Down
50 changes: 40 additions & 10 deletions lib/fuzzy_catalog_web/controllers/import_export_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ defmodule FuzzyCatalogWeb.ImportExportController do

alias FuzzyCatalog.ImportExport
alias FuzzyCatalog.ImportExport.{Job, Exporter}
alias FuzzyCatalog.Storage

def index(conn, _params) do
current_user = conn.assigns.current_scope.user
Expand All @@ -20,10 +19,14 @@ defmodule FuzzyCatalogWeb.ImportExportController do
end

def create_export(conn, %{"job" => job_params}) do
require Logger
current_user = conn.assigns.current_scope.user

Logger.info("Creating export job with params: #{inspect(job_params)}")

case ImportExport.create_export_job(current_user, job_params) do
{:ok, job} ->
Logger.info("Export job created successfully, spawning export process for job ##{job.id}")
# Start export process asynchronously
spawn(fn -> Exporter.perform_export(job) end)

Expand All @@ -35,6 +38,7 @@ defmodule FuzzyCatalogWeb.ImportExportController do
|> redirect(to: ~p"/admin/import-export")

{:error, changeset} ->
Logger.error("Failed to create export job: #{inspect(changeset.errors)}")
filter_options = Exporter.available_filters()
render(conn, :new_export, changeset: changeset, filter_options: filter_options)
end
Expand Down Expand Up @@ -148,14 +152,31 @@ defmodule FuzzyCatalogWeb.ImportExportController do
|> put_flash(:error, "Export file not available")
|> redirect(to: ~p"/admin/import-export")
else
case Storage.get_file_url(job.file_path) do
{:ok, file_url} ->
redirect(conn, external: file_url)
# Build the full file path
base_path =
Application.get_env(:fuzzy_catalog, :storage, [])
|> Keyword.get(:local, [])
|> Keyword.get(:base_path, "priv/static/uploads")

full_path = Path.join(base_path, job.file_path)

if File.exists?(full_path) do
# Determine content type based on file extension
content_type =
case Path.extname(job.file_name) do
".json" -> "application/json"
".csv" -> "text/csv"
_ -> "application/octet-stream"
end

{:error, _reason} ->
conn
|> put_flash(:error, "Export file not found")
|> redirect(to: ~p"/admin/import-export")
conn
|> put_resp_content_type(content_type)
|> put_resp_header("content-disposition", "attachment; filename=\"#{job.file_name}\"")
|> send_file(200, full_path)
else
conn
|> put_flash(:error, "Export file not found")
|> redirect(to: ~p"/admin/import-export")
end
end
end
Expand All @@ -167,8 +188,17 @@ defmodule FuzzyCatalogWeb.ImportExportController do
case ImportExport.delete_job(job) do
{:ok, _job} ->
# Clean up associated file
if job.file_path && File.exists?(job.file_path) do
File.rm(job.file_path)
if job.file_path do
base_path =
Application.get_env(:fuzzy_catalog, :storage, [])
|> Keyword.get(:local, [])
|> Keyword.get(:base_path, "priv/static/uploads")

full_path = Path.join(base_path, job.file_path)

if File.exists?(full_path) do
File.rm(full_path)
end
end

conn
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@
max="100"
>
</progress>
<%= if job.processed_items and job.total_items do %>
<%= if not is_nil(job.processed_items) and not is_nil(job.total_items) do %>
<p class="text-xs text-base-content/50 mt-1">
{job.processed_items} of {job.total_items} items processed
</p>
Expand Down
Loading