Sam Wing https://samwing.dev/posts Software Engineer who learns by making Sun, 22 Mar 2026 04:16:47 GMT https://validator.w3.org/feed/docs/rss2.html https://github.com/jpmonette/feed en Sam Wing https://samwing.dev/wingy.svg https://samwing.dev/posts All rights reserved 2026, Wingy <![CDATA[uv Shebang]]> https://samwing.dev/posts/h98vsl0z0bYp h98vsl0z0bYp Sun, 30 Mar 2025 13:22:18 GMT If you use python and aren't using uv yet, you should absolutely go try it! It's an all-in-one environment manager for Python that handles getting the correct interpreter and the correct packages, and it's distributed as a static binary with no dependency on Python. It makes Python an incredibly enjoyable ecosystem to work with because you can get your environment bootstrapped without worrying about having the right version of Python (or the right version of pip) to get your venv set up correctly.

uv also supports PEP 723 – Inline script metadata. That means you can write comments like this:

# /// script
# requires-python = "==3.13.*"
# dependencies = [
#   "termcolor"
# ]
# ///

from termcolor import cprint

cprint("Green text!", "green")

Then you can run your script with uv run:

You can also add the -q or --quiet flag to suppress the info about it reading the inline metadata and installing the dependencies for you.

You can take this one step further. You can set a shebang to automatically invoke your script with uv, making it a self-contained single-file Python program that automatically has its dependencies set up for it! The GNU coreutils env command allows you to specify the -S flag to split the argument and pass the flags to the command being invoked.

So we can write our shebang line like this:

#!/usr/bin/env -S uv run --script --quiet

When we execute our script, it does everything automatically:

There is one caveat to this approach: BusyBox. On most Linux systems you have GNU coreutils, and on macOS you have some other flavor of coreutils that supports the env -S flag, but the flag is not universal.

There's a simple enough workaround for this problem: install the GNU coreutils.

If you would like to avoid the GPL, you can use the MIT-licensed uutils-coreutils instead. It's a Rust reimplementation of GNU coreutils. It's a few megabytes larger, but is permissively licensed and written in a memory-safe language.

]]>
[email protected] (Wingy)
<![CDATA[Safe Shell String Interpolation]]> https://samwing.dev/posts/zJonlrXabVoc zJonlrXabVoc Sat, 22 Mar 2025 15:17:01 GMT Let's take the toy problem of getting the user's name and printing out a rainbow greeting banner. An inexperienced developer might solve the problem like this:

import { exec } from "node:child_process"
import { promisify } from "node:util"
const execPromise = promisify(exec)

const username = prompt("Hello, what's your name?")
const banner = (await execPromise(`figlet "Welcome, ${username}" | lolcat -f`)).stdout // security vulnerability!
console.log(banner)

This works as you might expect:

However, you might notice the bug when you run this with untrusted user input!

The constructed command was:

figlet "Welcome, "; ps; echo "" | lolcat -f

This kind of bug makes its way into production surprisingly often, although usually with trusted input. Even with trusted input, it can be a problem because you get strange bugs when your arguments contain spaces or something. 

The obvious solution is to use execFile rather than exec and pass the arguments directly to the command with no shells parsing user input. It looks like this:

import { execFile } from "node:child_process"
import { promisify } from "node:util"
const execFilePromise = promisify(execFile)

const username = prompt("Hello, what's your name?")
// now we have to spawn the two processes and pipe the output of figlet into lolcat manually:
const lolcat = execFilePromise("lolcat", ["-f"])
const figlet = execFile("figlet", [`Welcome, ${username}`]).stdout.pipe(lolcat.child.stdin)
console.log((await lolcat).stdout)

I think this is a rather ugly solution. An alternative is to continue using shells, but pass the inputs in as environment variables:

import { exec } from "node:child_process"
import { promisify } from "node:util"
const execPromise = promisify(exec)

const username = prompt("Hello, what's your name?")
const banner = (await execPromise('figlet "Welcome, $username" | lolcat -f', { env: { ...process.env, username } })).stdout
console.log(banner)

We get the same, correct, result.

In JavaScript, we can take this one step further! Tagged templates allow you to write functions that receive the arguments to a template literal and return whatever you want. Here's an example implementation of safe interpolation:

import { exec as exec } from "child_process"
import { promisify } from "util"
const execPromise = promisify(exec)

async function shell(fragments: TemplateStringsArray, ...values: unknown[]) {
  const env = { ...process.env }
  const command = fragments.reduce((constructedCommand, fragment, i) => {
    if (i < values.length) {
      const varName = `_val${i + 1}`
      env[varName] = `${values[i]}`
      return constructedCommand + fragment + `$\{${varName}}`
    }
    return constructedCommand + fragment
  }, "")

  return await execPromise(command, { env })
}

const username = prompt("Hello, what's your name?")
const banner = (await shell`figlet "Welcome, ${username}" | lolcat -f`).stdout
console.log(banner)

This allows you to safely do string interpolation with untrusted user input on shell commands! There are still plenty of footguns depending on the command you're using, like how the env command will start parsing out args if you start the first arg with -S . In general, be careful that the command you're passing untrusted user input to doesn't do unexpected things when given maliciously-crafted input.

Now how about in other languages? In most cases, the best you can do is the environment variable method. Here's an example in Python:

import subprocess
import os


def shell(command, **values):
    return subprocess.run(
        command,
        shell=True,
        check=True,
        text=True,
        capture_output=True,
        env=os.environ | values,
    )


username = input("Hello, what's your name? ")
banner = shell('figlet "Welcome, $username" | lolcat -f', username=username)
print(banner.stdout)

There is a proposal, PEP 750, that would enable something like:

shell(t'figlet "Welcome, {username}" | lolcat -f') 

It is expected to land in Python 3.14. 

Swift, however, is the only language other than JavaScript I have identified that has the equivalent feature today. It allows you to write structs extending ExpressibleByStringInterpolation in a similar fashion to JavaScript's tagged templates. 

I had not written a line of swift since I was eleven years old, and I didn't want to go learn Swift for a quick proof-of-concept, so this code is largely LLM-assisted. 

import Foundation

struct Shell: ExpressibleByStringInterpolation {
    private var command: String
    private var env: [String: String]
    var result: String

    struct Interpolation: StringInterpolationProtocol {
        var command = ""
        var env: [String: String] = [:]
        private var argCount = 0

        init(literalCapacity: Int, interpolationCount: Int) {}

        mutating func appendLiteral(_ literal: String) {
            command += literal
        }

        mutating func appendInterpolation(_ value: Any) {
            argCount += 1
            let varName = "_val\(argCount)"
            env[varName] = "\(value)"
            command += "${\(varName)}"
        }
    }

    init(stringLiteral value: String) {
        command = value
        env = [:]
        // We can't use the run() function until all of our properties are initialized
        // so we need to set result to "" first.
        result = ""
        // I'm not totally sure how this try? syntax works, the LLM generated it
        // Presumably, it does run() and returns null if it fails, then we optional-chain to "" if it fails
        result = (try? run()) ?? ""
    }

    init(stringInterpolation: Interpolation) {
        command = stringInterpolation.command
        env = stringInterpolation.env
        result = ""
        result = (try? run()) ?? ""
    }

    private func run() throws -> String {
        let process = Process()
        process.executableURL = URL(fileURLWithPath: "/bin/sh")
        process.arguments = ["-c", command]

        var environment = ProcessInfo.processInfo.environment
        for (key, value) in env {
            environment[key] = value
        }
        process.environment = environment

        // Combining stdout and stderr is different
        // than what I did in the Python and JS implementations.
        // This function is purely LLM-generated (excluding this comment).
        let pipe = Pipe()
        process.standardOutput = pipe
        process.standardError = pipe

        try process.run()
        process.waitUntilExit()

        let data = pipe.fileHandleForReading.readDataToEndOfFile()
        return String(data: data, encoding: .utf8) ?? ""
    }
}

