| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365 |
- """CLI dispatcher for room_cal sync/refresh/diff actions.
- Usage:
- room-cal fetch-reservations 2026-07-01 2026-07-31
- room-cal fetch-closed 2026-07-01
- room-cal push-closed 2026-07-01 2026-07-31
- room-cal fetch-user-cal 2026-07-01 2026-07-31
- room-cal apply-rules 2026-07-01 2026-07-31 [--pipeline-name this_month]
- room-cal fetch-feed 2026-07-01 2026-07-31 [--feed-name Fire] [--title "Block"]
- room-cal fill-prices 2026-07-01 2026-07-31 [--weekday 150] [--weekend 200]
- room-cal fetch-prices 2026-07-01 2026-07-31 [--service airbnb]
- room-cal push-prices 2026-07-01 2026-07-31
- room-cal apply-discounts 2026-07-01 2026-07-31
- room-cal push-busy 2026-07-01 2026-07-31
- room-cal local-sync 2026-07-01 2026-07-31
- room-cal local-refresh 2026-07-01 2026-07-31
- room-cal diff 2026-07-01 2026-07-31
- """
- import argparse
- import asyncio
- from datetime import date
- def _date(s: str) -> date:
- return date.fromisoformat(s)
- async def _fetch_reservations(start: date, end: date, args) -> None:
- from room_cal.actions.fetch_reservations import fetch_reservations
- result = await fetch_reservations(start, end)
- for r in result.data:
- print(f" {r['service']} {r['checkin_date']} – {r['checkout_date']}: {r['guest_name']}")
- print(f" {len(result.data)} reservation(s) synced" + (" (changed)" if result.changed else ""))
- async def _fetch_closed(start: date, end: date, args) -> None:
- from room_cal.actions.fetch_closed import fetch_closed
- result = await fetch_closed(start, end)
- for r in result.data:
- print(f" {r['date']}: {', '.join(r['closed_services'])}")
- print(f" {len(result.data)} closed event(s) synced" + (" (changed)" if result.changed else ""))
- async def _fetch_user_cal(start: date, end: date, args) -> None:
- from room_cal.actions.fetch_user_cal import fetch_user_cal
- result = await fetch_user_cal(start, end)
- for d in result.data['closed']:
- print(f" closed: {d}")
- print(f" {len(result.data['closed'])} date(s) closed synced from user_cal" + (" (changed)" if result.changed else ""))
- async def _push_closed(start: date, end: date, args) -> None:
- from room_cal.actions.push_closed import push_closed
- results = await push_closed(start, end)
- for r in results:
- print(f" {r['date']}: {r['action']}")
- print(f" {len(results)} date(s) changed")
- async def _apply_rules(start: date, end: date, args) -> None:
- from room_cal.actions.apply_rules import apply_rules
- results = await apply_rules(start, end, pipeline_name=args.pipeline_name or None)
- total = 0
- for name, dates in results.items():
- for d in dates:
- print(f" {name}: {d}")
- total += len(dates)
- print(f" {total} event(s) synced from rules")
- async def _fetch_feed(start: date, end: date, args) -> None:
- from room_cal.actions.fetch_feed import fetch_feed
- result = await fetch_feed(start, end, feed_name=args.feed_name, title=args.title or None)
- for d in result.data:
- print(f" {d}")
- print(f" {len(result.data)} date(s) marked closed" + (" (changed)" if result.changed else ""))
- async def _fetch_prices(start: date, end: date, args) -> None:
- from room_cal.actions.fetch_prices import fetch_prices
- result = await fetch_prices(start, end, service=args.service or None)
- for r in result.data:
- print(f" {r['date']}: net ${r['net']}")
- print(f" {len(result.data)} price event(s) written" + (" (changed)" if result.changed else ""))
- async def _push_prices(start: date, end: date, args) -> None:
- from room_cal.actions.push_prices import push_prices
- results = await push_prices(start, end)
- for r in results:
- prices_str = ' '.join(f"{s}: ${v}" for s, v in r['prices'].items())
- print(f" {r['date']}: {prices_str}")
- print(f" {len(results)} date(s) pushed")
- async def _fill_prices(start: date, end: date, args) -> None:
- from room_cal.actions.fill_prices import fill_prices
- results = await fill_prices(
- start, end,
- weekday=int(args.weekday) if args.weekday else None,
- weekend=int(args.weekend) if args.weekend else None,
- )
- for r in results:
- print(f" {r['date']}: net ${r['net']}")
- print(f" {len(results)} price event(s) created")
- async def _apply_discounts(start: date, end: date, args) -> None:
- from room_cal.actions.apply_discounts import apply_discounts
- results = await apply_discounts(start, end)
- for r in results:
- old = ' '.join(f"{s}: ${v}" for s, v in r['old_prices'].items() if v is not None)
- new = ' '.join(f"{s}: ${v}" for s, v in r['new_prices'].items())
- print(f" {r['date']}: {old} → {new}")
- print(f" {len(results)} date(s) updated")
- async def _push_busy(start: date, end: date, args) -> None:
- from room_cal.actions.push_busy import push_busy
- result = await push_busy(start, end)
- for d in result.data['busy']:
- print(f" busy: {d}")
- print(f" {len(result.data['busy'])} date(s) busy" + (" (changed)" if result.changed else ""))
- async def _local_sync(start: date, end: date, args) -> None:
- from room_cal.actions.local_sync import local_sync
- await local_sync(start, end)
- async def _local_refresh(start: date, end: date, args) -> None:
- from room_cal.actions.local_refresh import local_refresh
- await local_refresh(start, end)
- def _fmt_table(rows: list[list[str]], header: list[str]) -> None:
- all_rows = [header] + rows
- widths = [max(len(r[i]) for r in all_rows) for i in range(len(header))]
- sep = ' '
- fmt = sep.join(f'{{:<{w}}}' for w in widths)
- print(' ' + fmt.format(*header))
- print(' ' + sep.join('-' * w for w in widths))
- for row in rows:
- print(' ' + fmt.format(*row))
- def _fmt_price(local: int | None, remote: int | None) -> str:
- if local is None and remote is None:
- return '—'
- if local is None:
- return f'—→${remote}'
- if remote is None:
- return f'${local}→—'
- if local != remote:
- return f'${local}→${remote}'
- return f'${remote}'
- def _fmt_discount(d: dict | None) -> str:
- if d is None:
- return '—'
- if d['type'] == 'flat':
- return f"flat:${d['value']}"
- return f"pct:{d['value']}%"
- async def _diff(start: date, end: date, args) -> None:
- from room_cal.actions.diff import diff
- from room_cal._utils import services
- result = await diff(start, end)
- svc_keys = [s.key for s in services()]
- any_diff = False
- prices = result['prices']
- if prices:
- any_diff = True
- n = len(prices)
- print(f"\n Prices ({n} date{'s' if n != 1 else ''} differ)")
- header = ['date', 'local'] + svc_keys
- rows = []
- for p in prices:
- net_str = f"${p['net']}" if p['net'] is not None else '—'
- svc_cols = [
- _fmt_price(
- p['per_service'][svc]['local'] if svc in p['per_service'] else None,
- p['per_service'][svc]['remote'] if svc in p['per_service'] else None,
- )
- for svc in svc_keys
- ]
- rows.append([str(p['date']), net_str] + svc_cols)
- _fmt_table(rows, header)
- closed = result['closed']
- if closed:
- any_diff = True
- n = len(closed)
- print(f"\n Closed vs service_api ({n} date{'s' if n != 1 else ''} differ)")
- header = ['date', 'local'] + svc_keys
- rows = []
- for c in closed:
- local_str = ', '.join(c['local']) if c['local'] else '—'
- remote = c['remote'] or []
- svc_cols = ['closed' if svc in remote else 'open' for svc in svc_keys]
- rows.append([str(c['date']), local_str] + svc_cols)
- _fmt_table(rows, header)
- reservations = result['reservations']
- if reservations:
- any_diff = True
- n = len(reservations)
- print(f"\n Reservations ({n} {'change' if n == 1 else 'changes'})")
- for r in reservations:
- if r['status'] == 'added':
- print(f" [+] {r['service']} {r['checkin_date']} → {r['checkout_date']} {r['guest_name']} ({r['reservation_id']})")
- elif r['status'] == 'removed':
- print(f" [-] {r['service']} {r['checkin_date']} → {r['checkout_date']} {r['guest_name']} ({r['reservation_id']})")
- elif r['status'] == 'changed':
- for c in r['changes']:
- print(f" [~] {r['reservation_id']} {c['field']}: {c['local']!r} → {c['remote']!r}")
- uc_closed = result['user_cal_closed']
- if uc_closed:
- any_diff = True
- n = len(uc_closed)
- print(f"\n Closed vs user_cal ({n} date{'s' if n != 1 else ''} differ)")
- _fmt_table(
- [[str(r['date']), 'closed' if r['in_room'] else 'open', 'closed' if r['in_user_cal'] else 'open']
- for r in uc_closed],
- ['date', 'room', 'user_cal'],
- )
- ical_feeds_closed = result['ical_feeds_closed']
- for feed_name, rows_data in ical_feeds_closed.items():
- any_diff = True
- n = len(rows_data)
- print(f"\n Closed vs ical_feeds \"{feed_name}\" ({n} date{'s' if n != 1 else ''} differ)")
- _fmt_table(
- [[str(r['date']), 'closed' if r['in_room'] else 'open', 'blocked' if r['in_ical_feeds'] else 'open']
- for r in rows_data],
- ['date', 'room', 'ical_feeds'],
- )
- rule_discounts = result['rule_discounts']
- for pipeline_name, rows_data in rule_discounts.items():
- any_diff = True
- n = len(rows_data)
- print(f"\n Discounts vs rule \"{pipeline_name}\" ({n} date{'s' if n != 1 else ''} differ)")
- _fmt_table(
- [[str(d['date']), _fmt_discount(d['local']), _fmt_discount(d['rule'])] for d in rows_data],
- ['date', 'local', 'rule'],
- )
- rule_closed = result['rule_closed']
- for pipeline_name, rows_data in rule_closed.items():
- any_diff = True
- n = len(rows_data)
- print(f"\n Closed vs rule \"{pipeline_name}\" ({n} date{'s' if n != 1 else ''} differ)")
- _fmt_table(
- [[str(r['date']), 'closed' if r['in_room'] else 'open', 'closed' if r['in_rule'] else 'open']
- for r in rows_data],
- ['date', 'room', 'rule'],
- )
- rule_prices = result['rule_prices']
- for pipeline_name, rows_data in rule_prices.items():
- any_diff = True
- n = len(rows_data)
- print(f"\n Prices vs rule \"{pipeline_name}\" ({n} date{'s' if n != 1 else ''} differ)")
- _fmt_table(
- [[str(r['date']), f"${r['local_base']}" if r['local_base'] is not None else '(none)', f"${r['rule_min']}"]
- for r in rows_data],
- ['date', 'local_base', 'rule_min'],
- )
- orphans = result['orphans']
- if orphans:
- any_diff = True
- n = len(orphans)
- print(f"\n Orphans ({n} event{'s' if n != 1 else ''})")
- rows = []
- for o in orphans:
- src = o['source']
- src_str = f"{src['domain']}:{src.get('id','')}" if src else '(none)'
- rows.append([str(o['date']), o['event_type'], o['title'], src_str])
- _fmt_table(rows, ['date', 'type', 'title', 'source'])
- if not any_diff:
- print(" no differences")
- timings = result.get('_timings', {})
- if timings:
- total = timings.pop('_total', None)
- header = f" Timings (wall: {total:.1f}s)" if total is not None else " Timings"
- print(f"\n{header}")
- for k, v in sorted(timings.items(), key=lambda x: -x[1]):
- print(f" {k:<22} {v:.2f}s")
- _ACTIONS: dict[str, tuple] = {
- 'fetch-reservations': (_fetch_reservations, 'Fetch reservations from all services and write to CalDAV'),
- 'fetch-closed': (_fetch_closed, 'Fetch per-service blocked dates and write to CalDAV'),
- 'fetch-user-cal': (_fetch_user_cal, 'Sync Room Closed events from user_cal to the main calendar'),
- 'apply-rules': (_apply_rules, 'Sync rule-generated discounts and closed events into the main calendar'),
- 'push-closed': (_push_closed, 'Read local CalDAV closed events and push changes to all services'),
- 'fetch-feed': (_fetch_feed, 'Mark days with events on an iCal feed as closed on all services'),
- 'fill-prices': (_fill_prices, 'Create default price events for dates with no local price'),
- 'fetch-prices': (_fetch_prices, 'Fetch service API list prices and write net prices to CalDAV'),
- 'push-prices': (_push_prices, 'Read local CalDAV price events and push list prices to all services'),
- 'apply-discounts': (_apply_discounts, 'Re-apply discount events to price calendar and push changed list prices'),
- 'push-busy': (_push_busy, 'Sync Busy export calendar from local closed and reservation events'),
- 'local-sync': (_local_sync, 'Fetch all iCal feeds, apply rules, fill prices, and sync Busy — no service API calls'),
- 'local-refresh': (_local_refresh, 'Re-write all local Room calendar events (prices, closed, discounts, reservations)'),
- 'diff': (_diff, 'Compare local CalDAV data to live service APIs and report all differences (read-only)'),
- }
- def main() -> None:
- parser = argparse.ArgumentParser(
- prog='room-cal',
- description=__doc__,
- formatter_class=argparse.RawDescriptionHelpFormatter,
- )
- parser.add_argument(
- 'action', choices=list(_ACTIONS), metavar='ACTION',
- help='Action to run: ' + ', '.join(_ACTIONS),
- )
- parser.add_argument('start', type=_date, help='Start date (YYYY-MM-DD)')
- parser.add_argument('end', type=_date, nargs='?', help='End date inclusive (default: same as start)')
- parser.add_argument(
- '--feed-name', default='Fire', metavar='NAME',
- help='iCal feed name for fetch-feed (default: Fire)',
- )
- parser.add_argument(
- '--title', default=None, metavar='TITLE',
- help='Only close dates for feed events whose summary exactly matches TITLE',
- )
- parser.add_argument(
- '--pipeline-name', default=None, metavar='PIPELINE',
- help='Pipeline name for apply-rules (default: all pipelines)',
- )
- parser.add_argument(
- '--service', default=None, metavar='SERVICE',
- help='Service key to use for fetch-prices (default: first available)',
- )
- parser.add_argument(
- '--weekday', default=None, metavar='PRICE',
- help='Weekday net price override for fill-prices',
- )
- parser.add_argument(
- '--weekend', default=None, metavar='PRICE',
- help='Weekend net price override for fill-prices',
- )
- args = parser.parse_args()
- end = args.end or args.start
- if end < args.start:
- parser.error(f"end date {end} is before start date {args.start}")
- from room_cal.service_api.types import ServiceAuthError
- fn, _ = _ACTIONS[args.action]
- print(f"{args.action} {args.start} – {end}")
- try:
- asyncio.run(fn(args.start, end, args))
- except ServiceAuthError as e:
- print(f" error: {e}")
- raise SystemExit(1)
|