feat: Add Web UI for calendar and card generation
This commit is contained in:
parent
64a45a8641
commit
88765f396e
9 changed files with 394 additions and 136 deletions
0
src/kronos-ui/__init__.py
Normal file
0
src/kronos-ui/__init__.py
Normal file
74
src/kronos-ui/main.py
Normal file
74
src/kronos-ui/main.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
from datetime import datetime
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from fastapi import FastAPI, File, UploadFile, Form
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import typst
|
||||
|
||||
from kronos.mobilizon import get_raw_events
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.mount("/static", StaticFiles(directory="src/kronos-ui/static/"), name="static")
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def root():
|
||||
with open("src/kronos-ui/static/index.html", "rb") as f:
|
||||
index = f.read()
|
||||
return index
|
||||
|
||||
|
||||
@app.get("/calendar", responses={200: {"content": {"image/png": {}}}}, response_class=Response)
|
||||
async def calendar():
|
||||
get_raw_events()
|
||||
|
||||
png_bytes = typst.compile("src/typst/calendar.typ", format="png")
|
||||
|
||||
return Response(content=png_bytes, media_type="image/png")
|
||||
|
||||
|
||||
@app.post("/event_card", responses={200: {"content": {"image/png": {}}}}, response_class=Response)
|
||||
async def event_card( # noqa: PLR0913
|
||||
title: str = Form(""),
|
||||
description: str = Form(""),
|
||||
begins=Form(...),
|
||||
ends=Form(...),
|
||||
speaker: str = Form(""),
|
||||
picture: UploadFile = File(...),
|
||||
):
|
||||
pic_path = os.path.join("src", "typst", "assets", "tmp", f"{datetime.now()}.jpg")
|
||||
fp = open(pic_path, "wb")
|
||||
if picture and picture.size > 0:
|
||||
shutil.copyfileobj(picture.file, fp)
|
||||
else:
|
||||
with open(os.path.join("src", "typst", "assets", "photo.jpg"), "rb") as placeholder:
|
||||
shutil.copyfileobj(placeholder, fp)
|
||||
|
||||
# TODO: pack this in a method
|
||||
events = [
|
||||
{
|
||||
"title": title,
|
||||
"beginsOn": begins,
|
||||
"endsOn": ends,
|
||||
"description": description,
|
||||
"physicalAddress": None,
|
||||
"picture": os.path.relpath(pic_path, start="src/typst"),
|
||||
"url": None,
|
||||
"txt_description": description,
|
||||
"txt_date": f"{begins.split('T')[0]} {begins.split('T')[1]}-{ends.split('T')[1]}",
|
||||
# "txt_date": "2026-04-29 18:00-19:00",
|
||||
"speaker": speaker,
|
||||
}
|
||||
]
|
||||
sys_inputs = {"events": json.dumps(events)}
|
||||
print(sys_inputs)
|
||||
# FIXME: remove hardcoded paths
|
||||
png_bytes = typst.compile("src/typst/event.typ", format="png", sys_inputs=sys_inputs)
|
||||
|
||||
fp.close()
|
||||
|
||||
return Response(content=png_bytes, media_type="image/png")
|
||||
3
src/kronos-ui/setzer.py
Normal file
3
src/kronos-ui/setzer.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import typst
|
||||
|
||||
typst.compile("src/typst/calendar.typ", output="calendar.pdf")
|
||||
164
src/kronos-ui/static/index.html
Normal file
164
src/kronos-ui/static/index.html
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="description" content="Kronos Generator" />
|
||||
<title>Kronos UI -- Generator Genialnych Grafik</title>
|
||||
<link rel="shortcut icon" href="static/favicon.ico" />
|
||||
<!-- Font Awesome CSS -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
/>
|
||||
<!-- Bootstrap CSS -->
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
integrity=""
|
||||
/>
|
||||
<link rel="stylesheet" href="static/css/style.css" />
|
||||
<!-- DataTables CSS -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.datatables.net/1.13.6/css/jquery.dataTables.min.css"
|
||||
/>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.container {
|
||||
border: 1px solid grey;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
[data-tab-info] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.active[data-tab-info] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
margin-top: 1rem;
|
||||
padding-left: 1rem;
|
||||
font-size: 20px;
|
||||
font-family: sans-serif;
|
||||
font-weight: bold;
|
||||
color: rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
border-bottom: 1px solid grey;
|
||||
background-color: rgb(16, 153, 9);
|
||||
font-size: 25px;
|
||||
color: rgb(0, 0, 0);
|
||||
display: flex;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tabs span {
|
||||
background: rgb(16, 153, 9);
|
||||
padding: 10px;
|
||||
border: 1px solid rgb(255, 255, 255);
|
||||
}
|
||||
|
||||
.tabs span:hover {
|
||||
background: rgb(55, 219, 46);
|
||||
cursor: pointer;
|
||||
color: black;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="site-body" class="container-fluid">
|
||||
<div class="row flex-nonwrap">
|
||||
<div class="sidenav"></div>
|
||||
<div class="main">
|
||||
<h1>Kronos</h1>
|
||||
<div class="tabs">
|
||||
<span data-tab-value="#tab_1">Kalendarz</span>
|
||||
<span data-tab-value="#tab_2">Karta</span>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<div class="tabs__tab active" id="tab_1" data-tab-info>
|
||||
<h2>Kalendarz</h2>
|
||||
<form action="/calendar">
|
||||
<label for="calendar_begins">Początek:</label>
|
||||
<input type="date" id="calendar_begins" name="begins"><br>
|
||||
<input type="submit" value="Make me a calendar!">
|
||||
</form>
|
||||
<div>
|
||||
<img src="/calendar" width="80%">
|
||||
</div>
|
||||
</div>
|
||||
<div class="tabs__tab" id="tab_2" data-tab-info>
|
||||
<h2>Karta</h2>
|
||||
<form action="/event_card" method="post" enctype="multipart/form-data">
|
||||
<label for="title">Tytuł:</label><br>
|
||||
<input type="text" id="title" name="title"><br>
|
||||
<label for="description">Opis:</label><br>
|
||||
<input type="text" id="description" name="description"><br>
|
||||
<label for="begins">Początek:</label>
|
||||
<input type="datetime-local" id="card_begins" name="begins"><br>
|
||||
<label for="ends">Koniec:</label>
|
||||
<input type="datetime-local" id="card_ends" name="ends"><br>
|
||||
<label for="speaker">Występuje:</label><br>
|
||||
<input type="text" id="speaker" name="speaker"><br>
|
||||
<label for="picture">Fotka:</label><br>
|
||||
<input type="file" id="picture" name="picture" accept="image/png, image/jpeg"><br>
|
||||
<input type="submit" value="Make it so!">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<a href="https://forge.hs3.pl/leming/kronos"
|
||||
>Źródełka</a
|
||||
>
|
||||
</footer>
|
||||
<!-- jQuery -->
|
||||
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
|
||||
<script type="text/javascript">
|
||||
|
||||
// function to get each tab details
|
||||
const tabs = document.querySelectorAll('[data-tab-value]')
|
||||
const tabInfos = document.querySelectorAll('[data-tab-info]')
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const target = document
|
||||
.querySelector(tab.dataset.tabValue);
|
||||
tabInfos.forEach(tabInfo => {
|
||||
tabInfo.classList.remove('active')
|
||||
})
|
||||
target.classList.add('active');
|
||||
})
|
||||
})
|
||||
|
||||
const now = new Date();
|
||||
const dateValue = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const calendarStartDate = document.getElementById('calendar_begins');
|
||||
calendarStartDate.value = dateValue;
|
||||
|
||||
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
||||
|
||||
const cardStartDate = document.getElementById('card_begins');
|
||||
cardStartDate.value = now.toISOString().slice(0,16);
|
||||
|
||||
const cardEndsDate = document.getElementById('card_ends');
|
||||
cardEndsDate.value = now.toISOString().slice(0,16);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,11 +1,9 @@
|
|||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from zoneinfo import ZoneInfo
|
||||
from argparse import ArgumentParser
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from dotenv import load_dotenv
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
|
@ -13,10 +11,9 @@ from premailer import transform
|
|||
|
||||
from .discordbot import KronosBot, intents
|
||||
|
||||
from .mobilizon import get_raw_events
|
||||
|
||||
|
||||
MOBILIZON_API_URL = "https://events.hs3.pl/api"
|
||||
QUERY_LIMIT = 100
|
||||
TIME_WINDOW_DAYS = 365
|
||||
TRUNCATE_AFTER_CHARACTERS = 300
|
||||
TEMPLATES = {
|
||||
"templates/newsletter_template.html": "newsletter_events.html",
|
||||
|
|
@ -31,68 +28,6 @@ def sanitize_html(raw_html: str) -> str:
|
|||
return cleaned
|
||||
|
||||
|
||||
def get_time_window(days: int = 365):
|
||||
now_utc = datetime.datetime.now(datetime.timezone.utc)
|
||||
ends_utc = now_utc + datetime.timedelta(days=days)
|
||||
|
||||
begins_iso = now_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
ends_iso = ends_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
return begins_iso, ends_iso
|
||||
|
||||
|
||||
def build_graphql_query():
|
||||
return """
|
||||
query SearchEventsInWindow($beginsOn: DateTime, $endsOn: DateTime, $limit: Int) {
|
||||
searchEvents(beginsOn: $beginsOn, endsOn: $endsOn, limit: $limit) {
|
||||
total
|
||||
elements {
|
||||
__typename
|
||||
... on Event {
|
||||
id
|
||||
title
|
||||
description
|
||||
beginsOn
|
||||
endsOn
|
||||
picture {
|
||||
url
|
||||
}
|
||||
url
|
||||
physicalAddress {
|
||||
description
|
||||
locality
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def fetch_events(begins_on: str, ends_on: str, limit: int = QUERY_LIMIT):
|
||||
query = build_graphql_query()
|
||||
variables = {"beginsOn": begins_on, "endsOn": ends_on, "limit": limit}
|
||||
payload = {"query": query, "variables": variables}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
resp = requests.post(MOBILIZON_API_URL, json=payload, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if "errors" in data:
|
||||
raise RuntimeError(f"GraphQL error: {data['errors']}")
|
||||
|
||||
events = []
|
||||
for elem in data["data"]["searchEvents"]["elements"]:
|
||||
if elem.get("__typename") == "Event":
|
||||
begins_iso = elem.get("beginsOn")
|
||||
if begins_iso:
|
||||
dt = datetime.datetime.fromisoformat(begins_iso.replace("Z", "+00:00"))
|
||||
dt_begins = datetime.datetime.fromisoformat(begins_on.replace("Z", "+00:00"))
|
||||
dt_ends = datetime.datetime.fromisoformat(ends_on.replace("Z", "+00:00"))
|
||||
if dt_begins <= dt < dt_ends:
|
||||
events.append(elem)
|
||||
return events
|
||||
|
||||
|
||||
def prepare_events_for_template(raw_events: list) -> list:
|
||||
days_week = [
|
||||
"poniedziałek",
|
||||
|
|
@ -196,37 +131,6 @@ def inline_css(input_html_path, output_html_path):
|
|||
f.write(inlined_html)
|
||||
|
||||
|
||||
def download_picture():
|
||||
pass
|
||||
|
||||
|
||||
def save_json_content(events):
|
||||
json_dirname = "src/typst/content"
|
||||
os.makedirs(json_dirname, exist_ok=True)
|
||||
with open(os.path.join(json_dirname, "events.json"), "w") as f:
|
||||
json.dump(sorted(events, key=lambda event: event["beginsOn"]), f)
|
||||
|
||||
|
||||
def extract_picture(event):
|
||||
picture = event.get("picture")
|
||||
picture_url = picture.get("url") if picture else None
|
||||
picture_filename = None
|
||||
|
||||
if picture_url:
|
||||
if "?" in picture_url:
|
||||
picture_url = picture_url.split("?", 1)[0]
|
||||
|
||||
picture_dirname = "src/typst/assets/tmp"
|
||||
picture_filename = os.path.join(picture_dirname, picture_url.split("/")[-1])
|
||||
event["picture_filename"] = picture_filename
|
||||
|
||||
response = requests.get(picture_url)
|
||||
|
||||
os.makedirs(picture_dirname, exist_ok=True)
|
||||
with open(picture_filename, "wb") as picture_file:
|
||||
picture_file.write(response.content)
|
||||
|
||||
|
||||
def _main():
|
||||
load_dotenv()
|
||||
|
||||
|
|
@ -235,41 +139,7 @@ def _main():
|
|||
_args = parser.parse_args()
|
||||
|
||||
try:
|
||||
begins_on, ends_on = get_time_window(days=TIME_WINDOW_DAYS)
|
||||
print(
|
||||
f"Fetching events between {begins_on} and {ends_on}...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
raw_events = list(
|
||||
filter(
|
||||
lambda x: "https://events.hs3.pl/" in x.get("url", ""),
|
||||
fetch_events(begins_on, ends_on),
|
||||
)
|
||||
)
|
||||
if not raw_events:
|
||||
print("No events found in the requested period.", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
for ev in raw_events:
|
||||
print(f"{ev.get('title', 'Untitled')} — {ev.get('beginsOn', 'Unknown date')}")
|
||||
|
||||
ev["txt_description"] = BeautifulSoup(ev["description"], "html.parser").get_text()
|
||||
|
||||
begins = ev["beginsOn"].split("T")
|
||||
ends = ev["endsOn"].split("T")
|
||||
txt_date = f"{begins[0]} {begins[1][:-4]}-"
|
||||
if begins[0] != ends[0]:
|
||||
txt_date += f"{ends[0]}"
|
||||
txt_date += f"{ends[1][:-4]}"
|
||||
ev["txt_date"] = txt_date
|
||||
|
||||
extract_picture(ev)
|
||||
|
||||
save_json_content(raw_events)
|
||||
|
||||
raw_events.sort(key=lambda ev: ev.get("beginsOn", ""))
|
||||
|
||||
raw_events = get_raw_events()
|
||||
events = prepare_events_for_template(raw_events)
|
||||
|
||||
for template_filename, output_filename in TEMPLATES.items():
|
||||
|
|
|
|||
139
src/kronos/mobilizon.py
Normal file
139
src/kronos/mobilizon.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
MOBILIZON_API_URL = "https://events.hs3.pl/api"
|
||||
QUERY_LIMIT = 100
|
||||
TIME_WINDOW_DAYS = 365
|
||||
|
||||
|
||||
def get_time_window(start_utc=datetime.datetime.now(datetime.timezone.utc), days: int = 365):
|
||||
now_utc = start_utc
|
||||
ends_utc = now_utc + datetime.timedelta(days=days)
|
||||
|
||||
begins_iso = now_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
ends_iso = ends_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
return begins_iso, ends_iso
|
||||
|
||||
|
||||
def build_graphql_query():
|
||||
return """
|
||||
query SearchEventsInWindow($beginsOn: DateTime, $endsOn: DateTime, $limit: Int) {
|
||||
searchEvents(beginsOn: $beginsOn, endsOn: $endsOn, limit: $limit) {
|
||||
total
|
||||
elements {
|
||||
__typename
|
||||
... on Event {
|
||||
id
|
||||
title
|
||||
description
|
||||
beginsOn
|
||||
endsOn
|
||||
picture {
|
||||
url
|
||||
}
|
||||
url
|
||||
physicalAddress {
|
||||
description
|
||||
locality
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def fetch_events(begins_on: str, ends_on: str, limit: int = QUERY_LIMIT):
|
||||
query = build_graphql_query()
|
||||
variables = {"beginsOn": begins_on, "endsOn": ends_on, "limit": limit}
|
||||
payload = {"query": query, "variables": variables}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
resp = requests.post(MOBILIZON_API_URL, json=payload, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if "errors" in data:
|
||||
raise RuntimeError(f"GraphQL error: {data['errors']}")
|
||||
|
||||
events = []
|
||||
for elem in data["data"]["searchEvents"]["elements"]:
|
||||
if elem.get("__typename") == "Event":
|
||||
begins_iso = elem.get("beginsOn")
|
||||
if begins_iso:
|
||||
dt = datetime.datetime.fromisoformat(begins_iso.replace("Z", "+00:00"))
|
||||
dt_begins = datetime.datetime.fromisoformat(begins_on.replace("Z", "+00:00"))
|
||||
dt_ends = datetime.datetime.fromisoformat(ends_on.replace("Z", "+00:00"))
|
||||
if dt_begins <= dt < dt_ends:
|
||||
events.append(elem)
|
||||
return events
|
||||
|
||||
|
||||
def save_json_content(events):
|
||||
json_dirname = "src/typst/content"
|
||||
os.makedirs(json_dirname, exist_ok=True)
|
||||
with open(os.path.join(json_dirname, "events.json"), "w") as f:
|
||||
json.dump(sorted(events, key=lambda event: event["beginsOn"]), f)
|
||||
|
||||
|
||||
def extract_picture(event):
|
||||
picture = event.get("picture")
|
||||
picture_url = picture.get("url") if picture else None
|
||||
picture_filename = None
|
||||
|
||||
if picture_url:
|
||||
if "?" in picture_url:
|
||||
picture_url = picture_url.split("?", 1)[0]
|
||||
|
||||
picture_dirname = "src/typst/assets/tmp"
|
||||
picture_filename = os.path.join(picture_dirname, picture_url.split("/")[-1])
|
||||
event["picture_filename"] = picture_filename
|
||||
|
||||
response = requests.get(picture_url)
|
||||
|
||||
os.makedirs(picture_dirname, exist_ok=True)
|
||||
with open(picture_filename, "wb") as picture_file:
|
||||
picture_file.write(response.content)
|
||||
|
||||
|
||||
def get_raw_events():
|
||||
begins_on, ends_on = get_time_window(days=TIME_WINDOW_DAYS)
|
||||
print(
|
||||
f"Fetching events between {begins_on} and {ends_on}...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
raw_events = list(
|
||||
filter(
|
||||
lambda x: "https://events.hs3.pl/" in x.get("url", ""),
|
||||
fetch_events(begins_on, ends_on),
|
||||
)
|
||||
)
|
||||
if not raw_events:
|
||||
print("No events found in the requested period.", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
for ev in raw_events:
|
||||
print(f"{ev.get('title', 'Untitled')} — {ev.get('beginsOn', 'Unknown date')}")
|
||||
|
||||
ev["txt_description"] = BeautifulSoup(ev["description"], "html.parser").get_text()
|
||||
|
||||
begins = ev["beginsOn"].split("T")
|
||||
ends = ev["endsOn"].split("T")
|
||||
txt_date = f"{begins[0]} {begins[1][:-4]}-"
|
||||
if begins[0] != ends[0]:
|
||||
txt_date += f"{ends[0]}"
|
||||
txt_date += f"{ends[1][:-4]}"
|
||||
ev["txt_date"] = txt_date
|
||||
|
||||
extract_picture(ev)
|
||||
|
||||
save_json_content(raw_events)
|
||||
|
||||
raw_events.sort(key=lambda ev: ev.get("beginsOn", ""))
|
||||
|
||||
return raw_events
|
||||
BIN
src/typst/assets/hs3_logo_hires.png
Normal file
BIN
src/typst/assets/hs3_logo_hires.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
|
|
@ -17,7 +17,7 @@
|
|||
dy: 23mm,
|
||||
image(
|
||||
dither(
|
||||
read("assets/photo.jpg", encoding: none),
|
||||
read(picture, encoding: none),
|
||||
mode: "bw",
|
||||
method: "bayer8x8",
|
||||
size: 360,
|
||||
|
|
@ -61,11 +61,16 @@
|
|||
align(right+bottom)[#text(size: 14pt, weight: "bold", name)]
|
||||
}
|
||||
|
||||
#let events = json("content/events.json")
|
||||
#let events = none
|
||||
#if sys.inputs.len() < 1 {
|
||||
events = json("content/events.json")
|
||||
} else {
|
||||
events = json(bytes(sys.inputs.events))
|
||||
}
|
||||
#let index = 0
|
||||
|
||||
#for event in events {
|
||||
make_event_card(event.title, event.txt_description, event.txt_date, "Gdańsk, al. Wojska Polskiego 41", "", "Joanna Roscoe")
|
||||
make_event_card(event.title, event.txt_description, event.txt_date, "Gdańsk, al. Wojska Polskiego 41", event.picture, event.speaker)
|
||||
|
||||
index = index + 1
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue