#!/usr/bin/awk -f

# dunst-context-menu.awk
# Purpose: display a clickable menu for URLs and/or actions associated with dunst notifications.
# Copyright(c) 2019 step
# License: MIT
# Requires: dunst, gtkmenuplus, gawk for i18n otherwise awk is enough
# Suggests: dunstify
# Portability: runs with any awk.
# Localization: ready, enabled if gawk is used.
# Version: 1.0.0

# Description {{{1
#
# This script is a drop-in replacement for dmenu specific to providing dunst
# with a selection context menu for URLs and actions.

# Installation
#
# Copy this file to a directory in your path and make it executable
# Optionally run gawk --gen-pot -f dunst-context-menu.awk > dunst-context-menu.pot
# and localize the pot file.
# https://www.gnu.org/software/gawk/manual/html_node/I18N-Example.html#I18N-Example


# Configuration {{{1
#
# Edit dunstrc (in /etc/xdg/dunst for Fatdog64 or /usr/share/dunst) and set
#
#	dmenu = /path/to/dunst-context-menu.awk
#	show_indicators = yes
#	browser = /path/to/browser
#	context = ctrl+shift+period

# Dunst interface {{{1
#
#	dunstify[:1] <--> dunst ---> browser
#                           ^.
#                            `> context menu
#
#       [:1] notify-send supports (U)RLs but not (A)ctions.
#
#	dunstify or notify-send[:1] --> dunst <--> dunst-context-menu.awk <- gtkmenuplus
#
#
# A notification message summary or body can include a single URL, e.g.:
#	dunstify 'http://one'
#	dunstify the.summary 'the.body \n http://one'
#	dunstify the.summary 'the.body \n <a href="http:/one">go one</a>"
# (dustify returns immediately)
# The notification text starts with "(U)". Middle clicking the notification
# balloon makes dunst start the browser.  Pressing the 'context' key binding
# (default Ctrl+Shift+.) displays a context menu (default dmenu) for the user
# to select which URL to browse (only one in this case). After the user's
# selection, dunst starts the browser.
#
# If the notification message includes multiple URLs, e.g.:
#	dunstify 'http://one' '<a href="http://two">go two</a>'
# (dustify returns immediately)
# right clicking the notification balloon or pressing the 'context' menu
# key binding both display the context menu.
#
# Dunst preserves URL input order, and internally formats URLs as follows:
#	http://one
#	[go two] http://two
#
# A notification message can include a single action, e.g.:
#	dunstify the.summary -A 'echo "ONE",one' &
# then the notification text starts with "(A)", and middle clicking the
# notification balloon makes dunst dispatch the formatted action text to
# dustify's stdout.  Dustify doesn't return until the action is dispatched.
# An action expires after its balloon has closed (left click or Ctrl+space).
#
# If the notification message includes multiple actions, e.g.:
#	dunstify summary -A 'echo "ONE",one' -A 'echo "TWO",two' &
# then right clicking the notification window or pressing the 'context' menu
# binding both display a context menu for the user to select which action to
# run. After the user's selection, dunst dispatches the selected action text to
# dustify's stdout.  Dustify doesn't return until the action is dispatched.
#
# Dunst does not preserve the input order of actions, and internally formats
# them as follows:
#	#two (summary) [12,Xdialog --msgbox "TWO" 0x0]
#	#one (summary) [12,Xdialog --msgbox "ONE" 0x0]
# where 12 is the notification ID (dunstify -p prints the ID).
# The body of each action is the text between ',' and ']'.
#
# A message can include multiple actions and multiple URLs, e.g.:
#       dunstify 'http://one' 'http://two' -A '789,three' -A 'abc,four'
# then the notification text starts with "(AU)". If the user selects a URL then
# dunst runs the browser. If the user selects an action then dunst dispatches
# the formatted action text to dustify's stdout. Dustify doesn't return until
# the action is dispatched or the browser is started.

# This script {{{1
#
# This script is a drop-in replacement for dmenu specific to the dunst interface.
# It can work in two modes: selection only mode and execution mode.
# In selection mode this script sends formatted selections back to dunst, which
# then either starts the browser (U) or dispatches the action (A) to the client,
# which can be dustify or a custom client.  Selection mode should be compatible
# with existing clients that wait for dunst to dispatch them the selection
# (notify-send does *not* wait; dunstify does wait).
#
#       dunstify[:1] <--> dunst ---> browser
#                           ^.
#    selection mode          `> dunst-context-menu <--> gtkmenuplus
#
# In execution mode this script directly runs the browser or the selected action
# without sending the selection back to dunst. Of course, this only makes sense
# if you specify actions that can run, such as shell commands or executable
# file paths. In execution mode the client does not *need* to wait, because
# this script takes care of running the actions directly.
#
#       dunstify[:1] <--> dunst                              .-> (U) browser
#                           `.                              /
#    execution mode          `> dunst-context-menu --> gtkmenuplus --> (A) stdout
#
# By default, this script operates in selection mode for compatibility with
# existing clients.

