From 35c6c9f6e538e972a1deace9068d7ada2f59b14c Mon Sep 17 00:00:00 2001 From: Ariel Calzada Date: Thu, 13 Sep 2012 13:41:06 +0000 Subject: Code divided in modules and process handler improved --- diff --git a/activity/activity.info b/activity/activity.info index 0a96a25..4bc2190 100644 --- a/activity/activity.info +++ b/activity/activity.info @@ -3,5 +3,5 @@ name = Screencast bundle_id = org.laptop.Screencast exec = sugar-activity screencast_activity.ScreencastActivity icon = screencast-icon -activity_version = 6 +activity_version = 6.1 license = GPLv3+ diff --git a/screencast_activity.py b/screencast_activity.py index d085656..e87383f 100644 --- a/screencast_activity.py +++ b/screencast_activity.py @@ -1,4 +1,6 @@ -# Copyright 2008 Chris Ball. +# -*- coding: utf-8 -*- + +# Copyright 2012 Ariel Calzada - ariel@activitycentral.com # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -14,233 +16,134 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA -"""Screencast Activity: An activity for producing XO tutorials.""" +""" +Screencast Activity: An activity for producing XO tutorials. +Based on http://git.sugarlabs.org/screencast +""" + +# Localization from gettext import gettext as _ -from dbus.service import method -from dbus.service import signal as dbus_signal -import fcntl -import gobject -import gtk -import logging -import os -import popen2 -import re -import shutil -import signal -import sys +# Activity base class from sugar.activity import activity + +# Toolbar from sugar.activity.activity import ActivityToolbox -from sugar.activity.activity import get_bundle_path -from sugar.activity.activity import get_bundle_name -from sugar.graphics.alert import NotifyAlert -from sugar.graphics.combobox import ComboBox -SERVICE = "org.laptop.Screencast" -IFACE = SERVICE -PATH = "/org/laptop/Screencast" -OUTFILE = "/tmp/recordmydesktop.ogv" +# GTK +import gtk + +# UI +import screencast_ui + +# Process +import screencast_process +# GObject +import gobject + +# OS +import os + +# Bundlepath +from sugar.activity.activity import get_bundle_path class ScreencastActivity(activity.Activity): - """Screencast Activity as specified in activity.info""" + """ Screencast Activity + """ + + # Attributes + _ui = None + _process = None + _outfile = None + _state = None + def __init__(self, handle): - """Set up the Screencast activity.""" + """ Constructor + """ + + # Call super class "Activity" constructor method super(ScreencastActivity, self).__init__(handle) - self._logger = logging.getLogger('screencast-activity') - self.timed_id2 = None - - from sugar.graphics.menuitem import MenuItem - from sugar.graphics.icon import Icon - - # Main layout. Record button, stop button, label. - hbox = gtk.HBox() - vbox = gtk.VBox() - - # Toolbar. - toolbox = ActivityToolbox(self) - activity_toolbar = toolbox.get_activity_toolbar() - activity_toolbar.remove(activity_toolbar.share) - activity_toolbar.share = None - activity_toolbar.remove(activity_toolbar.keep) - activity_toolbar.keep = None - self.set_toolbox(toolbox) - toolbox.show() - - # Recording buttons. - self.record = gtk.Button("Record") - self.record.connect("clicked", self.record_cb) - self.record.set_size_request(150, 150) - recimage = gtk.Image() - recimage.set_from_icon_name("media-record", -1) - self.record.set_image(recimage) - self.stop = gtk.Button("Stop") - self.stop.connect("clicked", self.stop_cb) - self.stop.set_size_request(150, 150) - self.stop.set_sensitive(False) - stopimage = gtk.Image() - stopimage.set_from_icon_name("media-playback-stop", -1) - self.stop.set_image(stopimage) - - # Record sound checkbox and quality selector - hbox2 = gtk.HBox(spacing=50) - self.audiocheckbox = gtk.CheckButton(label="record sound") - self.audiocheckbox.set_active(True) - hbox2.add(self.audiocheckbox) - self.qualitycombo = ComboBox() - self.qualitycombo.append_item("0", "high quality video") - self.qualitycombo.append_item("1", "medium quality video") - self.qualitycombo.append_item("2", "low quality video") - self.qualitycombo.set_active(2) - - hbox2.add(self.qualitycombo) - options = gtk.Alignment(0.5, 0, 0, 0) - options.add(hbox2) - - # Status label. - self.status = gtk.Label(_("Status: Stopped")) - - hbox.pack_start(self.record, expand=False, padding=40) - hbox.pack_start(self.stop, expand=False, padding=40) - - # Encoding progress bar - self.progressbar = gtk.ProgressBar(adjustment=None) - self.progressbar.set_fraction(0) - self.progressbar.set_text("0% complete") - - valign = gtk.Alignment(0.5, 0.4, 0, 0) - valign.add(vbox) - vbox.pack_end(self.progressbar, expand=True, padding=20) - vbox.pack_end(self.status, expand=True, padding=40) - vbox.pack_end(hbox, expand=True, fill=False) - vbox.pack_end(options, expand=True, padding=40) - - self.set_canvas(valign) - self.show_all() - self.progressbar.hide() - - def write_file(self, file_path): - print "Saving file to %s" % file_path - self.metadata['mime_type'] = 'video/ogg' - #try: - # shutil.copy(OUTFILE, file_path) - - #except IOError, e: - # print "unable to save to outfile: %s" % e - - - # FIXME: This fails in /tmp. - # that comment by probably cjb - # I have no idea why it was saving to filepath - #added copy-to-journal in check_status_cb - #error msgs are OK probably just no video processed - #Tony Forster - - #try: - - # os.remove(OUTFILE) - #except OSError, e: - # print "unable to remove outfile: %s" % e - def can_close(self): - if self.status.get_text().startswith("Status: Stopped"): - return True - else: - self.alert("You need to finish operation before quitting.", self.status.get_text()) - - def alert(self, title, text=None): - alert = NotifyAlert(timeout=10) - alert.props.title = title - alert.props.msg = text - self.add_alert(alert) - alert.connect('response', self.alert_cancel_cb) - alert.show() - - def alert_cancel_cb(self, alert, response_id): - self.remove_alert(alert) - - def record_cb(self, record): - self.stop.set_sensitive(True) - self.record.set_sensitive(False) - self.audiocheckbox.set_sensitive(False) - self.qualitycombo.set_sensitive(False) - execargs = ["sleep 5", "./recordmydesktop", "--fps 15", "--quick-subsampling", "--no-frame", "--overwrite"] - if not self.audiocheckbox.get_active(): - execargs.append("--no-sound") - if self.qualitycombo.get_active() == 0: - execargs.append("-v_quality") # in later versions seems to be --v_quality instead - execargs.append("0") - elif self.qualitycombo.get_active() == 1: - execargs.append("-v_quality") # in later versions seems to be --v_quality instead - execargs.append("31") - execargs.append("-o") - execargs.append(OUTFILE) - self.childp = popen2.Popen3(execargs, "t", 0) - flags = fcntl.fcntl(self.childp.childerr, fcntl.F_GETFL) - fcntl.fcntl(self.childp.childerr, fcntl.F_SETFL, flags | os.O_NONBLOCK) - flags = fcntl.fcntl(self.childp.fromchild, fcntl.F_GETFL) - fcntl.fcntl(self.childp.fromchild, fcntl.F_SETFL, flags | os.O_NONBLOCK) - self.timed_id = gobject.timeout_add(1000, self.check_status_cb) - self.status.set_text("Status: Recording") - - def stop_cb(self, stop): - exitret = os.waitpid(self.childp.pid, os.WNOHANG) - if exitret[0] == 0: - os.kill(self.childp.pid, signal.SIGTERM) - self.stop.set_sensitive(False) - - def update_counter(self): - self.progressbar.show() - while True: - try: - strstdout = self.childp.fromchild.read() - self.counter_fraction = float(re.search("[0-9][0-9]?[0-9]?", strstdout).group()) - percentage = self.counter_fraction / 100.0 - if percentage > 1.0: - percentage = 1.0 - #print "PORCENTAJE %s " % str(percentage) - self.progressbar.set_fraction(percentage) - self.progressbar.set_text("%d%%"%int(percentage * 100)+' complete') - except IOError: - gtk.main_iteration(block=False) - except AttributeError: - break - except: - print "Unexpected error:", sys.exc_info()[0] - print "Unexpected error:", sys.exc_info()[1] - break - - def check_status_cb(self): - if self.childp.pid: - exitret = os.waitpid(self.childp.pid, os.WNOHANG) - if exitret[0] != 0: - # The recording process exited - self.status.set_text("Status: Stopped") - if self.timed_id2: - gobject.source_remove(self.timed_id2) - self.timed_id2 = None - self.progressbar.hide() - self.alert("Success:", "Saved recording to journal") - self.progressbar.set_fraction(0) - self.progressbar.set_text('0% complete') - self.record.set_sensitive(True) - self.audiocheckbox.set_sensitive(True) - self.qualitycombo.set_sensitive(True) - if self._jobject.metadata['title_set_by_user'] == '1': - title = self.metadata['title'] - else: - title = "My Screencast" - os.system("copy-to-journal /tmp/recordmydesktop.ogv -m video/ogg -t \"%s\""% title) - return False + # State + self._state = "stop" + + # Out file + self._outfile = os.path.join( get_bundle_path(), "screencast.ogv" ) + + # Build GUI + self._ui = screencast_ui.ScreencastUI(self) + self._ui.buildGUI() + + # Show GUI + self._ui.showGUI() + + # Process + self._process = screencast_process.ScreencastProcess() + self._process.connect('encode-start', self.startEncode) + self._process.connect('encode-finished', self.finishEncode) + self._process.connect('update-statusbar', self.updateStatusbar) + + # Connect UI signals + self._ui.connect('record-button-clicked-signal', self.recordButtonClicked) + self._ui.connect('stop-button-clicked-signal', self.stopButtonClicked) + + def recordButtonClicked(self, widget): + """ Record button clicked event + """ + self._ui.changeButtonsState("record") + self._process.runProcess(self._ui.getCurrentQuality(), self._ui.isSoundCheckActive(), self._outfile) + self._state = "record" + + def stopButtonClicked(self, widget): + """ Stop button clicked event + """ + self._ui.changeButtonsState("encode") + self._process.stopProcess() + + def startEncode(self,widget): + """ Start encoding + """ + self._ui.showProgressbar() + self._state = "encode" + + def finishEncode(self,widget): + """ Finish encoding + """ + self._ui.changeButtonsState("stop") + self._ui.hideProgressbar() + self._state = "stop" + self._ui.alert("Encode finished") + + + def updateStatusbar(self, widget, text, percentage): + """ Update status bar + """ + text = "Status: Encoding" + self._ui.changeStatusbarText(text) + self._ui.updateProgressbar(percentage) + + def write_file(self, filePath): + """ Journal write file method + """ + + if os.path.exists(self._outfile) and self._state == "stop": + self.metadata['mime_type'] = 'video/ogg' + + if self._jobject.metadata['title_set_by_user'] == '1': + title = self.metadata['title'] else: - # Maybe we have new stderr. - while True: - try: - err_line = self.childp.childerr.readline() - if err_line.startswith("STATE:ENCODING"): - if not self.timed_id2: - self.timed_id2=gobject.timeout_add(300, self.update_counter) - self.status.set_text("Status: Encoding, please wait") - except: - break + title = "My Screencast" + + cmd = "copy-to-journal %s -m video/ogg -t \"%s\"" % ( self._outfile, title ) + os.system(cmd) + + def can_close(self): + """ Close before verification + """ + + if self._state == "stop": return True + + self._ui.alert("You need to finish current operation before quitting") + return False diff --git a/screencast_process.py b/screencast_process.py new file mode 100644 index 0000000..4f87706 --- /dev/null +++ b/screencast_process.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Ariel Calzada - ariel@activitycentral.com +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import subprocess +import os + +import gobject +import os +import signal +import popen2 +import fcntl +import re +import signal + +class ScreencastProcess(gobject.GObject): + """ Process handler + """ + + # Attributes + _args = None + _childprocess = None + _childpid = None + _childtimer = None + _encodingtimer = None + _encodingstream = None + _encodingpid = None + + # Custom signals + __gsignals__ = { + 'encode-start': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ()), + 'encode-finished': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ()), + 'update-statusbar': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, (gobject.TYPE_STRING,gobject.TYPE_FLOAT,)), + } + + + def __init__(self): + """ Constructor + """ + super(ScreencastProcess, self).__init__() + + self._args = [] + self._ch_err = "" + + def runProcess(self, q, s, fp): + """ Run program + """ + + self._ch_err = "" + + # cmd + programName = "recordmydesktop" + fname = "/usr/bin/" + programName + if not os.path.isfile(fname): + fname = "./" + programName + + self._args.append ( fname ) + + # Output file + self._args.append ( "-o" ) + self._args.append ( fp ) + + # FPS - Framerate + self._args.append ( "--fps" ) + self._args.append ( "15" ) + + self._args.append ( "--quick-subsampling" ) + self._args.append ( "--no-frame" ) + + # Overwrite + self._args.append("--overwrite") + + # Quality + if str(q) == "2": + self._args.append("-v_quality") + self._args.append("0") + elif str(q) == "1": + self._args.append("-v_quality") + self._args.append("31") + + # Sound + if not s: + self._args.append("--no-sound") + + # Start program + self._childprocess = popen2.Popen3 ( self._args, "t", 0 ) + + flags = fcntl.fcntl(self._childprocess.childerr, fcntl.F_GETFL) + fcntl.fcntl(self._childprocess.childerr, fcntl.F_SETFL, flags | os.O_NONBLOCK) + self._childpid = self._childprocess.pid + + # Check process every second + self._childtimer = gobject.timeout_add (1000, self.checkProcessStatus) + + + def checkProcessStatus(self): + """ Check the current status of the process + """ + + # Not running + if self._childpid == None: + return False + + """ + os.waitpid(pid, options) + + On Unix: Wait for completion of a child process given by process + id pid, and return a tuple containing its process id and exit + status indication (encoded as for wait()). The semantics of the + call are affected by the value of the integer options, which + should be 0 for normal operation. + + If pid is greater than 0, waitpid() requests status information + for that specific process. If pid is 0, the request is for the + status of any child in the process group of the current process. + If pid is -1, the request pertains to any child of the current + process. If pid is less than -1, status is requested for any + process in the process group -pid (the absolute value of pid). + + An OSError is raised with the value of errno when the syscall + returns -1. + + ---------------------------------------------------------------- + + os.WNOHANG + The option for waitpid() to return immediately if no child + process status is available immediately. The function returns + (0, 0) in this case. + """ + + # Get process status + process_status = os.waitpid ( self._childpid, os.WNOHANG ) + process_status_id = process_status [ 0 ] + + # Not running + if process_status_id != 0: + self._childpid = None + return False + + return True + + def monitorEncoding(self): + """ Monitor encoding + """ + + strstdout = "" + try: + strstdout = self._encodingstream.read() + if strstdout == "": + self.emit('encode-finished') + return False + except: + return True + + try: + percentage = float ( strstdout.replace("[","").replace("%] ","") ) + except: + percentage = 0.0 + + if percentage > 100.0: + percentage = 100.0 + + percentage_raw = percentage + percentage = "%.2f%%" % ( percentage ) + self.emit('update-statusbar',percentage, percentage_raw) + + return True + + def stopProcess(self): + """ Stop process + """ + + # Stop timer + if self._childtimer != None: + gobject.source_remove(self._childtimer) + self._childtimer = None + + # Get process status + process_status = os.waitpid ( self._childpid, os.WNOHANG ) + process_status_id = process_status [ 0 ] + + # Running + if process_status_id == 0: + # Start encoding + os.kill(self._childpid, signal.SIGTERM) + + # Monitor encoding + self.emit('encode-start') + self._encodingstream = self._childprocess.fromchild + self._encodingpid = self._childpid + flags = fcntl.fcntl(self._encodingstream, fcntl.F_GETFL) + fcntl.fcntl(self._encodingstream, fcntl.F_SETFL, flags | os.O_NONBLOCK) + self._encodingtimer = gobject.timeout_add ( 100, self.monitorEncoding ) + + self._childpid = None diff --git a/screencast_ui.py b/screencast_ui.py new file mode 100644 index 0000000..2290dff --- /dev/null +++ b/screencast_ui.py @@ -0,0 +1,260 @@ +# -*- coding: utf-8 -*- + +# Copyright 2012 Ariel Calzada - ariel@activitycentral.com +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +""" +Screencast Activity: An activity for producing XO tutorials. +Based on http://git.sugarlabs.org/screencast +UI Component +""" + +# Toolbar +from sugar.activity.activity import ActivityToolbox + +# GTK +import gtk + +# Sugar graphics widget +from sugar.graphics.combobox import ComboBox + +# GObject used for subclassing and finally for managing signals +import gobject + +# Alert popup +from sugar.graphics.alert import NotifyAlert + +class ScreencastUI(gobject.GObject): + """ Screencast UI + """ + + # Attributes + _activity = None + _toolbar = None + _toolbox = None + _mainbox = None + _buttonsbox = None + _recordButton = None + _stopButton = None + _soundandquality = None + _soundCheck = None + _qualityCombo = None + _mainboxAlign = None + _statusbar = None + _progressbar = None + + # Custom signals + __gsignals__ = { + 'record-button-clicked-signal': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ()), + 'stop-button-clicked-signal': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ()), + } + + def __init__(self, activity): + """ Constructor + """ + super(ScreencastUI, self).__init__() + + self._activity = activity + + def buildGUI(self): + """ Build GUI + """ + + # Toolbar + self.buildToolbar() + + # Sound checkbox and quality combobox + self.buildSoundAndQuality() + + # Buttons + self.buildButtons() + + # Buttons + self.buildButtons() + + # Progress bar + self.buildProgressBar() + + # Status bar + self.buildStatusBar() + + # we do not have collaboration features + # make the share option insensitive + self.max_participants = 1 + + # Add items to mainbox + self._mainbox = gtk.VBox() + self._mainbox.pack_start(self._soundandquality, expand=True, fill=False, padding=20) + self._mainbox.pack_start(self._buttonsbox, expand=True, fill=False, padding=20) + self._mainbox.pack_start(self._progressbar, expand=True, fill=False, padding=20) + self._mainbox.pack_start(self._statusbar, expand=True, fill=False, padding=20) + + # Align mainbox + self._mainboxAlign = gtk.Alignment(0.5, 0.5, 0, 0) + self._mainboxAlign.add(self._mainbox) + + # Set canvas with box alignment + self._activity.set_canvas(self._mainboxAlign) + + def buildToolbar(self): + """ Build GUI Toolbar + """ + self._toolbox = ActivityToolbox(self._activity) + self._toolbar = self._toolbox.get_activity_toolbar() + + # Remove share button + self._toolbar.remove(self._toolbar.share) + self._toolbar.share = None + + self._activity.set_toolbox(self._toolbox) + + def buildSoundAndQuality(self): + """ Build sound checkbox and quality combobox + """ + self._soundandquality = gtk.HBox(spacing=50) + + self._soundCheck = gtk.CheckButton(label="Record sound") + self._soundCheck.set_active(True) + self._soundandquality.add(self._soundCheck) + + self._qualityCombo = ComboBox() + self._qualityCombo.append_item("0", " High quality video") + self._qualityCombo.append_item("1", " Medium quality video") + self._qualityCombo.append_item("2", " Low quality video") + self._qualityCombo.set_active(2) + self._soundandquality.add(self._qualityCombo) + + def buildButtons(self): + """ Build record and stop buttons + """ + + # Record button + self._recordButton = gtk.Button("Record") + self._recordButton.connect("clicked", self.recordButtonClicked) + self._recordButton.set_size_request(150, 150) + recordButtonIcon = gtk.Image() + recordButtonIcon.set_from_icon_name("media-record", -1) + self._recordButton.set_image(recordButtonIcon) + + # Stop button + self._stopButton = gtk.Button("Stop") + self._stopButton.connect("clicked", self.stopButtonClicked) + self._stopButton.set_size_request(150, 150) + self._stopButton.set_sensitive(False) + stopButtonIcon = gtk.Image() + stopButtonIcon.set_from_icon_name("media-playback-stop", -1) + self._stopButton.set_image(stopButtonIcon) + + # Buttons hbox + self._buttonsbox = gtk.HBox() + self._buttonsbox.pack_start(self._recordButton, expand=False, padding=40) + self._buttonsbox.pack_start(self._stopButton, expand=False, padding=40) + + def showGUI(self): + """ Show GUI + """ + self._activity.show_all() + self._progressbar.hide() + + def recordButtonClicked(self, widget): + """ Clicked event handler for record button + """ + self.emit('record-button-clicked-signal') + + def stopButtonClicked(self, widget): + """ Clicked event handler for stop button + """ + self.emit('stop-button-clicked-signal') + + def changeButtonsState(self, activate="record"): + """ Change sensitive property for the buttons + """ + if activate == "record": + self._recordButton.set_sensitive(False) + self._soundCheck.set_sensitive(False) + self._qualityCombo.set_sensitive(False) + self._stopButton.set_sensitive(True) + self._statusbar.set_text("Status: Recording") + elif activate == "encode": + self._stopButton.set_sensitive(False) + self._recordButton.set_sensitive(False) + self._soundCheck.set_sensitive(False) + self._qualityCombo.set_sensitive(False) + self._statusbar.set_text("Status: Encoding") + else: + self._stopButton.set_sensitive(False) + self._recordButton.set_sensitive(True) + self._soundCheck.set_sensitive(True) + self._qualityCombo.set_sensitive(True) + self._statusbar.set_text("Status: Stopped") + + def isSoundCheckActive(self): + """ Sound checked + """ + return self._soundCheck.get_active() + + def getCurrentQuality(self): + """ Get current video quality + """ + return self._qualityCombo.get_active() + + def buildProgressBar(self): + """ Progress bar + """ + self._progressbar = gtk.ProgressBar(adjustment=None) + self._progressbar.set_fraction(0) + self._progressbar.set_text("0% complete") + + def buildStatusBar(self): + """ Status bar + """ + self._statusbar = gtk.Label("Status: Stopped") + + def changeStatusbarText(self, text): + """ Change text of statusbar + """ + self._statusbar.set_text(text) + + def showProgressbar(self): + """ Show the progressbar + """ + self._progressbar.show() + + def hideProgressbar(self): + """ Hide the progressbar + """ + self._progressbar.hide() + + def updateProgressbar(self, percentage): + """ Update percentage value + """ + self._progressbar.set_fraction(percentage/100.0) + self._progressbar.set_text("%d%%"%int(percentage)+' complete') + + def alert(self, title, text=None): + """ Alert popup + """ + alert = NotifyAlert(timeout=10) + alert.props.title = title + alert.props.msg = text + self._activity.add_alert(alert) + alert.connect('response', self.alert_cancel_cb) + alert.show() + + def alert_cancel_cb(self, alert, response_id): + """ Destroy alert popup + """ + self._activity.remove_alert(alert) -- cgit v0.9.1