Kaynağa Gözat

Rename cal → store; extract _caldav.py; self-registering backends; StoreConfig

- room_cal/cal/ removed; replaced by room_cal/store/
- CalDAV implementation moved from cal/__init__ into store/_caldav.py
- store/_backend.py adds register_backend() decorator + _backend_factories registry
- CalDAVBackend and SQLiteBackend self-register via @register_backend and expose
  from_store_config(store_cfg, calendar_name) for factory construction
- Config.backend (flat string) replaced by Config.store: StoreConfig with nested
  caldav/sqlite sub-configs; store.active selects the active backend
- Calendar credentials now come from store.caldav config; caldav.env env-file
  fallback removed (no deployments to migrate)
- All import sites updated; store/__init__ re-exports Calendar/get_calendar for
  callers that need direct CalDAV access
ZiB 4 ay önce
ebeveyn
işleme
20a54049a9

+ 1 - 1
room_cal/_health.py

@@ -5,7 +5,7 @@ import random
 from dataclasses import dataclass
 from dataclasses import dataclass
 from datetime import datetime, timezone
 from datetime import datetime, timezone
 
 
-from room_cal.cal import Calendar, DEFAULT_CALENDAR_NAME
+from room_cal.store import Calendar, DEFAULT_CALENDAR_NAME
 from room_cal.service_api import ServiceAPI
 from room_cal.service_api import ServiceAPI
 from room_cal.service_api.types import ServiceAuthError
 from room_cal.service_api.types import ServiceAuthError
 from room_cal._utils import services, local_today
 from room_cal._utils import services, local_today

+ 44 - 2
room_cal/_utils.py

@@ -331,6 +331,47 @@ class CalendarEntry:
     )
     )
 
 
 
 
+@dataclass
+class CaldavStoreConfig:
+  url: str = ''
+  username: str = ''
+  password: str = ''
+  calendar_name: str = 'Room'
+
+  @classmethod
+  def from_dict(cls, d: dict) -> 'CaldavStoreConfig':
+    return cls(
+      url=d.get('url', ''),
+      username=d.get('username', ''),
+      password=d.get('password', ''),
+      calendar_name=d.get('calendar_name', 'Room'),
+    )
+
+
+@dataclass
+class SqliteStoreConfig:
+  db_path: str = ''
+
+  @classmethod
+  def from_dict(cls, d: dict) -> 'SqliteStoreConfig':
+    return cls(db_path=d.get('db_path', ''))
+
+
+@dataclass
+class StoreConfig:
+  active: str = 'caldav'
+  caldav: CaldavStoreConfig = field(default_factory=CaldavStoreConfig)
+  sqlite: SqliteStoreConfig = field(default_factory=SqliteStoreConfig)
+
+  @classmethod
+  def from_dict(cls, d: dict) -> 'StoreConfig':
+    return cls(
+      active=d.get('active', 'caldav'),
+      caldav=CaldavStoreConfig.from_dict(d.get('caldav', {})),
+      sqlite=SqliteStoreConfig.from_dict(d.get('sqlite', {})),
+    )
+
+
 @dataclass
 @dataclass
 class Config:
 class Config:
   global_: GlobalConfig = field(default_factory=GlobalConfig)
   global_: GlobalConfig = field(default_factory=GlobalConfig)
@@ -338,17 +379,18 @@ class Config:
   rules: RulesConfig = field(default_factory=RulesConfig)
   rules: RulesConfig = field(default_factory=RulesConfig)
   calendars: list[CalendarEntry] = field(default_factory=list)
   calendars: list[CalendarEntry] = field(default_factory=list)
   api_url: str | None = None
   api_url: str | None = None
-  backend: str = 'caldav'
+  store: StoreConfig = field(default_factory=StoreConfig)
 
 
   @classmethod
   @classmethod
   def from_dict(cls, d: dict) -> 'Config':
   def from_dict(cls, d: dict) -> 'Config':
+    store_d = d.get('store', {})
     return cls(
     return cls(
       global_=GlobalConfig.from_dict(d.get('global', {})),
       global_=GlobalConfig.from_dict(d.get('global', {})),
       services={k: ServiceConfig.from_dict(k, v) for k, v in d.get('services', {}).items()},
       services={k: ServiceConfig.from_dict(k, v) for k, v in d.get('services', {}).items()},
       rules=RulesConfig.from_dict(d.get('rules', {})),
       rules=RulesConfig.from_dict(d.get('rules', {})),
       calendars=[CalendarEntry.from_dict(e) for e in d.get('calendars', [])],
       calendars=[CalendarEntry.from_dict(e) for e in d.get('calendars', [])],
       api_url=d.get('api_url'),
       api_url=d.get('api_url'),
-      backend=d.get('backend', 'caldav'),
+      store=StoreConfig.from_dict(store_d),
     )
     )
 
 
 
 