print("What is your name?", terminator: " ")
let username = readLine()
// Swift doesn't have single-quoted strings, so we need to escape the double quotes
// in the figlet command.
let banner: Shell = "figlet \"Welcome, \(username!)\" | lolcat -f"
print(banner.result)

Appendix: Cursed Python Version

I wanted to see if it was possible to make shell(f'figlet "Welcome, {username}" | lolcat -f') not vulnerable to shell injection. Nobody should ever use this, but I managed to bludgeon it into doing what I wanted. It uses a decorator function that grabs the function's source code, manipulates it with regexes (I suppose you could use the AST for this, but editing code with regex makes the fun even more fun – nobody is putting this in production hopefully), and executes the newly updated code.

import inspect
import os
import re
import subprocess
import uuid


def with_shell(f):
    def wrapper(*args, **kwargs):
        # Horrifying hack within a horrifying hack:
        # Delete the first line of the function source code,
        # which is the decorator itself.
        # This is necessary to avoid infinite recursion.
        # It will break if there's a decorator above this one.
        function_source = "\n".join(inspect.getsource(f).splitlines()[1::])
        pattern = r'shell\(f(["\']{1,3})(.*?)\1\)'
        # We could replace all of the interpolated values with "?" or something,
        # but what if the command itself contains a "?"?
        # Instead, we generate an unpredictable placeholder.
        placeholder = str(uuid.uuid4())

        def replace_shell_invocation(match):
            cmd = match.group(2)
            placeholders = []

            def replace_braces(match):
                placeholders.append(match.group(1))
                return placeholder

            cmd_no_braces = re.sub(r"\{(.+?)\}", replace_braces, cmd)
            placeholders_str = ", ".join(placeholders)
            if placeholders_str:
                return f'shell.internal({repr(cmd_no_braces)}, "{placeholder}", {placeholders_str})'
            return f'shell.internal({repr(cmd_no_braces)}, "{placeholder}")'

        new_code = re.sub(
            pattern, replace_shell_invocation, function_source, flags=re.DOTALL
        )
        exec(compile(new_code, f.__code__.co_filename, "exec"), f.__globals__)
        # We can't use f(*args, **kwargs) because it would call the original function,
        # so we need to eval its name to get a reference to the newly-generated function
        # from the exec.
        eval(f.__name__)(*args, **kwargs)

    return wrapper


def shell(command: str) -> str:
    """
    Run a shell command and return the output. Prevents shell injection.

    Args:
        command: The shell command to run

    Returns:
        The output of the shell command
    """
    raise NotImplementedError("Make sure you're using the with_shell decorator")


def shell_internal(command, placeholder, *args):
    i = 0
    env = {}
    while placeholder in command:
        var_name = f"_val{i}"
        env[var_name] = args[i]
        command = command.replace(placeholder, f"${{{var_name}}}", 1)
        i += 1
    return subprocess.run(
        command,
        shell=True,
        check=True,
        text=True,
        capture_output=True,
        env=os.environ | env,
    )


shell.internal = shell_internal


@with_shell
def main():
    username = input("Hello, what's your name? ")
    banner = shell(f'figlet "Welcome, {username}" | lolcat -f')
    print(banner.stdout)


main()
]]>
[email protected] (Wingy)
<![CDATA[Actual Automation]]> https://samwing.dev/posts/dQ3wKkKozhyI dQ3wKkKozhyI Thu, 04 Apr 2024 03:09:45 GMT To start with, let's clear up the clickbait: by “Actual Automation” I mean “Automation for Actual, the budgeting application”. 


I use Actual to manage my budgets. I've used a spreadsheet in the past but I prefer to use a dedicated self-hosted application for it. It looks like this:

If there's one thing I like to write code for, it's automating away unnecessary tedious work. Actual has some support for “Goal Templates” that allow you to automatically allocate your budgets with tags in the category's note like #template $50 to allocate $50 every month. I have more advanced goals for my budgeting automation:

  • First, set aside a budget for tithing to my church.
  • Second, set aside what I need for bills, gas, etc.
  • Then, divide my income by percentages into the following envelopes:
    • General
    • Social
    • Gifts
  • Finally, take what's left and put it into savings.

Goal templates cannot accomplish that. So, I wrote this!

// My budgeting app, Actual (https://actualbudget.org) has "Goal Templates" but I wanted more flexibility. I wrote this script that uses the Actual API to apply some custom rules to my budget:
// 1. Essentials:
//    * Set aside money for bills, and save up for bills that are not monthly
//    * Set aside money for tithing
//    * Set aside money for an emergency fund
//    * Set aside money for gas
// 2. Divide the remainder of my income into categories, rounding down to the nearest cent. The categories are as follows:
//    * 30% for general
//    * 20% for social
//    * 10% for gifts
// 3. Save the rest in a savings category, including any rounding errors from dividing the income.

import api from '@actual-app/api'

// dotenv is used for loading the password for Actual from a .env file.
import { config as dotenv } from 'dotenv'
dotenv()

// fs is used for resetting the cache prior to each run.
import fs from 'fs/promises'

// Some configuration is written as constants.
const DATA_DIR = './cache'
const SERVER_URL = 'https://actual.ts.wingysam.xyz'
const BUDGET_FILE_ID = 'budget-uuid-here'
const CURRENCY_UNITS = 100 // https://actualbudget.org/docs/api/reference#primitives


// These numbers are dummy data
const TITHE_PORTION = 0.10
const EMERGENCY = 500
const GAS = 200
const INCOME_DISTRIBUTION = [ // [fraction of income, category id]
  [0.30, 'General'],
  [0.20, 'Social'],
  [0.10, 'Gifts']
]
const BILL_AMOUNTS_AND_PERIODS = [ // [monthly cost, months to save up = 1]
  [100, 3], // Car Insurance, quarterly
  [30], // Haircut
  [50], // Phone Service
  [10], // Phone Payment
  [30], // Gym monthly
  [5, 12] // Gym annual
]

// The password to Actual and the month to apply the budgeting rules to are taken from env.
const { ACTUAL_PASSWORD, MONTH } = process.env
if (!ACTUAL_PASSWORD) throw new Error('Set ACTUAL_PASSWORD.')
if (!MONTH) throw new Error('Set MONTH.')

// The budget object and income are stored globally so they don't need to be recomputed or passed around.
let budget, income

main().catch(error => {
  log('Fatal Error:', error)
  process.exit(1)
})

async function main () {
  await resetCache()
  await api.init({
    dataDir: DATA_DIR,
    serverURL: SERVER_URL,
    password: ACTUAL_PASSWORD
  })
  await api.downloadBudget(BUDGET_FILE_ID)

  budget = await api.getBudgetMonth(MONTH)
  income = getCategory('Income').received

  await applyBudgetRules()
  await printBalances()

  await api.shutdown()
}

// Caching the budget saves a fraction of a second and introduces more complexity and potential for bugs than I'm willing to tolerate.
// As such, I wipe it every time the script runs.
async function resetCache () {
  await fs.rm(DATA_DIR, {
    recursive: true,
    force: true
  })
  await fs.mkdir(DATA_DIR)
}

async function applyBudgetRules() {
  // These don't depend on each other, so they can be done in parallel.
  await Promise.all([
    setAsideBills(),
    setAsideTithe(),
    setAsideEmergency(),
    setAsideGas(),
    clearSavingsBudget()
  ])
  
  // These depend on the previous steps, so they must be done in sequence.
  await divideIncome()
  await saveRemainder()
}

async function clearSavingsBudget() {
  await setBudgetAmount('Savings', 0)
}

