mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-18 00:58:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a6dfbfc9a | ||
|
|
dcba6120f9 | ||
|
|
2f64dbc48c | ||
|
|
08265c1135 | ||
|
|
5713fd20a7 |
@@ -130,6 +130,9 @@
|
|||||||
"plugin_rotation_order": [],
|
"plugin_rotation_order": [],
|
||||||
"use_short_date_format": true,
|
"use_short_date_format": true,
|
||||||
"vegas_scroll": {
|
"vegas_scroll": {
|
||||||
|
"live_in_ticker": false,
|
||||||
|
"live_weight": 3,
|
||||||
|
"favorite_live_weight": 5,
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"scroll_speed": 50,
|
"scroll_speed": 50,
|
||||||
"separator_width": 32,
|
"separator_width": 32,
|
||||||
|
|||||||
@@ -64,10 +64,98 @@ JSON is optional.
|
|||||||
| `target_fps` | `125` | Target frame rate |
|
| `target_fps` | `125` | Target frame rate |
|
||||||
| `buffer_ahead` | `2` | Number of plugins buffered ahead |
|
| `buffer_ahead` | `2` | Number of plugins buffered ahead |
|
||||||
|
|
||||||
This table is a subset — `display.vegas_scroll` supports 26 keys in
|
This table is a subset — `display.vegas_scroll` supports 30 keys in
|
||||||
total. See the full list in
|
total. See the full list in
|
||||||
[CONFIG_REFERENCE.md](CONFIG_REFERENCE.md#displayvegas_scroll--continuous-scroll-mode).
|
[CONFIG_REFERENCE.md](CONFIG_REFERENCE.md#displayvegas_scroll--continuous-scroll-mode).
|
||||||
|
|
||||||
|
### Live Content in the Ticker
|
||||||
|
|
||||||
|
By default, live content **preempts** Vegas mode: while any plugin reports
|
||||||
|
live priority, the display controller refuses to run the ticker and shows
|
||||||
|
that plugin's full-screen display instead. You get a big readable scoreboard,
|
||||||
|
but the marquee stops entirely for the duration of the game.
|
||||||
|
|
||||||
|
Set `live_in_ticker` to keep the ticker running and let live content take
|
||||||
|
**extra turns inside it** instead:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"vegas_scroll": {
|
||||||
|
"live_in_ticker": true,
|
||||||
|
"live_weight": 3,
|
||||||
|
"favorite_live_weight": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Why weights exist
|
||||||
|
|
||||||
|
The rotation is otherwise a strict round robin — every plugin appears exactly
|
||||||
|
once per cycle. With a dozen plugins enabled, a live score comes round once a
|
||||||
|
lap and can be minutes old by the time you see it. A weight of *N* gives a
|
||||||
|
plugin *N* slots per cycle.
|
||||||
|
|
||||||
|
The slots are placed by **Smooth Weighted Round-Robin**, the same scheduler
|
||||||
|
the sports plugins use internally to rotate their own games. The important
|
||||||
|
property is that repeats are *spread through the cycle* rather than clumped:
|
||||||
|
three appearances in a row followed by a long silence would be worse than not
|
||||||
|
boosting at all.
|
||||||
|
|
||||||
|
Twelve plugins, with a favorite's baseball game and an ordinary live hockey
|
||||||
|
game (`live_weight: 3`, `favorite_live_weight: 5`):
|
||||||
|
|
||||||
|
```
|
||||||
|
baseball > hockey > weather > clock > baseball
|
||||||
|
stocks > news > flights > baseball > hockey
|
||||||
|
calendar > f1 > music > baseball > tides
|
||||||
|
birds > hockey > baseball
|
||||||
|
```
|
||||||
|
|
||||||
|
18 slots for 12 plugins. Baseball appears 5 times, hockey 3, everything else
|
||||||
|
once, and no plugin ever appears twice in a row — **including across the seam**
|
||||||
|
where the cycle loops back on itself. Smooth Weighted Round-Robin schedules the
|
||||||
|
heaviest item first and usually last as well, so the strip would otherwise show
|
||||||
|
it twice running at exactly the one join a within-cycle check cannot see. The
|
||||||
|
trailing repeat is moved into the widest remaining gap. Where a double is
|
||||||
|
unavoidable — a plugin holding most of the slots has to neighbour itself — the
|
||||||
|
schedule is left as it is.
|
||||||
|
|
||||||
|
#### Where the weight comes from
|
||||||
|
|
||||||
|
For each plugin in the rotation, in order:
|
||||||
|
|
||||||
|
1. **The plugin's own answer.** If it implements
|
||||||
|
`get_vegas_priority_weight()` and returns a number, that wins. This is the
|
||||||
|
only route for favorite-team awareness — the core can see *that* a game is
|
||||||
|
live, but not *whose*, so a scoreboard has to say so itself.
|
||||||
|
2. **The core's default.** When the plugin returns `None` (the base-class
|
||||||
|
default), a plugin where both `has_live_priority()` and `has_live_content()`
|
||||||
|
are true gets `live_weight`.
|
||||||
|
3. **Everything else** gets 1.
|
||||||
|
|
||||||
|
Because of step 2, **existing plugins need no changes** — any scoreboard with
|
||||||
|
`live_priority` enabled already gets extra turns. Step 1 is opt-in, for
|
||||||
|
plugins that want to distinguish a favorite's game from any other live game.
|
||||||
|
|
||||||
|
Weights are clamped to 1–10. A weight of 1 is no boost; a weight below 1 would
|
||||||
|
drop the plugin from the rotation entirely, which is never what is meant.
|
||||||
|
|
||||||
|
#### Things worth knowing
|
||||||
|
|
||||||
|
- **Weights are per plugin, not per game.** A scoreboard showing four live
|
||||||
|
games still occupies one slot at a time, rotating its own games within that
|
||||||
|
slot using its own `favorite_live_boost`. This controls how often the
|
||||||
|
*plugin* comes round.
|
||||||
|
- **The ticker is zero-sum.** Giving baseball 5 slots does not make the cycle
|
||||||
|
faster; it makes the cycle *longer* and everything else proportionally
|
||||||
|
rarer. If you want live scores sooner in wall-clock terms, pair this with a
|
||||||
|
smaller `plugins_per_cycle`.
|
||||||
|
- **Frequency is not freshness.** Each appearance redraws from the plugin's
|
||||||
|
current data (`refresh_updated_plugins()` drops cached content when a
|
||||||
|
plugin's data changes), but how current that data is depends on the
|
||||||
|
plugin's own `live_update_interval`. Showing a stale score five times a lap
|
||||||
|
is no better than showing it once.
|
||||||
|
- **Everything still appears.** A boost never starves another plugin out of
|
||||||
|
the cycle; low-weight plugins keep their single slot.
|
||||||
|
|
||||||
### Per-Plugin Configuration
|
### Per-Plugin Configuration
|
||||||
|
|
||||||
Override Vegas behavior for specific plugins:
|
Override Vegas behavior for specific plugins:
|
||||||
|
|||||||
@@ -104,7 +104,8 @@ logical image to multiple chained physical panels.
|
|||||||
## `display.vegas_scroll` — continuous scroll mode
|
## `display.vegas_scroll` — continuous scroll mode
|
||||||
|
|
||||||
Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for behavior details.
|
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for behavior details, including
|
||||||
|
[live content in the ticker](ADVANCED_FEATURES.md#live-content-in-the-ticker).
|
||||||
|
|
||||||
| Key | Type / default |
|
| Key | Type / default |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -135,6 +136,9 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
|||||||
| `max_cycle_duration` | int, `240` |
|
| `max_cycle_duration` | int, `240` |
|
||||||
| `frame_based_scrolling` | bool, `true` — frame-count-based scroll stepping |
|
| `frame_based_scrolling` | bool, `true` — frame-count-based scroll stepping |
|
||||||
| `scroll_delay` | float, `0.02` — seconds between scroll updates (~50 FPS) |
|
| `scroll_delay` | float, `0.02` — seconds between scroll updates (~50 FPS) |
|
||||||
|
| `live_in_ticker` | bool, `false` — keep scrolling during live games instead of handing the display to a full-screen scoreboard |
|
||||||
|
| `live_weight` | int, `3` (1–10) — slots per cycle for a plugin with live content |
|
||||||
|
| `favorite_live_weight` | int, `5` (1–10) — slots per cycle when a plugin reports a favorite team is live |
|
||||||
|
|
||||||
## `sync` — multi-display synchronization
|
## `sync` — multi-display synchronization
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,47 @@ Default returns `False`.
|
|||||||
List of display modes to show during a live takeover. Default returns the
|
List of display modes to show during a live takeover. Default returns the
|
||||||
plugin's `display_modes` from its manifest.
|
plugin's `display_modes` from its manifest.
|
||||||
|
|
||||||
|
#### `get_vegas_priority_weight() -> Optional[int]`
|
||||||
|
|
||||||
|
How many slots per Vegas cycle this plugin should get. Default returns
|
||||||
|
`None`, which defers to the core.
|
||||||
|
|
||||||
|
The Vegas ticker is otherwise a strict round robin — every plugin appears
|
||||||
|
exactly once per cycle — so with a dozen plugins enabled a live score can be
|
||||||
|
minutes stale by the time it comes round. A weight of *N* gives the plugin
|
||||||
|
*N* slots per cycle, spread evenly through it rather than clumped.
|
||||||
|
|
||||||
|
**You usually do not need this.** When the hook returns `None`, the core
|
||||||
|
already gives a plugin `vegas_scroll.live_weight` whenever
|
||||||
|
`has_live_priority()` and `has_live_content()` are both true. Live sports get
|
||||||
|
extra turns with no code at all.
|
||||||
|
|
||||||
|
Implement it only when the plugin knows something the core cannot. The
|
||||||
|
motivating case is favorite teams — the core can see *that* a game is live,
|
||||||
|
but not *whose*:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_vegas_priority_weight(self):
|
||||||
|
if not (self.has_live_priority() and self.has_live_content()):
|
||||||
|
return None # let the core decide
|
||||||
|
vegas = self.global_config.get('display', {}).get('vegas_scroll', {})
|
||||||
|
if self._favorite_is_live():
|
||||||
|
return vegas.get('favorite_live_weight', 5)
|
||||||
|
return vegas.get('live_weight', 3)
|
||||||
|
```
|
||||||
|
|
||||||
|
The weight is per *plugin*, not per game: a scoreboard showing four live games
|
||||||
|
still occupies one slot at a time and rotates its own games within it. Values
|
||||||
|
are clamped to 1–10 by the caller. An exception here is caught and logged, and
|
||||||
|
the core then falls back to its own live-content check — so a plugin whose
|
||||||
|
weight calculation is broken still gets `live_weight` for a game that really
|
||||||
|
is live, rather than being demoted to 1.
|
||||||
|
|
||||||
|
Only consulted when the user has set `vegas_scroll.live_in_ticker`. With the
|
||||||
|
default (`false`) live content preempts Vegas entirely and there is no ticker
|
||||||
|
to be weighted within. See
|
||||||
|
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md#live-content-in-the-ticker).
|
||||||
|
|
||||||
### Vegas scroll hooks
|
### Vegas scroll hooks
|
||||||
|
|
||||||
Vegas mode shows multiple plugins as a single continuous scroll instead of
|
Vegas mode shows multiple plugins as a single continuous scroll instead of
|
||||||
|
|||||||
@@ -1694,6 +1694,12 @@ class DisplayController:
|
|||||||
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
||||||
return live
|
return live
|
||||||
|
|
||||||
|
def _vegas_keeps_live_in_ticker(self) -> bool:
|
||||||
|
"""Whether live content should stay in the ticker instead of preempting it."""
|
||||||
|
coordinator = getattr(self, 'vegas_coordinator', None)
|
||||||
|
config = getattr(coordinator, 'vegas_config', None)
|
||||||
|
return bool(getattr(config, 'live_in_ticker', False))
|
||||||
|
|
||||||
def _check_live_priority(self, advance=False):
|
def _check_live_priority(self, advance=False):
|
||||||
"""Return the live-priority mode to display, or None if nothing is live.
|
"""Return the live-priority mode to display, or None if nothing is live.
|
||||||
|
|
||||||
@@ -1907,14 +1913,24 @@ class DisplayController:
|
|||||||
# Check for live priority content and switch to it immediately.
|
# Check for live priority content and switch to it immediately.
|
||||||
# advance=True so multiple simultaneously-live games take turns
|
# advance=True so multiple simultaneously-live games take turns
|
||||||
# (round-robin) instead of pinning to the first plugin.
|
# (round-robin) instead of pinning to the first plugin.
|
||||||
if not self.on_demand_active and not wifi_status_data:
|
# Skipped when the ticker is keeping live content: switching
|
||||||
|
# the rotation underneath Vegas would move current_mode_index
|
||||||
|
# and stash a resume point for a takeover that never happens.
|
||||||
|
if (not self.on_demand_active and not wifi_status_data
|
||||||
|
and not (self._is_vegas_mode_active()
|
||||||
|
and self._vegas_keeps_live_in_ticker())):
|
||||||
live_priority_mode = self._check_live_priority(advance=True)
|
live_priority_mode = self._check_live_priority(advance=True)
|
||||||
self._apply_live_priority(live_priority_mode)
|
self._apply_live_priority(live_priority_mode)
|
||||||
|
|
||||||
# Vegas scroll mode - continuous ticker across all plugins
|
# Vegas scroll mode - continuous ticker across all plugins
|
||||||
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
||||||
if self._is_vegas_mode_active() and not wifi_status_data:
|
if self._is_vegas_mode_active() and not wifi_status_data:
|
||||||
live_mode = self._check_live_priority()
|
# Live content normally preempts the ticker entirely. With
|
||||||
|
# vegas_scroll.live_in_ticker the marquee keeps running and
|
||||||
|
# the live plugin takes extra turns inside it instead --
|
||||||
|
# see StreamManager._apply_priority_weights.
|
||||||
|
live_mode = (None if self._vegas_keeps_live_in_ticker()
|
||||||
|
else self._check_live_priority())
|
||||||
if not live_mode:
|
if not live_mode:
|
||||||
try:
|
try:
|
||||||
# Run Vegas mode iteration
|
# Run Vegas mode iteration
|
||||||
|
|||||||
@@ -555,6 +555,48 @@ class BasePlugin(ABC):
|
|||||||
"""
|
"""
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def get_vegas_priority_weight(self) -> Optional[int]:
|
||||||
|
"""How many slots per Vegas cycle this plugin should get, or None.
|
||||||
|
|
||||||
|
The Vegas ticker is otherwise a strict round robin: every plugin
|
||||||
|
appears exactly once per cycle. With a dozen plugins enabled that puts
|
||||||
|
minutes between a live score and its next appearance. A weight of N
|
||||||
|
gives the plugin N slots per cycle, spread evenly through it rather
|
||||||
|
than clumped together.
|
||||||
|
|
||||||
|
Return ``None`` (the default) to let the core decide. It gives a
|
||||||
|
plugin ``vegas_scroll.live_weight`` when ``has_live_priority()`` and
|
||||||
|
``has_live_content()`` are both true, and 1 otherwise -- so live sports
|
||||||
|
already get extra turns without implementing this at all.
|
||||||
|
|
||||||
|
Implement it only when the plugin knows something the core cannot. The
|
||||||
|
motivating case is favorite teams: the core can see *that* a game is
|
||||||
|
live but not *whose*, so a scoreboard that wants its favorite's game
|
||||||
|
shown more often than other live games has to say so::
|
||||||
|
|
||||||
|
def get_vegas_priority_weight(self):
|
||||||
|
if not (self.has_live_priority() and self.has_live_content()):
|
||||||
|
return None # let the core decide
|
||||||
|
cfg = self.global_config.get('display', {}).get('vegas_scroll', {})
|
||||||
|
if self._favorite_is_live():
|
||||||
|
return cfg.get('favorite_live_weight', 5)
|
||||||
|
return cfg.get('live_weight', 3)
|
||||||
|
|
||||||
|
The weight is per *plugin*, not per game. A scoreboard showing four
|
||||||
|
live games still occupies one slot at a time and rotates its own games
|
||||||
|
within that slot; this controls how often the plugin itself comes
|
||||||
|
round.
|
||||||
|
|
||||||
|
Raising is safe: the core logs it and falls back to its own
|
||||||
|
live-content check, so a broken weight calculation costs the plugin
|
||||||
|
the favorite distinction but not the live boost.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Slots per cycle (clamped to 1..10 by the caller), or None to
|
||||||
|
defer to the core's own live-content weighting.
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
def get_live_modes(self) -> List[str]:
|
def get_live_modes(self) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Get list of display modes that should be used during live priority takeover.
|
Get list of display modes that should be used during live priority takeover.
|
||||||
|
|||||||
@@ -125,6 +125,32 @@ class VegasModeConfig:
|
|||||||
plugin_order: List[str] = field(default_factory=list)
|
plugin_order: List[str] = field(default_factory=list)
|
||||||
excluded_plugins: Set[str] = field(default_factory=set)
|
excluded_plugins: Set[str] = field(default_factory=set)
|
||||||
|
|
||||||
|
# --- Live content in the ticker -------------------------------------
|
||||||
|
#
|
||||||
|
# By default a live game preempts Vegas entirely: the display controller
|
||||||
|
# refuses to run the ticker while any plugin reports live priority, and you
|
||||||
|
# get the full-screen scoreboard instead. Set live_in_ticker to keep the
|
||||||
|
# marquee running and let live content take extra turns within it.
|
||||||
|
#
|
||||||
|
# The rotation is otherwise a strict round robin -- every plugin appears
|
||||||
|
# exactly once per cycle -- so with a dozen plugins enabled a live score
|
||||||
|
# comes round once a lap and can be minutes old on screen. Weighting lets a
|
||||||
|
# plugin claim several slots per cycle instead.
|
||||||
|
#
|
||||||
|
# Weights are per plugin, not per game: a scoreboard showing four live
|
||||||
|
# games still occupies one slot at a time, and rotates its own games within
|
||||||
|
# that slot using its own favorite_live_boost.
|
||||||
|
live_in_ticker: bool = False
|
||||||
|
|
||||||
|
# Slots per cycle for a plugin reporting live content. 1 disables the boost
|
||||||
|
# and restores the plain round robin.
|
||||||
|
live_weight: int = 3
|
||||||
|
|
||||||
|
# Slots per cycle for a plugin whose live content involves a favorite team.
|
||||||
|
# Only plugins implementing get_vegas_priority_weight() can claim this --
|
||||||
|
# the core cannot tell whose game is on, so the plugin reports it.
|
||||||
|
favorite_live_weight: int = 5
|
||||||
|
|
||||||
# Performance settings
|
# Performance settings
|
||||||
target_fps: int = 125 # Target frame rate
|
target_fps: int = 125 # Target frame rate
|
||||||
buffer_ahead: int = 2 # Number of plugins to buffer ahead
|
buffer_ahead: int = 2 # Number of plugins to buffer ahead
|
||||||
@@ -175,6 +201,12 @@ class VegasModeConfig:
|
|||||||
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
|
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
|
||||||
plugin_order=list(vegas_config.get('plugin_order', [])),
|
plugin_order=list(vegas_config.get('plugin_order', [])),
|
||||||
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
|
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
|
||||||
|
live_in_ticker=bool(vegas_config.get('live_in_ticker', False)),
|
||||||
|
# Clamped: a weight below 1 would drop the plugin from the rotation
|
||||||
|
# entirely, and a very large one starves everything else.
|
||||||
|
live_weight=max(1, min(10, int(vegas_config.get('live_weight', 3)))),
|
||||||
|
favorite_live_weight=max(
|
||||||
|
1, min(10, int(vegas_config.get('favorite_live_weight', 5)))),
|
||||||
target_fps=int(vegas_config.get('target_fps', 125)),
|
target_fps=int(vegas_config.get('target_fps', 125)),
|
||||||
buffer_ahead=int(vegas_config.get('buffer_ahead', 2)),
|
buffer_ahead=int(vegas_config.get('buffer_ahead', 2)),
|
||||||
frame_based_scrolling=vegas_config.get('frame_based_scrolling', True),
|
frame_based_scrolling=vegas_config.get('frame_based_scrolling', True),
|
||||||
@@ -204,6 +236,9 @@ class VegasModeConfig:
|
|||||||
'lead_in_width': self.lead_in_width,
|
'lead_in_width': self.lead_in_width,
|
||||||
'plugins_per_cycle': self.plugins_per_cycle,
|
'plugins_per_cycle': self.plugins_per_cycle,
|
||||||
'max_plugin_width_ratio': self.max_plugin_width_ratio,
|
'max_plugin_width_ratio': self.max_plugin_width_ratio,
|
||||||
|
'live_in_ticker': self.live_in_ticker,
|
||||||
|
'live_weight': self.live_weight,
|
||||||
|
'favorite_live_weight': self.favorite_live_weight,
|
||||||
'overflow_mode': self.overflow_mode,
|
'overflow_mode': self.overflow_mode,
|
||||||
'plugin_order': self.plugin_order,
|
'plugin_order': self.plugin_order,
|
||||||
'excluded_plugins': list(self.excluded_plugins),
|
'excluded_plugins': list(self.excluded_plugins),
|
||||||
@@ -371,6 +406,15 @@ class VegasModeConfig:
|
|||||||
|
|
||||||
if 'enabled' in vegas_config:
|
if 'enabled' in vegas_config:
|
||||||
self.enabled = vegas_config['enabled']
|
self.enabled = vegas_config['enabled']
|
||||||
|
if 'live_in_ticker' in vegas_config:
|
||||||
|
self.live_in_ticker = bool(vegas_config['live_in_ticker'])
|
||||||
|
# Clamped exactly as from_config does: a weight below 1 would drop the
|
||||||
|
# plugin from the rotation, and a huge one starves everything else.
|
||||||
|
if 'live_weight' in vegas_config:
|
||||||
|
self.live_weight = max(1, min(10, int(vegas_config['live_weight'])))
|
||||||
|
if 'favorite_live_weight' in vegas_config:
|
||||||
|
self.favorite_live_weight = max(
|
||||||
|
1, min(10, int(vegas_config['favorite_live_weight'])))
|
||||||
if 'scroll_speed' in vegas_config:
|
if 'scroll_speed' in vegas_config:
|
||||||
self.scroll_speed = float(vegas_config['scroll_speed'])
|
self.scroll_speed = float(vegas_config['scroll_speed'])
|
||||||
if 'separator_width' in vegas_config:
|
if 'separator_width' in vegas_config:
|
||||||
|
|||||||
@@ -528,6 +528,12 @@ class VegasModeCoordinator:
|
|||||||
if not self._live_priority_check:
|
if not self._live_priority_check:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if self.vegas_config.live_in_ticker:
|
||||||
|
# The ticker keeps live content rather than yielding to it; the
|
||||||
|
# extra turns are arranged in the rotation itself, so there is
|
||||||
|
# nothing to pause for.
|
||||||
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
live_mode = self._live_priority_check()
|
live_mode = self._live_priority_check()
|
||||||
if live_mode:
|
if live_mode:
|
||||||
|
|||||||
@@ -406,6 +406,8 @@ class StreamManager:
|
|||||||
)
|
)
|
||||||
logger.info("Ordered plugins: %s", ordered_plugins)
|
logger.info("Ordered plugins: %s", ordered_plugins)
|
||||||
|
|
||||||
|
ordered_plugins = self._apply_priority_weights(ordered_plugins)
|
||||||
|
|
||||||
# Atomically update shared state under lock to avoid races with prefetchers
|
# Atomically update shared state under lock to avoid races with prefetchers
|
||||||
with self._buffer_lock:
|
with self._buffer_lock:
|
||||||
self._ordered_plugins = ordered_plugins
|
self._ordered_plugins = ordered_plugins
|
||||||
@@ -417,6 +419,143 @@ class StreamManager:
|
|||||||
|
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
def _plugin_weight(self, plugin_id: str) -> int:
|
||||||
|
"""Slots per cycle for one plugin.
|
||||||
|
|
||||||
|
A plugin may answer for itself via get_vegas_priority_weight() -- the
|
||||||
|
only way favorite-team awareness can reach here, since the core can see
|
||||||
|
that a game is live but not whose. When it declines (returns None, the
|
||||||
|
default), live content earns ``live_weight`` and everything else 1.
|
||||||
|
"""
|
||||||
|
plugin = None
|
||||||
|
try:
|
||||||
|
plugin = self.plugin_manager.plugins.get(plugin_id)
|
||||||
|
except (AttributeError, TypeError):
|
||||||
|
return 1
|
||||||
|
if plugin is None:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
if hasattr(plugin, 'get_vegas_priority_weight'):
|
||||||
|
declared = plugin.get_vegas_priority_weight()
|
||||||
|
if declared is not None:
|
||||||
|
return max(1, min(10, int(declared)))
|
||||||
|
except Exception:
|
||||||
|
# Deliberately falls through to the core's own live check rather
|
||||||
|
# than demoting to 1. The plugin's weight calculation is broken,
|
||||||
|
# but has_live_priority() and has_live_content() are separate
|
||||||
|
# methods guarded separately below -- a plugin that genuinely has
|
||||||
|
# a live game should still get live_weight for it.
|
||||||
|
logger.exception("[%s] get_vegas_priority_weight() failed", plugin_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if (hasattr(plugin, 'has_live_priority')
|
||||||
|
and hasattr(plugin, 'has_live_content')
|
||||||
|
and plugin.has_live_priority()
|
||||||
|
and plugin.has_live_content()):
|
||||||
|
return self.config.live_weight
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[%s] live-content check failed", plugin_id)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def _apply_priority_weights(self, ordered: List[str]) -> List[str]:
|
||||||
|
"""Expand the rotation so weighted plugins take several turns per cycle.
|
||||||
|
|
||||||
|
Smooth Weighted Round-Robin, the same scheduler the sports plugins use
|
||||||
|
to rotate their own games: a plugin of weight N appears N times per
|
||||||
|
cycle, and the repeats are spaced through the cycle rather than
|
||||||
|
clumped, so a live score is never three-in-a-row followed by a long
|
||||||
|
silence.
|
||||||
|
|
||||||
|
Returns the input unchanged when nothing is weighted, which is both the
|
||||||
|
common case and the pre-existing behaviour.
|
||||||
|
"""
|
||||||
|
if not ordered or not self.config.live_in_ticker:
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
weights = {pid: self._plugin_weight(pid) for pid in ordered}
|
||||||
|
total = sum(weights.values())
|
||||||
|
if total <= len(ordered):
|
||||||
|
return ordered # nothing boosted; plain round robin
|
||||||
|
|
||||||
|
current = {pid: 0 for pid in ordered}
|
||||||
|
schedule: List[str] = []
|
||||||
|
for _ in range(total):
|
||||||
|
for pid in ordered:
|
||||||
|
current[pid] += weights[pid]
|
||||||
|
picked = max(current, key=lambda p: current[p])
|
||||||
|
current[picked] -= total
|
||||||
|
schedule.append(picked)
|
||||||
|
|
||||||
|
schedule = self._unclump_seam(schedule)
|
||||||
|
|
||||||
|
boosted = {p: w for p, w in weights.items() if w > 1}
|
||||||
|
logger.info(
|
||||||
|
"Vegas rotation weighted: %d slots for %d plugins (boosted: %s)",
|
||||||
|
len(schedule), len(ordered), boosted)
|
||||||
|
return schedule
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _unclump_seam(schedule: List[str]) -> List[str]:
|
||||||
|
"""Stop the heaviest plugin sitting on both ends of the cycle.
|
||||||
|
|
||||||
|
Smooth Weighted Round-Robin spaces repeats well *within* a pass, but
|
||||||
|
it schedules the heaviest item first and often last too. The strip
|
||||||
|
loops, so those two are neighbours: the one place the marquee shows
|
||||||
|
the same plugin twice running is the seam between cycles.
|
||||||
|
|
||||||
|
Rotating the list cannot fix this. Rotation preserves the cyclic order
|
||||||
|
exactly, so it only moves where the seam is drawn, not the adjacency
|
||||||
|
itself. The trailing entry has to be swapped with one from the middle
|
||||||
|
whose neighbours differ from it, which breaks the pair without
|
||||||
|
creating another.
|
||||||
|
|
||||||
|
Left alone when no such position exists -- a rotation short enough or
|
||||||
|
lopsided enough to have none is one where the plugin is unavoidably
|
||||||
|
adjacent to itself anyway.
|
||||||
|
"""
|
||||||
|
if len(schedule) < 3 or schedule[0] != schedule[-1]:
|
||||||
|
return schedule
|
||||||
|
|
||||||
|
repeated = schedule[-1]
|
||||||
|
size = len(schedule)
|
||||||
|
|
||||||
|
def cyclic_doubles(seq) -> int:
|
||||||
|
return sum(1 for i in range(size) if seq[i] == seq[(i + 1) % size])
|
||||||
|
|
||||||
|
def clearance(seq, value) -> int:
|
||||||
|
"""Smallest cyclic gap between appearances of `value`."""
|
||||||
|
at = [i for i, v in enumerate(seq) if v == value]
|
||||||
|
if len(at) < 2:
|
||||||
|
return size
|
||||||
|
return min(min((b - a) % size, (a - b) % size)
|
||||||
|
for i, a in enumerate(at) for b in at[i + 1:])
|
||||||
|
|
||||||
|
# Try each swap and judge the result, rather than reasoning about which
|
||||||
|
# neighbours the two moved elements will end up with. That reasoning is
|
||||||
|
# where the first version went wrong: it guarded the slot `repeated`
|
||||||
|
# moves into but not the one the displaced element lands in, so
|
||||||
|
# ['a','b','c','d','x','y','x','a'] came back ending ['x','x'] -- the
|
||||||
|
# seam duplicate traded for a fresh one.
|
||||||
|
best = None
|
||||||
|
best_clearance = -1
|
||||||
|
for j in range(1, size - 1):
|
||||||
|
candidate = list(schedule)
|
||||||
|
candidate[j], candidate[-1] = candidate[-1], candidate[j]
|
||||||
|
if cyclic_doubles(candidate):
|
||||||
|
continue
|
||||||
|
# Among the repairs that work, prefer the one that leaves the
|
||||||
|
# boosted plugin most evenly spread; taking the first that merely
|
||||||
|
# fits moved a repeat from a gap of 7 into a gap of 2.
|
||||||
|
spread = clearance(candidate, repeated)
|
||||||
|
if spread > best_clearance:
|
||||||
|
best, best_clearance = candidate, spread
|
||||||
|
|
||||||
|
# None exists when the value is unavoidably adjacent to itself -- a
|
||||||
|
# plugin holding most of the slots has to be. Schedule it as it is
|
||||||
|
# rather than refuse.
|
||||||
|
return best if best is not None else schedule
|
||||||
|
|
||||||
def _prefetch_content(self, count: int = 1) -> None:
|
def _prefetch_content(self, count: int = 1) -> None:
|
||||||
"""
|
"""
|
||||||
Prefetch content for upcoming plugins.
|
Prefetch content for upcoming plugins.
|
||||||
|
|||||||
@@ -77,6 +77,25 @@ def describe_exception(exc: BaseException,
|
|||||||
"""
|
"""
|
||||||
message = str(exc).strip()
|
message = str(exc).strip()
|
||||||
text = f"{type(exc).__name__}: {message}" if message else type(exc).__name__
|
text = f"{type(exc).__name__}: {message}" if message else type(exc).__name__
|
||||||
|
return redact_text(text, max_length)
|
||||||
|
|
||||||
|
|
||||||
|
def redact_text(text: str, max_length: int = _MAX_DETAIL_LENGTH) -> str:
|
||||||
|
"""Make arbitrary text safe to hand back over HTTP.
|
||||||
|
|
||||||
|
Split out of describe_exception because exceptions are not the only thing
|
||||||
|
worth returning: a subprocess's stderr, or a message a helper script
|
||||||
|
printed, is just as useful to a user and just as capable of carrying a
|
||||||
|
token or a password in it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text to redact
|
||||||
|
max_length: Truncate beyond this many characters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A single line, credentials replaced, length capped.
|
||||||
|
"""
|
||||||
|
text = text or ''
|
||||||
# Order matters: the URL and header forms are more specific than the
|
# Order matters: the URL and header forms are more specific than the
|
||||||
# generic key=value pattern, which would otherwise chew the scheme.
|
# generic key=value pattern, which would otherwise chew the scheme.
|
||||||
text = _REDACT_URL_USERINFO.sub(r'\1<redacted>\3', text)
|
text = _REDACT_URL_USERINFO.sub(r'\1<redacted>\3', text)
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"""
|
||||||
|
Getting Started checklist: what the server decides, and what it must not.
|
||||||
|
|
||||||
|
The timezone step used to tick server-side when the saved timezone differed
|
||||||
|
from the shipped default, OR-ed with the saved city. That made the step
|
||||||
|
unsatisfiable for anyone genuinely in the default zone (the card nagged
|
||||||
|
forever), and let a saved city tick it off while the timezone was still wrong.
|
||||||
|
The step is now verified in the browser against its own zone, so the server's
|
||||||
|
only job is to hand over the configured value and stay out of the decision.
|
||||||
|
|
||||||
|
These tests pin that contract: the panel-size step still reflects config, the
|
||||||
|
timezone step never pre-ticks, it carries the configured zone, and the city
|
||||||
|
has no influence on it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
BASE_CONFIG = {
|
||||||
|
"timezone": "America/New_York",
|
||||||
|
"location": {"city": "Tampa", "state": "Florida", "country": "US"},
|
||||||
|
"display": {
|
||||||
|
"hardware": {"rows": 32, "cols": 64, "chain_length": 2, "parallel": 1},
|
||||||
|
"runtime": {},
|
||||||
|
"double_sided": {"enabled": False},
|
||||||
|
"vegas_scroll": {"plugin_order": [], "excluded_plugins": []},
|
||||||
|
"plugin_rotation_order": [],
|
||||||
|
},
|
||||||
|
"plugin_system": {},
|
||||||
|
"schedule": {},
|
||||||
|
"dim_schedule": {},
|
||||||
|
"sync": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render(config):
|
||||||
|
"""Render the overview partial against one config, as app.py would."""
|
||||||
|
base = PROJECT_ROOT / "web_interface"
|
||||||
|
app = Flask(
|
||||||
|
__name__,
|
||||||
|
template_folder=str(base / "templates"),
|
||||||
|
static_folder=str(base / "static"),
|
||||||
|
)
|
||||||
|
app.config["TESTING"] = True
|
||||||
|
|
||||||
|
from web_interface.blueprints import pages_v3 as pv
|
||||||
|
|
||||||
|
# pages_v3 is a module-level singleton shared across the test process;
|
||||||
|
# restore whatever the previous test left on it.
|
||||||
|
original_cm = getattr(pv.pages_v3, "config_manager", None)
|
||||||
|
original_pm = getattr(pv.pages_v3, "plugin_manager", None)
|
||||||
|
|
||||||
|
mock_cm = MagicMock()
|
||||||
|
mock_cm.load_config.return_value = config
|
||||||
|
mock_cm.get_raw_file_content.return_value = config
|
||||||
|
pv.pages_v3.config_manager = mock_cm
|
||||||
|
|
||||||
|
mock_pm = MagicMock()
|
||||||
|
mock_pm.plugins = {}
|
||||||
|
mock_pm.get_all_plugin_info.return_value = []
|
||||||
|
mock_pm.get_plugin_display_modes.side_effect = lambda pid: []
|
||||||
|
pv.pages_v3.plugin_manager = mock_pm
|
||||||
|
|
||||||
|
app.register_blueprint(pv.pages_v3, url_prefix="")
|
||||||
|
try:
|
||||||
|
resp = app.test_client().get("/partials/overview")
|
||||||
|
assert resp.status_code == 200, resp.status_code
|
||||||
|
return resp.get_data(as_text=True)
|
||||||
|
finally:
|
||||||
|
pv.pages_v3.config_manager = original_cm
|
||||||
|
pv.pages_v3.plugin_manager = original_pm
|
||||||
|
|
||||||
|
|
||||||
|
def timezone_step(body):
|
||||||
|
"""The checklist <button> for the timezone step."""
|
||||||
|
match = re.search(r"<button[^>]*data-check=\"timezone\"[^>]*>", body)
|
||||||
|
assert match, "timezone step not found in the rendered checklist"
|
||||||
|
return match.group(0)
|
||||||
|
|
||||||
|
|
||||||
|
def config_with(**overrides):
|
||||||
|
config = copy.deepcopy(BASE_CONFIG)
|
||||||
|
for key, value in overrides.items():
|
||||||
|
config[key] = value
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"timezone",
|
||||||
|
["America/New_York", "America/Los_Angeles", "Europe/Madrid", "Asia/Kolkata"],
|
||||||
|
)
|
||||||
|
def test_timezone_step_never_pre_ticks_server_side(timezone):
|
||||||
|
"""The browser owns this decision; the server must not pre-empt it.
|
||||||
|
|
||||||
|
The default zone is in the list deliberately: that is the case the old
|
||||||
|
default-comparison could never tick.
|
||||||
|
"""
|
||||||
|
step = timezone_step(render(config_with(timezone=timezone)))
|
||||||
|
assert 'data-done="0"' in step, step
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"timezone",
|
||||||
|
["America/New_York", "Europe/Madrid", "Pacific/Auckland"],
|
||||||
|
)
|
||||||
|
def test_timezone_step_carries_the_configured_zone(timezone):
|
||||||
|
"""JS compares data-tz against the browser, so it has to be the real value."""
|
||||||
|
assert f'data-tz="{timezone}"' in timezone_step(render(config_with(timezone=timezone)))
|
||||||
|
|
||||||
|
|
||||||
|
def test_city_does_not_influence_the_timezone_step():
|
||||||
|
"""The coupling this change removes: city said nothing about the timezone,
|
||||||
|
and OR-ing it let a saved city tick the step off with the zone still wrong.
|
||||||
|
|
||||||
|
timezone_step() returns the opening tag only, so this compares the state
|
||||||
|
the step is in -- data-done and data-tz -- and not the label, which does
|
||||||
|
still show the configured city as context and so differs between the two.
|
||||||
|
"""
|
||||||
|
tampa = timezone_step(render(config_with(
|
||||||
|
location={"city": "Tampa", "state": "Florida", "country": "US"})))
|
||||||
|
seattle = timezone_step(render(config_with(
|
||||||
|
location={"city": "Seattle", "state": "Washington", "country": "US"})))
|
||||||
|
assert tampa == seattle
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_timezone_leaves_the_step_open():
|
||||||
|
"""Nothing saved means nothing to verify: the step stays unticked and the
|
||||||
|
JS bails on the empty value rather than comparing against ''."""
|
||||||
|
step = timezone_step(render(config_with(timezone="")))
|
||||||
|
assert 'data-tz=""' in step
|
||||||
|
assert 'data-done="0"' in step
|
||||||
|
|
||||||
|
|
||||||
|
def test_zone_comparison_asks_for_the_time_of_day():
|
||||||
|
"""Guard on the Intl options, which look like a stylistic choice.
|
||||||
|
|
||||||
|
dateStyle/timeStyle are late additions (Firefox shipped them in 91). An
|
||||||
|
implementation that does not know them ignores them and formats the date
|
||||||
|
alone -- which compares New York, Chicago and Madrid as equal and ticks
|
||||||
|
the step for a timezone that is plainly wrong. Explicit numeric fields
|
||||||
|
have been in Intl since ECMA-402 v1.
|
||||||
|
"""
|
||||||
|
template = (PROJECT_ROOT / "web_interface" / "templates" / "v3"
|
||||||
|
/ "partials" / "overview.html").read_text()
|
||||||
|
body = template[template.index("function sameZone"):]
|
||||||
|
body = body[:body.index("}())")]
|
||||||
|
# The comment above the options names dateStyle/timeStyle to explain why
|
||||||
|
# they are not used, so match on code only.
|
||||||
|
body = "\n".join(line for line in body.splitlines()
|
||||||
|
if not line.lstrip().startswith("//"))
|
||||||
|
assert "dateStyle" not in body and "timeStyle" not in body, (
|
||||||
|
"zone comparison must not depend on dateStyle/timeStyle")
|
||||||
|
for field in ("hour:", "minute:", "year:", "month:", "day:"):
|
||||||
|
assert field in body, f"zone comparison dropped {field!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_zone_comparison_samples_both_sides_of_dst():
|
||||||
|
"""One instant is not enough, and the shortfall is invisible for months.
|
||||||
|
|
||||||
|
America/New_York and America/Lima hold the same offset all winter, so a
|
||||||
|
check against now alone ticks the step in January for a panel that runs an
|
||||||
|
hour off from March. The comparison has to sample instants either side of
|
||||||
|
DST -- mid-January and mid-July, which covers both hemispheres.
|
||||||
|
"""
|
||||||
|
template = (PROJECT_ROOT / "web_interface" / "templates" / "v3"
|
||||||
|
/ "partials" / "overview.html").read_text()
|
||||||
|
body = template[template.index("function sameZone"):]
|
||||||
|
body = body[:body.index("}())")]
|
||||||
|
code = "\n".join(line for line in body.splitlines()
|
||||||
|
if not line.lstrip().startswith("//"))
|
||||||
|
assert "Date.UTC" in code, (
|
||||||
|
"zone comparison samples only the current instant, so zones that "
|
||||||
|
"coincide seasonally would read as equal")
|
||||||
|
assert code.count("Date.UTC") >= 2, "expected an instant either side of DST"
|
||||||
|
|
||||||
|
|
||||||
|
def _stamp(zone, instant):
|
||||||
|
"""The JS comparison's algorithm, for pinning what it must decide.
|
||||||
|
|
||||||
|
There is no JS runtime here (and the repo has no JS test infra), so this
|
||||||
|
mirrors sameZone rather than executing it: same instants, same wall-clock
|
||||||
|
equality. It records the verdicts the shipped code has to reach.
|
||||||
|
"""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
return instant.astimezone(ZoneInfo(zone)).strftime("%m/%d/%Y %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"left,right,equivalent",
|
||||||
|
[
|
||||||
|
# Aliases: one zone under two names.
|
||||||
|
("Asia/Calcutta", "Asia/Kolkata", True),
|
||||||
|
("Europe/Kiev", "Europe/Kyiv", True),
|
||||||
|
# Same rules year-round: either renders the same times, so a panel set
|
||||||
|
# to one and browsed from the other is correctly configured.
|
||||||
|
("America/New_York", "America/Toronto", True),
|
||||||
|
# Coincide in winter only -- the case a single-instant check gets wrong.
|
||||||
|
("America/New_York", "America/Lima", False),
|
||||||
|
("America/Phoenix", "America/Los_Angeles", False),
|
||||||
|
("Australia/Sydney", "Pacific/Guadalcanal", False),
|
||||||
|
# Plainly different.
|
||||||
|
("America/New_York", "America/Chicago", False),
|
||||||
|
("America/New_York", "Europe/Madrid", False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_which_zone_pairs_must_count_as_the_same(left, right, equivalent):
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
year = 2026
|
||||||
|
instants = [datetime(year, 1, 15, 12, tzinfo=ZoneInfo("UTC")),
|
||||||
|
datetime(year, 7, 15, 12, tzinfo=ZoneInfo("UTC"))]
|
||||||
|
matched = all(_stamp(left, at) == _stamp(right, at) for at in instants)
|
||||||
|
assert matched is equivalent, (
|
||||||
|
f"{left} vs {right}: sampling both seasons gave {matched}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"hardware,expected",
|
||||||
|
[
|
||||||
|
({"rows": 32, "cols": 64, "chain_length": 2, "parallel": 1}, "1"),
|
||||||
|
({"rows": 0, "cols": 0, "chain_length": 0, "parallel": 1}, "0"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_panel_size_step_still_reflects_config(hardware, expected):
|
||||||
|
"""Regression guard: the hardware step is still decided server-side."""
|
||||||
|
config = config_with()
|
||||||
|
config["display"]["hardware"] = hardware
|
||||||
|
body = render(config)
|
||||||
|
match = re.search(r"<button[^>]*data-tab=\"display\"[^>]*>", body)
|
||||||
|
assert match, "panel-size step not found"
|
||||||
|
assert f'data-done="{expected}"' in match.group(0), match.group(0)
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""Tests that live content can take extra turns inside the Vegas ticker.
|
||||||
|
|
||||||
|
Vegas was a strict round robin -- every plugin exactly once per cycle -- and
|
||||||
|
live content did not appear in it at all, because the display controller
|
||||||
|
refused to run the ticker while anything was live. With a dozen plugins
|
||||||
|
enabled that left a live score either absent or minutes stale.
|
||||||
|
|
||||||
|
Two things change, both off by default. `live_in_ticker` keeps the marquee
|
||||||
|
running instead of yielding to a full-screen takeover, and the rotation is
|
||||||
|
expanded by Smooth Weighted Round-Robin so a weighted plugin gets several
|
||||||
|
slots per cycle, spaced through it rather than clumped.
|
||||||
|
|
||||||
|
Weights are per plugin, not per game: a scoreboard showing four live games
|
||||||
|
still occupies one slot at a time and rotates its own games within it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.vegas_mode.config import VegasModeConfig
|
||||||
|
from src.vegas_mode.stream_manager import StreamManager
|
||||||
|
|
||||||
|
|
||||||
|
class FakePlugin:
|
||||||
|
"""A plugin that can fail in each place independently.
|
||||||
|
|
||||||
|
hook_raises and live_raises are separate because they mean different
|
||||||
|
things: a broken weight calculation should still leave the core's own
|
||||||
|
live-content check usable, while a plugin that cannot answer whether it is
|
||||||
|
live at all has nothing left to fall back on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, live=False, declared=None, raises=False,
|
||||||
|
hook_raises=False, live_raises=False):
|
||||||
|
self._live = live
|
||||||
|
self._declared = declared
|
||||||
|
self._hook_raises = hook_raises or raises
|
||||||
|
self._live_raises = live_raises or raises
|
||||||
|
self.enabled = True
|
||||||
|
|
||||||
|
def has_live_priority(self):
|
||||||
|
if self._live_raises:
|
||||||
|
raise RuntimeError("cannot say whether I am live")
|
||||||
|
return self._live
|
||||||
|
|
||||||
|
def has_live_content(self):
|
||||||
|
return self._live
|
||||||
|
|
||||||
|
def get_vegas_priority_weight(self):
|
||||||
|
if self._hook_raises:
|
||||||
|
raise RuntimeError("weight calculation blew up")
|
||||||
|
return self._declared
|
||||||
|
|
||||||
|
|
||||||
|
def _manager(plugins, **cfg):
|
||||||
|
config = VegasModeConfig(live_in_ticker=cfg.pop('live_in_ticker', True), **cfg)
|
||||||
|
pm = Mock()
|
||||||
|
pm.plugins = plugins
|
||||||
|
sm = StreamManager.__new__(StreamManager)
|
||||||
|
sm.config = config
|
||||||
|
sm.plugin_manager = pm
|
||||||
|
return sm
|
||||||
|
|
||||||
|
|
||||||
|
def _counts(schedule):
|
||||||
|
return {p: schedule.count(p) for p in set(schedule)}
|
||||||
|
|
||||||
|
|
||||||
|
def _max_gap(schedule, plugin_id):
|
||||||
|
"""Largest gap between consecutive appearances, wrapping around."""
|
||||||
|
at = [i for i, p in enumerate(schedule) if p == plugin_id]
|
||||||
|
if len(at) < 2:
|
||||||
|
return len(schedule)
|
||||||
|
gaps = [b - a for a, b in zip(at, at[1:])]
|
||||||
|
gaps.append(len(schedule) - at[-1] + at[0])
|
||||||
|
return max(gaps)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeightsComeFromTheRightPlace:
|
||||||
|
def test_a_quiet_plugin_gets_one_slot(self):
|
||||||
|
sm = _manager({'clock': FakePlugin()})
|
||||||
|
assert sm._plugin_weight('clock') == 1
|
||||||
|
|
||||||
|
def test_live_content_earns_the_configured_weight(self):
|
||||||
|
sm = _manager({'mlb': FakePlugin(live=True)}, live_weight=4)
|
||||||
|
assert sm._plugin_weight('mlb') == 4
|
||||||
|
|
||||||
|
def test_a_plugin_may_answer_for_itself(self):
|
||||||
|
# The only route for favorite-team awareness: the core can see that a
|
||||||
|
# game is live, not whose.
|
||||||
|
sm = _manager({'mlb': FakePlugin(live=True, declared=7)}, live_weight=3)
|
||||||
|
assert sm._plugin_weight('mlb') == 7
|
||||||
|
|
||||||
|
def test_declaring_none_defers_to_the_core(self):
|
||||||
|
sm = _manager({'mlb': FakePlugin(live=True, declared=None)}, live_weight=3)
|
||||||
|
assert sm._plugin_weight('mlb') == 3
|
||||||
|
|
||||||
|
def test_a_declared_weight_is_clamped(self):
|
||||||
|
sm = _manager({'a': FakePlugin(declared=99), 'b': FakePlugin(declared=0)})
|
||||||
|
assert sm._plugin_weight('a') == 10
|
||||||
|
assert sm._plugin_weight('b') == 1
|
||||||
|
|
||||||
|
def test_a_plugin_that_raises_everywhere_weighs_one(self):
|
||||||
|
sm = _manager({'bad': FakePlugin(raises=True)})
|
||||||
|
assert sm._plugin_weight('bad') == 1
|
||||||
|
|
||||||
|
def test_a_broken_hook_still_earns_the_live_boost(self):
|
||||||
|
# The hook is only how a plugin asks for *more* than live_weight.
|
||||||
|
# Losing it should cost the favorite distinction, not the live boost:
|
||||||
|
# has_live_priority/has_live_content are separate and still work.
|
||||||
|
sm = _manager({'mlb': FakePlugin(live=True, hook_raises=True)},
|
||||||
|
live_weight=4)
|
||||||
|
assert sm._plugin_weight('mlb') == 4
|
||||||
|
|
||||||
|
def test_a_broken_hook_on_a_quiet_plugin_weighs_one(self):
|
||||||
|
sm = _manager({'clock': FakePlugin(live=False, hook_raises=True)},
|
||||||
|
live_weight=4)
|
||||||
|
assert sm._plugin_weight('clock') == 1
|
||||||
|
|
||||||
|
def test_a_plugin_that_cannot_say_whether_it_is_live_weighs_one(self):
|
||||||
|
# Nothing left to fall back on, so no boost.
|
||||||
|
sm = _manager({'mlb': FakePlugin(live=True, live_raises=True)},
|
||||||
|
live_weight=4)
|
||||||
|
assert sm._plugin_weight('mlb') == 1
|
||||||
|
|
||||||
|
def test_an_unknown_plugin_weighs_one(self):
|
||||||
|
assert _manager({})._plugin_weight('ghost') == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheSchedule:
|
||||||
|
def test_nothing_weighted_leaves_the_order_untouched(self):
|
||||||
|
order = ['weather', 'clock', 'news']
|
||||||
|
sm = _manager({p: FakePlugin() for p in order})
|
||||||
|
assert sm._apply_priority_weights(order) == order
|
||||||
|
|
||||||
|
def test_off_by_default_the_order_is_untouched(self):
|
||||||
|
order = ['weather', 'mlb', 'news']
|
||||||
|
sm = _manager({'weather': FakePlugin(), 'mlb': FakePlugin(live=True),
|
||||||
|
'news': FakePlugin()}, live_in_ticker=False, live_weight=3)
|
||||||
|
assert sm._apply_priority_weights(order) == order
|
||||||
|
|
||||||
|
def test_a_live_plugin_takes_its_share_of_slots(self):
|
||||||
|
order = ['weather', 'mlb', 'news', 'clock']
|
||||||
|
sm = _manager({'weather': FakePlugin(), 'mlb': FakePlugin(live=True),
|
||||||
|
'news': FakePlugin(), 'clock': FakePlugin()},
|
||||||
|
live_weight=3)
|
||||||
|
schedule = sm._apply_priority_weights(order)
|
||||||
|
counts = _counts(schedule)
|
||||||
|
assert counts['mlb'] == 3, counts
|
||||||
|
assert counts['weather'] == counts['news'] == counts['clock'] == 1, counts
|
||||||
|
assert len(schedule) == 6
|
||||||
|
|
||||||
|
def test_every_plugin_still_appears(self):
|
||||||
|
# A boost must not starve anything out of the cycle.
|
||||||
|
order = ['a', 'b', 'c', 'd', 'e', 'f']
|
||||||
|
plugins = {p: FakePlugin() for p in order}
|
||||||
|
plugins['a'] = FakePlugin(live=True, declared=10)
|
||||||
|
sm = _manager(plugins)
|
||||||
|
schedule = sm._apply_priority_weights(order)
|
||||||
|
assert set(schedule) == set(order), set(order) - set(schedule)
|
||||||
|
|
||||||
|
def test_nothing_doubles_across_the_cycle_seam(self):
|
||||||
|
# The strip loops, so the last slot neighbours the first. Smooth
|
||||||
|
# Weighted Round-Robin schedules the heaviest item first and often
|
||||||
|
# last too, which put the one clump the algorithm exists to avoid at
|
||||||
|
# the one place a within-cycle check cannot see.
|
||||||
|
order = ['baseball', 'weather', 'geochron', 'flights', 'stocks',
|
||||||
|
'oftheday', 'youtube', 'stocknews', 'leaderboard',
|
||||||
|
'countdown', 'odds', 'f1', 'football', 'music']
|
||||||
|
plugins = {p: FakePlugin() for p in order}
|
||||||
|
plugins['baseball'] = FakePlugin(live=True, declared=5)
|
||||||
|
plugins['football'] = FakePlugin(live=True, declared=3)
|
||||||
|
schedule = _manager(plugins)._apply_priority_weights(order)
|
||||||
|
|
||||||
|
n = len(schedule)
|
||||||
|
doubles = [schedule[i] for i in range(n)
|
||||||
|
if schedule[i] == schedule[(i + 1) % n]]
|
||||||
|
assert not doubles, "%r repeats across the seam in %r" % (doubles, schedule)
|
||||||
|
|
||||||
|
def test_the_seam_repair_keeps_every_slot(self):
|
||||||
|
order = ['a', 'b', 'c', 'd', 'e', 'f']
|
||||||
|
plugins = {p: FakePlugin() for p in order}
|
||||||
|
plugins['a'] = FakePlugin(live=True, declared=4)
|
||||||
|
schedule = _manager(plugins)._apply_priority_weights(order)
|
||||||
|
assert _counts(schedule)['a'] == 4, _counts(schedule)
|
||||||
|
assert sorted(schedule) == sorted(
|
||||||
|
['a'] * 4 + ['b', 'c', 'd', 'e', 'f']), schedule
|
||||||
|
|
||||||
|
def test_the_repair_uses_the_widest_gap(self):
|
||||||
|
# Moving the trailing repeat into the first slot that merely fits
|
||||||
|
# undoes the spacing: on a 28-slot rotation that turned a gap of 7
|
||||||
|
# into a gap of 2, which is more clumped than the seam ever was.
|
||||||
|
order = ['a'] + ['p%d' % i for i in range(13)]
|
||||||
|
plugins = {p: FakePlugin() for p in order}
|
||||||
|
plugins['a'] = FakePlugin(live=True, declared=4)
|
||||||
|
schedule = _manager(plugins)._apply_priority_weights(order)
|
||||||
|
at = [i for i, p in enumerate(schedule) if p == 'a']
|
||||||
|
gaps = [b - a for a, b in zip(at, at[1:])]
|
||||||
|
gaps.append(len(schedule) - at[-1] + at[0])
|
||||||
|
ideal = len(schedule) / len(at)
|
||||||
|
assert min(gaps) >= ideal / 2, "gaps %r for ideal %.1f" % (gaps, ideal)
|
||||||
|
|
||||||
|
def test_an_unavoidable_double_is_left_alone(self):
|
||||||
|
# Five of seven slots are the same plugin, so it must neighbour
|
||||||
|
# itself. Better to schedule it than to refuse or loop forever.
|
||||||
|
order = ['a', 'b', 'c']
|
||||||
|
plugins = {p: FakePlugin() for p in order}
|
||||||
|
plugins['a'] = FakePlugin(live=True, declared=5)
|
||||||
|
schedule = _manager(plugins)._apply_priority_weights(order)
|
||||||
|
assert _counts(schedule) == {'a': 5, 'b': 1, 'c': 1}, _counts(schedule)
|
||||||
|
assert set(schedule) == {'a', 'b', 'c'}
|
||||||
|
|
||||||
|
def test_the_repair_never_creates_a_new_double(self):
|
||||||
|
# The first version guarded the slot the repeated value moves *into*
|
||||||
|
# but not the one the displaced element lands in, so this traded the
|
||||||
|
# seam duplicate for a fresh one and came back ending ['x', 'x'].
|
||||||
|
sm = _manager({})
|
||||||
|
out = sm._unclump_seam(['a', 'b', 'c', 'd', 'x', 'y', 'x', 'a'])
|
||||||
|
n = len(out)
|
||||||
|
doubles = [out[i] for i in range(n) if out[i] == out[(i + 1) % n]]
|
||||||
|
assert not doubles, "%r in %r" % (doubles, out)
|
||||||
|
assert sorted(out) == sorted(['a', 'b', 'c', 'd', 'x', 'y', 'x', 'a'])
|
||||||
|
|
||||||
|
def test_the_last_two_slots_are_a_usable_swap(self):
|
||||||
|
# Reasoning about indices said this candidate was unsafe because
|
||||||
|
# schedule[j] is schedule[-2]; after the swap its neighbour is the
|
||||||
|
# repeated value, not itself. Refusing it left the only repair this
|
||||||
|
# schedule has on the table.
|
||||||
|
assert _manager({})._unclump_seam(['a', 'b', 'c', 'a']) == ['a', 'b', 'a', 'c']
|
||||||
|
|
||||||
|
def test_no_seam_schedule_is_ever_made_worse(self):
|
||||||
|
import random
|
||||||
|
sm = _manager({})
|
||||||
|
random.seed(11)
|
||||||
|
checked = 0
|
||||||
|
for size in range(3, 10):
|
||||||
|
for _ in range(400):
|
||||||
|
original = [random.choice('abcd') for _ in range(size)]
|
||||||
|
if original[0] != original[-1]:
|
||||||
|
continue
|
||||||
|
checked += 1
|
||||||
|
out = sm._unclump_seam(list(original))
|
||||||
|
n = len(out)
|
||||||
|
before = sum(1 for i in range(n)
|
||||||
|
if original[i] == original[(i + 1) % n])
|
||||||
|
after = sum(1 for i in range(n) if out[i] == out[(i + 1) % n])
|
||||||
|
assert after <= before, (original, out)
|
||||||
|
assert sorted(out) == sorted(original), (original, out)
|
||||||
|
assert checked > 100, "the generator stopped producing seam cases"
|
||||||
|
|
||||||
|
def test_a_schedule_too_short_to_repair_is_returned_as_is(self):
|
||||||
|
sm = _manager({})
|
||||||
|
assert sm._unclump_seam(['a', 'a']) == ['a', 'a']
|
||||||
|
assert sm._unclump_seam(['a']) == ['a']
|
||||||
|
assert sm._unclump_seam([]) == []
|
||||||
|
|
||||||
|
def test_a_schedule_with_no_seam_clash_is_untouched(self):
|
||||||
|
sm = _manager({})
|
||||||
|
plain = ['a', 'b', 'c', 'a', 'd']
|
||||||
|
assert sm._unclump_seam(plain) == plain
|
||||||
|
|
||||||
|
def test_repeats_are_spread_not_clumped(self):
|
||||||
|
# The point of Smooth Weighted Round-Robin. Three-in-a-row followed by
|
||||||
|
# a long silence would be worse than not boosting at all.
|
||||||
|
order = ['weather', 'mlb', 'news', 'clock', 'stocks', 'f1']
|
||||||
|
plugins = {p: FakePlugin() for p in order}
|
||||||
|
plugins['mlb'] = FakePlugin(live=True)
|
||||||
|
sm = _manager(plugins, live_weight=3)
|
||||||
|
schedule = sm._apply_priority_weights(order)
|
||||||
|
|
||||||
|
assert _counts(schedule)['mlb'] == 3
|
||||||
|
# Evenly spread over 8 slots means a gap of about 3, never 6.
|
||||||
|
assert _max_gap(schedule, 'mlb') <= 4, schedule
|
||||||
|
# And never twice running.
|
||||||
|
assert not any(a == b == 'mlb' for a, b in zip(schedule, schedule[1:])), schedule
|
||||||
|
|
||||||
|
def test_a_favorite_outranks_another_live_game(self):
|
||||||
|
order = ['weather', 'mlb', 'nhl']
|
||||||
|
sm = _manager({'weather': FakePlugin(),
|
||||||
|
'mlb': FakePlugin(live=True, declared=5),
|
||||||
|
'nhl': FakePlugin(live=True)}, live_weight=2)
|
||||||
|
counts = _counts(sm._apply_priority_weights(order))
|
||||||
|
assert counts['mlb'] == 5 and counts['nhl'] == 2 and counts['weather'] == 1, counts
|
||||||
|
|
||||||
|
def test_an_empty_rotation_is_harmless(self):
|
||||||
|
assert _manager({})._apply_priority_weights([]) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigParsing:
|
||||||
|
def test_defaults_preserve_todays_behaviour(self):
|
||||||
|
cfg = VegasModeConfig.from_config({})
|
||||||
|
assert cfg.live_in_ticker is False
|
||||||
|
assert cfg.live_weight == 3 and cfg.favorite_live_weight == 5
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("given,expected", [(0, 1), (-4, 1), (99, 10), (4, 4)])
|
||||||
|
def test_weights_are_clamped(self, given, expected):
|
||||||
|
cfg = VegasModeConfig.from_config(
|
||||||
|
{'display': {'vegas_scroll': {'live_weight': given}}})
|
||||||
|
assert cfg.live_weight == expected
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
"""Tests the calendar plugin's OAuth and calendar-listing endpoints.
|
||||||
|
|
||||||
|
The plugin's config UI advertised a three-step setup, but only step 1 existed
|
||||||
|
on the server. Step 3's picker fetched /api/v3/plugins/calendar/list-calendars,
|
||||||
|
which was never registered, so Flask fell through to the global 404 handler and
|
||||||
|
the user saw "Resource not found" — with nothing to say which resource. Step 2
|
||||||
|
had no endpoint either, and no field in the schema at all, even though the
|
||||||
|
plugin ships calendar_registration.py written expressly for a web-driven
|
||||||
|
two-step flow.
|
||||||
|
|
||||||
|
These cover the two new routes: that they exist, that they fail with something
|
||||||
|
actionable rather than a bare 404, and that the shapes the widgets consume are
|
||||||
|
what the server actually sends.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pickle
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
project_root = Path(__file__).parent.parent.parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
from web_interface.blueprints import api_v3 as mod # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch, tmp_path):
|
||||||
|
"""A test client whose calendar plugin lives in tmp_path."""
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
plugin_dir = tmp_path / 'calendar'
|
||||||
|
plugin_dir.mkdir()
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.register_blueprint(mod.api_v3, url_prefix='/api/v3')
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
monkeypatch.setattr(mod, '_calendar_plugin_dir', lambda: plugin_dir)
|
||||||
|
with app.test_client() as c:
|
||||||
|
c.plugin_dir = plugin_dir
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def uninstalled(monkeypatch):
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.register_blueprint(mod.api_v3, url_prefix='/api/v3')
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
monkeypatch.setattr(mod, '_calendar_plugin_dir', lambda: None)
|
||||||
|
with app.test_client() as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheRoutesExistAtAll:
|
||||||
|
"""The original bug: the URLs the widgets call were not registered."""
|
||||||
|
|
||||||
|
def test_list_calendars_is_routed(self, client):
|
||||||
|
response = client.get('/api/v3/plugins/calendar/list-calendars')
|
||||||
|
# Reaching the handler is the whole point; what it then says about
|
||||||
|
# missing setup is TestItSaysWhatIsWrong's business.
|
||||||
|
assert response.status_code != 404, "still unrouted"
|
||||||
|
assert response.get_json()['message'] != 'Resource not found'
|
||||||
|
|
||||||
|
def test_authenticate_is_routed(self, client):
|
||||||
|
response = client.post('/api/v3/plugins/calendar/authenticate', json={})
|
||||||
|
assert response.status_code != 404, "still unrouted"
|
||||||
|
assert response.get_json()['message'] != 'Resource not found'
|
||||||
|
|
||||||
|
def test_both_urls_match_what_the_widgets_request(self):
|
||||||
|
# The widgets hardcode these; a rename on either side reintroduces the
|
||||||
|
# original bug silently.
|
||||||
|
picker = Path(project_root) / 'web_interface/static/v3/js/widgets/google-calendar-picker.js'
|
||||||
|
oauth = Path(project_root) / 'web_interface/static/v3/js/widgets/google-oauth.js'
|
||||||
|
assert '/api/v3/plugins/calendar/list-calendars' in picker.read_text(encoding='utf-8')
|
||||||
|
assert '/api/v3/plugins/calendar/authenticate' in oauth.read_text(encoding='utf-8')
|
||||||
|
source = (Path(project_root) / 'web_interface/blueprints/api_v3.py').read_text(encoding='utf-8')
|
||||||
|
assert "'/plugins/calendar/list-calendars'" in source
|
||||||
|
assert "'/plugins/calendar/authenticate'" in source
|
||||||
|
|
||||||
|
def test_the_oauth_widget_is_dispatched_not_rendered_as_a_text_box(self):
|
||||||
|
# The string branch of the config template dispatches on an allow-list
|
||||||
|
# of widget names; anything missing from it silently falls through to a
|
||||||
|
# plain <input type="text">. That produced two boxes on the calendar
|
||||||
|
# page -- the widget's own, and a stray one for the same field -- and
|
||||||
|
# no way to tell which to paste into.
|
||||||
|
template = (Path(project_root)
|
||||||
|
/ 'web_interface/templates/v3/partials/plugin_config.html'
|
||||||
|
).read_text(encoding='utf-8')
|
||||||
|
allow_list_line = [ln for ln in template.splitlines()
|
||||||
|
if "str_widget in [" in ln]
|
||||||
|
assert allow_list_line, "the string widget allow-list moved"
|
||||||
|
assert "'google-oauth'" in allow_list_line[0], allow_list_line[0]
|
||||||
|
|
||||||
|
def test_the_widget_script_is_served(self):
|
||||||
|
base = (Path(project_root) / 'web_interface/templates/v3/base.html'
|
||||||
|
).read_text(encoding='utf-8')
|
||||||
|
assert 'widgets/google-oauth.js' in base
|
||||||
|
|
||||||
|
def test_the_status_line_is_announced(self):
|
||||||
|
# Every message the widget gives arrives after an async call, so a
|
||||||
|
# screen reader hears nothing unless the element is a live region.
|
||||||
|
widget = (Path(project_root)
|
||||||
|
/ 'web_interface/static/v3/js/widgets/google-oauth.js'
|
||||||
|
).read_text(encoding='utf-8')
|
||||||
|
# Both attributes must be on the *status* element. Searching for them
|
||||||
|
# separately would pass with each on a different node, which announces
|
||||||
|
# nothing.
|
||||||
|
assert "status.setAttribute('role', 'status')" in widget, widget[:0]
|
||||||
|
assert "status.setAttribute('aria-live', 'polite')" in widget
|
||||||
|
|
||||||
|
def test_the_paste_box_has_an_accessible_name(self):
|
||||||
|
# A visible label is not enough on its own: without the association the
|
||||||
|
# input's only name is a placeholder, which vanishes on focus -- which
|
||||||
|
# is exactly when the value is being pasted.
|
||||||
|
widget = (Path(project_root)
|
||||||
|
/ 'web_interface/static/v3/js/widgets/google-oauth.js'
|
||||||
|
).read_text(encoding='utf-8')
|
||||||
|
# The binding is what matters, not that both lines exist: a `for` and
|
||||||
|
# an `id` that disagree leave the input just as anonymous. Both must
|
||||||
|
# go through the same identifier.
|
||||||
|
import re as _re
|
||||||
|
for_target = _re.search(r"codeLabel\.setAttribute\('for',\s*(\w+)\)", widget)
|
||||||
|
id_source = _re.search(r"codeInput\.id\s*=\s*(\w+)", widget)
|
||||||
|
assert for_target and id_source, (for_target, id_source)
|
||||||
|
assert for_target.group(1) == id_source.group(1), (
|
||||||
|
"label points at %r but the input is %r"
|
||||||
|
% (for_target.group(1), id_source.group(1)))
|
||||||
|
|
||||||
|
def test_the_failed_page_is_called_out_loudly(self):
|
||||||
|
# The loopback redirect lands on a browser error page at exactly the
|
||||||
|
# moment the user has to act. In small grey text it gets missed and the
|
||||||
|
# flow reads as broken while it is working.
|
||||||
|
widget = (Path(project_root)
|
||||||
|
/ 'web_interface/static/v3/js/widgets/google-oauth.js'
|
||||||
|
).read_text(encoding='utf-8')
|
||||||
|
assert 'expected' in widget.lower()
|
||||||
|
assert 'amber' in widget, "the warning is not visually distinguished"
|
||||||
|
|
||||||
|
|
||||||
|
class TestItSaysWhatIsWrong:
|
||||||
|
def test_listing_without_a_token_asks_for_step_2(self, client):
|
||||||
|
response = client.get('/api/v3/plugins/calendar/list-calendars')
|
||||||
|
assert response.status_code == 400
|
||||||
|
body = response.get_json()
|
||||||
|
assert body['status'] == 'error'
|
||||||
|
assert 'step 2' in body['message'].lower(), body['message']
|
||||||
|
|
||||||
|
def test_authenticating_without_credentials_asks_for_step_1(self, client):
|
||||||
|
response = client.post('/api/v3/plugins/calendar/authenticate', json={})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert 'step 1' in response.get_json()['message'].lower()
|
||||||
|
|
||||||
|
def test_an_uninstalled_plugin_says_so(self, uninstalled):
|
||||||
|
for response in (
|
||||||
|
uninstalled.get('/api/v3/plugins/calendar/list-calendars'),
|
||||||
|
uninstalled.post('/api/v3/plugins/calendar/authenticate', json={}),
|
||||||
|
):
|
||||||
|
assert response.status_code == 404
|
||||||
|
# A 404 here is honest -- but it must name the plugin, not read as
|
||||||
|
# the generic "Resource not found" that started this.
|
||||||
|
assert 'not installed' in response.get_json()['message'].lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestTheScriptRunner:
|
||||||
|
def test_it_returns_the_json_the_script_prints(self, tmp_path):
|
||||||
|
script = tmp_path / 'calendar_registration.py'
|
||||||
|
script.write_text(
|
||||||
|
'print(\'{"status": "success", "auth_url": "https://x"}\')\n',
|
||||||
|
encoding='utf-8')
|
||||||
|
payload, error = mod._run_calendar_registration(tmp_path, '')
|
||||||
|
assert error is None
|
||||||
|
assert payload['auth_url'] == 'https://x'
|
||||||
|
|
||||||
|
def test_it_ignores_noise_before_the_json(self, tmp_path):
|
||||||
|
# An import warning or a library writing to stdout would otherwise
|
||||||
|
# make the last-line parse fail.
|
||||||
|
script = tmp_path / 'calendar_registration.py'
|
||||||
|
script.write_text(
|
||||||
|
'print("some library warning")\n'
|
||||||
|
'print(\'{"status": "success"}\')\n', encoding='utf-8')
|
||||||
|
payload, error = mod._run_calendar_registration(tmp_path, '')
|
||||||
|
assert error is None and payload['status'] == 'success'
|
||||||
|
|
||||||
|
def test_it_passes_stdin_through(self, tmp_path):
|
||||||
|
script = tmp_path / 'calendar_registration.py'
|
||||||
|
script.write_text(
|
||||||
|
'import sys, json\n'
|
||||||
|
'print(json.dumps({"status": "success", "got": sys.stdin.read().strip()}))\n',
|
||||||
|
encoding='utf-8')
|
||||||
|
payload, _ = mod._run_calendar_registration(tmp_path, 'http://127.0.0.1/?code=abc')
|
||||||
|
assert payload['got'] == 'http://127.0.0.1/?code=abc'
|
||||||
|
|
||||||
|
def test_a_missing_script_is_reported(self, tmp_path):
|
||||||
|
payload, error = mod._run_calendar_registration(tmp_path, '')
|
||||||
|
assert payload is None
|
||||||
|
assert 'script not found' in error.lower()
|
||||||
|
|
||||||
|
def test_output_that_is_not_json_is_reported_with_context(self, tmp_path):
|
||||||
|
script = tmp_path / 'calendar_registration.py'
|
||||||
|
script.write_text('import sys\nsys.stderr.write("boom\\n")\n', encoding='utf-8')
|
||||||
|
payload, error = mod._run_calendar_registration(tmp_path, '')
|
||||||
|
assert payload is None
|
||||||
|
assert 'no result' in error.lower()
|
||||||
|
assert 'boom' in error
|
||||||
|
|
||||||
|
|
||||||
|
class TestListingShape:
|
||||||
|
"""The picker reads cal.id, cal.summary and cal.primary."""
|
||||||
|
|
||||||
|
def _authenticate(self, client, monkeypatch, items):
|
||||||
|
creds = type('C', (), {'expired': False, 'refresh_token': None, 'valid': True})()
|
||||||
|
(client.plugin_dir / 'token.pickle').write_bytes(pickle.dumps({'x': 1}))
|
||||||
|
monkeypatch.setattr(mod.pickle if hasattr(mod, 'pickle') else pickle,
|
||||||
|
'loads', lambda *a, **k: creds, raising=False)
|
||||||
|
|
||||||
|
import types
|
||||||
|
fake_pickle = types.SimpleNamespace(load=lambda f: creds, dump=lambda *a: None)
|
||||||
|
# Callers pass a flat list of calendars; the API returns them wrapped
|
||||||
|
# in a page. One page is all these cases need -- TestPagination builds
|
||||||
|
# its own multi-page sequences.
|
||||||
|
pages = [{'items': items}]
|
||||||
|
|
||||||
|
state = {'i': 0}
|
||||||
|
|
||||||
|
def fake_list(**kwargs):
|
||||||
|
page = pages[min(state['i'], len(pages) - 1)]
|
||||||
|
state['i'] += 1
|
||||||
|
return types.SimpleNamespace(execute=lambda: page)
|
||||||
|
|
||||||
|
def fake_build(*args, **kwargs):
|
||||||
|
return types.SimpleNamespace(
|
||||||
|
calendarList=lambda: types.SimpleNamespace(list=fake_list))
|
||||||
|
|
||||||
|
real_import = __builtins__['__import__'] if isinstance(__builtins__, dict) \
|
||||||
|
else __builtins__.__import__
|
||||||
|
|
||||||
|
def fake_import(name, *args, **kwargs):
|
||||||
|
if name == 'pickle':
|
||||||
|
return fake_pickle
|
||||||
|
if name == 'google.auth.transport.requests':
|
||||||
|
return types.SimpleNamespace(Request=object)
|
||||||
|
if name == 'googleapiclient.discovery':
|
||||||
|
return types.SimpleNamespace(build=fake_build)
|
||||||
|
return real_import(name, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr('builtins.__import__', fake_import)
|
||||||
|
|
||||||
|
def test_it_returns_id_summary_and_primary(self, client, monkeypatch):
|
||||||
|
self._authenticate(client, monkeypatch, [
|
||||||
|
{'id': 'b@x', 'summary': 'Work'},
|
||||||
|
{'id': 'a@x', 'summary': 'Personal', 'primary': True},
|
||||||
|
])
|
||||||
|
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
||||||
|
assert body['status'] == 'success'
|
||||||
|
assert {c['id'] for c in body['calendars']} == {'a@x', 'b@x'}
|
||||||
|
assert all(set(c) == {'id', 'summary', 'primary'} for c in body['calendars'])
|
||||||
|
|
||||||
|
def test_the_primary_calendar_comes_first(self, client, monkeypatch):
|
||||||
|
# Short list, but the one the user wants is almost always their own.
|
||||||
|
self._authenticate(client, monkeypatch, [
|
||||||
|
{'id': 'z@x', 'summary': 'Aardvarks'},
|
||||||
|
{'id': 'a@x', 'summary': 'Zebras', 'primary': True},
|
||||||
|
])
|
||||||
|
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
||||||
|
assert body['calendars'][0]['id'] == 'a@x'
|
||||||
|
assert body['calendars'][0]['primary'] is True
|
||||||
|
|
||||||
|
def test_a_calendar_without_a_name_still_lists(self, client, monkeypatch):
|
||||||
|
self._authenticate(client, monkeypatch, [{'id': 'noname@x'}])
|
||||||
|
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
||||||
|
assert body['calendars'][0]['summary'] == 'noname@x'
|
||||||
|
|
||||||
|
def test_entries_without_an_id_are_dropped(self, client, monkeypatch):
|
||||||
|
# Nothing could be selected by such a row, and the checkbox value
|
||||||
|
# would be undefined.
|
||||||
|
self._authenticate(client, monkeypatch, [{'summary': 'ghost'}, {'id': 'real@x'}])
|
||||||
|
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
||||||
|
assert [c['id'] for c in body['calendars']] == ['real@x']
|
||||||
|
|
||||||
|
|
||||||
|
class TestPagination:
|
||||||
|
"""calendarList.list pages at 250 and defaults to 100."""
|
||||||
|
|
||||||
|
def _paged(self, client, monkeypatch, pages):
|
||||||
|
import types
|
||||||
|
creds = type('C', (), {'expired': False, 'refresh_token': None, 'valid': True})()
|
||||||
|
(client.plugin_dir / 'token.pickle').write_bytes(b'x')
|
||||||
|
state = {'i': 0}
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def fake_list(**kwargs):
|
||||||
|
seen.append(kwargs)
|
||||||
|
page = pages[min(state['i'], len(pages) - 1)]
|
||||||
|
state['i'] += 1
|
||||||
|
return types.SimpleNamespace(execute=lambda: page)
|
||||||
|
|
||||||
|
def fake_build(*args, **kwargs):
|
||||||
|
return types.SimpleNamespace(
|
||||||
|
calendarList=lambda: types.SimpleNamespace(list=fake_list))
|
||||||
|
|
||||||
|
real_import = __builtins__['__import__'] if isinstance(__builtins__, dict) \
|
||||||
|
else __builtins__.__import__
|
||||||
|
|
||||||
|
def fake_import(name, *args, **kwargs):
|
||||||
|
if name == 'pickle':
|
||||||
|
return types.SimpleNamespace(load=lambda f: creds, dump=lambda *a: None)
|
||||||
|
if name == 'google.auth.transport.requests':
|
||||||
|
return types.SimpleNamespace(Request=object)
|
||||||
|
if name == 'googleapiclient.discovery':
|
||||||
|
return types.SimpleNamespace(build=fake_build)
|
||||||
|
return real_import(name, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr('builtins.__import__', fake_import)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
def test_every_page_is_collected(self, client, monkeypatch):
|
||||||
|
# Taking only the first page would hide calendars from the picker with
|
||||||
|
# nothing to say the list was cut short.
|
||||||
|
self._paged(client, monkeypatch, [
|
||||||
|
{'items': [{'id': 'a@x', 'summary': 'A'}], 'nextPageToken': 't1'},
|
||||||
|
{'items': [{'id': 'b@x', 'summary': 'B'}], 'nextPageToken': 't2'},
|
||||||
|
{'items': [{'id': 'c@x', 'summary': 'C'}]},
|
||||||
|
])
|
||||||
|
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
||||||
|
assert [c['id'] for c in body['calendars']] == ['a@x', 'b@x', 'c@x']
|
||||||
|
|
||||||
|
def test_the_page_token_is_passed_back(self, client, monkeypatch):
|
||||||
|
seen = self._paged(client, monkeypatch, [
|
||||||
|
{'items': [{'id': 'a@x', 'summary': 'A'}], 'nextPageToken': 'tok'},
|
||||||
|
{'items': [{'id': 'b@x', 'summary': 'B'}]},
|
||||||
|
])
|
||||||
|
client.get('/api/v3/plugins/calendar/list-calendars')
|
||||||
|
assert seen[0]['pageToken'] is None
|
||||||
|
assert seen[1]['pageToken'] == 'tok'
|
||||||
|
assert all(k['maxResults'] == 250 for k in seen)
|
||||||
|
|
||||||
|
def test_a_looping_token_cannot_spin_forever(self, client, monkeypatch):
|
||||||
|
# Every page claims another follows.
|
||||||
|
self._paged(client, monkeypatch, [
|
||||||
|
{'items': [{'id': 'a@x', 'summary': 'A'}], 'nextPageToken': 'same'},
|
||||||
|
])
|
||||||
|
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
||||||
|
assert body['status'] == 'success'
|
||||||
|
assert len(body['calendars']) <= mod._CALENDAR_LIST_MAX_PAGES
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiagnosticsAreRedacted:
|
||||||
|
def test_script_stderr_is_redacted_on_the_way_out(self, tmp_path):
|
||||||
|
script = tmp_path / 'calendar_registration.py'
|
||||||
|
script.write_text(
|
||||||
|
'import sys\n'
|
||||||
|
'sys.stderr.write("boom client_secret=hunter2 more\\n")\n',
|
||||||
|
encoding='utf-8')
|
||||||
|
payload, error = mod._run_calendar_registration(tmp_path, '')
|
||||||
|
assert payload is None
|
||||||
|
assert 'hunter2' not in error, error
|
||||||
|
assert '<redacted>' in error, error
|
||||||
|
|
||||||
|
def test_a_failing_script_payload_is_redacted(self, client):
|
||||||
|
(client.plugin_dir / 'credentials.json').write_text('{}', encoding='utf-8')
|
||||||
|
(client.plugin_dir / 'calendar_registration.py').write_text(
|
||||||
|
'import json\n'
|
||||||
|
'print(json.dumps({"status": "error", '
|
||||||
|
'"message": "Failed: client_secret=topsecret"}))\n',
|
||||||
|
encoding='utf-8')
|
||||||
|
body = client.post('/api/v3/plugins/calendar/authenticate',
|
||||||
|
json={}).get_json()
|
||||||
|
assert body['status'] == 'error'
|
||||||
|
assert 'topsecret' not in json.dumps(body), body
|
||||||
|
assert '<redacted>' in body['message'], body
|
||||||
|
|
||||||
|
def test_an_unrunnable_script_is_reported_without_raw_exception_text(self,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch):
|
||||||
|
# OSError from the spawn carries the interpreter path and whatever the
|
||||||
|
# OS chose to say; it reaches the client through the redactor like
|
||||||
|
# everything else.
|
||||||
|
script = tmp_path / 'calendar_registration.py'
|
||||||
|
script.write_text('', encoding='utf-8')
|
||||||
|
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise OSError("Exec format error: token=abcd1234 /usr/bin/python3")
|
||||||
|
|
||||||
|
monkeypatch.setattr(mod.subprocess, 'run', boom)
|
||||||
|
payload, error = mod._run_calendar_registration(tmp_path, '')
|
||||||
|
assert payload is None
|
||||||
|
assert 'abcd1234' not in error, error
|
||||||
|
assert 'OSError' in error, error
|
||||||
|
|
||||||
|
def test_a_missing_google_library_is_reported_without_raw_exception_text(
|
||||||
|
self, client, monkeypatch):
|
||||||
|
(client.plugin_dir / 'token.pickle').write_bytes(b'x')
|
||||||
|
real_import = __builtins__['__import__'] if isinstance(__builtins__, dict) \
|
||||||
|
else __builtins__.__import__
|
||||||
|
|
||||||
|
def fake_import(name, *args, **kwargs):
|
||||||
|
if name.startswith('google'):
|
||||||
|
raise ImportError("No module named 'google' password=hunter2")
|
||||||
|
return real_import(name, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr('builtins.__import__', fake_import)
|
||||||
|
body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
|
||||||
|
assert 'hunter2' not in json.dumps(body), body
|
||||||
|
assert 'requirements.txt' in body['message']
|
||||||
@@ -22,7 +22,7 @@ logger = logging.getLogger(__name__)
|
|||||||
from src.web_interface.api_helpers import success_response, error_response, validate_request_json
|
from src.web_interface.api_helpers import success_response, error_response, validate_request_json
|
||||||
from src.web_interface.errors import ErrorCode
|
from src.web_interface.errors import ErrorCode
|
||||||
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
|
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
|
||||||
from src.web_interface.error_handler import describe_exception
|
from src.web_interface.error_handler import describe_exception, redact_text
|
||||||
from src.plugin_system.operation_types import OperationType
|
from src.plugin_system.operation_types import OperationType
|
||||||
from src.web_interface.validators import (
|
from src.web_interface.validators import (
|
||||||
validate_file_upload
|
validate_file_upload
|
||||||
@@ -7317,6 +7317,227 @@ def upload_calendar_credentials():
|
|||||||
logger.error('Error in upload_calendar_credentials', exc_info=True)
|
logger.error('Error in upload_calendar_credentials', exc_info=True)
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||||
|
|
||||||
|
# calendarList.list pages at 250 entries maximum. Ten pages is far past any
|
||||||
|
# real account and exists only so a malformed nextPageToken cannot spin here.
|
||||||
|
_CALENDAR_LIST_MAX_PAGES = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _calendar_plugin_dir() -> Optional[Path]:
|
||||||
|
"""Where the calendar plugin is installed, or None if it is not."""
|
||||||
|
if api_v3.plugin_manager:
|
||||||
|
plugin_dir = api_v3.plugin_manager.get_plugin_directory('calendar')
|
||||||
|
else:
|
||||||
|
plugin_dir = PROJECT_ROOT / 'plugins' / 'calendar'
|
||||||
|
if not plugin_dir:
|
||||||
|
return None
|
||||||
|
plugin_dir = Path(plugin_dir)
|
||||||
|
return plugin_dir if plugin_dir.exists() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _run_calendar_registration(plugin_dir: Path, stdin_payload: str):
|
||||||
|
"""Run the plugin's OAuth script and return the JSON object it prints.
|
||||||
|
|
||||||
|
The script decides between web and terminal mode by whether stdin is a
|
||||||
|
tty, so it must be given a pipe. It emits one JSON object on stdout; the
|
||||||
|
last parsable line is taken, because an import warning or a library's
|
||||||
|
stderr redirection can land in front of it.
|
||||||
|
|
||||||
|
Returns (payload, error_message). Exactly one is None.
|
||||||
|
"""
|
||||||
|
script = plugin_dir / 'calendar_registration.py'
|
||||||
|
if not script.exists():
|
||||||
|
return None, 'Authentication script not found in the calendar plugin'
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run( # nosec B603 - fixed script path inside the plugin dir
|
||||||
|
[sys.executable, str(script)],
|
||||||
|
input=stdin_payload,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120,
|
||||||
|
cwd=str(plugin_dir),
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return None, 'Authentication timed out after 120s'
|
||||||
|
except OSError as e:
|
||||||
|
logger.error('Could not run calendar_registration.py', exc_info=True)
|
||||||
|
return None, 'Could not run the authentication script: %s' % describe_exception(e)
|
||||||
|
|
||||||
|
for line in reversed((result.stdout or '').splitlines()):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
payload = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
return payload, None
|
||||||
|
|
||||||
|
raw = (result.stderr or result.stdout or '').strip()
|
||||||
|
# The unredacted text goes to the log, where it is worth having in full.
|
||||||
|
# What comes back over HTTP is redacted: this is a script that handles
|
||||||
|
# OAuth client secrets, and its stderr can quote them.
|
||||||
|
if raw:
|
||||||
|
logger.error('calendar_registration.py failed (exit %s): %s',
|
||||||
|
result.returncode, raw)
|
||||||
|
return None, 'Authentication script produced no result%s' % (
|
||||||
|
': %s' % redact_text(raw) if raw else '')
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/plugins/calendar/authenticate', methods=['POST'])
|
||||||
|
def authenticate_calendar():
|
||||||
|
"""Google OAuth for the calendar plugin, in the two steps it requires.
|
||||||
|
|
||||||
|
Step 1 (no body) returns the consent URL to open. Step 2 posts back the
|
||||||
|
URL Google redirected to -- it fails to load, because the redirect points
|
||||||
|
at a loopback address nothing is listening on, but the address bar carries
|
||||||
|
the authorization code -- and the script exchanges it for a token.
|
||||||
|
|
||||||
|
Two calls rather than one because the user has to visit Google in between.
|
||||||
|
The script persists the PKCE verifier from step 1 for step 2 to reuse; the
|
||||||
|
exchange fails with "Missing code verifier" otherwise.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
plugin_dir = _calendar_plugin_dir()
|
||||||
|
if plugin_dir is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'The calendar plugin is not installed'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
if not (plugin_dir / 'credentials.json').exists():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': ('No credentials.json yet. Upload your Google OAuth '
|
||||||
|
'client file first (Step 1).')
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
redirect_url = (data.get('redirect_url') or data.get('code') or '').strip()
|
||||||
|
|
||||||
|
payload, error = _run_calendar_registration(plugin_dir, redirect_url)
|
||||||
|
if error:
|
||||||
|
return jsonify({'status': 'error', 'message': error}), 500
|
||||||
|
if payload.get('status') != 'success':
|
||||||
|
# The script's own diagnosis is more useful than anything that
|
||||||
|
# could be reconstructed here -- but it interpolates exceptions
|
||||||
|
# into its messages, so it reaches the client redacted and the
|
||||||
|
# original goes to the log.
|
||||||
|
logger.error('calendar authentication failed: %s', payload)
|
||||||
|
safe = dict(payload)
|
||||||
|
safe['message'] = redact_text(str(payload.get('message', '')
|
||||||
|
or 'Authentication failed'))
|
||||||
|
return jsonify(safe), 400
|
||||||
|
return jsonify(payload)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error in authenticate_calendar', exc_info=True)
|
||||||
|
return jsonify({'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details',
|
||||||
|
'details': describe_exception(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.route('/plugins/calendar/list-calendars', methods=['GET'])
|
||||||
|
def list_calendar_calendars():
|
||||||
|
"""The calendars this account can see, for the config picker.
|
||||||
|
|
||||||
|
Reads the token the OAuth flow wrote rather than shelling out again: the
|
||||||
|
picker is used interactively and a subprocess per click is slower than the
|
||||||
|
API call it would be wrapping.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
plugin_dir = _calendar_plugin_dir()
|
||||||
|
if plugin_dir is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'The calendar plugin is not installed'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
token_file = plugin_dir / 'token.pickle'
|
||||||
|
if not token_file.exists():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': ('Not authenticated with Google yet. Complete Step 2 '
|
||||||
|
'first, then load your calendars.')
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pickle
|
||||||
|
from google.auth.transport.requests import Request as GoogleRequest
|
||||||
|
from googleapiclient.discovery import build as build_google_service
|
||||||
|
except ImportError as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
# The name of the missing module is the whole diagnosis, but it
|
||||||
|
# arrives as an exception, so it goes through the redactor like
|
||||||
|
# any other -- an ImportError can quote a path.
|
||||||
|
'message': ('The Google API libraries are not installed. Install '
|
||||||
|
"the calendar plugin's requirements.txt. (%s)"
|
||||||
|
% describe_exception(e))
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
with open(token_file, 'rb') as handle:
|
||||||
|
# Written only by this plugin's own OAuth flow, into its own
|
||||||
|
# directory, and read here exactly as the plugin itself reads it.
|
||||||
|
creds = pickle.load(handle) # nosec B301 - locally generated token
|
||||||
|
|
||||||
|
if creds and creds.expired and creds.refresh_token:
|
||||||
|
creds.refresh(GoogleRequest())
|
||||||
|
with open(token_file, 'wb') as handle:
|
||||||
|
pickle.dump(creds, handle)
|
||||||
|
os.chmod(token_file, 0o600)
|
||||||
|
|
||||||
|
if not creds or not creds.valid:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': ('Stored Google credentials are no longer valid. '
|
||||||
|
'Run Step 2 again to re-authenticate.')
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
service = build_google_service('calendar', 'v3', credentials=creds)
|
||||||
|
|
||||||
|
# calendarList.list returns 100 entries per page by default and caps at
|
||||||
|
# 250, handing back a nextPageToken when there are more. Taking only
|
||||||
|
# the first page would silently hide calendars from the picker, and the
|
||||||
|
# user would have no way to tell the list was truncated.
|
||||||
|
entries = []
|
||||||
|
page_token = None
|
||||||
|
for _ in range(_CALENDAR_LIST_MAX_PAGES):
|
||||||
|
response = service.calendarList().list(
|
||||||
|
maxResults=250, pageToken=page_token).execute()
|
||||||
|
entries.extend(response.get('items', []))
|
||||||
|
page_token = response.get('nextPageToken')
|
||||||
|
if not page_token:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# 2500 calendars in, something is wrong with the account or the
|
||||||
|
# token is looping; show what was collected rather than spin.
|
||||||
|
logger.warning(
|
||||||
|
'calendarList paging stopped at %d pages with more remaining',
|
||||||
|
_CALENDAR_LIST_MAX_PAGES)
|
||||||
|
|
||||||
|
calendars = [{
|
||||||
|
'id': entry.get('id'),
|
||||||
|
# The picker labels each row with summary and falls back to the id
|
||||||
|
# only in its own display, so send something either way.
|
||||||
|
'summary': entry.get('summary') or entry.get('id'),
|
||||||
|
'primary': bool(entry.get('primary', False)),
|
||||||
|
} for entry in entries if entry.get('id')]
|
||||||
|
|
||||||
|
# Primary first, then alphabetically: the list is usually short but the
|
||||||
|
# one the user wants is almost always their own calendar.
|
||||||
|
calendars.sort(key=lambda c: (not c['primary'], c['summary'].lower()))
|
||||||
|
|
||||||
|
return jsonify({'status': 'success', 'calendars': calendars})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error in list_calendar_calendars', exc_info=True)
|
||||||
|
return jsonify({'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details',
|
||||||
|
'details': describe_exception(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/plugins/assets/delete', methods=['POST'])
|
@api_v3.route('/plugins/assets/delete', methods=['POST'])
|
||||||
def delete_plugin_asset():
|
def delete_plugin_asset():
|
||||||
"""Delete an asset file for a plugin"""
|
"""Delete an asset file for a plugin"""
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* Google OAuth Widget
|
||||||
|
*
|
||||||
|
* Step 2 of the calendar plugin's setup, between uploading the OAuth client
|
||||||
|
* file and picking calendars. Google will not let a headless device complete
|
||||||
|
* consent on its own, so the flow is necessarily two calls with a human in
|
||||||
|
* between:
|
||||||
|
*
|
||||||
|
* 1. POST /api/v3/plugins/calendar/authenticate with no body
|
||||||
|
* -> { auth_url } to open in a browser
|
||||||
|
* 2. the browser lands on a loopback address that fails to load; its URL
|
||||||
|
* carries the authorization code. POST it back as redirect_url
|
||||||
|
* -> the server exchanges it and writes token.pickle
|
||||||
|
*
|
||||||
|
* The failed page in step 2 is expected and is worth saying out loud, because
|
||||||
|
* it looks exactly like something went wrong.
|
||||||
|
*
|
||||||
|
* @module GoogleOAuthWidget
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
||||||
|
console.error('[GoogleOAuthWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENDPOINT = '/api/v3/plugins/calendar/authenticate';
|
||||||
|
|
||||||
|
window.LEDMatrixWidgets.register('google-oauth', {
|
||||||
|
name: 'Google OAuth Widget',
|
||||||
|
version: '1.0.0',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
* @param {Object} config - schema config (unused)
|
||||||
|
* @param {*} value - unused; this widget stores nothing
|
||||||
|
* @param {Object} options - { fieldId, pluginId, name }
|
||||||
|
*/
|
||||||
|
render: function (container, config, value, options) {
|
||||||
|
const fieldId = options.fieldId;
|
||||||
|
|
||||||
|
// Nothing is stored in config by this step -- the result is
|
||||||
|
// token.pickle on the device -- but the form still expects a field.
|
||||||
|
const hidden = document.createElement('input');
|
||||||
|
hidden.type = 'hidden';
|
||||||
|
hidden.id = fieldId + '_hidden';
|
||||||
|
hidden.name = options.name;
|
||||||
|
hidden.value = value || '';
|
||||||
|
|
||||||
|
const startBtn = document.createElement('button');
|
||||||
|
startBtn.type = 'button';
|
||||||
|
startBtn.className = 'px-3 py-1.5 text-sm rounded-md bg-blue-600 hover:bg-blue-700 text-white';
|
||||||
|
startBtn.innerHTML = '<i class="fas fa-key"></i> Connect Google Account';
|
||||||
|
|
||||||
|
const status = document.createElement('p');
|
||||||
|
status.className = 'text-xs text-gray-400 mt-2';
|
||||||
|
// Every message this widget gives -- the consent link is ready,
|
||||||
|
// the exchange failed -- arrives here after an async call, so a
|
||||||
|
// screen reader is told nothing unless it is a live region.
|
||||||
|
status.setAttribute('role', 'status');
|
||||||
|
status.setAttribute('aria-live', 'polite');
|
||||||
|
|
||||||
|
const step2 = document.createElement('div');
|
||||||
|
step2.className = 'mt-3 hidden';
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.target = '_blank';
|
||||||
|
link.rel = 'noopener noreferrer';
|
||||||
|
link.className = 'text-blue-400 underline text-sm break-all';
|
||||||
|
link.textContent = 'Open the Google consent screen';
|
||||||
|
|
||||||
|
// Deliberately loud. After consent the browser is redirected to a
|
||||||
|
// loopback address nothing is listening on, so it lands on a
|
||||||
|
// browser error page -- which reads as a failure at exactly the
|
||||||
|
// moment the user has to act on it. Said quietly in grey it gets
|
||||||
|
// missed, and the flow looks broken when it is working.
|
||||||
|
const hint = document.createElement('div');
|
||||||
|
hint.className =
|
||||||
|
'mt-3 p-3 rounded-md border border-amber-500/60 bg-amber-500/10';
|
||||||
|
hint.innerHTML =
|
||||||
|
'<p class="text-sm text-amber-300 font-semibold">'
|
||||||
|
+ '<i class="fas fa-triangle-exclamation"></i> '
|
||||||
|
+ 'The next page will fail to load. That is expected.</p>'
|
||||||
|
+ '<p class="text-xs text-amber-200/90 mt-1">'
|
||||||
|
+ 'After you approve access, Google sends your browser to '
|
||||||
|
+ '<code>127.0.0.1</code>, where nothing is running \u2014 so you will see '
|
||||||
|
+ '"This site can\u2019t be reached" or similar. Nothing has gone wrong. '
|
||||||
|
+ 'Copy the <strong>entire address</strong> out of the address bar '
|
||||||
|
+ '(it contains <code>?code=...</code>) and paste it in the box below.</p>';
|
||||||
|
|
||||||
|
const codeInputId = fieldId + '_redirect_url';
|
||||||
|
|
||||||
|
const codeLabel = document.createElement('label');
|
||||||
|
codeLabel.className = 'block text-xs text-gray-300 mt-3';
|
||||||
|
codeLabel.textContent = 'Paste the address from that failed page here:';
|
||||||
|
// The label was visible but not associated, so the input still had
|
||||||
|
// no accessible name -- a placeholder is not one, and it vanishes
|
||||||
|
// on focus, which is exactly when the value is being pasted.
|
||||||
|
codeLabel.setAttribute('for', codeInputId);
|
||||||
|
|
||||||
|
const codeInput = document.createElement('input');
|
||||||
|
codeInput.type = 'text';
|
||||||
|
codeInput.id = codeInputId;
|
||||||
|
codeInput.placeholder = 'http://127.0.0.1/?code=...';
|
||||||
|
codeInput.className =
|
||||||
|
'mt-2 block w-full px-3 py-2 text-sm border border-gray-600 '
|
||||||
|
+ 'rounded-md bg-gray-800 text-gray-100';
|
||||||
|
|
||||||
|
const finishBtn = document.createElement('button');
|
||||||
|
finishBtn.type = 'button';
|
||||||
|
finishBtn.className = 'mt-2 px-3 py-1.5 text-sm rounded-md bg-green-600 hover:bg-green-700 text-white';
|
||||||
|
finishBtn.innerHTML = '<i class="fas fa-check"></i> Finish Authentication';
|
||||||
|
|
||||||
|
function say(message, kind) {
|
||||||
|
status.textContent = message;
|
||||||
|
status.className = 'text-xs mt-2 ' + (
|
||||||
|
kind === 'error' ? 'text-red-400'
|
||||||
|
: kind === 'success' ? 'text-green-400'
|
||||||
|
: 'text-gray-400');
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(body) {
|
||||||
|
return fetch(ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body || {})
|
||||||
|
}).then(function (r) {
|
||||||
|
return r.json().catch(function () {
|
||||||
|
// A non-JSON body here means the request never reached
|
||||||
|
// the handler -- worth saying so rather than "undefined".
|
||||||
|
return { status: 'error', message: 'Server returned ' + r.status };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startBtn.addEventListener('click', function () {
|
||||||
|
startBtn.disabled = true;
|
||||||
|
say('Requesting a consent link...');
|
||||||
|
post({}).then(function (data) {
|
||||||
|
startBtn.disabled = false;
|
||||||
|
if (data.status !== 'success' || !data.auth_url) {
|
||||||
|
say(data.message || 'Could not start authentication.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
link.href = data.auth_url;
|
||||||
|
step2.classList.remove('hidden');
|
||||||
|
say(data.message || 'Open the link, approve, then paste the address back.');
|
||||||
|
}).catch(function (err) {
|
||||||
|
startBtn.disabled = false;
|
||||||
|
say('Request failed: ' + err.message, 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
finishBtn.addEventListener('click', function () {
|
||||||
|
const pasted = codeInput.value.trim();
|
||||||
|
if (!pasted) {
|
||||||
|
say('Paste the address your browser was redirected to.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finishBtn.disabled = true;
|
||||||
|
say('Exchanging the code with Google...');
|
||||||
|
post({ redirect_url: pasted }).then(function (data) {
|
||||||
|
finishBtn.disabled = false;
|
||||||
|
if (data.status !== 'success') {
|
||||||
|
say(data.message || 'Authentication failed.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
say(data.message || 'Authenticated.', 'success');
|
||||||
|
step2.classList.add('hidden');
|
||||||
|
codeInput.value = '';
|
||||||
|
}).catch(function (err) {
|
||||||
|
finishBtn.disabled = false;
|
||||||
|
say('Request failed: ' + err.message, 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
step2.appendChild(link);
|
||||||
|
step2.appendChild(hint);
|
||||||
|
step2.appendChild(codeLabel);
|
||||||
|
step2.appendChild(codeInput);
|
||||||
|
step2.appendChild(finishBtn);
|
||||||
|
|
||||||
|
container.appendChild(hidden);
|
||||||
|
container.appendChild(startBtn);
|
||||||
|
container.appendChild(status);
|
||||||
|
container.appendChild(step2);
|
||||||
|
},
|
||||||
|
|
||||||
|
getValue: function (fieldId) {
|
||||||
|
const hidden = document.getElementById(fieldId + '_hidden');
|
||||||
|
return hidden ? hidden.value : '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -987,6 +987,7 @@
|
|||||||
<script src="{{ url_for('static', filename='v3/js/widgets/custom-feeds.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/custom-feeds.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/array-table.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/array-table.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/google-calendar-picker.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/google-calendar-picker.js') }}" defer></script>
|
||||||
|
<script src="{{ url_for('static', filename='v3/js/widgets/google-oauth.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/day-selector.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/day-selector.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/time-range.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/time-range.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='v3/js/widgets/time-picker.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='v3/js/widgets/time-picker.js') }}" defer></script>
|
||||||
|
|||||||
@@ -63,13 +63,13 @@
|
|||||||
|
|
||||||
<!-- Getting Started checklist: non-gating, dismissible (localStorage), items
|
<!-- Getting Started checklist: non-gating, dismissible (localStorage), items
|
||||||
auto-check from existing config/endpoints — no new persisted state.
|
auto-check from existing config/endpoints — no new persisted state.
|
||||||
Known heuristic limits (acceptable, disclosed): values left at legitimate
|
The timezone step is verified against the browser's own zone rather than
|
||||||
defaults (e.g. a user actually in Tampa) read as "not done". -->
|
compared to the shipped default; see the data-check="timezone" block below
|
||||||
|
for why. -->
|
||||||
{% set _hw = main_config.display.hardware if main_config and main_config.display else {} %}
|
{% set _hw = main_config.display.hardware if main_config and main_config.display else {} %}
|
||||||
{% set _hw_done = (_hw.rows or 0) > 0 and (_hw.cols or 0) > 0 and (_hw.chain_length or 0) > 0 %}
|
{% set _hw_done = (_hw.rows or 0) > 0 and (_hw.cols or 0) > 0 and (_hw.chain_length or 0) > 0 %}
|
||||||
{% set _loc = main_config.location if main_config and main_config.location else {} %}
|
{% set _loc = main_config.location if main_config and main_config.location else {} %}
|
||||||
{% set _loc_done = (main_config.timezone and main_config.timezone != 'America/New_York')
|
{% set _tz = (main_config.timezone if main_config else '') or '' %}
|
||||||
or (_loc.city and _loc.city != 'Tampa') %}
|
|
||||||
<div id="getting-started-card" class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4" style="display:none" role="region" aria-label="Getting started checklist">
|
<div id="getting-started-card" class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4" style="display:none" role="region" aria-label="Getting started checklist">
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -78,8 +78,8 @@
|
|||||||
<ul class="space-y-1 text-sm" id="getting-started-items">
|
<ul class="space-y-1 text-sm" id="getting-started-items">
|
||||||
<li><button type="button" class="gs-item text-left w-full" data-done="{{ '1' if _hw_done else '0' }}" data-tab="display">
|
<li><button type="button" class="gs-item text-left w-full" data-done="{{ '1' if _hw_done else '0' }}" data-tab="display">
|
||||||
<i class="far fa-square mr-2"></i>Set your panel size (Display tab)</button></li>
|
<i class="far fa-square mr-2"></i>Set your panel size (Display tab)</button></li>
|
||||||
<li><button type="button" class="gs-item text-left w-full" data-done="{{ '1' if _loc_done else '0' }}" data-tab="general">
|
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="timezone" data-tz="{{ _tz }}" data-tab="general">
|
||||||
<i class="far fa-square mr-2"></i>Set your timezone and location (General tab)</button></li>
|
<i class="far fa-square mr-2"></i>Set your timezone{% if _tz %} — currently {{ _tz }}{% if _loc.city %}, {{ _loc.city }}{% endif %}{% endif %} (General tab)<span data-gs-tz-note class="text-xs"></span></button></li>
|
||||||
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="installed" data-tab="plugins">
|
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="installed" data-tab="plugins">
|
||||||
<i class="far fa-square mr-2"></i>Install a plugin from the Plugin Store</button></li>
|
<i class="far fa-square mr-2"></i>Install a plugin from the Plugin Store</button></li>
|
||||||
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="enabled" data-tab="plugins">
|
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="enabled" data-tab="plugins">
|
||||||
@@ -165,6 +165,91 @@
|
|||||||
});
|
});
|
||||||
maybeAutoHide();
|
maybeAutoHide();
|
||||||
|
|
||||||
|
// Timezone: verified against the browser's own zone.
|
||||||
|
//
|
||||||
|
// This step used to tick when the saved timezone differed from the value
|
||||||
|
// config.template.json ships (America/New_York), with the saved city
|
||||||
|
// OR-ed in. Two things were wrong with that. "Differs from the default"
|
||||||
|
// answers "did somebody edit this?", but what the checklist needs to know
|
||||||
|
// is whether the value is RIGHT — so anyone who genuinely lives in the
|
||||||
|
// default zone could never satisfy it and the card nagged forever. And
|
||||||
|
// the city has no bearing on whether the timezone is set: because the two
|
||||||
|
// were OR-ed, saving a city ticked the step off with the timezone still
|
||||||
|
// wrong, which is the direction that actually breaks displays (event
|
||||||
|
// times render in the wrong zone).
|
||||||
|
//
|
||||||
|
// The browser already knows its zone, so compare against that: no new
|
||||||
|
// persisted state, no network, and it catches the reverse case too — a
|
||||||
|
// panel still set to the old zone after a move now stays unticked, where
|
||||||
|
// the old test ticked it the moment the value stopped being the default.
|
||||||
|
function sameZone(a, b) {
|
||||||
|
if (a === b) return true;
|
||||||
|
// Compare the wall-clock time each zone yields, not the identifiers:
|
||||||
|
// aliases (Asia/Calcutta vs Asia/Kolkata, Europe/Kiev vs Europe/Kyiv)
|
||||||
|
// name one zone and must not read as a mismatch.
|
||||||
|
//
|
||||||
|
// Sampled at three instants, all of which have to agree. Checking only
|
||||||
|
// now is not enough: America/New_York and America/Lima hold the same
|
||||||
|
// offset all winter, so a panel set to the wrong one of those would
|
||||||
|
// tick in January and then run an hour off from March. Mid-January and
|
||||||
|
// mid-July sit either side of DST in both hemispheres, so only zones
|
||||||
|
// that agree year-round match -- while Toronto still matches New York,
|
||||||
|
// which is right, since either renders the same times.
|
||||||
|
try {
|
||||||
|
var now = new Date();
|
||||||
|
var year = now.getUTCFullYear();
|
||||||
|
var instants = [now,
|
||||||
|
new Date(Date.UTC(year, 0, 15, 12)),
|
||||||
|
new Date(Date.UTC(year, 6, 15, 12))];
|
||||||
|
var stamp = function (tz, at) {
|
||||||
|
// Explicit numeric fields rather than dateStyle/timeStyle:
|
||||||
|
// those are late additions to Intl (Firefox shipped them in
|
||||||
|
// 91), and an implementation that does not know them ignores
|
||||||
|
// them and formats the date alone. That would compare
|
||||||
|
// New York, Chicago and Madrid as equal and tick the step for
|
||||||
|
// a timezone that is plainly wrong -- the exact failure this
|
||||||
|
// check exists to catch. These options have been in Intl
|
||||||
|
// since ECMA-402 v1.
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: tz, year: 'numeric', month: '2-digit',
|
||||||
|
day: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
}).format(at);
|
||||||
|
};
|
||||||
|
for (var i = 0; i < instants.length; i++) {
|
||||||
|
if (stamp(a, instants[i]) !== stamp(b, instants[i])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
// An unparseable zone in the config is worth surfacing, not hiding.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
var tzBtn = card.querySelector('[data-check="timezone"]');
|
||||||
|
if (!tzBtn) return;
|
||||||
|
var configured = tzBtn.dataset.tz || '';
|
||||||
|
if (!configured) return; // nothing saved yet: leave it open
|
||||||
|
var local = '';
|
||||||
|
try {
|
||||||
|
local = (Intl.DateTimeFormat().resolvedOptions().timeZone) || '';
|
||||||
|
} catch (e) {
|
||||||
|
return; // no Intl: leave it to the manual tick
|
||||||
|
}
|
||||||
|
if (!local) return;
|
||||||
|
if (sameZone(configured, local)) {
|
||||||
|
markDone(tzBtn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Unticked on its own says "wrong" without saying why; name the zone
|
||||||
|
// the browser is in so the step is actionable.
|
||||||
|
var note = tzBtn.querySelector('[data-gs-tz-note]');
|
||||||
|
if (note) note.textContent = ' — this browser is in ' + local;
|
||||||
|
}());
|
||||||
|
|
||||||
// Plugin-derived states from the existing installed-plugins endpoint.
|
// Plugin-derived states from the existing installed-plugins endpoint.
|
||||||
fetch('/api/v3/plugins/installed')
|
fetch('/api/v3/plugins/installed')
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
|
|||||||
@@ -815,7 +815,7 @@
|
|||||||
<i class="fas fa-info-circle mr-1"></i>
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
Changes in the file manager save immediately — no need to click Save Configuration.
|
Changes in the file manager save immediately — no need to click Save Configuration.
|
||||||
</p>
|
</p>
|
||||||
{% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'time-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector', 'file-upload-single', 'plugin-file-manager'] %}
|
{% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'time-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector', 'file-upload-single', 'plugin-file-manager', 'google-oauth'] %}
|
||||||
{# Render widget container #}
|
{# Render widget container #}
|
||||||
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
|
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
Reference in New Issue
Block a user