Linux Class Portfolio

Overview
 

Linux Class Portfolio's cover image
This is my portfolio for Professor Ronald Loui's CSDS285 @ CWRU, a class that teaches various Linux tools and concepts, like bash, awk, regex, and bottom-up design. It's an eclectic mix of scripts and Linux-y things I've done with the stuff I've learned throughout the semester in the course. There are a few projects towards the beginning, and then I have a bunch of random bash scripts and more Linux-y things towards the bottom portion.
academic

Linux Scripting/Tools Class Portfolio

What's this?

This is my portfolio for Professor Ronald Loui's CSDS285 @ CWRU. It's an eclectic mix of scripts and linux-y things I've done with the stuff I've learned throughout the semester in the course.

Some Projects/Scripts

Text Sender

Desktop version
Desktop version

What is it?

Text sender is a basic website that uses client side Javascript to poll a PHP server to obtain the current text contents of a Redis cache. The server is deployed to AWS and is publicly accessible through AWS. The live polling is a feature I added later on, and I also added bootstrap to make the website look nice.

The current contents of the text box are accessed from redis by PHP using PHPRedis. I decided to use composer to manage this package, since it made installing it super easy. The project is also dockerized, so it can be easily deployed, and uses Nginx to serve the content.

Uses

  • Sending text data between devices (shipping from phone to phone or desktop to phone etc)
  • Sharing data in front of a live audience
  • Conversations and interactive use
Network Logs
Network Logs
Mobile Version
Mobile Version

Originally, I used the file system to cache the contents, but migrated for performance and to learn how to do Redis in PHP. I also added bootstrap to make it look nicer.

Basically, you can visit sender.404wolf.com and you'll be met with a text input box. You can plop text into it, and it will show up to date contents. If you go to a different device or tab it will keep in sync the current content.

All of the source code can be found here, since it's a bigger project than can reasonably fit here. But here's some of the important PHP code.

It's made of two parts: backend API and frontend client. The first codeblock is the main page with the actual layout and input boxes. The second codeblock is a backend that the clients use to update the state of the redis cache.

Changelog

  • Version 1 - Create basic working version that stores state in file.
  • Version 2 - Add redis with PHP redis. Get working on localhost
  • Version 3 - Add compose.yml and dockerize. Then deploy to aws and use Nginx to serve
1// Index.php 2 3<!doctype html> 4<html lang="en"> 5 6<?php 7require_once __DIR__ . '/vendor/autoload.php'; 8require 'vendor/autoload.php'; 9Predis\Autoloader::register(); 10 11$client = new Predis\Client([ 12 'scheme' => 'tcp', 13 'host' => 'redis', // Use the service name as the hostname 14 'port' => 6379, // Default port for Redis 15]); 16 17function injectVariable($name, $value) 18{ 19 // Add important env variables to the global scope by putting them in a script 20 // tag like this. 21 echo '<script> const ' . $name . ' = "' . $value . '"; </script>'; 22} 23?> 24 25<head> 26 <!-- Required meta tags --> 27 <meta charset="utf-8"> 28 <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> 29 30 <!-- Bootstrap CSS --> 31 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.3.1/dist/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous"> 32 33 <!-- Favicon --> 34 <link rel="icon" href="favicon.ico" type="image/x-icon"> 35 36 <!-- Page metadata --> 37 <title>Send it!</title> 38 39 <style> 40 .bg-light-gray { 41 background-color: #f8f9fa; 42 } 43 </style> 44 45 <?php 46 // Add some constants to the global scope 47 injectVariable('MAX_LENGTH', $_ENV['MAX_LENGTH']); 48 injectVariable('ADDRESS', $_ENV['ADDRESS']); 49 injectVariable('PORT', $_ENV['PORT']); 50 injectVariable('REFRESH_RATE', $_ENV['REFRESH_RATE']) 51 ?> 52 53 <!-- Setup webhook on load --> 54 <script> 55 let priorContents = false; 56 57 function setInputAreaText(text) { 58 document.getElementById("text-to-send").value = text; 59 } 60 61 function getInputAreaText() { 62 return document.getElementById("text-to-send").value; 63 } 64 65 function shipNewContents() { 66 const newContents = document.getElementById("text-to-send").value; 67 console.log("Shipping the contents of the text area. New contents: " + newContents); 68 fetch(`${ADDRESS}:${PORT}/state.php`, { 69 method: "POST", 70 headers: { 71 "Content-Type": "application/json" 72 }, 73 body: JSON.stringify({ 74 text: newContents.slice(0, MAX_LENGTH), 75 }) 76 }) 77 .then(response => response.json()) 78 .then(data => { 79 console.log(data); 80 }) 81 .catch(error => { 82 console.log(error); 83 }) 84 } 85 86 function justUpdated() { 87 // new Date() returns a new date object with the current time 88 document.getElementById("last-update").innerText = "Updated at " + formatTime(new Date()); 89 } 90 91 function beginPolling() { 92 setInterval(() => { 93 94 console.log("Attempting poll @" + (new Date())) 95 const textArea = document.getElementById("text-to-send"); 96 if (textArea.value !== priorContents) { 97 console.log("New contents detected. Shipping new contents."); 98 shipNewContents(); 99 priorContents = textArea.value; 100 justUpdated(); 101 return 102 } 103 104 fetch(`${ADDRESS}:${PORT}/state.php`) 105 .then(response => response.json()) 106 .then(data => { 107 if (data.text !== priorContents) { 108 console.log("Received new text contents. Updating text area."); 109 setInputAreaText(data.text); 110 } 111 justUpdated(); 112 }) 113 }, REFRESH_RATE); 114 } 115 116 function formatTime() { 117 const date = new Date(); 118 119 let hours = date.getHours(); 120 const minutes = date.getMinutes(); 121 const seconds = date.getSeconds(); 122 const ampm = hours >= 12 ? 'PM' : 'AM'; 123 124 hours = hours % 12; 125 hours = hours ? hours : 12; // the hour '0' should be '12' 126 const strHours = hours < 10 ? '0' + hours : hours; 127 const strMinutes = minutes < 10 ? '0' + minutes : minutes; 128 const strSeconds = seconds < 10 ? '0' + seconds : seconds; 129 130 return strHours + ':' + strMinutes + ':' + strSeconds + ' ' + ampm; 131 } 132 133 addEventListener("DOMContentLoaded", () => { 134 beginPolling(); 135 }) 136 </script> 137</head> 138 139</script> 140</head> 141 142<body> 143 <!-- Main container for body --> 144 <div class="container"> 145 <div class="row mt-5 justify-content-center"> 146 <div class="my-4 p-3 pb-0 border col-10 bg-light-gray"> 147 <h1 class="w-75 text-center mx-auto mb-2">Sender</h1> 148 <p> 149 Share your code! Whatever you enter below becomes what everyone visiting this site sees, 150 and the current contents on the page will be lost. 151 </p> 152 153 <!-- Area for user input --> 154 <code id="code-text-area"> 155 <textarea class="form-control font-monospace text-sm" style="min-height: 32rem; font-size: 12px" id="text-to-send" rows="3"> 156<?php 157echo $client->get('text'); 158?> 159</textarea> 160 </code> 161 162 <!-- Last update timestamp --> 163 <p class="text-right -mb-2" id="last-update"> 164 Updated at <?php echo date("h:i:s A"); ?> 165 </p> 166 </div> 167 </div> 168 </div> 169 170 <footer style="background-color: #f2f2f2; padding: 10px; position: fixed; bottom: 0; width: 100%;" class="container-fluid text-right border"> 171 Made by <span><a href="http://404wolf.com" target="_blank">Wolf</a></span> 172 </footer> 173</body> 174 175</html>
1// state.php 2<?php 3require_once __DIR__ . '/vendor/autoload.php'; 4require 'vendor/autoload.php'; 5Predis\Autoloader::register(); 6 7$client = new Predis\Client([ 8 'scheme' => 'tcp', 9 'host' => 'redis', // Use the service name as the hostname 10 'port' => 6379, // Default port for Redis 11]); 12 13if ($_SERVER['REQUEST_METHOD'] === 'POST') { 14 // Get the JSON data from the request body 15 $data = @json_decode(file_get_contents('php://input'), true); 16 17 // Make sure that the new text is not too long 18 if (strlen($data['text']) > 100000) { 19 http_response_code(400); 20 echo json_encode(["error" => "Input text too long"]); 21 exit(); 22 } 23 24 // Dump the contents to the contents file 25 $client->set('text', $data['text']); 26 27 http_response_code(200); 28 echo json_encode(["success" => true]); 29} 30 31if ($_SERVER['REQUEST_METHOD'] === 'GET') { 32 $contents = $client->get('text'); 33 $contents = json_encode(["text" => $contents]); 34 if ($contents === null) { 35 http_response_code(404); 36 echo json_encode(["error" => "No data found"]); 37 exit(); 38 } 39 echo $contents; 40}

Also, here is the Nginx config that serves the website. I haven't used Nginx before, but it wasn't too bad to set up and it works well. ChatGPT helped me configure this and also added all the comments.

