From d6d360025fd7ee6d94a3bb3560754759afb47041 Mon Sep 17 00:00:00 2001
From: mein Name <ich@example.com>
Date: Fri, 7 Mar 2025 15:10:42 -0500
Subject: [PATCH] ndjson: auto refresh progress for all import and export

---
 catalog/views.py                            |   2 +-
 common/views.py                             |  35 +-
 journal/exporters/csv.py                    |   2 +-
 journal/exporters/ndjson.py                 |   2 +-
 journal/importers/__init__.py               |  23 -
 journal/importers/csv.py                    |  10 +-
 journal/importers/douban.py                 |   2 +
 journal/importers/letterboxd.py             | 108 +++--
 journal/importers/ndjson.py                 |  76 ++-
 journal/importers/opml.py                   |  78 +--
 journal/migrations/0006_csvimporter.py      |  30 ++
 journal/tests/csv.py                        |   5 +-
 journal/tests/ndjson.py                     |   5 +-
 users/migrations/0008_alter_task_type.py    |   3 +
 users/models/task.py                        |   1 -
 users/templates/users/data.html             | 501 +++++++++-----------
 users/templates/users/user_task_status.html |  49 +-
 users/urls.py                               |   3 +
 users/views/data.py                         | 226 ++++-----
 19 files changed, 575 insertions(+), 586 deletions(-)

diff --git a/catalog/views.py b/catalog/views.py
index 1c8345f2..a8dd5d5e 100644
--- a/catalog/views.py
+++ b/catalog/views.py
@@ -86,7 +86,7 @@ def retrieve(request, item_path, item_uuid):
     if request.method == "HEAD":
         return HttpResponse()
     if request.headers.get("Accept", "").endswith("json"):
-        return JsonResponse(item.ap_object)
+        return JsonResponse(item.ap_object, content_type="application/activity+json")
     focus_item = None
     if request.GET.get("focus"):
         focus_item = get_object_or_404(
diff --git a/common/views.py b/common/views.py
index 3334a293..e21164e7 100644
--- a/common/views.py
+++ b/common/views.py
@@ -2,7 +2,7 @@ from django.conf import settings
 from django.contrib.auth.decorators import login_required
 from django.core.cache import cache
 from django.core.exceptions import DisallowedHost
-from django.http import HttpRequest, JsonResponse
+from django.http import HttpRequest, HttpResponse, JsonResponse
 from django.shortcuts import redirect, render
 from django.urls import reverse
 
@@ -81,36 +81,41 @@ def nodeinfo2(request):
     )
 
 
-def _is_json_request(request) -> bool:
-    return request.headers.get("HTTP_ACCEPT", "").endswith("json")
+def _error_response(request, status: int, exception=None, default_message=""):
+    message = str(exception) if exception else default_message
+    if request.headers.get("HTTP_ACCEPT").endswith("json"):
+        return JsonResponse({"error": message}, status=status)
+    if (
+        request.headers.get("HTTP_HX_REQUEST") is not None
+        and request.headers.get("HTTP_HX_BOOSTED") is None
+    ):
+        return HttpResponse(message, status=status)
+    return render(
+        request,
+        f"{status}.html",
+        status=status,
+        context={"message": message, "exception": exception},
+    )
 
 
 def error_400(request, exception=None):
     if isinstance(exception, DisallowedHost):
         url = settings.SITE_INFO["site_url"] + request.get_full_path()
         return redirect(url, permanent=True)
-    if _is_json_request(request):
-        return JsonResponse({"error": "invalid request"}, status=400)
-    return render(request, "400.html", status=400, context={"exception": exception})
+    return _error_response(request, 400, exception, "invalid request")
 
 
 def error_403(request, exception=None):
-    if _is_json_request(request):
-        return JsonResponse({"error": "forbidden"}, status=403)
-    return render(request, "403.html", status=403, context={"exception": exception})
+    return _error_response(request, 403, exception, "forbidden")
 
 
 def error_404(request, exception=None):
-    if _is_json_request(request):
-        return JsonResponse({"error": "not found"}, status=404)
     request.session.pop("next_url", None)
-    return render(request, "404.html", status=404, context={"exception": exception})
+    return _error_response(request, 404, exception, "not found")
 
 
 def error_500(request, exception=None):
-    if _is_json_request(request):
-        return JsonResponse({"error": "something wrong"}, status=500)
-    return render(request, "500.html", status=500, context={"exception": exception})
+    return _error_response(request, 500, exception, "something wrong")
 
 
 def console(request):
diff --git a/journal/exporters/csv.py b/journal/exporters/csv.py
index 5bbd9d78..2cbffb62 100644
--- a/journal/exporters/csv.py
+++ b/journal/exporters/csv.py
@@ -171,5 +171,5 @@ class CsvExporter(Task):
         shutil.make_archive(filename[:-4], "zip", temp_folder_path)
         self.metadata["file"] = filename
         self.metadata["total"] = total
-        self.message = "Export complete."
+        self.message = f"{total} records exported."
         self.save()
diff --git a/journal/exporters/ndjson.py b/journal/exporters/ndjson.py
index 119ee136..cb0457d4 100644
--- a/journal/exporters/ndjson.py
+++ b/journal/exporters/ndjson.py
@@ -215,5 +215,5 @@ class NdjsonExporter(Task):
 
         self.metadata["file"] = filename
         self.metadata["total"] = total
-        self.message = "Export complete."
+        self.message = f"{total} records exported."
         self.save()
diff --git a/journal/importers/__init__.py b/journal/importers/__init__.py
index 67693cc3..07075888 100644
--- a/journal/importers/__init__.py
+++ b/journal/importers/__init__.py
@@ -1,6 +1,3 @@
-import os
-import zipfile
-
 from .csv import CsvImporter
 from .douban import DoubanImporter
 from .goodreads import GoodreadsImporter
@@ -8,25 +5,6 @@ from .letterboxd import LetterboxdImporter
 from .ndjson import NdjsonImporter
 from .opml import OPMLImporter
 