+ 1 - 1
room_cal/actions/diff.py

@@ -2,7 +2,7 @@ import asyncio
 from datetime import date, timedelta
 from datetime import date, timedelta
 from typing import TYPE_CHECKING
 from typing import TYPE_CHECKING
 
 
-from room_cal.cal import get_backend
+from room_cal.store import get_backend
 from room_cal.closed import Closed
 from room_cal.closed import Closed
 from room_cal.discount import Discount
 from room_cal.discount import Discount
 from room_cal.pricing.price_calendar import CalPrice
 from room_cal.pricing.price_calendar import CalPrice

+ 1 - 1
room_cal/actions/push_prices.py

@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING
 
 
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
 
 
-from room_cal.cal import get_backend
+from room_cal.store import get_backend
 from room_cal._utils import safe_json_loads, filter_events, services as _services
 from room_cal._utils import safe_json_loads, filter_events, services as _services
 from room_cal.pricing.price_calendar import PriceData
 from room_cal.pricing.price_calendar import PriceData
 
 

+ 1 - 1
room_cal/api/_routes/closed.py

@@ -1,7 +1,7 @@
 from datetime import date
 from datetime import date
 from fastapi import APIRouter, Body
 from fastapi import APIRouter, Body
 from room_cal.closed import Closed, ClosedData
 from room_cal.closed import Closed, ClosedData
-from room_cal.cal import DEFAULT_CALENDAR_NAME
+from room_cal.store import DEFAULT_CALENDAR_NAME
 from room_cal._utils import safe_json_loads, iter_dates
 from room_cal._utils import safe_json_loads, iter_dates
 from room_cal.api._routes import API_SOURCE
 from room_cal.api._routes import API_SOURCE
 
 

+ 1 - 1
room_cal/api/_routes/discounts.py

@@ -1,7 +1,7 @@
 from datetime import date
 from datetime import date
 from fastapi import APIRouter, HTTPException, Body
 from fastapi import APIRouter, HTTPException, Body
 from room_cal.discount import Discount, DiscountData, DiscountType
 from room_cal.discount import Discount, DiscountData, DiscountType
-from room_cal.cal import DEFAULT_CALENDAR_NAME
+from room_cal.store import DEFAULT_CALENDAR_NAME
 from room_cal._utils import safe_json_loads, iter_dates
 from room_cal._utils import safe_json_loads, iter_dates
 from room_cal.api._routes import API_SOURCE
 from room_cal.api._routes import API_SOURCE
 
 

+ 1 - 1
room_cal/api/_routes/prices.py

@@ -1,7 +1,7 @@
 from datetime import date
 from datetime import date
 from fastapi import APIRouter, Body, HTTPException
 from fastapi import APIRouter, Body, HTTPException
 from room_cal.pricing import CalPrice
 from room_cal.pricing import CalPrice
-from room_cal.cal import DEFAULT_CALENDAR_NAME
+from room_cal.store import DEFAULT_CALENDAR_NAME
 
 
 router = APIRouter()
 router = APIRouter()
 
 

+ 1 - 1
room_cal/api/_routes/reload.py

@@ -1,5 +1,5 @@
 from fastapi import APIRouter
 from fastapi import APIRouter
-from room_cal.cal import clear_caches
+from room_cal.store import clear_caches
 
 
 router = APIRouter()
 router = APIRouter()
 
 

+ 1 - 1
room_cal/api/_routes/reservations.py

@@ -1,7 +1,7 @@
 from datetime import date
 from datetime import date
 from fastapi import APIRouter
 from fastapi import APIRouter
 from room_cal.reservations import Reservation
 from room_cal.reservations import Reservation
-from room_cal.cal import DEFAULT_CALENDAR_NAME
+from room_cal.store import DEFAULT_CALENDAR_NAME
 from room_cal._utils import safe_json_loads, iter_dates
 from room_cal._utils import safe_json_loads, iter_dates
 
 
 router = APIRouter()
 router = APIRouter()

+ 1 - 1
room_cal/busy/__init__.py

@@ -1,5 +1,5 @@
 from datetime import date
 from datetime import date
-from room_cal.cal import StoreBacked
+from room_cal.store import StoreBacked
 from room_cal._utils import get_config
 from room_cal._utils import get_config
 
 
 DEFAULT_CALENDAR_NAME = 'Busy'
 DEFAULT_CALENDAR_NAME = 'Busy'