1server 2{ 3 # Listen on all interfaces on port 80 4 listen 0.0.0.0:80; 5 6 # Set the root directory for requests 7 root /var/www/html; 8 9 # Default location block 10 location / 11 { 12 # Specify index files to be served 13 index index.php index.html; 14 } 15 16 # Location block for processing PHP files 17 location ~ \.php$ 18 { 19 include fastcgi_params; # Include FastCGI parameters 20 fastcgi_pass php:9000; # Forward PHP requests to the FastCGI server on port 9000 21 fastcgi_index index.php; # Default file to serve 22 fastcgi_param SCRIPT_FILENAME $document_root/$fastcgi_script_name; # Set the script filename 23 } 24 25 # Location block for serving a specific file (contents.json) 26 location = /contents.json 27 { 28 alias /var/www/html/contents.json; # Directly serve contents.json file from path 29 } 30}

Discord/Betterdiscord Installer

What it is

Discord update popup
Discord update popup

Discord (chatting app) requires you to completely redownload the app whenever there are updates on Desbian. It makes a popup that says that you need to download a whole new binary each time there is an update. Additionally, I use a client called BetterDiscord that requires you to "patch" Discord every time you install it. This means that every now and then, I open discord, and have to manually install a new version and then also re-patch it with Betterdiscord. It's a pain in the neck every time updates are released.

1#!/usr/bin/bash 2 3# get the "location" header from https://discord.com/api/download/stable?platform=linux&format=tar.gz and store it in a variable 4 5 6# code for finding location and cleaning it up is a friend's 7# ===== GET THE DOWNLOAD URL ===== 8location=$(curl -sI "https://discord.com/api/download/stable?platform=linux&format=tar.gz" | grep -i location | awk '{print $2}') 9 10# fix formatting of location to prevent "bad substitution" error 11location=$( 12 echo $location | sed 's/\r//' 13) 14 15# ===== GET THE DOWNLOAD URL ===== 16 17# download the discord.tar.gz (overwrite if it already exists) 18curl -L -o discord.tar.gz $location 19 20# Extract the tar file and overwrite ~/.local/share/Discord 21# Modified code from a friend. 22# -x = extract files from archive 23# -v verbose 24# -f use the following tarball file 25# The program currently doesn't run on my main laptop because discord's location doesn't seem to be ~/.local/share 26tar -xvf discord.tar.gz -C ~/.local/share --overwrite 27 28# The following is all my code. It uses Betterdiscordctl to patch discord if it isn't already patched 29 30# Remove the tar file from the current directory that we just downloaded it to 31rm discord.tar.gz 32 33# Define betterdiscordctl path 34betterdiscordctl=/usr/local/bin/betterdiscordctl 35 36# Check if BetterDiscord is already installed 37if ! $betterdiscordctl status | grep -q "no"; then 38 # If BetterDiscord is already installed, print a message and do nothing 39 echo "BetterDiscord is installed." 40 # Check if Discord is running and restart it after installing BetterDiscord 41 else 42 discordRunning=$(pgrep Discord) 43 killall Discord 44 $betterdiscordctl install 45 if [ $discordRunning ]; then 46 nohup discord & 47 fi 48fi

Output of running script:

1wolf@wolfs-laptop:~/Scripts/BetterDiscord$ source install.sh 2 % Total % Received % Xferd Average Speed Time Time Time Current 3 Dload Upload Total Spent Left Speed 4100 96.8M 100 96.8M 0 0 24.3M 0 0:00:03 0:00:03 --:--:-- 24.3M 5Discord/ 6Discord/libffmpeg.so 7... the various things that get unpacked unpack ... 8Discord/discord.desktop 9BetterDiscord is installed.

Originally this script only installed Betterdiscord, but I added the part to install Discord itself too after.

I added the part that installs Discord after and changed it slightly for my needs, which a friend had written for themselves. I wrote the part that installs betterdiscord myself, which uses an open source tool called betterdiscordctl. Basically, it checks to see if betterdiscord is installed, and if it isn't then it re-patches discord.

I then put it in my crontab to run automatically every day using

0 10 * * * /home/wolf/Scripts/BetterDiscord/install.sh

I won't know if this works until Discord releases an update, though.

Case Room Selection Utility

Website
Website
Loading... Part
Loading... Part

I made a very complex python webserver with a selenium virtual chromedriver to get an auth token to Case's room reservation system. Then I use that token to find all rooms that are not taken from X to Y time, so I can find a study room. I created a basic html/css/js page that hits the website and displays vacant rooms, and has textual input. It uses the API I built previously.

TLDR: I have an API to find empty rooms on campus. I made a frontend for it.

DISCLAIMER: I wrote this HTML/JS for this course portfolio, but it doesn't work at the moment only because of a dependency that I wrote outside of this course. I'm still going to include it since I think it's pretty cool.

The API was down when writing this to get screenshots, and I'm currently fixing a part of the authing flow. However, the general idea is that you can see it load and then fetch all empty rooms on campus.

Changelog

  • Make basic page to view the courses that lists all empty rooms
  • Add user inputs to specify hours and duration
  • Add loading animation, add clickable links to share
1<html> 2 <script type="text/javascript"> 3 const url = "http://149.28.40.6:5000/find-rooms" 4 5 function getRoomURL(duration_hours, hours_from_now) { 6 const params = { 7 duration_hours: duration_hours, 8 hours_from_now: hours_from_now 9 }; 10 const queryString = Object.keys(params).map(key => key + '=' + params[key]).join('&'); 11 return url + '?' + queryString 12 } 13 14 function findRooms(duration_hours, hours_from_now) { 15 const path = getRoomURL(duration_hours, hours_from_now) 16 17 return fetch(path, { 18 headers: { 19 'Content-Type': 'application/json' 20 } 21 }).then(resp => resp.json()) 22 } 23 24 function parseFoundRooms(rooms) { 25 rooms = rooms['rooms']; 26 rooms = rooms.sort((a, b) => a.building_code > b.building_code ? 1 : -1); 27 return rooms.map(room => room.building_code + " " + room.room_code).join("&#10;"); 28 } 29 30 function displayResults(results) { 31 const resultsBox = document.getElementById('results-box'); 32 resultsBox.innerHTML = results; 33 } 34 35 function onGetResultsClick() { 36 // Get the form values 37 const duration_hours = document.getElementById('duration_hours').value; 38 const hours_from_now = document.getElementById('hours_from_now').value; 39 40 // Run the query 41 findRooms(duration_hours, hours_from_now) 42 .then(results => parseFoundRooms(results)) 43 .then(displayResults); 44 document.getElementById('results-url').href = getRoomURL(duration_hours, hours_from_now); 45 46 // Update the results area header 47 document.getElementById('results-area-head').innerHTML = ( 48 "Results for " 49 + duration_hours 50 + " hours from now, for " 51 + hours_from_now 52 + " hours" 53 ); 54 document.getElementById('results-box').innerHTML = "Loading..."; 55 56 // Reset the form 57 document.getElementById('duration_hours').value = ""; 58 document.getElementById('hours_from_now').value = ""; 59 } 60 </script> 61 62 <head> 63 <title>Room Finder</title> 64 </head> 65 66 <body> 67 <!-- Header --> 68 <h1 style="text-align: center;">Room Finder</h1> 69 <h2 style="text-align: center;">Find an unbooked CWRU room</h2> 70 71 <!-- Form area --> 72 <div style="display: flex; justify-content: space-between;"> 73 <div> 74 <label for="duration_hours">Duration in hours</label> 75 <input type="number" id="duration_hours" placeholder="Duration in hours" value="0"> 76 </div> 77 78 <div> 79 <label for="hours_from_now">Hours from now</label> 80 <input type="number" id="hours_from_now" placeholder="Hours from now" value="6"> 81 </div> 82 83 <button onclick="onGetResultsClick()">Find a room</button> 84 </div> 85 86 <!-- Results area --> 87 <div> 88 <a id="results-url"><h3 id="results-area-head">Results Area</h3></a> 89 <textarea readonly style="width: 100%; min-height: 40vh;" id="results-box"> 90Results will appear here 91 </textarea> 92 </div> 93 </body> 94</html>

Archive Utility

What is it?

A tool that lets you zip something and then move it into /home/Archive automatically.

Basically, you run bash archive.sh file_to_be_archived.extension, and it automatically 7z compresses it, moves the file, and deletes the old uncompressed file.

After I made it I added some basic error handling, and automatically will add a _121 (counter) to the back of the file if there are duplicates already in the out directory (_121 indicates 120 other files already exist with its name).

Changelog

  • Make basic working version that archives an input
  • Add filename checking
  • Prevent duplicate clobbering by incrementing filename
1#!/bin/bash 2 3# Name the argument for the filename 4file=$1; 5 6# Grab the extension and file name seperately and assign them to variables 7filename=$(echo $file | grep -o '^[^\.]*'); 8extension=$(echo $file | grep -o '\..*$'); 9out_filename=$filename; 10 11# VERSION 2 -- ADD FILENAME CHECK 12# If the file is not passed exit 13if [ -z $file ]; then 14 echo "No filename provided"; 15 exit 1; 16fi 17 18# If the file does exist then make a new one with a affixed ID 19# VERSION 3 -- ADD FILE NAME INCREMENT 20if [ -e "$out_filename.7z" ]; then 21 ticker=1; 22 while [ -e "$file_$ticker.7z" ]; do 23 ((ticker++)); # Increment the counter 24 done 25 out_filename="${filename}_${ticker}"; 26fi; 27 28out_filename="${out_filename}.7z"; 29echo "Archiving $file to $out_filename.7z"; 307z a "$out_filename" "$file"; 31# Need to keep ~/Archive/ out of quotes since ~ must be expanded properly 32mv $out_filename ~/Archive/ 33 34# Check status and if the archive was successful report it and delete the old file 35if [ $? -eq 0 ]; then 36 echo "Archive $filename.7z created successfully"; 37 rm -i -r $file; 38else 39 echo "Failed to create archive $filename.7z" 40fi

Example use:

1wolf@wolfs-laptop:~/Desktop/Projects/Archiver$ tree 2. 3└── archive.sh 4 50 directories, 1 file 6wolf@wolfs-laptop:~/Desktop/Projects/Archiver$ echo "{ 'test' : 'test' }" > test.json 7wolf@wolfs-laptop:~/Desktop/Projects/Archiver$ tree 8. 9├── archive.sh 10└── test.json 11 120 directories, 2 files 13wolf@wolfs-laptop:~/Desktop/Projects/Archiver$ alias archive="bash archive.sh" 14wolf@wolfs-laptop:~/Desktop/Projects/Archiver$ archive test.json 15Archiving test.json to test.7z.7z 16 177-Zip [64] 16.02 : Copyright (c) 1999-2016 Igor Pavlov : 2016-05-21 18p7zip Version 16.02 (locale=en_US.UTF-8,Utf16=on,HugeFiles=on,64 bits,8 CPUs 11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz (806C1),ASM,AES-NI) 19 20Scanning the drive: 211 file, 20 bytes (1 KiB) 22 23Creating archive: test.7z 24 25Items to compress: 1 26 27 28Files read from disk: 1 29Archive size: 146 bytes (1 KiB) 30Everything is Ok 31Archive test.7z created successfully 32rm: remove regular file 'test.json'? yes 33wolf@wolfs-laptop:~/Desktop/Projects/Archiver$ ls 34archive.sh 35wolf@wolfs-laptop:~/Desktop/Projects/Archiver$ z ~/Archive 36wolf@wolfs-laptop:~/Archive$ ls 37test.7z

Move types into folders

I made a very basic script that moves files into folders named after their file type. It iterates through files in the current directory and moves each file to a folder named after its extension.

Changelog

  • Make a basic script to move different filetypes into different folders
  • Add protections, like ensuring the script file itself isn't moved, or that directories aren't touched or changed.

ChatGPT helped me figure out how to iterate over files in a directory (it is shockingly easy!).

1for file in ./*; do 2 # Skip if the file is the script itself. 3 if [ $file == "./folderFileType.sh" ]; then 4 echo "Skipping $file because it is the script itself."; 5 continue; 6 fi; 7 8 # Skip if the file is a folder. 9 if [ -d $file ]; then 10 echo "Skipping $file because it is a folder."; 11 continue; 12 fi; 13 14 echo "Processing $file"; 15 16 # Get the suffix of the file. 17 # "##" means remove the longest match from the beginning. 18 # Note that "*." is not regex, it's a shell pattern. 19 FILE_SUFFIX=${file##*.}; 20 21 # Create a folder with the same name as the suffix. 22 # "-p" means create the parent folder if it doesn't exist. 23 # "-v" means print the name of the folder. 24 mkdir -p -v $FILE_SUFFIX; 25 mv -v $file $FILE_SUFFIX; 26 echo "Processed $file by moving it to $FILE_SUFFIX"; 27done; 28echo "All files processed.";

Before, during, and after

1wolf@wolfs-laptop:~/Scripts/Tests$ tree 2. 3├── anotherTest.c 4├── anotherTest.cc 5├── anothertest.js 6├── anotherTest.ts 7├── folderFileType.sh 8├── test.py 9├── test.test 10├── test.ts 11└── test.txt
1Processing ./anotherTest.c 2mkdir: created directory 'c' 3renamed './anotherTest.c' -> 'c/anotherTest.c' 4Processed ./anotherTest.c by moving it to c 5Processing ./anotherTest.cc 6mkdir: created directory 'cc' 7renamed './anotherTest.cc' -> 'cc/anotherTest.cc' 8Processed ./anotherTest.cc by moving it to cc 9Processing ./anothertest.js 10mkdir: created directory 'js' 11renamed './anothertest.js' -> 'js/anothertest.js' 12Processed ./anothertest.js by moving it to js 13Processing ./anotherTest.ts 14mkdir: created directory 'ts' 15renamed './anotherTest.ts' -> 'ts/anotherTest.ts' 16Processed ./anotherTest.ts by moving it to ts 17Skipping ./folderFileType.sh because it is the script itself. 18Processing ./test.py 19mkdir: created directory 'py' 20renamed './test.py' -> 'py/test.py' 21Processed ./test.py by moving it to py 22Processing ./test.test 23mkdir: created directory 'test' 24renamed './test.test' -> 'test/test.test' 25Processed ./test.test by moving it to test 26Processing ./test.ts 27renamed './test.ts' -> 'ts/test.ts' 28Processed ./test.ts by moving it to ts 29Processing ./test.txt 30mkdir: created directory 'txt' 31renamed './test.txt' -> 'txt/test.txt' 32Processed ./test.txt by moving it to txt 33All files processed.
1wolf@wolfs-laptop:~/Scripts/Tests$ tree 2. 3├── c 4│   └── anotherTest.c 5├── cc 6│   └── anotherTest.cc 7├── folderFileType.sh 8├── js 9│   └── anothertest.js 10├── py 11│   └── test.py 12├── test 13│   └── test.test 14├── ts 15│   ├── anotherTest.ts 16│   └── test.ts 17└── txt 18 └── test.txt 19 207 directories, 9 files

React Native Boot Script

Video of it running

Example of it running
Example of it running

TLDR

wolf@wolfs-laptop:~$ lt -p 8080
your url is: https://petite-ends-stay.loca.lt

Localtunnel maps a public URL to your localhost at a port. This is a script that runs lt -p 8080 in the background, extracts the URL, exports it as an env variable, and then launches a process that requires it. It also does some basic environment setup.

Why?

When I boot a react native app (mobile app development framework that I'm using to make an Android app) project with the Metro bundler (a development tool for shipping the app to a virtual Android device) to get it to run usually my project needs me to manually do these things:

  1. Start the android emulator and press super shift t to set it to be always on top (so it sits on top of my IDE)
  2. Start a HTTPS tunnel so I can access localhost:8080 (where my API lives) from the android device
  3. Run the bundler to boot the app on the emulator
  4. Enter the letter "a" to get the bundler to know that I want to run the app on android

It's all really annoying, so I wrote a bash script to do it all for me. What was fun about this is that I got to learn how to use xdotool to actually simulate a keypress which I added later on (chatGPT definitely helped). In the future I want to figure out if there's a proper non-interactive way to boot Metro for Android.

1~/Android/Sdk/emulator/emulator @mainAndroid & sleep 3 && xdotool key super+shift+t 2# Call `lt -p 8080`, and pipe the output into /tmp/lt_out. Then sleep to wait for it to start, and use the output that was buffered in the temp file to grab the URL 3export SERVER_ADDRESS=$(lt -p 8080 > /tmp/lt_out & sleep 2 && cat /tmp/lt_out | grep -o 'https://.*') 4echo "Server address: $SERVER_ADDRESS" 5 6# An annoying thing that needs to be in the environment that it can't grab from .env for some reason 7export ANDROID_HOME=$HOME/Android/Sdk 8echo "ANDROID_HOME: $ANDROID_HOME" 9 10# Run the `sleep 8 && xdotool type 'a'` command in the background. 11# Run `npx react-natvie start --port 9097` in the foreground. 12# Running react native metro is blocking so it never terminates 13# (what coes to the right of & runs in the background without waiting 14# for the thing to the left of it to terminate) 15sleep 8 && xdotool type 'a' & npx react-native start --port 9097

Course Snipe Autoclicker

Successful click at 7:00:00!
Successful click at 7:00:00!

For course registration I made a super simple Python script to click the register button at exactly 7AM. It's a very simple script that simulates a left mouse click. I left the cursor over the register button, and ran it on a headed Linux system so the clock was super super accurate. I was able to get all the courses I wanted for next semester (all of which were very competitive) this way. It's Python, but it's definitely a script in the style of this course.

1import pyautogui 2import datetime 3import time 4 5def click_at_time(time_str): 6 target_time = datetime.datetime.strptime(time_str, "%H:%M").time() 7 while True: 8 now = datetime.datetime.now().time() 9 if ( 10 now.hour == target_time.hour 11 and now.minute == target_time.minute 12 and now.second == 0 13 and now.microsecond < 100000 14 ): 15 time.sleep(0.05) 16 pyautogui.click() 17 break 18 time.sleep(0.01) 19 20if __name__ == "__main__": 21 target = input("Enter the time in HH:MM format: ") 22 click_at_time(target)

Board game listing

Annoying to parse page
Annoying to parse page
Finding the API
Finding the API

TLDR

A simple bash script that uses JQ piping and an API I found to fetch a list of names of board games produced by a specific company.

I recently was interested in finding a list of all board games that the board game producing company Funforge had produced. They make a lot of great board games and I was curious to learn of new ones and also investigate resale potential for some of their older ones. I checked board game geek, a forum and data source for board games, and they have lists of games for specific publishers, but it's paginated and annoying to parse.

I decided to open network tab to see if there were any requests with Json data being sent from an API, and it turned out that there was. I tinkered a bit with getting the right parameters, and figured out that the API endpoint was paginating the board games that Funforge had produced. So, I wrote a simple bash script with a loop to list out all the games that they've produced, and automatically deal with the pagination.

Example of Content to Parse

1{ 2 "items": [ 3 { 4 "usersrated": "72478", 5 "average": "7.87762", 6 "avgweight": "3.6369", 7 "numowned": "85351", 8 "numprevowned": "9222", 9 "numtrading": "1393", 10 "numwanting": "1145", 11 "numwish": "12127", 12 "numcomments": "13733", 13 "yearpublished": "2007", 14 "rank": "52", 15 "name": "Agricola", 16 "postdate": "2007-08-09 15:05:03", 17 "linkid": "8215175", 18 "linktype": "boardgamepublisher", 19 "objecttype": "thing", 20 "objectid": "31260", 21 "itemstate": "approved", 22 "rep_imageid": "831744", 23 "subtype": "boardgame", 24 "links": [], 25 "href": "/boardgame/31260/agricola", 26 "images": { 27 "thumb": "https://cf.geekdo-images.com/dDDo2Hexl80ucK1IlqTk-g__thumb/img/GHGdnCfeysoP_34gLnofJcNivW8=/fit-in/200x150/filters:strip_icc()/pic831744.jpg", 28 "micro": "https://cf.geekdo-images.com/dDDo2Hexl80ucK1IlqTk-g__micro/img/SZgGqufNqaW8BCFT29wkYPaRXOE=/fit-in/64x64/filters:strip_icc()/pic831744.jpg", 29 "square": "https://cf.geekdo-images.com/dDDo2Hexl80ucK1IlqTk-g__square/img/buzgMbAtE6uf5rX-1-PwqvBnzDY=/75x75/filters:strip_icc()/pic831744.jpg", 30 "squarefit": "https://cf.geekdo-images.com/dDDo2Hexl80ucK1IlqTk-g__squarefit/img/nqUrgCUx_0WWtpCtOrQdSUxQkVU=/fit-in/75x75/filters:strip_icc()/pic831744.jpg", 31 "tallthumb": "https://cf.geekdo-images.com/dDDo2Hexl80ucK1IlqTk-g__tallthumb/img/mlIqrJemOrOnqg8Ha2WiXfdFtWE=/fit-in/75x125/filters:strip_icc()/pic831744.jpg", 32 "previewthumb": "https://cf.geekdo-images.com/dDDo2Hexl80ucK1IlqTk-g__previewthumb/img/MoFOfTG1NMtI-fKAyegRhCBlzmc=/fit-in/300x320/filters:strip_icc()/pic831744.jpg", 33 "square200": "https://cf.geekdo-images.com/dDDo2Hexl80ucK1IlqTk-g__square200/img/bhWPgT6a0vKXqTw448T-mhJy_rQ=/200x200/filters:strip_icc()/pic831744.jpg", 34 "original": "https://cf.geekdo-images.com/dDDo2Hexl80ucK1IlqTk-g__original/img/toobKoejPiHpfpHk4SYd1UAJafw=/0x0/filters:format(jpeg)/pic831744.jpg" 35 } 36 }, 37 { 38 "usersrated": "17602", 39 "average": "7.95942", 40 "avgweight": "3.4502", 41 "numowned": "24475", 42 "numprevowned": "2212", 43 "numtrading": "272", 44 "numwanting": "428", 45 "numwish": "3442", 46 "numcomments": "2254", 47 "yearpublished": "2016", 48 "rank": "76", 49 "name": "Agricola (Revised Edition)", 50 "postdate": "2016-05-23 15:43:12", 51 "linkid": "5670975", 52 "linktype": "boardgamepublisher", 53 "objecttype": "thing", 54 "objectid": "200680", 55 "itemstate": "approved", 56 "rep_imageid": "8093340", 57 "subtype": "boardgame", 58 "links": [], 59 "href": "/boardgame/200680/agricola-revised-edition", 60 "images": { 61 "thumb": "https://cf.geekdo-images.com/YCGWJMFwOI5efji2RJ2mSw__thumb/img/h_Rp2XYqaNElM2hrYxUSzMxRRgM=/fit-in/200x150/filters:strip_icc()/pic8093340.jpg", 62 "micro": "https://cf.geekdo-images.com/YCGWJMFwOI5efji2RJ2mSw__micro/img/GQNkMukGtKD2cVOgpUk4ZSmpUfA=/fit-in/64x64/filters:strip_icc()/pic8093340.jpg", 63 "square": "https://cf.geekdo-images.com/YCGWJMFwOI5efji2RJ2mSw__square/img/KjlOw3u2349C_-_Bewth-UotKPY=/75x75/filters:strip_icc()/pic8093340.jpg", 64 "squarefit": "https://cf.geekdo-images.com/YCGWJMFwOI5efji2RJ2mSw__squarefit/img/LjiHLhRV8Js7M-Yw2kIn9jUNqYs=/fit-in/75x75/filters:strip_icc()/pic8093340.jpg", 65 "tallthumb": "https://cf.geekdo-images.com/YCGWJMFwOI5efji2RJ2mSw__tallthumb/img/E1CGspVGwdQl011_rBuC-FLS-Lg=/fit-in/75x125/filters:strip_icc()/pic8093340.jpg", 66 "previewthumb": "https://cf.geekdo-images.com/YCGWJMFwOI5efji2RJ2mSw__previewthumb/img/8U7iPhmzZXjytK2qS_-M5SpJ028=/fit-in/300x320/filters:strip_icc()/pic8093340.jpg", 67 "square200": "https://cf.geekdo-images.com/YCGWJMFwOI5efji2RJ2mSw__square200/img/Yyxaz3YRjIEo1EZBN4ipT3gpEzY=/200x200/filters:strip_icc()/pic8093340.jpg", 68 "original": "https://cf.geekdo-images.com/YCGWJMFwOI5efji2RJ2mSw__original/img/jC_He46LcIcKWU-kSwkYdr9Z45E=/0x0/filters:format(jpeg)/pic8093340.jpg" 69 } 70 } 71 ], 72 "itemdata": [ 73 { 74 "datatype": "geekitem_fielddata", 75 "fieldname": "name", 76 "title": "Primary Name", 77 "primaryname": true, 78 "required": true, 79 "unclickable": true, 80 "fullcredits": true, 81 "subtype": "boardgame", 82 "keyname": "name" 83 }, 84 { 85 "datatype": "geekitem_fielddata", 86 "fieldname": "alternatename", 87 "title": "Alternate Names", 88 "alternate": true, 89 "unclickable": true, 90 "fullcredits": true, 91 "subtype": "boardgame", 92 "keyname": "alternatename" 93 }, 94 { 95 "datatype": "geekitem_fielddata", 96 "fieldname": "yearpublished", 97 "title": "Year Released", 98 "fullcredits": true, 99 "subtype": "boardgame", 100 "keyname": "yearpublished" 101 }, 102 { 103 "datatype": "geekitem_fielddata", 104 "fieldname": "minplayers", 105 "title": "Minimum Players", 106 "subtype": "boardgame", 107 "keyname": "minplayers" 108 }, 109 { 110 "datatype": "geekitem_fielddata", 111 "fieldname": "maxplayers", 112 "title": "Maximum Players", 113 "subtype": "boardgame", 114 "keyname": "maxplayers" 115 }, 116 { 117 "datatype": "geekitem_fielddata", 118 "fieldname": "minplaytime", 119 "title": "Minimum Playing Time", 120 "createposttext": " minutes", 121 "posttext": " minutes", 122 "subtype": "boardgame", 123 "keyname": "minplaytime" 124 }, 125 { 126 "datatype": "geekitem_fielddata", 127 "fieldname": "maxplaytime", 128 "title": "Maximum Playing Time", 129 "createposttext": " minutes", 130 "posttext": " minutes", 131 "subtype": "boardgame", 132 "keyname": "maxplaytime" 133 }, 134 { 135 "datatype": "geekitem_fielddata", 136 "fieldname": "minage", 137 "title": "Mfg Suggested Ages", 138 "createtitle": "Minimum Age", 139 "posttext": " and up", 140 "subtype": "boardgame", 141 "keyname": "minage" 142 }, 143 { 144 "datatype": "geekitem_fielddata", 145 "fieldname": "override_rankable", 146 "title": "Override Rankable", 147 "table": "geekitem_items", 148 "options": [ 149 { 150 "value": 1, 151 "title": "yes" 152 }, 153 { 154 "value": 0, 155 "title": "no" 156 } 157 ], 158 "adminonly": true, 159 "subtype": "boardgame", 160 "keyname": "override_rankable" 161 }, 162 { 163 "datatype": "geekitem_fielddata", 164 "fieldname": "targetco_url", 165 "unclickable": true, 166 "title": "Target Co Order Link", 167 "subtype": "boardgame", 168 "keyname": "targetco_url" 169 }, 170 { 171 "datatype": "geekitem_fielddata", 172 "fieldname": "walmart_id", 173 "unclickable": true, 174 "title": "Walmart Item Id", 175 "nullable": true, 176 "subtype": "boardgame", 177 "keyname": "walmart_id" 178 }, 179 { 180 "datatype": "geekitem_fielddata", 181 "fieldname": "instructional_videoid", 182 "unclickable": true, 183 "title": "Promoted Instructional Video ID", 184 "validatemethod": "ValidateVideoid", 185 "nullable": true, 186 "subtype": "boardgame", 187 "keyname": "instructional_videoid" 188 }, 189 { 190 "datatype": "geekitem_fielddata", 191 "fieldname": "summary_videoid", 192 "unclickable": true, 193 "title": "Promoted Summary Video ID", 194 "validatemethod": "ValidateVideoid", 195 "nullable": true, 196 "subtype": "boardgame", 197 "keyname": "summary_videoid" 198 }, 199 { 200 "datatype": "geekitem_fielddata", 201 "fieldname": "playthrough_videoid", 202 "unclickable": true, 203 "title": "Promoted Playthrough Video ID", 204 "validatemethod": "ValidateVideoid", 205 "nullable": true, 206 "subtype": "boardgame", 207 "keyname": "playthrough_videoid" 208 }, 209 { 210 "datatype": "geekitem_fielddata", 211 "fieldname": "focus_videoid", 212 "unclickable": true, 213 "title": "Promoted In Focus Video ID", 214 "validatemethod": "ValidateVideoid", 215 "nullable": true, 216 "subtype": "boardgame", 217 "keyname": "focus_videoid" 218 }, 219 { 220 "datatype": "geekitem_fielddata", 221 "fieldname": "howtoplay_videoid", 222 "unclickable": true, 223 "title": "Promoted BGG How to Play Video ID", 224 "validatemethod": "ValidateVideoid", 225 "nullable": true, 226 "subtype": "boardgame", 227 "keyname": "howtoplay_videoid" 228 }, 229 { 230 "datatype": "geekitem_fielddata", 231 "fieldname": "bggstore_product", 232 "unclickable": true, 233 "title": "Promoted BGG Store Product Name", 234 "nullable": true, 235 "subtype": "boardgame", 236 "keyname": "bggstore_product" 237 }, 238 { 239 "datatype": "geekitem_fielddata", 240 "fieldname": "short_description", 241 "title": "Short Description", 242 "table": "geekitem_items", 243 "maxlength": 85, 244 "editfieldsize": 60, 245 "required": true, 246 "subtype": "boardgame", 247 "keyname": "short_description" 248 }, 249 { 250 "datatype": "geekitem_linkdata", 251 "other_objecttype": "person", 252 "other_subtype": "boardgamedesigner", 253 "linktype": "boardgamedesigner", 254 "self_prefix": "src", 255 "title": "Designer", 256 "titlepl": "Designers", 257 "fullcredits": true, 258 "schema": { 259 "itemprop": "creator", 260 "itemtype": "https://schema.org/Person" 261 }, 262 "keyname": "boardgamedesigner" 263 }, 264 { 265 "datatype": "geekitem_linkdata", 266 "other_objecttype": "person", 267 "other_subtype": "boardgamesolodesigner", 268 "linktype": "boardgamesolodesigner", 269 "self_prefix": "src", 270 "title": "Solo Designer", 271 "titlepl": "Solo Designers", 272 "fullcredits": true, 273 "keyname": "boardgamesolodesigner" 274 }, 275 { 276 "datatype": "geekitem_linkdata", 277 "other_objecttype": "person", 278 "other_subtype": "boardgameartist", 279 "linktype": "boardgameartist", 280 "self_prefix": "src", 281 "title": "Artist", 282 "titlepl": "Artists", 283 "fullcredits": true, 284 "keyname": "boardgameartist" 285 }, 286 { 287 "datatype": "geekitem_linkdata", 288 "other_objecttype": "company", 289 "other_subtype": "boardgamepublisher", 290 "linktype": "boardgamepublisher", 291 "self_prefix": "src", 292 "title": "Publisher", 293 "titlepl": "Publishers", 294 "required": true, 295 "fullcredits": true, 296 "schema": { 297 "itemprop": "publisher", 298 "itemtype": "https://schema.org/Organization" 299 }, 300 "keyname": "boardgamepublisher" 301 }, 302 { 303 "datatype": "geekitem_linkdata", 304 "other_objecttype": "person", 305 "other_subtype": "boardgamedeveloper", 306 "linktype": "boardgamedeveloper", 307 "self_prefix": "src", 308 "title": "Developer", 309 "titlepl": "Developers", 310 "fullcredits": true, 311 "keyname": "boardgamedeveloper" 312 }, 313 { 314 "datatype": "geekitem_linkdata", 315 "other_objecttype": "person", 316 "other_subtype": "boardgamegraphicdesigner", 317 "linktype": "boardgamegraphicdesigner", 318 "self_prefix": "src", 319 "title": "Graphic Designer", 320 "titlepl": "Graphic Designers", 321 "fullcredits": true, 322 "keyname": "boardgamegraphicdesigner" 323 }, 324 { 325 "datatype": "geekitem_linkdata", 326 "other_objecttype": "person", 327 "other_subtype": "boardgamesculptor", 328 "linktype": "boardgamesculptor", 329 "self_prefix": "src", 330 "title": "Sculptor", 331 "titlepl": "Sculptors", 332 "fullcredits": true, 333 "keyname": "boardgamesculptor" 334 }, 335 { 336 "datatype": "geekitem_linkdata", 337 "other_objecttype": "person", 338 "other_subtype": "boardgameeditor", 339 "linktype": "boardgameeditor", 340 "self_prefix": "src", 341 "title": "Editor", 342 "titlepl": "Editors", 343 "fullcredits": true, 344 "keyname": "boardgameeditor" 345 }, 346 { 347 "datatype": "geekitem_linkdata", 348 "other_objecttype": "person", 349 "other_subtype": "boardgamewriter", 350 "linktype": "boardgamewriter", 351 "self_prefix": "src", 352 "title": "Writer", 353 "titlepl": "Writers", 354 "fullcredits": true, 355 "keyname": "boardgamewriter" 356 }, 357 { 358 "datatype": "geekitem_linkdata", 359 "other_objecttype": "person", 360 "other_subtype": "boardgameinsertdesigner", 361 "linktype": "boardgameinsertdesigner", 362 "self_prefix": "src", 363 "title": "Insert Designer", 364 "titlepl": "Insert Designers", 365 "fullcredits": true, 366 "keyname": "boardgameinsertdesigner" 367 }, 368 { 369 "datatype": "geekitem_linkdata", 370 "other_objecttype": "family", 371 "other_subtype": "boardgamehonor", 372 "lookup_subtype": "boardgamehonor", 373 "linktype": "boardgamehonor", 374 "self_prefix": "src", 375 "title": "Honors", 376 "keyname": "boardgamehonor" 377 }, 378 { 379 "datatype": "geekitem_linkdata", 380 "other_objecttype": "property", 381 "other_subtype": "boardgamecategory", 382 "lookup_subtype": "boardgamecategory", 383 "linktype": "boardgamecategory", 384 "self_prefix": "src", 385 "title": "Category", 386 "titlepl": "Categories", 387 "showall_ctrl": true, 388 "fullcredits": true, 389 "overview_count": 5, 390 "keyname": "boardgamecategory" 391 }, 392 { 393 "datatype": "geekitem_linkdata", 394 "other_objecttype": "property", 395 "other_subtype": "boardgamemechanic", 396 "lookup_subtype": "boardgamemechanic", 397 "linktype": "boardgamemechanic", 398 "self_prefix": "src", 399 "title": "Mechanism", 400 "titlepl": "Mechanisms", 401 "showall_ctrl": true, 402 "fullcredits": true, 403 "overview_count": 5, 404 "keyname": "boardgamemechanic" 405 }, 406 { 407 "datatype": "geekitem_linkdata", 408 "lookup_subtype": "boardgame", 409 "other_objecttype": "thing", 410 "other_subtype": "boardgameexpansion", 411 "linktype": "boardgameexpansion", 412 "self_prefix": "src", 413 "title": "Expansion", 414 "keyname": "boardgameexpansion" 415 }, 416 { 417 "datatype": "geekitem_linkdata", 418 "other_objecttype": "version", 419 "other_subtype": "boardgameversion", 420 "linktype": "boardgameversion", 421 "other_is_dependent": true, 422 "required": true, 423 "loadlinks": true, 424 "self_prefix": "src", 425 "title": "Version", 426 "createtitle": "Versions", 427 "hidecontrols": true, 428 "keyname": "boardgameversion" 429 }, 430 { 431 "datatype": "geekitem_linkdata", 432 "other_objecttype": "thing", 433 "other_subtype": "boardgame", 434 "lookup_subtype": "boardgame", 435 "linktype": "boardgameexpansion", 436 "self_prefix": "dst", 437 "title": "Expands", 438 "overview_count": 100, 439 "keyname": "expandsboardgame" 440 }, 441 { 442 "datatype": "geekitem_linkdata", 443 "other_objecttype": "thing", 444 "other_subtype": "boardgame", 445 "lookup_subtype": "boardgame", 446 "linktype": "boardgameintegration", 447 "correctioncomment": "Only for stand-alone games that integrate with other stand-alone games. \u003Cb\u003ENOT\u003C/b\u003E for expansions.", 448 "self_prefix": "src", 449 "title": "Integrates With", 450 "overview_count": 4, 451 "keyname": "boardgameintegration" 452 }, 453 { 454 "datatype": "geekitem_linkdata", 455 "other_objecttype": "thing", 456 "other_subtype": "boardgame", 457 "lookup_subtype": "boardgame", 458 "linktype": "boardgamecompilation", 459 "self_prefix": "dst", 460 "correctioncomment": "Items contained in this item (if this is a compilation, for example)", 461 "title": "Contains", 462 "overview_count": 4, 463 "keyname": "contains" 464 }, 465 { 466 "datatype": "geekitem_linkdata", 467 "lookup_subtype": "boardgame", 468 "other_objecttype": "thing", 469 "other_subtype": "boardgame", 470 "linktype": "boardgamecompilation", 471 "self_prefix": "src", 472 "title": "Contained in", 473 "keyname": "containedin" 474 }, 475 { 476 "datatype": "geekitem_linkdata", 477 "lookup_subtype": "boardgame", 478 "other_objecttype": "thing", 479 "other_subtype": "boardgame", 480 "linktype": "boardgameimplementation", 481 "self_prefix": "src", 482 "correctioncomment": "Add the \"child\" item(s) that reimplement this game", 483 "title": "Reimplemented By", 484 "overview_count": 4, 485 "keyname": "reimplementation" 486 }, 487 { 488 "datatype": "geekitem_linkdata", 489 "other_objecttype": "thing", 490 "other_subtype": "boardgame", 491 "lookup_subtype": "boardgame", 492 "linktype": "boardgameimplementation", 493 "self_prefix": "dst", 494 "correctioncomment": "Add the \"parent\" item(s) for this game, if it reimplements a previous game", 495 "title": "Reimplements", 496 "overview_count": 4, 497 "keyname": "reimplements" 498 }, 499 { 500 "datatype": "geekitem_linkdata", 501 "other_objecttype": "family", 502 "other_subtype": "boardgamefamily", 503 "lookup_subtype": "boardgamefamily", 504 "linktype": "boardgamefamily", 505 "self_prefix": "src", 506 "fullcredits": true, 507 "title": "Family", 508 "overview_count": 10, 509 "keyname": "boardgamefamily" 510 }, 511 { 512 "datatype": "geekitem_linkdata", 513 "other_objecttype": "thing", 514 "other_subtype": "videogamebg", 515 "lookup_subtype": "videogame", 516 "linktype": "videogamebg", 517 "self_prefix": "src", 518 "adminonly": true, 519 "title": "Video Game Adaptation", 520 "titlepl": "Video Game Adaptations", 521 "keyname": "videogamebg" 522 }, 523 { 524 "datatype": "geekitem_linkdata", 525 "other_objecttype": "family", 526 "other_subtype": "boardgamesubdomain", 527 "lookup_subtype": "boardgamesubdomain", 528 "linktype": "boardgamesubdomain", 529 "polltype": "boardgamesubdomain", 530 "display_inline": true, 531 "self_prefix": "src", 532 "title": "Type", 533 "showall_ctrl": true, 534 "hidecontrols": true, 535 "createposttext": "Enter the subdomain for this item.", 536 "keyname": "boardgamesubdomain" 537 }, 538 { 539 "datatype": "geekitem_linkdata", 540 "other_objecttype": "thing", 541 "other_subtype": "boardgameaccessory", 542 "lookup_subtype": "boardgameaccessory", 543 "linktype": "boardgameaccessory", 544 "self_prefix": "src", 545 "title": "Accessory", 546 "titlepl": "Accessories", 547 "addnew": true, 548 "keyname": "boardgameaccessory" 549 }, 550 { 551 "datatype": "geekitem_linkdata", 552 "other_objecttype": "family", 553 "other_subtype": "cardset", 554 "linktype": "cardset", 555 "self_prefix": "src", 556 "title": "Card Set", 557 "hidecontrols": true, 558 "keyname": "cardset" 559 }, 560 { 561 "datatype": "geekitem_polldata", 562 "title": "User Suggested # of Players", 563 "polltype": "numplayers", 564 "keyname": "userplayers" 565 }, 566 { 567 "datatype": "geekitem_polldata", 568 "title": "User Suggested Ages", 569 "polltype": "playerage", 570 "keyname": "playerage" 571 }, 572 { 573 "datatype": "geekitem_polldata", 574 "title": "Language Dependence", 575 "polltype": "languagedependence", 576 "keyname": "languagedependence" 577 }, 578 { 579 "datatype": "geekitem_polldata", 580 "title": "Subdomain", 581 "polltype": "boardgamesubdomain", 582 "keyname": "subdomain" 583 }, 584 { 585 "datatype": "geekitem_polldata", 586 "title": "Weight", 587 "polltype": "boardgameweight", 588 "keyname": "boardgameweight" 589 } 590 ], 591 "linkdata": [], 592 "config": { 593 "numitems": 100, 594 "sortdata": [ 595 { 596 "title": "Name", 597 "hidden": true, 598 "key": "name" 599 }, 600 { 601 "title": "Rank", 602 "onzero": "N/A", 603 "key": "rank" 604 }, 605 { 606 "title": "Num Ratings", 607 "href": "/collection/items/{$other_subtype}/{$item.objectid}?rated=1", 608 "key": "usersrated" 609 }, 610 { 611 "title": "Average Rating", 612 "string_format": "%0.2f", 613 "key": "average" 614 }, 615 { 616 "title": "Average Weight", 617 "string_format": "%0.2f", 618 "key": "avgweight" 619 }, 620 { 621 "title": "Num Owned", 622 "href": "/collection/items/{$other_subtype}/{$item.objectid}?own=1", 623 "key": "numowned" 624 }, 625 { 626 "title": "Prev. Owned", 627 "href": "/collection/items/{$other_subtype}/{$item.objectid}?prevown=1", 628 "key": "numprevowned" 629 }, 630 { 631 "title": "For Trade", 632 "href": "/collection/items/{$other_subtype}/{$item.objectid}?fortrade=1", 633 "key": "numtrading" 634 }, 635 { 636 "title": "Want in Trade", 637 "href": "/collection/items/{$other_subtype}/{$item.objectid}?want=1", 638 "key": "numwanting" 639 }, 640 { 641 "title": "Wishlist", 642 "href": "/collection/items/{$other_subtype}/{$item.objectid}?wishlist=1", 643 "key": "numwish" 644 }, 645 { 646 "title": "Comments", 647 "href": "/collection/items/{$other_subtype}/{$item.objectid}?comment=1", 648 "key": "numcomments" 649 }, 650 { 651 "title": "Year Released", 652 "key": "yearpublished" 653 } 654 ], 655 "filters": [ 656 { 657 "key": "categoryfilter", 658 "options": [ 659 { 660 "objectid": "1009", 661 "name": "Abstract Strategy" 662 }, 663 { 664 "objectid": "1032", 665 "name": "Action / Dexterity" 666 }, 667 { 668 "objectid": "1022", 669 "name": "Adventure" 670 }, 671 { 672 "objectid": "2726", 673 "name": "Age of Reason" 674 }, 675 { 676 "objectid": "1055", 677 "name": "American West" 678 }, 679 { 680 "objectid": "1050", 681 "name": "Ancient" 682 }, 683 { 684 "objectid": "1089", 685 "name": "Animals" 686 }, 687 { 688 "objectid": "2650", 689 "name": "Aviation / Flight" 690 }, 691 { 692 "objectid": "1023", 693 "name": "Bluffing" 694 }, 695 { 696 "objectid": "1002", 697 "name": "Card Game" 698 }, 699 { 700 "objectid": "1029", 701 "name": "City Building" 702 }, 703 { 704 "objectid": "1015", 705 "name": "Civilization" 706 }, 707 { 708 "objectid": "1044", 709 "name": "Collectible Components" 710 }, 711 { 712 "objectid": "1116", 713 "name": "Comic Book / Strip" 714 }, 715 { 716 "objectid": "1039", 717 "name": "Deduction" 718 }, 719 { 720 "objectid": "1017", 721 "name": "Dice" 722 }, 723 { 724 "objectid": "1021", 725 "name": "Economic" 726 }, 727 { 728 "objectid": "1094", 729 "name": "Educational" 730 }, 731 { 732 "objectid": "1084", 733 "name": "Environmental" 734 }, 735 { 736 "objectid": "1042", 737 "name": "Expansion for Base-game" 738 }, 739 { 740 "objectid": "1020", 741 "name": "Exploration" 742 }, 743 { 744 "objectid": "1010", 745 "name": "Fantasy" 746 }, 747 { 748 "objectid": "1013", 749 "name": "Farming" 750 }, 751 { 752 "objectid": "1046", 753 "name": "Fighting" 754 }, 755 { 756 "objectid": "1024", 757 "name": "Horror" 758 }, 759 { 760 "objectid": "1079", 761 "name": "Humor" 762 }, 763 { 764 "objectid": "1088", 765 "name": "Industry / Manufacturing" 766 }, 767 { 768 "objectid": "1035", 769 "name": "Medieval" 770 }, 771 { 772 "objectid": "1047", 773 "name": "Miniatures" 774 }, 775 { 776 "objectid": "1064", 777 "name": "Movies / TV / Radio theme" 778 }, 779 { 780 "objectid": "1082", 781 "name": "Mythology" 782 }, 783 { 784 "objectid": "1008", 785 "name": "Nautical" 786 }, 787 { 788 "objectid": "1026", 789 "name": "Negotiation" 790 }, 791 { 792 "objectid": "1093", 793 "name": "Novel-based" 794 }, 795 { 796 "objectid": "1098", 797 "name": "Number" 798 }, 799 { 800 "objectid": "1030", 801 "name": "Party Game" 802 }, 803 { 804 "objectid": "1090", 805 "name": "Pirates" 806 }, 807 { 808 "objectid": "2710", 809 "name": "Post-Napoleonic" 810 }, 811 { 812 "objectid": "1036", 813 "name": "Prehistoric" 814 }, 815 { 816 "objectid": "1120", 817 "name": "Print & Play" 818 }, 819 { 820 "objectid": "1028", 821 "name": "Puzzle" 822 }, 823 { 824 "objectid": "1031", 825 "name": "Racing" 826 }, 827 { 828 "objectid": "1037", 829 "name": "Real-time" 830 }, 831 { 832 "objectid": "1115", 833 "name": "Religious" 834 }, 835 { 836 "objectid": "1016", 837 "name": "Science Fiction" 838 }, 839 { 840 "objectid": "1113", 841 "name": "Space Exploration" 842 }, 843 { 844 "objectid": "1081", 845 "name": "Spies/Secret Agents" 846 }, 847 { 848 "objectid": "1086", 849 "name": "Territory Building" 850 }, 851 { 852 "objectid": "1034", 853 "name": "Trains" 854 }, 855 { 856 "objectid": "1011", 857 "name": "Transportation" 858 }, 859 { 860 "objectid": "1097", 861 "name": "Travel" 862 }, 863 { 864 "objectid": "1101", 865 "name": "Video Game Theme" 866 }, 867 { 868 "objectid": "1019", 869 "name": "Wargame" 870 }, 871 { 872 "objectid": "2481", 873 "name": "Zombies" 874 } 875 ], 876 "title": "Category" 877 }, 878 { 879 "key": "mechanicfilter", 880 "options": [ 881 { 882 "objectid": "2073", 883 "name": "Acting" 884 }, 885 { 886 "objectid": "2838", 887 "name": "Action Drafting" 888 }, 889 { 890 "objectid": "2001", 891 "name": "Action Points" 892 }, 893 { 894 "objectid": "2689", 895 "name": "Action Queue" 896 }, 897 { 898 "objectid": "2847", 899 "name": "Advantage Token" 900 }, 901 { 902 "objectid": "2916", 903 "name": "Alliances" 904 }, 905 { 906 "objectid": "2080", 907 "name": "Area Majority / Influence" 908 }, 909 { 910 "objectid": "2046", 911 "name": "Area Movement" 912 }, 913 { 914 "objectid": "2920", 915 "name": "Auction: Sealed Bid" 916 }, 917 { 918 "objectid": "2012", 919 "name": "Auction/Bidding" 920 }, 921 { 922 "objectid": "2903", 923 "name": "Automatic Resource Growth" 924 }, 925 { 926 "objectid": "2014", 927 "name": "Betting and Bluffing" 928 }, 929 { 930 "objectid": "2999", 931 "name": "Bingo" 932 }, 933 { 934 "objectid": "2913", 935 "name": "Bribery" 936 }, 937 { 938 "objectid": "2018", 939 "name": "Campaign / Battle Card Driven" 940 }, 941 { 942 "objectid": "2857", 943 "name": "Card Play Conflict Resolution" 944 }, 945 { 946 "objectid": "2887", 947 "name": "Catch the Leader" 948 }, 949 { 950 "objectid": "2956", 951 "name": "Chaining" 952 }, 953 { 954 "objectid": "2984", 955 "name": "Closed Drafting" 956 }, 957 { 958 "objectid": "2013", 959 "name": "Commodity Speculation" 960 }, 961 { 962 "objectid": "2912", 963 "name": "Contracts" 964 }, 965 { 966 "objectid": "2023", 967 "name": "Cooperative Game" 968 }, 969 { 970 "objectid": "2664", 971 "name": "Deck, Bag, and Pool Building" 972 }, 973 { 974 "objectid": "2072", 975 "name": "Dice Rolling" 976 }, 977 { 978 "objectid": "2856", 979 "name": "Die Icon Resolution" 980 }, 981 { 982 "objectid": "3096", 983 "name": "Drawing" 984 }, 985 { 986 "objectid": "2882", 987 "name": "Elapsed Real Time Ending" 988 }, 989 { 990 "objectid": "2043", 991 "name": "Enclosure" 992 }, 993 { 994 "objectid": "2875", 995 "name": "End Game Bonuses" 996 }, 997 { 998 "objectid": "2850", 999 "name": "Events" 1000 }, 1001 { 1002 "objectid": "2885", 1003 "name": "Finale Ending" 1004 }, 1005 { 1006 "objectid": "2978", 1007 "name": "Grid Coverage" 1008 }, 1009 { 1010 "objectid": "2676", 1011 "name": "Grid Movement" 1012 }, 1013 { 1014 "objectid": "2040", 1015 "name": "Hand Management" 1016 }, 1017 { 1018 "objectid": "2026", 1019 "name": "Hexagon Grid" 1020 }, 1021 { 1022 "objectid": "2891", 1023 "name": "Hidden Roles" 1024 }, 1025 { 1026 "objectid": "2987", 1027 "name": "Hidden Victory Points" 1028 }, 1029 { 1030 "objectid": "2906", 1031 "name": "I Cut, You Choose" 1032 }, 1033 { 1034 "objectid": "2902", 1035 "name": "Income" 1036 }, 1037 { 1038 "objectid": "2914", 1039 "name": "Increase Value of Unchosen Resources" 1040 }, 1041 { 1042 "objectid": "2837", 1043 "name": "Interrupts" 1044 }, 1045 { 1046 "objectid": "3001", 1047 "name": "Layering" 1048 }, 1049 { 1050 "objectid": "2975", 1051 "name": "Line of Sight" 1052 }, 1053 { 1054 "objectid": "2904", 1055 "name": "Loans" 1056 }, 1057 { 1058 "objectid": "2959", 1059 "name": "Map Addition" 1060 }, 1061 { 1062 "objectid": "2900", 1063 "name": "Market" 1064 }, 1065 { 1066 "objectid": "2047", 1067 "name": "Memory" 1068 }, 1069 { 1070 "objectid": "2011", 1071 "name": "Modular Board" 1072 }, 1073 { 1074 "objectid": "2962", 1075 "name": "Move Through Deck" 1076 }, 1077 { 1078 "objectid": "3099", 1079 "name": "Multi-Use Cards" 1080 }, 1081 { 1082 "objectid": "2851", 1083 "name": "Narrative Choice / Paragraph" 1084 }, 1085 { 1086 "objectid": "2915", 1087 "name": "Negotiation" 1088 }, 1089 { 1090 "objectid": "2081", 1091 "name": "Network and Route Building" 1092 }, 1093 { 1094 "objectid": "2846", 1095 "name": "Once-Per-Game Abilities" 1096 }, 1097 { 1098 "objectid": "2041", 1099 "name": "Open Drafting" 1100 }, 1101 { 1102 "objectid": "2055", 1103 "name": "Paper-and-Pencil" 1104 }, 1105 { 1106 "objectid": "2048", 1107 "name": "Pattern Building" 1108 }, 1109 { 1110 "objectid": "2685", 1111 "name": "Player Elimination" 1112 }, 1113 { 1114 "objectid": "2078", 1115 "name": "Point to Point Movement" 1116 }, 1117 { 1118 "objectid": "2661", 1119 "name": "Push Your Luck" 1120 }, 1121 { 1122 "objectid": "2876", 1123 "name": "Race" 1124 }, 1125 { 1126 "objectid": "2870", 1127 "name": "Re-rolling and Locking" 1128 }, 1129 { 1130 "objectid": "2831", 1131 "name": "Real-Time" 1132 }, 1133 { 1134 "objectid": "3103", 1135 "name": "Resource Queue" 1136 }, 1137 { 1138 "objectid": "2028", 1139 "name": "Role Playing" 1140 }, 1141 { 1142 "objectid": "2035", 1143 "name": "Roll / Spin and Move" 1144 }, 1145 { 1146 "objectid": "2813", 1147 "name": "Rondel" 1148 }, 1149 { 1150 "objectid": "2822", 1151 "name": "Scenario / Mission / Campaign Game" 1152 }, 1153 { 1154 "objectid": "2016", 1155 "name": "Secret Unit Deployment" 1156 }, 1157 { 1158 "objectid": "2820", 1159 "name": "Semi-Cooperative Game" 1160 }, 1161 { 1162 "objectid": "2004", 1163 "name": "Set Collection" 1164 }, 1165 { 1166 "objectid": "2070", 1167 "name": "Simulation" 1168 }, 1169 { 1170 "objectid": "2020", 1171 "name": "Simultaneous Action Selection" 1172 }, 1173 { 1174 "objectid": "3005", 1175 "name": "Slide/Push" 1176 }, 1177 { 1178 "objectid": "2819", 1179 "name": "Solo / Solitaire Game" 1180 }, 1181 { 1182 "objectid": "2940", 1183 "name": "Square Grid" 1184 }, 1185 { 1186 "objectid": "2853", 1187 "name": "Stat Check Resolution" 1188 }, 1189 { 1190 "objectid": "2861", 1191 "name": "Static Capture" 1192 }, 1193 { 1194 "objectid": "2027", 1195 "name": "Storytelling" 1196 }, 1197 { 1198 "objectid": "3100", 1199 "name": "Tags" 1200 }, 1201 { 1202 "objectid": "2686", 1203 "name": "Take That" 1204 }, 1205 { 1206 "objectid": "2019", 1207 "name": "Team-Based Game" 1208 }, 1209 { 1210 "objectid": "2849", 1211 "name": "Tech Trees / Tech Tracks" 1212 }, 1213 { 1214 "objectid": "2002", 1215 "name": "Tile Placement" 1216 }, 1217 { 1218 "objectid": "2939", 1219 "name": "Track Movement" 1220 }, 1221 { 1222 "objectid": "2008", 1223 "name": "Trading" 1224 }, 1225 { 1226 "objectid": "2814", 1227 "name": "Traitor Game" 1228 }, 1229 { 1230 "objectid": "2888", 1231 "name": "Tug of War" 1232 }, 1233 { 1234 "objectid": "2829", 1235 "name": "Turn Order: Claim Action" 1236 }, 1237 { 1238 "objectid": "2828", 1239 "name": "Turn Order: Progressive" 1240 }, 1241 { 1242 "objectid": "2826", 1243 "name": "Turn Order: Stat-Based" 1244 }, 1245 { 1246 "objectid": "2663", 1247 "name": "Turn Order: Time Track" 1248 }, 1249 { 1250 "objectid": "2015", 1251 "name": "Variable Player Powers" 1252 }, 1253 { 1254 "objectid": "2897", 1255 "name": "Variable Set-up" 1256 }, 1257 { 1258 "objectid": "2874", 1259 "name": "Victory Points as a Resource" 1260 }, 1261 { 1262 "objectid": "2017", 1263 "name": "Voting" 1264 }, 1265 { 1266 "objectid": "2082", 1267 "name": "Worker Placement" 1268 }, 1269 { 1270 "objectid": "2933", 1271 "name": "Worker Placement, Different Worker Types" 1272 }, 1273 { 1274 "objectid": "2974", 1275 "name": "Zone of Control" 1276 } 1277 ], 1278 "title": "Mechanic" 1279 } 1280 ] 1281 } 1282}

After, I added the -s flag for CURL to prevent getting status logs and just get the list of board games. I found that the for loop could iterate to an arbitrarily large value, like 50, and that all the requests would be empty afterwards anyway.

1for (( i=0; i<50; i++ )); do 2 # Fetch data using curl and parse it with jq 3 # The -s flag is used to suppress the progress meter and other non-error output 4 result=$(curl -s GET "https://api.geekdo.com/api/geekitem/linkeditems?ajax=1&linkdata_index=boardgame&nosession=1&objectid=8832&objecttype=company&pageid=$i&showcount=25&sort=name&subtype=boardgamepublisher") 5 6 # Use jq to parse JSON and extract the .items[].name field 7 echo "$result" | jq '.items[].name' 8done

I improved the script to automatically terminate instead of using an arbitrary guess for the number of pages by implementing a while loop, and using the -z bash flag to check if null.

1i=0; 2while true; do 3 ((i++)); 4 # Fetch data using curl and parse it with jq 5 # The -s flag is used to suppress the progress meter and other non-error output 6 result=$(curl -s GET "https://api.geekdo.com/api/geekitem/linkeditems?ajax=1&linkdata_index=boardgame&nosession=1&objectid=8832&objecttype=company&pageid=$i&showcount=25&sort=name&subtype=boardgamepublisher"); 7 8 # Use jq to parse JSON and extract the .items[].name field 9 parsed=$(echo "$result" | jq '.items[].name'); 10 if [ -z "$parsed" ]; then 11 break 12 fi 13 echo "$parsed"; 14done
1"Agricola" 2"Agricola (Revised Edition)" 3"Agricola: All Creatures Big and Small – The Big Box" 4"Agricola: Artifex Deck" 5"Agricola: Corbarius Deck" 6"Agricola: Family Edition" 7"Airship City" 8"Bärenpark" 9"Bärenpark: The Bad News Bears" 10"The Big Idea" 11"The Big Idea: La Science-Fiction Médiévale" 12"The Binding of Isaac: Four Souls" 13"The Binding of Isaac: Four Souls – Tapeworm Promo Cards" 14"The Binding of Isaac: Four Souls – Ultimate Collector's Edition" 15"The Binding of Isaac: Four Souls +" 16"The Binding of Isaac: Four Souls Requiem" 17"Bloodborne: The Board Game" 18"Brass: Birmingham" 19"Brass: Lancashire" 20"Caverna: Cave vs Cave" 21"Caverna: The Cave Farmers" 22"DONUTS" 23"Evolution" 24"Evolution: Climate" 25"Evolution: Promo Pack IV" 26"Agricola" 27"Agricola (Revised Edition)" 28"Agricola: All Creatures Big and Small – The Big Box" 29"Agricola: Artifex Deck" 30"Agricola: Corbarius Deck" 31"Agricola: Family Edition" 32"Airship City" 33"Bärenpark" 34"Bärenpark: The Bad News Bears" 35"The Big Idea" 36"The Big Idea: La Science-Fiction Médiévale" 37"The Binding of Isaac: Four Souls" 38"The Binding of Isaac: Four Souls – Tapeworm Promo Cards" 39"The Binding of Isaac: Four Souls – Ultimate Collector's Edition" 40"The Binding of Isaac: Four Souls +" 41"The Binding of Isaac: Four Souls Requiem" 42"Bloodborne: The Board Game" 43"Brass: Birmingham" 44"Brass: Lancashire" 45"Caverna: Cave vs Cave" 46"Caverna: The Cave Farmers" 47"DONUTS" 48"Evolution" 49"Evolution: Climate" 50"Evolution: Promo Pack IV" 51"Expedition to Newdale" 52"Fairy Trails" 53"Far Cry: Beyond" 54"Fight for Olympus" 55"Foothills" 56"Gingerbread House" 57"Glasgow" 58"Grand Austria Hotel" 59"Great Plains" 60"Hallertau" 61"HOP!" 62"Illusio" 63"Isla Dorada" 64"Isle of Skye: Druids" 65"Isle of Skye: From Chieftain to King" 66"Isle of Skye: Journeyman" 67"Llamaland" 68"Mandala" 69"Monumental" 70"Monumental Duel: Espionage" 71"Monumental Duel: Exploration" 72"Monumental Duel: Trade" 73"Monumental: African Empires" 74"Monumental: Lost Kingdoms" 75"Namiji" 76"Namiji: Aquamarine" 77"Namiji: Deluxe Edition" 78"Nemesis" 79"Nemesis: Lockdown" 80"Nemesis: Lockdown – Stretch Goals" 81"Nemesis: Untold Stories #1" 82"Nemesis: Untold Stories #2" 83"Night of the Living Dead: A Zombicide Game" 84"Oceans" 85"Patchwork" 86"Patchwork Doodle" 87"Patchwork Express" 88"The Phantom Society" 89"Piepmatz" 90"Pocket Madness" 91"Pony Express" 92"Port Royal: Big Box" 93"Professor Evil and The Citadel of Time" 94"Professor Evil and the Citadel of Time: Professor Evil and the Architects of Magic" 95"Project: ELITE" 96"Quantum" 97"Quantum: Entanglement Add-on Pack" 98"Quantum: The Void" 99"Robin of Locksley" 100"Runemasters" 101"Sagani" 102"Samurai Spirit" 103"Samurai Spirit: The 8th Boss" 104"Sheriff of Nottingham: 2nd Edition" 105"Spy Connection" 106"Tapas" 107"Tindaya" 108"Titan Race" 109"Tokaido" 110"Tokaido Collector's Edition" 111"Tokaido Duo" 112"Tokaido: Crossroads" 113"Tokaido: Deluxe Edition" 114"Tokaido: Eriku" 115"Tokaido: Felicia Promo Card" 116"Tokaido: Matsuri" 117"Tokaido: The New Encounters" 118"Trambahn" 119"Viceroy" 120"Warehouse 51" 121"Warehouse 51: Promo Cards" 122"Who Would Win" 123"WolfWalkers: My Story" 124"ZNA" 125"Zona: The Secret of Chernobyl"

Other Random Linux Scripts/Things

Towards the latter half of the semester I switched from Windows to PopOs (an Ubuntu fork), and have been loving it. Here's a list of random Linux-y things I've figured out and done...

Desktop Entries

Desktop entry syntax
Desktop entry syntax

I learned a bit about how to set up desktop entries on popos with gnome desktop. These are items displayed on the desktop of the computer, that have customized functionality specified in a specially formatted desktop file. Archwiki explains,

Desktop entries for applications, or .desktop files, are generally a combination of meta information resources and a shortcut of an application. These files usually reside in /usr/share/applications/ or /usr/local/share/applications/ for applications installed system-wide, or ~/.local/share/applications/ for user-specific applications. User entries take precedence over system entries.

1[Desktop Entry] 2Type=Application 3Terminal=false 4Name=Chrome - Primary 5Exec=/usr/bin/google-chrome-stable --profile-directory="Default" 6Icon=google-chrome

My desktop entries
My desktop entries

This then makes icons on your desktop that can do specific actions. I added some for chrome so that it launches chrome with a specific chrome profile (for example, a chrome profile on my desktop that launches me into my CWRU google account when it boots). It's super useful. Later on I added some for other accounts too.

Getting Fingerprint Sensor working

Getting fingerprint detection to work on my laptop with Linux was a bit annoying. I was able to make it work with a lot of trial and error and some help from ChatGPT. After I got it working, I wanted to be able to use my fingerprint for sudo instead of typing in my password every single time. To get this working, I followed the following steps...

ChatGPT instructions that seemed to work

11) install fprintd: use the following command to install fprintd. sudo apt-get install fprintd 22) enroll your fingerprint: enroll your right index finger with the following command. `fprintd-enroll -f right-index-finger` 33) install libpam-fprintd: install this pam module to enable fingerprint authentication. `sudo apt-get install libpam-fprintd` 4who uses 4 configure pam: open /etc/pam.d/common-auth in a text editor with root privileges. You can use nano editor with the following command. sudo nano /etc/pam.d/common-auth 55) add the following line at the top of the file. `auth [success=2 default=ignore] pam_fprintd.so` 6 7> The auth [success=2 default=ignore] pam_fprintd.so line in the PAM (Pluggable Authentication Modules) 8> configuration file is used for fingerprint authentication. If the fingerprint authentication is successful, 9> it will skip the next two lines specified in the configuration. If fingerprint authentication fails, it 10> will continue with the default behavior specified in the configuration. 11 126) save and close the file. if you are using nano, you can do this by pressing ctrl + x, then y, then enter. 137) test: try using sudo command. it should now ask for your fingerprint.

The place where fingerprints get stored
The place where fingerprints get stored

After a bit of additional research:

  • fprintd is a library for storing fingerprints of a user in a local database. It stores them (by default) in /var/lib/fprint/. I was curious, so I searched and found the actual fingerpint file, which was a binary file,, under /var/lib/fprint/wolf/goodixmoc/UIDFAE70212_XXXX_MOC_B0/7. You can use commands like fprintd-enroll and fprintd-verify to manage fingerprints.
  • PAM seems to be some sort of tool that acts as an intermediary between the actual authentication methods and the program trying to do authentication. So like, you can use fingerprint auth, password auth, or something else, and just change the PAM config.

Getting Hibernation working

I followed this guide to set up a swap partition and get hibernation to work. It was a bit annoying to do but I got it working where when my computer hibernates it moves all the ram into a specific disc swap partition and then powers off, and can restore from it.

Automatic mount Google Drive

I wrote a very simple cronjob to call a python script I wrote to automatically mount my Google Drives as hard drives using rclone, which is a CLI tool for mounting cloud storage drives with an interface similar to rsync.

Then, later, after writing the script I added an automatic call to my crontab.

@reboot nohup /usr/bin/python3 /home/wolf/Scripts/rclone.py

1import subprocess 2from time import sleep 3import logging 4from os import listdir, makedirs as mkdirs 5 6REMOTES = ("Primary", "School",) 7BINARY = "/usr/bin/rclone" 8LOGS = "/home/wolf/Scripts/logs" 9 10 11open(LOGS + "/rclone.log", "a", encoding="utf-8").close() 12logger = logging.getLogger(__name__) 13logging.basicConfig( 14 level=logging.INFO, 15 filename=LOGS + "/rclone.log", 16 format='%(asctime)s %(levelname)s - %(message)s' 17) 18 19def mount(binary, remote, directory): 20 subprocess.Popen([binary, "mount", f"{remote}:", directory]) 21 logging.info(f"Mounted {remote} to {directory}") 22 23def isMounted(directory): 24 try: 25 if listdir(directory): 26 logger.debug("Remote %s is already mounted", remote) 27 return True 28 else: 29 logger.debug("Remote %s is not mounted.", remote) 30 return False 31 except FileNotFoundError: 32 logger.warning("Creating directory for remote %s since it did not exist", remote) 33 mkdirs(directory) 34 return False 35 36while True: 37 for remote in REMOTES: 38 directory = f"~/GDrive/{remote}" 39 40 if not isMounted(directory): 41 mount(BINARY, remote, directory) 42 43 sleep(2)

Random Vim Things

NVim telescope search
NVim telescope search
NVim With File Tree
NVim With File Tree

Since the start of this course I've begun to use vimkeys for everything. I touch type very fast (120+) so I thought it'd be worth the effort to learn keybinds. Here's some fun things I've figured out:

  • I configured neovim using vim-plug to have language engine support so that I can get error checking. I also have symbol tab support and set up github Copilot.
  • zz centers your cursor on the page. If the majority of the page is above the cursor this can help center things so you can see more on either side of the cursor.
  • vi" selects everything inside quotes. Then you can do "+p to paste from clipboard into them
  • Following two keybinds work for any letter (not just "A"):
    • mA sets a global marker A which you can go to with `A.
    • qa begins recording a macro a. Then q again to stop recording. Then @a to play it back. Or <num>@a!
    • == automatically aligns the current line
    • V selects the current line. So you can also do Vd instead of dd to delete a line.
    • T (not t) goes up to the first occurrence backwards. Then pressing ; to progress forward through matches also goes backwards.
1-- My NVIM config (scrapped together from various internet sources with some personalizations too) 2 3local vim = vim 4local Plug = vim.fn['plug#'] 5 6vim.opt.relativenumber = true 7vim.opt.tabstop = 2 8vim.opt.shiftwidth = 2 9 10vim.g.loaded_netrw = 1 11vim.g.loaded_netrwPlugin = 1 12 13vim.api.nvim_set_keymap('i', '<C-j>', '<C-n>', {noremap = true, silent = true}) 14vim.api.nvim_set_keymap('i', '<C-k>', '<C-p>', {noremap = true, silent = true}) 15 16vim.api.nvim_set_keymap('i', '<S-Tab>', 'coc#_select_confirm()', {expr = true, silent = true}) 17 18vim.call('plug#begin') 19 20-- Add basic plugins 21Plug('junegunn/vim-easy-align') 22Plug('neoclide/coc.nvim') 23Plug('nvim-tree/nvim-tree.lua') 24Plug('nvim-tree/nvim-web-devicons') 25 26-- Setup bar for tabs 27Plug('lewis6991/gitsigns.nvim') 28Plug('nvim-tree/nvim-web-devicons') 29Plug('romgrk/barbar.nvim') 30 31-- Add search plugin 32Plug('nvim-lua/plenary.nvim') 33Plug('nvim-telescope/telescope.nvim') 34 35vim.call('plug#end') 36 37-- Setup telescope 38local builtin = require('telescope.builtin') 39vim.keymap.set('n', '<leader>ff', builtin.find_files, {}) 40vim.keymap.set('n', '<leader>fg', builtin.live_grep, {}) 41vim.keymap.set('n', '<leader>fb', builtin.buffers, {}) 42vim.keymap.set('n', '<leader>fh', builtin.help_tags, {}) 43 44-- Finish setting up nvim tree 45require("nvim-tree").setup({ 46 sort = { 47 sorter = "case_sensitive", 48 }, 49 view = { 50 width = 30, 51 }, 52 renderer = { 53 group_empty = true, 54 }, 55 filters = { 56 dotfiles = true, 57 }, 58})

I also configured my vscode vimkeys settings. I made it so that control + u, which normally scrolls up half a page, automatically then aligns the page such that the cursor is centered. Also, I added cords for leader, h and leader l to switch between split screen tabs. Finally, one interesting and slightly solution to a problem I was facing: I remapped o to ["I", "Enter"], which puts the cursor in insert mode at the end of the current line, and presses enter. This does the same thing as o, but in order to trigger Github copilot you need to press enter, and this seems to get it to trigger (whereas just pressing o to open a line does not).

1[ 2 { 3 "before": [ 4 "<C-u>" 5 ], 6 "after": [ 7 "<C-u>", 8 "z", 9 "z" 10 ] 11 }, 12 { 13 "before": [ 14 "<C-d>" 15 ], 16 "after": [ 17 "<C-d>", 18 "z", 19 "z" 20 ] 21 }, 22 { 23 "before": [ 24 "<leader>", 25 "h" 26 ], 27 "commands": [ 28 "workbench.action.focusLeftGroup" 29 ] 30 }, 31 { 32 "before": [ 33 "<leader>", 34 "l" 35 ], 36 "commands": [ 37 "workbench.action.focusRightGroup" 38 ] 39 }, 40 { 41 "before": [ 42 "<leader>", 43 "s", 44 "p" 45 ], 46 "commands": [ 47 "editor.action.quickFix" 48 ] 49 }, 50 { 51 "before": [ 52 "<leader>", 53 "s" 54 ], 55 "commands": [ 56 "workbench.view.explorer" 57 ] 58 }, 59 { 60 "before": [ 61 "o" 62 ], 63 "after": [ 64 "A", 65 "Enter" 66 ] 67 }, 68 { 69 "before": [ 70 "O" 71 ], 72 "after": [ 73 "I", 74 "Enter", 75 "Up" 76 ] 77 }, 78 { 79 "before": [ 80 "<leader>", 81 "p" 82 ], 83 "commands": [ 84 "editor.action.formatDocument" 85 ] 86 } 87]

Stuff I added to my .bashrc

This was all iterative and I added more things as the semester went along. The main things of note:

  • Aliases for:
    • suspend and hibernate
    • Editing my bashrc itself and then reloading it
    • Launching chrome with a specific user logged in to Google
  • My Zoxide installation (an alternative for CD that remembers commonly used paths to help you find them)
  • control k or control j
  • Exporting some API keys that I always want access to
1# Basic power aliases 2alias hibernate="systemctl hibernate" 3alias suspend="systemctl suspend" 4 5# Add alias for editing this file 6alias shelledit='vim ~/.bashrc' 7alias shellreload='source ~/.bashrc' 8 9# Add alias for passing clipboard 10alias clip='xclip -selection clipboard' 11 12# Install zoxide CD alternative 13eval "$(zoxide init bash)" 14 15# Improve shell history 16bind "\C-k":history-search-backward 17bind "\C-j":history-search-forward 18 19# Add pip alises 20alias gpt='/home/wolf/.local/bin/gpt --model gpt-4 --log_file ~/Logs/gpt4' 21alias cheapt='/home/wolf/.local/bin/gpt --model gpt-3.5-turbo-0125 --log_file ~/Logs/gpt3' 22alias black='/home/wolf/.local/bin/black' 23 24# Add open with explorer alias 25alias explorer='nautilus .' 26 27# Add chrome open alias 28alias chrome='/usr/bin/google-chrome-stable --profile-directory="Default"' 29alias chrome-school='/usr/bin/google-chrome-stable --profile-directory="Profile 3"' 30 31# Add alias for listing services 32alias services='systemctl list-unit-files --type service -all' 33 34export OPENAI_API_KEY=[REDACTED] 35 36# Add open with explorer alias 37alias explorer='nautilus .' 38 39# Add chrome open alias 40alias chrome='/usr/bin/google-chrome-stable --profile-directory="Default"' 41alias chrome-school='/usr/bin/google-chrome-stable --profile-directory="Profile 3"' 42 43# Add alias for listing services 44alias services='systemctl list-unit-files --type service -all'