Initial commit
This commit is contained in:
352
skills/storyboard-manager/scripts/timeline_tracker.py
Executable file
352
skills/storyboard-manager/scripts/timeline_tracker.py
Executable file
@@ -0,0 +1,352 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Timeline Tracker for Storyboard Manager
|
||||
|
||||
This script analyzes markdown files in a storyboard project to extract and organize
|
||||
timeline events, helping writers maintain chronological consistency.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class TimelineEvent:
|
||||
"""Represents a single event in the story timeline"""
|
||||
|
||||
def __init__(self, content: str, location: str, chapter: str = None,
|
||||
timepoint: str = None, characters: List[str] = None):
|
||||
self.content = content
|
||||
self.location = location # File path where event was found
|
||||
self.chapter = chapter
|
||||
self.timepoint = timepoint # Relative time (e.g., "Day 1", "3 weeks later")
|
||||
self.characters = characters or []
|
||||
|
||||
def __repr__(self):
|
||||
return f"TimelineEvent({self.timepoint}: {self.content[:50]}...)"
|
||||
|
||||
|
||||
class TimelineTracker:
|
||||
"""Main timeline tracking and analysis class"""
|
||||
|
||||
# Patterns to detect time markers in text
|
||||
TIME_PATTERNS = [
|
||||
r'(?:Day|Night)\s+(\d+)', # Day 1, Night 3
|
||||
r'(\d+)\s+(?:days?|weeks?|months?|years?)\s+(?:later|ago|after|before)',
|
||||
r'(?:Morning|Afternoon|Evening|Night)\s+of\s+(?:Day\s+)?(\d+)',
|
||||
r'Chapter\s+(\d+)', # Chapter markers
|
||||
r'\*\*(?:Timeline|Time|When):\*\*\s*(.+?)(?:\n|$)', # Explicit timeline markers
|
||||
r'\*\*Date:\*\*\s*(.+?)(?:\n|$)',
|
||||
]
|
||||
|
||||
# Patterns to detect character mentions
|
||||
CHARACTER_PATTERNS = [
|
||||
r'\*\*Characters?:\*\*\s*(.+?)(?:\n|$)',
|
||||
r'\*\*(?:POV|Perspective):\*\*\s*(.+?)(?:\n|$)',
|
||||
]
|
||||
|
||||
def __init__(self, project_root: str):
|
||||
self.project_root = Path(project_root)
|
||||
self.events: List[TimelineEvent] = []
|
||||
self.characters: set = set()
|
||||
|
||||
def scan_directory(self, directory: Path) -> List[Path]:
|
||||
"""Recursively find all markdown files in directory"""
|
||||
md_files = []
|
||||
if not directory.exists():
|
||||
return md_files
|
||||
|
||||
for item in directory.iterdir():
|
||||
if item.is_file() and item.suffix == '.md':
|
||||
md_files.append(item)
|
||||
elif item.is_dir() and not item.name.startswith('.'):
|
||||
md_files.extend(self.scan_directory(item))
|
||||
|
||||
return md_files
|
||||
|
||||
def extract_characters_from_file(self, file_path: Path) -> List[str]:
|
||||
"""Extract character names from character profile files"""
|
||||
try:
|
||||
content = file_path.read_text(encoding='utf-8')
|
||||
|
||||
# Look for character name in title (# Character Name)
|
||||
name_match = re.search(r'^#\s+(.+?)$', content, re.MULTILINE)
|
||||
if name_match:
|
||||
return [name_match.group(1).strip()]
|
||||
|
||||
# Look for explicit name field
|
||||
name_match = re.search(r'\*\*Name:\*\*\s*(.+?)(?:\n|$)', content)
|
||||
if name_match:
|
||||
return [name_match.group(1).strip()]
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not read {file_path}: {e}", file=sys.stderr)
|
||||
|
||||
return []
|
||||
|
||||
def extract_timeline_markers(self, content: str) -> List[Tuple[str, int]]:
|
||||
"""Extract time markers from content, return list of (timepoint, position)"""
|
||||
markers = []
|
||||
|
||||
for pattern in self.TIME_PATTERNS:
|
||||
for match in re.finditer(pattern, content, re.IGNORECASE):
|
||||
timepoint = match.group(1) if match.lastindex else match.group(0)
|
||||
markers.append((timepoint.strip(), match.start()))
|
||||
|
||||
return sorted(markers, key=lambda x: x[1])
|
||||
|
||||
def extract_character_mentions(self, content: str) -> List[str]:
|
||||
"""Extract character names from explicit character markers"""
|
||||
characters = []
|
||||
|
||||
for pattern in self.CHARACTER_PATTERNS:
|
||||
matches = re.finditer(pattern, content, re.IGNORECASE)
|
||||
for match in matches:
|
||||
char_text = match.group(1)
|
||||
# Split by commas, 'and', '&'
|
||||
names = re.split(r'[,&]|\sand\s', char_text)
|
||||
characters.extend([name.strip() for name in names if name.strip()])
|
||||
|
||||
return characters
|
||||
|
||||
def find_character_references(self, content: str, known_characters: set) -> List[str]:
|
||||
"""Find mentions of known characters in content"""
|
||||
found = []
|
||||
for character in known_characters:
|
||||
# Simple word boundary check
|
||||
if re.search(r'\b' + re.escape(character) + r'\b', content, re.IGNORECASE):
|
||||
found.append(character)
|
||||
return found
|
||||
|
||||
def parse_chapter_file(self, file_path: Path) -> List[TimelineEvent]:
|
||||
"""Parse a chapter/scene file for timeline events"""
|
||||
events = []
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding='utf-8')
|
||||
|
||||
# Get chapter number/name from filename or title
|
||||
chapter = file_path.stem
|
||||
title_match = re.search(r'^#\s+(.+?)$', content, re.MULTILINE)
|
||||
if title_match:
|
||||
chapter = title_match.group(1).strip()
|
||||
|
||||
# Extract explicit character mentions
|
||||
explicit_chars = self.extract_character_mentions(content)
|
||||
|
||||
# Find timeline markers
|
||||
markers = self.extract_timeline_markers(content)
|
||||
|
||||
# Split content into sections based on markers
|
||||
if markers:
|
||||
sections = []
|
||||
for i, (timepoint, pos) in enumerate(markers):
|
||||
start_pos = pos
|
||||
end_pos = markers[i + 1][1] if i + 1 < len(markers) else len(content)
|
||||
section_content = content[start_pos:end_pos]
|
||||
|
||||
# Find characters in this section
|
||||
section_chars = explicit_chars.copy()
|
||||
section_chars.extend(self.find_character_references(
|
||||
section_content, self.characters))
|
||||
|
||||
event = TimelineEvent(
|
||||
content=section_content[:500], # First 500 chars as preview
|
||||
location=str(file_path.relative_to(self.project_root)),
|
||||
chapter=chapter,
|
||||
timepoint=timepoint,
|
||||
characters=list(set(section_chars))
|
||||
)
|
||||
events.append(event)
|
||||
else:
|
||||
# No explicit markers, treat whole file as one event
|
||||
all_chars = explicit_chars.copy()
|
||||
all_chars.extend(self.find_character_references(content, self.characters))
|
||||
|
||||
event = TimelineEvent(
|
||||
content=content[:500],
|
||||
location=str(file_path.relative_to(self.project_root)),
|
||||
chapter=chapter,
|
||||
timepoint=None,
|
||||
characters=list(set(all_chars))
|
||||
)
|
||||
events.append(event)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Error parsing {file_path}: {e}", file=sys.stderr)
|
||||
|
||||
return events
|
||||
|
||||
def analyze_project(self) -> Dict:
|
||||
"""Analyze entire project and build timeline"""
|
||||
|
||||
# First, find all characters
|
||||
char_dirs = ['characters', 'Characters', 'cast']
|
||||
for dirname in char_dirs:
|
||||
char_dir = self.project_root / dirname
|
||||
if char_dir.exists():
|
||||
for char_file in self.scan_directory(char_dir):
|
||||
names = self.extract_characters_from_file(char_file)
|
||||
self.characters.update(names)
|
||||
|
||||
# Then scan chapters/scenes
|
||||
content_dirs = ['chapters', 'Chapters', 'scenes', 'Scenes', 'story']
|
||||
for dirname in content_dirs:
|
||||
content_dir = self.project_root / dirname
|
||||
if content_dir.exists():
|
||||
for content_file in self.scan_directory(content_dir):
|
||||
events = self.parse_chapter_file(content_file)
|
||||
self.events.extend(events)
|
||||
|
||||
# Build analysis
|
||||
analysis = {
|
||||
'total_events': len(self.events),
|
||||
'total_characters': len(self.characters),
|
||||
'characters': sorted(list(self.characters)),
|
||||
'events_by_timepoint': self._group_events_by_time(),
|
||||
'events_by_character': self._group_events_by_character(),
|
||||
'events_by_chapter': self._group_events_by_chapter(),
|
||||
'timeline': self._build_timeline(),
|
||||
'warnings': self._check_consistency()
|
||||
}
|
||||
|
||||
return analysis
|
||||
|
||||
def _group_events_by_time(self) -> Dict[str, List[Dict]]:
|
||||
"""Group events by their timepoint"""
|
||||
grouped = defaultdict(list)
|
||||
|
||||
for event in self.events:
|
||||
timepoint = event.timepoint or "Unspecified"
|
||||
grouped[timepoint].append({
|
||||
'location': event.location,
|
||||
'chapter': event.chapter,
|
||||
'characters': event.characters,
|
||||
'preview': event.content[:200]
|
||||
})
|
||||
|
||||
return dict(grouped)
|
||||
|
||||
def _group_events_by_character(self) -> Dict[str, List[Dict]]:
|
||||
"""Group events by character appearance"""
|
||||
grouped = defaultdict(list)
|
||||
|
||||
for event in self.events:
|
||||
for character in event.characters:
|
||||
grouped[character].append({
|
||||
'location': event.location,
|
||||
'chapter': event.chapter,
|
||||
'timepoint': event.timepoint,
|
||||
'preview': event.content[:200]
|
||||
})
|
||||
|
||||
return dict(grouped)
|
||||
|
||||
def _group_events_by_chapter(self) -> Dict[str, List[Dict]]:
|
||||
"""Group events by chapter"""
|
||||
grouped = defaultdict(list)
|
||||
|
||||
for event in self.events:
|
||||
chapter = event.chapter or "Unknown"
|
||||
grouped[chapter].append({
|
||||
'location': event.location,
|
||||
'timepoint': event.timepoint,
|
||||
'characters': event.characters,
|
||||
'preview': event.content[:200]
|
||||
})
|
||||
|
||||
return dict(grouped)
|
||||
|
||||
def _build_timeline(self) -> List[Dict]:
|
||||
"""Build chronological timeline of events"""
|
||||
# Sort events by timepoint (this is simplified, real implementation
|
||||
# would need more sophisticated time parsing)
|
||||
timeline = []
|
||||
|
||||
for event in self.events:
|
||||
timeline.append({
|
||||
'timepoint': event.timepoint or "Unknown",
|
||||
'chapter': event.chapter,
|
||||
'location': event.location,
|
||||
'characters': event.characters,
|
||||
'preview': event.content[:200]
|
||||
})
|
||||
|
||||
return timeline
|
||||
|
||||
def _check_consistency(self) -> List[str]:
|
||||
"""Check for potential timeline inconsistencies"""
|
||||
warnings = []
|
||||
|
||||
# Check for events without time markers
|
||||
unmarked_events = [e for e in self.events if not e.timepoint]
|
||||
if unmarked_events:
|
||||
warnings.append(
|
||||
f"Found {len(unmarked_events)} events without timeline markers"
|
||||
)
|
||||
|
||||
# Check for characters appearing in timeline without character files
|
||||
mentioned_chars = set()
|
||||
for event in self.events:
|
||||
mentioned_chars.update(event.characters)
|
||||
|
||||
undefined_chars = mentioned_chars - self.characters
|
||||
if undefined_chars:
|
||||
warnings.append(
|
||||
f"Characters mentioned but not defined: {', '.join(sorted(undefined_chars))}"
|
||||
)
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for timeline tracker"""
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: timeline_tracker.py <project_directory> [--output json|markdown]")
|
||||
sys.exit(1)
|
||||
|
||||
project_dir = sys.argv[1]
|
||||
output_format = 'markdown'
|
||||
|
||||
if len(sys.argv) > 2 and sys.argv[2] == '--output':
|
||||
output_format = sys.argv[3] if len(sys.argv) > 3 else 'markdown'
|
||||
|
||||
tracker = TimelineTracker(project_dir)
|
||||
analysis = tracker.analyze_project()
|
||||
|
||||
if output_format == 'json':
|
||||
print(json.dumps(analysis, indent=2))
|
||||
else:
|
||||
# Markdown output
|
||||
print("# Timeline Analysis\n")
|
||||
print(f"**Total Events:** {analysis['total_events']}")
|
||||
print(f"**Total Characters:** {analysis['total_characters']}\n")
|
||||
|
||||
print("## Characters")
|
||||
for char in analysis['characters']:
|
||||
appearances = len(analysis['events_by_character'].get(char, []))
|
||||
print(f"- {char} ({appearances} appearances)")
|
||||
|
||||
print("\n## Timeline")
|
||||
for event in analysis['timeline']:
|
||||
print(f"\n### {event['timepoint']} - {event['chapter']}")
|
||||
print(f"**Location:** {event['location']}")
|
||||
if event['characters']:
|
||||
print(f"**Characters:** {', '.join(event['characters'])}")
|
||||
print(f"\n{event['preview']}...\n")
|
||||
print("---")
|
||||
|
||||
if analysis['warnings']:
|
||||
print("\n## Warnings")
|
||||
for warning in analysis['warnings']:
|
||||
print(f"- ⚠️ {warning}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user