Files
OSIT-AE-App-Svelte/scripts/migrate_fa_to_lucide.py

321 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
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."""
"""
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)
# IDAA Guardrail: Abort if any target is src/routes/idaa or a subdirectory
for arg in sys.argv[1:]:
if Path(arg).resolve().as_posix().endswith('src/routes/idaa') or '/src/routes/idaa/' in Path(arg).resolve().as_posix():
print('ABORT: This script must not be run against src/routes/idaa or its subdirectories. IDAA content is private and protected.')
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()