// I want to budget for all of my bills every month.
// Some of my bills are quarterly or annual, so I save the cost per month of those bills.
// I don't want it to set the balance higher than (monthly cost * months to save up).
async function setAsideBills() {
  let billsPerMonth = 0
  let saveUpGoal = 0
  for (const [perMonth, monthsToSave = 1] of BILL_AMOUNTS_AND_PERIODS) {
    billsPerMonth += Math.round(perMonth * CURRENCY_UNITS)
    saveUpGoal += Math.round(perMonth * monthsToSave * CURRENCY_UNITS)
  }

  await addToCategoryBalance('Bills', billsPerMonth, saveUpGoal)
}

async function setAsideTithe() {
  await addToCategoryBalance('Tithe', Math.ceil(income * TITHE_PORTION))
}

async function setAsideEmergency() {
  await addToCategoryBalance('Emergency', EMERGENCY * CURRENCY_UNITS, EMERGENCY * CURRENCY_UNITS)
}

async function setAsideGas() {
  await addToCategoryBalance('Gas', GAS * CURRENCY_UNITS, GAS * CURRENCY_UNITS)
}

async function divideIncome() {
  for (const [_, categoryName] of INCOME_DISTRIBUTION) {
    await setBudgetAmount(categoryName, 0)
  }

  const remainder = budget.toBudget
  for (const [fraction, categoryName] of INCOME_DISTRIBUTION) {
    const amountToBudget = Math.floor(remainder * fraction)
    await addToCategoryBalance(categoryName, Math.max(0, amountToBudget))
  }
}

async function saveRemainder() {
  await addToCategoryBudget('Savings', budget.toBudget)
}

async function addToCategoryBalance(categoryName, amountToAllocate, maxToSaveUpTo = Number.MAX_SAFE_INTEGER) {
  const category = getCategory(categoryName)

  // I don't want this month's spending so far to impact the budget.
  const balanceAsOfEndOfLastMonth = category.balance - category.budgeted - category.spent

  const newBalance = Math.min(balanceAsOfEndOfLastMonth + amountToAllocate, maxToSaveUpTo)
  const newBudget = newBalance - balanceAsOfEndOfLastMonth

  await setBudgetAmount(categoryName, newBudget)
}

async function addToCategoryBudget(categoryName, amount) {
  const category = getCategory(categoryName)
  const newBudget = category.budgeted + amount

  await setBudgetAmount(categoryName, newBudget)
}

async function setBudgetAmount(categoryName, newBudget) {
  const category = getCategory(categoryName)
  await api.setBudgetAmount(MONTH, category.id, newBudget)

  // The API call doesn't update the budget object, so we need to update it manually.
  const oldBudget = category.budgeted
  category.budgeted = newBudget
  budget.toBudget -= newBudget - oldBudget
  category.balance += newBudget - oldBudget
}

function getCategory(categoryName) {
  const categories = budget.categoryGroups.flatMap(categoryGroup => categoryGroup.categories)
  const matches = categories.filter(category => category.name === categoryName)
  if (matches.length === 0) throw new Error(`Category not found: ${categoryName}`)
  if (matches.length > 1) throw new Error(`Duplicate categories found: ${categoryName}`)
  return matches[0]
}

async function printBalances() {
  log('New Balances:')
  for (const categoryGroup of budget.categoryGroups) {
    for (const category of categoryGroup.categories) {
      if (category.is_income) continue
      log(`${category.name}: ${formatCents(category.balance)}`)
    }
  }
}

// The script that invokes this program filters to lines that match /^> /. This is because Actual's SDK has some console.log statements that can't be turned off.
// I might open a PR to centralize the logging in the SDK at some point.
function log(...arg) {
  console.log('>', ...arg)
}

// The cents function turns a number of cents into a string formatted as a dollar amount.
// For example, 5000 cents becomes "$50.00". -5000 cents becomes "-$50.00".
function formatCents(cents) {
  const formatter = new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD'
  });
  return formatter.format(cents / 100);
}

So now, I can automatically do my budgeting for the month like this:

MONTH=2024-04 node src/index.js

But that's not good enough! So I used iOS Shortcuts with the “Run script over SSH” action:

So now, I open Siri and tell it to “Allocate Budget” and it automatically does all of my budgeting for the month in a few seconds! It also works from my Apple Watch. Here's an example with $2,000 of income:

Overall, I'm incredibly happy with what I've built. I can tell it to recompute the budget at any time and it tells me what I have available in each of my categories to spend.

]]>
[email protected] (Wingy)
<![CDATA[Site Iteration #4]]> https://samwing.dev/posts/g2OxV2CtuI2I g2OxV2CtuI2I Fri, 16 Dec 2022 05:03:26 GMT Hi, I've rewritten my site again. Let's take a look at the past iterations of my place on the web since I got my first domain, wingysam.xyz, at age 12.

Iteration 1: Particles

This was the first iteration of my site, based on this Jekyll theme. In my opinion, this is as good as I could have hoped for as a twelve-year-old.

Iteration 2: Hyde

This was the second iteration of my site, deployed on 2018-10-15. I was 13 at the time. The site used Jekyll again, this time with Hyde. I don't have much to say about the site but it worked well enough until it was replaced.

Iteration 3: Arwes

The third iteration of my site, deployed on 2020-05-31, was a single-page app written in React and Arwes. I was 14 at the time. The general design of the site was carried through to the new site.

Iteration 4: Svelte

This is the latest iteration of my site, created on 2022-12-15. At the time of writing, I am 17. The design has largely been inspired by iteration 3, but I'm no longer using a template other than what was created for me by SvelteKit. Posts are now rendered on the server rather than the client. JavaScript is no longer required to visit my site. For this iteration, I have moved from wingysam.xyz to samwing.dev.

]]>
[email protected] (Wingy)
<![CDATA[De-google 2: Elimination 1]]> https://samwing.dev/posts/Q7AbhlpJfPOr Q7AbhlpJfPOr Fri, 19 Feb 2021 00:00:00 GMT Continuing my de-google series, I've attempted to replace or stop using as many Google services as possible. 

ServiceDropped?AlternativeNotes
Cardboard
 
Not a service, just a standard
Connected Home
This would be replacable with Mycroft but I can't afford to buy new speakers. I'm not eligible to get a job given my age.
Gmail
Unknown

I use forwardemail.net to forward from wingysam.xyz, but I still haven't migrated everything there yet.

Once I have things migrated, I still won't be able to switch to a private provider because I don't have income (I'm 15 and can't get a job until I'm 16).

Google Cast
Unknown
Can't get rid of this until I get rid of Connected Home.
Google Cloud Print
AirPrint
Cloud Print shut down. Thanks Google.
Google Fonts
.woff2 files
Not related to my account, so I don't use it.
Maps
OpenStreetMap/Apple Maps
I use OpenStreetMap and Apple maps now.
Search
 I use DDG.
Sheets
Impossible to switch from. Other people use Google sheets and I have to be able to access those unfortunately. Maybe I could use a dummy account?
Slides
WPS Presentation
Previously LibreOffice, was recommended WPS because it's better than LibreOffice for presentations.
Translate
 I use DeepL whenever I need to translate now.
Voice
 No free VoIP services that I can find.
YouTube
Nothing is viable to replace YouTube for me yet.

 

Unknown Template

]]>
[email protected] (Wingy)
<![CDATA[Home Directory Oops]]> https://samwing.dev/posts/y4pI6kb4L45r y4pI6kb4L45r Sat, 07 Nov 2020 02:02:03 GMT Today I was attempting to scope some css to an id. I found scopify-cli which does this. I installed it and attempted to run it.

It gave me an error. I looked in the pull requests and found #1. It updates the version of a few dependencies and it works again.

I executed it. The css in question was bulma.min.css.

cat bulma.min.css | scopify-cli '#todorender'

However, I made a mistake. This tool does not output to stdout. It writes to the current directory. It scopified almost every CSS file in my home directory until I noticed what was wrong and ^C'd it.

