Initial commit: quoter CLI with 96 seed quotes

- quoter: bash CLI with random, list, search, add, delete, browse, TUI, import
- quotes.sql: 96 quotes (The Boys, Breaking Bad, Dark Knight, MCU, LOTR, GOT, games)
- install.sh: install/uninstall with --user/--system flags
- .gitignore: exclude .opencode, docs/superpowers, *.db, text.txt
This commit is contained in:
2026-05-25 16:52:22 +02:00
commit 303c56f03d
4 changed files with 1145 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
.opencode/
docs/superpowers/
*.db
text.txt
Executable
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BINARY="quoter"
check_sqlite3() {
if ! command -v sqlite3 &>/dev/null; then
echo "Error: sqlite3 is required but not installed." >&2
echo "" >&2
echo "Install it with one of:" >&2
echo " Debian/Ubuntu: sudo apt install sqlite3" >&2
echo " Fedora: sudo dnf install sqlite" >&2
echo " macOS: brew install sqlite3" >&2
echo " Arch: sudo pacman -S sqlite" >&2
exit 1
fi
echo " sqlite3: $(sqlite3 --version | head -c1; echo)"
}
check_optional_deps() {
local missing=()
if ! command -v fzf &>/dev/null; then
missing+=("fzf")
else
echo " fzf: $(fzf --version 2>/dev/null | head -c1; echo)"
fi
if ! command -v gum &>/dev/null; then
missing+=("gum")
else
echo " gum: $(gum --version 2>/dev/null | head -c1; echo)"
fi
if [[ ${#missing[@]} -gt 0 ]]; then
echo ""
echo " Note: Optional TUI dependencies not found: ${missing[*]}"
echo " quoter works without them, but for the best experience:"
echo ""
if [[ " ${missing[*]} " =~ " fzf " ]]; then
echo " fzf (interactive browsing & deletion):"
echo " Debian/Ubuntu: sudo apt install fzf"
echo " macOS: brew install fzf"
echo " Arch: sudo pacman -S fzf"
echo " Other: https://github.com/junegunn/fzf"
echo ""
fi
if [[ " ${missing[*]} " =~ " gum " ]]; then
echo " gum (styled display & interactive adding):"
echo " macOS: brew install gum"
echo " Arch: pacman -S gum"
echo " Other: https://github.com/charmbracelet/gum"
echo ""
fi
fi
}
do_install() {
local install_dir="$1"
echo "Installing ${BINARY}..."
check_sqlite3
if [[ "$install_dir" == "/usr/local/bin" ]]; then
if [[ $EUID -ne 0 ]]; then
echo " System install requires sudo. Re-running with elevated privileges..."
exec sudo "$0" --system
fi
fi
if [[ ! -d "$install_dir" ]]; then
echo " Creating directory: $install_dir"
mkdir -p "$install_dir"
fi
if [[ -f "$install_dir/$BINARY" ]]; then
echo " Updating existing installation at $install_dir/$BINARY"
else
echo " Installing to $install_dir/$BINARY"
fi
cp "$SCRIPT_DIR/$BINARY" "$install_dir/$BINARY"
chmod +x "$install_dir/$BINARY"
local data_dir="$HOME/.local/share/quoter"
mkdir -p "$data_dir"
if [[ -f "$SCRIPT_DIR/quotes.sql" ]]; then
cp "$SCRIPT_DIR/quotes.sql" "$data_dir/quotes.sql"
echo " Default quotes installed to $data_dir/quotes.sql"
fi
echo ""
echo " ${BINARY} installed successfully to $install_dir/$BINARY"
echo ""
check_optional_deps
echo ""
if ! echo "$PATH" | tr ':' '\n' | grep -qx "$install_dir"; then
echo " Note: $install_dir is not in your PATH."
echo " Add it by running:"
echo " echo 'export PATH=\"$install_dir:\$PATH\"' >> ~/.bashrc"
echo " source ~/.bashrc"
echo ""
fi
echo " Run ${BINARY} to get a random quote!"
}
do_uninstall() {
local found=0
echo "Uninstalling ${BINARY}..."
for dir in "$HOME/.local/bin" "/usr/local/bin"; do
if [[ -f "$dir/$BINARY" ]]; then
echo " Removing $dir/$BINARY"
rm -f "$dir/$BINARY"
found=1
fi
done
local data_dir="$HOME/.local/share/quoter"
if [[ -d "$data_dir" ]]; then
read -rp " Remove all quote data at $data_dir? [y/N] " confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
rm -rf "$data_dir"
echo " Data directory removed."
else
echo " Data directory kept at $data_dir"
fi
fi
if [[ "$found" -eq 0 ]]; then
echo " ${BINARY} was not found in standard locations."
else
echo ""
echo " ${BINARY} has been uninstalled."
fi
}
show_help() {
cat << HELP
${BINARY} installer
Usage:
$(basename "$0") [option]
Options:
--user Install to ~/.local/bin (default)
--system Install to /usr/local/bin (requires sudo)
--uninstall Remove ${BINARY} and optionally its data
--help Show this help
Data is stored in: ~/.local/share/quoter/quoter.db
HELP
}
ACTION="user"
while [[ $# -gt 0 ]]; do
case "$1" in
--user)
ACTION="user"
shift
;;
--system)
ACTION="system"
shift
;;
--uninstall)
ACTION="uninstall"
shift
;;
--help|-h)
show_help
exit 0
;;
*)
echo "Unknown option: $1" >&2
show_help >&2
exit 1
;;
esac
done
case "$ACTION" in
user)
do_install "$HOME/.local/bin"
;;
system)
do_install "/usr/local/bin"
;;
uninstall)
do_uninstall
;;
esac
Executable
+846
View File
@@ -0,0 +1,846 @@
#!/usr/bin/env bash
set -euo pipefail
DB_DIR="$HOME/.local/share/quoter"
DB="$DB_DIR/quoter.db"
BOLD=$(tput bold 2>/dev/null || echo "")
CYAN=$(tput setaf 6 2>/dev/null || echo "")
YELLOW=$(tput setaf 3 2>/dev/null || echo "")
GREEN=$(tput setaf 2 2>/dev/null || echo "")
DIM=$(tput dim 2>/dev/null || echo "")
RESET=$(tput sgr0 2>/dev/null || echo "")
HAS_FZF=false
HAS_GUM=false
command -v fzf &>/dev/null && HAS_FZF=true
command -v gum &>/dev/null && HAS_GUM=true
find_quotes_sql() {
if [[ -f "$DB_DIR/quotes.sql" ]]; then
echo "$DB_DIR/quotes.sql"
elif [[ -f "$(dirname "$(realpath "$0" 2>/dev/null || echo "$0")")/quotes.sql" ]]; then
echo "$(dirname "$(realpath "$0" 2>/dev/null || echo "$0")")/quotes.sql"
elif [[ -z "${QUOTER_SEED:-}" ]] && [[ -f "$QUOTER_SEED" ]]; then
echo "$QUOTER_SEED"
else
echo ""
fi
}
db_init() {
mkdir -p "$DB_DIR"
sqlite3 "$DB" "CREATE TABLE IF NOT EXISTS quotes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
quote TEXT NOT NULL,
source TEXT NOT NULL,
character TEXT NOT NULL,
season TEXT,
type TEXT DEFAULT 'tv',
added_at DATETIME DEFAULT CURRENT_TIMESTAMP
);"
local has_type
has_type=$(sqlite3 "$DB" "PRAGMA table_info(quotes);" 2>/dev/null | grep -c '^.*|type|.*$' || echo "0")
if [[ "$has_type" -eq 0 ]]; then
sqlite3 "$DB" "ALTER TABLE quotes ADD COLUMN type TEXT DEFAULT 'tv';"
sqlite3 "$DB" "UPDATE quotes SET type = 'movie' WHERE source IN ('The Dark Knight');"
sqlite3 "$DB" "UPDATE quotes SET type = 'tv' WHERE source IN ('Breaking Bad', 'The Boys');"
fi
}
db_seed() {
local count
count=$(sqlite3 "$DB" "SELECT COUNT(*) FROM quotes;")
if [[ "$count" -eq 0 ]]; then
local seed_file
seed_file=$(find_quotes_sql)
if [[ -n "$seed_file" ]]; then
sqlite3 "$DB" < "$seed_file"
fi
fi
}
import_quotes() {
local file="$ARG_IMPORT"
if [[ ! -f "$file" ]]; then
echo "Error: file not found: $file" >&2
exit 1
fi
sqlite3 "$DB" < "$file"
echo "Quotes imported from $file"
}
format_type() {
local t="$1"
case "$t" in
movie) echo "Movie" ;;
tv) echo "TV Show" ;;
game) echo "Game" ;;
*) echo "${t^}" ;;
esac
}
show_random() {
local result
result=$(sqlite3 "$DB" "SELECT id, quote, source, character, COALESCE(season, ''), COALESCE(type, 'tv') FROM quotes ORDER BY RANDOM() LIMIT 1;" 2>/dev/null)
if [[ -z "$result" ]]; then
echo "No quotes yet. Add one with ${BOLD}quoter --add${RESET}"
return
fi
IFS='|' read -r id quote source character season qtype <<< "$result"
if [[ "$ARG_SIMPLE" == "true" ]]; then
echo "\"${quote}\" — ${character}, ${source}"
return
fi
local season_part=""
if [[ -n "$season" ]]; then
season_part=" ${DIM}(${season})${RESET}"
fi
local type_label
type_label=$(format_type "$qtype")
if $HAS_GUM; then
local season_display=""
[[ -n "$season" ]] && season_display=" ($season)"
echo ""
printf '"%s"\n — %s, %s [%s]%s\n' "$quote" "$character" "$source" "$type_label" "$season_display" | gum style --border rounded --margin "1 2" --padding "1 3"
echo ""
else
echo ""
echo " ${BOLD}\"${quote}\"${RESET}"
echo " — ${CYAN}${character}${RESET}, ${YELLOW}${source}${RESET} ${DIM}[${type_label}]${RESET}${season_part}"
echo ""
fi
}
add_interactive() {
stty echo 2>/dev/null || true
local quote source character season qtype
if $HAS_GUM; then
quote=$(gum input --placeholder "Enter the quote")
[[ -z "$quote" ]] && { gum style --foreground 1 "Error: quote is required."; exit 1; }
source=$(gum input --placeholder "Source (movie, TV show, or game title)")
[[ -z "$source" ]] && { gum style --foreground 1 "Error: source is required."; exit 1; }
character=$(gum input --placeholder "Character who said it")
[[ -z "$character" ]] && { gum style --foreground 1 "Error: character is required."; exit 1; }
qtype=$(gum choose "tv" "movie" "game" --header="Select type")
if [[ "$qtype" == "tv" ]]; then
season=$(gum input --placeholder "Season/Episode (e.g. S01E01) or leave empty")
else
season=""
fi
local type_label
type_label=$(format_type "$qtype")
local season_display=""
[[ -n "$season" ]] && season_display=" ($season)"
echo ""
printf '"%s"\n — %s, %s [%s]%s\n' "$quote" "$character" "$source" "$type_label" "$season_display" | gum style --border rounded --margin "1 2" --padding "0 2"
echo ""
if gum confirm "Save this quote?"; then
if [[ -n "$season" ]]; then
sqlite3 "$DB" "INSERT INTO quotes (quote, source, character, season, type) VALUES ('$(sqlite3_escape "$quote")', '$(sqlite3_escape "$source")', '$(sqlite3_escape "$character")', '$(sqlite3_escape "$season")', '$(sqlite3_escape "$qtype")');"
else
sqlite3 "$DB" "INSERT INTO quotes (quote, source, character, type) VALUES ('$(sqlite3_escape "$quote")', '$(sqlite3_escape "$source")', '$(sqlite3_escape "$character")', '$(sqlite3_escape "$qtype")');"
fi
gum style --foreground 2 "Quote added!"
else
gum style --foreground 3 "Cancelled."
fi
else
echo "Add a new quote:"
echo ""
read -rp " Quote: " quote
read -rp " Source (movie/TV show/game): " source
read -rp " Character: " character
read -rp " Season/Episode (optional, e.g. S01E01): " season
echo " Type:"
echo " 1) tv"
echo " 2) movie"
echo " 3) game"
read -rp " Choose [1-3]: " type_choice
case "$type_choice" in
2) qtype="movie" ;;
3) qtype="game" ;;
*) qtype="tv" ;;
esac
if [[ -z "$quote" || -z "$source" || -z "$character" ]]; then
echo "Error: quote, source, and character are required." >&2
exit 1
fi
if [[ -n "$season" ]]; then
sqlite3 "$DB" "INSERT INTO quotes (quote, source, character, season, type) VALUES ('$(sqlite3_escape "$quote")', '$(sqlite3_escape "$source")', '$(sqlite3_escape "$character")', '$(sqlite3_escape "$season")', '$(sqlite3_escape "$qtype")');"
else
sqlite3 "$DB" "INSERT INTO quotes (quote, source, character, type) VALUES ('$(sqlite3_escape "$quote")', '$(sqlite3_escape "$source")', '$(sqlite3_escape "$character")', '$(sqlite3_escape "$qtype")');"
fi
echo "Quote added!"
fi
}
add_noninteractive() {
if [[ -z "$ARG_QUOTE" || -z "$ARG_SOURCE" || -z "$ARG_CHARACTER" ]]; then
echo "Error: --quote, --source, and --character are required when using --add non-interactively." >&2
echo "Run ${BOLD}quoter --help${RESET} for usage." >&2
exit 1
fi
local qtype="${ARG_TYPE:-tv}"
if [[ -n "$ARG_SEASON" ]]; then
sqlite3 "$DB" "INSERT INTO quotes (quote, source, character, season, type) VALUES ('$(sqlite3_escape "$ARG_QUOTE")', '$(sqlite3_escape "$ARG_SOURCE")', '$(sqlite3_escape "$ARG_CHARACTER")', '$(sqlite3_escape "$ARG_SEASON")', '$(sqlite3_escape "$qtype")');"
else
sqlite3 "$DB" "INSERT INTO quotes (quote, source, character, type) VALUES ('$(sqlite3_escape "$ARG_QUOTE")', '$(sqlite3_escape "$ARG_SOURCE")', '$(sqlite3_escape "$ARG_CHARACTER")', '$(sqlite3_escape "$qtype")');"
fi
echo "Quote added!"
}
sqlite3_escape() {
local str="$1"
str="${str//\'/\'\'}"
printf '%s' "$str"
}
list_quotes() {
local count
count=$(sqlite3 "$DB" "SELECT COUNT(*) FROM quotes;")
if [[ "$count" -eq 0 ]]; then
echo "No quotes yet. Add one with ${BOLD}quoter --add${RESET}"
return
fi
local where=""
local conditions=()
if [[ -n "$ARG_SOURCE" ]]; then
conditions+=("source LIKE '%$(sqlite3_escape "$ARG_SOURCE")%' COLLATE NOCASE")
fi
if [[ -n "$ARG_TYPE" ]]; then
conditions+=("type = '$(sqlite3_escape "$ARG_TYPE")'")
fi
if [[ -n "$ARG_CHARACTER" ]]; then
conditions+=("character LIKE '%$(sqlite3_escape "$ARG_CHARACTER")%' COLLATE NOCASE")
fi
if [[ ${#conditions[@]} -gt 0 ]]; then
where="WHERE $(IFS=' AND '; echo "${conditions[*]}")"
fi
if [[ "$ARG_SIMPLE" == "true" ]]; then
sqlite3 "$DB" "SELECT quote, character, source FROM quotes $where ORDER BY id;" | while IFS='|' read -r quote character source; do
echo "\"${quote}\" — ${character}, ${source}"
done
return
fi
printf "${BOLD}%-4s %-42s %-12s %-20s %-6s %-8s${RESET}\n" "ID" "Quote" "Type" "Source" "Season" "Char"
printf '%-4s %-42s %-12s %-20s %-6s %-8s\n' "----" "------------------------------------------" "------------" "--------------------" "------" "--------"
sqlite3 "$DB" "SELECT id, quote, type, source, COALESCE(season, '-'), character FROM quotes $where ORDER BY id;" | while IFS='|' read -r id quote qtype source season character; do
if [[ ${#quote} -gt 40 ]]; then
quote="${quote:0:37}..."
fi
if [[ ${#source} -gt 20 ]]; then
source="${source:0:17}..."
fi
if [[ ${#character} -gt 8 ]]; then
character="${character:0:5}..."
fi
local type_label
type_label=$(format_type "$qtype")
printf "%-4s %-42s %-12s %-20s %-6s %-8s\n" "$id" "$quote" "$type_label" "$source" "$season" "$character"
done
}
browse_quotes() {
if ! $HAS_FZF; then
echo "Error: --browse requires fzf. Install it from https://github.com/junegunn/fzf" >&2
exit 1
fi
local count
count=$(sqlite3 "$DB" "SELECT COUNT(*) FROM quotes;")
if [[ "$count" -eq 0 ]]; then
echo "No quotes yet. Add one with ${BOLD}quoter --add${RESET}"
return
fi
local selected
selected=$(sqlite3 "$DB" "SELECT id, quote, character, source, COALESCE(season, ''), COALESCE(type, 'tv') FROM quotes ORDER BY id;" | \
while IFS='|' read -r id quote character source season qtype; do
local type_label
type_label=$(format_type "$qtype")
local season_info=""
[[ -n "$season" ]] && season_info=" ($season)"
printf "%-4s %-50s — %s, %s [%s]%s\n" "$id" "$quote" "$character" "$source" "$type_label" "$season_info"
done | fzf \
--height=~80% \
--layout=reverse \
--border=rounded \
--header="Browse quotes (Enter to view, Esc to cancel)" \
--no-multi \
--ansi \
--cycle \
--query="${ARG_SEARCH:-}" 2>/dev/null)
if [[ -n "$selected" ]]; then
local sel_id
sel_id=$(echo "$selected" | awk '{print $1}')
show_quote_by_id "$sel_id"
fi
}
show_quote_by_id() {
local id="$1"
local result
result=$(sqlite3 "$DB" "SELECT quote, source, character, COALESCE(season, ''), COALESCE(type, 'tv') FROM quotes WHERE id = $id;" 2>/dev/null)
if [[ -z "$result" ]]; then
echo "Quote not found." >&2
return
fi
IFS='|' read -r quote source character season qtype <<< "$result"
local season_part=""
if [[ -n "$season" ]]; then
season_part=" ($season)"
fi
local type_label
type_label=$(format_type "$qtype")
if $HAS_GUM; then
echo ""
printf '"%s"\n — %s, %s [%s]%s\n' "$quote" "$character" "$source" "$type_label" "$season_part" | gum style --border rounded --margin "1 2" --padding "1 3"
echo ""
else
echo ""
echo " ${BOLD}\"${quote}\"${RESET}"
echo " — ${CYAN}${character}${RESET}, ${YELLOW}${source}${RESET} ${DIM}[${type_label}]${RESET}${DIM}${season_part}${RESET}"
echo ""
fi
}
search_quotes() {
if [[ -z "$ARG_SEARCH" ]]; then
echo "Error: --search requires a search term." >&2
exit 1
fi
local escaped
escaped=$(sqlite3_escape "$ARG_SEARCH")
local where="WHERE (quote LIKE '%${escaped}%' COLLATE NOCASE OR character LIKE '%${escaped}%' COLLATE NOCASE OR source LIKE '%${escaped}%' COLLATE NOCASE)"
local count
count=$(sqlite3 "$DB" "SELECT COUNT(*) FROM quotes $where;")
if [[ "$count" -eq 0 ]]; then
echo "No quotes found matching '${ARG_SEARCH}'."
return
fi
if [[ "$ARG_SIMPLE" == "true" ]]; then
sqlite3 "$DB" "SELECT quote, character, source FROM quotes $where ORDER BY id;" | while IFS='|' read -r quote character source; do
echo "\"${quote}\" — ${character}, ${source}"
done
return
fi
printf "${BOLD}%-4s %-42s %-12s %-20s %-6s %-8s${RESET}\n" "ID" "Quote" "Type" "Source" "Season" "Char"
printf '%-4s %-42s %-12s %-20s %-6s %-8s\n' "----" "------------------------------------------" "------------" "--------------------" "------" "--------"
sqlite3 "$DB" "SELECT id, quote, type, source, COALESCE(season, '-'), character FROM quotes $where ORDER BY id;" | while IFS='|' read -r id quote qtype source season character; do
if [[ ${#quote} -gt 40 ]]; then
quote="${quote:0:37}..."
fi
if [[ ${#source} -gt 20 ]]; then
source="${source:0:17}..."
fi
if [[ ${#character} -gt 8 ]]; then
character="${character:0:5}..."
fi
local type_label
type_label=$(format_type "$qtype")
printf "%-4s %-42s %-12s %-20s %-6s %-8s\n" "$id" "$quote" "$type_label" "$source" "$season" "$character"
done
}
delete_quote() {
if [[ -n "$ARG_DELETE" ]]; then
local result
result=$(sqlite3 "$DB" "SELECT id, quote, source, character FROM quotes WHERE id = $ARG_DELETE;" 2>/dev/null)
if [[ -z "$result" ]]; then
echo "Quote with ID $ARG_DELETE not found." >&2
exit 1
fi
IFS='|' read -r id quote source character <<< "$result"
if $HAS_GUM; then
echo ""
printf '"%s"\n — %s, %s\n' "$quote" "$character" "$source" | gum style --border rounded --margin "1 2" --padding "0 2"
echo ""
if gum confirm "Delete this quote?"; then
sqlite3 "$DB" "DELETE FROM quotes WHERE id = $ARG_DELETE;"
gum style --foreground 2 "Quote deleted."
else
gum style --foreground 3 "Cancelled."
fi
else
stty echo 2>/dev/null || true
echo "Delete this quote?"
echo " ${BOLD}\"${quote}\"${RESET} — ${CYAN}${character}${RESET}, ${YELLOW}${source}${RESET}"
echo ""
read -rp " Are you sure? [y/N] " confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
sqlite3 "$DB" "DELETE FROM quotes WHERE id = $ARG_DELETE;"
echo "Quote deleted."
else
echo "Cancelled."
fi
fi
else
if ! $HAS_FZF; then
echo "Error: --delete without ID requires fzf. Install it or use --delete <id>." >&2
exit 1
fi
local selected
selected=$(sqlite3 "$DB" "SELECT id, quote, character, source FROM quotes ORDER BY id;" | \
while IFS='|' read -r id quote character source; do
printf "%-4s %-50s — %s, %s\n" "$id" "$quote" "$character" "$source"
done | fzf \
--height=~80% \
--layout=reverse \
--border=rounded \
--header="Select a quote to delete (Esc to cancel)" \
--no-multi \
--ansi \
--cycle 2>/dev/null)
if [[ -n "$selected" ]]; then
local sel_id
sel_id=$(echo "$selected" | awk '{print $1}')
ARG_DELETE="$sel_id" delete_quote
fi
fi
}
tui_mode() {
if ! $HAS_FZF; then
echo "Error: --tui requires fzf." >&2
echo "Install it from https://github.com/junegunn/fzf" >&2
exit 1
fi
local quote_count
quote_count=$(sqlite3 "$DB" "SELECT COUNT(*) FROM quotes;" 2>/dev/null || echo "0")
local use_gum=false
$HAS_GUM && use_gum=true
local header
header=" quoter — ${quote_count} quotes in your collection"
tui_wait() {
fzf \
--height=~10% \
--layout=reverse \
--border=rounded \
--header="$1" \
--no-multi \
--disabled \
--prompt="" \
< /dev/null 2>/dev/null || true
}
tui_input() {
local label="$1"
fzf \
--height=~15% \
--layout=reverse \
--border=rounded \
--header="$label" \
--no-multi \
--disabled \
--prompt="> " \
--no-info \
< /dev/null 2>/dev/null || true
}
tui_confirm() {
local label="$1"
local result
result=$(printf "Yes\nNo\n" | fzf \
--height=~20% \
--layout=reverse \
--border=rounded \
--header="$label" \
--no-multi \
--prompt="> " \
2>/dev/null) || result="No"
[[ "$result" == "Yes" ]]
}
tui_add() {
local quote source character season qtype
quote=$(tui_input "Enter the quote")
[[ -z "$quote" ]] && { echo "Cancelled."; return; }
source=$(tui_input "Source (movie, TV show, or game title)")
[[ -z "$source" ]] && { echo "Cancelled."; return; }
character=$(tui_input "Character who said it")
[[ -z "$character" ]] && { echo "Cancelled."; return; }
qtype=$(printf "tv\nmovie\ngame\n" | fzf \
--height=~25% \
--layout=reverse \
--border=rounded \
--header="Select type" \
--no-multi \
--prompt="> " \
--cycle \
2>/dev/null) || qtype="tv"
season=""
if [[ "$qtype" == "tv" ]]; then
season=$(tui_input "Season/Episode (e.g. S01E01) — leave empty to skip")
fi
local type_label
type_label=$(format_type "$qtype")
local season_display=""
[[ -n "$season" ]] && season_display=" ($season)"
echo ""
printf '"%s"\n — %s, %s [%s]%s\n' "$quote" "$character" "$source" "$type_label" "$season_display"
if tui_confirm "Save this quote?"; then
if [[ -n "$season" ]]; then
sqlite3 "$DB" "INSERT INTO quotes (quote, source, character, season, type) VALUES ('$(sqlite3_escape "$quote")', '$(sqlite3_escape "$source")', '$(sqlite3_escape "$character")', '$(sqlite3_escape "$season")', '$(sqlite3_escape "$qtype")');"
else
sqlite3 "$DB" "INSERT INTO quotes (quote, source, character, type) VALUES ('$(sqlite3_escape "$quote")', '$(sqlite3_escape "$source")', '$(sqlite3_escape "$character")', '$(sqlite3_escape "$qtype")');"
fi
echo "Quote added!"
else
echo "Cancelled."
fi
}
while true; do
local choice
choice=$(printf "%s\n%s\n%s\n%s\n%s\n%s\n%s" \
"Random quote" \
"Search quotes" \
"List all quotes" \
"Browse quotes" \
"Add a quote" \
"Delete a quote" \
"Exit" \
| fzf \
--height=~40% \
--layout=reverse \
--border=rounded \
--header="$header" \
--prompt="> " \
--cycle \
--no-multi \
--ansi \
--marker="" \
--pointer=">" \
2>/dev/null)
[[ -z "$choice" ]] && break
case "$choice" in
"Random quote")
echo ""
show_random
echo ""
tui_wait "Press Enter to continue..."
;;
"Search quotes")
local selected
selected=$(sqlite3 "$DB" "SELECT id, quote, character, source, COALESCE(season, ''), COALESCE(type, 'tv') FROM quotes ORDER BY id;" | \
while IFS='|' read -r id quote character source season qtype; do
local type_label
type_label=$(format_type "$qtype")
local season_info=""
[[ -n "$season" ]] && season_info=" ($season)"
printf "%-4s %-50s — %s, %s [%s]%s\n" "$id" "$quote" "$character" "$source" "$type_label" "$season_info"
done | fzf \
--height=~70% \
--layout=reverse \
--border=rounded \
--header="Type to filter quotes — Enter to view, Esc to cancel" \
--prompt="> " \
--no-multi \
--ansi \
--cycle \
2>/dev/null)
if [[ -n "$selected" ]]; then
local sel_id
sel_id=$(echo "$selected" | awk '{print $1}')
echo ""
show_quote_by_id "$sel_id"
echo ""
fi
tui_wait "Press Enter to continue..."
;;
"List all quotes")
local filter_type
filter_type=$(printf "all\nmovie\ntv\ngame\n" \
| fzf \
--height=~30% \
--layout=reverse \
--border=rounded \
--header="Filter by type" \
--prompt="> " \
--no-multi \
--cycle \
2>/dev/null)
if [[ -n "$filter_type" ]]; then
echo ""
if [[ "$filter_type" == "all" ]]; then
ARG_TYPE="" ARG_SIMPLE="true" list_quotes
else
ARG_TYPE="$filter_type" ARG_SIMPLE="true" list_quotes
fi
echo ""
fi
tui_wait "Press Enter to continue..."
;;
"Browse quotes")
browse_quotes
tui_wait "Press Enter to continue..."
;;
"Add a quote")
echo ""
tui_add
echo ""
quote_count=$(sqlite3 "$DB" "SELECT COUNT(*) FROM quotes;" 2>/dev/null || echo "0")
header=" quoter — ${quote_count} quotes in your collection"
tui_wait "Press Enter to continue..."
;;
"Delete a quote")
echo ""
local selected
selected=$(sqlite3 "$DB" "SELECT id, quote, character, source FROM quotes ORDER BY id;" | \
while IFS='|' read -r id quote character source; do
printf "%-4s %-50s — %s, %s\n" "$id" "$quote" "$character" "$source"
done | fzf \
--height=~60% \
--layout=reverse \
--border=rounded \
--header="Select a quote to delete (Esc to cancel)" \
--no-multi \
--ansi \
--cycle \
--prompt="> " \
2>/dev/null)
if [[ -n "$selected" ]]; then
local sel_id
sel_id=$(echo "$selected" | awk '{print $1}')
local result
result=$(sqlite3 "$DB" "SELECT id, quote, source, character FROM quotes WHERE id = $sel_id;" 2>/dev/null)
if [[ -n "$result" ]]; then
IFS='|' read -r id quote source character <<< "$result"
printf '"%s"\n — %s, %s\n' "$quote" "$character" "$source"
if tui_confirm "Delete this quote?"; then
sqlite3 "$DB" "DELETE FROM quotes WHERE id = $sel_id;"
echo "Quote deleted."
else
echo "Cancelled."
fi
fi
fi
echo ""
quote_count=$(sqlite3 "$DB" "SELECT COUNT(*) FROM quotes;" 2>/dev/null || echo "0")
header=" quoter — ${quote_count} quotes in your collection"
tui_wait "Press Enter to continue..."
;;
"Exit")
break
;;
esac
done
echo " Bye!"
echo ""
}
show_help() {
cat << 'HELP'
quoter — Display quotes from movies, TV shows, and games
Usage:
quoter Show a random quote
quoter --tui Launch interactive TUI mode
quoter --add Add a quote (interactive)
quoter --add -q "quote" -s "source" -c "character" [-t type] [-e "S01E01"]
Add a quote (non-interactive)
quoter --list [-s "source"] [-t type] [-c "character"]
List quotes (optionally filtered)
quoter --browse Browse quotes interactively (fzf)
quoter --search "term" Search quotes, characters, sources
quoter --delete [id] Delete a quote (fzf picker if no ID)
quoter --import <file.sql> Import quotes from a SQL file
quoter --help Show this help
Options:
-a, --add Add a new quote
-q, --quote TEXT Quote text (with --add)
-s, --source TEXT Source title (with --add or --list)
-c, --char TEXT Character name (with --add or --list)
-e, --season TEXT Season/episode e.g. S01E01 (with --add, optional)
-t, --type TEXT Type: movie, tv, game (with --add or --list)
-l, --list List all quotes
-b, --browse Browse quotes interactively (requires fzf)
-S, --simple Simple output: just quote, character and source
-T, --tui Launch interactive TUI mode (requires fzf)
-i, --import FILE Import quotes from a SQL file
-f, --search TEXT Search quotes, characters, and sources
-d, --delete [ID] Delete a quote by ID, or pick interactively
-h, --help Show this help message
TUI features:
Install fzf for interactive browsing, deletion, and TUI mode
Install gum for styled display and interactive adding
Data is stored in: ~/.local/share/quoter/quoter.db
Default quotes loaded from: ~/.local/share/quoter/quotes.sql
HELP
}
ACTION=""
ARG_QUOTE=""
ARG_SOURCE=""
ARG_CHARACTER=""
ARG_SEASON=""
ARG_TYPE=""
ARG_SEARCH=""
ARG_DELETE=""
ARG_SIMPLE="false"
ARG_IMPORT=""
while [[ $# -gt 0 ]]; do
case "$1" in
-a|--add)
ACTION="add"
shift
;;
-q|--quote)
ARG_QUOTE="$2"
shift 2
;;
-s|--source)
ARG_SOURCE="$2"
shift 2
;;
-c|--char|--character)
ARG_CHARACTER="$2"
shift 2
;;
-e|--season)
ARG_SEASON="$2"
shift 2
;;
-t|--type)
ARG_TYPE="$2"
shift 2
;;
-l|--list)
ACTION="list"
shift
;;
-b|--browse)
ACTION="browse"
shift
;;
-S|--simple)
ARG_SIMPLE="true"
shift
;;
-T|--tui)
ACTION="tui"
shift
;;
-i|--import)
ACTION="import"
ARG_IMPORT="$2"
shift 2
;;
-f|--search)
ACTION="search"
ARG_SEARCH="$2"
shift 2
;;
-d|--delete)
ACTION="delete"
if [[ $# -gt 1 ]] && [[ "$2" =~ ^[0-9]+$ ]]; then
ARG_DELETE="$2"
shift 2
else
shift
fi
;;
-h|--help)
ACTION="help"
shift
;;
*)
echo "Unknown option: $1" >&2
echo "Run ${BOLD}quoter --help${RESET} for usage." >&2
exit 1
;;
esac
done
db_init
db_seed
case "$ACTION" in
add)
if [[ -n "$ARG_QUOTE" || -n "$ARG_SOURCE" || -n "$ARG_CHARACTER" || -n "$ARG_SEASON" || -n "$ARG_TYPE" ]]; then
add_noninteractive
else
add_interactive
fi
;;
list)
list_quotes
;;
browse)
browse_quotes
;;
tui)
tui_mode
;;
import)
import_quotes
;;
search)
search_quotes
;;
delete)
delete_quote
;;
help)
show_help
;;
"")
show_random
;;
*)
echo "Unknown action: $ACTION" >&2
exit 1
;;
esac
+97
View File
@@ -0,0 +1,97 @@
INSERT INTO quotes (quote, source, character, season, type) VALUES
('I am the one who knocks.', 'Breaking Bad', 'Walter White', 'S04E06', 'tv'),
('Never let them see you bleed.', 'The Boys', 'Homelander', 'S01E01', 'tv'),
('Why so serious?', 'The Dark Knight', 'Joker', NULL, 'movie'),
('Say my name.', 'Breaking Bad', 'Walter White', 'S05E04', 'tv'),
('Some people just want to watch the world burn.', 'The Dark Knight', 'Alfred Pennyworth', NULL, 'movie'),
('I am not in danger, Skyler. I am the danger.', 'Breaking Bad', 'Walter White', 'S04E06', 'tv'),
('It''s not about money. It''s about sending a message.', 'The Dark Knight', 'Joker', NULL, 'movie'),
('We can be heroes, just for one day.', 'The Boys', 'Queen Maeve', 'S01E06', 'tv'),
('Boy, that escalated quickly.', 'God of War', 'Kratos', NULL, 'game'),
('The right man in the wrong place can make all the difference.', 'Half-Life 2', 'G-Man', NULL, 'game'),
('The cake is a lie.', 'Portal', 'GLaDOS', NULL, 'game'),
('War. War never changes.', 'Fallout', 'Narrator', NULL, 'game'),
('A hero need not speak. When he is gone, the world will speak for him.', 'Halo', 'Narrator', NULL, 'game'),
('With great power comes the absolute certainty that you''ll turn into a right c**t.', 'The Boys', 'Butcher', 'S03E06', 'tv'),
('We''ll cross that bridge when we burn it.', 'The Boys', 'Butcher', 'S01E03', 'tv'),
('What''s Sporty Spice up to?', 'The Boys', 'Butcher', 'S01E04', 'tv'),
('A stranger is just a friend you ain''t met yet.', 'The Boys', 'Butcher', 'S02E03', 'tv'),
('Kid''s a weeper. Don''t want him to get snot on me jacket.', 'The Boys', 'Butcher', 'S02E04', 'tv'),
('For once, I''ve leveled the f**king playing field.', 'The Boys', 'Butcher', 'S03E05', 'tv'),
('Not the kid.', 'The Boys', 'Butcher', 'S03E08', 'tv'),
('You really are the spitting image of my little brother.', 'The Boys', 'Butcher', 'S03E08', 'tv'),
('I just had to pop down to the shop. I was running a bit low on mind your own f**king business.', 'The Boys', 'Butcher', 'S01E05', 'tv'),
('What have you got to lose that you ain''t already lost?', 'The Boys', 'Butcher', 'S01E01', 'tv'),
('People love that cozy feeling that Supes give them. Some golden c**t to swoop out of the sky and save the day so you don''t got to do it yourself.', 'The Boys', 'Butcher', 'S01E01', 'tv'),
('Never go into shark-infested waters without chum.', 'The Boys', 'Butcher', 'S02E06', 'tv'),
('It''s not power. It''s punishment.', 'The Boys', 'Butcher', 'S03E04', 'tv'),
('Don''t be a c**t.', 'The Boys', 'Butcher', 'S03E08', 'tv'),
('Congress. Please. What a bunch of corrupt f**king c**ts they are.', 'The Boys', 'Butcher', 'S02E08', 'tv'),
('Holy f**k. That was diabolical.', 'The Boys', 'Butcher', 'S01E05', 'tv'),
('Expecting a happy ending, were we? Well, I''m sorry, Hughie. It ain''t that kind of massage parlor.', 'The Boys', 'Butcher', 'S02E05', 'tv'),
('You''re a bunch of pathetic Supe-worshipping c**ts. I bet you''d thank a Supe if they sh*t on your mum''s best china.', 'The Boys', 'Butcher', 'S01E06', 'tv'),
('I''ll tickle your balls till you beg me to stop, and even then I won''t. I just won''t do it.', 'The Boys', 'Butcher', 'S02E03', 'tv'),
('Well, you could''ve warned us your pal Sameer was V-ing up a Kentucky Fried f**king massacre!', 'The Boys', 'Butcher', 'S04E05', 'tv'),
('Bloody hell, you w**k to your own voice, don''t you?', 'The Boys', 'Butcher', 'S04E01', 'tv'),
('You ain''t no God. How''s about I go fetch the virus, and then we''ll watch you sh*t your f**king spine out?', 'The Boys', 'Butcher', 'S05E04', 'tv'),
('Not so f**king super are you.', 'The Boys', 'Butcher', 'S05E04', 'tv'),
('I promise you, before I die, I''ll f**king have ya.', 'The Boys', 'Butcher', 'S05E04', 'tv'),
('No! My cake hole will remain open! You will never command me again. I am done with your cruelty. I deserve respect! And we all deserve paid vacation days, and a dental plan!', 'The Boys', 'Frenchie', 'S03E08', 'tv'),
('Nina was right. My papa, he put a chain around my neck. And all that f**king changes is who holds the other end.', 'The Boys', 'Frenchie', 'S03E08', 'tv'),
('So you lost your temper and hit a man. Oh, this is nothing. You know, I once took a Spaniard''s ear for jabbering at a screening of 27 Dresses...', 'The Boys', 'Frenchie', 'S03E08', 'tv'),
('It will be the greatest sorrow of my life that I missed Herogasm.', 'The Boys', 'Frenchie', 'S03E07', 'tv'),
('The Lord hates quitters.', 'The Boys', 'Frenchie', 'S03E07', 'tv'),
('It seems no matter how much we try to run, we cannot escape our old lives, huh? I suppose no one can run that fast.', 'The Boys', 'Frenchie', 'S03E06', 'tv'),
('Que c''est... Hamburger with a doughnut for a bun? Truly, there is no God here.', 'The Boys', 'Frenchie', 'S03E02', 'tv'),
('Girls do get it done.', 'The Boys', 'Frenchie', 'S02E08', 'tv'),
('Wile E. Coyote. Always chases Roadrunner, always with an elaborate plan, always fails. You know, I always say, "Why do this, Coyote? All you need is an AR-15, and meep-meep no more."', 'The Boys', 'Frenchie', 'S02E08', 'tv'),
('It won''t help you. All you''d be doing is ending his torment. You cannot punish him as much as he punishes himself.', 'The Boys', 'Frenchie', 'S02E06', 'tv'),
('Don''t be so closed-minded.', 'The Boys', 'Frenchie', 'S01E06', 'tv'),
('Cause all you say is oi, oi, oi, c**t, c**t, c**t!', 'The Boys', 'Kimiko', 'S05E01', 'tv'),
('I''m the guy who makes the deals.', 'The Boys', 'Homelander', 'S03E05', 'tv'),
('I''m not a superhero, I''m a supervillain.', 'The Boys', 'Butcher', 'S02E03', 'tv'),
('I am Iron Man.', 'Iron Man', 'Tony Stark', NULL, 'movie'),
('I love you 3000.', 'Avengers: Endgame', 'Tony Stark', NULL, 'movie'),
('I can do this all day.', 'Captain America: The First Avenger', 'Steve Rogers', NULL, 'movie'),
('Dormammu, I''ve come to bargain.', 'Doctor Strange', 'Doctor Strange', NULL, 'movie'),
('I am Groot.', 'Guardians of the Galaxy', 'Groot', NULL, 'movie'),
('The universe has put you in this position for a reason.', 'Avengers: Endgame', 'Tony Stark', NULL, 'movie'),
('With great power, comes great responsibility.', 'Spider-Man', 'Uncle Ben', NULL, 'movie'),
('I have nothing to prove to you.', 'Captain Marvel', 'Carol Danvers', NULL, 'movie'),
('Part of the journey is the end.', 'Avengers: Endgame', 'Tony Stark', NULL, 'movie'),
('I went for the head.', 'Avengers: Endgame', 'Thor', NULL, 'movie'),
('I''m always angry.', 'The Avengers', 'Bruce Banner', NULL, 'movie'),
('There was an idea... to bring together a group of remarkable people.', 'The Avengers', 'Nick Fury', NULL, 'movie'),
('The greater good.', 'Avengers: Age of Ultron', 'Ultron', NULL, 'movie'),
('I''m Mary Poppins, y''all!', 'Avengers: Endgame', 'Scott Lang', NULL, 'movie'),
('Don''t do anything I would do. And definitely don''t do anything I wouldn''t do.', 'Spider-Man: Homecoming', 'Tony Stark', NULL, 'movie'),
('One does not simply walk into Mordor.', 'The Lord of the Rings: The Fellowship of the Ring', 'Boromir', NULL, 'movie'),
('My precious.', 'The Lord of the Rings: The Two Towers', 'Gollum', NULL, 'movie'),
('Even the smallest person can change the course of the future.', 'The Lord of the Rings: The Fellowship of the Ring', 'Galadriel', NULL, 'movie'),
('A wizard is never late, nor is he early. He arrives precisely when he means to.', 'The Lord of the Rings: The Fellowship of the Ring', 'Gandalf', NULL, 'movie'),
('You shall not pass!', 'The Lord of the Rings: The Fellowship of the Ring', 'Gandalf', NULL, 'movie'),
('I would have followed you, my brother. My captain. My king.', 'The Lord of the Rings: The Fellowship of the Ring', 'Boromir', NULL, 'movie'),
('Not all those who wander are lost.', 'The Lord of the Rings: The Fellowship of the Ring', 'Aragorn', NULL, 'movie'),
('There is always hope.', 'The Lord of the Rings: The Two Towers', 'Aragorn', NULL, 'movie'),
('Fly, you fools!', 'The Lord of the Rings: The Fellowship of the Ring', 'Gandalf', NULL, 'movie'),
('I can avoid being seen if I wish, but to disappear entirely — that is a rare gift.', 'The Lord of the Rings: The Fellowship of the Ring', 'Aragorn', NULL, 'movie'),
('End? No, the journey doesn''t end here. Death is just another path, one that we all must take.', 'The Lord of the Rings: The Return of the King', 'Gandalf', NULL, 'movie'),
('All we have to decide is what to do with the time that is given to us.', 'The Lord of the Rings: The Fellowship of the Ring', 'Gandalf', NULL, 'movie'),
('I will take the ring to Mordor. Though... I do not know the way.', 'The Lord of the Rings: The Fellowship of the Ring', 'Frodo', NULL, 'movie'),
('A day may come when the courage of men fails, when we forsake our friends and break all bonds of fellowship. But it is not this day.', 'The Lord of the Rings: The Return of the King', 'Aragorn', NULL, 'movie'),
('No, Sam. I can''t recall the taste of food, nor the sound of water, nor the touch of grass.', 'The Lord of the Rings: The Return of the King', 'Frodo', NULL, 'movie'),
('When you play the game of thrones, you win or you die. There is no middle ground.', 'Game of Thrones', 'Cersei Lannister', 'S01E07', 'tv'),
('Winter is coming.', 'Game of Thrones', 'Ned Stark', 'S01E01', 'tv'),
('A lion doesn''t concern himself with the opinions of a sheep.', 'Game of Thrones', 'Tywin Lannister', 'S01E07', 'tv'),
('Chaos isn''t a pit. Chaos is a ladder.', 'Game of Thrones', 'Littlefinger', 'S03E06', 'tv'),
('The night is dark and full of terrors.', 'Game of Thrones', 'Melisandre', 'S02E05', 'tv'),
('I drink and I know things.', 'Game of Thrones', 'Tyrion Lannister', 'S06E02', 'tv'),
('Any man who must say "I am the king" is no true king.', 'Game of Thrones', 'Tywin Lannister', 'S03E10', 'tv'),
('If you think this has a happy ending, you haven''t been paying attention.', 'Game of Thrones', 'Ramsay Bolton', 'S03E07', 'tv'),
('The things I do for love.', 'Game of Thrones', 'Jaime Lannister', 'S01E02', 'tv'),
('Never forget what you are. The rest of the world will not. Wear it like armor, and it can never be used to hurt you.', 'Game of Thrones', 'Tyrion Lannister', 'S01E02', 'tv'),
('Valar morghulis.', 'Game of Thrones', 'Jaqen H''ghar', 'S02E10', 'tv'),
('Power resides where men believe it resides. It''s a trick, a shadow on the wall.', 'Game of Thrones', 'Varys', 'S02E03', 'tv'),
('Burn them all.', 'Game of Thrones', 'The Mad King', 'S06E05', 'tv'),
('A Lannister always pays his debts.', 'Game of Thrones', 'Tyrion Lannister', 'S01E03', 'tv'),
('I am the king! I am the king!', 'Game of Thrones', 'Joffrey Baratheon', 'S02E08', 'tv');