27 Commits

Author SHA1 Message Date
1813ec213b ci(version): 0.3.0 2026-06-15 23:19:57 +03:00
1b700086f2 feat(weather): update navigate icons 2026-06-15 23:18:48 +03:00
02a6ffc931 feat(weather): new weather icons 2026-06-15 11:22:41 +03:00
2dfedfea57 fix(easel): fix header icons 2026-06-15 11:22:28 +03:00
8012d9b8ed fix(gismeteo): add max_date validation 2026-06-14 10:23:38 +03:00
3a6faa85be fix(gismeteo): add new variants to sky parser 2026-06-14 10:23:13 +03:00
1af61aa3c7 feat(easel): add sky details tooltip 2026-06-14 10:22:43 +03:00
315838604e feat(easel): add widget mode support 2026-06-14 09:48:27 +03:00
f368e6717c style: format code 2026-06-12 00:16:15 +03:00
91e2c9d123 ci(version): 0.2.3 2026-06-10 11:49:56 +03:00
91d9c37612 build(vscode): add settings 2026-06-10 11:49:48 +03:00
b5f2c272bb feat(easel): update navbar 2026-06-10 11:37:49 +03:00
eec72c77ab docs: add screenshot 2026-05-07 01:11:42 +03:00
160ec2b48b ci(version): 0.2.2 2026-04-24 14:12:39 +03:00
7c57f939c0 feat(static): update and optimizate 2026-04-24 14:12:14 +03:00
c233b020fc ci(version): 0.2.1 2026-04-23 21:24:28 +03:00
869a8ae79f fix(gismeteo): fix optional subdistrict in gismeteo reponse 2026-04-23 21:24:13 +03:00
4c3b3aeafc ci(version): 0.2.0 2026-04-23 21:06:15 +03:00
d1592150fd build(scipts): add version script 2026-04-23 21:06:06 +03:00
9351b9f53a feat(easel): add localization 2026-04-23 15:47:16 +03:00
ecb574e286 feat(easel): add bootstrap 2026-04-22 21:42:39 +03:00
94870a5c86 feat(weather): add weather location search 2026-04-22 12:58:56 +03:00
3dd0a5410c docs: update README 2026-04-16 23:32:58 +03:00
a0e6f30e3b feat(schedule): update view 2026-04-16 23:05:41 +03:00
29fa6435ce feat(yandextv): add yandextv schedule api 2026-04-16 18:43:12 +03:00
a886322d0e test: fix tests 2026-04-14 22:40:10 +03:00
6112147b40 build(docker): update docker build 2026-04-14 09:22:07 +03:00
148 changed files with 17387 additions and 12105 deletions

View File

@@ -6,6 +6,7 @@ indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
max_line_length = 120
[*.md]
max_line_length = off

5
.env Normal file
View File

@@ -0,0 +1,5 @@
DOCKER_REPO=git.shmyga.ru
DOCKER_GROUP=infernalgames
DOCKER_ROOT="$DOCKER_REPO/$DOCKER_GROUP"
VERSION=$(grep -m 1 'version' ./pyproject.toml | grep -oP 'version\s*=\s*"\K[^"]+')
DOCKER_PROJECTS=("gallery")

3
.gitignore vendored
View File

@@ -1,4 +1,7 @@
*.pyc
*.mo
.pytest_cache
.venv
#.vscode
static/node_modules
static/dist

9
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"python-envs.pythonProjects": [
{
"path": ".",
"envManager": "ms-python.python:poetry",
"packageManager": "ms-python.python:poetry"
}
]
}

View File

@@ -1,23 +1,33 @@
FROM python:3.12 AS builder
ENV POETRY_HOME="/opt/poetry"
ENV PATH="$POETRY_HOME/bin:$PATH"
RUN apt update && \
apt install -y gettext
WORKDIR /app
RUN curl -sSL https://install.python-poetry.org | python3 -
COPY pyproject.toml poetry.lock ./
COPY pyproject.toml poetry.lock README.md ./
RUN poetry config virtualenvs.in-project true
RUN poetry install --with app
RUN poetry install --with app --no-root
COPY locales ./locales
RUN cd locales/ru/LC_MESSAGES && msgfmt messages.po
FROM node:24 AS node-builder
ENV PATH=/app/node_modules/.bin:$PATH
WORKDIR /app
COPY static/package.json static/package-lock.json ./
RUN npm ci
COPY static ./
RUN npm run build
FROM python:3.12-slim
ENV PATH="/app/.venv/bin:$PATH"
WORKDIR /app
RUN apt update && \
apt install -y locales && \
sed -i -e 's/# ru_RU.UTF-8 UTF-8/ru_RU.UTF-8 UTF-8/' /etc/locale.gen && \
dpkg-reconfigure --frontend=noninteractive locales
ENV LANG=ru_RU.UTF-8
ENV LC_ALL=ru_RU.UTF-8
ENV TZ="Europe/Moscow"
COPY --from=builder /app ./
COPY --from=node-builder /app/dist ./static/dist
COPY gallery gallery/
#COPY --from=builder /app/gallery/easel/route/view/locales /app/gallery/easel/route/view/locales
COPY --from=builder --parents locales/**/*.mo ./
CMD ["uvicorn", "gallery.main:app", "--host", "0.0.0.0", "--port", "80", "--log-config", "gallery/logging.yaml"]

View File

@@ -1 +1,13 @@
# Gallery
# API Gallery
Weather and TV program API
![API Gallery](docs/screenshot.png "API Gallery")
## View
https://api.shmyga.ru
## Swagger
https://api.shmyga.ru/docs

View File

@@ -0,0 +1,25 @@
name: gallery
services:
redis:
container_name: gallery-redis
image: redis:alpine
stop_grace_period: 3s
volumes:
- redis_data:/data
app:
container_name: gallery-app-develop
build: .
environment:
- REDIS_HOST=redis
- DEBUG=1
ports:
- 8000:80
develop:
watch:
- action: sync
path: ./gallery
target: /app/gallery
volumes:
redis_data:

View File

@@ -1,3 +1,5 @@
name: gallery
services:
redis:
container_name: gallery-redis
@@ -5,15 +7,15 @@ services:
stop_grace_period: 3s
volumes:
- redis_data:/data
command: [ "redis-server", "--bind", "0.0.0.0", "--port", "6379" ]
app:
container_name: gallery-app
build: .
# image: shmyga/gallery
image: ${DOCKER_ROOT}/gallery
environment:
- REDIS_HOST=redis
depends_on:
- redis
ports:
- 8000:80
- 127.0.0.1:8000:80
volumes:
redis_data:

BIN
docs/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

View File

@@ -33,12 +33,17 @@
"type": "debugpy",
"request": "launch",
"module": "uvicorn",
"args": [
"gallery.main:app",
"--reload",
"--log-config",
"gallery/logging.yaml",
],
"args": ["gallery.main:app", "--reload", "--log-config", "gallery/logging.yaml"],
"justMyCode": true,
"consoleTitle": "gallery:app",
},
{
"name": "gallery:static",
"cwd": "${workspaceFolder}/static",
"request": "launch",
"type": "node-terminal",
"command": "npm run dev",
"consoleTitle": "gallery:static",
},
],
},

View File

@@ -1,23 +1,22 @@
import locale as _locale
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from gallery.sketch.bundle import ApiBundle
from gallery.util import root_path
from .route import api, doc, view
DEFAULT_LOCALE = "ru_RU.UTF-8"
from .route import api, doc
from .route.view import router as view_router
def build_app(api_bundle: ApiBundle, *, locale: str = DEFAULT_LOCALE) -> FastAPI:
_locale.setlocale(_locale.LC_TIME, locale)
def build_app(api_bundle: ApiBundle) -> FastAPI:
app = FastAPI(
title="Gallery",
docs_url=None,
redoc_url=None,
)
app.state.api = api_bundle
app.mount("/static", StaticFiles(directory=root_path / "static/dist"))
doc.mount(app)
api.mount(app)
view.mount(app)
app.include_router(view_router)
return app

View File

@@ -1,5 +1,18 @@
import datetime
from fastapi import FastAPI
from gallery.easel.core import AppRequest
from gallery.sketch.schedule.model import ChannelId, Schedule
def mount(app: FastAPI):
pass
@app.get("/api/schedule/channels", tags=["API"])
async def get_api_schedule_channels(request: AppRequest) -> list[ChannelId]:
schedule_api = request.app.state.api.schedule
return await schedule_api.get_channels()
@app.get("/api/schedule/{channel}/{date}", tags=["API"])
async def get_api_schedule_channel_schedule(request: AppRequest, channel: str, date: datetime.date) -> Schedule:
schedule_api = request.app.state.api.schedule
return await schedule_api.get_channel_schedule(ChannelId(channel), date)

View File

@@ -3,25 +3,21 @@ import datetime
from fastapi import FastAPI
from gallery.easel.core import AppRequest
from gallery.sketch.weather.model import WeatherResponse
from gallery.sketch.weather.model import Location, WeatherResponse
def mount(app: FastAPI):
@app.get("/api/weather/locations")
async def get_api_weather_locations(request: AppRequest) -> list[str]:
@app.get("/api/weather/locations", tags=["API"])
async def get_api_weather_locations(request: AppRequest, query: str) -> list[Location]:
weather_api = request.app.state.api.weather
return await weather_api.get_locations()
return await weather_api.find_locations(query)
@app.get("/api/weather/{location}/day/{date}")
async def get_api_weather_day(
request: AppRequest, location: str, date: datetime.date
) -> WeatherResponse:
@app.get("/api/weather/{location}/day/{date}", tags=["API"])
async def get_api_weather_day(request: AppRequest, location: str, date: datetime.date) -> WeatherResponse:
weather_api = request.app.state.api.weather
return await weather_api.get_day(location, date)
@app.get("/api/weather/{location}/days/{days}")
async def get_api_weather_days(
request: AppRequest, location: str, days: int
) -> WeatherResponse:
@app.get("/api/weather/{location}/days/{days}", tags=["API"])
async def get_api_weather_days(request: AppRequest, location: str, days: int) -> WeatherResponse:
weather_api = request.app.state.api.weather
return await weather_api.get_days(location, days)

View File

@@ -1,9 +1,11 @@
from fastapi import FastAPI
from fastapi import APIRouter, Depends
from . import common, schedule, weather
from .common import router as common_router
from .schedule import router as schedule_router
from .translation import set_language
from .weather import router as weather_router
def mount(app: FastAPI):
common.mount(app)
weather.mount(app)
schedule.mount(app)
router = APIRouter(dependencies=[Depends(set_language)])
router.include_router(common_router)
router.include_router(weather_router)
router.include_router(schedule_router)

View File

