mirror of
https://gitlab.com/Anson-Projects/projects.git
synced 2025-09-19 03:52:37 +00:00
Compare commits
1 Commits
feature/gh
...
brachistoc
Author | SHA1 | Date | |
---|---|---|---|
7cd96f1997 |
@@ -2,8 +2,7 @@ const kProgressiveAttr = "data-src";
|
||||
let categoriesLoaded = false;
|
||||
|
||||
window.quartoListingCategory = (category) => {
|
||||
// category is URI encoded in EJS template for UTF-8 support
|
||||
category = decodeURIComponent(atob(category));
|
||||
category = atob(category);
|
||||
if (categoriesLoaded) {
|
||||
activateCategory(category);
|
||||
setCategoryHash(category);
|
||||
|
@@ -13,17 +13,3 @@ publish:
|
||||
- pages
|
||||
rules:
|
||||
- if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH"
|
||||
|
||||
publish_update:
|
||||
stage: deploy
|
||||
image: rust:latest
|
||||
variables:
|
||||
UPDATE_EXISTING: "true"
|
||||
script:
|
||||
- cd ./ghost-upload
|
||||
- cargo run
|
||||
needs:
|
||||
- pages
|
||||
rules:
|
||||
- if: "$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH"
|
||||
when: manual
|
||||
|
1072
ghost-upload/Cargo.lock
generated
1072
ghost-upload/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,3 @@
|
||||
# ghost-upload
|
||||
|
||||
This tool uploads posts from https://projects.ansonbiggs.com to https://notes.ansonbiggs.com.
|
||||
|
||||
What's new:
|
||||
- Uses the Ghost Admin API to check for existing posts by slug instead of probing the public site.
|
||||
- Optional update support: set `UPDATE_EXISTING=true` to update an existing post in-place (via `PUT /ghost/api/v3/admin/posts/{id}?source=html`).
|
||||
- Safer slug handling (trims trailing `/` and falls back to the last path segment).
|
||||
|
||||
Env vars:
|
||||
- `admin_api_key`: Ghost Admin API key in `key_id:secret` format.
|
||||
- `kagi_api_key`: Kagi Summarizer API key.
|
||||
- `UPDATE_EXISTING` (optional): if `true`/`1`, update posts that already exist in Ghost.
|
||||
|
||||
Notes:
|
||||
- Updates use optimistic concurrency by sending the current `updated_at` from Ghost. If someone edits a post in Ghost after we fetch it, the update will fail with a 409 and be reported in the console.
|
||||
- Summaries are always regenerated when creating or updating; if you want to avoid re-summarizing on updates, leave `UPDATE_EXISTING` unset.
|
||||
This code uploads posts from https://projects.ansonbiggs.com to https://notes.ansonbiggs.com. I couldn't figure out how to update posts, and the kagi API doesn't make it clear how long it caches results for so for now only posts that don't exist on the ghost blog will be uploaded. If you want to update content you need to manually make edits to the code and delete posts on the blog.
|
@@ -1,5 +1,6 @@
|
||||
use feed_rs::model::Entry;
|
||||
use feed_rs::parser;
|
||||
use futures::future::join_all;
|
||||
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
|
||||
use maud::html;
|
||||
use reqwest::Client;
|
||||
@@ -19,29 +20,6 @@ struct PostPayload {
|
||||
posts: Vec<Post>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
struct UpdatePost {
|
||||
id: String,
|
||||
title: String,
|
||||
slug: String,
|
||||
html: String,
|
||||
status: String,
|
||||
published_at: String,
|
||||
updated_at: String,
|
||||
canonical_url: String,
|
||||
tags: Vec<String>,
|
||||
feature_image: Option<String>,
|
||||
feature_image_alt: Option<String>,
|
||||
feature_image_caption: Option<String>,
|
||||
meta_description: Option<String>,
|
||||
custom_excerpt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
struct UpdatePayload {
|
||||
posts: Vec<UpdatePost>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
struct Post {
|
||||
title: String,
|
||||
@@ -143,54 +121,24 @@ impl Post {
|
||||
meta_description,
|
||||
custom_excerpt,
|
||||
};
|
||||
dbg!(&x);
|
||||
x
|
||||
}
|
||||
}
|
||||
|
||||
fn get_slug(link: &str) -> String {
|
||||
// Prefer portion after "/posts/" if present, otherwise fall back to the last path segment
|
||||
let raw = match link.split_once("/posts/") {
|
||||
Some((_, rest)) => rest,
|
||||
None => link.rsplit('/').next().unwrap_or(link),
|
||||
};
|
||||
raw.trim_end_matches('/')
|
||||
.to_string()
|
||||
link.split_once("/posts/").unwrap().1.to_string()
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct GhostPostSummary {
|
||||
id: String,
|
||||
slug: String,
|
||||
updated_at: String,
|
||||
}
|
||||
async fn check_if_post_exists(entry: &Entry) -> bool {
|
||||
let posts_url = "https://notes.ansonbiggs.com/";
|
||||
let link = entry.links.first().unwrap().href.as_str();
|
||||
let slug = get_slug(link);
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct GhostPostsResponse<T> {
|
||||
posts: Vec<T>,
|
||||
match reqwest::get(format!("{}{}", posts_url, slug)).await {
|
||||
Ok(response) => response.status().is_success(),
|
||||
Err(_) => false,
|
||||
}
|
||||
|
||||
async fn get_existing_post_by_slug(
|
||||
client: &Client,
|
||||
ghost_admin_base: &str,
|
||||
token: &str,
|
||||
slug: &str,
|
||||
) -> Option<GhostPostSummary> {
|
||||
// Use Ghost Admin API to search by slug
|
||||
let url = format!(
|
||||
"{}/posts/?filter=slug:{}&fields=id,slug,updated_at",
|
||||
ghost_admin_base, slug
|
||||
);
|
||||
let resp = client
|
||||
.get(url)
|
||||
.header("Authorization", format!("Ghost {}", token))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let json = resp.json::<GhostPostsResponse<GhostPostSummary>>().await.ok()?;
|
||||
json.posts.into_iter().next()
|
||||
}
|
||||
|
||||
async fn fetch_feed(url: &str) -> Vec<Entry> {
|
||||
@@ -257,8 +205,7 @@ async fn summarize_url(url: &str) -> String {
|
||||
}
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let ghost_admin_base = "https://notes.ansonbiggs.com/ghost/api/v3/admin";
|
||||
let ghost_posts_create_url = format!("{}/posts/?source=html", ghost_admin_base);
|
||||
let ghost_api_url = "https://notes.ansonbiggs.com/ghost/api/v3/admin/posts/?source=html";
|
||||
let ghost_admin_api_key = env::var("admin_api_key").unwrap();
|
||||
|
||||
let feed = "https://projects.ansonbiggs.com/index.xml";
|
||||
@@ -291,87 +238,51 @@ async fn main() {
|
||||
)
|
||||
.expect("JWT encoding failed");
|
||||
|
||||
let client = Client::new();
|
||||
// Prepare the post data
|
||||
let entries = fetch_feed(feed).await;
|
||||
|
||||
// Control whether to update existing posts via env var
|
||||
let update_existing = env::var("UPDATE_EXISTING").map(|v| v == "1" || v.eq_ignore_ascii_case("true")).unwrap_or(false);
|
||||
let post_exists_futures = entries.into_iter().map(|entry| {
|
||||
let entry_clone = entry.clone();
|
||||
async move { (entry_clone, check_if_post_exists(&entry).await) }
|
||||
});
|
||||
|
||||
for entry in entries {
|
||||
let link = entry.links.first().unwrap().href.as_str();
|
||||
let slug = get_slug(link);
|
||||
let post_exists_results = join_all(post_exists_futures).await;
|
||||
|
||||
let existing = get_existing_post_by_slug(&client, ghost_admin_base, &token, &slug).await;
|
||||
let filtered_entries: Vec<Entry> = post_exists_results
|
||||
.into_iter()
|
||||
.filter_map(|(entry, exists)| if !exists { Some(entry) } else { None })
|
||||
.collect();
|
||||
|
||||
match existing {
|
||||
None => {
|
||||
// Create new post
|
||||
let post = Post::new(entry.clone()).await;
|
||||
let post_payload = PostPayload { posts: vec![post.clone()] };
|
||||
if filtered_entries.is_empty() {
|
||||
println!("Nothing to post.");
|
||||
return;
|
||||
}
|
||||
|
||||
let post_futures = filtered_entries.into_iter().map(Post::new);
|
||||
|
||||
let client = Client::new();
|
||||
|
||||
for post in join_all(post_futures).await {
|
||||
let post_payload = PostPayload {
|
||||
posts: vec![post.clone()],
|
||||
};
|
||||
|
||||
let response = client
|
||||
.post(&ghost_posts_create_url)
|
||||
.post(ghost_api_url)
|
||||
.header("Authorization", format!("Ghost {}", token))
|
||||
.json(&post_payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("Request failed");
|
||||
|
||||
// Check the response
|
||||
if response.status().is_success() {
|
||||
println!("Post {} published successfully.", post.title);
|
||||
} else {
|
||||
println!(
|
||||
"Failed to publish post {}.\n\tStatus: {}",
|
||||
&post.title,
|
||||
response.status()
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(summary) => {
|
||||
if !update_existing {
|
||||
println!("Post '{}' exists (slug: {}), skipping.", entry.title.unwrap().content, slug);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update existing post
|
||||
let post = Post::new(entry.clone()).await;
|
||||
let update = UpdatePost {
|
||||
id: summary.id,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
html: post.html,
|
||||
status: post.status,
|
||||
published_at: post.published_at,
|
||||
updated_at: summary.updated_at,
|
||||
canonical_url: post.canonical_url,
|
||||
tags: post.tags,
|
||||
feature_image: post.feature_image,
|
||||
feature_image_alt: post.feature_image_alt,
|
||||
feature_image_caption: post.feature_image_caption,
|
||||
meta_description: post.meta_description,
|
||||
custom_excerpt: post.custom_excerpt,
|
||||
};
|
||||
|
||||
let update_url = format!("{}/posts/{}/?source=html", ghost_admin_base, update.id);
|
||||
let response = client
|
||||
.put(update_url)
|
||||
.header("Authorization", format!("Ghost {}", token))
|
||||
.json(&UpdatePayload { posts: vec![update] })
|
||||
.send()
|
||||
.await
|
||||
.expect("Update request failed");
|
||||
|
||||
if response.status().is_success() {
|
||||
println!("Post '{}' updated successfully.", entry.title.unwrap().content);
|
||||
} else {
|
||||
println!(
|
||||
"Failed to update post '{}' (status: {}).",
|
||||
entry.title.unwrap().content,
|
||||
response.status()
|
||||
"Failed to publish post {}.\n\tResp: {:?}",
|
||||
&post.title, response
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
114
posts/2025-05-25-Brachistochrone/index.qmd
Normal file
114
posts/2025-05-25-Brachistochrone/index.qmd
Normal file
@@ -0,0 +1,114 @@
|
||||
---
|
||||
title: "Brachistochrone"
|
||||
description: |
|
||||
Lets create a double pendulum in Observable JS!
|
||||
date: 2025-05-09
|
||||
categories:
|
||||
- Observable JS
|
||||
- Code
|
||||
- Math
|
||||
---
|
||||
|
||||
This is a bunch of text This is a bunch of text This is a bunch of text This is a bunch of text This is a bunch of text This is a bunch of text This is a bunch of text This is a bunch of text This is a bunch of text This is a bunch of text This is a bunch of text
|
||||
|
||||
:::{.column-screen}
|
||||
```{ojs}
|
||||
//| echo: false
|
||||
|
||||
|
||||
viewof canvas = {
|
||||
const height = 500;
|
||||
const canvas = html`<canvas width=${width} height="${height}" style="border: 1px solid #ccc; cursor: crosshair;"></canvas>`;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
let isDrawing = false;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
let pathLength = 0;
|
||||
let pathHistory = [{time: Date.now(), length: 0}];
|
||||
|
||||
// Drawing settings
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
canvas.addEventListener('mousedown', (e) => {
|
||||
if (!isDrawing) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.arc(300, 100, 5, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = 'red';
|
||||
ctx.fill();
|
||||
ctx.arc(600, 300, 5, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = 'red';
|
||||
ctx.fill();
|
||||
pathLength = 0;
|
||||
pathHistory = [{time: Date.now(), length: 0}];
|
||||
canvas.value = {length: pathLength, history: pathHistory};
|
||||
canvas.dispatchEvent(new CustomEvent("input"));
|
||||
}
|
||||
isDrawing = true;
|
||||
[lastX, lastY] = [e.offsetX, e.offsetY];
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
if (!isDrawing) return;
|
||||
|
||||
// Calculate distance
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(e.offsetX - lastX, 2) +
|
||||
Math.pow(e.offsetY - lastY, 2)
|
||||
);
|
||||
const angle = Math.atan2(e.offsetY - lastY, e.offsetX - lastX)
|
||||
pathLength += distance;
|
||||
|
||||
// Draw
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lastX, lastY);
|
||||
ctx.lineTo(e.offsetX, e.offsetY);
|
||||
ctx.stroke();
|
||||
[lastX, lastY] = [e.offsetX, e.offsetY];
|
||||
|
||||
// Update value and emit event
|
||||
pathHistory.push({time: Date.now(), length: pathLength});
|
||||
canvas.value = {length: pathLength, history: pathHistory};
|
||||
canvas.dispatchEvent(new CustomEvent("input"));
|
||||
});
|
||||
|
||||
canvas.addEventListener('mouseup', () => isDrawing = false);
|
||||
canvas.addEventListener('mouseout', () => isDrawing = false);
|
||||
|
||||
// Initial value
|
||||
canvas.value = {length: 0, history: pathHistory};
|
||||
return canvas;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
```{ojs}
|
||||
//| echo: false
|
||||
md`Current path length: **${canvas.length?.toFixed(0) || 0} pixels**`
|
||||
```
|
||||
|
||||
```{ojs}
|
||||
//| echo: false
|
||||
import {Plot} from "@observablehq/plot"
|
||||
|
||||
pathChart = Plot.plot({
|
||||
width: width,
|
||||
height: 200,
|
||||
y: {label: "Path Length (pixels)"},
|
||||
x: {label: "Time", type: "time"},
|
||||
marks: [
|
||||
Plot.line(canvas.history || [], {
|
||||
x: "time",
|
||||
y: "length",
|
||||
stroke: "steelblue",
|
||||
strokeWidth: 2
|
||||
})
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
This is a bunch of text This is a bunch of text This is a bunch of text This is a bunch of text This is a bunch of text This is a bunch of text This is a bunch of text This is a bunch of text This is a bunch of text This is a bunch of text This is a bunch of text
|
Reference in New Issue
Block a user