Compare commits
16 Commits
0.3.0
...
2aab080dec
| Author | SHA1 | Date | |
|---|---|---|---|
| 2aab080dec | |||
| df2f1d0d81 | |||
| 9192ca3e4a | |||
| e37ca6eeac | |||
| 657a62eab5 | |||
| d8e2be013a | |||
| a91ec12d22 | |||
| 9c862863e5 | |||
| fc59e52337 | |||
| 3e93a41400 | |||
| c2cd18386b | |||
| 2bca3dd75a | |||
| 469bd9bc1f | |||
| 027d1e2d55 | |||
| 7cf0012229 | |||
| edc014d98c |
@@ -3,3 +3,5 @@ DOCKER_GROUP=infernalgames
|
|||||||
DOCKER_ROOT="$DOCKER_REPO/$DOCKER_GROUP"
|
DOCKER_ROOT="$DOCKER_REPO/$DOCKER_GROUP"
|
||||||
VERSION=$(grep -m 1 'version' ./pyproject.toml | grep -oP 'version\s*=\s*"\K[^"]+')
|
VERSION=$(grep -m 1 'version' ./pyproject.toml | grep -oP 'version\s*=\s*"\K[^"]+')
|
||||||
DOCKER_PROJECTS=("gallery")
|
DOCKER_PROJECTS=("gallery")
|
||||||
|
|
||||||
|
OPENWEATHER_KEY="<EMPTY>"
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@
|
|||||||
#.vscode
|
#.vscode
|
||||||
static/node_modules
|
static/node_modules
|
||||||
static/dist
|
static/dist
|
||||||
|
.env
|
||||||
@@ -7,7 +7,9 @@ WORKDIR /app
|
|||||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||||
COPY pyproject.toml poetry.lock README.md ./
|
COPY pyproject.toml poetry.lock README.md ./
|
||||||
RUN poetry config virtualenvs.in-project true
|
RUN poetry config virtualenvs.in-project true
|
||||||
RUN poetry install --with app --no-root
|
RUN --mount=type=cache,target=/root/.cache/pypoetry/cache \
|
||||||
|
--mount=type=cache,target=/root/.cache/pypoetry/artifacts \
|
||||||
|
poetry install --with app --no-root
|
||||||
COPY locales ./locales
|
COPY locales ./locales
|
||||||
RUN cd locales/ru/LC_MESSAGES && msgfmt messages.po
|
RUN cd locales/ru/LC_MESSAGES && msgfmt messages.po
|
||||||
|
|
||||||
@@ -15,7 +17,8 @@ FROM node:24 AS node-builder
|
|||||||
ENV PATH=/app/node_modules/.bin:$PATH
|
ENV PATH=/app/node_modules/.bin:$PATH
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY static/package.json static/package-lock.json ./
|
COPY static/package.json static/package-lock.json ./
|
||||||
RUN npm ci
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm ci
|
||||||
COPY static ./
|
COPY static ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
@@ -27,7 +30,6 @@ ENV TZ="Europe/Moscow"
|
|||||||
COPY --from=builder /app ./
|
COPY --from=builder /app ./
|
||||||
COPY --from=node-builder /app/dist ./static/dist
|
COPY --from=node-builder /app/dist ./static/dist
|
||||||
COPY gallery gallery/
|
COPY gallery gallery/
|
||||||
#COPY --from=builder /app/gallery/easel/route/view/locales /app/gallery/easel/route/view/locales
|
|
||||||
COPY --from=builder --parents locales/**/*.mo ./
|
COPY --from=builder --parents locales/**/*.mo ./
|
||||||
|
|
||||||
CMD ["uvicorn", "gallery.main:app", "--host", "0.0.0.0", "--port", "80", "--log-config", "gallery/logging.yaml"]
|
CMD ["uvicorn", "gallery.main:app", "--host", "0.0.0.0", "--port", "80", "--log-config", "gallery/logging.yaml"]
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 182 KiB |
@@ -8,6 +8,7 @@
|
|||||||
"python.testing.pytestArgs": ["tests", "-s"],
|
"python.testing.pytestArgs": ["tests", "-s"],
|
||||||
"python.testing.unittestEnabled": false,
|
"python.testing.unittestEnabled": false,
|
||||||
"python.testing.pytestEnabled": true,
|
"python.testing.pytestEnabled": true,
|
||||||
|
"python.terminal.useEnvFile": true,
|
||||||
"python-envs.pythonProjects": [
|
"python-envs.pythonProjects": [
|
||||||
{
|
{
|
||||||
"path": ".",
|
"path": ".",
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from gallery.sketch.bundle import ApiBundle
|
from gallery.sketch.bundle import ApiBundle
|
||||||
from gallery.util import root_path
|
from gallery.util import root_path
|
||||||
|
|
||||||
from .route import api, doc
|
from .route import api, doc, view
|
||||||
from .route.view import router as view_router
|
|
||||||
|
|
||||||
|
|
||||||
def build_app(api_bundle: ApiBundle) -> FastAPI:
|
def build_app(api_bundle: ApiBundle) -> FastAPI:
|
||||||
@@ -17,6 +16,6 @@ def build_app(api_bundle: ApiBundle) -> FastAPI:
|
|||||||
app.state.api = api_bundle
|
app.state.api = api_bundle
|
||||||
app.mount("/static", StaticFiles(directory=root_path / "static/dist"))
|
app.mount("/static", StaticFiles(directory=root_path / "static/dist"))
|
||||||
doc.mount(app)
|
doc.mount(app)
|
||||||
api.mount(app)
|
app.include_router(api.router)
|
||||||
app.include_router(view_router)
|
app.include_router(view.router)
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from . import schedule, weather
|
from . import schedule, weather
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["API"])
|
||||||
def mount(app: FastAPI):
|
router.include_router(weather.router)
|
||||||
weather.mount(app)
|
router.include_router(schedule.router)
|
||||||
schedule.mount(app)
|
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from gallery.easel.core import AppRequest
|
from gallery.easel.core import AppRequest
|
||||||
from gallery.sketch.schedule.model import ChannelId, Schedule
|
from gallery.sketch.schedule.model import ChannelId, Schedule
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/schedule")
|
||||||
|
|
||||||
def mount(app: FastAPI):
|
|
||||||
@app.get("/api/schedule/channels", tags=["API"])
|
@router.get("/channels")
|
||||||
async def get_api_schedule_channels(request: AppRequest) -> list[ChannelId]:
|
async def get_api_schedule_channels(request: AppRequest) -> list[ChannelId]:
|
||||||
schedule_api = request.app.state.api.schedule
|
schedule_api = request.app.state.api.schedule
|
||||||
return await schedule_api.get_channels()
|
return await schedule_api.get_channels()
|
||||||
|
|
||||||
@app.get("/api/schedule/{channel}/{date}", tags=["API"])
|
|
||||||
|
@router.get("/{channel}/{date}")
|
||||||
async def get_api_schedule_channel_schedule(request: AppRequest, channel: str, date: datetime.date) -> Schedule:
|
async def get_api_schedule_channel_schedule(request: AppRequest, channel: str, date: datetime.date) -> Schedule:
|
||||||
schedule_api = request.app.state.api.schedule
|
schedule_api = request.app.state.api.schedule
|
||||||
return await schedule_api.get_channel_schedule(ChannelId(channel), date)
|
return await schedule_api.get_channel_schedule(ChannelId(channel), date)
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from gallery.easel.core import AppRequest
|
from gallery.easel.core import AppRequest
|
||||||
from gallery.sketch.weather.model import Location, WeatherResponse
|
from gallery.sketch.weather.model import Location, WeatherResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/weather")
|
||||||
|
|
||||||
def mount(app: FastAPI):
|
|
||||||
@app.get("/api/weather/locations", tags=["API"])
|
@router.get("/providers")
|
||||||
|
async def get_api_weather_providers(request: AppRequest) -> list[str]:
|
||||||
|
return request.app.state.api.get_api_providers("weather")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/locations")
|
||||||
async def get_api_weather_locations(request: AppRequest, query: str) -> list[Location]:
|
async def get_api_weather_locations(request: AppRequest, query: str) -> list[Location]:
|
||||||
weather_api = request.app.state.api.weather
|
weather_api = request.app.state.api.weather
|
||||||
return await weather_api.find_locations(query)
|
return await weather_api.find_locations(query)
|
||||||
|
|
||||||
@app.get("/api/weather/{location}/day/{date}", tags=["API"])
|
|
||||||
|
@router.get("/{location}/day/{date}")
|
||||||
async def get_api_weather_day(request: AppRequest, location: str, date: datetime.date) -> WeatherResponse:
|
async def get_api_weather_day(request: AppRequest, location: str, date: datetime.date) -> WeatherResponse:
|
||||||
weather_api = request.app.state.api.weather
|
weather_api = request.app.state.api.weather
|
||||||
return await weather_api.get_day(location, date)
|
return await weather_api.get_day(location, date)
|
||||||
|
|
||||||
@app.get("/api/weather/{location}/days/{days}", tags=["API"])
|
|
||||||
|
@router.get("/{location}/days/{days}")
|
||||||
async def get_api_weather_days(request: AppRequest, location: str, days: int) -> WeatherResponse:
|
async def get_api_weather_days(request: AppRequest, location: str, days: int) -> WeatherResponse:
|
||||||
weather_api = request.app.state.api.weather
|
weather_api = request.app.state.api.weather
|
||||||
return await weather_api.get_days(location, days)
|
return await weather_api.get_days(location, days)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from .common import router as common_router
|
from .root import router as root_router
|
||||||
from .schedule import router as schedule_router
|
from .schedule import router as schedule_router
|
||||||
from .translation import set_language
|
from .translation import set_language
|
||||||
from .weather import router as weather_router
|
from .weather import router as weather_router
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(set_language)])
|
router = APIRouter(tags=["view"], dependencies=[Depends(set_language)])
|
||||||
router.include_router(common_router)
|
router.include_router(root_router)
|
||||||
router.include_router(weather_router)
|
router.include_router(weather_router)
|
||||||
router.include_router(schedule_router)
|
router.include_router(schedule_router)
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
from typing import NamedTuple
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
|
|
||||||
from ..common.utils.template import build_templates
|
|
||||||
|
|
||||||
|
|
||||||
class Section(NamedTuple):
|
|
||||||
link: str
|
|
||||||
title: str
|
|
||||||
icon: str
|
|
||||||
|
|
||||||
|
|
||||||
SECTIONS = [
|
|
||||||
Section("weather", "Weather", "brightness-high"),
|
|
||||||
Section("schedule", "TV program", "tv"),
|
|
||||||
]
|
|
||||||
|
|
||||||
base_dir = Path(__file__).parent
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
templates = build_templates()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse)
|
|
||||||
async def get_section_list(request: Request):
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="root_index.html",
|
|
||||||
context={
|
|
||||||
"sections": SECTIONS,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -30,6 +30,9 @@
|
|||||||
{% block header %}{% endblock %}
|
{% block header %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
<ul class="navbar-nav flex-row flex-wrap ms-md-auto">
|
<ul class="navbar-nav flex-row flex-wrap ms-md-auto">
|
||||||
|
<li class="nav-item me-2">
|
||||||
|
<span class="nav-link">{{ version }}</span>
|
||||||
|
</li>
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
<button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
|
<button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
|
||||||
id="bd-language"
|
id="bd-language"
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import typing
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from babel.dates import format_date
|
from babel.dates import format_date
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from gallery.easel.core import AppRequest
|
||||||
from gallery.version import __version__
|
from gallery.version import __version__
|
||||||
|
|
||||||
from ...translation import _
|
from ...translation import _
|
||||||
from .tag import TagUtil
|
from .tag import TagUtil
|
||||||
|
|
||||||
|
ContextProcessor = typing.Callable[[AppRequest], dict[str, typing.Any]]
|
||||||
|
|
||||||
|
|
||||||
def is_widget(request: Request) -> bool:
|
def is_widget(request: Request) -> bool:
|
||||||
return (request.url.hostname and request.url.hostname.startswith("weather")) or (
|
return (request.url.hostname and request.url.hostname.startswith("weather")) or (
|
||||||
@@ -17,17 +21,23 @@ def is_widget(request: Request) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def context_processor(request: Request) -> dict:
|
def base_context_processor(request: Request) -> dict:
|
||||||
return {
|
return {
|
||||||
"is_widget": is_widget(request),
|
"is_widget": is_widget(request),
|
||||||
|
"provider": request.query_params.get("provider"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_templates(templates_dir: Path | None = None, filters: dict | None = None) -> Jinja2Templates:
|
def build_templates(
|
||||||
|
templates_dir: Path | None = None, filters: dict | None = None, context_processor: ContextProcessor | None = None
|
||||||
|
) -> Jinja2Templates:
|
||||||
directory = [Path(__file__).parent.parent / "templates"]
|
directory = [Path(__file__).parent.parent / "templates"]
|
||||||
if templates_dir:
|
if templates_dir:
|
||||||
directory.append(templates_dir)
|
directory.append(templates_dir)
|
||||||
templates = Jinja2Templates(directory=directory, context_processors=[context_processor])
|
context_processors: list[ContextProcessor] = [base_context_processor]
|
||||||
|
if context_processor:
|
||||||
|
context_processors.append(context_processor)
|
||||||
|
templates = Jinja2Templates(directory=directory, context_processors=context_processors)
|
||||||
templates.env.globals.update(
|
templates.env.globals.update(
|
||||||
{
|
{
|
||||||
"_": _,
|
"_": _,
|
||||||
|
|||||||
36
gallery/easel/route/view/root/__init__.py
Normal file
36
gallery/easel/route/view/root/__init__.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
from ..common.utils.template import build_templates
|
||||||
|
|
||||||
|
|
||||||
|
class Section(NamedTuple):
|
||||||
|
link: str
|
||||||
|
title: str
|
||||||
|
icon: str
|
||||||
|
|
||||||
|
|
||||||
|
SECTIONS = [
|
||||||
|
Section("weather", "Weather", "brightness-high"),
|
||||||
|
Section("schedule", "TV program", "tv"),
|
||||||
|
]
|
||||||
|
|
||||||
|
base_dir = Path(__file__).parent
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
templates = build_templates(Path(__file__).parent / "templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def get_section_list(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="root_index.html",
|
||||||
|
context={
|
||||||
|
"sections": SECTIONS,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -1,16 +1,32 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
from gallery.easel.core import AppRequest
|
from gallery.easel.core import AppRequest
|
||||||
|
from gallery.sketch.schedule.api import ScheduleApi
|
||||||
from gallery.sketch.schedule.catalog import BUNDLE
|
from gallery.sketch.schedule.catalog import BUNDLE
|
||||||
|
|
||||||
from ..common.utils.tag import TagType, TagUtil
|
from ..common.utils.tag import TagType, TagUtil
|
||||||
from ..common.utils.template import build_templates
|
from ..common.utils.template import build_templates
|
||||||
from .filters import timedelta_format
|
from .filters import timedelta_format
|
||||||
|
|
||||||
|
|
||||||
|
async def get_schedule_api(request: AppRequest, provider: str | None = None) -> ScheduleApi:
|
||||||
|
return request.app.state.api.get_schedule(provider)
|
||||||
|
|
||||||
|
|
||||||
|
ScheduleApiDepends = Annotated[ScheduleApi, Depends(get_schedule_api)]
|
||||||
|
|
||||||
|
|
||||||
|
def context_procesor(request: AppRequest) -> dict:
|
||||||
|
return {
|
||||||
|
"providers": request.app.state.api.get_api_providers(ScheduleApi.TYPE),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
templates = build_templates(
|
templates = build_templates(
|
||||||
Path(__file__).parent / "templates",
|
Path(__file__).parent / "templates",
|
||||||
{
|
{
|
||||||
@@ -18,12 +34,11 @@ templates = build_templates(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter(prefix="/schedule")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/schedule", response_class=HTMLResponse)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
async def get_schedule_list(request: AppRequest):
|
async def get_schedule_list(request: AppRequest, schedule_api: ScheduleApiDepends):
|
||||||
schedule_api = request.app.state.api.schedule
|
|
||||||
channels = await schedule_api.get_channels()
|
channels = await schedule_api.get_channels()
|
||||||
channels_data = BUNDLE.select_items(channels)
|
channels_data = BUNDLE.select_items(channels)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
@@ -35,10 +50,9 @@ async def get_schedule_list(request: AppRequest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/schedule/tag/{tag}", response_class=HTMLResponse)
|
@router.get("/tag/{tag}", response_class=HTMLResponse)
|
||||||
async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False):
|
async def get_schedule_tag(request: AppRequest, schedule_api: ScheduleApiDepends, tag: str, live: bool = False):
|
||||||
tag_value = TagUtil.parse_tag(tag)
|
tag_value = TagUtil.parse_tag(tag)
|
||||||
schedule_api = request.app.state.api.schedule
|
|
||||||
results = await schedule_api.get_all_schedules(tag_value.date)
|
results = await schedule_api.get_all_schedules(tag_value.date)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
@@ -53,15 +67,14 @@ async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/schedule/{channel}", response_class=RedirectResponse)
|
@router.get("/{channel}", response_class=RedirectResponse)
|
||||||
async def get_channel_default(channel: str):
|
async def get_channel_default(channel: str):
|
||||||
return RedirectResponse(f"{channel}/tag/today")
|
return RedirectResponse(f"{channel}/tag/today")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse)
|
@router.get("/{channel}/tag/{tag}", response_class=HTMLResponse)
|
||||||
async def get_channel_tag(request: AppRequest, channel: str, tag: str):
|
async def get_channel_tag(request: AppRequest, schedule_api: ScheduleApiDepends, channel: str, tag: str):
|
||||||
tag_value = TagUtil.parse_tag(tag)
|
tag_value = TagUtil.parse_tag(tag)
|
||||||
schedule_api = request.app.state.api.schedule
|
|
||||||
if tag_value.type == TagType.DAY:
|
if tag_value.type == TagType.DAY:
|
||||||
response = await schedule_api.get_channel_schedule(channel, tag_value.date)
|
response = await schedule_api.get_channel_schedule(channel, tag_value.date)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{{_("TV program")}} | {{response.channel.name}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}
|
{{_("TV program")}} | {{response.channel.name}} | {{format_date(response.date, DATE_FORMAT,
|
||||||
|
locale=request.state.language)}}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
@@ -10,13 +11,19 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h4>
|
<h4>
|
||||||
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
|
<a class="icon-link {{'disabled' if response.date == datetime.date.today() else ''}}"
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
|
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">
|
||||||
<a class="button"
|
<i class="bi bi-arrow-left-square"></i>
|
||||||
href="../..">⬆️</a>
|
</a>
|
||||||
<span>{{response.channel.name}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}</span>
|
<a class="icon-link"
|
||||||
<a class="button"
|
href="../..">
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
<i class="bi bi-arrow-up-square"></i>
|
||||||
|
</a>
|
||||||
|
<span>{{response.channel.name}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
|
||||||
|
<a class="icon-link"
|
||||||
|
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">
|
||||||
|
<i class="bi bi-arrow-right-square"></i>
|
||||||
|
</a>
|
||||||
</h4>
|
</h4>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>{{_("TV program")}}</h1>
|
<h1>{{_("TV program")}}</h1>
|
||||||
<div class="list-group mb-5">
|
<div class="list-group mb-5">
|
||||||
<a href="schedule/tag/today"
|
<a href="tag/today"
|
||||||
class="list-group-item list-group-item-action px-4">
|
class="list-group-item list-group-item-action px-4">
|
||||||
<span class="fw-bold">Все</span>
|
<span class="fw-bold">Все</span>
|
||||||
</a>
|
</a>
|
||||||
{% for channel in channels %}
|
{% for channel in channels %}
|
||||||
<a href="schedule/{{channel.id}}"
|
<a href="{{channel.id}}"
|
||||||
class="list-group-item list-group-item-action px-4">
|
class="list-group-item list-group-item-action px-4">
|
||||||
<span class="text-primary">{{channel.name}}</span>
|
<span class="text-primary">{{channel.name}}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{{_("Live broadcasts") if live else _("TV program")}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}
|
{{_("Live broadcasts") if live else _("TV program")}} | {{format_date(response.date, DATE_FORMAT,
|
||||||
|
locale=request.state.language)}}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
@@ -10,13 +11,20 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h4>
|
<h4>
|
||||||
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
|
<a class="icon-link {{'disabled' if response.date == datetime.date.today() else ''}}"
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
|
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">
|
||||||
<a class="button"
|
<i class="bi bi-arrow-left-square"></i>
|
||||||
href="..">⬆️</a>
|
</a>
|
||||||
<span>{{_("Live broadcasts") if live else _("TV program")}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}</span>
|
<a class="icon-link"
|
||||||
<a class="button"
|
href="..">
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
<i class="bi bi-arrow-up-square"></i>
|
||||||
|
</a>
|
||||||
|
<span>{{_("Live broadcasts") if live else _("TV program")}} | {{format_date(response.date, DATE_FORMAT,
|
||||||
|
locale=request.state.language)}}</span>
|
||||||
|
<a class="icon-link"
|
||||||
|
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">
|
||||||
|
<i class="bi bi-arrow-right-square"></i>
|
||||||
|
</a>
|
||||||
</h4>
|
</h4>
|
||||||
<div>
|
<div>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
|
|||||||
@@ -1,22 +1,39 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
from gallery.easel.core import AppRequest
|
from gallery.easel.core import AppRequest
|
||||||
|
from gallery.sketch.weather.api import WeatherApi
|
||||||
from gallery.sketch.weather.model import WeatherResponse
|
from gallery.sketch.weather.model import WeatherResponse
|
||||||
|
|
||||||
from ..common.utils.tag import TagType, TagUtil
|
from ..common.utils.tag import TagType, TagUtil
|
||||||
from ..common.utils.template import build_templates
|
from ..common.utils.template import build_templates
|
||||||
from .filters import cloudness_icon, wind_direction_icon
|
from .filters import cloudness_icon, wind_direction_icon
|
||||||
|
|
||||||
|
|
||||||
|
async def get_weather_api(request: AppRequest, provider: str | None = None) -> WeatherApi:
|
||||||
|
return request.app.state.api.get_weather(provider)
|
||||||
|
|
||||||
|
|
||||||
|
WeatherApiDepends = Annotated[WeatherApi, Depends(get_weather_api)]
|
||||||
|
|
||||||
|
|
||||||
|
def context_procesor(request: AppRequest) -> dict:
|
||||||
|
return {
|
||||||
|
"providers": request.app.state.api.get_api_providers(WeatherApi.TYPE),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
templates = build_templates(
|
templates = build_templates(
|
||||||
Path(__file__).parent / "templates",
|
Path(__file__).parent / "templates",
|
||||||
{
|
{
|
||||||
"wind_direction_icon": wind_direction_icon,
|
"wind_direction_icon": wind_direction_icon,
|
||||||
"cloudness_icon": cloudness_icon,
|
"cloudness_icon": cloudness_icon,
|
||||||
},
|
},
|
||||||
|
context_procesor,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -30,12 +47,17 @@ def build_weather_response(request: AppRequest, response: WeatherResponse):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter(prefix="/weather")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/weather", response_class=HTMLResponse)
|
@router.get("/", response_class=RedirectResponse)
|
||||||
async def get_weather_index(request: AppRequest, query: str | None = None):
|
async def get_weather(request: AppRequest):
|
||||||
weather_api = request.app.state.api.weather
|
default_provider = request.app.state.api.get_api_providers(WeatherApi.TYPE)[0]
|
||||||
|
return RedirectResponse(default_provider)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{provider}", response_class=HTMLResponse)
|
||||||
|
async def get_weather_index(request: AppRequest, weather_api: WeatherApiDepends, query: str | None = None):
|
||||||
locations = (await weather_api.find_locations(query)) if query else []
|
locations = (await weather_api.find_locations(query)) if query else []
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
@@ -46,29 +68,31 @@ async def get_weather_index(request: AppRequest, query: str | None = None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/weather/{location}", response_class=RedirectResponse)
|
@router.get("/{provider}/{location}", response_class=RedirectResponse)
|
||||||
async def get_weather_default(location: str):
|
async def get_weather_default(location: str):
|
||||||
return RedirectResponse(f"{location}/tag/today")
|
return RedirectResponse(f"{location}/tag/today")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
|
@router.get("/{provider}/{location}/day/{date}", response_class=HTMLResponse)
|
||||||
async def get_weather_day(request: AppRequest, location: str, date: datetime.date):
|
async def get_weather_day(
|
||||||
weather_api = request.app.state.api.weather
|
request: AppRequest,
|
||||||
|
weather_api: WeatherApiDepends,
|
||||||
|
location: str,
|
||||||
|
date: datetime.date,
|
||||||
|
):
|
||||||
response = await weather_api.get_day(location, date)
|
response = await weather_api.get_day(location, date)
|
||||||
return build_weather_response(request, response)
|
return build_weather_response(request, response)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
|
@router.get("/{provider}/{location}/days/{days}", response_class=HTMLResponse)
|
||||||
async def get_weather_days(request: AppRequest, location: str, days: int):
|
async def get_weather_days(request: AppRequest, weather_api: WeatherApiDepends, location: str, days: int):
|
||||||
weather_api = request.app.state.api.weather
|
|
||||||
response = await weather_api.get_days(location, days)
|
response = await weather_api.get_days(location, days)
|
||||||
return build_weather_response(request, response)
|
return build_weather_response(request, response)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse)
|
@router.get("/{provider}/{location}/tag/{tag}", response_class=HTMLResponse)
|
||||||
async def get_weather_tag(request: AppRequest, location: str, tag: str):
|
async def get_weather_tag(request: AppRequest, weather_api: WeatherApiDepends, location: str, tag: str):
|
||||||
tag_value = TagUtil.parse_tag(tag)
|
tag_value = TagUtil.parse_tag(tag)
|
||||||
weather_api = request.app.state.api.weather
|
|
||||||
if tag_value.type == TagType.DAY:
|
if tag_value.type == TagType.DAY:
|
||||||
response = await weather_api.get_day(location, tag_value.date)
|
response = await weather_api.get_day(location, tag_value.date)
|
||||||
elif tag_value.type == TagType.DAYS:
|
elif tag_value.type == TagType.DAYS:
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{_("Weather")}}{% endblock %}
|
{% block title %}{{_("Weather")}}{% endblock %}
|
||||||
|
|
||||||
|
{# {% block header %}
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-secondary dropdown-toggle"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false">
|
||||||
|
{{provider or providers[0]}}
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
{% for provider in providers %}
|
||||||
|
<li><a class="dropdown-item"
|
||||||
|
href="{{ url_for('get_weather_index').include_query_params(provider=provider) }}">{{provider}}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endblock %} #}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>{{_("Weather")}}</h1>
|
<h1>{{_("Weather")}}</h1>
|
||||||
<form action=""
|
<form action=""
|
||||||
@@ -16,59 +33,29 @@
|
|||||||
type="submit">{{_("Search")}}</button>
|
type="submit">{{_("Search")}}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
{% if locations %}
|
||||||
<ul id="locations"
|
<ul id="locations"
|
||||||
class="list-group mb-5">
|
class="list-group mb-5">
|
||||||
{% for location in locations %}
|
{% for location in locations %}
|
||||||
<a href="weather/{{location.id}}"
|
<weather-location location="{{location.model_dump() | tojson | forceescape}}"></weather-location>
|
||||||
class="list-group-item list-group-item-action px-4"
|
|
||||||
onclick="saveLocation({id:'{{location.id}}', name:'{{location.name}}'});">
|
|
||||||
<span class="fi fi-{{location.country_code}} me-1"></span>
|
|
||||||
<span class="text-primary">{{location.name}}</span>
|
|
||||||
<span class="small ms-1 text-secondary">
|
|
||||||
{{location.country}}, {{location.district}}, {{location.subdistrict}}
|
|
||||||
</span>
|
|
||||||
<span></span>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
<hr>
|
||||||
|
{% endif %}
|
||||||
|
<ul id="storedLocations"
|
||||||
|
class="list-group mb-5">
|
||||||
|
</ul>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
document.loadLocations = () => {
|
|
||||||
const locations = JSON.parse(window.localStorage.getItem('locations') || '{}');
|
|
||||||
const container = document.querySelector('#locations');
|
|
||||||
container.innerHTML = '';
|
|
||||||
for (const [id, name] of Object.entries(locations)) {
|
|
||||||
const element = document.createElement('a');
|
|
||||||
element.href = `weather/${id}`;
|
|
||||||
element.className = 'list-group-item list-group-item-action px-4 d-flex justify-content-between align-items-start';
|
|
||||||
element.innerHTML = `
|
|
||||||
<span class="text-primary me-auto">${name}</span>
|
|
||||||
<span class="text-danger" onclick="removeLocation('${id}'); event.preventDefault();">✕</span>
|
|
||||||
`;
|
|
||||||
container.appendChild(element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.saveLocation = (location) => {
|
|
||||||
const locations = JSON.parse(window.localStorage.getItem('locations') || '{}');
|
|
||||||
locations[location.id] = location.name;
|
|
||||||
window.localStorage.setItem('locations', JSON.stringify(locations));
|
|
||||||
}
|
|
||||||
|
|
||||||
document.removeLocation = (id) => {
|
|
||||||
const locations = JSON.parse(window.localStorage.getItem('locations') || '{}');
|
|
||||||
delete locations[id];
|
|
||||||
window.localStorage.setItem('locations', JSON.stringify(locations));
|
|
||||||
document.loadLocations();
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const searchQuery = params.get('query');
|
const searchQuery = params.get('query');
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
document.querySelector('#query').value = searchQuery;
|
document.querySelector('#query').value = searchQuery;
|
||||||
} else {
|
|
||||||
document.loadLocations();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", (event) => {
|
||||||
|
weatherLocationManager.loadLocations('#storedLocations');
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -89,7 +89,9 @@ class GismeteoApi(WeatherApi):
|
|||||||
lon=item["coordinates"]["longitude"],
|
lon=item["coordinates"]["longitude"],
|
||||||
country=item["translations"]["kk"]["country"]["name"],
|
country=item["translations"]["kk"]["country"]["name"],
|
||||||
country_code=item["country"]["code"].lower(),
|
country_code=item["country"]["code"].lower(),
|
||||||
district=item["translations"]["kk"]["district"]["name"],
|
district=(
|
||||||
|
item["translations"]["kk"]["district"]["name"] if item["translations"]["kk"]["district"] else ""
|
||||||
|
),
|
||||||
subdistrict=(
|
subdistrict=(
|
||||||
item["translations"]["kk"]["subdistrict"]["name"]
|
item["translations"]["kk"]["subdistrict"]["name"]
|
||||||
if "subdistrict" in item["translations"]["kk"]
|
if "subdistrict" in item["translations"]["kk"]
|
||||||
|
|||||||
@@ -39,14 +39,11 @@ class DateParser(RowParser[datetime.datetime]):
|
|||||||
KEY = "date"
|
KEY = "date"
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[datetime.datetime]:
|
def parse_row(self, tag: Tag) -> Iterable[datetime.datetime]:
|
||||||
datetime_date_tag = tag.select_one(".widget-row.widget-row-datetime-date > .row-item")
|
datetime_time_row = tag.select_one(".widget-row.widget-row-datetime-time")
|
||||||
if datetime_date_tag:
|
if datetime_time_row:
|
||||||
date_str = datetime_date_tag.find(text=True, recursive=False).text
|
for item in datetime_time_row.select(".row-item > time-value"):
|
||||||
date = dateparser.parse(date_str, languages=["ru"])
|
timestamp = int(item.attrs["timestamp"])
|
||||||
for item in tag.select(".widget-row.widget-row-datetime-time > .row-item"):
|
time = datetime.datetime.fromtimestamp(timestamp)
|
||||||
time_str = item.text
|
|
||||||
time = dateparser.parse(time_str, languages=["ru"])
|
|
||||||
time = time.replace(year=date.year, month=date.month, day=date.day)
|
|
||||||
yield time
|
yield time
|
||||||
else:
|
else:
|
||||||
for item in tag.select(".widget-row.widget-row-date > .row-item"):
|
for item in tag.select(".widget-row.widget-row-date > .row-item"):
|
||||||
@@ -173,8 +170,12 @@ class PrecipitationParser(RowParser[float]):
|
|||||||
KEY = "precipitation"
|
KEY = "precipitation"
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[float]:
|
def parse_row(self, tag: Tag) -> Iterable[float]:
|
||||||
for item in tag.select(".widget-row[data-row=precipitation-bars] > .row-item > .item-unit"):
|
for item in tag.select(".widget-row[data-row=precipitation-bars] > .row-item"):
|
||||||
yield float(item.text.replace(",", "."))
|
value = item.select_one("precipitation-value")
|
||||||
|
if value:
|
||||||
|
yield float(value.attrs["value"])
|
||||||
|
else:
|
||||||
|
yield 0
|
||||||
|
|
||||||
|
|
||||||
class PressureParser(RowParser[list[int]]):
|
class PressureParser(RowParser[list[int]]):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from os import environ
|
||||||
|
|
||||||
from aiocache import cached
|
from aiocache import cached
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ logger = logging.getLogger("openweather")
|
|||||||
|
|
||||||
class OpenWeatherApi(WeatherApi):
|
class OpenWeatherApi(WeatherApi):
|
||||||
PROVIDER = "openweather"
|
PROVIDER = "openweather"
|
||||||
SOURCE = OpenWeather("517a6bccceaa1c48127f6199ec3fb7cf")
|
SOURCE = OpenWeather(environ["OPENWEATHER_KEY"])
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _parse_location(cls, location_id: str) -> tuple[float, float]:
|
def _parse_location(cls, location_id: str) -> tuple[float, float]:
|
||||||
@@ -32,7 +33,20 @@ class OpenWeatherApi(WeatherApi):
|
|||||||
return await self.SOURCE.get_forecast(*self._parse_location(location_id))
|
return await self.SOURCE.get_forecast(*self._parse_location(location_id))
|
||||||
|
|
||||||
async def find_locations(self, query: str) -> list[Location]:
|
async def find_locations(self, query: str) -> list[Location]:
|
||||||
raise NotImplementedError
|
result = await self.SOURCE.get_locations(query)
|
||||||
|
return [
|
||||||
|
Location(
|
||||||
|
id=f"{item.lat}:{item.lon}",
|
||||||
|
name=item.name,
|
||||||
|
lat=item.lat,
|
||||||
|
lon=item.lon,
|
||||||
|
country=item.country,
|
||||||
|
country_code=item.country.lower(),
|
||||||
|
district=item.state,
|
||||||
|
subdistrict="",
|
||||||
|
)
|
||||||
|
for item in result
|
||||||
|
]
|
||||||
|
|
||||||
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
||||||
data: Forecast = await self._get_location_forecast(location_id)
|
data: Forecast = await self._get_location_forecast(location_id)
|
||||||
|
|||||||
@@ -67,6 +67,15 @@ class Forecast(Model):
|
|||||||
list: list[ForecastItem]
|
list: list[ForecastItem]
|
||||||
|
|
||||||
|
|
||||||
|
class Location(Model):
|
||||||
|
name: str
|
||||||
|
local_names: dict[str, str]
|
||||||
|
lat: float
|
||||||
|
lon: float
|
||||||
|
country: str
|
||||||
|
state: str
|
||||||
|
|
||||||
|
|
||||||
class OpenWeather:
|
class OpenWeather:
|
||||||
BASE_URL = "https://api.openweathermap.org"
|
BASE_URL = "https://api.openweathermap.org"
|
||||||
|
|
||||||
@@ -78,4 +87,10 @@ class OpenWeather:
|
|||||||
endpoint = f"data/2.5/forecast?lat={lat}&lon={lon}&appid={self._api_key}&units=metric"
|
endpoint = f"data/2.5/forecast?lat={lat}&lon={lon}&appid={self._api_key}&units=metric"
|
||||||
response = await self._source.request(endpoint)
|
response = await self._source.request(endpoint)
|
||||||
response_data = json.loads(response)
|
response_data = json.loads(response)
|
||||||
return Forecast(**response_data)
|
return Forecast.model_validate(response_data)
|
||||||
|
|
||||||
|
async def get_locations(self, query: str, limit: int = 5) -> list[Location]:
|
||||||
|
endpoint = f"geo/1.0/direct?q={query}&limit={limit}&appid={self._api_key}"
|
||||||
|
response = await self._source.request(endpoint)
|
||||||
|
response_data = json.loads(response)
|
||||||
|
return [Location.model_validate(item) for item in response_data]
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ from typing import TypeVar
|
|||||||
|
|
||||||
|
|
||||||
class Api:
|
class Api:
|
||||||
|
TYPE: str
|
||||||
PROVIDER: str
|
PROVIDER: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type(self) -> str:
|
||||||
|
return self.TYPE
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def provider(self) -> str:
|
def provider(self) -> str:
|
||||||
return self.PROVIDER
|
return self.PROVIDER
|
||||||
|
|||||||
@@ -3,26 +3,27 @@ from .schedule.api import ScheduleApi
|
|||||||
from .weather.api import WeatherApi
|
from .weather.api import WeatherApi
|
||||||
|
|
||||||
|
|
||||||
class ApiBundle(list[Api]):
|
class ApiBundle:
|
||||||
def __init__(self, values: list[Api]) -> None:
|
def __init__(self, values: list[Api]):
|
||||||
super().__init__(values)
|
self._values = values
|
||||||
|
self._by_provider = {value.provider: value for value in values}
|
||||||
|
|
||||||
def get_api_by_provider(self, provider: str) -> Api:
|
def get_api_providers(self, api_type: str) -> list[str]:
|
||||||
for value in self:
|
result = []
|
||||||
if value.PROVIDER == provider:
|
for value in self._values:
|
||||||
return value
|
if value.type == api_type:
|
||||||
raise ValueError(provider)
|
result.append(value.provider)
|
||||||
|
return result
|
||||||
|
|
||||||
def get_api_by_type(self, api_type: type[API]) -> API:
|
def get_api(self, api_type: type[API], provider: str | None = None) -> API:
|
||||||
for value in self:
|
for value in self._values:
|
||||||
if isinstance(value, api_type):
|
if isinstance(value, api_type):
|
||||||
|
if provider is None or provider == value.provider:
|
||||||
return value
|
return value
|
||||||
raise ValueError(api_type)
|
raise ValueError(api_type, provider)
|
||||||
|
|
||||||
@property
|
def get_weather(self, provider: str | None = None) -> WeatherApi:
|
||||||
def weather(self) -> WeatherApi:
|
return self.get_api(WeatherApi, provider)
|
||||||
return self.get_api_by_type(WeatherApi)
|
|
||||||
|
|
||||||
@property
|
def get_schedule(self, provider: str | None = None) -> ScheduleApi:
|
||||||
def schedule(self) -> ScheduleApi:
|
return self.get_api(ScheduleApi, provider)
|
||||||
return self.get_api_by_type(ScheduleApi)
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from .model import ChannelId, Schedule
|
|||||||
|
|
||||||
|
|
||||||
class ScheduleApi(Api):
|
class ScheduleApi(Api):
|
||||||
|
TYPE = "schedule"
|
||||||
INTERVAL: float = 0.5
|
INTERVAL: float = 0.5
|
||||||
|
|
||||||
async def get_channels(self) -> list[ChannelId]:
|
async def get_channels(self) -> list[ChannelId]:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ CACHE_PRESET = CachePreset(ttl=TimeUnit.HOUR * 6)
|
|||||||
|
|
||||||
|
|
||||||
class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
|
class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
|
||||||
CACHE_KEY = "schedule"
|
CACHE_KEY = ScheduleApi.TYPE
|
||||||
|
|
||||||
@cached(
|
@cached(
|
||||||
key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.channels",
|
key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.channels",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from .model import Location, WeatherResponse
|
|||||||
|
|
||||||
|
|
||||||
class WeatherApi(Api):
|
class WeatherApi(Api):
|
||||||
|
TYPE = "weather"
|
||||||
|
|
||||||
async def find_locations(self, query: str) -> list[Location]:
|
async def find_locations(self, query: str) -> list[Location]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ CACHE_PRESET = DEFAULT_CACHE_PRESET
|
|||||||
|
|
||||||
|
|
||||||
class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]):
|
class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]):
|
||||||
CACHE_KEY = "weather"
|
CACHE_KEY = WeatherApi.TYPE
|
||||||
|
|
||||||
@cached(
|
@cached(
|
||||||
key_builder=lambda fun, self, query: f"api.{self.CACHE_KEY}.{self.provider}.locations.{query}",
|
key_builder=lambda fun, self, query: f"api.{self.CACHE_KEY}.{self.provider}.locations.{query}",
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
__version__ = "0.2.2"
|
|
||||||
|
|
||||||
import tomllib
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "gallery"
|
name = "gallery"
|
||||||
version = "0.3.0"
|
version = "0.3.4"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["shmyga <shmyga.z@gmail.com>"]
|
authors = ["shmyga <shmyga.z@gmail.com>"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
set -e
|
set -e
|
||||||
cd "$(dirname $(dirname "$0"))" || exit
|
cd "$(dirname $(dirname "$0"))" || exit
|
||||||
|
|
||||||
|
if [[ ! -f .env ]]; then
|
||||||
|
cp .env-base .env
|
||||||
|
fi
|
||||||
|
|
||||||
PYTHON_VERSION=3.12
|
PYTHON_VERSION=3.12
|
||||||
poetry env use ${PYTHON_VERSION}
|
poetry env use ${PYTHON_VERSION}
|
||||||
poetry install
|
poetry install
|
||||||
|
|||||||
4
static/package-lock.json
generated
4
static/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "gallery",
|
"name": "gallery",
|
||||||
"version": "0.3.0",
|
"version": "0.3.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "gallery",
|
"name": "gallery",
|
||||||
"version": "0.3.0",
|
"version": "0.3.4",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gallery",
|
"name": "gallery",
|
||||||
"version": "0.3.0",
|
"version": "0.3.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"dev": "vite build --watch"
|
"dev": "vite build --watch"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import "./components";
|
|||||||
import "./language";
|
import "./language";
|
||||||
import "./main.scss";
|
import "./main.scss";
|
||||||
import "./theme";
|
import "./theme";
|
||||||
|
import "./weather/weather";
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", (event) => {
|
document.addEventListener("DOMContentLoaded", (event) => {
|
||||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
@import "./lib/weather-icons/weather-icons";
|
@import "./lib/weather-icons/weather-icons";
|
||||||
|
|
||||||
@import "./widget.scss";
|
@import "./widget.scss";
|
||||||
@import "./weather.scss";
|
@import "./weather/weather.scss";
|
||||||
|
|
||||||
.table.table-compact {
|
.table.table-compact {
|
||||||
td {
|
td {
|
||||||
|
|||||||
113
static/src/weather/weather.ts
Normal file
113
static/src/weather/weather.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
export interface Location {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
provider: string;
|
||||||
|
country_code: string;
|
||||||
|
country?: string;
|
||||||
|
district?: string;
|
||||||
|
subdistrict?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WeatherLocationStorage {
|
||||||
|
private storageKey: string = "weather:locations";
|
||||||
|
|
||||||
|
getAll(): Location[] {
|
||||||
|
const locations = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
|
||||||
|
return Object.values(locations);
|
||||||
|
}
|
||||||
|
|
||||||
|
add(location: Location) {
|
||||||
|
const locations = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
|
||||||
|
locations[location.id] = location;
|
||||||
|
window.localStorage.setItem(this.storageKey, JSON.stringify(locations));
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(id: string) {
|
||||||
|
const locations = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
|
||||||
|
delete locations[id];
|
||||||
|
window.localStorage.setItem(this.storageKey, JSON.stringify(locations));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WeatherLocationElement extends HTMLElement {
|
||||||
|
static observedAttributes = ["location", "removable"];
|
||||||
|
|
||||||
|
location: Location | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.handleClick = this.handleClick.bind(this);
|
||||||
|
this.handleRemoveClick = this.handleRemoveClick.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.location = JSON.parse(this.getAttribute("location") || "{}");
|
||||||
|
this.innerHTML = `
|
||||||
|
<a href="${this.location?.provider}/${this.location?.id}"
|
||||||
|
class="list-group-item list-group-item-action px-4 d-flex justify-content-between align-items-start">
|
||||||
|
<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">
|
||||||
|
${this.location?.country}, ${this.location?.district}, ${this.location?.subdistrict}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-danger" data-remove>
|
||||||
|
✕
|
||||||
|
</span>
|
||||||
|
</a>`;
|
||||||
|
this.addEventListener("click", this.handleClick);
|
||||||
|
if (this.hasAttribute("removable")) {
|
||||||
|
this.querySelector("[data-remove]")?.addEventListener("click", this.handleRemoveClick);
|
||||||
|
} else {
|
||||||
|
this.querySelector("[data-remove]")?.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this.removeEventListener("click", this.handleClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
weatherLocationStorage: WeatherLocationStorage;
|
||||||
|
weatherLocationManager: WeatherLocationManager;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.weatherLocationStorage = new WeatherLocationStorage();
|
||||||
|
window.weatherLocationManager = new WeatherLocationManager();
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
254
tests/data/openweather/locations.json
Normal file
254
tests/data/openweather/locations.json
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "London",
|
||||||
|
"local_names": {
|
||||||
|
"fo": "London",
|
||||||
|
"nn": "London",
|
||||||
|
"tg": "Лондон",
|
||||||
|
"uk": "Лондон",
|
||||||
|
"lt": "Londonas",
|
||||||
|
"zu": "ILondon",
|
||||||
|
"eo": "Londono",
|
||||||
|
"os": "Лондон",
|
||||||
|
"pa": "ਲੰਡਨ",
|
||||||
|
"jv": "London",
|
||||||
|
"hu": "London",
|
||||||
|
"gd": "Lunnainn",
|
||||||
|
"hy": "Լոնդոն",
|
||||||
|
"to": "Lonitoni",
|
||||||
|
"sr": "Лондон",
|
||||||
|
"sv": "London",
|
||||||
|
"ku": "London",
|
||||||
|
"te": "లండన్",
|
||||||
|
"tw": "London",
|
||||||
|
"co": "Londra",
|
||||||
|
"eu": "Londres",
|
||||||
|
"et": "London",
|
||||||
|
"ca": "Londres",
|
||||||
|
"nl": "Londen",
|
||||||
|
"kl": "London",
|
||||||
|
"fi": "Lontoo",
|
||||||
|
"az": "London",
|
||||||
|
"mr": "लंडन",
|
||||||
|
"km": "ឡុងដ៍",
|
||||||
|
"af": "Londen",
|
||||||
|
"hi": "लंदन",
|
||||||
|
"gl": "Londres",
|
||||||
|
"ka": "ლონდონი",
|
||||||
|
"ff": "London",
|
||||||
|
"de": "London",
|
||||||
|
"sl": "London",
|
||||||
|
"th": "ลอนดอน",
|
||||||
|
"bn": "লন্ডন",
|
||||||
|
"pl": "Londyn",
|
||||||
|
"an": "Londres",
|
||||||
|
"bg": "Лондон",
|
||||||
|
"ht": "Lonn",
|
||||||
|
"so": "London",
|
||||||
|
"mn": "Лондон",
|
||||||
|
"id": "London",
|
||||||
|
"ko": "런던",
|
||||||
|
"yo": "Lọndọnu",
|
||||||
|
"sa": "लन्डन्",
|
||||||
|
"nv": "Tooh Dineʼé Bikin Haalʼá",
|
||||||
|
"br": "Londrez",
|
||||||
|
"rm": "Londra",
|
||||||
|
"ascii": "London",
|
||||||
|
"en": "London",
|
||||||
|
"ba": "Лондон",
|
||||||
|
"ga": "Londain",
|
||||||
|
"tr": "Londra",
|
||||||
|
"om": "Landan",
|
||||||
|
"bh": "लंदन",
|
||||||
|
"gu": "લંડન",
|
||||||
|
"ee": "London",
|
||||||
|
"se": "London",
|
||||||
|
"vi": "Luân Đôn",
|
||||||
|
"sk": "Londýn",
|
||||||
|
"lb": "London",
|
||||||
|
"cs": "Londýn",
|
||||||
|
"io": "London",
|
||||||
|
"ab": "Лондон",
|
||||||
|
"ug": "لوندۇن",
|
||||||
|
"ha": "Landan",
|
||||||
|
"wo": "Londar",
|
||||||
|
"bi": "London",
|
||||||
|
"st": "London",
|
||||||
|
"fj": "Lodoni",
|
||||||
|
"tt": "Лондон",
|
||||||
|
"mt": "Londra",
|
||||||
|
"vo": "London",
|
||||||
|
"bs": "London",
|
||||||
|
"or": "ଲଣ୍ଡନ",
|
||||||
|
"is": "London",
|
||||||
|
"tk": "London",
|
||||||
|
"he": "לונדון",
|
||||||
|
"ia": "London",
|
||||||
|
"kn": "ಲಂಡನ್",
|
||||||
|
"ie": "London",
|
||||||
|
"es": "Londres",
|
||||||
|
"my": "လန်ဒန်မြို့",
|
||||||
|
"sn": "London",
|
||||||
|
"zh": "伦敦",
|
||||||
|
"wa": "Londe",
|
||||||
|
"fy": "Londen",
|
||||||
|
"sq": "Londra",
|
||||||
|
"be": "Лондан",
|
||||||
|
"oc": "Londres",
|
||||||
|
"hr": "London",
|
||||||
|
"da": "London",
|
||||||
|
"bm": "London",
|
||||||
|
"ru": "Лондон",
|
||||||
|
"ne": "लन्डन",
|
||||||
|
"pt": "Londres",
|
||||||
|
"lo": "ລອນດອນ",
|
||||||
|
"ro": "Londra",
|
||||||
|
"cu": "Лондонъ",
|
||||||
|
"ce": "Лондон",
|
||||||
|
"ny": "London",
|
||||||
|
"am": "ለንደን",
|
||||||
|
"gv": "Lunnin",
|
||||||
|
"no": "London",
|
||||||
|
"it": "Londra",
|
||||||
|
"cv": "Лондон",
|
||||||
|
"ta": "இலண்டன்",
|
||||||
|
"ar": "لندن",
|
||||||
|
"av": "Лондон",
|
||||||
|
"sm": "Lonetona",
|
||||||
|
"ja": "ロンドン",
|
||||||
|
"ky": "Лондон",
|
||||||
|
"si": "ලන්ඩන්",
|
||||||
|
"tl": "Londres",
|
||||||
|
"na": "London",
|
||||||
|
"sw": "London",
|
||||||
|
"mi": "Rānana",
|
||||||
|
"lv": "Londona",
|
||||||
|
"gn": "Lóndyre",
|
||||||
|
"su": "London",
|
||||||
|
"mg": "Lôndôna",
|
||||||
|
"ml": "ലണ്ടൻ",
|
||||||
|
"fr": "Londres",
|
||||||
|
"ur": "علاقہ لندن",
|
||||||
|
"kw": "Loundres",
|
||||||
|
"yi": "לאנדאן",
|
||||||
|
"ig": "London",
|
||||||
|
"sc": "Londra",
|
||||||
|
"ln": "Lóndɛlɛ",
|
||||||
|
"kk": "Лондон",
|
||||||
|
"el": "Λονδίνο",
|
||||||
|
"sd": "لنڊن",
|
||||||
|
"li": "Londe",
|
||||||
|
"cy": "Llundain",
|
||||||
|
"ms": "London",
|
||||||
|
"fa": "لندن",
|
||||||
|
"sh": "London",
|
||||||
|
"feature_name": "London",
|
||||||
|
"uz": "London",
|
||||||
|
"bo": "ལོན་ཊོན།",
|
||||||
|
"mk": "Лондон",
|
||||||
|
"qu": "London",
|
||||||
|
"ay": "London",
|
||||||
|
"kv": "Лондон",
|
||||||
|
"ps": "لندن"
|
||||||
|
},
|
||||||
|
"lat": 51.5073219,
|
||||||
|
"lon": -0.1276474,
|
||||||
|
"country": "GB",
|
||||||
|
"state": "England"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "City of London",
|
||||||
|
"local_names": {
|
||||||
|
"lt": "Londono Sitis",
|
||||||
|
"zh": "倫敦市",
|
||||||
|
"hi": "सिटी ऑफ़ लंदन",
|
||||||
|
"ko": "시티 오브 런던",
|
||||||
|
"es": "City de Londres",
|
||||||
|
"ur": "لندن شہر",
|
||||||
|
"uk": "Лондонське Сіті",
|
||||||
|
"he": "הסיטי של לונדון",
|
||||||
|
"en": "City of London",
|
||||||
|
"pt": "Cidade de Londres",
|
||||||
|
"fr": "Cité de Londres",
|
||||||
|
"ru": "Сити"
|
||||||
|
},
|
||||||
|
"lat": 51.5156177,
|
||||||
|
"lon": -0.0919983,
|
||||||
|
"country": "GB",
|
||||||
|
"state": "England"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "London",
|
||||||
|
"local_names": {
|
||||||
|
"ga": "Londain",
|
||||||
|
"ka": "ლონდონი",
|
||||||
|
"yi": "לאנדאן",
|
||||||
|
"cr": "ᓬᐊᐣᑕᐣ",
|
||||||
|
"th": "ลอนดอน",
|
||||||
|
"he": "לונדון",
|
||||||
|
"el": "Λόντον",
|
||||||
|
"lt": "Londonas",
|
||||||
|
"ja": "ロンドン",
|
||||||
|
"fa": "لندن",
|
||||||
|
"bn": "লন্ডন",
|
||||||
|
"iu": "ᓚᓐᑕᓐ",
|
||||||
|
"ru": "Лондон",
|
||||||
|
"oj": "Baketigweyaang",
|
||||||
|
"ug": "لوندۇن",
|
||||||
|
"en": "London",
|
||||||
|
"be": "Лондан",
|
||||||
|
"ar": "لندن",
|
||||||
|
"fr": "London",
|
||||||
|
"lv": "Landona",
|
||||||
|
"ko": "런던",
|
||||||
|
"hy": "Լոնտոն"
|
||||||
|
},
|
||||||
|
"lat": 42.9832406,
|
||||||
|
"lon": -81.243372,
|
||||||
|
"country": "CA",
|
||||||
|
"state": "Ontario"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Chelsea",
|
||||||
|
"local_names": {
|
||||||
|
"id": "Chelsea, London",
|
||||||
|
"ru": "Челси",
|
||||||
|
"ga": "Chelsea",
|
||||||
|
"ur": "چیلسی، لندن",
|
||||||
|
"et": "Chelsea",
|
||||||
|
"pl": "Chelsea",
|
||||||
|
"da": "Chelsea",
|
||||||
|
"ar": "تشيلسي",
|
||||||
|
"fa": "چلسی",
|
||||||
|
"ko": "첼시",
|
||||||
|
"sv": "Chelsea, London",
|
||||||
|
"es": "Chelsea",
|
||||||
|
"eu": "Chelsea",
|
||||||
|
"nl": "Chelsea",
|
||||||
|
"sk": "Chelsea",
|
||||||
|
"no": "Chelsea",
|
||||||
|
"af": "Chelsea, Londen",
|
||||||
|
"uk": "Челсі",
|
||||||
|
"he": "צ'לסי",
|
||||||
|
"it": "Chelsea",
|
||||||
|
"hu": "Chelsea",
|
||||||
|
"ja": "チェルシー",
|
||||||
|
"zh": "車路士",
|
||||||
|
"hi": "चेल्सी, लंदन",
|
||||||
|
"pt": "Chelsea",
|
||||||
|
"az": "Çelsi",
|
||||||
|
"el": "Τσέλσι",
|
||||||
|
"vi": "Chelsea, Luân Đôn",
|
||||||
|
"en": "Chelsea",
|
||||||
|
"de": "Chelsea",
|
||||||
|
"tr": "Chelsea, Londra",
|
||||||
|
"fr": "Chelsea",
|
||||||
|
"sh": "Chelsea, London"
|
||||||
|
},
|
||||||
|
"lat": 51.4875167,
|
||||||
|
"lon": -0.1687007,
|
||||||
|
"country": "GB",
|
||||||
|
"state": "England"
|
||||||
|
},
|
||||||
|
{ "name": "London", "lat": 37.1289771, "lon": -84.0832646, "country": "US", "state": "Kentucky" }
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user