From 944aa496ea846ce891bd0044b46ecc6a948f6ca3 Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Sat, 24 Oct 2026 11:49:12 +0330 Subject: [PATCH] feat(links): auto-populate link titles with domain fallback on model save and serializers --- apps/hadis/models/hadis.py | 11 ++ apps/hadis/serializers/hadis.py | 69 +----------- apps/hadis/utils/__init__.py | 5 + apps/hadis/utils/links.py | 184 ++++++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 68 deletions(-) create mode 100644 apps/hadis/utils/links.py diff --git a/apps/hadis/models/hadis.py b/apps/hadis/models/hadis.py index 27b9d42..96c681f 100644 --- a/apps/hadis/models/hadis.py +++ b/apps/hadis/models/hadis.py @@ -288,6 +288,10 @@ class Hadis(LowercaseSlugMixin, models.Model): if not self._has_localized_value(self.title): self.auto_populate_title() + if self.links: + from apps.hadis.utils.links import normalize_links_for_storage + self.links = normalize_links_for_storage(self.links) + # Automatically generate or update smart slug when title changes from utils.slug import build_slug_for_instance build_slug_for_instance(self, source_field='title') @@ -548,6 +552,9 @@ class HadisCorrection(models.Model): def save(self, *args, **kwargs): if not self._has_localized_value(self.title): self.auto_populate_title() + if self.links: + from apps.hadis.utils.links import normalize_links_for_storage + self.links = normalize_links_for_storage(self.links) # Generate smart slug from utils.slug import build_slug_for_instance build_slug_for_instance(self, source_field='title') @@ -652,6 +659,10 @@ class HadisInterpretation(models.Model): # Auto-fill title from the first source (book reference) if empty if not self._has_localized_value(self.title): self.auto_populate_title() + + if self.links: + from apps.hadis.utils.links import normalize_links_for_storage + self.links = normalize_links_for_storage(self.links) # 👈 ست کردن اسلاگ تفسیر برابر با اسلاگ کتگوری if self.category and self.category.slug: diff --git a/apps/hadis/serializers/hadis.py b/apps/hadis/serializers/hadis.py index 9307734..96ac8ab 100644 --- a/apps/hadis/serializers/hadis.py +++ b/apps/hadis/serializers/hadis.py @@ -657,74 +657,7 @@ def format_address_output(ref_or_data): return [str(addr)] -def format_links_output(links_data): - """ - Standardizes links into a list of structured objects: - [ - {"title": "Dorar", "url": "https://dorar.net/hadith/3420"}, - {"title": "IslamWeb", "url": "https://www.islamweb.net/ar/fatwa/1909"} - ] - Handles dict, list of dicts, list of strings, and raw JSON strings cleanly. - """ - if not links_data: - return [] - - # 1. Parse JSON string if stored as string - if isinstance(links_data, str): - trimmed = links_data.strip() - if trimmed.startswith(("[", "{")): - try: - import json - parsed = json.loads(trimmed) - return format_links_output(parsed) - except (ValueError, TypeError, json.JSONDecodeError): - pass - return [{"title": "Link", "url": trimmed}] if trimmed else [] - - # 2. Main case: Dict format from React Admin {"Title": "URL"} - if isinstance(links_data, dict): - out = [] - for title, url_or_obj in links_data.items(): - if isinstance(url_or_obj, str) and url_or_obj.strip(): - out.append({ - "title": str(title).strip(), - "url": url_or_obj.strip() - }) - elif isinstance(url_or_obj, dict): - # Nested dict safety: e.g. {"Dorar": {"url": "...", "title": "..."}} - url = url_or_obj.get("url") or url_or_obj.get("link") or url_or_obj.get("href") - t = url_or_obj.get("title") or title - if url and isinstance(url, str) and url.strip(): - out.append({ - "title": str(t).strip(), - "url": url.strip() - }) - elif isinstance(url_or_obj, list): - out.extend(format_links_output(url_or_obj)) - return out - - # 3. List case: e.g. [{"title": "...", "url": "..."}, "https://..."] - if isinstance(links_data, list): - out = [] - for item in links_data: - if isinstance(item, str) and item.strip(): - out.append({ - "title": "Link", - "url": item.strip() - }) - elif isinstance(item, dict): - url = item.get("url") or item.get("link") or item.get("href") or item.get("value") - title = item.get("title") or item.get("name") or "Link" - if url and isinstance(url, str) and url.strip(): - out.append({ - "title": str(title).strip(), - "url": url.strip() - }) - elif isinstance(item, list): - out.extend(format_links_output(item)) - return out - - return [] +from apps.hadis.utils.links import format_links_output, extract_domain_from_url class TransmitterOriginalTextSerializer(serializers.ModelSerializer): diff --git a/apps/hadis/utils/__init__.py b/apps/hadis/utils/__init__.py index 6f3002c..1b460ac 100644 --- a/apps/hadis/utils/__init__.py +++ b/apps/hadis/utils/__init__.py @@ -10,3 +10,8 @@ from .date_engine import ( suggest_canonical_fix, normalize_lang_code, ) +from .links import ( + extract_domain_from_url, + normalize_links_for_storage, + format_links_output, +) diff --git a/apps/hadis/utils/links.py b/apps/hadis/utils/links.py new file mode 100644 index 0000000..51b5f05 --- /dev/null +++ b/apps/hadis/utils/links.py @@ -0,0 +1,184 @@ +import re +from urllib.parse import urlparse + + +def extract_domain_from_url(url: str) -> str: + """ + Extracts the root domain / hostname without 'www.' from a URL. + Examples: + 'https://www.digikala.com/product/123' -> 'digikala.com' + 'http://dorar.net/hadith/3420' -> 'dorar.net' + 'www.al-islam.org/path' -> 'al-islam.org' + 'digikala.com/item' -> 'digikala.com' + """ + if not url or not isinstance(url, str): + return "" + trimmed = url.strip() + if not trimmed: + return "" + + candidate = trimmed + if not re.match(r"^[a-zA-Z][a-zA-Z0-9+\-.]*://", candidate): + candidate = "https://" + candidate + + try: + parsed = urlparse(candidate) + hostname = (parsed.hostname or "").lower().strip() + if hostname.startswith("www."): + hostname = hostname[4:] + if hostname: + return hostname + except Exception: + pass + + # Regex fallback if urlparse failed + match = re.match(r"^(?:https?://)?(?:www\.)?([^/\s:?#]+)", trimmed, re.IGNORECASE) + if match: + return match.group(1).lower().strip() + + return "" + + +def normalize_links_for_storage(links_data): + """ + Ensures any link without a title (or with an empty / generic placeholder title) + has its title automatically set to the domain of the URL. + Returns the normalized structure ready to be stored in JSONField. + """ + if not links_data: + return {} + + if isinstance(links_data, str): + trimmed = links_data.strip() + if trimmed.startswith(("{", "[")): + try: + import json + parsed = json.loads(trimmed) + return normalize_links_for_storage(parsed) + except (ValueError, TypeError, json.JSONDecodeError): + pass + return {} + + # Case 1: Dict format { title: url } + if isinstance(links_data, dict): + normalized = {} + for title, url_or_obj in links_data.items(): + if isinstance(url_or_obj, str) and url_or_obj.strip(): + url = url_or_obj.strip() + t = str(title).strip() if title else "" + if not t or t.lower() in ("link", "untitled", "url", "default"): + t = extract_domain_from_url(url) or "Link" + + # Deduplicate key if multiple links share the same domain/title + key = t + counter = 2 + while key in normalized and normalized[key] != url: + key = f"{t} ({counter})" + counter += 1 + normalized[key] = url + + elif isinstance(url_or_obj, dict): + url = url_or_obj.get("url") or url_or_obj.get("link") or url_or_obj.get("href") + if url and isinstance(url, str) and url.strip(): + url = url.strip() + t = str(url_or_obj.get("title") or title or "").strip() + if not t or t.lower() in ("link", "untitled", "url", "default"): + t = extract_domain_from_url(url) or "Link" + key = t + counter = 2 + while key in normalized and normalized[key] != url: + key = f"{t} ({counter})" + counter += 1 + normalized[key] = url + return normalized + + # Case 2: List format [ {"title": "...", "url": "..."}, ... ] + if isinstance(links_data, list): + normalized = [] + for item in links_data: + if isinstance(item, str) and item.strip(): + url = item.strip() + title = extract_domain_from_url(url) or "Link" + normalized.append({"title": title, "url": url}) + elif isinstance(item, dict): + url = item.get("url") or item.get("link") or item.get("href") or item.get("value") + if url and isinstance(url, str) and url.strip(): + url = url.strip() + title = str(item.get("title") or item.get("name") or "").strip() + if not title or title.lower() in ("link", "untitled", "url", "default"): + title = extract_domain_from_url(url) or "Link" + normalized.append({"title": title, "url": url}) + return normalized + + return links_data + + +def format_links_output(links_data): + """ + Standardizes links into a list of structured objects: + [ + {"title": "dorar.net", "url": "https://dorar.net/hadith/3420"}, + {"title": "islamweb.net", "url": "https://www.islamweb.net/ar/fatwa/1909"} + ] + Handles dict, list of dicts, list of strings, and raw JSON strings cleanly. + Always ensures title defaults to domain if title is empty or generic 'Link'. + """ + if not links_data: + return [] + + if isinstance(links_data, str): + trimmed = links_data.strip() + if trimmed.startswith(("[", "{")): + try: + import json + parsed = json.loads(trimmed) + return format_links_output(parsed) + except (ValueError, TypeError, json.JSONDecodeError): + pass + if trimmed: + return [{"title": extract_domain_from_url(trimmed) or "Link", "url": trimmed}] + return [] + + if isinstance(links_data, dict): + out = [] + for title, url_or_obj in links_data.items(): + if isinstance(url_or_obj, str) and url_or_obj.strip(): + url = url_or_obj.strip() + t = str(title).strip() if title else "" + if not t or t.lower() in ("link", "untitled", "url", "default"): + t = extract_domain_from_url(url) or "Link" + out.append({"title": t, "url": url}) + elif isinstance(url_or_obj, dict): + url = url_or_obj.get("url") or url_or_obj.get("link") or url_or_obj.get("href") + if url and isinstance(url, str) and url.strip(): + url = url.strip() + t = str(url_or_obj.get("title") or title or "").strip() + if not t or t.lower() in ("link", "untitled", "url", "default"): + t = extract_domain_from_url(url) or "Link" + out.append({"title": t, "url": url}) + elif isinstance(url_or_obj, list): + out.extend(format_links_output(url_or_obj)) + return out + + if isinstance(links_data, list): + out = [] + for item in links_data: + if isinstance(item, str) and item.strip(): + url = item.strip() + out.append({ + "title": extract_domain_from_url(url) or "Link", + "url": url + }) + elif isinstance(item, dict): + url = item.get("url") or item.get("link") or item.get("href") or item.get("value") + if url and isinstance(url, str) and url.strip(): + url = url.strip() + t = str(item.get("title") or item.get("name") or "").strip() + if not t or t.lower() in ("link", "untitled", "url", "default"): + t = extract_domain_from_url(url) or "Link" + out.append({"title": t, "url": url}) + elif isinstance(item, list): + out.extend(format_links_output(item)) + return out + + return []