| 1 | #!/bin/bash |
| 2 | # Desc: Generates Mediawiki subpage navigation links |
| 3 | # Input: file text file with list of chapters |
| 4 | # stdin text with list of chapters |
| 5 | # Output: [[../Chapter 4|Next]], [[../Chapter 2|Previous]], [[../|Up]] |
| 6 | # Version: 0.1.0 |
| 7 | # Attrib: Steven Baltakatei Sandoval. (2024-07-17). reboil.com |
| 8 | # License: GPLv3+ |
| 9 | |
| 10 | yell() { echo "$0: $*" >&2; } # print script path and all args to stderr |
| 11 | die() { yell "$*"; exit 111; } # same as yell() but non-zero exit status |
| 12 | must() { "$@" || die "cannot $*"; } # runs args as command, reports args if command fails |
| 13 | read_input() { |
| 14 | # Note: May accept specified file or stdin, but not both |
| 15 | # Input: arg input text (newline delimited) |
| 16 | # stdin input text (newline delimited) |
| 17 | # Output: stdout output text (newline delimited) |
| 18 | # Depends: BK-2020-03 read_stdin (v0.1.1), yell(), die() |
| 19 | |
| 20 | # Parse args |
| 21 | ## Only 1 argument. |
| 22 | if [[ "$#" -gt 1 ]]; then die "FATAL:Too many arguments ($#):$*"; fi; |
| 23 | ## File specified. |
| 24 | if [[ "$#" -eq 1 ]] && [[ -f "$1" ]] && [[ ! -p /dev/stdin ]]; then |
| 25 | while read -r line; do |
| 26 | printf "%s\n" "$line"; |
| 27 | done < "$1" && return 0; fi; |
| 28 | if [[ "$#" -eq 0 ]] && [[ -p /dev/stdin ]]; then |
| 29 | read_stdin && return 0; fi; |
| 30 | die "FATAL:Unknown error."; |
| 31 | }; |
| 32 | read_stdin() { |
| 33 | # Desc: Consumes stdin; outputs as stdout lines |
| 34 | # Input: stdin (consumes) |
| 35 | # Output: stdout (newline delimited) |
| 36 | # return 0 stdin read |
| 37 | # return 1 stdin not present |
| 38 | # Example: printf "foo\nbar\n" | read_stdin |
| 39 | # Depends: GNU bash (version 5.1.16), GNU Coreutils 8.32 (cat) |
| 40 | # Version: 0.1.1 |
| 41 | # Attrib: Steven Baltakatei Sandoval (2024-01-29). reboil.com |
| 42 | local input_stdin output; |
| 43 | |
| 44 | # Store stdin |
| 45 | if [[ -p /dev/stdin ]]; then |
| 46 | input_stdin="$(cat -)" || { |
| 47 | echo "FATAL:Error reading stdin." 1>&2; return 1; }; |
| 48 | else |
| 49 | return 1; |
| 50 | fi; |
| 51 | |
| 52 | # Store as output array elements |
| 53 | ## Read in stdin |
| 54 | if [[ -n $input_stdin ]]; then |
| 55 | while read -r line; do |
| 56 | output+=("$line"); |
| 57 | done < <(printf "%s\n" "$input_stdin") || { |
| 58 | echo "FATAL:Error parsing stdin."; return 1; }; |
| 59 | fi; |
| 60 | |
| 61 | # Print to stdout |
| 62 | printf "%s\n" "${output[@]}"; |
| 63 | |
| 64 | return 0; |
| 65 | }; # read stdin to stdout lines |
| 66 | get_path_fork_level() { |
| 67 | # Desc: Get fork level from two paths |
| 68 | # Input: arg1 str path |
| 69 | # arg2 str path |
| 70 | # Output: stdout int fork level |
| 71 | # Version: 0.0.1 |
| 72 | local path1="$1"; |
| 73 | local path2="$2"; |
| 74 | |
| 75 | # Squeeze multiple slashes and remove trailing slashes |
| 76 | path1="$(echo "$path1" | tr -s '/' | sed 's:/*$::' )"; |
| 77 | path2="$(echo "$path2" | tr -s '/' | sed 's:/*$::' )"; |
| 78 | |
| 79 | # Check for mixed absolute/relative paths |
| 80 | if [[ "$path1" =~ ^/ ]] && [[ "$path2" =~ ^/ ]]; then |
| 81 | flag_root=true; |
| 82 | # Remove initial / |
| 83 | path1="$(echo "$path1" | sed -e 's:^/::' )"; |
| 84 | path2="$(echo "$path2" | sed -e 's:^/::' )"; |
| 85 | elif [[ ! "$path1" =~ ^/ ]] && [[ ! "$path2" =~ ^/ ]]; then |
| 86 | flag_root=false; |
| 87 | else |
| 88 | declare -p path1 path2 flag_root; |
| 89 | echo "FATAL:Mixed relative and absolute paths not supported." 1>&2; |
| 90 | return 1; |
| 91 | fi; |
| 92 | |
| 93 | # Save path as arrays with `/` as element delimiter |
| 94 | local IFS='/'; |
| 95 | read -ra parts1 <<< "$path1"; |
| 96 | read -ra parts2 <<< "$path2"; |
| 97 | |
| 98 | # Get fork level by counting identical path elements from rootside |
| 99 | local fork_level=0; |
| 100 | for (( i=0; i<${#parts1[@]} && i<${#parts2[@]}; i++ )); do |
| 101 | if [[ "${parts1[i]}" != "${parts2[i]}" ]]; then break; fi; |
| 102 | ((fork_level++)); |
| 103 | done; |
| 104 | |
| 105 | echo "$fork_level"; |
| 106 | #declare -p path1 path2 flag_root parts1 parts2 fork_level; # debug |
| 107 | return 0; |
| 108 | }; # Get fork level int from two paths |
| 109 | prune_path_rootside() { |
| 110 | # Desc: Prunes a path from the root-side to a specified prune level. |
| 111 | # Input: arg1 str path |
| 112 | # arg2 int prune level (0-indexed) |
| 113 | # Depends: GNU sed 4.8 |
| 114 | # Version: 0.0.1 |
| 115 | local path="$1"; |
| 116 | local prune_level="$2"; |
| 117 | |
| 118 | # Check for absolute or relative path |
| 119 | if [[ "$path" =~ ^/ ]]; then |
| 120 | flag_root=true; |
| 121 | # Remove initial / |
| 122 | path="$(echo "$path" | sed -e 's:^/::' )"; |
| 123 | else |
| 124 | flag_root=false; |
| 125 | fi; |
| 126 | |
| 127 | # Save path as array with `/` as element delimiter |
| 128 | local IFS='/'; |
| 129 | read -ra parts <<< "$path"; |
| 130 | |
| 131 | # Assemble pruned path from prune_level |
| 132 | local pruned_path=""; |
| 133 | for (( i=prune_level; i<${#parts[@]}; i++ )); do |
| 134 | pruned_path+="${parts[i]}/"; |
| 135 | done; |
| 136 | |
| 137 | # Trim trailing `/` delimiter |
| 138 | pruned_path=$(echo "$pruned_path" | sed 's:/*$::'); |
| 139 | |
| 140 | # Restore initial / if appropriate |
| 141 | if [[ "$flag_root" == "true" ]] && [[ "$prune_level" -eq 0 ]]; then |
| 142 | pruned_path=/"$pruned_path"; |
| 143 | fi; |
| 144 | |
| 145 | # Output pruned path |
| 146 | echo "$pruned_path"; |
| 147 | #declare -p path prune_level parts pruned_path && printf "========\n"; # debug |
| 148 | return 0; |
| 149 | }; # prune path rootside to int specified level |
| 150 | get_path_hierarchy_level() { |
| 151 | # Desc: Outputs hierarchy level of input paths |
| 152 | # Example: $ cat lines.txt | get_path_hierarchy_level |
| 153 | # Input: stdin str lines with /-delimited paths |
| 154 | # Output: stdout int hierarchy level of each path |
| 155 | # Version: 0.0.1 |
| 156 | |
| 157 | local line level; |
| 158 | local flag_root; |
| 159 | local -a output; |
| 160 | |
| 161 | n=0; |
| 162 | while read -r line; do |
| 163 | # Check for mixed absolute/relative paths. |
| 164 | if [[ $n -le 0 ]] && [[ "$line" =~ ^/ ]]; then |
| 165 | flag_root=true; |
| 166 | else |
| 167 | flag_root=false; |
| 168 | fi; |
| 169 | if { [[ "$flag_root" == "true" ]] && [[ ! "$line" =~ ^/ ]]; } || \ |
| 170 | { [[ "$flag_root" == "false" ]] && [[ "$line" =~ ^/ ]]; } then |
| 171 | echo "FATAL:Mixed relative and absolute paths not supported." 1>&2; return 1; |
| 172 | fi; |
| 173 | |
| 174 | # Squeeze multiple slashes and remove trailing slashes |
| 175 | line="$(echo "$line" | tr -s '/' | sed 's:/*$::' )"; |
| 176 | |
| 177 | # Count the number of slashes to determine hierarchy level |
| 178 | level="$(echo "$line" | awk -F'/' '{print NF-1}' )"; |
| 179 | if [[ "$flag_root" == "true" ]]; then ((level--)); fi; |
| 180 | |
| 181 | # Append to output |
| 182 | output+=("$level"); |
| 183 | #declare -p flag_root level; # debug |
| 184 | ((n++)); |
| 185 | done; |
| 186 | # Print output |
| 187 | printf "%s\n" "${output[@]}"; |
| 188 | }; # return hierarchy level of lines as integers |
| 189 | validate_subpage_list() { |
| 190 | # Desc: Check for illegal characters in subpage titles |
| 191 | # Input: stdin unvalidated subpage list |
| 192 | # Output: stdout validated subpage list |
| 193 | # Depends: BK-2020-03 read_stdin(), yell(), die() |
| 194 | # GNU sed v4.8 |
| 195 | while read -r line; do |
| 196 | |
| 197 | # Reject chars illegal in Mediawiki page titles. |
| 198 | re_illegal='[][><|}{#_]'; # match illegal page names chars #, <, >, [, ], _, {, |, } |
| 199 | if [[ "$line" =~ $re_illegal ]]; then |
| 200 | die "FATAL:Illegal char. Not allowed: #, <, >, [, ], _, {, |, }:$line"; |
| 201 | fi; |
| 202 | |
| 203 | # Reject trailing spaces. |
| 204 | re_ts=' $'; # match trailing space |
| 205 | if [[ "$line" =~ $re_ts ]]; then |
| 206 | die "FATAL:Trailing spaces not allowed:$line"; |
| 207 | fi; |
| 208 | |
| 209 | # Replace some chars with HTML-style codes |
| 210 | ## replace ampersand & with & # must be first |
| 211 | ## replace double quote " with " |
| 212 | ## replace single quote ' with ' |
| 213 | line="$(sed \ |
| 214 | -e 's/&/\&/g' \ |
| 215 | -e 's/"/\"/g' \ |
| 216 | -e "s/'/\'/g" \ |
| 217 | <<< "$line" )" || { echo "FATAL:Error running sed."; }; |
| 218 | printf "%s\n" "$line"; |
| 219 | done || { |
| 220 | echo "FATAL:Error reading stdin." 1>&2; return 1; }; |
| 221 | }; |
| 222 | generate_wikicode() { |
| 223 | # Desc: Generates navigational link wikicode for subpages |
| 224 | # Input: stdin validated subpage list |
| 225 | # Output: stdout wikicode |
| 226 | # Depends: get_path_fork_level() |
| 227 | # prune_path_rootside() |
| 228 | # get_path_hierarchy_level() |
| 229 | |
| 230 | n=0; |
| 231 | while read -r line; do |
| 232 | #yell "$n:Processing line:$line"; # debug |
| 233 | # Advance input lines |
| 234 | lprev="$lcurr"; |
| 235 | lcurr="$lnext"; |
| 236 | lnext="$line"; |
| 237 | #declare -p lprev lcurr lnext; # debug |
| 238 | |
| 239 | # Update hierarchy tracker states |
| 240 | lprev_hier="$lcurr_hier"; |
| 241 | lcurr_hier="$lnext_hier"; |
| 242 | lnext_hier="$(echo "$lnext" | get_path_hierarchy_level)"; |
| 243 | |
| 244 | # Skip first iteration |
| 245 | if [[ "$n" -eq 0 ]]; then |
| 246 | # yell "$n:DEBUG:Skipping first iteration."; # debug |
| 247 | ((n++)); |
| 248 | #printf -- "----\n" 1>&2; # debug |
| 249 | continue; fi; |
| 250 | |
| 251 | # Get path fork levels |
| 252 | fork_level_next="$(get_path_fork_level "$lcurr" "$lnext")"; |
| 253 | fork_level_prev="$(get_path_fork_level "$lcurr" "$lprev")"; |
| 254 | |
| 255 | # Count relative ups needed (`../`) |
| 256 | relups_next="$((lcurr_hier - fork_level_next + 1))"; |
| 257 | relups_prev="$((lcurr_hier - fork_level_prev + 1))"; |
| 258 | |
| 259 | # Initialize Next and Prev links with relative ups to fork. |
| 260 | link_next=""; |
| 261 | for (( i=0; i<relups_next; i++ )); do link_next+="../"; done; |
| 262 | if [[ "$relups_next" -eq 0 ]]; then link_next+="/"; fi; # handle new subpage path dive |
| 263 | link_prev=""; |
| 264 | for (( i=0; i<relups_prev; i++ )); do link_prev+="../"; done; |
| 265 | |
| 266 | # Append branchs from fork to Next and Prev targets |
| 267 | link_next+="$(prune_path_rootside "$lnext" "$fork_level_next")"; |
| 268 | link_prev+="$(prune_path_rootside "$lprev" "$fork_level_prev")"; |
| 269 | |
| 270 | # Print navigation link wikicode |
| 271 | if [[ -z "$lprev" ]]; then |
| 272 | printf "[[%s|Next]], [[../|Up]]\n" "$link_next"; |
| 273 | elif [[ -n "$lprev" ]]; then |
| 274 | printf "[[%s|Next]], [[%s|Previous]], [[../|Up]]\n" "$link_next" "$link_prev"; |
| 275 | elif [[ -z "$lnext" ]]; then |
| 276 | printf "[[%s|Previous]], [[../|Up]]\n" "$link_prev"; |
| 277 | else |
| 278 | echo "FATAL:Here be dragons." 1>&2; |
| 279 | fi; |
| 280 | |
| 281 | #declare -p n lprev lcurr lnext lprev_hier lcurr_hier lnext_hier; # debug |
| 282 | #declare -p fork_level_next fork_level_prev relups_next relups_prev; # debug |
| 283 | #declare -p link_next link_prev; # debug |
| 284 | ((n++)); |
| 285 | done < <(read_stdin); # read stdin plus one more blank line |
| 286 | }; # Generate wikicode from validated subpage lines |
| 287 | main() { |
| 288 | read_input "$@" | validate_subpage_list | generate_wikicode; |
| 289 | }; # main program |
| 290 | |
| 291 | main "$@"; |