Allison is coding...

A Minimalist Cross-Device Publishing System

Objective: To create a minimalist, cross-platform publishing interface that sends text to specific blog pages with a single click, effectively implementing a social media-style “feed” on a personal blog.

Desktop Preview:

desktop client

Mobile Preview:

mobile client | 300

Architecture

The system utilizes a Cloudflare Worker as a bridge between the client devices and the GitHub repository.

flowchart TD
    A[Mobile Client]
    B[Desktop Client]

    A --> C[Cloudflare Worker]
    B --> C

    C --> D[GitHub Contents API]
    D --> E[Hugo Repository]

    E --> F[Vercel]
    F --> G[Blog]

Implementation

1. Cloudflare Worker

The Worker acts as the backend logic, handling authentication and interacting with the GitHub API.

Worker Architecture:

flowchart TD
    A[HTTP Request]
    B[Cloudflare Worker]
    C[Environment Variables]
    D[GitHub Contents API]

    A --> B
    C -.-> B
    B --> D

Worker Environment Variables:

VariablePurpose
WORKER_SECRETAuthenticates requests from your clients
GITHUB_TOKENPersonal Access Token for GitHub API
GITHUB_REPOThe target repository (username/repo)
GITHUB_FILE_PATH_TEMPLATEPath template for the Markdown file

Operational Logic:

flowchart TD
    A[Receive POST Request]
    B{Valid Secret?}
    C{Valid Text?}
    D[Calculate Year File Path]
    E[Fetch Existing Markdown]
    F[Insert Entry After Front Matter]
    G[Commit Updated File]

    A --> B
    B -->|No| H[401 Unauthorized]
    B -->|Yes| C

    C -->|No| I[400 Bad Request]
    C -->|Yes| D

    D --> E
    E --> F
    F --> G

Worker Script:

export default {
    async fetch(request, env) {
        if (request.method !== "POST") {
            return new Response("Only POST allowed", { status: 405 });
        }

        const authHeader = request.headers.get("X-Auth-Key");
        if (authHeader !== env.WORKER_SECRET) {
            return new Response("Unauthorized", { status: 401 });
        }

        let text;
        try {
            const body = await request.json();
            text = body.text;
        } catch (e) {
            return new Response("Invalid JSON payload from client", { status: 400 });
        }

        if (!text || !text.trim()) {
            return new Response("Empty text", { status: 400 });
        }

        const repo = env.GITHUB_REPO;
        const token = env.GITHUB_TOKEN;

        // Dates are processed based on UTC 0 
        const now = new Date();
        const year = now.getUTCFullYear();
        const dateStr = now.toISOString().split("T")[0];

        // Build path dynamically, e.g., year/{year}.md -> year/2026.md
        const filePath = env.GITHUB_FILE_PATH_TEMPLATE.replace("{year}", year);

        const apiUrl = `https://api.github.com/repos/${repo}/contents/${filePath}`;
        const headers = {
            "Authorization": `token ${token}`,
            "Accept": "application/vnd.github.v3+json",
            "User-Agent": "cf-worker-sync"
        };

        // 1. Fetch the existing file
        const getRes = await fetch(apiUrl, { headers });

        let existingContent = "";
        let sha = null;
        let isNewFile = false;

        if (getRes.status === 404) {
            // New year: initialize with front matter
            isNewFile = true;
            existingContent = buildFrontMatter(year);
        } else if (!getRes.ok) {
            const errText = await getRes.text();
            return new Response(`GET failed: ${getRes.status} ${errText}`, { status: 500 });
        } else {
            const fileData = await getRes.json();
            sha = fileData.sha;

            const binaryString = atob(fileData.content.replace(/\n/g, ""));
            const bytes = new Uint8Array(binaryString.length);
            for (let i = 0; i < binaryString.length; i++) {
                bytes[i] = binaryString.charCodeAt(i);
            }
            existingContent = new TextDecoder("utf-8").decode(bytes);
        }

        // 2. Format the new entry
        const entry = `<small>${dateStr}</small>\n\n${text.trim()}\n\n---\n`;

        // 3. Insert the entry immediately after the YAML front matter
        const fmRegex = /^(---\r?\n[\s\S]*?\r?\n---\r?\n)/;
        const match = existingContent.match(fmRegex);

        let updatedContent;
        if (match) {
            const frontMatter = match[1];
            const rest = existingContent.slice(frontMatter.length);
            const trimmedRest = rest.replace(/^\n+/, "");
            updatedContent = frontMatter + "\n" + entry + "\n" + trimmedRest;
        } else {
            updatedContent = entry + "\n" + existingContent;
        }

        const encodedContent = btoa(
            encodeURIComponent(updatedContent).replace(/%([0-9A-F]{2})/g, (match, p1) =>
                String.fromCharCode(parseInt(p1, 16))
            )
        );

        // 4. Commit to GitHub
        const payload = {
            message: isNewFile
                ? `create ${filePath} via Worker`
                : `update memo via Worker - ${dateStr}`,
            content: encodedContent
        };
        if (sha) {
            payload.sha = sha;
        }

        const putRes = await fetch(apiUrl, {
            method: "PUT",
            headers: { ...headers, "Content-Type": "application/json" },
            body: JSON.stringify(payload)
        });

        if (putRes.ok) {
            return new Response(`Sync successful${isNewFile ? " (auto-created " + filePath + ")" : ""}`, { status: 200 });
        } else {
            const errText = await putRes.text();
            return new Response(`PUT failed: ${putRes.status} ${errText}`, { status: 500 });
        }
    }
};

