Skip to content

Commit

Permalink
Use jinja2 instead of manual HTML templating (#2)
Browse files Browse the repository at this point in the history
* Add jinja as a dependency for litestar

* Switch manual HTML templating to jinja2

* Replace 412 status code with 404

There are no preconditions set in the headers so using this status code
here makes no sense.

* Update running instructions

* Add JavaScript to hide columns

* Update method of finding common anime

* Use lower instead of casefold

Casefold is a more expensive algorithm since it's unicode aware and
there's no purpose in supporting that when everything else uses lower()
  • Loading branch information
avayert authored Jul 15, 2024
1 parent ccdac13 commit db502df
Show file tree
Hide file tree
Showing 4 changed files with 92 additions and 89 deletions.
17 changes: 6 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ https://anilist.abstractumbra.dev/AbstractUmbra/OtherUmbra/etc...
By default this will compare entries in the "Planning" category.
You can add a `status` query parameter to further refine what category you wish to see!

You can also pass one (or more) `exclude` query parameters to exclude a column.

The `?status=` parameter accepts the following values:-
```
planning (the default)
Expand All @@ -22,14 +20,11 @@ paused
repeating
```

The `?exclude=` parameter accepts the following values:-
```
romaji
english
native
```


## Running your own

The provided docker-compose file should work on it's own, otherwise just clone the repository and install the necessary dependencies and run the `main.py` with a Python version >= 3.11
The provided docker-compose file should work on its own.

To run in development mode, use
```
litestar --app anilist-cmp:app run --debug --reload
```
100 changes: 23 additions & 77 deletions anilist-cmp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
from __future__ import annotations

import pathlib
from collections import ChainMap
from enum import Enum
from functools import reduce
from operator import and_, or_
from typing import TYPE_CHECKING

import httpx
from litestar import Litestar, MediaType, Response, get, status_codes
from litestar.contrib.jinja import JinjaTemplateEngine
from litestar.middleware.rate_limit import RateLimitConfig
from litestar.response import Template
from litestar.template import TemplateConfig

if TYPE_CHECKING:
from collections.abc import Sequence
Expand Down Expand Up @@ -38,47 +41,8 @@
}}
"""

OPENGRAPH_HEAD = """
<!DOCTYPE html>
<html prefix="og: https://ogp.me/ns#">
<head>
<meta charset="UTF-8" />
<title>Hi</title>
<meta property="og:title" content="Common '{status}' anilist entries for {users}." />
<meta property="og:description" content="They currently have {mutual} mutual entries." />
<meta property="og:locale" content="en_GB" />
<meta property="og:type" content="website" />
</head>
</html>
"""

HEADINGS = ["romaji", "english", "native"]

TABLE = """
<table>
<thead>
<tr>
<th>Media ID</th>
{included}
<th>URL</th>
</tr>
</thead>
<tbody>
{{body}}
</tbody>
</table>
"""

ROW = """
<tr>
<td>{{id}}</td>
{included}
<td><a href="{{siteUrl}}">Anilist</a></td>
</tr>
"""


class NoPlanningData(ValueError):
class EmptyAnimeList(ValueError):
def __init__(self, user: int, *args: object) -> None:
self.user = user
super().__init__(*args)
Expand All @@ -93,13 +57,6 @@ class Status(Enum):
repeating = "REPEATING"


def format_entries_as_table(entries: dict[int, InnerMediaEntry], excluded: list[str]) -> str:
included = "\n".join(f"<td>{{title[{h}]}}</td>" for h in HEADINGS if h not in excluded)
rows = [ROW.format(included=included).format_map(entry) for entry in entries.values()]
formatted_headings = "\n".join(f"<th>{h.title()}</th>" for h in HEADINGS if h not in excluded)
return TABLE.format(included=formatted_headings).format(body="\n".join(rows))


async def _fetch_user_entries(*usernames: str, status: Status) -> AnilistResponse | AnilistErrorResponse:
parameters = ", ".join(f"$username{n}: String" for n in range(len(usernames)))
subqueries = "".join(USER_QUERY.format(number=n) for n in range(len(usernames)))
Expand All @@ -117,17 +74,17 @@ def _restructure_entries(entries: list[MediaEntry]) -> dict[int, InnerMediaEntry
return {entry["media"]["id"]: entry["media"] for entry in entries}


def _get_common_planning(data: AnilistResponse) -> dict[int, InnerMediaEntry]:
def _get_common_anime(data: AnilistResponse) -> dict[int, InnerMediaEntry]:
media_entries: list[dict[int, InnerMediaEntry]] = []

for index, item in enumerate(data["data"].values()):
if not item or not item["lists"]:
raise NoPlanningData(index)
raise EmptyAnimeList(index)

media_entries.append(_restructure_entries(item["lists"][0]["entries"]))

all_anime: dict[int, InnerMediaEntry] = reduce(or_, media_entries)
common_anime: set[int] = reduce(and_, (d.keys() for d in media_entries))
all_anime = ChainMap(*media_entries)
common_anime = set(all_anime).intersection(*media_entries)

return {id_: all_anime[id_] for id_ in common_anime}

Expand Down Expand Up @@ -167,8 +124,9 @@ async def index() -> Response[str]:


@get("/{user_list:path}")
async def get_matches(user_list: str, exclude: list[str] | None = None, status: str = "planning") -> Response[str]:
users = list({user.casefold() for user in user_list.lstrip("/").split("/")})
async def get_matches(user_list: str, status: str = "planning") -> Response[str] | Template:
usernames = [username for username in user_list.split('/') if username]
users = list({user.lower() for user in usernames})

if len(users) <= 1:
return Response(
Expand All @@ -186,22 +144,11 @@ async def get_matches(user_list: str, exclude: list[str] | None = None, status:
)

try:
selected_status = Status[status.casefold()]
selected_status = Status[status.lower()]
except KeyError:
_statuses = "\n".join(item.name for item in Status)
return Response(f"Sorry, your chosen status of {status} is not valid. Please choose from:-\n\n{_statuses}")

excluded = list({ex.casefold() for ex in exclude or []})

faulty = [ex for ex in excluded if ex not in HEADINGS]

if faulty:
return Response(
f"Unknown excluded headings: {_human_join(faulty)}. Supported: {_human_join(HEADINGS)}",
media_type=MediaType.TEXT,
status_code=status_codes.HTTP_400_BAD_REQUEST,
)

data = await _fetch_user_entries(*users, status=selected_status)

if errors := data.get("errors"):
Expand All @@ -215,26 +162,25 @@ async def get_matches(user_list: str, exclude: list[str] | None = None, status:
)

try:
matching_items = _get_common_planning(data) # type: ignore # the type is resolved above.
except NoPlanningData as err:
matching_items = _get_common_anime(data) # type: ignore # the type is resolved above.
except EmptyAnimeList as err:
errored_user = users[err.user]
return Response(
f"Sorry, but {errored_user} has no {selected_status.value.lower()} entries!",
media_type=MediaType.TEXT,
status_code=status_codes.HTTP_412_PRECONDITION_FAILED,
status_code=status_codes.HTTP_404_NOT_FOUND,
)

if not matching_items:
return Response(
f"No {selected_status.value.lower()} anime in common :(",
media_type=MediaType.TEXT,
status_code=status_codes.HTTP_412_PRECONDITION_FAILED,
status_code=status_codes.HTTP_404_NOT_FOUND,
)

head = OPENGRAPH_HEAD.format(users=_human_join(users), mutual=len(matching_items), status=selected_status.value.title())
formatted = format_entries_as_table(matching_items, excluded=excluded)

return Response(head + "\n" + formatted, media_type=MediaType.HTML, status_code=status_codes.HTTP_200_OK)
context = dict(entries=sorted(matching_items.values(), key=lambda entry: entry['id']), status=selected_status,
description=f'Common anime for {_human_join(usernames)}')
return Template(template_name='page.html', context=context)


RL_CONFIG = RateLimitConfig(
Expand All @@ -246,9 +192,9 @@ async def get_matches(user_list: str, exclude: list[str] | None = None, status:
rate_limit_reset_header_key="X-Ratelimit-Reset",
)


template_directory = pathlib.Path(__file__).parent / 'templates'
app = Litestar(
route_handlers=[index, get_matches],
middleware=[RL_CONFIG.middleware],
debug=True,
template_config=TemplateConfig(directory=template_directory, engine=JinjaTemplateEngine),
)
62 changes: 62 additions & 0 deletions anilist-cmp/templates/page.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="utf-8">
<title>Anilist common anime tool</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{{ description }}">
</head>
<body>
<table>
<button data-column="romaji">Toggle Romaji</button>
<button data-column="english">Toggle English</button>
<button data-column="native">Toggle Native</button>
<thead>
<tr>
<th>Media ID</th>
<th>Romaji</th>
<th>English</th>
<th>Native</th>
</tr>
</thead>
<colgroup>
<col>
<col id="romaji">
<col id="english">
<col id="native">
</colgroup>
<tbody>
{% for entry in entries %}
<tr>
<td>{{ entry['id'] }}</td>
<td><a href="{{ entry['siteUrl'] }}">{{ entry['title']['romaji'] }}</a></td>
<td><a href="{{ entry['siteUrl'] }}">{{ entry['title']['english'] }}</a></td>
<td><a href="{{ entry['siteUrl'] }}">{{ entry['title']['native'] }}</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</body>
<script>
const toggles = document.querySelectorAll('button');

const toggle_setting = (setting) => {
if (localStorage.getItem(setting)) {
localStorage.removeItem(setting);
document.getElementById(setting).style.removeProperty('visibility');
} else {
localStorage.setItem(setting, '1')
document.getElementById(setting).style.setProperty('visibility', 'collapse');
}
}

toggles.forEach(button => {
button.addEventListener('click', () => toggle_setting(button.dataset.column))
})

for (const column of ['romaji', 'english', 'native'])
if (localStorage.getItem(column))
document.getElementById(column).style.setProperty('visibility', 'collapse');
</script>
</html>
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ authors = ["Alex Nørgaard <umbra@abstractumbra.dev>"]

[tool.poetry.dependencies]
python = "^3.12"
litestar = { version = "*", extras = ["standard"] }
litestar = { version = "*", extras = ["jinja"] }
uvicorn = "*"
uvloop = { version = "*", markers = "sys_platform != 'win32'" }

Expand Down

0 comments on commit db502df

Please sign in to comment.