+ 1 - 1
room_cal/closed/__init__.py

@@ -1,7 +1,7 @@
 import json
 import json
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
 from datetime import date
 from datetime import date
-from room_cal.cal import StoreBacked, DEFAULT_CALENDAR_NAME
+from room_cal.store import StoreBacked, DEFAULT_CALENDAR_NAME
 from room_cal._utils import EventSource, safe_json_loads, filter_events, services as _services, service_name, source_line
 from room_cal._utils import EventSource, safe_json_loads, filter_events, services as _services, service_name, source_line
 
 
 _PREFIX = 'Closed: '
 _PREFIX = 'Closed: '

+ 12 - 1
room_cal/config-all.json

@@ -41,7 +41,18 @@
       }
       }
     }
     }
   },
   },
-  "backend": "caldav",
+  "store": {
+    "active": "caldav",
+    "caldav": {
+      "url": "",
+      "username": "",
+      "password": "",
+      "calendar_name": "Room"
+    },
+    "sqlite": {
+      "db_path": ""
+    }
+  },
   "global": {
   "global": {
     "taxes": {
     "taxes": {
       "sales": 0.157
       "sales": 0.157

+ 1 - 1
room_cal/discount/__init__.py

@@ -2,7 +2,7 @@ import json
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
 from datetime import date
 from datetime import date
 from enum import StrEnum
 from enum import StrEnum
-from room_cal.cal import StoreBacked, DEFAULT_CALENDAR_NAME
+from room_cal.store import StoreBacked, DEFAULT_CALENDAR_NAME
 from room_cal._utils import safe_json_loads, filter_events, source_line
 from room_cal._utils import safe_json_loads, filter_events, source_line
 
 
 _PREFIX = 'Discount: '
 _PREFIX = 'Discount: '

+ 2 - 2
room_cal/ical_feeds/__init__.py

@@ -2,7 +2,7 @@ import urllib.request
 from datetime import date, datetime, timedelta
 from datetime import date, datetime, timedelta
 
 
 from room_cal._utils import CalendarEntry, EventSource, get_config
 from room_cal._utils import CalendarEntry, EventSource, get_config
-from room_cal.cal._backend import EventRecord
+from room_cal.store._backend import EventRecord
 
 
 _source = EventSource('ical_feeds')
 _source = EventSource('ical_feeds')
 
 
@@ -59,7 +59,7 @@ def _parse_ical(raw_bytes: bytes, calendar_name: str) -> list[EventRecord]:
 
 
 
 
 async def _fetch_caldav(entry: CalendarEntry) -> list[EventRecord]:
 async def _fetch_caldav(entry: CalendarEntry) -> list[EventRecord]:
-  from room_cal.cal import Calendar
+  from room_cal.store import Calendar
 
 
   calendar_name = entry.caldav
   calendar_name = entry.caldav
   today = datetime.today()
   today = datetime.today()

+ 2 - 2
room_cal/mcp/__init__.py

@@ -2,7 +2,7 @@ import asyncio
 from datetime import date
 from datetime import date
 from fastmcp import FastMCP
 from fastmcp import FastMCP
 from room_cal._utils import services as _services, safe_json_loads, iter_dates
 from room_cal._utils import services as _services, safe_json_loads, iter_dates
-from room_cal.cal import DEFAULT_CALENDAR_NAME
+from room_cal.store import DEFAULT_CALENDAR_NAME
 from room_cal._health import caldav_checks, credentials_checks
 from room_cal._health import caldav_checks, credentials_checks
 from room_cal.pricing import CalPrice
 from room_cal.pricing import CalPrice
 from room_cal.pricing.calc import (
 from room_cal.pricing.calc import (
@@ -168,7 +168,7 @@ async def delete_discount(target_date: str) -> None:
 @mcp.tool()
 @mcp.tool()
 async def reload_caches() -> dict:
 async def reload_caches() -> dict:
   """Clear all in-memory CalDAV event caches so the next request re-fetches from the server. Returns {cleared_calendars}."""
   """Clear all in-memory CalDAV event caches so the next request re-fetches from the server. Returns {cleared_calendars}."""
-  from room_cal.cal import clear_caches
+  from room_cal.store import clear_caches
   count = clear_caches()
   count = clear_caches()
   return {'cleared_calendars': count}
   return {'cleared_calendars': count}
 
 

+ 1 - 1
room_cal/pricing/price_calendar.py

@@ -1,7 +1,7 @@
 import json
 import json
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
 from datetime import date
 from datetime import date
-from room_cal.cal import StoreBacked, DEFAULT_CALENDAR_NAME
+from room_cal.store import StoreBacked, DEFAULT_CALENDAR_NAME
 from room_cal._utils import safe_json_loads, filter_events, services as _services, get_config
 from room_cal._utils import safe_json_loads, filter_events, services as _services, get_config
 from room_cal.discount import DiscountType
 from room_cal.discount import DiscountType
 from . import calc
 from . import calc

+ 1 - 1
room_cal/reservations/__init__.py

@@ -2,7 +2,7 @@ import json
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
 from datetime import date, datetime
 from datetime import date, datetime
 from zoneinfo import ZoneInfo
 from zoneinfo import ZoneInfo
-from room_cal.cal import StoreBacked, DEFAULT_CALENDAR_NAME
+from room_cal.store import StoreBacked, DEFAULT_CALENDAR_NAME
 from room_cal._utils import EventSource, safe_json_loads, filter_events, service_name, get_config
 from room_cal._utils import EventSource, safe_json_loads, filter_events, service_name, get_config
 from room_cal._mnemonic import wifi_key as _gen_wifi_key, door_code as _gen_door_code
 from room_cal._mnemonic import wifi_key as _gen_wifi_key, door_code as _gen_door_code
 
 

+ 88 - 0
room_cal/store/__init__.py

@@ -0,0 +1,88 @@
+import uuid
+from datetime import date, datetime, timedelta
+
+from room_cal.store._backend import EventBackend, EventRecord
+from room_cal.store._caldav import Calendar, DEFAULT_CALENDAR_NAME, get_calendar
+from room_cal.store import _caldav, _sqlite  # trigger self-registration
+
+_backend_instances: dict[tuple[str, str], EventBackend] = {}
+
+
+def get_backend(name: str = DEFAULT_CALENDAR_NAME) -> EventBackend:
+  """Return the shared EventBackend for the given store name.
+
+  The backend type is determined by store.active in config.json
+  ('caldav' by default, 'sqlite' for local SQLite storage).
+  """
+  from room_cal._utils import get_config
+  from room_cal.store._backend import _backend_factories
+  cfg = get_config()
+  active = cfg.store.active
+  key = (active, name)
+  if key not in _backend_instances:
+    factory = _backend_factories.get(active)
+    if factory is None:
+      raise ValueError(f'Unknown store backend: {active!r}')
+    _backend_instances[key] = factory.from_store_config(cfg.store, name)
+  return _backend_instances[key]
+
+
+def clear_caches() -> int:
+  """Clear event caches for all active backend instances and raw Calendar objects.
+
+  Returns the total number of objects cleared.
+  """
+  cleared = 0
+  for backend in _backend_instances.values():
+    if hasattr(backend, 'clear_cache'):
+      backend.clear_cache()
+      cleared += 1
+  for cal in _caldav._calendar_registry.values():
+    cal._event_cache.clear()
+    cal._cached_url = None
+    cleared += 1
+  return cleared
+
+
+class StoreBacked:
+  """Base for objects backed by a single named event store."""
+  def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *,
+               backend: 'EventBackend | None' = None):
+    self._calendar_name = calendar_name
+    self._backend: EventBackend = backend if backend is not None else get_backend(calendar_name)
+
+  async def _fetch_date_range(self, start_date: date, end_date: date) -> list[EventRecord]:
+    start = datetime(start_date.year, start_date.month, start_date.day)
+    end = datetime(end_date.year, end_date.month, end_date.day) + timedelta(days=1)
+    return await self._backend.fetch_range(start, end)
+
+  async def _create_all_day(self, start_date: date, title: str, description: str = '',
+                             x_data: str | None = None, *,
+                             end_date: date | None = None) -> EventRecord:
+    end = end_date if end_date is not None else start_date + timedelta(days=1)
+    record = EventRecord(
+      uid=str(uuid.uuid4()),
+      start=datetime(start_date.year, start_date.month, start_date.day),
+      end=datetime(end.year, end.month, end.day),
+      all_day=True,
+      title=title,
+      description=description,
+      x_data=x_data,
+    )
+    return await self._backend.create(record)
+
+  async def _create_timed(self, start: datetime, end: datetime, title: str,
+                           description: str = '', x_data: str | None = None) -> EventRecord:
+    record = EventRecord(
+      uid=str(uuid.uuid4()),
+      start=start,
+      end=end,
+      all_day=False,
+      title=title,
+      description=description,
+      x_data=x_data,
+    )
+    return await self._backend.create(record)
+
+
+CalendarBacked = StoreBacked  # backward compat alias

+ 1 - 1
room_cal/cal/__main__.py → room_cal/store/__main__.py

@@ -1,6 +1,6 @@
 import asyncio
 import asyncio
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
-from room_cal.cal import Calendar
+from room_cal.store._caldav import Calendar
 
 
 
 
 async def main():
 async def main():

+ 11 - 0
room_cal/cal/_backend.py → room_cal/store/_backend.py

@@ -21,3 +21,14 @@ class EventBackend(Protocol):
   async def update(self, uid: str, *, title: str | None = None,
   async def update(self, uid: str, *, title: str | None = None,
                    description: str | None = None, x_data: str | None = None) -> EventRecord: ...
                    description: str | None = None, x_data: str | None = None) -> EventRecord: ...
   async def delete(self, uid: str) -> None: ...
   async def delete(self, uid: str) -> None: ...
+
+
+_backend_factories: dict[str, type] = {}
+
+
+def register_backend(type_name: str):
+  """Class decorator that registers a backend under the given type name."""
+  def decorator(cls):
+    _backend_factories[type_name] = cls
+    return cls
+  return decorator

+ 24 - 105
room_cal/cal/__init__.py → room_cal/store/_caldav.py

@@ -1,28 +1,15 @@
 import asyncio
 import asyncio
 import logging
 import logging
-import os
 import uuid
 import uuid
 from caldav import aio
 from caldav import aio
 from datetime import date, datetime, timedelta, timezone
 from datetime import date, datetime, timedelta, timezone
 from icalendar import Calendar as _iCal, Event as _iEvent
 from icalendar import Calendar as _iCal, Event as _iEvent
 
 
-from room_cal._utils import data_home, read_env
-from room_cal.cal._backend import EventBackend, EventRecord
+from room_cal.store._backend import EventRecord, register_backend
 
 
 _log = logging.getLogger(__name__)
 _log = logging.getLogger(__name__)
 
 
 
 
-def _caldav_config() -> dict[str, str]:
-  env_file = data_home() / 'caldav.env'
-  env = {**read_env(env_file), **os.environ}
-  url = env.get('CALDAV_URL', '')
-  username = env.get('CALDAV_USERNAME', '')
-  password = env.get('CALDAV_PASSWORD', '')
-  if not url:
-    raise ValueError(f'CALDAV_URL must be set in {env_file}')
-  return {'url': url, 'username': username, 'password': password}
-
-
 def _naive(dt) -> datetime:
 def _naive(dt) -> datetime:
   if isinstance(dt, datetime):
   if isinstance(dt, datetime):
     return dt.replace(tzinfo=None)
     return dt.replace(tzinfo=None)
@@ -54,12 +41,10 @@ def _parse_event(caldav_event) -> dict:
     'title':        str(vevent.get('SUMMARY', '')),
     'title':        str(vevent.get('SUMMARY', '')),
     'description':  str(vevent.get('DESCRIPTION', '')),
     'description':  str(vevent.get('DESCRIPTION', '')),
     'x_data':       x_data,
     'x_data':       x_data,
-    # sync fields
     'uid':          str(vevent.get('UID', '')),
     'uid':          str(vevent.get('UID', '')),
     'url':          str(caldav_event.url),
     'url':          str(caldav_event.url),
     'etag':         getattr(caldav_event, 'etag', None),
     'etag':         getattr(caldav_event, 'etag', None),
     'recurrence_id': _naive(recurrence_id.dt) if recurrence_id else None,
     'recurrence_id': _naive(recurrence_id.dt) if recurrence_id else None,
-    # escape hatches
     '_vevent':      vevent,
     '_vevent':      vevent,
     '_raw_event':   caldav_event,
     '_raw_event':   caldav_event,
   }
   }
@@ -89,8 +74,9 @@ DEFAULT_CALENDAR_NAME = 'Room'
 
 
 
 
 class Calendar:
 class Calendar:
-  def __init__(self, calendar_name=DEFAULT_CALENDAR_NAME):
+  def __init__(self, calendar_name=DEFAULT_CALENDAR_NAME, *, url='', username='', password=''):
     self._calendar_name = calendar_name
     self._calendar_name = calendar_name
+    self._connection = {'url': url, 'username': username, 'password': password}
     self._cached_url = None
     self._cached_url = None
     self._event_cache: dict[tuple, list] = {}
     self._event_cache: dict[tuple, list] = {}
     self._inflight: dict[tuple, asyncio.Task] = {}
     self._inflight: dict[tuple, asyncio.Task] = {}
@@ -99,8 +85,13 @@ class Calendar:
   def calendar_name(self) -> str:
   def calendar_name(self) -> str:
     return self._calendar_name
     return self._calendar_name
 
 
+  def _resolved_connection(self) -> dict:
+    if not self._connection.get('url'):
+      raise ValueError('CALDAV_URL must be set via store.caldav.url in config.json')
+    return self._connection
+
   async def _client(self):
   async def _client(self):
-    return await aio.get_async_davclient(**_caldav_config())
+    return await aio.get_async_davclient(**self._resolved_connection())
 
 
   async def _get_calendar(self, client):
   async def _get_calendar(self, client):
     if self._cached_url is not None:
     if self._cached_url is not None:
@@ -127,10 +118,6 @@ class Calendar:
     return events
     return events
 
 
   async def fetch_date_range(self, start_date: date, end_date: date) -> list[dict]:
   async def fetch_date_range(self, start_date: date, end_date: date) -> list[dict]:
-    """Fetch events covering start_date through end_date (inclusive).
-
-    Results are cached; concurrent calls for the same range share one network request.
-    """
     key = (start_date, end_date)
     key = (start_date, end_date)
     if key in self._event_cache:
     if key in self._event_cache:
       return self._event_cache[key]
       return self._event_cache[key]
@@ -207,25 +194,14 @@ class Calendar:
     return _parse_event(raw)
     return _parse_event(raw)
 
 
 
 
-_registry: dict[str, 'Calendar'] = {}
+_calendar_registry: dict[str, Calendar] = {}
 
 
 
 
-def get_calendar(name: str = DEFAULT_CALENDAR_NAME) -> 'Calendar':
+def get_calendar(name: str = DEFAULT_CALENDAR_NAME) -> Calendar:
   """Return the shared Calendar instance for the given name, creating it on first call."""
   """Return the shared Calendar instance for the given name, creating it on first call."""
-  if name not in _registry:
-    _registry[name] = Calendar(name)
-  return _registry[name]
-
-
-def clear_caches() -> int:
-  """Clear event caches and cached URLs for all registered Calendar instances.
-
-  Returns the number of calendars cleared.
-  """
-  for cal in _registry.values():
-    cal._event_cache.clear()
-    cal._cached_url = None
-  return len(_registry)
+  if name not in _calendar_registry:
+    _calendar_registry[name] = Calendar(name)
+  return _calendar_registry[name]
 
 
 
 
 # ── CalDAVBackend ─────────────────────────────────────────────────────────────
 # ── CalDAVBackend ─────────────────────────────────────────────────────────────
@@ -243,10 +219,20 @@ def _dict_to_record(d: dict) -> EventRecord:
   )
   )
 
 
 
 
+@register_backend('caldav')
 class CalDAVBackend:
 class CalDAVBackend:
   def __init__(self, cal: Calendar):
   def __init__(self, cal: Calendar):
     self._cal = cal
     self._cal = cal
 
 
+  @classmethod
+  def from_store_config(cls, store_cfg, calendar_name: str) -> 'CalDAVBackend':
+    cal_cfg = store_cfg.caldav
+    return cls(Calendar(calendar_name, url=cal_cfg.url, username=cal_cfg.username, password=cal_cfg.password))
+
+  def clear_cache(self):
+    self._cal._event_cache.clear()
+    self._cal._cached_url = None
+
   async def fetch_range(self, start: datetime, end: datetime) -> list[EventRecord]:
   async def fetch_range(self, start: datetime, end: datetime) -> list[EventRecord]:
     events = await self._cal.fetch_events(start, end)
     events = await self._cal.fetch_events(start, end)
     return [_dict_to_record(e) for e in events]
     return [_dict_to_record(e) for e in events]
@@ -301,70 +287,3 @@ class CalDAVBackend:
       await raw.delete()
       await raw.delete()
     self._cal._event_cache.clear()
     self._cal._event_cache.clear()
     _log.info('delete uid=%s', uid)
     _log.info('delete uid=%s', uid)
-
-
-# ── Backend registry + factory ────────────────────────────────────────────────
-
-_backend_registry: dict[str, CalDAVBackend] = {}
-
-
-def get_backend(name: str = DEFAULT_CALENDAR_NAME) -> EventBackend:
-  """Return the shared EventBackend for the given calendar name.
-
-  Uses the backend type configured via the 'backend' key in config.json
-  ('caldav' by default, 'sqlite' for local SQLite storage).
-  """
-  from room_cal._utils import get_config, data_home as _data_home
-  cfg = get_config()
-  if cfg.backend == 'sqlite':
-    from room_cal.cal._sqlite import SQLiteBackend
-    return SQLiteBackend(_data_home() / 'room_cal.db', name)
-  if name not in _backend_registry:
-    _backend_registry[name] = CalDAVBackend(get_calendar(name))
-  return _backend_registry[name]
-
-
-# ── StoreBacked ───────────────────────────────────────────────────────────────
-
-class StoreBacked:
-  """Base for objects backed by a single named event backend."""
-  def __init__(self, calendar_name: str = DEFAULT_CALENDAR_NAME, *,
-               backend: 'EventBackend | None' = None):
-    self._calendar_name = calendar_name
-    self._backend: EventBackend = backend if backend is not None else get_backend(calendar_name)
-
-  async def _fetch_date_range(self, start_date: date, end_date: date) -> list[EventRecord]:
-    start = datetime(start_date.year, start_date.month, start_date.day)
-    end = datetime(end_date.year, end_date.month, end_date.day) + timedelta(days=1)
-    return await self._backend.fetch_range(start, end)
-
-  async def _create_all_day(self, start_date: date, title: str, description: str = '',
-                             x_data: str | None = None, *,
-                             end_date: date | None = None) -> EventRecord:
-    end = end_date if end_date is not None else start_date + timedelta(days=1)
-    record = EventRecord(
-      uid=str(uuid.uuid4()),
-      start=datetime(start_date.year, start_date.month, start_date.day),
-      end=datetime(end.year, end.month, end.day),
-      all_day=True,
-      title=title,
-      description=description,
-      x_data=x_data,
-    )
-    return await self._backend.create(record)
-
-  async def _create_timed(self, start: datetime, end: datetime, title: str,
-                           description: str = '', x_data: str | None = None) -> EventRecord:
-    record = EventRecord(
-      uid=str(uuid.uuid4()),
-      start=start,
-      end=end,
-      all_day=False,
-      title=title,
-      description=description,
-      x_data=x_data,
-    )
-    return await self._backend.create(record)
-
-
-CalendarBacked = StoreBacked  # backward compat alias

+ 9 - 1
room_cal/cal/_sqlite.py → room_cal/store/_sqlite.py

@@ -2,7 +2,7 @@ import logging
 from datetime import datetime
 from datetime import datetime
 from pathlib import Path
 from pathlib import Path
 
 
-from room_cal.cal._backend import EventRecord
+from room_cal.store._backend import EventRecord, register_backend
 
 
 _log = logging.getLogger(__name__)
 _log = logging.getLogger(__name__)
 
 
@@ -23,11 +23,19 @@ CREATE TABLE IF NOT EXISTS events (
 _COLS = 'uid, calendar_name, start, end, all_day, title, description, x_data'
 _COLS = 'uid, calendar_name, start, end, all_day, title, description, x_data'
 
 
 
 
+@register_backend('sqlite')
 class SQLiteBackend:
 class SQLiteBackend:
   def __init__(self, db_path: Path, calendar_name: str):
   def __init__(self, db_path: Path, calendar_name: str):
     self._db_path = db_path
     self._db_path = db_path
     self._calendar_name = calendar_name
     self._calendar_name = calendar_name
 
 
+  @classmethod
+  def from_store_config(cls, store_cfg, calendar_name: str) -> 'SQLiteBackend':
+    from room_cal._utils import data_home
+    db_path_str = store_cfg.sqlite.db_path
+    db_path = Path(db_path_str) if db_path_str else data_home() / 'store.db'
+    return cls(db_path, calendar_name)
+
   def _to_record(self, row) -> EventRecord:
   def _to_record(self, row) -> EventRecord:
     uid, _, start, end, all_day, title, description, x_data = row
     uid, _, start, end, all_day, title, description, x_data = row
     return EventRecord(
     return EventRecord(

+ 1 - 1
room_cal/user_cal/__init__.py

@@ -1,6 +1,6 @@
 import json
 import json
 from datetime import date
 from datetime import date
-from room_cal.cal import StoreBacked
+from room_cal.store import StoreBacked
 from room_cal._utils import EventSource, safe_json_loads, services as _services, service_name
 from room_cal._utils import EventSource, safe_json_loads, services as _services, service_name
 
 
 DEFAULT_CALENDAR_NAME = 'House'
 DEFAULT_CALENDAR_NAME = 'House'

+ 1 - 1
room_cal/workflows/closed.py

@@ -3,7 +3,7 @@ from typing import Callable
 from fastapi import APIRouter, BackgroundTasks
 from fastapi import APIRouter, BackgroundTasks
 from pydantic import BaseModel
 from pydantic import BaseModel
 
 
-from room_cal.cal import DEFAULT_CALENDAR_NAME
+from room_cal.store import DEFAULT_CALENDAR_NAME
 from room_cal.closed import Closed, ClosedData
 from room_cal.closed import Closed, ClosedData
 from room_cal.actions.fetch_closed import fetch_closed
 from room_cal.actions.fetch_closed import fetch_closed
 from room_cal.actions.push_closed import push_closed
 from room_cal.actions.push_closed import push_closed

+ 1 - 1
room_cal/workflows/prices.py

@@ -3,7 +3,7 @@ from typing import Callable, TYPE_CHECKING
 from fastapi import APIRouter, BackgroundTasks, HTTPException
 from fastapi import APIRouter, BackgroundTasks, HTTPException
 from pydantic import BaseModel
 from pydantic import BaseModel
 
 
-from room_cal.cal import DEFAULT_CALENDAR_NAME
+from room_cal.store import DEFAULT_CALENDAR_NAME
 from room_cal.pricing import CalPrice
 from room_cal.pricing import CalPrice
 from room_cal.discount import Discount, DiscountData, DiscountType
 from room_cal.discount import Discount, DiscountData, DiscountType
 from room_cal.actions.apply_discounts import apply_discounts
 from room_cal.actions.apply_discounts import apply_discounts

+ 1 - 1
scripts/patch_reservation_codes.py

@@ -12,7 +12,7 @@ import json
 import sys
 import sys
 from datetime import date
 from datetime import date
 
 
-from room_cal.cal import Calendar, DEFAULT_CALENDAR_NAME
+from room_cal.store import Calendar, DEFAULT_CALENDAR_NAME
 from room_cal._utils import safe_json_loads, filter_events, service_name
 from room_cal._utils import safe_json_loads, filter_events, service_name
 
 
 _PREFIX = 'Guest: '
 _PREFIX = 'Guest: '

+ 1 - 1
scripts/rename_source.py

@@ -29,7 +29,7 @@ from datetime import date
 
 
 sys.path.insert(0, str(__import__('pathlib').Path(__file__).parent.parent))
 sys.path.insert(0, str(__import__('pathlib').Path(__file__).parent.parent))
 
 
-from room_cal.cal import Calendar
+from room_cal.store import Calendar
 
 
 
 
 async def run(
 async def run(

+ 1 - 1
tests/test_api.py

@@ -111,7 +111,7 @@ async def test_action_dispatch_unknown_action(client):
 
 
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 async def test_reload_clears_caches(client):
 async def test_reload_clears_caches(client):
-  import room_cal.cal as _cal
+  import room_cal.store as _cal
   cal = _cal.get_calendar()
   cal = _cal.get_calendar()
   cal._event_cache[('fake_key',)] = []
   cal._event_cache[('fake_key',)] = []
   cal._cached_url = 'http://example.com/cal'
   cal._cached_url = 'http://example.com/cal'

+ 1 - 1
tests/test_ical_feeds.py

@@ -1,7 +1,7 @@
 import pytest
 import pytest
 from room_cal._utils import CalendarEntry
 from room_cal._utils import CalendarEntry
 from room_cal.ical_feeds import fetch_calendar
 from room_cal.ical_feeds import fetch_calendar
-from room_cal.cal._backend import EventRecord
+from room_cal.store._backend import EventRecord
 
 
 _FIRE_ENTRY = CalendarEntry(name='Fire', url='webcal://cal.4114.us/fire.ics')
 _FIRE_ENTRY = CalendarEntry(name='Fire', url='webcal://cal.4114.us/fire.ics')
 _HOUSE_ENTRY = CalendarEntry(name='House', caldav='House')
 _HOUSE_ENTRY = CalendarEntry(name='House', caldav='House')

+ 1 - 1
tests/test_utils.py

@@ -2,7 +2,7 @@ import pytest
 from datetime import date, datetime, timedelta
 from datetime import date, datetime, timedelta
 from pathlib import Path
 from pathlib import Path
 from room_cal._utils import safe_json_loads, iter_dates, event_dates, filter_events, read_env
 from room_cal._utils import safe_json_loads, iter_dates, event_dates, filter_events, read_env
-from room_cal.cal._backend import EventRecord
+from room_cal.store._backend import EventRecord
 
 
 
 
 # --- safe_json_loads ---
 # --- safe_json_loads ---