From a92a7fdc7db82c6cdf2a8ed27b6d702e725f0472 Mon Sep 17 00:00:00 2001 From: Nose Date: Thu, 12 Feb 2026 17:19:24 -0800 Subject: [PATCH] d --- cli.py | 29 +++ cli_renderers.py | 105 +++++--- src/tarot/card/details.py | 532 +++++++++++++++++++++++++++++++++++++- src/tarot/db_import.py | 297 +++++++++++++++++++++ src/tarot/deck/deck.py | 11 + src/utils/dates.py | 139 ++++++++++ src/utils/filter.py | 58 +++++ tarot.db | Bin 0 -> 106496 bytes 8 files changed, 1132 insertions(+), 39 deletions(-) create mode 100644 src/tarot/db_import.py create mode 100644 src/utils/dates.py create mode 100644 tarot.db diff --git a/cli.py b/cli.py index b12efbe..eb45eb4 100644 --- a/cli.py +++ b/cli.py @@ -375,6 +375,35 @@ def search( typer.echo("No cards matched that filter.") raise typer.Exit(code=0) + +@app.command("import-db") +def import_db( + file: Path = typer.Argument(..., help="CSV or XLSX file to import"), + table: str = typer.Option(..., "--table", "-t", help="Target table to import into"), + sheet: Optional[str] = typer.Option(None, "--sheet", help="Excel sheet name (optional)"), + delimiter: str = typer.Option(",", "--delimiter", "-d", help="CSV delimiter (default ',')"), + db_path: Optional[Path] = typer.Option(None, "--db", help="Path to target SQLite DB"), +) -> None: + """Import CSV or Excel data into the project's DB tables. + + Examples: + tarot import-db angels.xlsx --table card_angels + tarot import-db angels.csv --table card_angels --delimiter ',' + """ + try: + from tarot.db_import import import_file + except Exception as exc: # pragma: no cover - import errors bubble up in tests + typer.echo(f"Error loading import helper: {exc}") + raise typer.Exit(code=1) + + try: + res = import_file(file, table, db_path=db_path, sheet=sheet, delimiter=delimiter) + except Exception as exc: # pragma: no cover - errors will be surfaced for visibility + typer.echo(f"Import failed: {exc}") + raise typer.Exit(code=1) + + typer.echo(f"Inserted: {res.inserted}; Skipped: {res.skipped}; Updated: {res.updated}") + def _doc(obj: Any, fallback: str) -> str: return inspect.getdoc(obj) or fallback diff --git a/cli_renderers.py b/cli_renderers.py index be239db..1da557c 100644 --- a/cli_renderers.py +++ b/cli_renderers.py @@ -6,7 +6,7 @@ specialized handling for planets plus generic list/dict/object renderers. from __future__ import annotations -from typing import Any, Dict, List, Optional, Sequence +from typing import Any, Dict, List, Optional, Sequence, Tuple from rich import box from rich.console import Console, Group @@ -15,6 +15,8 @@ from rich.table import Table from tarot import Card from utils.attributes import Color, Planet +from utils.dates import format_month_day +from utils.object_formatting import format_value, get_object_attributes, is_nested_object class Renderer: @@ -69,27 +71,10 @@ class Renderer: self.console.print(table) def print_cards_table(self, cards: List[Card]) -> None: - table = Table(title="Cards", show_lines=True) - table.add_column("#", justify="right") - table.add_column("Name") - table.add_column("Arcana") - table.add_column("Suit") - table.add_column("Pip/Court") - - for card in cards: - suit = getattr(getattr(card, "suit", None), "name", "") - pip = str(getattr(card, "pip", "")) if getattr(card, "pip", None) else "" - court = getattr(card, "court_rank", "") - pip_display = court or pip - table.add_row( - str(getattr(card, "number", "")), - getattr(card, "name", ""), - getattr(card, "arcana", ""), - suit, - pip_display, - ) - - self.console.print(table) + for idx, card in enumerate(cards): + self._render_card(card) + if idx < len(cards) - 1: + self.console.print() # Internal renderers ------------------------------------------------- def _render_card(self, card: Card) -> None: @@ -99,7 +84,6 @@ class Renderer: arcana = getattr(card, "arcana", "") suit_obj = getattr(card, "suit", None) suit = getattr(suit_obj, "name", "") if suit_obj is not None else "" - pip = getattr(card, "pip", None) number = getattr(card, "number", "") keywords = getattr(card, "keywords", None) or [] meaning = getattr(card, "meaning", None) @@ -112,18 +96,7 @@ class Renderer: top_right = f"#{number}" if number not in (None, "") else "" top.add_row(top_left, top_center, top_right) - body_lines = [f"Arcana: {arcana or '-'}"] - if suit: - body_lines.append(f"Suit: {suit}") - if pip: - body_lines.append(f"Pip: {pip}") - if getattr(card, "court_rank", ""): - body_lines.append(f"Court Rank: {card.court_rank}") - if keywords: - body_lines.append(f"Keywords: {', '.join(keywords)}") - guidance = getattr(card, "guidance", None) - if guidance: - body_lines.append(f"Guidance: {guidance}") + body_lines = self._card_field_lines(card) body = Panel("\n".join(body_lines), box=box.MINIMAL, padding=(1, 1)) bottom = Table( @@ -292,6 +265,68 @@ class Renderer: safe_val = value if value not in (None, "") else "—" return f"{label}:\n{safe_val}" + def _card_field_lines(self, card: Card) -> List[str]: + exclude = {"name", "number", "meaning", "keywords"} + lines: List[str] = [] + for attr_name, attr_value in get_object_attributes(card): + if attr_name in exclude: + continue + if attr_value in (None, "", [], {}, ()): # Skip empty values + continue + + label = self._humanize_key(attr_name).title() + if is_nested_object(attr_value) and not hasattr(attr_value, "name"): + lines.append(f"{label}:") + nested = format_value(attr_value, indent=2) + lines.extend(nested.splitlines()) + continue + + lines.append(f"{label}: {self._format_card_value(attr_value)}") + + if not lines: + lines.append("(no fields)") + return lines + + def _format_card_value(self, value: Any) -> str: + if value is None: + return "—" + if isinstance(value, (str, int, float, bool)): + return str(value) + if isinstance(value, tuple): + date_range = self._format_date_range(value) + if date_range is not None: + return date_range + return ", ".join(self._format_card_value(v) for v in value) + if isinstance(value, list): + return ", ".join(self._format_card_value(v) for v in value) + if isinstance(value, dict): + return ", ".join( + f"{self._humanize_key(str(k)).title()}: {self._format_card_value(v)}" + for k, v in value.items() + ) + if hasattr(value, "name"): + return str(getattr(value, "name", value)) + if is_nested_object(value): + return format_value(value, indent=2) + return str(value) + + @staticmethod + def _format_date_range(value: Tuple[Any, ...]) -> Optional[str]: + if len(value) != 2: + return None + start, end = value + if not ( + isinstance(start, tuple) + and isinstance(end, tuple) + and len(start) == 2 + and len(end) == 2 + ): + return None + try: + return f"{format_month_day(start)} - {format_month_day(end)}" + except Exception: + return None + class PlanetRenderer: """Type-specific table rendering for planets.""" diff --git a/src/tarot/card/details.py b/src/tarot/card/details.py index d04a340..b9018c8 100644 --- a/src/tarot/card/details.py +++ b/src/tarot/card/details.py @@ -21,7 +21,11 @@ Usage: registry.load_into_card(card) """ -from typing import TYPE_CHECKING, Any, Dict, Optional +import json +import os +import sqlite3 +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple from tarot.constants import build_position_map @@ -43,11 +47,457 @@ class CardDetailsRegistry: - 65-78: Wands (same structure) """ - def __init__(self) -> None: - """Initialize the card details registry with interpretive data.""" - self._details: Dict[str, Dict[str, Any]] = self._build_registry() + _DETAIL_KEYS = { + "explanation", + "interpretation", + "keywords", + "reversed_keywords", + "guidance", + "date_range", + "degrees", + "day_angel", + "night_angel", + "day_demon", + "night_demon", + } + + def __init__(self, *, use_db: bool = True, db_path: Optional[Path] = None) -> None: + """Initialize the card details registry with interpretive data. + + Args: + use_db: If True, load details from a local SQLite database (creating it + on first run). If False, use the in-code registry only. + db_path: Optional custom path for the SQLite file. + """ # Map card positions (1-78) to registry keys self._position_map = self._build_position_map() + self._use_db = use_db + self._db_path = self._resolve_db_path(db_path) + + if self._use_db: + self._details = self._load_registry_from_db() + else: + self._details = self._build_registry() + + @staticmethod + def _resolve_db_path(db_path: Optional[Path]) -> Path: + """Resolve the database path with priority order. + + Priority: + 1. Explicit db_path argument + 2. PY_TAROT_DB_PATH or TAROT_DB_PATH environment variable + 3. Project root (where pyproject.toml is located) + 4. User home directory ~/.py-tarot/ + """ + if db_path is not None: + return Path(db_path) + + env_path = os.getenv("PY_TAROT_DB_PATH") or os.getenv("TAROT_DB_PATH") + if env_path: + return Path(env_path) + + # Try to find project root by looking for pyproject.toml + current = Path(__file__).resolve() + for parent in current.parents: + if (parent / "pyproject.toml").exists() or (parent / ".git").exists(): + return parent / "tarot.db" + + # Fallback to user home directory + return Path.home() / ".py-tarot" / "tarot.db" + + def _load_registry_from_db(self) -> Dict[str, Dict[str, Any]]: + try: + self._ensure_db() + except (OSError, sqlite3.Error): + return self._build_registry() + + conn = sqlite3.connect(self._db_path) + conn.row_factory = sqlite3.Row + try: + # Check for new schema columns + conn.execute("SELECT explanation_summary FROM card_details LIMIT 1") + + rows = conn.execute( + "SELECT registry_key, position, explanation_summary, explanation_waite, " + "interpretation, guidance, date_start_month, date_start_day, " + "date_end_month, date_end_day, degrees, extra_json FROM card_details" + ).fetchall() + except sqlite3.OperationalError: + conn.close() + if self._rebuild_db(): + conn = sqlite3.connect(self._db_path) + conn.row_factory = sqlite3.Row + try: + rows = conn.execute( + "SELECT registry_key, position, explanation_summary, explanation_waite, " + "interpretation, guidance, date_start_month, date_start_day, " + "date_end_month, date_end_day, degrees, extra_json FROM card_details" + ).fetchall() + finally: + pass + else: + return self._build_registry() + + # Load keywords + keywords_map: Dict[str, Dict[str, List[str]]] = {} + try: + kw_rows = conn.execute( + "SELECT card_key, keyword, is_reversed FROM card_keywords" + ).fetchall() + for k_row in kw_rows: + ckey = k_row["card_key"] + kw = k_row["keyword"] + is_rev = bool(k_row["is_reversed"]) + if ckey not in keywords_map: + keywords_map[ckey] = {"keywords": [], "reversed_keywords": []} + if is_rev: + keywords_map[ckey]["reversed_keywords"].append(kw) + else: + keywords_map[ckey]["keywords"].append(kw) + except sqlite3.OperationalError: + pass + + # Load angels and demons + angels_map: Dict[str, Dict[str, str]] = {} + demons_map: Dict[str, Dict[str, str]] = {} + try: + angel_rows = conn.execute( + "SELECT card_key, name, is_night FROM card_angels" + ).fetchall() + for a_row in angel_rows: + ckey = a_row["card_key"] + name = a_row["name"] + is_night = bool(a_row["is_night"]) + if ckey not in angels_map: + angels_map[ckey] = {} + field = "night_angel" if is_night else "day_angel" + angels_map[ckey][field] = name + + demon_rows = conn.execute( + "SELECT card_key, name, is_night FROM card_demons" + ).fetchall() + for d_row in demon_rows: + ckey = d_row["card_key"] + name = d_row["name"] + is_night = bool(d_row["is_night"]) + if ckey not in demons_map: + demons_map[ckey] = {} + field = "night_demon" if is_night else "day_demon" + demons_map[ckey][field] = name + except sqlite3.OperationalError: + pass + + details: Dict[str, Dict[str, Any]] = {} + for row in rows: + entry: Dict[str, Any] = {} + ex = {} + if row["explanation_summary"]: + ex["summary"] = row["explanation_summary"] + if row["explanation_waite"]: + ex["waite"] = row["explanation_waite"] + + if row["extra_json"]: + try: + extra = json.loads(row["extra_json"]) + if "explanation" in extra and isinstance(extra["explanation"], dict): + ex.update(extra.pop("explanation")) + entry.update(self._restore_details(extra)) + except (TypeError, ValueError): + pass + + if ex: + entry["explanation"] = ex + + if row["interpretation"]: + entry["interpretation"] = row["interpretation"] + if row["guidance"]: + entry["guidance"] = row["guidance"] + + kws = keywords_map.get(row["registry_key"], {}) + if kws.get("keywords"): + entry["keywords"] = kws["keywords"] + if kws.get("reversed_keywords"): + entry["reversed_keywords"] = kws["reversed_keywords"] + + # Merge angels from table + angels = angels_map.get(row["registry_key"], {}) + for field in ["day_angel", "night_angel"]: + if field in angels: + entry[field] = angels[field] + + # Merge demons from table + demons = demons_map.get(row["registry_key"], {}) + for field in ["day_demon", "night_demon"]: + if field in demons: + entry[field] = demons[field] + + if ( + row["date_start_month"] + and row["date_start_day"] + and row["date_end_month"] + and row["date_end_day"] + ): + entry["date_range"] = ( + (int(row["date_start_month"]), int(row["date_start_day"])), + (int(row["date_end_month"]), int(row["date_end_day"])), + ) + + if row["degrees"]: + entry["degrees"] = row["degrees"] + + details[row["registry_key"]] = entry + + conn.close() + return details + + def _ensure_db(self) -> None: + self._db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(self._db_path) + try: + # Main card details table + conn.execute( + "CREATE TABLE IF NOT EXISTS card_details (" + "registry_key TEXT PRIMARY KEY, " + "position INTEGER, " + "explanation_summary TEXT, " + "explanation_waite TEXT, " + "interpretation TEXT, " + "guidance TEXT, " + "date_start_month INTEGER, " + "date_start_day INTEGER, " + "date_end_month INTEGER, " + "date_end_day INTEGER, " + "degrees TEXT, " + "extra_json TEXT" + ")" + ) + + # Keywords table (normalized) + conn.execute( + "CREATE TABLE IF NOT EXISTS card_keywords (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "card_key TEXT NOT NULL, " + "keyword TEXT NOT NULL, " + "is_reversed BOOLEAN DEFAULT 0, " + "FOREIGN KEY(card_key) REFERENCES card_details(registry_key) ON DELETE CASCADE" + ")" + ) + + # Angels table (normalized) + conn.execute( + "CREATE TABLE IF NOT EXISTS card_angels (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "card_key TEXT NOT NULL, " + "name TEXT NOT NULL, " + "is_night BOOLEAN DEFAULT 0, " + "FOREIGN KEY(card_key) REFERENCES card_details(registry_key) ON DELETE CASCADE" + ")" + ) + + # Demons table (normalized) + conn.execute( + "CREATE TABLE IF NOT EXISTS card_demons (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "card_key TEXT NOT NULL, " + "name TEXT NOT NULL, " + "is_night BOOLEAN DEFAULT 0, " + "FOREIGN KEY(card_key) REFERENCES card_details(registry_key) ON DELETE CASCADE" + ")" + ) + + # Indexes + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_card_keywords_card_key ON card_keywords(card_key)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_card_angels_card_key ON card_angels(card_key)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_card_demons_card_key ON card_demons(card_key)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_card_details_position ON card_details(position)" + ) + + row = conn.execute("SELECT COUNT(1) FROM card_details").fetchone() + count = row[0] if row else 0 + if count == 0: + self._seed_db(conn) + finally: + conn.close() + + def _rebuild_db(self) -> bool: + try: + if self._db_path.exists(): + self._db_path.unlink() + self._ensure_db() + except (OSError, sqlite3.Error): + return False + return True + + def _seed_db(self, conn: sqlite3.Connection) -> None: + registry = self._build_registry() + key_to_position = {key: pos for pos, key in self._position_map.items()} + + details_rows = [] + keyword_rows = [] + angel_rows = [] + demon_rows = [] + + for key, details in registry.items(): + row_data, kws, angels, demons = self._prepare_rows(key, details) + row_data["position"] = key_to_position.get(key) + + details_rows.append( + ( + key, + row_data["position"], + row_data["explanation_summary"], + row_data["explanation_waite"], + row_data["interpretation"], + row_data["guidance"], + row_data["date_start_month"], + row_data["date_start_day"], + row_data["date_end_month"], + row_data["date_end_day"], + row_data["degrees"], + row_data["extra_json"], + ) + ) + + for kw in kws: + keyword_rows.append((key, kw["keyword"], kw["is_reversed"])) + + for angel in angels: + angel_rows.append((key, angel["name"], angel["is_night"])) + + for demon in demons: + demon_rows.append((key, demon["name"], demon["is_night"])) + + conn.executemany( + "INSERT OR REPLACE INTO card_details (" + "registry_key, position, explanation_summary, explanation_waite, " + "interpretation, guidance, date_start_month, date_start_day, " + "date_end_month, date_end_day, degrees, extra_json" + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + details_rows, + ) + + conn.executemany( + "INSERT INTO card_keywords (card_key, keyword, is_reversed) VALUES (?, ?, ?)", + keyword_rows, + ) + + conn.executemany( + "INSERT INTO card_angels (card_key, name, is_night) VALUES (?, ?, ?)", + angel_rows, + ) + + conn.executemany( + "INSERT INTO card_demons (card_key, name, is_night) VALUES (?, ?, ?)", + demon_rows, + ) + + conn.commit() + + def _prepare_rows( + self, key: str, details: Dict[str, Any] + ) -> Tuple[Dict[str, Any], List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]: + explanation = details.get("explanation") or {} + summary = explanation.get("summary") if isinstance(explanation, dict) else None + waite = explanation.get("waite") if isinstance(explanation, dict) else None + + extra_explanation = {} + if isinstance(explanation, dict): + extra_explanation = { + k: v for k, v in explanation.items() if k not in ("summary", "waite") + } + + keywords = details.get("keywords") or [] + reversed_keywords = details.get("reversed_keywords") or [] + + date_start_month = None + date_start_day = None + date_end_month = None + date_end_day = None + date_range = details.get("date_range") + if isinstance(date_range, tuple) and len(date_range) == 2: + start, end = date_range + if ( + isinstance(start, tuple) + and isinstance(end, tuple) + and len(start) == 2 + and len(end) == 2 + ): + date_start_month, date_start_day = start + date_end_month, date_end_day = end + + extras = {k: v for k, v in details.items() if k not in self._DETAIL_KEYS} + if extra_explanation: + extras["explanation"] = extra_explanation + + row = { + "explanation_summary": summary, + "explanation_waite": waite, + "interpretation": details.get("interpretation") or None, + "guidance": details.get("guidance") or None, + "date_start_month": date_start_month, + "date_start_day": date_start_day, + "date_end_month": date_end_month, + "date_end_day": date_end_day, + "degrees": details.get("degrees") or None, + "extra_json": json.dumps(self._to_json_compatible(extras)) + if extras + else None, + } + + kws_list = [] + for k in keywords: + kws_list.append({"keyword": k, "is_reversed": False}) + for k in reversed_keywords: + kws_list.append({"keyword": k, "is_reversed": True}) + + angels_list = [] + if details.get("day_angel"): + angels_list.append({"name": details["day_angel"], "is_night": False}) + if details.get("night_angel"): + angels_list.append({"name": details["night_angel"], "is_night": True}) + + demons_list = [] + if details.get("day_demon"): + demons_list.append({"name": details["day_demon"], "is_night": False}) + if details.get("night_demon"): + demons_list.append({"name": details["night_demon"], "is_night": True}) + + return row, kws_list, angels_list, demons_list + + def _details_to_row(self, details: Dict[str, Any]) -> Dict[str, Any]: + """Deprecated internal method, kept incase of direct calls but _prepare_rows is used now.""" + row, _ = self._prepare_rows("", details) + return row + + def _restore_details(self, details: Dict[str, Any]) -> Dict[str, Any]: + date_range = details.get("date_range") + if isinstance(date_range, list) and len(date_range) == 2: + start, end = date_range + if ( + isinstance(start, list) + and isinstance(end, list) + and len(start) == 2 + and len(end) == 2 + ): + details["date_range"] = (tuple(start), tuple(end)) + return details + + @classmethod + def _to_json_compatible(cls, value: Any) -> Any: + if isinstance(value, dict): + return {k: cls._to_json_compatible(v) for k, v in value.items()} + if isinstance(value, tuple): + return [cls._to_json_compatible(v) for v in value] + if isinstance(value, list): + return [cls._to_json_compatible(v) for v in value] + return value @staticmethod def key_to_roman(key: int) -> str: @@ -668,6 +1118,14 @@ class CardDetailsRegistry: "keywords": [], "reversed_keywords": [], "guidance": "", + # Date range: month/day (no year) — (start_month, start_day) to (end_month, end_day) + "date_range": ((6, 21), (7, 1)), + # Optional fields (can be populated later): + "degrees": 0-10, + "day_angel": "EIAEL", + "night_angel": "HABUIAH", + "day_demon": "Buer", + "night_demon": "Bifrons", }, "Three of Cups": { "explanation": { @@ -1152,8 +1610,74 @@ class CardDetailsRegistry: card.reversed_keywords = details.get("reversed_keywords", []) card.guidance = details.get("guidance", "") + # Optional temporal/astrological correspondences for minor cards + card.date_range = details.get("date_range") + card.degrees = details.get("degrees") + card.day_angel = details.get("day_angel") + card.night_angel = details.get("night_angel") + card.day_demon = details.get("day_demon") + card.night_demon = details.get("night_demon") + return True def __getitem__(self, card_name: str) -> Optional[Dict[str, Any]]: """Allow dict-like access: registry['Princess of Swords']""" return self.get(card_name) + + def find_positions_by_month_day(self, month: int, day: int) -> list: + """Return list of registry positions whose date_range includes the month/day. + + Args: + month: Month (1-12) + day: Day (1-31) + + Returns: + List of integer positions (1-78) matching the date + """ + from utils.dates import is_month_day_in_range + + matches: list[int] = [] + for pos, key in self._position_map.items(): + details = self._details.get(key) + if not details: + continue + dr = details.get("date_range") + if not dr: + continue + start, end = dr + try: + if is_month_day_in_range(month, day, start, end): + matches.append(pos) + except ValueError: + # Invalid date in registry or input; ignore + continue + return matches + + def find_cards_by_month_day(self, month: int, day: int, load_details: bool = True) -> list: + """Return a list of Card objects whose date_range includes month/day. + + If load_details is True, registry details will be loaded into returned Card objects. + """ + from tarot.deck import Deck + from .loader import load_card_details + + deck = Deck() + positions = self.find_positions_by_month_day(month, day) + cards = [] + for pos in positions: + # Positions are 1-indexed; deck.cards is list + try: + card = deck.cards[pos - 1] + except Exception: + continue + if load_details: + load_card_details(card, self) + cards.append(card) + return cards + + def find_cards_by_date_str(self, date_str: str, load_details: bool = True) -> list: + """Parse a date string and return matching Card objects.""" + from utils.dates import parse_month_day + + month, day = parse_month_day(date_str) + return self.find_cards_by_month_day(month, day, load_details) diff --git a/src/tarot/db_import.py b/src/tarot/db_import.py new file mode 100644 index 0000000..898a978 --- /dev/null +++ b/src/tarot/db_import.py @@ -0,0 +1,297 @@ +"""Helpers to import CSV/Excel data into the project's SQLite DB. + +This module provides a small, dependency-light importer that supports CSV out of the +box and Excel (.xlsx) when ``openpyxl`` is installed. It intentionally keeps the +mapping simple (header-driven) and provides safe deduplication for normalized +keyword/angel/demon tables. +""" +from __future__ import annotations + +import csv +import sqlite3 +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Tuple + +try: + from openpyxl import load_workbook # type: ignore +except Exception: # pragma: no cover - optional dependency + load_workbook = None # type: ignore + +from tarot.card.details import CardDetailsRegistry + + +# Simple helpers ----------------------------------------------------------- + +def _read_csv(path: Path, delimiter: str = ",") -> Iterable[Dict[str, str]]: + with path.open("r", encoding="utf-8-sig", newline="") as fh: + reader = csv.DictReader(fh, delimiter=delimiter) + for row in reader: + yield {k.strip(): (v.strip() if isinstance(v, str) else v) for k, v in row.items()} + + +def _read_excel(path: Path, sheet: Optional[str] = None) -> Iterable[Dict[str, str]]: + if load_workbook is None: + raise RuntimeError("openpyxl is required to import Excel files - pip install openpyxl") + wb = load_workbook(path, read_only=True, data_only=True) + ws = wb[sheet] if sheet and sheet in wb.sheetnames else wb[wb.sheetnames[0]] + it = ws.iter_rows(values_only=True) + try: + headers = next(it) + except StopIteration: + return + headers = [h.strip() if isinstance(h, str) else str(h) for h in headers] + for row in it: + yield {str(h).strip(): (str(v).strip() if v is not None else "") for h, v in zip(headers, row)} + + +def _get_conn(db_path: Optional[Path]) -> sqlite3.Connection: + if db_path is None: + reg = CardDetailsRegistry(use_db=True) + db_path = reg._db_path # use the same resolution logic + else: + # Ensure DB is seeded if missing + CardDetailsRegistry(use_db=True, db_path=db_path) + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + return conn + + +def _coerce_bool(val: Optional[str]) -> int: + if val is None: + return 0 + if isinstance(val, bool): + return 1 if val else 0 + s = str(val).strip().lower() + return 1 if s in {"1", "true", "yes", "y", "t"} else 0 + + +def _parse_date_to_parts(val: Optional[str]) -> Optional[Tuple[int, int]]: + if not val: + return None + s = str(val).strip() + if not s: + return None + # Accept formats: M/D, MM/DD, YYYY-MM-DD + if "/" in s: + parts = s.split("/") + if len(parts) >= 2: + try: + m = int(parts[0]) + d = int(parts[1]) + return (m, d) + except Exception: + return None + if "-" in s: + parts = s.split("-") + try: + m = int(parts[1]) + d = int(parts[2]) + return (m, d) + except Exception: + return None + return None + + +# Table-specific importers ----------------------------------------------- + +@dataclass +class ImportResult: + inserted: int = 0 + skipped: int = 0 + updated: int = 0 + + +def import_keywords(rows: Iterable[Dict[str, str]], db_path: Optional[Path] = None) -> ImportResult: + conn = _get_conn(db_path) + cur = conn.cursor() + res = ImportResult() + try: + for row in rows: + card_key = row.get("card_key") or row.get("registry_key") or row.get("card") + keyword = row.get("keyword") or row.get("kw") + is_rev = _coerce_bool(row.get("is_reversed") or row.get("reversed")) + if not card_key or not keyword: + res.skipped += 1 + continue + # deduplicate + exists = cur.execute( + "SELECT 1 FROM card_keywords WHERE card_key=? AND keyword=? AND is_reversed=?", + (card_key, keyword, is_rev), + ).fetchone() + if exists: + res.skipped += 1 + continue + cur.execute( + "INSERT INTO card_keywords (card_key, keyword, is_reversed) VALUES (?, ?, ?)", + (card_key, keyword, is_rev), + ) + res.inserted += 1 + conn.commit() + finally: + conn.close() + return res + + +def import_angels(rows: Iterable[Dict[str, str]], db_path: Optional[Path] = None) -> ImportResult: + conn = _get_conn(db_path) + cur = conn.cursor() + res = ImportResult() + try: + for row in rows: + card_key = row.get("card_key") or row.get("registry_key") or row.get("card") + name = row.get("name") or row.get("angel") + is_night = _coerce_bool(row.get("is_night") or row.get("night") or row.get("isNight")) + if not card_key or not name: + res.skipped += 1 + continue + exists = cur.execute( + "SELECT 1 FROM card_angels WHERE card_key=? AND name=? AND is_night=?", + (card_key, name, is_night), + ).fetchone() + if exists: + res.skipped += 1 + continue + cur.execute( + "INSERT INTO card_angels (card_key, name, is_night) VALUES (?, ?, ?)", + (card_key, name, is_night), + ) + res.inserted += 1 + conn.commit() + finally: + conn.close() + return res + + +def import_demons(rows: Iterable[Dict[str, str]], db_path: Optional[Path] = None) -> ImportResult: + conn = _get_conn(db_path) + cur = conn.cursor() + res = ImportResult() + try: + for row in rows: + card_key = row.get("card_key") or row.get("registry_key") or row.get("card") + name = row.get("name") or row.get("demon") + is_night = _coerce_bool(row.get("is_night") or row.get("night") or row.get("isNight")) + if not card_key or not name: + res.skipped += 1 + continue + exists = cur.execute( + "SELECT 1 FROM card_demons WHERE card_key=? AND name=? AND is_night=?", + (card_key, name, is_night), + ).fetchone() + if exists: + res.skipped += 1 + continue + cur.execute( + "INSERT INTO card_demons (card_key, name, is_night) VALUES (?, ?, ?)", + (card_key, name, is_night), + ) + res.inserted += 1 + conn.commit() + finally: + conn.close() + return res + + +def import_card_details(rows: Iterable[Dict[str, str]], db_path: Optional[Path] = None) -> ImportResult: + conn = _get_conn(db_path) + cur = conn.cursor() + res = ImportResult() + allowed = { + "registry_key", + "explanation_summary", + "explanation_waite", + "interpretation", + "guidance", + "date_start", + "date_end", + "date_start_month", + "date_start_day", + "date_end_month", + "date_end_day", + "degrees", + "extra_json", + } + try: + for row in rows: + if not row: + res.skipped += 1 + continue + reg_key = row.get("registry_key") or row.get("card_key") or row.get("card") + if not reg_key: + res.skipped += 1 + continue + # Process date short forms + ds = _parse_date_to_parts(row.get("date_start")) + de = _parse_date_to_parts(row.get("date_end")) + if ds and de: + row["date_start_month"], row["date_start_day"] = ds + row["date_end_month"], row["date_end_day"] = de + + # Build columns/values to upsert + cols = ["registry_key"] + vals = [reg_key] + up_cols = [] + up_vals = [] + for k, v in row.items(): + if k == "registry_key": + continue + if k not in allowed: + continue + if v is None or v == "": + continue + cols.append(k) + vals.append(v) + up_cols.append(f"{k}=excluded.{k}") + placeholders = ",".join("?" for _ in vals) + sql = ( + f"INSERT INTO card_details ({','.join(cols)}) VALUES ({placeholders}) " + f"ON CONFLICT(registry_key) DO UPDATE SET {', '.join(up_cols)}" + ) + cur.execute(sql, tuple(vals)) + # rough heuristic: treat as inserted if no row existed + if cur.rowcount > 0: + res.inserted += 1 + else: + res.updated += 1 + conn.commit() + finally: + conn.close() + return res + + +# Public helper ----------------------------------------------------------- + +def import_file( + path: Path, + table: str, + *, + db_path: Optional[Path] = None, + sheet: Optional[str] = None, + delimiter: str = ",", +) -> ImportResult: + """Import a CSV or Excel file into one of the supported tables. + + Supported tables: ``card_keywords``, ``card_angels``, ``card_demons``, ``card_details``. + + The importer is header-driven and will look for reasonable alternate column + names (e.g., ``card_key`` / ``card`` / ``registry_key``). + """ + if not path.exists(): + raise FileNotFoundError(path) + suffix = path.suffix.lower() + if suffix in {".xls", ".xlsx"}: + rows = _read_excel(path, sheet) + else: + rows = _read_csv(path, delimiter) + + table = table.lower() + if table == "card_keywords": + return import_keywords(rows, db_path) + if table == "card_angels": + return import_angels(rows, db_path) + if table == "card_demons": + return import_demons(rows, db_path) + if table == "card_details": + return import_card_details(rows, db_path) + raise ValueError(f"Unsupported table: {table}") diff --git a/src/tarot/deck/deck.py b/src/tarot/deck/deck.py index c72681f..dee9585 100644 --- a/src/tarot/deck/deck.py +++ b/src/tarot/deck/deck.py @@ -73,6 +73,17 @@ class Card: # Image path for custom deck images image_path: Optional[str] = None + # Optional astrological/temporal correspondences (used primarily for Minor Arcana) + # - date_range: ((start_month, start_day), (end_month, end_day)) + # - degrees: free-form degree information (string) + # - day/night angels and demons + date_range: Optional[Tuple[Tuple[int, int], Tuple[int, int]]] = None + degrees: Optional[str] = None + day_angel: Optional[str] = None + night_angel: Optional[str] = None + day_demon: Optional[str] = None + night_demon: Optional[str] = None + def __str__(self) -> str: return f"{self.number}. {self.name}" diff --git a/src/utils/dates.py b/src/utils/dates.py new file mode 100644 index 0000000..93d9f5a --- /dev/null +++ b/src/utils/dates.py @@ -0,0 +1,139 @@ +""" +Date utilities for month/day ranges (no year). + +Provides parsing and range-checking functions for month/day pairs +(e.g., (6,21) meaning June 21). + +Functions: +- parse_month_day: parse '6/21', '06-21', '2026-06-21', 'June 21', 'Jun 21' +- is_month_day_in_range: test whether a month/day lies within a start/end pair +- month_day_to_day_of_year: convert a month/day to day-of-year for comparisons +- format_month_day: display nicely + +Note: Uses a fixed non-leap reference year for day-of-year calculations. +""" + +import re +import calendar +from datetime import date +from typing import Tuple + +# Reference non-leap year used for day-of-year calculations +_REFERENCE_YEAR = 2001 + +_MONTH_NAME_MAP = { + 'jan': 1, + 'january': 1, + 'feb': 2, + 'february': 2, + 'mar': 3, + 'march': 3, + 'apr': 4, + 'april': 4, + 'may': 5, + 'jun': 6, + 'june': 6, + 'jul': 7, + 'july': 7, + 'aug': 8, + 'august': 8, + 'sep': 9, + 'sept': 9, + 'september': 9, + 'oct': 10, + 'october': 10, + 'nov': 11, + 'november': 11, + 'dec': 12, + 'december': 12, +} + + +def month_day_to_day_of_year(month: int, day: int) -> int: + """Convert (month, day) to day-of-year using a non-leap reference year.""" + try: + dt = date(_REFERENCE_YEAR, month, day) + except ValueError as e: + raise ValueError(f"Invalid month/day: {month}/{day}: {e}") + return dt.timetuple().tm_yday + + +def is_month_day_in_range( + month: int, day: int, start: Tuple[int, int], end: Tuple[int, int] +) -> bool: + """Return True if (month, day) is within the inclusive range start->end. + + Handles ranges that cross year boundary (e.g., Dec 20 -> Jan 5). + """ + day_num = month_day_to_day_of_year(month, day) + start_num = month_day_to_day_of_year(*start) + end_num = month_day_to_day_of_year(*end) + + if start_num <= end_num: + return start_num <= day_num <= end_num + # Wrap-around + return day_num >= start_num or day_num <= end_num + + +def parse_month_day(s: str) -> Tuple[int, int]: + """Parse a date string and return (month, day). + + Accepted formats: + - MM/DD or M/D + - MM-DD + - YYYY-MM-DD or YYYY/MM/DD (year ignored) + - MonthName D or Mon D (e.g., 'June 21' or 'Jun 21') + + Raises ValueError if cannot parse or the date is invalid. + """ + if not s or not isinstance(s, str): + raise ValueError("Empty date string") + + s = s.strip() + + # Numeric forms: MM/DD or MM-DD + m = re.match(r"^(?P\d{1,2})\s*[/-]\s*(?P\d{1,2})$", s) + if m: + month = int(m.group("m")) + day = int(m.group("d")) + # validate + month_day_to_day_of_year(month, day) + return month, day + + # ISO with year: YYYY-MM-DD or YYYY/MM/DD + m = re.match(r"^(?P\d{4})[/-](?P\d{1,2})[/-](?P\d{1,2})$", s) + if m: + month = int(m.group("m")) + day = int(m.group("d")) + month_day_to_day_of_year(month, day) + return month, day + + # Month name forms: 'June 21' or '21 June' + parts = re.split(r"[ ,]+", s) + if len(parts) == 2: + a, b = parts + # If a is month name + a_low = a.lower() + if a_low in _MONTH_NAME_MAP: + month = _MONTH_NAME_MAP[a_low] + day = int(b) + month_day_to_day_of_year(month, day) + return month, day + # If b is month name (e.g., '21 June') + b_low = b.lower() + if b_low in _MONTH_NAME_MAP: + month = _MONTH_NAME_MAP[b_low] + day = int(a) + month_day_to_day_of_year(month, day) + return month, day + + raise ValueError(f"Unrecognized date format: '{s}'") + + +def format_month_day(month: int, day: int) -> str: + """Return a short human-friendly representation like 'Jun 21'.""" + if not (1 <= month <= 12): + raise ValueError("month must be 1..12") + # Use abbreviated month name + month_name = calendar.month_abbr[month] + return f"{month_name} {day}" \ No newline at end of file diff --git a/src/utils/filter.py b/src/utils/filter.py index cc096f6..893d183 100644 --- a/src/utils/filter.py +++ b/src/utils/filter.py @@ -85,6 +85,64 @@ def _matches_filter(obj: Any, key: str, value: Any) -> bool: """ attr_value = getattr(obj, key, None) + # Special handling for date filters: accept strings like '6/21', 'Jun 21', + # ISO dates (YYYY-MM-DD), or tuples like (6, 21). The object's `date_range` + # (if present) will be checked; if not, fall back to registry lookup by + # numeric position when available (e.g., Card.number). + if key == "date": + from utils.dates import parse_month_day, is_month_day_in_range + + # Normalize the provided value(s) into a list of checks + values_to_check = [] + if isinstance(value, str) and "," in value: + values_to_check = [v.strip() for v in value.split(",")] + elif isinstance(value, (list, tuple)): + # Could be a single tuple (6,21) or list of such tuples/strings + if len(value) and isinstance(value[0], (list, tuple)): + values_to_check = list(value) + else: + values_to_check = [value] + else: + values_to_check = [value] + + # Obtain the object's date_range, fallback to registry if absent + dr = attr_value + if dr is None: + obj_num = getattr(obj, "number", None) + if obj_num is not None: + try: + from tarot.card.details import CardDetailsRegistry + + registry = CardDetailsRegistry() + details = registry.get_by_position(obj_num) + if details is not None: + dr = details.get("date_range") + except Exception: + dr = None + + if dr is None: + return False + + start, end = dr + + for check_value in values_to_check: + try: + if isinstance(check_value, (list, tuple)): + month, day = int(check_value[0]), int(check_value[1]) + elif isinstance(check_value, str): + month, day = parse_month_day(check_value) + else: + # Fallback: try to interpret as sequence + month, day = int(check_value[0]), int(check_value[1]) + + if is_month_day_in_range(month, day, start, end): + return True + except Exception: + # Ignore parse/validation errors per individual check + continue + + return False + if attr_value is None: return False diff --git a/tarot.db b/tarot.db new file mode 100644 index 0000000000000000000000000000000000000000..2223df3ef2e5a7c7afd51747242f3768de779eac GIT binary patch literal 106496 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