Now I needed to fix this mess. I have hourly backups with borgbackup, but I needed to use them and only extract css files. I looked up the documentation and spent ~20 minutes trying to make --exclude work. I then found --pattern and spent another 10 minutes trying to make that work. Every time it wouldn't match anything. I eventually found that I needed to use --exclude 're:something'.

I ended up with the following command:

borg extract --dry-run --list /data/Backups/borg/home/ssd::2020-11-06T20:00:02 'home/wingy/Code' --exclude 're:^((?!\.css).)*$'

I tested that it outputted the correct files and removed the --dry-run and did it again. Everything ended up being fine.

Lesson: RAID is not a backup. Learn to restore backups.

]]>
[email protected] (Wingy)
<![CDATA[Python Userscripts]]> https://samwing.dev/posts/XGJSdLfsiqU1 XGJSdLfsiqU1 Tue, 25 Aug 2020 17:46:19 GMT Today a friend asked me if I could help them do something on a web page. They didn't understand JavaScript's quirks well enough to easily get it working.

They did understand Brython enough to do pretty much anything on any page, but didn't know how to inject it. I built a userscript, then they improved the style a bit.

Here's the result:

brython_loader.js

// ==UserScript==
// @name          Brython Loader by Wingy
// @version       2020-08-25-0158AM-UTC-5-Wingy
// @run-at        document-end
// @grant         GM.xmlHttpRequest
// ==/UserScript==

const HOME_SWEET_HOME = 'http://127.0.0.1:18555'

function loadScript(url) {
    const script = document.createElement('script')
    script.src = url
    document.head.appendChild(script)
}

GM.xmlHttpRequest({
  method: 'GET',
  url: `${HOME_SWEET_HOME}/entrypoint`,
  onload: function(response) {
    // Placeholder for the entrypoint script
    const script = document.createElement('script')
    script.textContent = response.responseText
    script.type = 'text/python'
    document.body.appendChild(script)

    const brythonLoader = document.createElement('script')
    brythonLoader.textContent = 'brython()'
    setTimeout(() => document.body.appendChild(brythonLoader), 0) // runs on next js tick
  }
})

loadScript(`${HOME_SWEET_HOME}/brython/brython.js`)
loadScript(`${HOME_SWEET_HOME}/brython/brython_stdlib.js`)
]]>
[email protected] (Wingy)
<![CDATA[Re. #100DaysToOffload]]> https://samwing.dev/posts/Q7Hei6sANM2C Q7Hei6sANM2C Wed, 29 Jul 2020 12:56:30 GMT I've decided to give up doing this on my public site.

For the past few days, I've been writing in a private journal and enjoying it a lot more. I gave myself a lot of freedom in the private journal. I'm trying to write between one and infinity words every day.

I'll probably still post some things here, but they won't be #100DaysToOffload.

]]>
[email protected] (Wingy)
<![CDATA[STUCK SHIFT KEY]]> https://samwing.dev/posts/OGs7UAuuakb3 OGs7UAuuakb3 Thu, 02 Jul 2020 16:11:51 GMT When I woke up today I decided to try an old keyboard I had lying around. I grabbed it, unwrapped the cable, plugged it in, and tried to use it. At first I thought Caps Lock was on because it was typing uppercase letters.

I pressed Caps Lock and it returned to normal, and I thought everything was fine, then I tried Alt+Tab and it went backwards. I needed a solution, so tried to use Windows + 1 to go to my first app in the task bar, in this case Firefox.

When I pressed Control + T, it opened a new window instead of a new tab. Then I was searching for STUCK SHIFT KEY WINDOWS 10 because I had turned off capslock at this point.

I tried to ask for help in Discord, but I was in a game channel so I needed to go to a different server. I kept clicking a different server but it wasn't switching. After a minute I gave up and tried to go to Firefox. I then realized that I had 10 instances of that server in a Firefox window.

After typing out a message on Discord in Firefox, I pressed enter, and it dropped to a new line instead of sending. I then realized something.

Have you tried turning it off and on again?

Oh wait I forgot that part. Reboot. Windows advanced options because rebooting while holding shift opens advanced options. Shut down. Press power button to turn it on again.

🎉

]]>
[email protected] (Wingy)
<![CDATA[Why I don't use analytics]]> https://samwing.dev/posts/BWt7Ux4NevkP BWt7Ux4NevkP Thu, 25 Jun 2020 00:00:00 GMT This site has no analytics, I had initially planned to add analytics, such as Ackee or Google Analytics. However, I ended up with no analytics.

After going through some options for analytics, I decided to use none. My reason wasn't for technical reasons or moral reasons, but because I just wanted to write.

I wanted to write to you. I have no way of knowing that you are reading this, except that someone will probably read this, and if you are reading this, you are reading this.

I like that. If it were different, it would have a negative effect.

If I had few readers, it would discourage me and I would find it difficult to continue writing.

If I had many readers, I would potentially think I was an authority on something when I'm actually not.

]]>
[email protected] (Wingy)
<![CDATA[De-google 1: Identification]]> https://samwing.dev/posts/OEIBS4RdnPpF OEIBS4RdnPpF Wed, 24 Jun 2020 16:58:25 GMT Inspired by “Google blew a ten-year lead.", I've decided to restrict Google as much as possible.

I've decided to go through the entire list of Google services, and find which ones I use.

ServiceUsingAlternativeNotes
Android Auto
 
 
Android OS
 
Calendar
Trilium todo
 
Cardboard
 
Not a service, just a standard
 Chrome

I use Chrome to test apps once I've built them, but I mostly use Firefox.

Brave, Vivaldi, etc. Do not count. They are built on Blink.

Chrome Web Store
 
Chromebook
Custom built PC
 
Chromecast
 
 
Connected Home
Mycroft is a good alternative, but can't be put on my existing speakers. I can't afford to buy Mycroft speakers or Raspberry Pi speakers everywhere.
Contacts
 
Daydream View
 
 
Docs
 
Drawings
 
Drive
Samba+Wireguard
 
Earth
 
 
Finance
 
 
Forms
 
 
Gboard
iOS Keyboard
 
Gmail
Unknown

I use forwardemail.net to forward from wingysam.xyz, but I haven't migrated everything there yet.

I'd like to use a different provider too, ideally.

Google Alerts
 
 
Google Cast
Unknown
I use this to send messages from Home Assistant to Google Home Minis. Also used for Spotify.
Google Chat
 
Google Classroom
 
Google Cloud Print
AirPrint
I use AirPrint when I don't use a Chromebook. However, Google Cloud Print is shutting down at the end of this year in 6 months, and Chrome OS now has CUPS, which my AirPrint server already has.
Google Duo
 
Google Expeditions
 
 
Google Fi
 
Google Fit
 
Google Flights
 
I've never flown, despite my name (Wingy).
Google Fonts
.woff2 files
I try to self-host Google Fonts when I can.
Google Groups
Discourse
 
Google Input Tools
 
I don't quite understand what this does. It lets you type other languages to the web? I just don't see what the web does with them after that. Seems likely to end up in the graveyard.
Google Meet
 
Google One
 
Technically every Google account has it, but I haven't purchased a plan with it.
Google Pay
Apple Pay
 
Google Photos
Photos.app

Would like a web-based alternative, because currently I use Photos.app on my mom's laptop since I use Windows.

Would replace Windows with Linux if it supported anticheat in games.

Google Play
App Store
 
Google Play Books
Books on iOS
 
Google Play Games
App Store
 
Google Play Movies & TV
 
I mostly don't watch movies or TV. I generally prefer games like Celeste or Portal if I want to relax.
Google Play Music
Spotify
 
Google Shopping
Amazon
I'm a kid, so I mostly don't shop, except for buying computer parts mostly.
Google Store
Walmart
The Google Home Minis that I have were from Walmart, except for one that I got on Google Express from Spotify.
Google Street View
 
 
Hangouts
iMessage/SMS
 
Keep
 
Maps
OpenStreetMap/Apple Maps
I mostly don't use maps, but occasionally do from search results.
Messages
Messages (Apple)
 
Nest Wifi
Netgear Router
 
News
No news is good news I guess?
Pixel
iPhone 6S
 
Pixelbook Go
 
Play Protect
Apple's ridiculous process
 
Scholar
 
Search
 
Sheets
I use Sheets for collaboration, but not Docs. I'm weird I guess?
Sites
 
Slides
LibreOffice Impress
 
Stadia
My PC
 
Tilt Brush
 
 
Translate
 
Voice
 
Waze
 
Wear OS by Google
 
YouTube

This is going to be impossible to get rid of for me for now unfortunately. PeerTube doesn't have critical mass and nobody is creating content for it.

Maybe I could use Invidious though? I'd lose the recommendations AI.

YouTube Kids
 
YouTube Music
 
YouTube TV
 

Now that I've identified which services I use, I can work on getting rid of them in a followup.

]]>
[email protected] (Wingy)
<![CDATA[dd | ssh]]> https://samwing.dev/posts/rEQ04tDb5kHF rEQ04tDb5kHF Wed, 17 Jun 2020 18:44:01 GMT I archived my old laptop with sudo dd if=/dev/sda status=progress | ssh [email protected] "dd of=/data/mbpssd.img". I did that from an Ubuntu 20.04 live USB. The command runs dd as root to read /dev/sda, then pipes that to ssh, which reads from stdin and outputs to disk.

Once I did that, I used losetup -fP mbpssd.img to mount it, then mounted the partitions to make sure it worked. There could be some corruption that I didn't notice, but SSH is TCP and the encryption would fail if there was a corruption right?

]]>
[email protected] (Wingy)
<![CDATA[Submaniac]]> https://samwing.dev/posts/FdOVBLk161Ff FdOVBLk161Ff Thu, 11 Jun 2020 14:24:07 GMT I've been working on a project called Submaniac, a program to manage Subnautica installations.

It manages several installs of the game. You could want an install with the vanilla game, a game with tons of cheats with QMods, a game with not very cheaty mods, and a game with only mods that make it harder.

There's also one big problem with mods in Subnautica. QMods, the platform used by literally every mod except one, is completely incompatible with that one. And the incompatibility is Nitrox.

Nitrox is a MULTIPLAYER!! mod for Subnautica. I would give up all of my mods for Nitrox because it's so much fun. However, I don't have to because I made Submaniac.

Submaniac's menu defaults to the currently loaded save file. I switched to Nitrox and pressed enter, then it moved my old save into its directory, then moved my Nitrox save into the save directory. It then launched the game.

I might add a mod manager or game creator tool in the future, currently I have to create installs with Windows Explorer.

I'm going to go play Subnautica now.

]]>
[email protected] (Wingy)
<![CDATA[SSD Came in Early]]> https://samwing.dev/posts/E75Uz2hdz0CG E75Uz2hdz0CG Tue, 09 Jun 2020 21:42:33 GMT My MP34 came in early :D

When I put it in, it did the flying outward thing which I expected, then I tried to put the motherboard standoff in but couldn't get it because it was tiny, so I ended up using a tiny torx screwdriver and friction inside the screw hole as a head to put the standoff in.

I tried to push the SSD to the standoff and put the screw in, but I had put the standoff in the second hole, not the third which I needed. I moved the standoff with the same process, then realized that I had lost the screw. After a minute of panic I found the screw behind my toolkit.

Once I had the SSD in, I booted up Clonezilla and tried to copy the old SATA DRAM-less SSD to my new NVMe with DRAM SSD, and it told me that I can't have both GPT and MBR, and it gave me a command to use, but I wasn't comfortable just blindly pasting it in so I looked it up.

There was a SuperUser question about it and an answer with an explanation as to what everything did. I wiped out GPT and kept MBR, then cloned the drive. It transferred at around 10 GB per minute.

When it was cloned, I pulled the old SSD and kept the new one in the system. I pressed the power button, or reset button in my case because my power button is broken. The Windows logo showed up and it boote… wait…

I was met with a BSOD that said INACCESSIBLE BOOT DEVICE.

I found an article that had instructions to fix it, but I had to adapt the command that they used to my case.

This was their command:

Now, exit out of diskpart and run the following command: bcdboot c:\windows /s e: /f UEFI

I couldn't get it to work in the advanced options command prompt, and eventually had to use Safe Mode with Command Prompt.

I ended up with (after some trial and error with thinking that my C drive had moved to E: but it hadn't) bcdboot c:\windows.

Once I had done that, I rebooted and Windows worked perfectly!

]]>
[email protected] (Wingy)
<![CDATA[My all-day adventure with WireGuard]]> https://samwing.dev/posts/yrD9sSXea3eD yrD9sSXea3eD Fri, 05 Jun 2020 17:11:53 GMT I was trying to install Wireguard on my server (the same server that I didn't reboot for 400 days and said that I needed to reboot more frequently). I hadn't rebooted since I said that I needed to reboot more frequently.

To install the Wireguard kernel module, I needed to reboot since I couldn't find it with modprobe. I checked my backups and rebooted.

root@tundra:~# reboot
Connection to wingysam.xyz closed by remote host.
Connection to wingysam.xyz closed.

Then I used while true; do ssh root@vps; done to automatically log back in on startup. I also checked my Scaleway dashboard. The dashboard said that tundra was still running. I got logged back in.

I checked wingysam.xyz and it was still up. I checked Abide Within and it was still up. Church Alive was up too.

Wireguard, however, was not.

dmesg had nothing but ufw state changes.

I checked /var/log/apt/term.log and…

dpkg: warning: version '*-*' has bad syntax: version number does not start with digit                                                                                                       It is likely that 4.4.122-mainline-rev1 belongs to a chroot's host                                                                                                                          Module build for the currently running kernel was skipped since the                                                                                                                         kernel source for this kernel does not seem to be installed.

It seems that KVM/Qemu is in docker.

And since I can't get headers for my kernel, there's nothing I can do :(

Basically I know all of this because my friend Snekk helped me through all of it :)

Part 2

I didn't publish this post at the time of writing this second part but I realized that Scaleway has special instructions for this!

Installing and Configuring WireGuard® on Linux as a VPN server and Local boot option on Development and General Purpose Virtual Cloud Instances show how to get kernel modules working on Scaleway servers! I enabled it and rebooted.

After an hour of trying to get it to work, I noticed:

To switch to local boot on an older instance, you need to re-create it with one of our new local boot compatible images. All newly created Development or General Purpose virtual cloud Instances will use a local kernel by default. Previously created instance do not support local boot and can’t use this feature!

And I can't just nuke the server without setting up borgbackup and restoring, which would take a long time. So I can't set up Wireguard.

Also the new server type has:

  • 4 shared EPYC cores instead of 6 pinned EPYC cores
  • 80G storage instead of 200G storage
  • 400mbps network instead of 300mbps

Then I found this script on Scaleway's GitHub repository:

# Determine versions
arch="$(uname -m)"
release="$(uname -r)"
upstream="${release%%-*}"
local="${release#*-}"

# Get kernel sources
mkdir -p /usr/src
wget -O "/usr/src/linux-${upstream}.tar.xz" "https://cdn.kernel.org/pub/linux/kernel/v4.x/linux-${upstream}.tar.xz"
tar xf "/usr/src/linux-${upstream}.tar.xz" -C /usr/src/
ln -fns "/usr/src/linux-${upstream}" /usr/src/linux
ln -fns "/usr/src/linux-${upstream}" "/lib/modules/${release}/build"

