Lazy Automation

Some more scripts I’ve made because why not.

BTRFS snaphots

Quick and dirty script I made to automatically take BTRFS snapshots of my root and /home partitions. I run this daily on a cronjob:

#!/bin/bash

snapshot_dir="/mnt/snapshots"
date=$(date +"%Y-%m-%d--%H:%M:%S")

echo -e "\e[33mCreating snapshots\033[0m"
btrfs subvol snapshot / $snapshot_dir/root/$date
btrfs subvol snapshot /home $snapshot_dir/home/$date

echo -e "\e[33m$snapshot_dir/root\033[0m"
btrfs subvol list -o $snapshot_dir/root
echo -e "\e[33m$snapshot_dir/home\033[0m"
btrfs subvol list -o $snapshot_dir/home

pfSense config backup

pfSense only allows you to automatically backup your router config if you have a gold subscription, so I made this script that logins into the admin panel and retrieves the latest config and downloads it to my desktop using wget.

#!/bin/bash

backup="/mnt/storage/storage/Network/pfSense"
$password="password"
cd /tmp

wget -qO- --keep-session-cookies --save-cookies cookies.txt \
  --no-check-certificate https://10.0.1.1/diag_backup.php \
  | grep "name='__csrf_magic'" | sed 's/.*value="\(.*\)".*/\1/' > csrf.txt

wget -qO- --keep-session-cookies --load-cookies cookies.txt \
--save-cookies cookies.txt --no-check-certificate \
--post-data "login=Login&usernamefld=admin&passwordfld=$password&__csrf_magic=$(cat csrf.txt)" \
https://10.0.1.1/diag_backup.php  | grep "name='__csrf_magic'" \
| sed 's/.*value="\(.*\)".*/\1/' > csrf2.txt

wget --keep-session-cookies --load-cookies cookies.txt --no-check-certificate \
  --post-data "download=download&donotbackuprrd=yes&__csrf_magic=$(head -n 1 csrf2.txt)" \
  https://10.0.1.1/diag_backup.php -O config-router-`date +%Y%m%d%H%M%S`.xml

mv config-router* $backup

Mount NFS partition

A very long time ago I had issues on ubuntu automatically mounting my NFS connection from my server on bootup using fstab. so I made this script to either run on startup in my rc.local or manaully if I needed to mount it after the system has started.

#!/bin/bash

address="192.168.0.6"
mount_point="/mnt/nfs-storage"

if [[ $EUID -ne 0 ]]; then
    echo "This script must be run as root"
    exit 1
fi

case $1 in
    mount)
        if mountpoint -q $mount_point; then
            echo "$mount_point is active"
        else
            timeout 5 mount $address:/mnt/storage $mount_point
        fi
        ;;
    umount)
        if mountpoint -q $mount_point; then
            umount -f -l $mount_point
        else
            echo "$mount_point is not mounted"
        fi
        ;;
    *)
        echo "Usage $0 {mount|umount}"
esac

I got tired of manually compiling and packaging my LineageOS builds so I made this script to automate it. nothing too fancy but it gets the job done :smiley:

#!/bin/bash

CODENAME="h850" # device codename
CERTS_DIR="$HOME/android-certs" # certs directory for signing package
PACKAGE_DIR="$HOME/lineage-package" # package install directory
RELEASE_TOOLS="build/tools/releasetools" # android release tools
ENVFILE="build/envsetup.sh" # environment file
DATE=$(date +"%Y-%m-%d--%H:%M:%S")

source $ENVFILE # setup environment
export USE_CCACHE=1 # enable ccache
export CCACHE_COMPRESS=1 # compress ccache

breakfast $CODENAME # prepare environment for target device
rm -rf out/dist/lineage_$CODENAME* # remove old target files
time mka target-files-package dist # compile and package target files

# generate signed target files
./$RELEASE_TOOLS/sign_target_files_apks -o -d $CERTS_DIR \
	out/dist/lineage_${CODENAME}-target_files-*.zip \
	signed-target_files.zip

# generate install package
./$RELEASE_TOOLS/ota_from_target_files -k $CERTS_DIR/releasekey \
	--block --backup=true \
	signed-target_files.zip \
	signed-ota_update-$DATE.zip

mv signed-target_files.zip $PACKAGE_DIR/signed-target_files-$DATE.zip # move signed target files to package directory
mv signed-ota_update-* $PACKAGE_DIR # move install package to package directory
1 Like

When using the script above I noticed that my compile times were significantly longer than expected as I’m a dummy and I forgot to add the ccache variables. I’ve edited the script to include them.

1 Like

Very simple script I made that pops up an i3 message bar after a specific amount of time.

#!/bin/bash

TIME=$1
MESSAGE=$2

if [[ -z $1 ]]; then TIME=1s; fi
if [[ -z $2 ]]; then MESSAGE="Your timer is ready"; fi

sleep $TIME && exec i3-nagbar -t warning -m "$MESSAGE"

This allows me to set a timer when its too late to otherwise set it on my phone and no more will I burn pizza :smiley:

I can also be extra lazy and further automate it by setting an i3 hotkey for instant Pizza notification:

bindsym $mod+F10 exec ~/bin/timer 15m "Your Pizza is ready"
1 Like

I made a neat little python program that will check for errors. Is mostly due to the fact that im too lazy to just open up a terminal to find any errors myself.

Right now it only checks for errors on systemd and disk usage but when i think of more things i would like checked than ill add that in too

Added in support for checking entropy and whether or not there is anything in trash

1 Like

When installing Fedora 28 I forgot to backup my LineageOS container so I’ve made a build to automate the creation of a new container and a custom init script that sets up the build environment as much as possible. This does a couple of things:

  1. Installs all the required packages needed to compile LineageOS
  2. Creates a user with a pre-defined .bashrc and .profile for the build environment
  3. Downloads and install the Android repo and platform-tools
  4. Create and syncs the repository ready to build LineageOS

I also included my previous docker builds for flarum and tiddilywiki as well because why not.

Small script I made that will rotate the display and touchscreen input device on my laptop so that they are the correct orientation. The accelerometer doesn’t auto rotate in i3 like it does in gnome so I’ve setup a mode that calls this script and using the arrow keys to rotate it in whatever orientation I want it :smiley:

#!/bin/bash

OUTPUT="eDP-1" # laptop display
DEV="ELAN0732:00 04F3:2B45" # touchscreen input device
xrandr --output $OUTPUT --rotate $1 # rotate display

case $1 in
    left)
        xinput set-prop "$DEV" "Coordinate Transformation Matrix" 0 -1 1 1 0 0 0 0 1
        ;;
    right)
        xinput set-prop "$DEV" "Coordinate Transformation Matrix" 0 1 0 -1 0 1 0 0 1
        ;;
    normal)
        xinput set-prop "$DEV" "Coordinate Transformation Matrix" 1 0 0 0 1 0 0 0 1
        ;;
    inverted)
        xinput set-prop "$DEV" "Coordinate Transformation Matrix" -1 0 1 0 -1 1 0 0 1
        ;;
    *)
        echo "Usage: $0 {left|right|normal|inverted}"
esac
1 Like

Simple python script I made to change the brightness of my displays using xrandr by increments of .25 depending on which workspace is active (I.E which monitor has focus).

#!/usr/bin/env python3

import os, sys, string, subprocess

def main():
    cmd = "xrandr --verbose | grep {} -A 5 | grep Brightness | cut -f 2 -d ' '".format(display())
    brightness = float(subprocess.getoutput(cmd))

    if sys.argv[1] == "+" and brightness < 1:
        brightness += .25
    elif sys.argv[1] == "-" and brightness > 0:
        brightness -= .25
    
    os.system("xrandr --output {} --brightness {}".format(display(), brightness))

def display():
    cmd = "i3-msg -t get_workspaces | jq '.[] | select(.focused==true).name'"
    cmd = subprocess.getoutput(cmd).replace("\"", "")

    if cmd == "8: ":
        return "DP-1"
    return "DP-2"

if len(sys.argv) <= 1:
    print("Usage: {} [+|-]".format(sys.argv[0]))
    sys.exit(1)
else:
    main()

and in i3 i just set the bindings as

bindsym $mod+Shift+F12 exec ~/bin/brightness.py +
bindsym $mod+Shift+F11 exec ~/bin/brightness.py -

so I can now change the brightness levels of my displays on the fly without having to use their horrible OSDs to do it :smiley:

1 Like