function buildFrontMatter(year) {
    return `---
title: "${year}"
date: ${year}-01-01T00:00:00+00:00
type: page
hideComments: true
hideSuggestions: true
rss_ignore: true
hide: true
---
`;
}

2. Desktop Client (Ubuntu)

Architecture:

flowchart TD
    A[YAD Input]
    A1[config.json]
    B[Bash Script: sync-note.sh]
    C[archive.md]
    D[failed_queue.md]
    E[Worker]

    A --> B
    A1 -.-> B
    B --> C
    B --> E
    B -->|sync failure| D

Configuration (config.json):

{
  "worker_url": "https://xxxxxxxxxxxx.workers.dev/",
  "worker_secret": "your-worker-secret",
  "archive_dir": "path_to_archive_dir/"
}

Setup Instructions:

  1. Install Yad: Run sudo apt install yad to install the dialog tool.
  2. Create Files: Set up the sync-note.sh script and the config.json file.
  3. Permissions: Run chmod +x sync-note.sh to make the script executable.
  4. Global Command: Create a symbolic link in /usr/local/bin/memo so you can launch the window by typing memo in any terminal.
  5. Keyboard Shortcut: In Ubuntu System Settings, map a shortcut (e.g., Alt + M) to the command /usr/local/bin/memo for instant access.

The Script (sync-note.sh):

#!/bin/bash

# Optional: Proxy settings for local environments
export http_proxy="http://127.0.0.1:7890"
export https_proxy="http://127.0.0.1:7890"

CONFIG_FILE="$HOME/.config/memo-sync/config.json"

if [ ! -f "$CONFIG_FILE" ]; then
    yad --error --text="Config file not found at $CONFIG_FILE" --width=400 2>/dev/null
    exit 1
fi

WORKER_URL=$(jq -r '.worker_url' "$CONFIG_FILE")
WORKER_SECRET=$(jq -r '.worker_secret' "$CONFIG_FILE")
ARCHIVE_DIR=$(jq -r '.archive_dir' "$CONFIG_FILE" | sed "s|^~|$HOME|")

ARCHIVE_FILE="$ARCHIVE_DIR/archive.md"
FAILED_QUEUE="$ARCHIVE_DIR/failed_queue.md"

mkdir -p "$ARCHIVE_DIR"

# UI Styling
YAD_CSS="/tmp/yad_memo.css"
cat << 'EOF' > "$YAD_CSS"
window { background-color: #f6f6f6; border-radius: 12px; }
headerbar { background-color: #f6f6f6; border: none; padding-top: 24px; padding-bottom: 12px; }
headerbar .title { font-size: 13pt; font-weight: bold; color: #2e3436; }
headerbar image, headerbar button { display: none; }
textview text { background-color: #ffffff; font-size: 12pt; }
button { background-image: none; background-color: #ffffff; border: none; border-top: 1px solid #e5e5e5; padding: 14px; font-weight: bold; border-radius: 0px; }
button:hover { background-color: #f0f0f0; }
box button:first-child { border-right: 1px solid #e5e5e5; border-bottom-left-radius: 12px; }
box button:last-child { border-bottom-right-radius: 12px; }
EOF

TEXT=$(GTK_THEME=Adwaita:light yad --text-info \
  --title="Write something" \
  --editable \
  --width=400 \
  --height=280 \
  --wrap \
  --margins=20 \
  --borders=0 \
  --buttons-layout=expand \
  --button="Cancel":1 \
  --button="OK":0 \
  --theme="$YAD_CSS" \
  2>/dev/null)

rm -f "$YAD_CSS"

if [ $? -ne 0 ] || [ -z "$TEXT" ]; then
    exit 0
fi

TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

# Append to local archive
{
    echo "<small>${TIMESTAMP}</small>"
    echo ""
    echo "$TEXT"
    echo ""
    echo "---"
    echo ""
} >> "$ARCHIVE_FILE"

BODY=$(jq -n --arg text "$TEXT" '{text: $text}')

# Sync to Worker
HTTP_CODE=$(curl -s -o /tmp/sync_response.txt -w "%{http_code}" -X POST \
  "$WORKER_URL" \
  -H "X-Auth-Key: $WORKER_SECRET" \
  -H "Content-Type: application/json" \
  -d "$BODY")

RESPONSE_TEXT=$(cat /tmp/sync_response.txt)

if [ "$HTTP_CODE" -eq 200 ]; then
    notify-send "Sync successful" 2>/dev/null || yad --info --text="Sync successful" --width=250 2>/dev/null
else
    # Save to failed queue if sync fails
    {
        echo "${TIMESTAMP}"
        echo ""
        echo "$TEXT"
        echo ""
        echo "---"
        echo ""
    } >> "$FAILED_QUEUE"

    notify-send "Sync failed ($HTTP_CODE) — saved locally" 2>/dev/null || \
        yad --error --title="Failed ($HTTP_CODE)" --text="$RESPONSE_TEXT" --width=400 2>/dev/null
fi

3. Mobile Client (Android)

The mobile workflow is implemented using the HTTP Shortcuts app.

  1. Create Shortcut: Use the app’s Import from cURL command feature to quickly set up the endpoint.
  2. Define Variable: Create a Text Input variable (named text_input). Enable JSON encode in the Advanced Settings to handle special characters.
  3. Map Payload: In the shortcut’s request body, bind the variable: {"text": "{{text_input}}"}.
  4. One-Click Access: Add the shortcut widget to your Android home screen for instant publishing.