songs2slides

A tool that automatically finds song lyrics and creates lyric slideshows
git clone https://git.ashermorgan.net/songs2slides/
Log | Files | Refs | README

commit 092c9cfc00c807efde93e8834c5f13fe2571241f
parent 4109768149eed0b380a315f62397d162f1680958
Author: ashermorgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Tue, 26 Mar 2024 13:35:40 -0700

Merge pull request #9 from ashermorgan/refactor

Refactor
Diffstat:
M.github/workflows/main.yml | 6++----
MREADME.md | 26+-------------------------
DSongs2Slides/__init__.py | 7-------
DSongs2Slides/config.py | 46----------------------------------------------
DSongs2Slides/core.py | 224-------------------------------------------------------------------------------
DSongs2Slides/routes.py | 101-------------------------------------------------------------------------------
DSongs2Slides/static/error.js | 8--------
DSongs2Slides/static/favicon-180.png | 0
DSongs2Slides/static/favicon-32.png | 0
DSongs2Slides/static/global.css | 88-------------------------------------------------------------------------------
DSongs2Slides/static/global.js | 32--------------------------------
DSongs2Slides/static/home.css | 110-------------------------------------------------------------------------------
DSongs2Slides/static/home.js | 173-------------------------------------------------------------------------------
DSongs2Slides/static/plus.png | 0
DSongs2Slides/static/settings.css | 32--------------------------------
DSongs2Slides/static/settings.js | 127-------------------------------------------------------------------------------
DSongs2Slides/templates/error.html | 12------------
DSongs2Slides/templates/home.html | 62--------------------------------------------------------------
DSongs2Slides/templates/layout.html | 26--------------------------
DSongs2Slides/templates/settings.html | 130-------------------------------------------------------------------------------
DTests/test_core.py | 88-------------------------------------------------------------------------------
Dcliapp.py | 106-------------------------------------------------------------------------------
Mrequirements.txt | 6++----
Asongs2slides/__init__.py | 13+++++++++++++
Asongs2slides/core.py | 215+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asongs2slides/routes.py | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asongs2slides/static/create.css | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asongs2slides/static/create.js | 41+++++++++++++++++++++++++++++++++++++++++
Asongs2slides/static/global.css | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asongs2slides/static/home.css | 8++++++++
Asongs2slides/static/icon-16.png | 0
Asongs2slides/static/icon-180.png | 0
Asongs2slides/static/icon-32.png | 0
Asongs2slides/static/icon.svg | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asongs2slides/templates/create.html | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asongs2slides/templates/error.html | 5+++++
Asongs2slides/templates/home.html | 10++++++++++
Asongs2slides/templates/layout.html | 22++++++++++++++++++++++
Asongs2slides/templates/slides.html | 37+++++++++++++++++++++++++++++++++++++
RTests/__init__.py -> tests/__init__.py | 0
Atests/test_core.py | 345+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/test_routes.py | 229+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dwebapp.py | 7-------
43 files changed, 1486 insertions(+), 1412 deletions(-)

diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml @@ -1,4 +1,4 @@ -name: Python Application +name: Test Python Application on: push: @@ -13,10 +13,8 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up Python 3.8 + - name: Set up Python uses: actions/setup-python@v2 - with: - python-version: 3.8 - name: Install dependencies run: pip install -r requirements.txt diff --git a/README.md b/README.md @@ -1,26 +1,2 @@ # Songs2Slides -Creates a lyrics powerpoint from a list of songs. This program does NOT add any copyright information to the powerpoint. The user must do this manually. - -## Features -- Can parse any song given the artist and title. -- New slides are automatically started at the beginning of verses, bridges, choruses, etc. -- The user can easily review and edit the slides. -- The format of the powerpoint can be customized. -- The slides can be added to a new or existing powerpoint. - -## Usage -Note: Songs2Slides requires Python 3.6.x - -Install the python requirements. -``` -pip install -r requirements.txt -``` -To use the command line interface, run `cliapp.py`. -``` -python cliapp.py -``` -To use the web interface, run `webapp.py` and then open http://localhost:5000 in your web browser. -``` -python webapp.py -start http://localhost:5000 -``` +Creates a lyrics powerpoint from a list of songs diff --git a/Songs2Slides/__init__.py b/Songs2Slides/__init__.py @@ -1,6 +0,0 @@ -# Create app -from flask import Flask -app = Flask(__name__) - -# Define routes -from Songs2Slides import routes -\ No newline at end of file diff --git a/Songs2Slides/config.py b/Songs2Slides/config.py @@ -1,46 +0,0 @@ -# Contains default parsing and PowerPoint settings -defaultSettings = { - # Parsing settings - "title-slides": True, - "slide-between-songs": True, - "lines-per-slide": 4, - "remove-parentheses": False, - - # Slide settings - "slide-width": 13.333, - "slide-height": 7.5, - "slide-color": "#000000", - - # Margin settings - "margin-left": 0.5, - "margin-right": 0.5, - "margin-top": 0.5, - "margin-bottom": 0.5, - - # Font settings - "font-family": "Calibri", - "font-size": 40, - "font-bold": False, - "font-italic": False, - "font-color": "#FFFFFF", - - # Paragraph settings - "vertical-alignment": "Middle", # Can be Top, Middle, or Bottom - "line-spacing": 1.25, - "word-wrap": True -} - - - -# Contains cached and custom song information -cachedSongs = { - # Keys should be lowercase ASCII strings without whitespace or special characters - "test-artist-test-song": { - # Title and Artist of the song (formated however you want) - "title":"Test Song", - "artist":"Test Artist", - - # Lyrics with two newlines between each stanza and no newlines at the beginning or end - "lyrics":"test1\ntest2\n\ntest3\ntest4" - } -} diff --git a/Songs2Slides/core.py b/Songs2Slides/core.py @@ -1,224 +0,0 @@ -# Import dependencies -from bs4 import BeautifulSoup -from pptx import Presentation -from pptx.dml.color import RGBColor -from pptx.enum.text import MSO_ANCHOR, PP_ALIGN -from pptx.util import Inches, Pt -import re -import requests -from Songs2Slides import config -from unidecode import unidecode - - - -def GetLyrics(title, artist): - """ - Get the lyrics to a song. - - Parameters - ---------- - title : str - The title of the song. - artist : str - The name of the song's artist. - - Returns - ------- - lyrics : str - The lyrics to the song. - title : str - The title of the song. - artist : str - The name of the song's artist. - """ - - # Convert to lowercase - artist = artist.lower() - title = title.lower() - - # Replace invalid characters - old = [" ", "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "+", "=", "'", "?", "/", "|", "\\", ".", ","] - new = ["-", "", "", "", "s", "", "-", "-and-", "", "", "", "-", "-", "", "", "", "", "", "", ""] - for i in range(0, len(old)): - artist = artist.replace(old[i], new[i]) - title = title.replace(old[i], new[i]) - - # Replace unicode characters - artist = unidecode(artist) - title = unidecode(title) - - # Remove unnecessary dashes - artist = "-".join(list(filter(lambda a: a != "", artist.split("-")))) - title = "-".join(list(filter(lambda a: a != "", title.split("-")))) - - # Get song info - if (f"{artist}-{title}" in config.cachedSongs): - # Get the cache key - key = f"{artist}-{title}" - - # Get info from cache - lyrics = config.cachedSongs[key]["lyrics"] - title = config.cachedSongs[key]["title"] - artist = config.cachedSongs[key]["artist"] - else: - # Get page from the internet - page = requests.get(f"https://genius.com/{artist}-{title}-lyrics") - soup = BeautifulSoup(page.text, "html.parser") - - # Find song info - divs = soup.find_all("div", class_="Lyrics__Container-sc-1ynbvzw-8") - lyrics = "\n".join([div.get_text(separator="\n") for div in divs]) - title = soup.find("h1", class_="SongHeader__Title-sc-1b7aqpg-7").get_text() - artist = soup.find("a", class_="SongHeader__Artist-sc-1b7aqpg-9").get_text() - - # Return lyrics - return lyrics, title, artist - - - -def ParseLyrics(title, artist, settings): - """ - Parse the lyrics of a song into slides. - - Parameters - ---------- - title : str - The title of the song. - artist : str - The name of the song's artist. - settings : dict - The settings to use when parsing the lyrics into slides. - - Returns - ------- - list - A list of strings containing the lyrics to the song. Each list item is one slide. - """ - - # Get lyrics - rawLyrics, title, artist = GetLyrics(title, artist) - - # Remove content in parentheses - if (settings["remove-parentheses"]): - rawLyrics = re.sub(r'\s?\([^)]*\)', '', rawLyrics) - - # Parse Lyrics - rawLines = rawLyrics.split("\n") - - # Add title slide - slides = [] - if (settings["title-slides"]): - slides += ["{0}\n{1}".format(title, artist)] - - # Parse lyrics into slides - slideSize = settings["lines-per-slide"] - for i in range(0, len(rawLines)): - if (rawLines[i] == "" or rawLines[i].startswith("[")): - # Start a new slide without content - slides.append("") - slideSize = 0 - elif (slideSize == settings["lines-per-slide"]): - # Start a new slide with content - slides.append(rawLines[i]) - slideSize = 1 - elif (slideSize == 0): - # Continue a blank slide - slides[-1] = slides[-1] + rawLines[i] - slideSize += 1 - else: - # Continue a slide - slides[-1] = slides[-1] + "\n" + rawLines[i] - slideSize += 1 - - # Add/remove blank slide - if (slides[-1] != "" and settings["slide-between-songs"]): - slides += [""] - elif (slides[-1] == "" and not settings["slide-between-songs"]): - del slides[-1] - - # Return parsed lyrics - return slides - - - -def CreatePptx(parsedLyrics, filepath, settings, openFirst): - """ - Create a PowerPoint from a list of lyrics. - - Parameters - ---------- - parsedLyrics : list - The list of strings containing the lyrics. Each list item will be turned into one slide. - filepath : str - The filepath to save the PowerPoint to. - settings : dict - The settings to use while creating the filepath. - openFirst : bool - Whether to add on to the PowerPoint file if it already exists. - """ - - if (openFirst): - try: - # Open presentation - prs = Presentation(filepath) - except: - # Create presentation - prs = Presentation() - - # Set slide width and height - prs.slide_width = Inches(settings["slide-width"]) - prs.slide_height = Inches(settings["slide-height"]) - else: - # Create presentation - prs = Presentation() - - # Set slide width and height - prs.slide_width = Inches(settings["slide-width"]) - prs.slide_height = Inches(settings["slide-height"]) - - # Get blank slide - blank_slide_layout = prs.slide_layouts[6] - - # Get margins - left = Inches(settings["margin-left"]) - top = Inches(settings["margin-top"]) - width = prs.slide_width - Inches(settings["margin-left"] + settings["margin-right"]) - height = prs.slide_height - Inches(settings["margin-top"] + settings["margin-bottom"]) - - for lyric in parsedLyrics: - # Add slide - slide = prs.slides.add_slide(blank_slide_layout) - - # Apply slide formating - slide.background.fill.solid() - slide.background.fill.fore_color.rgb = RGBColor.from_string(settings["slide-color"][1:]) - - # Add text box - txBox = slide.shapes.add_textbox(left, top, width, height) - tf = txBox.text_frame - tf.clear() - - # Apply text formating - tf.word_wrap = settings["word-wrap"] - if (settings["vertical-alignment"].lower() == "top"): - tf.vertical_anchor = MSO_ANCHOR.TOP - elif (settings["vertical-alignment"].lower() == "bottom"): - tf.vertical_anchor = MSO_ANCHOR.BOTTOM - else: - tf.vertical_anchor = MSO_ANCHOR.MIDDLE - - # Add pharagraph - p = tf.paragraphs[0] - p.text = lyric - - # Apply pharagraph formating - p.font.name = settings["font-family"] - p.font.size = Pt(settings["font-size"]) - p.font.bold = settings["font-bold"] - p.font.italic = settings["font-italic"] - p.font.color.rgb = RGBColor.from_string(settings["font-color"][1:]) - p.alignment = PP_ALIGN.CENTER - p.line_spacing = settings["line-spacing"] - - # Save powerpoint - prs.save(filepath) diff --git a/Songs2Slides/routes.py b/Songs2Slides/routes.py @@ -1,101 +0,0 @@ -# Import dependencies -from copy import deepcopy -from flask import render_template, request, send_file, url_for, jsonify -import io -import json -import os -from Songs2Slides import app, core -from Songs2Slides.config import defaultSettings -import tempfile - - - -# Home page -@app.route("/", methods=["GET"]) -def home(): - return render_template("home.html") - - - -# Settings page -@app.route("/settings/", methods=["GET"]) -def settings(): - return render_template("settings.html", title = "Settings") - - - -# Settings JSON file -@app.route("/settings.json", methods=["GET"]) -def settingsJSON(): - return jsonify(defaultSettings) - - - -# Get Powerpoint -@app.route("/pptx", methods=["POST"]) -def pptx(): - # Get settings - settings = deepcopy(defaultSettings) - try: - for setting in request.json["settings"]: - settings[setting] = request.json["settings"][setting] - except: - pass - - try: - # Get temp - temp = tempfile.NamedTemporaryFile(mode="w+t", suffix=".pptx", delete=False) - temp.close() - - # Get lyrics - lyrics = json.loads(request.form["lyrics"]) - - # Save uploaded powerpoint - if (request.files["pptxFile"].filename != ""): - request.files["pptxFile"].save(temp.name) - - # Create powerpoint - core.CreatePptx(lyrics, temp.name, settings, True) - - # Read file into stream - with open(temp.name, 'rb') as f: - pptx = io.BytesIO(f.read()) - finally: - # Delete temp file - os.remove(temp.name) - - # Return powerpoint - return send_file(pptx, as_attachment=True, attachment_filename='download.pptx') - - - -# Get lyrics -@app.route("/lyrics", methods=["POST"]) -def lyrics(): - # Get settings - settings = deepcopy(defaultSettings) - try: - for setting in request.json["settings"]: - settings[setting] = request.json["settings"][setting] - except: - pass - - # Get lyrics - lyrics = [] - failed = [] - for song in request.json["songs"]: - try: - lyrics += core.ParseLyrics(song[0], song[1], settings) - except: - failed += [song] - - # Return lyrics - return jsonify({"lyrics": lyrics, "errors": failed}) - - - -# 404 page -@app.errorhandler(404) -def error404(e): - message = "The requested URL was not found on the server." - return render_template("error.html", title="404 Not Found", code="404", message=message), 404 diff --git a/Songs2Slides/static/error.js b/Songs2Slides/static/error.js @@ -1,8 +0,0 @@ -/** - * Finishes setting up the page - * @returns {void} - */ -function onLoad() { - // Set theme - UpdateTheme(); -} diff --git a/Songs2Slides/static/favicon-180.png b/Songs2Slides/static/favicon-180.png Binary files differ. diff --git a/Songs2Slides/static/favicon-32.png b/Songs2Slides/static/favicon-32.png Binary files differ. diff --git a/Songs2Slides/static/global.css b/Songs2Slides/static/global.css @@ -1,88 +0,0 @@ -/******** Variable styles ********/ -:root { - --theme-color: #ffff50; - --foreground-color: #000000; - --background-color: #ffffff; - --border-color: #808080; - --hover-color: #f0f0f0; -} - - - -/******** Global styles ********/ -body { - text-align: center; - font-family: Arial, Helvetica, sans-serif; - touch-action: manipulation; - margin-top: 80px; - color: var(--foreground-color); - background-color: var(--background-color); -} - -button:not(.songRemove), input, select, textarea { - background-color: var(--background-color); - border: 1px solid var(--border-color); - color: var(--foreground-color); - border-radius: 0px; -} - -button:hover:enabled:not(.songRemove) { - cursor: pointer; - background-color: var(--hover-color); -} - -button:disabled { - background-color: var(--hover-color); - color: var(--border-color); -} - -input[type="file"] { - background-color: var(--hover-color); - border: none; -} - -h1 { - margin-top: 15px; - margin-bottom: 15px; -} - -#header { - position: fixed; - top: 0px; - left: 0px; - width: 100%; - background-color: var(--theme-color); -} - - -.container { - margin: auto; - padding: 10px; - background-color: var(--hover-color); -} - -button { - height: 25px; -} - - - -/******** Dark styles ********/ -body.dark { - --foreground-color: #e0e0e0; - --background-color: #121416; - --hover-color: #323436; - --border-color: #505050; -} - -.dark h1 { - color: #000000; -} - -.dark button:not(.songRemove), .dark input, .dark select { - --background-color: #222426; -} - -.dark a { - color: #0080ff; -} diff --git a/Songs2Slides/static/global.js b/Songs2Slides/static/global.js @@ -1,32 +0,0 @@ -/** - * Updates the interface theme - * @param {bool} theme - False for light mode, true for dark mode - * @returns {void} - */ -function UpdateTheme(theme = null) { - // Get theme from localStorage - if (theme === null) { - theme = localStorage.getItem("theme"); - } - - // Detect preferred color scheme - if (theme === null) { - if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { - theme = "Dark"; - } - else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) { - theme = "Light"; - } - } - - // Apply theme - if (theme === "Dark") { - document.body.classList.add("dark"); - } - else { - document.body.classList.remove("dark"); - } - - // Save theme - localStorage.setItem("theme", theme); -} diff --git a/Songs2Slides/static/home.css b/Songs2Slides/static/home.css @@ -1,110 +0,0 @@ -/******** Variable styles ********/ -:root { - --song-color: #C0C0C0; - --error-color: #E00000; -} - - - -/******** Songs styles ********/ -#songsContainer { - width: fit-content; - width: -moz-fit-content; -} - -h3 { - margin-top: 0px; -} - -.song { - width: fit-content; - width: -moz-fit-content; - margin-left: auto; - margin-right: auto; - margin-top: 10px; - margin-bottom: 10px; - padding: 8px; - background: var(--song-color); - border-radius: 5px; -} - -.songRemove { - border: none; - padding: 0px; - margin: 0px; - cursor: pointer; - color: var(--foreground-color); - background-color: var(--song-color); -} - -#addSong { - display: inline-block; - padding: 0px; - height: 40px; - width: 40px; - background-color: var(--song-color); - border: none; - border-radius: 50%; -} - -#addSong img { - height: 40px; - width: 40px; -} - -#songsButtons { - margin-top: 15px; - height: 25px; - text-align: left; -} - -#reviewButton { - float: right; -} - - - -/******** Lyrics styles ********/ -#lyricsContainer { - max-width: 500px; -} - -#lyricsContainer p { - text-align: left; -} - -#rawLyrics { - width: 100%; - resize: none; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; -} - -#fileUpload { - text-align: left; - margin-top: 5px; -} - -#errors { - margin-top: 15px; - color: var(--error-color); - font-weight: bold; -} - -#lyricsButtons { - margin-top: 15px; - text-align: left; - height: 25px; -} - -#submitLyricsButton { - float: right; -} - - - -/******** Dark styles ********/ -body.dark { - --song-color: #525456 -} diff --git a/Songs2Slides/static/home.js b/Songs2Slides/static/home.js @@ -1,173 +0,0 @@ -// Declare global variables -setId = 0; // Next valid song id number - - - -/** - * Finishes setting up the page - * @returns {void} - */ -function onLoad() { - // Add song - AddSong(); - - // Set theme - UpdateTheme(); -} - - - -/** - * Adds a blank song to the songs div - * @returns {void} - */ -function AddSong() { - // Create row - let clone = document.getElementById("songTemplate").content.cloneNode(true); - - // Set row id - clone.children[0].setAttribute("id", `song-${setId}`); - - // Add remove button onclick event - clone.getElementById("remove").setAttribute("onclick", `let element = document.getElementById('song-${setId}'); element.parentNode.removeChild(element);`); - - // Add row - document.getElementById("songs").appendChild(clone); - - // Increment setId - setId++; -} - - - -/** - * Gets the list of songs in the songs div - * @returns {list} - List of songs information - */ -function getSongs() { - // Get song info - let titles = []; - for (title of document.getElementsByClassName("title")) { - titles.push(title.value); - } - let artists = []; - for (artist of document.getElementsByClassName("artist")) { - artists.push(artist.value); - } - - // Prepare songs - let songs = [] - for (let i = 0; i < titles.length; i++) { - songs.push([titles[i], artists[i]]) - } - - // Set songs - return songs -} - - - -/** - * Gets the parsed lyrics for the user to review - * @returns {void} - */ -async function ReviewLyrics() { - // Show and hide elements - document.getElementById("rawLyrics").value = "Loading lyrics..."; - document.getElementById("rawLyrics").readOnly = true; - document.getElementById("errors").textContent = ""; - document.getElementById("songsContainer").hidden = true; - document.getElementById("lyricsContainer").hidden = false; - - // Get songs - let songs = getSongs(); - - // Send POST request - const rawResponse = await fetch("/lyrics", { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, body: JSON.stringify({"songs":songs,"settings":JSON.parse(localStorage.getItem("settings"))}) - }); - const json = await rawResponse.json(); - - // Set lyrics - document.getElementById("rawLyrics").value = json["lyrics"].join("\n\n") - document.getElementById("rawLyrics").readOnly = false; - - // Set errors - if (json["errors"].length != 0) - { - let errors = "The lyrics to the following songs could not be found: "; - for (error of json["errors"]) { - errors += `"${error[0]}" by "${error[1]}", `; - } - errors = errors.slice(0, -2); // Remove trailing ', ' - document.getElementById("errors").textContent = errors; - } -} - - - -/** - * Prepares the lyrics form to be submitted and shows the thankyou screen - * @returns {void} - */ -async function SubmitLyrics() { - // Get lyrics - let lyrics = document.getElementById("rawLyrics").value.split(/\n\s*\n/); - - // Set hidden form values - document.getElementById("pptxSettingsField").value = localStorage.getItem("settings"); - document.getElementById("lyricsField").value = JSON.stringify(lyrics); - - // Show and hide elements - document.getElementById("lyricsContainer").hidden = true; - document.getElementById("thankyou").hidden = false; -} - - - -/** - * Makes the previous screen visible - * @returns {void} - */ -function Back() { - if (document.getElementById("lyricsContainer").hidden == false) { - document.getElementById("songsContainer").hidden = false; - document.getElementById("lyricsContainer").hidden = true; - document.getElementById("thankyou").hidden = true; - } - else if (document.getElementById("thankyou").hidden == false) { - document.getElementById("songsContainer").hidden = true; - document.getElementById("lyricsContainer").hidden = false; - document.getElementById("thankyou").hidden = true; - } -} - - - -/** - * Clears the list of songs and makes the songs screen visible - * @returns {void} - */ -function Reset() { - // Remove songs - let songs = document.getElementsByClassName("song"); - while (songs.length > 0) { - // Get song - songs[0].parentNode.removeChild(songs[0]); - } - - // Reset pptx file input - document.getElementsByName("pptxFile")[0].value = null; - - // Add blank song - AddSong(); - - // Makes songs visible - document.getElementById("songsContainer").hidden = false; - document.getElementById("lyricsContainer").hidden = true; - document.getElementById("thankyou").hidden = true; -} diff --git a/Songs2Slides/static/plus.png b/Songs2Slides/static/plus.png Binary files differ. diff --git a/Songs2Slides/static/settings.css b/Songs2Slides/static/settings.css @@ -1,32 +0,0 @@ -/******** Settings styles ********/ -#settings { - max-width: 300px; - text-align: left; - line-height: 30px; -} - -h2 { - margin-top: 0px; - margin-bottom: 0px; - text-align: center; -} - -h4:first-of-type { - margin-top: 0px; -} - -h4 { - margin-bottom: 0px; -} - -input[type="number"] { - width: 75px; -} - -#settingsFooter { - margin-top: 15px; -} - -#resetButton { - float: right; -} diff --git a/Songs2Slides/static/settings.js b/Songs2Slides/static/settings.js @@ -1,127 +0,0 @@ -/** - * Finishes setting up the page - * @returns {void} - */ -function onLoad() { - // Load settings - if (localStorage.getItem("settings") == null) { - resetSettings(); - } - else { - loadSettings(JSON.parse(localStorage.getItem("settings"))); - } -} - - - -// Loads settings -/** - * Loads settings - * @param {object} settings - The settings object - * @returns {void} - */ -function loadSettings(settings) { - // Interface settings (not stored with other settings) - UpdateTheme(); - if (document.body.classList.contains("dark")) { - document.getElementById("theme").value = "Dark"; - } - else { - document.getElementById("theme").value = "Light"; - } - - // Parsing settings - document.getElementById("title-slides").checked = settings["title-slides"]; - document.getElementById("slide-between-songs").checked = settings["slide-between-songs"]; - document.getElementById("lines-per-slide").value = settings["lines-per-slide"]; - document.getElementById("remove-parentheses").checked = settings["remove-parentheses"]; - - // Slide settings - document.getElementById("slide-width").value = settings["slide-width"]; - document.getElementById("slide-height").value = settings["slide-height"]; - document.getElementById("slide-color").value = settings["slide-color"]; - - // Margin settings - document.getElementById("margin-left").value = settings["margin-left"]; - document.getElementById("margin-right").value = settings["margin-right"]; - document.getElementById("margin-top").value = settings["margin-top"]; - document.getElementById("margin-bottom").value = settings["margin-bottom"]; - - // Font settings - document.getElementById("font-family").value = settings["font-family"]; - document.getElementById("font-size").value = settings["font-size"]; - document.getElementById("font-bold").checked = settings["font-bold"]; - document.getElementById("font-italic").checked = settings["font-italic"]; - document.getElementById("font-color").value = settings["font-color"]; - - // Pharagraph settings - document.getElementById("vertical-alignment").value = settings["vertical-alignment"]; - document.getElementById("line-spacing").value = settings["line-spacing"]; - document.getElementById("word-wrap").checked = settings["word-wrap"]; -} - - - -/** - * Saves settings to localStorage - * @returns {void} - */ -function saveSettings() { - // Save interface settings and update interface - UpdateTheme(document.getElementById("theme").value); - - // Get settings - const settings = { - // Parsing settings - "title-slides": document.getElementById("title-slides").checked, - "slide-between-songs": document.getElementById("slide-between-songs").checked, - "lines-per-slide": Number(document.getElementById("lines-per-slide").value), - "remove-parentheses": document.getElementById("remove-parentheses").checked, - - // Slide settings - "slide-width": Number(document.getElementById("slide-width").value), - "slide-height": Number(document.getElementById("slide-height").value), - "slide-color": document.getElementById("slide-color").value, - - // Margin settings - "margin-left": Number(document.getElementById("margin-left").value), - "margin-right": Number(document.getElementById("margin-right").value), - "margin-top": Number(document.getElementById("margin-top").value), - "margin-bottom": Number(document.getElementById("margin-bottom").value), - - // Font settings - "font-family": document.getElementById("font-family").value, - "font-size": Number(document.getElementById("font-size").value), - "font-bold": document.getElementById("font-bold").checked, - "font-italic": document.getElementById("font-italic").checked, - "font-color": document.getElementById("font-color").value, - - // Pharagraph settings - "vertical-alignment": document.getElementById("vertical-alignment").value, - "line-spacing": Number(document.getElementById("line-spacing").value), - "word-wrap": document.getElementById("word-wrap").checked, - } - - // Save settings - localStorage.setItem("settings", JSON.stringify(settings)); -} - - - -/** - * Resets all settings to their default values - * @returns {void} - */ -async function resetSettings() { - // Send POST request - const rawResponse = await fetch("/settings.json", { - method: 'GET' - }); - const json = await rawResponse.json(); - - // Load changes - loadSettings(json); - - // Save changes - localStorage.setItem("settings", JSON.stringify(json)); -} diff --git a/Songs2Slides/templates/error.html b/Songs2Slides/templates/error.html @@ -1,12 +0,0 @@ -{% extends "layout.html" %} - -{% block header %} - <script src="{{ url_for('static', filename='error.js') }}"></script> -{% endblock header %} - -{% block content %} - <h1>{{ code }}</h1> - <p>{{ message }}</p> - - <p><a href="{{ url_for('home') }}">Return to the homepage</a></p> -{% endblock content %} diff --git a/Songs2Slides/templates/home.html b/Songs2Slides/templates/home.html @@ -1,62 +0,0 @@ -{% extends "layout.html" %} - -{% block header %} - <link rel="stylesheet" href="{{ url_for('static', filename='home.css') }}"></link> - <script src="{{ url_for('static', filename='home.js') }}"></script> -{% endblock header %} - -{% block content %} - <div id="songsContainer" class="container"> - <h3>Choose your songs</h3> - - <template id="songTemplate"> - <div class="song"> - <input type="text" class="title" placeholder="Song Title"/> - <input type="text" class="artist" placeholder="Song Artist"/> - <button id="remove" class="songRemove">╳</button> - </div> - </template> - <div id="songs"></div> - - <div> - <button id="addSong" onclick="AddSong();"> - <img src="{{ url_for('static', filename='plus.png')}}"/> - </button> - </div> - - <div id="songsButtons"> - <a href="/settings/">Settings</a> - <button id="reviewButton" onclick="ReviewLyrics();">Next</button> - </div> - </div> - - <form id="lyricsContainer" class="container" hidden action="/pptx" method="POST" enctype="multipart/form-data"> - <h3>Review your PowerPoint</h3> - - <p>Review and edit the parsed lyrics below and then click the create PowerPoint button.</p> - <p>One blank line represents the start of a new slide and three blank lines represent a blank slide.</p> - - <input type="text" id="pptxSettingsField" name="settings" hidden> - <input type="text" id="lyricsField" name="lyrics" hidden> - - <textarea rows="15" id="rawLyrics"></textarea> - - <div id="fileUpload"> - <label>Starting PowerPoint (optional): </label> - <input type="file" name="pptxFile" accept="application/vnd.openxmlformats-officedocument.presentationml.presentation, application/vnd.ms-powerpoint"> - </div> - - <p id="errors"></p> - - <div id="lyricsButtons"> - <a id="lyricsBack" href="javascript:Back()">Back</a> - <button id="submitLyricsButton" onclick="SubmitLyrics()">Create Powerpoint</button> - </div> - </form> - - <div id="thankyou" hidden> - <h3>Thank you for using Songs2Slides</h3> - <p><a href="javascript:Back()">Edit your PowerPoint</a></p> - <p><a href="javascript:Reset()">Create another PowerPoint</a></p> - </div> -{% endblock content %} diff --git a/Songs2Slides/templates/layout.html b/Songs2Slides/templates/layout.html @@ -1,26 +0,0 @@ -<!DOCTYPE html> -<html> - <head> - {% if title %} - <title>{{ title }}</title> - {% else %} - <title>SongsSlides</title> - {% endif %} - - <meta name="description" content="Quickly and easily create lyric powerpoints from a list of songs."> - <meta name="viewport" content="width=device-width, user-scalable=no"/> - <link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon-32.png') }}"> - <link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='favicon-180.png') }}"> - <link rel="stylesheet" href="{{ url_for('static', filename='global.css') }}"></link> - <script src="{{ url_for('static', filename='global.js') }}"></script> - - {% block header %}{% endblock %} - </head> - <body onload="onLoad();"> - <div id="header"> - <h1>Songs2Slides</h1> - </div> - - {% block content %}{% endblock %} - </body> -</html> diff --git a/Songs2Slides/templates/settings.html b/Songs2Slides/templates/settings.html @@ -1,130 +0,0 @@ -{% extends "layout.html" %} - -{% block header %} - <link rel="stylesheet" href="{{ url_for('static', filename='settings.css') }}"></link> - <script src="{{ url_for('static', filename='settings.js') }}"></script> -{% endblock header %} - -{% block content %} - <div id="settings" class="container"> - <h2>Settings</h2> - <h4>Interface</h4> - <div> - Theme: - <select id="theme" onchange="saveSettings();"> - <option>Light</option> - <option>Dark</option> - </select> - </div> - <h4>Parsing</h4> - <div> - Add title slides: - <input type="checkbox" id="title-slides" onchange="saveSettings();"/> - </div> - <div> - Blank slide between songs: - <input type="checkbox" id="slide-between-songs" onchange="saveSettings();"/> - </div> - <div> - Default lines per slide: - <input type="number" min="1" step="1" id="lines-per-slide" onchange="saveSettings();"/> - </div> - <div> - Remove content in parentheses: - <input type="checkbox" id="remove-parentheses" onchange="saveSettings();"/> - </div> - - <h4>Slide settings</h4> - <div> - Width (Inches): - <input type="number" min="0" step="any" id="slide-width" onchange="saveSettings();"/> - </div> - <div> - Height (Inches): - <input type="number" min="0" step="any" id="slide-height" onchange="saveSettings();"/> - </div> - <div> - Background color: - <input type="color" id="slide-color" onchange="saveSettings();"/> - </div> - - <h4>Margin settings</h4> - <div> - Left (Inches): - <input type="number" min="0" step="any" id="margin-left" onchange="saveSettings();"/> - </div> - <div> - Right (Inches): - <input type="number" min="0" step="any" id="margin-right" onchange="saveSettings();"/> - </div> - <div> - Top (Inches): - <input type="number" min="0" step="any" id="margin-top" onchange="saveSettings();"/> - </div> - <div> - Bottom (Inches): - <input type="number" min="0" step="any" id="margin-bottom" onchange="saveSettings();"/> - </div> - - <h4>Font settings</h4> - <div> - Family: - <input id="font-family" list="fonts" onchange="saveSettings();"> - <datalist id="fonts"> - <option>Arial</option> - <option>Century</option> - <option>Century Gothic</option> - <option>Calibri</option> - <option>Comic Sans MS</option> - <option>Courier New</option> - <option>Georgia</option> - <option>Impact</option> - <option>Rockwell</option> - <option>Segoe UI</option> - <option>Tahoma</option> - <option>Times New Roman</option> - <option>Trebuchet MS</option> - <option>Verdana</option> - </datalist> - </div> - <div> - Size: - <input type="number" min="0" step="1" id="font-size" onchange="saveSettings();"/> - </div> - <div> - Bold: - <input type="checkbox" id = "font-bold" onchange="saveSettings();"/> - </div> - <div> - Italic: - <input type="checkbox" id="font-italic" onchange="saveSettings();"/> - </div> - <div> - Color: - <input type="color" id="font-color" onchange="saveSettings();"/> - </div> - - <h4>Pharagraph settings</h4> - <div> - Vertical Alignment: - <select id="vertical-alignment" onchange="saveSettings();"> - <option>Top</option> - <option>Middle</option> - <option>Bottom</option> - </select> - </div> - <div> - Line spacing: - <input type="number" min="0" step="any" id="line-spacing" onchange="saveSettings();"/> - </div> - <div> - Word wrap: - <input type="checkbox" id="word-wrap" onchange="saveSettings();"/> - </div> - - <div id="settingsFooter"> - <a href="../">Back</a> - <button id="resetButton" onclick="resetSettings();">Reset</button> - </div> - </div> -{% endblock content %} diff --git a/Tests/test_core.py b/Tests/test_core.py @@ -1,88 +0,0 @@ -# Import dependencies -from Songs2Slides import core, config -import unittest -from unittest.mock import patch - - - -class TestCore(unittest.TestCase): - # Test GetLyrics method on cached songs - def test_GetLyrics_cache(self): - # Set cached songs - config.cachedSongs = { - "test-artist-test-song": { - "title": "Test Song", - "artist": "Test Artist", - "lyrics":"test1\ntest2\n\ntest3\ntest4" - } - } - - # Test cached song - lyrics, title, artist = core.GetLyrics("tEsT sOnG", "tEsT aRtIsT") - self.assertEqual(lyrics, "test1\ntest2\n\ntest3\ntest4") - self.assertEqual(title, "Test Song") - self.assertEqual(artist, "Test Artist") - - - - # Test GetLyrics method on songs on the internet - def test_GetLyrics_web(self): - with patch('Songs2Slides.core.requests.get') as mocked_get: - # Initialize mocked_get - mocked_get.return_value.text = b"<!DOCTYPE html><html><head></head><body><h1 class=\"SongHeader__Title-sc-1b7aqpg-7\">Test Song 2</h1><a class=\"SongHeader__Artist-sc-1b7aqpg-9\">Test Artist</a><div class=\"Lyrics__Root-sc-1ynbvzw-1\"><div class=\"Lyrics__Container-sc-1ynbvzw-8\">[Verse 1]<br><a><span>Test1<br>Test2<br>Test3</span></a><br><a><span>Test4<br>Test5</span></a></div><div class=\"Lyrics__Container-sc-1ynbvzw-8\">[Verse 2]<br><a><span>Test10<br>Test20</span></a><br>Test30<br>Test40<br><a><span>Test50</span></a></div></div></body></html>" - - # Get song lyrics - lyrics, title, artist = core.GetLyrics("tEsT sOnG 2", "tEsT aRtIsT") - - # Validate responce - mocked_get.assert_called_with("https://genius.com/test-artist-test-song-2-lyrics") - self.assertEqual(lyrics, "[Verse 1]\nTest1\nTest2\nTest3\nTest4\nTest5\n[Verse 2]\nTest10\nTest20\nTest30\nTest40\nTest50") - self.assertEqual(title, "Test Song 2") - self.assertEqual(artist, "Test Artist") - - - - # Test ParseLyrics method - def test_ParseLyrics(self): - # Initialize settings - settings = { - "title-slides": True, - "slide-between-songs": True, - "lines-per-slide": 4, - "remove-parentheses": False, - } - - # Mock core.getLyrics method - with patch('Songs2Slides.core.GetLyrics') as mocked_get: - # Initialize mocked_get - mocked_get.return_value = ("[Verse 1]\nTest1\nTest2\nTest3\nTest4\nTest5 (Test5)\n[Verse 2]\nTest10\nTest20\nTest30\nTest40\nTest50(Test50)", "Test Song", "Test Artist") - - # Test parser - lyrics = core.ParseLyrics("tEsT sOnG 2", "tEsT aRtIsT", settings) - self.assertEqual(lyrics, ["Test Song\nTest Artist", "Test1\nTest2\nTest3\nTest4", "Test5 (Test5)", "Test10\nTest20\nTest30\nTest40", "Test50(Test50)", ""]) - mocked_get.assert_called_with("tEsT sOnG 2", "tEsT aRtIsT") - - # Test parser without title slide - settings["title-slides"] = False - lyrics = core.ParseLyrics("tEsT sOnG", "tEsT aRtIsT", settings) - self.assertEqual(lyrics, ["Test1\nTest2\nTest3\nTest4", "Test5 (Test5)", "Test10\nTest20\nTest30\nTest40", "Test50(Test50)", ""]) - - # Test parser without slide at end - settings["slide-between-songs"] = False - lyrics = core.ParseLyrics("tEsT sOnG", "tEsT aRtIsT", settings) - self.assertEqual(lyrics, ["Test1\nTest2\nTest3\nTest4", "Test5 (Test5)", "Test10\nTest20\nTest30\nTest40", "Test50(Test50)"]) - - # Test parser with 3 lines per slide - settings["lines-per-slide"] = 3 - lyrics = core.ParseLyrics("tEsT sOnG", "tEsT aRtIsT", settings) - self.assertEqual(lyrics, ["Test1\nTest2\nTest3", "Test4\nTest5 (Test5)", "Test10\nTest20\nTest30", "Test40\nTest50(Test50)"]) - - # Test parser with parentheses remover - settings["remove-parentheses"] = True - lyrics = core.ParseLyrics("tEsT sOnG 2", "tEsT aRtIsT", settings) - self.assertEqual(lyrics, ["Test1\nTest2\nTest3", "Test4\nTest5", "Test10\nTest20\nTest30", "Test40\nTest50"]) - - # Test parser with blank line - mocked_get.return_value = ("[Verse 1]\nTest1\n[Instrumental]\n[Verse 2]\nTest2", "Test Song", "Test Artist") - lyrics = core.ParseLyrics("tEsT sOnG 2", "tEsT aRtIsT", settings) - self.assertEqual(lyrics, ["Test1", "", "Test2"]) diff --git a/cliapp.py b/cliapp.py @@ -1,106 +0,0 @@ -# Import dependencies -import os -import re -from Songs2Slides import core -from Songs2Slides.config import defaultSettings -import subprocess -import sys -import tempfile - - - -# Run CLI -if (__name__ == "__main__"): - # Print title - print("Songs2Slides") - print() - - # Get song lyrics - lyrics = [] - song = 1 - while (True): - # Get song information - title = input("Song #{0} Title: ".format(song)) - artist = input("Song #{0} Artist: ".format(song)) - - # Get song lyrics - try: - lyrics += core.ParseLyrics(title, artist, defaultSettings) - except: - print("The song could not be found. Make sure that you spelled it correctly.") - song -= 1 - - # Add more songs - if (song >= 1 and input("Do you want to add another song? (y/n): ").lower() == "n"): - break - else: - song += 1 - - # Review lyrics - if (input("Do you want to review the parsed lyrics first? (y/n): ").lower() == "y"): - try: - # Create temp file - temp = tempfile.NamedTemporaryFile(mode="w+t", suffix=".txt", delete=False) - temp.writelines("\n\n".join(lyrics)) - temp.close() - - # Open temp file and wait - if (sys.platform == "win32"): - subprocess.Popen(["notepad", temp.name]).wait() - elif (sys.platform == "darwin"): - subprocess.Popen(["open", temp.name]).wait() - else: - subprocess.Popen(["xdg-open", temp.name]).wait() - - # Read file - with open(temp.name) as f: - rawLines = f.read() - - # Parse lyrics - lyrics = re.split("\n\s*\n", rawLines) - except: - print("There was an error while reviewing the lyrics. The unrevised lyrics will be used instead.") - finally: - # Delete temp file - os.remove(temp.name) - - # Get filepath - filepath = input("Enter a filepath to save the powerpoint to: ") - - # Add extension - if (len(filepath) == 0): - filepath = "Untitled.pptx" - elif (len(filepath) < 4): - filepath += ".pptx" - elif (len(filepath) == 4 and filepath[-4:] != ".ppt"): - filepath += ".pptx" - elif (len(filepath) > 4 and filepath[-5:] != ".pptx" and filepath[-4:] != ".ppt"): - filepath += ".pptx" - - # Confirm overwrite - if (os.path.exists(filepath)): - openFirst = (input("This powerpoint already exists. Do you want to add on to it? (y/n): ").lower() == "y") - else: - openFirst = False - - # Create powerpoint - try: - core.CreatePptx(lyrics, filepath, defaultSettings, openFirst) - except: - print("There was an error while creating the powerpoint.") - input("Press enter to exit...") - sys.exit() - - # Open powerpoint - if (input("Do you want to view the powerpoint now? (y/n): ").lower() == "y"): - try: - if (sys.platform == "win32"): - os.startfile(filepath) - elif (sys.platform == "darwin"): - subprocess.Popen(["open", filepath]) - else: - subprocess.Popen(["xdg-open", filepath]) - except: - print("There was an error while opening the powerpoint.") - input("Press enter to exit...") - sys.exit() diff --git a/requirements.txt b/requirements.txt @@ -1,5 +1,4 @@ -bs4 +flask +python-dotenv python-pptx requests -flask -unidecode -\ No newline at end of file diff --git a/songs2slides/__init__.py b/songs2slides/__init__.py @@ -0,0 +1,13 @@ +from flask import Flask, render_template + +def error_404(e): + return render_template('error.html', message='404 Not Found'), 404 + +def create_app(): + app = Flask(__name__) + + from . import routes + app.register_blueprint(routes.bp) + app.register_error_handler(404, error_404) + + return app diff --git a/songs2slides/core.py b/songs2slides/core.py @@ -0,0 +1,215 @@ +from dataclasses import dataclass +from dotenv import load_dotenv +import pptx +from pptx.dml.color import RGBColor +from pptx.enum.text import MSO_ANCHOR, PP_ALIGN +from pptx.util import Inches, Pt +import os +import re +import requests + +@dataclass +class SongData: + """ + Represents data about a song + + Attributes + ---------- + title : str + The title of the song + artist : str + The artist of the song + lyrics : str + The song's lyrics, with double newlines separating stanzas + """ + + title: str + artist: str + lyrics: str + +def filter_lyrics(lyrics: str): + """ + Filter raw lyrics to remove text enclosed in brackets or parenthesis + + Used by get_song_data + + Parameters + ---------- + lyrics : str + The raw lyrics + + Returns + ------- + str + The filtered lyrics + """ + + filtered = '\n' + lyrics + '\n' + + # Remove enclosed text that takes up whole numbers of lines + filtered = re.sub(r'\n\[[^\]]*\]\n', '\n', filtered) + filtered = re.sub(r'\n\([^\)]*\)\n', '\n', filtered) + + # Remove enclosed text that takes up partial lines + filtered = re.sub(r'\[[^\]]*\]', '', filtered) + filtered = re.sub(r'\([^\)]*\)', '', filtered) + + return filtered.strip() + +def get_song_data(title: str, artist:str): + """ + Get song data from an external API + + Parameters + ---------- + title : str + The title of the song + artist : str + The artist of the song + + Returns + ------- + SongData + The song data + """ + + url = os.getenv('API_URL') + if url is None: + raise Exception() + url = url.replace('{title}', title, 1) + url = url.replace('{artist}', artist, 1) + data = requests.get(url).json() + + if 'lyrics' in data.keys(): + return SongData(data['title'], data['artist'], + filter_lyrics(data['lyrics'])) + else: + raise Exception() + +def parse_song_lyrics(lyrics: str, lines_per_slide: int): + """ + Parse slide contents from the raw lyrics of a song + + Used by assemble_slides + + Parameters + ---------- + lyrics : str + The song lyrics + lines_per_slide : int + The maximum number of lines per slide + + Returns + ------- + list of str + The list of slide contents + """ + + slides = [''] + line_count = 0 + + for line in lyrics.strip().split('\n'): + line = line.strip() + + if line == '': + # Empty line represents new slide + if line_count != 0 or len(slides) < 2 or slides[-2] != '': + # Consecutive empty slides are not allowed + slides += [''] + line_count = 0 + + elif lines_per_slide is None or line_count < lines_per_slide: + # Add line to current slide + if line_count != 0: slides[-1] += '\n' + slides[-1] += line + line_count += 1 + + else: + # Overflow to new slide + slides += [line] + line_count = 1 + + # Address case where lyrics are empty + if slides == ['', '']: slides = [] + + return slides + +def assemble_slides(songs: list[SongData], lines_per_slide: int = 4, + title_slides: bool = True, blank_slides: bool = True): + """ + Assemble slides from a list of songs + + Paramters + --------- + songs : list of SongData + The songs + lines_per_slide : int + The maximum number of lines per slide (default: 4) + title_slides : bool + Whether to include title slides before songs (default: True) + blank_slides : bool + Whether to include blank slides between songs (default: True) + + Returns + ------- + list of str + The list of slide contents + """ + + slides = [] + for song in songs: + # Add slides for song + if title_slides: slides += [f'{song.title}'] + slides += parse_song_lyrics(song.lyrics.upper(), lines_per_slide) + if blank_slides: slides += [''] + + # Remove trailing blank slides + if len(slides) and blank_slides: slides = slides[:-1] + + return slides + +def create_pptx(slide_contents: list[str], filepath: str): + """ + Create a PowerPoint from a list of slide contents + + Parameters + ---------- + slide_contents : list of str + The list of slide contents + filepath : str + The file to save the PowerPoint to + """ + + # Create presentation + prs = pptx.Presentation() + + # Get blank slide template + blank_slide_layout = prs.slide_layouts[6] + + # Get textbox size parameters + margin = Inches(1) + width = prs.slide_width - Inches(2) + height = prs.slide_height - Inches(2) + + for slide_content in slide_contents: + # Create and format slide + slide = prs.slides.add_slide(blank_slide_layout) + slide.background.fill.solid() + slide.background.fill.fore_color.rgb = RGBColor.from_string('000000') + + # Create and format textbox + textbox = slide.shapes.add_textbox(margin, margin, width, height) + textbox.text_frame.vertical_anchor = MSO_ANCHOR.MIDDLE + textbox.text_frame.word_wrap = True + + # Format paragraph + paragraph = textbox.text_frame.paragraphs[0] + paragraph.font.color.rgb = RGBColor.from_string('ffffff') + paragraph.font.size = Pt(48) + paragraph.alignment = PP_ALIGN.CENTER + + # Add slide content + paragraph.text = slide_content + + # Save to file + prs.save(filepath) diff --git a/songs2slides/routes.py b/songs2slides/routes.py @@ -0,0 +1,85 @@ +from flask import abort, Blueprint, render_template, request, send_file +import tempfile + +from songs2slides import core + +bp = Blueprint('main', __name__) + +def parse_form(form): + """ + Parse song data from a form + + Parameters + ---------- + form : flask.Request.form + The form data + + Returns + ------- + list of core.SongData + The parsed song information + """ + + songs = [] + try: + i = 1 + while f'title-{i}' in request.form: + songs += [core.SongData( + form[f'title-{i}'], + form[f'artist-{i}'], + form.get(f'lyrics-{i}', None) + )] + i += 1 + except: + abort(400) + else: + return songs + +@bp.route('/') +def home(): + return render_template('home.html') + +@bp.get('/create/') +def create(): + return render_template('create.html', step=1, songs=[], missing=0) + +@bp.post('/create/') +def get_lyrics(): + # Parse form data + songs = parse_form(request.form) + + # Get lyrics + for i in range(len(songs)): + try: + songs[i] = core.get_song_data(songs[i].title, songs[i].artist) + slides = core.parse_song_lyrics(songs[i].lyrics, 4) + songs[i].lyrics = '\n\n'.join(slides) + except: + pass + + # Count missing songs + missing = sum([1 for x in songs if x.lyrics == None]) + + # Return song data + return render_template('create.html', step=2, songs=songs, missing=missing) + +@bp.post('/slides/') +def create_slides(): + # Parse form data + songs = parse_form(request.form) + title_slides = 'title-slides' in request.form + blank_slides = 'blank-slides' in request.form + + # Assemble slides + slides = core.assemble_slides(songs, lines_per_slide = None, + title_slides=title_slides, blank_slides=blank_slides) + + if (request.form.get('output-type') == 'pptx'): + # Create and send powerpoint + with tempfile.NamedTemporaryFile(suffix='.pptx') as f: + core.create_pptx(slides, f.name) + return send_file(f.name, as_attachment=True, + download_name='slides.pptx') + else: + # Render HTML slides + return render_template('slides.html', slides=slides) diff --git a/songs2slides/static/create.css b/songs2slides/static/create.css @@ -0,0 +1,86 @@ +/* step 1 and 2 */ +#actions { + margin-top: 2rem; +} +#actions :last-child { + float: right; +} + +/* step 1 */ +table { + width: 100%; + border-collapse: collapse; +} +table input { + width: 100%; +} +table td { + padding: 0.2rem; +} + +.loading-modal { + position: fixed; + top: 0px; + left: 0px; + width: 100%; + height: 100%; + background: #80808080; +} +.loading-modal > div { + margin: 10rem auto; + padding: 1rem; + width: 300px; + text-align: center; + background: var(--background1); + border-radius: 0.5rem; +} + +.spinner { + display: inline-block; +} +.spinner:after { + content: " "; + display: block; + width: 2rem; + height: 2rem; + border-radius: 50%; + border: 0.3rem solid; + border-color: var(--foreground) var(--foreground) var(--foreground) transparent; + animation: spinner 1s linear infinite; +} +@keyframes spinner { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +/* step 2 */ +details { + margin-bottom: 1rem; +} +textarea { + margin-top: 0.5rem; + width: 100%; + height: 15rem; + resize: vertical; +} + +.missing summary { + color: var(--error); +} +.missing summary span { + float: right; + font-weight: bold; +} + +fieldset { + border: none; + margin-top: 1rem; +} +label { + display: block; + margin-top: 0.5rem; +} diff --git a/songs2slides/static/create.js b/songs2slides/static/create.js @@ -0,0 +1,41 @@ +addEventListener('submit', () => { + if (document.getElementById('step-1').hidden == false) { + // Show step 1 spinner + document.getElementById('post-step-1').hidden = false + } else if (document.querySelector('input[value=pptx]').checked) { + // Show step 2 downloading message + document.getElementById('step-2').hidden = true + document.getElementById('post-step-2').hidden = false + } +}); + +/* step 1 functions */ +function add_song() { + let row = document.getElementById('row-template').content.children[0].cloneNode(true) + document.getElementById('songs').appendChild(row) + renumber_songs() +} + +function remove_song(n) { + document.getElementsByTagName('tr')[n].remove() + renumber_songs() + if (document.getElementsByTagName('tr').length === 1) add_song() +} + +function renumber_songs() { + const songs = document.getElementsByTagName('tr') + for (let i = 1; i < songs.length; i++) { + songs[i].children[0].textContent = `${i}.` + songs[i].children[1].children[0].name = `title-${i}` + songs[i].children[2].children[0].name = `artist-${i}` + songs[i].children[3].children[0].onclick = () => remove_song(i) + } +} + +/* step 2 functions */ +function back() { + document.getElementById('step-1').hidden = false + document.getElementById('post-step-1').hidden = true + document.getElementById('step-2').hidden = true + document.getElementById('post-step-2').hidden = true +} diff --git a/songs2slides/static/global.css b/songs2slides/static/global.css @@ -0,0 +1,123 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* page setup */ +body { + font-family: sans-serif; +} +header { + font-weight: bold; + text-align: center; + font-size: 1.5rem; +} +main { + margin: auto; + max-width: 500px; +} + +/* spacing and alignment */ +h1 { + text-align: center; +} +p { + line-height: 1.4; + margin-bottom: 1rem; +} + +/* padding and borders */ +header, main { + padding: 1rem; +} +button, input { + padding: 0.3rem 0.5rem; + border: 1px solid; + border-radius: 5px; +} +textarea { + padding: 0.5rem; +} + +/* colors */ +body { + color: var(--foreground); + background-color: var(--background1); +} +header { + color: #000000; + background: var(--theme); +} +input, button, textarea { + color: var(--foreground); + border-color: var(--background5); + background-color: var(--background2); +} +@media (hover: hover) { + button:hover, input[type=button]:hover { + background-color: var(--background3); + } +} +button:active, input[type=button]:active { + background-color: var(--background4); +} +a { + color: var(--link); +} + +/* light/default theme */ +:root { + /* The theme color of the app */ + --theme: hsl(60, 100%, 65%); + + /* The background color of the app */ + --background1: #ffffff; + + /* The default background color of app elements */ + --background2: #f8f8f8; + + /* The background color of focused app elements */ + --background3: #e8e8e8; + + /* The background color of active app elements */ + --background4: #e0e0e0; + + /* The border color of app elements */ + --background5: #a0a0a0; + + /* The foreground color of app elements */ + --foreground: #000000; + + /* The color of links */ + --link: hsl(210, 100%, 40%); + + /* The error color */ + --error: hsl(0, 100%, 40%); +} + +/* dark mode */ +@media only screen and (prefers-color-scheme: dark) { + :root { + --background1: hsl(210, 20%, 10%); + --background2: hsl(210, 20%, 15%); + --background3: hsl(210, 20%, 20%); + --background4: hsl(210, 20%, 25%); + --background5: hsl(210, 20%, 45%); + --foreground: #e8e8e8; + --link: hsl(210, 100%, 65%); + } +} + +/* print media mode */ +@media only print { + :root { + --background1: #ffffff; + --background2: #ffffff; + --background3: #ffffff; + --background4: #ffffff; + --background5: #000000; + --foreground: #000000; + --link: #0000ff; + } +} diff --git a/songs2slides/static/home.css b/songs2slides/static/home.css @@ -0,0 +1,8 @@ +main { + text-align: center; + padding: 2rem 0rem; +} +p { + font-size: 2rem; + font-weight: bold; +} diff --git a/songs2slides/static/icon-16.png b/songs2slides/static/icon-16.png Binary files differ. diff --git a/songs2slides/static/icon-180.png b/songs2slides/static/icon-180.png Binary files differ. diff --git a/songs2slides/static/icon-32.png b/songs2slides/static/icon-32.png Binary files differ. diff --git a/songs2slides/static/icon.svg b/songs2slides/static/icon.svg @@ -0,0 +1,73 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + width="256" + height="256" + viewBox="0 0 67.733332 67.733333" + version="1.1" + id="svg1" + xml:space="preserve" + inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)" + sodipodi:docname="icon.svg" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview + id="namedview1" + pagecolor="#ffffff" + bordercolor="#000000" + borderopacity="0.25" + inkscape:showpageshadow="2" + inkscape:pageopacity="0.0" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#d1d1d1" + inkscape:document-units="mm" + inkscape:zoom="1.6819304" + inkscape:cx="85.615908" + inkscape:cy="77.589417" + inkscape:window-width="1884" + inkscape:window-height="1009" + inkscape:window-x="0" + inkscape:window-y="0" + inkscape:window-maximized="0" + inkscape:current-layer="svg1" /><defs + id="defs1" /><rect + style="fill:#ffff50;fill-opacity:1;stroke:#000000;stroke-width:2.5131;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" + id="rect3" + width="36.645321" + height="36.645321" + x="25.598217" + y="5.4898834" + rx="4.2333331" + ry="4.2333331" + inkscape:label="slide-3" /><rect + style="fill:#ffff50;fill-opacity:1;stroke:#000000;stroke-width:2.5131;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" + id="rect2" + width="36.645321" + height="36.645321" + x="15.54405" + y="15.54405" + rx="4.2333331" + ry="4.2333331" + inkscape:label="slide-2" /><rect + style="fill:#ffff50;fill-opacity:1;stroke:#000000;stroke-width:2.5131;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" + id="rect1" + width="36.645321" + height="36.645321" + x="5.4898834" + y="25.598217" + rx="4.2333331" + ry="4.2333331" + inkscape:label="slide-1" /><text + xml:space="preserve" + style="font-size:39.511px;line-height:1;font-family:sans-serif;-inkscape-font-specification:sans-serif;text-align:center;text-decoration-color:#000000;word-spacing:0px;text-anchor:middle;white-space:pre;fill:#000000;fill-opacity:1;stroke:none;stroke-width:2.117;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1" + x="23.276875" + y="58.275337" + id="text1" + inkscape:label="text"><tspan + sodipodi:role="line" + id="tspan1" + style="font-weight:bold;fill:#000000;fill-opacity:1;stroke:none;stroke-width:2.117" + x="23.276875" + y="58.275337">S</tspan></text></svg> diff --git a/songs2slides/templates/create.html b/songs2slides/templates/create.html @@ -0,0 +1,189 @@ +{% extends "layout.html" %} + +{% block head %} +<link rel="stylesheet" href="{{ url_for('static', filename='create.css') }}"/> +<script src="{{ url_for('static', filename='create.js') }}"></script> +{% endblock head %} + +{% block main %} +<form id="step-1" method="POST" {% if step == 2 %} hidden {% endif %}> + <h1>Step 1: Select Songs</h1> + + <p> + Select the songs to include in the slide show by their title and artist. + </p> + + <template id="row-template"> + <tr> + <td></td> + <td> + <input name="title-" placeholder="Song title" required/> + </td> + <td> + <input name="artist-" placeholder="Song artist"/> + </td> + <td> + <input type="button" value="Remove"/> + </td> + </tr> + </template> + + <table> + <thead> + <tr> + <th></th> + <th>Title</th> + <th>Artist</th> + <th></th> + </tr> + </thead> + <tbody id="songs"> + {% if step == 1 %} + <tr> + <td>1.</td> + <td> + <input type="text" name="title-1" placeholder="Song title" required/> + </td> + <td> + <input type="text" name="artist-1" placeholder="Song artist"/> + </td> + <td> + <input type="button" value="Remove" onclick="remove_song(1)"/> + </td> + </tr> + {% else %} + {% for song in songs %} + <tr> + <td>{{ loop.index }}.</td> + <td> + <input type="text" name="title-{{ loop.index }}" required + placeholder="Song title" value="{{ song.title }}"/> + </td> + <td> + <input type="text" name="artist-{{ loop.index }}" + placeholder="Song artist" value="{{ song.artist }}"/> + </td> + <td> + <input type="button" value="Remove" + onclick="remove_song({{ loop.index }})"/> + </td> + </tr> + {% endfor %} + {% endif %} + </tbody> + </table> + + <div id="actions"> + <input type="button" value="Add song" onclick="add_song()"/> + <button> + Next + </button> + </div> +</form> + +<div id="post-step-1" class="loading-modal" hidden> + <div> + <p>Loading your song lyrics...</p> + <div class="spinner"></div> + </div> +</div> + +<form id="step-2" method="POST" {% if step == 1 %} hidden {% endif %} + action="{{ url_for('.create_slides') }}"> + + {% set format_hint = + 'One blank line represents the start of a new slide and three blank ' + + 'lines represent an empty slide.' + %} + + <h1>Step 2: Preview Slides</h1> + <p> + Review the parsed song lyrics below and make any necessary corrections. + {{ format_hint }} + </p> + + {% if missing > 0 %} + <p> + Lyrics must be entered manually for + {{ missing }} {% if missing == 1 %} song. {% else %} songs. {% endif %} + </p> + {% endif %} + + <div> + {% for song in songs %} + <details + {% if missing == 0 and loop.index == 1 %} open {% endif %} + {% if not song.lyrics %} open class="missing" {% endif %}> + + <input hidden name="title-{{ loop.index }}" + value="{{ song.title }}"/> + <input hidden name="artist-{{ loop.index }}" + value="{{ song.artist }}"/> + + <summary> + <i>{{ song.title }}</i> + + {% if song.artist %} + ({{ song.artist }}) + {% endif %} + + {% if not song.lyrics %} + <span>lyrics not found</span> + {% endif %} + </summary> + + {% if song.lyrics %} + <textarea name="lyrics-{{ loop.index }}" + placeholder="{{format_hint}}">{{ song.lyrics }}</textarea> + {% else %} + <textarea name="lyrics-{{ loop.index }}" placeholder="{{ + 'Lyrics not found, please enter them here manually.\n\n' + + format_hint }}"></textarea> + {% endif %} + </details> + {% endfor %} + </div> + + <div> + <fieldset> + <legend>Extra slides:</legend> + <label> + <input type="checkbox" name="title-slides" checked/> + Include a title slide before each song + </label> + <label> + <input type="checkbox" name="blank-slides" checked/> + Include a blank slide between each song + </label> + </fieldset> + <fieldset> + <legend>Output type:</legend> + <label> + <input type="radio" name="output-type" value="pptx" checked/> + PowerPoint Download + </label> + <label> + <input type="radio" name="output-type" value="html"/> + Online View + </label> + </fieldset> + </div> + + <div id="actions"> + <input onclick="back()" type="button" value="Back"/> + <button> + Create Slide Show + </button> + </div> +</form> + +<div id="post-step-2" hidden> + <p> + Your slide show is being downloaded, + thank you for using Songs2Slides. + </p> + <p> + <a href="{{ url_for('.create') }}">Create another slide show</a> + </p> +</div> +{% endblock main %} diff --git a/songs2slides/templates/error.html b/songs2slides/templates/error.html @@ -0,0 +1,5 @@ +{% extends "layout.html" %} + +{% block main %} +<h1>{{ message }}</h1> +{% endblock main %} diff --git a/songs2slides/templates/home.html b/songs2slides/templates/home.html @@ -0,0 +1,10 @@ +{% extends "layout.html" %} + +{% block head %} +<link rel="stylesheet" href="{{ url_for('static', filename='home.css') }}"/> +{% endblock head %} + +{% block main %} +<p>Create lyric slide shows easily and quickly</p> +<a href="{{ url_for('.create') }} ">Get Started</a> +{% endblock main %} diff --git a/songs2slides/templates/layout.html b/songs2slides/templates/layout.html @@ -0,0 +1,22 @@ +<!DOCTYPE html> +<html> + <head> + <title>SongsSlides</title> + <meta name="description" content="Automatically creates lyric slide shows from a list of songs"> + <link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='icon-32.png') }}"> + <link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='icon-32.png') }}"> + <link rel="apple-touch-icon" href="{{ url_for('static', filename='icon-180.png') }}"> + <link rel="stylesheet" href="{{ url_for('static', filename='global.css') }}"/> + + {% block head %}{% endblock %} + </head> + <body> + <header> + Songs2Slides + </header> + + <main> + {% block main %}{% endblock %} + </main> + </body> +</html> diff --git a/songs2slides/templates/slides.html b/songs2slides/templates/slides.html @@ -0,0 +1,37 @@ +<!DOCTYPE html> +<html> + <head> + <title>Online View - SongsSlides</title> + + <link rel="stylesheet" href="https://unpkg.com/reveal.js@^4//dist/reveal.css"> + <link rel="stylesheet" href="https://unpkg.com/reveal.js@^4//dist/theme/black.css"> + <script src="https://unpkg.com/reveal.js@^4//dist/reveal.js"></script> + </head> + + <body> + <div class="reveal"> + <div class="slides"> + {% for slide in slides %} + <section> + {% for line in slide.split('\n') %} + <p>{{ line }}</p> + {% endfor %} + </section> + {% endfor %} + </div> + </div> + + <script> + // Start Reveal.js slideshow without extra features + Reveal.initialize({ + controls: false, + help: false, + jumpToSlide: false, + overview: false, + pause: false, + progress: false, + transition: 'none', + }); + </script> + </body> +</html> diff --git a/Tests/__init__.py b/tests/__init__.py diff --git a/tests/test_core.py b/tests/test_core.py @@ -0,0 +1,345 @@ +import unittest +from unittest.mock import patch, call + +from songs2slides import core + +class TestCore(unittest.TestCase): + def test_filter_lyrics_inline(self): + # Declare raw lyrics and expected cleaned lyrics + lyrics = 'A[remove]B\nC(remove)D' + expected = 'AB\nCD' + + # Clean lyrics + result = core.filter_lyrics(lyrics) + + # Assert slides are correct + self.assertEqual(result, expected) + + def test_filter_lyrics_whole_lines(self): + # Declare raw lyrics and expected cleaned lyrics + lyrics = 'A\n[remove]\nB\n(remove)\nC' + expected = 'A\nB\nC' + + # Clean lyrics + result = core.filter_lyrics(lyrics) + + # Assert slides are correct + self.assertEqual(result, expected) + + def test_filter_lyrics_multiple_lines(self): + # Declare raw lyrics and expected cleaned lyrics + lyrics = 'A\n[re\nmove]\nB\n(re\nmove)\nC' + expected = 'A\nB\nC' + + # Clean lyrics + result = core.filter_lyrics(lyrics) + + # Assert slides are correct + self.assertEqual(result, expected) + + def test_filter_lyrics_blank_lines(self): + # Declare raw lyrics and expected cleaned lyrics + lyrics = 'A\n[remove]\n\n(remove)\nB' + expected = 'A\n\nB' + + # Clean lyrics + result = core.filter_lyrics(lyrics) + + # Assert slides are correct + self.assertEqual(result, expected) + + def test_filter_lyrics_all(self): + # Declare raw lyrics and expected cleaned lyrics + lyrics = 'A[remove]B\n[remove]\n\nC(remove)D\n(re\nmove)' + expected = 'AB\n\nCD' + + # Clean lyrics + result = core.filter_lyrics(lyrics) + + # Assert slides are correct + self.assertEqual(result, expected) + + def test_filter_lyrics_empty_string(self): + # Clean lyrics + result = core.filter_lyrics('') + + # Assert slides are correct + self.assertEqual(result, '') + + def test_get_song_data_success(self): + with patch('songs2slides.core.os.getenv') as mocked_env, \ + patch('songs2slides.core.requests.get') as mocked_get, \ + patch('songs2slides.core.filter_lyrics') as mocked_clean: + + # Mock os.getenv, requests.get, and core.filter_lyrics + mocked_env.return_value = 'api://lyrics/{artist}/{title}' + mocked_get.return_value.json.return_value = { + 'lyrics': 'raw', + 'title': 'Foo', + 'artist': 'Bar', + } + mocked_get.return_value.status_code = 200 + mocked_clean.return_value = 'clean' + + # Get song data + song_data = core.get_song_data('foo', 'bar') + + # Assert mocked methods were used correctly + mocked_env.assert_called_with('API_URL') + mocked_get.assert_called_with('api://lyrics/bar/foo') + mocked_clean.assert_called_with('raw') + + # Assert song data is correct + self.assertEqual(song_data.title, 'Foo') + self.assertEqual(song_data.artist, 'Bar') + self.assertEqual(song_data.lyrics, 'clean') + + def test_get_song_data_no_url(self): + with patch('songs2slides.core.os.getenv') as mocked_env, \ + patch('songs2slides.core.requests.get') as mocked_get: + + # Mock os.getenv and requests.get + mocked_env.return_value = None + mocked_get.return_value.text = b'{}' + mocked_get.return_value.status_code = 200 + + # Try to get song data + with self.assertRaises(Exception): + song_data = core.get_song_data('foo', 'bar') + + # Assert request was not called + mocked_get.assert_not_called() + + def test_get_song_data_not_found(self): + with patch('songs2slides.core.os.getenv') as mocked_env, \ + patch('songs2slides.core.requests.get') as mocked_get: + + # Mock os.getenv and requests.get + mocked_env.return_value = 'api://lyrics/{artist}/{title}' + mocked_get.return_value.text = b'{}' + mocked_get.return_value.status_code = 200 + + # Try to get song data + with self.assertRaises(Exception): + song_data = core.get_song_data('foo', 'bar') + + # Assert request was called + mocked_get.assert_called_with('api://lyrics/bar/foo') + + def test_parse_song_lyrics_basic(self): + # Declare song data and expected slides + lyrics = 'A\nB\nC\nD\nE\nF\n\nG\nH' + expected = ['A\nB\nC\nD', 'E\nF', 'G\nH'] + + # Get slide content + result = core.parse_song_lyrics(lyrics, 4) + + # Assert slides are correct + self.assertEqual(result, expected) + + def test_parse_song_lyrics_3_lines_per_slide(self): + # Declare song data and expected slides + lyrics = 'A\nB\nC\nD\nE\nF\n\nG\nH' + expected = ['A\nB\nC', 'D\nE\nF', 'G\nH'] + + # Get slide content + result = core.parse_song_lyrics(lyrics, 3) + + # Assert slides are correct + self.assertEqual(result, expected) + + def test_parse_song_lyrics_empty_string(self): + # Declare song data and expected slides + lyrics = '' + expected = [] + + # Get slide content + result = core.parse_song_lyrics(lyrics, 4) + + # Assert slides are correct + self.assertEqual(result, expected) + + def test_parse_song_lyrics_one_line(self): + # Declare song data and expected slides + lyrics = 'A' + expected = ['A'] + + # Get slide content + result = core.parse_song_lyrics(lyrics, 4) + + # Assert slides are correct + self.assertEqual(result, expected) + + def test_parse_song_lyrics_one_slide(self): + # Declare song data and expected slides + lyrics = 'A\nB\nC\nD' + expected = ['A\nB\nC\nD'] + + # Get slide content + result = core.parse_song_lyrics(lyrics, 4) + + # Assert slides are correct + self.assertEqual(result, expected) + + def test_parse_song_lyrics_tripple_newlines(self): + # Declare song data and expected slides + lyrics = 'A\nB\n\n\nC\nD' + expected = ['A\nB', '', 'C\nD'] + + # Get slide content + result = core.parse_song_lyrics(lyrics, 4) + + # Assert slides are correct + self.assertEqual(result, expected) + + def test_parse_song_lyrics_extra_whitespace(self): + # Declare song data and expected slides + lyrics = ' A\n B \nC D\nE ' + expected = ['A\nB\nC D\nE'] + + # Get slide content + result = core.parse_song_lyrics(lyrics, 4) + + # Assert slides are correct + self.assertEqual(result, expected) + + def test_parse_song_lyrics_extra_newlines(self): + # Declare song data and expected slides + lyrics = '\n\n\nA\n\n\n\n\nB\n\n\n' + expected = ['A', '', 'B'] + + # Get slide content + result = core.parse_song_lyrics(lyrics, 4) + + # Assert slides are correct + self.assertEqual(result, expected) + + def test_assemble_slides_calls_parse_song_lyrics(self): + with patch('songs2slides.core.parse_song_lyrics') as mocked_parse: + + # Mock parse_song_lyrics + mocked_parse.side_effect = [['aaa'], ['b1', 'b2']] + + # Declare song data and expected slides + songs = [ + core.SongData('T1', 'A1', 'l1'), + core.SongData('T2', 'A3', 'l2'), + ] + expected = ['T1', 'aaa', '', 'T2', 'b1', 'b2'] + + # Get slides + slides = core.assemble_slides(songs) + + # Assert slides are correct + self.assertEqual(slides, expected) + + # Assert parse_song_lyrics called + mocked_parse.assert_has_calls([call('L1', 4), call('L2', 4)]) + + def test_assemble_slides_default(self): + # Declare song data and expected slides + songs = [ + core.SongData('T1', 'A1', 'l1\nl2\nl3\nl4\nl5'), + core.SongData('T2', 'A3', 'L6\nL7\n\nL8\n\n\nL9'), + ] + expected = [ + 'T1', 'L1\nL2\nL3\nL4', 'L5', '', + 'T2', 'L6\nL7', 'L8', '', 'L9', + ] + + # Get slides + slides = core.assemble_slides(songs) + + # Assert slides are correct + self.assertEqual(slides, expected) + + def test_assemble_slides_custom_lines_per_slide(self): + with patch('songs2slides.core.parse_song_lyrics') as mocked_parse: + + # Mock parse_song_lyrics + mocked_parse.side_effect = [['aaa'], ['b1', 'b2']] + + # Declare song data and expected slides + songs = [ + core.SongData('T1', 'A1', 'l1'), + core.SongData('T2', 'A3', 'l2'), + ] + expected = ['T1', 'aaa', '', 'T2', 'b1', 'b2'] + + # Get slides + slides = core.assemble_slides(songs, lines_per_slide = 3) + + # Assert slides are correct + self.assertEqual(slides, expected) + + # Assert parse_song_lyrics called correctly + mocked_parse.assert_has_calls([call('L1', 3), call('L2', 3)]) + + def test_assemble_slides_no_title_slides(self): + # Declare song data and expected slides + songs = [ + core.SongData('T1', 'A1', 'l1\nl2\nl3\nl4\nl5'), + core.SongData('T2', 'A3', 'L6\nL7\n\nL8\n\n\nL9'), + ] + expected = [ + 'L1\nL2\nL3\nL4', 'L5', '', + 'L6\nL7', 'L8', '', 'L9', + ] + + # Get slides + slides = core.assemble_slides(songs, title_slides = False) + + # Assert slides are correct + self.assertEqual(slides, expected) + + def test_assemble_slides_no_blank_slides(self): + # Declare song data and expected slides + songs = [ + core.SongData('T1', 'A1', 'l1\nl2\nl3\nl4\nl5'), + core.SongData('T2', 'A3', 'L6\nL7\n\nL8\n\n\nL9'), + ] + expected = [ + 'T1', 'L1\nL2\nL3\nL4', 'L5', + 'T2', 'L6\nL7', 'L8', '', 'L9', + ] + + # Get slides + slides = core.assemble_slides(songs, blank_slides = False) + + # Assert slides are correct + self.assertEqual(slides, expected) + + def test_assemble_slides_no_extra_slides(self): + # Declare song data and expected slides + songs = [ + core.SongData('T1', 'A1', 'l1\nl2\nl3\nl4\nl5'), + core.SongData('T2', 'A3', 'L6\nL7\n\nL8\n\n\nL9'), + ] + expected = [ + 'L1\nL2\nL3\nL4', 'L5', + 'L6\nL7', 'L8', '', 'L9', + ] + + # Get slides + slides = core.assemble_slides(songs, title_slides = False, blank_slides = False) + + # Assert slides are correct + self.assertEqual(slides, expected) + + def test_assemble_slides_no_songs(self): + # Declare expected slides + expected = [] + + # Get slides + slides = core.assemble_slides([]) + + # Assert slides are correct + self.assertEqual(slides, expected) + + def test_create_pptx(self): + with patch('songs2slides.core.pptx.presentation.Presentation.save') as mocked_save: + # Create PowerPoint + core.create_pptx(['A', 'B\nC', 'D'], 'test.pptx') + + # Assert PowerPoint was saved + mocked_save.assert_called_with('test.pptx') diff --git a/tests/test_routes.py b/tests/test_routes.py @@ -0,0 +1,229 @@ +import unittest +from unittest.mock import patch, call + +from songs2slides import create_app, core + +class TestRoutes(unittest.TestCase): + def setUp(self): + self.app = create_app() + self.client = self.app.test_client() + + def test_get_lyrics_basic(self): + with patch('songs2slides.core.get_song_data') as mocked_get, \ + patch('songs2slides.core.parse_song_lyrics') as mocked_parse, \ + patch('songs2slides.routes.render_template') as mocked_render: + + # Mock get_song_data and parse_song_lyrics + songs = [ + core.SongData('T1', 'A1', 'L1'), + core.SongData('T2', 'A2', 'L2'), + ] + mocked_get.side_effect = songs + mocked_parse.side_effect = ['L1', 'L2'] + + # Send request + self.client.post('/create/', data={ + 'title-1': 'T1', + 'artist-1': 'A1', + 'title-2': 'T2', + 'artist-2': 'A2', + }) + + # Assert mocks called correctly + mocked_get.assert_has_calls([call('T1', 'A1'), call('T2', 'A2')]) + mocked_parse.assert_has_calls([call('L1', 4), call('L2', 4)]) + mocked_render.assert_called_with('create.html', step=2, songs=songs, missing=0) + + def test_get_lyrics_one_error(self): + with patch('songs2slides.core.get_song_data') as mocked_get, \ + patch('songs2slides.core.parse_song_lyrics') as mocked_parse, \ + patch('songs2slides.routes.render_template') as mocked_render: + + # Mock get_song_data and parse_song_lyrics + songs = [ + core.SongData('T1', 'A1', None), + core.SongData('T2', 'A2', 'L2'), + ] + mocked_get.side_effect = [Exception(), songs[1]] + mocked_parse.side_effect = ['L1', 'L2'] + + # Send request + self.client.post('/create/', data={ + 'title-1': 'T1', + 'artist-1': 'A1', + 'title-2': 'T2', + 'artist-2': 'A2', + }) + + # Assert mocks called correctly + mocked_get.assert_has_calls([call('T1', 'A1'), call('T2', 'A2')]) + mocked_parse.assert_has_calls([call('L2', 4)]) + mocked_render.assert_called_with('create.html', step=2, songs=songs, missing=1) + + def test_get_lyrics_missing_artist(self): + with patch('songs2slides.core.get_song_data') as mocked_get: + + # Send request + res = self.client.post('/create/', data={ + 'title-1': 'T1', + 'title-2': 'T2', + 'artist-2': 'A2', + }) + + # Assert mocks not called + mocked_get.assert_not_called() + + # Assert response has 400 status code + self.assertEqual(res.status_code, 400) + + def test_create_slides_basic(self): + with patch('songs2slides.core.assemble_slides') as mocked_assemble, \ + patch('songs2slides.core.create_pptx') as mocked_create, \ + patch('songs2slides.routes.send_file') as mocked_send: + + # Send request + self.client.post('/slides/', data={ + 'title-1': 'T1', + 'artist-1': 'A1', + 'lyrics-1': 'L1', + 'title-2': 'T2', + 'artist-2': 'A2', + 'lyrics-2': 'L2', + 'output-type': 'pptx', + 'title-slides': 'on', + 'blank-slides': 'on', + }) + + # Assert mocks called correctly + mocked_assemble.assert_called_with([ + core.SongData('T1', 'A1', 'L1'), + core.SongData('T2', 'A2', 'L2'), + ], + lines_per_slide = None, + title_slides = True, + blank_slides = True, + ) + file = mocked_create.call_args.args[1] + mocked_send.assert_called_with(file, as_attachment=True, + download_name='slides.pptx') + + def test_create_slides_mising_artist(self): + with patch('songs2slides.core.assemble_slides') as mocked_assemble: + + # Send request + res = self.client.post('/slides/', data={ + 'title-1': 'T1', + 'artist-1': 'A1', + 'lyrics-1': 'L1', + 'title-2': 'T2', + 'lyrics-2': 'L2', + 'output-type': 'pptx', + 'title-slides': 'on', + 'blank-slides': 'on', + }) + + # Assert response has 400 status code + self.assertEqual(res.status_code, 400) + + # Assert assemble_slides not called + mocked_assemble.assert_not_called() + + def test_create_slides_html_slides(self): + with patch('songs2slides.core.assemble_slides') as mocked_assemble, \ + patch('songs2slides.core.create_pptx') as mocked_create, \ + patch('songs2slides.routes.render_template') as mocked_render: + + # Mock assemble_slides + slides = ['T1', 'L1\nL2', 'L3', 'T2', 'L4'] + mocked_assemble.return_value = slides + + # Send request + self.client.post('/slides/', data={ + 'title-1': 'T1', + 'artist-1': 'A1', + 'lyrics-1': 'L1', + 'title-2': 'T2', + 'artist-2': 'A2', + 'lyrics-2': 'L2', + 'output-type': 'html', + 'title-slides': 'on', + 'blank-slides': 'on', + }) + + # Assert mocks called correctly + mocked_assemble.assert_called_with([ + core.SongData('T1', 'A1', 'L1'), + core.SongData('T2', 'A2', 'L2'), + ], + lines_per_slide = None, + title_slides = True, + blank_slides = True, + ) + mocked_create.assert_not_called() + mocked_render.assert_called_with('slides.html', slides=slides) + + def test_create_slides_no_title_slides(self): + with patch('songs2slides.core.assemble_slides') as mocked_assemble, \ + patch('songs2slides.core.create_pptx') as mocked_create, \ + patch('songs2slides.routes.render_template') as mocked_render: + + # Mock assemble_slides + slides = ['T1', 'L1\nL2', 'L3', 'T2', 'L4'] + mocked_assemble.return_value = slides + + # Send request + self.client.post('/slides/', data={ + 'title-1': 'T1', + 'artist-1': 'A1', + 'lyrics-1': 'L1', + 'title-2': 'T2', + 'artist-2': 'A2', + 'lyrics-2': 'L2', + 'output-type': 'html', + 'blank-slides': 'on', + }) + + # Assert mocks called correctly + mocked_assemble.assert_called_with([ + core.SongData('T1', 'A1', 'L1'), + core.SongData('T2', 'A2', 'L2'), + ], + lines_per_slide = None, + title_slides = False, + blank_slides = True, + ) + mocked_create.assert_not_called() + mocked_render.assert_called_with('slides.html', slides=slides) + + def test_create_slides_no_blank_slides(self): + with patch('songs2slides.core.assemble_slides') as mocked_assemble, \ + patch('songs2slides.core.create_pptx') as mocked_create, \ + patch('songs2slides.routes.render_template') as mocked_render: + + # Mock assemble_slides + slides = ['T1', 'L1\nL2', 'L3', 'T2', 'L4'] + mocked_assemble.return_value = slides + + # Send request + self.client.post('/slides/', data={ + 'title-1': 'T1', + 'artist-1': 'A1', + 'lyrics-1': 'L1', + 'title-2': 'T2', + 'artist-2': 'A2', + 'lyrics-2': 'L2', + 'output-type': 'html', + 'title-slides': 'on', + }) + + # Assert mocks called correctly + mocked_assemble.assert_called_with([ + core.SongData('T1', 'A1', 'L1'), + core.SongData('T2', 'A2', 'L2'), + ], + lines_per_slide = None, + title_slides = True, + blank_slides = False, + ) + mocked_create.assert_not_called() + mocked_render.assert_called_with('slides.html', slides=slides) diff --git a/webapp.py b/webapp.py @@ -1,6 +0,0 @@ -# Import app -from Songs2Slides import app - -# Run app -if (__name__ == "__main__"): - app.run(debug=True) -\ No newline at end of file