# Prepare kernel
zcat /proc/config.gz > /usr/src/linux/.config
printf 'CONFIG_LOCALVERSION="%s"\nCONFIG_CROSS_COMPILE=""\n' "${local:+-$local}" >> /usr/src/linux/.config
wget -O /usr/src/linux/Module.symvers "http://mirror.scaleway.com/kernel/${arch}/${release}/Module.symvers"
apt-get install -y libssl-dev # adapt to your package manager
make -C /usr/src/linux prepare modules_prepare

I ran that, Levi told me to dpkg-reconfigure wireguard-dkms and then Wireguard worked!

]]>
[email protected] (Wingy)
<![CDATA[OnTrack Dockerfile]]> https://samwing.dev/posts/ZSR3muUVB9S1 ZSR3muUVB9S1 Thu, 04 Jun 2020 14:15:13 GMT 8 months ago, I made a GitHub issue on Iana Noda's OnTrack budgeting tool about dockerizing the application. After discussion, nothing happened.

Until now. Yesterday I took a look at my dockerfile and found that the problem my months old dockerfile had was postgresql. I had been trying to expect an out of container one. I tried to get that method working but I couldn't. I decided to just put postgres in the container with the application even though it's considered bad practice.

I got an error that the role “root” did not exist in postgres. The image also didn't have sudo so I had to put it in the image. I probably could apt-get install sudo && sudo -u postgres … && apt-get remove sudo to work though.

I also eventually realized that I can just su postgres -c … instead of installing then removing sudo.

I got postgres running, and now the app throws a 500 error on startup. I'm giving up for today because I've been working on it for ~3 hours. I'll probably post a follow-up soon.

Update 2021-02-18: I never got it working, but someone else did.

]]>
[email protected] (Wingy)
<![CDATA[Bitwarden's new color]]> https://samwing.dev/posts/y1wzSPuIeJML y1wzSPuIeJML Wed, 03 Jun 2020 01:03:39 GMT

100 Days to Offload

I've decided to try doing #100DaysToOffload. “Just. Write.” I don't know if I'll reach my goal, or give up after a week or something. What I do know is that I'm starting now and that I intend to write something else soon!

Bitwarden used to have this color scheme:

Bitwarden Password Manager - Apps on Google Play

And now it has this:

Bitwarden - Posts | Facebook

Personally I prefer the old one.

]]>
[email protected] (Wingy)
<![CDATA[100 Days to Offload]]> https://samwing.dev/posts/IHIJ11vdMroX IHIJ11vdMroX Tue, 02 Jun 2020 00:56:03 GMT I've decided to try doing #100DaysToOffload. “Just. Write.” I don't know if I'll reach my goal, or give up after a week or something. What I do know is that I'm starting now and that I intend to write something else soon!

]]>
[email protected] (Wingy)
<![CDATA[Trilium as a CMS]]> https://samwing.dev/posts/tePIFQKUbTQ3 tePIFQKUbTQ3 Mon, 01 Jun 2020 13:50:00 GMT I'm writing this post in Trilium Notes. It's a personal knowledge base like TiddlyWiki or Notion, but with powerful scripting. I can make a note, set the type to code (backend JS) and click the run button, then it will run that code on the server!

My implementation looks like this:

This is the code that I have for making a new post (always up to date, embedded from real script):

Manage

// Run this script to create a new post.

const POSTS_FOLDER = 'E0sJWbschXAm'

;(async () => {
    const note = await api.createNewNote({
        parentNoteId: POSTS_FOLDER,
        type: 'text',
        title: 'Untitled Post',
        content: ''
    })
    await note.note.setAttribute('label', 'draft', 'true')
})()

The External API is a folder with some HTTP APIs. The site you're on calls these APIs. The first one is Posts:

Posts

// This script is the API for listing posts

const FOLDER_CONTAINING_POSTS_ID = 'E0sJWbschXAm'

// express.js API provided by Trilium
const { req, res } = api

;(async () => {
  // Modules - have to be in an async context to use import()
  // thenby: my favorite micro-library, helps sorting things
  const { firstBy } = (await import('/home/node/trilium-data/npm/node_modules/thenby/thenBy.module.js')).default

  if (req.method == 'GET') {
    const root = await api.getNote(FOLDER_CONTAINING_POSTS_ID)
    const children = await root.getChildNotes()

    // Simultaneously process every note, deciding if it should be in the post list
    const filtered = (
      await Promise.all(
        children.map(async (note) => {
          // Drafts are filtered from the post list, but stil accessible from the Post endpoint
          const draft = await note.getAttribute('label', 'draft')
          if (draft && draft.value === 'true') return null

          // The date of the post should be included in the response
          note.date = await note.getAttribute('label', 'date')
          note.date = (note.date && note.date.value) || note.utcDateCreated

          // The tag attribute can be specified multiple times
          note.tags = await note.getAttributes('label', 'tag')
          if (!note.tags) note.tags = []
          note.tags = note.tags.map((tag) => tag.value)

          // The script for creating a new post (Manage) should not be included in the post index
          // This is a more generic way to allow children in the root to not be listed
          const article = await note.getAttribute('label', 'article')
          if (!article) return note
          if (article.value === 'false') return null

          return note
        })
      )
    )
      .filter((noteOrNull) => noteOrNull)
      .sort(firstBy('date', -1))

    // This header is required to tell browsers that my website is allowed to access the response.
    // Documentation on CORS: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
    res.header('Access-Control-Allow-Origin', '*')

    res.send(
      filtered.map((note) => {
        return {
          id: note.noteId,
          title: note.title,
          date: note.date,
          tags: note.tags
        }
      })
    )
  } else {
    // This route should only be accessible by GET.
    res.sendStatus(400)
  }
})()

It returns a list of posts that are available to read (except drafts).

The second one is Post, and that returns the content of a post. The code is:

Post

const { req, res } = api

;(async () => {
  if (req.method == 'GET') {
    const target = req.query.id
    if (!target) return res.sendStatus(404)
    const result = await getContent(target)

    const public_fileserve = await result.note.getAttribute(
      'label',
      'public_fileserve'
    )
    if (!public_fileserve) return res.sendStatus(403)
    if (public_fileserve.value !== 'true') return res.sendStatus(403)

    result.note.date = await result.note.getAttribute('label', 'date')
    result.note.tags = await result.note.getAttributes('label', 'tag')
    result.note.fullwidth = await result.note.getAttribute('label', 'fullwidth')
    console.log('note fullwidth', result.note.fullwidth)
    if (!result.note.tags) result.note.tags = []
    result.note.tags = result.note.tags.map((tag) => tag.value)
    res.header('Access-Control-Allow-Origin', '*')
    res.send({
      title: result.note.title,
      type: result.note.type,
      date:
        (result.note.date && result.note.date.value) ||
        result.note.utcDateCreated,
      fullwidth:
        result.note.fullwidth && result.note.fullwidth.value !== 'false',
      tags: result.note.tags,
      content: result.content
    })
  } else {
    res.sendStatus(400)
  }
})()

It does quite a lot to the content to make it suitable for the site.

I also have a third endpoint, image. It just returns an image when requested. It's so that images I upload are available on the site.

Image

const { req, res } = api

;(async () => {
  if (req.method == 'GET') {
    const target = req.query.id
    if (!target) return res.sendStatus(404)
    let note = await api.getNote(target)
    let attachment
    if (!note) {
        attachment = await api.getAttachment(target)
        if (attachment) note = await api.getNote(attachment.ownerId)
    }
    if (!note) return res.sendStatus(404)

    if (!attachment && !['image', 'file', 'code'].includes(note.type))
      return res.sendStatus(400)
    const public_fileserve = await note.getAttribute(
      'label',
      'public_fileserve'
    )
    if (!public_fileserve) return res.sendStatus(403)
    if (public_fileserve.value !== 'true') return res.sendStatus(403)
    const mime = attachment ? attachment.mine : ((await note.getAttribute('label', 'serve_mime'))?.value ?? note.mime)

    res.header('Content-Type', mime)
    res.header('Access-Control-Allow-Origin', '*')
    res.header('Cross-Origin-Resource-Policy', 'cross-origin')
    res.header(
      'Content-Security-Policy',
      'frame-ancestors https://samwing.dev;'
    )
    res.header('X-Frame-Options', '')
    const content = await (attachment ?? note).getContent()
    res.header('Content-Length', content.length)
    res.send(content)
  } else {
    res.sendStatus(400)
  }
})()

