Files
OSIT-AE-App-Svelte/scripts/migrate_fa_to_lucide.py
Scott Idem 8db806c6ab style(pres_mgmt): Phase 3 — FA→Lucide icon migration across all 24 pres_mgmt files
Used scripts/migrate_fa_to_lucide.py to batch-replace all FontAwesome
<span class="fas fa-*"> icons with Lucide SVG components across the
entire presentation management module (273 icon instances, 69 icon types).

Manual fixes applied post-script:
- presenter_view.svelte: remove duplicate @lucide/svelte Pencil import
- presenter_page_menu.svelte: remove duplicate X from @lucide/svelte import
- ae_comp__event_presenter_obj_tbl.svelte: fix Lucide import inserted
  inside multiline import block (script bug with multiline imports)
- ae_comp__event_session_alert.svelte: same multiline import fix
Script updated with fix: use complete-statement matching for import
insertion instead of single-line matching.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 14:01:56 -04:00

240 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
migrate_fa_to_lucide.py — Replace FontAwesome <span class="fas fa-X"> with Lucide components.
Usage:
python3 scripts/migrate_fa_to_lucide.py src/routes/events/[event_id]/\(pres_mgmt\)/
Skips content inside HTML comments. Adds/merges lucide-svelte imports.
"""
import re
import sys
import os
from pathlib import Path
# ── FA icon → Lucide component name ─────────────────────────────────────────
FA_TO_LUCIDE = {
'fa-spinner': 'LoaderCircle',
'fa-cog': 'LoaderCircle', # only when fa-spin
'fa-sync-alt': 'RefreshCw',
'fa-times': 'X',
'fa-exclamation-triangle': 'TriangleAlert',
'fa-check': 'Check',
'fa-check-circle': 'CheckCircle',
'fa-plus': 'Plus',
'fa-minus': 'Minus',
'fa-save': 'Save',
'fa-edit': 'Pencil',
'fa-eye': 'Eye',
'fa-eye-slash': 'EyeOff',
'fa-toggle-on': 'ToggleRight',
'fa-toggle-off': 'ToggleLeft',
'fa-star-of-life': 'Asterisk',
'fa-id-card': 'IdCard',
'fa-paper-plane': 'Send',
'fa-map-marker-alt': 'MapPin',
'fa-file-alt': 'FileText',
'fa-envelope': 'Mail',
'fa-book': 'BookOpen',
'fa-angle-right': 'ChevronRight',
'fa-user': 'User',
'fa-tasks': 'ListChecks',
'fa-plane': 'Plane',
'fa-list': 'List',
'fa-link': 'Link',
'fa-file-archive': 'Archive',
'fa-comment-dots': 'MessageCircle',
'fa-chevron-up': 'ChevronUp',
'fa-chevron-down': 'ChevronDown',
'fa-camera': 'Camera',
'fa-barcode': 'Barcode',
'fa-upload': 'Upload',
'fa-search': 'Search',
'fa-mail-bulk': 'Mails',
'fa-laptop-code': 'Laptop',
'fa-copy': 'Copy',
'fa-user-tag': 'Tag',
'fa-user-secret': 'UserRound',
'fa-users': 'Users',
'fa-user-circle': 'CircleUser',
'fa-sort': 'ArrowUpDown',
'fa-question': 'HelpCircle',
'fa-map-marked': 'MapPinned',
'fa-list-ol': 'ListOrdered',
'fa-laptop': 'Laptop',
'fa-info': 'Info',
'fa-building': 'Building2',
'fa-user-slash': 'UserX',
'fa-user-check': 'UserCheck',
'fa-unlink': 'Unlink',
'fa-star': 'Star',
'fa-search-location': 'MapPin',
'fa-remove-format': 'RemoveFormatting',
'fa-qrcode': 'QrCode',
'fa-key': 'Key',
'fa-heartbeat': 'HeartPulse',
'fa-hat-wizard': 'Wand2',
'fa-fingerprint': 'Fingerprint',
'fa-file-csv': 'FileSpreadsheet',
'fa-file': 'File',
'fa-clock': 'Clock',
'fa-clipboard-list': 'ClipboardList',
'fa-chart-line': 'TrendingUp',
'fa-chalkboard-teacher': 'Presentation',
'fa-calendar-day': 'CalendarDays',
'fa-bell-slash': 'BellOff',
'fa-bell': 'Bell',
}
# Skip modifiers — not real icon names
FA_MODIFIERS = {'fas', 'far', 'fab', 'fa-spin', 'fa-fw', 'fa-lg', 'fa-2x', 'fa-sm'}
# ── Pattern: <span class="fas fa-X [extras]" [other-attrs]></span> ───────────
# [^>]* matches newlines too (character class, not dot)
SPAN_RE = re.compile(
r'<span\s+class="((?:fas|far|fab)\s+fa-[^"]*)"[^>]*>\s*</span>'
)
# ── Comment splitter ─────────────────────────────────────────────────────────
COMMENT_RE = re.compile(r'(<!--[\s\S]*?-->)')
# ── Lucide import line ────────────────────────────────────────────────────────
IMPORT_RE = re.compile(r"import\s*\{([^}]+)\}\s*from\s*'lucide-svelte'\s*;?")
def parse_fa_class(class_str):
"""Return (icon_name, extra_classes, has_spin) from a FA class string."""
parts = class_str.split()
icon_name = None
has_spin = 'fa-spin' in parts
extra = []
for p in parts:
if p in FA_MODIFIERS:
continue
elif p.startswith('fa-'):
if icon_name is None:
icon_name = p # first real icon name wins
else:
extra.append(p)
return icon_name, extra, has_spin
def replace_span(m):
"""Regex sub callback: replace a single FA span with a Lucide component."""
icon_name, extra, has_spin = parse_fa_class(m.group(1))
if icon_name is None:
return m.group(0)
lucide = FA_TO_LUCIDE.get(icon_name)
if lucide is None:
print(f' ⚠ no mapping for {icon_name!r} — left as-is', file=sys.stderr)
return m.group(0)
classes = extra[:]
if has_spin:
classes.append('animate-spin')
class_attr = f' class="{" ".join(classes)}"' if classes else ''
return f'<{lucide} size="1em"{class_attr} />'
def process_content(content):
"""Replace FA spans, skip HTML comments. Return (new_content, used_icons)."""
used_icons = set()
def track_and_replace(m):
result = replace_span(m)
if result != m.group(0):
# Extract lucide name from result
lucide_name = result.split()[0].lstrip('<')
used_icons.add(lucide_name)
return result
# Split by comments; only process non-comment segments
parts = COMMENT_RE.split(content)
new_parts = []
for part in parts:
if part.startswith('<!--'):
new_parts.append(part)
else:
new_parts.append(SPAN_RE.sub(track_and_replace, part))
return ''.join(new_parts), used_icons
def add_import(content, icons):
"""Add/merge lucide-svelte import line inside <script>."""
if not icons:
return content
sorted_icons = sorted(icons)
existing = IMPORT_RE.search(content)
if existing:
current = [s.strip() for s in existing.group(1).split(',') if s.strip()]
merged = sorted(set(current) | set(sorted_icons))
new_import = f"import {{ {', '.join(merged)} }} from 'lucide-svelte';"
return content[:existing.start()] + new_import + content[existing.end():]
else:
# Insert after the last COMPLETE import statement (handles multiline imports).
# A complete import statement ends with: } from '...'; or import '...';
complete_import_re = re.compile(
r'^[ \t]*import\b[\s\S]*?(?:from\s*[\'"][^\'"]+[\'"]|[\'"][^\'"]+[\'"])\s*;?',
re.MULTILINE
)
all_imports = list(complete_import_re.finditer(content))
if all_imports:
pos = all_imports[-1].end()
new_line = f"\n import {{ {', '.join(sorted_icons)} }} from 'lucide-svelte';"
return content[:pos] + new_line + content[pos:]
# Fallback: add after <script> tag
script_tag = content.find('<script')
if script_tag != -1:
end = content.index('>', script_tag) + 1
return content[:end] + f"\n import {{ {', '.join(sorted_icons)} }} from 'lucide-svelte';" + content[end:]
return content
def migrate_file(filepath):
path = Path(filepath)
original = path.read_text()
new_content, used_icons = process_content(original)
if used_icons:
new_content = add_import(new_content, used_icons)
if new_content != original:
path.write_text(new_content)
print(f'{path.name} ({len(used_icons)} icon types: {", ".join(sorted(used_icons))})')
return True
else:
print(f' {path.name} (no changes)')
return False
def main():
if len(sys.argv) < 2:
print('Usage: migrate_fa_to_lucide.py <directory_or_file ...>')
sys.exit(1)
targets = []
for arg in sys.argv[1:]:
p = Path(arg)
if p.is_dir():
targets.extend(sorted(p.rglob('*.svelte')))
elif p.is_file():
targets.append(p)
else:
print(f'Not found: {arg}', file=sys.stderr)
changed = 0
for t in targets:
if migrate_file(t):
changed += 1
print(f'\n{changed}/{len(targets)} files modified.')
if __name__ == '__main__':
main()