From 9ab322751a732d8cbc1ddf4f2ecf5022d7242baa Mon Sep 17 00:00:00 2001 From: Marijn Besseling Date: Sun, 7 Sep 2025 20:56:09 +0200 Subject: WIP migration --- Blog/Components/Pages/Calc.razor | 56 +++++++ Blog/Components/Pages/Calc.razor.js | 140 ++++++++++++++++ Blog/Components/Pages/Concat.razor | 138 +++++++++++++++ Blog/Components/Pages/Concat.razor.js | 261 +++++++++++++++++++++++++++++ Blog/Components/Pages/Error.razor | 37 ++++ Blog/Components/Pages/Home.razor | 10 ++ Blog/Components/Pages/Letterflixd.razor | 30 ++++ Blog/Components/Pages/Letterflixd.razor.js | 99 +++++++++++ Blog/Components/Pages/NotFound.razor | 4 + Blog/Components/Pages/Note.razor | 8 + Blog/Components/Pages/Note.razor.js | 33 ++++ 11 files changed, 816 insertions(+) create mode 100644 Blog/Components/Pages/Calc.razor create mode 100644 Blog/Components/Pages/Calc.razor.js create mode 100644 Blog/Components/Pages/Concat.razor create mode 100644 Blog/Components/Pages/Concat.razor.js create mode 100644 Blog/Components/Pages/Error.razor create mode 100644 Blog/Components/Pages/Home.razor create mode 100644 Blog/Components/Pages/Letterflixd.razor create mode 100644 Blog/Components/Pages/Letterflixd.razor.js create mode 100644 Blog/Components/Pages/NotFound.razor create mode 100644 Blog/Components/Pages/Note.razor create mode 100644 Blog/Components/Pages/Note.razor.js (limited to 'Blog/Components/Pages') diff --git a/Blog/Components/Pages/Calc.razor b/Blog/Components/Pages/Calc.razor new file mode 100644 index 0000000..942d9a9 --- /dev/null +++ b/Blog/Components/Pages/Calc.razor @@ -0,0 +1,56 @@ +@page "/Calc" +Calculator + + +
+

A rpn calculator

+
+ + +
+
+
+
+ +
+ Available stack operations +
+ dup +

Duplicates the top value on the stack

+
+ [1, 2, 3] + dup +
+
+ [1, 1, 2, 3] + +
+
+ +
+ drop +

Drops the top value on the stack

+
+ [1, 2, 3] + drop +
+
+ [2, 3] + +
+
+ +
+ swap +

Swaps the top two values on the stack

