Files
OSIT-AE-App-Svelte/scripts/migrate_fa_to_lucide.py
Scott Idem b543c8a930 chore: migrate all FA icons to Lucide (@lucide/svelte)
- Replaced all active FontAwesome <span class="fas fa-*"> icons with
  Lucide components across 145 files (excluding /idaa/ which is intentional)
- Fixed merge script bug: consolidated lucide-svelte imports into @lucide/svelte
- Replaced dynamic toggle patterns (fa-toggle-on/off) with ToggleRight/ToggleLeft
- Replaced fa-eye/fa-eye-slash with Eye/EyeOff
- Replaced fa-bug/fa-bug-slash with Bug/BugOff
- Replaced fa-sync fa-spin with RefreshCw + animate-spin
- Replaced fa-microchip with Cpu
- Fixed {@const} placement in element_manage_event_file_li.svelte
- Removed obsolete CSS hover rules for .unlock_icon/.lock_icon
- svelte-check: 0 errors, 0 warnings
2026-03-16 18:07:43 -04:00

315 lines
12 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',
# ── Additional mappings ──────────────────────────────────────────────────
'fa-arrow-left': 'ArrowLeft',
'fa-arrow-right': 'ArrowRight',
'fa-arrow-up': 'ArrowUp',
'fa-arrow-down': 'ArrowDown',
'fa-ban': 'Ban',
'fa-broom': 'Trash2', # closest semantic match
'fa-calendar-alt': 'CalendarDays',
'fa-database': 'Database',
'fa-door-open': 'DoorOpen',
'fa-download': 'Download',
'fa-exchange-alt': 'ArrowLeftRight',
'fa-file-image': 'FileImage',
'fa-lock': 'Lock',
'fa-magic': 'Sparkles',
'fa-print': 'Printer',
'fa-sticky-note': 'StickyNote',
'fa-sync': 'RefreshCw',
'fa-tag': 'Tag',
'fa-trash': 'Trash2',
'fa-user-ninja': 'UserRound',
'fa-user-tie': 'UserRound',
'fa-video': 'Video',
'fa-archive': 'Archive',
'fa-link-slash': 'Unlink',
'fa-question-circle': 'HelpCircle',
# ── Additional unmapped icons ──────────────────────────────────────────────
'fa-compress-arrows-alt':'Minimize2',
'fa-expand-arrows-alt': 'Maximize2',
'fa-secret': 'ShieldCheck',
'fa-user-shield': 'ShieldUser',
'fa-user-nurse': 'UserRound',
'fa-user-friends': 'Users',
'fa-user-plus': 'UserPlus',
'fa-user-edit': 'UserRoundPen',
'fa-palette': 'Palette',
'fa-eraser': 'Eraser',
'fa-code': 'Code',
'fa-lock-open': 'LockOpen',
'fa-unlock': 'LockOpen',
'fa-trash-alt': 'Trash2',
'fa-folder-open': 'FolderOpen',
'fa-minus-circle': 'MinusCircle',
'fa-plus-circle': 'PlusCircle',
'fa-window-close': 'X',
'fa-cut': 'Scissors',
'fa-caret-down': 'ChevronDown',
'fa-caret-right': 'ChevronRight',
'fa-cogs': 'Settings2',
'fa-phone': 'Phone',
'fa-phone-slash': 'PhoneOff',
'fa-flag': 'Flag',
'fa-calendar-week': 'CalendarDays',
'fa-address-book': 'BookUser',
'fa-info-circle': 'Info',
'fa-comment-slash': 'MessageX',
'fa-paperclip': 'Paperclip',
'fa-keyboard': 'Keyboard',
'fa-crosshairs': 'Crosshair',
'fa-redo': 'RotateCcw',
'fa-tools': 'Wrench',
'fa-video-slash': 'VideoOff',
'fa-home': 'House',
'fa-calendar': 'Calendar',
'fa-check-square': 'SquareCheck',
'fa-square': 'Square',
'fa-times-circle': 'CircleX',
'fa-undo': 'RotateCcw',
'fa-trash-restore': 'ArchiveRestore',
'fa-lock-open': 'LockOpen',
'fa-compress': 'Minimize2',
'fa-expand': 'Maximize2',
'fa-grip-lines': 'GripHorizontal',
'fa-bars': 'Menu',
'fa-refresh': 'RefreshCw',
}
# 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()