#!/usr/bin/env bash
help_text="
NAME
gen-bookmark-page - generates bookmark web pages from bookmarks JSON files.
USAGE
gen-bookmark-page [options] [filename]
gen-bookmark-page -a|--add <page> <section> <title> <url>
gen-bookmark-page -e|--edit <page>
ARGUMENTS
filename
Optional JSON file to process. If not provided, processes all bookmarks-*.json files.
OPTIONS
-a|--add <page> <section> <title> <url>
Add a new bookmark to the specified page. <page> is the page name after 'bookmarks-'
in bookmarks-<page>.json (e.g., 'mjn' for bookmarks-mjn.json, or leave
empty for bookmarks.json).
-e|--edit <page>
Open bookmarks-<page>.json in vi for editing. After saving, automatically
regenerates the HTML page.
-h|--help
Show help text.
DESCRIPTION
Generates HTML bookmark pages from JSON bookmark files. Output files have the same
base name as the input (e.g., bookmarks-mjn.json -> bookmarks-mjn.html).
If no filename is provided, processes bookmarks.json and all bookmarks-*.json files
in the script directory.
AUTHOR
mjnurse.github.io - 2026
"
help_line="Generates bookmark web pages from bookmarks JSON files"
web_desc_line="Generates bookmark web pages from bookmarks JSON files"
try="Try ${0##*/} -h for more information"
tmp="${help_text##*USAGE}"
usage="$(echo Usage: ${tmp%%ARGUMENTS*})"
input_file=""
add_mode=false
edit_mode=false
interactive_mode=false
add_page=""
add_section=""
add_title=""
add_url=""
while [[ "$1" != "" ]]; do
case $1 in
-h|--help)
echo "$help_text"
exit
;;
-a|--add)
add_mode=true
shift
if [[ $# -lt 4 ]]; then
# Interactive mode - prompt for page, section, title, and url
interactive_mode=true
else
add_page="$1"
add_section="$2"
add_title="$3"
add_url="$4"
shift 3
fi
;;
-e|--edit)
edit_mode=true
shift
if [[ $# -lt 1 ]]; then
echo "Error: -e|--edit requires 1 argument: <page>"
echo "${try}"
exit 1
fi
add_page="$1"
;;
*)
if [[ -z "$input_file" ]]; then
input_file="$1"
else
echo "${usage}"
echo "${try}"
exit 1
fi
;;
esac
shift
done
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
winroot="$(echo $MJNWINROOT | sed 's/\//\\/g; s/\\c/C:/')"
tidy() {
local bm_file="$1"
local tmp_file=$(mktemp)
# Sort sections and links alphabetically (case-insensitive), then apply standard formatting
jq -S '
.sections |= (sort_by(.heading | ascii_downcase) |
map(.links |= sort_by(.title | ascii_downcase)))
' "$bm_file" > "$tmp_file" && mv "$tmp_file" "$bm_file"
# Compress JSON formatting to make file more concise to read.
sed -i '
/{$/{N;s/\n */ /};
:a; N; s/\n *\(}[,]*$\)/ \1/; ta; P; D;
' "$bm_file"
sed -i '
s/\("url":\)/\1 /;
' "$bm_file"
}
# Function to process a single bookmark file
process_bookmark_file() {
local bm_file="$1"
# Get the "page" field from JSON (mandatory)
local bm_page=$(jq -r '.page // empty' "$bm_file")
if [[ -z "$bm_page" ]]; then
echo "Error: Missing required 'page' field in $bm_file"
return 1
fi
echo "Processing: $bm_file -> $bm_page"
tmp1="${bm_file/.json/}"
tmp2="${tmp1/bookmarks-}"
title="${tmp2##*/}"
echo '<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<head>
<meta http-equiv="refresh" content="60">
<TITLE>Bookmarks</TITLE>
<link rel="icon" type="image/x-icon" href="'"$winroot"'\Pictures\Star.png">
<style>
h1, h3, p {
font-family: Arial, Helvetica, sans-serif;
}
p {
font-size: 10pt;
margin: 10px 0px 10px 0px;
}
a {
color: #155799;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1 {
font-size: 30pt;
text-align: center;
margin-top: 20px;
margin-bottom: 5px;
text-transform: uppercase;
}
h3 {
font-size: 18pt;
}
.sections-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.section {
min-width: 250px;
vertical-align: top;
margin: 10px;
}
.input {
width: 300px;
margin: 0 auto;
}
input {
font-size: 11pt;
margin: 10px;
width: 100%;
}
</style>
</head>
<body scroll="no" style="overflow: hidden">
<div>
<h1>'$title'</h1>
<div class="input">
<input type="text" id="search" onkeyup="search()" style="visibility: hidden">
</div>
<div class="sections-container">
' > "$bm_page"
# Build all sections with continuous numbering
# Use process substitution to avoid subshell and maintain pos counter
pos=1
while IFS= read -r section; do
# Decode section
section_data=$(echo "$section" | base64 -d)
# Get heading
heading=$(echo "$section_data" | jq -r '.heading')
# Start new section div
echo '<div class="section">' >> "$bm_page"
echo "<h3>$heading</h3>" >> "$bm_page"
# Add links, sorted alphabetically by title (case-insensitive)
while IFS=$'\t' read -r link_title link_url; do
echo "<p><a id=\"item$pos\" href=\"$link_url\" target=\"_blank\">$link_title ($pos)</a></p>" >> "$bm_page"
((pos++))
done < <(echo "$section_data" | jq -r '.links | sort_by(.title | ascii_downcase) | .[] | [.title, .url] | @tsv')
# Close section div
echo '</div>' >> "$bm_page"
done < <(jq -r '.sections | sort_by(.heading | ascii_downcase) | .[] | @base64' "$bm_file")
echo '</div>
<p style="text-align: center; color: #BBBBBB; font-size: 12px">Last Updated: '$(date)'</p>
</div>
<script>
const maxNum = document.querySelectorAll("[id^=\"item\"]").length;
let numTyped = "";
let lastKeyPress = Date.now();
function highlightListItems() {
try {
document.getElementById("item"+numTyped).style.background = "lightgrey";
} catch { }
try {
for (i=1; i<100; i++) {
if (i != numTyped ) {
document.getElementById("item"+i).style.background = "transparent";
document.getElementById("item"+i).style.visibility = "visible";
}
}
} catch { }
}
function addKeyListener() {
document.addEventListener("keyup", function() {
if (event.code == "Escape") {
document.getElementById("search").blur();
document.getElementById("search").value = "";
document.getElementById("search").style.visibility = "hidden";
for (let i = 1; i <= maxNum; i++) {
const item = document.getElementById("item"+i);
item.style.background = "transparent";
item.style.color = "#155799";
}
}
if (document.activeElement.tagName == "INPUT" ||
document.activeElement.tagName == "TEXTAREA") {
return;
}
if (event.ctrlKey) {
return;
}
switch (event.code) {
case "Slash":
document.getElementById("search").style.visibility = "visible";
document.getElementById("search").focus();
document.getElementById("search").value = "";
break;
case "Backspace":
case "Delete":
numTyped = numTyped.slice(0, -1);
highlightListItems();
break;
case "Escape":
document.getElementById("search").blur();
document.getElementById("search").style.visibility = "hidden";
numTyped = "";
highlightListItems();
break;
case "Enter":
if (numTyped != "") {
window.open( document.getElementById("item"+numTyped).href, "_blank").focus();
}
break;
case "ArrowDown":
if (numTyped == "") {
numTyped = "1";
} else {
if (parseInt(numTyped) < maxNum) {
const tmpNum = parseInt(numTyped) + 1;
numTyped = tmpNum.toString();
}
}
highlightListItems();
break;
case "ArrowUp":
if (numTyped != "" && numTyped != "1") {
const tmpNum = parseInt(numTyped) - 1;
numTyped = tmpNum.toString();
}
if (numTyped == "0") {
numTyped = "";
}
highlightListItems();
break;
}
if (event.key >= "0" && event.key <= "9") {
if ( (Date.now() - lastKeyPress) > 500 ) {
numTyped = event.key;
} else {
numTyped += event.key;
}
if (numTyped == "0") {
numTyped = "";
}
lastKeyPress = Date.now();
highlightListItems();
}
});
}
function search() {
const searchVal = document.getElementById("search").value.toUpperCase();
const go = event.code == "Enter";
let firstMatchHref = null;
// Split search string into individual words
const searchWords = searchVal.trim().split(/\s+/).filter(word => word.length > 0);
for (let i = 1; i <= maxNum; i++) {
const item = document.getElementById("item"+i);
const itemVal = item.innerHTML.toUpperCase();
// Check if all search words are found in the item text (in any order)
const allWordsMatch = searchWords.every(word => itemVal.indexOf(word) > -1);
if (allWordsMatch) {
item.style.color = "#155799";
if (!firstMatchHref) {
firstMatchHref = item.href;
}
} else {
item.style.color = "#DDDDDD";
}
}
if (go && firstMatchHref) {
// Reset all colors
for (let i = 1; i <= maxNum; i++) {
const item = document.getElementById("item"+i);
item.style.background = "transparent";
item.style.color = "#155799";
}
document.getElementById("search").blur();
document.getElementById("search").value = "";
document.getElementById("search").style.visibility = "hidden";
window.open(firstMatchHref, "_blank").focus();
}
}
addKeyListener();
</script>
</body>' >> "$bm_page"
}
# Interactive prompting for add mode
if [[ "$interactive_mode" == true ]]; then
# List available pages
echo "Available pages:"
pages=()
page_num=1
for bm_file in "$script_dir"/bookmarks-*.json; do
if [[ -f "$bm_file" ]]; then
page_name=$(basename "$bm_file" .json)
page_name="${page_name#bookmarks-}"
echo " $page_num) $page_name"
pages+=("$page_name")
((page_num++))
fi
done
# Prompt for page
read -p "Select page (number or name): " page_input
# Check if input is a number
if [[ "$page_input" =~ ^[0-9]+$ ]]; then
page_idx=$((page_input - 1))
if [[ $page_idx -ge 0 && $page_idx -lt ${#pages[@]} ]]; then
add_page="${pages[$page_idx]}"
else
echo "Error: Invalid page number"
exit 1
fi
else
add_page="$page_input"
fi
bm_file="$script_dir/bookmarks-${add_page}.json"
if [[ ! -f "$bm_file" ]]; then
echo "Error: File not found: $bm_file"
exit 1
fi
# List available sections
echo ""
echo "Available sections:"
sections=()
section_num=1
while IFS= read -r section; do
echo " $section_num) $section"
sections+=("$section")
((section_num++))
done < <(jq -r '.sections[] | .heading' "$bm_file" | sort)
# Prompt for section
read -p "Select section (number or name): " section_input
# Check if input is a number
if [[ "$section_input" =~ ^[0-9]+$ ]]; then
section_idx=$((section_input - 1))
if [[ $section_idx -ge 0 && $section_idx -lt ${#sections[@]} ]]; then
add_section="${sections[$section_idx]}"
else
echo "Error: Invalid section number"
exit 1
fi
else
add_section="$section_input"
fi
# Show existing titles in the selected section (alphabetically)
echo ""
echo "Existing titles in '$add_section':"
while IFS= read -r title; do
echo " - $title"
done < <(jq -r --arg section "$add_section" \
'.sections[] | select(.heading == $section) | .links[] | .title' "$bm_file" | sort -f)
# Prompt for title
echo ""
read -p "Enter bookmark title (blank to exit): " add_title
if [[ -z "$add_title" ]]; then
echo "Exiting..."
exit 0
fi
# Prompt for URL
read -p "Enter bookmark URL (blank to exit): " add_url
if [[ -z "$add_url" ]]; then
echo "Exiting..."
exit 0
fi
fi
# Main logic: edit, add bookmark, or process files
if [[ "$edit_mode" == true ]]; then
# Edit mode
if [[ -z "$add_page" ]]; then
echo "Error: -e|--edit requires a page name"
exit 1
fi
bm_file="$script_dir/bookmarks-${add_page}.json"
if [[ ! -f "$bm_file" ]]; then
echo "Error: File not found: $bm_file"
exit 1
fi
# Open in vi
vi "$bm_file"
# Regenerate the HTML page
echo "Regenerating HTML page..."
process_bookmark_file "$bm_file"
tidy "$bm_file"
echo "Done."
elif [[ "$add_mode" == true ]]; then
# Add bookmark mode
if [[ -z "$add_page" ]]; then
bm_file="$script_dir/bookmarks.json"
bm_page="$MJNWINROOT/MJN/bookmarks.html"
else
bm_file="$script_dir/bookmarks-${add_page}.json"
bm_page="$MJNWINROOT/MJN/bookmarks-${add_page}.html"
fi
# Create file if it doesn't exist
if [[ ! -f "$bm_file" ]]; then
echo "Creating new bookmark file: $bm_file"
tmp_file=$(mktemp)
jq -n --arg page "$bm_page" \
--arg section "$add_section" \
--arg title "$add_title" \
--arg url "$add_url" \
'{
page: $page,
sections: [
{
heading: $section,
links: [
{
title: $title,
url: $url
}
]
}
]
}' > "$tmp_file"
mv "$tmp_file" "$bm_file"
echo "Added bookmark '$add_title' to new section '$add_section' in $bm_file"
else
# File exists, check if section exists
section_exists=$(jq --arg section "$add_section" '.sections[] | select(.heading == $section) | .heading' "$bm_file")
tmp_file=$(mktemp)
if [[ -z "$section_exists" ]]; then
# Section doesn't exist, create it with the bookmark
echo "Creating new section '$add_section' in $bm_file"
jq --arg section "$add_section" \
--arg title "$add_title" \
--arg url "$add_url" \
'.sections += [{"heading": $section, "links": [{"title": $title, "url": $url}]}]' \
"$bm_file" > "$tmp_file"
else
# Section exists, add bookmark to it
jq --arg section "$add_section" \
--arg title "$add_title" \
--arg url "$add_url" \
'(.sections[] | select(.heading == $section).links) += [{"title": $title, "url": $url}]' \
"$bm_file" > "$tmp_file"
fi
mv "$tmp_file" "$bm_file"
echo "Added bookmark '$add_title' to section '$add_section' in $bm_file"
fi
# Regenerate the HTML page
process_bookmark_file "$bm_file"
elif [[ -n "$input_file" ]]; then
# Process single file
if [[ ! -f "$input_file" ]]; then
# Try with script directory prefix
input_file="$script_dir/$input_file"
if [[ ! -f "$input_file" ]]; then
echo "Error: File not found: $input_file"
exit 1
fi
fi
process_bookmark_file "$input_file"
else
# Process bookmarks.json and all bookmarks-*.json files
found=0
# Check for bookmarks.json first
if [[ -f "$script_dir/bookmarks.json" ]]; then
process_bookmark_file "$script_dir/bookmarks.json"
found=1
fi
# Then process all bookmarks-*.json files
for bm_file in "$script_dir"/bookmarks-*.json; do
if [[ -f "$bm_file" ]]; then
process_bookmark_file "$bm_file"
tidy "$bm_file"
found=1
fi
done
if [[ $found -eq 0 ]]; then
echo "No bookmarks.json or bookmarks-*.json files found in $script_dir"
exit 1
fi
fi