+
+ [1, 2, 3] + swap +
+
+ [2, 1, 3] + +
+
+
+
\ No newline at end of file diff --git a/Blog/Components/Pages/Calc.razor.js b/Blog/Components/Pages/Calc.razor.js new file mode 100644 index 0000000..d18634e --- /dev/null +++ b/Blog/Components/Pages/Calc.razor.js @@ -0,0 +1,140 @@ +import { getById, div, span, h, writeError } from "/common.module.js" + +export function onLoad() { + console.log('Loaded'); + const form = getById("form"); + const input = getById("input"); + const log = getById("log"); + + form.addEventListener("submit", submitForm); + + const urlParams = new URLSearchParams(window.location.search); + const queryInput = urlParams.get('in'); + + if (input.value.length === 0) { + input.value = queryInput; + } +} + +export function onUpdate() { + console.log('Updated'); +} + +export function onDispose() { + console.log('Disposed'); +} + +/** @param {SubmitEvent} event */ +function submitForm(event) { + console.log(input.value); + resetLog(); + try { + const stack = evalString(input.value); + console.log(stack); + + const path = window.location.pathname; + const params = new URLSearchParams(window.location.search); + const hash = window.location.hash; + + params.set("in", input.value); + window.history.replaceState({}, '', `${path}?${params.toString()}${hash}`); + } + catch (error) { + writeError(error); + } + event.preventDefault(); +} + +/** @param {string} input */ +function evalString(input) { + let words = input.trim().split(' ').filter(i => i); + return evalWords([], words); +} + +function effect2(f) { + return (stack) => { + if (stack.length <= 1) throw "stack underflow"; + let [x, y, ...rest] = stack; + return [f(y, x), ...rest]; + } +} + +const plus = effect2((a, b) => a + b); +const subtract = effect2((a, b) => a - b); +const multiply = effect2((a, b) => a * b); +const divide = effect2((a, b) => a / b); + + +function evalWords(stack, words) { + writeLog(stack, words); + if (words.length === 0) return stack; + + let [word, ...rest] = words; + return evalWords(evalWord(word, stack), rest); +} + +function evalWord(word, stack, rest) { + switch (word) { + case "+": return plus(stack); + case "-": return subtract(stack); + case "*": return multiply(stack); + case "/": return divide(stack); + case "dup": return dup(stack); + case "drop": return drop(stack); + case "swap": return swap(stack); + default: return parse(word, stack); + } +} + +function dup(stack) { + let [x, ...rest] = stack; + return [x, x, ...rest]; +} + +function drop(stack) { + let [_, ...rest] = stack; + return rest; +} + +function swap(stack) { + let [x, y, ...rest] = stack; + return [y, x, ...rest]; +} + +function parse(word, stack) { + let num = Number(word); + if (isNaN(num)) { + throw `word '${word}' not recognised`; + } + return [num, ...stack]; +} + +function writeLog(stack, words) { + let log_left = span(`[${stack.join(', ')}]`); + let log_right = span(words.join(' ')); + + if (words.length === 0) { + log_left.textContent += " <==" + } + + let log_row = div(log_left, log_right); + log_row.className = "flex-spread"; + + log.appendChild(log_row); +} + +function resetLog() { + if (!log.hasChildNodes()) return; + + let summary = h("summary"); + summary.textContent = log.firstChild.lastChild.textContent; + + let old_log = log.cloneNode(true); + old_log.id = ''; + + let details = h("details", summary, old_log); + details.className = "history" + + log.insertAdjacentElement("afterend", details); + log.replaceChildren(); //remove children, clear log +} \ No newline at end of file diff --git a/Blog/Components/Pages/Concat.razor b/Blog/Components/Pages/Concat.razor new file mode 100644 index 0000000..856d4e0 --- /dev/null +++ b/Blog/Components/Pages/Concat.razor @@ -0,0 +1,138 @@ +@page "/Concat" +Concatenator + +
+

A "concatenative language"

+
+ + +
+
+
+
+ +
+ Predefined operations +
+ dup +

Duplicates the top value on the stack

+
+ [1, 2, 3] + dup +
+
+ [1, 1, 2, 3] + +
+
+ +
+ drop +

Drops the top value on the stack

+
+ [1, 2, 3] + drop +
+
+ [2, 3] + +
+
+ +
+ swap +

Swaps the top two values on the stack

+
+ [1, 2, 3] + swap +
+
+ [2, 1, 3] + +
+
+ +
+ skip +

Skips forward in the program by the amount on the top of the stack. + If the value on the top of the stack is 0 or negative, + the value is dropped and the program does not skip ahead. +

+
+ [1, 2, 3] + skip drop dup +
+
+ [2, 3] + dup +
+
+ [2, 2, 3] + +
+ Or +
+ [-1, 2, 3] + skip drop dup +
+
+ [2, 3] + drop dup +
+
+ [3] + dup +
+
+ [3, 3] + +
+
+ +
+ : word ... ; +

Creates a new definition.

+

Example: ": double 2 * ;" defines a new word called double. Any subsequent mention of double will + replace the word with its definition.

+
+ [] + : double 2 * ; 3 double +
+
+ [] + 3 double +
+
+ [3] + double +
+
+ [3] + 2 * +
+
+ [2, 3] + * +
+
+ [6] <== + +
+
+
+
+ Example programs +
+ Factorial + + Replace program + +
+                : counter   dup 2 swap - ;
+                : !   dup 1 - counter skip ! * ;
+                7 !
+            
+
+
+
\ No newline at end of file diff --git a/Blog/Components/Pages/Concat.razor.js b/Blog/Components/Pages/Concat.razor.js new file mode 100644 index 0000000..bd476e9 --- /dev/null +++ b/Blog/Components/Pages/Concat.razor.js @@ -0,0 +1,261 @@ +import { getById, div, span, h, writeError } from "/common.module.js"; +import lzString from "/lz-string.module.js"; + +let definitions = {}; +let log = undefined; +let input = undefined; + +export function onLoad() { + console.log('Loaded'); + const form = getById("form"); + input = getById("input"); + log = getById("log"); + + form.onsubmit = async (event) => { + event.preventDefault(); + await submitForm(); + }; + + const urlParams = new URLSearchParams(window.location.search); + const queryInput = urlParams.get("in"); + + if (input.value.length === 0) { + input.value = lzString.decompressFromEncodedURIComponent(queryInput); + } +} + +export function onUpdate() { + const urlParams = new URLSearchParams(window.location.search); + const queryInput = urlParams.get("in"); + + if (input.value.length === 0) { + input.value = lzString.decompressFromEncodedURIComponent(queryInput); + } +} + +async function submitForm() { + console.log(input.value); + resetLog(); + try { + definitions = {}; + const stack = await evalString(input.value); + console.log(stack); + + const path = window.location.pathname; + const params = new URLSearchParams(window.location.search); + const hash = window.location.hash; + + params.set("in", lzString.compressToEncodedURIComponent(input.value)); + window.history.replaceState( + {}, + "", + `${path}?${params.toString()}${hash}`, + ); + } catch (error) { + writeError(error); + console.log(error); + } +} + +function splitWords(input) { + return input + .trim() + .split(/\s+/) + .filter((i) => i); +} + +/** @param {string} inputString */ +function evalString(inputString) { + let words = splitWords(inputString); + return evalWords(words); +} + +function effect2(f) { + return (stack) => { + if (stack.length <= 1) throw "stack underflow, need 2 numbers"; + let [x, y, ...rest] = stack; + return [f(y, x), ...rest]; + }; +} + +const plus = effect2((a, b) => a + b); +const subtract = effect2((a, b) => a - b); +const multiply = effect2((a, b) => a * b); +const divide = effect2((a, b) => a / b); + +async function evalWords(inputWords) { + let words = inputWords; + let stack = []; + + while (words.length > 0) { + await writeLog(stack, words); + if (words.length === 0) return stack; + + let [word, ...rest] = words; + [stack, words] = evalWord(word, stack, rest); + + await new Promise((r) => setTimeout(r, 100)); + } + await writeLog(stack, words); + return stack; +} + +function evalWord(word, stack, rest) { + switch (word) { + case "+": + return [plus(stack), rest]; + case "-": + return [subtract(stack), rest]; + case "*": + return [multiply(stack), rest]; + case "/": + return [divide(stack), rest]; + case "dup": + return [dup(stack), rest]; + case "drop": + return [drop(stack), rest]; + case "swap": + return [swap(stack), rest]; + case "skip": + return skip(stack, rest); + case ",,": + return unquote(stack, rest); + case ":": + return [stack, define(rest)]; + default: + return parse(word, stack, rest); + } +} + +function unquote(stack, rest) { + let [quote, ...restStack] = stack; + if (typeof quote === "string" || quote instanceof String) { + return [restStack, [...splitWords(quote), ...rest]]; + } else { + throw "not a string, only strings are unquoteable"; + } +} + +function skip(stack, words) { + if (stack.length === 0) throw "stack underflow, dont know how much to skip"; + let [amount, ...restStack] = stack; + + if (amount > words.length) + throw `program underflow, cant skip ${amount} words`; + if (amount <= 0) return [stack.slice(1), words]; // no skipping on <= 0 + + let restWords = words.slice(amount); + + return [restStack, restWords]; +} + +function define(words) { + if (words.length < 2) throw "missing definition after ':'"; + let [ident, ...rest] = words; + let index = 0; + let definition = []; + + for (; index < rest.length; index++) { + const word = rest[index]; + if (word === ";") { + break; + } else if (rest.length - 1 === index) { + throw "expected ';', found end of program"; + } + definition.push(word); + } + + definitions[ident] = definition; + + return rest.slice(index + 1); +} + +function dup(stack) { + if (stack.length === 0) throw "stack underflow, nothing to duplicate"; + let [x, ...rest] = stack; + return [x, x, ...rest]; +} + +function drop(stack) { + if (stack.length === 0) throw "stack underflow, nothing to drop"; + let [_, ...rest] = stack; + return rest; +} + +function swap(stack) { + if (stack.length < 2) throw "stack underflow, not enough to swap"; + let [x, y, ...rest] = stack; + return [y, x, ...rest]; +} + +function parse(word, stack, rest) { + if (word.startsWith('"')) { + return parseString(word, stack, rest); + } + + if (word in definitions) { + return [stack, [...definitions[word], ...rest]]; + } + + let num = Number(word); + if (isNaN(num)) { + throw `word '${word}' not recognised`; + } + + return [[num, ...stack], rest]; +} + +function parseString(word, stack, rest) { + if (word.length > 1 && word.endsWith('"')) { + return [[word.slice(1, -1), ...stack], rest]; + } + + let string = word.slice(1); + let index = 0; + + for (; index < rest.length; index++) { + const word = rest[index]; + if (word.endsWith('"')) { + string = string.concat(" ", word.slice(0, -1)); + break; + } else if (rest.length - 1 === index) { + throw "expected word ending with '\"', found end of program"; + } + string = string.concat(" ", word); + } + + return [[string, ...stack], rest.slice(index + 1)]; +} + +function writeLog(stack, words) { + return new Promise((resolve, _reject) => { + let log_left = span(`[${stack.join(", ")}]`); + let log_right = span(words.join(" ")); + + if (words.length === 0) { + log_left.textContent += " <=="; + } + + let log_row = div(log_left, log_right); + log_row.className = "flex-spread"; + + log.appendChild(log_row); + resolve(); + }); +} + +function resetLog() { + if (!log.hasChildNodes()) return; + + let summary = h("summary"); + summary.textContent = log.firstChild.lastChild.textContent; + + let old_log = log.cloneNode(true); + old_log.id = ""; + + let details = h("details", summary, old_log); + details.className = "history"; + + log.insertAdjacentElement("afterend", details); + log.replaceChildren(); //remove children, clear log +} diff --git a/Blog/Components/Pages/Error.razor b/Blog/Components/Pages/Error.razor new file mode 100644 index 0000000..6567978 --- /dev/null +++ b/Blog/Components/Pages/Error.razor @@ -0,0 +1,37 @@ +@page "/Error" +@using System.Diagnostics +@using Microsoft.AspNetCore.Http + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; + +} \ No newline at end of file diff --git a/Blog/Components/Pages/Home.razor b/Blog/Components/Pages/Home.razor new file mode 100644 index 0000000..be924d5 --- /dev/null +++ b/Blog/Components/Pages/Home.razor @@ -0,0 +1,10 @@ +@page "/" + +Home + +
+

A couple of JS/HTML experiments.

+
+
+
+
\ No newline at end of file diff --git a/Blog/Components/Pages/Letterflixd.razor b/Blog/Components/Pages/Letterflixd.razor new file mode 100644 index 0000000..b6a088a --- /dev/null +++ b/Blog/Components/Pages/Letterflixd.razor @@ -0,0 +1,30 @@ +@page "/Letterflixd" +Netflix to Letterboxd converter + +
+

Convert Netflix viewing history to a Letterboxd import file.

+

Download your viewing history from Netflix at https://www.netflix.com/settings/viewed/

+

Import the resulting Letterboxd file at https://letterboxd.com/import/

+
+
+
+ +
+
+ +
+
+
+
+ +
+
+
\ No newline at end of file diff --git a/Blog/Components/Pages/Letterflixd.razor.js b/Blog/Components/Pages/Letterflixd.razor.js new file mode 100644 index 0000000..a3903e2 --- /dev/null +++ b/Blog/Components/Pages/Letterflixd.razor.js @@ -0,0 +1,99 @@ +import { getById, resetLog, writeError, writeInfo, writeDebug, a, span, div, addClass } from "/common.module.js" + +let netflix_file_input = undefined; + +export function onLoad() { + + const form = getById("form"); + netflix_file_input = getById("netflix-file"); + + form.addEventListener("submit", submitForm); +} + + +/** @param {SubmitEvent} event */ +function submitForm(event) { + event.preventDefault(); + event.stopPropagation(); + + resetLog(); + const files = netflix_file_input.files; + if (files.length !== 1) { + writeError("First select your viewing history file."); + return; + } + + const netflix_file = files[0]; + + writeInfo("Checking metadata..."); + + if (!netflix_file.type.match('text/csv')) { + let detected_filetype = ""; + if (netflix_file.type) { + detected_filetype = ", detected filetype: " + netflix_file.type; + } + writeError("Select a CSV file" + detected_filetype); + return; + } + + handleFile(netflix_file); +} + +/** @param {File} file */ +function handleFile(file) { + writeInfo("Reading file..."); + file.text() + .then(convertText); +} + +const viewingHistoryPattern = /"(.*)","(\d{1,2})\/(\d{1,2})\/(\d+)"/; + +/** @param {string} data */ +function convertText(data) { + writeInfo("Checking header..."); + const [header, ...lines] = data.split(/\r?\n|\r|\n/g); + if (header !== "Title,Date") { + writeError("Invalid Netflix viewing history file, expected header \"Title,Date\""); + return; + } + writeInfo("History count: " + lines.length); + let letterboxd_output = "Title,WatchedDate,Rewatch\n"; + let watched_movies = new Set(); + + for (let line of lines) { + if (!line) continue; + if (line.includes(": Season") + || line.includes(": Limited Series:") + || line.includes(": Part") + || line.includes(": Chapter ") + || line.includes(": Volume ") + || line.includes(": Series ") + || line.includes(": Book ")) { + writeDebug("Skipping show episode: " + line); + continue; + } + if (line.startsWith(": ")) { + writeDebug("Skipping empty title entry") + } + const [_, title, day, month, year] = viewingHistoryPattern.exec(line); + if (title && day && month && year) { + const rewatch = watched_movies.has(title); + letterboxd_output += `"${title}",${year}-${month}-${day},${rewatch}\n` + if (rewatch) { + writeDebug("Rewatch of: " + title); + } + watched_movies.add(title); + } + else { + writeError("could not parse line: " + line); + } + } + + let letterboxd_import = new Blob([letterboxd_output], { type: 'text/csv' }); + + const filename = "netflix-letterboxd-import.csv" + let download_button = a(window.URL.createObjectURL(letterboxd_import), filename) + let download_text = span("Download import file ==> ") + download_button.download = filename; + log.appendChild(div(addClass(download_text, "info"), download_button)); +} \ No newline at end of file diff --git a/Blog/Components/Pages/NotFound.razor b/Blog/Components/Pages/NotFound.razor new file mode 100644 index 0000000..5278494 --- /dev/null +++ b/Blog/Components/Pages/NotFound.razor @@ -0,0 +1,4 @@ +@page "/not-found" + +

Not Found

+

Sorry, the content you are looking for does not exist.

\ No newline at end of file diff --git a/Blog/Components/Pages/Note.razor b/Blog/Components/Pages/Note.razor new file mode 100644 index 0000000..531adcf --- /dev/null +++ b/Blog/Components/Pages/Note.razor @@ -0,0 +1,8 @@ +@page "/Note" +Note + + +
+ +
+
\ No newline at end of file diff --git a/Blog/Components/Pages/Note.razor.js b/Blog/Components/Pages/Note.razor.js new file mode 100644 index 0000000..3d47d23 --- /dev/null +++ b/Blog/Components/Pages/Note.razor.js @@ -0,0 +1,33 @@ +import { getById, debounce, writeError, resetLog } from "/common.module.js"; +import lzString from "/lz-string.module.js"; + +let input = undefined; +export function onLoad() { + input = getById("input"); + input.addEventListener("input", debounce(() => { + if (input.value === '') { + window.location.hash = '' + } + else { + window.location.hash = '#' + lzString.compressToEncodedURIComponent(input.value); + } + resetLog(); + }, 10)) + + window.addEventListener('hashchange', loadState); + loadState(); +} + +export function onUpdate() { + loadState(); +} + +function loadState() { + if (window.location.hash !== '') { + input.value = lzString.decompressFromEncodedURIComponent(window.location.hash.substring(1)); + if (input.value === '') { + //Hash but no content? + writeError("Failed to load note from url.") + } + } +} \ No newline at end of file -- cgit v1.2.3