One of its main issues of the script above is it relies on me never changing the workspace on my second monitor, while this doesn’t happen often in an event that it does it’ll change the brightness of my primary display even though the second has focus, so to fix this i made a couple of improvements.

First I decided to get the location of my mouse and using that extrapolate which display has focused using:

xdotool getmouselocation --shell | head -n -3 | sed 's/[^0-9]*//g'

At first I was going to try and calculate between the x and y which has focus until I realized that all that really matters is that the x of the mouse is below the width of my left most display since the resolutions are combined into Screen 0 so could just be lazy and do a check such as:

if x < 2560:
    return "DP-1"
return "DP-2"

with 2560 being the width of my display and x the mouse coordinates on that axis.

Here is the new script with the changes is below:

#!/usr/bin/env python3

import os, sys, string, subprocess

def main():
    cmd = "xrandr --verbose | grep {} -A 5 | grep Brightness | cut -f 2 -d ' '".format(display())
    brightness = float(subprocess.getoutput(cmd))

    if sys.argv[1] == "+" and brightness < 1:
        brightness += .25
    elif sys.argv[1] == "-" and brightness > 0:
        brightness -= .25
    os.system("xrandr --output {} --brightness {}".format(display(), brightness))

def display():
    cmd = "xdotool getmouselocation --shell | head -n -3 | sed 's/[^0-9]*//g'"
    cmd = subprocess.getoutput(cmd)

    if int(cmd) < 2560:
        return "DP-1"
    return "DP-2"

if len(sys.argv) <= 1:
    print("Usage: {} [+|-]".format(sys.argv[0]))
    sys.exit(1)
else:
    main()

I plan to improve it further by pulling the display id from xrandr based on the coordinates so nothing is hard-coded but that is for another time and this works well enough for my needs now and is vast improvement over my previous version with the added benefit that this now works without requiring the use of i3.

kinda simple one but mother wanted me to print 34 pdf files and when I asked Windows to print them from the file manager it told me i had to open each one and print them individually so transferred them over to Linux and made this small shell script to do it.

#!/bin/sh

for f in *.pdf; do
    cat "$f" | lp -o scaling=//100// -oColorModel=KGray
done

Probably could’ve found a way todo it on Windows too but I’m far less familiar with batch scripting and this way still saves me a lot of time and effort.

1 Like

@tsk Its very rough draft but this should do it. It grabs the user list based from the tier level/group you specify inside the api section and checks if the user is inside the group, if not it will send a POST API request to the forum and assign the group to that user. The script is in two parts, first handles the API requests (api.py).

import requests, json

Api_Key = "" # your discourse api key
groupName = "NSFW" # name of the group
tier_level = 3
url = "https://forum.0cd.xyz"

def _url(path):
    return url + path

def get_group():
    return requests.get(_url('/g/{}.json').format(groupName))

def get_group_members():
    return requests.get(_url('/g/{}/members.json').format(groupName))

def get_tl_members():
    return requests.get(_url('/g/trust_level_{}/members.json').format(tier_level))

def add_users(users, gid):
    headers = {'Api-Key': Api_Key, 'content-type':'application/json'}
    return requests.put(_url("/g/{}/members.json").format(gid), data=json.dumps({'usernames': ','.join(users)}), headers=headers)

and the second the main logic of the program

#!/usr/bin/env python3

import sys, requests, json, api

def main():
    try:
        resp = api.get_tl_members()
        gid = api.get_group()
        if resp.status_code != 200 or gid.status_code != 200:
            raise ApiError('Cannot fetch data: tl: {} group: {}'.format(resp.status_code, gid.status_code))
        member = []
        for users in resp.json()['members']:
            if users['username'] not in group():
                member.append(users['username'])
        api.add_users(member, gid.json()['group']['id'])
    except(ApiError, requests.exceptions.ConnectionError) as e:
        print(e)
        sys.exit(0)

def group():
    try:
        resp = api.get_group_members()
        if resp.status_code != 200:
            raise ApiError('Cannot fetch data: {}'.format(resp.status_code))
        member = []
        for members in resp.json()['members']:
            member.append(members['username'])
        return member
    except(ApiError, requests.exceptions.ConnectionError) as e:
        print(e)
        sys.exit(0)        

class ApiError(Exception): pass

main()

