diff --git a/.env.template b/.env.template index 1c4b05e..183571d 100644 --- a/.env.template +++ b/.env.template @@ -2,4 +2,7 @@ CALENDAR_URL= CALENDAR_OUTLOOK_DAYS= CALENDAR_EVENT_MAXIMUM= CALENDAR_TIMEZONE= -CALENDAR_API_KEY= \ No newline at end of file +CALENDAR_API_KEY= +WATCHED_CHANNELS= +SLACK_API_TOKEN= +SLACK_JUMPSTART_MESSAGE= \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 3d22dd5..4173207 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,24 @@ +FROM ghcr.io/astral-sh/uv:python3.14-alpine AS docbuilder + +WORKDIR /jumpstartdocs + +COPY mkdocs.yml . +COPY docs ./docs +COPY src ./src + +RUN uv pip install --no-cache-dir -r ./docs/requirements.txt --system && \ + zensical build + FROM ghcr.io/astral-sh/uv:python3.14-alpine COPY src /jumpstart +COPY --from=docbuilder /jumpstartdocs/site /jumpstart/docs + WORKDIR /jumpstart -RUN addgroup -g 2000 jumpgroup && adduser -S -u 1001 -G jumpgroup jumpstart \ -uv pip install --no-cache-dir -r requirements.txt --system && rm requirements.txt +RUN addgroup -g 2000 jumpgroup && adduser -S -u 1001 -G jumpgroup jumpstart && \ + uv pip install --no-cache-dir -r requirements.txt --system && rm requirements.txt + +USER jumpstart CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-config", "/jumpstart/logging_config.yaml"] diff --git a/docker-compose.yml b/docker-compose.yml index da1922e..084be92 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,4 +10,7 @@ services: - CALENDAR_URL=${CALENDAR_URL} - CALENDAR_OUTLOOK_DAYS=${CALENDAR_OUTLOOK_DAYS} - CALENDAR_EVENT_MAXIMUM=${CALENDAR_EVENT_MAXIMUM} - - CALENDAR_TIMEZONE=${CALENDAR_TIMEZONE} \ No newline at end of file + - CALENDAR_TIMEZONE=${CALENDAR_TIMEZONE} + - WATCHED_CHANNELS=${WATCHED_CHANNELS} + - SLACK_API_TOKEN=${SLACK_API_TOKEN} + - SLACK_JUMPSTART_MESSAGE=${SLACK_JUMPSTART_MESSAGE} diff --git a/docs/requirements.txt b/docs/requirements.txt index 0f789fe..0eff80d 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,4 @@ -mkdocs -mkdocs-material +zensical==0.0.24 mkdocstrings mkdocstrings-python mkdocs-minify-plugin diff --git a/mkdocs.yml b/mkdocs.yml index 69d6281..3663282 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -51,4 +51,11 @@ plugins: python: paths: ["src"] options: - heading_level \ No newline at end of file + heading_level: 3 + show_source: true + docstring_style: google + show_root_heading: false + show_root_full_path: false + show_signature: false + filters: + - ".*" diff --git a/src/api/endpoints.py b/src/api/endpoints.py index 8335129..151e980 100644 --- a/src/api/endpoints.py +++ b/src/api/endpoints.py @@ -1,93 +1,165 @@ from logging import getLogger, Logger +import json +import httpx import random import textwrap -import requests -from fastapi import APIRouter, Query +from fastapi import APIRouter, Request, Form from fastapi.responses import JSONResponse -from core import slack -from src.core import cshcalendar +from core import slack, cshcalendar logger: Logger = getLogger(__name__) - router: APIRouter = APIRouter() + @router.get("/calendar") def get_calendar() -> JSONResponse: """ Returns calendar data. + + Returns: + JSONResponse: A JSON response containing the calendar data. """ - pass + get_future_events_ical: list[cshcalendar.CalendarInfo] = ( + cshcalendar.get_future_events_ical() + ) + formatted_events: dict = cshcalendar.format_events(get_future_events_ical) + + return JSONResponse(formatted_events) @router.get("/announcement") def get_announcement() -> JSONResponse: """ Returns announcement data. + + Returns: + JSONResponse: A JSON response containing the announcement data. """ - pass + return JSONResponse({"data": slack.get_announcement()}) -@router.put("/announcement") -def update_announcement() -> JSONResponse: +@router.post("/slack/events") +async def slack_events(request: Request) -> JSONResponse: """ - Updates an existing announcement. + Handles slack events. + + Args: + request (Request): The incoming request from Slack. + + Returns: + JSONResponse: A JSON response indicating the result of the event handling. """ - pass + try: + logger.info("Received Slack event!") + if request.headers.get("content-type") == "application/json": + body: dict = await request.json() -@router.get("/harold") -def get_harold() -> JSONResponse: - """ - Returns harold data. - """ + if body.get("type") == "url_verification": + return JSONResponse({"challenge": body.get("challenge")}) + + body: dict = await request.json() + if not body: + return JSONResponse({"challenge": body.get("challenge")}) - pass + event: dict = body.get("event", {}) + cleaned_text: str = slack.clean_text(event.get("text", "")) + + if event.get("subtype", None) is not None: + return JSONResponse({"status": "ignored"}) + + if not event.get("channel", "") in slack.WATCHED_CHANNELS: + return JSONResponse({"status": "ignored"}) + + await slack.request_upload_via_dm(event.get("user", ""), cleaned_text) + except Exception as e: + logger.error(f"Error handling Slack event: {e}") + return JSONResponse({"status": "error", "message": str(e)}) + return JSONResponse({"status": "success"}) -@router.put("/harold") -def update_harold() -> JSONResponse: + +@router.post("/slack/message_actions") +async def message_actions(payload: str = Form(...)) -> JSONResponse: """ - Updates harold data. + Handles slack message action. + + Args: + payload (str): The payload from the Slack message action. + + Returns: + JSONResponse: A JSON response indicating the result of the action. """ - pass + try: + form_json: dict = json.loads(payload) + response_url = form_json.get("response_url") + + if form_json.get("type") != "block_actions": + return JSONResponse({}, status_code=200) + + if slack.convert_user_response_to_bool(form_json): + logger.info("User approved the announcement!") + + slack.add_announcement(form_json.get("text", None)) + + if response_url: + await httpx.post( + response_url, + json={"text": "Posting right now :^)", "replace_original": True}, + ) + else: + if response_url: + await httpx.post( + response_url, + json={"text": "Okay :( maybe next time", "replace_original": True}, + ) + + except Exception as e: + logger.error(f"Error in message_actions: {e}") + return JSONResponse({"status": "error", "message": str(e)}, status_code=500) + + return JSONResponse({"status": "success"}, status_code=200) @router.get("/showerthoughts") -def showerthoughts() -> JSONResponse: +async def showerthoughts() -> JSONResponse: """ Returns a random shower thought from the Reddit API. Returns: JSONResponse: A JSON response containing a random shower thought. """ - + response: dict = {"data": "No shower thoughts found."} try: logger.info("Fetching shower thoughts from Reddit API...") - reddit_data: requests.Response = requests.get( - "https://www.reddit.com/r/showerthoughts/top.json", - headers={"User-agent": "Showerthoughtbot 0.1"}, - ) + async with httpx.AsyncClient() as client: + reddit_data: httpx.Response = await client.get( + "https://www.reddit.com/r/showerthoughts/top.json", + headers={"User-agent": "Showerthoughtbot 0.1"}, + ) + + reddit_json = reddit_data.json() - if len(reddit_data.json()["data"]["children"]) == 0: + if len(reddit_json["data"]["children"]) == 0: logger.warning("No shower thoughts found in Reddit API response.") return JSONResponse(response) - + shower_thought: str = textwrap.fill( - (random.choice(reddit_data.json()["data"]["children"])["data"]["title"]), 50 + (random.choice(reddit_json["data"]["children"])["data"]["title"]), 50 ) response["data"] = shower_thought except Exception as e: logger.error(f"Error fetching shower thoughts: {e}") - + return JSONResponse(response) diff --git a/src/config.py b/src/config.py index e4127e2..9a02244 100644 --- a/src/config.py +++ b/src/config.py @@ -1,4 +1,5 @@ import os +import json from dotenv import load_dotenv @@ -6,8 +7,22 @@ BASE_DIR: str = os.path.dirname(os.path.abspath(__file__)) +SLACK_API_TOKEN: str | None = os.getenv("SLACK_API_TOKEN", None) +SLACK_JUMPSTART_MESSAGE: str = "Would you like to post this message to Jumpstart?" +WATCHED_CHANNELS: tuple[str] = tuple(os.getenv("WATCHED_CHANNELS", "").split(",")) +SLACK_DM_TEMPLATE: dict | None = None + CALENDAR_URL: str | None = os.getenv("CALENDAR_URL", None) CALENDAR_OUTLOOK_DAYS: int = int(os.getenv("CALENDAR_OUTLOOK_DAYS", "7")) CALENDAR_EVENT_MAXIMUM: int = int(os.getenv("CALENDAR_EVENT_MAXIMUM", "10")) CALENDAR_TIMEZONE: str = os.getenv("CALENDAR_TIMEZONE", "America/New_York") -CALENDAR_API_KEY: str = os.getenv("CALENDAR_API_KEY",None) \ No newline at end of file +CALENDAR_API_KEY: str = os.getenv("CALENDAR_API_KEY", None) + +if SLACK_API_TOKEN in (None, ""): + raise Exception("Missing SLACK_API_TOKEN") + +if CALENDAR_API_KEY in (None, "") and CALENDAR_URL in (None, ""): + raise Exception("Missing CALENDAR_API_KEY or CALENDAR_URL") + +with open(os.path.join(BASE_DIR, "static", "slack", "dm_request_template.json")) as f: + SLACK_DM_TEMPLATE = json.load(f) diff --git a/src/core/cshcalendar.py b/src/core/cshcalendar.py index 7e17174..8e82498 100644 --- a/src/core/cshcalendar.py +++ b/src/core/cshcalendar.py @@ -11,131 +11,153 @@ import recurring_ical_events import arrow -from src import config +import config logger: Logger = getLogger(__name__) operation_start_time = time.perf_counter() -calendar_service = build("calendar","v3",developerKey=config.CALENDAR_API_KEY) + +logger.info("Starting up the calendar service!") +try: + calendar_service = build("calendar", "v3", developerKey=config.CALENDAR_API_KEY) +except: + logger.warning( + "Failed to build the calendar service, check your API key and internet connection!" + ) + # Automatically format all info into the class class CalendarInfo: - """ - Class that represents standardized calendar info. This is here so when pulling from different things, we can establish it as this class to only update - certain parts of the codebass - """ - def __init__(self,name : str,date_time : date): - self.name : str = name - self.date : arrow.arrow = arrow.get(date_time) # Arrow has way cooler stuff - -def report_timing(display_tag : str) -> None: - """ - Helper function to report how long an operation took since the lastly established operation start time. - - Args: - displayTag: The tag to be printed into the terminal. - """ - - operation_timestamp = time.perf_counter() - operation_start_time - logger.info(operation_timestamp, "::", display_tag) - -def format_events(events : list[CalendarInfo]) -> dict: - """ - Formats a parsed list of CalendarInfos, and returns the HTML required for front end - - Args: - events: The list of CalendarInfos to be formatted - - Returns: - dict: Returns a dictionary with the "data" key mapping to the HTML data. - """ - - current_date : date = datetime.now(ZoneInfo(config.CALENDAR_TIMEZONE)) - final_events = "
" - - if not events: - print('No upcoming events found.') - - for event in events: - formatted = event.Date.humanize() if event.Date > current_date else "Happening Now!" - event.Date = formatted - final_events += ( - """
""" - + formatted + - """
""" - ) - final_events += ( - ""+ - ''.join(event.Name)+ - "
" - ) - final_events += "
" - return {"data": final_events} + """ + Class that represents standardized calendar info. This is here so when pulling from different things, we can establish it as this class to only update + certain parts of the codebass + """ -def get_future_events_google_api() -> list[CalendarInfo]: - """ - Fetches the first ten events using the google api client. - Requires an API key to be estbalished as a env variable + def __init__(self, name: str, date_time: date): + self.name: str = name + self.date: arrow.arrow = arrow.get(date_time) # Arrow has way cooler stuff - Returns: - list: A list of CalendarInfo objects3 + +def report_timing(display_tag: str) -> None: """ - # pylint: disable=no-member - events_result = calendar_service.events().list( - calendarId='rti648k5hv7j3ae3a3rum8potk@group.calendar.google.com', - timeMin=datetime.now(ZoneInfo(config.CALENDAR_TIMEZONE)).isoformat(), - maxResults=10, - singleEvents=True, - orderBy='startTime', - ).execute() + Helper function to report how long an operation took since the lastly established operation start time. - events = events_result.get('items', []) - formatted_events : list[CalendarInfo] = [] + Args: + displayTag: The tag to be printed into the terminal. + """ - for event in events: - start = event["start"].get("dateTime") or event["start"].get("date") - new_event = CalendarInfo(event["summary"],datetime.fromisoformat(start)) - formatted_events.append(new_event) + operation_timestamp = time.perf_counter() - operation_start_time + logger.info(f"{operation_timestamp}:: {display_tag}") - return formatted_events -def get_future_events_ical() -> list[CalendarInfo]: - """ - Fetches the first ten events using the Ical library, - loops through the first 7 days of the current time. +def format_events(events: list[CalendarInfo]) -> dict: + """ + Formats a parsed list of CalendarInfos, and returns the HTML required for front end + + Args: + events: The list of CalendarInfos to be formatted Returns: - list: A list of CalendarInfo objects3 + dict: Returns a dictionary with the "data" key mapping to the HTML data. """ - found_events :list[CalendarInfo] = [] - try: - response = requests.get(config.CALENDAR_URL,timeout=10) - report_timing("Fetched the calendar from google") - - cal = Calendar.from_ical(response.content) - report_timing("Converted the calendar info") + current_date: date = datetime.now(ZoneInfo(config.CALENDAR_TIMEZONE)) + final_events = "
" + + if not events: + print("No upcoming events found.") + + for event in events: + formatted = ( + event.date.humanize() if event.date > current_date else "Happening Now!" + ) + event.date = formatted + final_events += ( + """
""" + + formatted + + """
""" + ) + final_events += ( + "" + + "".join(event.name) + + "
" + ) + final_events += "
" + return {"data": final_events} - current_day = 1 - current_time = datetime.now(ZoneInfo(config.CALENDAR_TIMEZONE)) - while ((current_day < config.CALENDAR_OUTLOOK_DAYS) and - (len(found_events) < config.CALENDAR_EVENT_MAXIMUM)): +def get_future_events_google_api() -> list[CalendarInfo]: + """ + Fetches the first ten events using the google api client. + Requires an API key to be estbalished as a env variable - fetched_daily_events : list = recurring_ical_events.of(cal).between(current_time, current_time + timedelta(days=1)) - report_timing("Sorted events on day " + str(current_day)) + Returns: + list: A list of CalendarInfo objects3 + """ + # pylint: disable=no-member + events_result = ( + calendar_service.events() + .list( + calendarId="rti648k5hv7j3ae3a3rum8potk@group.calendar.google.com", + timeMin=datetime.now(ZoneInfo(config.CALENDAR_TIMEZONE)).isoformat(), + maxResults=10, + singleEvents=True, + orderBy="startTime", + ) + .execute() + ) + + events = events_result.get("items", []) + formatted_events: list[CalendarInfo] = [] + + for event in events: + start = event["start"].get("dateTime") or event["start"].get("date") + new_event = CalendarInfo(event["summary"], datetime.fromisoformat(start)) + formatted_events.append(new_event) + + return formatted_events - for event in fetched_daily_events: - if len(found_events) >= config.CALENDAR_EVENT_MAXIMUM: - break - else: - new_event = CalendarInfo(event.get("SUMMARY"), event.get("DTSTART").dt) - found_events.append(new_event) - current_time += timedelta(days=1) - current_day += 1 - except Exception as e: - logger.warning("Failed to fetch the Calendar! Failed with error:") - logger.warning(e) +def get_future_events_ical() -> list[CalendarInfo]: + """ + Fetches the first ten events using the Ical library, + loops through the first 7 days of the current time. - sorted_events = sorted(found_events, key=lambda x: x.Date) - return sorted_events + Returns: + list: A list of CalendarInfo objects3 + """ + found_events: list[CalendarInfo] = [] + try: + response = requests.get(config.CALENDAR_URL, timeout=10) + report_timing("Fetched the calendar from google") + + cal = Calendar.from_ical(response.content) + report_timing("Converted the calendar info") + + current_day = 1 + current_time = datetime.now(ZoneInfo(config.CALENDAR_TIMEZONE)) + + while (current_day < config.CALENDAR_OUTLOOK_DAYS) and ( + len(found_events) < config.CALENDAR_EVENT_MAXIMUM + ): + fetched_daily_events: list = recurring_ical_events.of(cal).between( + current_time, current_time + timedelta(days=1) + ) + report_timing("Sorted events on day " + str(current_day)) + + for event in fetched_daily_events: + if len(found_events) >= config.CALENDAR_EVENT_MAXIMUM: + break + else: + new_event = CalendarInfo( + event.get("SUMMARY"), event.get("DTSTART").dt + ) + found_events.append(new_event) + + current_time += timedelta(days=1) + current_day += 1 + except Exception as e: + logger.warning("Failed to fetch the Calendar! Failed with error:") + logger.warning(e) + + sorted_events = sorted(found_events, key=lambda x: x.date) + return sorted_events diff --git a/src/core/slack.py b/src/core/slack.py index e69de29..6420a08 100644 --- a/src/core/slack.py +++ b/src/core/slack.py @@ -0,0 +1,140 @@ +import re + +from logging import getLogger, Logger + +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.errors import SlackApiError + +from config import SLACK_API_TOKEN, SLACK_JUMPSTART_MESSAGE, SLACK_DM_TEMPLATE + + +logger: Logger = getLogger(__name__) + +client: AsyncWebClient | None = None + +try: + client = AsyncWebClient(token=SLACK_API_TOKEN) +except Exception as e: + logger.error(f"Failed to initialize Slack client: {e}") + +announcements: list[str] = ["Welcome to Jumpstart!"] + + +def clean_text(raw: str) -> str: + """ + Strip Slack mrkdwn, HTML entities, and formatting characters. + + Args: + raw (str): The raw text to be cleaned. + + Returns: + str: The cleaned text. + """ + + text: str = re.sub(r"<[^>]+>", "", str(raw), flags=re.IGNORECASE) + text = re.sub(r"<.*?>", "", text, flags=re.IGNORECASE) + return text.replace("*", "").replace("_", "").replace("`", "").strip() + + +async def gather_emojis() -> dict: + """ + Gathers emojis from Slack and returns a mapping of emoji names to their URLs. + + Returns: + dict: A mapping of emoji names to their URLs. + """ + + logger.info("Gathering emojis from slack!") + + try: + emoji_request: dict = await client.emoji_list() + assert emoji_request.get("ok", False) + + return emoji_request.get("emoji", {}) + except Exception as e: + logger.error(f"Error gathering emojis: {e}") + + return {} + + +async def request_upload_via_dm(user_id: str, announcement_text: str) -> None: + """ + Sends a DM to the user with the announcement text and a prompt to post it to Jumpstart. + + Args: + user_id (str): The ID of the user to send the DM to. + announcement_text (str): The text of the announcement to be posted. + """ + + logger.info("Requesting upload announcement permission!") + + try: + message: dict = SLACK_DM_TEMPLATE.copy() + + message[0]["text"]["text"] += announcement_text + message[1]["elements"][0]["value"] = { + "text": announcement_text, + "user": user_id, + } + + await client.chat_postMessage( + channel=user_id, text=SLACK_JUMPSTART_MESSAGE, blocks=message + ) + except Exception as e: + logger.error(f"Error messaging user {user_id}: {e}") + + +def convert_user_response_to_bool(message_data: dict) -> bool: + """ + Converts a Slack message action response to a boolean indicating whether the user approved the announcement. + + Args: + message_data (dict): The data from the Slack message action payload. + + Returns: + bool: True if the user approved the announcement, False otherwise. + """ + + user_response: bool = False + + try: + user_response = ( + message_data.get("actions", []).get(0, {}).get("action_id", "no_j") + == "yes_j" + ) + except Exception as e: + logger.error(f"Failed to parse data: {e}") + + return user_response + + +def get_announcement() -> str | None: + """ + Returns the next announcement in the queue. + + Returns: + str | None: The next announcement text, or None if there are no announcements. + """ + + if len(announcements) == 0: + return None + + if len(announcements) == 1: + return announcements[0] + + return announcements.pop(0) + + +def add_announcement(announcement_text: str) -> None: + """ + Adds an announcement to the queue. + + Args: + announcement_text (str): The text of the announcement to be added. + """ + + if announcement_text is None or announcement_text.strip() == "": + logger.warning("Attempted to add empty announcement, skipping!") + return + + announcements.append(announcement_text) diff --git a/src/main.py b/src/main.py index 9898d11..420ded8 100644 --- a/src/main.py +++ b/src/main.py @@ -7,27 +7,53 @@ import os +from logging import getLogger, Logger + from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from fastapi.responses import HTMLResponse - +from fastapi.responses import RedirectResponse, HTMLResponse from config import BASE_DIR from api import endpoints -app: FastAPI = FastAPI() +logger: Logger = getLogger(__name__) + +logger.info("Starting up the Jumpstart application!") +app: FastAPI = FastAPI(docs_url="/swag") + +logger.info("Mounting static files and templates!") app.mount( - "/static", - StaticFiles(directory=os.path.join(BASE_DIR, "static")), - name="static" + "/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static" +) + +templates: Jinja2Templates = Jinja2Templates( + directory=os.path.join(BASE_DIR, "templates") ) -templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates")) +if os.path.exists(os.path.join(BASE_DIR, "docs")): + logger.info("Documentation directory found, setting up documentation endpoint!") + + app.mount( + "/docs", StaticFiles(directory=os.path.join(BASE_DIR, "docs")), name="docs" + ) + + @app.get("/docs", include_in_schema=False) + async def docs_redirect(): + # Mkdocs links dynamically and not being on the direct index.html causes issues + return RedirectResponse(url="/docs/index.html") + +else: + logger.warning("Documentation directory not found, skipping documentation setup!") + +logger.info("Importing API endpoints!") app.include_router(endpoints.router, prefix="/api") +logger.info("Finished setting up the application!") + + @app.get("/", response_class=HTMLResponse) async def read_index(request: Request): - return templates.TemplateResponse("index.html", {"request": request}) \ No newline at end of file + return templates.TemplateResponse("index.html", {"request": request}) diff --git a/src/requirements.txt b/src/requirements.txt index aa2ff9e..305684d 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -1,11 +1,24 @@ +# Base fastapi==0.135.1 -uvicorn==0.41.0 -logging==0.4.9.6 jinja2==3.1.6 +python-multipart==0.0.22 +logging==0.4.9.6 requests==2.32.5 +httpx==0.28.1 + +# Config dotenv==0.9.9 -pyyaml==6.0.3 + +# Slack stuff +aiohttp==3.13.3 +slack-sdk==3.40.1 + +# All for calendar google-api-python-client==2.191.0 icalendar==7.0.3 recurring-ical-events==3.8.1 arrow==1.4.0 + +# For the docker to run +uvicorn==0.41.0 +pyyaml==6.0.3 diff --git a/src/static/slack/dm_request_template.json b/src/static/slack/dm_request_template.json new file mode 100644 index 0000000..998b881 --- /dev/null +++ b/src/static/slack/dm_request_template.json @@ -0,0 +1,28 @@ +[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Would you like to post this message to Jumpstart?\n\n" + } + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Yes"}, + "style": "primary", + "action_id": "yes_j", + "value": null + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "No"}, + "style": "danger", + "action_id": "no_j", + "value": "no" + } + ] + } +] \ No newline at end of file diff --git a/src/templates/index.html b/src/templates/index.html index 7e2c75e..fbfe873 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -31,121 +31,26 @@
ROCHESTER WEATHER
+
+
+
+ Shower Thoughts - /r/showerthoughts +
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. +
+
+
+
- - - +
+
+
+
+
+ + + + +{% endblock %} \ No newline at end of file