feat: implement specialty content module with expanded Page entity fields, database migrations, and data import utilities

This commit is contained in:
2026-07-28 19:20:56 +07:00
parent 01a3b83672
commit 186f70ec32
429 changed files with 52283 additions and 35 deletions
@@ -0,0 +1,63 @@
import oracledb
dsn = "localhost:1521/sisvietnam"
conn = oracledb.connect(user="sisvietnam", password="sisvietnam", dsn=dsn)
cursor = conn.cursor()
# Get primaryMenu ID
cursor.execute("SELECT id FROM SIS_MENU WHERE name = 'primaryMenu'")
menu = cursor.fetchone()
if not menu:
print("primaryMenu not found")
exit(1)
menu_id = menu[0]
# Get CHUYÊN KHOA ID
cursor.execute("SELECT id FROM SIS_MENU_ITEM WHERE menu_id = :1 AND UPPER(title) = 'CHUYÊN KHOA'", [menu_id])
parent_item = cursor.fetchone()
if not parent_item:
print("CHUYÊN KHOA menu item not found")
exit(1)
parent_id = parent_item[0]
# Get existing children to calculate item_order
cursor.execute("SELECT MAX(item_order) FROM SIS_MENU_ITEM WHERE parent_id = :1", [parent_id])
max_order = cursor.fetchone()[0]
if max_order is None:
max_order = 0
# Get all specialties
cursor.execute("SELECT title, slug FROM SIS_PAGE WHERE layout = 'SPECIALTY_DETAIL' ORDER BY title")
specialties = cursor.fetchall()
print(f"Found {len(specialties)} specialties to add.")
# Add to menu
count = 0
for spec in specialties:
title = spec[0]
slug = spec[1]
url = f"/chuyen-khoa/{slug}"
# Check if already exists
cursor.execute("SELECT id FROM SIS_MENU_ITEM WHERE parent_id = :1 AND url = :2", [parent_id, url])
if cursor.fetchone():
print(f"Skipping {title}, already exists in menu.")
continue
max_order += 1
# Get sequence for ID
cursor.execute("SELECT sequence_generator.nextval FROM dual")
seq_val = cursor.fetchone()[0]
cursor.execute("""
INSERT INTO SIS_MENU_ITEM (id, menu_id, parent_id, title, url, item_order, css_class, icon_class)
VALUES (:1, :2, :3, :4, :5, :6, :7, :8)
""", [seq_val, menu_id, parent_id, title, url, max_order, '', ''])
count += 1
print(f"Added {title} ({url})")
conn.commit()
print(f"Successfully added {count} items to menu.")
@@ -0,0 +1,35 @@
import oracledb
conn = oracledb.connect(user="sisvietnam", password="sisvietnam", dsn="localhost:1521/sisvietnam")
cursor = conn.cursor()
# === STEP 1: Clean up duplicates ===
duplicates_to_delete = [
"tim-mach-2",
"phau-thuat-gay-me-hoi-suc-2",
"khoa-dinh-dung-tiet-che",
"khoa-than-kinh-dot-quy-2",
]
# Also delete from menu items first
for slug in duplicates_to_delete:
cursor.execute("DELETE FROM sis_menu_item WHERE url = :1", [f"/chuyen-khoa/{slug}"])
cursor.execute("DELETE FROM sis_page WHERE slug = :1", [slug])
print(f"Deleted duplicate: {slug}")
# Fix title for khoa-than-kinh-dot-quy (was "Khoa Thần kinh - Đột quỵ 2")
cursor.execute("UPDATE sis_page SET title = N'Khoa Thần kinh - Đột quỵ' WHERE slug = 'khoa-than-kinh-dot-quy'")
# Fix title for tim-mach
cursor.execute("UPDATE sis_page SET title = N'Khoa Nội Tổng Hợp' WHERE slug = 'tim-mach'")
conn.commit()
# === STEP 2: Verify remaining specialties ===
cursor.execute("SELECT slug, title FROM sis_page WHERE layout='SPECIALTY_DETAIL' ORDER BY title")
rows = cursor.fetchall()
print(f"\n=== Remaining specialties: {len(rows)} ===")
for row in rows:
print(f" {row[0]} | {row[1]}")
conn.close()
+44
View File
@@ -0,0 +1,44 @@
import oracledb
import json
def convert_all():
conn = oracledb.connect(user="sisvietnam", password="sisvietnam", dsn="localhost:1521/sisvietnam")
cursor = conn.cursor()
cursor.execute("SELECT slug, content FROM sis_page WHERE page_type = 'CUSTOM'")
rows = cursor.fetchall()
count = 0
for row in rows:
slug = row[0]
content = row[1].read() if hasattr(row[1], 'read') else row[1]
if not content:
continue
try:
json.loads(content)
# Already JSON
continue
except ValueError:
# Not JSON, convert to Editor.js Raw block
editor_json = {
"time": 1620000000,
"blocks": [
{
"type": "raw",
"data": {
"html": content
}
}
],
"version": "2.22.2"
}
new_content = json.dumps(editor_json)
cursor.execute("UPDATE sis_page SET content = :1 WHERE slug = :2", [new_content, slug])
count += 1
conn.commit()
print(f"Converted {count} pages to Editor.js JSON.")
if __name__ == "__main__":
convert_all()
+144
View File
@@ -0,0 +1,144 @@
import sys
from bs4 import BeautifulSoup
import re
import unicodedata
def slugify(value):
value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('utf-8')
value = re.sub(r'[^\w\s-]', '', value).strip().lower()
return re.sub(r'[-\s]+', '-', value)
# The slugs used in our DB
db_slugs = [
"chuyen-khoa-tai-mui-hong",
"khoa-chan-doan-hinh-anh",
"khoa-cap-cuu",
"khoa-dinh-duong-tiet-che",
"khoa-duoc",
"khoa-kham-benh",
"don-vi-kiem-soat-nhiem-khuan",
"khoa-ngoai-tong-hop",
"tim-mach", # This is Khoa Nội Tổng Hợp in our DB
"phau-thuat-gay-me-hoi-suc",
"khoa-than-kinh-dot-quy",
"khoa-vat-ly-tri-lieu-phuc-hoi-chuc-nang",
"khoa-xet-nghiem",
"phong-quan-ly-van-hanh",
"don-vi-can-thiep-mach-dsa",
"don-vi-cap-cuu-ngoai-vien",
"don-vi-kham-suc-khoe-ngoai-vien"
]
# Manual mapping mapping bvdaihoc.com.vn titles to our slugs (if slugify is not perfect)
manual_mapping = {
"noi tong hop": "tim-mach",
"phau thuat gay me hoi suc phong mo": "phau-thuat-gay-me-hoi-suc",
"dsa": "don-vi-can-thiep-mach-dsa",
"tai mui hong": "chuyen-khoa-tai-mui-hong",
"kiem soat nhiem khuan": "don-vi-kiem-soat-nhiem-khuan",
"kham suc khoe ngoai vien": "don-vi-kham-suc-khoe-ngoai-vien",
"cap cuu ngoai vien": "don-vi-cap-cuu-ngoai-vien"
}
html_path = '/home/x79/sisvietnamvn_01/BV_DHYD_HCM/Các chuyên khoa tại Bệnh viện Đại học Y Dược TP. Hồ Chí Minh.html'
with open(html_path, 'r', encoding='utf-8') as f:
soup = BeautifulSoup(f, 'html.parser')
# Find SVGs by looking for "Chuyên khoa" or "Khoa" links
icons = {}
# We'll just look for SVGs in specific items or a tags with title text
a_tags = soup.find_all('a')
for a in a_tags:
svg = a.find('svg')
if svg:
text = a.text.strip()
if text:
# Try to match it to our slugs
s_text = slugify(text)
# Simple substring matching
matched_slug = None
for key, val in manual_mapping.items():
if slugify(key) in s_text:
matched_slug = val
break
if not matched_slug:
for s in db_slugs:
if s_text in s or s in s_text or s_text.replace("khoa-", "") in s:
matched_slug = s
break
if matched_slug:
# Clean up the SVG
svg['class'] = "w-8 h-8 text-primary-600 transition-colors duration-300"
# Remove some inline styles if any
for attr in ['style', 'width', 'height']:
if attr in svg.attrs:
del svg.attrs[attr]
icons[matched_slug] = str(svg)
# If we didn't find many icons, let's look for div with specific classes
if len(icons) < 5:
print("Found few icons, looking for specific classes...")
items = soup.select('.elementor-widget-icon-box .elementor-icon-box-icon svg, .elementor-icon svg')
for svg in items:
# traverse up to find title
parent = svg.parent
title = ""
while parent and parent.name not in ['body']:
header = parent.find(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
if header and header.text.strip():
title = header.text.strip()
break
parent = parent.parent
if title:
s_text = slugify(title)
matched_slug = None
for key, val in manual_mapping.items():
if slugify(key) in s_text:
matched_slug = val
break
if not matched_slug:
for s in db_slugs:
if s_text in s or s in s_text:
matched_slug = s
break
if matched_slug:
# Clean up the SVG
svg['class'] = "w-8 h-8 text-primary-600 transition-colors duration-300"
for attr in ['style', 'width', 'height']:
if attr in svg.attrs:
del svg.attrs[attr]
icons[matched_slug] = str(svg)
print(f"Mapped {len(icons)} icons to specialties")
# Generate the thymeleaf switch block
switch_code = '<th:block th:switch="${spec.slug}">\n'
for slug, svg_html in icons.items():
switch_code += f' <!-- {slug} -->\n'
switch_code += f' <th:block th:case="\'{slug}\'">\n'
switch_code += f' {svg_html}\n'
switch_code += f' </th:block>\n'
# Default case
switch_code += ' <!-- Default -->\n'
switch_code += ' <th:block th:case="*">\n'
switch_code += ' <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-8 h-8 text-primary-600 transition-colors duration-300">\n'
switch_code += ' <path d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2zM9 14h6M12 11v6"/>\n'
switch_code += ' </svg>\n'
switch_code += ' </th:block>\n'
switch_code += ' </th:block>'
# Write the new block to a file so we can see it
with open('/tmp/icons_block.txt', 'w', encoding='utf-8') as f:
f.write(switch_code)
print("Wrote switch block to /tmp/icons_block.txt")
+119
View File
@@ -0,0 +1,119 @@
import sys
from bs4 import BeautifulSoup
import re
import unicodedata
def slugify(value):
value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('utf-8')
value = re.sub(r'[^\w\s-]', '', value).strip().lower()
return re.sub(r'[-\s]+', '-', value)
# The slugs used in our DB
db_slugs = [
"chuyen-khoa-tai-mui-hong",
"khoa-chan-doan-hinh-anh",
"khoa-cap-cuu",
"khoa-dinh-duong-tiet-che",
"khoa-duoc",
"khoa-kham-benh",
"don-vi-kiem-soat-nhiem-khuan",
"khoa-ngoai-tong-hop",
"tim-mach", # This is Khoa Nội Tổng Hợp in our DB
"phau-thuat-gay-me-hoi-suc",
"khoa-than-kinh-dot-quy",
"khoa-vat-ly-tri-lieu-phuc-hoi-chuc-nang",
"khoa-xet-nghiem",
"phong-quan-ly-van-hanh",
"don-vi-can-thiep-mach-dsa",
"don-vi-cap-cuu-ngoai-vien",
"don-vi-kham-suc-khoe-ngoai-vien"
]
manual_mapping = {
"noi tong hop": "tim-mach",
"phau thuat gay me hoi suc phong mo": "phau-thuat-gay-me-hoi-suc",
"dsa": "don-vi-can-thiep-mach-dsa",
"tai mui hong": "chuyen-khoa-tai-mui-hong",
"kiem soat nhiem khuan": "don-vi-kiem-soat-nhiem-khuan",
"kham suc khoe ngoai vien": "don-vi-kham-suc-khoe-ngoai-vien",
"cap cuu ngoai vien": "don-vi-cap-cuu-ngoai-vien"
}
html_path = '/home/x79/sisvietnamvn_01/BV_DHYD_HCM/Các chuyên khoa tại Bệnh viện Đại học Y Dược TP. Hồ Chí Minh.html'
with open(html_path, 'r', encoding='utf-8') as f:
soup = BeautifulSoup(f, 'html.parser')
icons = {}
links = soup.find_all('a', href=True)
for a in links:
href = a.get('href')
if '/chuyen-khoa/' in href:
img = a.find('img')
if img:
src = img.get('src')
if not src:
# Next image srcset or similar fallback
src = img.get('data-src') or img.get('srcset')
if src and ' ' in src:
src = src.split(' ')[0]
title = href.split('/')[-1] # use the slug from href
# Find the actual name
name_span = a.find('span')
if name_span:
title = name_span.text.strip()
else:
title_text = a.text.strip()
if title_text:
title = title_text
if src:
# Decode src if it's encoded or make it absolute
if src.startswith('/_next/image?url='):
import urllib.parse
src = urllib.parse.unquote(src.replace('/_next/image?url=', '').split('&')[0])
# Match it
s_text = slugify(title)
matched_slug = None
for key, val in manual_mapping.items():
if slugify(key) in s_text:
matched_slug = val
break
if not matched_slug:
for s in db_slugs:
if s_text in s or s in s_text or s_text.replace("khoa-", "") in s:
matched_slug = s
break
if matched_slug:
icons[matched_slug] = src
print(f"Mapped {len(icons)} image icons to specialties")
# Generate the thymeleaf switch block for images
switch_code = '<th:block th:switch="${spec.slug}">\n'
for slug, src in icons.items():
switch_code += f' <!-- {slug} -->\n'
switch_code += f' <th:block th:case="\'{slug}\'">\n'
switch_code += f' <img src="{src}" alt="Icon" class="w-12 h-12 object-contain" />\n'
switch_code += f' </th:block>\n'
# Default case
switch_code += ' <!-- Default -->\n'
switch_code += ' <th:block th:case="*">\n'
switch_code += ' <div class="w-12 h-12 rounded-full bg-primary-100 flex items-center justify-center text-primary-600">\n'
switch_code += ' <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-6 h-6">\n'
switch_code += ' <path d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2zM9 14h6M12 11v6"/>\n'
switch_code += ' </svg>\n'
switch_code += ' </div>\n'
switch_code += ' </th:block>\n'
switch_code += ' </th:block>'
with open('/tmp/icons_block_img.txt', 'w', encoding='utf-8') as f:
f.write(switch_code)
print("Wrote switch block to /tmp/icons_block_img.txt")
+115
View File
@@ -0,0 +1,115 @@
import urllib.parse
import re
import unicodedata
html_path = '/home/x79/sisvietnamvn_01/BV_DHYD_HCM/Các chuyên khoa tại Bệnh viện Đại học Y Dược TP. Hồ Chí Minh.html'
with open(html_path, 'r', encoding='utf-8') as f:
text = f.read()
def slugify(value):
value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('utf-8')
value = re.sub(r'[^\w\s-]', '', value).strip().lower()
return re.sub(r'[-\s]+', '-', value)
db_slugs = [
"chuyen-khoa-tai-mui-hong", "khoa-chan-doan-hinh-anh", "khoa-cap-cuu",
"khoa-dinh-duong-tiet-che", "khoa-duoc", "khoa-kham-benh",
"don-vi-kiem-soat-nhiem-khuan", "khoa-ngoai-tong-hop", "tim-mach",
"phau-thuat-gay-me-hoi-suc", "khoa-than-kinh-dot-quy",
"khoa-vat-ly-tri-lieu-phuc-hoi-chuc-nang", "khoa-xet-nghiem",
"phong-quan-ly-van-hanh", "don-vi-can-thiep-mach-dsa",
"don-vi-cap-cuu-ngoai-vien", "don-vi-kham-suc-khoe-ngoai-vien"
]
manual_mapping = {
"noi tong hop": "tim-mach",
"phau thuat gay me hoi suc phong mo": "phau-thuat-gay-me-hoi-suc",
"dsa": "don-vi-can-thiep-mach-dsa",
"tai mui hong": "chuyen-khoa-tai-mui-hong",
"kiem soat nhiem khuan": "don-vi-kiem-soat-nhiem-khuan",
"kham suc khoe ngoai vien": "don-vi-kham-suc-khoe-ngoai-vien",
"cap cuu ngoai vien": "don-vi-cap-cuu-ngoai-vien",
"chan doan hinh anh": "khoa-chan-doan-hinh-anh",
"vat ly tri lieu phuc hoi chuc nang": "khoa-vat-ly-tri-lieu-phuc-hoi-chuc-nang",
"phuc hoi chuc nang": "khoa-vat-ly-tri-lieu-phuc-hoi-chuc-nang",
"kham benh": "khoa-kham-benh",
"cap cuu": "khoa-cap-cuu",
"than kinh": "khoa-than-kinh-dot-quy",
"ngoai tong hop": "khoa-ngoai-tong-hop",
"xet nghiem": "khoa-xet-nghiem",
"dinh duong tiet che": "khoa-dinh-duong-tiet-che"
}
chunks = text.split('href="https://bvdaihoc.com.vn/chuyen-khoa/')
icons = {}
for chunk in chunks[1:]:
slug_in_href = chunk.split('"')[0]
if '?' in slug_in_href: continue
# Try to find absolute img src in srcset
src = None
srcset_m = re.search(r'srcset=\"([^\"]+)\"', chunk)
if srcset_m:
# get the first url from srcset
srcsets = srcset_m.group(1).split(', ')
first = srcsets[0].split(' ')[0]
if first.startswith('/_next/image?url='):
src = urllib.parse.unquote(first.replace('/_next/image?url=', '').split('&')[0])
if not src:
# look for absolute console.bvdaihoc url anywhere in chunk
abs_m = re.search(r'https%3A%2F%2Fconsole.bvdaihoc.com.vn%2Fuploads%2Fchuyen-khoa%2F[^&\s\"\'\\]+', chunk)
if abs_m:
src = urllib.parse.unquote(abs_m.group(0))
if src:
title = slug_in_href
text_match = re.findall(r'>([^<]+)<', chunk[:1000])
texts = [t.strip() for t in text_match if len(t.strip()) > 3 and not t.strip().startswith('&')]
if texts:
title = texts[0]
s_text = slugify(title)
matched = None
for key, val in manual_mapping.items():
if slugify(key) in s_text:
matched = val
break
if not matched:
for s in db_slugs:
if s_text in s or s in s_text or s_text.replace("khoa-", "") in s:
matched = s
break
if matched:
icons[matched] = src
else:
s_slug = slugify(slug_in_href)
for s in db_slugs:
if s_slug in s or s in s_slug:
icons[s] = src
break
switch_code = ' <th:block th:switch="${spec.slug}">\n'
for slug, src in sorted(icons.items()):
switch_code += f' <!-- {slug} -->\n'
switch_code += f' <th:block th:case="\'{slug}\'">\n'
switch_code += f' <img src="{src}" alt="Icon" class="w-12 h-12 object-contain mx-auto" />\n'
switch_code += f' </th:block>\n'
switch_code += ' <!-- Default -->\n'
switch_code += ' <th:block th:case="*">\n'
switch_code += ' <div class="w-12 h-12 rounded-full bg-primary-100 flex items-center justify-center text-primary-600 mx-auto">\n'
switch_code += ' <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-6 h-6">\n'
switch_code += ' <path d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2zM9 14h6M12 11v6"/>\n'
switch_code += ' </svg>\n'
switch_code += ' </div>\n'
switch_code += ' </th:block>\n'
switch_code += ' </th:block>'
with open('/tmp/icons_block_img.txt', 'w', encoding='utf-8') as f:
f.write(switch_code)
print("Wrote block to /tmp/icons_block_img.txt")
+110
View File
@@ -0,0 +1,110 @@
import urllib.parse
import xml.etree.ElementTree as ET
import oracledb
import json
import re
def extract_siteorigin_html(content_text):
# Find all occurrences of siteorigin-panels block
# It might look like <!-- wp:siteorigin-panels/layout-block {"panelsData":{...}} /-->
html_parts = []
# regex to match <!-- wp:siteorigin-panels/layout-block {json} /-->
matches = re.finditer(r'<!--\s*wp:siteorigin-panels/layout-block\s+(.*?)\s*/?-->', content_text, re.DOTALL)
has_siteorigin = False
for match in matches:
has_siteorigin = True
json_str = match.group(1).strip()
if json_str.endswith('/'):
json_str = json_str[:-1].strip()
try:
data = json.loads(json_str)
if 'panelsData' in data and 'widgets' in data['panelsData']:
widgets = data['panelsData']['widgets']
for widget in widgets:
if 'text' in widget:
# Extract the HTML text from the widget
text = widget['text']
# The text might have unicode escapes like \u003c
# json.loads already decoded it!
html_parts.append(text)
except Exception as e:
print(f"Failed to parse JSON: {e}")
pass
if has_siteorigin:
return "\n".join(html_parts)
return content_text
def wrap_in_editorjs(html_content):
if not html_content.strip():
return ""
editor_json = {
"time": 1620000000,
"blocks": [
{
"type": "raw",
"data": {
"html": html_content
}
}
],
"version": "2.22.2"
}
return json.dumps(editor_json)
def main(xml_file_path):
print(f"Reading {xml_file_path} ...")
tree = ET.parse(xml_file_path)
root = tree.getroot()
ns = {
'wp': 'http://wordpress.org/export/1.2/',
'content': 'http://purl.org/rss/1.0/modules/content/'
}
dsn = "localhost:1521/sisvietnam"
conn = oracledb.connect(user="sisvietnam", password="sisvietnam", dsn=dsn)
cursor = conn.cursor()
count = 0
for item in root.findall('.//item'):
post_name_tag = item.find('wp:post_name', ns)
if post_name_tag is not None and post_name_tag.text:
slug = post_name_tag.text
else:
continue
slug = urllib.parse.unquote(slug)
if slug == "khoa-than-kinh-dot-quy-2":
slug = "khoa-than-kinh-dot-quy"
content_tag = item.find('content:encoded', ns)
content_text = content_tag.text if content_tag is not None and content_tag.text else ""
# Check if it has siteorigin
if 'wp:siteorigin-panels/layout-block' in content_text:
print(f"Found SiteOrigin content in: {slug}")
extracted_html = extract_siteorigin_html(content_text)
# Wrap in wrapper
extracted_html = f'<div class="wp-content-container prose max-w-none">\n{extracted_html}\n</div>'
final_content = wrap_in_editorjs(extracted_html)
cursor.execute("UPDATE sis_page SET content = :1 WHERE slug = :2", [final_content, slug])
count += 1
conn.commit()
print(f"\n--- TỔNG KẾT ---")
print(f"Successfully processed {count} SiteOrigin pages.")
if __name__ == "__main__":
import sys
if len(sys.argv) > 1:
main(sys.argv[1])
else:
print("Usage: python3 fix_siteorigin.py <xml_file>")
+168
View File
@@ -0,0 +1,168 @@
import oracledb
from bs4 import BeautifulSoup
import re
import xml.etree.ElementTree as ET
def transform_html(raw_html):
if not raw_html:
return "", None, None, None, None
soup = BeautifulSoup(raw_html, "html.parser")
# Extract all images
images = []
for img in soup.find_all('img'):
src = img.get('src')
if src:
images.append(src)
img.decompose() # Remove from flow
hero_image = images[0] if len(images) > 0 else "https://sisvietnam.vn/wp-content/uploads/2021/07/DSC07517-1024x576.jpg"
other_images = images[1:]
# Extract Contact Info
text = soup.get_text()
email_match = re.search(r"(?i)Email\s*:\s*([\w\.-]+@[\w\.-]+)", text)
phone_match = re.search(r"(?i)(?:SĐT|Điện thoại|ĐT|ĐIỆN THOẠI)\s*:\s*([\d\.\s-]+)", text)
address_match = re.search(r"(?i)Địa chỉ\s*:\s*([^\n]+)", text)
contact_email = email_match.group(1).strip() if email_match else None
contact_phone = phone_match.group(1).strip() if phone_match else None
contact_address = address_match.group(1).strip() if address_match else None
# Parse sections based on wp:list or headings
sections_data = []
top_uls = soup.find_all('ul', recursive=False)
if not top_uls and soup.find('div', class_='wp-content-container'):
top_uls = soup.find('div', class_='wp-content-container').find_all('ul', recursive=False)
for ul in top_uls:
heading = "Nội dung"
strong_tag = ul.find('strong')
if strong_tag:
heading = strong_tag.text.strip()
strong_tag.decompose()
content_html = ""
for li in ul.find_all('li', recursive=False):
inner_uls = li.find_all('ul')
if inner_uls:
for inner in inner_uls:
content_html += str(inner)
else:
content_html += str(li) # fallback to just raw text if no inner UL
if not content_html:
content_html = str(ul)
sections_data.append({
'heading': heading,
'content': content_html
})
if not sections_data:
sections_data.append({
'heading': 'Thông tin chi tiết',
'content': str(soup)
})
out = ""
bg_colors = ["bg-white", "bg-gray-50", "bg-white", "bg-gray-100"]
for i, sec in enumerate(sections_data):
bg = bg_colors[i % len(bg_colors)]
img_html = ""
if i < len(other_images):
img_src = other_images[i]
img_html = f"""
<div class="w-full lg:w-1/2">
<div class="rounded-2xl overflow-hidden shadow-lg aspect-[4/3] relative">
<img src="{img_src}" class="absolute inset-0 w-full h-full object-cover">
</div>
</div>
"""
flex_dir = "lg:flex-row-reverse" if i % 2 == 1 else "lg:flex-row"
text_width = "lg:w-1/2" if img_html else "lg:w-3/4 lg:mx-auto"
out += f"""
<!-- SECTION {i+1} -->
<section class="xl:py-12 md:py-8 py-4 {bg}">
<div class="container mx-auto px-4">
<div class="flex flex-col gap-6 {flex_dir} lg:items-center">
<div class="w-full {text_width}">
<h2 class="display-6 text-primary-600 mb-4 md:mb-6 xl:mb-8">{sec['heading']}</h2>
<div class="prose max-w-none text-gray-700 body-2" style="list-style-type: disc;">
{sec['content']}
</div>
</div>
{img_html}
</div>
</div>
</section>
"""
return out, hero_image, contact_email, contact_address, contact_phone
def main():
print("Loading original HTML from XML...")
file_path = "/home/x79/sisvietnamvn_01/sisvietnamvn_Trang chính thức hiện tại/post_chuyenkhoa.xml"
tree = ET.parse(file_path)
root = tree.getroot()
channel = root.find("channel")
ns = {"wp": "http://wordpress.org/export/1.2/", "content": "http://purl.org/rss/1.0/modules/content/"}
slug_to_raw = {}
for item in channel.findall("item"):
post_name = item.find("wp:post_name", ns).text
content_node = item.find("content:encoded", ns)
if content_node is not None and content_node.text:
slug_to_raw[post_name] = content_node.text
print(f"Loaded {len(slug_to_raw)} raw posts from XML")
dsn = "localhost:1521/sisvietnam"
conn = oracledb.connect(user="sisvietnam", password="sisvietnam", dsn=dsn)
cursor = conn.cursor()
cursor.execute("SELECT slug, title FROM sis_page WHERE page_type = 'CUSTOM'")
rows = cursor.fetchall()
count = 0
warnings = []
for row in rows:
slug = row[0]
title = row[1]
# If slug doesn't match exactly, maybe some slight difference? We'll try exact match
if slug not in slug_to_raw:
print(f"Slug {slug} not found in XML! Skipping.")
continue
raw_html = slug_to_raw[slug]
# Wrap it in wp-content-container for parsing if needed
if 'wp-content-container' not in raw_html:
raw_html = f'<div class="wp-content-container">{raw_html}</div>'
transformed_content, hero_image, email, address, phone = transform_html(raw_html)
if "Thông tin chi tiết" in transformed_content and "wp-content-container" not in transformed_content:
warnings.append(slug)
# Update DB
update_sql = """
UPDATE sis_page
SET content = :1, hero_image = :2, contact_email = :3, contact_address = :4, contact_phone = :5
WHERE slug = :6
"""
cursor.execute(update_sql, [transformed_content, hero_image, email, address, phone, slug])
count += 1
print(f"Restored and Updated {slug}")
conn.commit()
print(f"Successfully fixed {count} pages.")
if __name__ == "__main__":
main()
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
View File
+114
View File
@@ -0,0 +1,114 @@
import urllib.parse
import xml.etree.ElementTree as ET
import oracledb
from datetime import datetime
import sys
import json
def wrap_in_editorjs(html_content):
editor_json = {
"time": int(datetime.now().timestamp() * 1000),
"blocks": [
{
"type": "raw",
"data": {
"html": html_content
}
}
],
"version": "2.22.2"
}
return json.dumps(editor_json)
def main(xml_file_path):
print(f"Reading {xml_file_path} ...")
tree = ET.parse(xml_file_path)
root = tree.getroot()
ns = {
'wp': 'http://wordpress.org/export/1.2/',
'content': 'http://purl.org/rss/1.0/modules/content/',
'dc': 'http://purl.org/dc/elements/1.1/'
}
dsn = "localhost:1521/sisvietnam"
conn = oracledb.connect(user="sisvietnam", password="sisvietnam", dsn=dsn)
cursor = conn.cursor()
dt = datetime.now()
layout = "SPECIALTY_DETAIL"
page_type = "CUSTOM"
status = "PUBLISHED"
count = 0
warnings = []
for channel in root.findall('channel'):
for item in channel.findall('item'):
title_tag = item.find('title')
title = title_tag.text if title_tag is not None else "Untitled"
post_name_tag = item.find('wp:post_name', ns)
if post_name_tag is not None and post_name_tag.text:
slug = post_name_tag.text
else:
link_tag = item.find('link')
slug = link_tag.text.strip('/').split('/')[-1] if link_tag is not None else "unknown"
slug = urllib.parse.unquote(slug)
if slug == "khoa-than-kinh-dot-quy-2":
slug = "khoa-than-kinh-dot-quy"
# No longer skipping khoa-cap-cuu
content_tag = item.find('content:encoded', ns)
content_text = content_tag.text if content_tag is not None and content_tag.text else ""
# Check if missing container
if '<div class="wp-content-container' not in content_text and content_text.strip():
warnings.append(f"CẢNH BÁO: Bài viết '{title}' ({slug}) thiếu thẻ container. Hệ thống đã tự động bổ sung.")
content_text = f'<div class="wp-content-container prose max-w-none">\n{content_text}\n</div>'
# Wrap into Editor.js JSON
final_content = wrap_in_editorjs(content_text)
cursor.execute("SELECT id FROM sis_page WHERE slug = :1", [slug])
row = cursor.fetchone()
if row:
sql = "UPDATE sis_page SET title = :1, content = :2, layout = :3, page_type = :4 WHERE slug = :5"
cursor.execute(sql, [title, final_content, layout, page_type, slug])
else:
sql = """
INSERT INTO sis_page (
id, title, slug, content, status, page_type, display_order, layout,
created_date, last_modified_date, created_by, last_modified_by
) VALUES (
sequence_generator.nextval, :title, :slug, :content, :status, :page_type, 0, :layout,
:created_date, :last_modified_date, 'system', 'system'
)
"""
cursor.execute(sql, {
'title': title,
'slug': slug,
'content': final_content,
'status': status,
'page_type': page_type,
'layout': layout,
'created_date': dt,
'last_modified_date': dt
})
count += 1
conn.commit()
print(f"\n--- TỔNG KẾT ---")
print(f"Successfully processed {count} specialty pages.")
for w in warnings:
print(w)
if __name__ == "__main__":
if len(sys.argv) > 1:
main(sys.argv[1])
else:
print("Usage: python3 import_specialties.py <path_to_xml>")
+20
View File
@@ -0,0 +1,20 @@
const fs = require('fs');
const html = fs.readFileSync('/home/x79/sisvietnamvn_01/BV_DHYD_HCM/Các chuyên khoa tại Bệnh viện Đại học Y Dược TP. Hồ Chí Minh.html', 'utf8');
const chunks = html.split('href="https://bvdaihoc.com.vn/chuyen-khoa/');
for (let i = 1; i < chunks.length; i++) {
const slug = chunks[i].split('"')[0];
if (slug.includes('?category=')) continue;
const imgIdx = chunks[i].indexOf('<img ');
if (imgIdx !== -1) {
const srcIdx = chunks[i].indexOf('src="', imgIdx);
if (srcIdx !== -1) {
let src = chunks[i].substring(srcIdx + 5).split('"')[0];
if (src.startsWith('/_next/image?url=')) {
src = decodeURIComponent(src.replace('/_next/image?url=', '').split('&')[0]);
}
console.log(slug + ': ' + src);
}
}
}
+25
View File
@@ -0,0 +1,25 @@
const fs = require('fs');
const html = fs.readFileSync('/home/x79/sisvietnamvn_01/BV_DHYD_HCM/Các chuyên khoa tại Bệnh viện Đại học Y Dược TP. Hồ Chí Minh.html', 'utf8');
let imgCount = 0;
let idx = 0;
while (true) {
idx = html.indexOf('<img', idx);
if (idx === -1) break;
const endIdx = html.indexOf('>', idx);
const tag = html.substring(idx, endIdx + 1);
// get parent context 100 chars before
const ctxBefore = html.substring(Math.max(0, idx - 100), idx);
if (ctxBefore.includes('chuyen-khoa')) {
console.log('Context:', ctxBefore);
console.log('Tag:', tag);
console.log('---');
imgCount++;
}
idx = endIdx;
if (imgCount > 5) break;
}
@@ -0,0 +1,8 @@
const fs = require('fs');
const html = fs.readFileSync('/home/x79/sisvietnamvn_01/BV_DHYD_HCM/Khoa Giải phẫu bệnh.html', 'utf8');
const h2s = html.match(/<h2[^>]*>([\s\S]*?)<\/h2>/gi);
console.log('H2s:', h2s ? h2s.map(h => h.replace(/<[^>]+>/g, '').trim()) : 'None');
const h3s = html.match(/<h3[^>]*>([\s\S]*?)<\/h3>/gi);
console.log('H3s:', h3s ? h3s.map(h => h.replace(/<[^>]+>/g, '').trim()) : 'None');
+103
View File
@@ -0,0 +1,103 @@
import oracledb
import sys
try:
conn = oracledb.connect(user="sisvietnam", password="sisvietnam", dsn="localhost:1521/sisvietnam")
except Exception as e:
print(f"Connection failed: {e}")
sys.exit(1)
cursor = conn.cursor()
# Get menu by ID directly (we know it's 2001)
menu_id = 2001
# Find CHUYÊN KHOA parent item - search by label or title containing "khoa"
cursor.execute("""
SELECT id, label, title FROM sis_menu_item
WHERE menu_id = :1 AND parent_id IS NULL
ORDER BY display_order
""", [menu_id])
top_items = cursor.fetchall()
print("Top-level menu items:")
ck_id = None
for r in top_items:
print(f" id={r[0]} label={r[1]} title={r[2]}")
if r[1] and 'KHOA' in r[1].upper():
ck_id = r[0]
elif r[2] and 'KHOA' in r[2].upper():
ck_id = r[0]
if not ck_id:
print("\nCHUYÊN KHOA menu item not found! Creating one...")
ck_id_val = cursor.execute("SELECT sequence_generator.nextval FROM dual").fetchone()[0]
from datetime import datetime
dt = datetime.now()
cursor.execute("""
INSERT INTO sis_menu_item (id, menu_id, label, url, parent_id, display_order, title, created_date, last_modified_date, created_by, last_modified_by)
VALUES (:id, :menu_id, :label, :url, NULL, 2, :title, :cd, :md, 'system', 'system')
""", {
'id': ck_id_val, 'menu_id': menu_id, 'label': 'CHUYÊN KHOA',
'url': '/chuyen-khoa', 'title': 'CHUYÊN KHOA', 'cd': dt, 'md': dt
})
ck_id = ck_id_val
print(f"Created CHUYÊN KHOA menu item with id={ck_id}")
else:
print(f"\nFound CHUYÊN KHOA menu item with id={ck_id}")
# Delete existing children under CHUYÊN KHOA (clear and rebuild)
cursor.execute("""
DELETE FROM sis_menu_item WHERE parent_id IN (
SELECT id FROM sis_menu_item WHERE parent_id = :1
)
""", [ck_id])
cursor.execute("DELETE FROM sis_menu_item WHERE parent_id = :1", [ck_id])
print("Cleared existing children")
from datetime import datetime
dt = datetime.now()
def next_id():
cursor.execute("SELECT sequence_generator.nextval FROM dual")
return cursor.fetchone()[0]
categories = [
{"label": "Khoa lâm sàng", "url": "/chuyen-khoa?category=khoa-lam-sang", "order": 1, "db_cat": "KHOA_LAM_SANG"},
{"label": "Khoa cận lâm sàng", "url": "/chuyen-khoa?category=khoa-can-lam-sang", "order": 2, "db_cat": "KHOA_CAN_LAM_SANG"},
{"label": "Khoa hỗ trợ lâm sàng", "url": "/chuyen-khoa?category=khoa-ho-tro-lam-sang", "order": 3, "db_cat": "KHOA_HO_TRO_LAM_SANG"},
]
for cat in categories:
cat_item_id = next_id()
cursor.execute("""
INSERT INTO sis_menu_item (id, menu_id, label, url, parent_id, display_order, title, created_date, last_modified_date, created_by, last_modified_by)
VALUES (:id, :menu_id, :label, :url, :parent_id, :display_order, :title, :cd, :md, 'system', 'system')
""", {
'id': cat_item_id, 'menu_id': menu_id, 'label': cat['label'],
'url': cat['url'], 'parent_id': ck_id, 'display_order': cat['order'],
'title': cat['label'], 'cd': dt, 'md': dt
})
print(f"\nCreated sub-group: {cat['label']} (id={cat_item_id})")
cursor.execute("""
SELECT slug, title FROM sis_page
WHERE layout = 'SPECIALTY_DETAIL' AND specialty_category = :1
ORDER BY title
""", [cat['db_cat']])
specs = cursor.fetchall()
for idx, spec in enumerate(specs):
spec_id = next_id()
cursor.execute("""
INSERT INTO sis_menu_item (id, menu_id, label, url, parent_id, display_order, title, created_date, last_modified_date, created_by, last_modified_by)
VALUES (:id, :menu_id, :label, :url, :parent_id, :display_order, :title, :cd, :md, 'system', 'system')
""", {
'id': spec_id, 'menu_id': menu_id, 'label': spec[1],
'url': f"/chuyen-khoa/{spec[0]}", 'parent_id': cat_item_id,
'display_order': idx + 1, 'title': spec[1], 'cd': dt, 'md': dt
})
print(f" Added: {spec[1]}")
conn.commit()
print("\n✅ Menu reorganization complete!")
conn.close()
+13 -2
View File
@@ -27,7 +27,7 @@ try:
# Fetch the latest 10 published posts with this tag
sql_posts = """
SELECT p.title, p.featured_image, p.slug, p.excerpt, p.created_date
SELECT p.title, p.featured_image, p.slug, p.excerpt, p.created_date, p.content
FROM sis_post p
JOIN sis_post_tag pt ON p.id = pt.post_id
WHERE pt.tag_id = :tag_id AND p.status = 'PUBLISHED'
@@ -38,13 +38,24 @@ try:
posts = cursor.fetchall()
import random
import re
def extract_words(html_text, num_words=100):
if not html_text:
return ""
if hasattr(html_text, 'read'):
html_text = html_text.read()
text = re.sub('<[^<]+?>', ' ', html_text)
words = text.split()
return " ".join(words[:num_words]) + ("..." if len(words) > num_words else "")
prices = ["42.400.000 VNĐ", "23.000.000 VNĐ", "32.500.000 VNĐ", "15.000.000 VNĐ", "Liên hệ"]
badges = ["SAT", "SUN", "MON", "TUE", "WED", "THU", "FRI"]
items = []
for post in posts:
img = post[1] if post[1] else ""
desc = post[3] if post[3] else ""
desc = post[3] if post[3] else extract_words(post[5], 100)
date_val = post[4]
date_str = date_val.strftime("%d/%m/%Y") if hasattr(date_val, 'strftime') else str(date_val) if date_val else "12/09/2026"
Binary file not shown.
@@ -0,0 +1,16 @@
package com.sisvietnamvn.web.config;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;
import com.sisvietnamvn.web.repository.PageRepository;
@Component
public class DebugRunner implements CommandLineRunner {
@Autowired
private PageRepository pageRepository;
@Override
public void run(String... args) {
long count = pageRepository.findAll().stream().filter(p -> "SPECIALTY_DETAIL".equals(p.getLayout().name())).count();
System.out.println("TOTAL_SPECIALTIES_IN_DB: " + count);
}
}
@@ -0,0 +1,73 @@
package com.sisvietnamvn.web.config;
import com.sisvietnamvn.web.domain.Menu;
import com.sisvietnamvn.web.domain.MenuItem;
import com.sisvietnamvn.web.domain.Page;
import com.sisvietnamvn.web.domain.PageLayout;
import com.sisvietnamvn.web.repository.MenuItemRepository;
import com.sisvietnamvn.web.repository.MenuRepository;
import com.sisvietnamvn.web.service.PageService;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class MenuSeeder implements CommandLineRunner {
private final MenuRepository menuRepository;
private final MenuItemRepository menuItemRepository;
private final PageService pageService;
public MenuSeeder(MenuRepository menuRepository, MenuItemRepository menuItemRepository, PageService pageService) {
this.menuRepository = menuRepository;
this.menuItemRepository = menuItemRepository;
this.pageService = pageService;
}
@Override
@org.springframework.transaction.annotation.Transactional
public void run(String... args) {
Menu primaryMenu = menuRepository.findAll().stream()
.filter(m -> m.getName() != null && m.getName().trim().equals("primaryMenu"))
.findFirst().orElse(null);
if (primaryMenu == null) return;
MenuItem chuyenKhoa = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
.filter(i -> i.getLabel() != null && "CHUYÊN KHOA".equalsIgnoreCase(i.getLabel().trim()))
.findFirst().orElse(null);
if (chuyenKhoa == null) return;
List<Page> specialties = pageService.findAll().stream()
.filter(p -> PageLayout.SPECIALTY_DETAIL.equals(p.getLayout()))
.collect(Collectors.toList());
int maxOrder = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
.filter(i -> chuyenKhoa.equals(i.getParent()))
.mapToInt(i -> i.getDisplayOrder() != null ? i.getDisplayOrder() : 0)
.max().orElse(0);
for (Page spec : specialties) {
boolean exists = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
.filter(i -> chuyenKhoa.equals(i.getParent()))
.anyMatch(i -> (i.getLabel() != null && i.getLabel().equalsIgnoreCase(spec.getTitle())) ||
(i.getTitle() != null && i.getTitle().equalsIgnoreCase(spec.getTitle())));
if (!exists) {
maxOrder++;
MenuItem newItem = new MenuItem();
newItem.setMenu(primaryMenu);
newItem.setParent(chuyenKhoa);
newItem.setLabel(spec.getTitle()); // Set label
newItem.setTitle(spec.getTitle()); // Set title as well
newItem.setUrl("/chuyen-khoa/" + spec.getSlug());
newItem.setDisplayOrder(maxOrder);
menuItemRepository.save(newItem);
System.out.println("ADDED MENU ITEM: " + spec.getTitle());
}
}
}
}
@@ -55,7 +55,7 @@ public class SecurityConfiguration {
.requestMatchers(HttpMethod.GET, "/", "/about", "/flex-finish", "/tin-tuc", "/tin-tuc/**", "/lien-he",
"/manage/login", "/css/**", "/images/**", "/js/**", "/vendor/**", "/fonts/**", "/login-assets/**", "/UMass*/**", "/Undergraduate*/**",
"/favicon.ico", "/favicons/**", "/flex-finish/**", "/contact-us/**", "/uploads/**", "/upload/**", "/api/manage/snippets/**", "/page/**", "/news/article/**", "/post/**", "/error",
"/about-us", "/specialty", "/doctor", "/bac-si/**", "/service", "/health-library", "/news-and-events", "/patient-support", "/medical-expert", "/umcers", "/bidding", "/contact-us", "/theme-assets/**", "/dao-tao/**",
"/about-us", "/specialty", "/doctor", "/bac-si/**", "/service", "/health-library", "/news-and-events", "/patient-support", "/medical-expert", "/umcers", "/bidding", "/contact-us", "/theme-assets/**", "/dao-tao/**", "/chuyen-khoa", "/chuyen-khoa/**",
"/Đào tạo tại UMC_files/**")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/manage/media/upload").permitAll()
@@ -0,0 +1,19 @@
package com.sisvietnamvn.web.controller;
import com.sisvietnamvn.web.repository.PageRepository;
import com.sisvietnamvn.web.domain.Page;
import org.springframework.web.bind.annotation.*;
import org.springframework.beans.factory.annotation.Autowired;
@RestController
public class DebugController {
@Autowired
private PageRepository pageRepository;
@GetMapping("/api/debug-page")
public String debugPage(@RequestParam String slug) {
Page page = pageRepository.findBySlug(slug).orElse(null);
if (page == null) return "PAGE NOT FOUND";
String content = page.getContent();
return "CONTENT_LENGTH: " + (content == null ? "NULL" : content.length()) + "\nCONTENT_PREVIEW: " + (content == null ? "NULL" : content);
}
}
@@ -132,6 +132,44 @@ public class PageController {
@GetMapping("/specialty")
public String getSpecialty(Model model) { return renderPage(pageService.findByPageType(com.sisvietnamvn.web.domain.PageType.SPECIALTY), model); }
@GetMapping("/chuyen-khoa")
public String getSpecialtyList(@org.springframework.web.bind.annotation.RequestParam(required = false) String category, Model model) {
List<Page> allSpecialties = pageService.findAll().stream()
.filter(p -> com.sisvietnamvn.web.domain.PageLayout.SPECIALTY_DETAIL.equals(p.getLayout()))
.filter(p -> com.sisvietnamvn.web.domain.PageStatus.PUBLISHED.equals(p.getStatus()) || com.sisvietnamvn.web.domain.PageStatus.DRAFT.equals(p.getStatus()))
.sorted((a, b) -> {
String ta = a.getTitle() != null ? a.getTitle() : "";
String tb = b.getTitle() != null ? b.getTitle() : "";
return ta.compareToIgnoreCase(tb);
})
.collect(java.util.stream.Collectors.toList());
java.util.function.Predicate<Page> catFilter = p -> true;
if ("khoa-lam-sang".equals(category)) {
catFilter = p -> "KHOA_LAM_SANG".equals(p.getSpecialtyCategory());
} else if ("khoa-can-lam-sang".equals(category)) {
catFilter = p -> "KHOA_CAN_LAM_SANG".equals(p.getSpecialtyCategory());
} else if ("khoa-ho-tro-lam-sang".equals(category)) {
catFilter = p -> "KHOA_HO_TRO_LAM_SANG".equals(p.getSpecialtyCategory());
}
List<Page> filtered = allSpecialties.stream().filter(catFilter).collect(java.util.stream.Collectors.toList());
model.addAttribute("specialties", filtered);
model.addAttribute("currentCategory", category != null ? category : "all");
model.addAttribute("countAll", allSpecialties.size());
model.addAttribute("countLamSang", allSpecialties.stream().filter(p -> "KHOA_LAM_SANG".equals(p.getSpecialtyCategory())).count());
model.addAttribute("countCanLamSang", allSpecialties.stream().filter(p -> "KHOA_CAN_LAM_SANG".equals(p.getSpecialtyCategory())).count());
model.addAttribute("countHoTro", allSpecialties.stream().filter(p -> "KHOA_HO_TRO_LAM_SANG".equals(p.getSpecialtyCategory())).count());
return "pages/specialty-list";
}
@GetMapping("/chuyen-khoa/{slug}")
public String getSpecialtyDetail(@PathVariable String slug, Model model) {
model.addAttribute("activeTheme", "umass"); // Consistent with other routes
return renderPage(pageService.findBySlug(slug), model);
}
@org.springframework.beans.factory.annotation.Autowired
private com.sisvietnamvn.web.repository.TagRepository tagRepository;
@org.springframework.beans.factory.annotation.Autowired
@@ -174,6 +212,54 @@ public class PageController {
return "Seeded " + items.size() + " posts!";
}
@org.springframework.beans.factory.annotation.Autowired
private com.sisvietnamvn.web.repository.MenuItemRepository menuItemRepository;
@org.springframework.beans.factory.annotation.Autowired
private com.sisvietnamvn.web.repository.MenuRepository menuRepository;
@GetMapping("/run-add-specialties")
@org.springframework.web.bind.annotation.ResponseBody
@org.springframework.transaction.annotation.Transactional
public String addSpecialtiesToMenu() {
com.sisvietnamvn.web.domain.Menu primaryMenu = menuRepository.findByName("primaryMenu").orElse(null);
if (primaryMenu == null) return "Primary menu not found";
com.sisvietnamvn.web.domain.MenuItem chuyenKhoa = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
.filter(i -> "CHUYÊN KHOA".equalsIgnoreCase(i.getTitle()))
.findFirst().orElse(null);
if (chuyenKhoa == null) return "CHUYÊN KHOA menu item not found";
List<com.sisvietnamvn.web.domain.Page> specialties = pageService.findAll().stream()
.filter(p -> com.sisvietnamvn.web.domain.PageLayout.SPECIALTY_DETAIL.equals(p.getLayout()))
.collect(java.util.stream.Collectors.toList());
int maxOrder = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
.filter(i -> chuyenKhoa.equals(i.getParent()))
.mapToInt(i -> i.getDisplayOrder() != null ? i.getDisplayOrder() : 0)
.max().orElse(0);
int count = 0;
for (com.sisvietnamvn.web.domain.Page spec : specialties) {
boolean exists = menuItemRepository.findByMenu_Id(primaryMenu.getId()).stream()
.filter(i -> chuyenKhoa.equals(i.getParent()))
.anyMatch(i -> i.getTitle().equalsIgnoreCase(spec.getTitle()));
if (!exists) {
maxOrder++;
com.sisvietnamvn.web.domain.MenuItem newItem = new com.sisvietnamvn.web.domain.MenuItem();
newItem.setMenu(primaryMenu);
newItem.setParent(chuyenKhoa);
newItem.setTitle(spec.getTitle());
newItem.setUrl("/chuyen-khoa/" + spec.getSlug());
newItem.setDisplayOrder(maxOrder);
menuItemRepository.save(newItem);
count++;
}
}
return "Added " + count + " specialties to CHUYÊN KHOA menu.";
}
@GetMapping("/doctor")
@org.springframework.transaction.annotation.Transactional(readOnly = true)
public String getDoctor(Model model) {
@@ -273,24 +359,42 @@ public class PageController {
}
// Parse Editor.js content JSON to extract blocks
List<Map<String, Object>> blocks = Collections.emptyList();
List<Map<String, Object>> blocks = new java.util.ArrayList<>();
if (page.getContent() != null && !page.getContent().trim().isEmpty()) {
try {
Map<String, Object> editorData = objectMapper.readValue(page.getContent(), new TypeReference<>() {});
if (editorData.containsKey("blocks")) {
blocks = (List<Map<String, Object>>) editorData.get("blocks");
for (Map<String, Object> block : blocks) {
if ("snippet".equals(block.get("type"))) {
Map<String, Object> data = (Map<String, Object>) block.get("data");
if (data != null && data.containsKey("id")) {
String snippetId = (String) data.get("id");
data.put("htmlContent", snippetService.getSnippetContent(snippetId));
String contentTrimmed = page.getContent().trim();
if (contentTrimmed.startsWith("{")) {
try {
Map<String, Object> editorData = objectMapper.readValue(contentTrimmed, new TypeReference<>() {});
if (editorData.containsKey("blocks")) {
blocks = (List<Map<String, Object>>) editorData.get("blocks");
for (Map<String, Object> block : blocks) {
if ("snippet".equals(block.get("type"))) {
Map<String, Object> data = (Map<String, Object>) block.get("data");
if (data != null && data.containsKey("id")) {
String snippetId = (String) data.get("id");
data.put("htmlContent", snippetService.getSnippetContent(snippetId));
}
}
}
}
} catch (JsonProcessingException e) {
LOG.error("Failed to parse Editor.js JSON for page ID: {}", page.getId(), e);
// Fallback to raw HTML on error
Map<String, Object> rawBlock = new java.util.HashMap<>();
rawBlock.put("type", "raw");
Map<String, Object> data = new java.util.HashMap<>();
data.put("html", contentTrimmed);
rawBlock.put("data", data);
blocks.add(rawBlock);
}
} catch (JsonProcessingException e) {
LOG.error("Failed to parse Editor.js JSON for page ID: {}", page.getId(), e);
} else {
// Fallback to raw HTML
Map<String, Object> rawBlock = new java.util.HashMap<>();
rawBlock.put("type", "raw");
Map<String, Object> data = new java.util.HashMap<>();
data.put("html", contentTrimmed);
rawBlock.put("data", data);
blocks.add(rawBlock);
}
}
@@ -301,6 +405,10 @@ public class PageController {
return "pages/contact-us";
}
if (com.sisvietnamvn.web.domain.PageLayout.SPECIALTY_DETAIL.equals(page.getLayout())) {
return "pages/specialty-detail";
}
return "page";
}
@@ -58,6 +58,21 @@ public class Page extends AbstractAuditingEntity<Long> {
@Column(name = "layout", length = 20, nullable = false)
private PageLayout layout = PageLayout.STANDARD;
@Column(name = "contact_email", length = 255)
private String contactEmail;
@Column(name = "contact_address", length = 500)
private String contactAddress;
@Column(name = "contact_phone", length = 100)
private String contactPhone;
@Column(name = "hero_image", length = 1000)
private String heroImage;
@Column(name = "specialty_category", length = 50)
private String specialtyCategory;
// --- Getters and Setters ---
@Override
@@ -133,6 +148,46 @@ public class Page extends AbstractAuditingEntity<Long> {
this.layout = layout;
}
public String getContactEmail() {
return contactEmail;
}
public void setContactEmail(String contactEmail) {
this.contactEmail = contactEmail;
}
public String getContactAddress() {
return contactAddress;
}
public void setContactAddress(String contactAddress) {
this.contactAddress = contactAddress;
}
public String getContactPhone() {
return contactPhone;
}
public void setContactPhone(String contactPhone) {
this.contactPhone = contactPhone;
}
public String getHeroImage() {
return heroImage;
}
public void setHeroImage(String heroImage) {
this.heroImage = heroImage;
}
public String getSpecialtyCategory() {
return specialtyCategory;
}
public void setSpecialtyCategory(String specialtyCategory) {
this.specialtyCategory = specialtyCategory;
}
// --- equals, hashCode, toString ---
@Override
@@ -6,5 +6,6 @@ package com.sisvietnamvn.web.domain;
public enum PageLayout {
STANDARD,
SIDEBAR,
FULL_WIDTH
FULL_WIDTH,
SPECIALTY_DETAIL
}
@@ -261,7 +261,7 @@ public class SwiperSliderPlugin {
return "<div class=\"swiper-slide max-w-[789px] !mr-2\" style=\"width: 789px; max-width: 100%;\"><div class=\"relative rounded-lg aspect-[789/460] overflow-hidden\"><img src=\"{{imageUrl}}\" alt=\"{{title}}\" class=\"absolute inset-0 w-full h-full object-cover\"></div></div>";
}
if ("course-card".equals(slug)) {
return "<div class=\"swiper-slide\" style=\"width: 261px; max-width: 100%;\"><article class=\"relative p-3 lg:p-4 bg-white rounded-lg border border-gray-100 group\" style=\"display: flex; flex-direction: column; gap: 14px;\"><a class=\"relative overflow-hidden rounded block\" style=\"aspect-ratio: 9/5;\" href=\"{{linkUrl}}\"><img alt=\"{{title}}\" class=\"object-cover h-full w-full\" src=\"{{imageUrl}}\"/><div class=\"absolute inset-0 w-full h-full bg-black/40 opacity-0 lg:group-hover:opacity-100 duration-300 ease-in-out\"></div></a><div class=\"px-1.5 w-full\" style=\"display: flex; flex-direction: column; gap: 14px;\"><div class=\"w-full space-y-1\"><a class=\"title-1 font-bold line-clamp-2 lg:group-hover:text-primary-600 duration-300 ease-in-out block\" style=\"font-size: 18px; height: 54px; line-height: 1.5;\" href=\"{{linkUrl}}\">{{title}}</a><div class=\"body-3 text-gray-700 line-clamp-3\" style=\"font-size: 14px; height: 64px; line-height: 1.5;\">{{description}}</div></div><div class=\"w-full h-px bg-gray-100\"></div><div class=\"flex justify-between items-center w-full\"><div class=\"flex gap-4 items-center self-stretch my-auto\"><div class=\"flex gap-1.5 items-center self-stretch my-auto\"><svg class=\"lucide lucide-calendar size-3.5\" fill=\"none\" height=\"14\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" viewBox=\"0 0 24 24\" width=\"14\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M8 2v4\"></path><path d=\"M16 2v4\"></path><rect height=\"18\" rx=\"2\" width=\"18\" x=\"3\" y=\"4\"></rect><path d=\"M3 10h18\"></path></svg><time class=\"self-stretch body-3 my-auto\" style=\"font-size: 14px;\">{{date}}</time></div><span class=\"text-primary-600 font-bold border-b-2 border-primary-600\" style=\"font-size: 14px;\">{{badge}}</span></div></div></div><a class=\"p-2 lg:p-3 w-full text-white bg-primary-600 rounded lg:hover:bg-primary-300 duration-300 ease-in-out block mt-2\" href=\"{{linkUrl}}\"><div class=\"flex items-center gap-0.5 justify-center w-full font-bold\" style=\"font-size: 18px;\"><span>{{price}}</span></div></a></article></div>";
return "<div class=\"swiper-slide\" style=\"width: 261px; max-width: 100%;\"><article class=\"relative p-3 lg:p-4 bg-white rounded-lg border border-gray-100 group\" style=\"display: flex; flex-direction: column; gap: 14px; text-align: left;\"><a class=\"relative overflow-hidden rounded block\" style=\"aspect-ratio: 9/5;\" href=\"{{linkUrl}}\"><img alt=\"{{title}}\" class=\"object-cover h-full w-full\" src=\"{{imageUrl}}\"/><div class=\"absolute inset-0 w-full h-full bg-black/40 opacity-0 lg:group-hover:opacity-100 duration-300 ease-in-out\"></div></a><div class=\"px-1.5 w-full\" style=\"display: flex; flex-direction: column; gap: 14px;\"><div class=\"w-full space-y-1\"><a class=\"title-1 font-bold line-clamp-2 lg:group-hover:text-primary-600 duration-300 ease-in-out block\" style=\"font-size: 20px; height: 54px; line-height: 1.5;\" href=\"{{linkUrl}}\">{{title}}</a><div class=\"body-3 text-gray-700 line-clamp-3\" style=\"font-size: 14px; height: 64px; line-height: 1.5; overflow: hidden;\">{{description}}</div></div><div class=\"w-full h-px bg-gray-100\"></div><div class=\"flex justify-between items-center w-full\"><div class=\"flex gap-4 items-center self-stretch my-auto\"><div class=\"flex gap-1.5 items-center self-stretch my-auto\"><svg class=\"lucide lucide-calendar size-3.5\" fill=\"none\" height=\"14\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" viewBox=\"0 0 24 24\" width=\"14\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M8 2v4\"></path><path d=\"M16 2v4\"></path><rect height=\"18\" rx=\"2\" width=\"18\" x=\"3\" y=\"4\"></rect><path d=\"M3 10h18\"></path></svg><time class=\"self-stretch body-3 my-auto\" style=\"font-size: 14px;\">{{date}}</time></div><span class=\"text-primary-600 font-bold border-b-2 border-primary-600\" style=\"font-size: 14px;\">({{badge}})</span></div></div></div><div class=\"btn\"><a class=\"p-2 lg:p-3 w-full text-white bg-primary-600 rounded lg:hover:bg-primary-300 duration-300 ease-in-out block mt-2\" href=\"{{linkUrl}}\"><div class=\"flex items-center gap-0.5 justify-center w-full font-bold\" style=\"font-size: 18px;\"><span>{{price}}</span></div></a></div></article></div>";
}
return "<div class=\"swiper-slide\"><div class=\"relative rounded-lg aspect-[3/2] overflow-hidden\"><img src=\"{{imageUrl}}\" alt=\"{{title}}\" class=\"absolute inset-0 w-full h-full object-cover\"></div></div>";
}
@@ -9,4 +9,5 @@ import org.springframework.stereotype.Repository;
*/
@Repository
public interface MenuItemRepository extends JpaRepository<MenuItem, Long> {
java.util.List<MenuItem> findByMenu_Id(Long menuId);
}
@@ -21,4 +21,7 @@ public interface MenuRepository extends JpaRepository<Menu, Long> {
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"items", "items.children"})
Optional<Menu> findByLocation(String location);
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"items", "items.children"})
Optional<Menu> findByName(String name);
}
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="20260728140500-1" author="system">
<addColumn tableName="sis_page">
<column name="contact_email" type="varchar(255)"/>
<column name="contact_address" type="varchar(500)"/>
<column name="contact_phone" type="varchar(100)"/>
<column name="hero_image" type="varchar(1000)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="20260728160000-1" author="system">
<addColumn tableName="sis_page">
<column name="specialty_category" type="varchar(50)"/>
</addColumn>
</changeSet>
<changeSet id="20260728160000-2" author="system">
<!-- Khoa lâm sàng -->
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'khoa-cap-cuu'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'khoa-than-kinh-dot-quy'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'tim-mach'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'khoa-ngoai-tong-hop'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'khoa-kham-benh'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'phau-thuat-gay-me-hoi-suc'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'don-vi-can-thiep-mach-dsa'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'don-vi-cap-cuu-ngoai-vien'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_LAM_SANG"/><where>slug = 'chuyen-khoa-tai-mui-hong'</where></update>
<!-- Khoa cận lâm sàng -->
<update tableName="sis_page"><column name="specialty_category" value="KHOA_CAN_LAM_SANG"/><where>slug = 'khoa-chan-doan-hinh-anh'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_CAN_LAM_SANG"/><where>slug = 'khoa-xet-nghiem'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_CAN_LAM_SANG"/><where>slug = 'khoa-vat-ly-tri-lieu-phuc-hoi-chuc-nang'</where></update>
<!-- Khoa hỗ trợ lâm sàng -->
<update tableName="sis_page"><column name="specialty_category" value="KHOA_HO_TRO_LAM_SANG"/><where>slug = 'khoa-duoc'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_HO_TRO_LAM_SANG"/><where>slug = 'khoa-dinh-duong-tiet-che'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_HO_TRO_LAM_SANG"/><where>slug = 'don-vi-kiem-soat-nhiem-khuan'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_HO_TRO_LAM_SANG"/><where>slug = 'don-vi-kham-suc-khoe-ngoai-vien'</where></update>
<update tableName="sis_page"><column name="specialty_category" value="KHOA_HO_TRO_LAM_SANG"/><where>slug = 'phong-quan-ly-van-hanh'</where></update>
</changeSet>
</databaseChangeLog>
@@ -50,4 +50,6 @@
<include file="config/liquibase/changelog/20260723185500_add_doctor_schedule.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260724150000_alter_setting_value.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260724151500_alter_setting_value_to_clob.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260728140500_add_contact_fields_to_page.xml" relativeToChangelogFile="false"/>
<include file="config/liquibase/changelog/20260728160000_add_specialty_category.xml" relativeToChangelogFile="false"/>
</databaseChangeLog>
@@ -1213,12 +1213,19 @@ figure.table table tr:hover {
}
.education {
.gap-3 {
gap: 0.75rem;
}
.flex {
display: flex !important;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.rounded-full {
justify-content: center;
background-color: var(--color-white);
@@ -1227,10 +1234,27 @@ figure.table table tr:hover {
height: 32px;
align-items: center;
}
.btn-navigation {
border-radius: 24px;
cursor: pointer;
background-color: var(--color-white);
color: var(--color-black);
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.15);
position: relative;
display: flex;
justify-content: center;
align-items: center;
svg {
color: var(--color-old-brick);
}
svg:hover{
background-color: unset;
}
}
.training-sidebar {
.gap-3 {
gap: 0.75rem;
}
.text-white {
margin-bottom: 4px;
@@ -1245,4 +1269,21 @@ figure.table table tr:hover {
}
}
}
.swiper-slide {
border: solid 1px #efeff0;
border-radius: 15px;
padding: 1rem;
.btn {
padding: 0.75rem;
border-radius: 15px;
}
.flex.items-center {
justify-content: center;
}
a {
text-decoration-line: none;
}
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
:root{--swiper-navigation-size:44px}.swiper-button-next,.swiper-button-prev{position:absolute;top:var(--swiper-navigation-top-offset,50%);width:calc(var(--swiper-navigation-size) / 44 * 27);height:var(--swiper-navigation-size);margin-top:calc(0px - (var(--swiper-navigation-size) / 2));z-index:10;cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--swiper-navigation-color,var(--swiper-theme-color))}.swiper-button-next.swiper-button-disabled,.swiper-button-prev.swiper-button-disabled{opacity:.35;cursor:auto;pointer-events:none}.swiper-button-next.swiper-button-hidden,.swiper-button-prev.swiper-button-hidden{opacity:0;cursor:auto;pointer-events:none}.swiper-navigation-disabled .swiper-button-next,.swiper-navigation-disabled .swiper-button-prev{display:none!important}.swiper-button-next svg,.swiper-button-prev svg{width:100%;height:100%;object-fit:contain;transform-origin:center}.swiper-rtl .swiper-button-next svg,.swiper-rtl .swiper-button-prev svg{transform:rotate(180deg)}.swiper-button-prev,.swiper-rtl .swiper-button-next{left:var(--swiper-navigation-sides-offset,10px);right:auto}.swiper-button-lock{display:none}.swiper-button-next:after,.swiper-button-prev:after{font-family:swiper-icons;font-size:var(--swiper-navigation-size);text-transform:none!important;letter-spacing:0;font-variant:normal;line-height:1}.swiper-button-prev:after,.swiper-rtl .swiper-button-next:after{content:"prev"}.swiper-button-next,.swiper-rtl .swiper-button-prev{right:var(--swiper-navigation-sides-offset,10px);left:auto}.swiper-button-next:after,.swiper-rtl .swiper-button-prev:after{content:"next"}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
/* PLEASE DO NOT COPY AND PASTE THIS CODE. */(function(){var w=window,C='___grecaptcha_cfg',cfg=w[C]=w[C]||{},N='grecaptcha';var gr=w[N]=w[N]||{};gr.ready=gr.ready||function(f){(cfg['fns']=cfg['fns']||[]).push(f);};w['__recaptcha_api']='https://www.google.com/recaptcha/api2/';(cfg['render']=cfg['render']||[]).push('onload');(cfg['anchor-ms']=cfg['anchor-ms']||[]).push(20000);(cfg['execute-ms']=cfg['execute-ms']||[]).push(30000);w['__google_recaptcha_client']=true;var d=document,po=d.createElement('script');po.type='text/javascript';po.async=true; po.charset='utf-8';po.src='https://www.gstatic.com/recaptcha/releases/A7KpaEASfhDcK0nXxgQEyyYv/recaptcha__en_gb.js';po.crossOrigin='anonymous';po.integrity='sha384-5OOK2erh/YOEG9kGHGnFP3+VW3JJ6xFMntvj2Hukmf0CErQumRfz6RUGAs6A/57r';var e=d.querySelector('script[nonce]'),n=e&&(e['nonce']||e.getAttribute('nonce'));if(n){po.setAttribute('nonce',n);}var s=d.getElementsByTagName('script')[0];s.parentNode.insertBefore(po, s);})();
@@ -0,0 +1 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[7358],{24473:(e,s,n)=>{Promise.resolve().then(n.t.bind(n,14933,23)),Promise.resolve().then(n.t.bind(n,86695,23)),Promise.resolve().then(n.t.bind(n,54775,23)),Promise.resolve().then(n.t.bind(n,22908,23)),Promise.resolve().then(n.t.bind(n,23624,23)),Promise.resolve().then(n.t.bind(n,59440,23)),Promise.resolve().then(n.t.bind(n,62920,23)),Promise.resolve().then(n.t.bind(n,19710,23))}},e=>{var s=s=>e(e.s=s);e.O(0,[1032,4619],()=>(s(36596),s(24473))),_N_E=e.O()}]);
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
!function(r,i){"use strict";var e,o=r.location,s=r.document,t=s.querySelector('[src*="'+i+'"]'),l=t&&t.getAttribute("data-domain"),p=r.localStorage.plausible_ignore;function c(e){console.warn("Ignoring Event: "+e)}function a(e,t){if(/^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$/.test(o.hostname)||"file:"===o.protocol)return c("localhost");if(!(r.phantom||r._phantom||r.__nightmare||r.navigator.webdriver||r.Cypress)){if("true"==p)return c("localStorage flag");var a={};a.n=e,a.u=o.href,a.d=l,a.r=s.referrer||null,a.w=r.innerWidth,t&&t.meta&&(a.m=JSON.stringify(t.meta)),t&&t.props&&(a.p=JSON.stringify(t.props));var n=new XMLHttpRequest;n.open("POST",i+"/api/event",!0),n.setRequestHeader("Content-Type","text/plain"),n.send(JSON.stringify(a)),n.onreadystatechange=function(){4==n.readyState&&t&&t.callback&&t.callback()}}}function n(){e!==o.pathname&&(e=o.pathname,a("pageview"))}try{var u,h=r.history;h.pushState&&(u=h.pushState,h.pushState=function(){u.apply(this,arguments),n()},r.addEventListener("popstate",n));var g=r.plausible&&r.plausible.q||[];r.plausible=a;for(var f=0;f<g.length;f++)a.apply(this,g[f]);"prerender"===s.visibilityState?s.addEventListener("visibilitychange",function(){e||"visible"!==s.visibilityState||n()}):n()}catch(e){console.error(e),(new Image).src=i+"/api/error?message="+encodeURIComponent(e.message)}}(window,"https://analytics.jamstackvietnam.com");
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -186,22 +186,20 @@
cursor: auto;
pointer-events: none;
}
[class*='btn-navigation-']:hover:not([disabled]):not(.swiper-button-disabled):not(.swiper-button-lock) {
background-color: var(--color-primary-600);
color: white;
}
</style>
<!-- Related Courses (Chương trình khác) -->
<section class="py-6 xl:py-12 md:py-8 bg-white">
<div class="container mx-auto px-4">
<div class="container mx-auto px-4" style="text-align: center">
<h2 class="display-7 text-primary-600 text-center xl:mb-8 md:mb-6 mb-4">Chương trình khác</h2>
<div class="relative md:flex md:items-center">
<div class="relative flex items-center gap-3">
<button
class="btn-navigation flex-shrink-0 lg:mr-4 mr-2 !relative z-10 md:size-[42px] size-[32px] items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group btn-navigation-chuong-trinh-khac-prev md:!flex hidden"
class="btn-navigation flex-shrink-0 lg:mr-4 mr-2 z-10 flex items-center justify-center btn-navigation-chuong-trinh-khac-prev"
style="width: 42px; height: 42px; min-width: 42px; flex-shrink: 0"
>
<svg
class="lucide lucide-chevron-up size-4 -rotate-90"
class="lucide lucide-chevron-up size-4"
style="transform: rotate(-90deg)"
fill="none"
height="24"
stroke="currentColor"
@@ -215,19 +213,19 @@
<path d="m18 15-6-6-6 6"></path>
</svg>
</button>
<div
class="swiper swiper-chuong-trinh-khac [&>.swiper-pagination]:!static [&>.swiper-pagination]:mt-1 lg:[&>.swiper-pagination]:!hidden w-full"
>
<div class="swiper swiper-chuong-trinh-khac w-full" style="overflow: hidden">
<div class="swiper-wrapper">
<th:block th:utext="${hookManager.applyFilters('swiper_slider_items', '', 'chuong-trinh-khac')}"></th:block>
</div>
<div class="swiper-pagination"></div>
<div class="swiper-pagination hidden"></div>
</div>
<button
class="btn-navigation flex-shrink-0 lg:ml-4 ml-2 !relative z-10 md:size-[42px] size-[32px] items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group btn-navigation-chuong-trinh-khac-next md:!flex hidden"
class="btn-navigation flex-shrink-0 lg:ml-4 ml-2 z-10 flex items-center justify-center btn-navigation-chuong-trinh-khac-next"
style="width: 42px; height: 42px; min-width: 42px; flex-shrink: 0"
>
<svg
class="lucide lucide-chevron-down size-4 -rotate-90"
class="lucide lucide-chevron-down size-4"
style="transform: rotate(-90deg)"
fill="none"
height="24"
stroke="currentColor"
@@ -180,6 +180,42 @@
title.</small>
</div>
<!-- Specialty/Contact Info -->
<div class="card bg-light mb-4">
<div class="card-body">
<h6 class="font-weight-bold text-primary mb-3">Thông tin liên hệ & Banner (Dành riêng cho trang Chuyên khoa)</h6>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="contactEmail" class="font-weight-bold">Email</label>
<input type="text" class="form-control" id="contactEmail" th:field="*{contactEmail}" placeholder="vd: capcuu@umc.edu.vn">
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="contactPhone" class="font-weight-bold">Điện thoại / SĐT</label>
<input type="text" class="form-control" id="contactPhone" th:field="*{contactPhone}" placeholder="vd: 028 3952 5115">
</div>
</div>
</div>
<div class="form-group">
<label for="contactAddress" class="font-weight-bold">Địa chỉ</label>
<input type="text" class="form-control" id="contactAddress" th:field="*{contactAddress}" placeholder="vd: Tầng trệt - Khu A">
</div>
<div class="form-group">
<label for="heroImage" class="font-weight-bold">Ảnh Banner (Hero Image URL)</label>
<div class="input-group">
<input type="text" class="form-control" id="heroImage" th:field="*{heroImage}" placeholder="URL ảnh">
<div class="input-group-append">
<button type="button" class="btn btn-outline-secondary" onclick="document.getElementById('mediaManagerBtn').click()">
<i class="fas fa-image"></i> Chọn ảnh
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Block Editor Content -->
<div class="form-group">
<div class="d-flex justify-content-between align-items-center mb-2">
@@ -249,6 +285,7 @@
<script src="https://cdn.jsdelivr.net/npm/@editorjs/underline@1.1.0/dist/bundle.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/image@2.9.0/dist/image.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/attaches@1.3.0/dist/bundle.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@editorjs/raw@2.4.3/dist/bundle.js"></script>
<!--
============================================================
File diff suppressed because one or more lines are too long
@@ -0,0 +1,302 @@
<!doctype html>
<html
lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{themes/__${activeTheme}__/layout(forceFullWidth=true, bodyClass='specialty-list-page')}"
>
<head>
<title>Chuyên khoa - Bệnh viện S.I.S Cần Thơ</title>
<meta name="description" content="Danh sách các chuyên khoa tại Bệnh viện Đa khoa Quốc tế S.I.S Cần Thơ" />
<link rel="stylesheet" th:href="@{/theme-assets/bvdhyd/css/b6b2bf2d3af810a6.css}" />
<link rel="stylesheet" th:href="@{/theme-assets/bvdhyd/css/3cd83cfe34ca397f.css}" />
<link rel="stylesheet" th:href="@{/theme-assets/bvdhyd/css/45d4f6442d75f756.css}" />
<link rel="stylesheet" th:href="@{/theme-assets/bvdhyd/css/c37340727ca4fe15.css}" />
<style>
.specialty-card {
background: white;
border-radius: 0.75rem;
padding: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.1),
0 1px 2px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
text-decoration: none;
color: inherit;
position: relative;
overflow: hidden;
}
.specialty-card:hover {
background: var(--color-old-brick);
color: white;
transform: translateY(-4px);
box-shadow: 0 10px 25px rgba(0, 84, 166, 0.25);
}
.specialty-card:hover .specialty-icon {
background: rgba(255, 255, 255, 0.2);
}
.specialty-card:hover .specialty-icon svg {
color: white;
}
.specialty-card:hover .specialty-name {
color: white;
}
.specialty-icon {
width: 72px;
height: 72px;
border-radius: 50%;
background: #f0f7ff;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
transition: all 0.3s ease;
}
.specialty-icon svg {
width: 32px;
height: 32px;
color: var(--color-old-brick);
transition: color 0.3s ease;
}
.specialty-name {
font-weight: 600;
font-size: 0.9rem;
line-height: 1.4;
color: var(--color-old-brick);
transition: color 0.3s ease;
}
.filter-btn {
padding: 0.5rem 1.25rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
border: 1px solid #e5e7eb;
background: white;
color: #374151;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.filter-btn:hover {
border-color: var(--color-primary-600, #0054a6);
color: var(--color-primary-600, #0054a6);
}
.filter-btn.active {
background: var(--color-primary-600, #0054a6);
color: white;
border-color: var(--color-primary-600, #0054a6);
}
.filter-btn .count {
background: rgba(0, 0, 0, 0.1);
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
}
.filter-btn.active .count {
background: rgba(255, 255, 255, 0.25);
}
.section-label {
font-size: 1.125rem;
font-weight: 700;
color: var(--color-primary-600, #0054a6);
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--color-primary-600, #0054a6);
display: inline-block;
}
@media (max-width: 768px) {
.specialty-card {
padding: 1rem;
}
.specialty-icon {
width: 56px;
height: 56px;
}
.specialty-icon svg {
width: 24px;
height: 24px;
}
.specialty-name {
font-size: 0.8rem;
}
}
</style>
</head>
<body>
<div layout:fragment="content">
<section class="xl:py-12 md:py-8 py-6" style="background: #f6f6f6">
<div class="container mx-auto px-4">
<!-- Page Title -->
<h1 class="display-4 text-primary-600 text-center xl:mb-8 md:mb-6 mb-4">Chuyên khoa</h1>
<!-- Filter Dropdown -->
<div
id="specialties_sel"
class="flex justify-center xl:mb-10 md:mb-7 mb-5 relative w-full md:max-w-[320px] mx-auto xl:mb-10 md:mb-7 mb-5 title-4"
>
<select
onchange="window.location.href = this.value"
class="w-full group bg-white rounded-lg border lg:hover:border-primary-600 cursor-pointer xl:px-6 px-4 py-3 flex items-center justify-between lg:duration-150 shadow border-gray-200"
>
<option th:value="@{/chuyen-khoa}" th:selected="${currentCategory == 'all'}">Tất cả ([[${countAll}]])</option>
<option th:value="@{/chuyen-khoa(category='khoa-lam-sang')}" th:selected="${currentCategory == 'khoa-lam-sang'}">
Khoa lâm sàng ([[${countLamSang}]])
</option>
<option th:value="@{/chuyen-khoa(category='khoa-can-lam-sang')}" th:selected="${currentCategory == 'khoa-can-lam-sang'}">
Khoa cận lâm sàng ([[${countCanLamSang}]])
</option>
<option
th:value="@{/chuyen-khoa(category='khoa-ho-tro-lam-sang')}"
th:selected="${currentCategory == 'khoa-ho-tro-lam-sang'}"
>
Khoa hỗ trợ lâm sàng ([[${countHoTro}]])
</option>
</select>
</div>
<!-- Specialty Grid -->
<div class="grid lg:grid-cols-4 md:grid-cols-3 grid-cols-2 xl:gap-8 md:gap-6 gap-4">
<a th:each="spec : ${specialties}" th:href="@{/chuyen-khoa/{slug}(slug=${spec.slug})}" class="specialty-card">
<div class="specialty-icon">
<th:block th:switch="${spec.slug}">
<!-- chuyen-khoa-tai-mui-hong -->
<th:block th:case="'chuyen-khoa-tai-mui-hong'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/Taimuihong-blue.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- don-vi-kiem-soat-nhiem-khuan -->
<th:block th:case="'don-vi-kiem-soat-nhiem-khuan'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/library-29.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- khoa-cap-cuu -->
<th:block th:case="'khoa-cap-cuu'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/library-37.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- khoa-chan-doan-hinh-anh -->
<th:block th:case="'khoa-chan-doan-hinh-anh'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/library-3.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- khoa-dinh-duong-tiet-che -->
<th:block th:case="'khoa-dinh-duong-tiet-che'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/library-9.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- khoa-duoc -->
<th:block th:case="'khoa-duoc'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/library-11.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- khoa-kham-benh -->
<th:block th:case="'khoa-kham-benh'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/library-27.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- khoa-than-kinh-dot-quy -->
<th:block th:case="'khoa-than-kinh-dot-quy'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/Thankinh-blue.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- khoa-vat-ly-tri-lieu-phuc-hoi-chuc-nang -->
<th:block th:case="'khoa-vat-ly-tri-lieu-phuc-hoi-chuc-nang'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/Phuchoichucnang-blue.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- khoa-xet-nghiem -->
<th:block th:case="'khoa-xet-nghiem'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/Xetnghiem-blue.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- phau-thuat-gay-me-hoi-suc -->
<th:block th:case="'phau-thuat-gay-me-hoi-suc'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/library-13.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- tim-mach -->
<th:block th:case="'tim-mach'">
<img
src="https://console.bvdaihoc.com.vn/uploads/chuyen-khoa/Timmachcanthiep-blue.png"
alt="Icon"
class="w-12 h-12 object-contain mx-auto"
/>
</th:block>
<!-- Default -->
<th:block th:case="*">
<div class="w-12 h-12 rounded-full bg-primary-100 flex items-center justify-center text-primary-600 mx-auto">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="w-6 h-6"
>
<path d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2zM9 14h6M12 11v6" />
</svg>
</div>
</th:block>
</th:block>
</div>
<span class="specialty-name" th:text="${spec.title}">Tên chuyên khoa</span>
</a>
</div>
<!-- Empty state -->
<div th:if="${#lists.isEmpty(specialties)}" class="text-center py-12">
<p class="text-gray-500 text-lg">Không tìm thấy chuyên khoa nào trong nhóm này.</p>
</div>
</div>
</section>
</div>
</body>
</html>
@@ -0,0 +1,153 @@
import oracledb
from bs4 import BeautifulSoup
import re
def transform_html(title, raw_html):
soup = BeautifulSoup(raw_html, "html.parser")
# Extract all images
images = []
for img in soup.find_all('img'):
src = img.get('src')
if src:
images.append(src)
img.decompose() # Remove from flow
hero_image = images[0] if len(images) > 0 else "https://sisvietnam.vn/wp-content/uploads/2021/07/DSC07517-1024x576.jpg"
other_images = images[1:]
# Extract Contact Info
text = soup.get_text()
email_match = re.search(r"(?i)Email\s*:\s*([\w\.-]+@[\w\.-]+)", text)
phone_match = re.search(r"(?i)(?:SĐT|Điện thoại|ĐT)\s*:\s*([\d\.\s-]+)", text)
address_match = re.search(r"(?i)Địa chỉ\s*:\s*([^\n]+)", text)
contact_email = email_match.group(1).strip() if email_match else None
contact_phone = phone_match.group(1).strip() if phone_match else None
contact_address = address_match.group(1).strip() if address_match else None
# Parse sections based on wp:list or headings
sections_data = []
top_uls = soup.find_all('ul', recursive=False)
if not top_uls and soup.find('div', class_='wp-content-container'):
top_uls = soup.find('div', class_='wp-content-container').find_all('ul', recursive=False)
for ul in top_uls:
heading = "Nội dung"
strong_tag = ul.find('strong')
if strong_tag:
heading = strong_tag.text.strip()
strong_tag.decompose()
content_html = ""
for li in ul.find_all('li', recursive=False):
inner_uls = li.find_all('ul')
for inner in inner_uls:
content_html += str(inner)
if not content_html:
content_html = str(ul)
sections_data.append({
'heading': heading,
'content': content_html
})
if not sections_data:
sections_data.append({
'heading': 'Thông tin chi tiết',
'content': str(soup)
})
out = ""
bg_colors = ["bg-white", "bg-gray-50", "bg-white", "bg-gray-100"]
for i, sec in enumerate(sections_data):
bg = bg_colors[i % len(bg_colors)]
img_html = ""
if i < len(other_images):
img_src = other_images[i]
img_html = f"""
<div class="w-full">
<div class="rounded-2xl overflow-hidden shadow-lg aspect-[4/3] relative">
<img src="{img_src}" class="absolute inset-0 w-full h-full object-cover">
</div>
</div>
"""
out += f"""
<!-- SECTION {i+1} -->
<section class="xl:py-12 md:py-8 py-4 {bg}">
<div class="container mx-auto px-4">
<div class="flex flex-col gap-6 lg:items-center">
<div class="w-full">
<h2 class="display-6 text-primary-600 mb-4 md:mb-6 xl:mb-8">{sec['heading']}</h2>
<div class="prose max-w-none text-gray-700 body-2" style="list-style-type: disc;">
{sec['content']}
</div>
</div>
{img_html}
</div>
</div>
</section>
"""
return out, hero_image, contact_email, contact_address, contact_phone
def main():
dsn = "localhost:1521/sisvietnam"
conn = oracledb.connect(user="sisvietnam", password="sisvietnam", dsn=dsn)
cursor = conn.cursor()
cursor.execute("SELECT slug, title, content FROM sis_page WHERE page_type = 'CUSTOM'")
rows = cursor.fetchall()
count = 0
warnings = []
for row in rows:
slug = row[0]
title = row[1]
raw_content = row[2].read() if hasattr(row[2], "read") else row[2]
import json
if not raw_content:
print(f"Skipping {slug} as content is empty/None.")
continue
if raw_content.strip().startswith("{"):
try:
data = json.loads(raw_content)
html_parts = []
for b in data.get("blocks", []):
if b.get("type") == "raw" or b.get("type") == "snippet":
html_parts.append(b.get("data", {}).get("html", b.get("data", {}).get("htmlContent", "")))
raw_content = "".join(html_parts)
except Exception as e:
print(f"Failed to parse JSON for {slug}: {e}")
transformed_content, hero_image, email, address, phone = transform_html(title, raw_content)
if "Thông tin chi tiết" in transformed_content and "wp-content-container" not in transformed_content:
warnings.append(slug)
# Update DB
update_sql = """
UPDATE sis_page
SET content = :1, hero_image = :2, contact_email = :3, contact_address = :4, contact_phone = :5
WHERE slug = :6
"""
cursor.execute(update_sql, [transformed_content, hero_image, email, address, phone, slug])
count += 1
print(f"Updated {slug}")
conn.commit()
print(f"Successfully transformed {count} pages.")
if warnings:
print("\nWARNING: The following pages lacked standard WP lists (<ul>) and were migrated as a single flat section:")
for w in warnings:
print(f" - {w}")
if __name__ == "__main__":
main()