It should work mostly fine except there’s an issue in that the proper way handle the request is broken in Discourse so I’m brute forcing it by iterating over the users and sending a separate API request for each user, this has a downside in that Discourse has a request limit so adding lots of users at once will kill the API requests., haven’t done extensive testing because the only way todo that is in production but tested code works outside of the main for loop and it works fine.


fixed most of the issues above. it now makes a single API request with a list of usernames so no chance of hitting the requests limit and its a lot more efficient. old way was to add the users from their page on the admin panel one by one but now it pushes a list using the proper Add user call to the groups API.

4 Likes

Massive thanks mate

1 Like

no problem, was fun to make. if you end up use it and have any issues let me know.

1 Like

Had todo another one of these large prints again and got complained at because they needed to be in reverse order and double-sided, fixed it up and included a watch for printer queue. thankfully zsh makes it super easy to reverse the order of the file list using the On glob qualifier.

#!/bin/zsh

for f in *.pdf(On); do
    cat "$f" | lp -o scaling=//100// -o ColorModel=KGray -o sides=two-sided-long-edge
done

watch -n1 lpstat
1 Like

lel did this with my media server but it actually paid off now i just hit go(to save power and shit by actually shutting it off at night/when go to work and whatever)

1 Like

Was bored and decided to rewrite my xrandr brightness control script in golang, it effectively does exactly the same thing as the python version but its now its all fancy with compiled code.

package main

import (
	"fmt"
	"log"
	"os"
	"os/exec"
	"strconv"
	"strings"
)

func main() {
	if len(os.Args) <= 1 {
		fmt.Printf("Usage: %s [+|-]", os.Args[0])
		return
	}
	brightness()
}

func brightness() {
	cmd := fmt.Sprintf("xrandr --verbose | grep %s -A 5 | grep Brightness | cut -f 2 -d ' '", display())
	brightness, err := exec.Command("sh", "-c", cmd).Output()
	if err != nil {
		log.Fatal(err)
	}
	f, err := strconv.ParseFloat(strings.TrimSpace(string(brightness)), 64)
	if err != nil {
		log.Fatal(err)
	}
	switch {
	case os.Args[1] == "+" && f < 1:
		f += .25
	case os.Args[1] == "-" && f > 0:
		f -= .25
	}
	exe := fmt.Sprintf("xrandr --output %s --brightness %s", display(), strconv.FormatFloat(f, 'f', 2, 64))
	exec.Command("sh", "-c", exe).Output()
}

func display() string {
	cmd, err := exec.Command("xdotool", "getmouselocation", "--shell").CombinedOutput()
	if err != nil {
		log.Fatal(err)
	}
	lines := strings.SplitAfter(string(cmd), "\n")
	i, err := strconv.Atoi(strings.TrimSpace(lines[0][len(lines)-3:]))
	if err != nil {
		log.Fatal(err)
	}
	switch {
	case i < 1920:
		return "HDMI-1"
	case i < 4480:
		return "DP-1"
	default:
		return "DP-2"
	}
}
1 Like

So my mother got some Philips Hue lights for Christmas and messing around with them there 100% local and have an API that can interface directly with the hub. A couple of minutes and dirty shell functions in my .zshrc later I can turn off/on and the brightness of the light in my room from the terminal.

# lights

lights() {
    curl \
        -X PUT \
        -H "Content-Type: application/json" \
        -d "{\"on\": $1}" \
        "http://10.0.2.24/api/YHp15uuWjnbOt8cjRJEnUY-Tf86oMUoL5BpuHypg/lights/4/state"
}

light_bri() {
    curl \
        -X PUT \
        -H "Content-Type: application/json" \
        -d "{
                \"on\": true,
                \"bri\": $1
            }" \
        "http://10.0.2.24/api/YHp15uuWjnbOt8cjRJEnUY-Tf86oMUoL5BpuHypg/lights/4/state"
}

and with the addition of some i3 hotkeys…

bindsym $mod+Shift+h mode "lights"

mode "lights" {
    bindsym o exec zsh -i --login -c "lights true"
    bindsym f exec zsh -i --login -c "lights false"
    bindsym F12 exec zsh -i --login -c "light_bri 254"
    bindsym F11 exec zsh -i --login -c "light_bri 150"

    bindsym Return mode "default"
    bindsym Escape mode "default"
}

I can now turn on/off my lights using only my keyboard without even having to get up :smiley:

1 Like

