From 670aa229c263d3f31f8023ea7f649ec4c3a2ef3b Mon Sep 17 00:00:00 2001 From: Zhixu Ni Date: Tue, 4 Aug 2020 17:13:09 +0200 Subject: [PATCH 01/24] Update Linker GUI and fix typos in Converter UI --- README.md | 5 +- lynx/controllers/linker.py | 8 +- lynx/routers/api.py | 42 ++++- lynx/routers/frontend.py | 114 +++++++------ lynx/templates/base.html | 2 +- lynx/templates/converter.html | 4 +- lynx/templates/{userguide.html => guide.html} | 0 lynx/templates/linker.html | 152 ++++++------------ lynx/templates/resources.html | 1 + requirements.txt | 8 +- 10 files changed, 163 insertions(+), 173 deletions(-) rename lynx/templates/{userguide.html => guide.html} (100%) diff --git a/README.md b/README.md index 4028faf..7df1494 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,9 @@ ![LipidLynx_Logo](doc/images/LipidLynxX_Logo_128.jpg) ![Platforms](https://img.shields.io/badge/Platform-Linux%20%7C%20macOS%20%7C%20Windows-blue.svg?color=orange) +![total downloads](https://img.shields.io/github/downloads/SysMedOs/LipidLynxX/total.svg?color=green) ![GitHub (Pre-)Release Date](https://img.shields.io/github/release-date-pre/SysMedOs/LipidLynxX.svg) -![total downloads](https://img.shields.io/github/downloads/SysMedOs/LipidLynxX/total.svg?color=success) +![GitHub commits since latest release](https://img.shields.io/github/commits-since/SysMedOs/LipidLynxX/v0.4.12-beta.svg?color=green) ![GitHub last commit](https://img.shields.io/github/last-commit/SysMedOs/LipidLynxX.svg) The LipidLynxX project is aimed to provide a unified identifier for major lipids, especially oxidized lipids @@ -128,7 +129,7 @@ Please find our user guide in folder `doc`. - **Use as Python module** - Please check `examples_notebook.ipynb` - You can find online interactive version via Binder - [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/ZhixuNi/LipidLynxX/develop?filepath=examples_notebook.ipynb) + [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/ZhixuNi/LipidLynxX/develop?filepath=converter_notebook.ipynb) ### LipidLynxX Nomenclature diff --git a/lynx/controllers/linker.py b/lynx/controllers/linker.py index 5c204b6..1b8e49b 100644 --- a/lynx/controllers/linker.py +++ b/lynx/controllers/linker.py @@ -179,7 +179,7 @@ async def get_lmsd_id( else: url_safe_lipid_name = urllib.parse.quote(lipid_name, safe="") ref_url = re.sub(r"", url_safe_lipid_name, lmsd_bulk_base_url) - print(ref_url) + # print(ref_url) async with aiohttp.request("GET", ref_url) as r_cross_ref_obj: r_cross_ref_status = r_cross_ref_obj.status if r_cross_ref_status == 200: @@ -325,7 +325,7 @@ async def get_external_link(ref_id: str, ref_db: str, check_url: bool = False) - if ref_base_url: ref_url = re.sub(r"", ref_id, ref_base_url) if check_url: - print(ref_url) + # print(ref_url) async with aiohttp.request("GET", ref_url) as r_cross_ref_obj: r_cross_ref_status = r_cross_ref_obj.status if r_cross_ref_status == 200: @@ -359,7 +359,7 @@ async def get_cross_links( r"\((\d{1,2}[EZ]?)(,\d{1,2}[EZ]?)+\)", "", siwss_lv_s_str ) siwss_lv_m_str = re.sub(r"/", "_", siwss_lv_s_str) - print("SWISS_NAMES: ", swisslipids_name, siwss_lv_s_str, siwss_lv_m_str) + # print("SWISS_NAMES: ", swisslipids_name, siwss_lv_s_str, siwss_lv_m_str) if ( lipid_name == swisslipids_name or lipid_name == siwss_lv_s_str @@ -524,7 +524,7 @@ async def link_lipids(lipid_list: List[str]) -> pd.DataFrame: else: resources[db] = "" linked_info_dct[idx] = resources - print(resources) + # print(resources) idx += 1 default_col = ["Input_name", "ShorthandNotation", "LipidLynxX", "BioPAN"] diff --git a/lynx/routers/api.py b/lynx/routers/api.py index 4f4c42d..b689dfb 100644 --- a/lynx/routers/api.py +++ b/lynx/routers/api.py @@ -23,7 +23,7 @@ from pydantic import parse_obj_as import requests -from lynx.controllers.linker import get_cross_links +from lynx.controllers.linker import get_cross_links, get_lmsd_name, get_swiss_name from lynx.controllers.converter import Converter from lynx.controllers.encoder import Encoder from lynx.controllers.equalizer import Equalizer @@ -79,14 +79,44 @@ async def parse_name(lipid_name: str = "PLPC"): @router.get("/link/lipid/") -async def link_str(lipid_name: str = "PC(16:0/18:2(9Z,12Z))", export_url: bool = False): +async def link_str( + lipid_name: str = "PC(16:0/18:2(9Z,12Z))", + export_url: bool = False, + export_names: bool = True, +): """ link one lipid name from data """ - linked_ids = await get_cross_links(lipid_name, export_url=export_url) - if linked_ids: - return linked_ids + if lipid_name: + if re.match(r"^LM\w\w\d{8}$", lipid_name, re.IGNORECASE): + safe_lipid_name = await get_lmsd_name(lipid_name) + elif re.match(r"^SLM:\d{9}$", lipid_name, re.IGNORECASE): + safe_lipid_name = await get_swiss_name(lipid_name) + else: + safe_lipid_name = lipid_name + else: + raise HTTPException(status_code=404) + search_name = await convert_name( + safe_lipid_name, level="MAX", style="BracketsShorthand" + ) + shorthand_name = await convert_name( + safe_lipid_name, level="MAX", style="ShorthandNotation" + ) + lynx_name = await convert_name(safe_lipid_name, level="MAX", style="LipidLynxX") + + resource_data = await get_cross_links(search_name, export_url=export_url) + if resource_data: + if export_names: + render_data_dct = { + "lipid_name": lipid_name, + "shorthand_name": shorthand_name, + "lynx_name": lynx_name, + "resource_data": resource_data, + } + return render_data_dct + else: + return resource_data else: raise HTTPException(status_code=500) @@ -155,7 +185,7 @@ async def equalize_multiple_levels( """ Equalize a dict of lipid names into supported levels and export to supported style """ - print(levels.levels) + # print(levels.levels) equalizer = Equalizer(data.data, level=levels.levels) equalizer_data = equalizer.cross_match() return equalizer_data diff --git a/lynx/routers/frontend.py b/lynx/routers/frontend.py index a58e115..e61f5ed 100644 --- a/lynx/routers/frontend.py +++ b/lynx/routers/frontend.py @@ -16,20 +16,11 @@ import base64 import io import json -import re - -from fastapi import ( - APIRouter, - File, - Form, - Request, - UploadFile, -) + +from fastapi import APIRouter, File, Form, Request, UploadFile, HTTPException from fastapi.responses import StreamingResponse from fastapi.templating import Jinja2Templates - -from lynx.controllers.linker import get_lmsd_name, get_swiss_name from lynx.models.api_models import ( FileType, StyleType, @@ -134,10 +125,7 @@ async def converter_file( "not_converted_html": not_converted_html, } else: - render_data_dct = { - "request": request, - "err_msgs": err_lst, - } + render_data_dct = {"request": request, "err_msgs": err_lst} return templates.TemplateResponse("converter.html", render_data_dct) @@ -150,7 +138,7 @@ async def equalizer(request: Request): @router.post("/equalizer/file/", include_in_schema=False) -async def equalize_file( +async def equalizer_file( request: Request, file_obj: UploadFile = File(...), match_levels: str = Form(...) ): table_info, err_lst = get_table(file_obj, err_lst=[]) @@ -177,15 +165,9 @@ async def equalize_file( "output_file_data": data_encoded, } else: - render_data_dct = { - "request": request, - "err_msgs": err_lst, - } + render_data_dct = {"request": request, "err_msgs": err_lst} else: - render_data_dct = { - "request": request, - "err_msgs": err_lst, - } + render_data_dct = {"request": request, "err_msgs": err_lst} return templates.TemplateResponse("equalizer.html", render_data_dct) @@ -193,39 +175,67 @@ async def equalize_file( @router.get("/linker/", include_in_schema=False) async def linker(request: Request,): return templates.TemplateResponse( - "linker.html", {"request": request, "out_dct": {}} + "linker.html", {"request": request, "all_resources": {}} ) -@router.post("/linker/lipid/", include_in_schema=False) -async def linker_resources( - request: Request, lipid_name: str = Form(...), +@router.post("/linker/list", include_in_schema=False) +async def linker_list( + request: Request, + lipid_names: str = Form(...), + export_url: str = Form(...), + file_type: FileType = Form(...), ): - if lipid_name: - if re.match(r"^LM\w\w\d{8}$", lipid_name, re.IGNORECASE): - safe_lipid_name = await get_lmsd_name(lipid_name) - elif re.match(r"^SLM:\d{9}$", lipid_name, re.IGNORECASE): - safe_lipid_name = await get_swiss_name(lipid_name) - else: - safe_lipid_name = lipid_name + if not lipid_names: + raise HTTPException(status_code=404) + if export_url == "include": + export_url = True else: - safe_lipid_name = "PC(16:0/18:2(9Z,12Z))" - search_name = await api.convert_name( - safe_lipid_name, level="MAX", style="BracketsShorthand" - ) - shorthand_name = await api.convert_name( - safe_lipid_name, level="MAX", style="ShorthandNotation" - ) - lynx_name = await api.convert_name(safe_lipid_name, level="MAX", style="LipidLynxX") - resource_data = await api.link_str(search_name, export_url=True) + export_url = False + names = lipid_names.splitlines() + all_resources = {} + lynx_names = {} + for lipid_name in names: + resource_info = await api.link_str(lipid_name, export_url=True) + all_resources[lipid_name] = base64.urlsafe_b64encode(json.dumps(resource_info).encode("utf-8")).decode("utf-8") + lynx_names[lipid_name] = resource_info.get('lynx_name', "") + + output_name = get_output_name("Linker", file_type) render_data_dct = { "request": request, - "lipid_name": lipid_name, - "shorthand_name": shorthand_name, - "lynx_name": lynx_name, - "resource_data": resource_data, + "export_url": export_url, + "all_resources": all_resources, + "lynx_names": lynx_names, + "output_file_name": output_name, + "output_file_type": file_type, + "output_file_data": {} } - return templates.TemplateResponse("resources.html", render_data_dct) + + return templates.TemplateResponse( + "linker.html", render_data_dct + ) + + +@router.post("/linker/file", include_in_schema=False) +async def linker_file(request: Request,): + return templates.TemplateResponse( + "linker.html", {"request": request, "out_dct": {}} + ) + + +@router.post("/linker/lipid/", include_in_schema=False) +async def linker_lipid(request: Request, lipid_name: str = Form(...)): + resource_info = await api.link_str(lipid_name, export_url=True) + resource_info["request"] = request + return templates.TemplateResponse("resources.html", resource_info) + + +@router.get("/linker/resources/{data}", include_in_schema=False) +async def view_lipid_resources(request: Request, data: str): + decoded_data = base64.urlsafe_b64decode(data.encode("utf-8")) + resource_info = json.loads(decoded_data.decode("utf-8")) + resource_info["request"] = request + return templates.TemplateResponse("resources.html", resource_info) @router.get( @@ -244,10 +254,10 @@ async def get_download_file(data: str, file_type: str, file_name: str): else: try: output_io = create_converter_output(data) - except Exception as e: + except Exception as e1: try: output_io = create_converter_output(data) - except Exception as e: + except Exception as e2: raise ValueError() else: output_io = None @@ -289,6 +299,6 @@ def nomenclature(request: Request): @router.get("/user_guide", include_in_schema=False) def user_guide(request: Request): return templates.TemplateResponse( - "userguide.html", + "guide.html", {"request": request, "lynx_version": lynx_version, "api_version": api_version}, ) diff --git a/lynx/templates/base.html b/lynx/templates/base.html index 1ff0ca8..ed27b26 100644 --- a/lynx/templates/base.html +++ b/lynx/templates/base.html @@ -93,7 +93,7 @@
-

-

LipidLynxX Converter

+

LipidLynxX Linker

Input lipid abbreviations

-
+

-

-

- -

- -
+

+
-
+

-

-

- -

- -
+

+

 

 

+
+ {{ converted_html|safe }}
{% if not_converted_html %} +
+ {{ not_converted_html|safe }}
{% endif %} {% endif %} diff --git a/lynx/templates/equalizer.html b/lynx/templates/equalizer.html index a349631..9f987d4 100644 --- a/lynx/templates/equalizer.html +++ b/lynx/templates/equalizer.html @@ -60,13 +60,12 @@

#2. Set Lipid information level(s) {% endfor %} {% endif %} - {% if output_file_data %} + {% if output_generated %}

LipidLynxX Equalizer output

 

diff --git a/lynx/templates/linker.html b/lynx/templates/linker.html index 82da029..472aa2b 100644 --- a/lynx/templates/linker.html +++ b/lynx/templates/linker.html @@ -87,13 +87,24 @@

Input lipid abbreviations

{% if all_resources %}

LipidLynxX Linker output

 

- {# #} + {% if err_msgs %} + {% for err_msg in err_msgs %} + + {% endfor %} + {% endif %} + {% if output_generated %} + + {% endif %}

 

Input lipid abbreviations

-
+

Input lipid abbreviations

-
+
{% if all_resources %}

LipidLynxX Linker output

 

{% if err_msgs %} diff --git a/lynx/utils/toolbox.py b/lynx/utils/toolbox.py index 7efb606..4bbb1df 100644 --- a/lynx/utils/toolbox.py +++ b/lynx/utils/toolbox.py @@ -159,8 +159,11 @@ def get_style_level( return export_style, to_level -def get_url_safe_str(data: dict) -> str: - data_json: str = json.dumps(data) +def get_url_safe_str(data: Union[str, list, dict]) -> str: + if isinstance(data, str): + data_json: str = data + else: + data_json: str = json.dumps(data) data_bytes: bytes = base64.urlsafe_b64encode(data_json.encode("utf-8")) data_str: str = data_bytes.decode("utf-8") From 458afe7e3452e377089519ee5fde84b680a98ef6 Mon Sep 17 00:00:00 2001 From: Zhixu Ni Date: Fri, 7 Aug 2020 17:24:17 +0200 Subject: [PATCH 05/24] Update Linker UI and results display pages. --- .github/workflows/black.yml | 11 + lynx/config.ini | 3 +- lynx/controllers/converter.py | 10 +- lynx/liblynx/LipidNomenclature.py | 4 +- lynx/liblynx/nomenclature.py | 1 + lynx/models/defaults.py | 6 +- lynx/routers/api.py | 26 ++- lynx/routers/frontend.py | 212 ++++++++++-------- lynx/temp/temporary_file_info.md | 44 ++++ lynx/templates/base.html | 5 +- lynx/templates/home.html | 2 +- lynx/templates/linker.html | 34 ++- ...urces.html => linker_results_details.html} | 0 lynx/templates/linker_results_summary.html | 42 ++++ lynx/tools.py | 14 +- lynx/utils/cfg_reader.py | 1 + lynx/utils/file_handler.py | 68 +++--- lynx/utils/frontend_tools.py | 4 +- 18 files changed, 309 insertions(+), 178 deletions(-) create mode 100644 .github/workflows/black.yml create mode 100644 lynx/temp/temporary_file_info.md rename lynx/templates/{resources.html => linker_results_details.html} (100%) create mode 100644 lynx/templates/linker_results_summary.html diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml new file mode 100644 index 0000000..af929af --- /dev/null +++ b/.github/workflows/black.yml @@ -0,0 +1,11 @@ +name: Lint + +on: [push, pull_request] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + - uses: psf/black@stable \ No newline at end of file diff --git a/lynx/config.ini b/lynx/config.ini index fe4698b..dca5993 100644 --- a/lynx/config.ini +++ b/lynx/config.ini @@ -11,4 +11,5 @@ output_rules = lynx/configurations/rules/output resource_kegg = lynx/configurations/resources/KEGG_COMPOUND.json resource_lion = lynx/configurations/resources/LION.json temp_folder = lynx/temp -temp_max_days = 7 \ No newline at end of file +temp_max_days = 3 +temp_max_files = 99 \ No newline at end of file diff --git a/lynx/controllers/converter.py b/lynx/controllers/converter.py index b40c018..fca0454 100644 --- a/lynx/controllers/converter.py +++ b/lynx/controllers/converter.py @@ -79,10 +79,10 @@ def convert_list( output_dct[k].append(abbr_result.get(k, "")) # for k in output_dct: # output_dct[k] = [v for v in output_dct[k] if v] # remove "" or None - if 'skipped' in output_dct: - output_dct['skipped'] = [v for v in output_dct['skipped'] if v] - if 'converted' in output_dct: - output_dct['converted'] = [v for v in output_dct['converted'] if v] + if "skipped" in output_dct: + output_dct["skipped"] = [v for v in output_dct["skipped"] if v] + if "converted" in output_dct: + output_dct["converted"] = [v for v in output_dct["converted"] if v] converted_lst_obj = ConvertedListData( input=output_dct.get("input"), output=output_dct.get("output"), @@ -170,7 +170,7 @@ def convert_lipid( "FACoA 18:0", "Cer 24:2", "LMGP01010594", - "lid" + "lid", ] lv = "B1" # test_out_rule = "COMP_DB" diff --git a/lynx/liblynx/LipidNomenclature.py b/lynx/liblynx/LipidNomenclature.py index a165ce4..7b83f2a 100644 --- a/lynx/liblynx/LipidNomenclature.py +++ b/lynx/liblynx/LipidNomenclature.py @@ -182,8 +182,8 @@ def get_smi_fa(self, abbr: str) -> str: c_shift = 3 if c_shift * _mod_count > int(fa_info_dct["NUM_C"]) - 2: c_shift = ( - 2 - ) # if more C=C in chain and no bis-allylic position + 2 # if more C=C in chain and no bis-allylic position + ) logger.info( "Too many C=C, try to remove bis-allylic positions" ) diff --git a/lynx/liblynx/nomenclature.py b/lynx/liblynx/nomenclature.py index bd0ad79..d00b13f 100644 --- a/lynx/liblynx/nomenclature.py +++ b/lynx/liblynx/nomenclature.py @@ -13,6 +13,7 @@ # For more info please contact: # Developer Zhixu Ni zhixu.ni@uni-leipzig.de + class LynxObject(object): def __init__(self, lynx_id: str): diff --git a/lynx/models/defaults.py b/lynx/models/defaults.py index 9f10dde..3aab164 100644 --- a/lynx/models/defaults.py +++ b/lynx/models/defaults.py @@ -34,13 +34,15 @@ default_alias_file = get_abs_path(app_cfg_info["defined_alias"]) default_kegg_file = get_abs_path(app_cfg_info["resource_kegg"]) default_lion_file = get_abs_path(app_cfg_info["resource_lion"]) -default_temp_folder = get_abs_path(app_cfg_info.get("temp_folder", r"lynx/temp")) -default_temp_max_days = int(app_cfg_info.get("temp_max_days", "7")) +default_temp_folder = app_cfg_info.get("temp_folder", r"lynx/temp") +default_temp_max_days = int(app_cfg_info.get("temp_max_days", "3")) +default_temp_max_files = int(app_cfg_info.get("temp_max_files", "99")) if os.path.isdir(default_temp_folder): pass else: os.mkdir(default_temp_folder) +default_temp_folder = get_abs_path(default_temp_folder) with open(default_cv_file, "r") as cv_js: cv_alias_json = json.load(cv_js) diff --git a/lynx/routers/api.py b/lynx/routers/api.py index 9cd9868..28981f8 100644 --- a/lynx/routers/api.py +++ b/lynx/routers/api.py @@ -32,7 +32,11 @@ LevelsData, StyleType, ) -from lynx.models.defaults import default_temp_folder, default_temp_max_days +from lynx.models.defaults import ( + default_temp_folder, + default_temp_max_days, + default_temp_max_files, +) from lynx.utils.log import app_logger from lynx.utils.toolbox import get_level from lynx.utils.file_handler import clean_temp_folder @@ -41,7 +45,9 @@ default_levels = LevelsData(levels=["B1", "D1"]) -removed_files = clean_temp_folder(default_temp_folder, default_temp_max_days) +removed_files = clean_temp_folder( + default_temp_folder, default_temp_max_days, default_temp_max_files +) if removed_files: app_logger.info( f"Remove temporary output files older than {default_temp_max_days} days..." @@ -218,24 +224,22 @@ async def link_str( @router.post("/link/list/") async def link_list( - lipid_names: list, - export_url: bool = False, - export_names: bool = True, + lipid_names: list, export_url: bool = False, export_names: bool = True, ) -> dict: """ link a list of lipids to related resources from posted lipid name list """ linked_info = {} for lipid_name in lipid_names: - linked_info[lipid_name] = await link_one_lipid(lipid_name, export_url, export_names) + linked_info[lipid_name] = await link_one_lipid( + lipid_name, export_url, export_names + ) return linked_info @router.post("/link/dict/") async def link_dict( - lipid_names: dict, - export_url: bool = False, - export_names: bool = True, + lipid_names: dict, export_url: bool = False, export_names: bool = True, ) -> dict: """ link a list of lipids to related resources from posted lipid name list @@ -245,7 +249,9 @@ async def link_dict( lipid_col = lipid_names[col] linked_col_info = {} for lipid_name in lipid_col: - linked_col_info[lipid_name] = await link_one_lipid(lipid_name, export_url, export_names) + linked_col_info[lipid_name] = await link_one_lipid( + lipid_name, export_url, export_names + ) linked_info[col] = linked_col_info return linked_info diff --git a/lynx/routers/frontend.py b/lynx/routers/frontend.py index 241539b..54dc158 100644 --- a/lynx/routers/frontend.py +++ b/lynx/routers/frontend.py @@ -58,6 +58,9 @@ # TemplateResponse from jinja2, ignored in API docs page + +# get methods +# top-level pages @router.get("/about", include_in_schema=False) def about(request: Request): return templates.TemplateResponse( @@ -73,6 +76,67 @@ async def converter(request: Request): ) +@router.get("/equalizer/", include_in_schema=False) +async def equalizer(request: Request): + return templates.TemplateResponse( + "equalizer.html", {"request": request, "out_dct": {}} + ) + + +@router.get(f"/", include_in_schema=False) +async def home(request: Request): + return templates.TemplateResponse( + "home.html", + {"request": request, "lynx_version": lynx_version, "api_version": api_version}, + ) + + +@router.get("/levels", include_in_schema=False) +def levels(request: Request): + return templates.TemplateResponse("levels.html", {"request": request}) + + +@router.get("/linker/", include_in_schema=False) +async def linker(request: Request,): + return templates.TemplateResponse( + "linker.html", {"request": request, "all_resources": {}} + ) + + +@router.get("/nomenclature", include_in_schema=False) +def nomenclature(request: Request): + return templates.TemplateResponse("nomenclature.html", {"request": request}) + + +@router.get("/user-guide", include_in_schema=False) +def user_guide(request: Request): + return templates.TemplateResponse( + "guide.html", + {"request": request, "lynx_version": lynx_version, "api_version": api_version}, + ) + + +# other functions +@router.get("/downloads/{file_name}", name="get_download_file", include_in_schema=False) +async def get_download_file(file_name: str): + file_path = os.path.join(default_temp_folder, file_name) + if os.path.isfile(file_path): + response = FileResponse(file_path) + response.headers["Content-Disposition"] = f"attachment; filename={file_name}" + return response + else: + return f"Failed to generate output file: {file_name}" + + +@router.get("/linker/results/details/{data}", include_in_schema=False) +async def view_resource_details(request: Request, data: str): + decoded_data = base64.urlsafe_b64decode(data.encode("utf-8")) + resource_info = json.loads(decoded_data.decode("utf-8")) + resource_info["request"] = request + return templates.TemplateResponse("linker_results_details.html", resource_info) + + +# post methods @router.post("/converter/text/", include_in_schema=False) async def converter_text( request: Request, @@ -137,13 +201,6 @@ async def converter_file( return templates.TemplateResponse("converter.html", response_data) -@router.get("/equalizer/", include_in_schema=False) -async def equalizer(request: Request): - return templates.TemplateResponse( - "equalizer.html", {"request": request, "out_dct": {}} - ) - - @router.post("/equalizer/file/", include_in_schema=False) async def equalizer_file( request: Request, file_obj: UploadFile = File(...), match_levels: str = Form(...) @@ -201,13 +258,6 @@ async def equalizer_file( return templates.TemplateResponse("equalizer.html", render_data_dct) -@router.get("/linker/", include_in_schema=False) -async def linker(request: Request,): - return templates.TemplateResponse( - "linker.html", {"request": request, "all_resources": {}} - ) - - @router.post("/linker/text", include_in_schema=False) async def linker_text( request: Request, @@ -234,97 +284,65 @@ async def linker_text( response_data = { "request": request, "export_url": export_url, - "all_resources": all_resources, - "lynx_names": lynx_names, + "display_data": get_url_safe_str(all_resources), + "lynx_names": get_url_safe_str(lynx_names), } response_data = get_linker_response_data(export_file_data, file_type, response_data) return templates.TemplateResponse("linker.html", response_data) -# @router.post("/linker/file", include_in_schema=False) -# async def linker_file( -# request: Request, -# file_obj: UploadFile = File(...), -# export_url: str = Form(...), -# file_type: FileType = Form(...), -# ): -# table_info, err_lst = get_table(file_obj, err_lst=[]) -# if table_info: -# if not lipid_names: -# raise HTTPException(status_code=404) -# if export_url == "include": -# export_url = True -# else: -# export_url = False -# names = lipid_names.splitlines() -# all_resources = {} -# export_file_data = {} -# lynx_names = {} -# for lipid_name in names: -# resource_info = await api.link_lipid(lipid_name, export_url=True) -# all_resources[lipid_name] = get_url_safe_str(resource_info) -# export_file_data[lipid_name] = resource_info -# lynx_names[lipid_name] = resource_info.get("lynx_name", "") -# -# response_data = { -# "request": request, -# "export_url": export_url, -# "all_resources": all_resources, -# "lynx_names": lynx_names, -# } -# response_data = get_linker_response_data(export_file_data, file_type, response_data) -# -# return templates.TemplateResponse("linker.html", response_data) - - -@router.post("/linker/lipid/", include_in_schema=False) +@router.post("/linker/file", include_in_schema=False) +async def linker_file( + request: Request, + file_obj: UploadFile = File(...), + export_url: str = Form(...), + file_type: FileType = Form(...), +): + table_info, err_lst = get_table(file_obj, err_lst=[]) + if table_info: + resource_info = await api.link_dict(table_info, export_url=True) + # for k in resource_info: + # all_resources[lipid_name] = get_url_safe_str(resource_info) + # export_file_data[lipid_name] = resource_info + # lynx_names[lipid_name] = resource_info.get("lynx_name", "") + # + # response_data = { + # "request": request, + # "export_url": export_url, + # "all_resources": all_resources, + # "lynx_names": lynx_names, + # } + # response_data = get_linker_response_data( + # export_file_data, file_type, response_data + # ) + + # return templates.TemplateResponse("linker.html", response_data) + + +# direct fast link of one lipid on the home page +@router.post("/linker/results/details", include_in_schema=False) async def linker_lipid(request: Request, lipid_name: str = Form(...)): resource_info = await api.link_lipid(lipid_name, export_url=True) resource_info["request"] = request - return templates.TemplateResponse("resources.html", resource_info) - - -@router.get("/linker/resources/{data}", include_in_schema=False) -async def view_lipid_resources(request: Request, data: str): - decoded_data = base64.urlsafe_b64decode(data.encode("utf-8")) - resource_info = json.loads(decoded_data.decode("utf-8")) - resource_info["request"] = request - return templates.TemplateResponse("resources.html", resource_info) - - -@router.get("/downloads/{file_name}", name="get_download_file", include_in_schema=False) -async def get_download_file(file_name: str): - file_path = os.path.join(default_temp_folder, file_name) - if os.path.isfile(file_path): - response = FileResponse(file_path) - response.headers["Content-Disposition"] = f"attachment; filename={file_name}" - return response - else: - return f"Failed to generate output file: {file_name}" - - -@router.get(f"/", include_in_schema=False) -async def home(request: Request): - return templates.TemplateResponse( - "home.html", - {"request": request, "lynx_version": lynx_version, "api_version": api_version}, - ) - - -@router.get("/levels", include_in_schema=False) -def levels(request: Request): - return templates.TemplateResponse("levels.html", {"request": request}) + return templates.TemplateResponse("linker_results_details.html", resource_info) -@router.get("/nomenclature", include_in_schema=False) -def nomenclature(request: Request): - return templates.TemplateResponse("nomenclature.html", {"request": request}) - - -@router.get("/user_guide", include_in_schema=False) -def user_guide(request: Request): - return templates.TemplateResponse( - "guide.html", - {"request": request, "lynx_version": lynx_version, "api_version": api_version}, - ) +@router.post("/linker/results/summary", include_in_schema=False) +async def view_resource_summary( + request: Request, + display_data: str = Form(...), + lynx_names: str = Form(...), + file_name: str = Form(...), + export_url: bool = Form(...), +): + decoded_data = base64.urlsafe_b64decode(display_data.encode("utf-8")) + decoded_lynx_names = base64.urlsafe_b64decode(lynx_names.encode("utf-8")) + resource_info = { + "data": json.loads(decoded_data.decode("utf-8")), + "lynx_names": json.loads(decoded_lynx_names.decode("utf-8")), + "file_name": file_name, + "export_url": export_url, + "request": request, + } + return templates.TemplateResponse("linker_results_summary.html", resource_info) diff --git a/lynx/temp/temporary_file_info.md b/lynx/temp/temporary_file_info.md new file mode 100644 index 0000000..eeb953d --- /dev/null +++ b/lynx/temp/temporary_file_info.md @@ -0,0 +1,44 @@ +Temp files ending with `.csv`/`.xlsx` will be checked every time when LipidLynxX api server starts. + +**if** + +some temp files that older than defined lifetime threshold + +**or** + +if there are more files than the `temp_max_files` value + +Files fit to **any of above situation** will be removed. + +Examples: + ++ If 200 files were generated in 2 days while +`temp_max_days` set to `3` days and +`temp_max_files` is set to `99` files, +only latest 99 files will be kept and all other older files will be removed. + ++ If 40 files were generated in 2 days while +`temp_max_days` set to `3` days and +`temp_max_files` is set to `99` files, +all 40 files will be kept. + + +Please modify the settings in `LipidLynxX/lynx/config.ini` + +Change following settings: + +``` +temp_folder = lynx/temp +temp_max_days = 3 +temp_max_files = 99 +``` + + +`temp_folder` is set to this folder by default. +Any other folder **MUST be provided as absolute path**. + +`temp_max_days` is set to `3` days by default. +Accept `int` values only. + +`temp_max_files` is set to `99` by default. +Accept `int` values only. diff --git a/lynx/templates/base.html b/lynx/templates/base.html index 22342b1..da1be21 100644 --- a/lynx/templates/base.html +++ b/lynx/templates/base.html @@ -5,11 +5,12 @@ {% block title %}LipidLynxX{% endblock %} {# use bootstrap v4 from cdn #} - + + - + {# use Google font fira code#} diff --git a/lynx/templates/home.html b/lynx/templates/home.html index f785011..01710df 100644 --- a/lynx/templates/home.html +++ b/lynx/templates/home.html @@ -15,7 +15,7 @@ image
 
- +
diff --git a/lynx/templates/resources.html b/lynx/templates/linker_results_details.html similarity index 100% rename from lynx/templates/resources.html rename to lynx/templates/linker_results_details.html diff --git a/lynx/templates/linker_results_summary.html b/lynx/templates/linker_results_summary.html new file mode 100644 index 0000000..a96e2c4 --- /dev/null +++ b/lynx/templates/linker_results_summary.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} + +{% block title %} + Linker Results @ LipidLynxX +{% endblock %} + +{% block body %} +
+

+

LipidLynxX Linker Results:

+
+ +

 

+ +
+ + + {% for lipid_name, resource_info in data.items() %} + + + + + + {% endfor %} +
Input LipidConverted NotationResources
{{ lipid_name }}{{ lynx_names[lipid_name] }}View Resources
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/lynx/tools.py b/lynx/tools.py index 20c92cf..5a4728c 100644 --- a/lynx/tools.py +++ b/lynx/tools.py @@ -215,10 +215,7 @@ def convert_lipids( def convert_file( file: Path = typer.Argument(None), column: str = typer.Option( - None, - "--column", - "-c", - help="name of the column that contains lipid notations", + None, "--column", "-c", help="name of the column that contains lipid notations", ), output_file: Path = typer.Option( None, @@ -302,7 +299,9 @@ def convert_file( output_file = Path.joinpath(input_folder, get_output_name("Converter", "xlsx")) typer.echo(f"Generating output file...") with click_spinner.spinner(): - output_info = create_converter_output(converted_dct, output_name=output_file, converted_only=converted_only) + output_info = create_converter_output( + converted_dct, output_name=output_file, converted_only=converted_only + ) cli_save_output(output_info, output_file) @@ -310,10 +309,7 @@ def convert_file( def convert( source: str = typer.Argument(None), column: str = typer.Option( - None, - "--column", - "-c", - help="name of the column that contains lipid notations", + None, "--column", "-c", help="name of the column that contains lipid notations", ), style: StyleType = typer.Option( "LipidLynxX", diff --git a/lynx/utils/cfg_reader.py b/lynx/utils/cfg_reader.py index 893bcbd..7cee762 100644 --- a/lynx/utils/cfg_reader.py +++ b/lynx/utils/cfg_reader.py @@ -38,6 +38,7 @@ def load_cfg_info(cfg_path: str = None) -> Dict[str, str]: "resource_lion", "temp_folder", "temp_max_days", + "temp_max_files", ] config = configparser.ConfigParser() if cfg_path and isinstance(cfg_path, str): diff --git a/lynx/utils/file_handler.py b/lynx/utils/file_handler.py index e7289fe..e1b90c8 100644 --- a/lynx/utils/file_handler.py +++ b/lynx/utils/file_handler.py @@ -15,8 +15,9 @@ from datetime import datetime import json -import os from io import BytesIO +import os +import re from pathlib import Path import time from typing import List, Union @@ -69,7 +70,10 @@ def load_folder(folder: str, file_type: str = "", logger=app_logger) -> List[str def create_converter_output( - data: dict, output_name: Union[str, Path] = None, file_type: str = ".xlsx", converted_only: bool =False + data: dict, + output_name: Union[str, Path] = None, + file_type: str = ".xlsx", + converted_only: bool = False, ) -> Union[BytesIO, str]: file_info = None converted_df = pd.DataFrame() @@ -139,9 +143,7 @@ def create_converter_output( else: file_info = get_abs_path(output_name) except IOError: - file_info = ( - f"[IO error] Cannot create file: {output_name} as output." - ) + file_info = f"[IO error] Cannot create file: {output_name} as output." else: file_info = BytesIO() if file_type.lower().endswith("csv"): @@ -249,7 +251,10 @@ def create_equalizer_output( def create_linker_output( - data: dict, output_name: Union[str, Path] = None, file_type: str = ".xlsx", export_url: bool = True + data: dict, + output_name: Union[str, Path] = None, + file_type: str = ".xlsx", + export_url: bool = True, ) -> Union[BytesIO, str]: file_info = None linked_df = pd.DataFrame() @@ -260,7 +265,9 @@ def create_linker_output( lipid_resources = {} if isinstance(data[lipid_name], dict): lipid_resources["Input_Name"] = data[lipid_name].get("lipid_name", "") - lipid_resources["Shorthand_Notation"] = data[lipid_name].get("shorthand_name", "") + lipid_resources["Shorthand_Notation"] = data[lipid_name].get( + "shorthand_name", "" + ) lipid_resources["LipidLynxX"] = data[lipid_name].get("lynx_name", "") lipid_resources["BioPAN"] = data[lipid_name].get("biopan_name", "") resource_data = data[lipid_name].get("resource_data", {}) @@ -270,12 +277,16 @@ def create_linker_output( db_resources = db_group_resources.get(db) if db_resources and isinstance(db_resources, dict): if len(list(db_resources.keys())) < 2: - lipid_resources[db] = ";".join(list(db_resources.keys())) + lipid_resources[db] = ";".join( + list(db_resources.keys()) + ) lipid_resources[f"Link_{db}"] = ";".join( [db_resources.get(i) for i in db_resources] ) else: - lipid_resources[db] = json.dumps(list(db_resources.keys())) + lipid_resources[db] = json.dumps( + list(db_resources.keys()) + ) lipid_resources[f"Link_{db}"] = json.dumps( [db_resources.get(i) for i in db_resources] ) @@ -296,11 +307,7 @@ def create_linker_output( link_cols.append(col) elif col in default_col: sum_df_columns.remove(col) - sum_df_columns = ( - default_col - + natsorted(sum_df_columns) - + natsorted(link_cols) - ) + sum_df_columns = default_col + natsorted(sum_df_columns) + natsorted(link_cols) linked_df = pd.DataFrame(sum_df, columns=sum_df_columns) if not linked_df.empty: @@ -326,9 +333,7 @@ def create_linker_output( else: file_info = get_abs_path(output_name) except IOError: - file_info = ( - f"[IO error] Cannot create file: {output_name} as output." - ) + file_info = f"[IO error] Cannot create file: {output_name} as output." else: file_info = BytesIO() if file_type.lower().endswith("csv"): @@ -435,16 +440,27 @@ def save_table(df: pd.DataFrame, file_name: str) -> (bool, str): return is_output, abs_output_path -def clean_temp_folder(temp_dir: str = r'lynx/temp', expire_days: float = 7.0): +def clean_temp_folder( + temp_dir: str = r"lynx/temp", max_days: float = 7.0, max_files: int = 99 +) -> list: current_time = time.time() - life_time_threshold = current_time - expire_days * 86400 # 24 * 60 * 60 == 86400 + earliest_unix_time = current_time - max_days * 86400 # 24 * 60 * 60 == 86400 removed_files = [] - for temp_file_name in os.listdir(temp_dir): - temp_file_path = os.path.join(temp_dir, temp_file_name) - if os.path.isfile(temp_file_path): - stat = os.stat(temp_file_path) - if stat.st_ctime < life_time_threshold: - removed_files.append(temp_file_path) - os.remove(temp_file_path) + temp_files_lst = os.listdir(temp_dir) + file_suffix_rgx = re.compile(r"^(.*)(\.)(csv|xlsx?)$", re.IGNORECASE) + temp_file_path_lst = [ + os.path.join(temp_dir, f) for f in temp_files_lst if file_suffix_rgx.match(f) + ] + temp_file_ctime_lst = [os.path.getctime(p) for p in temp_file_path_lst] + temp_file_info_lst = list(zip(temp_file_ctime_lst, temp_file_path_lst)) + if len(temp_file_info_lst) > max_files: + temp_file_info_lst.sort(key=lambda tup: tup[0], reverse=True) + removed_files = temp_file_info_lst[max_files:] + for temp_file_info in temp_file_info_lst: + if temp_file_info[0] < earliest_unix_time: + removed_files.append(temp_file_info) + for temp in removed_files: + if os.path.isfile(temp[1]): + os.remove(temp[1]) return removed_files diff --git a/lynx/utils/frontend_tools.py b/lynx/utils/frontend_tools.py index 1d225d2..e21c121 100644 --- a/lynx/utils/frontend_tools.py +++ b/lynx/utils/frontend_tools.py @@ -59,9 +59,7 @@ def get_converter_response_data(data: dict, file_type: FileType, response_data: return response_data -def get_linker_response_data( - data: dict, file_type: FileType, response_data: dict -): +def get_linker_response_data(data: dict, file_type: FileType, response_data: dict): file_type = get_file_type(file_type) output_name = get_output_name("Linker", file_type) output_path = os.path.join(default_temp_folder, output_name) From ff6e2db5917df33682fa867ec85ce0c40e92809c Mon Sep 17 00:00:00 2001 From: Zhixu Ni Date: Mon, 10 Aug 2020 16:29:20 +0200 Subject: [PATCH 06/24] Update Linker Results UI to support multiple columns file input. Add Ubuntu 20.04 LTS to GitHub Actions. --- .github/workflows/black.yml | 11 -- .github/workflows/pythonapp.yml | 2 +- lynx/routers/frontend.py | 34 +++-- lynx/templates/linker.html | 27 ++-- lynx/utils/file_handler.py | 197 ++++++++++++++++------------ lynx/utils/frontend_tools.py | 38 ++++-- requirements.txt | 4 +- test/test_output/test_convert.xlsx | Bin 8102 -> 8101 bytes test/test_output/test_equalize.xlsx | Bin 9475 -> 9475 bytes 9 files changed, 186 insertions(+), 127 deletions(-) delete mode 100644 .github/workflows/black.yml diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml deleted file mode 100644 index af929af..0000000 --- a/.github/workflows/black.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: Lint - -on: [push, pull_request] - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - uses: psf/black@stable \ No newline at end of file diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index ac1f138..a46a408 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, ubuntu-20.04, macos-latest, windows-latest] python-version: [3.7, 3.8] name: Test Python ${{ matrix.python-version }} diff --git a/lynx/routers/frontend.py b/lynx/routers/frontend.py index 54dc158..6f31839 100644 --- a/lynx/routers/frontend.py +++ b/lynx/routers/frontend.py @@ -284,10 +284,15 @@ async def linker_text( response_data = { "request": request, "export_url": export_url, - "display_data": get_url_safe_str(all_resources), - "lynx_names": get_url_safe_str(lynx_names), + "data": { + "List input": { + "all_resources": all_resources, + "export_file_data": export_file_data, + "lynx_names": lynx_names, + } + }, } - response_data = get_linker_response_data(export_file_data, file_type, response_data) + response_data = get_linker_response_data(response_data, file_type) return templates.TemplateResponse("linker.html", response_data) @@ -301,12 +306,25 @@ async def linker_file( ): table_info, err_lst = get_table(file_obj, err_lst=[]) if table_info: + sum_all_resources = {} resource_info = await api.link_dict(table_info, export_url=True) - # for k in resource_info: - # all_resources[lipid_name] = get_url_safe_str(resource_info) - # export_file_data[lipid_name] = resource_info - # lynx_names[lipid_name] = resource_info.get("lynx_name", "") - # + for k in resource_info: + if len(k) > 16: + display_k = f"{k[:15]}~{k[-1]}" + else: + display_k = k + all_resources = {} + export_file_data = {} + lynx_names = {} + for lipid_name in resource_info.get(k, []): + all_resources[lipid_name] = get_url_safe_str(resource_info) + export_file_data[lipid_name] = resource_info + lynx_names[lipid_name] = resource_info.get("lynx_name", "") + sum_all_resources[display_k] = { + "all_resources": all_resources, + "export_file_data": export_file_data, + "lynx_names": lynx_names, + } # response_data = { # "request": request, # "export_url": export_url, diff --git a/lynx/templates/linker.html b/lynx/templates/linker.html index 63e2a8c..7c7d75c 100644 --- a/lynx/templates/linker.html +++ b/lynx/templates/linker.html @@ -85,7 +85,7 @@

Input lipid abbreviations

- {% if display_data %} + {% if data %}

LipidLynxX Linker output

 

{% if err_msgs %} {% for err_msg in err_msgs %} @@ -106,14 +106,23 @@

Input lipid abbreviations

 

 

-
-
- - - - - -
+
+ + {% for col_name, col_data in data.items() %} + + + + + {% endfor %} +
{{ col_name }} +
+ + + + + +
+
{% endif %} {% endif %} diff --git a/lynx/utils/file_handler.py b/lynx/utils/file_handler.py index e1b90c8..ee79d3c 100644 --- a/lynx/utils/file_handler.py +++ b/lynx/utils/file_handler.py @@ -257,97 +257,124 @@ def create_linker_output( export_url: bool = True, ) -> Union[BytesIO, str]: file_info = None - linked_df = pd.DataFrame() - sum_linked_resources = {} + file_linked_resources = {} if data: - idx = 1 - for lipid_name in data: - lipid_resources = {} - if isinstance(data[lipid_name], dict): - lipid_resources["Input_Name"] = data[lipid_name].get("lipid_name", "") - lipid_resources["Shorthand_Notation"] = data[lipid_name].get( - "shorthand_name", "" - ) - lipid_resources["LipidLynxX"] = data[lipid_name].get("lynx_name", "") - lipid_resources["BioPAN"] = data[lipid_name].get("biopan_name", "") - resource_data = data[lipid_name].get("resource_data", {}) - for db_group in resource_data: - db_group_resources = resource_data[db_group] - for db in db_group_resources: - db_resources = db_group_resources.get(db) - if db_resources and isinstance(db_resources, dict): - if len(list(db_resources.keys())) < 2: - lipid_resources[db] = ";".join( - list(db_resources.keys()) - ) - lipid_resources[f"Link_{db}"] = ";".join( - [db_resources.get(i) for i in db_resources] - ) - else: - lipid_resources[db] = json.dumps( - list(db_resources.keys()) - ) - lipid_resources[f"Link_{db}"] = json.dumps( - [db_resources.get(i) for i in db_resources] - ) - else: - lipid_resources[db] = "" - sum_linked_resources[idx] = lipid_resources - idx += 1 - - default_col = ["Input_Name", "Shorthand_Notation", "LipidLynxX", "BioPAN"] - if sum_linked_resources: - sum_df = pd.DataFrame(data=sum_linked_resources).T - sum_df_columns = sum_df.columns.tolist() - link_cols = [] - for col in sum_df_columns: - if col.startswith("Link_"): - sum_df_columns.remove(col) - if export_url: - link_cols.append(col) - elif col in default_col: - sum_df_columns.remove(col) - sum_df_columns = default_col + natsorted(sum_df_columns) + natsorted(link_cols) - linked_df = pd.DataFrame(sum_df, columns=sum_df_columns) - - if not linked_df.empty: - if output_name: - try: - err_msg = None - if isinstance(output_name, Path): - output_name = output_name.as_posix() - elif isinstance(output_name, str): - pass - else: - err_msg = ( - f"[Type error] Cannot create file: {output_name} as output." + for sheet in data: + sheet_linked_resources = {} + sheet_data = data.get(sheet, {}) + sheet_export_data = sheet_data.get("export_file_data", {}) + idx = 1 + for lipid_name in sheet_export_data: + lipid_resources = {} + if isinstance(sheet_export_data[lipid_name], dict): + lipid_resources["Input_Name"] = sheet_export_data[lipid_name].get( + "lipid_name", "" ) - if output_name.lower().endswith("csv"): - linked_df.to_csv(output_name) - else: - linked_df.to_excel( - output_name, sheet_name="LinkerResult", index=False + lipid_resources["Shorthand_Notation"] = sheet_export_data[ + lipid_name + ].get("shorthand_name", "") + lipid_resources["LipidLynxX"] = sheet_export_data[lipid_name].get( + "lynx_name", "" ) - if err_msg: - file_info = err_msg - else: - file_info = get_abs_path(output_name) - except IOError: - file_info = f"[IO error] Cannot create file: {output_name} as output." - else: - file_info = BytesIO() - if file_type.lower().endswith("csv"): - file_info.write(linked_df.to_csv().encode("utf-8")) + lipid_resources["BioPAN"] = sheet_export_data[lipid_name].get( + "biopan_name", "" + ) + resource_data = sheet_export_data[lipid_name].get( + "resource_data", {} + ) + for db_group in resource_data: + db_group_resources = resource_data[db_group] + for db in db_group_resources: + db_resources = db_group_resources.get(db) + if db_resources and isinstance(db_resources, dict): + if len(list(db_resources.keys())) < 2: + lipid_resources[db] = ";".join( + list(db_resources.keys()) + ) + lipid_resources[f"Link_{db}"] = ";".join( + [db_resources.get(i) for i in db_resources] + ) + else: + lipid_resources[db] = json.dumps( + list(db_resources.keys()) + ) + lipid_resources[f"Link_{db}"] = json.dumps( + [db_resources.get(i) for i in db_resources] + ) + else: + lipid_resources[db] = "" + sheet_linked_resources[idx] = lipid_resources + idx += 1 + file_linked_resources[sheet] = sheet_linked_resources + default_col = ["Input_Name", "Shorthand_Notation", "LipidLynxX", "BioPAN"] + file_linked_df_dct = {} + if file_linked_resources: + for sheet in file_linked_resources: + sum_df = pd.DataFrame(data=file_linked_resources.get(sheet)).T + sum_df_columns = sum_df.columns.tolist() + link_cols = [] + for col in sum_df_columns: + if col.startswith("Link_"): + sum_df_columns.remove(col) + if export_url: + link_cols.append(col) + elif col in default_col: + sum_df_columns.remove(col) + sum_df_columns = ( + default_col + natsorted(sum_df_columns) + natsorted(link_cols) + ) + linked_df = pd.DataFrame(sum_df, columns=sum_df_columns) + file_linked_df_dct[sheet] = linked_df + + if output_name: + try: + err_msg = None + if isinstance(output_name, Path): + output_name = output_name.as_posix() + elif isinstance(output_name, str): + pass else: - output_writer = pd.ExcelWriter( - file_info, engine="openpyxl" - ) # write to BytesIO instead of file path - linked_df.to_excel( - output_writer, sheet_name="LinkerResult", index=False - ) + err_msg = f"[Type error] Cannot create file: {output_name} as output." + if output_name.lower().endswith("csv"): + for s in file_linked_df_dct: + s_df = file_linked_df_dct.get(s, pd.DataFrame()) + s_df.to_csv(output_name) + break + else: + output_writer = pd.ExcelWriter(output_name, engine="openpyxl") + for s in file_linked_df_dct: + s_df = file_linked_df_dct.get(s, pd.DataFrame()) + if not s_df.empty: + s_df.to_excel(output_writer, sheet_name=s) + else: + pass output_writer.save() - file_info.seek(0) + if err_msg: + file_info = err_msg + else: + file_info = get_abs_path(output_name) + except IOError: + file_info = f"[IO error] Cannot create file: {output_name} as output." + else: + file_info = BytesIO() + if file_type.lower().endswith("csv"): + for s in file_linked_df_dct: + s_df = file_linked_df_dct.get(s, pd.DataFrame()) + file_info.write(s_df.to_csv().encode("utf-8")) + break + else: + output_writer = pd.ExcelWriter( + file_info, engine="openpyxl" + ) # write to BytesIO instead of file path + for s in file_linked_df_dct: + s_df = file_linked_df_dct.get(s, pd.DataFrame()) + if not s_df.empty: + s_df.to_excel(output_writer, sheet_name=s) + else: + pass + output_writer.save() + file_info.seek(0) return file_info diff --git a/lynx/utils/frontend_tools.py b/lynx/utils/frontend_tools.py index e21c121..a3c4eb5 100644 --- a/lynx/utils/frontend_tools.py +++ b/lynx/utils/frontend_tools.py @@ -24,6 +24,7 @@ get_file_type, get_output_name, ) +from lynx.utils.toolbox import get_url_safe_str def get_response_data( @@ -59,16 +60,31 @@ def get_converter_response_data(data: dict, file_type: FileType, response_data: return response_data -def get_linker_response_data(data: dict, file_type: FileType, response_data: dict): - file_type = get_file_type(file_type) - output_name = get_output_name("Linker", file_type) - output_path = os.path.join(default_temp_folder, output_name) - export_url = response_data.get("export_url", True) - output_info = create_linker_output( - data, output_name=output_path, file_type=file_type, export_url=export_url - ) - response_data = get_response_data( - output_info, output_path, output_name, response_data - ) +def get_linker_response_data(response_data: dict, file_type: FileType = FileType.xlsx): + export_data = response_data.get("data", {}) + if export_data: + file_type = get_file_type(file_type) + output_name = get_output_name("Linker", file_type) + output_path = os.path.join(default_temp_folder, output_name) + export_url = response_data.get("export_url", True) + output_info = create_linker_output( + response_data.get("data", {}), + output_name=output_path, + file_type=file_type, + export_url=export_url, + ) + data = {} + for col in export_data: + col_data = export_data.get(col, {}) + all_resources = col_data.get("all_resources", {}) + lynx_names = col_data.get("lynx_names", {}) + data[col] = { + "display_data": get_url_safe_str(all_resources), + "lynx_names": get_url_safe_str(lynx_names), + } + response_data["data"] = data + response_data = get_response_data( + output_info, output_path, output_name, response_data + ) return response_data diff --git a/requirements.txt b/requirements.txt index 190019d..62093f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ aiofiles>=0.5.0 aiohttp>=3.6.2 click>=7.1.2 click-spinner>=0.1.10 -fastapi>=0.60.1 +fastapi>=0.61.0 jsonschema==3.2.0 jupyter==1.0.0 ipython>=7.17.0 @@ -17,7 +17,7 @@ pytest>=6.0.1 python-multipart>=0.0.5 regex>=2020.7.14 requests>=2.24.0 -starlette>=0.13.6 +starlette>=0.13.7 typer>=0.3.1 uvicorn>=0.11.8 xlrd>=1.2.0 diff --git a/test/test_output/test_convert.xlsx b/test/test_output/test_convert.xlsx index d65c21df7479da8c1244f2d5b5d68a5c0148a6e9..ddafe5c7215c84e70522c18ffdad70d7ec164085 100644 GIT binary patch delta 580 zcmZ2xzto;Dz?+#xgn@y9gCV+^D{vy;>Ut2}{`h+EE1>9UMg|5!Af1w*98i>BP^_Pv zUzDm>k(;x0;z7T|20U%?si*dq-g0bRv_@P&eZqtpERV!2?}=U95}0)5-P_F5*BT!j zGyG>?zfH8)F;V=bW?7?>wb$x{=D9%#q3~HOFjOn(N=E z`3HDKHmaLl+tBDaqxGnUpI3LcQO%Jwu?Uu|6TzoByY@72pBAb8wK>sGtjHs8;jew? ze}6sGD=qrr*N+c2`tx_1ZOokLvQ}5CCuK+X*OEXBzTUQc*VPk!w#|KIh6_3N^C^2atWxc1@o{a5~|LA3cg zqb(Z?D3CTwaD3*4u=vHUFo9WI?T28N0p{qp>x?4@Uv)F)&_UijjMAmAo2Tpe#rs0FtTG6aWAK delta 597 zcmZ2#zs#O5z?+#xgn@y9gF!Ftf51e()%75H`pZKRuYsbc85tM^fpkiKazIgjL9u>v zeo?AkMQ+a0iN5}a4S3rApSn{0dWq`-*GPQ@KLtfY&Lg^Bd)6-c8IW}4-Tlea)-oUL zP5xt7m!loTzOeSl@)yi*uMF0@-QKNIazliDkpjacy9J>uPqmzWc+P#bhW%r?rc3qj zv-$&!%Xq~+C0)7H5+%iw3xlNgY8-Zdliql-!8WR7N<+Aeo=)PHm*?9oBTpEXa+~rv zo0=F;H-Gfu*N+c2`tfCY6N{v}!=!XeTwLw8U3PtF@l9x-=+n$^;f!as`~^yQc4(eE z>0MmCeXstlSy$Jjn5Q1zzDF!PJ8=Ddwd?%~|GFBP zCohpTg9zV~m0~-#}kafwoE27sjF)&neGcfQ1BLxN;7#Sxw z$jLyoEC6b`IeE97R18Q<#4aCcGhiI*0JZVM)C1`T#zT?}3>7*0<@rU~N%{HNpg0Zi wW@Hj!h9m}%=7qXurR#x;e*+DWLeZ=`*-%~+VqcuR6eG{%N_jQ5AX$(a04A^1P5=M^ diff --git a/test/test_output/test_equalize.xlsx b/test/test_output/test_equalize.xlsx index 1028cbd8fe4b98d1cbe3c5dbc757a6ab3bdb8447..448de6a50ecff1fb5f32a07c4feec54c7c825558 100644 GIT binary patch delta 490 zcmZqnYWCs_@MdNaVPIh3V90Og3Y^Hdngc|)KfWG3@t}JBslBDQ99tKy5f@OOFkuGE zBQeW+Vi&grCS7^=HuLng#s|j?|Jm1X6YX_O6o08%)~ICdwfdlWZjgl8qXRM`4u_a4 zCx4olalGKVYkoz|F8g^{AI*LDS+PC-iz3yF8mY#Xy8+IZ$aOI|;>*4v6U%UTzePR8& z?4A6v%?qx5czyqsKdSd<#>`O`U}IpIqt3t}3=EXb&lq*tSU>@?S)5}wJ0FN8@_g#8 zC#(z%t5hc|a!78z$^V%ZqT+(+6&46{EzPrd%T)Tz delta 490 zcmZqnYWCs_@MdNaVPIh3U?>jyA25+`H3x`}IxA!}@t}JBtG%U{yc8Dci7%V$;FKiP z5&fhh+U2raXW;Jb%XOpKo1~Bb`2Xi-SQZc0ZV&&B+#)vvqj>jaPCm5ZT;CZ-MirY$ z<|`#S%Xq%WerY?NZsTC^Bae?|set)|&R)};1fge%hH2?bkGe|QB^h(;FquAC`IKeT zgo4zWi~Q@&&YX1Ot3Rn5a{s$){MkD@CbnEZ-u}Gu=bf#czTHz^-dc3Xqiy$!<=uxe zGqQe!tvSDWKUYflW0s@K9bUhgv?t{I?7L={muk4`<$d;gw8oMS6Cpd6;fB2z^us)vQHsGnR1Pc5TSSSA0a}I6?~vV z#!6Q3P@i0?WDZfdLCF@v_^9LrVVEmJ8MQFRVP&Y|f6As%H7b@6Mv00Ggt14(9RS|~ B)7}68 From eee83e3cf525bc91a1652c2d178c831f19c4c351 Mon Sep 17 00:00:00 2001 From: Zhixu Ni Date: Tue, 11 Aug 2020 17:42:41 +0200 Subject: [PATCH 07/24] Fix Linker Results UI to export excel output from file linker. Add bootstrap_icons. update README.md. --- .github/workflows/pythonapp.yml | 35 +++---- README.md | 77 +++++++------- doc/LipidLynxX_UserGuide.md | 3 + ...ipidLynxX_Logo.png => LipidLynxX_icon.png} | Bin ...X_Logo_128.jpg => LipidLynxX_icon_128.jpg} | Bin .../images/LipidLynxX_logo_color.png | Bin lynx/app.py | 2 +- lynx/routers/frontend.py | 42 ++++---- ...ipidLynxX_Logo.png => LipidLynxX_icon.png} | Bin ...X_Logo_128.jpg => LipidLynxX_icon_128.jpg} | Bin ...X_Logo_512.png => LipidLynxX_icon_512.png} | Bin ..._banner.png => LipidLynxX_logo_banner.png} | Bin lynx/static/images/LipidLynxX_logo_color.png | Bin 0 -> 43653 bytes .../images/icons/bootstrap_icons_license.md | 9 ++ lynx/static/images/icons/bug.svg | 3 + lynx/static/images/icons/chat-square-dots.svg | 4 + lynx/static/images/icons/chat-square-text.svg | 4 + lynx/static/images/icons/cloud-download.svg | 4 + .../images/icons/exclamation-diamond.svg | 4 + .../images/icons/file-earmark-spreadsheet.svg | 5 + lynx/static/images/icons/gear.svg | 4 + lynx/static/images/icons/info-circle.svg | 5 + lynx/static/images/icons/lightning.svg | 3 + lynx/static/images/icons/link-45deg.svg | 6 ++ lynx/static/images/icons/search.svg | 4 + lynx/templates/about.html | 73 ++++++++------ lynx/templates/base.html | 28 ++++-- lynx/templates/home.html | 28 ++++-- lynx/templates/linker.html | 95 +++++++++--------- test/test_cli_lynx.py | 6 +- test/test_input/test_linker.csv | 5 + test/test_output/test_convert.xlsx | Bin 8101 -> 8101 bytes 32 files changed, 282 insertions(+), 167 deletions(-) rename doc/images/{LipidLynxX_Logo.png => LipidLynxX_icon.png} (100%) rename doc/images/{LipidLynxX_Logo_128.jpg => LipidLynxX_icon_128.jpg} (100%) rename lynx/static/images/LipidlynxX_logo_color.png => doc/images/LipidLynxX_logo_color.png (100%) rename lynx/static/images/{LipidLynxX_Logo.png => LipidLynxX_icon.png} (100%) rename lynx/static/images/{LipidLynxX_Logo_128.jpg => LipidLynxX_icon_128.jpg} (100%) rename lynx/static/images/{LipidLynxX_Logo_512.png => LipidLynxX_icon_512.png} (100%) rename lynx/static/images/{LipidlynxX_logo_banner.png => LipidLynxX_logo_banner.png} (100%) create mode 100644 lynx/static/images/LipidLynxX_logo_color.png create mode 100644 lynx/static/images/icons/bootstrap_icons_license.md create mode 100644 lynx/static/images/icons/bug.svg create mode 100644 lynx/static/images/icons/chat-square-dots.svg create mode 100644 lynx/static/images/icons/chat-square-text.svg create mode 100644 lynx/static/images/icons/cloud-download.svg create mode 100644 lynx/static/images/icons/exclamation-diamond.svg create mode 100644 lynx/static/images/icons/file-earmark-spreadsheet.svg create mode 100644 lynx/static/images/icons/gear.svg create mode 100644 lynx/static/images/icons/info-circle.svg create mode 100644 lynx/static/images/icons/lightning.svg create mode 100644 lynx/static/images/icons/link-45deg.svg create mode 100644 lynx/static/images/icons/search.svg create mode 100644 test/test_input/test_linker.csv diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index a46a408..b5fce54 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -14,21 +14,22 @@ jobs: os: [ubuntu-latest, ubuntu-20.04, macos-latest, windows-latest] python-version: [3.7, 3.8] - name: Test Python ${{ matrix.python-version }} + name: Test Python ${{ matrix.python-version }} on ${{ matrix.os }} steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - architecture: x64 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Black Code Formatter - uses: lgeiger/black-action@v1.0.1 - - name: Test with pytest - run: | - pip install codecov pytest pytest-cov - pytest --cov=./ + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install codecov pytest pytest-cov + - name: Black Code Formatter + uses: lgeiger/black-action@v1.0.1 + - name: GitHub Action for pytest + uses: cclauss/GitHub-Action-for-pytest@master + with: + args: pytest -h diff --git a/README.md b/README.md index 7df1494..a018779 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # LipidLynxX -![LipidLynx_Logo](doc/images/LipidLynxX_Logo_128.jpg) +![LipidLynx_icon_Logo](doc/images/LipidLynxX_icon_128.jpg) +![LipidLynx_text_Logo](lynx/static/images/LipidLynxX_logo_color.png) ![Platforms](https://img.shields.io/badge/Platform-Linux%20%7C%20macOS%20%7C%20Windows-blue.svg?color=orange) ![total downloads](https://img.shields.io/github/downloads/SysMedOs/LipidLynxX/total.svg?color=green) @@ -13,23 +14,34 @@ in the epilipidome. ![LipidLynx_01_Home](doc/images/LipidLynxX_Start_Chromium.png) -## Try LipidLynxX simple converter demo on [`mybinder.org`](https://mybinder.org) 🆕 +## Main Modules -**This demo is always updated automatically to the latest source code on the master branch.** -To preview the latest changes on the converter without dealing with source code. +- **LipidLynxX Converter** -Just click this button 👉 -[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/ZhixuNi/LipidLynxX/develop?filepath=converter_notebook.ipynb) + - Convert different abbreviations to uniformed LipidLynxX ID -And wait a bit ☕ Binder and Jupyter Notebook will prepare LipidLynxX demo for you. +- **LipidLynxX Equalizer** -- You can paste a list of lipid abbreviations, select export style, and download the output table as `.csv` or `.xlsx`. + - Cross compare different level of LipidLynxX ID on selected level -- If you observed some IDs not converted in the Windows .exe version, try this demo to see if it got fixed. +- **LipidLynxX Linker** -- You can run the notebook named `converter_notebook.ipynb` in this repository as well. + - Link lipid abbreviations to available resources -## Important Notice +## Key Features + +- Optimized for manual interpretation and computer processing +- Suitable for both unmodified lipids and modified lipids +- Unified modification controlled vocabularies +- Unified position specific annotations +- Cross level match based on shared levels +- Extract key information from LipidLynxX ID +- Strictly controlled format using JSON schema +- Easy to use Graphic User Interface +- API access for professional users +- Command line tools for professional users + +### Supported lipid notation styles The current LipidLynxX source code was tested using our collection of lipid abbreviations for major lipid classes from following databases and programs: @@ -48,6 +60,8 @@ for major lipid classes from following databases and programs: - Abbreviations such as DHA, PAPE, PLPC, PONPC .etc are also included as `defined alias`. detailed settings can be found in `lynx/configurations/defined_alias.json` +## Important Notice + **If your database / program is not included in the list above**, you can test if any of the configuration files located in `lynx/configurations/rules/input` would fit to your database / program. If conversion is not possible, please contact us so that we can help you to generate suitable configuration file. @@ -72,30 +86,6 @@ We kindly ask if you have any plans to use LipidLynxX API contact us first, or f New features of LipidLynxX is generally developed using repository [https://github.com/ZhixuNi/LipidLynxX](https://github.com/ZhixuNi/LipidLynxX). -### Key Features - -- Optimized for manual interpretation and computer processing -- Suitable for both unmodified lipids and modified lipids -- Unified modification controlled vocabularies -- Unified position specific annotations -- Cross level match based on shared levels -- Extract key information from LipidLynxX ID -- Strictly controlled format using JSON schema -- Easy to use Graphic User Interface -- API access for professional users -- Command line tools for professional users - - -### Main Modules - -- **LipidLynxX Converter** - - - Convert different abbreviations to uniformed LipidLynxX ID - -- **LipidLynxX Equalizer** - - - Cross link different level of LipidLynxX ID on selected level - ## Instructions ### Sample files: @@ -109,6 +99,23 @@ Please find our user guide in folder `doc`. - [User Guide in PDF format](doc/LipidLynxX_UserGuide.pdf) - [User Guide in Markdown format](doc/LipidLynxX_UserGuide.md) +## Try LipidLynxX simple converter demo on [`mybinder.org`](https://mybinder.org) + +**This demo is always updated automatically to the latest source code on the master branch.** +To preview the latest changes on the converter without dealing with source code. + +Just click this button 👉 +[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/ZhixuNi/LipidLynxX/develop?filepath=converter_notebook.ipynb) + +And wait a bit ☕ Binder and Jupyter Notebook will prepare LipidLynxX demo for you. + +- You can paste a list of lipid abbreviations, select export style, and download the output table as `.csv` or `.xlsx`. + +- If you observed some IDs not converted in the Windows .exe version, try this demo to see if it got fixed. + +- You can run the notebook named `converter_notebook.ipynb` in this repository as well. + + ### Screenshots - **GUI** diff --git a/doc/LipidLynxX_UserGuide.md b/doc/LipidLynxX_UserGuide.md index c1a3a3c..e9bf32e 100644 --- a/doc/LipidLynxX_UserGuide.md +++ b/doc/LipidLynxX_UserGuide.md @@ -1,3 +1,6 @@ + +![LipidLynx_icon_Logo](images/LipidLynxX_icon_128.jpg) +![LipidLynx_text_Logo](images/LipidLynxX_logo_color.png) # LipidLynxX User Guide ![LipidLynxX_Start_Chromium](images/LipidLynxX_Start_Chromium.png) diff --git a/doc/images/LipidLynxX_Logo.png b/doc/images/LipidLynxX_icon.png similarity index 100% rename from doc/images/LipidLynxX_Logo.png rename to doc/images/LipidLynxX_icon.png diff --git a/doc/images/LipidLynxX_Logo_128.jpg b/doc/images/LipidLynxX_icon_128.jpg similarity index 100% rename from doc/images/LipidLynxX_Logo_128.jpg rename to doc/images/LipidLynxX_icon_128.jpg diff --git a/lynx/static/images/LipidlynxX_logo_color.png b/doc/images/LipidLynxX_logo_color.png similarity index 100% rename from lynx/static/images/LipidlynxX_logo_color.png rename to doc/images/LipidLynxX_logo_color.png diff --git a/lynx/app.py b/lynx/app.py index bf88d5a..f1184ef 100644 --- a/lynx/app.py +++ b/lynx/app.py @@ -41,7 +41,7 @@ def custom_openapi(): description=f"This is the api (V{api_version}) used in LipidLynxX (V{lynx_version})", routes=app.routes, ) - openapi_schema["info"]["x-logo"] = {"url": "images/LipidLynxX_Logo.png"} + openapi_schema["info"]["x-logo"] = {"url": "images/LipidLynxX_icon.png"} app.openapi_schema = openapi_schema return app.openapi_schema diff --git a/lynx/routers/frontend.py b/lynx/routers/frontend.py index 6f31839..85ff0a2 100644 --- a/lynx/routers/frontend.py +++ b/lynx/routers/frontend.py @@ -291,6 +291,7 @@ async def linker_text( "lynx_names": lynx_names, } }, + "submitted": True, } response_data = get_linker_response_data(response_data, file_type) @@ -305,37 +306,38 @@ async def linker_file( file_type: FileType = Form(...), ): table_info, err_lst = get_table(file_obj, err_lst=[]) + if export_url == "include": + export_url = True + else: + export_url = False + sum_all_resources = {} if table_info: - sum_all_resources = {} resource_info = await api.link_dict(table_info, export_url=True) for k in resource_info: if len(k) > 16: display_k = f"{k[:15]}~{k[-1]}" else: display_k = k - all_resources = {} - export_file_data = {} + col_resource_info = resource_info.get(k, []) lynx_names = {} - for lipid_name in resource_info.get(k, []): - all_resources[lipid_name] = get_url_safe_str(resource_info) - export_file_data[lipid_name] = resource_info - lynx_names[lipid_name] = resource_info.get("lynx_name", "") + for lipid_name in col_resource_info: + lipid_name_info = col_resource_info.get(lipid_name, {}) + lynx_names[lipid_name] = lipid_name_info.get("lynx_name") sum_all_resources[display_k] = { - "all_resources": all_resources, - "export_file_data": export_file_data, + "all_resources": get_url_safe_str(col_resource_info), + "export_file_data": col_resource_info, "lynx_names": lynx_names, } - # response_data = { - # "request": request, - # "export_url": export_url, - # "all_resources": all_resources, - # "lynx_names": lynx_names, - # } - # response_data = get_linker_response_data( - # export_file_data, file_type, response_data - # ) - - # return templates.TemplateResponse("linker.html", response_data) + + response_data = { + "request": request, + "export_url": export_url, + "data": sum_all_resources, + "submitted": True, + } + response_data = get_linker_response_data(response_data, file_type) + + return templates.TemplateResponse("linker.html", response_data) # direct fast link of one lipid on the home page diff --git a/lynx/static/images/LipidLynxX_Logo.png b/lynx/static/images/LipidLynxX_icon.png similarity index 100% rename from lynx/static/images/LipidLynxX_Logo.png rename to lynx/static/images/LipidLynxX_icon.png diff --git a/lynx/static/images/LipidLynxX_Logo_128.jpg b/lynx/static/images/LipidLynxX_icon_128.jpg similarity index 100% rename from lynx/static/images/LipidLynxX_Logo_128.jpg rename to lynx/static/images/LipidLynxX_icon_128.jpg diff --git a/lynx/static/images/LipidLynxX_Logo_512.png b/lynx/static/images/LipidLynxX_icon_512.png similarity index 100% rename from lynx/static/images/LipidLynxX_Logo_512.png rename to lynx/static/images/LipidLynxX_icon_512.png diff --git a/lynx/static/images/LipidlynxX_logo_banner.png b/lynx/static/images/LipidLynxX_logo_banner.png similarity index 100% rename from lynx/static/images/LipidlynxX_logo_banner.png rename to lynx/static/images/LipidLynxX_logo_banner.png diff --git a/lynx/static/images/LipidLynxX_logo_color.png b/lynx/static/images/LipidLynxX_logo_color.png new file mode 100644 index 0000000000000000000000000000000000000000..dea41e76719263a44d4778b0c7d038613968bfc5 GIT binary patch literal 43653 zcmXte1yCGa)AgdkVR4tm9TFr!&@Arm?jAyb;0}ws2ML7W?k>UIU4lb!XIbRudB6Hk z)kxLUOifqyJ>7loiBM6J#zZ4U0{{S+vNB*b0080b77&Gk{B~ZTd_j4;pnj3jbp-&( z7XCZnqDQbx0RU=%ELdFQ`;U`sj~^O)^Mn2epYD~`J4a`{2azGfVh)hv8cFjQ!vgLa zI=jfuX`9lT{yv-Nfj(k=o;y-*EwO16PM~;ZGE$>rZp*#r1Z=Pay|`Kkz3l}49tMpz zW&Gfp>zN~TpW~XdDIiT_Qr3g0rVtsC9)XVs1ab#b8wxiEC&L63_%52x)+9%-KWoh`CzZh&kZ&F%YOh^9!wb)KVsm+f%LVU_j4x zq+~ZT2&BXBZwV$|6Vl@|*IC|RJlFCatIixUB~{CCLnKh$d)ZVU#cDNGTKC5g&%oRu z!ucPnbGqRUE%5Q|X^{a^w~&w`Y)&$vRz5*CeXh&&aYar}IlsHh%(Zvtm1Dbr0910~ z;8hU?>K)0l@xxLSleCySb#RLC=Ulh| z6vTjfAc{!%YhqbW%K1;;ZbydU*$OuqJy^%;#Z{i41HIBNPOc2 z>>T3Gr6uZ%DrDet8tefE?;EV3PXuQ>i(P6Sx1z62R?On~fR6PifL_lB`)605U`_*) z2Cim0!fY98Vo5p$lkZ`?WQ)Z7PI1ry z$_DdI*yKT#>*5(aSH3Eq;N!!2>X&6%De`&}=V?*{RaBs>#{a^H1X2t#W4uLggKh_n zbC4d`-WQV`IMd)(Iv&`CNQihv|U@-$=2<|Kf2(5F-MsFm1XmLCe+1T^u^V*o_(> zXU^|zyTQ)(zHdUN^!%HZ!svTnqHO#{GMO(c6~poPdyh+=vu-*pZIxC|g2ih$eMC?C zW~e76ApsB8|1KOdsF9}UYiQ24h*BFXu09cpQV+t#N$#(&v1w%j@N!ck{k^Stn(vH6 zpuQ$dS8G4W~=x>1+3Y!aSN7K8}>uI5bR zQU^~Ev`M<3Km1OCHlNb1v&cPyFL${bB$cstDVH}ttxAq+(p zNEaFp2f%FxpaH(ZTRizYbD)VO2QUSops1qc!-s$fHU0E*{r4WVwS~cmsTB!rvWQ($ zVI@yTx1x2(7$h-16AxciP1A4TynykJv|V3Y+(omh#4&6tm`TwESP4b5Ja0Pv^&;Ah zl=4cgrUzbYLvF1e!}h(R78+^eF)5`0Ut>l{#VdnFx<*Yvd^o-vi(WY@ngkiO413Ld zO?HS*4@N=n4A+!ES5(M}+ zxT_EcFk@bGSn^SPa1q0RVX^mX7c6AQ0RtnM#th@@EB)Q(KO;_QMJ#Z$+EmC-v^+Fl zDD7189MR!$8s7W+dt$(g+Z@`2NT2WTDd3379fjF*bdUnrq6BC`gm@%A^WEps=irC@ zq#h|T@DO_ZY2S{!Aeh>*ug@_Q&%I1gIAzvM6&6j#fICQ>@sZ09Hz2JoA0jITySOj= zXqN8Nfdo zLpE^=c7ibeuWFLeY-?5qW^oYn8*;IWH!hqt=x*kIO&iL6- z!+5$bByxI@rBJGi@ayNg;^qg`_;&PROMrHph7pO}hW9ErbeYo+zA#zyOD!uqYbnh@ zz=CcW*(%&21{mOoXz?M_LBgDos!H7yWCP3)X>V_J_Ox+uouHQ_Q`BLrEDS$o zc>h!(fmKX$u9O+oRUkmBd~yQiFb4+!6z_AHo_v*U^w>iMg#|ur_?Fsj8aMc$I$E>m z>yzepG;u-KNIW<{vYgvwek!i_D$1GeBs5)IhxNNR=wd4F^VSD$O|-oHFH{F}oCGmM z*99K7$JkPQ1jSO;8&RVjB~#GzmGy86S!~7Z#V=8(qv1{A)ygxsbXgudrm|)O{y;cj zRI_~MtIsL+-&PDO1i->ce~bSq|F3H6S`!vlP^teUZd+qs|A+vx=OXbQ{f^rZB3U4z zSV{=KGQrJAE}8kb)Jc^-Fmpzb`oQ>E+X8izgdjspH}c#r`Ak%6ebJC`2@7nFpo znj%?y2~TreV{Ff{^u*rKeHXq-hdWC8&_-Flx_>wL>x$H-@8sH332YY7A_LL$PGBdd)bM`re6 zXI^Dc(H4ojY-=J5QgTH-y_Mfi@77gl6~@6|`dK|zjKBai;CnurBhmSvJLrczSBP|Q zH@#M11_*>)+hX5aQP!lRr@iAR3V1iA5C0N3r9N=XFjZ;Nu<`Ix|DZqD_fY?jQx$uS z((p%yCDgaFgtFAb4Uzzi8)cUI`-yMg&xWN^APvb3qP#~!u)_UE$JpH@uPHefWVWl# zkd18w%QKxQ?H`VV%bV3;1qUqjSrQodtw5V-v)B>pq-t<=&3XFe%b#91V7`wv?{X39 zkjxhdh-x5WB|&fiXdXw_V;%?WZgaoFw@o|3cecvpwRoF={$=xiHZxi)P%;8mf z#fENj`(>Clkuh{C(x493e`E(;b6oig6gBtg>FrDWHltg@_U~P=`*!gvK;W9bUAFbU3`Nk}`(7sF>HDWRW7Q zbKs*ps7M#_H*Mju--VuBuN-Ri-wbGszu#FBZ=5L1*6*b^?5TC}NQpV^Gav!Jjzj3W zm&!%)R3uTMS*5hOyY?ViN8-lx8llMunXxD@IZW4CsTQK`pVgkZYr$Ous?{na7u@;T z@87l)!#k)AW?Np%+?8q{dd-qg>dJDN45E6vzBZ_{AF54H?g#H$%A+~H8;HH!R~wgW zSyom*a|KFTOm$LVvd4ebpI-aD4@>=sjIrJQc)kGv5aJbHAy0D3h!CPP&z!PtT7bXt z^xd3AuBoGIAK)seSiNI~vqan`b#Y{X?eGd5q$uP0v!JrBOz+cBzrkb&a)5Zz#`*!e zx@iDgq2%{}$kyLy%XVTdrvs(l-IdxQEka5lERW|0M_E=+ZZ9FUXL{e)$6fKRiTHLS zA~cE!s~Oa|q@;gS@$t28(uk&kB|iJM6rM8xiP;TQOYrz#o)oSpR!NwU4mn#{FzOxt zct>DX0t+M!7*Oyetc7bIh6QqqUes_^9afCvHUNDX(3@?>RD+ddH%%EJ!gi}vy*;>3 zIeUrn>;hg47~ziTH8msq_%z!;GOPdB37?{z>o!XH;G&(RpSf%Y#^?az0c z6Q1B6&frsuyf)+#qiNMqT}<{eFd6}<5Kt|Ligfr~3!_a*3mO9w>< z8!jwj$ZWg5paMNt@3~j@_35Ph3R2EJz{=lIILHN0fz9maYoQEgGv}F6*rD4H9tK>bW=>6!)526F7V%Tf1Dc`Lh zEso?bxC>DuBR6x6zr^RalKz&CU=|SCt5N*Z=-L}I_Jff+9JL;p2|1aO{!v|%ihQ^A zyL$CXT`J8h9LE}gZ$@I*Qq*OFwH=U6q<8RS?44GVo!mtd0ww>Z5f&w;ed;(l`DZiK zETy3@zz%;HM0li0y1i;_?Aqh#%*SB@dT%wD;%f`!hKEk}_VrpuJmpvSuR4l=|0B@1 z#zAl>vN@+?Z$)8Yhl=0-%V3t<;C%8*<<4HR)3oqsLatDJrM}|sJonH>B3MGg<;=bJ|;I{d6YcKz5@0o4c1cR>RaKS4gx;>Zkaz4R=AJy&UGoy%XQm$5maW#%%Tcjs6!0BY$dbFj$j-~6CtHnJ zyWo*fpP~VxNs!Zot;iDmu+{n&c2Rgw3FwW zwz@X&mcemYt1hBrSBhF2r@fi$b+}n181H$%7ftrN0@&!QMA=7q=U zq5GN{*Lg7OuRx-4wd7L3ik-b7)S9b_M`B|T5yEcwLr4@hoFDeP=={-nyD;wGHFZ!B z5k1xtG7wN1h%B@IvB+t`_ncQj*jHlw=IFCL7O5TuMUF?|oK~{!*vF`xXCChB%BJI7 zsD&bjJR<#!KtEz5uO1qE5!!$B`AaENC1xNxYGS+@?LQ(|(y~pc;N&l7{zpBhzU_THBt0JFcB4dqXU$>rk~hoxzER^@HC5m15ou9{j!#W(_)g<_+9I4S z{ATRNunQ&(!SAd82Ozlafd};IEG(dRMT4j_7UgGR;hYKXOT1}1@x%f$J3gkQcYJhn z#&1rOI3W=L%MdJ7#a&s5iqD+82ii#XxiL!{KhKO8`~gyH zeMY@oC)j(q%dWb>ajQ$v3wLTAUv~0&;OyEuMMlcxGUt*&`P{v}0k1Ic|mg_23$olIs zt*}Ni>m5%T)g&<)X_A*S-x06Uf^zuQIBQC415%iYM+A{ef~-I~gh^&sxR5rlb~uV~ zLhjuvApol;*AF+08Klp#38`*H-4B~Qe5%zXg#YPfD)@SN8AFfjC#}mLL=~TYvpFz# zkf>3Ez9^BAXaJY*k7-9eaK)CKO_sN~@S+Lrw||w^M!+`h5>W%y`i0o^$YRsJ?ze<@ z<@)c;_nTPZC)7>-Mwt3~?|V6GQJN>rSn#oZ5w#eKu6&aBK(ECMSGjo)KqLk>R{}c< ztbLuTAYEq{3+oTZ93gJUl;u9Gh)H-j)xi?GBvQtHh-%#dS{Dvl(+VmF!Zo`@M?pD7 z&4!jO`qc2z_H;hOMjf0e%!x|2vOA8fOqyy=EJ{xsid?@%+YMH?+n%LBrPUT&UAXsa zendLDOz(@A8WH|(28-GZH6&}tyEYv`_}IX5D(!Z|yQl@3YrWXF5#SN`-> zVebo&xPPw3@F;=+yq0LLB(y=vbpsx@FyJC;n>7J&>HbU^Uab_uRtf}g_y0?doF}5O zOK{3Xv@txzDq1rC-=u&Tkg-~`enJBn)6QD|X0KD?ejYAcZ5dk zxJ`13QPHuoXOyOPehto(8!^45B7eUUfs+sfkxw<8vOm5$`1gXe9AxJ$JEt^C9av#FwA&5 z%5kieEv8_1ZeFkhzl86>Gxblcgm^3$INwZ}JH3xUMi^!Bb241~BAJr%{b9lt{nfk5 zNgcC`PjVWod;jIf$M}!`3#vYRysE|U+rh1qxX1gG0K(wb>j5w#&WApn=;;efl22b$ zKGx5k(Vp1&_OwXC0f7fE5Y(Yt&i`l^!^|I z4Uc{Qheq{#sGTgGUTlHboG*DGd$u-cMK8=X3nEjLLrW2&O^Uw)DVERC^*YUYmZqXq z(jXrO&fLH#j8R_DFBrArc#gaf1>kL2@VhiS9m)_xYzd`cAA7Z>}#AB z4je1r@=fWksFNq$`a0j`pKyG%f?fShfppqwInKn5f!J_jYH~Mq?3Yv7j(^t<;iRE2 z>E$a%247D|!ifi&Y2$mOJf1qBsRnn~yTVPKpwLf&GJ2R1xZbrC1#p+{m?x@2X`xuq zfzB-(sOjCIVUb$9#YYlT7c>A6+%{G6i=WjGlL!cx&%N|V`WyqO6O78^$wHUn@MU(~ z^(sollZW4a-+ZDxZNUrEGx?ty<7lq-d|eZZ@Lf5rB8PpFnONlVJrItSet9gjZ`0Xp zViRcZn+8-{Ikkd&0k+o&JI8Wr;Znwk zj33+USel#Zd6T1SqSepv-TkAHQCQ`}MjR(ZLFBKOERo9wJbR2Gb<5h1Io~E>KtR*J zQyBMDr0lcj*}r>iGEKkvo2|Vr+X=gh<{=p&0Q}OlRGM_TaFsywra;lB>O;c*dZ_)D z@jrCW*s1IOn4Qo8r1RnO}3gd&Jl2F7iTf0{@^7(l#Cu3PFP*+{xf z3Z_CmUX&P@lKQcU@t4U{uW=S@*G5>BL>t$*;&|fZVvx;FiVPUTA&Nc$B1VR!JaDZU z-sjp&EM-s1L!7YHrW8ZT7at;#spfC$tR({ zY4o7<*X$oMR8pGsIoaMdHLbn^D<$R3d+i3OC zunBZ5)UEIM1YcD`gg1wz%+}#_<}pZKNu2aIyGc?AE;1tPdgVv6{w*iv>&MI8u8k_= z-^AsLLRrDwc=ZgL>%k+j!aMHehWcoQdVZfB3mW!ozp}yx$`-ARzyG7xNkv**FSi#T zwEFPwe;MzoTn4p%tGKvj;<79}Z!yWu+nRME=9{L+d6>L&hk zsRnkZ^!P{~ZnC`Lv2^2M!9;+}%6PwUto(2lIB`VsdG67m=@W#&p)Ixd=MaNPuxWHW z!A#9F)^f$;0l%omj0 z+m$&1k&z2hY3U~cb#J^;A$V^1!lAhev8$J^AAhEGNXO91o5!#9bouJ}@MF%k+^LqiHISY_0b@zpK8)Xp7wEuKs(D)hmVf zU5NMK^mc-_Xx5CEqJa@*ew&n53jKOj^p$tuJ}rsvdNIcb7`oiW`8%X6p0E$NsXF+> z0Wnt_=~BKkLYqVlI4aAY3^K<-UjYA;BwDe-I7MQiO~(EK5~iGyYlLHLE#$hvVTw(U z*rfpwBIZ-4?*mz=aZ4?6^L3L(YD{^G!4pxkhYj$-}f{{U`@*|;kj(N!`zI!dNmPX!3YLRJ#oXaYa^eH^k+-NWUcCHWW;Syo3jql6~w-$Q*%-Z^)@<1%D4?rkj<`{#VU&<^X9 zR_rQ|)g5uPzSd+Yj<&xEcz=9hZ*?PzDxudC$vPn#odC|bC$nvwt(T-dn z2LdN)9y<5z@#Cj9$7nE#KJEz$`Tvb)XCZLZ6Ybi@5+ZF1pUjIY$as5*|0L*4UYx1l zUFIy0B+Qsmn{lZh+QY~J`R_NW?bboO$mFp!_gmiBa)L*ai*1tP#8~L7yf0MsO1*ek zM~5W`3dJo~*j0L;r48&dNkQ<_DrlWs%?)f{!YmVHc?70c;D|K+UQ4MGE!+G;^D1~k zFuA0HW$2x|w&H%*Z0Rn!*C2BT%0#=%%PT)`oEPV-QP;XXQgVF&_ho)8dGFQzlV3(q z*CAXF0oea@XlCD@Q`9(>!!rK;p+whw6`F+s`@h7ZG{f2W`Rm@uruUJWLakQWXm5z5IaWs>lhD{Goa&rcZ_OYw;4k4O<`B1b4{Xoj{94)|3kx#D!7g{4br z84*@VI#o1CvRGmUzm@P2*^A+GzuwIYCi|Vm|MvnA2G{|N@~ajQ#k36`>grfxMGpvE z)?E_ETUF$ImOty)-@tKQM8EIo*~~I@MjEO8#;_-KFLLqkK{8GG8uPNk8TS34AstLx zc=oj3qV>rL+dNW!pFwTtpkC9^+vX{xF80==nXjr!tmeDg2Y8c_IN{?(z~iHUpyPOU zw_E5aEA#E4;Q>COj{){5h9`5KVnftcJJ^>_IzIAPlKQgHbT&yGCe_6R_t zQx9ZFGHBgkl78pDwV#yh7ydw=`~;>sM!M!*!<+77Cz$m zRe`=fRVfAjzrCj&X+Qh(sXU;yJVQm4U=DZYwo8if!zI;B+&YXcz2a<+zZ+sRT84JB zpOwpFtWVjvVmOAfdjCHo#X zAde`ANM}?LhDk*SZ^^Aon54F0p4CXfuj4@vD074z0|4m|d^}cvVa;Rv)@iprC%>0> z4)oF7IMlBbSyfr!Ssxv~pQl6opUK`BAa^K)Ssm^hCrLb~KPonbaE!#39E4f!`?bf;=;yVAaf-FNe;SdpDyu;_1*RB1wfN!r zgqj*aiJv^l4e`ldWU0@{%*TpM#|TUfKjcPvv^BVwY0yFJihU~|fJU_3!NAKXzeiti zFEYr`&8Zn)ZT$WP%F$$c8;lYfrMPPWA!eGddODH!3zV4?n-Wl?Hm<~_e9va^^@iD> zXBmTtS(}@>G33JZ+K~Ub@7D*6JbSrnC-?+6&A^99#?+_Y`Lpmylb_i__I!91ifvze zl8yM4NHo-TNP+!ix)@eL?mxrg*x0k)@3^aXrJAgsCcF@HupXR@4f-Yf!)}$I&`cf3_wp6;0}ftA)@X4d z0M~*7fdMpY*eXm22kZu8z}W-M!5E$2Htry+CRf4U8<}2SPQY<+qC0YGeWXfNZrDCvgpRUBQmoe(r(2$EDZbe0sWAo_K3wib%?UXaopaU^jlqyx*@ z)_oos-ijV-@cFD_dGh;Yech}wBM2aovL`&FnWA<#yZO@3w~+C>W%%|u!})16!iKnj z30Ga^B=h&9Kt`D6qySZiXxqiYkIAVe4QvUobKB*@NSd8sevg`acfc=0*=e)I1Z5s2gch3hg zebKHA-S@VRm?Y2HOC+0ZLaw83RWRPaLTBLnMk^9}7elZ8d(Hxt??kdikr6uR^@JR( z9+^hpZxo(4R&~4Hft%WjYy+7X{C@t7Z}U96IyY#)&rAC^i?y;$J?7ywb$)OB=6&jH zdOeRWr;QA_HC}?nu!ih7L6=3L?9ARFA1gW3a1Wl1dQC@lYfeh`VNS55&3E~KMqZJz zy0!BAFX#gdXt_30lQ>YB$-DIDD~Xhjpz8cp+T=HJNTQYU0-=@P;1@{T0k*VLAX4~o z@WbQK)eF(-{PBtD{FbnDddI)3&GFK#QLw{GGk1fJzhj6GWA6=HP0jh=q2gfa)ELgg zspbykG>no6Jz;WMZcqsI6g<8|XkTVM&xGDuJ6q|YKb7y?!?eBb^KkoKMng%EQ%irH z8HhH&EeUmfTP(191%)Kg9{g?1?8m3-Z{02LZaU~R^1TdSP7a7(j{gj#tPew1@fE-) z0wLQ%UyuOLVWG(f`b7(cKPUYW zmZ`n(l%K1h!AQ5z|J+rw2Mxn;0rp^cMw54;x_P`;&J!=%7I7t__{zw0kC?56q*L9d zAIe{X<$m;|tF7lVr}HB_ViOyfVw3VIe$}l%8Y3=X#;y5L7?tBnT#7yDGNSLyAO4g+?e*fY-JUOs6lG+8WJgIos)`C%6o+wdu_S!oE^WU z(xJsTO2^Z?2Khp#!Z63C4*mRwpEF-mp3i@<*6|zj6Qx>;AD=BpA>`o80O$5TkXccD z%wMDm*IFgoCwIw+Y>LXrOZ2SpI42T)W`y1NT@-yt6D>&Bkk6Y-Q*9}f_C21Os#ZOa zQ|}0~lm5dAtd;oL5Ye^%Kq1S3A}1@o&xeF1#`yQ0`%s!&JRuFxrbB$VB-=|$esaC; zpx*YgyfUd4g$Ubmrb?gLC*PF|zwq_Cze_2KNZ(8f?iJ(aVc6WBi4KlWHIMw6)n*DO zc&r)RrDo>{(y7_iu8ub<3>p>`!DLue&DIJn-p~K`9`94E*PP!}S=!a|m_tba!~TTk zXKWHp_ugH&Lg_o!CJ$sB27v9L7+K(RAqW9J&tG7t9F-RUXXU^PR^jiWdEF=tjj(q5 zvH3Jt7L|0Jh1PtWgl3N7;V}^sv%>9cd=zIk=hWj|puuGS2*Qy?-^9q{%(% z9)sALM*&nO6mRi3=+w-#@gYhTZY)fcV?8$`HfO@6zhKtml;rtYx+G*d$F&@;6(rUu z`H|?l?>+3}lOkVYuK)R`(ij7LcnacLKs$H`#DNz{vs@qIo_oVyc?y-TYgKCcCcaUNx{MJ$tui7f?7msBb!pu8 zhxFr#!m!nf(iy$ll%Be7f5=E3N2kfDGe=9$rs4Wk(w}2>@zTf1xNfT4hd@m3yoM!OkIGmom)?p*8(gNc2mOp=QcbO&xF4uQM1{_8Cxv{9 z-UT95OL7JJ9NmR^*6ezj;h#EvQH=;E_Qqs8^_WxAOhVRPeRsZGwHu%VfgO+s50s-b z%pA3w4`4G1Jhl#ZD1F*1DfB>3nxW9(UySQ+I-KbIs5SZ5n13Oskk9>$gyKv0RBgm; zcna00-_jO(bYO(KSZYHq90)_alT53nX!^?ey}z#zx36Yz0%3vkA9GxB5+FsLtiQd% z?cur7iRYG+uNh;jlw4h`xbHw{mnB0aN?z4&KNmlW8;^${iD`j1;e97T-iXydk+~tzO;aDg7VcZ@r(+tpU&-H@8PCV z!=prATl9oTaCSq6P20)Q|bGn!azO^clDoTYhdUWGXbGVcR(i|f@YlE(h{lsmI# z5h-6vhg4zOFFzRmh)t5MZ+IWrRabjk)tEzUD9<-O?%#H_{cqHH3483bN;y7L3fBT< zk{_YqOL92+{bs|jLj7~Xm|^87cA-7*BG+@jSp`>fml-Oc3R|k&%hAe*+Y!qgyxI9S zC&@e{Gw!X1$qx}_#zrn1d$TZM?X)kuqvAt+CV|aTTsO+$nC*5~6;y`F*HMHjAM-~i zpj%XbHC0$D{o((fzVvUiFZTxY1^besK4H^Yk zeZGg=k3mH;t;WHr(u+CM`2@psl`YcAc&D-Eu!uh8-z z@!}HifQIC$uN4L>nd^c6?KvyRX<5Jo9>XM}d)-+^c3v_3zSBX&vERcrHnvh$h`QSn z)cC3B7v%2TPsj6lGtr~pf4Zz*Y%&D=d4KIi|n>CE6N@h zS@|Ik=M#Q}?kWrLN?YbB1{nz1nha0CusJr(WeSDebQCSfi8~s)&fZ*oTnRNp#vUL! zqG%E$3IQvJszb|bEeo|VLrx?+S&h4_!b4^R`BIMbwd=8}xY7ruvEMW$?cyM3lTX5; z>0+fzw`+f?7l<$glU$;Ip@RSp{q?A_;@wzq34we}W7)SdZw*D-=U=DSff&^)1AR2GOL;%VYBUy-BbpakhQ z-9wT3x=~y$c?ELYJ=7m_M4y>pFG2zULLFP;9j8X~ea$WxF$;dti?nwYF5Z+j((;qI zr-Z9{a>2TxcWWNGYx=IDHntOup25o!(ZFi6gB_2oI%ScE)1_%fVQ1WNwZ2&7z-1W@ zeXW{vQ~pZN6Ju#Pj?pFD;GU!xNXNmP(N@RZYlW>7kL3K*qMmDcpWI~D?Vp>mK@m9C zP^T21S;3Vyf8E8m-q5pqD6;EpMaW%>uSmb@-!*-@3_ZHu`3%2TPfFY3({;FWrEhS* z%+t~bB@|3j_k3HYZ$HboPaHX?v*>e;Qy*q|59C>fbL!SEi){V^9FW;Vz27|34fZ=) zZsG;?43&0q#OeZb^Y5*$0&0Q6a^EdH`$)}LBXM>t-{ZupcY{&6dhSPLxkWR^Q@f7SaBx5s$F+gV zjsoGU%7{g&Q%dRR5^@ck4_;!DHhmABmq0>Ou;&=dr~q;$kZ0de%tXurKk)hdHMC_8 zq(RYOH+UO?nb`!ohJ!h>Hhmk*ipq`Zt^__eTvb25&^&kf9v0W;h7e#oOxXTH&rh+w z`OxGyn}SKn?$Atou}2ndY#&tJ;|-5A6&tC28uWpx1FBlz^3s(`kjb{8q*3;sdPb_4 zPVl4PrG3&5`fKA8H4>GmC+pr_X8ed-yvb_2&##;cKT)pE$9!>O5tu@qXkVcq<7dZ6 zVjOPCeCQghvVYN0>hW2!4`F9WkEHZc*hXyRz>1gL`qQeh#`9*wpzI3sC3zxWCD9nD zg-t?8IbCl0VLVZGgpg!HKav>JYW}I)*jQz!=!s*kX;P9Q*W($tG%HMoCU?UVtrq?sJ__iyv?F@qhuS z3<>;CWkqSvyTj_zJukF^-hc-$WxNy>A1^!)d*l@?2d^L(4xjvUdP zdZ6kdb&U%Os)hWuJJe(jXT*f$z77q4E_yo2*kgR!%Wk;N$OH2w+AZV+1K6mq@6C~4 z|K)KLY)r8MYNu?s(vdfjj4Hj^$rvOdie;ApkI2 znN~|^t0nfzXOA4VBm5PIJjq{$@l7YFNpal;_nj!(9_uX@7&5vjak<|@K6>IaznUgZ za>#WF9}@u96<_LXeGa0x3*91woIY|~-+bo$lLR}&;x~)trdET-Z5m}7x9kq9DCXkz zG2_>vcf$uPM+fQKxL`EhXthDVA2bK*t3>Ggxp>~r`{!Wo8v?k+$SfUFK*PW3d2Zv$ zPKqc!H*_DD_vt%eGr2S7%DJZ4WS+IQEOKvRRtPYz%JyZC{veY+sy*c7jJn01QLj%& z;ooMIy8W3~+sk$m;1O{fMFj{&B{s0Kc6@17Y4uiGI!?ryoO(Ws=DFsIs>|E; zs-`VBOf5T8X5H70&Y6~pfgWDK#bACekYKB-O5x*tM0+g1rC}Z4&c6ndMfT=x(m+)O zGwvE|PRF@!C_GrKhqt&KQXD~BMq)GUyYdMFa0JUqpbeg=dAg|2l*sP86}vp^xGiv3 zqF~_lS(!25^J-CD4nV3!`F}|kYe&RbD^a-|a|D}r8Kulhy*b?U>``V-al}LMRecBC z^7-Pro*!tcUVOZAg=56jYeaivx)J%{uWu8kb70u)!d;amQ?$BV1d6)adi(D+rz(wc zH60l21`R6lMqpLv@9`79MyJban)>ba^=Lnz#2Zyv_1ejq56pu%VzK#L)^P&w_9V#b zmP^@d1SriaAgN5hJtMjFxyQ8>oLPStUgESh(DMyCRO!YapUyZ4{VWKm2)5>;y&n># zA~LT9=#|1bl3JzuV14@X&|~-auS^;*k~sr}*uHzJSlH$PV|Z_+@`cuH2<>OQ4lG^w zNq2%XbotqzC?_eux!%EZ`;H823fClA9&aHf$Da*bGhatuUsF@VdMmZGEtHTZEta&@{(?Iw`{IqT(N|sOPV+iq9DR!)%DDcW`-5Cz{Pz4s6o^8A(>@fY zu@KsDZ9m~ENj4;HpsrttN0cqM8t+E%AKnEzd% zM{h_|K8E4T0u2BR!>ngQGTPxjm&R zM5z4abZBh9vrw-Y@kv5~wBnEkX=(*UnoUd76n(he=xLMSD8uzlmiDo9`MMF*0M%5G zfe;;C)tiegUzC11o$I0BVZ>$;{^C%zWV#2`T#a`Vrye&vK|%UP`{_?&wx_Fz>v8I& z8m};Rz>bYdS;Q8Gc^{f&SonZqX1rmuNh($7YO5eA2suG(r$`A*Cx{6BQP2L$9SH*N zzPjIc#ZOkThObmyT8ff$8m2U#hLL1gQ$0>a8F`U(WqTO_hr8}(+8^M;t$WBN^$X45 zx_b25`$|vOivZLa#-#g84e%`rnmzz0iEEId&r;EmlTYm z!45Vz9dhv@_<*LTA$5N_2TY>j28x=y|f}RI4)-CcxMLlvo z4yI}zWQbT|Ley7u=n3(?ker0AjGzU-4CzyCM9{YYmvtJd?H<54TQH_J*i<_T8DWfW zE-<`?fD~@3JF@!vaQKhYa*6fLZdm#8QshaDfocpNNa2?RVM%eJi7)pg#DM*W8zx$1 zH>BBbu4O$I0A$OOsZwg47tLNc*wQ9qy!Serz4jk&?v zjk~fbd)b`KG4>nNeAN{>Ej`dyfGuB-#_cfSj5RxC=XaO%wTun#G_1@0jbtN{cNuB|F?WFFKfUZq| z{V_RCiBpaW^LA@kS*gHlZ$Xh&j_-F4$8ltpH#5YNVN3mApSxi*V`Po75YI(|ZI;*b zN)JE)A#;5CJnN4)@6`)mTC(NSdT!%{8`9!53wegl*GK3wDRi&yvrV`9&V1s`E8ABE>nK2O1(hyev!ijv;a>7lGkV7*Dh_6i zbPT7G6)MiY!90bgKMzFQ)oi15Sua`5FC)2sTr@{-E9+3*0W0AEBZ6B0N#!SFS)E$~ zT5h<<*ob2BXIQ_qek!RcSYxEja3iO2hC)Pw{OV)Hy%B{_FO8XWVFf92`m2leJf<7@0B7p$&0<$@(@~) zfp9>fT)*}i!%HZ-!Rj#?6TU)Lf~tAQ>0$Ag^T0OiP49!-3O&rBwAT=C?6vIA(+LbI zggO<1)bK#bas|_~*E)}Vm#@k5kNy*$WnS65#}<~dC^V=v76`a}a0n=Ld0%gh)-3VF z{c8^;`;iE$h>#?4RNz#r(6hwEfDk5Bd)!`1YE*e@i|mH`O0P-ivnp>qb;el^{Ri1c z7*s4V=jg1dtn8ofq-XB+5BjEejR*(^FSH;7edjCIoc8kPS3ftQXqWkw&XngTp%62; zA!{SJ%TLK^A*g;uDv-OQq1NCAcyoobpIsd|xh{wN`E>_I%q6iik}U((79SEn?jD{q zPNaYN|6YJyEaRuN?LXL^%Y_`d5YvhZ!;1)4Mc*BA_M!{zJ~hP(_3u_kadrM}zQA-? zUv8%;syD)6Z8>L}=*Cn3Xesm1FXXrYkI z)>etp`OVjM3-$gl+E(G<=XpRLydXktG~>fAd(}qE?PM5$SdcC?olmgk{{g!|M8E&} zbBCWjwJH9Yj)EmF$Cj**==9`&V0Fiu2Ud6dtn=|Pb%aDT)^Pl|+NodOzu}W($JKqN zC1o9ARI+eYmqDAecGb+7^Y(WRAO29vv#t{?9^ZKlQMJruk`a^t=bDZmUi0AkD;BOz zrNbX{Xd}7J$u6o?@I@Y2-TwLmtJ^P~KdJHND^A$sicz)Z!yN@HPB)|>E|b}kwQ|Db zhD$y?rQwc0Jh$aF=UtEcu0LonI!PB5AN}*X#Q+v>{quc?#CZDX#6BnQ*Ld20Oda%< z)|_7>icXo;fF-Pl@V_^2{MjYyc?QfomE~u-_@d$v%>O>T?&)JE)LeeZ*qU#o3f8{Y@>)S~ zAjohOUmt!d@*F`@@{n?;D(hRSZ*($??)_TfiHD!T%!43T4q z%of0`+>+1!@#*F#4!(BfXRm)^%gKWiPBzSf_HRR{ph1&kh>RgJZ+0lZygAqQ)5kYG zJnQCH&iKyX*B(|EGphre%ksu53aN=0{OgVM9mmfcI)Bfh@gws^wOhN`h^ilP*wh++ z=E8f{9(LS~uUxutZ7NN{Sc0bG3f~vCTmL5tpMo9(LX~y&FzH<1mOQMEUR4Why1(=|J!#m$Q0Q2&ey!`R$mX|;F$2H&j>}{{l zEqJop5RndG`AQi4*e^{Vz0Ws~8h!i5l$UsK@b+F*Aw)2P;^wvU7Op+|r;luSz)BF0 z`J%m&vhYQP3D#wbp8M#grT_PQ>m7|Thy0zz2C+)t5x4^!(s_e~$}IP|Qe9-5{;~#5q5KNCQgOO{iK6PSzCjf4S*% z{u^uZFs59&)lP{7pU#|f*Bd`bTYWKH{BJW42@&dS9#;%G7EdPjMy;)2@h@U28h7lb z_5Voy;a68E$iWUaDX0bj-NdR|E9zTS->O;yEEC9KVpVm&bk7@i{bu==|JN9I$oBK% zMHL#3)rJMj(mB>GMRLFQ(5A=Fzjy72k}fxg_ra1EH325;s~w&9UOaBU`X99xL>+$j z1|q69>KNzEw&J2W*S++CYo6G=j0uh*A`QmU}=bI1z-|Fu?usU`A zkQ%p}PCnpFQDnxb>6@^vwZWjUw>fT#~R`$ zZ$kHq?* zzq*sBPc@B$r@-^Hi~zuZO(C=Nu{+->2;CVVih_vxZ4n*rAp0BEZ3%7j%fF`=3Vk%F zULeT0o8kitU(39>?0k+V8B#`e*cg&bT5C@H_?6CtNwpBG+ti*A8>raish>W@8MD_% zG@rF4Yg4SXrmlgrE1&vS`<;JYv7}ulje1*Ivsz-**&G(t zOu}`8jzWXYR3`m_5=Gqh>>sW@Ls~X#Uv>NKifRDRODKX9#upVL@&TAj7p=c3=gBHV zWS|2l8yqxX>KvKm7YuCofx{X>|-aqVR1u7K>q4ShAitr~Gc!l@Gtt_M?WVact!txx_@0^(1=X z+~Mcex&%?}aXL_f084yP&tY=@^i0cBPpnPflZ=?26fgG)p>~Rs5w_Q!ve%UjaWlgg z^-J_0(4vS8t&Pf$9@zX}*FLfNW!I47i+X)I=c}mD01Tk2^b>&_5a4-K_}BfnQY1>{ zNqWetSdPs;?4&R=-}m#LDuW2|gAO}Utr&nx+y~T>>ymBT@`U&Fl2_2_@4j=O1EEg= zxo)Slw1VzU1310V=>R3_q%QsHP5zd*@1SVhDZ>?tp(?7fckSt4`8JFZ1oEIjbSqZf zNz)o)$De$qw6`jZ)3u~l#8Ncs_?y>1l)n0ZE(g%PZo@kvR@5@VeB-fA552Ob`0JVo z8&MSn;HpKdN#dKsKMD)3tdpGYTdr^9^h5WYpLuxEtD$Sw4!_>30YD!R+FatTj-tQh z_0)sOh=B~C0AOoW0af@+;9H+5M|MWGyXdD8{=zWI?? zwiMDXlksiOJc5I095(BTIscEZUYyDJ|8z|0>iqY@1kQS*Q<~x*n?5QzR1~%nN1(3g zoC0&6(Bp4r?ny=%qB@{v7{g?&!hZYcktZC`6g{&wXRYblPaRevjZw!u`BK{tfAYxs zMGgmr!#irsRjRy0knz{T;}1Wi+00v^Zn;ELFd{AF;ZB@-5;ruO&{nbU#u$hjEjKh$ zWa8A5w2&*^(a{Pho|7+@<3)O7+w*k5H+AYGJAT8vn{g+pK5stGV ztcB`&J;BVH%i7qu{mx4sebV&coM!{TP$VXGA3yz4vbsn3I$f;;6#!8r+MfRX^=H`i z>jccYyPTWBhljRV<#X?O^oYHFO~%P-uS zzVYfCVT|c7tKVt>&_{$a3#@7`JQ{Tff`PRV+#0K@qL6>RntFY0TXA*N;jR2x-j_}% zjj;@jrmZ~it*F+=UApt3x6i)r$=0_FlkvJC*LgpD399p-E?E7{!nJ4BxSSEK_-461u;%;e}$QRWk$JN)Ldq~qY%~`9iDY+67 zL*Tq8FI;fvYv1qiJ);V{o814a3^W4UN8_)5EA6d$X(2^prG4^48IWlhd_d%Y*;7EI zB_$6%ftf+1kppJ$M-77yEJOaIATnzGY<}tDyTdUL$b``VrSoo-2C(ZTCPW0raPiq? z8?yJ^aURz;lw-R|V1{Hfk=jpu;VLt73`2z=;wL}!kvWm6Gtbd%+Lr86YpqaCEoT=$ zaDMKQ2iNphrn*0|64WC;xVpXN+Q&DYpR9>ejhE58>!#!8#>3EPGw{nbCsb9i!+FoH zOa12O=V&fVQ0=!Jv}yp*M|4_nKDRORO3^9?xGkgtV5{(5rq$t@So#J7jN9f#6gKLx z*_bNaaqeRq|6&M6dwHuctj>29u55i}$!qDK*T$Ho!Ws)E(p*vWgZtE;5-|)bD!r8u z>@1>QwI#paSKUMdDH|1ROv%NE5C5ve*h5Qaf8dLvG49Y!D_SmI(pD@uOz4h`t4ft# zgBegke)%J}k{dy|W7!o13S>a>nTMSeN-O#@aL#>-{Pv7{{8$sM}nd<%~Pzu0&XVKfYX4bXd*&1s! zmkc|_Iu$N&RMonPI5pk>yGx5NJ-eQmO@ChN19?%pQ`5OZdhCsCi)Xb!1EJOU_E3Nb zY^`8JTVa#KRL(#d+nzrv07qQTop<-zuQ%r`0i=E55uik(3c7sBhASMVW>x%Kb$(Yc z!Q_0Q34`Nv=Zvi#q=59645*W+!|fon_U3E$1VhADAxDp^+xr9K>b~5T^Tm{~)l|T` zOuQv$ANkp%o9^MD;n{9E0aO`4gaLnH`J<0YCbbI2lx`SD#9GKBGI9U;X3$WEs`QBr zD7eiF4Qe7YaoR~JgE=OimMi^nRM6ohY5t}qcxdqA1tS`x1}e(be|F*3 z0HBYFyOd`j*Wcsl3I$+@knt4{t<7{KU4~S6or}UIBTUb)>Gm_-|oRRyD}(9u{3oS9flL24Unc zf3h`o_y3*EgN6XU|Bfroc3?0=3i*b{3oiXj^xzL%Ea?s{;rXu+t6Wn6XS)BDjlYSx z9)*jH=PuzkArrAcX5lDed_SWCr zUs&<@Ra9GN%FyI9)G8e32oQ#2tD3#-yoXRUZoJo4{WAT#U)%*EXSWYZ!vH99;2~p1 z9eeV%@l=Z=C7LF0P3Hyuy^Xh{Nm8^aOM2?Gy2}pVt7f=W71P%XrtMG-0Q#O%5&D2a zyWv)(3LA5nR&U8IzUGzorwO2LSaeoIr*8k|r_#^9-co!b?l5UZ`cEZ+A{lXKMuGyK zUipqX)9Nh%HUJXotrzYAs!FaQTy%KT>7tsTVB5IM8;+_<(q$}pBYpdtmO`EYBfD~T zt;)_tkd37DlaD-QGpXm`IOWKINb`XVh#MNH*WG{u_aA|A5Z!aad?^;*@u=_Ob1+3? z@Sa(AtLC!QuYUSFIYFD7UHsT-m{-i@OvjB^eO+_e7vMOiWIqfLbvkI3c!!`9yx~{c zufO~v6*WQNyo(>#t_UQ2M9CVhJ@bO=i-l~X5O>)$b88&6XfsKi+4@f$mwpcIoM0Cs zY2P1q#R;Q-R2w&{s-{08fNB6xrN>xR)I<#yu59~bYr05=iTnC~5jGt#n=(EYy_&i$ z5m;`P{jN+znWA9&h~&5_!xAhC>CGUV0J!M?#Xl2~DD0Hs@%qE|sQpB$ASkEa!75C| zHMoCO`(G*8(dqAxQl-kf1f5ciYn$YWRK+Ft~ zME2ZgtcfJ&z}lb?vx`pwA{MW>G5yq%`@?_l+8OLBJx4@g4E(igGO62sdj=(I{IE%0 zse3e|M7Lk~o>E<-blmd0)4kOzJ79R1?)F<2L<|vI|CukJV}=et9>tkD1XgOPy!qop^hDjcVdd(VW4dLFo6lci4z-+iMl*0td%JB zImKn7ww(X=iuL&w8n&d_0mH3QrF_DmwD9OZZzsS9sMPgOAo5kzP0Tv z##&2t^``LpOaGHy_@~9--PiT!9Rlf(jUGFH-`HWt{75rt3sauKDUN$2sMKu7Z(j!8xI5wud=U;0nyiNcqc8pfM zvm@WqHK~`IQ^i%0()|nm4OKKkF}RP0*uPNFstp)M0zGTu|q zt*z@%vlXxMN7z2pWIbQl(n#Wi}s z>;*yduM*R%2va;xg?nzl1-Tr-80^+kiz=03p=vs=zx;d17nXv#`yF~!1;%kSlUbJf z{V%?QLMCW7wmYAX%mfkQ(POCYtP6jQVzDOtNm(}xqk?vmz?Ylv$D!F*&|zXQTj$XT ztfJP7o5}xk+~}_+3<-q^yB$R}0I1Rls;Sx<;dSbqXR(2$52Jh(db zj4z5EA?d18rD9YS#u&+@wfOvsTR9ppEmt67^*uOsgJ!$?O&t$~^ok59(C&*fe!mg! z$gziNp-{^GM_>#~HnXm~yReFRf$YH{Rt5Z(}l`=bS4Xqk%qj^P(2VZG> zHW@LnmAUyS5urZr4(+o+_9KMAqwK?z69*+-Gt^31@*e;o!$48=ML%e@yqix1RR%Bu zRpg(1_--ZS!U?*R49Jr2$6`m$J3h?1_eKCcho+*G?<3IErgtzT4mM zRu)7CySK|?mF}P-U^dxBcRrJQ>d|jfB3arJ_q|xFTw6!EMGG&?{p-Hx;5fTox8Hxm z7=~EC_V`n0BxWA|ZJWzjaF^Dp??3=N7}VPwez5U&IIytTZg!CzCUoR%;+sd0y0WR( zRjXR2xoFR+1^`uhgaF1EmN(mqFNY2|JNm|<3Ls|iVt20@tJ=_;0~#oZDk*?N_ozFx zXa(iSe}JGTLeW<|wJx(7K*h4EDphtE!D5FCFF&`&Zr<<++z6E+1A^pph)kHefQC1r z$8JDGfVDzy1o5e}PL@Jm!QAyVqiV=;QCzm zaA59rWH<)X#S}^ellR3=92z_JyT^<^jX{>n+-c}3)w@oWo}h*Zxq{7ZNqesW@5pj? z+yKJG>HVwQ-z->B>U!J#eIaP=JAd6p2-{9eUCf|17Mo>*Ony0^Pl?xX|u?6DhAus9HB-zkH6#MmR@6{t(8 zfU3Y4l62~&!m`I6f9J6(RVsxDP+>T(P0pEjG1BQKnDQ+u0R-1iCauxghhG*y@nZ); z#0T>Zccd8*1c493IJWlF=Y5wFH3z`={StD%cQ6pNnS>mldIqPrJd8GzgegPW=P=R6 zw5Mmz8v3K7$JY<`cThoaH2|p6ZB(HVLs*~o*8lVMR4agrI|%o94B?-vnv1Pg&_VC4 zeJjH4o5m00?M{yWGC@V^yjQNQeYP=veXaLKR7s zB?AIk@@+iUw8wE__ucN#g+UWCN**}(C~}>_2&8shx&H{oV(?yl=}v#sx+0jJs@}Rv zw+Nhi{KPRQ&56x9;+vY!DyZUMmf$$5ty{R^qo4bAWUqY_u(mU>-Mt8%j}r4191)vw z$VHm&sL1uF0E6MhHvZnGJ5-4(nK;=HN!GKYE#yDAMbr>VF6Ttkot@w28KQtQ-_bi6YfEL#tAy7ZDMtvj5^U z>-|D{5gA8IX4ILXUJ=n*hn`Hac#whb&TJdF|0t44H8W=~(0m?X?)tq|4H?J!%NO4s z$}Otd|0vpa0Sg(%S5RO=mC4ieC*1tB@(RXI=0EFk2v~YuRn+Jf2OX z>S|9r>jp`+6M~J9a-~0-k0k9S+547GEUc$AN0loG0hqw4qLm}Z)qUZMvxm&~MXl*$ zDNr>4sL~TU&+g_h@!uT>f?gSdC~^RD-F$^zZUXLfH2Ua%jU(d@$3p-AvTkGwz#+;x zM8&Nhs#0ZO63T#(N>kzCMYm8aUb>qu5o;k2Gh*~ycl^ZBVE|D1d>FX@SZA;C4bF%$ zM{Cgix2s88U<}zp?%DK;CsqI;R4sO^R7xmSF|ni!KI3aYR^LtpU;%W0!N|;-%h||2 zlfPVZ>SyOcY}pO3c6vIcKhcQPU-Z4p$vES}+Mx12zlcRNL3nHRpYFKnu6YGF@s<(Ypb_jwX0#LJ_2e3ZL6~VO9FP zorapE86Dm}%35ToswP~*^BZ&dm$wu^1n$rE36)`6TBQmA3v0D--<|iX?`V4F6=;hnC6XvUvG@*I_m&MM2h{+eN@ax7pXA3r zx4`T*{w(D4y`kGDFq34{RCChDuaE3My)m#F-3=<#DLkJ4an}6ko)ga0e6Et?DS)yC zzg@if+KcX4zP|0+=aUSXCq= z27h1EkxmsY43|#4^*#guL`ooK+nr=Jk>-1SSp82R0TNVXajF1ZCTz}n#g2mCt%8c) zr0A==8*M^Y=`JD)BKFs>ZT8++b00-xTC$5Nvr4g`(F0~LAU6V7Te%DsV!d&Ud+CF3 z{b^I$yEpE#(F*%}I84->_2tC<>c4-)UbRiWs2c8@wR$xGsL~^tfDX@Q%GQ_k6+t0i zw?-j~md;n{)iDhK2pAOpwd~Ip1_8FezE2{+Dv*p2j@a9EGM?_(_J~z>PMAqOj|xvc zb_+$KRI;#_$e>UlGh)mU?u5xB07ylJk3pwUYnnzT-3gQDX+B3??jooPTo-op#wUv} zu6Pjul?+mC|1? z6{z!g#uHM1=J{8Xcq79=sl2tZwU+AYO=0PhU*>MOdZ7jd{kA=C>5}{3`byMrIxyg? zXl=p4plHZ7C++dWIzxdHs5_sp^cz*F(lZSATLCb_>q?as$`w0#5CMoYHuwIx1`M(8 zJV^=tnwnHqILsJXR~z?5w%4z=TDmydG2CDv`&tG_!Q#a^j z^@^o*`U*hd72lF{$N#}`O83bC0ApBkS*<ajQ4e%KIq*q6#|ewm0<1#2e{ zkA3=kvxgoLNP&949%I-0LaXd}6#baR3IbzNVp4J{dnQrA7ybJy?VEvJw&7Lf4o3u# z?RV*or~*YRHI+ITRoP8ZRS+3(?V7az#_EL}jg{_-$_y>$5j$Yk$rO)k_!{NCUm!a| zbjIBI>U*W!d=x|u6xqU)i|-B>{=1y{K$Sj(*}z&GJL*IGMh-sma?PYmFUqYiRNqLs zWsASpeB+Nlg=BIyLa&@R=m&KQR8OJwTIvB~2;{8Q~{ z7dTG2A5bA8luSzMmK#s^*1nd8G1OVlqY7aHfBpWoKgoOYyvt-NG#ep@39SW7|21#) zPbUnCxW1}#FK5Dbp>MNFKhlpAnRfm^rj=#8HCX4OWlfv6F?@|(F>2QNm;eAE07*na zQ~;<5CZe<_m@z08nNk;lD6}8sHv|Ol%S81%{N1bUoOE{7wHBGDmfuQ|XlZ|cBGy6y z&d9MxM8;1X9=ZXQKI54K_n(ntV(z$!$7w!?E{i2q1#SfXmi0>tFFpGj04^DvRH@QQ z;5G#_Y=l#H+81t+Lbf)nAL;6xvoMa4blX4Ei~oM3Jo%WU{&wp*T;D*jK2U1DV>X)4 z`A*ZA1vUS9#u)(0cf}d#1l_xdh78rZ&wu5Yu>N2K$D_P8v52K)jpIGL;_~dhw>}JJ zC#anWf2^uRphwph3zsk1_~qJ|Q3(oy%U)v;V0=-(&f#g-pFHwB0tulTMg?6^4FIa_ z{rZ&=&4Zde2yjlp|< z*{#~q9s~fI5uO>cvx$92`Lb>17=(M$#c$i$L@7(MXo=pmWUl& z$gXI+{zuZpJ1U~EhRIy^(A)obabxaxHBn$J{LjaKXwN@2nXNyHuYa)ylZ)abAZbQyz0?V!`0V4WPNgQAgPo|N6ixXSKMAj>g!?Q41O`4x9jEMA@rs8GqgN;fmRabZJ#lPl}BN^7xPTeKg7>2ce z=$6pgB((ubw~Rhl3KV0`0ZV0K0VnV3?@4HC0u1ES+E8xWSP|!$^cOo-)2uQX(K- zl;xfkVAs7C(&3-wp63Jr3N&PWZK&|My#j^rH+4*8L_>{f^r|-LRVsm5A%gtkMYn}r zfxGSrK*Z`5$Qd!_D0iR9gHg&2C~*Jrou(14JAUGaG@t9bhk&XjPJU6$0eyZ9~zO$_D`pbU|z(7?XA~1%EFRs{5UOFV;s-jRLIK8>8Qf0@{nHiopzF|z%Fti+p0|27RQHSxjSK5{W0PlK!+?`d1 z9Th&0RG|?@-JxU6AmH5v`3{5tpsncd2~n5I4(h5@0Xiq-!m~@4N^A3TaNSbQCj?O; z*Bu<6efZG`WI@}XV8gk>oqpJKPS#FD;Q!xM0KgQBQQ=?r-->jaU`$p1Q>8Sa9F5`$ zsr$^AeoU@A5!Tug4eJyl;JSJ*^@r=vu^Zmxg6PC zaO_H4%c^9Vz(4xehF@&Xc+a>@Mk|y8IZSBDdAQ`T(O1kGUKjO6)%3zXz|Qpr_7a_% z;zReUON<{9byeY8RS5!t>Ox~Dg9w1<%ft$%RAmC^J#Bve?fg;zsIWqRr@^}AT1@a%NdZkw>pP(4nk@gl_eEGRMC>lda|8r)Md>*lB zGfy_9+<-&~0Nm+wPgLJ0bbWp*h*+(^xp4o&`(dq?wcx5!h1kGaKY7u&7ex1&e3oX? zK8z_P{jn5Fn8K5f{3Q3l!li+2=vEo(u=$+6^(J-v{uk#_EZzYvOO`@lLz-%h*ZtS& zzj8*6i9luNk^&JKSnJ1+I$=g^)?we)Ov-{w9M5+NYAWLKbo(zaKV1qr3uCmLz6zqM zOhnIZ$QJ+i;SCow)VNw{hhP9k1(qWI0T&%T^os^8MCkD@!0s6U5IE|PUNe8}kDoku z(z6Rcz0bwR?o&S|2n3*9-3{0&AOd^|wEV~IQ#U1D6qqtH`Kqun$LOl&+!K$!nQA3~ zrJM#SJ0UH3Ss#A1N^aC@1#sBxZ#bv|?xW%T8{tf(apPdhki^WO2Y~K8lT4L?0g*rz z`DKgmAU0kZG9ZIu0X$;#QD(xVMyT5Nbps0ASS&Y8b0C(yAZz&p6@AcJx z_tvd#3FH)2OUbFup*Ga0aobI#&RY&8|l^uV${(EYD4j+5ZZ;8Md zE-t$7_55T1{1(NN46&tyQX+_+qHbutMm=b zfC%%Tp<4gRv#x{ZH33w*JFT_Kb&Zn#+wJH2Z?4IPdiVFg=LdGTrsJlot`_kggK?a) zr9ioijZBz&QS$5y4hMFEF2R9|5(8@MzH-U;xwdX9{Gwk%O7$)x7V)^_zx~F-^q+6M zKIrFB*)E;}AR=wa`t*%^*M2SHQiqhc^RhMU5NX=?>c4!{pdZyo92M0bO^&?_SyZSJl9-YX=N>N237XGB&wPn{}(lNd*rZ5I$E#HJ|5ds{AayX(yjkpvFWgqH3`h$FjZU0)3LFQp_Wks43?fA9 zhn)zq?`!|J6{5_nx!hZYrH?!WK+EdFT4nbp(4_md|8>S$ZqvwzEnqs(9P=*wUNpe|JbNokt`S`+r3;>B^)(0FmQvZu{NUXWP~-1m=I=RVjnc4#B^DHTA}$tFr&o7CW1q;LQ&`JGm& z^1d(&0HmK-bSI3-L)AeE8Ib04gXZA5wE*lk3mYPWwU!b!6qz#pBng^-ZU5X9 z5st>-z4_`w@2ywURrybqk{E|!t&M%+^ofaiCtogY%>q-hJ8iH#U}$aTrXPPDg$#qa zWN|7~1nIcix6-#b~ z@1rXJsnR9P1nU#+J>IFG_mLZe390$)m#!do#s%Ga z%as0D>r=AE6jwg;`^--+{WFX)(4N`%ib9!*9^cTOyZNb27YKvUWCZVe?T}mPp!y0?MZ#tM^sL4&r9Gb<+V(`Zs+@Ek^Vio~ z{*v;2n11wz+$C3~y$CZ{6>`YfhQXc%pVqOP=)k{?O0cY0XjZEbI_b&HO>q#2++H6 zW7|dA)mP~mszEVjDn*6mkKe*UQ?Ra|LnP`K5uG`A0f=>*jJ+yB6|q@|eh453sy?=V zi3^z7=F+Ru%N~6Uoizqks=Obai&e+94WByi8x%{-2`xXnzL%+5ip7n$`lUal|NQH_ z!(FEGC9Q#c2i|L|vgtqHa2_XXw5;F(AToxfqg^A%e)PJ?wAsU8tqiCx!9nvgKQaGf z3*7yto?e!VQYWec$1&;^)~0T{>dRUvFjUKO{V9d0TEk?nShDHf7dPbYNx0md4#C`( z_vMhi>n@(ZU&B5&n4c>?_9dUtDS??gY>*RSsvwK_PPh=MbWnYPVKSnyADh~E;PjC- z^Rq<}+VyB) z4y^dPJZ4rE4KasqeyaH=Z?+eU9Gnxq8ftY8AO;{QTA6<(ZSxX6hKW=GEPO4sJZ1<| znPUTss*SoBamw@|rzz0Q#dvuT2v!xtVvc@c=J2niiUQ`+-|2hk6tPD8W|UQSIszTE z=9ezHSA8!PE~vJD%`>y+^Ki%Qdz>?BOih^CBoF~2*3{P{I{Co)S|~05p=b==mi2e} zZ@iYT%73bK33ISmJ?@k_u>)p*L(?6$EY~035^8ETcRY0G1)9$hn6+Ykde|Ev^N)p# zirKas$hhV81^@^|LcTYu{I`dk*5iMM3D7q5P7 z%jD3m}mBFf#ykCLTD>3~ibP&+DrBmjW>4IGTUr;oIQ* zz^eGXQdC~TE z?zsAg7i(?uEr^t-O(!y%&->2kJwI0aiL*Ze*Y!c%zg>cv0a3>2(OP%GrB|cqjR2M5 zqST3sP&{Gm>gTV{-f{DNU{kT{PbsXZH3Yunh1M6Ielzu}nuuYkaJ@+cQ}ES}taDHN z`Mhx-6Tp_S4%&vYe>n$#aAgu84K)WkwDW;V2G*MxB4b7Ew~iV4!70PzCuEBvOxrFl z6wrhr{Ifa#&}&5j0o=22FSq~%h*Z1_LS)ErY?~LV@(u(t4vb!Rh{97Y-hBS$n}c<~;#=IT$9Six%@H z)Hl_|4E&yYq*dAZ2^Ke``8*Y#dGc0@MyV_rkXJ-x=G+CLB_j~J|HNh=dYr1p!yw}Q z&#pi|K+@j&T;Z-;mW7Y)icVuydI}MsYEV}z^&da;a&)Bj1sjV}NkdhONX%&SmV48` zx%zjo*7PVt9Xk1VPdu_Eci&$w{ELc>|)6;YMk{1OSGJd{G>>M_v5?&1w31 zd*0IfZLC>^i5SMBRqc-v0{KMmyg!lv5*0ozLO^YAy#KC-VPY#P^TyW=nLA_H*E({( zmMRogg>@V@9UF4@U$uC{Uqa`fKAn(RHBM5|?pGKoP)+#n9xq*01IK`(C>~gyx-;%F zgU7@P%1_Z&R=9ezMPkpaBELQAV22{wy-Fx5Tou<)os9IPn9Irua z(zN+O^RF(ZPEn;;48@ht-XXcH1+&u^3y3Pa0HO4!?$py4xTE(vTZ;uBrSGo_RpMB@ zJ@wmPoU2@RDghPoj;X9C1iG+n%4&z}&J3Y9N9DESR3K!$<(IFAbtLs#SvV(L5f;c3`)3}uRpZ@ zOKVdeF%kM&jhnaHAV6d1&`bS36KT;3J~C<0P(o0!-W#aGssamFw%%t|w-73}9V0`8 zw!Dpe^{7$5Icm?kWFQ6Vor~FFGFIS!;hIoTe$ZzxIIlC9*r8IOpxZBT zO}#dL<}24BlWl}CrCpRd5o@Wo&g34x|FYa&e||I!&iX7-*x4K`Z-uUSWc@W!Ls?O+ zypWf`nWDAhN5wyR<n-K+3O2jaZ8dp0ZTePTPdx5M135WJLz~xa0 zG(kHoBqRVXlk1E67aZ0!|DdtS3)*tlZl_XO942Io^301H^UVlMczR_8<{H{341~*T z!UTwjVq#-#R2U6Z7ce0Xk>iW{zdX6=qjSdAoslYtW$JpX)QZ}=m@#)e)BKHJztHxw zVPdJU`9>vlc95PhBsSb3Y6#DxGQPv$^oT`i&&s5BT_dW>1hafyX7lqKvUemROj2Q{ zhzM*&Z6d-`uUoLkuM;kd6=l=IHsr1$PPuy5}2SdtaBH4&%&;v*a9i$V>-8Ne-Ss_X~?Hz252MCSP?Z(%nYWGK6W$f{RF zbmk!ozy?qybI6=|ASA=A==SA5u1kJ<^YX%T%U=M{YsG_Ab|HuW1yd}p^{1bIty;ec zoqqmZp=#l}M%$VnPTlmgUx3K;s=JhkfDPxLSp0f+*^*1BuAbrh<-I<$(M(Drv*unF zKl;S|%5pL4O_&*~w)XU|oQEMpj)hnB%YJ`V3&(Y|qit#W+RML(blL#uR>QAK2zLSK zmX+lt6!Qk{1*$x)s6H8)+xgksLNjnvu848a5OnM?#w8P zzO%q7XHcqYU59H9omd|<6WT#dwU|IkaEn&GFB~-Npo@`@zxp&>vs7qzr zj1oY31@>}y@+gNp8+rAZ@O(zPD7bv_hMy-K3NPcz&Tu9&9eL}I84~&UUp_JZnxxCt zii&HYZs`P~X<& z+XYQheErbGrtf~?;2~p+Ry#kh<1jf6lW~|#Ut5~?jLr~nr?~ca4parI$ggxoi#YUHool<6hPb9pYKKJM!YC2`hw)_lSe?WH&ef8K9v z_Fzo!9@j1)A}aVo|5%;+UD7qM70Pr&*eIl#FzUvS?S0dm7jL=d@r- z!|eLFGs=oesbb4*UYtlXp5Wm98cuT!g*zVQKD%<505`C9M6JU6*1^L*c;)=Df6Wwa z12b(m6RQe}ItH(`6rRlc0;ZnbvV)>V*nI~_&785x!!ku%j!O*?43KNN0_TAC(Onjg=Yr5fzGpJw_#_j2#}UQ-x43T`gACGQr&OLhJL( z-p>9p88O@)i(7}uN#(6QV(;2>o;YjL??1R#U5#heD!_)xbTwK+P>Tj;zTDi~~5Xg>kSm{6|js_kl{_24w8&mph8DzH%=`W2Fjn z12>?W+VRl?W=>-ht#xV|4p-|3Kikf`K-B?JKC|r6yTXNDHTz#>TZjy-wTWZq@0*xD z_j1Xk1zg5rRm7TT%;er$_5IxPCC`TvmEJ$UL=?uTu6@I1{&2$=Igu!aJ7%TtnM4L& z(VDu3IklfT@9Tk$uIXDT5D`Iyc+4KA_O!EZfLG|Oy({ZY)%PveHJIWffB!~)$%D_q z7^f%Qe!GDXpm(fneI)BiTbN9(czz;+5l{R?%*^}A$H#rEA>#N%T_@Qlm=fi6_l&MRjshym8vdM;^a#{UML9%ihlfBMQH#;|hy`wcsoL?2)&> z{riuPJ2_QAZ8XSDR&-}JOr||=?fBu*)BbSU-jjdy@P@D5xOB_?ExBNrOmN;O=pkQJ ztg1j^_iT)stL6;*?C}Q=|3TKuuuRcfhpDsCAXH)F4x2ZcbNAl-bn_GcbKK}JT3E=uS-&vZ=cDkGN_sHHQlmQ`?LgBtUZ;gH7 zt7mE<&s}Eoi3GmS?tup#LybclAX0~}WEx@fAit1#r10Fb)ljX3u{q`Z|wlMj?7aR2}y07*naRL&tqt3u_k9p1q( znU=h@NA1_}oy(5ibHzD-TYa}{$nmVc?>b_e0N@?t`XB;vJkjDsM~s?%;K=01TXMd3 z$Nj@FQG3CsGY%Pk^DE886K+_s<(bYOnLrez6gr?Q{=MLud7C{9LNK`#G$`&=g$*^$ zGGRyzXB{-`#M2HK{PnsxAF?rT;RdY>x-1eUfGp|8rCa`gduJXdXHo6{?>SYy_snEw zvhNEdkc2&e1Q4Pq`wv8~`-UL6pchfOSMlOiK`*!~iXaGrpsX$|ia;O|mOui51d@;t z0@-Kp>Al~#>iqtAyR(_h^h{;ih34w35YR*+Aq%@`I)EIth$PT2sK@GR2yHg4pst1g1dzl*W$$? zI20}JUR+9Xw?J_y?!^khp-`OS?k*{%xVyv4@16Jd?7w&C%$@mW=g!W~N1*s**M5u! zF{Mv{Kylk&HqpgA*6OpweV5>HzsY*!J~e#G!jC5YKOb??KN)P0am&R)_w6S#UHV!} z=Z%+E_qcgHSRWs>XHPTLc0Rf<aw6 z)K^+-45t4XNRHF zX>r`m#RFPoU1m~v_0wfq#~7bS{ad4NH7tK03MnG)bDE5A&@SeO!6_OaOZN^vd{L|f zt|lG=xGodEkB6GR3VDOI)|ke`WgTXxD)zAbmq3-$BDGVCjbvtjg&+ONw9uyGW%qWM z`Ne{V8D%as!~}v`sI`ByV6@xxGk?OUygUq8M2LZx`Q>7pP`h_5=k49);j%&6Ph9cm zg$X|P*UXrA+HF)#)|JpQHnl`&G!Ut{E|XH4DvjHq6(d%wu)#aCxnwC!VctP~T~Pn( zB~9{nhp+z8w@#m`y?D;5iJx2T$I^~t*A}&FxZGpuvmlI48^g^8o`LckYVPmPq1Z!{ z+&O4886n(s>%S&maCEK9q~hl5OATuKf)+h`Sbhg=-Q%Ou>cgW|B9_))&QLhSy>t&p zwYrywd!L3qIk!o6T;h_bZuMu=XI>hp77`OCQr8!?k_u2R};3fd9PpY;R8l7L$5Ycf(Z9uf=|h zymTC=4SXmQW-pg6O^dnmghi=iZw)X1WImXw$(($se(!Pkz?mUxzOg!&6JP1X&d%`r z6^7PcL`~rj8)~h<+1dU*nqJJ!Ncov~y>NatsOT zCbsN|XTpJDMoBE%N$gf2_Jqj!yCEyjN&o;04^;8J%z7}fM$7|GiwTgzHQm?wxF>ll zHk@h{NgtGM<|lr=H$g)qdf)53G#+PH{)F7GTlGUd{4M7**Uf4B>w970G5#AOroQc^ zySvLCPa_TA>w#@WIg0$xe6P9DhdIY!!Y@fDO@TW})$#>plK*a&exUJmlVZe+)AIl8 zA%Q!*z60rg++-j(_3QD#et)sE(y5Ut?up%?efostYr{&-hKPIglgj*spQL{Iei^Wd zP9NHi)_57IXB1Hm;|aWSn6U?1a@#|G%Ev83fy3$pSf*YgVM59FIJdX30vhqnpk*at z#gg%+1SeRPQ4_;$6|Yr$#LWb(^Vpwfzw>OkWVMaYvG4AtbNS3?Dh*qCdjBCM;BoB3 zCYe1_f;QwN(~tt^6Q&-ktBjTnnsh17*EkVxjj`ZZZvVy6#9A`5?LAoxH?+`H7-O_T zIZ;a`(C#ic%%491GQo2 zX_<4>=~3>D181xCdjE>{PeRc^itw_5#Ox&+`U@g?D@Ms#1 zBDt`u6?k6jS(~$HOF*LabO{ZtRXEO9nE;2Hn#T2LZ<8aF=dHJlW)0uwDXydAHVL7e((zwSM-rRSEj`>#=J16El=Z}Ir(3@b}yrCUwBUI89zf< zWqxm~0VVsO{Mff?jQuI@q!uLWe8~}1U@64oodRyR z+-Zu9pKfAQjpCx9#qudn9?qg0?d?9I&{w7S{}y?;2w;-8`8`REr2&7evsCQMSiI}^?5q{=z7|}ucx0U=c-uE5fCTK3%HE+ISl(+oFMQKl`X9v*-F`R`BEu) z$M16ElEyip!MqlJ^abstuuyHV`q!Tkxbz!0pqn1e=p0!+jE_*s_GIiEqJgbriY5{xa=i(Gr39cq7ItqK$6*Qwi7&C&9lT#Pv7wy^J!BkJqV!h99oa@ADTqLQhg z;cXFRJ36{?7Edk2ayErK>HvU7qYmjQ^?h_igTIoZ;8wRqbUv^$5GQg4F#*l+x-kEc zeQmQz3`as00m01?sqqhh{NEK4YlEgxYGJN+;RBq`QnWIvWdYIcq}?$#9P)a*Hl-khX+c- z%f|0_9riDVKn_>v)1%9KD`m%9D9mOmoS2-S(>De%E|l{RbA-=DUl&r{7MV70T7ZJV*{(X>m@pvzJT+5|MB=8(?}PpE$8#42Vg~=*IRvEnjqA(wpxFhNLBetQOD#c4JuhD5j#0QuT;{LLZ6DMsHTMZsi~90UaYh|sB5SI*J++4CDho_ zemv4T)2G+cf9Y(d-!L^#^qt9h?Fa5Z63x%mjLK#yw@6aU?#kvx+;=5mSKlbP=#3}9BUW~3~E_(LGUGhP1& zriCGi@p^>z^|s0rM3Xt+(-T<8rSrK7aAEDcow(F)c&M~0_S;p**=NK3epSp9<ZqoT@22>$o>BH`VUdmk9uToxdHyJLl^clU&?yHSt6RE`~`Oxuo z-;temA|(Mt(f|?Q3WiBkiF&LmM8?JCd?e9Cr+N<-<0;~*HD#NY*}LG4My&xSjb-3d z@o;#b4JGe$h`ijk?7;V#L_OBr#}@Xb2O#nARpUBTQk-)f4L#`IM7GnY6?)k_18b=v zc!2g{(O;%3vz1=tXtFQT5!z{->s!C6!q`#VOJ*sLxBPi+L_1eawDCTkZ`^E=2{JR( zA!l)r+oN&K>(JYSXG(ed!JM!Wk__lQ?GFu;-&^}i{5tqq%s!(KZU)L-3fgj_zjWat zjI_vgZzWr7*)&0a42~y^FrR}*L_bR#9)#HF7~k#QS3mxVip9Ot24RNOJZxGq0H#47 z33$An9a=0M@2nbGnABlfG$f+TCKJS8`?oP8VuI{`+Z|5o+9D^IXn)dlt~yGqKzp9B zI3Uq*Kg&tW=H9MSd-oZ_abPeOCied70Q0O8K{&pa4)id7T8#Kw<>S|5gofbZhB{*y z`%C(YttrZD`y1iu2166Ot{96#0MVIc+GZ;9TVum=+8$1*5+$5pcMtPWgFp8dIgIqD zvUgI9av6|@{tjV0{z}7rfXU37*)H1)rqq7!Z;U6@VYf%(`jY{n3?(cE_AgEff1u2` z?JBKmqN+qT`j}uR#2-HIvF?5mC>a(y%jmm1S2rJ|TYS>~)Nwjgx>mZ`ctSE!Q8h_Y zFI#IWMNr~bzWt~~tqgy>@C|(rY+ox?Ksr4TySbuC)=utC-ZxAdo{*eM=cx2~^4xnk zzU4{3l0e;03)hYN#9#WM;$gnP!o1Y-oZ-}zIf^UsW4lXF<@I$z{$%A4VJx{3!F#9s z#a`C~;r5S);6Ohnmz*i(Nh^~-!CxdQZJi;g>(`x*FY`1<(~!Ybhn@TJX@f4}&dcNF zXssz_3^XC!f{~|%Jb#ke515!_;e?5J)OKtLx=t0}1orNB;V$LL%qXG3eoG84*)^_rUB-ZRJU6<>h8gpi=PKH{RK6u&FQ|+7_UAr6-gs z?Af2-8pXx>(aGI=mG25p%Jpr)2rmIxFXrjuN$K&zxMo*g_mx>aK4QnP3O_F-vi~Oc4!^p1QLT$=5y1 z@eSWz#?PXUl$CI*$PTO+FCXD0uS{~0%XSg#f9pJg7(1|8Jb&Wve0*PxXkIX=TG&_= zMFrDVW-;Th2(OkS-=MIt-!I_Lw0P4=(e1Z$A$RHjbs=0YMDy%nMdJoJnG2;O*?)7Q zq`S&{o`B2O3gXW(XmZO6s!JC$(md8R{n@*At9lT%fdP}$p@iqN-VawM1uhL#lHKL| z4DVU%G8$B}@462P2XY19mkF+Z)fCXioWC7X4cPEc{1j~V_EF4bBM z1-YUL&>f+B9B1ciRRjAhN*n>9#ZzhHK5D`X1QSVW;l+{R(PRi{BX2&Y zRv#UXwD08^iN5|84n|Vb1laky9z6%e5CsjE=En-Vhb|R%IX>B>p>j|lC+4SA#eLF_ zRS%DAb2M%%c$&}3J7|6`>8o@`meg0Yu#!juM@=#Xv4wCE?whZ+-al0>ZZ)S(bYlT- z&E>97TUvW{xuc^{Tl=?99xzVe1k;XS&Ql&rT1U;wza zVQ8OKAfJHxCkD&Up%pt$w@V{g=d#?)_EwQaCf%P5%W10#W4*Rg%8A4SFs4T_nHj30 znC635JO&Qf!;768Nb)jU%iX(6U`8UI9|ss@G*;8EJsnc&pHDczQX5>4*aw-)GyPIR z6L+Rd1YhEy63pzCl>x3(?l*mK_v_l9Ja5~vjDOOaAEfdBBYC!W`s1?w;Tt6Y$_sh? zH^9-Ni%sJnr_y+*_ObaHN*YE#?7Zxv^KerAa&fFT@{k~5t5fV9Uo}R<{Sdv2QTnJq zog=38kqAHrk#+lpcST}!T~V^7v~KfWbM^aGNQPM~Hr1EIntysUkKe|ui^|y)iDEbn z;m}zR_zO($SK;@=dmgnD79qd<=L<(inX3K#^7C_uB z8MbIoifAX5ETOB~?2qQqlU%P4>eBYB9^rV``o8LR^B`w#~H>ZX5r zcblv^fsbpyQ^mzQ`CBm-$(<+RLeC?pVk+QR*{*)fNFq9X^7NagU4s-@4IV(s!{6J{ zqVHX>K5TN%T|mTb=0YXjn9Kgwn}mAJ>#XR0H@gte2%{OjoLuL}?4HD*-O}{*h+aZ_ z*$4ZB*PX5RQP|=SoA!YMjCS| z$=`lG^HLnXYWi}vLsZEr(#YS)zw@+WKdLOrkne`M7OZz0in%1lWow6h$v#iMn{Ycu zp_8R098xF}DxtdGJ{l6$Z8m{Q;IwX1TL^B^tEZcT7xDNs@6fQF_0S0O>Zf7g?o zvUkT9`h59cov#wX>>2?JMOhH6C&aAX2=0JGDn=?$mizL{()CaLh7y)}q+p;s@BHdn z4~-GSFScOsiC@o;!o;I2{wp2=5Npb3x?X(7?sRjZSM>+7_(;aJOnk9B44ln}kF%;( zZN+w5C6rl1QRF|`|J0fMPFDy0SoItbD-L{^B9-Qo{vD3?a<6b46}f|xaxj78h!5bC zeRFJZ#%hcwd}nA<-)em?u1BLLkLQWJ7IKg57 zRjA^dQ-GK7x+!ixQdk%WoCuOIb2yTZtxT2v_)Lasi-JLw8S_S`(NiqvLiz1veXDO2 z5TwnbL-COUX_(haaot{}n3v!k{$-RT7qdIyd@Ija3IgQD?**}SkxPVS?BkG;#UtTI z^?5vw_K^R+sh)6*QX;jDA=0Fo+5efQA9yyKdAUX-`gu3wyWJHAA|u7M=`V|ZE%(#X zvusDrn_AuoMG=k0&WKl zU)%*cJ2kvtOS*WnQVGYxn+7w{k-EQ@q7I=9@%+vhCIPj`axJAj_&64J@8V`_82LFY zd1^AXIbFePJG^g8qSDW2>qGMlgdc=SxVu=E2Jd5%KY9QgA)Eiv z$pJ^(U0YP(7S{wO7SXWDlD^mN84*>}qqX|MX0_9JY118xIw+YAzc&VmJb){lU(5py za(ROxO3Nv;=z0gBxPZMLq$HxeykvkLidCV02nRz?SCc~S) z`;lsW2TiI(r-#-M9Q&aiknroo=Oy*u(G;!E@-_6Ugb23U{%Ed(qj8dEM zSo9ALj95aJB7{C4qo?e&Bjp^SczKbp9J!)?dv_rE{3IwHWXT14Qm}= zr3q}GuZXo-o3ol%b{+1rPQ$hKF&-_SOR|oAijTe^Pupu2|D|w5;Xdw4i2(LvW5~%Q z7{c{foK{LYq(Dp_5Xsy>+ilK)?KR#*)g=6!3SoCIapcLQ-mA^G4P^A2JQumj!mj3H z&Hc^gHdBmz!)Iw|0~xlsdFFeG1? zxXDR(Jtgnrz@5>U0QUl#HwJ$UZ8iR6l)~(|B%rl~ChwrO+k10UG#4YUb}r%Ec}EzV ziEmbID)I55{8&`ieo%+yWqu_djeI4)PWw1BztVvxr{wg!UR-%x$f7>QjTi>oon%64 zuaCCZ<*FYm(II)ceYorT!0zKql-e83ygF9=+tx+kdB30Z9E{l?SoN4C`u_B1%7{9h zFS>U)6Pu?uGVMG$M-q!H{Rm6veoqMqU~tnBPx+%!|I^Hmw+(Y2i&+yVkWD*HwM%Jk zXDWgB0jIzhA_VD3c(`i2zSmKolKEyy+dyhvM7a?h#y6PbFKe&lE0Q%tR)!PjwN=ay*)uU0i}!B;+2 zuLd?Gwu5z6ju+F<>Xir6u=~5T>3#dka?ume3ub#BKp2JiMWgXjASqJ`2Jjn*n&Uq-|-fLmfK8Dm9F{tY~$Uu-&02zZmP zD{sX`-5=*(ydR;?sr3{ddn-EzXOVBE?nzXgpRX65JFZ#4VWRYJ4{I>yA*5#sA=vN8 zZ1EF7Y^V`XSZ&Ds>IW-5ZHRK%QTYNk!@ck7$c5IuUG6n1SOV>nv$qXWHy>WO7t~Cq z`7mz+`BvG|4N=}~ep)B$9ZBZX_JMUUGw^FWDDy~^HPhFcgA=3Cm;QuG)2Y=l$cz7~ z{*5&=HN`V>XBrHo4JoHWmH=3&dEU*Xg_ly_TMT(>>JIAX{lzfSuiIsGgumSD(7kJh zHMUFIyQpB7EeoUof9i()LzgsjtSm?x+SgG;X5%TUtTf5|zP~>?;}`@W?Mt}6Zl_Dg z79=G8z(6Nz3`eC!OD;EGD`P@$Y*Z`Y3Q2BpQZ4=2N(AgCZ;Z4^`5mpc+@r8!Pmft^ z3XjlkP*K$|b*&&wT_%tkkN_`cekv-(A4(0Nmw*(p1zkRjYJ9V}uDn^>1cvN4XAa@c zYk?h(^c6`ei@wf-)v&&lOtJJCFqng6Nkp>v!0i=YbE+;vEA69XPy(XmNdN5Us$pacUAw%CU<2oGp(&so^dOD(F9WpJHXyb zN7q*6)Sp81Zgx{^a}veTw?e`rqk1jq+qu#&nZCCZ?NC~Uzw5LOh>p~WJd2u`7gIZ5 zwSXc|ck`M{8_&y<&Zc6<2{H%4b`w=}xxC?H(%<(Xjvn#PY}Z>ZfW_l>3PR`n@6L+^ zn=K0}`Q5d9f-NCTgRRu6@_*BX@*3|byN2^Co~HEG3Saek2X>A*>7JYf!@y4i>moMj znk(p<2KQfs{raB@#LNE!E(<|Z>U=vwNh?I*7P#eT6dxvDY3g4?UYxV5y| zazhsMLsY+bZo@^;Cs-5kpMcuH-=j725hddWyi*9$c8muFh@=)Np6GQBi6RH#813jrq~3JuQ$5*9)Uo(uDVc+&47#^qai zeGA6=s6p5Fvw1j?7M;D!0ED=W#TQd+QVaUh93Hy&zT+fsW|&QneN-D#nG+#rL!B@3 z`Uaup)#LXk2%V(3Clt+Coc(#bCP>itng@3+cAc@Yshth_yqd`hRb{) zLKmICgaCvLmqQb#V!FSK9JkYCc7IcFm42AE3_YUgDrLiD&hl{m27dCR%?FY^93X^f z{J#+NN+Lga{J76RdZxv?$M=RNjFjUR-co3ROISO$^Hbx+M(g%Bo)^J`PJ|Nnu9!jJ zIbALWH(am+kT>AF+?$(xBz{+_^EdrLzw8%iBuv@WMbrnMH~O6w%TYKk;t`8hTtl)f zn&_k~RhzmI7Wjgp{^xr0K{h@ERAy{z?iR9-hVZq#x~wp{52pWaO1~9*CFf$ zv3=Dx3&rD9aV)fe4$ckHwg25o=HP0v9#Zkk4&1mZ@uMD3H^(Q1q>($d@>bEw5IvN0 z*_~MNXKKIrZik#JIAT+d1kt)`sUUNCnA}Ku^j@A8f*D$2Fl<8PCcgKSaiTbQ!HBve zO;oc&_D$5yZ1&9$ii8oBB=dDGy2sZs&O&-4KYj&16?R^aeO|gh0S8hc{2(6T2P^8A zj?D+Q&6Oa$yWGASjG=<}qX})b|9BCms4WFX`*OpqptoH8@s!*Zba%BAfvlgqF017Y zSo2f%N|v328Y8}A7>$+&B(^?9$Vn8@{l)Fl6r{wF>)a#+pa;?BvX6%SkS?rE`o^JX z=JZfZOI)RvwBc<6=+f&)xMG+3k8LFg$V}Is)*PwM=kXui+06KZlg4VMf>Dq-AV5wf#=stFyutnPvQs-IzkW{&?3h`XCZ`fIq8&1H*D!GI!M4j zNq`*f0uzD}B8nh*deq!UH*ncCRuBkn0sSrP`);A8)z_M6v+5SJYmYFJPC-7M7z5}9^ zDWaUl%2*hA^)^QhDKs=YGNYvmHmM_w^4zU2S&Zz=i%E?zQLf)<%;4zvb)t-b#idoj zM?q3?wmz@}gH=~_h1(so2^BaxlDA0M`C;x?IHCEWO^~ z$JORqBHy)>bJCiJoW+*W_rHnnaCBX%0RMKWlE8`lPLnU6a#iL^FO5zqqE5r*m@4*f z@cU<#HDN8sCnSw?-8S%>8H^yTbPQNI(Id)8C-iU2z2QBsKpUq!+;JjQuZMK!=0ETX zA#epCNQ4ez5ZLv{S7FDmWV3>`VsSx$R|S9cvc)QopLE3foBk=zw+Na>_Dq#>@xU5= z9*ZIh7M_ny{(dV-z>z!`f^yd!IRX-CET7e5>>eMRWO^ed+>QKw@8c-OM^-%#lGTPW z{)0ze9}%*<1T7n2ER$4hsw+%L(70L=i8f@|Te}1&HupTShuoSx*mh3Ej0zDScFBkV z3Su)(!uoDWBT*`i-+FKSB-m2$xenaatL+Ffkb=5M^HQdhl&f~zp6)$ujy-<=2l^XV z%&q_&0z4$r620+s{g)rvHr=92Vn|F3D|)<%tlKdi)^7gtBJ_kB|F%;RQYc_(&_$D` z`CEIqPvT!!WbROT`o#gCW;;5UJ8m)8Baox}QsnZ>jS{2PN#{{9dm{=WAY0J%V|;pG zR;C^6C7$dE4?K#ZhX+bX|0`;KJ|$bo7JDvN=QD%zm}5+4D!3*T zP5c*B(2)VjPJzD!&Y$^pNiDiyUh)Nxgja}A1y{akO>!NYuUH+ zcH8y_XcS&H1a1~TM|VY=#wwX2-dNYKvyHxpTWt?Qc2|d_!4+|~&--<73@S290 z3hac{n$u?r;cZ{vIa?h+3`Adxi$2R*PaTZ)#c56DlnYbF85a_ElSWax|4qNI4mj}` z+WBjG>@Q)RveJ zzBEvx)gX*QF5pTy@|%PCT}52NkMI09zU>Gjp)vyJzy0rO8V>ATF)Xw@L4`=YY-vC%U&Oy;P0)nLuO0UT9 z$>nTCZ?Xx&X#g#TaB7^&z@U;t`)3=IhoSi7Bkmop-+B(bAv!=p!JfRakd>7!Ilz-vV`j|aC>J}lrk+cGY|@@!S#Uv*Z9c5e*h0hJPRGl z3lsuW0+x|JV + + \ No newline at end of file diff --git a/lynx/static/images/icons/chat-square-dots.svg b/lynx/static/images/icons/chat-square-dots.svg new file mode 100644 index 0000000..f074f36 --- /dev/null +++ b/lynx/static/images/icons/chat-square-dots.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/lynx/static/images/icons/chat-square-text.svg b/lynx/static/images/icons/chat-square-text.svg new file mode 100644 index 0000000..4ab1736 --- /dev/null +++ b/lynx/static/images/icons/chat-square-text.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/lynx/static/images/icons/cloud-download.svg b/lynx/static/images/icons/cloud-download.svg new file mode 100644 index 0000000..d489ade --- /dev/null +++ b/lynx/static/images/icons/cloud-download.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/lynx/static/images/icons/exclamation-diamond.svg b/lynx/static/images/icons/exclamation-diamond.svg new file mode 100644 index 0000000..e475e20 --- /dev/null +++ b/lynx/static/images/icons/exclamation-diamond.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/lynx/static/images/icons/file-earmark-spreadsheet.svg b/lynx/static/images/icons/file-earmark-spreadsheet.svg new file mode 100644 index 0000000..cecee95 --- /dev/null +++ b/lynx/static/images/icons/file-earmark-spreadsheet.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/lynx/static/images/icons/gear.svg b/lynx/static/images/icons/gear.svg new file mode 100644 index 0000000..5414b1d --- /dev/null +++ b/lynx/static/images/icons/gear.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/lynx/static/images/icons/info-circle.svg b/lynx/static/images/icons/info-circle.svg new file mode 100644 index 0000000..396f778 --- /dev/null +++ b/lynx/static/images/icons/info-circle.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/lynx/static/images/icons/lightning.svg b/lynx/static/images/icons/lightning.svg new file mode 100644 index 0000000..0f3c5e9 --- /dev/null +++ b/lynx/static/images/icons/lightning.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/lynx/static/images/icons/link-45deg.svg b/lynx/static/images/icons/link-45deg.svg new file mode 100644 index 0000000..6ab425b --- /dev/null +++ b/lynx/static/images/icons/link-45deg.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/lynx/static/images/icons/search.svg b/lynx/static/images/icons/search.svg new file mode 100644 index 0000000..0d7ac0b --- /dev/null +++ b/lynx/static/images/icons/search.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/lynx/templates/about.html b/lynx/templates/about.html index 3db7c6f..4fbeb9b 100644 --- a/lynx/templates/about.html +++ b/lynx/templates/about.html @@ -6,54 +6,58 @@ {% block body %}

 

-

About LipidLynxX

+
+ image +      + image +
 
+

About LipidLynxX

+

 

  • LipidLynxX version: {{ lynx_version }}
  • API version: {{ api_version }}
- Check latest version of LipidLynxX on GitHub 🔗 + Check latest version of LipidLynxX on GitHub + external_link

 

Developed by Team SysMedOs

-

+

Team SysMedOs -
- Fedorova Research Group,
AG Bioanalytik, - University Leipzig -
+ external_link
+ Fedorova Research Group + external_link
+ AG Bioanalytik + external_link
+ University of Leipzig + external_link

 

License

  • -

    LipidLynxX is Dual-licensed

    +

    LipidLynxX is using GPLv3 License:

    +
  • Please cite our publication in an appropriate form.

    • -

      LipidLynx is based on the previous project epiLION

      +

      LipidLynxX preprint on bioRxiv.org

        -
      • Ni, Zhixu, Laura Goracci, Gabriele Cruciani, and Maria Fedorova. - "Computational solutions in redox lipidomics–Current strategies and future perspectives." - Free Radical Biology and Medicine (2019). +
      • +

        Zhixu Ni, Maria Fedorova. + "LipidLynxX: a data transfer hub to support integration of large scale lipidomics + datasets"

      @@ -61,11 +65,13 @@

      License

+

 

Report issues

+

 

Funding

We acknowledge all projects that supports the development of LipidLynxX:

    @@ -83,25 +89,34 @@

    Funding

+

 

Powered by open-source software:

We acknowledge all open-source projects that used by LipidLynxX:

  • -

    Data processing:

    +

    LipidLynxX is powered by open-source projects, main dependencies are:

    • -

      jsonschema, natsort, pandas, openpyxl, xlrd, xlwt

      +

      FastAPI, starlette, Typer & uvicorn

      +
    • +
    • +

      jsonschema, pandas & regex

  • -

    UI and webservice

    +

    LipidLynxX is based on the previous project epiLION

      -
    • -

      flask, requests, wtforms, werkzeug, zerorpc

      +
    • Ni, Zhixu, Laura Goracci, Gabriele Cruciani, and Maria Fedorova. + "Computational solutions in redox lipidomics–Current strategies and future perspectives." + Free Radical Biology and Medicine (2019). +
+

 

{% endblock %} \ No newline at end of file diff --git a/lynx/templates/base.html b/lynx/templates/base.html index da1be21..95673d7 100644 --- a/lynx/templates/base.html +++ b/lynx/templates/base.html @@ -34,7 +34,7 @@
-
Developed by team SysMedOs
+
Developed by + Team SysMedOs + info_circle +
diff --git a/lynx/templates/home.html b/lynx/templates/home.html index 01710df..3952bab 100644 --- a/lynx/templates/home.html +++ b/lynx/templates/home.html @@ -10,12 +10,13 @@
 
- image + image      - image + image
 
-
+
 
-
+
+ +