-
-def get_neodb_importer(
-    filename: str,
-) -> type[CsvImporter] | type[NdjsonImporter] | None:
-    if not os.path.exists(filename) or not zipfile.is_zipfile(filename):
-        return None
-    with zipfile.ZipFile(filename, "r") as z:
-        files = z.namelist()
-        if any(f == "journal.ndjson" for f in files):
-            return NdjsonImporter
-        if any(
-            f.endswith("_mark.csv")
-            or f.endswith("_review.csv")
-            or f.endswith("_note.csv")
-            for f in files
-        ):
-            return CsvImporter
-
-
 __all__ = [
     "CsvImporter",
     "NdjsonImporter",
@@ -34,5 +12,4 @@ __all__ = [
     "OPMLImporter",
     "DoubanImporter",
     "GoodreadsImporter",
-    "get_neodb_importer",
 ]
diff --git a/journal/importers/csv.py b/journal/importers/csv.py
index f84bc98a..24401de3 100644
--- a/journal/importers/csv.py
+++ b/journal/importers/csv.py
@@ -5,7 +5,6 @@ import zipfile
 from typing import Dict
 
 from django.utils import timezone
-from django.utils.translation import gettext as _
 from loguru import logger
 
 from catalog.models import ItemCategory
@@ -15,6 +14,9 @@ from .base import BaseImporter
 
 
 class CsvImporter(BaseImporter):
+    class Meta:
+        app_label = "journal"  # workaround bug in TypedModel
+
     def import_mark(self, row: Dict[str, str]) -> str:
         """Import a mark from a CSV row.
 
@@ -249,7 +251,7 @@ class CsvImporter(BaseImporter):
 
                 # Set the total count in metadata
                 self.metadata["total"] = total_rows
-                self.message = f"Found {total_rows} items to import"
+                self.message = f"found {total_rows} records to import"
                 self.save(update_fields=["metadata", "message"])
 
                 # Now process all files
@@ -257,7 +259,5 @@ class CsvImporter(BaseImporter):
                     import_function = getattr(self, f"import_{file_type}")
                     self.process_csv_file(file_path, import_function)
 
-        self.message = _("Import complete")
-        if self.metadata.get("failed_items", []):
-            self.message += f": {self.metadata['failed']} items failed ({len(self.metadata['failed_items'])} unique items)"
+        self.message = f"{self.metadata['imported']} items imported, {self.metadata['skipped']} skipped, {self.metadata['failed']} failed."
         self.save()
diff --git a/journal/importers/douban.py b/journal/importers/douban.py
index 1157671c..627fd999 100644
--- a/journal/importers/douban.py
+++ b/journal/importers/douban.py
@@ -154,6 +154,8 @@ class DoubanImporter(Task):
     def run(self):
         logger.info(f"{self.user} import start")
         self.load_sheets()
+        self.message = f"豆瓣标记和评论导入开始,共{self.metadata['total']}篇。"
+        self.save(update_fields=["message"])
         logger.info(f"{self.user} sheet loaded, {self.metadata['total']} lines total")
         for name, param in self.mark_sheet_config.items():
             self.import_mark_sheet(self.mark_data[name], param[0], name)
diff --git a/journal/importers/letterboxd.py b/journal/importers/letterboxd.py
index f37241f8..4e76c5b0 100644
--- a/journal/importers/letterboxd.py
+++ b/journal/importers/letterboxd.py
@@ -1,4 +1,5 @@
 import csv
+import os
 import tempfile
 import zipfile
 from datetime import timedelta
@@ -35,6 +36,13 @@ class LetterboxdImporter(Task):
         "file": None,
     }
 
+    @classmethod
+    def validate_file(cls, uploaded_file):
+        try:
+            return zipfile.is_zipfile(uploaded_file)
+        except Exception:
+            return False
+
     def get_item_by_url(self, url):
         try:
             h = BasicDownloader(url).download().html()
@@ -121,7 +129,6 @@ class LetterboxdImporter(Task):
         self.progress(1)
 
     def progress(self, mark_state: int, url=None):
-        self.metadata["total"] += 1
         self.metadata["processed"] += 1
         match mark_state:
             case 1:
@@ -142,49 +149,56 @@ class LetterboxdImporter(Task):
             with tempfile.TemporaryDirectory() as tmpdirname:
                 logger.debug(f"Extracting {filename} to {tmpdirname}")
                 zipref.extractall(tmpdirname)
-                with open(tmpdirname + "/reviews.csv") as f:
-                    reader = csv.DictReader(f, delimiter=",")
-                    for row in reader:
-                        uris.add(row["Letterboxd URI"])
-                        self.mark(
-                            row["Letterboxd URI"],
-                            ShelfType.COMPLETE,
-                            row["Watched Date"],
-                            row["Rating"],
-                            row["Review"],
-                            row["Tags"],
-                        )
-                with open(tmpdirname + "/ratings.csv") as f:
-                    reader = csv.DictReader(f, delimiter=",")
-                    for row in reader:
-                        if row["Letterboxd URI"] in uris:
-                            continue
-                        uris.add(row["Letterboxd URI"])
-                        self.mark(
-                            row["Letterboxd URI"],
-                            ShelfType.COMPLETE,
-                            row["Date"],
-                            row["Rating"],
-                        )
-                with open(tmpdirname + "/watched.csv") as f:
-                    reader = csv.DictReader(f, delimiter=",")
-                    for row in reader:
-                        if row["Letterboxd URI"] in uris:
-                            continue
-                        uris.add(row["Letterboxd URI"])
-                        self.mark(
-                            row["Letterboxd URI"],
-                            ShelfType.COMPLETE,
-                            row["Date"],
-                        )
-                with open(tmpdirname + "/watchlist.csv") as f:
-                    reader = csv.DictReader(f, delimiter=",")
-                    for row in reader:
-                        if row["Letterboxd URI"] in uris:
-                            continue
-                        uris.add(row["Letterboxd URI"])
-                        self.mark(
-                            row["Letterboxd URI"],
-                            ShelfType.WISHLIST,
-                            row["Date"],
-                        )
+                if os.path.exists(tmpdirname + "/reviews.csv"):
+                    with open(tmpdirname + "/reviews.csv") as f:
+                        reader = csv.DictReader(f, delimiter=",")
+                        for row in reader:
+                            uris.add(row["Letterboxd URI"])
+                            self.mark(
+                                row["Letterboxd URI"],
+                                ShelfType.COMPLETE,
+                                row["Watched Date"],
+                                row["Rating"],
+                                row["Review"],
+                                row["Tags"],
+                            )
+                if os.path.exists(tmpdirname + "/ratings.csv"):
+                    with open(tmpdirname + "/ratings.csv") as f:
+                        reader = csv.DictReader(f, delimiter=",")
+                        for row in reader:
+                            if row["Letterboxd URI"] in uris:
+                                continue
+                            uris.add(row["Letterboxd URI"])
+                            self.mark(
+                                row["Letterboxd URI"],
+                                ShelfType.COMPLETE,
+                                row["Date"],
+                                row["Rating"],
+                            )
+                if os.path.exists(tmpdirname + "/watched.csv"):
+                    with open(tmpdirname + "/watched.csv") as f:
+                        reader = csv.DictReader(f, delimiter=",")
+                        for row in reader:
+                            if row["Letterboxd URI"] in uris:
+                                continue
+                            uris.add(row["Letterboxd URI"])
+                            self.mark(
+                                row["Letterboxd URI"],
+                                ShelfType.COMPLETE,
+                                row["Date"],
+                            )
+                if os.path.exists(tmpdirname + "/watchlist.csv"):
+                    with open(tmpdirname + "/watchlist.csv") as f:
+                        reader = csv.DictReader(f, delimiter=",")
+                        for row in reader:
+                            if row["Letterboxd URI"] in uris:
+                                continue
+                            uris.add(row["Letterboxd URI"])
+                            self.mark(
+                                row["Letterboxd URI"],
+                                ShelfType.WISHLIST,
+                                row["Date"],
+                            )
+        self.metadata["total"] = self.metadata["processed"]
+        self.message = f"{self.metadata['imported']} imported, {self.metadata['skipped']} skipped, {self.metadata['failed']} failed"
+        self.save(update_fields=["metadata", "message"])
diff --git a/journal/importers/ndjson.py b/journal/importers/ndjson.py
index bb243923..b9f7a291 100644
--- a/journal/importers/ndjson.py
+++ b/journal/importers/ndjson.py
@@ -4,7 +4,6 @@ import tempfile
 import zipfile
 from typing import Any, Dict
 
-from django.utils.translation import gettext as _
 from loguru import logger
 
 from journal.models import (
@@ -26,6 +25,9 @@ from .base import BaseImporter
 class NdjsonImporter(BaseImporter):
     """Importer for NDJSON files exported from NeoDB."""
 
+    class Meta:
+        app_label = "journal"  # workaround bug in TypedModel
+
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.items = {}
@@ -60,8 +62,8 @@ class NdjsonImporter(BaseImporter):
                 metadata = item_entry.get("metadata", {})
                 collection.append_item(item, metadata=metadata)
             return "imported"
-        except Exception as e:
-            logger.error(f"Error importing collection: {e}")
+        except Exception:
+            logger.exception("Error importing collection")
             return "failed"
 
     def import_shelf_member(self, data: Dict[str, Any]) -> BaseImporter.ImportResult:
@@ -86,8 +88,8 @@ class NdjsonImporter(BaseImporter):
                 created_time=published_dt,
             )
             return "imported"
-        except Exception as e:
-            logger.error(f"Error importing shelf member: {e}")
+        except Exception:
+            logger.exception("Error importing shelf member")
             return "failed"
 
     def import_shelf_log(self, data: Dict[str, Any]) -> BaseImporter.ImportResult:
@@ -110,8 +112,8 @@ class NdjsonImporter(BaseImporter):
             # return "imported" if created else "skipped"
             # count skip as success otherwise it may confuse user
             return "imported"
-        except Exception as e:
-            logger.error(f"Error importing shelf log: {e}")
+        except Exception:
+            logger.exception("Error importing shelf log")
             return "failed"
 
     def import_post(self, data: Dict[str, Any]) -> BaseImporter.ImportResult:
@@ -152,8 +154,8 @@ class NdjsonImporter(BaseImporter):
                 metadata=metadata,
             )
             return "imported"
-        except Exception as e:
-            logger.error(f"Error importing review: {e}")
+        except Exception:
+            logger.exception("Error importing review")
             return "failed"
 
     def import_note(self, data: Dict[str, Any]) -> BaseImporter.ImportResult:
@@ -185,8 +187,8 @@ class NdjsonImporter(BaseImporter):
                 metadata=data.get("metadata", {}),
             )
             return "imported"
-        except Exception as e:
-            logger.error(f"Error importing note: {e}")
+        except Exception:
+            logger.exception("Error importing note")
             return "failed"
 
     def import_comment(self, data: Dict[str, Any]) -> BaseImporter.ImportResult:
@@ -218,8 +220,8 @@ class NdjsonImporter(BaseImporter):
                 metadata=metadata,
             )
             return "imported"
-        except Exception as e:
-            logger.error(f"Error importing comment: {e}")
+        except Exception:
+            logger.exception("Error importing comment")
             return "failed"
 
     def import_rating(self, data: Dict[str, Any]) -> BaseImporter.ImportResult:
@@ -251,8 +253,8 @@ class NdjsonImporter(BaseImporter):
                 metadata=metadata,
             )
             return "imported"
-        except Exception as e:
-            logger.error(f"Error importing rating: {e}")
+        except Exception:
+            logger.exception("Error importing rating")
             return "failed"
 
     def import_tag(self, data: Dict[str, Any]) -> BaseImporter.ImportResult:
@@ -271,8 +273,8 @@ class NdjsonImporter(BaseImporter):
                 },
             )
             return "imported" if created else "skipped"
-        except Exception as e:
-            logger.error(f"Error importing tag member: {e}")
+        except Exception:
+            logger.exception("Error importing tag member")
             return "failed"
 
     def import_tag_member(self, data: Dict[str, Any]) -> BaseImporter.ImportResult:
@@ -309,8 +311,8 @@ class NdjsonImporter(BaseImporter):
                 },
             )
             return "imported" if created else "skipped"
-        except Exception as e:
-            logger.error(f"Error importing tag member: {e}")
+        except Exception:
+            logger.exception("Error importing tag member")
             return "failed"
 
     def process_journal(self, file_path: str) -> None:
@@ -348,6 +350,9 @@ class NdjsonImporter(BaseImporter):
                 journal[data_type].append(data)
 
         self.metadata["total"] = sum(len(items) for items in journal.values())
+        self.message = f"found {self.metadata['total']} records to import"
+        self.save(update_fields=["metadata", "message"])
+
         logger.debug(f"Processing {self.metadata['total']} entries")
         if lines_error:
             logger.error(f"Error processing journal.ndjson: {lines_error} lines")
@@ -369,8 +374,8 @@ class NdjsonImporter(BaseImporter):
                 for line in jsonfile:
                     try:
                         i = json.loads(line)
-                    except (json.JSONDecodeError, Exception) as e:
-                        logger.error(f"Error processing catalog item: {e}")
+                    except (json.JSONDecodeError, Exception):
+                        logger.exception("Error processing catalog item")
                         continue
                     u = i.get("id")
                     if not u:
@@ -381,8 +386,8 @@ class NdjsonImporter(BaseImporter):
                     self.items[u] = self.get_item_by_info_and_links("", "", links)
             logger.info(f"Loaded {item_count} items from catalog")
             self.metadata["catalog_processed"] = item_count
-        except Exception as e:
-            logger.error(f"Error parsing catalog file: {e}")
+        except Exception:
+            logger.exception("Error parsing catalog file")
 
     def parse_header(self, file_path: str) -> Dict[str, Any]:
         try:
@@ -392,8 +397,8 @@ class NdjsonImporter(BaseImporter):
                     header = json.loads(first_line)
                     if header.get("server"):
                         return header
-        except (json.JSONDecodeError, IOError) as e:
-            logger.error(f"Error parsing NDJSON header: {e}")
+        except (json.JSONDecodeError, IOError):
+            logger.exception("Error parsing header")
         return {}
 
     def run(self) -> None:
@@ -424,24 +429,5 @@ class NdjsonImporter(BaseImporter):
                 logger.debug(f"Importing journal.ndjson with {header}")
                 self.process_journal(journal_path)
 
-        source_info = self.metadata.get("journal_header", {})
-        source_summary = f" from {source_info.get('username', 'unknown')}@{source_info.get('server', 'unknown')} ver:{source_info.get('neodb_version', 'unknown')}."
-        self.message = _("Import complete") + source_summary
-
-        metadata_stats = self.metadata.get("metadata_stats", {})
-        partial_updates = metadata_stats.get("partial_updates", 0)
-        if partial_updates > 0:
-            self.message += f", {partial_updates} items with partial metadata updates"
-
-        ratings = metadata_stats.get("ratings_updated", 0)
-        comments = metadata_stats.get("comments_updated", 0)
-        tags = metadata_stats.get("tags_updated", 0)
-
-        if ratings > 0 or comments > 0 or tags > 0:
-            self.message += (
-                f" ({ratings} ratings, {comments} comments, {tags} tag sets)"
-            )
-
-        if self.metadata.get("failed_items", []):
-            self.message += f": {self.metadata['failed']} items failed ({len(self.metadata['failed_items'])} unique items)"
+        self.message = f"{self.metadata['imported']} items imported, {self.metadata['skipped']} skipped, {self.metadata['failed']} failed."
         self.save()
diff --git a/journal/importers/opml.py b/journal/importers/opml.py
index 184b8151..d1685d61 100644
--- a/journal/importers/opml.py
+++ b/journal/importers/opml.py
@@ -1,43 +1,54 @@
-import django_rq
 import listparser
-from auditlog.context import set_actor
 from django.utils.translation import gettext as _
 from loguru import logger
-from user_messages import api as msg
 
 from catalog.common import *
 from catalog.common.downloaders import *
 from catalog.sites.rss import RSS
 from journal.models import *
+from users.models.task import Task
 
 
-class OPMLImporter:
-    def __init__(self, user, visibility, mode):
-        self.user = user
-        self.visibility = visibility
-        self.mode = mode
+class OPMLImporter(Task):
+    class Meta:
+        app_label = "journal"  # workaround bug in TypedModel
 
-    def parse_file(self, uploaded_file):
-        return listparser.parse(uploaded_file.read()).feeds
+    TaskQueue = "import"
+    DefaultMetadata = {
+        "total": 0,
+        "mode": 0,
+        "processed": 0,
+        "skipped": 0,
+        "imported": 0,
+        "failed": 0,
+        "visibility": 0,
+        "failed_urls": [],
+        "file": None,
+    }
 
-    def import_from_file(self, uploaded_file):
-        feeds = self.parse_file(uploaded_file)
-        if not feeds:
+    @classmethod
+    def validate_file(cls, f):
+        try:
+            return bool(listparser.parse(f.read()).feeds)
+        except Exception:
             return False
-        django_rq.get_queue("import").enqueue(self.import_from_file_task, feeds)
-        return True
 
-    def import_from_file_task(self, feeds):
-        logger.info(f"{self.user} import opml start")
-        skip = 0
-        collection = None
-        with set_actor(self.user):
-            if self.mode == 1:
+    def run(self):
+        with open(self.metadata["file"], "r") as f:
+            feeds = listparser.parse(f.read()).feeds
+            self.metadata["total"] = len(feeds)
+            self.message = f"Processing {self.metadata['total']} feeds."
+            self.save(update_fields=["metadata", "message"])
+
+            collection = None
+            if self.metadata["mode"] == 1:
                 title = _("{username}'s podcast subscriptions").format(
                     username=self.user.display_name
                 )
                 collection = Collection.objects.create(
-                    owner=self.user.identity, title=title
+                    owner=self.user.identity,
+                    title=title,
+                    visibility=self.metadata["visibility"],
                 )
             for feed in feeds:
                 logger.info(f"{self.user} import {feed.url}")
@@ -47,21 +58,26 @@ class OPMLImporter:
                     res = None
                 if not res or not res.item:
                     logger.warning(f"{self.user} feed error {feed.url}")
+                    self.metadata["failed"] += 1
                     continue
                 item = res.item
-                if self.mode == 0:
+                if self.metadata["mode"] == 0:
                     mark = Mark(self.user.identity, item)
                     if mark.shelfmember:
                         logger.info(f"{self.user} marked, skip {feed.url}")
-                        skip += 1
+                        self.metadata["skipped"] += 1
                     else:
+                        self.metadata["imported"] += 1
                         mark.update(
-                            ShelfType.PROGRESS, None, None, visibility=self.visibility
+                            ShelfType.PROGRESS,
+                            None,
+                            None,
+                            visibility=self.metadata["visibility"],
                         )
-                elif self.mode == 1 and collection:
+                elif self.metadata["mode"] == 1 and collection:
+                    self.metadata["imported"] += 1
                     collection.append_item(item)
-        logger.info(f"{self.user} import opml end")
-        msg.success(
-            self.user,
-            f"OPML import complete, {len(feeds)} feeds processed, {skip} exisiting feeds skipped.",
-        )
+                self.metadata["processed"] += 1
+                self.save(update_fields=["metadata"])
+        self.message = f"{self.metadata['imported']} feeds imported, {self.metadata['skipped']} skipped, {self.metadata['failed']} failed."
+        self.save(update_fields=["message"])
diff --git a/journal/migrations/0006_csvimporter.py b/journal/migrations/0006_csvimporter.py
index 7b6f45c6..ceaa90b9 100644
--- a/journal/migrations/0006_csvimporter.py
+++ b/journal/migrations/0006_csvimporter.py
@@ -10,6 +10,16 @@ class Migration(migrations.Migration):
     ]
 
     operations = [
+        migrations.CreateModel(
+            name="BaseImporter",
+            fields=[],
+            options={
+                "proxy": True,
+                "indexes": [],
+                "constraints": [],
+            },
+            bases=("users.task",),
+        ),
         migrations.CreateModel(
             name="CsvImporter",
             fields=[],
@@ -20,4 +30,24 @@ class Migration(migrations.Migration):
             },
             bases=("users.task",),
         ),
+        migrations.CreateModel(
+            name="OPMLImporter",
+            fields=[],
+            options={
+                "proxy": True,
+                "indexes": [],
+                "constraints": [],
+            },
+            bases=("users.task",),
+        ),
+        migrations.CreateModel(
+            name="NdjsonImporter",
+            fields=[],
+            options={
+                "proxy": True,
+                "indexes": [],
+                "constraints": [],
+            },
+            bases=("journal.baseimporter",),
+        ),
     ]
diff --git a/journal/tests/csv.py b/journal/tests/csv.py
index 22f3ace7..d8fb45bd 100644
--- a/journal/tests/csv.py
+++ b/journal/tests/csv.py
@@ -9,7 +9,7 @@ from loguru import logger
 
 from catalog.models import Edition, IdType, Movie, TVEpisode, TVSeason, TVShow
 from journal.exporters import CsvExporter
-from journal.importers import CsvImporter, get_neodb_importer
+from journal.importers import CsvImporter
 from users.models import User
 
 from ..models import *
@@ -219,10 +219,9 @@ class CsvExportImportTest(TestCase):
                             f"Expected file {filename} with {expected_data_count} data rows, but file not found"
                         )
 
-        self.assertEqual(get_neodb_importer(export_path), CsvImporter)
         importer = CsvImporter.create(user=self.user2, file=export_path, visibility=2)
         importer.run()
-        self.assertEqual(importer.message, "Import complete")
+        self.assertEqual(importer.message, "11 items imported, 0 skipped, 0 failed.")
 
         # Verify imported data
 
diff --git a/journal/tests/ndjson.py b/journal/tests/ndjson.py
index f882e6d0..cb236a68 100644
--- a/journal/tests/ndjson.py
+++ b/journal/tests/ndjson.py
@@ -18,7 +18,7 @@ from catalog.models import (
     TVShow,
 )
 from journal.exporters import NdjsonExporter
-from journal.importers import NdjsonImporter, get_neodb_importer
+from journal.importers import NdjsonImporter
 from users.models import User
 
 from ..models import *
@@ -363,12 +363,11 @@ class NdjsonExportImportTest(TestCase):
                     self.assertEqual(type_counts["ShelfLog"], logs.count())
 
         # Now import the export file into a different user account
-        self.assertEqual(get_neodb_importer(export_path), NdjsonImporter)
         importer = NdjsonImporter.create(
             user=self.user2, file=export_path, visibility=2
         )
         importer.run()
-        self.assertIn("Import complete", importer.message)
+        self.assertIn("61 items imported, 0 skipped, 0 failed.", importer.message)
 
         # Verify imported data
 
diff --git a/users/migrations/0008_alter_task_type.py b/users/migrations/0008_alter_task_type.py
index aba30336..5b120d5d 100644
--- a/users/migrations/0008_alter_task_type.py
+++ b/users/migrations/0008_alter_task_type.py
@@ -14,6 +14,7 @@ class Migration(migrations.Migration):
             name="type",
             field=models.CharField(
                 choices=[
+                    ("journal.baseimporter", "base importer"),
                     ("journal.csvexporter", "csv exporter"),
                     ("journal.csvimporter", "csv importer"),
                     ("journal.doubanimporter", "douban importer"),
@@ -21,6 +22,8 @@ class Migration(migrations.Migration):
                     ("journal.goodreadsimporter", "goodreads importer"),
                     ("journal.letterboxdimporter", "letterboxd importer"),
                     ("journal.ndjsonexporter", "ndjson exporter"),
+                    ("journal.ndjsonimporter", "ndjson importer"),
+                    ("journal.opmlimporter", "opml importer"),
                 ],
                 db_index=True,
                 max_length=255,
diff --git a/users/models/task.py b/users/models/task.py
index 85fab411..71f9cf26 100644
--- a/users/models/task.py
+++ b/users/models/task.py
@@ -82,7 +82,6 @@ class Task(TypedModel):
         task.refresh_from_db()
         task.state = cls.States.complete if ok else cls.States.failed
         task.save()
-        task.notify()
 
     def enqueue(self):
         return django_rq.get_queue(self.TaskQueue).enqueue(
diff --git a/users/templates/users/data.html b/users/templates/users/data.html
index 631a1fe9..dc530535 100644
--- a/users/templates/users/data.html
+++ b/users/templates/users/data.html
@@ -10,6 +10,13 @@
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <title>{{ site_name }} - {% trans 'Data Management' %}</title>
     {% include "common_libs.html" %}
+    <script>
+    document.addEventListener('htmx:responseError', (event) => {
+      let response = event.detail.xhr.response;
+      let body = response ? response : `Request error: ${event.detail.xhr.statusText}`;
+      alert(body);
+    });
+    </script>
   </head>
   <body>
     {% include "_header.html" %}
@@ -17,80 +24,217 @@
       <div class="grid__main">
         <article>
           <details>
-            <summary>{% trans 'Export Data' %}</summary>
-            <form action="{% url 'users:export_csv' %}"
+            <summary>{% trans 'Import Marks and Reviews from Douban' %}</summary>
+            <form action="{% url 'users:import_douban' %}"
                   method="post"
                   enctype="multipart/form-data">
               {% csrf_token %}
+              {% blocktrans %}Select <code>.xlsx</code> exported from <a href="https://doufen.org" target="_blank" rel="noopener">Doufen</a>{% endblocktrans %}
+              <input type="file" name="file" id="excel" required accept=".xlsx">
+              <fieldset>
+                <legend>{% trans "Import Method" %}</legend>
+                <label for="import_mode_0">
+                  <input id="import_mode_0" type="radio" name="import_mode" value="0" checked>
+                  {% trans "Merge: only update when status changes from wishlist to in-progress, or from in-progress to complete." %}
+                </label>
+                <label for="import_mode_1">
+                  <input id="import_mode_1" type="radio" name="import_mode" value="1">
+                  {% trans "Overwrite: update all imported status." %}
+                </label>
+              </fieldset>
+              <p>
+                {% trans 'Visibility' %}:
+                <br>
+                <label for="id_visibility_0">
+                  <input type="radio"
+                         name="visibility"
+                         value="0"
+                         required=""
+                         id="id_visibility_0"
+                         checked>
+                  {% trans 'Public' %}
+                </label>
+                <label for="id_visibility_1">
+                  <input type="radio"
+                         name="visibility"
+                         value="1"
+                         required=""
+                         id="id_visibility_1">
+                  {% trans 'Followers Only' %}
+                </label>
+                <label for="id_visibility_2">
+                  <input type="radio"
+                         name="visibility"
+                         value="2"
+                         required=""
+                         id="id_visibility_2">
+                  {% trans 'Mentioned Only' %}
+                </label>
+              </p>
               <input type="submit"
-                     value="{% trans 'Export marks, reviews and notes in CSV' %}" />
-              {% if csv_export_task %}
-                <br>
-                {% trans 'Last export' %}: {{ csv_export_task.created_time }}
-                {% trans 'Status' %}: {{ csv_export_task.get_state_display }}
-                <br>
-                {{ csv_export_task.message }}
-                {% if csv_export_task.metadata.file %}
-                  <a href="{% url 'users:export_csv' %}" download>{% trans 'Download' %}</a>
-                {% endif %}
-              {% endif %}
-            </form>
-            <hr>
-            <form action="{% url 'users:export_ndjson' %}"
-                  method="post"
-                  enctype="multipart/form-data">
-              {% csrf_token %}
-              <input type="submit" value="{% trans 'Export everything in NDJSON' %}" />
-              {% if ndjson_export_task %}
-                <br>
-                {% trans 'Last export' %}: {{ ndjson_export_task.created_time }}
-                {% trans 'Status' %}: {{ ndjson_export_task.get_state_display }}
-                <br>
-                {{ ndjson_export_task.message }}
-                {% if ndjson_export_task.metadata.file %}
-                  <a href="{% url 'users:export_ndjson' %}" download><i class="fa fa-file-code"></i> {% trans 'Download' %}</a>
-                {% endif %}
-              {% endif %}
-            </form>
-            <hr>
-            <form action="{% url 'users:export_marks' %}"
-                  method="post"
-                  enctype="multipart/form-data">
-              {% csrf_token %}
-              <input type="submit"
-                     class="secondary"
-                     value="{% trans 'Export marks and reviews in XLSX (Doufen format)' %}" />
-              <small>exporting to this format will be deprecated soon.</small>
-              {% if export_task %}
-                <br>
-                {% trans 'Last export' %}: {{ export_task.created_time }}
-                {% trans 'Status' %}: {{ export_task.get_state_display }}
-                <br>
-                {{ export_task.message }}
-                {% if export_task.metadata.file %}
-                  <a href="{% url 'users:export_marks' %}" download>{% trans 'Download' %}</a>
-                {% endif %}
-              {% endif %}
+                     {% if import_task.status == "pending" %} onclick="return confirm('{% trans "Another import is in progress, starting a new import may cause issues, sure to import?" %}')" value="{% trans "Import in progress, please wait" %}" {% else %} value="{% trans 'Import' %}" {% endif %} />
             </form>
           </details>
         </article>
         <article>
           <details>
-            <summary>{% trans 'Import Data' %}</summary>
-            <form action="{% url 'users:import_neodb' %}"
-                  method="post"
+            <summary>{% trans 'Import Shelf or List from Goodreads' %}</summary>
+            <form hx-post="{% url 'users:import_goodreads' %}">
+              {% csrf_token %}
+              <div>
+                {% trans 'Link to Goodreads Profile / Shelf / List' %}
+                <ul>
+                  <li>
+                    Profile <code>https://www.goodreads.com/user/show/12345-janedoe</code>
+                    <br>
+                    {% trans 'want-to-read / currently-reading / read books and their reviews will be imported.' %}
+                  </li>
+                  <li>
+                    Shelf <code>https://www.goodreads.com/review/list/12345-janedoe?shelf=name</code>
+                    <br>
+                    {% trans 'Shelf will be imported as a new collection.' %}
+                  </li>
+                  <li>
+                    List <code>https://www.goodreads.com/list/show/155086.Popular_Highlights</code>
+                    <br>
+                    {% trans 'List will be imported as a new collection.' %}
+                  </li>
+                  <li>
+                    <mark>Who Can View My Profile</mark> must be set as <mark>anyone</mark> prior to import.
+                  </li>
+                </ul>
+                <input type="url"
+                       name="url"
+                       value=""
+                       placeholder="https://www.goodreads.com/user/show/12345-janedoe"
+                       required>
+                <input type="submit"  value="{% trans 'Import' %}" />
+              </div>
+              {% include "users/user_task_status.html" with task=goodreads_task %}
+            </form>
+          </details>
+        </article>
+        <article>
+          <details>
+            <summary>{% trans 'Import from Letterboxd' %}</summary>
+            <form hx-post="{% url 'users:import_letterboxd' %}"
+                  enctype="multipart/form-data">
+              {% csrf_token %}
+              <ul>
+                <li>
+                  In letterboxd.com,
+                  <a href="https://letterboxd.com/settings/data/"
+                     target="_blank"
+                     rel="noopener">click DATA in Settings</a>;
+                  or in its app, tap Advanced Settings in Settings, tap EXPORT YOUR DATA
+                </li>
+                <li>
+                  download file with name like <code>letterboxd-username-2018-03-11-07-52-utc.zip</code>, do not unzip.
+                </li>
+              </ul>
+              <br>
+              <input type="file" name="file" required accept=".zip">
+              <p>
+                {% trans 'Visibility' %}:
+                <br>
+                <label for="l_visibility_0">
+                  <input type="radio"
+                         name="visibility"
+                         value="0"
+                         required=""
+                         id="l_visibility_0"
+                         checked>
+                  {% trans 'Public' %}
+                </label>
+                <label for="l_visibility_1">
+                  <input type="radio"
+                         name="visibility"
+                         value="1"
+                         required=""
+                         id="l_visibility_1">
+                  {% trans 'Followers Only' %}
+                </label>
+                <label for="l_visibility_2">
+                  <input type="radio"
+                         name="visibility"
+                         value="2"
+                         required=""
+                         id="l_visibility_2">
+                  {% trans 'Mentioned Only' %}
+                </label>
+              </p>
+              <input type="submit" value="{% trans 'Import' %}" />
+              <small>{% trans 'Only forward changes(none->to-watch->watched) will be imported.' %}</small>
+              {% include "users/user_task_status.html" with task=letterboxd_task %}
+            </form>
+          </details>
+        </article>
+        <article>
+          <details>
+            <summary>{% trans 'Import Podcast Subscriptions' %}</summary>
+            <form hx-post="{% url 'users:import_opml' %}" enctype="multipart/form-data">
+              {% csrf_token %}
+              <div>
+                {% trans 'Import Method' %}:
+                <label for="opml_import_mode_0">
+                  <input id="opml_import_mode_0"
+                         type="radio"
+                         name="import_mode"
+                         value="0"
+                         checked>
+                  {% trans 'Mark as listening' %}
+                </label>
+                <label for="opml_import_mode_1">
+                  <input id="opml_import_mode_1" type="radio" name="import_mode" value="1">
+                  {% trans 'Import as a new collection' %}
+                </label>
+                {% trans 'Visibility' %}:
+                <label for="opml_visibility_0">
+                  <input type="radio"
+                         name="visibility"
+                         value="0"
+                         required=""
+                         id="opml_visibility_0"
+                         checked>
+                  {% trans 'Public' %}
+                </label>
+                <label for="opml_visibility_1">
+                  <input type="radio"
+                         name="visibility"
+                         value="1"
+                         required=""
+                         id="opml_visibility_1">
+                  {% trans 'Followers Only' %}
+                </label>
+                <label for="opml_visibility_2">
+                  <input type="radio"
+                         name="visibility"
+                         value="2"
+                         required=""
+                         id="opml_visibility_2">
+                  {% trans 'Mentioned Only' %}
+                </label>
+                <br>
+                {% trans 'Select OPML file' %}
+                <input type="file" name="file" required accept=".opml,.xml">
+                <input type="submit"  value="{% trans 'Import' %}" />
+              </div>
+              {% include "users/user_task_status.html" with task=opml_import_task %}
+            </form>
+          </details>
+        </article>
+        <article>
+          <details>
+            <summary>{% trans 'Import NeoDB Archive' %}</summary>
+            <form hx-post="{% url 'users:import_neodb' %}"
                   enctype="multipart/form-data">
               {% csrf_token %}
               <ul>
                 <li>
                   {% trans 'Upload a <code>.zip</code> file containing <code>.csv</code> or <code>.ndjson</code> files exported from NeoDB.' %}
                 </li>
-                <li>{% trans 'Existing marks and reviews with newer dates will be preserved.' %}</li>
-                <li>
-                  {% trans 'Both CSV and NDJSON formats exported from NeoDB are supported. NDJSON format includes more data, like collections.' %}
-                </li>
+                <li>{% trans 'Existing data may be overwritten.' %}</li>
               </ul>
-              <br>
               <input type="file" name="file" id="neodb_import_file" required accept=".zip">
               <div id="detected_format_info"
                    style="display: none;
@@ -132,8 +276,8 @@
                   </label>
                 </p>
               </div>
-              <input type="hidden" name="format_type" id="format_type" value="">
-              <input type="submit" value="{% trans 'Import' %}" />
+              <input type="hidden" name="format_type" id="format_type" value="" required>
+              <input type="submit" value="{% trans 'Import' %}" id="import_submit" />
               <script src="{{ cdn_url }}/npm/jszip@3.10.1/dist/jszip.min.js"></script>
               <script>
                 document.addEventListener('DOMContentLoaded', function() {
@@ -203,8 +347,7 @@
                       document.getElementById('detected_format').innerHTML = formatIcon + format;
                       document.getElementById('format_type').value = formatValue;
 
-                      // Show visibility settings only for NDJSON format
-                      if (formatValue === 'ndjson') {
+                      if (formatValue === 'csv') {
                         document.getElementById('visibility_settings').style.display = 'block';
                       } else {
                         document.getElementById('visibility_settings').style.display = 'none';
@@ -222,210 +365,53 @@
                       // Hide visibility settings on error
                       document.getElementById('visibility_settings').style.display = 'none';
                     }
+                    if (document.getElementById('format_type').value == '') {
+                      document.getElementById('import_submit').setAttribute('disabled', '')
+                    } else {
+                      document.getElementById('import_submit').removeAttribute('disabled', '')
+                    }
                   });
                 });
               </script>
-              {% if neodb_import_task %}
-                {% include "users/user_task_status.html" with task=neodb_import_task %}
-              {% endif %}
+              {% include "users/user_task_status.html" with task=neodb_import_task %}
             </form>
           </details>
         </article>
         <article>
           <details>
-            <summary>{% trans 'Import Marks and Reviews from Douban' %}</summary>
-            <form action="{% url 'users:import_douban' %}"
-                  method="post"
-                  enctype="multipart/form-data">
+            <summary>{% trans 'Export NeoDB Archive' %}</summary>
+            <form hx-post="{% url 'users:export_csv' %}" enctype="multipart/form-data">
               {% csrf_token %}
-              {% blocktrans %}Select <code>.xlsx</code> exported from <a href="https://doufen.org" target="_blank" rel="noopener">Doufen</a>{% endblocktrans %}
-              <input type="file" name="file" id="excel" required accept=".xlsx">
-              <fieldset>
-                <legend>{% trans "Import Method" %}</legend>
-                <label for="import_mode_0">
-                  <input id="import_mode_0" type="radio" name="import_mode" value="0" checked>
-                  {% trans "Merge: only update when status changes from wishlist to in-progress, or from in-progress to complete." %}
-                </label>
-                <label for="import_mode_1">
-                  <input id="import_mode_1" type="radio" name="import_mode" value="1">
-                  {% trans "Overwrite: update all imported status." %}
-                </label>
-              </fieldset>
-              <p>
-                {% trans 'Visibility' %}:
-                <br>
-                <label for="id_visibility_0">
-                  <input type="radio"
-                         name="visibility"
-                         value="0"
-                         required=""
-                         id="id_visibility_0"
-                         checked>
-                  {% trans 'Public' %}
-                </label>
-                <label for="id_visibility_1">
-                  <input type="radio"
-                         name="visibility"
-                         value="1"
-                         required=""
-                         id="id_visibility_1">
-                  {% trans 'Followers Only' %}
-                </label>
-                <label for="id_visibility_2">
-                  <input type="radio"
-                         name="visibility"
-                         value="2"
-                         required=""
-                         id="id_visibility_2">
-                  {% trans 'Mentioned Only' %}
-                </label>
-              </p>
               <input type="submit"
-                     {% if import_task.status == "pending" %} onclick="return confirm('{% trans "Another import is in progress, starting a new import may cause issues, sure to import?" %}')" value="{% trans "Import in progress, please wait" %}" {% else %} value="{% trans 'Import' %}" {% endif %} />
+                     value="{% trans 'Export marks, reviews and notes in CSV' %}" />
+              {% include "users/user_task_status.html" with task=csv_export_task %}
             </form>
-            <div hx-get="{% url 'users:import_status' %}"
-                 hx-trigger="load delay:1s"
-                 hx-swap="outerHTML"></div>
-          </details>
-        </article>
-        <article>
-          <details>
-            <summary>{% trans 'Import Shelf or List from Goodreads' %}</summary>
-            <form action="{% url 'users:import_goodreads' %}" method="post">
+            <hr>
+            <form hx-post="{% url 'users:export_ndjson' %}"
+                  enctype="multipart/form-data">
               {% csrf_token %}
-              <div>
-                {% trans 'Link to Goodreads Profile / Shelf / List' %}
-                <input type="url"
-                       name="url"
-                       value=""
-                       placeholder="https://www.goodreads.com/user/show/12345-janedoe"
-                       required>
-                <input type="submit"  value="{% trans 'Import' %}" />
-                <small>
-                  {% if goodreads_task %}
-                    <br>
-                    {% trans 'Last import started' %}: {{ goodreads_task.created_time }}
-                    {% trans 'Status' %}: {{ goodreads_task.get_state_display }}。
-                    <br>
-                    {{ goodreads_task.message }}
-                  {% endif %}
-                </small>
-              </div>
-              <ul>
-                <li>
-                  Profile <code>https://www.goodreads.com/user/show/12345-janedoe</code>
-                  {% trans 'want-to-read / currently-reading / read books and their reviews will be imported.' %}
-                </li>
-                <li>
-                  Shelf <code>https://www.goodreads.com/review/list/12345-janedoe?shelf=name</code>
-                  {% trans 'Shelf will be imported as a new collection.' %}
-                </li>
-                <li>
-                  List <code>https://www.goodreads.com/list/show/155086.Popular_Highlights</code>
-                  {% trans 'List will be imported as a new collection.' %}
-                </li>
-                <li>
-                  <mark>Who Can View My Profile</mark> must be set as <mark>anyone</mark> prior to import.
-                </li>
-              </ul>
+              <input type="submit" value="{% trans 'Export everything in NDJSON' %}" />
+              {% include "users/user_task_status.html" with task=ndjson_export_task %}
             </form>
-          </details>
-        </article>
-        <article>
-          <details>
-            <summary>{% trans 'Import from Letterboxd' %}</summary>
-            <form action="{% url 'users:import_letterboxd' %}"
+            <hr>
+            <form action="{% url 'users:export_marks' %}"
                   method="post"
                   enctype="multipart/form-data">
               {% csrf_token %}
-              <ul>
-                <li>
-                  In letterboxd.com,
-                  <a href="https://letterboxd.com/settings/data/"
-                     target="_blank"
-                     rel="noopener">click DATA in Settings</a>;
-                  or in its app, tap Advanced Settings in Settings, tap EXPORT YOUR DATA
-                </li>
-                <li>
-                  download file with name like <code>letterboxd-username-2018-03-11-07-52-utc.zip</code>, do not unzip.
-                </li>
-              </ul>
-              <br>
-              <input type="file" name="file" required accept=".zip">
-              <p>
-                {% trans 'Visibility' %}:
+              <b>exporting to this format will be deprecated soon, please use csv or ndjson format.</b>
+              <input type="submit"
+                     class="secondary"
+                     value="{% trans 'Export marks and reviews in XLSX (Doufen format)' %}" />
+              {% if export_task %}
                 <br>
-                <label for="l_visibility_0">
-                  <input type="radio"
-                         name="visibility"
-                         value="0"
-                         required=""
-                         id="l_visibility_0"
-                         checked>
-                  {% trans 'Public' %}
-                </label>
-                <label for="l_visibility_1">
-                  <input type="radio"
-                         name="visibility"
-                         value="1"
-                         required=""
-                         id="l_visibility_1">
-                  {% trans 'Followers Only' %}
-                </label>
-                <label for="l_visibility_2">
-                  <input type="radio"
-                         name="visibility"
-                         value="2"
-                         required=""
-                         id="l_visibility_2">
-                  {% trans 'Mentioned Only' %}
-                </label>
-              </p>
-              <input type="submit" value="{% trans 'Import' %}" />
-              <small>
-                {% trans 'Only forward changes(none->to-watch->watched) will be imported.' %}
-                {% if letterboxd_task %}
-                  <br>
-                  {% trans 'Last import started' %}: {{ letterboxd_task.created_time }}
-                  {% trans 'Status' %}: {{ letterboxd_task.get_state_display }}。
-                  <br>
-                  {{ letterboxd_task.message }}
-                  {% if letterboxd_task.metadata.failed_urls %}
-                    {% trans 'Failed links, likely due to Letterboxd error, you may have to mark them manually' %}:
-                    <br>
-                    <textarea readonly>{% for url in letterboxd_task.metadata.failed_urls %}{{url}}&#10;{% endfor %}</textarea>
-                  {% endif %}
+                {% trans 'Last export' %}: {{ export_task.created_time }}
+                {% trans 'Status' %}: {{ export_task.get_state_display }}
+                <br>
+                {{ export_task.message }}
+                {% if export_task.metadata.file %}
+                  <a href="{% url 'users:export_marks' %}" download>{% trans 'Download' %}</a>
                 {% endif %}
-              </small>
-            </form>
-          </details>
-        </article>
-        <article>
-          <details>
-            <summary>{% trans 'Import Podcast Subscriptions' %}</summary>
-            <form action="{% url 'users:import_opml' %}"
-                  method="post"
-                  enctype="multipart/form-data">
-              {% csrf_token %}
-              <div>
-                {% trans 'Import Method' %}:
-                <label for="opml_import_mode_0">
-                  <input id="opml_import_mode_0"
-                         type="radio"
-                         name="import_mode"
-                         value="0"
-                         checked>
-                  {% trans 'Mark as listening' %}
-                </label>
-                <label for="opml_import_mode_1">
-                  <input id="opml_import_mode_1" type="radio" name="import_mode" value="1">
-                  {% trans 'Import as a new collection' %}
-                </label>
-                <br>
-                {% trans 'Select OPML file' %}
-                <input type="file" name="file" id="excel" required accept=".opml,.xml">
-                <input type="submit"  value="{% trans 'Import' %}" />
-              </div>
+              {% endif %}
             </form>
           </details>
         </article>
@@ -441,25 +427,6 @@
             </div>
           </details>
         </article>
-        {% comment %}
-        <article>
-          <details>
-            <summary>{% trans 'Reset visibility for all marks' %}</summary>
-            <form action="{% url 'users:reset_visibility' %}" method="post">
-              {% csrf_token %}
-              <input type="submit"  value="{% trans 'Reset' %}" />
-              <div>
-                <input type="radio" name="visibility" id="visPublic" value="0" checked>
-                <label for="visPublic">{% trans 'Public' %}</label>
-                <input type="radio" name="visibility" id="visFollower" value="1">
-                <label for="visFollower">{% trans 'Followers Only' %}</label>
-                <input type="radio" name="visibility" id="visSelf" value="2">
-                <label for="visSelf">{% trans 'Mentioned Only' %}</label>
-              </div>
-            </form>
-          </details>
-        </article>
-        {% endcomment %}
       </div>
       {% include "_sidebar.html" with show_profile=1 identity=request.user.identity %}
     </main>
diff --git a/users/templates/users/user_task_status.html b/users/templates/users/user_task_status.html
index 25a8c474..077c5e76 100644
--- a/users/templates/users/user_task_status.html
+++ b/users/templates/users/user_task_status.html
@@ -1,20 +1,33 @@
 {% load i18n %}
-<div hx-target="this"
-     {% if task.state == 0 or task.state == 1 %} hx-get="{% url 'users:user_task_status' task.type %}" hx-trigger="intersect once, every 30s"{% endif %}
-     hx-swap="outerHTML">
-  {% trans 'Requested' %}: {{ task.created_time }}
-  ({{ task.get_state_display }})
-  {{ task.message }}
-  {% if task.state == 0 or task.state == 1 %}
-    {% if task.metadata.total and task.metadata.processed %}
-      <div>
-        <progress value="{{ task.metadata.processed }}" max="{{ task.metadata.total }}"></progress>
-      </div>
+{% if task %}
+  <div hx-target="this"
+       {% if task.state == 0 or task.state == 1 %} hx-get="{% url 'users:user_task_status' task.type %}" hx-trigger="every 30s"{% endif %}
+       hx-swap="outerHTML">
+    <div>
+      {% if task.state == 0 %}
+        <i class="fa-solid fa-spinner fa-spin"></i>
+      {% elif task.state == 1 %}
+        <i class="fa-solid fa-gear fa-spin"></i>
+      {% elif task.state == 3 %}
+        <i class="fa-solid fa-triangle-exclamation"></i>
+      {% elif 'exporter' in task.type %}
+        <a href="{% url 'users:user_task_download' task.type %}" download><i class="fa fa-download"></i></a>
+      {% else %}
+        <i class="fa-solid fa-check"></i>
+      {% endif %}
+      {{ task.created_time }}
+      {{ task.message }}
+    </div>
+    {% if task.state == 0 or task.state == 1 %}
+      {% if task.metadata.total and task.metadata.processed %}
+        <div>
+          <progress value="{{ task.metadata.processed }}" max="{{ task.metadata.total }}"></progress>
+        </div>
+      {% endif %}
     {% endif %}
-  {% endif %}
-  {% if task.metadata.failed_items %}
-    {% trans 'Failed items' %}:
-    <br>
-    <textarea readonly>{% for item in task.metadata.failed_items %}{{item}}&#10;{% endfor %}</textarea>
-  {% endif %}
-</div>
+    {% if task.metadata.failed_items %}
+      {% trans 'Failed items' %}:
+      <textarea readonly>{% for item in task.metadata.failed_items %}{{item}}&#10;{% endfor %}</textarea>
+    {% endif %}
+  </div>
+{% endif %}
diff --git a/users/urls.py b/users/urls.py
index 7751a9fa..4ef5fa80 100644
--- a/users/urls.py
+++ b/users/urls.py
@@ -11,6 +11,9 @@ urlpatterns = [
     path("info", account_info, name="info"),
     path("profile", account_profile, name="profile"),
     path("task/<str:task_type>/status", user_task_status, name="user_task_status"),
+    path(
+        "task/<str:task_type>/download", user_task_download, name="user_task_download"
+    ),
     path("data/import/status", data_import_status, name="import_status"),
     path("data/import/goodreads", import_goodreads, name="import_goodreads"),
     path("data/import/douban", import_douban, name="import_douban"),
diff --git a/users/views/data.py b/users/views/data.py
index eb4104fa..e615e066 100644
--- a/users/views/data.py
+++ b/users/views/data.py
@@ -4,6 +4,7 @@ import os
 from django.conf import settings
 from django.contrib import messages
 from django.contrib.auth.decorators import login_required
+from django.core.exceptions import BadRequest
 from django.db.models import Min
 from django.http import HttpResponse
 from django.shortcuts import redirect, render
@@ -20,7 +21,6 @@ from journal.importers import (
     LetterboxdImporter,
     NdjsonImporter,
     OPMLImporter,
-    get_neodb_importer,
 )
 from journal.models import ShelfType
 from takahe.utils import Takahe
@@ -97,7 +97,6 @@ def data(request):
     # Import tasks - check for both CSV and NDJSON importers
     csv_import_task = CsvImporter.latest_task(request.user)
     ndjson_import_task = NdjsonImporter.latest_task(request.user)
-
     # Use the most recent import task for display
     if ndjson_import_task and (
         not csv_import_task
@@ -119,6 +118,7 @@ def data(request):
             "ndjson_export_task": NdjsonExporter.latest_task(request.user),
             "letterboxd_task": LetterboxdImporter.latest_task(request.user),
             "goodreads_task": GoodreadsImporter.latest_task(request.user),
+            # "opml_task": OPMLImporter.latest_task(request.user),
             "years": years,
         },
     )
@@ -150,6 +150,8 @@ def user_task_status(request, task_type: str):
             task_cls = LetterboxdImporter
         case "journal.goodreadsimporter":
             task_cls = GoodreadsImporter
+        case "journal.opmlimporter":
+            task_cls = OPMLImporter
         case "journal.doubanimporter":
             task_cls = DoubanImporter
         case _:
@@ -158,6 +160,28 @@ def user_task_status(request, task_type: str):
     return render(request, "users/user_task_status.html", {"task": task})
 
 
+@login_required
+def user_task_download(request, task_type: str):
+    match task_type:
+        case "journal.csvexporter":
+            task_cls = CsvExporter
+        case "journal.ndjsonexporter":
+            task_cls = NdjsonExporter
+        case _:
+            return redirect(reverse("users:data"))
+    task = task_cls.latest_task(request.user)
+    if not task or task.state != Task.States.complete or not task.metadata.get("file"):
+        messages.add_message(request, messages.ERROR, _("Export file not available."))
+        return redirect(reverse("users:data"))
+    response = HttpResponse()
+    response["X-Accel-Redirect"] = (
+        settings.MEDIA_URL + task.metadata["file"][len(settings.MEDIA_ROOT) :]
+    )
+    response["Content-Type"] = "application/zip"
+    response["Content-Disposition"] = f'attachment; filename="{task.filename}.zip"'
+    return response
+
+
 @login_required
 def export_reviews(request):
     if request.method != "POST":
@@ -167,6 +191,7 @@ def export_reviews(request):
 
 @login_required
 def export_marks(request):
+    # TODO: deprecated
     if request.method == "POST":
         DoufenExporter.create(request.user).enqueue()
         messages.add_message(request, messages.INFO, _("Generating exports."))
@@ -206,22 +231,10 @@ def export_csv(request):
             )
             return redirect(reverse("users:data"))
         CsvExporter.create(request.user).enqueue()
-        messages.add_message(request, messages.INFO, _("Generating exports."))
-        return redirect(reverse("users:data"))
-    else:
-        task = CsvExporter.latest_task(request.user)
-        if not task or task.state != Task.States.complete:
-            messages.add_message(
-                request, messages.ERROR, _("Export file not available.")
-            )
-            return redirect(reverse("users:data"))
-        response = HttpResponse()
-        response["X-Accel-Redirect"] = (
-            settings.MEDIA_URL + task.metadata["file"][len(settings.MEDIA_ROOT) :]
+        return redirect(
+            reverse("users:user_task_status", args=("journal.csvexporter",))
         )
-        response["Content-Type"] = "application/zip"
-        response["Content-Disposition"] = f'attachment; filename="{task.filename}.zip"'
-        return response
+    return redirect(reverse("users:data"))
 
 
 @login_required
@@ -238,22 +251,10 @@ def export_ndjson(request):
             )
             return redirect(reverse("users:data"))
         NdjsonExporter.create(request.user).enqueue()
-        messages.add_message(request, messages.INFO, _("Generating exports."))
-        return redirect(reverse("users:data"))
-    else:
-        task = NdjsonExporter.latest_task(request.user)
-        if not task or task.state != Task.States.complete:
-            messages.add_message(
-                request, messages.ERROR, _("Export file not available.")
-            )
-            return redirect(reverse("users:data"))
-        response = HttpResponse()
-        response["X-Accel-Redirect"] = (
-            settings.MEDIA_URL + task.metadata["file"][len(settings.MEDIA_ROOT) :]
+        return redirect(
+            reverse("users:user_task_status", args=("journal.ndjsonexporter",))
         )
-        response["Content-Type"] = "application/zip"
-        response["Content-Disposition"] = f'attachment; filename="{task.filename}.zip"'
-        return response
+    return redirect(reverse("users:data"))
 
 
 @login_required
@@ -280,24 +281,26 @@ def sync_mastodon_preference(request):
 
 @login_required
 def import_goodreads(request):
-    if request.method == "POST":
-        raw_url = request.POST.get("url")
-        if GoodreadsImporter.validate_url(raw_url):
-            GoodreadsImporter.create(
-                request.user,
-                visibility=int(request.POST.get("visibility", 0)),
-                url=raw_url,
-            ).enqueue()
-            messages.add_message(request, messages.INFO, _("Import in progress."))
-        else:
-            messages.add_message(request, messages.ERROR, _("Invalid URL."))
-    return redirect(reverse("users:data"))
+    if request.method != "POST":
+        return redirect(reverse("users:data"))
+    raw_url = request.POST.get("url")
+    if not GoodreadsImporter.validate_url(raw_url):
+        raise BadRequest(_("Invalid URL."))
+    task = GoodreadsImporter.create(
+        request.user,
+        visibility=int(request.POST.get("visibility", 0)),
+        url=raw_url,
+    )
+    task.enqueue()
+    return redirect(reverse("users:user_task_status", args=(task.type,)))
 
 
 @login_required
 def import_douban(request):
     if request.method != "POST":
         return redirect(reverse("users:data"))
+    if not DoubanImporter.validate_file(request.FILES["file"]):
+        raise BadRequest(_("Invalid file."))
     f = (
         settings.MEDIA_ROOT
         + "/"
@@ -307,64 +310,75 @@ def import_douban(request):
     with open(f, "wb+") as destination:
         for chunk in request.FILES["file"].chunks():
             destination.write(chunk)
-    if not DoubanImporter.validate_file(request.FILES["file"]):
-        messages.add_message(request, messages.ERROR, _("Invalid file."))
-        return redirect(reverse("users:data"))
-    DoubanImporter.create(
+    task = DoubanImporter.create(
         request.user,
         visibility=int(request.POST.get("visibility", 0)),
         mode=int(request.POST.get("import_mode", 0)),
         file=f,
-    ).enqueue()
-    messages.add_message(
-        request, messages.INFO, _("File is uploaded and will be imported soon.")
     )
-    return redirect(reverse("users:data"))
+    task.enqueue()
+    return redirect(reverse("users:user_task_status", args=(task.type,)))
 
 
 @login_required
 def import_letterboxd(request):
-    if request.method == "POST":
-        f = (
-            settings.MEDIA_ROOT
-            + "/"
-            + GenerateDateUUIDMediaFilePath("x.zip", settings.SYNC_FILE_PATH_ROOT)
-        )
-        os.makedirs(os.path.dirname(f), exist_ok=True)
-        with open(f, "wb+") as destination:
-            for chunk in request.FILES["file"].chunks():
-                destination.write(chunk)
-        LetterboxdImporter.create(
-            request.user,
-            visibility=int(request.POST.get("visibility", 0)),
-            file=f,
-        ).enqueue()
-        messages.add_message(
-            request, messages.INFO, _("File is uploaded and will be imported soon.")
-        )
-    return redirect(reverse("users:data"))
+    if request.method != "POST":
+        return redirect(reverse("users:data"))
+    if not LetterboxdImporter.validate_file(request.FILES["file"]):
+        raise BadRequest(_("Invalid file."))
+    f = (
+        settings.MEDIA_ROOT
+        + "/"
+        + GenerateDateUUIDMediaFilePath("x.zip", settings.SYNC_FILE_PATH_ROOT)
+    )
+    os.makedirs(os.path.dirname(f), exist_ok=True)
+    with open(f, "wb+") as destination:
+        for chunk in request.FILES["file"].chunks():
+            destination.write(chunk)
+    task = LetterboxdImporter.create(
+        request.user,
+        visibility=int(request.POST.get("visibility", 0)),
+        file=f,
+    )
+    task.enqueue()
+    return redirect(reverse("users:user_task_status", args=(task.type,)))
 
 
 @login_required
 def import_opml(request):
-    if request.method == "POST":
-        importer = OPMLImporter(
-            request.user,
-            int(request.POST.get("visibility", 0)),
-            int(request.POST.get("import_mode", 0)),
-        )
-        if importer.import_from_file(request.FILES["file"]):
-            messages.add_message(
-                request, messages.INFO, _("File is uploaded and will be imported soon.")
-            )
-        else:
-            messages.add_message(request, messages.ERROR, _("Invalid file."))
-    return redirect(reverse("users:data"))
+    if request.method != "POST":
+        return redirect(reverse("users:data"))
+    if not OPMLImporter.validate_file(request.FILES["file"]):
+        raise BadRequest(_("Invalid file."))
+    f = (
+        settings.MEDIA_ROOT
+        + "/"
+        + GenerateDateUUIDMediaFilePath("x.zip", settings.SYNC_FILE_PATH_ROOT)
+    )
+    os.makedirs(os.path.dirname(f), exist_ok=True)
+    with open(f, "wb+") as destination:
+        for chunk in request.FILES["file"].chunks():
+            destination.write(chunk)
+    task = OPMLImporter.create(
+        request.user,
+        visibility=int(request.POST.get("visibility", 0)),
+        mode=int(request.POST.get("import_mode", 0)),
+        file=f,
+    )
+    task.enqueue()
+    return redirect(reverse("users:user_task_status", args=(task.type,)))
 
 
 @login_required
 def import_neodb(request):
     if request.method == "POST":
+        format_type_hint = request.POST.get("format_type", "").lower()
+        if format_type_hint == "csv":
+            importer = CsvImporter
+        elif format_type_hint == "ndjson":
+            importer = NdjsonImporter
+        else:
+            raise BadRequest("Invalid file.")
         f = (
             settings.MEDIA_ROOT
             + "/"
@@ -374,49 +388,11 @@ def import_neodb(request):
         with open(f, "wb+") as destination:
             for chunk in request.FILES["file"].chunks():
                 destination.write(chunk)
-
-        # Get format type hint from frontend, if provided
-        format_type_hint = request.POST.get("format_type", "").lower()
-
-        # Import appropriate class based on format type or auto-detect
-        from journal.importers import CsvImporter, NdjsonImporter
-
-        if format_type_hint == "csv":
-            importer = CsvImporter
-            format_type = "CSV"
-        elif format_type_hint == "ndjson":
-            importer = NdjsonImporter
-            format_type = "NDJSON"
-        else:
-            # Fall back to auto-detection if no hint provided
-            importer = get_neodb_importer(f)
-            if importer == CsvImporter:
-                format_type = "CSV"
-            elif importer == NdjsonImporter:
-                format_type = "NDJSON"
-            else:
-                format_type = ""
-                importer = None  # Make sure importer is None if auto-detection fails
-
-        if not importer:
-            messages.add_message(
-                request,
-                messages.ERROR,
-                _(
-                    "Invalid file. Expected a ZIP containing either CSV or NDJSON files exported from NeoDB."
-                ),
-            )
-            return redirect(reverse("users:data"))
-
-        importer.create(
+        task = importer.create(
             request.user,
             visibility=int(request.POST.get("visibility", 0)),
             file=f,
-        ).enqueue()
-
-        messages.add_message(
-            request,
-            messages.INFO,
-            _(f"{format_type} file is uploaded and will be imported soon."),
         )
+        task.enqueue()
+        return redirect(reverse("users:user_task_status", args=(task.type,)))
     return redirect(reverse("users:data"))