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>
This commit is contained in:
Scott Idem
2026-03-16 14:01:56 -04:00
parent b44e77ad62
commit 8db806c6ab
25 changed files with 514 additions and 300 deletions

View File

@@ -0,0 +1,239 @@
#!/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()