# Modes: Execution vs. Selection
#  Execution: the client specifieѕ actions, this script executes them.
#  Selection: the client specifies actions and executes them.
#  Execution: Actions never expire, they can be retrieved/replayed from dunst's history.
#  Selection: Actions expire when their balloon is closed or times out.
#
# Modifiers:
# Through this script, a client can change the mode and tailor actions by
# appending modifier strings at the end of any action body, before the comma
# that separates the label.  Multiple modifiers are permitted. In execution
# mode modifiers are deleted before the action is executed directly.  Instead
# in selection mode modifiers are left in place, therefore they are dispatched
# as part of the action body.
# The modifiers follow:
#
#            Always honored
# ":mode=exec"                   set execution mode for the notification
# ":hide=yes"                    exclude the action from the context menu

#       Honored in execution mode only
# ":browser=<command>"           open ALL URLs with <command>[:1]
# ":stdout=<path>"               redirect ALL actions' stdout[:2]
# ":stderr=<path>"               redirect ALL actions' stderr[:2]
# ":launcher=/filepath.desktop"  launch the desktop file[:3]
# ":icon=iconpath_or_name"       set action icon[:4]
# ":tooltip=text"                set tooltip text[:4]
#
# [:1] %u in <command> is substituted with the selected URL
# [:2] default: redirect stdout/stderr to /dev/null
# [:3] specify either launcher= or an action body, not both.
#      launcher label, icon and tooltip are read from the .desktop file, cf. `man 5 gtkmenuplus`.
# [:4] Default action icon: "gtk-execute". URL action can't be changed.
# [:5] Default action tooltip: "run %s", <action body>. URL tooltip can't be changed.
#
# Example:
#	dunstify example -A ':hide=yes,1' -A ':hide=yes:mode=exec:browser=yad --text=%u,2' http://one
#
# formatted:
#	#2 (example) [8,:hide=yes:mode=exec:browser=yad --text=%u]
#	#1 (example) [8,:hide=yes]
#	http://one
#
# displays a menu with one entry, "http://one", which runs `yad --text=http://one`.
#
# Example:
#	dunstify example -A ':hide=yes:mode=exec:stdout=/tmp/test,1' -A 'date,see /tmp/test'
#
# displays a menu with one entry, "see /tmp/test", which writes the current date to file /tmp/test.
#

# How to force displaying the context menu
#
# Since dunst calls this script and not viceversa, there is nothing that this
# script can do to force displaying the menu - it's up to dunst. These are
# dunst's rules:
# A) With only one URL, or one action, or one rule and one action:
#   - Middle-click does not display the menu
#   - Shift+Ctrl+. displays the menu.
# B) With three or more elements:
#   - Middle-click and Shift+Ctrl+. display the menu.
#
# The client can force displaying the menu even in case A for a
# middle-click by adding two hidden actions, e.g.:
#	dunstify -A 'x:hide=yes,_' -A 'y:hide=yes,_' http://go.there
#
# Note that the two actions must differ -- 'x' vs. 'y' in the example.

# {{{1}}}

BEGIN {
	# requires gawk
	TEXTDOMAIN = "fatdog"

	# bail out parser loop at
	g_max_modifier = 16

	# Global properties for all actions

	Gp["mode"] = "selection"
	Gp["stdout"] = Gp["stderr"] = "/dev/null"
	Gp["browser"] = "/usr/local/bin/defaultbrowser"

	#DEBUG g_menu = "exec yad --text-info --geometry=1000x700+70+50"
}

### Component properties

{
	type = item = icon = data = tooltip = ""
}

### Got URL?

/^[[]/ || /^[hH][tT][tT][pP][sS]?:\/\// {

	## parse formatted URL

	if(1 == index($0, "[") && 0 < (p = index($0, "]")) ) {
		label = substr($0, 2, p - 2)
		url = substr($0, p + 2)

	} else { # parse bare URL
		label = substr($0, index($0, "/") + 2)
		url = $0
	}

	# chop HTTP GET variables
	if(p = index(label, "?")) {
		label = substr(label, 1, p - 1)
	}

	# to menu
	type = "url"
	item = label
	icon = _"/usr/share/pixmaps/midi-icons/www48.png #dunst"
	data = url
	ttip = sprintf(_"browse %s", url)
}