Run a small private local minecraft server and got tired of trying to keep it up to date with all of the snapshots. Made a script that will perform backups, update the server, and gracefully stop the server.

import docker
import os
import shutil
import time
client = docker.from_env()

def getURL(): #Gets URL from User
  userInput = input("Please provide the URL for the server jar you wish to update too: ")
  if userInput[0:4] != "http": #Validate that the URL is valid
    print("Invalid URL. Make sure it includes the http protocall")
    userInput = getURL()

  if userInput[-4:] != ".jar": #Verify that we are downloading the correct file
    print("Invalid URL. Missing proper file extension")
    userInput = getURL()

  return userInput

def fetchJar(URL): #Downloads the server jar file from a URL
  if URL[0:4] != "http" or URL[-4:] != ".jar": #One last check to see if the URL was malformed between input and download
    print("Function fetchJar was passed a malformed URL")
    print("URL:" + URL)
    exit(1)

  file = URL.split("/")[-1] #Get the file name of the file we are downloading
  if os.path.exists("/tmp/" + file) == True:
    os.remove("/tmp/" + file)

  if os.path.exists("/tmp/" + file) == True: #Verify that the remove was successful
    print("ABORT. os.remove failed. Please delete /tmp/" + file)
    exit(127)

  print("Fetching Server Jar")
  os.system("wget --quiet -O /tmp/" + file + " " + URL)
  return file #Returns file name to ensure consistency across the code

def getVersion(): #Get version from User
  version = input("What version are you updating too: ")
  return version

def renameServer(version, file): #Takes a file and renames it to the server exec standard. "minecraft.VERSION.jar"
  newFile = "minecraft-server." + version + ".jar"

  if os.path.exists("/tmp/" + newFile) == True:
    os.remove("/tmp/" + newFile)

  if os.path.exists("/tmp/" + newFile) == True: # Verify the remove was successful
    print("ABORT. os.remove failed. Please delete /tmp/" + file)
    exit(126)

  os.rename("/tmp/" + file, "/tmp/" + newFile)
  return newFile #Returns the new file name

def moveServer(file): #Moves server from /tmp to /opt/minecraft-server/
  if os.path.exists("/opt/minecraft-server/" + file) == True: #Verify to see if we are replacing a version that already exists EX: Corrupted server jar
    if input("Server version already exists. Overwrite? (y/n): ").lower() == "y":
      os.remove("/opt/minecraft-server/" + file)
    else:
      print("Aborting upgrade")
      exit(0) #0 exit code since its not a error

  if os.path.exists("/opt/minecraft-server/" + file) == True:
    print("ABORT. os.remove failed. Please delete /tmp/" + file)
    exit(125)

  shutil.move("/tmp/" + file, "/opt/minecraft-server/" + file) #Since /tmp usually resides on a ramfs. Need to use shutil.move as we are going cross device and built in functions dont support it well

def dockerStop(version): # Stop and Verify that its stopped
  print("Stopping Docker container")

  if client.containers.list(filters={"name":"minecraft"}) == []: #Check to see if its already stopped. If so, just return
    return
  os.system("mcrcon -H 192.168.0.6 -P 25575 -p REDACTED 'say Stopping server in 60 seconds to update to '" + version) #Use mcRcon to connect and issue remote commands
  time.sleep(55)
  os.system("mcrcon -H 192.168.0.6 -P 25575 -p REDACTED 'say Stopping server in 5 seconds'")
  time.sleep(5)
  os.system("mcrcon -H 192.168.0.6 -P 25575 -p REDACTED 'kick @a Updating'")
  os.system("mcrcon -H 192.168.0.6 -P 25575 -p REDACTED 'save-all flush'")
  os.system("mcrcon -H 192.168.0.6 -P 25575 -p REDACTED 'stop'")
  print("Waiting for server to gracefully stop")

  while client.containers.list(filters={"name":"minecraft"}) != []: #Waits in a loop until the docker container drops off the list
    time.sleep(1)

def backupWorld(): #Backs up the save
  print("Backing up the world file")
  os.chdir("/opt/minecraft-server")
  os.system("cp -a world Backups/world-$(date +%B-%d-%Y-%R)") #Runs the backup command and timestamps the backup