@@ -1,37 +1,36 @@
from pathlib import Path
from typing import NamedTuple
from fastapi import FastAPI, Request
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from gallery.version import __version__
from ..common.utils.template import build_templates
class Section(NamedTuple):
link: str
title: str
icon: str
SECTIONS = [
Section("weather", "Погода"),
Section("schedule", "Телепрограмма"),
Section("weather", "Weather", "brightness-high"),
Section("schedule", "TV program", "tv"),
]
base_dir = Path(__file__).parent
def mount(app: FastAPI):
base_dir = Path(__file__).parent
app.mount("/static/common", StaticFiles(directory=base_dir / "static"))
templates = Jinja2Templates(directory=base_dir / "templates")
router = APIRouter()
@app.get("/", response_class=HTMLResponse)
async def get_section_list(request: Request):
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={
"version": __version__,
"sections": SECTIONS,
},
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,112 +0,0 @@
/*
base
*/
body {
font-size: 1.5rem;
}
h3 {
margin: 0.5rem 0;
}
/*
table
*/
table {
table-layout: fixed;
border-collapse: collapse;
}
table,
th,
td {
text-align: center;
}
td {
padding: 0.1rem 0.4rem;
}
/*
a.button
*/
a.button {
text-decoration: none;
color: inherit;
}
.button.disabled {
pointer-events: none;
cursor: default;
color: gray;
filter: grayscale(100%);
}
/*
app
*/
.app-container {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: stretch;
}
.app-menu {
display: flex;
flex-direction: column;
margin: 0.5rem;
}
.app-content {
display: flex;
flex: 1;
flex-direction: column;
}
.app-header {
width: 100%;
display: flex;
flex-direction: row;
gap: 0.5rem;
justify-content: center;
}
.app-link-home > * {
width: 2rem;
height: 2rem;
background-image: url("/static/common/gallery.png");
background-size: contain;
}
.icon {
display: inline-block;
width: 2rem;
height: 2rem;
background-size: contain;
}
ul.app-list {
list-style: none;
padding-left: 0;
}
ul.app-list > li {
border: 1px solid lightgrey;
}
ul.app-list > li > a {
display: flex;
gap: 0.25rem;
padding: 0.5rem 2rem;
text-decoration: none;
color: inherit;
}
ul.app-list > li:hover {
border-color: blue;
}
ul.app-list > li:hover > a {
color: blue;
}

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="{{request.state.language}}">
<head>
{% block head %}
@@ -10,28 +10,112 @@
content="ie=edge">
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet"
href="/static/common/style.css?v={{version}}">
href="/static/gallery.css?v={{version}}">
<script type="module"
src="/static/gallery.es.js?v={{version}}"></script>
<link rel="icon"
href="/static/common/favicon.ico?v={{version}}"
href="/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
</head>
<body class="app-container">
<div class="app-menu">
<a class="app-link-home"
href="/">
<div></div>
</a>
<body class="{{ is_widget and 'widget' or ''}}">
<div class="app col-lg-8 mx-auto p-3 py-md-5">
{% if not is_widget %}
<header class="app-header pb-3 mb-5 border-bottom">
<div>{{request.query_params.widget}}</div>
<div class="link-list">
<app-link href="/"
icon="gear">API Gallery</app-link>
{% block header %}{% endblock %}
</div>
<div class="app-content">
<h3 class="app-header">
{% block header %}{% endblock %}</span>
</h3>
<ul class="navbar-nav flex-row flex-wrap ms-md-auto">
<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"
id="bd-language"
type="button"
aria-expanded="false"
data-bs-toggle="dropdown"
aria-label="{{_('Select language')}} (default)">
<span class="fi fir fi-gb me-2 language-icon-active icon-header"></span>
<span class="d-lg-none ms-2"
id="bd-language-text">{{_("Select language")}}</span>
</button>
<ul class="dropdown-menu dropdown-menu-end"
aria-labelledby="bd-language-text">
<li>
<button type="button"
class="dropdown-item d-flex align-items-center"
data-bs-language-value="en"
aria-pressed="false">
<span class="fi fir fi-gb me-2 language-icon-active"></span>
{{_("English")}}
</button>
</li>
<li>
<button type="button"
class="dropdown-item d-flex align-items-center"
data-bs-language-value="ru"
aria-pressed="false">
<span class="fi fir fi-ru me-2 language-icon-active"></span>
{{_("Russian")}}
</button>
</li>
</ul>
</li>
<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"
id="bd-theme"
type="button"
aria-expanded="false"
data-bs-toggle="dropdown"
aria-label="Toggle theme (auto)">
<span class="bi bi-circle-half me-2 opacity-50 theme-icon-active icon-header"></span>
<span class="d-lg-none ms-2"
id="bd-theme-text">{{_("Toggle theme")}}</span>
</button>
<ul class="dropdown-menu dropdown-menu-end"
aria-labelledby="bd-theme-text">
<li>
<button type="button"
class="dropdown-item d-flex align-items-center"
data-bs-theme-value="light"
aria-pressed="false">
<span class="bi bi-sun-fill me-2 opacity-50 theme-icon"></span>
{{_("Light")}}
</button>
</li>
<li>
<button type="button"
class="dropdown-item d-flex align-items-center"
data-bs-theme-value="dark"
aria-pressed="false">
<span class="bi bi-moon-stars-fill me-2 opacity-50 theme-icon"></span>
{{_("Dark")}}
</button>
</li>
<li>
<button type="button"
class="dropdown-item d-flex align-items-center active"
data-bs-theme-value="auto"
aria-pressed="true">
<span class="bi bi-circle-half me-2 opacity-50 theme-icon"></span>
{{_("Auto")}}
</button>
</li>
</ul>
</li>
</ul>
</header>
{% endif %}
<main>
{% block content %}{% endblock %}
<div class="app-footer">
{% block footer %}{% endblock %}
</div>
</main>
{% if not is_widget %}
<footer class="pt-5 my-5 text-muted border-top">
Created by shmyga &middot; &copy; 2026
</footer>
{% endif %}
</div>
</body>

View File

@@ -1,21 +1,26 @@
{% extends "base.html" %}
{% block title %}Информация{% endblock %}
{% block title %}{{_("Index")}}{% endblock %}
{% block head %}
{{ super() }}
{% endblock %}
{% block header %}Информация{% endblock %}
{% block content %}
<ul class="app-list">
<h1>{{_("View")}}</h1>
<div class="list-group mb-5">
{% for section in sections %}
<li>
<a href="{{section.link}}">
<span class="icon"
style="background-image: url(/static/{{section.link}}/{{section.link}}.png);"></span>
<span>{{section.title}}</span>
<a href="{{section.link}}"
class="list-group-item list-group-item-action px-4">
<app-link href="{{section.link}}"
icon="{{section.icon}}">
{{_(section.title)}}
</app-link>
</a>
</li>
{% endfor %}
</ul>
</div>
<hr class="col-3 col-md-2 mb-5">
<h1>{{_("Docs")}}</h1>
<a href="/docs"
target="_blank">
<h4>Swagger</h4>
</a>
{% endblock %}

View File

@@ -0,0 +1,43 @@
import datetime
from pathlib import Path
from babel.dates import format_date
from fastapi import Request
from fastapi.templating import Jinja2Templates
from gallery.version import __version__
from ...translation import _
from .tag import TagUtil
def is_widget(request: Request) -> bool:
return (request.url.hostname and request.url.hostname.startswith("weather")) or (
request.query_params.get("widget") is not None
)
def context_processor(request: Request) -> dict:
return {
"is_widget": is_widget(request),
}
def build_templates(templates_dir: Path | None = None, filters: dict | None = None) -> Jinja2Templates:
directory = [Path(__file__).parent.parent / "templates"]
if templates_dir:
directory.append(templates_dir)
templates = Jinja2Templates(directory=directory, context_processors=[context_processor])
templates.env.globals.update(
{
"_": _,
"version": __version__,
"format_date": format_date,
"datetime": datetime,
"tag_util": TagUtil,
"DATE_FORMAT": "E, d MMMM Y",
}
)
if filters:
templates.env.filters.update(filters)
return templates

View File