### Parse action modifiers
# return: s with modifiers stripped, array rA by reference
# input s: [<commands>] [':'<name> '=' <value>]* ']'
# input regex: ERE <name> [ '|' <name> ]*
function parse_modifiers(s, rA, regex,   p, name, value, max) {
	while(match(s, ":("regex")=[^]:]*") && max++ < g_max_modifier) {
		RSTART += 1; RLENGTH -= 1 # chop ^:
		p     = index(substr(s, RSTART), "=")
		name  = substr(s, RSTART, p - 1)
		value = substr(s, RSTART + p, RLENGTH - p)
		rA[name] = value
		gsub(":"name"=[^]:]*", "", s)
	}
	return s
}

### Got action?

/^#/ {
	## Parse formatted action '#'<label> ' (' <subject> ') [' <id> ', '<action with modifiers> ']'

	if(0 < (p = index($0, "(")) ) {

		label = substr($0, 2, p - 3)
		s = substr($0, p + 1)
		if(0 < (p = index(s, ") [")) ) {
			subject = substr(s, 1, p - 1) # not used
			s = substr(s, p + 3)
			#               vv  :  an id can be negative, too
			if(match(s, /^-?[[:digit:]]+,.+]/)) {
				p = index(substr(s, RSTART), ",")
				id = substr(s, RSTART, p - 1)
				s = substr(s, p + 1)

				# parse action modifiers => Ap[]
				delete(Ap)
				s = parse_modifiers(s, Ap, "hide|launcher|icon|tooltip")

				# parse global modifiers => Gp[]
				s = parse_modifiers(s, Gp, "mode|browser|stdout|stderr")

				# continue to next action if this one is hidden
				if("yes" == Ap["hide"]) {
					next
				}

				action = "" != Ap["launcher"] ? Ap["launcher"] : substr(s, 1, length(s) - 1)
				tooltip = "" != Ap["tooltip"] ? Ap["tooltip"] : sprintf(_"run %s", action)

				# to menu
				type    = "action"
				item    = label
				icon    = ("" != Ap["icon"] ? Ap["icon"] : _"gtk-execute #dunst")
				data    = action
				ttip = tooltip
			}
		}
	}
}

### Save for menu END game

{
	++nC
	C[nC, "$0"]       = $0
	C[nC, "s"]        = s
	C[nC, "type"]     = type
	C[nC, "item"]     = item
	C[nC, "icon"]     = icon
	C[nC, "ttip"]     = ttip
	C[nC, "data"]     = data
	C[nC, "blau"]     = "" != Ap["launcher"] # data == <path>
}

### Show menu

END {
	# the menu pipe
	if("" == g_menu) {
		g_menu = "gtkmenuplus -q -q -"

		if("exec" == Gp["mode"]) {
			# redirect menu stdout and stderr away from dunst's input pipe
			if("" != Gp["stdout"]) {
				g_menu = g_menu "  > \"" Gp["stdout"] "\""
			}
			if("" != Gp["stderr"]) {
				g_menu = g_menu " 2> \"" Gp["stderr"] "\""
			}
		}
	}

	# initialize the menu
	print _"iconsize=24 #dunst" | g_menu
	print _"configure=errorconsoleonly nolaunchernodisplay #dunst" | g_menu

	for(i = 1; i <= nC; i++) {
		line = C[i, "$0"]
		type = C[i, "type"]
		item = C[i, "item"]
		icon = C[i, "icon"]
		ttip = C[i, "ttip"]; gsub(/#/, "⋕", ttip) # U+22D5
		data = C[i, "data"]
		blau = C[i, "blau"]

		if("selection" == Gp["mode"]) {
			cmd = "echo " (1 == index(line, "#") ? "\\" : "") line
			# Why the \\?  Gtkmenudialog passes cmd verbatim to
			# g_spawn_command_line_async --> g_shell_parse_argv -->
			# tokenize_command_line, which is designed to discard
			# everything between # in position 1 and \n unless # is
			# escaped with \.

		} else if("exec" == Gp["mode"]) {
			if("url" == type) {
				if("" == Gp["browser"]) {
					cmd = "defaultbrowser "data
				} else {
					cmd = Gp["browser"]
					gsub(/%u/, data, cmd)
				}

			} else if("action" == type) {
				if(blau) {
					printf "launcher=%s\n", data | g_menu
					continue
				}
				cmd = data

			} else {
				item = sprintf(_"ERROR: input: %s", C[i, "$0"]) # shouldn't happen
			}
		}

# URL, action (except launcher) and ERROR, any mode:
		printf "item=%s\nicon=%s\ncmd=%s\ntooltip=%s\n", item, icon, cmd, ttip | g_menu
	}
	close(g_menu)
}
