Last active 1726989895

A Twtxt v2 Bash script reference implementation

twtxt-v2.sh Raw
1#!/bin/bash
2
3# twtxt.sh - A simple script to manage a Twtxt v2 feed with following capabilities
4
5FEED_FILE="twtxt.txt"
6CONFIG_DIR="$HOME/.config/twtxt"
7FOLLOWING_FILE="$CONFIG_DIR/following.txt"
8FEEDS_DIR="$CONFIG_DIR/feeds"
9TIMELINE_FILE="$CONFIG_DIR/timeline.txt"
10FEED_URLS_FILE="$CONFIG_DIR/feed_urls.txt"
11
12# Create configuration directories if they don't exist
13mkdir -p "$CONFIG_DIR"
14mkdir -p "$FEEDS_DIR"
15
16# Initialize feed file if it doesn't exist
17if [ ! -f "$FEED_FILE" ]; then
18 echo "# nick: ${USER}" >"$FEED_FILE"
19 echo "# url: https://yourdomain.com/$FEED_FILE" >>"$FEED_FILE"
20 echo "" >>"$FEED_FILE"
21fi
22
23# Get the URL of a feed from its metadata file
24# $1 - path to the metadata file
25get_feed_url() {
26 grep -m1 -E "^#\s*url\s*[:=]\s*" "$1" | tail -1 | sed 's/^#\s*url\s*[:=]\s*//'
27}
28
29# Get the nick of a feed from its metadata file
30# $1 - path to the metadata file
31get_feed_nick() {
32 grep -m1 -E "^#\s*nick\s*[:=]\s*" "$1" | tail -1 | sed 's/^#\s*nick\s*[:=]\s*//'
33}
34
35# Check if GNU date is available
36#
37# GNU date is needed to support the -d option for parsing dates in any format.
38# If GNU date is not available, the script will not be able to parse dates
39# in other formats than the default ISO 8601 format.
40check_gnu_date() {
41 if date --version >/dev/null 2>&1; then
42 DATE_IS_GNU=1
43 else
44 DATE_IS_GNU=0
45 fi
46}
47
48# Convert a timestamp in ISO 8601 format to a UNIX timestamp
49#
50# $1 - timestamp in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ)
51#
52# Returns the timestamp as a UNIX timestamp (seconds since Jan 1, 1970, 00:00:00 UTC)
53timestamp_to_unix() {
54 local timestamp="$1"
55 # Remove fractional seconds if present
56 timestamp=$(echo "$timestamp" | sed -E 's/\.[0-9]+//')
57 if [ "$DATE_IS_GNU" -eq 1 ]; then
58 # GNU date
59 date -u -d "$timestamp" +"%s" 2>/dev/null
60 else
61 # BSD date (macOS)
62 date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$timestamp" +"%s" 2>/dev/null
63 fi
64}
65
66# Calculate the Twt Hash for a given feed URL, timestamp, and content
67#
68# $1 - feed URL
69# $2 - timestamp
70# $3 - content
71#
72# Returns the first 11 characters of the SHA-256 hash of the concatenated
73# components, separated by newline characters.
74calculate_hash() {
75 local feed_url timestamp content data hash
76
77 feed_url="$1"
78 timestamp="$2"
79 content="$3"
80
81 # Concatenate components with newline separators
82 data="${feed_url}\n${timestamp}\n${content}"
83
84 # Calculate SHA-256 hash and get the first 11 characters
85 hash=$(echo -e "$data" | sha256sum | awk '{print $1}' | cut -c1-11)
86 echo "$hash"
87}
88
89# Post a new twt to the feed file
90# $1 - the content of the twt
91#
92# Appends the twt to the feed file and calculates the Twt Hash
93post_twt() {
94 local content timestamp feed_url hash
95
96 content="$1"
97 timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
98 feed_url=$(get_feed_url "$FEED_FILE")
99
100 # Append the twt to the feed file
101 echo -e "${timestamp}\t${content}" >>"$FEED_FILE"
102
103 # Calculate and display the Twt Hash
104 hash=$(calculate_hash "$feed_url" "$timestamp" "$content")
105 echo "Posted twt with hash: $hash"
106}
107
108# Reply to a twt
109#
110# $1 - the hash of the twt to reply to
111# $2 - the content of the reply
112#
113# Appends the reply to the feed file and calculates the Twt Hash
114reply_twt() {
115 local reply_to_hash="$1"
116 shift
117 local content="$* (reply-to:${reply_to_hash})"
118 post_twt "$content"
119}
120
121# Edit a twt
122#
123# $1 - the hash of the twt to edit
124# $2 - the new content of the twt
125#
126# Appends the edited twt to the feed file and calculates the Twt Hash
127edit_twt() {
128 local edit_hash="$1"
129 shift
130 local content="$* (edit:${edit_hash})"
131 post_twt "$content"
132}
133
134# Delete a twt
135#
136# $1 - the hash of the twt to delete
137#
138# Posts a special twt with the content "(delete:<hash>)" to the feed file,
139# which marks the twt with the given hash as deleted when processing the feed.
140delete_twt() {
141 local delete_hash="$1"
142 local content="(delete:${delete_hash})"
143 post_twt "$content"
144}
145
146# Calculate the hash of a twt in a feed file by line number
147#
148# $1 - line number of the twt
149# $2 - path to the feed file
150#
151# Extracts the timestamp and content of the twt from the feed file,
152# calculates the hash using the feed URL and the extracted timestamp
153# and content, and returns the hash.
154calculate_twt_hash_by_line() {
155 local line_number feed_file feed_url line_content timestamp content
156
157 line_number="$1"
158 feed_file="$2"
159 feed_url=$(get_feed_url "$feed_file")
160 line_content=$(sed -n "${line_number}p" "$feed_file")
161 timestamp=$(echo "$line_content" | cut -f1)
162 content=$(echo "$line_content" | cut -f2-)
163
164 calculate_hash "$feed_url" "$timestamp" "$content"
165}
166
167# Follow a new feed
168#
169# $1 - URL of the feed to follow
170#
171# If the feed is already being followed, a message is printed indicating
172# that. Otherwise, the URL is appended to the following file and a message
173# indicating that the feed was added is printed.
174follow_feed() {
175 local feed_url="$1"
176
177 # Check if the feed is already being followed
178 if grep -Fxq "$feed_url" "$FOLLOWING_FILE" 2>/dev/null; then
179 echo "You are already following $feed_url"
180 else
181 echo "$feed_url" >>"$FOLLOWING_FILE"
182 echo "Started following $feed_url"
183 fi
184}
185
186# Unfollow a feed
187#
188# $1 - URL of the feed to unfollow
189#
190# If the feed is being followed, the URL is removed from the following file
191# and a message indicating that the feed was removed is printed. Otherwise,
192# a message indicating that you are not following the feed is printed.
193unfollow_feed() {
194 local feed_url="$1"
195
196 if grep -Fxq "$feed_url" "$FOLLOWING_FILE" 2>/dev/null; then
197 grep -Fxv "$feed_url" "$FOLLOWING_FILE" >"${FOLLOWING_FILE}.tmp"
198 mv "${FOLLOWING_FILE}.tmp" "$FOLLOWING_FILE"
199 echo "Stopped following $feed_url"
200 else
201 echo "You are not following $feed_url"
202 fi
203}
204
205# Fetch all feeds listed in $FOLLOWING_FILE and store them in $FEEDS_DIR.
206#
207# For each feed, fetch the content using curl and store it in a file
208# with the same name as the feed URL, but with all non-alphanumeric
209# characters replaced with underscores. The mapping between the feed
210# file, feed URL, and nick is recorded in $FEED_URLS_FILE. If the feed
211# does not contain a # url: line, one is prepended to the feed file.
212fetch_feeds() {
213 if [ ! -f "$FOLLOWING_FILE" ]; then
214 echo "You are not following any feeds."
215 exit 1
216 fi
217
218 # Create or clear the mapping file
219 echo -n >"$FEED_URLS_FILE"
220
221 while read -r feed_url; do
222 feed_filename=${feed_url//[^a-zA-Z0-9]/_}
223 feed_path="$FEEDS_DIR/${feed_filename}.txt"
224
225 echo "Fetching $feed_url"
226 if curl -s -f -A "twtxt.sh/1.0 (+$feed_url; @${USER})" "$feed_url" -o "$feed_path"; then
227 # Extract nick from the feed
228 feed_nick="$(get_feed_nick "$feed_path")"
229 if [ -z "$feed_nick" ]; then
230 feed_nick="unknown"
231 fi
232 # Record the mapping between feed file, feed URL, and nick
233 echo "${feed_filename}.txt|${feed_url}|${feed_nick}" >>"$FEED_URLS_FILE"
234 # Check if the feed contains a # url: line
235 if ! grep -q "^# url:" "$feed_path"; then
236 # Prepend the # url: line to the feed file
237 sed -i "1i# url: $feed_url" "$feed_path"
238 fi
239 else
240 echo "Failed to fetch $feed_url"
241 fi
242 done <"$FOLLOWING_FILE"
243}
244
245# Display your timeline, which is a combined feed of your own feed and
246# the feeds you are following. The timeline is sorted in reverse
247# chronological order and the specified number of most recent twts are
248# displayed. If no number is specified, 20 twts are displayed.
249#
250# $1 - number of twts to display (default: 20)
251display_timeline() {
252 local num_twts="$1"
253 num_twts="${num_twts:-20}" # Default to 20 if not specified
254
255 # Remove any existing combined feed
256 combined_feed="$CONFIG_DIR/combined_feed.txt"
257 rm -f "$combined_feed"
258
259 # Check if GNU date is available
260 check_gnu_date
261
262 # Process your own feed
263 process_feed "$FEED_FILE" "$combined_feed" "local"
264
265 # Process followed feeds
266 if [ -d "$FEEDS_DIR" ]; then
267 for feed_file in "$FEEDS_DIR"/*.txt; do
268 [ -e "$feed_file" ] || continue
269 process_feed "$feed_file" "$combined_feed"
270 done
271 fi
272
273 # Sort and display the combined feed
274 sort -n -k 1 "$combined_feed" | tail -n "$num_twts" >"$TIMELINE_FILE"
275
276 # Display the timeline with ANSI colors
277 while IFS=$'\t' read -r unix_timestamp display_line; do
278 echo -e "$display_line"
279 done <"$TIMELINE_FILE"
280}
281
282# Process a single feed and append formatted twts to combined feed
283#
284# Reads a single feed, extracts the nick and URL from the metadata or
285# mapping file, formats each twt, and appends it to the combined feed.
286#
287# $1 - path to the feed file
288# $2 - path to the output file
289# $3 - if set to "local", this is your own feed
290process_feed() {
291 local feed_file output_file feed_filename feed_url feed_nick mapping_line feed_domain unix_timestamp hash display_line
292
293 feed_file="$1"
294 output_file="$2"
295 local_feed="$3" # If set to "local", this is your own feed
296
297 feed_filename=$(basename "$feed_file")
298
299 if [ "$local_feed" == "local" ]; then
300 # For your own feed, get the URL and nick from the metadata
301 feed_url=$(get_feed_url "$feed_file")
302 feed_nick=$(get_feed_nick "$feed_file")
303 else
304 # Get feed_url and feed_nick from mapping file
305 mapping_line=$(grep "^${feed_filename}|" "$FEED_URLS_FILE")
306 if [ -n "$mapping_line" ]; then
307 feed_url=$(echo "$mapping_line" | cut -d'|' -f2)
308 feed_nick=$(echo "$mapping_line" | cut -d'|' -f3)
309 else
310 # Fallback to metadata if mapping not found
311 feed_url=$(get_feed_url "$feed_file")
312 feed_nick=$(get_feed_nick "$feed_file")
313 fi
314 fi
315
316 # Ensure feed_url and feed_nick are set
317 [ -z "$feed_url" ] && feed_url="unknown"
318 [ -z "$feed_nick" ] && feed_nick="unknown"
319
320 feed_domain=$(echo "$feed_url" | awk -F[/:] '{print $4}')
321 [ -z "$feed_domain" ] && feed_domain="unknown"
322
323 # Read the feed and format each twt
324 while IFS=$'\t' read -r timestamp content; do
325 # Skip empty lines or lines without a timestamp
326 [[ "$timestamp" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T ]] || continue
327
328 # Calculate the UNIX timestamp
329 unix_timestamp=$(timestamp_to_unix "$timestamp")
330 if [ -z "$unix_timestamp" ]; then
331 # Skip if unable to parse timestamp
332 continue
333 fi
334
335 # Calculate the hash of the twt
336 hash=$(calculate_hash "$feed_url" "$timestamp" "$content")
337
338 # Format the display line
339 display_line="$(format_twt "$feed_nick" "$feed_domain" "$timestamp" "$hash" "$content")"
340
341 # Prepend the UNIX timestamp for sorting
342 echo -e "${unix_timestamp}\t${display_line}" >>"$output_file"
343 done <"$feed_file"
344}
345
346# Format a twt for display with ANSI colors
347#
348# $1 - nick
349# $2 - domain
350# $3 - timestamp
351# $4 - hash
352# $5 - content
353#
354# Returns a formatted line with ANSI colors.
355format_twt() {
356 local nick="$1"
357 local domain="$2"
358 local timestamp="$3"
359 local hash="$4"
360 local content="$5"
361
362 # ANSI color codes
363 local color_nick="\033[1;34m" # Bold Blue
364 local color_domain="\033[0;34m" # Blue
365 local color_timestamp="\033[0;32m" # Green
366 local color_hash="\033[0;33m" # Yellow
367 local color_reset="\033[0m" # Reset
368
369 # Build the formatted line
370 local formatted_line="${color_nick}${nick}${color_reset}@${color_domain}${domain}${color_reset} "
371 formatted_line+="[${color_timestamp}${timestamp}${color_reset}] "
372 formatted_line+="<${color_hash}${hash}${color_reset}> "
373 formatted_line+="${content}"
374
375 echo -e "$formatted_line"
376}
377
378# _main() - Main entry point for twtxt CLI
379#
380# Checks the first argument to determine the command to execute and
381# calls the appropriate function with the remaining arguments.
382#
383# Available commands:
384# post <message> - Post a new twt
385# reply <hash> <message> - Reply to a twt
386# edit <hash> <new message> - Edit a twt
387# delete <hash> - Delete a twt
388# calc-hash <line-number> - Calculate hash of twt at line number
389# follow <feed-url> - Follow a new feed
390# unfollow <feed-url> - Unfollow a feed
391# fetch - Fetch latest twts from followed feeds
392# timeline [N] - Display the latest N twts (default 20)
393_main() {
394 case "$1" in
395 post)
396 shift
397 if [ -z "$1" ]; then
398 echo "Usage: $0 post <message>"
399 exit 1
400 fi
401 post_twt "$*"
402 ;;
403 reply)
404 shift
405 if [ -z "$1" ] || [ -z "$2" ]; then
406 echo "Usage: $0 reply <reply-to-hash> <message>"
407 exit 1
408 fi
409 reply_to_hash="$1"
410 shift
411 reply_twt "$reply_to_hash" "$*"
412 ;;
413 edit)
414 shift
415 if [ -z "$1" ] || [ -z "$2" ]; then
416 echo "Usage: $0 edit <edit-hash> <new message>"
417 exit 1
418 fi
419 edit_hash="$1"
420 shift
421 edit_twt "$edit_hash" "$*"
422 ;;
423 delete)
424 shift
425 if [ -z "$1" ]; then
426 echo "Usage: $0 delete <delete-hash>"
427 exit 1
428 fi
429 delete_hash="$1"
430 delete_twt "$delete_hash"
431 ;;
432 calc-hash)
433 shift
434 if [ -z "$1" ]; then
435 echo "Usage: $0 calc-hash <line-number>"
436 exit 1
437 fi
438 line_number="$1"
439 hash=$(calculate_twt_hash_by_line "$line_number" "$FEED_FILE")
440 echo "Hash of twt at line $line_number: $hash"
441 ;;
442 follow)
443 shift
444 if [ -z "$1" ]; then
445 echo "Usage: $0 follow <feed-url>"
446 exit 1
447 fi
448 follow_feed "$1"
449 ;;
450 unfollow)
451 shift
452 if [ -z "$1" ]; then
453 echo "Usage: $0 unfollow <feed-url>"
454 exit 1
455 fi
456 unfollow_feed "$1"
457 ;;
458 fetch)
459 fetch_feeds
460 ;;
461 timeline)
462 shift
463 display_timeline "$1"
464 ;;
465 *)
466 echo "Usage: $0 <command> [options]"
467 echo "Commands:"
468 echo " post <message> - Post a new twt"
469 echo " reply <hash> <message> - Reply to a twt"
470 echo " edit <hash> <new message> - Edit a twt"
471 echo " delete <hash> - Delete a twt"
472 echo " calc-hash <line-number> - Calculate hash of twt at line number"
473 echo " follow <feed-url> - Follow a new feed"
474 echo " unfollow <feed-url> - Unfollow a feed"
475 echo " fetch - Fetch latest twts from followed feeds"
476 echo " timeline [N] - Display the latest N twts (default 20)"
477 ;;
478 esac
479}
480
481if [ -n "$0" ] && [ x"$0" != x"-bash" ]; then
482 _main "$@"
483fi

Powered by Opengist Load: 10ms