diff --git a/requirements.txt b/requirements.txt index df0766f..ca7213b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,7 @@ Jinja2==3.1.2 Markdown==3.3.7 MarkupSafe==2.1.1 packaging==21.3 +Pillow==9.2.0 pluggy==1.0.0 py==1.11.0 pycparser==2.21 diff --git a/website/__init__.py b/website/__init__.py index 8e5645a..565afcd 100644 --- a/website/__init__.py +++ b/website/__init__.py @@ -18,6 +18,7 @@ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +import io import os import random @@ -25,7 +26,8 @@ import markdown from flask_talisman import Talisman -from website.repositories import Repository, blog_repositories +from website.repositories import (Repository, blog_repositories, + image_repositories) app = flask.Flask(__name__) @@ -41,6 +43,7 @@ app.jinja_env.add_extension('pypugjs.ext.jinja.PyPugJSExtension') blog_repo = blog_repositories.PostRepository('blog') +image_repo = image_repositories.ImageRepository('images') @app.route('/') def index() -> str: @@ -67,15 +70,9 @@ def images() -> str: Returns: str: The rendered template. """ - images = [] - directory = os.path.join('website', 'images') - - for image in os.listdir(directory): - images.append(image.replace(' ', '_')) - return flask.render_template( 'images.pug', - images=images + images=image_repo.get_all() ) @app.route('/images/') @@ -88,10 +85,40 @@ def image(name: str) -> str: Returns: str: The rendered template. - """ - return flask.send_from_directory( - "images", - name.lower().replace('_', ' ') + """ + image = image_repo.get(name) + + if image is None: + flask.abort(404) + + mimetype = 'image/jpeg' if image.extension == 'jpg' else 'image/png' + + return flask.Response( + image.content, + mimetype=mimetype, + ) + +@app.route('/images//thumbnail') +def image_thumbnail(name: str) -> str: + """ + Returns the image with the given name. + + Args: + name: The name of the image. + + Returns: + str: The rendered template. + """ + image = image_repo.get(name) + + if image is None: + flask.abort(404) + + mimetype = 'image/jpeg' if image.extension == 'jpg' else 'image/png' + + return flask.send_file( + io.BytesIO(image.thumbnail(200).tobytes()), + mimetype=mimetype, ) @app.route('/articles////') diff --git a/website/images/lion 1.jpg b/website/images/lion 1.jpg new file mode 100644 index 0000000..783631e Binary files /dev/null and b/website/images/lion 1.jpg differ diff --git a/website/images/lion 2.jpg b/website/images/lion 2.jpg new file mode 100644 index 0000000..6af93db Binary files /dev/null and b/website/images/lion 2.jpg differ diff --git a/website/images/lion 3.jpg b/website/images/lion 3.jpg new file mode 100644 index 0000000..6af93db Binary files /dev/null and b/website/images/lion 3.jpg differ diff --git a/website/images/lion 4.jpg b/website/images/lion 4.jpg new file mode 100644 index 0000000..127e1a6 Binary files /dev/null and b/website/images/lion 4.jpg differ diff --git a/website/models/image.py b/website/models/image.py new file mode 100644 index 0000000..5e0ab98 --- /dev/null +++ b/website/models/image.py @@ -0,0 +1,56 @@ +# Copyright (c) 2022 Johnathan P. Irvin +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +from dataclasses import dataclass + +import PIL.Image +import io + +@dataclass +class Image: + title: str + content: bytes + + @property + def extension(self) -> str: + return self.title.split('.')[-1] + + def thumbnail(self, size: int) -> PIL.Image.Image: + """ + Returns a thumbnail of the image. + + Args: + size (int): The size of the thumbnail. + + Returns: + PIL.Image.Image: The thumbnail of the image. + """ + image = PIL.Image.open(io.BytesIO(self.content)) + image.thumbnail((size, size)) + return image + + def get_identifier(self) -> str: + """ + Returns the identifier of the image. + + Returns: + str: The identifier of the image. + """ + return self.title diff --git a/website/repositories/blog_repositories.py b/website/repositories/blog_repositories.py index a5da8d3..c7887b9 100644 --- a/website/repositories/blog_repositories.py +++ b/website/repositories/blog_repositories.py @@ -21,10 +21,9 @@ import os import frontmatter +import website.repositories.errors as errors from website.models.blog import Post -from .repository import Repository - class PostRepository: def _get_path(self, model: Post) -> str: @@ -101,7 +100,7 @@ def create(self, model: Post) -> Post: } path = self._get_path(model) if os.path.exists(path): - raise Repository.AlreadyExists() + raise errors.EntityAlreadyExists() with open(path, 'w') as f: f.write(frontmatter.dumps(header)) @@ -122,7 +121,7 @@ def update(self, identifier: str, model: Post) -> Post: """ model = self._posts.get(identifier, None) if model is None: - raise Repository.NotFound() + raise errors.EntityNotFound() header = { key: value @@ -163,7 +162,7 @@ def get(self, identifier: str) -> Post: """ post = self._posts.get(identifier, None) if post is None: - raise Repository.NotFound() + raise errors.EntityNotFound() return post diff --git a/website/repositories/errors.py b/website/repositories/errors.py new file mode 100644 index 0000000..f73ebc7 --- /dev/null +++ b/website/repositories/errors.py @@ -0,0 +1,38 @@ +# Copyright (c) 2022 Johnathan P. Irvin +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +class EntityNotFound(Exception): + """ + Raised when an entity is not found. + """ + pass + +class EntityAlreadyExists(Exception): + """ + Raised when an entity already exists. + """ + pass + +class EntityInvalid(Exception): + """ + Raised when an entity is invalid. + """ + pass diff --git a/website/repositories/image_repositories.py b/website/repositories/image_repositories.py new file mode 100644 index 0000000..2d9a3b7 --- /dev/null +++ b/website/repositories/image_repositories.py @@ -0,0 +1,165 @@ +# Copyright (c) 2022 Johnathan P. Irvin +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +import os + +import website.repositories.errors as errors +from website.models.image import Image + + +class ImageRepository: + def _get_path(self, model: Image) -> str: + """ + Gets the path of a image. + + Args: + model (Image): The image to get the path of. + + Returns: + str: The path of the image. + """ + return os.path.join( + self._dir, + model.get_identifier() + ) + + def _load_images(self, directory: str) -> dict[str, Image]: + """ + Loads all images from the given directory and child directories. + + Args: + directory (str): The directory to load images from. + + Returns: + dict[str, Image]: The loaded images. + """ + posts = {} + directory = os.path.join('website', directory) + for _, _, files in os.walk(directory): + for file in files: + if not file.endswith('.jpg') and not file.endswith('.png'): + continue + + with open(os.path.join(directory, file), 'rb') as f: + content = f.read() + image = Image( + title=file, + content=content, + ) + posts[image.get_identifier()] = image + + return posts + + def __init__(self, directory: str = 'images'): + """ + Initializes a new instance of the ImageRepository class. + + Args: + directory (str, optional): The directory to load images from. Defaults to './images'. + """ + self._dir = directory + self._images: dict[str, Image] = self._load_images(directory) + + def create(self, model: Image) -> Image: + """ + Creates a new image. + + Args: + model (Image): The image to create. + + Raises: + EntityAlreadyExists: If the image already exists. + + Returns: + Image: The created image. + """ + path = self._get_path(model) + if os.path.exists(path): + raise errors.EntityAlreadyExists() + + with open(path, 'w') as f: + f.write(model.content) + + return model + + def update(self, identifier: str, model: Image) -> Image: + """ + Updates an existing image. + + Args: + identifier (str): The identifier of the image to update. + model (Image): The image to update. + + Raises: + EntityNotFound: If the image does not exist. + + Returns: + Image: The updated image. + """ + model = self._images.get(identifier, None) + if model is None: + raise errors.EntityNotFound() + + with open(self._get_path(model), 'w') as f: + f.write(model.content) + + return model + + def delete(self, identifier: str) -> Image: + """ + Deletes a image by its identifier. + + Args: + identifier (str): The identifier of the image to delete. + + Returns: + Image: The deleted image. + """ + os.remove(self._get_path(self._images[identifier])) + del self._images[identifier] + return self._images[identifier] + + def get(self, identifier: str) -> Image: + """ + Gets a image by its identifier. + + Args: + identifier (str): The identifier of the image to get. + + Raises: + EntityNotFound: If the image does not exist. + + Returns: + Image: The image. + """ + model = self._images.get(identifier, None) + if model is None: + raise errors.EntityNotFound() + + return model + + def get_all(self) -> list[Image]: + """ + Gets all images. + + Returns: + list[Image]: The images. + """ + return list(self._images.values()) diff --git a/website/repositories/repository.py b/website/repositories/repository.py index ec17d46..2fb3db7 100644 --- a/website/repositories/repository.py +++ b/website/repositories/repository.py @@ -24,15 +24,6 @@ class Repository(Protocol): - class NotFound(Exception): - """ - Raised when an entity is not found. - """ - class AlreadyExists(Exception): - """ - Raised when an entity already exists. - """ - def create(self, model: Entity) -> Entity: """ Creates a new entity. @@ -81,3 +72,12 @@ def get(self, identifier: str | int) -> Entity: Entity: The entity. """ pass + + def get_all(self) -> list: + """ + Gets all existing entities. + + Returns: + list: The entities. + """ + pass \ No newline at end of file diff --git a/website/templates/images.pug b/website/templates/images.pug index 59bf2dd..2d1fdc6 100644 --- a/website/templates/images.pug +++ b/website/templates/images.pug @@ -7,7 +7,7 @@ block content .d-flex.justify-content-center.flex-wrap // {% for image in images %} img.flex( - src='/images/{{ image }}', + src='/images/{{ image.title }}/thumbnail', width='200px', height='200px' )