Compare commits

...
5 Commits
Author SHA1 Message Date
shmyga 1bffafd6a3 ci(version): 0.8.1 2026-09-10 18:48:42 +03:00
shmyga 46e72feb82 fix(rp5): fix rp5 parser 2026-09-10 18:48:23 +03:00
shmyga fdf6e0eac3 ci(version): 0.8.0 2026-09-10 16:16:03 +03:00
shmyga 60651de186 fix(rp5): fix rp5 parser 2026-09-10 10:28:24 +03:00
shmyga bed8d7af7b refactor(static): add base component types 2026-09-10 10:28:12 +03:00
23 changed files with 594 additions and 453 deletions
@@ -23,7 +23,6 @@
<div class="app col-lg-8 mx-auto p-3 py-md-5"> <div class="app col-lg-8 mx-auto p-3 py-md-5">
{% if not is_widget %} {% if not is_widget %}
<header class="app-header pb-3 mb-5 border-bottom"> <header class="app-header pb-3 mb-5 border-bottom">
<div>{{request.query_params.widget}}</div>
<div class="link-list"> <div class="link-list">
<app-link href="/" <app-link href="/"
icon="gear">API Gallery</app-link> icon="gear">API Gallery</app-link>
+1 -1
View File
@@ -45,7 +45,7 @@ async def get_schedule_index(request: AppRequest, provider: str | None = None, q
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="index.html", name="index.html",
context={"channels": channels, "groups": GROUP_MAP.keys()}, context={"channels": [item.model_dump() for item in channels], "groups": GROUP_MAP.keys()},
) )
@@ -35,23 +35,26 @@
</div> </div>
</form> </form>
{% if channels %} {% if channels %}
<ul id="channels" <div class="mb-4">
class="list-group mb-5"> <schedule-channel-list value="{{channels | tojson | forceescape}}"
{% for channel in channels %} onclick="scheduleChannelManager.store(element)"
<schedule-channel channel="{{channel.model_dump() | tojson | forceescape}}" onadd="scheduleChannelManager.add(element)"></schedule-channel-list>
addeable></schedule-channel> <hr>
{% endfor %} </div>
</ul>
<hr>
{% endif %} {% endif %}
<ul id="storedChannels" <div class="mb-4">
class="list-group mb-4"> <h3>{{_("Stored channels")}}</h3>
</ul> <schedule-channel-list id="storedChannels"
<ul class="list-group"> onremove="scheduleChannelManager.remove(element)"></schedule-channel-list>
</div>
<div class="mb-4">
<h3>{{_("Channel groups")}}</h3>
<ul class="list-group">
{% for group in groups %} {% for group in groups %}
<schedule-channel-group group="{{group}}"></schedule-channel-group> <schedule-channel-group value="{{group | tojson | forceescape}}"></schedule-channel-group>
{% endfor %} {% endfor %}
</ul> </ul>
</div>
<script> <script>
(function () { (function () {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -59,9 +62,8 @@
if (searchQuery) { if (searchQuery) {
document.querySelector('#query').value = searchQuery; document.querySelector('#query').value = searchQuery;
} }
document.addEventListener("DOMContentLoaded", (event) => { document.addEventListener("DOMContentLoaded", (event) => {
scheduleChannelManager.loadChannels('#storedChannels'); scheduleChannelManager.load();
}); });
})(); })();
</script> </script>
+1 -1
View File
@@ -53,7 +53,7 @@ async def get_weather_index(request: AppRequest, provider: str | None = None, qu
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="index.html", name="index.html",
context={"locations": locations}, context={"locations": [item.model_dump() for item in locations]},
) )
@@ -35,17 +35,18 @@
</div> </div>
</form> </form>
{% if locations %} {% if locations %}
<ul id="locations" <div class="mb-4">
class="list-group mb-5"> <weather-location-list value="{{locations | tojson | forceescape}}"
{% for location in locations %} onclick="weatherLocationManager.store(element)"
<weather-location location="{{location.model_dump() | tojson | forceescape}}"></weather-location> onadd="weatherLocationManager.add(element)"></weather-location-list>
{% endfor %} <hr>
</ul> </div>
<hr>
{% endif %} {% endif %}
<ul id="storedLocations" <div class="mb-4">
class="list-group mb-5"> <h3>{{_("Stored locations")}}</h3>
</ul> <weather-location-list id="storedLocations"
onremove="weatherLocationManager.remove(element)"></weather-location-list>
</div>
<script> <script>
(function () { (function () {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -55,7 +56,7 @@
} }
document.addEventListener("DOMContentLoaded", (event) => { document.addEventListener("DOMContentLoaded", (event) => {
weatherLocationManager.loadLocations('#storedLocations'); weatherLocationManager.load();
}); });
})(); })();
</script> </script>
+12 -2
View File
@@ -12,12 +12,22 @@ from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
from gallery.sketch.weather.util import merge_weather_values from gallery.sketch.weather.util import merge_weather_values
from . import datehelp
from .parser import DAYS_PARSER, LOCATION_PARSER, ONE_DAY_PARSER, ROW_PARSERS from .parser import DAYS_PARSER, LOCATION_PARSER, ONE_DAY_PARSER, ROW_PARSERS
logger = logging.getLogger("gismeteo") logger = logging.getLogger("gismeteo")
def format_date(date: datetime.date) -> str:
today = datetime.date.today()
days = (date - today).days
if days == 0:
return "today"
elif days == 1:
return "tomorrow"
else:
return f"{days + 1}-day"
class GismeteoApi(WeatherApi): class GismeteoApi(WeatherApi):
PROVIDER = "gismeteo" PROVIDER = "gismeteo"
@@ -107,7 +117,7 @@ class GismeteoApi(WeatherApi):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail={"max_date": max_date.strftime("%Y-%m-%d")} status_code=status.HTTP_400_BAD_REQUEST, detail={"max_date": max_date.strftime("%Y-%m-%d")}
) )
data = await self.source.request(f"weather-{location_id}/{datehelp.dump(date)}") data = await self.source.request(f"weather-{location_id}/{format_date(date)}")
return self._parse_oneday(date, data) return self._parse_oneday(date, data)
async def get_days(self, location_id: str, days: int) -> WeatherResponse: async def get_days(self, location_id: str, days: int) -> WeatherResponse:
-31
View File
@@ -1,31 +0,0 @@
from typing import Generic, Iterable, Optional, TypeVar
from bs4 import Tag
T = TypeVar("T")
class WidgetParser:
def parse_widget(self, tag: Tag) -> Tag:
raise NotImplementedError
class BaseWidgetParser(WidgetParser):
SELECT: str
def __init__(self, select: Optional[str] = None):
super().__init__()
self._select = select or self.SELECT
def parse_widget(self, tag: Tag) -> Tag:
widget = tag.select_one(self._select)
if widget is None:
raise ValueError(self._select)
return widget
class RowParser(Generic[T]):
KEY: str
def parse_row(self, tag: Tag) -> Iterable[T]:
raise NotImplementedError
-39
View File
@@ -1,39 +0,0 @@
import datetime
from enum import Enum
import dateparser
import dateparser.date_parser
class Day(str, Enum):
TODAY = "today"
TOMORROW = "tomorrow"
DAY_3 = "3-day"
DAY_4 = "4-day"
DAY_5 = "5-day"
def parse(value: str) -> datetime.date:
if value in ["today", "mock"]:
return datetime.date.today()
elif value == "tomorrow":
return datetime.date.today() + datetime.timedelta(days=1)
elif value.endswith("-day"):
days = int(value.split("-")[0]) - 1
return datetime.date.today() + datetime.timedelta(days=days)
else:
date = dateparser.parse(value)
if date is None:
raise ValueError(value)
return date.date()
def dump(date: datetime.date) -> str:
today = datetime.date.today()
days = (date - today).days
if days == 0:
return "today"
elif days == 1:
return "tomorrow"
else:
return f"{days + 1}-day"
+30 -3
View File
@@ -1,7 +1,7 @@
import datetime import datetime
import logging import logging
import re import re
from typing import Iterable from typing import Generic, Iterable, Optional, TypeVar
import dateparser import dateparser
from bs4 import Tag from bs4 import Tag
@@ -14,10 +14,37 @@ from gallery.sketch.weather.model import (
) )
from gallery.sketch.weather.parser import ParseError, parse_wind_direction from gallery.sketch.weather.parser import ParseError, parse_wind_direction
from .core import BaseWidgetParser, RowParser
logger = logging.getLogger("gismeteo") logger = logging.getLogger("gismeteo")
T = TypeVar("T")
class WidgetParser:
def parse_widget(self, tag: Tag) -> Tag:
raise NotImplementedError
class BaseWidgetParser(WidgetParser):
SELECT: str
def __init__(self, select: Optional[str] = None):
super().__init__()
self._select = select or self.SELECT
def parse_widget(self, tag: Tag) -> Tag:
widget = tag.select_one(self._select)
if widget is None:
raise ValueError(self._select)
return widget
class RowParser(Generic[T]):
KEY: str
def parse_row(self, tag: Tag) -> Iterable[T]:
raise NotImplementedError
ONE_DAY_PARSER = BaseWidgetParser(".widget.widget-oneday .widget-items") ONE_DAY_PARSER = BaseWidgetParser(".widget.widget-oneday .widget-items")
DAYS_PARSER = BaseWidgetParser(".widget.widget-days .widget-items") DAYS_PARSER = BaseWidgetParser(".widget.widget-days .widget-items")
+63 -25
View File
@@ -1,4 +1,6 @@
import re import re
from enum import Enum, auto
from typing import Iterable
import dateparser import dateparser
from bs4 import BeautifulSoup, Tag from bs4 import BeautifulSoup, Tag
@@ -36,29 +38,56 @@ def _parse_wind_speed(cell: Tag) -> int:
return 0 return 0
class RowType(Enum):
DATE = auto()
TIME = auto()
CLOUDNESS = auto()
PRECIPITATION = auto()
TEMPERATURE = auto()
PRESSURE = auto()
WIND_SPEED = auto()
WIND_GUST = auto()
WIND_DIRECTION = auto()
HUMIDITY = auto()
def _iter_table_rows(table: Tag) -> Iterable[tuple[RowType, Tag]]:
for row in table.select("tr"):
title = row.select_one("td").text.strip().lower()
if "сегодня" in title:
yield RowType.DATE, row
elif "местное время" in title:
yield RowType.TIME, row
elif "облачность" in title:
yield RowType.CLOUDNESS, row
elif "осадки" in title:
yield RowType.PRECIPITATION, row
elif "температура" in title:
yield RowType.TEMPERATURE, row
elif "давление" in title:
yield RowType.PRESSURE, row
elif "скорость" in title:
yield RowType.WIND_SPEED, row
elif "порывы" in title:
yield RowType.WIND_GUST, row
elif "направление" in title:
yield RowType.WIND_DIRECTION, row
elif "влажность" in title:
yield RowType.HUMIDITY, row
# pylint:disable=too-many-branches # pylint:disable=too-many-branches
def parse_page(page: BeautifulSoup) -> list[WeatherResponse]: def parse_page(page: BeautifulSoup) -> list[WeatherResponse]:
location = page.select(".intoLeftNavi > .verticalBottom > a")[-1].text.strip() location = page.select(".intoLeftNavi > .verticalBottom > a")[-1].text.strip()
table = page.select_one("#forecastTable") table = page.select_one("#forecastTable")
if table is None: if table is None:
raise ParseError("#forecastTable not found") raise ParseError("#forecastTable not found")
(
date_row,
time_row,
cloudness_row,
precipitation_row,
temperature_row,
_,
pressure_row,
wind_speed_row,
wind_gust_row,
wind_direction_row,
humidity_row,
*_,
) = table.select("tr")
result: list[WeatherResponse] = [] result: list[WeatherResponse] = []
values: list[WeatherValue] = [] values: list[WeatherValue] = []
for index, cell in enumerate(date_row.select("td")): for row_type, row in _iter_table_rows(table):
match row_type:
case RowType.DATE:
for index, cell in enumerate(row.select("td")):
colspan = int(cell.attrs["colspan"]) colspan = int(cell.attrs["colspan"])
if index == 0: if index == 0:
colspan -= 1 colspan -= 1
@@ -76,14 +105,17 @@ def parse_page(page: BeautifulSoup) -> list[WeatherResponse]:
) )
) )
values += item_values values += item_values
for index, cell in enumerate(time_row.select("td")[1:]): case RowType.TIME:
for index, cell in enumerate(row.select("td")[1:]):
hour = int(cell.text.strip()) hour = int(cell.text.strip())
values[index].date = values[index].date.replace(hour=hour) values[index].date = values[index].date.replace(hour=hour)
for index, cell in enumerate(cloudness_row.select("td")[1:]): case RowType.CLOUDNESS:
for index, cell in enumerate(row.select("td")[1:]):
onmouseover = cell.select_one(".cc_0").next_element.attrs["onmouseover"] onmouseover = cell.select_one(".cc_0").next_element.attrs["onmouseover"]
cloudness_str = re.compile("<b>(.*?)</b>").search(onmouseover).group(1).strip() cloudness_str = re.compile("<b>(.*?)</b>").search(onmouseover).group(1).strip()
values[index].sky.cloudness = CLOUDNESS_MAP[cloudness_str] values[index].sky.cloudness = CLOUDNESS_MAP[cloudness_str]
for index, cell in enumerate(precipitation_row.select("td")[1:]): case RowType.PRECIPITATION:
for index, cell in enumerate(row.select("td")[1:]):
onmouseover = cell.select_one(".pr_0").attrs["onmouseover"] onmouseover = cell.select_one(".pr_0").attrs["onmouseover"]
precipitation_str = onmouseover.split(",")[1].strip(" '") precipitation_str = onmouseover.split(",")[1].strip(" '")
precipitation_value_matched = re.compile("(\\d+\\.\\d+) мм").search(precipitation_str) precipitation_value_matched = re.compile("(\\d+\\.\\d+) мм").search(precipitation_str)
@@ -93,16 +125,22 @@ def parse_page(page: BeautifulSoup) -> list[WeatherResponse]:
if "(" in precipitation_str: if "(" in precipitation_str:
precipitation_str = precipitation_str.split(" (")[0] precipitation_str = precipitation_str.split(" (")[0]
values[index].sky.precipitation = PRECIPITATION_MAP[precipitation_str] values[index].sky.precipitation = PRECIPITATION_MAP[precipitation_str]
for index, cell in enumerate(temperature_row.select("td")[1:]): case RowType.TEMPERATURE:
for index, cell in enumerate(row.select("td")[1:]):
values[index].temperature = [int(cell.select_one(".t_0").text.lstrip("+").strip())] values[index].temperature = [int(cell.select_one(".t_0").text.lstrip("+").strip())]
for index, cell in enumerate(pressure_row.select("td")[1:]): case RowType.PRESSURE:
for index, cell in enumerate(row.select("td")[1:]):
values[index].pressure = [int(cell.select_one(".p_0").text.strip())] values[index].pressure = [int(cell.select_one(".p_0").text.strip())]
for index, cell in enumerate(wind_speed_row.select("td")[1:]): case RowType.WIND_SPEED:
for index, cell in enumerate(row.select("td")[1:]):
values[index].wind_speed = _parse_wind_speed(cell) values[index].wind_speed = _parse_wind_speed(cell)
for index, cell in enumerate(wind_gust_row.select("td")[1:]): case RowType.WIND_GUST:
values[index].wind_speed = _parse_wind_speed(cell) for index, cell in enumerate(row.select("td")[1:]):
for index, cell in enumerate(wind_direction_row.select("td")[1:]): values[index].wind_gust = _parse_wind_speed(cell)
case RowType.WIND_DIRECTION:
for index, cell in enumerate(row.select("td")[1:]):
values[index].wind_direction = parse_wind_direction(cell.text.strip()) values[index].wind_direction = parse_wind_direction(cell.text.strip())
for index, cell in enumerate(humidity_row.select("td")[1:]): case RowType.HUMIDITY:
for index, cell in enumerate(row.select("td")[1:]):
values[index].humidity = int(cell.text.strip()) values[index].humidity = int(cell.text.strip())
return result return result
+9
View File
@@ -69,9 +69,18 @@ msgstr "Давление, мм рт. ст."
msgid "Humidity, %" msgid "Humidity, %"
msgstr "Влажность, %" msgstr "Влажность, %"
msgid "Stored locations"
msgstr "Сохранённые локации"
# tv # tv
msgid "TV program" msgid "TV program"
msgstr "Телепрограмма" msgstr "Телепрограмма"
msgid "Live broadcasts" msgid "Live broadcasts"
msgstr "Прямые трансляции" msgstr "Прямые трансляции"
msgid "Stored channels"
msgstr "Сохранённые каналы"
msgid "Channel groups"
msgstr "Группы каналов"
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "gallery" name = "gallery"
description = "" description = ""
version = "0.7.0" version = "0.8.1"
readme = "README.md" readme = "README.md"
authors = [{ name = "shmyga", email = "shmyga.z@gmail.com" }] authors = [{ name = "shmyga", email = "shmyga.z@gmail.com" }]
requires-python = '>=3.14,<4.0' requires-python = '>=3.14,<4.0'
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "gallery", "name": "gallery",
"version": "0.7.0", "version": "0.8.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gallery", "name": "gallery",
"version": "0.7.0", "version": "0.8.1",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gallery", "name": "gallery",
"version": "0.7.0", "version": "0.8.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "webpack --mode production", "build": "webpack --mode production",
+196
View File
@@ -0,0 +1,196 @@
export interface Type<T> extends Function {
new (...args: any[]): T;
}
export abstract class ValueElement<T, O> extends HTMLElement {
static observedAttributes = ["value"];
private _value: T | null = null;
get value(): T | null {
return this._value;
}
set value(value: T | null) {
if (value) {
this.setAttribute("value", JSON.stringify(value));
} else if (this.hasAttribute("value")) {
this.removeAttribute("value");
}
}
options: O | null;
constructor(value: T | null = null, options: O | null = null) {
super();
if (value) {
this.value = value;
}
this.options = options;
this.onInit();
}
onInit() {}
beforeRefresh() {}
onRefresh() {}
abstract buildHTML(): string;
refresh() {
const rawValue = this.getAttribute("value");
if (rawValue) {
this._value = JSON.parse(rawValue);
} else {
this._value = null;
}
if (this.className) {
this.classList = this.className;
}
this.beforeRefresh();
this.innerHTML = this.buildHTML();
this.onRefresh();
}
connectedCallback() {
this.refresh();
}
attributeChangedCallback(name: string, oldValue: any, newValue: any) {
setTimeout(() => this.refresh());
}
}
export interface ListOptions<T> {
onClick?: (element: ListItemElement<T>) => void;
onAdd?: (element: ListItemElement<T>) => void;
onRemove?: (element: ListItemElement<T>) => void;
}
export abstract class ListItemElement<T> extends ValueElement<T, ListOptions<T>> {
abstract getHref(value: T): string;
abstract getContent(value: T): string;
className = "list-group-item list-group-item-action";
onInit(): void {
this.handleClick = this.handleClick.bind(this);
this.handleRemoveClick = this.handleRemoveClick.bind(this);
this.handleAddClick = this.handleAddClick.bind(this);
}
buildHTML(): string {
if (!this.value) {
return "";
}
const href = this.getHref(this.value);
const content = this.getContent(this.value);
return `
<a href="${href}"
class="d-flex justify-content-between align-items-start text-decoration-none">
<span>
<span class="text-success me-2 ${this.options?.onAdd ? "" : "d-none"}" style="color: green;" data-add>
&plus;
</span>
${content}
</span>
<span>
<span class="text-danger ms-2 ${this.options?.onRemove ? "" : "d-none"}" data-remove>
&#x2715;
</span>
</span>
</a>
`;
}
onRefresh(): void {
this.addEventListener("click", this.handleClick);
if (this.options?.onAdd) {
this.querySelector<HTMLElement>("[data-add]")?.addEventListener("click", this.handleAddClick);
} else {
this.querySelector("[data-add]")?.remove();
}
if (this.options?.onRemove) {
this.querySelector<HTMLElement>("[data-remove]")?.addEventListener("click", this.handleRemoveClick);
} else {
this.querySelector("[data-remove]")?.remove();
}
}
handleClick(event: MouseEvent) {
if (this.value && this.options?.onClick) {
this.options.onClick(this);
}
}
handleAddClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (this.value && this.options?.onAdd) {
this.options.onAdd(this);
}
}
handleRemoveClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (this.value && this.options?.onRemove) {
this.options.onRemove(this);
}
}
disconnectedCallback() {
this.removeEventListener("click", this.handleClick);
this.querySelector<HTMLElement>("[data-add]")?.removeEventListener("click", this.handleAddClick);
this.querySelector<HTMLElement>("[data-remove]")?.removeEventListener("click", this.handleRemoveClick);
}
}
export abstract class ListElement<T, C extends ListItemElement<T> = ListItemElement<T>> extends ValueElement<
T[],
ListOptions<T>
> {
static observedAttributes = ["value", "onadd", "onremove", "onclick"];
abstract childType: Type<C>;
className = "list-group";
buildHTML(): string {
return "";
}
beforeRefresh() {
if (this.hasAttribute("onclick")) {
const onclick = new Function("element", this.getAttribute("onclick")!) as ListOptions<T>["onClick"];
if (!this.options) {
this.options = {};
}
this.options.onClick = onclick;
}
if (this.hasAttribute("onadd")) {
const onadd = new Function("element", this.getAttribute("onadd")!) as ListOptions<T>["onAdd"];
if (!this.options) {
this.options = {};
}
this.options.onAdd = onadd;
}
if (this.hasAttribute("onremove")) {
const onremove = new Function("element", this.getAttribute("onremove")!) as ListOptions<T>["onRemove"];
if (!this.options) {
this.options = {};
}
this.options.onRemove = onremove;
}
}
onRefresh() {
if (this.value) {
for (const item of this.value) {
this.appendChild(new this.childType(item, this.options));
}
}
}
}
+50
View File
@@ -0,0 +1,50 @@
import { ListElement, ListItemElement } from "./component";
import { DataStorage, StorageValue } from "./storage";
export class DataManager<T extends StorageValue> {
private storage: DataStorage<T>;
private get dataList(): ListElement<T> {
return document.querySelector(this.dataListSelector)!;
}
constructor(
listComponentType: typeof ListElement<T>, //prevent removing as unusing
storageKey: string,
private dataListSelector: string,
) {
this.storage = new DataStorage(storageKey);
this.load = this.load.bind(this);
this.store = this.store.bind(this);
this.add = this.add.bind(this);
this.remove = this.remove.bind(this);
}
load() {
this.dataList.value = this.storage.getAll();
}
store(element: ListItemElement<T>) {
if (element.value) {
this.storage.add(element.value);
}
}
add(element: ListItemElement<T>) {
const value = element.value;
value;
if (value && this.storage.add(value)) {
const clone: ListItemElement<T> = element.cloneNode(true) as ListItemElement<T>;
clone.options = { onRemove: this.remove };
this.dataList.appendChild(clone);
}
}
remove(element: ListItemElement<T>) {
const value = element.value;
if (value) {
this.storage.remove(value.id);
element.remove();
}
}
}
+5 -1
View File
@@ -1,4 +1,8 @@
export class DataStorage<T extends { id: string }> { export interface StorageValue {
id: string;
}
export class DataStorage<T extends StorageValue> {
constructor(private storageKey: string) {} constructor(private storageKey: string) {}
private readValues(): T[] { private readValues(): T[] {
+41 -99
View File
@@ -1,110 +1,52 @@
import { ListElement, ListItemElement, ValueElement } from "../base/component";
import { Channel } from "./model"; import { Channel } from "./model";
export class ScheduleChannelElement extends HTMLElement { export class ScheduleChannelGroup extends ValueElement<string | Channel[], {}> {
static observedAttributes = ["channel", "removable"]; className: string = "list-group-item list-group-item-action";
channel: Channel | null = null; buildHTML(): string {
const groupName = Array.isArray(this.value) ? "Open All" : this.value;
constructor() { const groupId = Array.isArray(this.value)
super(); ? this.value.map((item) => `${item.provider}:${item.id}`).join(",")
this.handleClick = this.handleClick.bind(this); : this.value || "";
this.handleRemoveClick = this.handleRemoveClick.bind(this); return `
this.handleAddClick = this.handleAddClick.bind(this);
}
connectedCallback() {
this.channel = JSON.parse(this.getAttribute("channel") || "{}");
this.innerHTML = `
<a href="${this.channel?.provider}/${this.channel?.id}"
class="d-flex justify-content-between align-items-start text-decoration-none">
<span>
<span class="text-success me-2" style="color: green;" data-add>
&plus;
</span>
<span class="badge me-1 text-bg-secondary">${this.channel?.provider}</span>
<span class="text-primary">${this.channel?.name}</span>
</span>
<span>
<span class="text-danger ms-2" data-remove>
&#x2715;
</span>
</span>
</a>`;
this.classList = "list-group-item list-group-item-action";
this.addEventListener("click", this.handleClick);
if (this.hasAttribute("removable")) {
this.querySelector<HTMLElement>("[data-remove]")?.addEventListener("click", this.handleRemoveClick);
} else {
this.querySelector("[data-remove]")?.remove();
}
if (this.hasAttribute("addeable")) {
this.querySelector<HTMLElement>("[data-add]")?.addEventListener("click", this.handleAddClick);
} else {
this.querySelector("[data-add]")?.remove();
}
}
disconnectedCallback() {
this.removeEventListener("click", this.handleClick);
this.querySelector<HTMLElement>("[data-remove]")?.removeEventListener("click", this.handleRemoveClick);
}
handleClick(event: MouseEvent) {
if (this.channel) {
window.scheduleChannelStorage.add(this.channel);
}
}
handleRemoveClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (this.channel) {
window.scheduleChannelStorage.remove(this.channel.id);
}
this.remove();
}
handleAddClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (this.channel) {
if (window.scheduleChannelStorage.add(this.channel)) {
const storedChannels = document.querySelector("#storedChannels");
if (storedChannels) {
const clone: Element = this.cloneNode(true) as Element;
clone.removeAttribute("addeable");
clone.setAttribute("removable", "");
storedChannels.appendChild(clone);
}
}
}
}
}
export class ScheduleChannelGroupElement extends HTMLElement {
static observedAttributes = ["group", "channels"];
group: string | null = null;
channels: Channel[] | null = null;
constructor() {
super();
}
connectedCallback() {
this.channels = JSON.parse(this.getAttribute("channels") || "[]");
this.group = this.getAttribute("group");
const groupId = this.channels?.map((item) => `${item.provider}:${item.id}`).join(",") || this.group || "n/a";
this.innerHTML = `
<a href="group/${encodeURIComponent(groupId)}" <a href="group/${encodeURIComponent(groupId)}"
class="d-flex justify-content-between align-items-start text-decoration-none"> class="d-flex justify-content-between align-items-start text-decoration-none">
<span> <span>
<span class="text-primary" style="text-transform: capitalize">${this.group || "Open group"}</span> <span class="text-primary" style="text-transform: capitalize">${groupName}</span>
</span> </span>
</a>`; </a>`;
this.classList = "list-group-item list-group-item-action";
} }
} }
customElements.define("schedule-channel", ScheduleChannelElement); customElements.define("schedule-channel-group", ScheduleChannelGroup);
customElements.define("schedule-channel-group", ScheduleChannelGroupElement);
export class ScheduleChannelItem extends ListItemElement<Channel> {
getHref(value: Channel): string {
return `${value.provider}/${value.id}`;
}
getContent(value: Channel): string {
return `
<span class="badge me-1 text-bg-secondary">${value.provider}</span>
<span class="text-primary">${value.name}</span>
`;
}
}
export class ScheduleChannelList extends ListElement<Channel, ScheduleChannelItem> {
childType = ScheduleChannelItem;
public onRefresh(): void {
super.onRefresh();
if (this.value?.length) {
const groupElement = new ScheduleChannelGroup(this.value);
this.prepend(groupElement);
} else {
this.prepend("Empty");
}
}
}
customElements.define("schedule-channel-item", ScheduleChannelItem);
customElements.define("schedule-channel-list", ScheduleChannelList);
+4 -27
View File
@@ -1,34 +1,11 @@
import { DataStorage } from "../base/storage"; import { DataManager } from "../base/manager";
import { ScheduleChannelElement, ScheduleChannelGroupElement } from "./component"; import { ScheduleChannelList } from "./component";
import { Channel } from "./model"; import { Channel } from "./model";
export class ScheduleChannelManager {
loadChannels(selector: string) {
const channels = window.scheduleChannelStorage.getAll();
const container = document.querySelector(selector);
if (container) {
container.innerHTML = "";
if (channels.length > 0) {
const groupElement = new ScheduleChannelGroupElement();
groupElement.setAttribute("channels", JSON.stringify(channels));
container.append(groupElement);
}
for (const channel of channels) {
const element = new ScheduleChannelElement();
element.setAttribute("channel", JSON.stringify(channel));
element.setAttribute("removable", "");
container.appendChild(element);
}
}
}
}
declare global { declare global {
interface Window { interface Window {
scheduleChannelStorage: DataStorage<Channel>; scheduleChannelManager: DataManager<Channel>;
scheduleChannelManager: ScheduleChannelManager;
} }
} }
window.scheduleChannelStorage = new DataStorage("schedule:channels"); window.scheduleChannelManager = new DataManager(ScheduleChannelList, "schedule:channels", "#storedChannels");
window.scheduleChannelManager = new ScheduleChannelManager();
+24 -54
View File
@@ -1,63 +1,33 @@
import { ListElement, ListItemElement } from "../base/component";
import { Location } from "./model"; import { Location } from "./model";
export class WeatherLocationElement extends HTMLElement { export class WheatherLocationItem extends ListItemElement<Location> {
static observedAttributes = ["location", "removable"]; getHref(value: Location): string {
return `${value.provider}/${value.id}`;
location: Location | null = null;
constructor() {
super();
this.handleClick = this.handleClick.bind(this);
this.handleRemoveClick = this.handleRemoveClick.bind(this);
} }
connectedCallback() { getContent(value: Location): string {
this.location = JSON.parse(this.getAttribute("location") || "{}"); return `
this.innerHTML = ` <span class="badge me-1 text-bg-secondary">${value.provider}</span>
<a href="${this.location?.provider}/${this.location?.id}" <span class="fi fi-${value.country_code} me-1"></span>
class="d-flex justify-content-between align-items-start text-decoration-none"> <span class="text-primary">${value.name}</span>
<span>
<span class="badge me-1 text-bg-secondary">${this.location?.provider}</span>
<span class="fi fi-${this.location?.country_code} me-1"></span>
<span class="text-primary">${this.location?.name}</span>
<span class="small ms-1 text-secondary"> <span class="small ms-1 text-secondary">
${this.location?.country}, ${this.location?.district}, ${this.location?.subdistrict} ${value.country}, ${value.district}, ${value.subdistrict}
</span> </span>
</span> `;
<span>
<span class="text-danger ms-2" data-remove>
&#x2715;
</span>
</span>
</a>`;
this.classList = "list-group-item list-group-item-action";
this.addEventListener("click", this.handleClick);
if (this.hasAttribute("removable")) {
this.querySelector<HTMLElement>("[data-remove]")?.addEventListener("click", this.handleRemoveClick);
} else {
this.querySelector("[data-remove]")?.remove();
}
}
disconnectedCallback() {
this.removeEventListener("click", this.handleClick);
this.querySelector<HTMLElement>("[data-remove]")?.removeEventListener("click", this.handleRemoveClick);
}
handleClick(event: MouseEvent) {
if (this.location) {
window.weatherLocationStorage.add(this.location);
}
}
handleRemoveClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (this.location) {
window.weatherLocationStorage.remove(this.location.id);
}
this.remove();
} }
} }
customElements.define("weather-location", WeatherLocationElement); export class WeatherLocationList extends ListElement<Location, WheatherLocationItem> {
childType = WheatherLocationItem;
public onRefresh(): void {
super.onRefresh();
if (!this.value?.length) {
this.prepend("Empty");
}
}
}
customElements.define("weather-location-item", WheatherLocationItem);
customElements.define("weather-location-list", WeatherLocationList);
+4 -22
View File
@@ -1,29 +1,11 @@
import { DataStorage } from "../base/storage"; import { DataManager } from "../base/manager";
import { WeatherLocationElement } from "./component"; import { WeatherLocationList } from "./component";
import { Location } from "./model"; import { Location } from "./model";
export class WeatherLocationManager {
loadLocations(selector: string) {
const locations = window.weatherLocationStorage.getAll();
const container = document.querySelector(selector);
if (container) {
container.innerHTML = "";
for (const location of locations) {
const element = new WeatherLocationElement();
element.setAttribute("location", JSON.stringify(location));
element.setAttribute("removable", "");
container.appendChild(element);
}
}
}
}
declare global { declare global {
interface Window { interface Window {
weatherLocationStorage: DataStorage<Location>; weatherLocationManager: DataManager<Location>;
weatherLocationManager: WeatherLocationManager;
} }
} }
window.weatherLocationStorage = new DataStorage("weather:locations"); window.weatherLocationManager = new DataManager(WeatherLocationList, "weather:locations", "#storedLocations");
window.weatherLocationManager = new WeatherLocationManager();
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -18,10 +18,10 @@ async def test_search(rp5_api: RP5WeatherApi):
async def test_day(rp5_api: RP5WeatherApi): async def test_day(rp5_api: RP5WeatherApi):
result = await rp5_api.get_day("test", datetime.date(2026, 9, 2)) result = await rp5_api.get_day("test", datetime.date(2026, 9, 11))
assert len(result.values) == 4 assert len(result.values) == 4
async def test_days(rp5_api: RP5WeatherApi): async def test_days(rp5_api: RP5WeatherApi):
result = await rp5_api.get_days("test", 10) result = await rp5_api.get_days("test", 10)
assert len(result.values) == 7 assert len(result.values) == 8