@@ -1,32 +1,28 @@
import datetime
from pathlib import Path
from fastapi import FastAPI
from fastapi import APIRouter
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from gallery.easel.core import AppRequest
from gallery.sketch.schedule.catalog import BUNDLE
from gallery.version import __version__
from ..common.util import TagType, TagUtil
from ..common.utils.tag import TagType, TagUtil
from ..common.utils.template import build_templates
from .filters import timedelta_format
templates = build_templates(
Path(__file__).parent / "templates",
{
"timedelta_format": timedelta_format,
},
)
def mount(app: FastAPI):
base_dir = Path(__file__).parent
app.mount("/static/schedule", StaticFiles(directory=base_dir / "static"))
templates = Jinja2Templates(
directory=[
base_dir.parent / "common/templates",
base_dir / "templates",
]
)
templates.env.filters["timedelta_format"] = timedelta_format
router = APIRouter()
@app.get("/schedule", response_class=HTMLResponse)
async def get_schedule_list(request: AppRequest):
@router.get("/schedule", response_class=HTMLResponse)
async def get_schedule_list(request: AppRequest):
schedule_api = request.app.state.api.schedule
channels = await schedule_api.get_channels()
channels_data = BUNDLE.select_items(channels)
@@ -34,40 +30,36 @@ def mount(app: FastAPI):
request=request,
name="index.html",
context={
"version": __version__,
"channels": channels_data,
},
)
@app.get("/schedule/tag/{tag}", response_class=HTMLResponse)
async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False):
@router.get("/schedule/tag/{tag}", response_class=HTMLResponse)
async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False):
tag_value = TagUtil.parse_tag(tag)
schedule_api = request.app.state.api.schedule
channels = await schedule_api.get_channels()
responses = [
await schedule_api.get_channel_schedule(channel, tag_value.date)
for channel in channels
]
results = await schedule_api.get_all_schedules(tag_value.date)
return templates.TemplateResponse(
request=request,
name="schedule.html",
context={
"version": __version__,
"tag_util": TagUtil,
"datetime": datetime,
"channels": channels,
"response": responses[0],
"responses": responses,
"response": results[0],
"responses": results,
"live": live,
},
)
@app.get("/schedule/{channel}", response_class=RedirectResponse)
async def get_channel_default(channel: str):
@router.get("/schedule/{channel}", response_class=RedirectResponse)
async def get_channel_default(channel: str):
return RedirectResponse(f"{channel}/tag/today")
@app.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse)
async def get_channel_tag(request: AppRequest, channel: str, tag: str):
@router.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse)
async def get_channel_tag(request: AppRequest, channel: str, tag: str):
tag_value = TagUtil.parse_tag(tag)
schedule_api = request.app.state.api.schedule
if tag_value.type == TagType.DAY:
@@ -78,7 +70,6 @@ def mount(app: FastAPI):
request=request,
name="channel.html",
context={
"version": __version__,
"tag_util": TagUtil,
"datetime": datetime,
"response": response,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,18 +0,0 @@
tr {
border-bottom: 1px solid lightgray;
}
td {
text-align: left;
}
tr.live {
font-weight: bold;
}
.title {
margin-top: 0.5rem;
font-style: italic;
font-weight: bold;
font-size: 120%;
}

View File

@@ -1,28 +1,24 @@
{% extends "base.html" %}
{% block title %}
Программа | {{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}
{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/schedule/style.css?v={{version}}">
<link rel="icon"
href="/static/schedule/favicon.ico?v={{version}}"
type="image/x-icon">
{{_("TV program")}} | {{response.channel.name}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}
{% endblock %}
{% block header %}
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
<a class="button"
href="../..">⬆️</a>
<span>{{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
<a class="button"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
<app-link href="/schedule"
icon="tv">{{_("TV program")}}</app-link>
{% endblock %}
{% block content %}
<table>
<h4>
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
<a class="button"
href="../..">⬆️</a>
<span>{{response.channel.name}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}</span>
<a class="button"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
</h4>
<table class="table">
<thead>
<tr>
<td></td>
@@ -32,7 +28,7 @@
</thead>
<tbody>
{% for value in response.values %}
<tr class="{{'live' if value.live else ''}}">
<tr class="{{'table-success' if value.live else ''}}">
<td>{{value.start.strftime('%H:%M')}}</td>
<td>{{(value.end - value.start) | timedelta_format}}</td>
<td>{{value.label}}</td>

View File

@@ -1,21 +1,18 @@
{% extends "base.html" %}
{% block title %}ТВ{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/schedule/style.css?v={{version}}">
<link rel="icon"
href="/static/schedule/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
{% block header %}Телепрограмма{% endblock %}
{% block title %}{{_("TV program")}}{% endblock %}
{% block content %}
<ul class="app-list">
<li style="margin-bottom: 0.25rem; font-weight: bold;"><a href="schedule/tag/today">Все</a></li>
<h1>{{_("TV program")}}</h1>
<div class="list-group mb-5">
<a href="schedule/tag/today"
class="list-group-item list-group-item-action px-4">
<span class="fw-bold">Все</span>
</a>
{% for channel in channels %}
<li><a href="schedule/{{channel.id}}">{{channel.name}}</a></li>
<a href="schedule/{{channel.id}}"
class="list-group-item list-group-item-action px-4">
<span class="text-primary">{{channel.name}}</span>
</a>
{% endfor %}
</ul>
</div>
{% endblock %}

View File

@@ -1,29 +1,25 @@
{% extends "base.html" %}
{% block title %}
{{'Прямые трансляции' if live else 'Программа'}} | {{response.date.strftime('%a, %d %B %Y')}}
{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/schedule/style.css?v={{version}}">
<link rel="icon"
href="/static/schedule/favicon.ico?v={{version}}"
type="image/x-icon">
{{_("Live broadcasts") if live else _("TV program")}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}
{% endblock %}
{% block header %}
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
<a class="button"
href="..">⬆️</a>
<span>{{'Прямые трансляции' if live else 'Программа'}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
<a class="button"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
<app-link href="/schedule"
icon="tv">{{_("TV program")}}</app-link>
{% endblock %}
{% block content %}
<h4>
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
<a class="button"
href="..">⬆️</a>
<span>{{_("Live broadcasts") if live else _("TV program")}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}</span>
<a class="button"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
</h4>
<div>
<table class="{{'live' if live else ''}}">
<table class="table">
<thead>
<tr>
<td></td>
@@ -35,15 +31,13 @@
{% for response in responses %}
{% set values = (response.values|selectattr('live') if live else response.values)|list %}
{% if values|length > 0 %}
<tr>
<tr class="table-primary fs-4">
<td colspan="3">
<div class="title">{{response.channel.name}}</div>
<div>{{response.channel.name}}</div>
</td>
<td></td>
<td></td>
</tr>
{% for value in values %}
<tr class="{{'live' if not live and value.live else ''}}">
<tr class="{{'table-success' if not live and value.live else ''}}">
<td>{{value.start.strftime('%H:%M')}}</td>
<td>{{(value.end - value.start) | timedelta_format}}</td>
<td>{{value.label}}</td>

View File

@@ -0,0 +1,31 @@
import gettext
from contextvars import ContextVar
from fastapi import Cookie, Header, Request
from gallery.util import root_path
_translation: ContextVar[gettext.GNUTranslations | gettext.NullTranslations] = ContextVar("translation")
async def set_language(
request: Request,
accept_language: str = Header("en"),
language: str | None = Cookie(None),
):
# Simplify the header (e.g., "en-US,en;q=0.9" -> "en")
lang = language or accept_language.split(",")[0].split("-")[0]
try:
t = gettext.translation("messages", localedir=root_path / "locales", languages=[lang])
except FileNotFoundError:
t = gettext.NullTranslations()
token = _translation.set(t)
request.state.language = lang
yield lang
_translation.reset(token)
def _(message: str) -> str:
return _translation.get().gettext(message)

View File

@@ -1,87 +1,72 @@
import datetime
from pathlib import Path
from fastapi import FastAPI
from fastapi import APIRouter
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from gallery.easel.core import AppRequest
from gallery.sketch.weather.catalog import BUNDLE
from gallery.sketch.weather.mock import WEATHER_MOCK_DATA
from gallery.sketch.weather.model import WeatherResponse
from gallery.version import __version__
from ..common.util import TagType, TagUtil
from ..common.utils.tag import TagType, TagUtil
from ..common.utils.template import build_templates
from .filters import cloudness_icon, wind_direction_icon
templates = build_templates(
Path(__file__).parent / "templates",
{
"wind_direction_icon": wind_direction_icon,
"cloudness_icon": cloudness_icon,
},
)
def mount(app: FastAPI):
base_dir = Path(__file__).parent
app.mount("/static/weather", StaticFiles(directory=base_dir / "static"))
templates = Jinja2Templates(
directory=[
base_dir.parent / "common/templates",
base_dir / "templates",
]
)
templates.env.filters["wind_direction_icon"] = wind_direction_icon
templates.env.filters["cloudness_icon"] = cloudness_icon
def build_weather_response(request: AppRequest, response: WeatherResponse):
def build_weather_response(request: AppRequest, response: WeatherResponse):
return templates.TemplateResponse(
request=request,
name="weather.html",
context={
"version": __version__,
"tag_util": TagUtil,
"datetime": datetime,
"response": response,
},
)
@app.get("/weather", response_class=HTMLResponse)
async def get_weather_list(request: AppRequest):
router = APIRouter()
@router.get("/weather", response_class=HTMLResponse)
async def get_weather_index(request: AppRequest, query: str | None = None):
weather_api = request.app.state.api.weather
locations = await weather_api.get_locations()
locations_data = BUNDLE.select_items(locations)
locations = (await weather_api.find_locations(query)) if query else []
return templates.TemplateResponse(
request=request,
name="index.html",
context={
"version": __version__,
"locations": locations_data,
"locations": locations,
},
)
@app.get("/weather/{location}", response_class=RedirectResponse)
async def get_weather_default(location: str):
@router.get("/weather/{location}", response_class=RedirectResponse)
async def get_weather_default(location: str):
return RedirectResponse(f"{location}/tag/today")
@app.get("/weather/{location}/day/mock", response_class=HTMLResponse)
async def get_weather_day_mock(request: AppRequest):
response = WEATHER_MOCK_DATA.get_response("day")
return build_weather_response(request, response)
@app.get("/weather/{location}/days/mock", response_class=HTMLResponse)
async def get_weather_days_mock(request: AppRequest):
response = WEATHER_MOCK_DATA.get_response("days")
return build_weather_response(request, response)
@app.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
async def get_weather_day(request: AppRequest, location: str, date: datetime.date):
@router.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
async def get_weather_day(request: AppRequest, location: str, date: datetime.date):
weather_api = request.app.state.api.weather
response = await weather_api.get_day(location, date)
return build_weather_response(request, response)
@app.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
async def get_weather_days(request: AppRequest, location: str, days: int):
@router.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
async def get_weather_days(request: AppRequest, location: str, days: int):
weather_api = request.app.state.api.weather
response = await weather_api.get_days(location, days)
return build_weather_response(request, response)
@app.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse)
async def get_weather_tag(request: AppRequest, location: str, tag: str):
@router.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse)
async def get_weather_tag(request: AppRequest, location: str, tag: str):
tag_value = TagUtil.parse_tag(tag)
weather_api = request.app.state.api.weather
if tag_value.type == TagType.DAY:

View File

@@ -1,3 +1,5 @@
import datetime
from gallery.sketch.weather.model import (
Cloudness,
Precipitation,
@@ -9,40 +11,45 @@ from gallery.sketch.weather.model import (
def wind_direction_icon(wind_direction_deg: float) -> str:
wind_direction = WindDirectionDeg(wind_direction_deg).direction
return {
WindDirection.N: "⬇️",
WindDirection.NE: "↙️",
WindDirection.E: "⬅️",
WindDirection.SE: "↖️",
WindDirection.S: "⬆️",
WindDirection.SW: "↗️",
WindDirection.W: "➡️",
WindDirection.NW: "↘️",
WindDirection.CALM: "",
}.get(wind_direction, wind_direction)
if wind_direction == WindDirection.CALM:
return "wind-calm"
else:
return f"wind-from-{wind_direction.name.lower()}"
def cloudness_icon(sky: Sky) -> list[str]:
def cloudness_icon(sky: Sky, date: datetime.datetime, period: str) -> list[str]:
day = (3 < date.hour < 22) if period == "day" else True
day_prefix = "day" if day else "night-alt"
main_icon = ""
if sky.thunder:
if sky.cloudness == Cloudness.CLEAR:
main_icon = "🌩️"
if sky.precipitation == Precipitation.NO:
main_icon = ""
else:
main_icon = "⛈️"
main_icon = {
Precipitation.NO: "lightning",
Precipitation.SMALL_RAIN: "storm-showers",
Precipitation.RAIN: "thunderstorm",
Precipitation.HEAVY_RAIN: "thunderstorm",
Precipitation.SHOWER: "thunderstorm",
Precipitation.SNOW: "storm-showers",
Precipitation.HEAVY_SNOW: "storm-showers",
}[sky.precipitation]
if sky.cloudness == Cloudness.PARTLY_CLOUDY:
main_icon = f"{day_prefix}-{main_icon}"
elif sky.precipitation == Precipitation.NO:
main_icon = {
Cloudness.CLEAR: "☀️",
Cloudness.PARTLY_CLOUDY: "🌤️",
Cloudness.CLOUDY: "",
Cloudness.MAINLY_CLOUDY: "☁️",
Cloudness.CLEAR: "day-sunny" if day else "night-clear",
Cloudness.PARTLY_CLOUDY: f"{day_prefix}-cloudy",
Cloudness.CLOUDY: "cloud",
Cloudness.MAINLY_CLOUDY: "cloudy",
}[sky.cloudness]
elif sky.precipitation in [Precipitation.SNOW, Precipitation.HEAVY_SNOW]:
main_icon = "🌨️"
else:
main_icon = "🌧️"
main_icon = {
Precipitation.SMALL_RAIN: "showers",
Precipitation.RAIN: "rain-mix",
Precipitation.HEAVY_RAIN: "rain",
Precipitation.SHOWER: "rain",
Precipitation.SNOW: "snow",
Precipitation.HEAVY_SNOW: "snow",
}[sky.precipitation]
if sky.cloudness == Cloudness.PARTLY_CLOUDY:
main_icon = f"{day_prefix}-{main_icon}"
icons = [main_icon]
if sky.fog:
icons.append("🌫️")
return icons

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,72 +0,0 @@
.header {
font-size: 1rem;
text-align: left;
padding-top: 0.25rem;
}
.date {
font-size: 1.5rem;
background: rgba(0, 0, 0, 0.1);
}
.date.now {
background: rgba(0, 128, 255, 0.2);
}
.date .value a {
all: unset;
cursor: pointer;
}
.cloudness {
vertical-align: top;
}
.cloudness .icon {
font-size: 1rem;
}
.cloudness .icon:first-child {
font-size: 2rem;
}
.temperature {
padding: 0;
}
.temperature .value {
padding: 0.1rem 0.4rem;
}
.temperature .value.positive {
color: orangered;
}
.temperature .value.negative {
color: blue;
}
.wind .direction {
font-size: 1rem;
}
.wind .gust {
font-size: 1rem;
}
.precipitation .value {
color: blue;
}
.pressure {
padding: 0;
}
.pressure .value {
padding: 0.1rem 0.4rem;
color: blueviolet;
}
.humidity .value {
color: blue;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -1,20 +1,74 @@
{% extends "base.html" %}
{% block title %}Погода{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/weather/style.css?v={{version}}">
<link rel="icon"
href="/static/weather/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
{% block header %}Погода{% endblock %}
{% block title %}{{_("Weather")}}{% endblock %}
{% block content %}
<ul class="app-list">
<h1>{{_("Weather")}}</h1>
<form action=""
method="get"
class="mb-4">
<div class="input-group mb-3">
<input type="text"
class="form-control"
id="query"
name="query"
placeholder="{{_('Enter the city name')}}">
<button class="btn btn-primary"
type="submit">{{_("Search")}}</button>
</div>
</form>
<ul id="locations"
class="list-group mb-5">
{% for location in locations %}
<li><a href="weather/{{location.id}}">{{location.name}}</a></li>
<a href="weather/{{location.id}}"
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 %}
</ul>
<script>
(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();">&#x2715;</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 searchQuery = params.get('query');
if (searchQuery) {
document.querySelector('#query').value = searchQuery;
} else {
document.loadLocations();
}
})();
</script>
{% endblock %}

View File

@@ -1,32 +1,37 @@
{% extends "base.html" %}
{% block title %}Погода | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/weather/style.css?v={{version}}">
<link rel="icon"
href="/static/weather/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
{% block title %}{{_("Weather")}} | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %}
{% block header %}
{% if response.period == 'day' %}
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
<a class="button"
href="../tag/days-10">⬆️</a>
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
<a class="button"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
{% endif %}
{% if response.period == 'days' %}
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
{% endif %}
<app-link href="/weather"
icon="brightness-high">{{_("Weather")}}</app-link>
{% endblock %}
{% block content %}
<div>
<table style="margin: auto;">
<h4>
{% if response.period == 'day' %}
<a class="icon-link {{'disabled' if response.date == datetime.date.today() else ''}}"
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">
<i class="bi bi-arrow-left-square"></i>
</a>
<a class="icon-link"
href="../tag/days-10">
<i class="bi bi-arrow-up-square"></i>
</a>
<span>{{response.location}} | {{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>
{% endif %}
{% if response.period == 'days' %}
<span>{{response.location}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
<span>- {{format_date(response.date + datetime.timedelta(days=(response.values | length - 1)), DATE_FORMAT,
locale=request.state.language)}}</span>
{% endif %}
</h4>
<div class="table-responsive">
<table class="table table-weather table-borderless table-compact text-center w-auto"
style="font-size: 130%;">
<tbody>
<!-- date -->
<tr>
@@ -39,9 +44,9 @@
{% endif %}
{% if response.period == 'days' %}
<td class="date {{'now' if value.date.date() == datetime.date.today() else ''}}">
<span class="value">
<span class="value {{'text-danger' if value.date.weekday() in [5,6] else ''}}">
<a href="../tag/{{tag_util.create_tag('day', value.date.date())}}">
{{value.date.strftime('%a %d')}}
{{format_date(value.date, 'E d', locale=request.state.language)}}
</a>
</span>
</td>
@@ -52,14 +57,16 @@
<tr>
<td colspan="{{response.values | length}}"
class="header">
Облачность
{{_("Cloudiness")}}
</td>
</tr>
<tr>
{% for value in response.values %}
<td class="cloudness">
{% for icon in value.sky | cloudness_icon %}
<div class="icon">{{icon}}</div>
<td class="cloudness"
data-bs-toggle="tooltip"
data-bs-title="{{ value.sky }}">
{% for icon in value.sky | cloudness_icon(value.date, response.period) %}
<div class="wi wi-{{icon}} wi-xl text-primary"></div>
{% endfor %}
</td>
{% endfor %}
@@ -68,7 +75,7 @@
<tr>
<td colspan="{{response.values | length}}"
class="header">
Температура, °C
{{_("Temperature, °C")}}
</td>
</tr>
<tr>
@@ -87,13 +94,15 @@
<tr>
<td colspan="{{response.values | length}}"
class="header">
Направление ветра
{{_("Wind direction")}}
</td>
</tr>
<tr>
{% for value in response.values %}
<td class="wind">
<span class="icon">{{value.wind_direction | wind_direction_icon}}</span>
<td class="wind"
data-bs-toggle="tooltip"
data-bs-title="{{ value.wind_direction }}">
<div class="wi wi-wind-deg wi-{{value.wind_direction | wind_direction_icon}} wi-l text-primary"></div>
</td>
{% endfor %}
</tr>
@@ -101,7 +110,7 @@
<tr>
<td colspan="{{response.values | length}}"
class="header">
Скорость ветра, м/с
{{_("Wind speed, m/s")}}
</td>
</tr>
<tr>
@@ -121,7 +130,7 @@
<tr>
<td colspan="{{response.values | length}}"
class="header">
Осадки, мм
{{_("Precipitation, mm")}}
</td>
</tr>
<tr>
@@ -136,7 +145,7 @@
<tr>
<td colspan="{{response.values | length}}"
class="header">
Давление, мм рт. ст.
{{_("Pressure, mmHg")}}
</td>
</tr>
<tr>
@@ -154,7 +163,7 @@
<tr>
<td colspan="{{response.values | length}}"
class="header">
Влажность, %
{{_("Humidity, %")}}
</td>
</tr>
<tr>

View File

@@ -7,12 +7,14 @@ from gallery.easel import build_app
from gallery.painting.gismeteo.api import GismeteoApi
from gallery.painting.matchtv.api import MatchTvApi
from gallery.painting.openweather.api import OpenWeatherApi
from gallery.painting.yandextv.api import YandexTvApi
from gallery.sketch.bundle import ApiBundle
from gallery.sketch.schedule.cached import CachedScheduleApi
from gallery.sketch.weather.cached import CachedWeatherApi
api = ApiBundle(
[
CachedScheduleApi(YandexTvApi()),
CachedScheduleApi(MatchTvApi()),
CachedWeatherApi(GismeteoApi()),
CachedWeatherApi(OpenWeatherApi()),
@@ -24,8 +26,8 @@ app = build_app(api)
def run():
uvicorn.run(
"gallery.main:app",
host="0.0.0.0",
port=8000,
host=environ.get("GALLERY_HOST", "0.0.0.0"),
port=int(environ.get("GALLERY_PORT", 8000)),
log_config=str(Path(__file__).parent / "logging.yaml"),
reload="DEBUG" in environ,
)

View File

@@ -1,13 +1,14 @@
import datetime
import json
import logging
from typing import Any, Dict, List
from typing import Any
from bs4 import BeautifulSoup
from fastapi import HTTPException, status
from gallery.sketch.source import ApiSource
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.catalog import LocationId
from gallery.sketch.weather.model import WeatherResponse, WeatherValue
from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
from . import datehelp
from .parser import DAYS_PARSER, LOCATION_PARSER, ONE_DAY_PARSER, ROW_PARSERS
@@ -34,7 +35,7 @@ class GismeteoApi(WeatherApi):
)
def _parse_oneday(self, date: datetime.date, data: str) -> WeatherResponse:
result: List[Dict[str, Any]] = []
result: list[dict[str, Any]] = []
soup = BeautifulSoup(data, features="html.parser")
location = LOCATION_PARSER.parse_location(data)
widget = ONE_DAY_PARSER.parse_widget(soup)
@@ -52,7 +53,7 @@ class GismeteoApi(WeatherApi):
)
def _parse_manydays(self, data: str) -> WeatherResponse:
result: List[Dict[str, Any]] = []
result: list[dict[str, Any]] = []
soup = BeautifulSoup(data, features="html.parser")
location = LOCATION_PARSER.parse_location(data)
widget = DAYS_PARSER.parse_widget(soup)
@@ -69,13 +70,41 @@ class GismeteoApi(WeatherApi):
values=values,
)
async def get_locations(self) -> list[str]:
return [
LocationId.OREL,
LocationId.ZMIYEVKA,
]
async def find_locations(self, query: str) -> list[Location]:
geo = "ru"
latitude = 52.968498
longitude = 36.0695
data = json.loads(
await self.SOURCE.request(
f"mq/city/q/?q={query}&geo={geo}&latitude={latitude}&longitude={longitude}&limit=10"
)
)
result = []
for item in data["data"]:
result.append(
Location(
id=f"{item['slug']}-{item['id']}",
name=item["translations"]["kk"]["city"]["name"],
lat=item["coordinates"]["latitude"],
lon=item["coordinates"]["longitude"],
country=item["translations"]["kk"]["country"]["name"],
country_code=item["country"]["code"].lower(),
district=item["translations"]["kk"]["district"]["name"],
subdistrict=(
item["translations"]["kk"]["subdistrict"]["name"]
if "subdistrict" in item["translations"]["kk"]
else ""
),
)
)
return result
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
max_date = datetime.date.today() + datetime.timedelta(days=9)
if date > max_date:
raise HTTPException(
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)}")
return self._parse_oneday(date, data)

View File

@@ -4,6 +4,7 @@ from bs4 import Tag
T = TypeVar("T")
class WidgetParser:
def parse_widget(self, tag: Tag) -> Tag:
raise NotImplementedError

View File

@@ -1,5 +0,0 @@
from pathlib import Path
from gallery.sketch.mock import MockData
GISMETEO_MOCK_DATA = MockData(Path(__file__).parent / "data")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,5 @@
import datetime
import logging
import re
from typing import Iterable
@@ -15,6 +16,8 @@ from gallery.sketch.weather.model import (
from .core import BaseWidgetParser, RowParser
logger = logging.getLogger("gismeteo")
ONE_DAY_PARSER = BaseWidgetParser(".widget.widget-oneday .widget-items")
DAYS_PARSER = BaseWidgetParser(".widget.widget-days .widget-items")
@@ -36,9 +39,7 @@ class DateParser(RowParser[datetime.datetime]):
KEY = "date"
def parse_row(self, tag: Tag) -> Iterable[datetime.datetime]:
datetime_date_tag = tag.select_one(
".widget-row.widget-row-datetime-date > .row-item"
)
datetime_date_tag = tag.select_one(".widget-row.widget-row-datetime-date > .row-item")
if datetime_date_tag:
date_str = datetime_date_tag.find(text=True, recursive=False).text
date = dateparser.parse(date_str, languages=["ru"])
@@ -59,6 +60,7 @@ class SkyParser(RowParser[Sky]):
CLOUDNESS_MAP: dict[str, Cloudness] = {
"ясно": Cloudness.CLEAR,
"безоблачно": Cloudness.CLEAR,
"малооблачно": Cloudness.PARTLY_CLOUDY,
"облачно": Cloudness.CLOUDY,
"пасмурно": Cloudness.MAINLY_CLOUDY,
@@ -66,8 +68,11 @@ class SkyParser(RowParser[Sky]):
PRECIPITATION_MAP: dict[str, Precipitation] = {
"без осадков": Precipitation.NO,
"небольшой дождь": Precipitation.SMALL_RAIN, # TODO: remove it?
"небольшой дождь": Precipitation.SMALL_RAIN,
"сильный дождь": Precipitation.HEAVY_RAIN, # TODO: remove it?
"сильный дождь": Precipitation.HEAVY_RAIN,
"ливневый дождь": Precipitation.SHOWER,
"дождь": Precipitation.RAIN,
"ливень": Precipitation.SHOWER,
"снег": Precipitation.SNOW,
@@ -80,22 +85,35 @@ class SkyParser(RowParser[Sky]):
"небольшой мокрый снег": Precipitation.SNOW,
}
THUNDER = "гроза"
FOG = "дымка"
def parse_row(self, tag: Tag) -> Iterable[Sky]:
for item in tag.select(".widget-row[data-row=icon-tooltip] > .row-item"):
sky_str = item.attrs["data-tooltip"]
values = {item.strip().lower() for item in sky_str.split(",")}
cloudness = Cloudness.CLEAR
precipitation = Precipitation.NO
thunder = "гроза" in values
fog = "дымка" in values
thunder = False
fog = False
if self.THUNDER in values:
thunder = True
values.remove(self.THUNDER)
if self.FOG in values:
fog = True
values.remove(self.FOG)
for k, v in self.CLOUDNESS_MAP.items():
if k in values:
cloudness = v
values.remove(k)
break
for k, v in self.PRECIPITATION_MAP.items():
if k in values:
precipitation = v
values.remove(k)
break
if values:
logger.warning("unknown sky values: %s:", values)
yield Sky(
cloudness=cloudness,
precipitation=precipitation,
@@ -108,21 +126,15 @@ class TemperatureParser(RowParser[list[int]]):
KEY = "temperature"
def parse_row(self, tag: Tag) -> Iterable[list[int]]:
for item in tag.select(
".widget-row-chart[data-row=temperature-air] > .chart > .values > .value"
):
yield [
int(value.attrs["value"]) for value in item.select("temperature-value")
]
for item in tag.select(".widget-row-chart[data-row=temperature-air] > .chart > .values > .value"):
yield [int(value.attrs["value"]) for value in item.select("temperature-value")]
class WindSpeedParser(RowParser[int]):
KEY = "wind_speed"
def parse_row(self, tag: Tag) -> Iterable[int]:
for item in tag.select(
".widget-row-wind > .row-item > .wind-speed > speed-value"
):
for item in tag.select(".widget-row-wind > .row-item > .wind-speed > speed-value"):
yield int(item.attrs["value"])
@@ -152,22 +164,16 @@ class WindDirectionParser(RowParser[WindDirection]):
}
def parse_row(self, tag: Tag) -> Iterable[float]:
for item in tag.select(
".widget-row-wind > .row-item > .wind-speed > .wind-direction"
):
for item in tag.select(".widget-row-wind > .row-item > .wind-speed > .wind-direction"):
wind_direction_str = item.text.lower().strip()
yield WindDirectionDeg.from_direction(
self.WIND_DIRECTION_MAP[wind_direction_str]
).value
yield WindDirectionDeg.from_direction(self.WIND_DIRECTION_MAP[wind_direction_str]).value
class PrecipitationParser(RowParser[float]):
KEY = "precipitation"
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 > .item-unit"):
yield float(item.text.replace(",", "."))
@@ -175,9 +181,7 @@ class PressureParser(RowParser[list[int]]):
KEY = "pressure"
def parse_row(self, tag: Tag) -> Iterable[list[int]]:
for item in tag.select(
".widget-row-chart[data-row=pressure] > .chart > .values > .value"
):
for item in tag.select(".widget-row-chart[data-row=pressure] > .chart > .values > .value"):
yield [int(value.attrs["value"]) for value in item.select("pressure-value")]

View File

@@ -4,8 +4,7 @@ import logging
from bs4 import BeautifulSoup
from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.schedule.catalog import ChannelId
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
from gallery.sketch.schedule.model import Channel, ChannelId, Schedule, ScheduleValue
from gallery.sketch.source import ApiSource
logger = logging.getLogger("matchtv")
@@ -15,7 +14,7 @@ class MatchTvApi(ScheduleApi):
PROVIDER = "matchtv"
SOURCE = ApiSource("https://matchtv.ru")
async def get_channels(self) -> list[str]:
async def get_channels(self) -> list[ChannelId]:
return [
ChannelId.MATCH_TV,
ChannelId.MATCH_IGRA,
@@ -26,21 +25,13 @@ class MatchTvApi(ScheduleApi):
ChannelId.MATCH_STRANA,
]
async def get_channel_schedule(
self, channel_id: str, date: datetime.date
) -> Schedule:
async def get_channel_schedule(self, channel_id: ChannelId, date: datetime.date) -> Schedule:
endpoint = f"tvguide/{channel_id}?date={date:%Y%m%d}"
data = await self.SOURCE.request(endpoint)
soup = BeautifulSoup(data, features="html.parser")
values = []
channel_name = (
soup.select_one(".p-tv-guide-header__title")
.text.replace("Телепрограмма ", "")
.strip()
)
current_day = datetime.datetime.combine(
date.today(), datetime.datetime.min.time()
)
channel_name = soup.select_one(".p-tv-guide-header__title").text.replace("Телепрограмма ", "").strip()
current_day = datetime.datetime.combine(date.today(), datetime.datetime.min.time())
end = current_day + datetime.timedelta(days=1, hours=6)
prev_value: ScheduleValue | None = None
for item in soup.select(
@@ -59,6 +50,4 @@ class MatchTvApi(ScheduleApi):
if prev_value is not None:
prev_value.end = item_date
prev_value = value
return Schedule(
channel=Channel(id=channel_id, name=channel_name), date=date, values=values
)
return Schedule(channel=Channel(id=channel_id, name=channel_name), date=date, values=values)

View File

@@ -1,5 +0,0 @@
from pathlib import Path
from gallery.sketch.mock import MockData
MATCHTV_MOCK_DATA = MockData(Path(__file__).parent / "data")

File diff suppressed because one or more lines are too long

View File

@@ -5,8 +5,7 @@ from collections import defaultdict
from aiocache import cached
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.catalog import BUNDLE, LocationId
from gallery.sketch.weather.model import WeatherResponse, WeatherValue
from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
from gallery.sketch.weather.util import merge_weather_values
from gallery.util import TimeUnit
@@ -20,11 +19,9 @@ class OpenWeatherApi(WeatherApi):
PROVIDER = "openweather"
SOURCE = OpenWeather("517a6bccceaa1c48127f6199ec3fb7cf")
async def get_locations(self) -> list[str]:
return [
LocationId.OREL,
LocationId.ZMIYEVKA,
]
@classmethod
def _parse_location(cls, location_id: str) -> tuple[float, float]:
return tuple(map(float, location_id.split(":", maxsplit=2)))
@cached(
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast",
@@ -32,8 +29,10 @@ class OpenWeatherApi(WeatherApi):
ttl=TimeUnit.DAY,
)
async def _get_location_forecast(self, location_id: str) -> Forecast:
location = BUNDLE.get_item(location_id)
return await self.SOURCE.get_forecast(location.lat, location.lon)
return await self.SOURCE.get_forecast(*self._parse_location(location_id))
async def find_locations(self, query: str) -> list[Location]:
raise NotImplementedError
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
data: Forecast = await self._get_location_forecast(location_id)
@@ -42,9 +41,8 @@ class OpenWeatherApi(WeatherApi):
value = FORECAST_ITEM_PARSER.parse(item)
if value.date.date() == date:
values.append(value)
location = BUNDLE.get_item(location_id)
return WeatherResponse(
location=location.name,
location=location_id,
date=date,
period="day",
values=values,
@@ -57,13 +55,9 @@ class OpenWeatherApi(WeatherApi):
value = FORECAST_ITEM_PARSER.parse(item)
item_date = value.date.replace(hour=0, minute=0)
values_by_date[item_date].append(value)
values = [
merge_weather_values(date, values)
for date, values in values_by_date.items()
]
location = BUNDLE.get_item(location_id)
values = [merge_weather_values(date, values) for date, values in values_by_date.items()]
return WeatherResponse(
location=location.name,
location=location_id,
date=datetime.date.today(),
period="days",
values=list(sorted(values, key=lambda item: item.date)),

View File

@@ -1,5 +0,0 @@
from pathlib import Path
from gallery.sketch.mock import MockData
OPENWEATHER_MOCK_DATA = MockData(Path(__file__).parent / "data")

View File

@@ -75,9 +75,7 @@ class OpenWeather:
self._source = ApiSource(self.BASE_URL)
async def get_forecast(self, lat: float, lon: float) -> Forecast:
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_data = json.loads(response)
return Forecast(**response_data)

View File

@@ -24,11 +24,7 @@ class ForecastItemParser:
def parse(self, item: ForecastItem) -> WeatherValue:
item_date = datetime.datetime.fromtimestamp(item.dt, datetime.UTC)
item_date = (
item_date.replace(tzinfo=datetime.timezone.utc)
.astimezone(tz=None)
.replace(tzinfo=None)
)
item_date = item_date.replace(tzinfo=datetime.timezone.utc).astimezone(tz=None).replace(tzinfo=None)
value = build_weather_value(item_date)
# TODO parse temperature interval flag
value.temperature = [round(item.main.temp)]
@@ -38,12 +34,8 @@ class ForecastItemParser:
value.wind_speed = round(item.wind.speed)
value.wind_gust = round(item.wind.gust)
value.wind_direction = item.wind.deg
value.sky.cloudness = self.CLOUDNESS_MAP.get(
item.weather[0].description, Cloudness.CLEAR
)
value.sky.precipitation = self.PRECIPITATION_MAP.get(
item.weather[0].description, Precipitation.NO
)
value.sky.cloudness = self.CLOUDNESS_MAP.get(item.weather[0].description, Cloudness.CLEAR)
value.sky.precipitation = self.PRECIPITATION_MAP.get(item.weather[0].description, Precipitation.NO)
if item.rain:
value.precipitation = round(item.rain.interval_3h, 1)
return value

View File

View File

@@ -0,0 +1,88 @@
import datetime
import logging
from bs4 import BeautifulSoup
from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.schedule.model import Channel, ChannelId, Schedule, ScheduleValue
from gallery.sketch.source import ApiSource
logger = logging.getLogger("matchtv")
CHANNELS_MAP: dict[ChannelId, str] = {
ChannelId.MATCH_TV: "match-tv-49",
ChannelId.MATCH_IGRA: "match-igra-1174",
ChannelId.MATCH_ARENA: "match-arena-1173",
ChannelId.MATCH_FUTBOL_1: "match-futbol-1-646",
ChannelId.MATCH_FUTBOL_2: "match-futbol-2-593",
ChannelId.MATCH_FUTBOL_3: "match-futbol-3-797",
ChannelId.MATCH_STRANA: "match-strana-1356",
ChannelId.MATCH_PLANETA: "match-planeta-1177",
# ChannelId.EUROSPORT: "eurosport-677",
# ChannelId.EUROSPORT_2: "eurosport-2-720",
ChannelId.START: "start-103",
}
HEADERS: dict[str, str] = {
"Accept": (
"text/html,"
"application/xhtml+xml,"
"application/xml;q=0.9,"
"image/avif,image/webp,"
"image/apng,*/*;q=0.8,"
"application/signed-exchange;v=b3;q=0.9"
),
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "en-US,en;q=0.9",
"Connection": "keep-alive",
"Host": "tv.yandex.ru",
"sec-ch-ua": '"Chromium";v="100", " Not A;Brand";v="99"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Linux"',
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/100.0.4896.133 "
"Safari/537.36"
),
}
class YandexTvApi(ScheduleApi):
PROVIDER = "yandextv"
SOURCE = ApiSource("https://tv.yandex.ru", headers=HEADERS)
async def get_channels(self) -> list[ChannelId]:
return list(CHANNELS_MAP.keys())
async def get_channel_schedule(self, channel_id: ChannelId, date: datetime.date) -> Schedule:
endpoint = f"channel/{CHANNELS_MAP[channel_id]}?date={date:%Y-%m-%d}"
data = await self.SOURCE.request(endpoint)
soup = BeautifulSoup(data, features="html.parser")
if soup.select_one(".CheckboxCaptcha") is not None:
raise RuntimeError("Captcha")
values = []
channel_name = soup.select_one(".channel-header__text").text.strip()
current_day = datetime.datetime.combine(date.today(), datetime.datetime.min.time())
end = current_day + datetime.timedelta(days=1, hours=6)
prev_value: ScheduleValue | None = None
for item in soup.select(".channel-schedule .channel-schedule__event"):
title = item.select_one(".channel-schedule__title").text.strip()
time_str = item.select_one(".channel-schedule__time").text.strip()
hours, minutes = map(int, time_str.split(":"))
item_date = current_day.replace(hour=hours, minute=minutes)
if prev_value is not None and item_date.hour < prev_value.start.hour:
current_day += datetime.timedelta(days=1)
item_date += datetime.timedelta(days=1)
live = item.select_one(".channel-schedule__info .icon_live") is not None
value = ScheduleValue(start=item_date, end=end, label=title, live=live)
values.append(value)
if prev_value is not None:
prev_value.end = item_date
prev_value = value
return Schedule(channel=Channel(id=channel_id, name=channel_name), date=date, values=values)

View File

@@ -1,5 +1,3 @@
from typing import Type
from .api import API, Api
from .schedule.api import ScheduleApi
from .weather.api import WeatherApi
@@ -15,7 +13,7 @@ class ApiBundle(list[Api]):
return value
raise ValueError(provider)
def get_api_by_type(self, api_type: Type[API]) -> API:
def get_api_by_type(self, api_type: type[API]) -> API:
for value in self:
if isinstance(value, api_type):
return value

View File

@@ -1,13 +1,19 @@
from typing import Generic
from typing import Generic, NamedTuple
from gallery.util import TimeUnit
from .api import API, Api
class CachePreset(NamedTuple):
ttl: int = TimeUnit.HOUR
alias: str = "redis"
DEFAULT_CACHE_PRESET = CachePreset()
class CachedApi(Api, Generic[API]):
CACHE_TTL: int = TimeUnit.HOUR
CACHE_ALIAS: str = "redis"
CACHE_KEY: str
def __init__(self, api: API):

View File

@@ -1,17 +0,0 @@
import json
class MockData:
def __init__(self, data_dir) -> None:
self._data_dir = data_dir
def get_text(self, key: str) -> str:
return (self._data_dir / f"{key}").read_text()
def get_html(self, key: str) -> str:
return self.get_text(f"{key}.html")
def get_json(self, key: str) -> dict:
data = json.loads(self.get_text(f"{key}.json"))
return data

View File

@@ -1,14 +1,24 @@
import asyncio
import datetime
from ..api import Api
from .model import Schedule
from .model import ChannelId, Schedule
class ScheduleApi(Api):
async def get_channels(self) -> list[str]:
INTERVAL: float = 0.5
async def get_channels(self) -> list[ChannelId]:
raise NotImplementedError
async def get_channel_schedule(
self, channel_id: str, date: datetime.date
) -> Schedule:
async def get_channel_schedule(self, channel_id: ChannelId, date: datetime.date) -> Schedule:
raise NotImplementedError
async def get_all_schedules(self, date: datetime.date) -> list[Schedule]:
channels = await self.get_channels()
results = []
for channel in channels:
results.append(await self.get_channel_schedule(channel_id=channel, date=date))
if self.INTERVAL > 0:
await asyncio.sleep(self.INTERVAL)
return results

View File

@@ -2,10 +2,13 @@ import datetime
from aiocache import cached
from gallery.sketch.cached import CachedApi
from gallery.sketch.cached import CachedApi, CachePreset
from gallery.util import TimeUnit
from .api import ScheduleApi
from .model import Schedule
from .model import ChannelId, Schedule
CACHE_PRESET = CachePreset(ttl=TimeUnit.HOUR * 6)
class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
@@ -13,20 +16,23 @@ class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
@cached(
key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.channels",
alias=CachedApi.CACHE_ALIAS,
ttl=CachedApi.CACHE_TTL,
**CACHE_PRESET._asdict(),
)
async def get_channels(self) -> list[str]:
async def get_channels(self) -> list[ChannelId]:
return await self._api.get_channels()
@cached(
key_builder=lambda fun, self, channel_id, date: (
f"api.{self.CACHE_KEY}.{self.provider}.channel.{channel_id}.{date}"
),
alias=CachedApi.CACHE_ALIAS,
ttl=CachedApi.CACHE_TTL,
**CACHE_PRESET._asdict(),
)
async def get_channel_schedule(
self, channel_id: str, date: datetime.date
) -> Schedule:
async def get_channel_schedule(self, channel_id: ChannelId, date: datetime.date) -> Schedule:
return await self._api.get_channel_schedule(channel_id, date)
@cached(
key_builder=lambda fun, self, date: (f"api.{self.CACHE_KEY}.{self.provider}.all.{date}"),
**CACHE_PRESET._asdict(),
)
async def get_all_schedules(self, date: datetime.date) -> list[Schedule]:
return await self._api.get_all_schedules(date)

View File

@@ -1,22 +1,6 @@
from enum import Enum
from gallery.sketch.catalog import CatalogBundle
from .model import Channel
class ChannelId(str, Enum):
MATCH_TV = "matchtv"
MATCH_IGRA = "igra"
MATCH_ARENA = "arena"
MATCH_FUTBOL_1 = "futbol-1"
MATCH_FUTBOL_2 = "futbol-2"
MATCH_FUTBOL_3 = "futbol-3"
MATCH_STRANA = "strana"
def __str__(self) -> str:
return self.value
from .model import Channel, ChannelId
BUNDLE = CatalogBundle(
[
@@ -27,5 +11,11 @@ BUNDLE = CatalogBundle(
Channel(id=ChannelId.MATCH_FUTBOL_2, name="Футбол 2"),
Channel(id=ChannelId.MATCH_FUTBOL_3, name="Футбол 3"),
Channel(id=ChannelId.MATCH_STRANA, name="Матч! Страна"),
Channel(id=ChannelId.MATCH_PLANETA, name="Матч! Планета"),
Channel(id=ChannelId.MATCH_PLANETA, name="Матч! Планета"),
Channel(id=ChannelId.EUROSPORT, name="Europsort"),
Channel(id=ChannelId.EUROSPORT_2, name="Europsort 2"),
Channel(id=ChannelId.START, name="Старт!"),
Channel(id=ChannelId.TEST, name="Тест"),
]
)

View File

@@ -1,4 +1,5 @@
import datetime
from enum import StrEnum
from pydantic import BaseModel
@@ -8,8 +9,26 @@ class Model(BaseModel):
use_enum_values = True
class ChannelId(StrEnum):
MATCH_TV = "matchtv"
MATCH_IGRA = "igra"
MATCH_ARENA = "arena"
MATCH_FUTBOL_1 = "futbol-1"
MATCH_FUTBOL_2 = "futbol-2"
MATCH_FUTBOL_3 = "futbol-3"
MATCH_STRANA = "strana"
MATCH_PLANETA = "planeta"
EUROSPORT = "eurosport"
EUROSPORT_2 = "eurosport-2"
START = "start"
TEST = "test"
def __str__(self) -> str:
return self.value
class Channel(Model):
id: str
id: ChannelId
name: str

View File

@@ -7,9 +7,7 @@ logger = logging.getLogger("source")
class ApiSource:
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36"
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
)
DEFAULT_TIMEOUT = 30.0
@@ -19,18 +17,18 @@ class ApiSource:
user_agent: str = DEFAULT_USER_AGENT,
timeout: float = DEFAULT_TIMEOUT,
cookies: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
):
self._base_url = base_url
self._user_agent = user_agent
self._timeout = timeout
self._cookies = cookies
self._headers = headers
async def request(self, endpoint: str) -> str:
url = f"{self._base_url}/{endpoint}"
logger.info(url)
headers = {
"User-Agent": self._user_agent,
}
headers = {"User-Agent": self._user_agent, **(self._headers or {})}
async with aiohttp.ClientSession(
headers=headers,
cookies=self._cookies,

View File

@@ -1,12 +1,12 @@
import datetime
from ..api import Api
from .model import WeatherResponse
from .model import Location, WeatherResponse
class WeatherApi(Api):
async def get_locations(self) -> list[str]:
async def find_locations(self, query: str) -> list[Location]:
raise NotImplementedError
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:

View File

@@ -2,29 +2,29 @@ import datetime
from aiocache import cached
from gallery.sketch.cached import CachedApi
from gallery.sketch.cached import DEFAULT_CACHE_PRESET, CachedApi
from .api import WeatherApi
from .model import WeatherResponse
from .model import Location, WeatherResponse
CACHE_PRESET = DEFAULT_CACHE_PRESET
class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]):
CACHE_KEY = "weather"
@cached(
key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.locations",
alias=CachedApi.CACHE_ALIAS,
ttl=CachedApi.CACHE_TTL,
key_builder=lambda fun, self, query: f"api.{self.CACHE_KEY}.{self.provider}.locations.{query}",
**CACHE_PRESET._asdict(),
)
async def get_locations(self) -> list[str]:
return await self._api.get_locations()
async def find_locations(self, query: str) -> list[Location]:
return await self._api.find_locations(query)
@cached(
key_builder=lambda fun, self, location_id, date: (
f"api.{self.CACHE_KEY}.{self.provider}.day.{location_id}.{date}"
),
alias=CachedApi.CACHE_ALIAS,
ttl=CachedApi.CACHE_TTL,
**CACHE_PRESET._asdict(),
)
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
return await self._api.get_day(location_id, date)
@@ -33,8 +33,7 @@ class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]):
key_builder=lambda fun, self, location_id, date: (
f"api.{self.CACHE_KEY}.{self.provider}.day.{location_id}.{date}"
),
alias=CachedApi.CACHE_ALIAS,
ttl=CachedApi.CACHE_TTL,
**CACHE_PRESET._asdict(),
)
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
return await self._api.get_days(location_id, days)

View File

@@ -1,31 +0,0 @@
from enum import Enum
from gallery.sketch.catalog import CatalogBundle
from .model import Location
class LocationId(str, Enum):
OREL = "orel-4432"
ZMIYEVKA = "zmiyevka-184640"
def __str__(self) -> str:
return self.value
BUNDLE = CatalogBundle(
[
Location(
id=LocationId.OREL,
name="Орёл",
lat=52.9687747,
lon=36.0694937,
),
Location(
id=LocationId.ZMIYEVKA,
name="Змиёвка",
lat=52.672192,
lon=36.380112,
),
]
)

View File

@@ -1,12 +0,0 @@
from pathlib import Path
from gallery.sketch.mock import MockData
from gallery.sketch.weather.model import WeatherResponse
class WeatherMockData(MockData):
def get_response(self, key: str) -> WeatherResponse:
return WeatherResponse(**self.get_json(key))
WEATHER_MOCK_DATA = WeatherMockData(Path(__file__).parent / "data")

View File

@@ -1 +0,0 @@
{"location":"Орел","date":"2024-07-29","period":"day","values":[{"date":"2024-07-29T00:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[20],"wind_speed":1,"wind_gust":1,"wind_direction":"SW","precipitation":0.0,"pressure":[744],"humidity":85},{"date":"2024-07-29T03:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[18],"wind_speed":1,"wind_gust":1,"wind_direction":"W","precipitation":0.6,"pressure":[742],"humidity":96},{"date":"2024-07-29T06:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"rain","thunder":false,"fog":false},"temperature":[19],"wind_speed":1,"wind_gust":2,"wind_direction":"S","precipitation":4.9,"pressure":[741],"humidity":95},{"date":"2024-07-29T09:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"rain","thunder":false,"fog":false},"temperature":[19],"wind_speed":3,"wind_gust":7,"wind_direction":"S","precipitation":3.8,"pressure":[740],"humidity":83},{"date":"2024-07-29T12:00:00","sky":{"cloudness":"clear","precipitation":"no","thunder":false,"fog":false},"temperature":[21],"wind_speed":4,"wind_gust":11,"wind_direction":"W","precipitation":0.0,"pressure":[740],"humidity":54},{"date":"2024-07-29T15:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[21],"wind_speed":4,"wind_gust":10,"wind_direction":"SW","precipitation":0.0,"pressure":[738],"humidity":48},{"date":"2024-07-29T18:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[19],"wind_speed":3,"wind_gust":10,"wind_direction":"SW","precipitation":0.0,"pressure":[737],"humidity":63},{"date":"2024-07-29T21:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[17],"wind_speed":3,"wind_gust":7,"wind_direction":"SW","precipitation":0.0,"pressure":[737],"humidity":77}]}

View File

@@ -1 +0,0 @@
{"location":"Орел","date":"2024-07-29","period":"days","values":[{"date":"2024-07-29T00:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"rain","thunder":false,"fog":false},"temperature":[21,17],"wind_speed":4,"wind_gust":11,"wind_direction":"W","precipitation":9.3,"pressure":[744,737],"humidity":96},{"date":"2024-07-30T00:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"rain","thunder":true,"fog":false},"temperature":[19,14],"wind_speed":2,"wind_gust":7,"wind_direction":"N","precipitation":11.0,"pressure":[737,733],"humidity":100},{"date":"2024-07-31T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[22,14],"wind_speed":3,"wind_gust":10,"wind_direction":"NW","precipitation":1.8,"pressure":[741,738],"humidity":99},{"date":"2024-07-01T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[24,14],"wind_speed":3,"wind_gust":10,"wind_direction":"W","precipitation":0.1,"pressure":[741,740],"humidity":97},{"date":"2024-07-02T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[24,17],"wind_speed":2,"wind_gust":8,"wind_direction":"W","precipitation":0.2,"pressure":[740],"humidity":84},{"date":"2024-07-03T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[25,14],"wind_speed":1,"wind_gust":4,"wind_direction":"N","precipitation":0.0,"pressure":[740,739],"humidity":99},{"date":"2024-07-04T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[25,14],"wind_speed":3,"wind_gust":6,"wind_direction":"N","precipitation":0.0,"pressure":[743,740],"humidity":92},{"date":"2024-07-05T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":true,"fog":false},"temperature":[25,15],"wind_speed":3,"wind_gust":7,"wind_direction":"NW","precipitation":2.1,"pressure":[744,743],"humidity":98},{"date":"2024-07-06T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[24,14],"wind_speed":3,"wind_gust":5,"wind_direction":"NW","precipitation":0.3,"pressure":[745,744],"humidity":98},{"date":"2024-07-07T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[26,14],"wind_speed":2,"wind_gust":5,"wind_direction":"NW","precipitation":0.2,"pressure":[747,745],"humidity":95}]}

View File

@@ -14,6 +14,10 @@ class Location(Model):
name: str
lat: float
lon: float
country: str
country_code: str
district: str
subdistrict: str
class Cloudness(str, Enum):
@@ -63,7 +67,9 @@ class WindDirectionDeg(float):
# pylint:disable=too-many-return-statements
def to_direction(self) -> WindDirection:
if self > 337.5 or self <= 22.25:
if self == -1:
return WindDirection.CALM
elif self > 337.5 or self <= 22.25:
return WindDirection.N
elif self <= 67.5:
return WindDirection.NE
@@ -80,7 +86,7 @@ class WindDirectionDeg(float):
elif self <= 337.5:
return WindDirection.NW
else:
return WindDirection.CALM
raise ValueError(self)
@classmethod
def from_direction(cls, direction: WindDirection) -> "WindDirectionDeg":

View File

@@ -23,9 +23,7 @@ def build_weather_value(date: datetime.datetime) -> WeatherValue:
)
def merge_weather_values(
date: datetime.datetime, values: list[WeatherValue]
) -> WeatherValue:
def merge_weather_values(date: datetime.datetime, values: list[WeatherValue]) -> WeatherValue:
result = build_weather_value(date)
temperatures = []
pressures = []

View File

@@ -1,5 +1,11 @@
from pathlib import Path
class TimeUnit:
SECOND = 1
MINUTE = 60 * SECOND
HOUR = 60 * MINUTE
DAY = 24 * HOUR
root_path = Path(__file__).parent.parent

View File

@@ -1 +1,6 @@
__version__ = "0.1.0"
__version__ = "0.2.2"
import tomllib
from pathlib import Path
__version__ = tomllib.loads((Path(__file__).parent.parent / "pyproject.toml").read_text())["tool"]["poetry"]["version"]

View File

@@ -0,0 +1,77 @@
msgid ""
msgstr ""
"Project-Id-Version: Gallery\n"
"Last-Translator: shmyga <shmyga.z@gmail.com>\n"
"Language: ru\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
msgid "Index"
msgstr "Содержание"
msgid "View"
msgstr "Просмотр"
msgid "Docs"
msgstr "Документация"
msgid "Toggle theme"
msgstr "Переключить тему"
msgid "Light"
msgstr "Светлая"
msgid "Dark"
msgstr "Тёмная"
msgid "Auto"
msgstr "Авто"
msgid "Select language"
msgstr "Выберите язык"
msgid "English"
msgstr "Английский"
msgid "Russian"
msgstr "Русский"
# weather
msgid "Weather"
msgstr "Погода"
msgid "Enter the city name"
msgstr "Введите название города"
msgid "Search"
msgstr "Поиск"
msgid "Cloudiness"
msgstr "Облачность"
msgid "Temperature, °C"
msgstr "Температура, °C"
msgid "Wind direction"
msgstr "Направление ветра"
msgid "Wind speed, m/s"
msgstr "Скорость ветра, м/с"
msgid "Precipitation, mm"
msgstr "Осадки, мм"
msgid "Pressure, mmHg"
msgstr "Давление, мм рт. ст."
msgid "Humidity, %"
msgstr "Влажность, %"
# tv
msgid "TV program"
msgstr "Телепрограмма"
msgid "Live broadcasts"
msgstr "Прямые трансляции"

673
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "gallery"
version = "0.1.0"
version = "0.3.0"
description = ""
authors = ["shmyga <shmyga.z@gmail.com>"]
readme = "README.md"
@@ -12,11 +12,12 @@ aiohttp = "^3.9.5"
beautifulsoup4 = "^4.12.3"
dateparser = "^1.2.0"
pydantic = "^2.8.2"
aiocache = {extras = ["redis"], version = "^0.12.2"}
aiocache = { extras = ["redis"], version = "^0.12.2" }
[tool.poetry.group.app.dependencies]
fastapi = "^0.111.1"
jinja2 = "^3.1.4"
babel = "^2.18.0"
[tool.poetry.group.test.dependencies]
pytest = "^8.3.1"
@@ -34,6 +35,12 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
gallery = "gallery.main:run"
[tool.black]
line-length = 120
[tool.isort]
profile = "black"
[tool.pytest.ini_options]
addopts = "-p no:warnings"
asyncio_mode = "auto"

5
scripts/develop Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
docker compose -f docker-compose-develop.yaml up --build --watch

45
scripts/docker-action Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
. .env
build () {
echo "build: $1"
. "$1/.env"
for PROJECT in "${DOCKER_PROJECTS[@]}"; do
IFS=: read -r PROJECT_NAME PROJECT_TARGET <<< "$PROJECT"
ARGS=("build")
for ARG in ${DOCKER_ARGS[@]}; do
ARGS+=("--build-arg" "$ARG")
done
if [ -n "$PROJECT_TARGET" ]; then
ARGS+=("--target" "$PROJECT_TARGET")
fi
ARGS+=("-t" "$DOCKER_GROUP/$PROJECT_NAME" ".")
ARGS+=("-f" "$1/Dockerfile")
echo "${ARGS[@]}"
docker "${ARGS[@]}"
done
}
publish () {
echo "publish: $1"
. "$1/.env"
for PROJECT in "${DOCKER_PROJECTS[@]}"; do
IFS=: read -r PROJECT_NAME PROJECT_TARGET <<< "$PROJECT"
docker tag $DOCKER_GROUP/$PROJECT_NAME $DOCKER_ROOT/$PROJECT_NAME:$VERSION
docker tag $DOCKER_GROUP/$PROJECT_NAME $DOCKER_ROOT/$PROJECT_NAME:latest
docker push $DOCKER_ROOT/$PROJECT_NAME:$VERSION
docker push $DOCKER_ROOT/$PROJECT_NAME:latest
done
}
DEFAULT_TARGETS="."
TARGETS="${@-$DEFAULT_TARGETS}"
DOCKER_ACTION="${DOCKER_ACTION-build}"
for TARGET in $TARGETS; do
$DOCKER_ACTION "$TARGET"
done

8
scripts/format Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
TARGET="gallery"
poetry run isort $TARGET
poetry run black $TARGET -q

View File

@@ -2,4 +2,8 @@
set -e
cd "$(dirname $(dirname "$0"))" || exit
poetry run pylint gallery
TARGET="gallery"
poetry run pylint $TARGET
poetry run isort $TARGET --check-only
poetry run black $TARGET -q --check --diff

View File

@@ -2,4 +2,5 @@
set -e
cd "$(dirname $(dirname "$0"))" || exit
docker build -t shmyga/gallery .
cd locales/ru/LC_MESSAGES || exit
msgfmt messages.po

View File

@@ -1,8 +0,0 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
IMAGE_NAME=shmyga/gallery
docker tag $IMAGE_NAME instreamatic.com:8083/$IMAGE_NAME
docker push instreamatic.com:8083/$IMAGE_NAME

View File

@@ -1,6 +0,0 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
# docker run --rm -p 8000:80 shmyga/gallery
docker compose up --build

15
scripts/setup Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
PYTHON_VERSION=3.12
poetry env use ${PYTHON_VERSION}
poetry install
cd static || exit
if [[ -f $HOME/.nvm/nvm.sh ]]; then
source "$HOME/.nvm/nvm.sh"
nvm use
fi
npm ci

19
scripts/version Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
if [ -z "$1" ]; then
echo "Usage: $0 [version]"
exit 1
fi
if [[ ! -z "$(git status -s)" ]]; then
echo "Uncomitted changes"
exit 1
fi
poetry version $1
(cd static && npm version $1 --allow-same-version)
git add .
git commit -m "ci(version): $1"
git tag $1

2
static/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

1
static/.nvmrc Normal file
View File

@@ -0,0 +1 @@
24

1241
static/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
static/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "gallery",
"version": "0.3.0",
"scripts": {
"build": "vite build",
"dev": "vite build --watch"
},
"author": "shmyga <shmyga.z@gmail.com>",
"license": "ISC",
"description": "",
"dependencies": {
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"flag-icons": "^7.5.0",
"sass": "^1.99.0"
},
"devDependencies": {
"vite": "^8.0.9"
}
}

17
static/src/components.ts Normal file
View File

@@ -0,0 +1,17 @@
class AppLinkElement extends HTMLElement {
static observedAttributes = ["icon", "href"];
constructor() {
super();
this.innerHTML = `
<a href="${this.getAttribute("href")}"
class="d-flex align-items-center text-body text-decoration-none">
<span class="fs-4">
<span class="bi bi-${this.getAttribute("icon")} me-1"></span>
<span>${this.textContent}</span>
</span>
</a>`;
}
}
customElements.define("app-link", AppLinkElement);

10
static/src/index.ts Normal file
View File

@@ -0,0 +1,10 @@
import * as bootstrap from "bootstrap";
import "./components";
import "./language";
import "./main.scss";
import "./theme";
document.addEventListener("DOMContentLoaded", (event) => {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
const tooltipList = [...tooltipTriggerList].map((tooltipTriggerEl) => new bootstrap.Tooltip(tooltipTriggerEl));
});

64
static/src/language.ts Normal file
View File

@@ -0,0 +1,64 @@
(() => {
const getStoredLanguage = () => {
const m = document.cookie.match(/language=(\w+)/);
return m ? m[1] : null;
};
const setStoredLanguage = (language: string) => (document.cookie = `language=${language}; max-age=34560000; path=/`);
const getPreferredLanguage = () => {
const storedLanguage = getStoredLanguage();
if (storedLanguage) {
return storedLanguage;
}
const result = window.navigator.language.split("-")[0];
return ["en", "ru"].includes(result) ? result : "en";
};
const setLanguage = (language: string) => {};
setLanguage(getPreferredLanguage());
const showActiveLanguage = (language: string, focus = false) => {
const languageSwitcher = document.querySelector("#bd-language");
if (!languageSwitcher) {
return;
}
const languageSwitcherText = document.querySelector("#bd-language-text");
const activeLanguageIcon = document.querySelector(".language-icon-active");
const btnToActive = document.querySelector(`[data-bs-language-value="${language}"]`);
const activeLanguageIconClass = btnToActive.querySelector(".fi").className.match(/fi-[\w-]+/)[0];
document.querySelectorAll("[data-bs-language-value]").forEach((element) => {
element.classList.remove("active");
element.setAttribute("aria-pressed", "false");
});
btnToActive.classList.add("active");
btnToActive.setAttribute("aria-pressed", "true");
const classesToRemove = Array.from(activeLanguageIcon.classList).filter((className) => className.startsWith("fi-"));
activeLanguageIcon.classList.remove(...classesToRemove);
activeLanguageIcon.classList.add(activeLanguageIconClass);
const languageSwitcherLabel = `${languageSwitcherText.textContent} (${btnToActive.dataset.bsLanguageValue})`;
languageSwitcher.setAttribute("aria-label", languageSwitcherLabel);
if (focus) {
languageSwitcher.focus();
}
};
window.addEventListener("DOMContentLoaded", () => {
showActiveLanguage(getPreferredLanguage());
document.querySelectorAll("[data-bs-language-value]").forEach((toggle) => {
toggle.addEventListener("click", () => {
const language = toggle.getAttribute("data-bs-language-value") || "";
setStoredLanguage(language);
setLanguage(language);
showActiveLanguage(language, true);
window.location.reload();
});
});
});
})();

45
static/src/lib/bootstrap-icons.scss vendored Normal file
View File

@@ -0,0 +1,45 @@
.icon-link {
vertical-align: -0.25rem;
}
.bi {
display: inline-block;
text-transform: none;
line-height: 1;
vertical-align: -0.125em;
width: 1em;
height: 1em;
mask-size: contain;
mask-position: 50%;
mask-repeat: no-repeat;
background-color: currentColor;
&.bi-circle-half {
mask-image: url(bootstrap-icons/icons/circle-half.svg);
}
&.bi-moon-stars-fill {
mask-image: url(bootstrap-icons/icons/moon-stars-fill.svg);
}
&.bi-brightness-high {
mask-image: url(bootstrap-icons/icons/brightness-high.svg);
}
&.bi-gear {
mask-image: url(bootstrap-icons/icons/gear.svg);
}
&.bi-sun-fill {
mask-image: url(bootstrap-icons/icons/sun-fill.svg);
}
&.bi-tv {
mask-image: url(bootstrap-icons/icons/tv.svg);
}
&.bi-arrow-left-square {
mask-image: url(bootstrap-icons/icons/arrow-left-square.svg);
}
&.bi-arrow-right-square {
mask-image: url(bootstrap-icons/icons/arrow-right-square.svg);
}
&.bi-arrow-up-square {
mask-image: url(bootstrap-icons/icons/arrow-up-square.svg);
}
}

52
static/src/lib/bootstrap.scss vendored Normal file
View File

@@ -0,0 +1,52 @@
@import "bootstrap/scss/mixins/banner";
@include bsBanner("");
// scss-docs-start import-stack
// Configuration
@import "bootstrap/scss/functions";
@import "bootstrap/scss/variables";
@import "bootstrap/scss/variables-dark";
@import "bootstrap/scss/maps";
@import "bootstrap/scss/mixins";
@import "bootstrap/scss/utilities";
// Layout & components
@import "bootstrap/scss/root";
@import "bootstrap/scss/reboot";
@import "bootstrap/scss/type";
//@import "bootstrap/scss/images";
//@import "bootstrap/scss/containers";
@import "bootstrap/scss/grid";
@import "bootstrap/scss/tables";
@import "bootstrap/scss/forms";
@import "bootstrap/scss/buttons";
//@import "bootstrap/scss/transitions";
@import "bootstrap/scss/dropdown";
//@import "bootstrap/scss/button-group";
@import "bootstrap/scss/nav";
@import "bootstrap/scss/navbar";
//@import "bootstrap/scss/card";
//@import "bootstrap/scss/accordion";
//@import "bootstrap/scss/breadcrumb";
//@import "bootstrap/scss/pagination";
//@import "bootstrap/scss/badge";
//@import "bootstrap/scss/alert";
//@import "bootstrap/scss/progress";
@import "bootstrap/scss/list-group";
//@import "bootstrap/scss/close";
//@import "bootstrap/scss/toasts";
//@import "bootstrap/scss/modal";
@import "bootstrap/scss/tooltip";
//@import "bootstrap/scss/popover";
//@import "bootstrap/scss/carousel";
//@import "bootstrap/scss/spinners";
//@import "bootstrap/scss/offcanvas";
//@import "bootstrap/scss/placeholders";
// Helpers
@import "bootstrap/scss/helpers";
// Utilities
@import "bootstrap/scss/utilities/api";
// scss-docs-end import-stack

View File

@@ -0,0 +1,17 @@
@use "flag-icons/sass/flag-icons" with (
$flag-icons-path: "flag-icons/flags",
$flag-icons-included-countries: (
"gb",
"ru",
"by",
"ua",
"kz",
)
);
.fir {
@extend .fis;
border-radius: 50%;
height: 1em;
border: 1px solid gray;
}

View File

@@ -0,0 +1,22 @@
from pathlib import Path
from string import Template
LIST_ITEM = Template(
""".$name {
mask-image: url(./svg/$name.svg);
}
"""
)
def generate():
work_dir = Path(__file__).parent
data = ""
for item in (work_dir / "svg").glob("*.svg"):
data += LIST_ITEM.substitute({"name": item.stem})
target = work_dir / "classes-list.scss"
target.write_text(data)
if __name__ == "__main__":
generate()

View File

@@ -0,0 +1,84 @@
.wi-day-snow {
mask-image: url(./svg/wi-day-snow.svg);
}
.wi-rain-mix {
mask-image: url(./svg/wi-rain-mix.svg);
}
.wi-thunderstorm {
mask-image: url(./svg/wi-thunderstorm.svg);
}
.wi-day-rain {
mask-image: url(./svg/wi-day-rain.svg);
}
.wi-cloudy {
mask-image: url(./svg/wi-cloudy.svg);
}
.wi-night-clear {
mask-image: url(./svg/wi-night-clear.svg);
}
.wi-day-cloudy {
mask-image: url(./svg/wi-day-cloudy.svg);
}
.wi-day-storm-showers {
mask-image: url(./svg/wi-day-storm-showers.svg);
}
.wi-lightning {
mask-image: url(./svg/wi-lightning.svg);
}
.wi-day-rain-mix {
mask-image: url(./svg/wi-day-rain-mix.svg);
}
.wi-day-showers {
mask-image: url(./svg/wi-day-showers.svg);
}
.wi-storm-showers {
mask-image: url(./svg/wi-storm-showers.svg);
}
.wi-snow {
mask-image: url(./svg/wi-snow.svg);
}
.wi-cloud {
mask-image: url(./svg/wi-cloud.svg);
}
.wi-night-alt-snow {
mask-image: url(./svg/wi-night-alt-snow.svg);
}
.wi-night-alt-lightning {
mask-image: url(./svg/wi-night-alt-lightning.svg);
}
.wi-day-sunny {
mask-image: url(./svg/wi-day-sunny.svg);
}
.wi-night-alt-cloudy {
mask-image: url(./svg/wi-night-alt-cloudy.svg);
}
.wi-night-alt-storm-showers {
mask-image: url(./svg/wi-night-alt-storm-showers.svg);
}
.wi-day-thunderstorm {
mask-image: url(./svg/wi-day-thunderstorm.svg);
}
.wi-wind-deg {
mask-image: url(./svg/wi-wind-deg.svg);
}
.wi-showers {
mask-image: url(./svg/wi-showers.svg);
}
.wi-night-alt-rain-mix {
mask-image: url(./svg/wi-night-alt-rain-mix.svg);
}
.wi-rain {
mask-image: url(./svg/wi-rain.svg);
}
.wi-night-alt-thunderstorm {
mask-image: url(./svg/wi-night-alt-thunderstorm.svg);
}
.wi-night-alt-showers {
mask-image: url(./svg/wi-night-alt-showers.svg);
}
.wi-day-lightning {
mask-image: url(./svg/wi-day-lightning.svg);
}
.wi-night-alt-rain {
mask-image: url(./svg/wi-night-alt-rain.svg);
}

View File

@@ -0,0 +1,107 @@
@mixin wind-rotate( $val: 0deg ) {
-webkit-transform: rotate($val);
-moz-transform: rotate($val);
-ms-transform: rotate($val);
-o-transform: rotate($val);
transform: rotate($val);
}
.wi-wind-calm {
display: none !important;
}
.wi-wind-towards-n {
@include wind-rotate(0deg);
}
.wi-wind-towards-nne {
@include wind-rotate(23deg);
}
.wi-wind-towards-ne {
@include wind-rotate(45deg);
}
.wi-wind-towards-ene {
@include wind-rotate(68deg);
}
.wi-wind-towards-e {
@include wind-rotate(90deg);
}
.wi-wind-towards-ese {
@include wind-rotate(113deg);
}
.wi-wind-towards-se {
@include wind-rotate(135deg);
}
.wi-wind-towards-sse {
@include wind-rotate(158deg);
}
.wi-wind-towards-s {
@include wind-rotate(180deg);
}
.wi-wind-towards-ssw {
@include wind-rotate(203deg);
}
.wi-wind-towards-sw {
@include wind-rotate(225deg);
}
.wi-wind-towards-wsw {
@include wind-rotate(248deg);
}
.wi-wind-towards-w {
@include wind-rotate(270deg);
}
.wi-wind-towards-wnw {
@include wind-rotate(293deg);
}
.wi-wind-towards-nw {
@include wind-rotate(313deg);
}
.wi-wind-towards-nnw {
@include wind-rotate(336deg);
}
.wi-wind-from-n {
@include wind-rotate(180deg);
}
.wi-wind-from-nne {
@include wind-rotate(180+23deg);
}
.wi-wind-from-ne {
@include wind-rotate(180+45deg);
}
.wi-wind-from-ene {
@include wind-rotate(180+68deg);
}
.wi-wind-from-e {
@include wind-rotate(180+90deg);
}
.wi-wind-from-ese {
@include wind-rotate(180+113deg);
}
.wi-wind-from-se {
@include wind-rotate(180+135deg);
}
.wi-wind-from-sse {
@include wind-rotate(180+158deg);
}
.wi-wind-from-s {
@include wind-rotate(180+180deg);
}
.wi-wind-from-ssw {
@include wind-rotate(180+203deg);
}
.wi-wind-from-sw {
@include wind-rotate(180+225deg);
}
.wi-wind-from-wsw {
@include wind-rotate(180+248deg);
}
.wi-wind-from-w {
@include wind-rotate(180+270deg);
}
.wi-wind-from-wnw {
@include wind-rotate(180+293deg);
}
.wi-wind-from-nw {
@include wind-rotate(180+313deg);
}
.wi-wind-from-nnw {
@include wind-rotate(180+336deg);
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M4.61,16.88c0-1.15,0.36-2.17,1.08-3.07c0.72-0.9,1.63-1.48,2.74-1.73c0.31-1.37,1.02-2.49,2.11-3.37s2.35-1.32,3.76-1.32
c1.38,0,2.61,0.43,3.69,1.28s1.78,1.95,2.1,3.29h0.33c0.9,0,1.73,0.22,2.49,0.65s1.37,1.03,1.81,1.79c0.44,0.76,0.67,1.58,0.67,2.48
c0,0.88-0.21,1.7-0.63,2.45s-1,1.35-1.73,1.8c-0.73,0.45-1.54,0.69-2.41,0.72H9.41c-1.34-0.06-2.47-0.57-3.4-1.53
C5.08,19.37,4.61,18.22,4.61,16.88z M6.32,16.88c0,0.87,0.3,1.62,0.9,2.26s1.33,0.98,2.19,1.03h11.19c0.86-0.04,1.59-0.39,2.19-1.03
c0.61-0.64,0.91-1.4,0.91-2.26c0-0.88-0.33-1.63-0.98-2.27c-0.65-0.64-1.42-0.96-2.32-0.96H18.8c-0.11,0-0.17-0.06-0.17-0.18
l-0.07-0.57c-0.11-1.08-0.58-1.99-1.4-2.72c-0.82-0.73-1.77-1.1-2.86-1.1c-1.09,0-2.05,0.37-2.85,1.1
c-0.81,0.73-1.27,1.64-1.37,2.72l-0.08,0.57c0,0.12-0.07,0.18-0.2,0.18H9.27c-0.84,0.1-1.54,0.46-2.1,1.07S6.32,16.05,6.32,16.88z"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M3.89,17.6c0-0.99,0.31-1.88,0.93-2.65s1.41-1.27,2.38-1.49c0.26-1.17,0.85-2.14,1.78-2.88c0.93-0.75,2-1.12,3.22-1.12
c1.18,0,2.24,0.36,3.16,1.09c0.93,0.73,1.53,1.66,1.8,2.8h0.27c1.18,0,2.18,0.41,3.01,1.24s1.25,1.83,1.25,3
c0,1.18-0.42,2.18-1.25,3.01s-1.83,1.25-3.01,1.25H8.16c-0.58,0-1.13-0.11-1.65-0.34S5.52,21,5.14,20.62
c-0.38-0.38-0.68-0.84-0.91-1.36S3.89,18.17,3.89,17.6z M5.34,17.6c0,0.76,0.28,1.42,0.82,1.96s1.21,0.82,1.99,0.82h9.28
c0.77,0,1.44-0.27,1.99-0.82c0.55-0.55,0.83-1.2,0.83-1.96c0-0.76-0.27-1.42-0.83-1.96c-0.55-0.54-1.21-0.82-1.99-0.82h-1.39
c-0.1,0-0.15-0.05-0.15-0.15l-0.07-0.49c-0.1-0.94-0.5-1.73-1.19-2.35s-1.51-0.93-2.45-0.93c-0.94,0-1.76,0.31-2.46,0.94
c-0.7,0.62-1.09,1.41-1.18,2.34l-0.07,0.42c0,0.1-0.05,0.15-0.16,0.15l-0.45,0.07c-0.72,0.06-1.32,0.36-1.81,0.89
C5.59,16.24,5.34,16.87,5.34,17.6z M14.19,8.88c-0.1,0.09-0.08,0.16,0.07,0.21c0.43,0.19,0.79,0.37,1.08,0.55
c0.11,0.03,0.19,0.02,0.22-0.03c0.61-0.57,1.31-0.86,2.12-0.86c0.81,0,1.5,0.27,2.1,0.81c0.59,0.54,0.92,1.21,0.99,2l0.09,0.64h1.42
c0.65,0,1.21,0.23,1.68,0.7c0.47,0.47,0.7,1.02,0.7,1.66c0,0.6-0.21,1.12-0.62,1.57s-0.92,0.7-1.53,0.77c-0.1,0-0.15,0.05-0.15,0.16
v1.13c0,0.11,0.05,0.16,0.15,0.16c1.01-0.06,1.86-0.46,2.55-1.19s1.04-1.6,1.04-2.6c0-1.06-0.37-1.96-1.12-2.7
c-0.75-0.75-1.65-1.12-2.7-1.12h-0.15c-0.26-1-0.81-1.82-1.65-2.47c-0.83-0.65-1.77-0.97-2.8-0.97C16.28,7.29,15.11,7.82,14.19,8.88
z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<path d="M1.56,16.9c0,0.9,0.22,1.73,0.66,2.49s1.04,1.36,1.8,1.8c0.76,0.44,1.58,0.66,2.47,0.66h10.83c0.89,0,1.72-0.22,2.48-0.66
c0.76-0.44,1.37-1.04,1.81-1.8c0.44-0.76,0.67-1.59,0.67-2.49c0-0.66-0.14-1.33-0.42-2C22.62,13.98,23,12.87,23,11.6
c0-0.71-0.14-1.39-0.41-2.04c-0.27-0.65-0.65-1.2-1.12-1.67C21,7.42,20.45,7.04,19.8,6.77c-0.65-0.28-1.33-0.41-2.04-0.41
c-1.48,0-2.77,0.58-3.88,1.74c-0.77-0.44-1.67-0.66-2.7-0.66c-1.41,0-2.65,0.44-3.73,1.31c-1.08,0.87-1.78,1.99-2.08,3.35
c-1.12,0.26-2.03,0.83-2.74,1.73S1.56,15.75,1.56,16.9z M3.27,16.9c0-0.84,0.28-1.56,0.84-2.17c0.56-0.61,1.26-0.96,2.1-1.06
l0.5-0.03c0.12,0,0.19-0.06,0.19-0.18l0.07-0.54c0.14-1.08,0.61-1.99,1.41-2.71c0.8-0.73,1.74-1.09,2.81-1.09
c1.1,0,2.06,0.37,2.87,1.1c0.82,0.73,1.27,1.63,1.37,2.71l0.07,0.58c0.02,0.11,0.09,0.17,0.21,0.17h1.61c0.88,0,1.64,0.32,2.28,0.96
c0.64,0.64,0.96,1.39,0.96,2.27c0,0.91-0.32,1.68-0.95,2.32c-0.63,0.64-1.4,0.96-2.28,0.96H6.49c-0.88,0-1.63-0.32-2.27-0.97
C3.59,18.57,3.27,17.8,3.27,16.9z M9.97,4.63c0,0.24,0.08,0.45,0.24,0.63l0.66,0.64c0.25,0.19,0.46,0.27,0.64,0.25
c0.21,0,0.39-0.09,0.55-0.26s0.24-0.38,0.24-0.62c0-0.24-0.09-0.44-0.26-0.59l-0.59-0.66c-0.18-0.16-0.38-0.24-0.61-0.24
c-0.24,0-0.45,0.08-0.62,0.25C10.05,4.19,9.97,4.39,9.97,4.63z M15.31,9.06c0.69-0.67,1.51-1,2.45-1c0.99,0,1.83,0.34,2.52,1.03
c0.69,0.69,1.04,1.52,1.04,2.51c0,0.62-0.17,1.24-0.51,1.84C19.84,12.48,18.68,12,17.32,12H17C16.75,10.91,16.19,9.93,15.31,9.06z
M16.94,3.78c0,0.26,0.08,0.46,0.23,0.62s0.35,0.23,0.59,0.23c0.26,0,0.46-0.08,0.62-0.23c0.16-0.16,0.23-0.36,0.23-0.62V1.73
c0-0.24-0.08-0.43-0.24-0.59s-0.36-0.23-0.61-0.23c-0.24,0-0.43,0.08-0.59,0.23s-0.23,0.35-0.23,0.59V3.78z M22.46,6.07
c0,0.26,0.07,0.46,0.22,0.62c0.21,0.16,0.42,0.24,0.62,0.24c0.18,0,0.38-0.08,0.59-0.24l1.43-1.43c0.16-0.18,0.24-0.39,0.24-0.64
c0-0.24-0.08-0.44-0.24-0.6c-0.16-0.16-0.36-0.24-0.59-0.24c-0.24,0-0.43,0.08-0.58,0.24l-1.47,1.43
C22.53,5.64,22.46,5.84,22.46,6.07z M23.25,17.91c0,0.24,0.08,0.45,0.25,0.63l0.65,0.63c0.15,0.16,0.34,0.24,0.58,0.24
s0.44-0.08,0.6-0.25c0.16-0.17,0.24-0.37,0.24-0.62c0-0.22-0.08-0.42-0.24-0.58l-0.65-0.65c-0.16-0.16-0.35-0.24-0.57-0.24
c-0.24,0-0.44,0.08-0.6,0.24C23.34,17.47,23.25,17.67,23.25,17.91z M24.72,11.6c0,0.23,0.09,0.42,0.26,0.58
c0.16,0.16,0.37,0.24,0.61,0.24h2.04c0.23,0,0.42-0.08,0.58-0.23s0.23-0.35,0.23-0.59c0-0.24-0.08-0.44-0.23-0.6
s-0.35-0.25-0.58-0.25h-2.04c-0.24,0-0.44,0.08-0.61,0.25C24.8,11.17,24.72,11.37,24.72,11.6z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

Some files were not shown because too many files have changed in this diff Show More