Files
windyboy f7e54692a9 Initial project setup: Obsidian intelligent journal organizer
- Add core agent architecture with Command + Skill pattern
- Implement Claude API integration for content analysis
- Add Obsidian REST API integration for vault operations
- Create conversational interface (v2.0) with natural language processing
- Add comprehensive configuration management and validation
- Include project documentation and developer guides
- Set up testing framework with unit, integration, and property tests
- Add Kiro specs for Claude API configuration and code quality improvements
- Configure project steering files for development guidelines
2025-12-31 17:55:10 +08:00

675 lines
26 KiB
Python

"""
Comprehensive date validation and parsing utilities
Provides robust date format validation and constraint checking
"""
import logging
import re
from datetime import datetime, date, timedelta
from typing import Optional, List, Tuple, Union, Dict, Any
try:
from .error_handling import ValidationError
except ImportError:
from error_handling import ValidationError
class DateValidator:
"""Comprehensive date validation with multiple format support"""
def __init__(self):
self.logger = logging.getLogger(__name__)
# Supported date formats with their regex patterns and strptime formats
self.date_formats = {
'iso_date': {
'pattern': r'^\d{4}-\d{2}-\d{2}$',
'strptime': '%Y-%m-%d',
'description': 'ISO date format (YYYY-MM-DD)'
},
'iso_datetime': {
'pattern': r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$',
'strptime': '%Y-%m-%dT%H:%M:%S',
'description': 'ISO datetime format (YYYY-MM-DDTHH:MM:SS)'
},
'iso_datetime_ms': {
'pattern': r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$',
'strptime': '%Y-%m-%dT%H:%M:%S.%f',
'description': 'ISO datetime with milliseconds'
},
'iso_datetime_tz': {
'pattern': r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$',
'strptime': '%Y-%m-%dT%H:%M:%S%z',
'description': 'ISO datetime with timezone'
},
'us_date': {
'pattern': r'^\d{1,2}/\d{1,2}/\d{4}$',
'strptime': '%m/%d/%Y',
'description': 'US date format (MM/DD/YYYY)'
},
'eu_date': {
'pattern': r'^\d{1,2}/\d{1,2}/\d{4}$',
'strptime': '%d/%m/%Y',
'description': 'European date format (DD/MM/YYYY)'
},
'dot_date': {
'pattern': r'^\d{1,2}\.\d{1,2}\.\d{4}$',
'strptime': '%d.%m.%Y',
'description': 'Dot-separated date (DD.MM.YYYY)'
},
'compact_date': {
'pattern': r'^\d{8}$',
'strptime': '%Y%m%d',
'description': 'Compact date format (YYYYMMDD)'
}
}
# Default constraints
self.default_constraints = {
'min_year': 1900,
'max_year': 2100,
'allow_future': True,
'max_future_days': 365 * 10, # 10 years
'allow_past': True,
'max_past_days': 365 * 50, # 50 years
}
def validate_date_string(
self,
date_string: str,
field_name: str = "date",
allowed_formats: Optional[List[str]] = None,
constraints: Optional[Dict[str, Any]] = None,
auto_detect_format: bool = True
) -> Tuple[datetime, str]:
"""
Validate a date string with comprehensive format and constraint checking
Args:
date_string: Date string to validate
field_name: Name of the field for error messages
allowed_formats: List of allowed format names (None = all formats)
constraints: Date constraints (None = use defaults)
auto_detect_format: Whether to auto-detect format
Returns:
Tuple of (parsed_datetime, detected_format)
Raises:
ValidationError: If validation fails
"""
if not date_string or not isinstance(date_string, str):
raise ValidationError(
message=f"{field_name} must be a non-empty string",
field_name=field_name,
validation_rule="non_empty_string"
)
date_string = date_string.strip()
if not date_string:
raise ValidationError(
message=f"{field_name} cannot be empty",
field_name=field_name,
validation_rule="non_empty"
)
# Determine which formats to try
formats_to_try = allowed_formats or list(self.date_formats.keys())
parsed_date = None
detected_format = None
parsing_errors = []
# Try each format
for format_name in formats_to_try:
if format_name not in self.date_formats:
self.logger.warning(f"Unknown date format: {format_name}")
continue
format_info = self.date_formats[format_name]
# Check regex pattern first (faster than strptime)
if not re.match(format_info['pattern'], date_string):
continue
# Try to parse with strptime
try:
parsed_date = datetime.strptime(date_string, format_info['strptime'])
detected_format = format_name
break
except ValueError as e:
parsing_errors.append(f"{format_name}: {str(e)}")
continue
# If no format worked, provide helpful error
if parsed_date is None:
if auto_detect_format:
error_msg = f"{field_name} format not recognized. Tried formats: {', '.join(formats_to_try)}"
if parsing_errors:
error_msg += f". Errors: {'; '.join(parsing_errors[:3])}" # Limit error details
else:
format_descriptions = [f'{fmt} ({self.date_formats[fmt]["description"]})' for fmt in formats_to_try if fmt in self.date_formats]
error_msg = f"{field_name} must match one of these formats: {', '.join(format_descriptions)}"
raise ValidationError(
message=error_msg,
field_name=field_name,
validation_rule="date_format"
)
# Apply constraints
self._validate_date_constraints(parsed_date, field_name, constraints or self.default_constraints)
return parsed_date, detected_format
def validate_date_range(
self,
start_date: Union[str, datetime, date],
end_date: Union[str, datetime, date],
field_name_start: str = "start_date",
field_name_end: str = "end_date",
allow_same_date: bool = True,
max_range_days: Optional[int] = None
) -> Tuple[datetime, datetime]:
"""
Validate a date range with logical constraints
Args:
start_date: Start date (string or datetime)
end_date: End date (string or datetime)
field_name_start: Name of start date field
field_name_end: Name of end date field
allow_same_date: Whether start and end can be the same
max_range_days: Maximum allowed range in days
Returns:
Tuple of (start_datetime, end_datetime)
Raises:
ValidationError: If validation fails
"""
# Parse start date
if isinstance(start_date, str):
start_dt, _ = self.validate_date_string(start_date, field_name_start)
elif isinstance(start_date, datetime):
start_dt = start_date
elif isinstance(start_date, date):
start_dt = datetime.combine(start_date, datetime.min.time())
else:
raise ValidationError(
message=f"{field_name_start} must be a string, date, or datetime",
field_name=field_name_start,
validation_rule="date_type"
)
# Parse end date
if isinstance(end_date, str):
end_dt, _ = self.validate_date_string(end_date, field_name_end)
elif isinstance(end_date, datetime):
end_dt = end_date
elif isinstance(end_date, date):
end_dt = datetime.combine(end_date, datetime.min.time())
else:
raise ValidationError(
message=f"{field_name_end} must be a string, date, or datetime",
field_name=field_name_end,
validation_rule="date_type"
)
# Validate range logic
if start_dt > end_dt:
raise ValidationError(
message=f"{field_name_start} cannot be after {field_name_end}",
field_name=field_name_start,
validation_rule="date_range_order"
)
if not allow_same_date and start_dt.date() == end_dt.date():
raise ValidationError(
message=f"{field_name_start} and {field_name_end} cannot be the same date",
field_name=field_name_start,
validation_rule="date_range_same"
)
# Check maximum range
if max_range_days is not None:
range_days = (end_dt - start_dt).days
if range_days > max_range_days:
raise ValidationError(
message=f"Date range cannot exceed {max_range_days} days (current: {range_days} days)",
field_name=field_name_start,
validation_rule="date_range_too_large"
)
return start_dt, end_dt
def validate_journal_date(
self,
date_string: str,
field_name: str = "journal_date"
) -> datetime:
"""
Validate a date for journal entries with specific constraints
Args:
date_string: Date string to validate
field_name: Name of the field for error messages
Returns:
Parsed datetime
Raises:
ValidationError: If validation fails
"""
# Journal dates should typically be in ISO format and not too far in the future
journal_constraints = {
'min_year': 2000, # Journals unlikely before 2000
'max_year': datetime.now().year + 1, # Allow up to next year
'allow_future': True,
'max_future_days': 30, # Allow up to 30 days in future
'allow_past': True,
'max_past_days': 365 * 20, # Allow up to 20 years in past
}
# Prefer ISO date format for journals
preferred_formats = ['iso_date', 'compact_date', 'us_date', 'eu_date']
parsed_date, detected_format = self.validate_date_string(
date_string,
field_name,
allowed_formats=preferred_formats,
constraints=journal_constraints
)
# Additional journal-specific validations
today = datetime.now().date()
date_only = parsed_date.date()
# Warn about future dates (but don't reject)
if date_only > today:
days_future = (date_only - today).days
if days_future > 7: # More than a week in future
self.logger.warning(f"Journal date is {days_future} days in the future: {date_string}")
# Check for weekend dates (informational)
if date_only.weekday() >= 5: # Saturday = 5, Sunday = 6
self.logger.debug(f"Journal date is on weekend: {date_string}")
return parsed_date
def validate_obsidian_date_format(
self,
date_string: str,
obsidian_format: str = "YYYY-MM-DD",
field_name: str = "date"
) -> datetime:
"""
Validate date against Obsidian's date format configuration
Args:
date_string: Date string to validate
obsidian_format: Obsidian date format (e.g., "YYYY-MM-DD", "DD-MM-YYYY")
field_name: Name of the field for error messages
Returns:
Parsed datetime
Raises:
ValidationError: If validation fails
"""
# Convert Obsidian format to Python strptime format
python_format = self._obsidian_to_python_format(obsidian_format)
if not date_string or not isinstance(date_string, str):
raise ValidationError(
message=f"{field_name} must be a non-empty string",
field_name=field_name,
validation_rule="non_empty_string"
)
date_string = date_string.strip()
try:
parsed_date = datetime.strptime(date_string, python_format)
except ValueError as e:
raise ValidationError(
message=f"{field_name} must match Obsidian format '{obsidian_format}': {str(e)}",
field_name=field_name,
validation_rule="obsidian_date_format"
)
# Apply basic constraints
self._validate_date_constraints(parsed_date, field_name, self.default_constraints)
return parsed_date
def get_supported_formats(self) -> Dict[str, str]:
"""
Get list of supported date formats
Returns:
Dictionary of format_name -> description
"""
return {name: info['description'] for name, info in self.date_formats.items()}
def suggest_date_format(self, date_string: str) -> List[Tuple[str, str]]:
"""
Suggest possible date formats for a given string
Args:
date_string: Date string to analyze
Returns:
List of (format_name, description) tuples for matching patterns
"""
suggestions = []
for format_name, format_info in self.date_formats.items():
if re.match(format_info['pattern'], date_string.strip()):
suggestions.append((format_name, format_info['description']))
return suggestions
def _validate_date_constraints(
self,
date_obj: datetime,
field_name: str,
constraints: Dict[str, Any]
) -> None:
"""Validate date against constraints"""
# Year constraints
if date_obj.year < constraints.get('min_year', 1900):
raise ValidationError(
message=f"{field_name} year cannot be before {constraints['min_year']}",
field_name=field_name,
validation_rule="min_year"
)
if date_obj.year > constraints.get('max_year', 2100):
raise ValidationError(
message=f"{field_name} year cannot be after {constraints['max_year']}",
field_name=field_name,
validation_rule="max_year"
)
# Future date constraints
today = datetime.now()
if date_obj > today:
if not constraints.get('allow_future', True):
raise ValidationError(
message=f"{field_name} cannot be in the future",
field_name=field_name,
validation_rule="no_future_dates"
)
days_future = (date_obj - today).days
max_future = constraints.get('max_future_days', 365 * 10)
if days_future > max_future:
raise ValidationError(
message=f"{field_name} cannot be more than {max_future} days in the future",
field_name=field_name,
validation_rule="max_future_days"
)
# Past date constraints
if date_obj < today:
if not constraints.get('allow_past', True):
raise ValidationError(
message=f"{field_name} cannot be in the past",
field_name=field_name,
validation_rule="no_past_dates"
)
days_past = (today - date_obj).days
max_past = constraints.get('max_past_days', 365 * 50)
if days_past > max_past:
raise ValidationError(
message=f"{field_name} cannot be more than {max_past} days in the past",
field_name=field_name,
validation_rule="max_past_days"
)
def _obsidian_to_python_format(self, obsidian_format: str) -> str:
"""Convert Obsidian date format to Python strptime format"""
# Common Obsidian format mappings
mappings = {
'YYYY': '%Y', # 4-digit year
'YY': '%y', # 2-digit year
'MM': '%m', # Month with zero padding
'M': '%m', # Month without zero padding (Python doesn't distinguish)
'DD': '%d', # Day with zero padding
'D': '%d', # Day without zero padding (Python doesn't distinguish)
'HH': '%H', # Hour (24-hour)
'hh': '%I', # Hour (12-hour)
'mm': '%M', # Minute
'ss': '%S', # Second
'A': '%p', # AM/PM
}
python_format = obsidian_format
# Replace in order of length (longest first to avoid partial replacements)
for obsidian_token in sorted(mappings.keys(), key=len, reverse=True):
python_format = python_format.replace(obsidian_token, mappings[obsidian_token])
return python_format
class DateRangeValidator:
"""Specialized validator for date ranges and periods"""
def __init__(self):
self.logger = logging.getLogger(__name__)
self.date_validator = DateValidator()
def validate_journal_date_range(
self,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
period: Optional[str] = None
) -> Tuple[datetime, datetime]:
"""
Validate a date range for journal operations
Args:
start_date: Start date string (optional)
end_date: End date string (optional)
period: Period specification (e.g., "week", "month", "year")
Returns:
Tuple of (start_datetime, end_datetime)
Raises:
ValidationError: If validation fails
"""
today = datetime.now()
# Handle period-based ranges
if period:
return self._get_period_range(period, today)
# Handle explicit date ranges
if start_date and end_date:
return self.date_validator.validate_date_range(
start_date, end_date,
max_range_days=365 * 2 # Max 2 years for journal ranges
)
# Handle single date (default to that day)
if start_date:
start_dt = self.date_validator.validate_journal_date(start_date)
end_dt = start_dt.replace(hour=23, minute=59, second=59)
return start_dt, end_dt
if end_date:
end_dt = self.date_validator.validate_journal_date(end_date)
start_dt = end_dt.replace(hour=0, minute=0, second=0)
return start_dt, end_dt
# Default to today
start_dt = today.replace(hour=0, minute=0, second=0, microsecond=0)
end_dt = today.replace(hour=23, minute=59, second=59, microsecond=999999)
return start_dt, end_dt
def _get_period_range(self, period: str, reference_date: datetime) -> Tuple[datetime, datetime]:
"""Get date range for a period specification"""
period = period.lower().strip()
if period in ['today', 'day']:
start = reference_date.replace(hour=0, minute=0, second=0, microsecond=0)
end = reference_date.replace(hour=23, minute=59, second=59, microsecond=999999)
elif period in ['yesterday']:
yesterday = reference_date - timedelta(days=1)
start = yesterday.replace(hour=0, minute=0, second=0, microsecond=0)
end = yesterday.replace(hour=23, minute=59, second=59, microsecond=999999)
elif period in ['week', 'this_week']:
# Start of week (Monday)
days_since_monday = reference_date.weekday()
start = (reference_date - timedelta(days=days_since_monday)).replace(
hour=0, minute=0, second=0, microsecond=0
)
end = (start + timedelta(days=6)).replace(
hour=23, minute=59, second=59, microsecond=999999
)
elif period in ['last_week']:
# Previous week
days_since_monday = reference_date.weekday()
this_week_start = reference_date - timedelta(days=days_since_monday)
start = (this_week_start - timedelta(days=7)).replace(
hour=0, minute=0, second=0, microsecond=0
)
end = (start + timedelta(days=6)).replace(
hour=23, minute=59, second=59, microsecond=999999
)
elif period in ['month', 'this_month']:
# Start of month
start = reference_date.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
# End of month
if reference_date.month == 12:
next_month = reference_date.replace(year=reference_date.year + 1, month=1, day=1)
else:
next_month = reference_date.replace(month=reference_date.month + 1, day=1)
end = (next_month - timedelta(days=1)).replace(
hour=23, minute=59, second=59, microsecond=999999
)
elif period in ['last_month']:
# Previous month
if reference_date.month == 1:
last_month = reference_date.replace(year=reference_date.year - 1, month=12, day=1)
else:
last_month = reference_date.replace(month=reference_date.month - 1, day=1)
start = last_month.replace(hour=0, minute=0, second=0, microsecond=0)
# End of last month
this_month_start = reference_date.replace(day=1)
end = (this_month_start - timedelta(days=1)).replace(
hour=23, minute=59, second=59, microsecond=999999
)
elif period in ['year', 'this_year']:
start = reference_date.replace(
month=1, day=1, hour=0, minute=0, second=0, microsecond=0
)
end = reference_date.replace(
month=12, day=31, hour=23, minute=59, second=59, microsecond=999999
)
elif period in ['last_year']:
last_year = reference_date.year - 1
start = reference_date.replace(
year=last_year, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
)
end = reference_date.replace(
year=last_year, month=12, day=31, hour=23, minute=59, second=59, microsecond=999999
)
else:
# Try to parse as number of days
try:
if period.endswith('d') or period.endswith('days'):
days = int(period.rstrip('days').rstrip('d'))
start = (reference_date - timedelta(days=days)).replace(
hour=0, minute=0, second=0, microsecond=0
)
end = reference_date.replace(hour=23, minute=59, second=59, microsecond=999999)
else:
raise ValueError("Invalid period format")
except ValueError:
raise ValidationError(
message=f"Unknown period specification: {period}. "
f"Supported: today, yesterday, week, month, year, last_week, last_month, last_year, or Nd (N days)",
field_name="period",
validation_rule="unknown_period"
)
return start, end
# Global validator instances
date_validator = DateValidator()
date_range_validator = DateRangeValidator()
def validate_date_input(
date_input: Union[str, datetime, date, None],
field_name: str = "date",
required: bool = True,
format_hint: Optional[str] = None
) -> Optional[datetime]:
"""
Convenience function for validating date inputs
Args:
date_input: Date input to validate
field_name: Name of the field for error messages
required: Whether the date is required
format_hint: Hint about expected format
Returns:
Validated datetime or None if not required and empty
Raises:
ValidationError: If validation fails
"""
if date_input is None or (isinstance(date_input, str) and not date_input.strip()):
if required:
raise ValidationError(
message=f"{field_name} is required",
field_name=field_name,
validation_rule="required"
)
return None
if isinstance(date_input, datetime):
return date_input
elif isinstance(date_input, date):
return datetime.combine(date_input, datetime.min.time())
elif isinstance(date_input, str):
# Use format hint if provided
allowed_formats = None
if format_hint:
if format_hint in date_validator.date_formats:
allowed_formats = [format_hint]
else:
# Try to match format hint to known formats
for fmt_name, fmt_info in date_validator.date_formats.items():
if format_hint.lower() in fmt_info['description'].lower():
allowed_formats = [fmt_name]
break
parsed_date, _ = date_validator.validate_date_string(
date_input, field_name, allowed_formats=allowed_formats
)
return parsed_date
else:
raise ValidationError(
message=f"{field_name} must be a string, date, or datetime",
field_name=field_name,
validation_rule="date_type"
)