Additionally, I have an endpoint for accessing code notes directly.

Code

const { req, res } = api

;async () => {
  if (req.method == 'GET') {
    const target = req.query.id
    if (!target) return res.sendStatus(404)
    const note = await api.getNote(target)
    if (!note) return res.sendStatus(404)

    if (note.type !== 'code') return res.sendStatus(400)
    const public_fileserve = await note.getAttribute(
      'label',
      'public_fileserve'
    )
    if (!public_fileserve) return res.sendStatus(403)
    if (public_fileserve.value !== 'true') return res.sendStatus(403)

    res.header('Content-Type', note.mime)
    res.header('Access-Control-Allow-Origin', '*')
    res.header(
      'Content-Security-Policy',
      'frame-ancestors https://samwing.dev;'
    )
    res.header('X-Frame-Options', '')
    const content = await note.getContent()
    res.header('Content-Length', content.length)
    res.send(content)
  } else {
    res.sendStatus(400)
  }
}

In the samwing.dev note, I have an inherited attribute that allows files or notes to be served by the API.

You may have also noticed that I use getContent and it's not defined anywhere. I have it defined in a code note child in the endpoints:

getContent

const CLASSMAP = {
  'language-application-javascript-env-backend': 'language-js',
  'language-application-javascript-env-frontend': 'language-js',
  'language-text-x-rustsrc': 'language-rust',
  'language-text-x-sh': 'language-bash',
  'language-text-x-python': 'language-python',
  'language-text-x-swift': 'language-swift'
}
const MIMEMAP = {
  'application/javascript;env=backend': 'language-js',
  'application/javascript;env=frontend': 'language-js',
  'text/x-lua': 'language-lua',
}

async function getContent(id, recursionDepth) {
  const { default: cheerio } = await import('/home/node/trilium-data/npm/node_modules/cheerio/index.js')
  const { decodeHTML } = await import('/home/node/trilium-data/npm/node_modules/entities/dist/index.js')
  const { firstBy } = (await import('/home/node/trilium-data/npm/node_modules/thenby/thenBy.module.js')).default

  // Options Sanity Check
  if (!id) throw new Error('No ID provided')
  if (!recursionDepth) recursionDepth = 0

  // Setup
  const note = await api.getNote(id)
  const cNote = note
  const content = await note.getContent()

  // --- Files Special Case---
  if (note.type === 'file') {
    let resultingContent = `This is a ${note.mime} file. There is currently not support for displaying ${note.mine} files.`
    switch (note.mime) {
      case 'audio/mp4':
        resultingContent = `<audio controls autoplay src="https://samwing.dev/posts/image/${id}">`
        break
      case 'application/pdf':
        resultingContent = `<a href="https://samwing.dev/posts/image/${id}">View PDF</a><embed src="https://samwing.dev/posts/image/${id}" style='width: 100%; height: 20em; margin-top: 1em;'/>`
        break
    }
    return { note, content: resultingContent }
  }
  // --- End Files ---

  let $ = cheerio.load(content, { xml: { decodeEntities: false } })

  // Transforms
  if (note.type === 'text') {
    // Convert images to use the public file API instead of trying to use the internal trilium link
    // The links look like:
    // api/attachments/<id>/image/image.png
    // or:
    // api/images/<id>/image.png
    $('[src]').map((i, el) => {
      const e = $(el)
      try {
          e.attr(
            'src',
            'https://samwing.dev/posts/image/' +
              e.attr('src').split('attachments/')[1].split('/')[0]
          )
      } catch {
          e.attr(
            'src',
            'https://samwing.dev/posts/image/' +
              e.attr('src').split('images/')[1].split('/')[0]
          )
      }
      e.css('width', '100%')
      e.css('height', 'auto')
      const parent = e.parent()
      if (parent && !parent.css('width')) {
          parent.css('width', '100%')
      }
    })
    // Mostly for converting code classes to prism.js ones
    for (const classMapEntry in CLASSMAP) {
      replaceClass(classMapEntry, CLASSMAP[classMapEntry])
    }
    // Convert local links to post links
    $('a').each((i, el) => {
      let href = $(el).attr('href')
      if (href.startsWith('#root/'))
        href = '/posts/' + href.split('/').reverse()[0]
      $(el).attr('href', href)
    })
    // Put card data on tables
    $('figure.table > table').each((_0, table) => {
      let items = []
      $(table)
        .find('thead > tr > th')
        .each((i, td) => {
          items[i] = $(td).text()
        })
      $(table)
        .find('tbody > tr')
        .each((_1, tr) => {
          $(tr)
            .find('td')
            .each((i, td) => {
              $(td).attr('data-label', items[i])
              // Have you ever seen hackier code?
              // Puts td content inside a div so that it doesn't break in display: flex
              $(td).html(`<div>${$(td).html()}</div>`)
            })
        })
    })
    // Remove inline CSS from tables
    $('figure.table').each((_, table) => {
      $(table).attr('style', '')
    })
    await Promise.all(
      $('code.language-text-plain')
        .map(async (_, codeEl) => {
          codeEl = $(codeEl)
          let text = decodeHTML(codeEl.html())
          if (!text.startsWith('{{') || !text.endsWith('}}')) return
          text = text.substr(1, text.length - 2)
          const el = codeEl.parent().wrap('<div>').parent()
          const data = JSON.parse(text)
          switch (data.template) {
            case 'subnotes':
              el.html('<ul></ul>')
              const children = await note.getChildNotes()
              if (data.sort === true) children.sort(firstBy('title', -1))
              for (const child of children) {
                if (child.isDeleted) continue
                if (data.startsWith && !child.title.startsWith(data.startsWith))
                  continue
                el.find('ul').append(
                  `<li class="subnotes-${child.noteId}"><a/></li>`
                )
                const a = el.find(`li.subnotes-${child.noteId} > a`)
                a.attr('href', '/posts/' + child.noteId)
                a.text(child.title)
              }
              break
            case 'rawhtml':
              el.replaceWith(data.html)
              break
            default:
              el.html('<p>Unknown Template</p>')
              break
          }
        })
        .get()
    )
  } else if (note.type === 'code') {
    // bit hacky, seems to work ok though
    $ = cheerio.load('<pre><code></code></pre>', { xml: true })
    const mimeClass = MIMEMAP[note.mime]
    if (mimeClass) $('code').addClass(mimeClass)
    $('code').text(content)
  }

  // Recursion
  if (recursionDepth < 3)
    await Promise.all(
      $('.include-note[data-note-id]')
        .map(async (i, el) => {
          try {
            const { note, content } = await getContent(
              $(el).attr('data-note-id'),
              recursionDepth + 1
            )
            const $2 = cheerio.load(content)
            const $all = cheerio.load('<div></div>')
            const all = $all('div')
            const section = await cNote.getAttribute(
              'label',
              `includeNoteLink_${note.noteId}_section`
            )
            const quote = (await cNote.getAttribute(
              'label',
              `includeNoteLink_${note.noteId}_quote`
            )) || { value: section ? 'false' : 'true' }
            const head = (await cNote.getAttribute(
              'label',
              `includeNoteLink_${note.noteId}_head`
            )) || { value: section ? 'false' : 'true' }
            if (section) {
              const sectionName = section.value
              const header = $2('h1, h2, h3, h4, h5, h6').filter(
                (_, h) => $2(h).text() === sectionName
              )
              all.append(header.nextUntil(header.prop('tagName')))
              $(el).html(all.html())
            } else $(el).html(content)
            if (!head || head.value !== 'false')
              $(el).html(`<h2>${note.title}</h2>${$(el).html()}`)
            if (!quote || quote.value !== 'false')
              $(el).html(`<blockquote>${$(el).html()}</blockquote>`)
          } catch {}
        })
        .get()
    )

  // Return
  return { note, content: $.html() }

  // Utilities
  function replaceClass(oldClass, newClass) {
    $(`.${oldClass}`).each((i, el) => {
      $(el).removeClass(oldClass)
      $(el).addClass(newClass)
    })
  }
}