def updateServer(file): #Update executable
  os.chdir("/opt/minecraft-server") #Make sure we are in the correct directory
  os.remove("server.jar") #Remove the old link
  os.system("ln -s " + file + " server.jar") #Links the new file to the server.jar exec

def main(): #Main function that ties everything together
  URL = getURL()
  version = getVersion()
  file = fetchJar(URL)
  file = renameServer(version, file)
  moveServer(file)

  dockerStop(version)
  backupWorld()
  updateServer(file)
  print("Starting Minecraft")
  os.system("docker start minecraft")

main()
1 Like

I had a lot of .jpg files on my computer that were actually png files so I wrote this small go program that goes though all the files in the specified directory and checks the first 18 bytes against the png header and renames the file to .png if the bytes of the header matches.

package main

import (
	"bytes"
	"flag"
	"fmt"
	"io/ioutil"
	"log"
	"os"
	"path"
	"path/filepath"
	"strings"
)

func main() {
	dir := flag.String("dir", "", "directory to scan")
	flag.Parse()

	files, err := ioutil.ReadDir(*dir)
	if err != nil {
		log.Fatal(err)
	}

	pngheader := []byte{
		0x89, 0x50, 0x4e, 0x47, 0xd, 0xa, 0x1a, 0xa, 0x0,
		0x0, 0x0, 0xd, 0x49, 0x48, 0x44, 0x52, 0x0, 0x0}

	var count int
	for _, f := range files {
		orig := *dir + "/" + f.Name()
		new := *dir + "/" + strings.TrimSuffix(f.Name(), filepath.Ext(f.Name())) + ".png"
		if path.Ext(f.Name()) == ".jpg" {
			file, err := ioutil.ReadFile(orig)
			if err != nil {
				log.Fatal(err)
			}
			if bytes.Equal(file[:18], pngheader) == true {
				if err := os.Rename(orig, new); err != nil {
					log.Fatal(err)
				}
				count++
			}
		}
	}
	fmt.Printf("renamed %d jpg files png\n", count)
}
1 Like

I had a lot of files I wanted to convert to flac so I decided to rework this script so that it is multi-threaded using GNU parallel, I have all the cores might as well put them to good use.

#!/bin/sh

function flac() {
    echo $1
    sox -b 24 "$1" "${1%.*}".flac
    shopt -s nullglob
    if [ ! -d "flac" ]; then
        mkdir flac
    fi
    mv "${1%.*}".flac flac/
}

declare -x -f flac

find . -name "*.wav" | parallel flac {}

I wrote a function in bash todo the conversion and create the flac folder if it doesn’t exsist and then exported it so parallel could actually see it as a command and just passed in the file names as an argument that were piped by find.

Using the origonal script it took 17.245 seconds to convert my test data from WAV to FLAC:

michael@fedora ~/Desktop/WAV » time convert_flac
01- Tapestry.wav
02- Journey_s Lift Off.wav
03- Faith in Our Steps.wav
04- The Wind_s Bazaar.wav
05- Moonlit Cartographers (Part 1).wav
06- Moonlit Cartographers (Part 2).wav
07- Swiftly Through the Valley.wav
08- We Return to Our Home (Night).wav
09- Traders of the Winds.wav
10- Untraveled.wav
11- We Return to Our Home (Day).wav
12- A Prophecy Fulfilled.wav
13- Muluab_s Endless Canon.wav
14- Airborne Nocturne.wav
convert_flac  16.19s user 0.50s system 96% cpu 17.245 total

and using the parallel version I had it down to 2.402 seconds:

michael@fedora ~/Desktop/WAV » time convert_flacv2.sh
./13- Muluab_s Endless Canon.wav
./01- Tapestry.wav
./14- Airborne Nocturne.wav
./05- Moonlit Cartographers (Part 1).wav
./11- We Return to Our Home (Day).wav
./02- Journey_s Lift Off.wav
./08- We Return to Our Home (Night).wav
./03- Faith in Our Steps.wav
./06- Moonlit Cartographers (Part 2).wav
./10- Untraveled.wav
./12- A Prophecy Fulfilled.wav
./09- Traders of the Winds.wav
./04- The Wind_s Bazaar.wav
./07- Swiftly Through the Valley.wav
convert_flacv2.sh  22.65s user 0.68s system 971% cpu 2.402 total

Those 14.843 seconds saved were totally worth the 30 minutes it took me to write this XD

2 Likes