Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6e3a41508 | ||
|
|
3fb00e2661 | ||
|
|
fc88372bfb | ||
|
|
4e87aa97cd | ||
|
|
eb4131f11b | ||
|
|
dc6ac76daa | ||
|
|
a78b68f054 | ||
|
|
e4a91e7432 | ||
|
|
a119d3209b | ||
|
|
5589d2cd48 | ||
|
|
0e0ffea161 | ||
|
|
fec3e6dace | ||
|
|
06e4c0ecfe | ||
|
|
3ce4de70a2 | ||
|
|
00f8b193ce | ||
|
|
7718424958 | ||
|
|
3e9f3e8cc0 | ||
|
|
114d8fc4e4 | ||
|
|
a627cfc7e9 | ||
|
|
1e20135091 | ||
|
|
fabfd9705c | ||
|
|
9718d84565 | ||
|
|
4f441c0abc | ||
|
|
8f26ee6a89 | ||
|
|
cef4ed883f | ||
|
|
2b5ca36e54 | ||
|
|
fd4d32f682 | ||
|
|
91670cb928 | ||
|
|
0a2985600d | ||
|
|
98f8672b03 | ||
|
|
0505341863 | ||
|
|
20f9969ad4 | ||
|
|
3887e6d2a0 | ||
|
|
80c5997664 | ||
|
|
3c47d1bfac | ||
|
|
2722795115 | ||
|
|
543d0c1de7 | ||
|
|
3e57686315 | ||
|
|
6e31b888d8 | ||
|
|
192331d27f | ||
|
|
d8bfe2c5ed | ||
|
|
50b9797612 | ||
|
|
0ba2691d1b | ||
|
|
ed6146510d | ||
|
|
320ac02c69 | ||
|
|
1269744285 | ||
|
|
1e1349fd0b |
45
.github/workflows/main.yaml
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
name: Build Saki and Deploy to Github Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["pages"]
|
||||||
|
#workflow dispatch only works when workflow is on default branch
|
||||||
|
#workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: "pages"
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/configure-pages@v3
|
||||||
|
id: pages
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
cache: 'npm'
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run build
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: ./build
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/deploy-pages@v4
|
||||||
|
id: deployment
|
||||||
22
posts/190k_faucet.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
My Banano faucet, [faucet.prussia.dev](https://faucet.prussia.dev) recently reached an incredible 190k claims. I want to thank everyone who donated to the faucet or used it, and of course the people who contributed to the code: HalfBakedBread, SaltyWalty and KaffinPX.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Version 1
|
||||||
|
Around 2 and a half years ago (wow...), I first launched my Banano faucet. It was my first experience programming in cryptocurrency. After struggling with the libraries for Banano on Python (a problem I fixed a few months ago by writing [bananopie](https://github.com/jetstream0/bananopie)), I switched to using Node.js and Banano.js.
|
||||||
|
|
||||||
|
The original faucet... I was not very good at CSS back then:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
I launched the faucet sometime in October, and it was a great success thanks to everyone in the Banano community. Soon after, SaltyWalty made an awesome PR that got the faucet looking a lot better. Later, Nano and xDai support was also added to the faucet.
|
||||||
|
For the next 2 years or so, the faucet was sustained by the generosity of many donors, and I was able to help dozens of others to launch their own Banano faucet, or faucets for other currencies.
|
||||||
|
|
||||||
|
It's great to see the Banano faucet scene now thriving, and along the way I've also been commissioned to make faucets to help first time users with gas fees on other chains, like Polygon and Arbitrum Nova.
|
||||||
|
|
||||||
|
## Version 2
|
||||||
|
A couple months ago, I decided I was not satisfied with the code quality of the [original faucet](https://github.com/jetstream0/Banano-Faucet), and started rewriting the code from scratch. The new goal was not just better code, but also a config file that people with just a little, or no technical experience, could modify and easily, quickly start their own faucets.
|
||||||
|
|
||||||
|
HalfBakedBread and I finally finished Faucet v2, and the current version of faucet.prussia.dev is using it! I also added Vite support with the help of KaffinPX. Of course, it is open source on [Github](https://github.com/jetstream0/Faucet-v2).
|
||||||
|
|
||||||
|
Since the faucet was having problems with Replit (specifically connecting to the mongodb database as well as bad uptime), the host was also migrated from Replit to Render, which will hopefully work better.
|
||||||
@@ -1,29 +1,290 @@
|
|||||||
{
|
{
|
||||||
"example": {
|
"meta": {
|
||||||
"title": "Example!",
|
"title": "Meta",
|
||||||
"slug": "example",
|
"slug": "meta",
|
||||||
"filename": "example",
|
"filename": "meta",
|
||||||
"date": "30/12/1999",
|
"date": "01/08/2023",
|
||||||
"author": "Prussia",
|
"author": "jet/Prussia",
|
||||||
"tags": ["example", "fake", "please remember to remove"],
|
"tags": ["meta", "code", "project", "web", "markdown", "typescript_javascript", "css"],
|
||||||
"archived": false
|
"archived": false
|
||||||
},
|
},
|
||||||
"another_example": {
|
"neet-admiration": {
|
||||||
"title": "Another Example!",
|
"title": "NEETs are good, actually",
|
||||||
"slug": "another-example",
|
"slug": "neet-admiration",
|
||||||
"filename": "another_example",
|
"filename": "neet_admiration",
|
||||||
"date": "31/07/2023",
|
"date": "13/12/2025",
|
||||||
"author": "John Dough",
|
"author": "jet/Prussia",
|
||||||
"tags": ["example", "jia", "please remember to remove"],
|
"tags": ["neet", "lifestyle"],
|
||||||
"archived": false
|
"archived": false
|
||||||
},
|
},
|
||||||
"archived_post": {
|
"haguro-book-review": {
|
||||||
"title": "Archived Post",
|
"title": "Book Review: A Religious Study of the Mount Haguro Sect of Shugendo",
|
||||||
"slug": "archived",
|
"slug": "haguro-book-review",
|
||||||
"filename": "archived_post",
|
"filename": "haguro_book_review",
|
||||||
"date": "17/01/2024",
|
"date": "09/11/2025",
|
||||||
"author": "John Dough",
|
"author": "jet/Prussia",
|
||||||
"tags": ["example"],
|
"tags": ["religion", "book"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"manga-translation-one-rule": {
|
||||||
|
"title": "What makes a manga translation good?",
|
||||||
|
"slug": "manga-translation-one-rule",
|
||||||
|
"filename": "manga_translation_one_rule",
|
||||||
|
"date": "28/10/2025",
|
||||||
|
"author": "prussianbluehedgehog",
|
||||||
|
"tags": ["manga", "complaint"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"dns-server-misadventures": {
|
||||||
|
"title": "DNS Server Misadventures",
|
||||||
|
"slug": "dns-server-misadventures",
|
||||||
|
"filename": "dns_server_misadventures",
|
||||||
|
"date": "05/04/2025",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["dns", "web", "intranet", "complaint"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"the-ming-wm-philosophy": {
|
||||||
|
"title": "The ming-wm Philosophy",
|
||||||
|
"slug": "the-ming-wm-philosophy",
|
||||||
|
"filename": "the_ming_wm_philosophy",
|
||||||
|
"date": "21/02/2025",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["project", "linux", "window_manager", "rust"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"operation-media-freedom": {
|
||||||
|
"title": "Operation Media Freedom",
|
||||||
|
"slug": "operation-media-freedom",
|
||||||
|
"filename": "operation_media_freedom",
|
||||||
|
"date": "01/09/2024",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["selfnet", "project", "music", "manga", "anime", "web", "typescript_javascript"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"recommended-blogs": {
|
||||||
|
"title": "Recommended Blogs",
|
||||||
|
"slug": "recommended-blogs",
|
||||||
|
"filename": "recommended_blogs",
|
||||||
|
"date": "30/08/2024",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["lists"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"two-types-of-brutalism": {
|
||||||
|
"title": "Two Types of Brutalism",
|
||||||
|
"slug": "two-types-of-brutalism",
|
||||||
|
"filename": "two_types_of_brutalism",
|
||||||
|
"date": "17/07/2024",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["brutalism", "web", "design"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"new-hobbies": {
|
||||||
|
"title": "New Hobbies",
|
||||||
|
"slug": "new-hobbies",
|
||||||
|
"filename": "new_hobbies",
|
||||||
|
"date": "25/05/2024",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["manga", "toki_pona", "copyright"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"bananopie": {
|
||||||
|
"title": "Bananopie",
|
||||||
|
"slug": "bananopie",
|
||||||
|
"filename": "bananopie",
|
||||||
|
"date": "26/12/2023",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["code", "python", "cryptocurrency"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"golfing-and-scheming": {
|
||||||
|
"title": "Golfing and Scheming",
|
||||||
|
"slug": "golfing-and-scheming",
|
||||||
|
"filename": "golfing_and_scheming",
|
||||||
|
"date": "23/12/2023",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["code", "typescript_javascript", "python", "scheme", "code golf"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"hash-functions": {
|
||||||
|
"title": "Hash Functions",
|
||||||
|
"slug": "hash-functions",
|
||||||
|
"filename": "hash_functions",
|
||||||
|
"date": "13/12/2023",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["cryptography"],
|
||||||
|
"archived": true
|
||||||
|
},
|
||||||
|
"rushed-captcha": {
|
||||||
|
"title": "Rushed Captcha Rewrite",
|
||||||
|
"slug": "rushed-captcha-rewrite",
|
||||||
|
"filename": "rushed_captcha_rewrite",
|
||||||
|
"date": "13/12/2023",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["project", "hosting", "typescript_javascript", "ruby", "faucets"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"dbless-captcha": {
|
||||||
|
"title": "DBless Captcha",
|
||||||
|
"slug": "dbless-captcha",
|
||||||
|
"filename": "dbless_captcha",
|
||||||
|
"date": "13/11/2023",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["project", "ruby", "docs"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"downloading-my-spotify-playlist-for-free": {
|
||||||
|
"title": "Downloading my Spotify Playlist for Free",
|
||||||
|
"slug": "downloading-my-spotify-playlist-for-free",
|
||||||
|
"filename": "downloading_my_spotify_playlist_for_free",
|
||||||
|
"date": "27/10/2023",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["code", "typescript_javascript", "bash", "music"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"ryuji-rust": {
|
||||||
|
"title": "Ryuji Rust",
|
||||||
|
"slug": "ryuji-rust",
|
||||||
|
"filename": "ryuji_rust",
|
||||||
|
"date": "27/10/2023",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["rust", "project"],
|
||||||
|
"archived": true
|
||||||
|
},
|
||||||
|
"llm": {
|
||||||
|
"title": "LLM",
|
||||||
|
"slug": "llm",
|
||||||
|
"filename": "llm",
|
||||||
|
"date": "16/09/2023",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["complaint"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"hex-to-bytes-and-back": {
|
||||||
|
"title": "Hex to Bytes and Back",
|
||||||
|
"slug": "hex-to-bytes-and-back",
|
||||||
|
"filename": "hex_to_bytes_and_back",
|
||||||
|
"date": "15/09/2023",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["typescript_javascript", "code", "math"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"rss-feed": {
|
||||||
|
"title": "RSS!",
|
||||||
|
"slug": "rss-feed",
|
||||||
|
"filename": "rss_feed",
|
||||||
|
"date": "19/08/2023",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["meta", "typescript_javascript", "project", "web"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"fermats-little-theorem": {
|
||||||
|
"title": "Fermats Little Theorem",
|
||||||
|
"slug": "fermats-little-theorem",
|
||||||
|
"filename": "fermats_little_theorem",
|
||||||
|
"date": "12/08/2023",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["code", "typescript_javascript", "math"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"wikipedia-rabbitholes": {
|
||||||
|
"title": "Wikipedia Rabbitholes",
|
||||||
|
"slug": "wikipedia-rabbitholes",
|
||||||
|
"filename": "wikipedia_rabbitholes",
|
||||||
|
"date": "09/08/2023",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["reading", "history", "wikipedia", "lists"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"eve": {
|
||||||
|
"title": "Eve",
|
||||||
|
"slug": "eve",
|
||||||
|
"filename": "eve",
|
||||||
|
"date": "06/08/2023",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["cryptography"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"month-start-unix": {
|
||||||
|
"title": "Finding the Unix Timestamp of the Start of the Month with Javascript",
|
||||||
|
"slug": "month-start-unix",
|
||||||
|
"filename": "month_start_unix",
|
||||||
|
"date": "01/04/2023",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["code", "web", "typescript_javascript"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"190k-faucet": {
|
||||||
|
"title": "190000 Payouts!",
|
||||||
|
"slug": "190-faucet",
|
||||||
|
"filename": "190k_faucet",
|
||||||
|
"date": "12/02/2023",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["project", "web", "milestone", "cryptocurrency", "faucets"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"adding-commas": {
|
||||||
|
"title": "Adding Commas to Numbers",
|
||||||
|
"slug": "adding-commas",
|
||||||
|
"filename": "adding_commas",
|
||||||
|
"date": "15/11/2022",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["code", "typescript_javascript"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"solving-problems-with-a-timeout": {
|
||||||
|
"title": "Solving Problems With a Timeout",
|
||||||
|
"slug": "solving-problems-with-a-timeout",
|
||||||
|
"filename": "solving_problems_with_a_timeout",
|
||||||
|
"date": "19/08/2022",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["bot", "typescript_javascript"],
|
||||||
|
"archived": true
|
||||||
|
},
|
||||||
|
"gobanme-v1-2": {
|
||||||
|
"title": "GoBanMe v1.2",
|
||||||
|
"slug": "gobanme-v1-2",
|
||||||
|
"filename": "gobanme_v1-2",
|
||||||
|
"date": "30/05/2022",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["release", "cryptocurrency"],
|
||||||
|
"archived": true
|
||||||
|
},
|
||||||
|
"fake-typing-effect": {
|
||||||
|
"title": "Making a Fake Typing Effect",
|
||||||
|
"slug": "fake-typing-effect",
|
||||||
|
"filename": "fake_typing_effect",
|
||||||
|
"date": "27/01/2022",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["code", "web", "typescript_javascript"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"ryuji-docs": {
|
||||||
|
"title": "Ryuji Documentation",
|
||||||
|
"slug": "ryuji-docs",
|
||||||
|
"filename": "ryuji_docs",
|
||||||
|
"date": "02/08/2023",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["code", "project", "web", "docs", "typescript_javascript"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"saki-docs": {
|
||||||
|
"title": "Saki Documentation",
|
||||||
|
"slug": "saki-docs",
|
||||||
|
"filename": "saki_docs",
|
||||||
|
"date": "02/08/2023",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["code", "project", "web", "build", "docs", "typescript_javascript"],
|
||||||
|
"archived": false
|
||||||
|
},
|
||||||
|
"pilanimate": {
|
||||||
|
"title": "PilAnimate Docs",
|
||||||
|
"slug": "pilanimate",
|
||||||
|
"filename": "pilanimate",
|
||||||
|
"date": "Either 2022 or 2021?",
|
||||||
|
"author": "jet/Prussia",
|
||||||
|
"tags": ["code", "project", "animation", "images"],
|
||||||
"archived": true
|
"archived": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
47
posts/adding_commas.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
I had to add commas to a number in Javascript today. I thought it was kinda interesting, and here is what I came up with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function format_commas(amount) {
|
||||||
|
let amount_mod = String(amount);
|
||||||
|
//iterate the amount of commas there are
|
||||||
|
for (let i=0; i < Math.floor((String(amount).length-1)/3); i++) {
|
||||||
|
let position = amount_mod.length-3*(i+1)-i;
|
||||||
|
amount_mod = amount_mod.substring(0, position)+","+amount_mod.substring(position, amount_mod.length);
|
||||||
|
}
|
||||||
|
return amount_mod;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Basically, we calculate how many commas we will need to add (`Math.floor((String(amount).length-1)/3)`). If the `amount` is 3 digits, we need 0 commas, since `floor((3-1)/3) = floor(2/3) = 0`. If the `amount` is 7 digits, we need 2 commas, since `floor((7-1)/3) = floor(6/3) = 2`. And so on.
|
||||||
|
|
||||||
|
Then, we do a for loop with that number, and insert our commas, *starting from the back*. We find the position where we need to split the string in half, and then insert a comma in between the two halves of the string.
|
||||||
|
|
||||||
|
I think the most interesting part of this code was the 5th line (`let position = amount_mod.length-3*(i+1)-i;`). You might be wondering with the `-i` at the end is necessary. That's there because we are increasing the string's length by adding a comma, so we need to offset it. Remember, we are inserting commas starting from the back of the string, so we are subtracting to offset, not adding.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Here is a version that can handle decimals (keep in mind that Javascript does cut off decimals after a certain point), negative numbers and invalid inputs:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function format_commas(amount) {
|
||||||
|
if (isNaN(Number(amount))) {
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
let negative = amount < 0;
|
||||||
|
amount = Math.abs(amount);
|
||||||
|
let before_dec = String(amount).split('.')[0];
|
||||||
|
let amount_mod = before_dec;
|
||||||
|
//iterate the amount of commas there are
|
||||||
|
for (let i=0; i < Math.floor((before_dec.length-1)/3); i++) {
|
||||||
|
let position = amount_mod.length-3*(i+1)-i;
|
||||||
|
amount_mod = amount_mod.substring(0, position)+","+amount_mod.substring(position, amount_mod.length);
|
||||||
|
}
|
||||||
|
if (String(amount).split('.')[1]) {
|
||||||
|
amount_mod = amount_mod+"."+String(amount).split('.')[1];
|
||||||
|
}
|
||||||
|
if (negative) {
|
||||||
|
amount_mod = `-${amount_mod}`;
|
||||||
|
}
|
||||||
|
return amount_mod;
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
Have some lorem ~~ipsvm~~ ipsum. On the house!
|
|
||||||
...
|
|
||||||
No, I **insist**. Don't be shy.
|
|
||||||
|
|
||||||
1. lorem
|
|
||||||
2. ipsum
|
|
||||||
|
|
||||||
Repellat et vel consequuntur et. Suscipit animi ipsam tempora consequatur hic ea tenetur. Culpa rerum quos eum vero ea.
|
|
||||||
Aperiam quia facilis doloremque ducimus. Amet quas similique officia quas et enim aut. Non ut vel sint distinctio consectetur ipsa.
|
|
||||||
Illum id sit laboriosam corrupti veritatis et quam. Ut ea quaerat omnis doloribus enim. Sed atque ad est doloribus esse.
|
|
||||||
|
|
||||||
Molestias omnis ut voluptatem. Eius vel deleniti quia quam odio. Adipisci voluptas dolor nulla voluptatem. Molestiae sunt veritatis qui ex atque molestiae nobis. Expedita repellat dolores adipisci.
|
|
||||||
|
|
||||||
Deleniti totam molestiae necessitatibus rem dolores. Natus ut quos beatae aut. Corrupti eum provident perferendis eum dolores maiores eos.
|
|
||||||
262
posts/bananopie.md
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
Bananopie was written with the aim of being the Python equivalent of Banano.js (but better :)) and furthering my understanding of the Nano/Banano protocol. I learned quite a bit about how blocks were constructed and whatnot. Very fun, would recommend.
|
||||||
|
|
||||||
|
Anyways, I think it's acheived that goal, and hopefully went a bit above and beyond in simplicity and powerfulness (it has some useful functions that Banano.js does not, like `send_all` and the old message signing, as well as local work generation).
|
||||||
|
|
||||||
|
The only two frustrations I had while writing Bananopie was not knowing whether certain things were big-endian or little-endian, since the Nano docs don't specify (I just tested against the output of Banano.js or wallets), and also dealing with Python's decimal precision fuckery.
|
||||||
|
|
||||||
|
You can see the syntax and documentation on [Github](https://github.com/jetstream0/bananopie) so I won't bother with that.
|
||||||
|
|
||||||
|
What I *do* want to talk about is how blocks are constructed (exciting, I know).
|
||||||
|
|
||||||
|
Remember, Banano/Nano is a DAG, not a blockchain. In Bitcoin, blocks contain multiple transactions are in one long chain. In Nano, each account (address), has it's own chain of blocks, where each transaction is one block. All those chains are connected to each other through sends and receive blocks.
|
||||||
|
|
||||||
|
In general, Nano blocks can be classified into three subtypes: send, receive, and change Representative, each which does exactly what you would expect. There is no restriction that only change blocks can change representatives - send and receive blocks can also change representatives.
|
||||||
|
|
||||||
|
All blocks have the following:
|
||||||
|
|
||||||
|
- type: Always "state"
|
||||||
|
- account: The address that is sending the block
|
||||||
|
- previous: The hash of the previous block of the account, or "0000000000000000000000000000000000000000000000000000000000000000" if the account is unopened (has never done any transactions)
|
||||||
|
- representative: The representative of the account that is sending the block
|
||||||
|
- balance: The balance of the account that is the sending the block
|
||||||
|
- link: Depends on the block type
|
||||||
|
|
||||||
|
## Send Block
|
||||||
|
|
||||||
|
A send block is one where the "balance" decreases. The "link" is the public key of the recipient.
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Wallet:
|
||||||
|
...
|
||||||
|
def send(self, to: str, amount: str, work = False, previous = None):
|
||||||
|
amount = whole_to_raw(amount)
|
||||||
|
address_sender = self.get_address()
|
||||||
|
private_key_sender = get_private_key_from_seed(self.seed, self.index)
|
||||||
|
#public_key_sender = get_public_key_from_private_key(get_private_key_from_seed(self.seed, self.index))
|
||||||
|
public_key_receiver = get_public_key_from_address(to)
|
||||||
|
info = self.get_account_info()
|
||||||
|
if not previous:
|
||||||
|
previous = info["frontier"]
|
||||||
|
representative = info["representative"]
|
||||||
|
before_balance = info["balance"]
|
||||||
|
#height not actually needed
|
||||||
|
new_balance = int(int(before_balance)-amount)
|
||||||
|
if new_balance < 0:
|
||||||
|
raise ValueError(f"Insufficient funds to send. Cannot send more than balance (before balance {str(before_balance)} less than send amount {str(amount)})")
|
||||||
|
block = {
|
||||||
|
"type": "state",
|
||||||
|
"account": address_sender,
|
||||||
|
"previous": previous,
|
||||||
|
"representative": representative,
|
||||||
|
"balance": str(new_balance),
|
||||||
|
#link in this case is public key of account to send to
|
||||||
|
"link": public_key_receiver,
|
||||||
|
"link_as_account": to
|
||||||
|
}
|
||||||
|
block_hash = hash_block(block)
|
||||||
|
signature = sign(private_key_sender, block_hash)
|
||||||
|
block["signature"] = signature
|
||||||
|
if work:
|
||||||
|
block["work"] = work
|
||||||
|
return self.send_process(block, "send")
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Receive Block
|
||||||
|
|
||||||
|
A receive block is one where the "balance" increases. The "link" is the hash of the block to receive.
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Wallet:
|
||||||
|
...
|
||||||
|
def receive_specific(self, hash: str, work = False, previous = None):
|
||||||
|
#no need to check as opened, I think?
|
||||||
|
#get block info of receiving
|
||||||
|
block_info = self.rpc.get_block_info(hash)
|
||||||
|
amount = int(block_info["amount"])
|
||||||
|
address_sender = self.get_address()
|
||||||
|
private_key_receiver = get_private_key_from_seed(self.seed, self.index)
|
||||||
|
#public_key_sender = get_public_key_from_private_key(get_private_key_from_seed(self.seed, self.index))
|
||||||
|
#public_key_sender = get_public_key_from_address(block_info["block_account"])
|
||||||
|
#these are the defaults, if the account is unopened
|
||||||
|
before_balance = 0
|
||||||
|
representative = address_sender
|
||||||
|
if not previous:
|
||||||
|
try:
|
||||||
|
#if account is opened
|
||||||
|
info = self.get_account_info()
|
||||||
|
previous = info["frontier"]
|
||||||
|
representative = info["representative"]
|
||||||
|
before_balance = info["balance"]
|
||||||
|
except Exception as e:
|
||||||
|
#probably, unopened account
|
||||||
|
previous = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
#height not actually needed
|
||||||
|
block = {
|
||||||
|
"type": "state",
|
||||||
|
"account": address_sender,
|
||||||
|
"previous": previous,
|
||||||
|
"representative": representative,
|
||||||
|
"balance": str(int(before_balance)+amount),
|
||||||
|
#link in this case is hash of send
|
||||||
|
"link": hash
|
||||||
|
}
|
||||||
|
block_hash = hash_block(block)
|
||||||
|
signature = sign(private_key_receiver, block_hash)
|
||||||
|
block["signature"] = signature
|
||||||
|
if work:
|
||||||
|
block["work"] = work
|
||||||
|
return self.send_process(block, "receive")
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Change Representative Block
|
||||||
|
|
||||||
|
A change block is one where the "balance" does not change, but the "representative" does. In this case, "link" is just "0000000000000000000000000000000000000000000000000000000000000000".
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Wallet:
|
||||||
|
...
|
||||||
|
def change_rep(self, new_representative, work = False, previous = None):
|
||||||
|
address_self = self.get_address()
|
||||||
|
private_key_self = get_private_key_from_seed(self.seed, self.index)
|
||||||
|
#public_key_sender = get_public_key_from_private_key(get_private_key_from_seed(self.seed, self.index))
|
||||||
|
#account must be opened to do a change rep
|
||||||
|
info = self.get_account_info()
|
||||||
|
if not previous:
|
||||||
|
previous = info["frontier"]
|
||||||
|
before_balance = info["balance"]
|
||||||
|
block = {
|
||||||
|
"type": "state",
|
||||||
|
"account": address_self,
|
||||||
|
"previous": previous,
|
||||||
|
"representative": new_representative,
|
||||||
|
"balance": before_balance,
|
||||||
|
#link in this case is 0
|
||||||
|
"link": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
}
|
||||||
|
block_hash = hash_block(block)
|
||||||
|
signature = sign(private_key_self, block_hash)
|
||||||
|
block["signature"] = signature
|
||||||
|
if work:
|
||||||
|
block["work"] = work
|
||||||
|
return self.send_process(block, "change")
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hashing Blocks
|
||||||
|
|
||||||
|
Before being signed, the block must be hashed. The resulting block hash is what you usually seen used to identify blocks (eg, to find a block on the block explorer, you would give it the block hash).
|
||||||
|
|
||||||
|
Let's look at Bananopie's `hash_block` function:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def hash_block(block) -> str:
|
||||||
|
blake_obj = blake2b(digest_size=32)
|
||||||
|
blake_obj.update(hex_to_bytes(PREAMBLE))
|
||||||
|
blake_obj.update(hex_to_bytes(get_public_key_from_address(block["account"])))
|
||||||
|
blake_obj.update(hex_to_bytes(block["previous"]))
|
||||||
|
blake_obj.update(hex_to_bytes(get_public_key_from_address(block["representative"])))
|
||||||
|
padded_balance = hex(int(block["balance"])).replace("0x","")
|
||||||
|
while len(padded_balance) < 32:
|
||||||
|
padded_balance = '0' + padded_balance
|
||||||
|
blake_obj.update(hex_to_bytes(padded_balance))
|
||||||
|
blake_obj.update(hex_to_bytes(block["link"]))
|
||||||
|
#return hash
|
||||||
|
return bytes_to_hex(blake_obj.digest())
|
||||||
|
```
|
||||||
|
|
||||||
|
`digest_size=32` means that the blake2b hash will have a 32 byte output, which makes sense, since block hashes are supposed to be 32 bytes.
|
||||||
|
|
||||||
|
The input for the hash is the preamble, the public key of the "account" field of the block, the "previous" of the block (hash of the previous block), the public key of the "representative" field of the block (the new/unchanged representative of the address), the "balance" field of the block, and the "link" field of the block, all concatenated.
|
||||||
|
|
||||||
|
For both Banano and Nano, the `PREAMBLE` in hexadecimal is `0000000000000000000000000000000000000000000000000000000000000006` (0x6). You see, Nano used to have different block types, each with it's own preamble: send (0x2), receive (0x3), open (0x4), change (0x5). Now, **everything is a state block, so all preambles are 0x6**.
|
||||||
|
|
||||||
|
A preamble could also be used to make sure Nano blocks can't be broadcasted to a Nano fork, and vice versa (prevent replay attacks). But as mentioned, Banano and Nano actually use the same preamble, so this actually isn't the case.
|
||||||
|
|
||||||
|
## Signing Blocks
|
||||||
|
|
||||||
|
The block hash must be cryptographically signed of the address to be valid. This proves that the address meant to create the block. If a block signature was not required, people could spend your funds without even having your private key (bad)!
|
||||||
|
|
||||||
|
The cryptography is complicated, but we don't need to worry about that. At a high level, signing blocks is very simple. You just need the private key and block hash, then boom, you got a signature:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def sign(private_key: str, hash: str) -> str:
|
||||||
|
#ed25519_blake2b verify
|
||||||
|
signing_key = ed25519_blake2b.SigningKey(hex_to_bytes(private_key))
|
||||||
|
signature = bytes_to_hex(signing_key.sign(hex_to_bytes(hash)))
|
||||||
|
return signature
|
||||||
|
```
|
||||||
|
|
||||||
|
## Generating Work
|
||||||
|
|
||||||
|
The Kalium public node generates nicely generates the block work for you, but not all nodes do.
|
||||||
|
|
||||||
|
In case the node being used doesn't generate block work, we'll need to do it ourselves. Bananopie has two work generation methods, `gen_work_random` and `gen_work_deterministic`. `gen_work_deterministic` is the default, but `gen_work_random` is easier to explain and basically the same as the deterministic way, so let's look at that:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def gen_work_random(hash: str, threshold: str) -> str:
|
||||||
|
#generate work with random.randbytes()
|
||||||
|
while True:
|
||||||
|
#work is 64 bit (8 byte) nonce
|
||||||
|
#only generate 3 random bytes, first 5 are 0s. I see kalium do that I think, so I dunno if its more efficient but I copied that
|
||||||
|
#nonce = hex_to_bytes("0000000000"+bytes_to_hex(random.randbytes(3)))
|
||||||
|
nonce = random.randbytes(8)
|
||||||
|
#when blake2b hashed with the hash, should be larger than the threshold
|
||||||
|
blake_obj = blake2b(digest_size=8)
|
||||||
|
blake_obj.update(nonce)
|
||||||
|
blake_obj.update(hex_to_bytes(hash))
|
||||||
|
#since hex_to_bytes returns big endian, for BANANO_WORK, after we convert to hex, we convert to bytes with big endian
|
||||||
|
if int.from_bytes(blake_obj.digest(), byteorder="little") > int.from_bytes(hex_to_bytes(threshold), byteorder="big"):
|
||||||
|
#return as big endian
|
||||||
|
return bytes_to_hex(bytearray.fromhex(bytes_to_hex(nonce))[::-1])
|
||||||
|
```
|
||||||
|
|
||||||
|
The block work is just 8 bytes that, when added to the block hash, and hashed again, is larger than the threshold. In Banano, the threshold is `FFFFFE0000000000` (18446741874686296064). In Nano, the threshold is larger, so Nano work takes longer to generate.
|
||||||
|
|
||||||
|
Basically, we generate random bytes until the hash of the block hash plus the random bytes is greater than the threshold (18446741874686296064). If it is, we found valid work for the block. Hurray!
|
||||||
|
|
||||||
|
## Broadcasting Blocks
|
||||||
|
|
||||||
|
Nothing fancy here. We just do a ["process"](https://docs.nano.org/commands/rpc-protocol/#process) RPC call to the node, which will broadcast the block:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Wallet:
|
||||||
|
...
|
||||||
|
def send_process(self, block, subtype: str):
|
||||||
|
payload = {
|
||||||
|
"action": "process",
|
||||||
|
"subtype": subtype,
|
||||||
|
"json_block": "true",
|
||||||
|
"block": block
|
||||||
|
}
|
||||||
|
if "work" not in block:
|
||||||
|
if self.try_work:
|
||||||
|
#if opening block, there is no previous, so use public key as hash instead
|
||||||
|
if block["previous"] == "0000000000000000000000000000000000000000000000000000000000000000":
|
||||||
|
block["work"] = gen_work(self.get_public_key())
|
||||||
|
else:
|
||||||
|
block["work"] = gen_work(block["previous"])
|
||||||
|
else:
|
||||||
|
payload["do_work"] = True
|
||||||
|
return self.rpc.call(payload)
|
||||||
|
...
|
||||||
|
|
||||||
|
class RPC:
|
||||||
|
...
|
||||||
|
#send rpc calls
|
||||||
|
def call(self, payload):
|
||||||
|
headers = {}
|
||||||
|
#add auth header, if exists
|
||||||
|
if self.auth:
|
||||||
|
headers['Authorization'] = self.auth
|
||||||
|
resp = requests.post(self.rpc_url, json=payload, headers=headers)
|
||||||
|
#40x or 50x error codes returned, then there is a failure
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
raise Exception("Request failed with status code "+str(resp.status_code))
|
||||||
|
resp = resp.json()
|
||||||
|
if "error" in resp:
|
||||||
|
raise Exception("Node response: "+resp["error"])
|
||||||
|
return resp
|
||||||
|
...
|
||||||
|
```
|
||||||
72
posts/dbless_captcha.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
Google's Recaptcha costs money for large customers, and is biased towards those who are using Chrome or are logged in with a Google account. Hcaptcha can be easily bypassed with an accessibility cookie.
|
||||||
|
|
||||||
|
Seeing these problems, and since it seemed interesting, I decided to make my own text captcha.
|
||||||
|
|
||||||
|
It wouldn't a (good) replacement, since text captchas can be cracked trivially with a ML model. I'm sure most Recaptchas and hCaptchas can also be broken with machine learning, but those can be bypassed a lot easier by paying fractions of a cent per solve to a captcha solving service like 2captcha (which iirc just turns around and pays real people to do them). Still, just needing to run a model or pay someone discourages would-be botters, as they may decide it's not worth the effort.
|
||||||
|
|
||||||
|
Back on topic, while my text captcha wouldn't be a replacement for stuff like Recaptcha, there are other uses. For example, on platforms like Discord, if someone wanted to have a bot with a captcha (usually to prevent bots spamming members with scammy DMs), the only way they could use Recaptcha or hCaptcha would be by directing users to their own website, where they have the captcha. That's pretty annoying for the user and adds complexity to the bot. Ideally, users should be able to complete the captcha without leaving Discord.
|
||||||
|
|
||||||
|
In that case, a text captcha with a good API would mean that all the bot has to do is send a message with the image of the captcha (image URL provided by the API, of course), then wait for the user to respond, and check if it is correct. Having an API would mean the captcha wouldn't be restricted to just Discord - it could be used on any service with a backend.
|
||||||
|
|
||||||
|
Additionally, I didn't feel like using a database, as it would mean more setup, so I tried using cryptography to make a database unnecessary.
|
||||||
|
|
||||||
|
I don't think this was a terribly interesting project, code or concept wise, but I just felt like writing this. Oh, learning about Salsa20 was cool though.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
The server has a 32 byte secret key. When it is asked for a captcha, it generates a 8 byte nonce, and a 6 character code (the alphanumeric thing the user needs to be into the captcha). The code is encrypted with the secret key and nonce by Salsa20.
|
||||||
|
|
||||||
|
> Nonces in cryptographically, are random numbers that are one-time use to improve security. If the same nonce is used more than once though, security may be compromised.
|
||||||
|
|
||||||
|
Then, once the server gets a request to generate the captcha image (the request provides it with the nonce and encrypted code), it decrypts the encrypted code with the given nonce and the secret key, then generates the image of that decrypted text.
|
||||||
|
|
||||||
|
Finally, once the server gets a request to verify that the user's answer to the captcha is correct. It is given the user's guess, encrypted code, and nonce. Again, it decrypts the encrypted code with nonce and secret key. If the decrypted code matches the user's guess, it is correct. If not, the user is wrong.
|
||||||
|
|
||||||
|
Since the client does not know the server's secret key, they cannot know the code, unless they read the captcha image. All that cryptography also allows the server to create and verify the captcha without storing information anywhere (no database needed!).
|
||||||
|
|
||||||
|
There is also a timestmap that is appended to the code before it is encrypted, that allows captchas to expire (eg, after 5 minutes, you need to ask for a new captcha if you haven't successfully solved this one).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### GET `/captcha`
|
||||||
|
Returns `image`, `code`, and `nonce`. Here, the `code` is the encrypted 6 character alpha-numeric code that the user is supposed to read the captcha and solve for. I don't know why I called both the encrypted and unencrypted codes "code". Sorry if it's confusing. Here's an example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"image": "20ee1a711f77e7aba151eb66584ed8e374.png",
|
||||||
|
"code": "20ee1a711f77e7aba151eb66584ed8e374",
|
||||||
|
"nonce": "40ae72c55dda39fb"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET `/encrypted/<encrypted>.png?nonce=<nonce>`
|
||||||
|
Decrypt encrypted with the nonce and the secret key, extracting the code from the decrypted text. Create a 210x70 png with the code text. Draw dots and lines and blurs and whatnot to make it a little bit harder to automate.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### POST `/captcha`
|
||||||
|
This endpoint is to verify whether a user successfully solved a captcha. The encrypted `code`, `nonce`, and user's `guess` must be sent in a payload. The ruby captcha wants the payload to be sent as form data, while the Node.js captcha wants it as JSON.
|
||||||
|
|
||||||
|
The response will be JSON with either `"success": true` or `"success": false`. Success means the user successfully solved the captcha. In the Node.js version, there may be an `"error"` (in addition to the `"success": false`) key if the request sent is invalid.
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> I thought I could streamline the process and remove the need for the POST request to `/captcha` by returning the [hashed and salted](/posts/hashing) answer, so the service that uses the captcha could validate the result for itself. But then I remembered why I went for symmetric encryption instead of hashing in the first place: the captcha service needed to know the image's text content to generate the image, and hashing would obviously make that impossible.
|
||||||
|
> It could still be done if I stored a map of image URL to the answer in the database, but the entire point of this project was to **not** use a database. Also, if I was storing stuff into the database, there was no point in hashing, as I could associate any key with the answer. Plus, hashing it without a salt would be terribly insecure, since anyone could easily pre-compute the hashes of all possible 6 character alpha-numeric strings and match them to instantly solve the captcha.
|
||||||
|
|
||||||
|
## How To Use The API
|
||||||
|
|
||||||
|
Here's how you would probably do it:
|
||||||
|
|
||||||
|
1. Send GET request to `/captcha`. Send the image and nonce to the user.
|
||||||
|
2. Get the user to submit an answer, along with the code (the image url without the ".png") and nonce.
|
||||||
|
3. Make POST request to `/captcha` with the code, nonce, and user's guess.
|
||||||
|
4. Let them through if the request gives a successful response. Do not if it doesn't.
|
||||||
|
|
||||||
|
Try the captcha out at [captcha.prussia.dev](https://captcha.prussia.dev), and see the code on [Github](https://github.com/jetstream0/Captcha).
|
||||||
132
posts/dns_server_misadventures.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
> About a year ago, I wrote a DNS over HTTPS server for an intranet. Recently, I [wrote one](https://github.com/stjet/poc-bns-doh-and-resolver) for BNS (Banano Name System). The code is very messy. Both times, I ran into incredibly frustrating situations that apply more broadly into why I don't really like the World Wide Web. Don't expect this to be too well written, the first half is an explanation of the parts of the DNS protocol that I implemented, and the second half is a rant about how the World Wide Web works.
|
||||||
|
|
||||||
|
## DNS? Deep Nautical... Submarines?
|
||||||
|
|
||||||
|
Let's get a few definitions out of the way first.
|
||||||
|
|
||||||
|
DNS stands for the "Domain Name System". To simplify and omit, when you visit a website, DNS is how your computer knows what server to send requests to. After all, what does a domain name (say, prussiafan.club or www.example.com) mean to a computer? Nothing. Your computer doesn't know what to do with this. So, it asks a DNS server what IP address is behind the domain name. Now, an IP address (Internet Protocol address; four 8-bit numbers separated by dots eg 127.0.0.1) is something your computer *does* know how to deal with!
|
||||||
|
|
||||||
|
This is not the right place to get into the muck of how the internet works, but an IP address is just like a real address ("10 Downing Street"): there is a *lot* of infrastructure and code that makes it very easy for your computer to find where it is. In contrast, with a domain name, some extra effort needs to be spent: you can't send mail to "That place where that [adjective] Margaret Thatcher used to live", you'd have to figure out the address first.
|
||||||
|
|
||||||
|
So, a DNS servers is basically some computer out there that translate domain names into IP addresses.
|
||||||
|
|
||||||
|
DoH stands for "DNS over HTTPS". Normally, DNS requests are sent unencrypted to the DNS server, and so is the response. So, any party in-between them could tamper or read the contents of the message, which would be very bad^tm^. The first concern, tampering, is less of a concern nowadays because HTTPS prevents it (since any tamperers will very probably not be able to get HTTPS certificates for the domain they are pretending to be). The second is very real, and can be used for censorship or sale of data to advertisers. Well, it does need to be noted that it is usually the DNS servers themselves doing the censorship and selling data. DNSSEC allows for signing records, which means any tampering (including by the DNS server itself!) is obvious, but doesn't prevent spying on the DNS messages. Also, it is not very commonly used. DoT (DNS over TLS) encrypts the messages between the client (your computer) and the DNS server, which prevents tampering and spying by anyone in-between. DoH is very similar to DoT, but instead of using the low level TLS protocol, DNS messages are sent using HTTPS to a domain which acts like a DNS server. The advantage is that DoH requests are very hard to censor as they look like any other HTTPS request, while DoT can easily be blocked because they look unique and go to a unique port.
|
||||||
|
|
||||||
|
But hey, wait a second! Since DoH requests are sent to a domain name, but DNS is required in order to figure out what server that domain name is... isn't this a chicken and egg problem? Sure, but there is a boring solution. To resolve the DoH domain name, use regular DNS. Now, DoH can be used exclusively. Boring, yes, but it works. This doesn't undermine the security of the whole thing, because as stated earlier, HTTPS means that the client can be confident that it is sending messages to the correct server. We'll come back to this HTTPS stuff later, mostly to complain, but but there will be some explain too.
|
||||||
|
|
||||||
|
## Why?
|
||||||
|
|
||||||
|
I actually worked on two DoH servers. One was for my intranet, where subdomains of one of my domains would resolve to local IP addresses (either on my home network or on Tailscale), so I could have a more convenient way of accessing my self-hosted services (ie, not having to memorise and type in their IP addresses), that didn't involve exposing my home network to the rest of the scary world.
|
||||||
|
|
||||||
|
The second is a DNS server for BNS (Banano Name System). BNS allows infinite, decentralised TLDs, and once a TLD issues a domain, it cannot be revoked by the TLD, and the domain does not need to be renewed. It will never expire. Everything is cryptographically verifiable as it builds on top of the Banano block lattice. DNS records (and other arbitrary metadata) can be encoded on-chain as a IPFS hash, and then the DoH server can resolve it.
|
||||||
|
|
||||||
|
## Actually writing the thing
|
||||||
|
|
||||||
|
The first hurdle was, of course, to actually write the server - parse the DNS request and give a response. Luckily, this is all well documented in RFC 1035 (which covers the DNS message format) and RFC 8484 (which covers how DNS over HTTPS works). Alright, "well documented" is not entirely accurate, but it is documented *enough*. What was unclear or confusing clarified with the help of the TCP/IP guide, logging actual requests/responses, and in one case, a random blog post. In my case, I mainly wanted DoH to work for browsers, so there some parts of the protocol I could ignore. I'll somewhat briefly summarise the relevant parts of the format.
|
||||||
|
|
||||||
|
### Message format
|
||||||
|
|
||||||
|
Both requests ("queries") and responses ("replies") have the same format.
|
||||||
|
|
||||||
|
#### The header
|
||||||
|
|
||||||
|
All DNS messages start with a 12-byte header (see RFC 1035 Section 4.1.1).
|
||||||
|
|
||||||
|
The first part of the header is a 16-bit (2 byte) ID that the request tells the server, and the server copies so when it sends a response to the client, the client knows which message the server is responding to. This is not needed when doing DoH since HTTP can already associate a request with a response. So, the ID will just be all 0s.
|
||||||
|
|
||||||
|
The next part of the header is 16 bits (2 bytes) of flags:
|
||||||
|
|
||||||
|
```
|
||||||
|
QR (query: 0, reply: 1), 1 bit
|
||||||
|
OPCODE (standard: 0), 4 bits (opcode in query is repeated in response)
|
||||||
|
AA (in response, if authorative answer for hostname), 1 bit
|
||||||
|
TC (whether message was truncated), 1 bit
|
||||||
|
RD (in request, then copied in response, where recursion desired), 1 bit
|
||||||
|
RA (in response, whether recursion available), 1 bit
|
||||||
|
Z (reserved), 3 bits
|
||||||
|
RCODE (response code, NOERROR: 0, FORM(at)ERR: 1, SERVFAIL: 2, NXDOMAIN: 3, Not Implemented: 4, Refused: 5), 4 bits
|
||||||
|
```
|
||||||
|
|
||||||
|
`OPCODE` also has `1` (inverse query) and `2` (server status request). Our server won't need to worry about that. At least, afaik browsers don't do inverse queries or ask for server status, so we'll leave that unimplemented. We can also ignore the truncation, all of our response records will be short enough that it won't be a concern.
|
||||||
|
|
||||||
|
So, a normal query will have the QR flag set to `0`. In our reply, we can copy the queries' flags, changing the QR to `1`, changing the RA to `0` (we *could* implement recursion for CNAMEs but if we indicate we don't support it, the browser will do it for us, saving us a lot of work), then finally changing the RCODE depending on what we find. Typically, our RCODE will either be NOERROR (if we found the intranet domain) or NXDOMAIN (if the intranet domain doesn't exist).
|
||||||
|
|
||||||
|
The final part of the header are four 16-bit integers (so 64 bits, or 8 bytes) that, in order, tell us the question count, resource record count in the answer section, name server resource record count in the authority section, and the resource record count in the additional records section.
|
||||||
|
|
||||||
|
Browsers seem to send queries with only one question, which saves us yet more work. Queries will have a count of 0 for the rest of the counts as queries are requesting records, not sending them to the server. You might imagine that the reply would have 0 questions, since it is a reply, but we actually need to copy the question in the reply. So the reply with a question count of 1. I only care about responding to CNAME (other domains) or A (IPv4 addresses) record requests, and (assuming no CNAME recursion) those will only have 1 per domain/subdomain, so the answer section resource record count will usually be 1. That is, unless the intranet domain doesn't exist, in which case RCODE in the previous section of the header will be NXDOMAIN as mentioned, and the answer section count will be 0 for obvious reasons. For our intranet domains, they do not have nameservers (DNS servers which act as the final authority on what records a domain has), so the authority section count will be 0. I'm not totally sure what the use case of the additional records section would be. Since my browser only sends requests for A or AAAA (IPv6) records for domains, I thought the additional record section might be where the CNAME is supposed to be (a domain/subdomain can't have both a CNAME record and an A/AAAA record). Alas, that doesn't seem to be the case. Even if an A record is requested, the CNAME should be in the answer section (and if the server supports CNAME recursion, then it will be followed by A/AAAA/CNAME records of the domain the CNAME is pointing at). So, in our server's reply, the additional records section count will also be 0.
|
||||||
|
|
||||||
|
Going a bit off-topic from what this section is supposed to be, what is CNAME record anyways? A/AAAA records tell the client what IPv4/6 address the domain name points at, and if you remember, IP addresses are what computers want (just like how plants crave electrolytes). A CNAME record points to *another* domain, which hopefully has an A/AAAA record. Or, it could have another CNAME record, that the resolver would have to keep following... if the CNAME records point at each other, that would create an infinite loop! Luckily, those are easy to detect and won't crash modern DNS servers. We can avoid all this by setting the RA flag to `0` and having the browser handle that, as previously mentioned.
|
||||||
|
|
||||||
|
#### Questions
|
||||||
|
|
||||||
|
This is covered in RFC 1035's Section 4.1.2. To resummarise, questions are composed of three parts, a QNAME (variable length), QTYPE (2 bytes), and QCLASS (2 bytes). The QNAME is a little complicated, so we'll get back to that later. The QTYPE is the type of record being requested (eg `1` for A records, `5` for CNAME records). QCLASS is even simpler, it will always be `1` for IN (internet). The other option for QTYPE is for [Chaosnet](https://en.wikipedia.org/wiki/Chaosnet), in case you were curious.
|
||||||
|
|
||||||
|
So, let's tackle QNAMEs. QNAMEs are domain names encoded in a way that makes it easier for the parser to know when it ends. They are made out of components called labels, which are also used in resource records.
|
||||||
|
|
||||||
|
The easiest way to explain is to show. Consider a domain name `chat.example.org`. It is composed of three parts, `chat`, `example`, and `org`, right? Well actually technically, it is composed of four parts, since `chat.example.org` is really a convenient shortening of `chat.example.org.`. These parts are actually called "zones" (well, a little more complicated than that but it really doesn't matter). The reason why there is an extra dot is because that is the root zone, which ICANN manages. ICANN is the terrible organisation that decides what TLDs (top level domains, think `.com`, `.org`, `.ninja`, `.baidu`) exist, and what company/non-profit gets to administer them. ccTLDs (country code TLDs like `.uk`, `.jp`, `.de`) are supposed to be managed by whoever the nations appoint, though since ICANN controls the root zone in practice they *could* take over. Anyways, each zone is a label.
|
||||||
|
|
||||||
|
A label starts with one byte stating the length of the zone name, and then zone name in bytes. So for example, `chat.example.org` would become `4chat7example3org0`. Imagine the letters are in their ASCII byte representation. The final 0 is the because the root zone has no name. This also means we know when the domain name is over if the length is 0.
|
||||||
|
|
||||||
|
Simple, right? Yup. Except, wait! Terrible news, there's more. As the authors of the RFCs were concerned with compression, they wanted to avoid repeating too much. Imagine we return a hundred records for `chat.example.org`, `4chat7example3org0` would be repeated an awful lot. If the first two bits of one of the lengths is `11` (I neglected to mention earlier the length must start with the bits 00, and so be less than 6 bits, or 64), then that label is a pointer to a domain name encoded earlier. The remaining 6 bis is the offset to the domain name the pointer is pointing at. The good news is that pointers are optional, so we won't bother with them in the resource records in our replies. But we do need to know how to parse them from the queries.
|
||||||
|
|
||||||
|
#### Answer Section
|
||||||
|
|
||||||
|
So, assuming whatever hostname the requester is asking for information on exists, we'll return the answer in the... answer section. Specifically as a resource record, whose format is specified in Section 4.1.3. Resource records are composed of six parts: NAME, TYPE, CLASS, TTL, RDLENGTH, RDATA.
|
||||||
|
|
||||||
|
NAME is the same as the QNAMEs in the question section. So nothing new here. TYPE is technically a subset of the QTYPE in the question section, but still uses the same integers to represent A records, CNAME records, etc, so nothing new here either. CLASS is the same QCLASS, it should be `1` for IN (Internet).
|
||||||
|
|
||||||
|
TTL means "Time to Live", or the time, in seconds, that the client should cache the DNS response. It is 32 bits (4 bytes). If the DNS record changes frequently, it's a good idea to set this to a small number. 10 minutes? 30 minutes? An hour? You choose. If it is unlikely to change, setting it to a few hours or a day should (*in theory*) reduce the load on the DNS server.
|
||||||
|
|
||||||
|
RDLENGTH is 8 bits (2 bytes) which inform the reader about the length, in bytes, of the RDATA section. For example, with A records, which are IPv4 addresses, RDLENGTH will always be 4. With CNAME records, RDLENGTH will vary, since domain names can be of varying lengths. This is needed because otherwise clients would have difficulty figuring out when a resource record starts or ends, if there are multiple.
|
||||||
|
|
||||||
|
RDATA is the actual data, and is variable length. Again, if the record we are returning is an A record, RDATA will be four bytes long and contain whatever the IPv4 address is. If the record is a CNAME record, RDATA will be domain name, in the same format as NAME/QNAME (the labels, at least; I'm not totally sure if the pointers are allowed, though I would assume so).
|
||||||
|
|
||||||
|
### Are we done?
|
||||||
|
|
||||||
|
So, wonderful. We parse the query message, see what domain it is requesting, if it is a intranet domain, we then copy the query message, change it a bit, and append our CNAME/A record answer.
|
||||||
|
|
||||||
|
But I probably want to visit normal websites too. This current setup isn't aware of say, wikipedia.org's existence, that's not an intranet domain! So, our poor server would have to query the root domain to find the DNS server for `.org`, then query that server for `wikipedia.org`'s nameservers, then query those namesevers for the actual record. Sounds like an awful lot of work.... which we can avoid entirely! We'll just route queries for non-intranet TLDs/domains to another DoH service, like NextDNS, Cloudflare, or Mullvad's. They'll handle everything, and we can just proxy whatever they return.
|
||||||
|
|
||||||
|
Ok, now are we done?
|
||||||
|
|
||||||
|
## Well...
|
||||||
|
|
||||||
|
### HTTPS/CAs
|
||||||
|
|
||||||
|
For intranets, there are two possible approaches.
|
||||||
|
|
||||||
|
One is to make the intranet be subdomains of a "real" domain. If you own `example.com`, intranet sites could use subdomains of that site. Certificate authorities (CAs, to be explained in a few paragraphs), will happily issue you a HTTPS certificate, and you can keep the intranet intra since DNS records will only exist for those using your DNS server.
|
||||||
|
|
||||||
|
The other is to make up a TLD that isn't already used. Maybe `.internal`, `.intranet`. Or something creative. Doesn't matter. This approach is a path of pain, at least when it comes to HTTPS.
|
||||||
|
|
||||||
|
To understand why this is such a pain in the ass, how HTTPS works must be explained first. HTTPS encrypts the connection between the the client (say, a browser), and the server. Other than that, it is just normal HTTP. That's common knowledge. But how does it do that?
|
||||||
|
|
||||||
|
> To be more specific, HTTPS is just HTTP on TLS (Transport Layer Security), a more generalised way to encrypt connections, including connections that aren't HTTP.
|
||||||
|
|
||||||
|
Public-private key cryptography, that's how. If you randomly generate a private key (just think of it as a really big number), with some fancy math, a public key (another really big number) can be generated. Anyone who knows the public key can use it to encrypt a message, and then only the holder of the private key will be able to decrypt it. However, public-private key cryptographically is slow, so it is often just used initially to exchange a symmetric key to do symmetric cryptography, which is much faster.
|
||||||
|
|
||||||
|
So, great, if the browser knows the public key of the website, it can communicate with it securely. But how does it know? It needs to ask some entity it trusts for the public key. After all, if it uses the wrong public key, perhaps one of a hacker, the encryption is useless. But needing to fetch the public key from some source before sending any requests to the site would be quite time consuming, and raise serious privacy concerns (the public key directory would be able to tell what websites people are visiting). So, in HTTPS, entities called Certificate Authorities (CAs) sign a certificate (the website's public key and some other information), vouching for its legitimacy. The website then sends that signed certificate to the client. Clients have a CA store, or a list of CAs that it trusts. It can then verify that the certificate was signed by a CA that it trusts. **That it trusts.**
|
||||||
|
|
||||||
|
But can we trust CAs? Most of them have good track records. And there are various ways in which CAs are gradually forced to become more transparent. But still, HTTPS requires that we involve, and trust, yet another third party. CAs could be hacked. Or pressured by governments. Or just plain malicious. A bad CA means HTTPS is useless. Worse than useless, because the connection is presented as secure and with the server when that isn't true, giving a false sense of security. And there are about 150 of them that need to be trusted! The situation is gradually improving, and the especially paranoid can do things like certificate pinning, but the entire system has fundamental flaws.
|
||||||
|
|
||||||
|
Most CAs are businesses, and charge for certificates. Charging money for HTTPS? Well, that's bad. Luckily, there are non-profit organisations that issue certificates for free, like Let's Encrypt.
|
||||||
|
|
||||||
|
Still, CAs mean additional trust, additional risk. And CAs will obviously not issue certificates for non-existent domains, which makes it quite painful to run an intranet with HTTPS. Intranet operators will likely need to create their own internal CA. Luckily, since intranet CAs don't need to be (and won't be) added to certificate stores by default, making a CA is as simple as generating some cryptographic keys. No vetting or whatnot needed. However, *every* client that wants to use HTTPS on the intranet will need to add that CA to their certificate stores. This is a huge pain, and possibly dangerous (if "Name Constraints" aren't used).
|
||||||
|
|
||||||
|
What would an alternative be? DANE, that's what! DANE has the public key in the DNS records. We have to trust the DNS records anyways, because those are what tell us the IP address of the server(s) behind the domain. If that is a lie, the HTTPS certificate being invalid doesn't matter anyways.
|
||||||
|
|
||||||
|
### Self-hosting
|
||||||
|
|
||||||
|
For a website to work, it needs to have a A (IPv4), AAAA (IPv6), or CNAME (another domain name) record. CNAME records need to eventually point to a A or AAAA record. Now, many people probably have a IPv4 or IPv6 address from their ISP. So all that needs to be done is open some ports, right? Well, ISP will usually change it around, so the DNS record will quickly become invalid. There are automated ways to change the DNS record when your IP changes. You could also get a static IP, probably by paying an extra fee.
|
||||||
|
|
||||||
|
Great! Except... no. There is a huge IPv4 address shortage (only 4 billion of them, after all), so many people may be behind CGNAT (sharing IPs with many other people). Or in any other situation where they don't have their own IP and can't open/forward ports. The IPv6 situation is much better, but it isn't an option everywhere. Plus, IPv6 is famously in the midst of a multi-decade botched adoption, so many clients just don't support IPv6. Even assuming that isn't a problem, and ignoring any monetary costs, there are naturally many, many, security concerns related to allowing anyone to send stuff to your ports. Additionally, anyone could figure out the general location of your residence with your IP. The authorities or anyone resourceful could figure out the exact address, name, etc.
|
||||||
|
|
||||||
|
The usual solution to this is to have Cloudflare manage the DNS and proxy requests to you. They will do this for free. It's quite nice, but ultimately this is something that depends on the generosity of Cloudflare, and cannot be counted on forever. Plus, it is quite unhealthy for large swaths of the internet to depend on just Cloudflare, and letting Cloudflare snoop on all the traffic isn't great. Even encrypted traffic has valuable metadata. Cloudflare also provides a service for those who can't (or don't want to) open ports, called Cloudflare Tunnel (thank you, Cloudflare). But again, this depends on the generosity of Cloudflare, allows them to snoop, and understandably has limitations if doing anything out of the ordinary. For example, if I wanted to host a web server at the domain prussia.ban (`.ban` not being a "real" [ICANN] TLD), publically accessible to anyone who uses my DoH server, I'd need a IPv4/IPv6 address, there is just no way around it. Cloudflare won't work for these non-existent domains, understandably. So, to self-host (or host on a VPS without paying for a IPv4 address), the only free choice is basically to use Cloudflare.
|
||||||
|
|
||||||
|
For an intranet, where everyone is on the same network, this isn't too bad of an issue, since people who are in control of the router can just use the internal 192.168.\* IPs. No need to worry about security/privacy, or pay for a IPv4/IPv6 address. Oh, except Firefox refuses to accept 192.168.\* A records, probably for security reasons? 127.0.0.1 does seem to work, though... So it would have to be painfully proxied through 127.0.0.1. Argh!
|
||||||
|
|
||||||
|
This is terrible! Terrible! Webhosting should be accessible to anyone with an internet connection and some sort of computing device, without being forced to rely on the generosity of some mega-corp.
|
||||||
|
|
||||||
|
It's hard to argue making self-hosting easier and more accessible would be a bad thing, but perhaps this is just the way the world works. No one's entitled to a free lunch (well, so goes the saying). People should just shell out some money for an IPv4 address, right?
|
||||||
|
|
||||||
|
Wrong! Wrong! Wrong! This isn't how the world works. There is a free lunch. Tor onionsites (aka hidden services) does everything the World Wide Web cannot. No ports need to be opened. It could run behind CGNAT, or McDonald's wifi. Privacy is preserved, and the identity of the server is secret to clients (and the identity of the clients is secret to the server, but that's orthogonal for self-hosting). An encrypted connection is provided without needing to trust CAs. There are middlemen, but they can't peep and are interchangeable. How Tor onionsites work is out-of-scope of this post (but easily searchable on-line), but the point is, everyone I dream of is not only possible, but has already been done. Tor is the self-hosters dream. Tor is what the internet should be. I wake in the morning, furious that the World Wide Web works the way it does.
|
||||||
74
posts/downloading_my_spotify_playlist_for_free.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
Like most people, I like listening to music.
|
||||||
|
|
||||||
|
Also like most people, I don't like listening to ads or paying subscriptions^\[0\]^. Spotify's free tier is pretty good, and I can block their ads by using the web player and uBlock Origin.
|
||||||
|
|
||||||
|
I'm mostly content with that, but there are still some annoyances - I can't listen offline, the UI is a little frustrating, and even though the ads are blocked, the player still sometimes freezes when an ad is supposed to be playing (so the next song isn't played, have to reload the page). Combined with not morally being a fan of relying on a for-profit third-party service for my music, I decided today to write a few scripts to download all the songs on my favourite Spotify playlist (around 60 songs).
|
||||||
|
|
||||||
|
First step is getting all the song and artist names on my playlist from Spotify. I did find an [Spotify API endpoint](https://developer.spotify.com/documentation/web-api/reference/get-playlists-tracks), but it seemed like a pain with OAuth required. Instead, I just opened the playlist in my browser. After scrolling down to load all the playlist tracks, I ran something similar to the following to get all the track names:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
[...document.querySelectorAll(".iCQtmPqY0QvkumAOuCjr")].map((d) => d.innerText)
|
||||||
|
```
|
||||||
|
|
||||||
|
After a bit of processing with Javascript, vim find-and-replace commands, and a few manual corrections, I ended up with a `songs.txt` file in this format:
|
||||||
|
|
||||||
|
```
|
||||||
|
1|Alice in Freezer|Orangestar
|
||||||
|
2|無人駅|n-buna
|
||||||
|
...and so on
|
||||||
|
```
|
||||||
|
|
||||||
|
Now that we have all the song and artist names, we need to fetch the Youtube urls of the songs, so we can download them with [yt-dlp](https://github.com/yt-dlp/yt-dlp).
|
||||||
|
|
||||||
|
It turns out the Youtube search API also needs an API key, so I used the [usetube](https://github.com/valerebron/usetube) npm package, which scrapes the actual web page instead of using the API. Here's the code:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const fs = require("fs");
|
||||||
|
const yt = require("usetube");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
let songs = fs.readFileSync("../songs.txt", "utf-8").split("\n");
|
||||||
|
for (let i = 0; i < songs.length; i++) {
|
||||||
|
let song_name = songs[i].split("|")[1];
|
||||||
|
let artist_name = songs[i].split("|")[2];
|
||||||
|
console.log(song_name+" "+artist_name);
|
||||||
|
const videos = (await yt.searchVideo(song_name+" "+artist_name)).videos;
|
||||||
|
songs[i] = songs[i]+"|https://youtube.com/watch?v="+videos[0].id;
|
||||||
|
console.log(songs[i].split("|")[3]);
|
||||||
|
}
|
||||||
|
fs.writeFileSync("../songs.txt", songs.join("\n"), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
```
|
||||||
|
|
||||||
|
Now `songs.txt` looks a little like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
1|Alice in Freezer|Orangestar|https://youtube.com/watch?v=jQmYZWjLwzw
|
||||||
|
2|無人駅|n-buna|https://youtube.com/watch?v=G8PFPUCNOg4
|
||||||
|
...and so on
|
||||||
|
```
|
||||||
|
|
||||||
|
The final step is just writing a bash script to download all the songs. I don't really know much bash, but after a few StackOverflow searches, I got a working script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
IFS='|' read -ra ADDR <<< "$line"
|
||||||
|
for i in "${!ADDR[@]}"; do
|
||||||
|
printf "\n${ADDR[$i]}"
|
||||||
|
if [ $i -eq 3 ]; then
|
||||||
|
yt_dlp -x --audio-format mp3 "${ADDR[$i]}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done < songs.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Yay!
|
||||||
|
|
||||||
|
This was a pretty "boring" project that didn't involve much thinking or code. But being able to automate small, tedious stuff like this is a pretty underrated perk of having even a little programming knowledge.
|
||||||
|
|
||||||
|
===
|
||||||
|
- \[0\]: It's almost like artists are people too, and need to make money to support themselves! In all seriousness though, Spotify pays artists something like $0.003 USD per stream. It makes much more sense to support them through buying albums or going to concerts, instead of wasting time listening to ads. Also, ads just suck.
|
||||||
1
posts/eve.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|

|
||||||
@@ -1,19 +0,0 @@
|
|||||||
Hello! This is an **example** post *not meant* to be actually served on the blog. Go to the `pages` branch of the repo for the actual blog posts and stuff.
|
|
||||||
|
|
||||||
[look its a link](https://example.com)
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Wow a header
|
|
||||||
|
|
||||||
```js
|
|
||||||
console.log("code block!!!");
|
|
||||||
//todo: syntax highlighting
|
|
||||||
```
|
|
||||||
|
|
||||||
> ## woah a blockquote
|
|
||||||
> Yeehaw ^\[1]^
|
|
||||||
|
|
||||||
----
|
|
||||||
|
|
||||||
- \[1]: Source? Me.
|
|
||||||
116
posts/fake_typing_effect.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
Let's make a fake typing effect. When an user types on the keyboard, instead of showing the user the real text they typed, we will instead show them some other text (something similar to [hacker typer](https://hackertyper.net/)).
|
||||||
|
|
||||||
|
## HTML
|
||||||
|
|
||||||
|
Normally, to get user keyboard input, an element like `<input>`. The problem is, `<input>` actually shows what the user types. Usually, this is good. For us, not good.
|
||||||
|
|
||||||
|
So we will instead be using `<textarea>`, with user input disabled (meaning users cannot type into the `<textarea>`), so `<textarea disabled>`. Let's also give it a placeholder so the user knows what to do, and an id, maybe `"ouput"`?
|
||||||
|
|
||||||
|
```html
|
||||||
|
<textarea id="output" placeholder="Type something!" disabled></textarea>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Javascript
|
||||||
|
|
||||||
|
This is the bulk of the program.
|
||||||
|
|
||||||
|
First of all, we have to detect when the keyboard is pressed. To do this, we just need to add an event listener to the document.
|
||||||
|
|
||||||
|
```js
|
||||||
|
document.addEventListener("keyup", function(_event) {
|
||||||
|
//we haven't written this code yet
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The first parameter is the event name we are listening for: `"keyup"`. The `"keyup"` event is emitted whenever a key on the keyboard is released.
|
||||||
|
|
||||||
|
The second parameter of the `addEventListener` function is the callback function that is run whenever the `"keyup"` event is emitted. The `_event` parameter of that function is a [KeyboardEvent](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent), that contains information about the emitted event, like what key was pressed. In this case, we don't need any of that information, so I put an underline in front of the parameter name (`_event`) to indicate we will not use it. In Javascript, we can actually get rid of the `_event` parameter all together, and the code will still work. But I'm keeping it since I like knowing it exists.
|
||||||
|
|
||||||
|
Now that we can detect key presses, we want the `<textarea>` element's content to change. Specifically, for every key press, a character from the predetermined text needs to show up. We will have to keep track of the number of keypresses, since the characters have to go in the right order.
|
||||||
|
|
||||||
|
To keep track of the number, we can use a global variable which we can increment every key press.
|
||||||
|
|
||||||
|
Oh, plus we need a variable to store the text we want to display.
|
||||||
|
|
||||||
|
So, so far we have:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const text = "Lorem ipsum something something blah blah blah...";
|
||||||
|
let letter_index = 0;
|
||||||
|
document.addEventListener("keyup", function(_event) {
|
||||||
|
//we haven't written this code yet
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Next, let's add the code that does the actual adding of the text:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const text = "Lorem ipsum something something blah blah blah...";
|
||||||
|
let letter_index = 0;
|
||||||
|
document.addEventListener("keyup", function(_event) {
|
||||||
|
let output = document.getElementById("output");
|
||||||
|
if (letter_index === text.length) {
|
||||||
|
output.innerHTML = "";
|
||||||
|
letter_index = 0;
|
||||||
|
} else {
|
||||||
|
output.innerHTML += text[letter_index];
|
||||||
|
letter_index++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Whenever a `"keyup"` event is emitted, we see if `letter_index` is equal to the length of the predetermined text. If it is, that means the user finished typing the fake text. So, we clear the textarea's content, and reset the `letter_index` to `0`.
|
||||||
|
|
||||||
|
If `letter_index` is less than the length of the predetermiend text (it will never be greater since we reset it before it gets large than `text.length`), we just add the next character to the textarea, and increment `letter_index`.
|
||||||
|
|
||||||
|
That's it.
|
||||||
|
|
||||||
|
Now, what if we want to cycle between different fake texts after an user finishes typing one of them, instead of restarting and typing the same fake text over and over? That should be fairly simple - we'll make an array that has all the fake texts we want to cycle through, and add another index variable like `letter_index`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const texts = ["Lorem ipsum something something blah blah blah...", "She sells seashells by the seashore.", "Si shi si, shi shi shi. Shi si shi shi si, si shi shi si shi.", "So long and thanks for all the fish."];
|
||||||
|
let text_index = 0;
|
||||||
|
let letter_index = 0;
|
||||||
|
document.addEventListener("keyup", function(_event) {
|
||||||
|
let output = document.getElementById("output");
|
||||||
|
if (letter_index === text.length) {
|
||||||
|
output.innerHTML = "";
|
||||||
|
text_index++;
|
||||||
|
if (text_index === texts.length) {
|
||||||
|
text_index = 0;
|
||||||
|
}
|
||||||
|
letter_index = 0;
|
||||||
|
} else {
|
||||||
|
output.innerHTML += texts[text_index][letter_index];
|
||||||
|
letter_index++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
## Result
|
||||||
|
|
||||||
|
Here's the code put together:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<textarea id="output" placeholder="Type something!" disabled></textarea>
|
||||||
|
<script>
|
||||||
|
const texts = ["Lorem ipsum something something blah blah blah...", "She sells seashells by the seashore.", "Si shi si, shi shi shi. Shi si shi shi si, si shi shi si shi.", "So long and thanks for all the fish."];
|
||||||
|
let text_index = 0;
|
||||||
|
let letter_index = 0;
|
||||||
|
document.addEventListener("keyup", function(_event) {
|
||||||
|
let output = document.getElementById("output");
|
||||||
|
if (letter_index === texts[text_index].length) {
|
||||||
|
output.innerHTML = "";
|
||||||
|
text_index++;
|
||||||
|
if (text_index === texts.length) {
|
||||||
|
text_index = 0;
|
||||||
|
}
|
||||||
|
letter_index = 0;
|
||||||
|
} else {
|
||||||
|
output.innerHTML += texts[text_index][letter_index];
|
||||||
|
letter_index++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
See a demo of the above [here](https://demos.prussiafan.club/demos/fake-typing-effect).
|
||||||
35
posts/fermats_little_theorem.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
I read an article from [Quanta Magazine](https://www.quantamagazine.org/how-randomness-improves-algorithms-20230403/) that had this very interesting piece of information:
|
||||||
|
|
||||||
|
> The basic idea goes back to a result from the 17th-century French mathematician Pierre de Fermat, known as his "little theorem." Fermat considered two integers — call them `N` and `x`. He proved that if `N` is a prime number, then `x\^N - x` is always a multiple of `N`, regardless of the value of `x`. Equivalently, if `x\^N - x` is not a multiple of `N`, then `N` can't be a prime number. But the inverse statement isn't always true: If `x\^N - x` is a multiple of `N`, then `N` is usually but not always prime.
|
||||||
|
> To turn Fermat's little theorem into a primality test, just take the `N` that you're interested in, choose `x` at random, and plug the two numbers into `x\^N - x`. If the result is not a multiple of `N`, then you're done: You know that `N` is definitely composite. If the result is a multiple of N, then `N` is probably prime. Now pick another random `x` and try again. In most cases, after a few dozen tries, you can conclude with near certainty that `N` is a prime number. "You do this a small number of times," Blais said, "and somehow now your probability of having an error is less than the probability of an asteroid hitting the Earth between now and when you look at the answer."
|
||||||
|
|
||||||
|
It reminded me a little of zero knowledge proofs.
|
||||||
|
|
||||||
|
Anyways, using Fermat's Little Theorem, I wanted to create a little function that could see whether a number was a prime number or not. First, we had to have a number to check as one of the inputs. And since I was using Javascript, it probably should be a `BigInt`, so big inputs don't lose any precision. We probably also want to optionally let the caller specify how many checks to do.
|
||||||
|
|
||||||
|
Then, for each check, we can generate a random `x`, calculate `x\^N - x`. If we call that, say, `m`, then we can do `m % N`, where `%` means modulo. If `m % N` is zero, that means `m` is a multiple of `N`, so we continue. If not, then we know the input is **not** a prime number, and can end it there. If we generate `x` many times, and `m % N` is always `0`, we can conclude with high probability that the input is a prime.
|
||||||
|
|
||||||
|
At first, I mistakenly did `N % m === 0`, but that means `m` is a factor (not a *multiple*) of `N`, and `N` would be by definition, not a prime number (assuming `m` is not or `N`, as the only factors of a prime number are itself and 1). I realised the problem pretty quickly.
|
||||||
|
|
||||||
|
After fixing that, this is the code I had:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function is_prime(potential_prime, iterations=50) {
|
||||||
|
for (let i=0; i < iterations; i++) {
|
||||||
|
let x = BigInt(Math.floor(Math.random()*10000)); //0 <= x <= 9999
|
||||||
|
let m = x**potential_prime - x;
|
||||||
|
if (m%potential_prime !== BigInt(0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
I tested a random few of the [first 1000 prime numbers](https://en.wikipedia.org/wiki/List_of_prime_numbers#The_first_1000_prime_numbers), and some numbers that were not prime numbers, and it works! Yay!
|
||||||
|
|
||||||
|
Just to see if it worked, I tried putting in the very large prime number "531137992816767098689588206552468627329593117727031923199444138200403559860852242739162502265229285668889329486246501015346579337652707239409519978766587351943831270835393219031728127" as a test. And:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
...fair enough.
|
||||||
16
posts/gobanme_v1-2.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|

|
||||||
|
|
||||||
|
GoBanMe is a pretty neat browser extension that was made to **tip your favorite websites Banano**. It also functions as a rudimentary wallet.
|
||||||
|
|
||||||
|
Essentially, you can microtip your favorite Banano related websites like JungleTV, Yellowspyglass, or various faucets. The funds will go to the creator's wallet. There's even a 'Discover' Tab where you can find a list of websites that support GoBanMe.
|
||||||
|
|
||||||
|
It's been around for a while, but recently it was (finally) updated.
|
||||||
|
|
||||||
|
For firefox, this update makes GoBanMe more like a wallet, letting you send banano to any address, not just website operators. There will be many more updates to come, with the intent of GoBanMe becoming a "Metamask" of sorts for Banano. Additionally, **youtube channels with a banano address in their description can now be tipped!**
|
||||||
|
|
||||||
|
For chrome, there are now huge usability improvements!
|
||||||
|
|
||||||
|
Download it here for [Firefox](https://addons.mozilla.org/en-US/firefox/addon/gobanme/) (recommended).
|
||||||
|
Download it here for [Chrome and Chromium](https://chrome.google.com/webstore/detail/gobanme/chcpbckfgpcogpgceecknjgcaicdonje).
|
||||||
|
|
||||||
|
Want to add GoBanMe support for your website? Read [here](https://www.prussia.dev/docs/gobanme/index.html#add-gobanme).
|
||||||
172
posts/golfing_and_scheming.md
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
Every December, Advent of Code releases a programming puzzle every day until Christmas. To get on the leaderboard, the goal is to solve the puzzle as soon as possible.
|
||||||
|
|
||||||
|
I'm not really into that, and I usually don't participate. Still, it's a good way to learn a new language, and it can be pretty fun if some extra challenges are imposed (eg, writing all the solutions in an esolang). This year though, I decided to use it as an excuse to go code golfing, and learn Scheme. I did days 2, 4, 6, 8, and 10.
|
||||||
|
|
||||||
|
> It feels like this is too much exposition already, but I guess I should quickly explain what code golfing is. Code golfing is trying to write code that solves a problem in the least amount of characters/bytes. There are some programming languages that are specifically made for code golfing but I feel like that's kinda cheating.
|
||||||
|
|
||||||
|
## Node.js
|
||||||
|
|
||||||
|
I initially golfed in Node.js since I thought Javascript's quirky truthy and falsy system would save characters.
|
||||||
|
|
||||||
|
My favourite solution was day 4 part 1 ([input](https://gist.github.com/jetstream0/a0381d894eabb36845ca4b587bdb0494#file-4input-txt), 169 chars):
|
||||||
|
|
||||||
|
```js
|
||||||
|
console.log((require("fs").readFileSync("4input.txt")+"").split`
|
||||||
|
C`.reduce((p,v)=>p+(v+" ").match(/[0-9]+ /g).reduce((w,c,i,a)=>i<=9?a.includes(c,10)?(w*2||1):w:w,0),0))
|
||||||
|
```
|
||||||
|
|
||||||
|
The task is to find the total points in the input. Each line of the input represents a scratchcard, with two lists of numbers separated by a "|". To the left are the winning numbers for that card, and to the right are the numbers we have. The first number we have that is also a winning number is worth one point, and every subsequent match doubles the point value: a card with 5 winning numbers is worth 16 points (`2^(5-1)` or 1,2,4,8,16), a card with 3 matches is worth 4 points, a card with no matches is worth 0.
|
||||||
|
|
||||||
|
There is also a *secret* rule! All the numbers we have are unique - there are no repeats. This means that if 4 is a winning number for a card, there will be no more than one 4 in the numbers we have. This will be important for later.
|
||||||
|
|
||||||
|
The code first reads the file, splits it into lines, then for each line, finds **all** the numbers (both the winning numbers and the numbers we have) with regex. For each number, if it is a winning number (`i<=9`, because the winning numbers are the first 10 numbers on the card), it checks if the numbers we have contain that winning number, and if so, appropriately changes the point total for that line. Finally, all the point totals for each line are added up, and the answer is logged.
|
||||||
|
|
||||||
|
Notice that **instead of checking whether the numbers we have are in the winning numbers, we do the opposite - check that the winning numbers are in the numbers we have**. This is *only* possible because of the secret rule mentioned earlier. Or well, the logic would be significantly longer if we couldn't assume only one of the winning number was present.
|
||||||
|
|
||||||
|
Some explanations about tricks in the code:
|
||||||
|
|
||||||
|
- Some functions can be called with backticks instead of parentheses; essentially, `split("a")` can be rewritten as `split\`a\``. I don't know what this is called, and it doesn't always work, but it does save two characters
|
||||||
|
- The `C` in the `split` is to make sure the last empty line of the file is ignored when splitting the file into lines (as you can see in the input, every line with a card starts with "Card ")
|
||||||
|
- The `(w,c,i,a)` in the reduce function are the accumulator, current value, current index, and array being iterated over. See [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce#syntax)
|
||||||
|
- `(require("fs").readFileSync("4input.txt")+"")` converts the return type to a string (it's shorter than `String(require("fs").readFileSync("4input.txt"))` and `require("fs").readFileSync("4input.txt","utf8")`)
|
||||||
|
- The regex that has a space at the end (`/[0-9]+ /g`) is to ensure that the card number is not counted as one of the numbers, since the card number is always followed by a colon. I found that to take up less characters than doing the `/[0-9]+/g` regex and `shift()`ing or otherwise ignoring the card number. This does create a problem, however. The last number of the line does not end with a space! The `v+" "` fixes that, by adding a space to the end of the line
|
||||||
|
- The question marks and colons are [ternary operators](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_operator) (a shorthand for if-else statements)
|
||||||
|
- The `a.includes(c,10)` is my favourite part of this. The second parameter of the includes function is actually the index of `a` to start searching from. So, we are ignoring the first 10 elements of the `a` array (remember? we are checking to see if the winning numbers are in the numbers we have). See [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/includes#syntax)
|
||||||
|
- `w*2||1` handles increasing the points if the winning number is present in the numbers we have. If `w` (the current count of points in the card) is 0, `w*2||1` will return `1`, while if `w` is non-zero, `w*2` will be returned
|
||||||
|
|
||||||
|
## Python
|
||||||
|
|
||||||
|
Surprisingly, Python was actually better for code golfing than Node.js, even though indentation is required and some standard library utils need to be imported. Python makes up for that by providing shorter function names (`len()` instead of `.length`), as well as stuff like `[1:]` instead of `.slice(1)`.
|
||||||
|
|
||||||
|
Here's day 8 ([input](https://gist.github.com/jetstream0/b9d93e734c48c06544380903fb914b8f#file-8input-txt), 181 chars):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import re;a,*b=re.findall("\w+.",open("8input.txt").read());c="AAA";i=d=0
|
||||||
|
while 1:
|
||||||
|
i+=1;c=b[b.index(c+" ")+(2,1)[a[d]=="L"]][:3]
|
||||||
|
d=(d+1,0)[d==len(a)-1]
|
||||||
|
if c=="ZZZ":print(i);break
|
||||||
|
```
|
||||||
|
|
||||||
|
And here's day 10 ([input](https://gist.github.com/jetstream0/d302d53b5159265021b5c30923bdf4f7#file-10input-txt), 294 chars):
|
||||||
|
|
||||||
|
```python
|
||||||
|
f=list(open("10input.txt").read());q=[f.index("S")];v=[];n=141
|
||||||
|
while len(q)>0:
|
||||||
|
c=q[0];d=f[c];s=d=="S";a=["|LJ","|7F"];b=["-7J","-LF"];q=q[1:];v=[*v,c]
|
||||||
|
for o,w in[[-n,a],[n,a[::-1]],[-1,b],[1,b[::-1]]]:
|
||||||
|
if 0<c+o<len(f):
|
||||||
|
if([d,f[c+o]][s]in w[s])&((c+o in v)^1):q.append(c+o)
|
||||||
|
print(len(v)//2)
|
||||||
|
```
|
||||||
|
|
||||||
|
Eaz later pointed out to me that `c=q[0];q=q[1:];` could be rewritten as `c=q.pop(0);`, saving some more characters. After some more tweaking, he trimmed his version of my solution to 271 characters (!).
|
||||||
|
|
||||||
|
With help from Eaz, here's a 253 char version that somehow works on Python 3.11:
|
||||||
|
|
||||||
|
```python
|
||||||
|
f=open("10input.txt").read();q=v=[f.find("S")];n=141
|
||||||
|
while q:d=f[c:=q.pop(0)];a=["|LJ","|7F"];b=["-7J","-LF"];v+=[c];q=[c+o for o,w in[[-n,a],[n,a[::-1]],[-1,b],[1,b[::-1]]]if 0<c+o<len(f)if([d,f[c+o]][s:=d=="S"]in w[s])&((c+o in v)^1)]
|
||||||
|
print(len(v)//2)
|
||||||
|
```
|
||||||
|
|
||||||
|
In day 8, we're using regex to find all the words, storing the first word (the instructions to go left and right) into `a` and the rest into the list `b`. We set the current location as "AAA" and go into a loop. In every iteration of the loop, we look for the position of the current location with `index` (which should be replaced with `find` to save 1 character). The `+" "` takes advantage of the fact that the input is formatted as `SVN = (JGN, FSL)`. The two connected locations end with `,` and `)`, while what we want, the actual location, ends with a space. Based on whether the current instruction is a left or a right, we make either the left or right connection the current location. We increment the instruction location, wrapping back to the beginning if we reached the last instruction. If the current location is "ZZZ", we have reached the end!
|
||||||
|
|
||||||
|
If you read the problems, and know that `[1,2][False] == 1` and `[1,2][True] == 2` (basically, a ternary operator), the code for day 8 should be reasonably understandable. At least for a code golf.
|
||||||
|
|
||||||
|
In day 10, we start at "S", and need to find the length of the connecting pipes (which are in one continuous loop). With that, we can do `//2` to find the distance the pipe furthest from the start is. To do this, we are setting up a queue list (`q`) of locations (of connecting pipes) to visit, and a visited (`v`) list of pipe locations we have already visited. In the loop, we are checking the current pipe's surrounding pipes, adding those that connect to our current pipe and have not already been visited to the queue.
|
||||||
|
|
||||||
|
Some notes about day 10:
|
||||||
|
|
||||||
|
- `v=[*v,c]` is a shortcut for `v.append(c)`\
|
||||||
|
- 141 is the length of the lines in the input. Since we store locations not as a x,y coord, but rather just a char position in the entire file, this is important
|
||||||
|
- `a=["|LJ","|7F"];b=["-7J","-LF"];` and `[[-n,a],[n,a[::-1]],[-1,b],[1,b[::-1]]]` help the logic figure out which pipes connect to which. I don't really want to explain more deeply, just read the code
|
||||||
|
- The `^1` is a "not"
|
||||||
|
|
||||||
|
## Scheme Lisp
|
||||||
|
|
||||||
|
Here's my Scheme solution for day 6 part 1:
|
||||||
|
|
||||||
|
```scheme
|
||||||
|
;extract a list of numbers from the line
|
||||||
|
(define extract-numbers (lambda (line)
|
||||||
|
(define extract-numbers-tail (lambda (line index current-number-string number-list)
|
||||||
|
;string->number list->string
|
||||||
|
(if (< index (string-length line))
|
||||||
|
(begin
|
||||||
|
(if (char-numeric? (string-ref line index))
|
||||||
|
(extract-numbers-tail line (+ index 1) (string-append current-number-string (list->string (cons (string-ref line index) '()))) number-list)
|
||||||
|
(begin
|
||||||
|
(if (string=? current-number-string "")
|
||||||
|
(extract-numbers-tail line (+ index 1) "" number-list)
|
||||||
|
(extract-numbers-tail line (+ index 1) "" (cons (string->number current-number-string) number-list))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
(begin
|
||||||
|
(if (string=? current-number-string "")
|
||||||
|
number-list
|
||||||
|
(cons (string->number current-number-string) number-list)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
))
|
||||||
|
(reverse (extract-numbers-tail line 0 "" '()))
|
||||||
|
))
|
||||||
|
(define count-and-mul-wins (lambda (time-list distance-list wins-mul)
|
||||||
|
(define count-wins (lambda (time distance secs wins)
|
||||||
|
(if (< secs time)
|
||||||
|
(if (> (* secs (- time secs)) distance)
|
||||||
|
(count-wins time distance (+ secs 1) (+ wins 1))
|
||||||
|
(count-wins time distance (+ secs 1) wins)
|
||||||
|
)
|
||||||
|
wins
|
||||||
|
)
|
||||||
|
))
|
||||||
|
;check to make sure lists aren't empty
|
||||||
|
(if (= (length time-list) 0)
|
||||||
|
wins-mul
|
||||||
|
(let ([wins (count-wins (car time-list) (car distance-list) 0 0)])
|
||||||
|
(if (= wins-mul 0)
|
||||||
|
(count-and-mul-wins (cdr time-list) (cdr distance-list) wins)
|
||||||
|
(count-and-mul-wins (cdr time-list) (cdr distance-list) (* wins wins-mul))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
))
|
||||||
|
(define file (open-input-file "6input.txt"))
|
||||||
|
(let* ([line1 (get-line file)] [line2 (get-line file)])
|
||||||
|
(display (count-and-mul-wins (extract-numbers line1) (extract-numbers line2) 0))
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
In comparison, here's my Node.js solution ([input](https://gist.github.com/jetstream0/9241c005d12c039f296a5c53a66236e6#file-6input-txt), 149 chars):
|
||||||
|
|
||||||
|
```js
|
||||||
|
console.log((require("fs").readFileSync("6input.txt")+"").match(/\d+/g).reduce((p,c,i,a)=>{for(o=j=0;j<+c;)j*(c-j++)>+a[i+4]&&o++;return o?p*o:p},1))
|
||||||
|
```
|
||||||
|
|
||||||
|
Yeah, Scheme isn't ideal for code golfing (maybe with macros?). That's fine, since with Scheme, I was just trying to learn the language, not golf.
|
||||||
|
|
||||||
|
It's a bit of a mess since I had no Scheme (or Lisp) experience before I wrote that, unless you count <5 minutes of fiddling around with [try.scheme.org](https://try.scheme.org).
|
||||||
|
|
||||||
|
With the help of [The Scheme Programming Language, 4th Edition](https://www.scheme.com/tspl4/) (a wonderful book), and [Scheme Programming](https://en.wikibooks.org/wiki/Scheme_Programming) to figure out where to get started, everything went smoother than expected.
|
||||||
|
|
||||||
|
I was initially disappointed to see Scheme missing many "basic" utility functions (eg, a string split function), and considered switching to something like Racket instead. But after just a few minutes I fell deep in love. The syntax was clean and beautiful, and rewriting "basic" functions was incredibly fun. Scheme gives enough of a base to be useful (managing memory, records, lists, some string operations...), but leaves the joy of most everything else up to the programmer. As y'all know, I like implementing things from close to scratch, and hate bloat. It's a perfect match.
|
||||||
|
|
||||||
|
It's been a while since I had so much fun programming. To further learn Scheme, I then wrote my first project in Scheme, [rescli](https://github.com/jetstream0/rescli), a CLI interface for [Reservoir](https://github.com/jetstream0/reservoir), a bookmark organizer app I made. I'll talk about those in a different post.
|
||||||
|
|
||||||
|
## Why Learn Scheme?
|
||||||
|
|
||||||
|
But anyways, how did I end up learning scheme?
|
||||||
|
|
||||||
|
After writing [Mingde](https://github.com/stjet/mingde), and a few Rust projects, I was thinking a lot about types. Specifically, how much I loved them (hint: a lot). I was also curious about functional programming.
|
||||||
|
|
||||||
|
Naturally, I looked for languages that combined both. Idris I simply could not wrap my head around (and might just be too obscure, even for me), and I found it too difficult to get into OCaml. I will probably try OCaml again later, maybe with ReasonML for more familiar syntax instead. Haskell is also on my "try later" list.
|
||||||
|
|
||||||
|
Lisp Scheme is dynamically, not statically, typed (Typed Racket, a similar language, can be statically typed though), which was a bummer and initially discouraged me from trying it. But it's simplicity and lack of bloat was compelling enough for me to give it a try. I'm glad I did. Again, writing in Scheme has been the most fun I've had programming in months, possibly years. Functional programming really is a different, more fun, and arguably better way to think about programming. Avoiding variable mutation and recursing instead of iterating is just... fun. Having the entire language reference as a 3.3 mb PDF is pretty neat too. And I haven't even written a macro yet!
|
||||||
|
|
||||||
|
S-expressions (the name for all those parentheses) are really simple to understand, and everything in general is pretty simple to understand. Do a brief read of Scheme Programming, then TSPL4 for reference.
|
||||||
|
|
||||||
|
You should learn Scheme too. It's simple. It's fun. Do it now.
|
||||||
46
posts/haguro_book_review.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
I recently stumbled upon "A Religious Study of the Mount Haguro Sect of Shugendo" by H. Byron Earhart. I didn't know too much about Shugendo, but I had met a Shingon-sect Shugendo practitioner (shugenja) before, and was vaguely familiar with them. Anyhow, it was enough for me to go read it.
|
||||||
|
|
||||||
|
To very briefly summarise, Shugendo is a syncretic, mountain-worshipping Japanese folk religion. It was founded by the famous ascetic En no Gyoja (aka En no Ozunu or En no Ubasoku), and has been associated with wandering shugenja who endure harsh ascetic practices and wear somewhat distinctive clothing. It was banned in the 1870s (Meiji era), but has been somewhat revived post-war. The Mount Haguro sect holds Mount Haguro in particularly high esteem. Mount Haguro is one the Dewa Sanzan, three famous sacred mountains in northern Japan.
|
||||||
|
|
||||||
|
Wait, that isn't entirely accurate! One of the first misunderstandings that the author clears up is the notion of mountain worship. Shugendo (and more broadly, the religious practice of "sangaku shinko") considers (certain) mountains to be sacred places, where gods, Buddhas/Bodhisattvas, and/or spirits dwell. So, it may be more accurate to describe mountain worship as worshipping *in* the mountains, because they are sacred, rather than the worshipping *of* mountains, simply because they are mountains.^\[0\]^ Secondly, the Haguro sect claims not be founded by En no Ozunu, but Prince Hachiko (aka Shoken-dai-bosatsu). The Haguro sect claims that En no Ozuno merely founded the sect of Oomine (a different mountain in Nara), and that their sect was older. More on this in a bit.
|
||||||
|
|
||||||
|
## What was good
|
||||||
|
|
||||||
|
The book does a great job at explaining what Shugendo is exactly. I think anyone with a little bit of knowledge about Buddhism and Japanese history should have no problem understanding the writing. As previously shown, the author also excellently cleared up misconceptions and prevented misunderstandings.
|
||||||
|
|
||||||
|
Another thing the book did well was describing both what the many rituals were, and the meaning behind them. Or rather, the meanings. The author uses two main sources, which ascribe multiple different meanings to aspects of the rituals, and the author also adds in his own knowledge, from his participation in the Aki no Mine (Fall Peak) ritual period, and his conversations with practitioners. Combined with the well-written glossary, this book seems like an incredibly useful guide to understanding the ceremonial parts of Shugendo.^\[1\]^ The rituals themselves were, of course, very interesting and certainly worth writing about. Though the Fall Peak was given the most in-depth explanation, I found the Winter Peak to be the most curious, with its initial long period of confinement for two senior ascetics, and ending with a fun festival full of competitions and celebrations. I understand why the Fall Peak was given much more coverage, given that it is slightly less public, and since the Winter Peak is more under the jurisdiction of the Shinto Dewa Shrine in modern times. The Summer Peak is the season where pilgrims come. The Spring Peak is no longer done, and is essentially a bunch of rituals perfomed privately by the highest-ranking members. I had no problem with how much each peak was covered, and thought it made sense. It was decently fascinating, and I was not aware of the lay pilgrimages in Shugendo before; I had mostly only thought of Shugendo in terms of the ascetics/priests that practice it, and had not thought too much about what the lay followers did.
|
||||||
|
|
||||||
|
With his own experience of the rituals, the author is able to provide details on how modern practices differ from the pre-Meiji ones. Besides modern practices generally being more abbreviated, it seems some holy sites no longer exist, or in one case, are inaccessible due to hydroelectric dam.
|
||||||
|
|
||||||
|
What I liked most is the explanation of the history of (Haguro) Shugendo, and how it changed through the ages. It seems it arose from the interaction of already ancient indigenous^\[2\]^ beliefs about the sacred role of mountains, and the imported Buddhist, and later Taoist, beliefs. As it became more organised, a formal hierarchy arose, divided between ascetics practicing on or near the mountain year round, and wandering folk priests serving various communities. Lay adherents received charms and various ceremonies (eg, to protect crops), and undertook long pilgrimages from across the country to the mountain. By the Edo/Tokugawa period, these wandering priests started growing more and more settled, and eventually stopped wandering entirely. After Shugendo was banned by the Meiji government, Shugendo priests and ascetics were either forced to give up their practices or become Shinto priests or Buddhist monks. A great deal of sites associated with Haguro Shugendo disappeared. But, enough was preserved and survived that it was revived post-war, even if in an abbreviated form.
|
||||||
|
|
||||||
|
Overall, the book touched on many interesting topics and tidbits, far too many to list. I learned about the involvement of the lay followers, and was surprised by the great (historical) reach and power of Haguro Shugendo. At its height, it had great financial and even military power. It quibbled with other Shugendo sects (such as the Hozan-ha, associated with the Tendai Buddhist sect^\[3\]^).
|
||||||
|
|
||||||
|
The book presents convincing evidence of pre-Shugendo beliefs around mountains, which besides being fascinating, gave a good idea of how Shugendo arose. I find it plausible that Shugendo independently arose in many areas of the country, and based on inter-group influences came to have some common characteristics. I don't really know whether Haguro Shugendo really predates En no Gyoja (or even if En no Gyoja was real [probably?]), but I don't see why not.
|
||||||
|
|
||||||
|
## What I wish was different
|
||||||
|
|
||||||
|
While the pre-Meiji history and doctrine of Shugendo is described in good detail, the post-Meiji history and doctrine of Shugendo is confusingly, barely covered. I was left with many burning questions. How exactly did Shugendo survive and revive? How did they decide which rituals to continue, and which to discontinue? When the resurrected Haguro Shugendo matures, will more rituals and practices be revived? What is the relationship exactly with Dewa shrine? What do the modern lay practitioners believe? Where did the shugenja of revived Shugendo come from, exactly? And what are their motivations?
|
||||||
|
|
||||||
|
When reading, small bits of answers are teased, but never fully fleshed out. For example, some of the modern Haguro Shugendo believers don't seem to like Dewa shrine, and how they carry out the rituals, but the other hand, (presumably Haguro) shungeja participate in some of the shrine's rituals. What's up with that? Who knows, no elaboration is made. Another example, the author at one point "accidentally observed an unofficial but interesting religious activity", where an older woman, who seemed to be in a "mild form of possession", and a young man were praying together. Afterwards, he talked to the woman (who he had, apparently, previously interviewed, though this is news to us) who explained that they were praying for the young man's dead father. She also said she had seen images of the founder (Prince Hachiko) while praying before. This short, off-hand passage is unfortunately the closest readers get to understanding the modern followers of Haguro Shungendo.
|
||||||
|
|
||||||
|
I don't understand why the author neglects the modern history. They obviously have plenty of sources to draw on, including his own experience and his conversations with modern practitioners, who surely remember the recent revival, and likely the pre-war underground state of Shugendo, so that isn't the issue. The modern history and motivations should be the easiest part to research! I also don't see why this would be out of scope of the work^\[4\]^, since revived Haguro Shugendo is still Shugendo. In my opinion, if the author would've included some of his takeaways from the interviews he clearly conducted with many followers and senior priests, the work would be greatly enhanced.
|
||||||
|
|
||||||
|
A major theme in the work is how Shugendo has changed over the years. As previously mentioned, the itineracy of the shugenja had declined by the Edo era, and strict asceticism had begun to fade too. And after the banning and revival, many rituals ceased or were abbreviated, certain positions/ranks were un-filled, and some holy sites or buildings no longer even exist. Meanings and interpretation seem to slightly change with time, though as the author points out, these meanings can coexist without contradiction. The revived Haguro Shugendo has a small fraction of the power it once had, and objectively it has declined. Perhaps the author considered the modern history and doctrine less important due to this decline? The author does associate the revived Haguro with other Japanese "new religions" in the conclusion (possibly implying that the revived version does not deserve quite the same comprehensive treatment as its pre-banned version), and separates it firmly from Buddhism (though obviously it acknowledges the great influence from Buddhism). These are both conclusions I disagree with, based on the practices and beliefs of Haguro Shugendo (both modern and pre-banning), as described in the book itself. Or perhaps the author just had a page limit and had to prioritise^\[5\]^. Either way, I was left wanting for more.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
For someone with just a passing minor interest in Shugendo, or Japanese religions in general, I do think chapters 1 (religious background and historical development), 2 (background and religious history of the Haguro sect), 4 (religious doctrine), 5 (religious life and ritual activity), and the illustration section are worth reading, or at least skimming. I do realise that is a decent chunk of the book. The other sections are probably too dry for this type of reader.
|
||||||
|
|
||||||
|
For someone who has a more major interest in Shugendo, and especially the meaning and minutae of the rituals, this book is an excellent resource.
|
||||||
|
|
||||||
|
However, as mentioned in the previous section, don't expect to learn too much about the modern history or the modern practitioners.
|
||||||
|
|
||||||
|
===
|
||||||
|
|
||||||
|
[0]: Though, my impression was the mountains themselves were actually venerated... though only because of their association with various deities, so it's a little more complicated than that, I suppose
|
||||||
|
[1]: The bibliography/citations and the index were also great, but those are table stakes for academic works
|
||||||
|
\[2\]: Given that "Shinto" was inextricably intertwined with Buddhism until the Meiji government forced them to separate ([shinbutsu bunri](https://en.wikipedia.org/wiki/Shinbutsu_bunri) in the 1860s, and given that the unification of the many indigenous beliefs of Japan into "Shinto" wasn't really attempted until hundreds of years after the introduction of Buddhism, I did not use that term. Of course, it's not accurate at all to call Shinto entirely an artificial creation of the Meiji government, given that the oldest surviving work of literature in Japan, the [Kojiki](https://en.wikipedia.org/wiki/Kojiki) include national founding myths, and clearly in the early years of Buddhism in Japan, there was a contrast (and conflict) between it and the native practices. However, in the 1000+ years since, Japanese Buddhism and these native beliefs have merged together and influenced each other quite thoroughly, each changing from their "original" forms. So clearly, Shinto as we know it today is not an accurate term for the native religious practices pre-Buddhism, but it's complicated! I recommend reading "The Religious Traditions of Japan - 500-1600" by Andrew Edmund Goble for a really good analysis of the relationship between these native beliefs and Buddhism, and how Shinto started forming
|
||||||
|
[3]: Tozan-ha, on the other hand, is associated with the Shingon Buddhist sect. I would guess this might be the Shingon gyoja I met was associated with, but I could be totally wrong. He said he was on the path to ordain officially as a monk of the Koyasan subsect of Shingon. I'm not really sure of the relationship between Tozan-ha and the other Shingon denominations
|
||||||
|
[4]: There were a few things that I wanted to learn more about, like the military power of Haguro Shugendo, or how other sects viewed them, but that is more understandably out of scope. Perhaps I'll look through the bibliography and see if there's anything that seems to mention it, though more sources than you would expect seem to be in French or German
|
||||||
|
[5]: By the way, the whole thing with "-ise" being for nouns and "-ize" for verbs (or is the other way around?) is so confusing and weird. I don't want to just use "-ize" though, z just looks... weird
|
||||||
47
posts/hash_functions.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
This aims to give a working understanding of what hash functions are and what their uses are. I won't go into the actual math, since I'm unqualified to talk about that, and I probably wouldn't understand it anyways. We're also not going to talk about hash tables since my understanding of them is very limited.
|
||||||
|
|
||||||
|
A hash function is basically an algorithmn that takes data as an input, and outputs something with these important properties:
|
||||||
|
|
||||||
|
- **Irreversible**: Given the output of a hash function, it should not be possible to (algorithmically) find the input
|
||||||
|
- **Uniformity**: Inputs should have evenly distributed outputs - meaning, if you split the possible ranges of outputs into *n* buckets, and randomly hashed stuff a gazillion times, all the buckets should have a roughly equal amount of outputs in them. This also means that two similar but not identical inputs will likely have completely different outputs
|
||||||
|
- **Deterministic**: The same two inputs will always give the same output (well, that's more of a characteristic of functions in general)
|
||||||
|
- **Unique**: No two inputs should give the same output
|
||||||
|
- **Fixed length**: An input of arbitary length will be converted into an output of fixed length
|
||||||
|
|
||||||
|
Now wait a second - **the last two properties seem to contradict each other**. How can no two inputs give the same output if an infinite number of inputs become a finite number of inputs? Well, hash functions are not immune to the rules of logic, so it isn't actually true that no two inputs give the same output. However, *a good hash function should make it practically impossible for anyone to find two inputs that result in the same output* (called a collision). If a collision can be found, the hash function would be unsafe to use for most usecases.
|
||||||
|
|
||||||
|
# Usecases?
|
||||||
|
|
||||||
|
Hashing has a huuuuggggeeeee amount of applications, but I'll describe a few.
|
||||||
|
|
||||||
|
## Checksums
|
||||||
|
|
||||||
|
What if your best friend, the President of the United States, wants to send you an important large file (say, a video of Colonel Sanders making fried chicken), but can't physically give it to you via a USB stick or hard drive? Your friend might upload the file to a file sharing service, and share the link with you. But how do you know the file hasn't been tampered with? The file sharing service might be run by someone who has an interest in preventing you from eating some finger licking good chicken, who'll replace the real video with a fake video with Colonel Sanders putting toxic ingredients into the chicken and undercooking it. To verify the video is genuine, the President could hash the video, and tell you the hash in person (to avoid anyone also tricking you about what the hash is). Once you download the video, you can hash it too. If the hashes match (and the hash function is secure), you can be confident that the video hasn't been messed with. If they don't, you know the video has been modified.
|
||||||
|
|
||||||
|
This is possible since hash functions shouldn't have two inputs that result in the same output.
|
||||||
|
|
||||||
|
## Passwords
|
||||||
|
|
||||||
|
Instead of storing passwords in plaintext, which would result in disaster if the database was hacked, passwords are typically hashed. Since hash functions are supposed to be irreversible, the password remains a secret, but can still be verified - the site can hash the user provided password, and make sure it matches the hash it has on file (since the same input will always get the same output).
|
||||||
|
|
||||||
|
### Rainbow Tables and Salting
|
||||||
|
|
||||||
|
However, if an attacker gets their hands on a database full of hashed passwords, they can still easily crack many of the passwords with something called a rainbow table. Essentially, attackers can precompute the hashes of millions of likely or known passwords before they even attack. Since two inputs will always have the same output, an attacker with a bunch of stolen hashes can just look for a matching hash in their rainbow table, and figure out what the plaintext password is, even though hash functions are irreversible.
|
||||||
|
|
||||||
|
To prevent this, it is highly recommended to append random text to the password (each user should have a unique random text added) before hashing. This is called a salt. As long as that salt is stored, the password verifying process is the same - just add the stored salt before hashing. If this is done, rainbow tables are useless for attackers, since even if the user uses a common password, the random salt makes it so the hash will not be in the precomputed rainbow table. The attacker will have to generate a new rainbow table for every single user/salt, instead of just one for everyone! Salts are usually just stored wherever the hashed passwords are, but if they are kept hidden somewhere else, they are called "peppers".
|
||||||
|
|
||||||
|
SALT PASSWORDS!!!
|
||||||
|
|
||||||
|
## Digital Signatures
|
||||||
|
|
||||||
|
In digital signatures, the hash of the message is usually signed, instead of the actual message, since the hash is guaranteed to be a certain size, which is usually smaller than the actual message, making it much easier to sign.
|
||||||
|
|
||||||
|
## Proof-of-Work
|
||||||
|
|
||||||
|
Hashes can be a way to impose a cost in energy. Most famously, Bitcoin and many other cryptocurrencies use PoW to reach consensus trustlessly (as long as more than 50% of the computing power isn't controlled by one entity), and some captchas also use PoW as an anti-spam. Taking Bitcoin as an example, it requires adding random bytes to the block data, then hashing it, until it the hash starts with *n* number of zeroes, in order for the block to be mined (valid). Basically, it's just making millions of guesses about what random bytes appended to the block data will result in a hash that starts with the correct number of zeroes. Using more powerful computers and more energy results in more guesses in less time, making it more likely to find the right random bytes to add to mine the block. The more zeroes are the hash is required to start with, the more difficult the problem is, and the more energy it will take to generate the work.
|
||||||
|
|
||||||
|
## Key Derivation
|
||||||
|
|
||||||
|
Hashing is also a great way to derive cryptographic keys. For example, a password would usually not be able to be an encryption key, since encryption keys are typically a fixed length of bytes long. So, a password can be hashed, turning it into the right length, so it can be used in cryptography.
|
||||||
|
|
||||||
|
Hashes are cool.
|
||||||
61
posts/hex_to_bytes_and_back.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
I've written a lot of Javascript over the past few years. How much? I'm not sure, but 100 thousand lines is probably a good estimate^\[0\]^^\[1\]^. The two functions that I've written the most, over and over (and over and over and over... and over), are no doubt the function to convert hexadecimals to bytes, and vice versa.
|
||||||
|
|
||||||
|
Part of this is because I do a lot of stuff related to cryptography and cryptocurrency (which, no surprise, is basically just *more* cryptography), which involves tons of work with bytes and often, converting them to hex for storage or display. The other part is because Javascript doesn't have a builtin way to convert hex to bytes or the other way around (Node.js apparently has `Buffer.from` but I never use that), and also because I just like writing things from scratch, which you may notice is a common theme in this blog. In addition to my trademark unnecessarily long sentences, of course.
|
||||||
|
|
||||||
|
## Bytes
|
||||||
|
|
||||||
|
I assume you already know what bytes and hexadecimals are, but in case you don't, here's a brief overview.
|
||||||
|
|
||||||
|
Bits have two states. Bytes are made out of eight bits, so one byte can have 256 (`2^8=256`) states.
|
||||||
|
|
||||||
|
Now, there are a couple ways you can represent bytes. One way could be representing them in binary, with 1s and 0s. Another would be just using our normal decimal (base 10 numbers), where a byte could be represented by a number from 0 to 255. But the best way (in my opinion) is to use hexadecimals (base 16 numbers) which uses the digits 0-9 and A-F. `A` represents 10, `B` represents 11, and so on. `FF` would represent 255 in decimal (`15*16+15=255`), `10` would represent 16 (`1*16+0`), and `32` would represent 50 (`3*16+2=50`).
|
||||||
|
|
||||||
|
Why base 16? If you remember, one byte can have 256 states, meaning that two hexadecimal digits can perfectly represent one byte (`16^2=256`) which is a lot more elegant than decimal, and a lot more concise than binary. A base 256 would of course, be impractical. With decimal, it isn't exactly clear how many bytes 2402655566 is, while it is very clear how many bytes 8F359D4E is (8 hex digits, so 4 bytes).
|
||||||
|
|
||||||
|
## Uint8Array
|
||||||
|
|
||||||
|
Anyways, back on topic. In Javascript, bytes are often represented by [Uint8Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array), which are shockingly an array of Uint8s. What are Uint8s? Uint means "unsigned integer", or basically a non-negative whole number. The 8 stands for the 8 bits, so a Uint8 is an array of one byte unsigned integers^\[2\]^. Basically, it's a way to represent bytes in Javascript by storing in as an array of decimal numbers from 0-255.
|
||||||
|
|
||||||
|
## Converting Bytes to Hexadecimal
|
||||||
|
|
||||||
|
```js
|
||||||
|
function uint8_to_hex(uint8) {
|
||||||
|
const hex_chars = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'];
|
||||||
|
let hex = "";
|
||||||
|
for (let i=0; i < uint8.length; i++) {
|
||||||
|
hex += hex_chars[Math.floor(uint8[i]/16)];
|
||||||
|
hex += hex_chars[uint8[i] % 16];
|
||||||
|
}
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The loop iterates through through the `Uint8Array`, first dividing it by 16 and rounding down, to find the first hex character. Then, it divides by 16 and takes the remainder for the second hex character.
|
||||||
|
|
||||||
|
## Converting Hexadecimal to Bytes
|
||||||
|
|
||||||
|
```js
|
||||||
|
function hex_to_uint8(hex) {
|
||||||
|
const hex_chars = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'];
|
||||||
|
hex = hex.toUpperCase();
|
||||||
|
let uint8 = new Uint8Array(Math.floor(hex.length/2));
|
||||||
|
for (let i=0; i < Math.floor(hex.length/2); i++) {
|
||||||
|
uint8[i] = hex_chars.indexOf(hex[i*2])*16;
|
||||||
|
uint8[i] += hex_chars.indexOf(hex[i*2+1]);
|
||||||
|
}
|
||||||
|
return uint8;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Here, we determine how many whole bytes^\[4\]^ are in the hex string, by diving it by two. We then loop that many times, each time convering the two hex characters into a number by finding the value of the first hex character (`indexOf`), multiplying it by 16, then finding the value of the second of the second hex character, and adding it.
|
||||||
|
|
||||||
|
By the way, doing, for example, `new Uint8Array(5)`, will initialize an `Uint8Array` of all 0s, of length 5.
|
||||||
|
|
||||||
|
This function, as written, isn't designed to take in invalid input, so make sure to validate any inputs. In fact, I would encourage you to go and write your own conversion functions, instead of copy pasting these examples. You'll (hopefully) understand the concepts much faster that way.
|
||||||
|
|
||||||
|
===
|
||||||
|
- \[0\]: ±50 thousand lines (estimating skills are not my strong suit).
|
||||||
|
- \[1\]: [my 6000 lines of unfinished code in one horrific file](https://github.com/jetstream0/Muskets-and-Bayonets/blob/main/script.js).
|
||||||
|
- \[2\]: Signed integers have "signs", ie, they can represent negative numbers.
|
||||||
|
- \[3\]: In case you were wondering, I do write the `hex_chars` array out every time... slightly painful, but it's too much work to copy paste it from somewhere
|
||||||
|
- \[4\]: Note that the `Math.floor` means that this function only works with an even hex string length, since an odd hex string length would mean there's half of a byte (aka a nybble) being used, which is rare-ish.
|
||||||
11
posts/llm.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"LLMs can write all your cod-"
|
||||||
|
Don't care.
|
||||||
|
|
||||||
|
"LLMs can supercharge your produc-"
|
||||||
|
Don't care.
|
||||||
|
|
||||||
|
"We can force LLMs to return valid JSON by not letting them use invalid tokens!"
|
||||||
|
Fine, that's pretty cool.
|
||||||
|
|
||||||
|
"If you pay for this subscription..."
|
||||||
|
Please leave.
|
||||||
61
posts/manga_translation_one_rule.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
[This is a guest post by prussia of the prussianbluehedgehog scanlation group, a legally distinct entity from Prussia of prussiafan.club]
|
||||||
|
|
||||||
|
I have been scanlating for apparently, well over a year now. As the translator of such ~~mildly notable~~ globally renowned works such as Shikanokonokonokokoshitantan and ~~unbelievably obscure~~ cultural giants like Ubunchu, I feel like I have enough street cred to say there's really only one fundamental thing that separates good translations from bad ones: Whether or not the translation accurately conveys the author's intent.
|
||||||
|
|
||||||
|
Well, yeah, no shit, right? Unfortunately, this is apparently not so obvious to everyone. Some hobbyist and (even) professional translations do a disappointing job of preserving the author's meaning.
|
||||||
|
|
||||||
|
So, I will vainly (in both senses of the word) try to get all us translators on the same page.
|
||||||
|
|
||||||
|
## Localisation and Cultural Context
|
||||||
|
|
||||||
|
"Localisation" has always been a bit of a loaded and controversial topic, so I think it would be helpful to separate into two categories.
|
||||||
|
|
||||||
|
First, the intent of some localisations is to change or remove content from the source material, in order to avoid offending the non-Japanese audience's perceived sensibilities. A infamous example (though for an anime, not a manga) is the english version of Sailor Moon, which [removed violent or sexual scenes, and changed queer characters to be non-queer, accidentally making a lesbian couple into a pair of incestuous cousins instead](https://en.wikipedia.org/wiki/Sailor_Moon#Westernization). More recently, lines or scenes perceived as misogynstic have been removed too. This can hardly even be called localisation, since it really is just censorship.
|
||||||
|
|
||||||
|
Second, other localisations change things in order to make them foreign concepts understandable to non-Japanese people. **Good translations should do this!** The easiest way to illustrate this is by looking at how dialects are handled^\[0\]^. In "2DK, G Pen, Mezamashi Tokei" by Oosawa Yayoi, one the main characters speaks in a strong Hakata dialect. The excellent fan translation by Ropponmatsu, a Scottish dialect is used instead, and *importantly*, the translator explains that they substituted the Hakata dialect for the Scottish dialect. It's cute, and as the plot is divded between the character at work, where she speaks in standard Japanese, and at home, where she speaks in a dialect, the choice ends up working quite well. Similarily, the character ~~Ayumu Kasuga~~ Osaka in Azumanga Daioh speaks in a Osaka dialect, and is a bit of a "slow" character, which is portrayed as a American Southern dialect in both the dub of the anime and the manga translations. Now, the Osaka dialect is not a perfect analogue to a Southern dialect. When localising, you will find that these concepts rarely have perfect analogues. To avoid misleading readers, translators need to explain what decision was made, and why (which is what Ropponmatsu did). In Azumanga Daioh, they leave in that Osaka is from Osaka and speaks Osaka dialect. They do not change her into a character from the American South. Not having any localisation at all would make our translations only marginally better than machine^\[1\]^ or dictionary translations, and confuse readers.
|
||||||
|
|
||||||
|
**However, there is a difference between translating concepts and removing them entirely.** In many manga, a spoken word game called [shiritori](https://en.wikipedia.org/wiki/Shiritori) is played. No such equivalent exists in the western world. I have seen some translations change a game of shiritori into a different game entirely. That is wrong! This type of "localisation" doesn't help readers understand an unfamiliar concept, it just avoids doing so entirely. And in a practical sense, this will confuse the reader, especially if the omitted concept is referenced later, or there is some important information contained in it. Beyond that, it is not our place as translators to make drastic changes. We are not writing our own work, but trying to faithfully reproduce someone else's work in another language! Perhaps it is acceptable to change the words played into shiritori so that they start and end with the same letters, so readers have a vague (but not quite accurate) idea of what shiritori is. If that is done, the original words should be disclosed. And either way, there should be a TL note somewhere explaining what shiritori is.
|
||||||
|
|
||||||
|
There are other examples of this misguided practice. Some translations do not indicate (whether by font, wording, or note) when a character is speaking in a certain level of formality. Bad! Some translations change Japanese names into local names. The Detective Conan manga calls Kudo Shinichi "Jimmy Kudo", and Ran Mori "Rachel Moore" (to be fair, they do this to maintain consistency with the english dub, so it's really the anime's fault). C'mon guys, I bet people can handle Japanese names. Some translations also change Japanese cultural references into western ones without noting they did so. Others change panel and page order so manga can be read left-to-right instead of right-to-left, messing up the artwork. Bad, bad, bad! Luckily, these practices are decreasing over time.
|
||||||
|
|
||||||
|
Shockingly, manga written by Japanese people are often set in Japan or have Japanese characters. Therefore, improper localisation which tries to deny to this origin, ends up removing key parts of characters or the plot, and looks down on readers. Good localisation familiarize unfamiliar concepts, enhancing the reader's understanding. Good localisation assumes the reader is curious!
|
||||||
|
|
||||||
|
## Jokes
|
||||||
|
|
||||||
|
I often see jokes literally translated, so that they don't make sense at all, or even worse, removed. Needless to say, this is bad. I see this a lot on r/umamusume. This kind of thing is probably just the translator not being actually knowing Japanese (or being tired and missing a joke), so there isn't much that can be done about that, I guess.
|
||||||
|
|
||||||
|
Besides that, I think most translators handle jokes reasonably well. Most people handle puns by thinking of a similar pun in English and substituting it. To me, this is perfectly reasonable. Puns are normally "throw away" jokes, so the exact pun is *usually* not important. In many cases, a good pun cannot be found, so a TL note is left explaining the pun.
|
||||||
|
|
||||||
|
Transforming puns into puns is widely accepted, but other kinds of jokes are more complicated. Jokes that are funny because they make fun of a character or thing can usually be easily translated. But for more difficult jokes, I am on the side that they should be preserved, and an explanation given. These more complex jokes are not really "throw away", and can be elaborate setups by the author, and so it isn't right to replace them with our own jokes.
|
||||||
|
|
||||||
|
## Content and Wording
|
||||||
|
|
||||||
|
This is the last section I'm writing so my lengthy tendencies have tired out a bit. I'll try to keep this short.
|
||||||
|
|
||||||
|
Some translations re-word and rephrase lines so much that it almost reads like the translator was given the cleaned page (the original text removed, only the drawings), and writes the text based on vibes. This is my criticism of the Shikanokonokonokokoshitantan manga's official translation. Well, it's not quite that bad, but you can see the wording and sentences are often quite different from the Japanese. As I am a fan translator of shikanokonokonokokoshitantan, do not take that as an objective criticism. It is probably coloured. Whether it sometimes goes too far, and loses some nuance, is probably a matter of personal opinion. Anyhow, I am against the styles of translation that treat it almost like an oral tradition that can retold a million different ways. Yes, there are many ways to translate a text, but the number of ways to translate it such that the voice of the characters (as the author wrote it) is preserved, is much fewer.
|
||||||
|
|
||||||
|
## Accuracy vs. Flow?
|
||||||
|
|
||||||
|
So is a good, accurate translation at odds with readability? Are faithful translations doomed to be long, wordy, and clunky? Nope. In fact, it is just the opposite. Truly excellent translations flow with the same smoothness (or possibly, lack of smoothness) as the original text. The flow and length of the original text is part of the context, and cannot be ignored. How is this possible without compromising accuracy? Accuracy is not looking up every word in the dictionary and copying the definition into the text. It isn't even necessarily replicating the sentence structure exactly. It is about understanding what the text is actually saying, and successfully communicating it a different language, nuances and all. Rephrasing or saying something a different way is perfectly fine *if* the same meaning and nuances are preserved. Which is difficult. When a character says a line of dialogue, we should have questions like: "Why did the character say this?", "What emotions are present in the line?", "Is this speech casual, formal, passive-aggressive, or insulting?", etc etc. Then, we can ask the same questions of our translated text and see if the answers remain the same. **Essentially, we should think: "If the character were to rephrase this, how would they do so?"**
|
||||||
|
|
||||||
|
I'll admit sometimes I am too conservative and opt to preserve certain phrases, lengthening the translation as a result. It takes a lot of skill to have accuracy and flow co-exist, instead of balance. Hopefully I'll get better at this^\[2\]^.
|
||||||
|
|
||||||
|
But there no doubt will be situations where certain concepts or nuances cannot be succinctly conveyed, no matter the skill of the translator. In that case:
|
||||||
|
|
||||||
|
## Use TL Notes, Goddammit!
|
||||||
|
|
||||||
|
At this point, you've probably noticed my preferred solution to most problems, translator notes. They really are a miracle cure, and underutilised. Please use them more. I *especially* love it when the translator adds a whole page at the end of notes to explain things that wouldn't fit under the panel.
|
||||||
|
|
||||||
|
Translator notes add nuance and clarification without making the dialogue too wordy and clunky. Translator notes educate the reader about cultural context or even plot context. Translator notes provide a window into the mind of the translator - what decisions they made, and why. Without translator notes, readers who don't know Japanese and don't have access to the source material will mistakenly believe the author/characters said something they never did, or even worse, not understand what is going on at all. If that happens, we as translators have failed.
|
||||||
|
|
||||||
|
What I assert that our job is to smoothly and correctly translate the author's intent, I do not mean we do not leave any of our own influence on the work. That is impossible, since there is no deterministic algorithm for translation. Anyone who says otherwise is delusional. We have to make decisions about every phrase and line. Hopefully, those decisions make it so non-Japanese readers can experience the manga in a very similar way as Japanese readers do. But, those readers should be made aware of those decisions.
|
||||||
|
|
||||||
|
Hence, translator notes are essential.
|
||||||
|
|
||||||
|
===
|
||||||
|
|
||||||
|
Footnotes:
|
||||||
|
- \[0\]: I used to be of the opinion that sfx should not be localised, but I have changed my mind. Old me was wrong. English readers do not know that "ガタガタ" is the sound of something shaking or clattering, and even if they could read it as "gata gata", it wouldn't really help. I think the best way to handle sfx is to write, in small text next to the sfx, the meaning of the sfx. Unfortuantely, in some manga, there is no space, and having a TL note underneath the panel does not always work (eg, multiple sfx), so in that case cleaning the sfx and replacing with the English equivalent is appropriate. Honestly, leaving the sfx as the original is quite common and acceptable too, but providing a localisation is better.
|
||||||
|
- \[1\]: For obvious reasons, I have been thinking a lot about AI lately, mostly against my will. It really sucks that entire classes of art and craftsmanship are being wiped out. Regular translation has already been gutted, and I'm a bit scared for when it comes for scanlation too. Some people are probably already using it to translate... augh. Interestingly enough, the world's greatest piece of literature since the Epic of Gilgamesh, Shimeji Simulation, seems to have something to say about this. I want to eventually write an analysis (what a fancy word...) about it. Hopefully Shimeji Simulation will help me cope.
|
||||||
|
- \[2\]: Translating to Toki Pona, which has only a few sentence structures, and 120~ words, is a good way to practice how to rephrase a line without losing the essence or important nuances, in my experience. If you try to translate too literally, the sentence will be too long and confusing, so you are forced to simplify.
|
||||||
|
|
||||||
187
posts/meta.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
There used to be a different blog here. But it wasn't very good, [code](https://github.com/jetstream0/Markup-to-HTML) *and* writing-wise. So I decided to rewrite everything.
|
||||||
|
|
||||||
|
The old blog was an express server that looked for `.md` files in a directory, converted them to HTML, and served it. That worked mostly fine. However, the converter didn't support many Markdown features, and was pretty buggy. Using an express server also meant that the site wasn't static, and had limited options for hosting.
|
||||||
|
|
||||||
|
Replit's free tier was pretty unreliable, and Render only allowed one free web service per account, which I was already using to run my [faucet](https://faucet.prussia.dev). I could've created a new Render account, but they recently started requiring credit card verification, which I didn't really want to do.
|
||||||
|
|
||||||
|
Besides, making the blog static instead of relying on an express server wouldn't be that hard. A month before, I had already wrote a better (?) or at least more fully featured Markdown to HTML parser, [Makoto](https://github.com/jetstream0/Makoto-Markdown-to-HTML), so that was already one of the problems with the old blog solved.
|
||||||
|
|
||||||
|
Anyways, once I decided to start completely rewriting the blog, I established some goals that I wanted the new blog to accomplish.
|
||||||
|
|
||||||
|
## Technical Goals
|
||||||
|
- Static, so it can be deployed by eg, Github Pages or Cloudflare Pages
|
||||||
|
- Built from scratch with no third-party dependencies (builtin modules like `path` and `fs` are ok of course, `makoto` is not third party, also doesn't count)
|
||||||
|
- Use Typescript
|
||||||
|
- No Javascript running client side - the web pages should be pure HTML and CSS
|
||||||
|
- Style-wise, should be minimalistic, nothing fancy
|
||||||
|
- Load quickly and be small in size
|
||||||
|
|
||||||
|
## Non-Technical Goals
|
||||||
|
- Make two things I can call "Ryuji" and "Saki" to go along with "Makoto" (those are the three main characters of one of the best manga series ever)
|
||||||
|
- Move over some of the old blog posts (only the stuff I like), after rewriting them
|
||||||
|
- Start writing stuff on the blog again, at least semi-regularly
|
||||||
|
|
||||||
|
## Code
|
||||||
|
Hedgeblog (oh, that's what I'm calling it by the way) is made of three components: Makoto (Markdown to HTML parser), Ryuji (templating language), and Saki (build system).
|
||||||
|
|
||||||
|
You can find the code on [Github](https://github.com/jetstream0/hedgeblog).
|
||||||
|
|
||||||
|
### Makoto
|
||||||
|
Makoto is the Markdown-to-HTML parser, made with no dependencies. It was made around two months before Ryuji and Saki, and is meant to be more of a standalone thing. This is the sole npm dependency of the project. I `npm install`ed it instead of just copying the file over mostly because I published Makoto to npm and wanted to make sure it worked. Also, it has a different license, documentation and stuff.
|
||||||
|
|
||||||
|
All the standard Markdown are supported (headers, bolds, italics, images, links, blockquotes, unordered lists, ordered lists, code, code blocks...), as well as superscripts and tables (although tables are probably buggy). Makoto also does some pretty neat stuff like passing on the language of the code block (if given) as a class in the resulting div: `code-<language>`, or automatically add ids to headers, so they can have anchor links.
|
||||||
|
|
||||||
|
It also has a very cool warnings feature, which isn't used in this project, but can be seen in action if you use the [Makoto Web Editor](https://makoto.prussia.dev).
|
||||||
|
|
||||||
|
### Ryuji
|
||||||
|
Ryuji is a simple templating system that supports `if` statements, `for` loops, components, and inserting variables. It isn't quite as fully featured as Jinja/Nunjucks, but on the upside, Ryuji is less than 280 lines of code, and worked very well for my usecase. I think it's pretty cool.
|
||||||
|
|
||||||
|
Here's a quick overview of the syntax:
|
||||||
|
|
||||||
|
```html
|
||||||
|
[[ component:navbar ]] <!--this looks for templates/components/navbar.html and displays it here-->
|
||||||
|
<p>You can insert variables. My favourite food is: [[ favourite_food ]]</p>
|
||||||
|
<p>And make sure the variable is truthy then do something.</p>
|
||||||
|
[[ if:show_secrets ]]
|
||||||
|
<ul>
|
||||||
|
<li>Secret 1: lorem ipsum</li>
|
||||||
|
<li>Secret 2: My favourite is not actually [[ favourite_food ]]</li>
|
||||||
|
</ul>
|
||||||
|
[[ endif ]]
|
||||||
|
<div>
|
||||||
|
<p>Variables are by default sanitized so HTML/CSS/JS can't actually be executed, but you can disable this.</p>
|
||||||
|
[[ html:html_from_database ]]
|
||||||
|
</div>
|
||||||
|
[[ for:members:member ]]
|
||||||
|
<p><b>[[ member ]]</b> is a proud member of our group!</p>
|
||||||
|
[[ endfor ]]
|
||||||
|
```
|
||||||
|
|
||||||
|
After finishing writing Ryuji, and writing some tests to make sure it all worked, I realized that I needed a few features that were missing.
|
||||||
|
|
||||||
|
There was no way to see the current index in a for loop, or the max index (length-1) of whatever variable we were looping over. This was needed for the tag links in the post.
|
||||||
|
|
||||||
|
In addition, if statements only checked if the variable was truthy (not `false` or `0` or `""`), but I needed if statements making sure two variables were equal, as well as if statements making sure two variables were *not* equal. You can see this being used in the post's tags along with the new for loop features, as well as the "Next Post" link at the bottom of the post.
|
||||||
|
|
||||||
|
So, I added those features. Let's take a look of these new features being used to show and link post tags.
|
||||||
|
|
||||||
|
Formatting the code a little nicer, this is what it looks like:
|
||||||
|
|
||||||
|
```
|
||||||
|
[[ for:post.tags:tag:index:max ]]
|
||||||
|
<a href="/tags/[[ tag ]]">[[ tag ]]</a>[[ if:index:!max ]], [[ endif ]]
|
||||||
|
[[ endfor ]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Ok, in the first line (`for:post.tags:tag:index:max`), we are looping over the variable `post.tags`, and assigning each item in `post.tags` as the variable `tag`. That's nothing new, what's new is the `:index:max` portion. `index` is the index variable, starting at 0 and incrementing every loop, while `max` is the maximum index (the length of the variable to loop over - 1).
|
||||||
|
|
||||||
|
If you look at the Ryuji code, now you can see that it is looping over the tags of the post, and creating a link for each tag. If the tag is *not* the last tag (`index` is not equal to `max`), we will also add a comma (and a space).
|
||||||
|
|
||||||
|
Here's the equivalent python code, if it helps:
|
||||||
|
|
||||||
|
```python
|
||||||
|
html = ""
|
||||||
|
max = len(post.tags)-1
|
||||||
|
index = 0
|
||||||
|
for tag in post.tags:
|
||||||
|
html += "<a href=\"/tags/"+tag+"\">"+tag+"</a>"
|
||||||
|
if index != max:
|
||||||
|
html += ","
|
||||||
|
index += 1
|
||||||
|
```
|
||||||
|
|
||||||
|
While Ryuji is meant for HTML, there is no reason it can't be used for other formats.
|
||||||
|
|
||||||
|
Take a look at the [docs](/posts/ryuji-docs) for Ryuji.
|
||||||
|
|
||||||
|
### Saki
|
||||||
|
Saki is the build system that uses Ryuji templates to generate all the HTML files, and then outputs everything (including static files, of course) to the `build` directory. Even more simple than Ryuji, it is just around 70 lines of code.
|
||||||
|
|
||||||
|
Here are the very short [docs](/posts/saki-docs) for Saki.
|
||||||
|
|
||||||
|
## Putting It All Together
|
||||||
|
When building, the program (`index.ts`) reads `/posts/_metadata.json` and passes all the post metadata information to the `templates/index.html` template, which is the home page! Then, it renders all the posts with the `templates/post.html` template and outputs to `/posts/*`. Next, it looks again at the post metadata and gets all the tags used. Once it has all the tags, pages for the tags are generated at `/tags/*`. That page lists all the posts with that tag. Scroll up and try it! It also outputs the `static` directory, as well, static files.
|
||||||
|
|
||||||
|
> ### Tip: Serve Without The `.html`
|
||||||
|
> Say we want to serve a HTML file at `/posts/example` instead of `/posts/example.html`. Just create a directory called `example` and put `example.html` inside it, renaming it `index.html`.
|
||||||
|
> Basically, `/posts/example.html` becomes `/posts/example/index.html`, and now the HTML file is served at `/posts/example`. You probably already knew that, but if you didn't now you know ^\[citation needed]^.
|
||||||
|
|
||||||
|
## Buttons Without Javascript
|
||||||
|
If you think back to around 950 words ago, you may recall that this site has zero frontend Javascript (or WebAssembly, as cool as it is). If you didn't recall that, I just reminded you. I will be sending an invoice later.
|
||||||
|
|
||||||
|
So how does the "Show MD", "Fancy Title", and Dark/Light theme toggle work? The key thing here is that all three of these are checkboxes. Yes, even the Dark/Light theme toggle is a checkbox. That toggle hides its checkbox and takes advantage of the fact you can click on a `<label>` with an appropriate `for` attribute to toggle a checkbox, and uses the `::after` psuedo-element to change the content between the moon emoji and the sun emoji, depending on the state of the checkbox.
|
||||||
|
|
||||||
|
What's so special about checkboxes? The `:checked` ([see MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/:checked)) property, that's what! With the `:checked` property, we can apply styles depending on whether a checkbox is checked. See the following, which scales the checkbox by 3x when the checkbox is checked:
|
||||||
|
|
||||||
|
```css
|
||||||
|
input[type="checkbox"] {
|
||||||
|
display: inline-block;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"]:checked {
|
||||||
|
transform: scale(3);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
And because of the `a ~ b` (select selector b *after* selector a) and `a + b` (select selector b *immediately after* selector a) CSS selectors, we can change the properties of other elements. Unfortunately, there doesn't seem to be a pure CSS way to change the style of elements *before* the checkbox. There are ways around this, like putting the checkbox before the desired element in the code, but using `position: relative`, `position: absolute`, and other ways to make the checkbox visually look like it is after the desired element. I didn't really want to do that though, so you can notice that all the checkboxes on this website are before the element whose style they change.
|
||||||
|
|
||||||
|
Here's a real example. The following CSS makes the "Show MD" checkbox functional:
|
||||||
|
|
||||||
|
```css
|
||||||
|
#post-md {
|
||||||
|
margin-top: 7px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#show-md:checked ~ #post-md {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#show-md:checked ~ #post-html {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Other Style Notes
|
||||||
|
I used Verdana for headers, Courier New for code, and Times New Roman for everything else. These fonts were chosen mostly because they are websafe, ie included in most OSes. You cannot stop me from using Times New Roman. I like the look.
|
||||||
|
|
||||||
|
The colour for visited links is `orchid`, and the colour for non-visited links is `forestgreen`.
|
||||||
|
|
||||||
|
Since this blog is basically for my own "enjoyment", and doesn't need to look "sleek" or "professional", this site's design philosophy is moreso a rejection of those overdesigned corporate sites with too many popups, as well as certain React (or other framework) sites that don't even load with Javascript disabled, than a specific set of tenets. Combined with the fact that choosing colour schemes makes me angry, don't expect very consistent or aesthetic designs.
|
||||||
|
|
||||||
|
But I'll try my best.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
After `git clone`ing the repo, `cd` into the directory and install the (sole) dependency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
To build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
To build and preview locally at `http://localhost:8042`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
To run tests for Ryuji:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Todo
|
||||||
|
In the future, I would love to have those fun box gifs^\[0\]^ you used to see on geocities and other websites (like the ones on the bottom of the [pensquid](https://pensquid.net/) website), plus also something similar to [Wikipedia Userboxes](https://en.wikipedia.org/wiki/Wikipedia:Userboxes).
|
||||||
|
|
||||||
|
And I'll keep improving the site and fixing bugs, and occasionally write articles for the roughly four readers of this blog.
|
||||||
|
|
||||||
|
===
|
||||||
|
- \[0\]: This has been done! Plus, there's a [RSS feed](/posts/rss-feed) now too.
|
||||||
169
posts/month_start_unix.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
I came across a problem while making a new faucet for Astral Credits. This faucet had a limited amount of claims each month, so once that amount of claims were made, the faucet would stop dispensing coins for the rest of the month. So, an useful feature to have would be a countdown on the page telling users when a new month would begin and faucet claims would be reset.
|
||||||
|
|
||||||
|
The beginning of the month would depend on your timezone, but we want the faucet to reset at the same time for everyone. The obvious solution is to make the faucet reset on the beginning of the month in the standard UTC timezone.
|
||||||
|
|
||||||
|
Now, for writing the code for the countdown, I could just use Javascript's built in `Date` class. This is a slightly modified version of the code I came up with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function get_next_month_diff() {
|
||||||
|
let current_date = new Date();
|
||||||
|
//get Date object set to the beginning of the next month
|
||||||
|
//if current month is january, next month will technically give the date of december 31st midnight but that's fine since that's the same time as january 1st 00:00:00
|
||||||
|
let next_month = new Date(Date.UTC(current_date.getUTCFullYear(), current_date.getUTCMonth()+1));
|
||||||
|
//get difference in seconds between current time and the start of the next month
|
||||||
|
return (next_month.getTime() - current_date.getTime()) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(function() {
|
||||||
|
let seconds_until = get_next_month_diff();
|
||||||
|
//... rest of the code omitted
|
||||||
|
}, 1000);
|
||||||
|
```
|
||||||
|
|
||||||
|
This works perfectly fine, but wouldn't it be cool if we could do this, without using `Date.UTC()`?
|
||||||
|
|
||||||
|
Did I hear someone say "Not really"? Get out!
|
||||||
|
|
||||||
|
Anyways, Unix time starts on 00:00 UTC on January 1st, 1970. So to find the Unix timestamp at the start of a month, we would see how many years it has been since 1970, and add the number of years times the number of seconds in a year. Then, we would see what number month it is, and add the number of months since the beginning of the year times the number of seconds in a month. We don't need to worry about days or hours or seconds, since we are only calculating the Unix timestamp of the start of the a month (which is the 1st day, 0 hours and 0 minutes and 0 seconds).
|
||||||
|
|
||||||
|
Here's the code:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function get_next_month_unix() {
|
||||||
|
let current_date = new Date();
|
||||||
|
let current_year = current_date.getUTCFullYear();
|
||||||
|
let current_month = current_date.getUTCMonth();
|
||||||
|
let next_year = current_year;
|
||||||
|
let next_month = current_month + 1;
|
||||||
|
//this time next month being january needs to be properly handled
|
||||||
|
if (current_month == 11) {
|
||||||
|
//if december, next year is +1 and month is 0
|
||||||
|
next_year = current_year + 1;
|
||||||
|
next_month = 0;
|
||||||
|
}
|
||||||
|
let unix_timestamp = 0;
|
||||||
|
//years since 1970 * seconds in a year
|
||||||
|
unix_timestamp += (next_year-1970)*(60*60*24*365);
|
||||||
|
//months * seconds in a month
|
||||||
|
unix_timestamp += next_month*(60*60*24*30);
|
||||||
|
return unix_timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_next_month_diff() {
|
||||||
|
return get_next_month_unix() - (Date.now() / 1000);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
But wait! Months don't always have 30 days. Oops.
|
||||||
|
|
||||||
|
That's not too hard to fix. We can just hardcode in a object that stores how many days each month has:
|
||||||
|
|
||||||
|
```js
|
||||||
|
let days_months = {
|
||||||
|
"0": 31,
|
||||||
|
"1": 28,
|
||||||
|
"2": 31,
|
||||||
|
"3": 30,
|
||||||
|
"4": 31,
|
||||||
|
"5": 30,
|
||||||
|
"6": 31,
|
||||||
|
"7": 31,
|
||||||
|
"8": 30,
|
||||||
|
"9": 31,
|
||||||
|
"10": 30,
|
||||||
|
"11": 31
|
||||||
|
};
|
||||||
|
|
||||||
|
function get_next_month_unix() {
|
||||||
|
let current_date = new Date();
|
||||||
|
let current_year = current_date.getUTCFullYear();
|
||||||
|
let current_month = current_date.getUTCMonth();
|
||||||
|
let next_year = current_year;
|
||||||
|
let next_month = current_month + 1;
|
||||||
|
//this time next month being january needs to be properly handled
|
||||||
|
if (current_month == 11) {
|
||||||
|
//if december, next year is +1 and month is 0
|
||||||
|
next_year = current_year + 1;
|
||||||
|
next_month = 0;
|
||||||
|
}
|
||||||
|
let unix_timestamp = 0;
|
||||||
|
//years since 1970 * seconds in a year
|
||||||
|
unix_timestamp += (next_year-1970)*(60*60*24*365);
|
||||||
|
//months * seconds in a month
|
||||||
|
for (let i=0; i < next_month; i++) {
|
||||||
|
unix_timestamp += 60*60*24*days_months[String(i)];
|
||||||
|
}
|
||||||
|
return unix_timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_next_month_diff() {
|
||||||
|
return get_next_month_unix() - (Date.now() / 1000);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
And don't forget leap days...
|
||||||
|
|
||||||
|
Leap days are apparently on years that are divisible by 4, with the exception being if they are divisible by 100 but not 400.
|
||||||
|
|
||||||
|
```js
|
||||||
|
let days_months = {
|
||||||
|
"0": 31,
|
||||||
|
"1": 28,
|
||||||
|
"2": 31,
|
||||||
|
"3": 30,
|
||||||
|
"4": 31,
|
||||||
|
"5": 30,
|
||||||
|
"6": 31,
|
||||||
|
"7": 31,
|
||||||
|
"8": 30,
|
||||||
|
"9": 31,
|
||||||
|
"10": 30,
|
||||||
|
"11": 31
|
||||||
|
};
|
||||||
|
|
||||||
|
const is_leap_year = (year) => year%4 == 0 && (year%100 != 0 || year%400 == 0);
|
||||||
|
|
||||||
|
function get_next_month_unix() {
|
||||||
|
let current_date = new Date();
|
||||||
|
let current_year = current_date.getUTCFullYear();
|
||||||
|
let current_month = current_date.getUTCMonth();
|
||||||
|
let next_year = current_year;
|
||||||
|
let next_month = current_month + 1;
|
||||||
|
//this time next month being january needs to be properly handled
|
||||||
|
if (current_month == 11) {
|
||||||
|
//if december, next year is +1 and month is 0
|
||||||
|
next_year = current_year + 1;
|
||||||
|
next_month = 0;
|
||||||
|
}
|
||||||
|
let unix_timestamp = 0;
|
||||||
|
//years since 1970 * seconds in a year
|
||||||
|
unix_timestamp += (next_year-1970)*(60*60*24*365);
|
||||||
|
//add leap days
|
||||||
|
for (let year=1970; year < next_year; year++) {
|
||||||
|
if (is_leap_year(year)) {
|
||||||
|
unix_timestamp += 60*60*24;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//months * seconds in a month
|
||||||
|
for (let i=0; i < next_month; i++) {
|
||||||
|
unix_timestamp += 60*60*24*days_months[String(i)];
|
||||||
|
//if feburary, and is leap year, add another day
|
||||||
|
if (i == 1 && is_leap_year(next_year)) {
|
||||||
|
unix_timestamp += 60*60*24;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unix_timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_next_month_diff() {
|
||||||
|
return get_next_month_unix() - (Date.now() / 1000);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
At this point, while thinking about leap days, I realized one huge problem: [leap seconds](https://en.wikipedia.org/wiki/Leap_second). It's a pretty bizzare concept.
|
||||||
|
|
||||||
|
Time is defined by the rotation of the Earth and Earth's orbit around the sun, which does not take a constant amount of time, because the orbit changes or something like that. Don't ask me, I'm not an astronomer. But since there can only be 24 hours in a day, sometimes the difference between our measured time (aka "precise time") and the real time (aka "solar time") can drift, and it needs to be corrected.
|
||||||
|
|
||||||
|
So, similar to how sometimes leap days happen, the international time keeping authorities (known as the "International Earth Rotation and Reference Systems Service"), occassionally issue leap seconds (if a leap second is issued, instead of the second after 23:59:59 being 00:00:00, it wil be 23:59:60). But unlike leap days, the issuance of leap seconds cannot be predicted, and does not follow any pattern, making it a huge pain in the ass to deal with. There would be no practical way to account for leap seconds (hardcoding all the leap seconds in, and update them when new leap seconds are announced is not practical^citation needed^), so I thought I had to give up.
|
||||||
|
|
||||||
|
Luckily, I did a quick [search](https://stackoverflow.com/questions/16539436/unix-time-and-leap-seconds), and it turns out Unix time **ignores leap seconds**, so the above code works correctly. Yay!
|
||||||
47
posts/neet_admiration.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
I think NEETs get a bad rap. Really, I'll go even further and say NEETs deserve some admiration, in the same way that monks do.
|
||||||
|
|
||||||
|
Not being in education, employment or training isn't inherently bad, we know that. Most of us aspire to be NEETs in a sense, when we hopefully one day retire (please, please, let us retire).
|
||||||
|
|
||||||
|
What pisses people off is the age when NEETs become NEETs, which is several decades before the common retirement age. The sentiment is then that they are lazy parasites on society, while the elderly have earned their NEET-hood.
|
||||||
|
|
||||||
|
But is this not a double standard?
|
||||||
|
|
||||||
|
The trust fund millionaire, who hasn't worked a day in their life, while not exactly respected, is not viewed as negatively as a NEET.
|
||||||
|
|
||||||
|
A better example may be a 20-something year old entrepreneur, who exits after their startup is sold, with a bajillion dollars, and retires. These people are lauded in the newspapers, and there are many people aspiring to be like them. Sure, they have worked, but was their work truly that many times more valuable than someone who can only retire in their 60s?
|
||||||
|
|
||||||
|
So it is clear now that standard for whether NEET-hood is acceptable or not is based on money.
|
||||||
|
|
||||||
|
But if the NEET has the financial or practical means (eg, a vegetable garden) to maintain their NEET-hood, how is it any different?
|
||||||
|
|
||||||
|
Well, obviously, the argument goes, many NEETs live off of welfare, or family handouts, giving nothing back, and are therefore parasites. But the NEET is not extorting any money, or stealing. The government, or family, are willingly lending support, in the same way a community gives alms to a monk.
|
||||||
|
|
||||||
|
Though, many monks provide spiritual services to alms-givers, or at least giving alms spiritually benefits the alms-giver in some way. This is not a very convincing argument to someone not sharing the same religion as the monk, of course.
|
||||||
|
|
||||||
|
Some monks create works of art, such as sculpture or literature. They also do work, just not as part of employment. They probably have religious duties, like rituals or prayers, and physical chores like cleaning or taking care of a small garden.
|
||||||
|
|
||||||
|
So if a NEET does the same, either creating works of art or doing chores, are they not similar? Can we not justify them too?
|
||||||
|
|
||||||
|
I think we can, anyways. While being a NEET is not quite the same as a bum, [Utah Phillips](https://en.wikipedia.org/wiki/Utah_Phillips) was a vagrant trainhopping bum for several years, and I would say he ended up providing great value to a lot of people, including me. Just as one example. It wasn't like he "recovered" from or disavowed being a bum, it was a key component of his successes.
|
||||||
|
|
||||||
|
NEETs, again similar to monks, typically live a simple and poor lifestyle. To be satisfied with what you have is quite admirable in my eyes. Being poor is not easy.
|
||||||
|
|
||||||
|
A common framing of the media and others is that NEETs are an economic problem, because they are not creating value for society in the typical way. Sure, this is true. If the whole nation became monks many problems would arise too. But this is not the fault of the NEETs. NEETs are a symptom of a sick society. Just as monks increase when there is war, famine, or general instability, NEETs are increasing because the world kinda sucks, y'all.
|
||||||
|
|
||||||
|
Given my admiration for NEETs, why not become one?
|
||||||
|
|
||||||
|
I've thought about it.
|
||||||
|
|
||||||
|
I somewhat believe people have an obligation to society, that we should pay through work, so that is part of it. But there is no doubt that people can be of service to society while still being a NEET. Further, it is arguable whether most jobs are indeed really making value for society. So that is pure cope on my part.
|
||||||
|
|
||||||
|
The truth is I do not have the balls or bravery to do it. I do somewhat care about how people I know perceive me. This is unsarcastically unfortunate. I would characterise myself as much less materialistic than typical, and I don't wish I was uber wealthy, but I am scared of being in poverty, still. These are the same exact reasons why I wouldn't become a monk, or a drifter or some sort.
|
||||||
|
|
||||||
|
To have the courage and lack of greed to be a NEET, or, if it was not by choice, the courage to accept the situation and still be happy, is something that we can strive for, at least to some extent.
|
||||||
|
|
||||||
|
===
|
||||||
|
|
||||||
|
Some addendums:
|
||||||
|
|
||||||
|
- This should almost go without saying, but not all NEETs are noble, there are surely some who do nothing, hate their situation, and abuse others. But that's pretty much true for any group of people, I guess
|
||||||
|
- My main criticism of NEETs, is the tendency to be hikikomoris. I mean that in the strictest definition; that is, not going outside. I'm pretty sure this is not healthy over a long period of time. It is not that I am against refusing social interactions, or generally isolating oneself. I simply believe that mentally it is better to go outside, go on a walk, and see nature, like how traditional ascetics
|
||||||
|
- Being a monk is also not always a life-long vow, again like NEET-hood, depending on the religion and tradition
|
||||||
20
posts/new_hobbies.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
So, it's been roughly 5 months since I've written anything on here. Unfortunately, I am still alive, meaning I am obliged to write a post in order to justify paying for this domain name.
|
||||||
|
|
||||||
|
Recently, I've gotten into two new hobbies as programming-wise things are a little boring. I'm only occasionally working on two projects, one of which is a job from a client. The other is an offline wiki* cli reader (currently only tested wikiquote but it probably works with wikipedia and whatnot) written in Lisp Scheme.
|
||||||
|
|
||||||
|
## Toki Pona
|
||||||
|
|
||||||
|
Learning Toki Pona has been on my list of things to do for a while now, so I decided to at least start learning the basics. If you aren't familiar with Toki Pona, it is a conlang (constructed language; think Esperanto or Klingon), with only around 120 words and a little more than a dozen sounds.
|
||||||
|
|
||||||
|
To help memorize the vocab and practice grammar, I made [toki-pona.pages.dev](https://toki-pona.pages.dev) (please don't look at the source code, it's an absolute mess). That site takes all of its content from [mun.la/sona](https://mun.la/sona) which is a fantastic resource for learning the basics of Toki Pona. I can't speak to the more advanced sections, because I haven't gotten there, and because some are incomplete. Iirc the associated youtube channel has a lot more content.
|
||||||
|
|
||||||
|
In the last week I haven't practiced. Hopefully I'll get back to this soon. It would be nice to find someone who actually speaks / writes in the language, for some motivation and practice.
|
||||||
|
|
||||||
|
## Scanlation
|
||||||
|
|
||||||
|
Another big thing that has been on my todo list for a long time is scanlating manga. Scanlations are fan translations of manga. One of the manga I was reading on mangadex was dropped with only one volume left. The original scanlation group stopped because an official english translation was available. I really wanted to read the rest, so I decided to take matters in my own hands and start translating it. After the first two or three chapters translated, two others joined my group and helped with translation and upscaling, which I'm incredibly grateful for.
|
||||||
|
|
||||||
|
It should be fairly trivial to find my group and what titles we've scanlated, but I will avoid doing so here because, well, scanlating is not exactly legal. Rather, it is illegal and a violation of copyright law (hurray).
|
||||||
|
|
||||||
|
That's all.
|
||||||
|
|
||||||
46
posts/operation_media_freedom.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
In the last six months, the piece of software I have used the most is undoubtably [pla-den-tor](https://github.com/stjet/pla-den-tor). Except for Firefox, that is. Oh, and (neo)vim I guess.
|
||||||
|
|
||||||
|
Alright.
|
||||||
|
|
||||||
|
In the last six months, the piece of software I have used the third most is undoubtably pla-den-tor.
|
||||||
|
|
||||||
|
## What is a pla-den-tor?
|
||||||
|
|
||||||
|
Pla-den-tor is one of those word contraction things for "plausible deniability tor" because the original idea was a sort of media server running as a Tor hidden service (a .onion site), password protected^\[0\]^ with [HTTP Basic authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). The Tor and password part constitute all three of the words in the project title, but 90% of the time, I just run it locally without a password, since I have a USB stick with all the pla-den-tor stuff on it. So the name doesn't really make much sense. There is however, a possibility I run an instance of pla-den-tor as an onion site with the password and everything, for when I don't have that USB on me, at least. Who knows.
|
||||||
|
|
||||||
|
Back to what pla-den-tor actually is, it started out as a simple media server with a web interface. It has three categories: anime, manga, and music. For the purposes of this article, let's say everything is completely legally obtained. In each of those three categories, there are arbitrary subcategories. For anime, that would be anime serieses. For manga, it would be... manga serieses. And for music that would be artists. Manga serieses have their own subcategory, chapters, which contain pages.
|
||||||
|
|
||||||
|
Essentially, you create a directory for, say, an artist, in the music directory. Drop whatever song files you want in there, and repeat. Then, you run the build script, and it'll generate a bunch of html files linking to them. Every song gets it's own page (and the actual song file is embedded on that page), and each artist gets a page that links to all the song pages. The main page links to all the artists/manga/anime. For manga specifically there is a special viewer that just shows all the pages in the chapter. Oh, and all the song/chapter/episode pages have links back to their artist/manga/anime, as well as links to the next or previous song/chapter/episode. Nothing too fancy at this stage.
|
||||||
|
|
||||||
|
This format works well for manga and anime, but not so much for music. I don't want to click a bunch of links to get to the next song when the current song ends. Naturally, a music player was added soon after. After some work, the player got to a point where I'm very satisfied with it. It has a queue, filters (lots and lots of checkboxes for songs and artists), playlists, history (which I don't really use but might one day in order to make a "spotify wrapped" knockoff), and obviously shuffling.
|
||||||
|
|
||||||
|
## Oh yeah, it shows time-synced lyrics too
|
||||||
|
|
||||||
|
...of songs that I manually make WebVTT files for. I use a [tool](https://ztmy.prussia.dev/subtitles) from a previous project to make these. It takes some time, especially putting furigana on japanese (and other non-english) songs, but I have *a lot* of tolerance for these kinds of tasks^\[1\]^.
|
||||||
|
|
||||||
|
## Why (do I) use pla-den-tor?
|
||||||
|
|
||||||
|
All the stuff on there is stuff I already know I like, not new stuff I'm planning to consume. This is for two reasons. The first is that downloading and adding it to pla-den-tor takes some (very little, but yet still non-zero) effort^\[2\]^. The second is that I don't have that much storage space and upgrading it (which I have done several times already) costs money.
|
||||||
|
|
||||||
|
This means for anime and manga, where I'm mostly consuming new content, and only ocassionally revisiting old favourites, it isn't used too often. But for music, you usually want to listen to the songs you already like rather than constantly listening to new stuff that on average is not so great. As a result, over 75% of my music listening is now done on either pla-den-tor's player. Another 20% or so is done on a local music player on my phone. All the music there is the same as the pla-den-tor music library. On a bit of a side note, my phone actually can run pla-den-tor, because termux is awesome. But pla-den-tor doesn't show album cover art (well, I haven't bothered to figure out how), so I use a FOSS music player app most of the time. Of course, pla-den-tor will always be missing one part of the streaming experience: discovery. Unfortunately, that will still have to be done on some platform (youtube?), or even worse, asking real people.
|
||||||
|
|
||||||
|
## Why not use another FOSS music player?
|
||||||
|
|
||||||
|
Specifically regarding local usage of pla-den-tor for music, couldn't I have just used some existing music player app and saved myself some work? You know the answer to that. I like to make my own stuff. Plus, I don't want to spent a few hours poking around some random guy's code and either submit a PR or maintain a fork whenever I want to add some specific feature.
|
||||||
|
|
||||||
|
It should be implicit that spotify or similar services are not an option - I am sick of half the listening time being ads (and yes, sick of spending time trying to block them), and even more sick of paying for services with barely functional apps^\[3\]^.
|
||||||
|
|
||||||
|
## The code
|
||||||
|
|
||||||
|
The code's nothing special. It uses Ryuji for templating and Saki as a build system, same as this blog. The player code is a lot of spaghetti but not excessively so.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
===
|
||||||
|
|
||||||
|
Footnotes:
|
||||||
|
- \[0\]: The password actually changes every UTC day. It is derived by hashing a secret master password with the current date
|
||||||
|
- \[1\]: [See my 1 hour and 18 minutes of concert subtitles](https://github.com/stjet/ztmy/blob/master/static/vtt/cleaning-labo.vtt)
|
||||||
|
- \[2\]: Besides "legally" downloading it and moving it into the right directory, I also need to manually add some metadata so things look nice when I play music on my phone
|
||||||
|
- \[3\]: I am mainly basing this comment on my experiences with the official spotify clients, and to a lesser extent apple music. Spotify's clients in particular are absolutely terrible (Why does it have to keep loading and unloading stuff? Just keep it loaded!!! And why does it just randomly stop being responsive? Why are some clients missing the features of other clients?). My understanding is with spotify premium, you can use custom clients, at least
|
||||||
|
|
||||||
488
posts/pilanimate.md
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
PilAnimate is a python library that renders video frame by frame, using PIL.
|
||||||
|
|
||||||
|
Documentations are up to date for version 0.4.1. A lot of the library is just a wrapper for PIL, so if you are having trouble it could be useful to reference those if you encounter any issues.
|
||||||
|
|
||||||
|
## Why use PilAnimate?
|
||||||
|
|
||||||
|
I mostly made PilAnimate for my personal use, and I'm sure there's probably better ones out there, but I made PilAnimate to be fairly simple and easily extendable. It renders frame by frame, meaning that even the most complex tasks can be completed on old computers, albeit slowly.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install pilanimate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Animation
|
||||||
|
|
||||||
|
**Class**
|
||||||
|
|
||||||
|
Creates layers
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- layer_num: number of layers
|
||||||
|
- size (optional=(1600,900)): size in pixels [width,height]
|
||||||
|
- fps (optional=25): frames per second
|
||||||
|
- mode (optional="RGBA"): Type and depth of a pixel in the image. See PIL docs.
|
||||||
|
- color (optional=0): background color to use for the image
|
||||||
|
|
||||||
|
Returns: itself
|
||||||
|
|
||||||
|
Properties: layers, fps, mode, size
|
||||||
|
|
||||||
|
### export
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Turns frames into video.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- filename (optional="hey"): The name of the output file
|
||||||
|
|
||||||
|
Returns: Nothing, creates filename+".avi" video
|
||||||
|
|
||||||
|
## Layer
|
||||||
|
|
||||||
|
**Class**
|
||||||
|
|
||||||
|
Creates layer. A layer is essentially an array of images. When the video is exporting, the layers will be pasted on each other to create an array of frames (layer 0 is at the bottom).
|
||||||
|
|
||||||
|
> Warning: Do not create this class yourself, making the Animation class will do it for you.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- size: size in pixels [width,height]
|
||||||
|
- fps: frames per second
|
||||||
|
- mode (optional="RGBA"): Type and depth of a pixel in the image
|
||||||
|
- color (optional=0): background color to use for the image
|
||||||
|
|
||||||
|
Returns: itself
|
||||||
|
|
||||||
|
Properties: size, img, layer, fps, frames, mode
|
||||||
|
|
||||||
|
### createPoint
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Creates a point at coords
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- coords: Coordinates of the point [x,y]
|
||||||
|
- fill (optional=None): Color of pixel
|
||||||
|
|
||||||
|
Returns: nothing
|
||||||
|
|
||||||
|
### createLine
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Creates line, where each array [x,y] inside coords is a point, connected in order.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- coords: Coordinates of line [[x,y],[x,y],[x,y]...]
|
||||||
|
- fill (optional=None): Color of line
|
||||||
|
- width (optional=0): Width of line
|
||||||
|
- joint (optional=None): if 'curve', joint type between the points is curved
|
||||||
|
|
||||||
|
Returns: nothing
|
||||||
|
|
||||||
|
### createArc
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Creates arc with starting and ending angles inside the bounding box.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- boundingBox: array consisting of upper left and lower right corners [[x,y],[x,y]]
|
||||||
|
- startAngle: angle in degreees
|
||||||
|
- endAngle: angle in degrees
|
||||||
|
- fill (optional=None): color of arc
|
||||||
|
- width (optional=0): width of arc line
|
||||||
|
|
||||||
|
Returns: nothing
|
||||||
|
|
||||||
|
### createEllipse
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Creates ellipse inside bounding box
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- boundingBox: array consisting of upper left and lower right corners [[x,y],[x,y]]
|
||||||
|
- fill (optional=None): color of ellipse inside
|
||||||
|
- outline (optional=None): outline color
|
||||||
|
- width (optional=0): pixel width of outline
|
||||||
|
|
||||||
|
Returns: nothing
|
||||||
|
|
||||||
|
### createPolygon
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Creates polygon
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- coords: List of points of the polygon outline [[x,y],[x,y],[x,y]...]
|
||||||
|
- fill (optional=None): color of polygon inside
|
||||||
|
- outline (optional=None): outline color
|
||||||
|
|
||||||
|
Returns: nothing
|
||||||
|
|
||||||
|
### createRectangle
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Creates rectangle
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- boundingBox: array consisting of upper left and lower right corners [[x,y],[x,y]]
|
||||||
|
- fill (optional=None): color of rectangle inside
|
||||||
|
- outline (optional=None): outline color
|
||||||
|
- width (optional=1): width of outline
|
||||||
|
|
||||||
|
Returns: nothing
|
||||||
|
|
||||||
|
### createRoundedRectangle
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Creates a rounded rectangle
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- boundingBox: array consisting of upper left and lower right corners [[x,y],[x,y]]
|
||||||
|
- radius (optional=0): radius of the rounded corners of the rectangle
|
||||||
|
- fill (optional=None): color of rectangle inside
|
||||||
|
- outline (optional=None): outline color
|
||||||
|
- width (optional=0): width of outline
|
||||||
|
|
||||||
|
Returns: nothing
|
||||||
|
|
||||||
|
### fillAll
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Fills entire layer with color
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- fill (optional=None): fill of frame
|
||||||
|
- outline (optional=None): outline color of frame
|
||||||
|
- width (optional=0): outline line width
|
||||||
|
|
||||||
|
Returns: nothing
|
||||||
|
|
||||||
|
### createText
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Creates text at coords to layer. Has a kinds of parameters that can be fiddled with, making it a really powerful function.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- anchorCoords: Anchor coordinates of the text
|
||||||
|
- text: The text to be added
|
||||||
|
- fill (optional=None): Fill color of text
|
||||||
|
- font (optional=None): A Pil ImageFont
|
||||||
|
- anchor (optional=None): Relative location of anchor to the text.
|
||||||
|
- spacing (optional=4): Number of pixels between the lines of text
|
||||||
|
- align (optional='left'): Alignment of lines (center, left, or right)
|
||||||
|
- direction (optional=None): Direction of text. See PIL docs for more information.
|
||||||
|
- features (optional=None): OpenType font features. See PIL docs for more information.
|
||||||
|
- language (optional=None): Language of text. See PIL docs for more information.
|
||||||
|
- stroke_width (optional=0): Width of stroke
|
||||||
|
- stroke_fill (optional=None): Fill color of the stroke
|
||||||
|
- embedded_color (optional=False): True/False to specify if font embedded color glyphs should be used or not
|
||||||
|
|
||||||
|
Returns: nothing
|
||||||
|
|
||||||
|
### addImage
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Adds image to layer
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- imageToAdd: PIL Image
|
||||||
|
- coords (optional=None): Coords to put image. Array that is upper left and lower right corner. ((x,y), (x,y))
|
||||||
|
|
||||||
|
Returns: nothing
|
||||||
|
|
||||||
|
### addGif
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Adds a gif to the layer (this function also creates frames for you, for the number of frames long the gif is * times_to_repeat frames)
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- gif_location: location of the gif in the file system
|
||||||
|
- times_to_repeat: times to repeat the gif
|
||||||
|
- coords (optional=None): Coords to put image. Array that is upper left and lower right corner. ((x,y), (x,y))
|
||||||
|
|
||||||
|
Returns: nothing, but appends frames
|
||||||
|
|
||||||
|
### rotate
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Rotates layer
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- angle: degrees
|
||||||
|
- center (optional=None): Center of rotation,
|
||||||
|
- outsideFillColor (optional=None):
|
||||||
|
- copy (optional=None): a PIL Image. Probably want this to be a copy of the current layer.
|
||||||
|
|
||||||
|
Returns: nothing
|
||||||
|
|
||||||
|
### translate
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Move layer
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- x: amount of pixels to move horizontally
|
||||||
|
- y: amount of pixels to move vertically
|
||||||
|
- img: copy of current layer
|
||||||
|
|
||||||
|
Returns: nothing
|
||||||
|
|
||||||
|
### changeOpacity
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Changes opacity (transparency) of the layer, of every non transparent pixel.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- value: new opacity from 0 to 100, where 0 is invisible and 100 is opaque (fully non transparent)
|
||||||
|
|
||||||
|
Returns: nothing
|
||||||
|
|
||||||
|
### changeEntireOpacity
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Changes opacity of entire layer, including transparent pixels, so only use this for layers with no transparent parts
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- value: new opacity from 0 to 100, where 0 is invisible and 100 is opaque (fully non transparent)
|
||||||
|
|
||||||
|
Returns: nothing
|
||||||
|
|
||||||
|
### fadeIn
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Slowly fades in a layer, going from transparent to fully opaque.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- frames: number of frames it should take to become fully opaque
|
||||||
|
|
||||||
|
Returns: nothing, but appends frames
|
||||||
|
|
||||||
|
### fadeOut
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Slowly fades out a layer, going from fully opaque to fully transparent.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- frames: number of frames it should take to become fully transparent
|
||||||
|
|
||||||
|
Returns: nothing, but appends frames
|
||||||
|
|
||||||
|
### transform
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Transforms layer. This function is very complicated so and frankly I have no clue what most of thse are, so please refer to PIL Docs for more detail.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- size: output size
|
||||||
|
- method: transformation method, so please refer to PIL Docs
|
||||||
|
- data (optional=None): According to PIL docs, 'extra data to the transformation method.'
|
||||||
|
- resample (optional=0): Resampling filter, please refer to PIL Docs
|
||||||
|
- fill (optional=1): Please refer to PIL Docs
|
||||||
|
- fillcolor (optional=None): Fill color for area outside transformed image
|
||||||
|
|
||||||
|
Returns: nothing
|
||||||
|
|
||||||
|
### blur
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Blurs layer
|
||||||
|
|
||||||
|
Parameters: none
|
||||||
|
|
||||||
|
Returns: nothing
|
||||||
|
|
||||||
|
### clear
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Clears area of layer, turning it transparent
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- coords: Array that is upper left and lower right corner of the area that should be cleared. ((x,y), (x,y))
|
||||||
|
|
||||||
|
### clearAll
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Clears entire layer, turning it transparent
|
||||||
|
|
||||||
|
Parameters: none
|
||||||
|
|
||||||
|
Returns: nothing
|
||||||
|
|
||||||
|
### saveFrame
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Adds the layer in its current state to the frames array
|
||||||
|
|
||||||
|
Parameters: none
|
||||||
|
|
||||||
|
Returns: nothing, but appends a frame
|
||||||
|
|
||||||
|
### doNothing
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Adds frames without changing the layer
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- frames: number of frames to append
|
||||||
|
|
||||||
|
Returns: nothing
|
||||||
|
|
||||||
|
### save
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Save current layer as a file
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- filename: file name of current layer
|
||||||
|
|
||||||
|
Returns: nothing
|
||||||
|
|
||||||
|
### rise
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Make layer slowly rise
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- frames: number of frames the rising should last
|
||||||
|
- total_rise_amount: amount of pixels it should move up by
|
||||||
|
|
||||||
|
Returns: nothing, but appends frames
|
||||||
|
|
||||||
|
### descend
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Make layer slowly descend
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- frames: number of frames the descending should last
|
||||||
|
- total_descend_amount: amount of pixels it should down up by
|
||||||
|
|
||||||
|
Returns: nothing, but appends frames
|
||||||
|
|
||||||
|
### slide
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Makes the layer slide to the side
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- frames: number of frames the sliding should last
|
||||||
|
- total_slide_amount: amount of pixels it should go to the side
|
||||||
|
|
||||||
|
Returns: nothing, but appends frames
|
||||||
|
|
||||||
|
### spin
|
||||||
|
|
||||||
|
**Function**
|
||||||
|
|
||||||
|
Makes the layer spin
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
- frames: number of frames the spinning should last
|
||||||
|
- degrees: amount of degrees to spin
|
||||||
|
- center: center where the rest of the image should spin around (x,y) coordinates
|
||||||
|
|
||||||
|
Returns: nothing, but appends frames
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Here's a basic example that creates a sun rising from the sea:
|
||||||
|
|
||||||
|
```
|
||||||
|
from PilAnimate import Animation, ImageColor, Image
|
||||||
|
animation = Animation(3)
|
||||||
|
animation.layers[0].fillAll(fill="SkyBlue")
|
||||||
|
#sun layer
|
||||||
|
animation.layers[1].createEllipse(((700,650),(900,850)), fill="yellow")
|
||||||
|
#ocean layer
|
||||||
|
animation.layers[2].createRectangle(((0, 500),(1600,900)), fill="Blue", width=0)
|
||||||
|
animation.layers[0].doNothing(100)
|
||||||
|
animation.layers[2].doNothing(100)
|
||||||
|
animation.layers[1].rise(100, -501)
|
||||||
|
animation.export()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extend
|
||||||
|
|
||||||
|
Example of an extension I made that makes backgrounds:
|
||||||
|
|
||||||
|
```
|
||||||
|
from PilAnimate import Layer
|
||||||
|
import math
|
||||||
|
class Background():
|
||||||
|
#remember to add params
|
||||||
|
def __init__(self, layer, background_image):
|
||||||
|
self.background_image = background_image
|
||||||
|
self.layer = layer
|
||||||
|
self.layer.addImage(self.background_image.copy().crop((0,0,self.layer.size[0],self.layer.size[1])), coords=(0, 0, self.layer.size[0], self.layer.size[1]))
|
||||||
|
def pan_down(self, frames, all_the_way=True):
|
||||||
|
#speed is pixels per frame
|
||||||
|
#(background_image height-layer height)/speed
|
||||||
|
#
|
||||||
|
amount = (self.background_image.size[1]-self.layer.size[1])/frames
|
||||||
|
amount = math.floor(amount)
|
||||||
|
for i in range(frames-1):
|
||||||
|
self.layer.addImage(self.background_image.copy().crop((0, i*amount, self.background_image.size[0], i*amount+self.layer.size[1])), coords=(0, 0, self.layer.size[0], self.layer.size[1]))
|
||||||
|
self.layer.saveFrame()
|
||||||
|
if all_the_way:
|
||||||
|
self.layer.addImage(self.background_image.copy().crop((0, (self.background_image.size[1]-self.layer.size[1]), self.background_image.size[0], (self.background_image.size[1]-self.layer.size[1])+self.layer.size[1])), coords=(0, 0, self.layer.size[0], self.layer.size[1]))
|
||||||
|
self.layer.saveFrame()
|
||||||
|
```
|
||||||
|
|
||||||
8
posts/recommended_blogs.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
A very short (so far) list of blogs/sites I read when HN's been reloaded for the tenth time and the front page hasn't changed:
|
||||||
|
|
||||||
|
- [A Collection of Unmitigated Pedantry](https://acoup.blog): classical and medieval history (mostly rome and greece)
|
||||||
|
- [Vitalik Buterin's website ](https://vitalik.eth.limo): ethereum and decentralisation
|
||||||
|
- [BIG by Matt Stoller](https://www.thebignewsletter.com): monopolies (with american focus)
|
||||||
|
- [Matt Lakeman](https://mattlakeman.org): travel and pop history
|
||||||
|
- [Richard Stallman](https://stallman.org/articles): digital and human rights
|
||||||
|
- [Lwn.net](https://lwn.net/Archives): linux and open source
|
||||||
9
posts/rss_feed.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
After 4 ([1](https://github.com/jetstream0/hedgeblog/commit/ccb848a9afdb7405d1cf6018c537aae803fe4199), [2](https://github.com/jetstream0/hedgeblog/commit/500cbd6f0217095541e5462d76282d8a40f116a9), [3](https://github.com/jetstream0/hedgeblog/commit/64889b8fae77199a4bad0c0e7915bc9f7a9f5fa9), [4](https://github.com/jetstream0/hedgeblog/commit/2a1ca739369ff2a364116510c207d5531b915b07)) painful commits, the RSS feed is up and running! And this post should confirm that, hopefully.
|
||||||
|
|
||||||
|
I used [Planet KDE's RSS Feed](https://planet.kde.org/global/atom.xml) and W3C's [explanation of Atom](https://validator.w3.org/feed/docs/atom.html) for reference. W3C's [validator](https://validator.w3.org/feed/#validate_by_input) and Firefox's XML parsing thing were helpful in figuring out what exactly was wrong with the RSS feed during my testing and first three miserable commits, since Akregator (my RSS client), just told me it was invalid, with no error explanation or real error message.
|
||||||
|
|
||||||
|
Some minor improvements were made to [Ryuji](/posts/ryuji-docs) (my templating "language") as part of this. Unrelated, a Rust version of Ryuji is being worked on. Hopefully, it'll be faster than the typescript implementation, and some cool ideas involving Ryuji will pop up *eventually*. For now, it's just mostly a way to prevent my Rust from getting rusty.
|
||||||
|
|
||||||
|
But that's not very interesting to write or read about, so let me wrap it up. RSS is pretty cool. This blog has a RSS feed now. Plus, having to make multiple commits that could've just been one (if I made less mistakes and tested better) is frustrating. Also, I now finally kinda know how to use (neo)vim. Yay.
|
||||||
|
|
||||||
|
You can find the feed at [https://www.prussiafan.club/atom.xml](https://www.prussiafan.club/atom.xml).
|
||||||
21
posts/rushed_captcha_rewrite.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
Today, I noticed around a hundred failed faucet claims for a client's Discord bot. Wonderful.
|
||||||
|
|
||||||
|
I checked the logs. Ah, the errors told me that the [captcha service](https://github.com/jetstream0/Captcha) was down. It looked like Replit, where I hosted the captcha service, had decided to remove all my environmental variables. Annoying.
|
||||||
|
|
||||||
|
The fix seemed fairly simple. But Replit was planning to discontinue free non-static hosting after the end of the year, and dealing with Replit's interface has been a generally unpleasant experience for me, so I decided to take the opportunity and migrate hosts. It had to done be sooner or later after all.
|
||||||
|
|
||||||
|
We wanted to keep hosting costs at $0, so I decided to move the service to render.com. Render's free tier allows for one free "Web Service", which is basically just any site with a backend that can be run with the resoruces given (512 mb, 0.1 cpu). It does require entering payment information (credit card) in though, which my client didn't particularly want to do.
|
||||||
|
|
||||||
|
Luckily, I made my client's Render account before this requirement, so it had a grandfathered-in web service.
|
||||||
|
|
||||||
|
Unfortunately, it was already running the project's website. The website used to have the faucet, but since the faucet was moved to Discord (too much abuse otherwise), the site didn't *really* need a backend anymore. It did two database queries to display information like remaining claims for the month, but that was all. It was fairly simple to remove the site's backend, then have the Discord bot host a very simple API that the site's frontend would call to find that information.
|
||||||
|
|
||||||
|
> Since I was hosting the Discord bot on Fly.io, which suspends free-tier VMs if there is no traffic to them, the bot already had a webserver that was being pinged to keep it from stopping. So tacking on the very very limited API took <2 minutes.
|
||||||
|
|
||||||
|
Alright, since the site is now static, it can be hosted on Github pages, and the captcha can be hosted on our newly available web service. As expected, it isn't that simple. I can change what repo the web service pulls from, which is good, but it's stuck in the [Node.js runtime](https://render.com/docs/native-runtimes), which I can't seem to change. This is a problem because I wrote the captcha service in Ruby, a language that is most definitely not Node.js (to prove it, note that "Ruby" and "Node.js" share no letters *and* are different lengths - therefore they must not be the same).
|
||||||
|
|
||||||
|
I was fairly certain that if I deleted the web service, I would need to enter in payment info if I tried to create a new one. So that was not an option. I had to [rewrite the captcha service in Node.js](https://github.com/jetstream0/Captcha-node). It was mostly straightforward, but unluckily, I used a poorly documented cryptography library. There were a few limited examples, but I mostly had to look at the code and tests to figure out how it worked...
|
||||||
|
|
||||||
|
And when I switched out salsa20 for xsalsa20 (larger nonce size means that csprngs can be securely used without fear of nonces being repeated), the output turned out to be too large to fit in the [`custom_id` of Discord buttons](https://discord.com/developers/docs/interactions/message-components#button-object-button-structure). I was forced to switch back to salsa20, and just decided to rotate keys every few hours or so (I did not have the energy to make the nonce an incrementing count, which would require storing the count in the database). Kinda stupid, but it's (probably) fine.
|
||||||
|
|
||||||
|
Anyways, once I rewrote it in Node.js, I switched the web service to run that instead of the website, and everything started working again. Yay.
|
||||||
225
posts/ryuji_docs.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
Ryuji is a templating language written in less than 280 lines of code. There are no dependencies besides the builtin Node.js module `fs`. If that is an issue (eg, running in a browser environment), it should be very straightforward to remove the dependency by deleting the `render_template` function and using the `render` function directory.
|
||||||
|
|
||||||
|
# Syntax Docs
|
||||||
|
|
||||||
|
Ryuji syntax is typically in the format `[[ something ]]` or `[[ some:thing ]]` (with more `:`s if necessary). The spaces matter! Specifically, Ryuji checks for syntax using the regex statement: `/\[\[ [a-zA-Z0-9.:\-_!]+ \]\]/g`.
|
||||||
|
|
||||||
|
## Variables
|
||||||
|
```html
|
||||||
|
<p>Hi [[ employee.name ]],</p>
|
||||||
|
<p>You may recently heard some distressing news about your colleague Dave.</p>
|
||||||
|
<p>Please rest assured that these reports are <b>false and exaggerated</b>. Although he may be charged with [[ current_manslaughter_count ]] counts of manslaughter, we believe that these charges will be dismissed.</p>
|
||||||
|
<p>[[ corporate_slogan ]]</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Non-HTML Escaped Variables
|
||||||
|
```html
|
||||||
|
<div>
|
||||||
|
[[ html:biography ]]
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## For Loop Statements
|
||||||
|
```html
|
||||||
|
<li>
|
||||||
|
[[ for:trees ]]
|
||||||
|
<li>There is a tree.</li>
|
||||||
|
[[ endfor ]]
|
||||||
|
</li>
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<ul>
|
||||||
|
[[ for:trees:tree ]]
|
||||||
|
<li>There is a [[ tree.type ]] tree that is [[ tree.height ]] metres tall.</li>
|
||||||
|
[[ endfor ]]
|
||||||
|
</ul>
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<ul>
|
||||||
|
[[ for:trees:tree:index ]]
|
||||||
|
<!--index starts at zero, but you get the point-->
|
||||||
|
<li>[[ index ]]. There is a [[ tree.type ]] tree that is [[ tree.height ]] metres tall.</li>
|
||||||
|
[[ endfor ]]
|
||||||
|
</ul>
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
<ul>
|
||||||
|
[[ for:trees:tree:index:max ]]
|
||||||
|
<!--index starts at zero, and max is length-1 (the max index), but you get the point-->
|
||||||
|
<li>[[ index ]]/[[ max ]] There is a [[ tree.type ]] tree that is [[ tree.height ]] metres tall.</li>
|
||||||
|
[[ endfor ]]
|
||||||
|
</ul>
|
||||||
|
```
|
||||||
|
|
||||||
|
## If Truthy Statements
|
||||||
|
```html
|
||||||
|
[[ if:trees_are_real ]]
|
||||||
|
<ul>
|
||||||
|
[[ for:trees:tree ]]
|
||||||
|
<li>There is a [[ tree.type ]] tree that is [[ tree.height ]] metres tall. [[ if:tree.old ]]Be warned that this tree is very old and may fall down.[[ endif ]]</li>
|
||||||
|
[[ endfor ]]
|
||||||
|
</ul>
|
||||||
|
[[ endif ]]
|
||||||
|
```
|
||||||
|
|
||||||
|
## If Comparison Statements
|
||||||
|
```html
|
||||||
|
[[ if:user.lactose_intolerant:user.vegan ]]
|
||||||
|
<p>We don't think you should order the cheeseburger.</p>
|
||||||
|
[[ endif ]]
|
||||||
|
```
|
||||||
|
|
||||||
|
## If Not Comparison Statements
|
||||||
|
```html
|
||||||
|
[[ if:user.lactose_intolerant:user.vegan ]]
|
||||||
|
<p>We don't think you should order the cheeseburger.</p>
|
||||||
|
[[ endif ]]
|
||||||
|
```
|
||||||
|
|
||||||
|
## If In List Statements
|
||||||
|
*Not supported in ryuji-rust*
|
||||||
|
|
||||||
|
```html
|
||||||
|
[[ if:tree:*user.friends ]]
|
||||||
|
<p>Trees are friends, not food.</p>
|
||||||
|
[[ endif ]]
|
||||||
|
```
|
||||||
|
|
||||||
|
## If Not In List Statements
|
||||||
|
*Not supported in ryuji-rust*
|
||||||
|
|
||||||
|
```html
|
||||||
|
[[ if:tree:*!user.friends ]]
|
||||||
|
<p>Trees are food, not friends.</p>
|
||||||
|
[[ endif ]]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
```html
|
||||||
|
[[ component:nav-bar ]]
|
||||||
|
<p>Blah blah blah blah.</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
*templates/components/navbar.html*
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div>
|
||||||
|
<a href="/">Home</a> - <a id="donate-link" href="/donate">Donate to Dave's Bail Fund</a>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
#donate-link {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
```html
|
||||||
|
[[ for trees:tree ]]
|
||||||
|
[[ component:tree-info ]]
|
||||||
|
[[ endfor ]]
|
||||||
|
```
|
||||||
|
|
||||||
|
*templates/components/tree-info.html*
|
||||||
|
|
||||||
|
```html
|
||||||
|
<img src="[[ tree.picture ]]"/>
|
||||||
|
<h2>[[ tree.type ]], [[ tree.age ]] years old.</h2>
|
||||||
|
<p>Favourite song: [[ tree.favourite_song ]], Likes Dave: [[ tree.likes_dave ]]</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- For loops can be nested.
|
||||||
|
- Components can have other components inside them (but there is a depth limit of 4 or 5 or 6 nested within each other, I forgot).
|
||||||
|
|
||||||
|
# API/Library Docs
|
||||||
|
|
||||||
|
## Class: Renderer
|
||||||
|
|
||||||
|
### `constructor`
|
||||||
|
Creates an instance of the `Renderer` class.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `templates_dir` (`string`): Templates directory.
|
||||||
|
- `components_dir` (`string`): Components directory.
|
||||||
|
- `file_extension` (`\`.${string}\``, optional, default is `".html"`): File extension of templates.
|
||||||
|
|
||||||
|
### `render`
|
||||||
|
Render a template given template contents and variables.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `template_contents` (`string`): Content of template.
|
||||||
|
- `vars` (`any`, optional but highly recommended): Dictionary/object of variables to render template with.
|
||||||
|
- `recursion_layer` (`number`, optional, defaults to `0`): Used internally to prevent infinite loops when templates circularly refer to each other.
|
||||||
|
|
||||||
|
**Returns:** `string` (the rendered template)
|
||||||
|
|
||||||
|
### `render_template`
|
||||||
|
Render a template given the template name. Basically, gets the contents of the template and then calls `render`.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `template_name` (`string`): The name of the template.
|
||||||
|
- `vars` (`any`, optional but highly recommended): Dictionary/object of variables to render template with.
|
||||||
|
- `recursion_layer` (`number`, optional, defaults to `0`): Used internally to prevent infinite loops when templates circularly refer to each other.
|
||||||
|
|
||||||
|
**Returns:** `string` (the rendered template)
|
||||||
|
|
||||||
|
### `remove_empty_lines` (static)
|
||||||
|
Removes empty lines from text.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `text` (`string`): Text to rid empty lines from.
|
||||||
|
|
||||||
|
### `concat_path` (static)
|
||||||
|
Adds two paths together. Mostly intended for internal use only.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `path1` (`string`): First path.
|
||||||
|
- `path2` (`string`): Second path.
|
||||||
|
|
||||||
|
**Returns:** `string` (`path1` added to `path2`)
|
||||||
|
|
||||||
|
### `sanitize` (static)
|
||||||
|
Sanitizes text to make sure it cannot render as HTML. It replaces "<" with the HTML entity "&\lt;" and ">" with the HTML entity "&\gt;". Automatically done to
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `non_html` (`string`): The text to sanitize.
|
||||||
|
|
||||||
|
**Returns:** `string` (the sanitized text)
|
||||||
|
|
||||||
|
### `check_var_name_legality` (static)
|
||||||
|
Checks to make sure a variable name is legal. Intended for internal use.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `var_name` (`string`): The variable name to check.
|
||||||
|
- `dot_allowed` (`boolean`, optional, default is `true`): Whether "." is allowed in the variable name.
|
||||||
|
|
||||||
|
**Returns:** `boolean` (`true` is variable name is legal, `false` otherwise)
|
||||||
|
|
||||||
|
### `get_var` (static)
|
||||||
|
Gets the value of a variable, errors if variable undefined. Intended for internal use.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `var_name` (`string`): Name of variable.
|
||||||
|
- `vars` (`any`, optional but highly recommended): Dictionary/object of variables to get value from.
|
||||||
|
|
||||||
|
**Returns:** `any` (the value of the variable)
|
||||||
|
|
||||||
|
### Properties
|
||||||
|
- `templates_dir`: `string`
|
||||||
|
- `components_dir`: `string`
|
||||||
|
- `file_extension`: `\`.${string}\`` (see in Types/Interfaces/Consts `file_extension`)
|
||||||
|
|
||||||
|
## Types/Interfaces/Consts
|
||||||
|
These are exported, but there is no real use for them (outside of the module obviously), with the possible exception of writing an extension to Ryuji. Feel free to skip this section.
|
||||||
|
|
||||||
|
- `const SYNTAX_REGEX`: Regex to search for Ryuji syntax.
|
||||||
|
- `type file_extension`: Typescript type `\`.${string}\``, that represents... file extensions. Shocker.
|
||||||
|
- `interface ForLoopInfo`: Used internally for Ryuji's for loops.
|
||||||
|
|
||||||
|
# Usage Examples
|
||||||
|
Check Ryuji's [tests](https://github.com/jetstream0/hedgeblog/blob/master/tests.ts) for more examples.
|
||||||
|
|
||||||
|
There is a real world example in [hedgeblog's code](https://github.com/jetstream0/hedgeblog). For a syntax example, look in the `templates` [directory](https://github.com/jetstream0/hedgeblog/tree/master/templates), or an API example in `saki.ts` and `index.ts`. [pla-den-tor](https://github.com/stjet/pla-den-tor) is another of my projects where Ryuji is used.
|
||||||
5
posts/ryuji_rust.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
A month or two ago, I "translated" [Ryuji](/posts/ryuji-docs), originally written in Typescript, into Rust.
|
||||||
|
|
||||||
|
I mostly wrote this to practice writing Rust, which I guess is a success. In this project, I've somehow once again avoided properly figuring out how to use lifetimes (hey, I tried). That's a victory for my brain but probably a failure for my Rust skills.
|
||||||
|
|
||||||
|
The source code is on [Github](https://github.com/jetstream0/ryuji-rust) and the auto-generated documentation is on [docs.rs](https://docs.rs/ryuji-rust/latest/ryuji_rust/). If, for whatever reason, you actually want to use Ryuji-Rust, it might be helpful to look at the [tests](https://github.com/jetstream0/ryuji-rust/blob/master/src/lib.rs), [example](https://github.com/jetstream0/ryuji-rust/tree/master/example), and the [Ryuji docs](/posts/ryuji-docs) page.
|
||||||
112
posts/saki_docs.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
Saki is a very simple static build system, written in Typescript. There are no dependencies besides the builtin Node.js modules `fs` and `path`.
|
||||||
|
|
||||||
|
# Class: Builder
|
||||||
|
|
||||||
|
## `constructor`
|
||||||
|
Creates an instance of the `Builder` class.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `build_dir` (`string`, optional, default is `"/build"`): The directory to output the build to.
|
||||||
|
|
||||||
|
## `copy_folder` (static)
|
||||||
|
Copies a directory to another directory. Used internally in `serve_static_folder`, there is probably no need to use this.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `folder_path` (`string`): Path to directory that should be copied.
|
||||||
|
- `dest_path` (`string`): Path to directory to copy to.
|
||||||
|
|
||||||
|
## `serve_static_folder`
|
||||||
|
Adds a static folder to the build directory, meaning that it will be served.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `static_dir` (`string`): The path to the static directory to serve
|
||||||
|
- `dest_path` (`string`, optional, default is `"/"`): The path that the static directory should be served under. For example, if the static directory has a file "example.png", if the `dest_path` is `"/"`, the file will be written to `"/<build dir>/example.png"`, and the url for the file will be `/example.png`. If the `dest_path` is `"/files"`, the file will be written to `"/<build dir>/files/example.png"`, and the url for the file will be `/files/example.png`.
|
||||||
|
|
||||||
|
## `serve_content`
|
||||||
|
Write HTML to a file in the build directory. In most cases, it is probably more convenient to use `serve_file`, `serve_template` or `serve_templates` instead.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `content` (`string`): The HTML content.
|
||||||
|
- `serve_path` (`string`): The path to serve the file under, inside the build directory. If the `serve_path` does **not** end with ".html", the content will be written to an `index.html` file inside the path as a directory, ensuring that the HTML will be served under that url. For example, if `serve_path` is `"/burgers"`, the HTML will be written to `"/<build dir>/burgers/index.html"`, and can be accessed at the url `/burgers`.
|
||||||
|
|
||||||
|
## `serve_file`
|
||||||
|
Write a (non-HTML) file to the build directory. If serving multiple non-HTML files, putting those files into one directory and using `serve_static_folder` is probably a good idea.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `file_path` (`string`): Path to the file.
|
||||||
|
- `serve_path` (`string`): The path to serve the file under, inside the build directory.
|
||||||
|
|
||||||
|
## `serve_template`
|
||||||
|
Render a (probably [Ryuji](/posts/ryuji-docs)) template, and write the result to the build directory.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `renderer` (`Renderer`): Most likely the Ryuji renderer.
|
||||||
|
- `serve_path` (`string`): The path to serve the file under, inside the build directory.
|
||||||
|
- `template_name` (`string`): Name of the template to render (see Ryuji docs for more information).
|
||||||
|
- `vars` (`any`): The variables as a dictionary/object to render the template with (see Ryuji docs for more information).
|
||||||
|
|
||||||
|
## `serve_templates`
|
||||||
|
Render multiple templates, and write the results to the build directory.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `renderer` (`Renderer`): Most likely the Ryuji renderer.
|
||||||
|
- `serve_paths` (`string[]`): The paths to serve the files under, inside the build directory.
|
||||||
|
- `template_name` (`string`): Name of the template to render (see Ryuji docs for more information).
|
||||||
|
- `vars_array` (`any[]`): An array of variables as a dictionary/object to render the templates with (see Ryuji docs for more information).
|
||||||
|
|
||||||
|
`serve_paths` and `vars_array` need to have the same length, since the first item of `serve_paths` is rendered with the first item of `vars_array` as the variable, and so on.
|
||||||
|
|
||||||
|
# Usage Examples
|
||||||
|
```ts
|
||||||
|
import { Renderer } from './ryuji.js';
|
||||||
|
import { Builder } from './saki.js';
|
||||||
|
|
||||||
|
let renderer: Renderer = new Renderer("templates", "components");
|
||||||
|
let builder: Builder = new Builder();
|
||||||
|
|
||||||
|
builder.serve_static_folder("static");
|
||||||
|
|
||||||
|
builder.serve_template(renderer, "/", "index.html", {
|
||||||
|
notices: [
|
||||||
|
"Dave got drunk again and fed the chipmunks. As a result, they are more brazen than usual. Be on your guard!",
|
||||||
|
"Please stop anthropomorphizing the rocks. They WILL come alive.",
|
||||||
|
"Oxygen has decided to retire. Until we find a replacement for him, do not be selfish in your consumption of water and air.",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.serve_templates(renderer, [
|
||||||
|
"/departments/water",
|
||||||
|
"/departments/energy",
|
||||||
|
"/departments/sanitation",
|
||||||
|
"/departments/permitting",
|
||||||
|
"/departments/human_rights_abuses",
|
||||||
|
], "post.html", [
|
||||||
|
{
|
||||||
|
"name": "Department of Water",
|
||||||
|
"employees": 79,
|
||||||
|
"sanctioned_by_ICC": false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Department of Energy",
|
||||||
|
"employees": 140,
|
||||||
|
"sanctioned_by_ICC": false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Department of Sanitation",
|
||||||
|
"employees": 217,
|
||||||
|
"sanctioned_by_ICC": false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Department of Permitting",
|
||||||
|
"employees": 1,
|
||||||
|
"sanctioned_by_ICC": false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Department of Human Rights Abuses",
|
||||||
|
"employees": 9000,
|
||||||
|
"sanctioned_by_ICC": true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
If a real world example is preferable, [this blog uses Saki](https://github.com/jetstream0/hedgeblog/blob/master/index.ts) to build as a static site.
|
||||||
21
posts/solving_problems_with_a_timeout.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
> **Update, October 2023**: I realize I was being kind of an idiot. In retrospect, what I should've done was have the MongoDB operation be a `replaceOne` that *only* modifies if the damage dealt is under the hp - so it would only be possible for a write to happen for one player even if two click at the same time. Then, I could check the return value of the `replaceOne` (which tells me whether the operation modified documents or not), and discard any fake winners whose clicks did not result in the database being modified. Beware that if you continue reading, you will encounter a very stupid solution to a problem.
|
||||||
|
|
||||||
|
Recently, I've been working on a discord bot game for Beer Goggles NFT on Algorand.
|
||||||
|
|
||||||
|
The specifics aren't too important, but essentially the game works like this: A game is started by an admin, and a secret number of hp is specified. Then, players can click a button, and depending on the amount of NFTs they hold, they will do "damage". All the damage is added up, and the person who crosses the secret number of hp wins. Like a pinata. Or in the case of our bar themed game, a huge opaque mug of beer that is passed around, with the goal being the one to finish the drink.
|
||||||
|
|
||||||
|
Now, onto the more technical details. Whenever a user clicks the button, the callback for the button interaction event is run.
|
||||||
|
|
||||||
|
First, the program reads the database (MongoDB) and sees the current hp left for the current game. If the current hp is less than zero, or a game over flag is set, the user is notified that the game is over, and the click does nothing. If that isn't the case, it then calculates the damage done by the player (a combination of luck, the amount of NFTs they hold, and powerups they bought). The damage done is subtracted from the hp (new hp is written to the db). Finally, the program checks if the user has won (hp at or under 0). If so, it ends the game and announces the winner.
|
||||||
|
|
||||||
|
However if one player clicks, then another player clicks milliseconds after, in some cases there just hasn't been enough time for the db writes to take place, and so there can be multiple winners. This is **bad**!!!
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The main problem is, the two functions being run at the same time aren't aware of the existence of the other, and can't communicate to ensure only one winner. I tried a variety of methods, like adding another check to make sure the game didn't end already. None of it worked, unfortunately.
|
||||||
|
|
||||||
|
What did work though, is adding a random delay using `setTimeout` and `Math.random`, if the program thought the user won (`hp <= 0`). After the random delay, a global array variable would be looked at. If the array was empty, then the winner would be announced, and something (doesn't matter what) would be added to the array. I'm not sure why I made it an array, and not an dictionary, but it worked^tm^ (only one game can be run at a time, but if the bot had multiple games running, a dictionary with the keys as game ids set when the winner was found would work better). I probably had a good reason at the time, or at least fooled myself into thinking I did. Anyways, so if there was another winner about to be announced, the program would see the array was not empty, and not announce the extra winner(s).
|
||||||
|
|
||||||
|
Technically, if the random delay was the same or only a few milliseconds different, the global variable could be looked at the same time, and two winners (or more) could still be announced.
|
||||||
|
|
||||||
|
... let's not worry about that
|
||||||
78
posts/the_ming_wm_philosophy.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
> [ming-wm](https://github.com/stjet/ming-wm) is a window manager for Linux, that writes directly to framebuffer, instead of X windows or Wayland. It is 100% keyboard operated, and retro-themed. Documentation (including where the "philosophy" was originally "published" can be found [here](https://github.com/stjet/ming-wm/tree/master/docs).
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
# Philosophy
|
||||||
|
|
||||||
|
Ming-wm has two missions. In order of importance:
|
||||||
|
1. Create an ideal desktop experience for me, the creator, and be fun to write code for
|
||||||
|
2. Present an alternate vision (keyboard-based interfaces and an older aesthetic) to today's ugly and inefficient window managers
|
||||||
|
|
||||||
|
## Keyboard-Only
|
||||||
|
|
||||||
|
Mice are a local maximum - watch a power user of any application, and it's clear that the usage of keyboard shortcuts are a large part of what distinguishes them from everyone else. Mouse-based interfaces are definitely more intuitive, though there's some bias as nearly all mainstream applications of the last 30+ years have trained people to expect and understand them. But the reality is, no matter how familiar a person is with a mouse-based application, they can only move a mouse and click so fast. Typing on a keyboard will always be physically much faster who can competently type.
|
||||||
|
|
||||||
|
But are mice *actually* a local maximum? Could there not be some mythical interface that remains intuitive, but is just as powerful and fast as keyboard-based interfaces? Decades of multi-billion-dollar companies pouring countless engineer-, designer-, and scientist-hours have not invented or come close to inventing such an interface. This isn't a complex problem. It would be quite reasonable to believe no such interface exists.
|
||||||
|
|
||||||
|
And yes, there are some applications which *are* better off as mouse-operated than keyboard-operated: first person shooter games and apps such as Krita come to mind. Still, that is not to say it is not possible to practically make a first person shooter that is keyboard-operated; it just wouldn't be quite as efficient. Furthermore, these applications are a tiny minority. More applications than one might expect could absolutely become keyboard-based and blast the efficiency of the original out of the water; Inkscape comes to mind. Want real proof? the success of Vim (text editor), i3 (window manager), and Vimium (browser extension for controlling the browser) don't seem obvious or expected, but yet it is undeniable they are much better than any mouse-based counterparts.
|
||||||
|
|
||||||
|
Doesn't a mixed environment where the mouse and keyboard are equals seem the best of both worlds then? Yes, to some extent. There are three reasons ming-wm does not take that approach.
|
||||||
|
|
||||||
|
First, there is no real way to guarantee applications will fully support keyboard operation (in the unlikely event someone other than me makes a window for ming-wm) without making ming-wm keyboard only. That is, if mice were supported, applications may decide not to make themselves fully usable with only a keyboard. The only way to ensure applications treat the keyboard as a first-class citizen is to make that the only input method. This is a real threat. In the normal world of Linux where people use the X Window System or Wayland, a person can make the choice to use a keyboard-operated tiling WM like i3 or Sway, but there's really nothing to force the applications opened in that WM to support keyboard operation. Really, most applications can't be used with just a keyboard. Adjacent to the issue of applications possibly choosing not to fully support keyboard operation are users who, out of laziness, don't take advantage of the keyboard, choosing to use the more familiar mouse. Again, the only way to force users to learn the more efficient method of keyboard operation is to leave them no other choice. Imagine a world where *everyone* is a power user!
|
||||||
|
|
||||||
|
Second and more importantly, this window manager is first and foremost for my personal use and enjoyment. I know that I won't use a mouse. There really isn't a point to do double the work to support mice.
|
||||||
|
|
||||||
|
Thirdly, moving a mouse forces excessive screen redrawing. While that isn't an issue on any modern system, it still feels wasteful and goes against the Elm Architecture worshipped in ming-wm, which is discussed somewhere down below in its own section.
|
||||||
|
|
||||||
|
Local maximums are hard to move off of. They are maximums, after all. Moving off means, at least briefly, suffering productivity losses, confusion, and possibly frustration ("How do I exit vim??"). However, for anyone who uses a computer frequently, a relatively small one-time cost to learn some keyboard commands to permanently gain efficiency certainly seems worth it. Serious users already learn and use keyboard shortcuts, why not take the next logical step?
|
||||||
|
|
||||||
|
PS:
|
||||||
|
1. Drawing applications are also not suited for keyboard operation, but neither are they suited for mouse operation. Those are best used with one of those fancy stylus things.
|
||||||
|
2. Anecdotally, after two weeks of Vim (with no LSPs!), I was already faster and more productive than after years of Visual Studio Code. Disclaimer, I was by no means a Visual Studio Code power user or expert, but still.
|
||||||
|
3. Ming-wm should not be confused as an elitist project. The point of the project is not for power-users to look down upon "normies". The focus on power-users is firstly because they are incredibly efficient (and therefore a sort of ideal to aspire to), and secondly because their habits align with the argument, proving it beyond me saying "trust me, this is true". As said in the main text, imagine if *everyone* was a power-user.
|
||||||
|
|
||||||
|
## Simplicity / Rejection of Modern (Tech) Design
|
||||||
|
|
||||||
|
The design of today's websites, apps, and devices? Ugly, deceptive, corporate, soulless, boring, homogeneous, and devoid of meaning or beauty. New designs are pushed for the sake of newness and "modernisation". Those new designs not only rarely result in any real improvement, but more often than not are bundled with updates that **actively degrade functionality**. Some of this can be explained by "enshittification" (ie, trading quality for profit), but many are shockingly not driven by any profit motive.
|
||||||
|
|
||||||
|
In the past, design was a good proxy for quality. A "good" design meant that someone put in a decent amount of effort, and had some money. A "bad" (or lack of a) design meant the opposite. This is no longer true, because of how frequently this association is taken advantage of. Instead of real innovation, new designs are rolled out, creating the mere appearance of change. All the while, long requested features and bug fixes are neglected. Modern design is nothing but an exercise in fooling users. These farces aren't necessarily intentionally and maliciously plotted by evil corporations. The heuristic that "new and pretty = better" has so deeply penetrated our psyches that even the executives, product managers, and programmers perpuating that falsehood have "drunk their own Kool-Aid", so to speak. They truly do believe that putting lipstick on their dying pig is a good use of their resources. I beg them to feed it.
|
||||||
|
|
||||||
|
Of course, design is highly subjective. Is it right to call any design ugly and blan? Yes, yes it is. There are some objective measures of design, because design is part of a functional product, and functionality can be measured. A good designcan enhance functionality, discoverability, ease of use, etc. By this criteria, modern design admittedly does not always fail. But part of its success is because users have been trained to be accustomed to it. The elderly who have not had this exposure or training (a "control group"?) do not seem to think that modern design is particularly easy to use. People often dismiss the old as being too stubborn and resistant to change, and while that isn't entirely untrue, it seems too hasty to entirely dismiss their experience.
|
||||||
|
|
||||||
|
In contrast, designs of the ancient past (the 90s) *had* to quickly usable for those with little or no exposure to computer interfaces. At the same time, modern design of desktop interfaces is being influenced (read: dumbed-down) by how mobile interfaces work, losing functionality. So by measurable standards, modern design is a regression.
|
||||||
|
|
||||||
|
But how does this argument fit with ming-wm being keyboard-based? Aren't keyboard-based interfaces more unintuitive? Could a person with very little computer exposure really quickly understand keyboard-based interfaces? Indeed, this is a reasonable point. Telling grandma to write her e-mails in vim would not go well. However, there is an important difference between design and operation. Design and operation cannot be evaluated using the same criteria. The method of operation is not just essential, but *is* the product, the function, while design is a "nice-to-have". An apt analogy is food. It would be nice for food to look good (have a good design), but not strictly necessarily. On the other hand, the aesthetics of a food must not interfere with its taste or nutrition (function). The world's most beautiful dish will enjoy a trip to the trash if it tastes rotten and has negative nutritional value. Ming-wm achieves the best of both worlds: it looks good (by rejecting modern design), and tastes/functions great (by embracing keyboard-operation).
|
||||||
|
|
||||||
|
PS:
|
||||||
|
1. Toddlers do seem to be able to get a good grasp of modern tech products. Then again, toddlers don't tend to use or understand any advanced features. And do we really want to rate designs is based on how *toddlers* can use it? Surely we can do better.
|
||||||
|
2. In fact, rather than modern design no longer being a good indicator for high quality, I would argue that it is now a good indicator for low quality. I find that almost always, it is the shadiest companies and crypto-tokens that have the "sleekest" and most modern-looking websites. What they lack in sense and substance they are clearly compensating with fancy animations and mega-bytes of assets. This is especially now in the age of LLMs where any Dick or Jane can generate a swanky-looking website in thirty seconds, but also previously once CSS libraries like Bootstrap became common.
|
||||||
|
|
||||||
|
## Elm Architecture
|
||||||
|
|
||||||
|
The Elm Architecture is an elegant and uncomplicated pattern named after the functional programming language Elm (used to create websites). In the Elm Architecture, components have a state, a view function (called `draw` in ming-wm) to turn that state into something that can be displayed (eg, HTML), and an update function (`handle_message` in ming-wm) that takes a message as input, and can choose to mutate the state.
|
||||||
|
|
||||||
|
Imagine there is a number input box. The state of the box would be the number it currently holds. The view function would draw the input box, with the number it holds inside. If the user presses a key on their keyboard while inside the input box, a message (that contains the key being pressed) is passed to the box's update function. If the key is a number, the box can update its state, and call for its view function to be run. If the key isn't a number, the box won't update its state, and doesn't need its view function to be run.
|
||||||
|
|
||||||
|
Restricting mutation of the state to the update function, and further defining how the state changes depending on what message is passed, makes code significantly cleaner and easier to reason about. The architecture is dead simple to understand and implement. More head space can be dedicated to the logic of a new feature, or figuring out what exactly causes a particular bug, rather than worrying about boilerplate, footguns, or other minutiae. The messages (which would typically be an enum) result in a complete definition of the functionality of an application, without having to go out of one's way. Having only one place where state changes happen makes it trivial to see, given a certain message, how the state is affected. In other models, one may need to run a search, perhaps even wading through several files to just find where the state of a component could be changed. As a side effect, it is incredibly easy to determine whether a redraw is necessary. If the state doesn't change, we *know* that there is no need to redraw!
|
||||||
|
|
||||||
|
Plus, with help from the Rust compiler, all applications can be forced to adhere to the architecture, and several classes of bugs can be caught at compile-time.
|
||||||
|
|
||||||
|
All of the above results not only in an exceptional developer experience and well defined functionality, but also allows any reader of the code to understand what is going on in as little time as possible.
|
||||||
|
|
||||||
|
PS:
|
||||||
|
1. The number input box state should also account for it not holding any number (being empty), but assume it can never be empty - maybe it defaults to 0.
|
||||||
|
2. Somewhat ironically, while the architecture is simple, explaining why and how it is so wonderful, in a way that gives it justice is not. The best way to understand is to try it out in a new project.
|
||||||
|
3. Ming-wm does have a further belief that re-renders should only by initiated by user key-presses. One key-press can result in at most one re-render. This does mean videos or gifs won't be supported, but waste and unnecessary animations do not need to be worried about. Please note that without this belief, it would be fully possible for ming-wm to both support videos and use the Elm Architecture.
|
||||||
|
|
||||||
|
## Not Invented Here
|
||||||
|
|
||||||
|
The term is usually used negatively, and fairly so. Business and research suffer with not-invented-here syndrome. Luckily, this is neither! Again, ming-wm is primarily for my own usage and enjoyment. I happen to enjoy not relying on dependencies, and writing as much as possible from scratch. In the minds of programmers, external packages are often black boxes. No one really reads the code of these libraries unless something is wrong (eg, some patch needs to be made, or the documentation is bad). All that time spent reading and understanding external code could probably be better spent writing it myself. I usually learn something new, and inherently have a better understanding of the project (it "fits in your head"). Of course, I'm not interested in rewriting everything from scratch, and I'm not qualified to do so anyways (eg, cryptography!). SerenityOS is another (much more impressive and large-scale) project that espouses this philosophy.
|
||||||
|
|
||||||
|
More often than not, not relying on dependencies removes unnecessary bloat and complexity (see the next "Simplicity / Rejection of Modern Design" section).
|
||||||
|
|
||||||
|
Expect to see more dependencies in Cargo.toml eliminated soon.
|
||||||
|
|
||||||
|
PS:
|
||||||
|
1. `rodio` is unlikely to ever be eliminated (simply because audio is *complex*), and it's optional (if the audio player is not wanted)
|
||||||
|
2. `bmp-rust` is written by me and so isn't technically an external dependency
|
||||||
80
posts/two_types_of_brutalism.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
Three months ago, I was wasting time on HN as usual when I saw [this post](https://news.ycombinator.com/item?id=39955057), about someone's "brutalist hacker news". I wondered what could be more brutalist than HN already was. Just plain HTML, no CSS? A text file? Anyways, it was not something I would call brutalist. For those who can't be bothered to open the page, it has a nav bar with a bunch of emojis instead of words, which had some glitchy^\[0\]^ animation, and it had a list of article titles underneath, which loaded after some weird glitchy text animation played. It is something that I would *not* call brutalist in any sense.
|
||||||
|
|
||||||
|
I was about to write a sarcastic comment, but gracefully decided to write a blog post about my thoughts on brutalist web design instead. Of course, as all blog posts are classically made, I created the file for this post and then left it untouched for 3 months until I decided I should probably finally write something for once.
|
||||||
|
|
||||||
|
Back on topic, the author cited [brutalist-web.design](https://brutalist-web.design)^\[1\]^ which claims the following as tenets of brutalist web design:
|
||||||
|
|
||||||
|
- Content is readable on all reasonable screens and devices.
|
||||||
|
- Only hyperlinks and buttons respond to clicks.
|
||||||
|
- Hyperlinks are underlined and buttons look like buttons.
|
||||||
|
- The back button works as expected.
|
||||||
|
- View content by scrolling.
|
||||||
|
- Decoration when needed and no unrelated content.
|
||||||
|
- Performance is a feature.
|
||||||
|
|
||||||
|
Well, that's weird. Most, with the exception of the 6th bullet point, seem mostly like what people would expect of a decent normal website, as opposed to anything specific to brutalist design.
|
||||||
|
|
||||||
|
Now let me read what the brutalist-web.design guy [has to say](https://brutalist-web.design/#decoration) about that very load-bearing 6th bullet:
|
||||||
|
|
||||||
|
> A website is neither an application nor a video game. It is for content, and so its design must serve that purpose. Being true to these materials need not imply a boring website or require that all sites look the same.
|
||||||
|
|
||||||
|
Well, I suppose that seems reasonable, though I'm not sure if this is specific to brutalist design. I'm going to skip the next paragraph and go to the last two:
|
||||||
|
|
||||||
|
> Decoration for its own sake, often to satisfy the vanity of the designer, goes counter to Brutalist Web Design. Such needless decoration distracts the visitor from the reason for visiting and makes the content secondary.
|
||||||
|
|
||||||
|
OK... that does seem like a brutalist sentiment.
|
||||||
|
|
||||||
|
> The same can be said of unrelated content, such as misleading links, sensationalist headlines, or distracting images. These all attempt to take the visitor away from the content either for advertising or to create a false increase in engagement. Effort should be spent on compelling content, not trickery. Content drives engagement.
|
||||||
|
|
||||||
|
Sure, clickbait is bad. I would agree that sensationalism and advertising do seem contrary to the barebones spirit of brutalism... "content drives engagement", though? At least for me, the word "engagement" seems a little opposed to what brutalism is.
|
||||||
|
|
||||||
|
So, sure, I agree on some points, but overall I don't think this is a good definition of brutalism or brutalist-style websites. But that got me thinking, *what exactly do I think brutalism is?*
|
||||||
|
|
||||||
|
As with most things as I have strong opinions on, once I tried to actually examine what I knew and believed, I realised beyond the surface, it was pretty murky down there.
|
||||||
|
|
||||||
|
Anyways, after some thinking and a skim of the brutalist architecture [article on Wikipedia](https://en.wikipedia.org/wiki/Brutalist_architecture), I think brutalism (in terms of web design) is better split into two separate categories. Sincere apologies for the spoiler in the title.
|
||||||
|
|
||||||
|
## The Soviet Apartments
|
||||||
|
|
||||||
|

|
||||||
|
Photo by lafleur ([license](https://creativecommons.org/licenses/by/2.0/deed.en))
|
||||||
|
|
||||||
|
The first type is what I'll call the "soviet apartments", named so because like the apartments, they use bare, basic building materials, only have essential structure, and the minimum required to make it work. The colours are grayscales (or most commonly for websites, but not really buildings, black and white).
|
||||||
|
|
||||||
|
Though they can be a political statement, they don't necessarily need to be - in fact, I would very un-scientifically say they typically aren't. These websites are the way they are because it's incredibly easy to build them, and the authors don't see the need for anything fancier.
|
||||||
|
|
||||||
|
These "soviet apartment"-style websites are usually so because they don't need to have anything extra. They might be some HTML with a few lines of CSS perhaps adding some margings, line spacing, and a font. They might be just plain HTML. They might even be a text file with lines broken up every 80 characters.
|
||||||
|
|
||||||
|
If I were to make a very unsubstantiated claim, I say that the main political message of this kind of website is one that is against bloat, excess, and one that yearns for a simpler past.
|
||||||
|
|
||||||
|
This style of website isn't flashy or attention grabbing - it's built to serve a simple purpose, do nothing unnecessary, and it does it well.
|
||||||
|
|
||||||
|
## The Habitat 67s
|
||||||
|
|
||||||
|

|
||||||
|
Photo by Denis Tremblay ([license](https://creativecommons.org/licenses/by/2.0/deed.en))
|
||||||
|
|
||||||
|
The "Habitat 67s", on the other hand, are in many ways the opposite, yet still put under the brutalist umbrella.
|
||||||
|
|
||||||
|
Like the "soviet apartments", they use bare, basic building materials, and do the minimal required, they do so in a completely different way. Notice, "Habitat 67"s are not composed of only essential structures - rather, they are artistic in structure with weird little juts and shapes.
|
||||||
|
|
||||||
|
This style of brutalist website often has a bizzare, even surreal look, with unusual or unfashionable fonts, and an unconventional layout. Most importantly, they try to violate as many web design principles as possible.
|
||||||
|
|
||||||
|
In other words, they *intentionally* try to be conventionally "ugly". To a regular person, these sites may look like someone just discovered how to add text and images in MS Paint.
|
||||||
|
|
||||||
|
"Habitat 67"-type websites are a lot more deliberate and political than "soviet apartment"-style websites. These websites are a huge "fuck you" to modern mainstream web design. You know what I mean. Obnoxiously round buttons, the sans-serif font on every other website (and a generic colour scheme to match!), shiny images, smooth gradients, and CSS (or SVG) animations that are admittedly pretty damn cool, at least if this wasn't the 100th time you've seen that exact animation trick. Bonus points for unnecessarily large images (in terms of file size), and a megabyte of *minified* Javascript. Super bonus points for guessing what framework they used to make the site. Just guess, you have an 1 in 3 chance of getting it right, anyways.
|
||||||
|
|
||||||
|
Many examples of these websites can be seen at [this site](https://brutalistwebsites.com/index_backup.html), though the site has a few "soviet apartment"-style websites too. Fair bit of warning: at least for me, it doesn't ever seem to finish loading, but you can still scroll and see the images.
|
||||||
|
|
||||||
|
More broadly, the philosophy behind this kind of design might be a general backlash against the genericness and homogeneity of the modern web monoculture. Now, you've realised that I've more or less abandoned objectivity, so I'll go out and I would like to say to designers that modern does not look sleek, and has not for years. Please try something different! Put down your Tailwind and Bootstrap, your UI components, and make something that doesn't look like the slop of every other website trying to sell you some bullshit^\[2\]^. The initial novelty and awe have long wore off, and frankly if I had to choose the web to be infested by a single horrible design style I would choose the Wordpress era over this.
|
||||||
|
|
||||||
|
Anyways, I was going to write a conclusion but I think that previous paragraph more or less does it. Thanks.
|
||||||
|
|
||||||
|
===
|
||||||
|
|
||||||
|
Footnotes:
|
||||||
|
- \[0\]: Glitchy not as in "not working properly", but the visual effect
|
||||||
|
- \[1\]: Oh yeah, `.design` is a tld, apparently
|
||||||
|
- \[2\]: Nowadays, even non-corporate websites not trying to sell any product adopt this design. I've seen non-profits and personal portfolio sites go all in on this look...
|
||||||
|
- Is this blog brutalist? I don't know. Maybe "soviet apartment"-style with just a tiny sprinkle of "Habitat 67"? I would say maybe it is more minimalist (and a teensy bit of retro) than brutalist? As you may know, we are militantly opposed to the use of any Javascript on this blog (please parse as "Javascript on this blog", and not "Javascript, on this blog"), but I don't think I had a particularly brutalist mood while creating this site
|
||||||
|
|
||||||
221
posts/wikipedia_rabbitholes.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
Wikipedia articles. I like them. The "Did you know" and "On this day" sections on the front page are real treasure troves. They have archives too, so you'll never run out of articles to read.
|
||||||
|
|
||||||
|
Here's a very incomplete (and maybe actively updated) list of ones that led to more clicks and were interesting to read:
|
||||||
|
|
||||||
|
- [Second Anglo-Dutch War](https://en.wikipedia.org/wiki/Second_Anglo-Dutch_War)
|
||||||
|
- [Koxinga](https://en.wikipedia.org/wiki/Koxinga), rogue Ming loyalist general who defeated the Dutch and Qing, to rule Taiwan
|
||||||
|
- [Red Turban Rebellions](https://en.wikipedia.org/wiki/Red_Turban_Rebellions) and [Chinese Manichaeism](https://en.wikipedia.org/wiki/Chinese_Manichaeism) connected to each other through Manichaeian influence on the [White Lotus Society](https://en.wikipedia.org/wiki/White_Lotus)
|
||||||
|
- [List of Ethnic Groups in China](https://en.wikipedia.org/wiki/List_of_ethnic_groups_in_China)
|
||||||
|
- [Battle of Dien Bien Phu](https://en.wikipedia.org/wiki/Battle_of_Dien_Bien_Phu), where the Viet Minh kick out the French
|
||||||
|
- [Battle of Saigon (1955)](https://en.wikipedia.org/wiki/Battle_of_Saigon_%281955%29)
|
||||||
|
- [KHTML](https://en.wikipedia.org/wiki/KHTML), made by KDE, which suprisingly is the parent of both Chrome ([Blink](https://en.wikipedia.org/wiki/Blink_%28browser_engine%29)) and Safari ([Webkit](https://en.wikipedia.org/wiki/WebKit))
|
||||||
|
- [Syrian Civil War](https://en.wikipedia.org/wiki/Syrian_civil_war) and it's numerous factions, like the non-secular [Syrian Salvation Government](https://en.wikipedia.org/wiki/Syrian_Salvation_Government) rebels or the Kurdish [Rojava](https://en.wikipedia.org/wiki/Autonomous_Administration_of_North_and_East_Syria) (related: )
|
||||||
|
- [Circassian Genocide](https://en.wikipedia.org/wiki/Circassian_genocide), possibly the biggest genocide of the 19th century
|
||||||
|
- [Basmachi movement](https://en.wikipedia.org/wiki/Basmachi_movement), Central Asian rebellion against Soviet rule, with notable participant [Enver Pasha](https://en.wikipedia.org/wiki/Enver_Pasha), one of the [Three Pashas](https://en.wikipedia.org/wiki/Three_Pashas) who perpetrated the [Armenian Genocide](https://en.wikipedia.org/wiki/Armenian_genocide)
|
||||||
|
- [Saigo Takamori](https://en.wikipedia.org/wiki/Saig%C5%8D_Takamori), [Meiji Restoration](https://en.wikipedia.org/wiki/Meiji_Restoration) and [Satsuma Rebellion](https://en.wikipedia.org/wiki/Satsuma_Rebellion) leader
|
||||||
|
- [Shimabara Rebellion](https://en.wikipedia.org/wiki/Shimabara_Rebellion), Christian rebellion in Japan
|
||||||
|
- [May 68](https://en.wikipedia.org/wiki/May_68), leftist French civil unrest
|
||||||
|
- [Diffie-Hellman key exchange](https://en.wikipedia.org/wiki/Diffie%E2%80%93Hellman_key_exchange)
|
||||||
|
- [Xi'an Incident](https://en.wikipedia.org/wiki/Xi%27an_Incident), where [Chiang Kai-shek](https://en.wikipedia.org/wiki/Chiang_Kai-shek) (leader of the Nationalists) is kidnapped by his generals [Yang Hucheng](https://en.wikipedia.org/wiki/Yang_Hucheng) and [Chang Hsueh-liang](https://en.wikipedia.org/wiki/Chang_Hsueh-liang), and forced to cooperate with the Communists against the invading Japanese
|
||||||
|
- [Abdullah Öcalan](https://en.wikipedia.org/wiki/Abdullah_%C3%96calan), imprisoned [PKK](https://en.wikipedia.org/wiki/Kurdistan_Workers%27_Party) leader
|
||||||
|
- [Oda Nobunaga](https://en.wikipedia.org/wiki/Oda_Nobunaga), extremely notable Japanese warlord, who was killed in the [Honnō-ji Incident](https://en.wikipedia.org/wiki/Honn%C5%8D-ji_Incident)
|
||||||
|
- [Ishiyama Hongan-ji](https://en.wikipedia.org/wiki/Ishiyama_Hongan-ji), former [Jōdo Shinshū](https://en.wikipedia.org/wiki/J%C5%8Ddo_Shinsh%C5%AB) temple/fortress, was burned down and replaced by [Osaka Castle](https://en.wikipedia.org/wiki/Osaka_Castle), and the reason why the city of [Osaka](https://en.wikipedia.org/wiki/Osaka) exists
|
||||||
|
- [Peninsular War](https://en.wikipedia.org/wiki/Peninsular_War), [Napoleon](https://en.wikipedia.org/wiki/Napoleon)'s invasion of Spain and Portugal
|
||||||
|
- [Thomas Cochrane, 10th Earl of Dundonald](https://en.wikipedia.org/wiki/Thomas_Cochrane,_10th_Earl_of_Dundonald), successful British Navy officer accused of stock exchange fraud, and later participated in the [Liberating Expedition of Peru](https://en.wikipedia.org/wiki/Liberating_Expedition_of_Peru) from the Spanish
|
||||||
|
- [Indonesia invades East Timor](https://en.wikipedia.org/wiki/Indonesian_invasion_of_East_Timor) to overthrow [Fretilin](https://en.wikipedia.org/wiki/Fretilin)
|
||||||
|
- [Special Region of Yogyakarta](https://en.wikipedia.org/wiki/Special_Region_of_Yogyakarta), a region of Indonesia *currently* hereditarily ruled by the [Yogyakarta Sultanate](https://en.wikipedia.org/wiki/Yogyakarta_Sultanate) and the [Duchy of Pakualaman](https://en.wikipedia.org/wiki/Pakualaman)
|
||||||
|
- [Nanboku-chō period](https://en.wikipedia.org/wiki/Nanboku-ch%C5%8D_period), when two opposing Japanese Imperial Courts existed, after the overthrow of the [Kamakura Shogunate](https://en.wikipedia.org/wiki/Kamakura_shogunate) and the failure of the [Kenmu Restoration](https://en.wikipedia.org/wiki/Kenmu_Restoration)
|
||||||
|
- [COINTELPRO](https://en.wikipedia.org/wiki/COINTELPRO), where the FBI unsurprisingly misbehaves
|
||||||
|
- [Transition to the New Order](https://en.wikipedia.org/wiki/Transition_to_the_New_Order), where [Suharto](https://en.wikipedia.org/wiki/Suharto) purges the [Indonesian Communist Party](https://en.wikipedia.org/wiki/Communist_Party_of_Indonesia), and overthrows [Sukarno](https://en.wikipedia.org/wiki/Sukarno)
|
||||||
|
- [Crypto Wars](https://en.wikipedia.org/wiki/Crypto_Wars), where the US Government tries to prevent the public and foreigners from using strong encryption
|
||||||
|
- [The Battle of Blair Mountain](https://en.wikipedia.org/wiki/Battle_of_Blair_Mountain), where striking coal members are bombed
|
||||||
|
- [Haymarket Affair](https://en.wikipedia.org/wiki/Haymarket_affair), where a bomb was thrown at police during a rally supporting the eight-hour work day
|
||||||
|
- [Tigray War](https://en.wikipedia.org/wiki/Tigray_War), a recent rebellion of the Tigrayan Government against the Ethiopian Government
|
||||||
|
- [Timur](https://en.wikipedia.org/wiki/Timur), conquerer and founder of the Timurid Empire, self proclaimed successor of Genghis Khan, and ancestor of the [Mughal Empire](https://en.wikipedia.org/wiki/Mughal_Empire)'s founders
|
||||||
|
- [Year of the Four Emperors](https://en.wikipedia.org/wiki/Year_of_the_Four_Emperors), a period of civil war in the Roman Empire
|
||||||
|
- [Frederick the Great](https://en.wikipedia.org/wiki/Frederick_the_Great), Prussian king, and military general
|
||||||
|
- [Tadeusz Kościuszko](https://en.wikipedia.org/wiki/Tadeusz_Ko%C5%9Bciuszko), leader of the Polish-Lithuanian [Kościuszko Uprising](https://en.wikipedia.org/wiki/Ko%C5%9Bciuszko_Uprising) against Russian rule, and American Revolutionary War hero
|
||||||
|
- [Favelas](https://en.wikipedia.org/wiki/Favela), Brazilian slums, some of which are ruled by cartels or vigilantes
|
||||||
|
- [Princely State](https://en.wikipedia.org/wiki/Princely_state), Indian prince ruled territory under the British
|
||||||
|
- [Annexation of Goa](https://en.wikipedia.org/wiki/Annexation_of_Goa), India invades Portugese ruled Goa
|
||||||
|
- [List of ethnic armed organisations in Myanmar](https://en.wikipedia.org/wiki/List_of_ethnic_armed_organisations_in_Myanmar)
|
||||||
|
- [Marcionism](https://en.wikipedia.org/wiki/Marcionism), early interpretation of Christianity
|
||||||
|
- [Lion-Eating Poet in the Stone Den](https://en.wikipedia.org/wiki/Lion-Eating_Poet_in_the_Stone_Den), shi shi shi shi...
|
||||||
|
- [Fuke-shū](https://en.wikipedia.org/wiki/Fuke-sh%C5%AB), [Shakuhachi](https://en.wikipedia.org/wiki/Shakuhachi) playing, basket wearing, Zen Buddhism sect
|
||||||
|
- [Zen Koans](https://en.wikipedia.org/wiki/Koan), "A monk asked Dongshan Shouchu, 'What is Buddha?' Dongshan said, 'Three pounds of flax.'"
|
||||||
|
- [Shugendō](https://en.wikipedia.org/wiki/Shugend%C5%8D), a religion combining Buddhism, folk religion, and Shinto mountain worship
|
||||||
|
- [Spartacist uprising](https://en.wikipedia.org/wiki/Spartacist_uprising), crushed communist revolt during the early Weimar Republic
|
||||||
|
- [Self-immolation](https://en.wikipedia.org/wiki/Self-immolation)
|
||||||
|
- [Charles Maurice de Talleyrand-Périgord](https://en.wikipedia.org/wiki/Charles_Maurice_de_Talleyrand-P%C3%A9rigord), important French Foreign Minister/diplomat across the French Monarchy, revolution, and Napoleonic period, [Bourbon Restoration](https://en.wikipedia.org/wiki/Bourbon_Restoration_in_France)
|
||||||
|
- [Luigi Lucheni](https://en.wikipedia.org/wiki/Luigi_Lucheni), anarchist assassin using a four-inch file as his weapon
|
||||||
|
- [Hundred Days](https://en.wikipedia.org/wiki/Hundred_Days), Napoleon returns to power, briefly
|
||||||
|
- [Green Corn Rebellion](https://en.wikipedia.org/wiki/Green_Corn_Rebellion), anti-draft rebellion in Oklahoma, USA during WW1
|
||||||
|
- [William Augustus Bowles](https://en.wikipedia.org/wiki/William_Augustus_Bowles), British leader of the Native American State of Muskogee
|
||||||
|
- [Mexican Revolution](https://en.wikipedia.org/wiki/Mexican_Revolution)
|
||||||
|
- [Frozen Conflict](https://en.wikipedia.org/wiki/Frozen_conflict)
|
||||||
|
- [Czechoslovak Legion](https://en.wikipedia.org/wiki/Czechoslovak_Legion), Czechoslovakian volunteer soldiers who found themselves in the middle of the [Russian Civil War](https://en.wikipedia.org/wiki/Russian_Civil_War)
|
||||||
|
- [Yuan Shikai](https://en.wikipedia.org/wiki/Yuan_Shikai), who overthrew the Qing Dynasty, then [tried and failed to proclaim himself emperor](https://en.wikipedia.org/wiki/National_Protection_War)
|
||||||
|
- [Wuchang Uprising](https://en.wikipedia.org/wiki/Wuchang_Uprising)
|
||||||
|
- [Nio](https://en.wikipedia.org/wiki/Nio), guardians at the entrance of many Buddhist temples
|
||||||
|
- [Mount Hiei](https://en.wikipedia.org/wiki/Mount_Hiei), the location of [Enryaku-ji](https://en.wikipedia.org/wiki/Enryaku-ji)
|
||||||
|
- [Mongol Invasions of Japan](https://en.wikipedia.org/wiki/Mongol_invasions_of_Japan)
|
||||||
|
- [Language Isolate](https://en.wikipedia.org/wiki/Language_isolate), languages that aren't classified as part of larger [language families](https://en.wikipedia.org/wiki/Language_family)
|
||||||
|
- [Jiajing Wokou Raids](https://en.wikipedia.org/wiki/Jiajing_wokou_raids), pirates
|
||||||
|
- [Word (computer architecture)](https://en.wikipedia.org/wiki/Word_%28computer_architecture%29)
|
||||||
|
- [Transition from Ming to Qing](https://en.wikipedia.org/wiki/Transition_from_Ming_to_Qing)
|
||||||
|
- [Eighty Years' War](https://en.wikipedia.org/wiki/Eighty_Years%27_War), Dutch independence from Spain
|
||||||
|
- [Abbasid Revolution](https://en.wikipedia.org/wiki/Abbasid_Revolution), the [Umayyad Caliphate](https://en.wikipedia.org/wiki/Umayyad_Caliphate) is overthrown and replaced by the Abbasid Caliphate
|
||||||
|
- [Operation Motorman](https://en.wikipedia.org/wiki/Operation_Motorman), British military operation in Northern Ireland
|
||||||
|
- [Green Armies](https://en.wikipedia.org/wiki/Green_armies) in the Russian Civil War
|
||||||
|
- [Recusants](https://en.wikipedia.org/wiki/Recusancy) are those that did not support the Church of England after the English Reformation
|
||||||
|
- [Terrorism Act 2000](https://en.wikipedia.org/wiki/Terrorism_Act_2000)
|
||||||
|
- [Pontoon bridge](https://en.wikipedia.org/wiki/Pontoon_bridge)
|
||||||
|
- [Francisco Macías Nguema](https://en.wikipedia.org/wiki/Francisco_Mac%C3%ADas_Nguema), completely insane dictator of Equatorial Guinea
|
||||||
|
- [Yazidism](https://en.wikipedia.org/wiki/Yazidism)
|
||||||
|
- [Göbekli Tepe](https://en.wikipedia.org/wiki/G%C3%B6bekli_Tepe), Neolithic archaeological site
|
||||||
|
- [List of unsolved problems in mathematics](https://en.wikipedia.org/wiki/List_of_unsolved_problems_in_mathematics)
|
||||||
|
- [Hartal](https://en.wikipedia.org/wiki/Hartal), Indian strike action
|
||||||
|
- [Achaemenid Empire](https://en.wikipedia.org/wiki/Achaemenid_Empire), very big, pretty cool
|
||||||
|
- [The Camden 28](https://en.wikipedia.org/wiki/The_Camden_28), anti-Vietnam War activists
|
||||||
|
- [Howard Zinn](https://en.wikipedia.org/wiki/Howard_Zinn), controversial historian
|
||||||
|
- [Gnosticism](https://en.wikipedia.org/wiki/Gnosticism) was a set of early Christian beliefs
|
||||||
|
- [TempleOS](https://en.wikipedia.org/wiki/TempleOS)
|
||||||
|
- [Epic of Gilgamesh](https://en.wikipedia.org/wiki/Epic_of_Gilgamesh), Sumerian epic poem
|
||||||
|
- [An Lushan Rebellion](https://en.wikipedia.org/wiki/An_Lushan_Rebellion), An Lushan rebels, greatly weakened the Tang Dynasty
|
||||||
|
- [Bahmani Sultanate](https://en.wikipedia.org/wiki/Bahmani_Sultanate), South Indian empire
|
||||||
|
- [Pagan Empire](https://en.wikipedia.org/wiki/Pagan_Kingdom), the first Burmese kingdom
|
||||||
|
- [Warsaw Uprising](https://en.wikipedia.org/wiki/Warsaw_Uprising)
|
||||||
|
- [Polish-Lithuanian Commonwealth](https://en.wikipedia.org/wiki/Polish%E2%80%93Lithuanian_Commonwealth)
|
||||||
|
- [Atoll](https://en.wikipedia.org/wiki/Atoll)
|
||||||
|
- [Chambre introuvable](https://en.wikipedia.org/wiki/Chambre_introuvable), ultra-royalists Chamber of Deputies elected after the Second Bourbon Restoration
|
||||||
|
- [Fedayeen](https://en.wikipedia.org/wiki/Fedayeen)
|
||||||
|
- [Battle of Nagashino](https://en.wikipedia.org/wiki/Battle_of_Nagashino), where Takeda Katsuyori learns it is not a good idea to cavalry charge into gunfire
|
||||||
|
- [Twenty-Four Generals of Takeda Shingen](https://en.wikipedia.org/wiki/Twenty-Four_Generals_of_Takeda_Shingen), related to the Battle of Nagashino above, but too interesting to leave out
|
||||||
|
- [Battle of Bannockburn](https://en.wikipedia.org/wiki/Battle_of_Bannockburn), decisive Scottish victory in the First War of Scottish Independence
|
||||||
|
- [Three Gorges](https://en.wikipedia.org/wiki/Three_Gorges), are three gorges, in China
|
||||||
|
- [Annexation of Hyderabad](https://en.wikipedia.org/wiki/Annexation_of_Hyderabad)
|
||||||
|
- [Emperor Xuanzong of Tang (9th century)](https://en.wikipedia.org/wiki/Emperor_Xuanzong_of_Tang_%289th_century%29)
|
||||||
|
- [List of coups and coup attempts](https://en.wikipedia.org/wiki/List_of_coups_and_coup_attempts)
|
||||||
|
- [Lazarus Group](https://en.wikipedia.org/wiki/Lazarus_Group), North Korean hackers
|
||||||
|
- [Pasquale Paoli](https://en.wikipedia.org/wiki/Pasquale_Paoli), Corsican nationalist admired by Napoleon
|
||||||
|
- [Doge (title)](https://en.wikipedia.org/wiki/Doge_%28title%29), like a King, but elected
|
||||||
|
- [Frank Serpico](https://en.wikipedia.org/wiki/Frank_Serpico), New York Police Department whistleblower
|
||||||
|
- [Inner Mongolia Incident](https://en.wikipedia.org/wiki/Inner_Mongolia_incident), part of the Cultural Revolution
|
||||||
|
- [Nationalist Party of Puerto_Rico](https://en.wikipedia.org/wiki/Nationalist_Party_of_Puerto_Rico)
|
||||||
|
- [Rotating locomotion in living systems](https://en.wikipedia.org/wiki/Rotating_locomotion_in_living_systems)
|
||||||
|
- [Japanese dialects](https://en.wikipedia.org/wiki/Japanese_dialects)
|
||||||
|
- [Ihor Kolomoyskyi](https://en.wikipedia.org/wiki/Ihor_Kolomoyskyi), Ukrainian oligarch
|
||||||
|
- [Anglophone Crisis](https://en.wikipedia.org/wiki/Anglophone_Crisis), war in Cameroon due to tensions between English speakers and French speakers
|
||||||
|
- [Kivu conflict](https://en.wikipedia.org/wiki/Kivu_conflict)
|
||||||
|
- [History of Somalia](https://en.wikipedia.org/wiki/History_of_Somalia)
|
||||||
|
- [Migration Period](https://en.wikipedia.org/wiki/Migration_Period), which led to the fall of the Western Roman Empire
|
||||||
|
- [Hydrofoil](https://en.wikipedia.org/wiki/Hydrofoil)
|
||||||
|
- [General Sherman (tree)](https://en.wikipedia.org/wiki/General_Sherman_%28tree%29), very big and very old
|
||||||
|
- [Simón Bolívar](https://en.wikipedia.org/wiki/Sim%C3%B3n_Bol%C3%ADvar), South American revolutionary hero
|
||||||
|
- [Battle of Dibrivka](https://en.wikipedia.org/wiki/Battle_of_Dibrivka)
|
||||||
|
- [Battle of the Teutoburg Forest](https://en.wikipedia.org/wiki/Battle_of_the_Teutoburg_Forest)
|
||||||
|
- [Intentional community](https://en.wikipedia.org/wiki/Intentional_community)
|
||||||
|
- [Jellyfish](https://en.wikipedia.org/wiki/Jellyfish) are apparently "the informal common names given to the medusa-phase of certain gelatinous members of the subphylum Medusozoa"
|
||||||
|
- [Sinecure](https://en.wikipedia.org/wiki/Sinecure), get paid to do nothing
|
||||||
|
- [Yelü Dashi](https://en.wikipedia.org/wiki/Yel%C3%BC_Dashi), founder of the Western Liao dynasty
|
||||||
|
- [List of Unicode characters](https://en.wikipedia.org/wiki/List_of_Unicode_characters)
|
||||||
|
- [Operation Cyclone](https://en.m.wikipedia.org/wiki/Operation_Cyclone), where the CIA funds Islamist groups to fight against the communist Afghan government
|
||||||
|
- [Witold Pilecki](https://en.wikipedia.org/wiki/Witold_Pilecki), brave Polish soldier who intentionally got sent to Auschwitz to spy, *then* voluntarily returned to Communist Poland, again to spy, and was executed
|
||||||
|
- [Ascall mac Ragnaill](https://en.wikipedia.org/wiki/Ascall_mac_Ragnaill), Last Norse-Gaelic King of Dublin
|
||||||
|
- [Iodine](https://en.wikipedia.org/wiki/Iodine), the element
|
||||||
|
- [Riot control](https://en.wikipedia.org/wiki/Riot_control)
|
||||||
|
- [Feoffment](https://en.wikipedia.org/wiki/Feoffment), land for loyalty
|
||||||
|
- [Kent State shootings](https://en.wikipedia.org/wiki/Kent_State_shootings), US National Guard shoots students protesting against US involvement in Cambodia during the Vietnam War
|
||||||
|
- [Mountain men](https://en.wikipedia.org/wiki/Mountain_man)
|
||||||
|
- [1728 Musin Rebellion](https://en.wikipedia.org/wiki/1728_Musin_Rebellion) against the Korean Joseon Dynasty
|
||||||
|
- [Kurt Gödel](https://en.wikipedia.org/wiki/Kurt_G%C3%B6del), mathematician
|
||||||
|
- [Joe Hill](https://en.wikipedia.org/wiki/Joe_Hill_%28activist%29)
|
||||||
|
- [Dual_EC_DRBG](https://en.wikipedia.org/wiki/Dual_EC_DRBG), a (probably) NSA backdoored random number generator
|
||||||
|
- [English-based creole languages](https://en.wikipedia.org/wiki/English-based_creole_languages)
|
||||||
|
- [Austro-Hungarian concession of Tianjin](https://en.wikipedia.org/wiki/Austro-Hungarian_concession_of_Tianjin)
|
||||||
|
- [Steve Mann (inventor)](https://en.wikipedia.org/wiki/Steve_Mann_%28inventor%29), inventor of wearable computing technology
|
||||||
|
- [Kronstadt rebellion](https://en.wikipedia.org/wiki/Kronstadt_rebellion), revolt against the Bolsheviks by Soviet soldiers
|
||||||
|
- [Great Purge](https://en.wikipedia.org/wiki/Great_Purge), Stalin purges a lot of people
|
||||||
|
- [Iranian Revolution](https://en.wikipedia.org/wiki/Iranian_Revolution)
|
||||||
|
- [Cultural Revolution](https://en.wikipedia.org/wiki/Cultural_Revolution), which didn't turn out very well at all
|
||||||
|
- [National Treasure (Japan)](https://en.wikipedia.org/wiki/National_Treasure_%28Japan%29)
|
||||||
|
- [Protozoa](https://en.wikipedia.org/wiki/Protozoa)
|
||||||
|
- [Akira Kurosawa](https://en.wikipedia.org/wiki/Akira_Kurosawa), filmmaker
|
||||||
|
- [Isle of Man](https://en.wikipedia.org/wiki/Isle_of_Man)
|
||||||
|
- [Fantastic War](https://en.wikipedia.org/wiki/Fantastic_War), fought between Spain and Portugal as part of the [Seven Years' War](https://en.wikipedia.org/wiki/Seven_Years%27_War). Was not fantastic for Spain, since they lost
|
||||||
|
- [Nikoli](https://en.wikipedia.org/wiki/Nikoli_%28publisher%29), publisher of puzzle gmaes like Sudoku
|
||||||
|
- [Sikkim](https://en.wikipedia.org/wiki/Sikkim)
|
||||||
|
- [Subutai](https://en.wikipedia.org/wiki/Subutai), Mongol general who invaded Hungary
|
||||||
|
- [Free speech fights](https://en.wikipedia.org/wiki/Free_speech_fights), usually suppression of labour issue related speech
|
||||||
|
- [Sesshū Tōyō](https://en.wikipedia.org/wiki/Sessh%C5%AB_T%C5%8Dy%C5%8D), painter
|
||||||
|
- [Java War (1741–1743)](https://en.wikipedia.org/wiki/Java_War_%281741%E2%80%931743%29)
|
||||||
|
- [Tuareg people](https://en.wikipedia.org/wiki/Tuareg_people)
|
||||||
|
- [Amnesty International](https://en.wikipedia.org/wiki/Amnesty_International)
|
||||||
|
- [Julian Assange](https://en.wikipedia.org/wiki/Julian_Assange)
|
||||||
|
- [Bukharian (Judeo-Tajik dialect)](https://en.wikipedia.org/wiki/Bukharian_%28Judeo-Tajik_dialect%29)
|
||||||
|
- [Qt (software)](https://en.wikipedia.org/wiki/Qt_%28software%29), for developing GUIs
|
||||||
|
- [Sailfish OS](https://en.wikipedia.org/wiki/Sailfish_OS)
|
||||||
|
- [SerenityOS](https://en.wikipedia.org/wiki/SerenityOS)
|
||||||
|
- [Puputan](https://en.wikipedia.org/wiki/Puputan), Balinese mass ritual suicide instead of surrender
|
||||||
|
- [Harrying of the North](https://en.wikipedia.org/wiki/Harrying_of_the_North), the Normans put down English rebellions
|
||||||
|
- [Austerity](https://en.wikipedia.org/wiki/Austerity)
|
||||||
|
- [Muqtada al-Sadr](https://en.wikipedia.org/wiki/Muqtada_al-Sadr), Iraqi militia leader
|
||||||
|
- [Messier objects](https://en.wikipedia.org/wiki/Messier_object), 110 non-comet space objects that the astronomer Messier recorded
|
||||||
|
- [De jure](https://en.wikipedia.org/wiki/De_jure)
|
||||||
|
- [Iona Nikitchenko](https://en.wikipedia.org/wiki/Iona_Nikitchenko), Soviet judge who had trouble writing a dissenting opinion because that was not done in Soviet law
|
||||||
|
- [Interstate Highway System](https://en.wikipedia.org/wiki/Interstate_Highway_System), an American highway system
|
||||||
|
- [Bushrangers](https://en.wikipedia.org/wiki/Bushranger), Australian escaped convicts turned outlaws
|
||||||
|
- [Eyuwan Soviet](https://en.wikipedia.org/wiki/Eyuwan_Soviet), a Chinese government led by a rival of Mao Zedong
|
||||||
|
- [Altai Mountains](https://en.wikipedia.org/wiki/Altai_Mountains)
|
||||||
|
- [Republic of Genoa](https://en.wikipedia.org/wiki/Republic_of_Genoa)
|
||||||
|
- [Moral rights](https://en.wikipedia.org/wiki/Moral_rights)
|
||||||
|
- [Seaplane tender](https://en.wikipedia.org/wiki/Seaplane_tender), an early type of aircraft carrier
|
||||||
|
- [List of cities founded by Alexander the Great](https://en.wikipedia.org/wiki/List_of_cities_founded_by_Alexander_the_Great)
|
||||||
|
- [Microcode](https://en.wikipedia.org/wiki/Microcode), translates machine code into CPU operations
|
||||||
|
- [Army of Sambre and Meuse](https://en.wikipedia.org/wiki/Army_of_Sambre_and_Meuse), a French revolutionary army
|
||||||
|
- [Governor General of Canada](https://en.wikipedia.org/wiki/Governor_General_of_Canada)
|
||||||
|
- [European Convention on Human Rights](https://en.wikipedia.org/wiki/European_Convention_on_Human_Rights)
|
||||||
|
- [Túpac Amaru](https://en.wikipedia.org/wiki/T%C3%BApac_Amaru)
|
||||||
|
- [Fossil](https://en.wikipedia.org/wiki/Fossil)
|
||||||
|
- [Vi](https://en.wikipedia.org/wiki/Vi), a text editor
|
||||||
|
- [Scheme (programming language)](https://en.wikipedia.org/wiki/Scheme_%28programming_language%29), a lisp dialect
|
||||||
|
- [Trans-Neptunian object](https://en.wikipedia.org/wiki/Trans-Neptunian_object)
|
||||||
|
- [Brackish water](https://en.wikipedia.org/wiki/Brackish_water), salty, but not that salty
|
||||||
|
- [May Days](https://en.wikipedia.org/wiki/May_Days), infighting between the Spanish Republican faction
|
||||||
|
- [List of HTTP header fields](https://en.wikipedia.org/wiki/List_of_HTTP_header_fields)
|
||||||
|
- [Bo Xilai](https://en.wikipedia.org/wiki/Bo_Xilai), disgraced Chinese politician
|
||||||
|
- [Zapatista Army of National Liberation](https://en.wikipedia.org/wiki/Zapatista_Army_of_National_Liberation)
|
||||||
|
- [Materiel](https://en.wikipedia.org/wiki/Materiel)
|
||||||
|
- [Mindanao](https://en.wikipedia.org/wiki/Mindanao), Filipino island
|
||||||
|
- [Autonomous communities of Spain](https://en.wikipedia.org/wiki/Autonomous_communities_of_Spain)
|
||||||
|
- [Ba'ath_Party](https://en.wikipedia.org/wiki/Ba'ath_Party)
|
||||||
|
- [Ink wash painting](https://en.wikipedia.org/wiki/Ink_wash_painting)
|
||||||
|
- [Dahomey](https://en.wikipedia.org/wiki/Dahomey), West African kingdom
|
||||||
|
- [Palmer Raids](https://en.wikipedia.org/wiki/Palmer_Raids), a series of raids by the US government to deport suspected leftists
|
||||||
|
- [Stingray phone tracker](https://en.wikipedia.org/wiki/Stingray_phone_tracker), which mimicks a cell tower
|
||||||
|
- [Vettius Agorius Praetextatus](https://en.wikipedia.org/wiki/Vettius_Agorius_Praetextatus), 4th century Roman pagan aristocrat and high-ranking priest
|
||||||
|
- [Inca architecture](https://en.wikipedia.org/wiki/Inca_architecture)
|
||||||
|
- [Matteo Ricci](https://en.wikipedia.org/wiki/Matteo_Ricci), one of the most important priests of the Jesuit missions in China
|
||||||
|
- [Pop art](https://en.wikipedia.org/wiki/Pop_art)
|
||||||
|
- [Battle of Actium](https://en.wikipedia.org/wiki/Battle_of_Actium), where Octavian defeats Mark Antony
|
||||||
|
- [Favourite](https://en.wikipedia.org/wiki/Favourite), a close companion to a ruler
|
||||||
|
- [Heshen](https://en.wikipedia.org/wiki/Heshen), an extremely corrupt Chinese imperial official who amassed the equivalent of 270 billion USD
|
||||||
|
- [List of richest Americans in history](https://en.wikipedia.org/wiki/List_of_richest_Americans_in_history)
|
||||||
|
- [Proscription](https://en.wikipedia.org/wiki/Proscription), government degree declaring one an enemy of the state, originating in the late Roman Republic
|
||||||
|
- [Cato the Younger](https://en.wikipedia.org/wiki/Cato_the_Younger), Roman politician
|
||||||
|
- [Cicero](https://en.wikipedia.org/wiki/Cicero), prolific writer and Roman politician
|
||||||
|
- [Moro people](https://en.wikipedia.org/wiki/Moro_people)
|
||||||
|
- [Bogd Khan](https://en.wikipedia.org/wiki/Bogd_Khan)
|
||||||
|
- [Pancho Villa Expedition](https://en.wikipedia.org/wiki/Pancho_Villa_Expedition), Mexican revolutionary leader attacks an US town, US military retaliates
|
||||||
|
- [Unicelluar organism](https://en.wikipedia.org/wiki/Unicellular_organism)
|
||||||
|
- [Revolutions of 1848](https://en.wikipedia.org/wiki/Revolutions_of_1848), mostly failed democratic and liberal uprisings in Europe
|
||||||
|
- [Donation of Pepin](https://en.wikipedia.org/wiki/Donation_of_Pepin), lead to the creation of the Papal States
|
||||||
|
- [Informal economy](https://en.wikipedia.org/wiki/Informal_economy)
|
||||||
|
- [Sinicization](https://en.wikipedia.org/wiki/Sinicization)
|
||||||
|
- [Romansh language](https://en.wikipedia.org/wiki/Romansh_language), a national language of Switzerland
|
||||||
|
- [Toyota Group](https://en.wikipedia.org/wiki/Toyota_Group)
|
||||||
|
- [California grizzly bear](https://en.wikipedia.org/wiki/California_grizzly_bear), extinct
|
||||||
|
- [Cognates](https://en.wikipedia.org/wiki/Cognate) are word cousins
|
||||||
|
- [Mun (religion)](https://en.wikipedia.org/wiki/Mun_(religion))
|
||||||
|
- [Vaqueiros de alzada](https://en.wikipedia.org/wiki/Vaqueiros_de_alzada), nomads in northern Spain
|
||||||
|
- [Muslim conquest of Persia](https://en.wikipedia.org/wiki/Muslim_conquest_of_Persia)
|
||||||
BIN
static/images/190kyay.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
static/images/c/eve.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
static/images/captcha.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
static/images/commas.gif
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
static/images/gobanme_example.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
static/images/habitat67.jpg
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
static/images/og_faucet.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
static/images/pdt1.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
static/images/prime_test.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
static/images/screenshot_double_win.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
static/images/sovietapartment.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
static/images/ws1.png
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
static/images/ws3.png
Normal file
|
After Width: | Height: | Size: 906 KiB |