module.exports = getContent

Update: I've added a feed endpoint. It's available at https://trilium.home.wingysam.xyz/custom/wingysam.xyz-feed.

Update 2024-09-22: In an effort to harden my security posture, Trilium is no longer accessible except through a VPN. samwing.dev now proxies it at https://samwing.dev/posts/feed.

Feed

// This script is the API for getting an RSS feed

// express.js API
const { req, res } = api

;(async () => {
  // Modules - have to be in an async context to use import()
  // thenby: my favorite micro-library, helps sorting things
  const { firstBy } = (await import('/home/node/trilium-data/npm/node_modules/thenby/thenBy.module.js')).default
  // feed: generates an RSS/Atom feed.
  const { Feed } = await import('/home/node/trilium-data/npm/node_modules/feed/lib/feed.js')

  if (req.method == 'GET') {
    // E0sJWbschXAm is the folder with my posts in it
    const targetParentNoteId = 'E0sJWbschXAm'
    const parent = await api.getNote(targetParentNoteId)
    const children = await parent.getChildNotes()
    // I don't want drafts in the post list. Should probably extract some things to (... months later, no idea what i was trying to write there)
    const filtered = (
      await Promise.all(
        children.map(async (note) => {
          note.con = (await getContent(note.noteId)).content
          const draft = await note.getAttribute('label', 'draft')
          if (draft && draft.value === 'true') return null
          note.date = await note.getAttribute('label', 'date')
          note.date = (note.date && note.date.value) || note.utcDateCreated
          note.tags = await note.getAttributes('label', 'tag')
          if (!note.tags) note.tags = []
          note.tags = note.tags.map((tag) => tag.value)
          note.description = await note.getAttribute('label', 'description')
          if (!note.description)
            note.description = { value: 'No description provided' }
          note.description = note.description.value
          const article = await note.getAttribute('label', 'article')
          if (!article) return note
          if (article.value === 'false') return null
          return note
        })
      )
    )
      .filter((noteOrNull) => noteOrNull)
      .sort(firstBy('date', -1))
    res.header('Access-Control-Allow-Origin', '*')
    const author = {
      name: 'Wingy',
      email: '[email protected]',
      link: 'https://samwing.dev'
    }
    const feed = new Feed({
      title: 'Sam Wing',
      description: 'Software Engineer who learns by making',
      id: 'https://samwing.dev',
      link: 'https://samwing.dev/posts',
      language: 'en',
      image: 'https://samwing.dev/wingy.svg',
      favicon: 'https://samwing.dev/favicon.png',
      copyright: `All rights reserved ${new Date().getFullYear()}, Wingy`,
      feedLinks: {},
      author
    })

    filtered.forEach((note) => {
      feed.addItem({
        title: note.title,
        id: note.noteId,
        link: `https://samwing.dev/posts/${note.noteId}`,
        description: note.description,
        author: [author],
        date: new Date(note.date),
        content: note.con || 'none'
      })
    })

    res.header('Content-Type', 'application/xml')
    res.send(feed.rss2())
  } else {
    res.sendStatus(400)
  }
})()
]]>
[email protected] (Wingy)
<![CDATA[Improperly Configured Systemd: A nightmare]]> https://samwing.dev/posts/gCYJZa0iy7bu gCYJZa0iy7bu Thu, 01 Aug 2019 00:00:00 GMT I woke up on Jul 23, an ordinary day. I checked to see if my favorite games had any updates (I had seen a new item announced), and then I went to upload a YouTube video I've been wanting to upload for a while.

Then I remembered:

Scaleway - Planned migration impacting your server

So, like any sysadmin, I logged onto the server to make sure everything was okay. I logged on, it worked. The server was up! uptime. The server was rebooted and not just "paused" like QEMU can.

Then I tried my website. Connection refused. I checked nginx. Code 1. journalctl -xe. No nginx logs lately. service nginx start then journalctl -xe. Something's listening on 80. !lsof from that weird time I needed it last. Then adjust to port 80. Lighttpd was listening on port 80! systemctl disable lighttpd. systemctl stop lighttpd.

Then 443. I had forgotten to pm2 save when I decommissioned my old Discord bot which ran its own HTTPS server. That started back up and used 443, but then refused connections. pm2 rm nanobot. Nope, need pm2 del nanobot. Better. Now only NanoBot 2 is running. pm2 save.

service nginx start. Profit!

docker ps -a. VPN is stopped. I should set a --restart always. docker update --restart always gallant_feynman. docker start gallant_feynman. 1194 is already bound. systemctl disable openvpn. systemctl stop openvpn. Okay, now test? docker start gallant_feynman. It works!

pm2 ls. Most things are working, but some things are infinitely restarting. Okay, that http interceptor can go. pm2 del it. Now, Last Seen is infinitely restarting... What is running it? Oh, of course! Systemd and pm2 are fighting. systemctl disable lastseen && service lastseen stop. Now I can start it in pm2. pm2 start lastseen. pm2 ls a few times to make sure it's not restarting and it's not. Good!

Now I wait for the messages to come in telling me what's broken... :(

The lesson of this story is to reboot frequently and be capable of rebooting. I should have been rebooting regularly for updates. It had not been rebooted in more than 400 days!

Thanks for reading.

]]>
[email protected] (Wingy)
<![CDATA[DSi Hacking]]> https://samwing.dev/posts/YZXDL6aDiF5C YZXDL6aDiF5C Fri, 12 Jul 2019 00:00:00 GMT Hey! So I was on YouTube (which somehow forgot my watch history and is recommending me videos I've watched) and it recommended me a video from Modern Vintage Gamer about the Nintendo DSi hacking tool "Memory Pit". I hadn't realized that this existed since May.

So obviously, I had to find my DSi and softmod it. If you're looking to do this, please don't look for how on YouTube. Please use https://dsi.cfw.guide.

It took me about 3 hours to go from a DSi that won't boot because the battery is dead to... I press the power button, and it shows me my games! I then proceeded to spend the rest of the day tweaking it, building ROM hacks, etc.

I built 23 New Super Mario Bros ROM hacks from patches. I'm thinking that creating a tool to list games and patches might be feasible.

]]>
[email protected] (Wingy)
<![CDATA[Increase power from Raspberry Pi USB ports]]> https://samwing.dev/posts/kcx7vhQZ8o6H kcx7vhQZ8o6H Mon, 25 Sep 2017 00:00:00 GMT The Raspberry Pi is a nice $35 computer, but it has a maximum of 32GB of storage on the SD card (in 2017, I thought that was the case. It is now 2019. There are 1TB SDXC microSD cards now). If you plug in a large hard drive, such as my Silicon Power 1TB Rugged Armor A60 Military-grade Shockproof/Water-Resistant USB 3.0 2.5" External Hard Drive for PC, Mac, Xbox One, Xbox 360, PS4, PS4 Pro and PS4 Slim, Black from Amazon, it doesn't work!

There's a way to increase the maximum power out of the USB ports, but don't forget to use a good power supply.

It's simple. Type this into your terminal:

sudo echo 'max_usb_current=1' >> /boot/config.txt

And then reboot your Pi.

Done! Your Raspberry Pi should power whatever you plug into the USB port.

]]>
[email protected] (Wingy)