Compare commits
12 commits
1cdc202731
...
070a03142f
| Author | SHA1 | Date | |
|---|---|---|---|
| 070a03142f | |||
| 82a241ddee | |||
| 53ef4d9147 | |||
| 728dcdb3d9 | |||
| cdca57012a | |||
| 84543184e7 | |||
| d384ecfa9d | |||
| c86d8052c5 | |||
| 0e78c506a4 | |||
| d14fd083f3 | |||
| 1ea3024e4e | |||
| 283df32afe |
38 changed files with 2047 additions and 312 deletions
|
|
@ -31,16 +31,19 @@ jobs:
|
|||
|
||||
- name: Lint
|
||||
run: |
|
||||
set -eo pipefail
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm run qa:no-em-dashes
|
||||
npm run qa:spelling
|
||||
npm run qa:no-github-links
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: QA build
|
||||
run: |
|
||||
set -eo pipefail
|
||||
npm run qa:astro-audit
|
||||
npm run qa:links
|
||||
npm run qa:no-js
|
||||
|
|
|
|||
39
.vscode/settings.json
vendored
39
.vscode/settings.json
vendored
|
|
@ -1,42 +1,9 @@
|
|||
{
|
||||
"cSpell.words": [
|
||||
"andras",
|
||||
"Andras",
|
||||
"astra",
|
||||
"colour",
|
||||
"colours",
|
||||
"datetime",
|
||||
"decla",
|
||||
"EEPROM",
|
||||
"favicons",
|
||||
"favourite",
|
||||
"forex",
|
||||
"froms",
|
||||
"Glsl",
|
||||
"leds",
|
||||
"linkedin",
|
||||
"mesmerising",
|
||||
"microcontrollers",
|
||||
"mixins",
|
||||
"noopener",
|
||||
"optimisations",
|
||||
"optimised",
|
||||
"organiser",
|
||||
"playsinline",
|
||||
"realised",
|
||||
"schmelczer",
|
||||
"serialisation",
|
||||
"synchronised",
|
||||
"tabindex",
|
||||
"utilised",
|
||||
"utilising",
|
||||
"webm",
|
||||
"webp",
|
||||
"youtube"
|
||||
],
|
||||
"files.exclude": {
|
||||
"node_modules": true
|
||||
},
|
||||
"editor.rulers": [90],
|
||||
"editor.rulers": [
|
||||
90
|
||||
],
|
||||
"editor.wordWrap": "on"
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@
|
|||
"immich",
|
||||
"immich's",
|
||||
"importmap",
|
||||
"incrementality",
|
||||
"janky",
|
||||
"jemalloc",
|
||||
"keepalives",
|
||||
|
|
@ -58,6 +59,7 @@
|
|||
"loggable",
|
||||
"malloc",
|
||||
"metas",
|
||||
"mispredictions",
|
||||
"mixolydian",
|
||||
"mlockall",
|
||||
"mosfets",
|
||||
|
|
@ -68,10 +70,12 @@
|
|||
"nums",
|
||||
"permacomputing",
|
||||
"physarum",
|
||||
"piskel",
|
||||
"pocketbase",
|
||||
"powerups",
|
||||
"proptest",
|
||||
"refetches",
|
||||
"reflashing",
|
||||
"reimplementable",
|
||||
"requantised",
|
||||
"rgba",
|
||||
|
|
@ -92,6 +96,7 @@
|
|||
"subvolume",
|
||||
"syncer",
|
||||
"szintű",
|
||||
"tamagotchi",
|
||||
"tamagotchis",
|
||||
"tarjan's",
|
||||
"tidepool",
|
||||
|
|
@ -99,6 +104,7 @@
|
|||
"tweakpane",
|
||||
"unsets",
|
||||
"vaultlink",
|
||||
"vtable",
|
||||
"waveshare",
|
||||
"waveshare's",
|
||||
"webform",
|
||||
|
|
|
|||
1937
package-lock.json
generated
1937
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -18,15 +18,16 @@
|
|||
"qa:astro-audit": "node scripts/install-playwright-deps.mjs && node scripts/export-astro-audit.mjs --fail-on-issues",
|
||||
"qa:no-em-dashes": "node scripts/check-no-em-dashes.mjs",
|
||||
"qa:spelling": "cspell --no-progress \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/**/*.mjs\" \"*.md\"",
|
||||
"qa:no-github-links": "node scripts/check-no-github-links.mjs",
|
||||
"qa:links": "node scripts/check-links.mjs",
|
||||
"qa:no-js": "node scripts/check-no-js.mjs",
|
||||
"qa:overflow": "node scripts/install-playwright-deps.mjs && node scripts/check-overflow.mjs",
|
||||
"qa:preview-cropping": "node scripts/install-playwright-deps.mjs && node scripts/check-preview-cropping.mjs",
|
||||
"qa": "npm run typecheck && npm run lint && npm run qa:no-em-dashes && npm run qa:spelling && npm run build && npm run qa:astro-audit && npm run qa:links && npm run qa:no-js && npm run qa:overflow && npm run qa:preview-cropping"
|
||||
"qa": "npm run typecheck && npm run lint && npm run qa:no-em-dashes && npm run qa:spelling && npm run qa:no-github-links && npm run build && npm run qa:astro-audit && npm run qa:links && npm run qa:no-js && npm run qa:overflow && npm run qa:preview-cropping"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/schmelczer/schmelczer.github.io.git"
|
||||
"url": "git+https://git.schmelczer.dev/andras/schmelczer.dev.git"
|
||||
},
|
||||
"keywords": [
|
||||
"blog",
|
||||
|
|
@ -37,9 +38,9 @@
|
|||
"author": "Andras Schmelczer",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"bugs": {
|
||||
"url": "https://github.com/schmelczer/schmelczer.github.io/issues"
|
||||
"url": "https://git.schmelczer.dev/andras/schmelczer.dev/issues"
|
||||
},
|
||||
"homepage": "https://github.com/schmelczer/schmelczer.github.io#readme",
|
||||
"homepage": "https://git.schmelczer.dev/andras/schmelczer.dev#readme",
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.9",
|
||||
"@astrojs/rss": "^4.0.18",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ const textExtensions = new Set([
|
|||
'.mjs',
|
||||
'.ts',
|
||||
'.txt',
|
||||
'.vtt',
|
||||
'.webmanifest',
|
||||
'.xml',
|
||||
]);
|
||||
|
|
|
|||
99
scripts/check-no-github-links.mjs
Normal file
99
scripts/check-no-github-links.mjs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// cspell:ignore githubusercontent streetsidesoftware forgejo
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { walk } from './lib/walk.mjs';
|
||||
|
||||
const root = process.cwd();
|
||||
|
||||
// GitHub is no longer linked from anywhere on the site: project source links,
|
||||
// the social links, and the repo metadata all point at the self-hosted git
|
||||
// host instead. Matching the host substrings (rather than the bare word
|
||||
// "github") keeps the Shiki `github-light`/`github-dark` themes and the
|
||||
// Forgejo Actions `github.*` context variables from tripping the check.
|
||||
const bannedHosts = [/github\.com/i, /github\.io/i, /githubusercontent\.com/i];
|
||||
|
||||
// Upstream tooling URLs that genuinely live on GitHub and have no self-hosted
|
||||
// equivalent (e.g. the cspell config JSON Schema). A flagged URL is permitted
|
||||
// only when it exactly equals one of these.
|
||||
const permitted = new Set([
|
||||
'https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json',
|
||||
]);
|
||||
|
||||
const textExtensions = new Set([
|
||||
'.astro',
|
||||
'.css',
|
||||
'.html',
|
||||
'.js',
|
||||
'.json',
|
||||
'.md',
|
||||
'.mjs',
|
||||
'.ts',
|
||||
'.txt',
|
||||
'.webmanifest',
|
||||
'.xml',
|
||||
]);
|
||||
|
||||
const roots = [
|
||||
'src',
|
||||
'public',
|
||||
'scripts',
|
||||
'README.md',
|
||||
'package.json',
|
||||
'astro.config.mjs',
|
||||
'cspell.json',
|
||||
].map((entry) => path.resolve(root, entry));
|
||||
|
||||
async function exists(filePath) {
|
||||
try {
|
||||
await stat(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Grows a match outward to the whole whitespace/quote-delimited URL token so it
|
||||
// can be compared against the permitted set.
|
||||
function urlToken(line, index, length) {
|
||||
const boundary = /[\s"'`<>()[\]{}]/;
|
||||
let start = index;
|
||||
while (start > 0 && !boundary.test(line[start - 1])) start -= 1;
|
||||
let end = index + length;
|
||||
while (end < line.length && !boundary.test(line[end])) end += 1;
|
||||
return line.slice(start, end);
|
||||
}
|
||||
|
||||
const files = [];
|
||||
|
||||
for (const entry of roots) {
|
||||
if (!(await exists(entry))) continue;
|
||||
files.push(...(await walk(entry)));
|
||||
}
|
||||
|
||||
const textFiles = files.filter((file) => textExtensions.has(path.extname(file)));
|
||||
|
||||
const failures = [];
|
||||
|
||||
for (const file of textFiles) {
|
||||
const lines = (await readFile(file, 'utf8')).split('\n');
|
||||
|
||||
lines.forEach((line, lineIndex) => {
|
||||
for (const pattern of bannedHosts) {
|
||||
for (const match of line.matchAll(new RegExp(pattern, 'gi'))) {
|
||||
const token = urlToken(line, match.index, match[0].length);
|
||||
if (permitted.has(token)) continue;
|
||||
const location = `${path.relative(root, file)}:${lineIndex + 1}:${match.index + 1}`;
|
||||
failures.push(location);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(
|
||||
`GitHub links are not allowed; link the self-hosted git host instead:\n${failures.join('\n')}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('No GitHub links found.');
|
||||
|
|
@ -79,7 +79,6 @@ const MIME = {
|
|||
'.woff2': 'font/woff2',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.vtt': 'text/vtt; charset=utf-8',
|
||||
'.pdf': 'application/pdf',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -8,18 +8,16 @@ interface Props {
|
|||
projectPeriod?: string;
|
||||
stack?: string[];
|
||||
scale?: string;
|
||||
outcome?: string;
|
||||
links?: Link[];
|
||||
headingId: string;
|
||||
}
|
||||
|
||||
const { projectPeriod, stack = [], scale, outcome, links = [], headingId } = Astro.props;
|
||||
const { projectPeriod, stack = [], scale, links = [], headingId } = Astro.props;
|
||||
|
||||
const rows: Array<[string, string]> = [];
|
||||
if (projectPeriod) rows.push(['Period', projectPeriod]);
|
||||
if (stack.length > 0) rows.push(['Stack', stack.join(', ')]);
|
||||
if (scale) rows.push(['Scale', scale]);
|
||||
if (outcome) rows.push(['Outcome', outcome]);
|
||||
---
|
||||
|
||||
{
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const year = new Date().getFullYear();
|
|||
<address class="footer-contact">
|
||||
<a href={`mailto:${site.email}`}>Email</a>
|
||||
<a href={site.cv} rel="noopener">CV</a>
|
||||
<a href={site.github} rel="noopener me">GitHub</a>
|
||||
<a href={site.git} rel="noopener me">Git</a>
|
||||
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
|
||||
</address>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ const videoHeight = item.type === 'video' ? (item.poster?.height ?? 720) : undef
|
|||
poster={item.poster?.src}
|
||||
width={videoWidth}
|
||||
height={videoHeight}
|
||||
aria-label={item.alt}
|
||||
aria-label={item.caption}
|
||||
>
|
||||
{item.webm && <source src={item.webm} type="video/webm" />}
|
||||
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
|
||||
|
|
@ -50,7 +50,7 @@ const videoHeight = item.type === 'video' ? (item.poster?.height ?? 720) : undef
|
|||
item.src && (
|
||||
<Picture
|
||||
src={item.src}
|
||||
alt={item.decorative ? '' : (item.alt ?? '')}
|
||||
alt={item.decorative ? '' : (item.caption ?? '')}
|
||||
formats={['avif', 'webp']}
|
||||
widths={[480, 960, 1280, 1920]}
|
||||
sizes="(max-width: 700px) calc(100vw - 2 * clamp(20px, 4vw, 32px)), (max-width: 1100px) min(calc(100vw - 4rem), 56rem), 56rem"
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ const playScript = `
|
|||
class="video-thumbnail__play"
|
||||
type="button"
|
||||
data-video-play
|
||||
aria-label={`Play video: ${video.alt ?? alt}`}
|
||||
aria-label={`Play video: ${video.caption ?? alt}`}
|
||||
>
|
||||
<span class="video-thumbnail__play-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" focusable="false">
|
||||
|
|
@ -106,7 +106,7 @@ const playScript = `
|
|||
preload="none"
|
||||
playsinline
|
||||
poster={video.poster?.src}
|
||||
aria-label={video.alt}
|
||||
aria-label={video.caption}
|
||||
hidden
|
||||
>
|
||||
{video.webm && <source src={video.webm} type="video/webm" />}
|
||||
|
|
|
|||
|
|
@ -72,13 +72,15 @@ const thumbnailOverrideSchema = ({ image }: SchemaContext) =>
|
|||
src: image(),
|
||||
});
|
||||
|
||||
// The caption is the single source of truth for a media item's text: it shows
|
||||
// as the visible <figcaption> and doubles as the image alt / accessible name,
|
||||
// so meaningful media carries one string instead of a caption/alt pair.
|
||||
const mediaSchema = ({ image }: SchemaContext) =>
|
||||
z
|
||||
.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.enum(['image', 'diagram']),
|
||||
src: image(),
|
||||
alt: z.string().optional(),
|
||||
decorative: z.boolean().optional(),
|
||||
caption: z.string().optional(),
|
||||
transcript: z.string().optional(),
|
||||
|
|
@ -89,7 +91,6 @@ const mediaSchema = ({ image }: SchemaContext) =>
|
|||
poster: image().optional(),
|
||||
mp4: mediaUrl.optional(),
|
||||
webm: mediaUrl.optional(),
|
||||
alt: z.string().optional(),
|
||||
decorative: z.boolean().optional(),
|
||||
caption: z.string().optional(),
|
||||
transcript: z.string().optional(),
|
||||
|
|
@ -98,15 +99,9 @@ const mediaSchema = ({ image }: SchemaContext) =>
|
|||
message: 'Video media needs at least one mp4 or webm source.',
|
||||
}),
|
||||
])
|
||||
.refine((item) => item.decorative || (Boolean(item.alt) && Boolean(item.caption)), {
|
||||
message: 'Meaningful media needs both alt text and a caption.',
|
||||
})
|
||||
.refine(
|
||||
(item) => item.type !== 'video' || item.decorative || Boolean(item.transcript),
|
||||
{
|
||||
message: 'Meaningful video needs a transcript.',
|
||||
}
|
||||
);
|
||||
.refine((item) => item.decorative || Boolean(item.caption), {
|
||||
message: 'Meaningful media needs a caption.',
|
||||
});
|
||||
|
||||
// A single collection where each entry can carry an `article` facet (a written
|
||||
// page under /articles/<slug>), a `project` facet (a card in the /projects/
|
||||
|
|
@ -123,7 +118,6 @@ const articleFacet = ({ image }: SchemaContext) =>
|
|||
tags: z.array(z.enum(TAGS)).default([]),
|
||||
stack: z.array(z.string()).optional(),
|
||||
scale: z.string().min(1).optional(),
|
||||
outcome: z.string().min(1).optional(),
|
||||
media: z.array(mediaSchema({ image })).default([]),
|
||||
});
|
||||
|
||||
|
|
|
|||
BIN
src/content/work/_assets/ad-astra-breadboard.jpg
Normal file
BIN
src/content/work/_assets/ad-astra-breadboard.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 389 KiB |
|
|
@ -8,7 +8,7 @@ thumbnail:
|
|||
alt: The Ad Astra game running on a small OLED display.
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/ad_astra
|
||||
url: https://git.schmelczer.dev/andras/ad_astra
|
||||
article:
|
||||
tags: ['embedded', 'games', 'systems']
|
||||
stack: ['C', 'ATtiny85V', 'SPI OLED', 'IR receiver', 'EEPROM', 'KiCad']
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: Avoid
|
||||
description: My first browser game, and later the handout for a small JS/Canvas workshop. Kept around so the timeline stays honest.
|
||||
description: My first browser game, and later the handout for a JS/Canvas workshop at SchDesign, a society at the Budapest Technology University (BME). It's trivial yet I still have fun playing with it now, so it made the cut.
|
||||
date: 2026-04-29
|
||||
period: 'January 2018'
|
||||
thumbnail:
|
||||
|
|
@ -8,18 +8,13 @@ thumbnail:
|
|||
alt: Screenshot of the Avoid web game.
|
||||
article:
|
||||
tags: ['games', 'web']
|
||||
role: Game author
|
||||
stack: ['JavaScript', 'Canvas']
|
||||
outcome: My first browser game; kept for the timeline, reused for teaching
|
||||
audience: general
|
||||
project:
|
||||
description: My first browser game, kept around so the timeline is honest.
|
||||
thumbnail:
|
||||
alt: Screenshot of the Avoid canvas game.
|
||||
project: {}
|
||||
|
||||
---
|
||||
|
||||
January 2018, my first browser game. You're a peach-coloured dot; red dots stream in from the edges and chase you; the score counts how many the game has dared to spawn so far. It isn't good. I keep it here because pretending the older work didn't happen would be revisionism, and because it marks the moment a `<canvas>` element stopped being mysterious to me.
|
||||
January 2018, my first browser game. You're a dot; other dots stream in from the edges and chase you; you have to out-manoeuvre them to escape while getting them to collide with each other. The game is simple but rather fun, at least for the first few minutes.
|
||||
|
||||
It had a second job, too. The README isn't documentation; it's the handout for a small JS/Canvas workshop, in Hungarian: what JavaScript is, what a 2D context gives you, why a game loop wants `requestAnimationFrame` and a delta time, why `var` is past tense. The little game turned out to be the right size for explaining things to people who'd never drawn a pixel from code before. The process advice in that handout (write working code first, then make it pretty) is still roughly the only process I follow.
|
||||
This project marks my first encounter with the `<canvas>` element acting as the catalyst for my later (and still going) obsession with graphics programming.
|
||||
|
||||
When I archived the repo in 2022 I allowed myself to fix the retina scaling and make the page vaguely responsive, and then made myself stop.
|
||||
A few months later I also reused this for our design society's web design workshop. We rebuilt the webpage based on the handout in the README while getting familiar with expressing art and movement through JavaScript. The little game turned out to be the right size for explaining things to people who'd never drawn a pixel from code before. The process advice in that handout (write working code first, then make it pretty) is still roughly the only process I follow.
|
||||
|
|
|
|||
|
|
@ -1,99 +1,28 @@
|
|||
---
|
||||
title: Backing Up Running Databases Without Stopping Them
|
||||
description: A Bash container around BorgBackup. BTRFS snapshots give atomic consistency, numeric env vars give multi-target 3-2-1, the loop is sleep not cron.
|
||||
title: Simple Sophisticated Backup Strategy
|
||||
description: When simple beats complex and a case for not reinventing the wheel.
|
||||
date: 2026-05-29
|
||||
period: '2024-2026'
|
||||
period: '2023-2026'
|
||||
thumbnail:
|
||||
src: ./_assets/backup.png
|
||||
alt: Placeholder thumbnail for the backup container post.
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/backup-container
|
||||
url: https://git.schmelczer.dev/andras/backup-container
|
||||
- label: Container image
|
||||
url: https://github.com/schmelczer/backup-container/pkgs/container/backup-container
|
||||
url: https://git.schmelczer.dev/andras/-/packages/container/backup-container
|
||||
article:
|
||||
tags: ['systems', 'tools']
|
||||
stack: ['Bash', 'BorgBackup', 'BTRFS', 'Alpine', 'Docker', 'SSH', 'zstd']
|
||||
scale: One container, multiple targets per host, two years of restored incidents
|
||||
outcome: A self-hosted backup that has survived every actual incident I've thrown at it
|
||||
project:
|
||||
title: Backup Container
|
||||
scale: One container, multiple targets per host, four years of restored incidents
|
||||
---
|
||||
|
||||
Once you self-host a few services with live databases, the backup question stops being theoretical: everything on the box is mid-write at every moment of the day. This container is my answer, two years and several real restores in (including the photo library behind the [e-ink frame](/articles/frame-eink-photo-display/)). One Alpine container and four short shell scripts (the longest is 84 lines) push a BTRFS snapshot to one or more [Borg](https://borgbackup.readthedocs.io/) repositories on a fixed interval. Multi-target is numeric env vars (`BORG_REPO_0`, `BORG_REPO_1`, ...); there's no config format and no DSL, because the env file is the configuration. The design has exactly one clever moment, the snapshot, and I've worked to keep everything else too simple to break.
|
||||
There's merit to keeping systems simpler the more critical they are. More precisely, we must keep them easily understandable by building on solid foundations and only adding on top the most necessary extra logic.
|
||||
|
||||
## The problem the snapshot solves
|
||||
A good example of this is my home server's backup. I ran various databases, even when I aim to mostly use sqlite, there are multiple postgres instances, redis, message queues, etc. Who doesn't like time machine like backups which allow granular file history? To achieve this, we need frequent backups, my setup uses an hourly cadence. However, we can't shut down the entire stack every hour to back it up. So the only solution is to backup everything live.
|
||||
|
||||
`tar | borg create` against a live volume is a race: a Postgres or SQLite file that's half-written when borg reads it goes into the archive in a state nothing on Earth can replay, and you find out at restore time, which is the one moment you can least afford the discovery. The "right" answer is to coordinate a quiesce with every database: a fan-out of `pg_dump`, SQLite `.backup`, Redis `BGSAVE`, and so on, each with retries, timeouts, and per-app credentials.
|
||||
I could've gone down the rabbit hole of using each database's dedicated backup mechanism, however, that would've been everything but simple. So instead I rely on the DB's crash recovery mechanism being solid.
|
||||
|
||||
The cheaper answer, if you've put everything on one BTRFS volume, is `btrfs subvolume snapshot`. It returns instantly with a copy-on-write fork of the entire filesystem. Every file is now atomically consistent at exactly the same instant. Run borg against the snapshot, not against the live volume.
|
||||
With the above context, I can now reveal that the backup container is just a short shell script that takes a btrfs snapshot (all container volumes are mounted from a btrfs subvolume) and then feeding into borg which handles incrementality and deduplication. The backup script supports configuring borg to run against multiple remote backup servers, gracefully handle failures, and to maintain a healthcheck status in case human intervention is needed.
|
||||
|
||||
```bash
|
||||
btrfs subvolume snapshot /btrfs-root /snapshot
|
||||
cd "/snapshot/btrfs-root${BACKUP_RELATIVE_PATH:-}"
|
||||
borg create ... ::"{hostname}-{now:%Y-%m-%dT%H:%M:%S}" .
|
||||
```
|
||||
|
||||
The snapshot lives only for the duration of the borg run. A `trap cleanup EXIT` deletes the subvolume whether the backup succeeded, failed, or was killed. The next run snapshots fresh.
|
||||
|
||||
This shifts the entire correctness argument from "did I quiesce every database in time" to "does BTRFS give me a consistent snapshot." It does. That's why everything below it can be a shell script.
|
||||
|
||||
## Multi-target as numeric env vars
|
||||
|
||||
The 3-2-1 backup rule wants three copies, two media, one offsite. My answer is a remote (rsync.net) and a local HDD, both fed from the same snapshot. The wire format for "multiple targets" is just numbered env vars:
|
||||
|
||||
```sh
|
||||
BORG_PASSPHRASE_0=...
|
||||
BORG_REMOTE_PATH_0=borg1
|
||||
BORG_REPO_0=username@username.rsync.net:~/backup
|
||||
|
||||
BORG_PASSPHRASE_1=...
|
||||
BORG_REPO_1=/local-backup
|
||||
```
|
||||
|
||||
`backup-wrapper.sh` loops `index=0` upward, exports `BORG_PASSPHRASE` / `BORG_REPO` / `BORG_REMOTE_PATH` from the indexed copies, runs `backup.sh`, unsets them, increments. Stops the first time the next index has no passphrase.
|
||||
|
||||
There's also a no-index fallback (`BORG_REPO=...` with no number) for the single-target case. Same script, no extra config plane.
|
||||
|
||||
I keep coming back to this pattern for small-system orchestration. The env file _is_ the data structure. There's no YAML parsing, no JSON schema, no config-validation layer between you and the variable that actually matters.
|
||||
|
||||
## The scheduler is a sleep, not cron
|
||||
|
||||
```bash
|
||||
while true; do
|
||||
/src/backup-wrapper.sh 2>&1 | log_message
|
||||
sleep "$SLEEP_TIME"
|
||||
done
|
||||
```
|
||||
|
||||
A comment in the file says it out loud: "Using a simple sleep loop to schedule backups instead of cron to avoid concurrency issues." Cron with a one-hour cadence and a backup that occasionally takes 70 minutes will eventually overlap itself, and two borg processes writing the same repo is a bad afternoon. The sleep loop can't overlap: the next run starts when the previous one finishes, plus the interval. One process, one snapshot, one borg invocation, and a whole category of bug that has nowhere to live.
|
||||
|
||||
## Healthcheck is a file mtime
|
||||
|
||||
`borg create` succeeded? Write `date > /health/backup_completion_time.log`. The Docker healthcheck shells out every 10 seconds and compares that mtime against `MAX_BACKUP_AGE_SECONDS` (default 86400). Older than that, container is unhealthy and whatever's watching containers (in my case a notification hook) finds out.
|
||||
|
||||
Two subtleties hide in there:
|
||||
|
||||
- **First-boot grace period.** If `backup_completion_time.log` doesn't exist yet (fresh container, first backup still running), fall back to `container_start_time.log` so the container isn't reported unhealthy during the first scheduled run.
|
||||
- **Partial success is not success.** In multi-target mode, the completion log is only written if _every_ target succeeded. One repo failing means the healthcheck stays red even if the other two are fine. Stale-but-quiet was the failure mode I wanted to make impossible.
|
||||
|
||||
## Smaller calls
|
||||
|
||||
- **`borg break-lock` at the start of every run.** If the previous container was killed mid-backup, the repo is locked and the next `borg create` will hang. Just break it. There's only ever one writer because of the sleep loop.
|
||||
- **`set -e` after `borg init`, not before.** The init line is the only one allowed to fail (first run on a fresh repo). Everything after halts on error.
|
||||
- **`BORG_RSH='ssh -oBatchMode=yes'`.** Fail fast if SSH would have prompted, instead of hanging forever inside a detached container.
|
||||
- **`ServerAliveInterval 30` in `ssh_config`.** Long borg transfers across home-ISP NAT get killed if nothing flows for a few minutes. Keepalives keep the tunnel open.
|
||||
- **`--files-cache=ctime,size,inode`.** The default `mtime,size,inode` re-hashes files when their mtime changes; on BTRFS, ctime is the more honest signal of "this content actually changed."
|
||||
- **`compression=zstd,12`.** The sweet spot for backup data on my hardware: substantially better than zlib, not so slow it dominates the run.
|
||||
- **`borg compact --threshold=5 --cleanup-commits`.** Reclaims space from pruned archives whenever the segment-file fragmentation crosses 5%.
|
||||
- **Retention: 6 daily, 3 weekly, 48 monthly, 10 yearly.** Four years of monthly archives sounds extravagant until the question becomes "when exactly did this file change?", which is the question every incident eventually asks.
|
||||
- **`IGNORE_GIT_UNTRACKED=true`.** Optional. Walks every `.git` dir under the snapshot, runs `git ls-files --others --exclude-standard`, and feeds the result into `--exclude-from`. Skips `target/`, `node_modules/`, build caches; anything the repo already knows isn't worth keeping.
|
||||
- **`SYS_ADMIN` capability on the container.** Needed for `btrfs subvolume snapshot` and `delete` from inside the namespace. The narrower capability set didn't have a way through.
|
||||
|
||||
## What I'd change
|
||||
|
||||
- **A test rig that restores into an empty volume on a schedule.** "Backups exist" is not the property I care about. "Backups restore" is. I have anecdotal evidence after every incident; I don't have a green checkmark before one.
|
||||
- **A failure notifier separate from the healthcheck.** Docker healthcheck-unhealthy is one signal; I'd also want an explicit push (ntfy, email, Telegram) on first failure of a run, so I don't have to be watching the container state.
|
||||
- **Parallel targets when network and disk don't compete.** The current loop is strictly sequential: rsync.net then local HDD. They share neither bandwidth nor spindles; they could run in parallel and halve the wall-clock. Sequential made the wrapper trivial; the trade was knowable and I made it.
|
||||
|
||||
Two years in, the part I'd defend hardest is still the snapshot. Everything above it is a wrapper anyone could rewrite in an afternoon, and that's not an apology for the wrapper, it's the property I now optimise for on purpose.
|
||||
I've been using this for 4 years now and I restored various files and folders and did two full disaster recoveries. These were enough to convince me of the soundness of borg, btrfs, and my backup configuration. That's why I don't think I will migrate away from this method any time soon.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ thumbnail:
|
|||
article:
|
||||
tags: ['simulation', 'systems']
|
||||
stack: ['Unity', 'C#', 'REST API', 'Blender']
|
||||
outcome: Visible consequences for an otherwise abstract PLC challenge
|
||||
project:
|
||||
title: City Simulation
|
||||
---
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ thumbnail:
|
|||
alt: The decla.red browser game interface showing a space scene.
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/decla.red
|
||||
url: https://git.schmelczer.dev/andras/decla.red
|
||||
- label: BSc thesis
|
||||
url: /media/downloads/sdf2d-andras-schmelczer.pdf
|
||||
download: true
|
||||
|
|
@ -16,11 +16,9 @@ article:
|
|||
tags: ['games', 'web', 'systems']
|
||||
stack: ['TypeScript', 'Node.js', 'WebSockets', 'WebGL', 'SDF-2D']
|
||||
scale: Game servers ticking at 200 Hz, each serving 16–32 clients at 25 updates a second, browser and mobile
|
||||
outcome: A multiplayer browser game that proved SDF-2D survived a real game loop
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/decla-red.jpg
|
||||
alt: The decla.red browser game interface showing a space scene with team controls and planets.
|
||||
caption: Two teams, small planets, real gravity. The renderer underneath is the SDF-2D library from my thesis.
|
||||
project:
|
||||
title: decla.red
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ links:
|
|||
article:
|
||||
tags: ['web', 'tools']
|
||||
stack: ['jQuery', 'vanilla HTML/CSS', 'Node/Express', 'JSON', 'localStorage']
|
||||
outcome: A free practice app real students still find when they search for past érettségi physics papers
|
||||
project:
|
||||
title: Fizika
|
||||
technologies: ['jQuery', 'HTML/CSS', 'Node/Express', 'JSON', 'localStorage']
|
||||
|
|
|
|||
|
|
@ -16,11 +16,9 @@ article:
|
|||
tags: ['graphics', 'simulation', 'web']
|
||||
stack: ['TypeScript', 'WebGPU', 'WGSL', 'Compute shaders', 'Vite', 'Tweakpane']
|
||||
scale: One HTML file, 10 WGSL shaders, 6 vibe presets, up to 1.5M agents, 60 FPS target on consumer hardware
|
||||
outcome: A browser drawing toy where user strokes seed an agent simulation that overwrites them
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/fleeting-garden.jpg
|
||||
alt: Close-up of intertwining cyan, violet, and yellow agent trails radiating into a kaleidoscopic central knot.
|
||||
caption: A snapshot from one session. What you see is the trail texture; the agents that drew it are already gone.
|
||||
project:
|
||||
title: Fleeting Garden
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
---
|
||||
title: Predicting EUR/USD With Hanning Windows
|
||||
description: A weekend frequency-domain experiment that did a passable job on EUR/USD. I would not have trusted it with my money, and I didn't.
|
||||
date: 2026-05-03
|
||||
period: 'Autumn 2019'
|
||||
thumbnail:
|
||||
src: ./_assets/forex.jpg
|
||||
alt: Chart comparing predicted and actual EUR/USD exchange rates.
|
||||
article:
|
||||
tags: ['systems', 'tools']
|
||||
stack: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
|
||||
outcome: A prediction server, an MQL4 trading client, and a clearer view of how far my edge wasn't
|
||||
project:
|
||||
title: Foreign Exchange Prediction Experiment
|
||||
---
|
||||
|
||||
In the autumn of 2019 I was an undergrad with a few free weekends and the quiet conviction that I could find a small edge on EUR/USD. The screenshots that survive are flattering: the predicted rate in blue hugging the actual rate in green closely enough to look like skill. It was a linear extrapolation in the frequency domain wearing a nice coat.
|
||||
|
||||
The pipeline: smooth the price series, differentiate it, run a short-time Fourier transform over overlapped Hanning-windowed frames, extrapolate the frequency-domain coefficients forward, then invert everything back into a predicted price. A Python server (NumPy, SciPy, Flask) served the model; an MQL4 client sitting in a broker terminal called it and stood ready to place trades, if I'd dared. I never dared, and that restraint is the part of the project that aged best.
|
||||
|
||||
What the weekends actually bought me was a working understanding of the trap: even a naive model will hand you a sometimes-profitable backtest, and a sometimes-profitable backtest is the most persuasive wrong evidence there is. The people playing this game for real have co-located servers, microsecond ticks, and teams whose whole job is the thing I was doing between lectures. I didn't learn how to predict currencies. I learned, precisely and cheaply, how far my edge wasn't, which I've come to think is the best possible return on a project like this.
|
||||
|
|
@ -22,11 +22,9 @@ article:
|
|||
'Atkinson dither',
|
||||
]
|
||||
scale: One panel, one household, ~64 refreshes a day at peak
|
||||
outcome: A wall-mounted photo frame that pulls from self-hosted Immich, gated on home presence, with no cloud dependencies
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/frame.jpg
|
||||
alt: The frame on the wall showing a 6-colour Atkinson-dithered landscape scene, with "2 years ago" and a location label painted into the bottom corners.
|
||||
caption: The bottom corners carry the photo's age and EXIF location. Painted as text on top, so the dither can't smear them.
|
||||
project:
|
||||
title: Frame
|
||||
|
|
|
|||
|
|
@ -19,11 +19,9 @@ article:
|
|||
tags: ['ai', 'systems', 'tools']
|
||||
stack: ['Python', 'decorators', 'FastAPI', 'survey design']
|
||||
scale: 33 deployment habits surveyed, 6 proposed additions, framework evaluated by working data scientists and engineers
|
||||
outcome: A pip-installable framework, an MSc thesis, and one strong opinion about API surface area
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/great-ai.png
|
||||
alt: Example Python code using GreatAI decorators and prediction helpers.
|
||||
caption: A working GreatAI service is about ten lines on top of a plain prediction function.
|
||||
project:
|
||||
title: GreatAI
|
||||
|
|
|
|||
|
|
@ -8,17 +8,15 @@ thumbnail:
|
|||
alt: Life Towers goal tracking interface with tower-like visual structures.
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/life-towers/
|
||||
url: https://git.schmelczer.dev/andras/life-towers/
|
||||
article:
|
||||
featuredOrder: 4
|
||||
tags: ['systems', 'web', 'tools']
|
||||
stack: ['Python', 'Angular', 'TypeScript', 'FastAPI', 'SQLite']
|
||||
scale: Multi-device goal and task state shared between clients and a server
|
||||
outcome: A goal tracker still in use, and a lesson about when clever sync isn't worth it
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/towers.jpg
|
||||
alt: Screenshot of a life tracking web interface represented with tower-like visual structures.
|
||||
caption: Towers of finished tasks, one column per goal. Done blocks fall into place with a small gravity animation.
|
||||
project:
|
||||
title: Life Towers
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ thumbnail:
|
|||
article:
|
||||
tags: ['systems', 'tools']
|
||||
stack: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'MOSFETs', 'vanilla web']
|
||||
outcome: The first non-trivial project I started and finished
|
||||
project:
|
||||
title: Lights Synchronized to Music
|
||||
---
|
||||
|
|
|
|||
|
|
@ -11,15 +11,12 @@ article:
|
|||
tags: ['simulation', 'systems', 'tools']
|
||||
stack: ['Python', 'Flask', 'NumPy', 'HTML canvas', 'JavaFX']
|
||||
scale: One remote sim server, many monitoring clients, separate JavaFX graph editor
|
||||
outcome: A believable PLC simulation usable by non-specialists during a live cybersecurity challenge
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/process-simulator.jpg
|
||||
alt: Screenshot of the cooling system simulator with pipes, pumps, coolers, and temperature values.
|
||||
caption: Flow ran first as a graph traversal, then heat solved as a matrix equation.
|
||||
- type: image
|
||||
src: ./_assets/process-simulator-input.jpg
|
||||
alt: Screenshot of the JavaFX graph editor used to define simulator input.
|
||||
caption: The JavaFX editor produced JSON that the simulator ate as input.
|
||||
project:
|
||||
title: Cooling System Simulation
|
||||
|
|
|
|||
|
|
@ -26,11 +26,9 @@ article:
|
|||
'Gemini',
|
||||
]
|
||||
scale: ~25M historical properties, ~2.5M postcodes, ~150 numeric features per row, all in RAM on a single VM
|
||||
outcome: A single-binary UK property-intelligence service with sub-100ms hexagon aggregations under filter
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/perfect-postcode.jpg
|
||||
alt: A Perfect Postcode dashboard view of Manchester with five active filters (property type, price, public-transport time to Manchester city centre, crime, noise) and a hex heatmap of 1,247 matching properties.
|
||||
caption: A normal user pan triggers a hexagon aggregation under filter. The hot path holds itself to three integer compares per row.
|
||||
project:
|
||||
title: Perfect Postcode
|
||||
|
|
|
|||
|
|
@ -1,21 +1,16 @@
|
|||
---
|
||||
title: A Photo Site That Generated Itself From a Folder
|
||||
description: 'A folder of photos becomes a static site: five widths, three formats, hashed filenames. Started as a Webpack script in 2016; mostly an excuse to walk.'
|
||||
description: 'Simple slideshow of my photos'
|
||||
date: 2026-04-27
|
||||
period: '2016 onwards'
|
||||
thumbnail:
|
||||
src: ./_assets/photos.jpg
|
||||
alt: Screenshot of a generated photography site.
|
||||
article:
|
||||
tags: ['web', 'tools']
|
||||
stack: ['Webpack', 'Vite', 'TypeScript', 'Image processing']
|
||||
outcome: A photography site that updates itself when I drop new images into a folder
|
||||
project:
|
||||
title: Photo Site Generator
|
||||
---
|
||||
|
||||
I take walks with a camera. Most of what I shoot isn't good, but walking slowly with a frame to think about is the most reliable way I know to come back with an idea for whatever I'm actually working on. In the summer of 2016 I wanted somewhere to put the few frames that survived, and I wasn't going to run a CMS for it. So: a Webpack script. Point it at a directory of full-size photos, get a static site with responsive variants of each. Drop a photo in, build, deploy.
|
||||
I bought my first camera, a proper DSLR (a Nikon D3200) from my first summer job's salary at 15. I've been taking photos of everything that captures my imagination, or sparks aesthetic joy in me ever since. I wanted to have a simple but personal way of sharing these photos while avoiding going through social media, so this was the perfect excuse to build a photo portfolio website.
|
||||
|
||||
The site still exists and still works exactly that way, though almost none of the 2016 code survives. The current build renders every photo into five widths and three formats (AVIF, WebP, and a JPEG fallback), strips the EXIF, and bakes a content hash into each filename so a change to the encoder settings can never serve anyone a stale image (a lesson from the first iteration, learned the usual way). A catalogue file and the photo folder have to agree exactly, in both directions, or the build refuses to run. It's a lot of pipeline for a site whose whole job is showing pictures without a framework attached, but the pipeline is what keeps the habit cheap: photograph, drop in the folder, push.
|
||||
It started off with PHP running on a Raspberry PI in my room but, to keep maintenance simple, has since evolved into a simple Vite script generating a static HTML and variously sized photos from a folder.
|
||||
|
||||
The same walks later produced a [colour grader](/articles/photo-colour-grader/) for the same shots.
|
||||
The only rule for good tech is it has to give me more value than the effort it takes to use it. Given that part of photography I enjoy is taking photos, the website gives me little value, thus, committing a JPEG into a Git repo and pushing it is the right amount of effort to expect of me. It's still one more step than what I had to do with PHP version but I have taste in tech too, not just photography.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ thumbnail:
|
|||
article:
|
||||
tags: ['games', 'systems']
|
||||
stack: ['C', 'SDL 1.2', 'Voxel terrain']
|
||||
outcome: A playable course project, and the moment programming clicked
|
||||
project:
|
||||
title: Platform Game
|
||||
---
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ links:
|
|||
- label: Demo
|
||||
url: /reconcile/
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/reconcile
|
||||
url: https://git.schmelczer.dev/andras/reconcile
|
||||
- label: crates.io
|
||||
url: https://crates.io/crates/reconcile-text
|
||||
- label: npm
|
||||
|
|
@ -22,11 +22,9 @@ article:
|
|||
tags: ['systems', 'tools', 'web']
|
||||
stack: ['Rust', 'WebAssembly', 'Python', 'pyo3', 'wasm-bindgen']
|
||||
scale: One Rust core, three published packages (crates.io, npm, PyPI), driving an Obsidian sync plugin
|
||||
outcome: A small Rust library that auto-resolves prose conflicts, with WASM and Python bindings
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/reconcile.png
|
||||
alt: The reconcile-text logo, a stylised merge arrow, with the tagline "Conflict-free 3-way text merging".
|
||||
caption: reconcile-text weaves conflicting edits together instead of asking a human to choose.
|
||||
project:
|
||||
title: reconcile-text
|
||||
|
|
|
|||
|
|
@ -26,11 +26,9 @@ article:
|
|||
'Dynamic shader generation',
|
||||
]
|
||||
scale: Browser library, mobile-targeted, real-time on consumer GPUs, both WebGL1 and WebGL2 paths
|
||||
outcome: An NPM package and BSc thesis; the renderer behind the decla.red multiplayer game
|
||||
media:
|
||||
- type: image
|
||||
src: ./_assets/sdf2d.jpg
|
||||
alt: Browser demo page showing SDF-2D scenes rendered with soft lighting effects.
|
||||
caption: SDF-2D shipped as a TypeScript library, not a one-shot demo. That distinction shaped most of the design.
|
||||
project:
|
||||
title: SDF-2D
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ thumbnail:
|
|||
alt: 'The VaultLink logo: a chain-link mark in a soft gradient.'
|
||||
links:
|
||||
- label: Source
|
||||
url: https://github.com/schmelczer/vault-link
|
||||
url: https://git.schmelczer.dev/andras/vault-link
|
||||
- label: Docs
|
||||
url: https://vault-link.schmelczer.dev
|
||||
article:
|
||||
|
|
@ -27,7 +27,6 @@ article:
|
|||
'reconcile-text',
|
||||
]
|
||||
scale: One Rust server, one TypeScript sync engine, three published consumers (Obsidian plugin, CLI, fuzz/deterministic test harnesses)
|
||||
outcome: A self-hosted Obsidian sync I trust enough to use as my primary vault transport
|
||||
project:
|
||||
title: VaultLink
|
||||
selected: true
|
||||
|
|
|
|||
|
|
@ -140,7 +140,6 @@ const personJsonLd = buildPersonJsonLd();
|
|||
projectPeriod={post.data.period}
|
||||
stack={post.data.article?.stack}
|
||||
scale={post.data.article?.scale}
|
||||
outcome={post.data.article?.outcome}
|
||||
links={post.data.links}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const site = {
|
|||
'Notebook of someone who keeps reaching for the same two moves: let the hard constraint pick the data structure, then keep the API small enough to defend.',
|
||||
url: 'https://schmelczer.dev',
|
||||
email: 'andras@schmelczer.dev',
|
||||
github: 'https://github.com/schmelczer',
|
||||
git: 'https://git.schmelczer.dev/andras',
|
||||
linkedin: 'https://www.linkedin.com/in/andras-schmelczer',
|
||||
cv: '/media/downloads/cv-andras-schmelczer.pdf',
|
||||
};
|
||||
|
|
@ -211,7 +211,7 @@ export function buildPersonJsonLd(extra?: Record<string, unknown>) {
|
|||
name: site.name,
|
||||
url: site.url,
|
||||
email: `mailto:${site.email}`,
|
||||
sameAs: [site.github, site.linkedin],
|
||||
sameAs: [site.git, site.linkedin],
|
||||
description: site.description,
|
||||
...extra,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ const personJsonLd = buildPersonJsonLd({
|
|||
<dt>Elsewhere</dt>
|
||||
<dd class="about-links">
|
||||
<a href={site.cv} rel="noopener">CV</a>
|
||||
<a href={site.github} rel="noopener me">GitHub</a>
|
||||
<a href={site.git} rel="noopener me">Git</a>
|
||||
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
|
||||
</dd>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,12 +19,14 @@ const personJsonLd = buildPersonJsonLd();
|
|||
|
||||
<Base jsonLd={personJsonLd}>
|
||||
<section class="home-intro">
|
||||
<p class="eyebrow">Engineering notes</p>
|
||||
<h1>
|
||||
<span class="home-intro-name">Andras Schmelczer</span> sharing cool projects and lessons learned
|
||||
<span class="home-intro-name">Andras Schmelczer</span> sharing cool projects and lessons
|
||||
learned
|
||||
</h1>
|
||||
<p>
|
||||
I enjoy learning by building exciting things that sometimes even turn out to be useful. This page is dedicated to the projects I actually finished. To learn more about me, check out the <a href="/about/">About page</a>.
|
||||
I enjoy learning by building exciting things that sometimes even turn out to be
|
||||
useful. This page is dedicated to the projects I actually finished. To learn more
|
||||
about me, check out the <a href="/about/">About page</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
37
todo.md
Normal file
37
todo.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
- Update vault-link/fleeting-notes cover
|
||||
- remove "Engineering notes"
|
||||
- new og photo
|
||||
- download cv from forgejo
|
||||
- copy reconcile text and frame text
|
||||
|
||||
|
||||
- css is crazy long
|
||||
- ban github links
|
||||
- enforce cspell in CI
|
||||
- astro extension, cspell extension
|
||||
- don't duplicate description
|
||||
- remove role & audience fields from articles
|
||||
- no vtt caption
|
||||
- link avoid and repo
|
||||
- should the photo generator be an article
|
||||
- double check BTU
|
||||
- remove outcome field
|
||||
- no caption and alt text at the same time
|
||||
- merge graph-editor-javafx-simulation-input.md and nuclear-cooling-simulation.md into one
|
||||
- Look at src/content/work/ad-astra-attiny85-game-engine.md and fix typos, phrasing, and pacing. Don't change much of what I wrote but make it more interesting to read, explain the vocabulary and make it something postable on hacker news.
|
||||
|
||||
|
||||
|
||||
website to take a link or copypasted text, extract all text, format it into markdown (use gemini flash), send to reader and render into TTS
|
||||
|
||||
|
||||
- portable ac
|
||||
- printer/scanner
|
||||
- bike
|
||||
- books
|
||||
|
||||
|
||||
Impressionint water-colour painting of an Autumn forest with trees reaching the sky. The trees are sparsely covered in orange and brown leaves with a few branches exposed bare. The sky is grey and barely visible between the straight towering trees. There's a lightly travelled path curving across the scene with 2 people holding hands in the distance. You can't make out much detail from the little dark people who are barely visible. There are leaves mid-fall. The scene is painted from a low position looking up.
|
||||
|
||||
Recommend me audiobooks. I've really enjoyed listening to the 3-body problem trilogy, Project Hail Mary, the 1st Foundation book, hitchikers guide to the galaxy. What similar titles are there?
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue