Skip to content

script

FUME – A Better Confluence User Macro Editor

One of the best and most overlooked aspects of Confluence, both by Atlassian and Confluence administrators, is user macros. There are so many useful scenarios for user macros. Here are some:

  • Templated snippets
  • Overriding built in macros example with task list report
  • Quickly creating your own macros
  • Inserting arbitrary html/css/javascript into a page without having to enabled the html macro

However, there are some big usability issues with the user macro editor. First it’s super easy to accidentally delete one. The delete link is right next to the edit link and seriously, there is no confirmation on the delete link. It’s just gone. Ack!

Second, the link to create new user macros is at the bottom of the page. If you have more that what can fit on a screen you have to scroll down to get to the link to create a new one … this just gets worse over time as you create more.

Third, the template box in the editor is just a plain old text area … no line numbers, not syntax highlighting, it’s not even a mono-spaced font! Grr!

Fourth, the cancel button doesn’t ask you to confirm canceling the edit if you have made changes to the user macro and since it sits right next to the save button it’s easy to miss. Hope you can recreate your work quickly.

Finally, every time you save it kicks you back to the list page. So, if you want to make some changes and try it out on a page you have to click back into the editor every time you save and whoops you accidentally just clicked delete instead of edit! There goes all that work.

So, without further ado … FUME. Fantastic user macro editor. The fantastic part is really just because I needed a word that ended in “ume” and that was the only word I could think of. Really it’s not all that fantastic … maybe just great, but gume isn’t even a word. Then I thought “How about  great looking user macro editor”, but that would be glume and … well … yeah, that kinda defeats the purpose. So, FUME it is. All in all I think it is a much better editing experience than the default setup. Here are some of the features:

  • Copy that “Create a User Macro” link to the top of the list page … no more scrolling
  • Delete confirmation on the list page
  • Template box changed to a source code editor with (Ace editor):
    • monospaced font
    • line numbers
    • syntax highlighting
    • find and replace
    • code folding
    • column select
  • Confirmation on cancelling edits of the user macro if the template has been changed
  • Asynchronous user macro saves
  • It will do your dishes and laundry … ok, not quite yet

Update 4/9/2018:

Ignore the “How to Setup” section below. I’ll leave it there, however, for the sake of continuity. I decided to package this up as an add-on in the Atlassian Marketplace. I named it Enhanced User Macro Editor (EUME … pronounced you-me … it’s a stretch I know). It seemed a bit more humble of a name and is more descriptive of what it is. I hope it is as useful for you as it has been for me. Marketplace link below.

Enhanced User Macro Editor

How to Setup

  • Download these CSS and Javascript files. (right click the links and choose “Save link as”)
  • Place them on a web server where they will be web accessible to your user macro editors.
  • Add this to the end of Confluence Admin -> Custom HTML -> At end of the BODY
<!--
*****************************************
* Fantastic User Macro Editor           * *****************************************
-->
<link rel="stylesheet" type="text/css" href="http(s)://{your server}/path/to/fume.css">
<script src="http(s)://{your server}/path/to/fume.js" type="text/javascript"></script>
  • Enjoy editing your user macros.  🙂

Screenshots

User Macro List

User Macro List Page

User Macro Template Editor

User Macro Template Editor

Remove Jira Issue Attachments by MD5 Hash Redux

In my previous post Remove Jira Issue Attachments by MD5 Hash I showed how to remove attachments from JIRA based on the MD5 hash of the attachment.

I was feeling pretty good after writing that post and having eaten my doughnut. So, I went to tell a couple of my colleagues about it. This was their reaction …

So, you expect me to …

  1. know what an MD5 hash is?
  2. know how to get the MD5 hash of a file?
  3. know where to find this script to add the hash to?
  4. not mess the whole thing up in the process?

Um … uhh … yes? Ok, so maybe my approach isn’t super easy except to the programmer type. And now that I think about it I don’t want to have to be the one to always fix these. So, back to the drawing board. Let’s get this right.

So, I need to make it easy for others than myself to help maintain. Maybe if I made a way for my colleagues to take an attachment from an issue ticket and simply drop to a centralized storage location that could be scanned by the script … yeah that could work. It involves no knowledge of MD5 hashes or scripting and should be easy for pretty much anyone to do.

Now if I only had a location where we could place these attachments. A place that JIRA is able to scan. A place that all my colleagues have easy access to. If only such a place actually existed … hmm … oh, wait!! I could just have them attach the files to another JIRA ticket that will be used as a control ticket of sorts. Any attachments attached to this ticket would be compared against by the script and if a match is found then the issue attachment is deleted. (insert Handel’s Messiah playing in my head here)

The great thing is that most of my script doesn’t really need to be changed. All I need to do is specify a control ticket key in the script and have the script build the list of hashes based on that ticket. Here is my ticket …

And here is the new script. I’ve cleaned it up a little from the last version and removed a call to a method that is currently set as deprecated. It still worked even with the call, but best to get rid of that call before Atlassian removes the method altogether. Simply replace “{Project Key}-{Issue Number}” on line 12 with the issue key that holds your attachments to remove. So, if for instance the issue is in the FOO project and the issue number is 789 then that line would look like this …

def controlIssue = “FOO-789”;

import com.atlassian.jira.component.ComponentAccessor;
import com.atlassian.jira.issue.AttachmentManager;
import com.atlassian.jira.issue.attachment.FileSystemAttachmentDirectoryAccessor
import com.atlassian.jira.issue.Issue;
import com.atlassian.jira.issue.IssueManager;
import java.security.*;

/***********************************************************************************/
/* This is the ticket that has the attachments on it to compare MD5 hashes against */
/***********************************************************************************/

def controlIssue = "{Project Key}-{Issue Number}";

/***********************************************************************************/
/*                                                                                 */
/***********************************************************************************/

/************************************************************/
/* Don't edit below this unless you know what you are doing */
/************************************************************/

// Get the attachment hashes for our control issue to compare against
def attachmentHashes = getAttachmentHashesFromIssue(controlIssue);

// Obviously we don't want to run this on the control issue ... only on other issues.
if(event.issue.key != controlIssue) {
    deleteMatchingAttachments(attachmentHashes);
}

public void deleteMatchingAttachments(List<String> deleteHashes){
    def issue = event.issue;
    def attachmentManager = ComponentAccessor.getComponent(AttachmentManager);
    def attachments = issue.getAttachments();
    def attachmentFile = null;
    def bytes = null;
    def md = MessageDigest.getInstance("MD5");
    def digest = null;
    def hash = "";

    // Loop through each attachment on the issue
    for(a in attachments) {
        attachmentFile = getAttatchmentFile(issue, a.getId());
        bytes = getBytesFromFile(attachmentFile);
        digest = md.digest(bytes);
        hash = String.format("%032x", new BigInteger(1, digest));

        // Compare hash to the list of hashes we don't want
        for(h in deleteHashes) {
            if(hash == h) {
                attachmentManager.deleteAttachment(a);
                break;
            }
        }
    }
}

public List<String> getAttachmentHashesFromIssue(String controlIssueKey) {
    def deleteHashes = [];
    def attachmentManager = ComponentAccessor.getComponent(AttachmentManager);
    def issueManager = ComponentAccessor.getComponent(IssueManager);
    def issue = issueManager.getIssueObject(controlIssueKey);
    def controlIssueAttachments = attachmentManager.getAttachments(issue);
    def attachmentFile = null;
    def bytes = null;
    def md = MessageDigest.getInstance("MD5");
    def digest = null;
    def hash = "";

    // Get hashes for all the attachments in the control issue
    for(a in controlIssueAttachments) {
        attachmentFile = getAttatchmentFile(issue, a.getId());
        bytes = getBytesFromFile(attachmentFile);
        digest = md.digest(bytes);
        hash = String.format("%032x", new BigInteger(1, digest));

        deleteHashes.add(hash);
    }

    return deleteHashes;
}

public byte[] getBytesFromFile(File file) throws IOException {        
    def length = file.length();

    if (length > Integer.MAX_VALUE) {
        throw new IOException("File is too large!");
    }

    def bytes = new byte[(int)length];

    def offset = 0;
    def numRead = 0;

    def is = new FileInputStream(file);
    try {
        while (offset < bytes.length && (numRead=is.read(bytes, offset, bytes.length-offset)) >= 0) {
            offset += numRead;
        }
    } finally {
        is.close();
    }

    if (offset < bytes.length) {
        throw new IOException("Could not completely read file " + file.getName());
    }

    return bytes;
}

public File getAttatchmentFile(Issue issue, Long attatchmentId){
    return ComponentAccessor.getComponent(FileSystemAttachmentDirectoryAccessor.class).getAttachmentDirectory(issue).listFiles().find({
        File it->
         it.getName().equals(attatchmentId.toString())
    });
}

And now my colleagues sing my praises (in my dreams) instead of cursing my name (which maybe still happens when I make hard to update workflows). Oh well, you live and learn.

Remove Jira Issue Attachments by MD5 Hash

Recently, we started using Jira at work to track some IT related things. Thus, I have quickly had to learn how to administer Jira. One thing that I really wanted to get set up and working well was the ability to respond to an email sent by the system via a reply email and have that filed in the ticket. That wasn’t too terribly hard to set up. First I created a mailbox for Jira to check in our mail system. Then I set up a mail handler to pull the reply emails in. I settled for using a “Add a comment before a specified marker or separator in the email body” handler so that I could provide a regular expression to define how to extract just the reply. The below screenshots show my setup for this.

The Split Regex field below is this /[Ff]{1}rom:[^\n]+{myuser}@{mydomain}\.{extention}/. Replace the {myuser}, {mydomain}, and {extention} parts with the email address of the account that Jira mails as. So, if your Jira system sends email as jira@coolstuff.org the above expressions would look like this /[Ff]{1}rom:[^\n]+jira@coolstuff\.org/. This will split the email where it sees the first line that looks something like this From: Jira [mailto:jira@coolstuff.org] or from: Jira [mailto:jira@coolstuff.org] … which is how Outlook formats its replies.

So, I got that part working great now I can go get a doughnut right? Nope! Turns out every time I would reply the issue icon and the image attachment in my email signature would get attached to the issue … over and over and over. So, before long I had a veritable glut of the same images attached to the issue. Grrrr!! So, I asked myself “Self, what can we do about this?” To which I so helpfully replied to myself, “Go check the Atlassian Marketplace, Atlassian Community, and Google for an answer.” After a couple grueling hours of trying to find the answer I came to the stark conclusion that there wasn’t one. Double grrrr!!

After a bit of thinking I decided I could just scan the images against a set of MD5 hashes to exclude when the issue is updated and here is the fruit of my labor. This solution requires having ScriptRunner for Jira installed. If you don’t have it … well, you should. The possibilities are pretty endless with what you can do with it. I created a Script Listener that would respond to the “Issue Updated” event.

And the actual contents of the script file.

import java.security.*;
import com.atlassian.jira.issue.Issue;
import com.atlassian.jira.component.ComponentAccessor;
import com.atlassian.jira.issue.AttachmentManager;
import com.atlassian.jira.util.AttachmentUtils;

// Add new MD5 hashes to the below array to auto remove them when they are attached to the ticket.
// This is helpful to get rid of things like images in email signatures, JIRA issue type icons in the email, etc.
def deleteHashes = [
    "eaf938ae5025889b60029d6d839d19db", //JIRA blue check mark
    "f370264d9a3d1b92666419e6ecc102ef", //email signature logo v1
    "662b051e6082e4499079ddc18e5eb302", //email signature logo v2
    "a4ab3c522859297084064502477effd8"  //Pulse line icon
];

def issue = event.getIssue();
def attachments = issue.getAttachments();
def attachmentFile = null;
def bytes = null;
def md = MessageDigest.getInstance("MD5");
def digest = null;
def hash = "";
def manager = ComponentAccessor.getComponent(AttachmentManager)

for(a in attachments) {
    attachmentFile = AttachmentUtils.getAttachmentFile(a);
    bytes = getBytesFromFile(attachmentFile);
    digest = md.digest(bytes);
    hash = String.format("%032x", new BigInteger(1, digest));

    for(h in deleteHashes) {
        if(hash == h) {
            manager.deleteAttachment(a);
            break;
        }
    }
}

public byte[] getBytesFromFile(File file) throws IOException {        
    // Get the size of the file
    long length = file.length();

    // You cannot create an array using a long type.
    // It needs to be an int type.
    // Before converting to an int type, check
    // to ensure that file is not larger than Integer.MAX_VALUE.
    if (length > Integer.MAX_VALUE) {
        log.info("File is too large!");

        // File is too large
        throw new IOException("File is too large!");
    }

    // Create the byte array to hold the data
    byte[] bytes = new byte[(int)length];

    // Read in the bytes
    int offset = 0;
    int numRead = 0;

    InputStream is = new FileInputStream(file);
    try {
        while (offset < bytes.length && (numRead=is.read(bytes, offset, bytes.length-offset)) >= 0) {
            offset += numRead;
        }
    } finally {
        is.close();
    }

    // Ensure all the bytes have been read in
    if (offset < bytes.length) {
        log.info("Could not completely read file " + file.getName());
        throw new IOException("Could not completely read file " + file.getName());
    }

    return bytes;
}

Now, when I respond to an issue via email if any of the attachments on the issue match any of the MD5 hashes at the top of the script that attachment will get deleted from the issue. And if I find that there are other attachments we start seeing like this on a regular basis all I have to do is add the MD5 hash to the list and save the script … problem solved.

Now, about that doughnut.

Update: So, the whole editing the script and having others put in MD5 hashes part … yeah, that went over like a lead balloon. Here is an updated version that is much easier to administer.

Finding the Character Set For a SQL Field

The other day I was working on a project and I needed to know all of the different types of characters contained in a SQL field so that I could make a regular expression pattern that would not fail. Thus, I decided to write a stored procedure that would loop through a range of characters and test if that character is contained in a specified field in any records. From there I can get a list of all of the used characters and then more intelligently write my regular expression pattern.

This could also be handy if you are trying to cast a field and you keep getting errors because of a character that won’t cast correctly. An example would be trying to cast a varchar or char field into an int and for some reason or another one of the records has a letter in the field. Your cast will fail and it can be tricky to track down where the issue is. With this stored procedure you can quickly see what the offending character is and then query up all the records containing that character.

Example usage:

exec FieldCharacterSet 'dbo', 'mytable, 'myfield', 1, 0, 255

Feel free to use this if you find is useful, and leave a comment letting me know if it worked well for you.

-- =============================================
-- Author:        Davin Studer
-- Create date: 9/26/2010
-- Description:    This stored procedure will let you
-- know all the characters that are contained within
-- a field of a specified table.  This is handy for things
-- such as creating regular expression patterns and figuring
-- out casting issues.
-- =============================================
create procedure [dbo].[FieldCharacterSet]
    @TableSchema nvarchar(255) = '',-- this is the schema for table to look at
    @TableName nvarchar(255) = '',    -- this is the table to look at
    @FieldName nvarchar(255) = '',    -- this is the field that will be scanned
    @RTrim bit = 1,                    -- should the field be right trimmed before finding the character set ... mostly for char field types
    @LowCharRange int = 0,            -- character scan start point
    @HighCharRange int = 255        -- character scan end point
as
begin
    -- SET NOCOUNT ON added to prevent extra result sets from
    -- interfering with SELECT statements.
    set nocount on;
 
    declare
        @count bigint = 0,
        @sql nvarchar(max) = '',
        @selects nvarchar(max) = '',
        @error varchar(200) = '',
        @field nvarchar(264) = '',
        @sqlbuild nvarchar(max) = ''
    declare @chars table(
        [character] nchar(1)
    )
    declare @statements table(
        [sql] varchar(max)
    )
 
    if @TableName = ''
    begin
        set @error = 'You must supply a table name.'
        raiserror (@error, 15, 1)
    end
 
    if @FieldName = ''
    begin
        set @error = 'You must supply a field name.'
        raiserror (@error, 15, 1)
    end
    
    if @error = ''
    begin
        -- Bracket the table name to avoid naming issues
        if left(@TableName, 1) <> '[' and right(@TableName, 1) <> ']'
        begin
            set @TableName = '[' + @TableName + ']'
        end
        
        -- Bracket the field name to avoid naming issues
        if left(@FieldName, 1) <> '[' and right(@FieldName, 1) <> ']'
        begin
            set @FieldName = '[' + @FieldName + ']'
        end
        
        -- Should we rtrim the field to deal with trailing spaces ... mostly for char fields
        if @RTrim = 1
        begin
            set @field = 'rtrim(' + @FieldName + ')'
        end
        
        -- Create a temp table to hold our examples of each character matched
        if object_id('tempdb..##fieldCharacterSetTemp') is not null
        begin
            drop table ##fieldCharacterSetTemp
        end
        set @sql = 'select * into ##fieldCharacterSetTemp from (select t1.* from ' + @TableSchema + '.' + @TableName + ' t1 inner join ' + @TableSchema + '.' + @TableName + ' t2 on 1 = 1 where 1 = 0) X'
        exec(@sql)
 
        --Loop through the ascii characters low to high
        while @LowCharRange <= @HighCharRange
        begin
            --Build the from and where clauses
            set @sqlbuild = ''
            set @sqlbuild = @sqlbuild + 'from '
            if @TableSchema <> ''
            begin
                set @sqlbuild = @sqlbuild + @TableSchema + '.'
            end
            set @sqlbuild = @sqlbuild + @TableName + nchar(13)
            set @sqlbuild = @sqlbuild + 'where ' + @field
            -- If the character is A-Z or a-z then case sensitive compare
            if (@LowCharRange between 65 and 90) or (@LowCharRange between 97 and 122)
            begin
                set @sqlbuild = @sqlbuild + ' COLLATE Latin1_General_CS_AS'
            end
            set @sqlbuild = @sqlbuild + ' like '
            -- Deal with a few special SQL characters
            if @LowCharRange = 37 -- % character
            begin
                set @sqlbuild = @sqlbuild + '''%\%%'' escape ''\''' + nchar(13)
            end
            else if @LowCharRange = 39 -- ' character
            begin
                set @sqlbuild = @sqlbuild + '''%''''%''' + nchar(13)
            end
            else if @LowCharRange = 95 -- _ character
            begin
                set @sqlbuild = @sqlbuild + '''%\_%'' escape ''\''' + nchar(13)
            end
            else
            begin
                set @sqlbuild = @sqlbuild + '''%' + nchar(@LowCharRange) + '%''' + nchar(13)
            end
 
            -- Buld main counting sql
            set @sql = ''
            set @sql = @sql + 'select @countOut = count(*)' + nchar(13)
            set @sql = @sql + @sqlbuild
 
            --Get count of rows that have the character
            execute sp_executesql @sql,N'@countOut bigint = 0 output',@countOut=@count output
 
            --If any rows have this character add it to the result set
            if @count > 0
            begin
                insert into @chars values (nchar(@LowCharRange))
 
                -- Build the select sql to look at example lines that have this character
                set @selects = ''
                set @selects = @selects + 'select top 1 *' + nchar(13)
                set @selects = @selects + @sqlbuild
                
                insert into @statements values (rtrim(replace(@selects, nchar(13), ' ')))
 
                set @sql = ''
                set @sql = @sql + 'insert into ##fieldCharacterSetTemp' +nchar(13)
                set @sql = @sql + @selects
                
                exec(@sql)
            end
            
            -- Next character
            set @LowCharRange = @LowCharRange + 1
        end
 
        select * from @chars
        select * from @statements
        if object_id('tempdb..##fieldCharacterSetTemp') is not null
        begin
            select * from ##fieldCharacterSetTemp
            drop table ##fieldCharacterSetTemp
        end
    end
end

Google Calendar

So, I’ve been working on creating a php script that pulls Google Calendar data. I couldn’t find many examples of how to do it on the web. So, after much trial and error here is what I’ve come up with. Use as you wish.

<?php
/**********************************************************/
/* The username/password used to login to Google Calendar */
/*                                                        */
/* This is the only part you should have to edit in this  */
/* script.                                                */
/**********************************************************/
$google_username = '???';
$google_password = '???';

/**************************************************************************/
/* Include Zend framework and load libraries, used to interact with Gdata */
/* Get the Zend framework at http://framework.zend.com/download           */
/**************************************************************************/
require_once 'Zend/Loader.php';
Zend_Loader::loadClass('Zend_Gdata');
Zend_Loader::loadClass('Zend_Gdata_AuthSub');
Zend_Loader::loadClass('Zend_Gdata_ClientLogin');
Zend_Loader::loadClass('Zend_Gdata_Calendar');

/******************/
/* Login to Gdata */
/******************/
$authService = Zend_Gdata_Calendar::AUTH_SERVICE_NAME;
$httpClient = Zend_Gdata_ClientLogin::getHttpClient($google_username, $google_password, $authService);

/***********************************************************************/
/* Replaces [link:url]Text[/link] with a properly formatted anchor tag */
/* The url must have the http:// or https:// at the front              */
/*                                                                     */
/* Params:                                                             */
/*  $text - String of text                                             */
/***********************************************************************/
function createLink($text) {
    $pattern1 = '/\[\/link\]/';
    $pattern2 = '/(\[link:)([^\]]*)(\])/';

    $replacement1 = '</a>';
    $replacement2 = '<a href="$2" target="_blank" rel="noopener">';

    $text = preg_replace($pattern1 , $replacement1, $text);
    $text = preg_replace($pattern2 , $replacement2, $text);

    return $text;
}

/*****************************************************************************************/
/* Replaces [email:email address]Text[/email] with a properly formatted email anchor tag */
/*                                                                                       */
/* Params:                                                                               */
/*  $text - String of text                                                               */
/*****************************************************************************************/
function createEmail($text) {
    $pattern1 = '/\[\/email\]/';
    $pattern2 = '/(\[email:)([^\]]*)(\])/';

    $replacement1 = '</a>';
    $replacement2 = '<a href="mailto:$2">';

    $text = preg_replace($pattern1 , $replacement1, $text);
    $text = preg_replace($pattern2 , $replacement2, $text);

    return $text;
}

/*****************************************************/
/* Replaces line breaks in the text with <br /> tags */
/*                                                   */
/* Params:                                           */
/*  $text - String of text                           */
/*****************************************************/
function createLineBreaks($text) {
    $text = str_replace("\n", "<br />", $text);

    return $text;
}

/*****************************************************************/
/* This function applies the formatting functions defined above. */
/*                                                               */
/* Params:                                                       */
/*  $text - String of text                                       */
/*****************************************************************/
function formatText($text) {
    $text = createLink($text);
    $text = createEmail($text);
    $text = createLineBreaks($text);

    return $text;
}

/*****************************************************************/
/* This function formats the date/time shown to the user.        */
/*                                                               */
/* Params:                                                       */
/*  $start - Starting date/time                                  */
/*  $end - Ending date/time                                      */
/*****************************************************************/
function formatDate($start, $end) {
    if (date("H:i:s", $start) == "00:00:00" && date("H:i:s", $end) == "00:00:00") //All day event
    {
        $end = $end - 1; //For all day events the end day is one greater than it really is

        if(date("M j, Y", $start) == date("M j, Y", $end)) //Single day all day event
        {
            $date = date("M j, Y", $start);
        }
        else //All day event spanning multiple days
        {
            if (date("Y", $start) == date("Y", $end))
            {
                if (date("M", $start) == date("M", $end)) //All day event spanning multiple days in same month
                {
                    $date = date("M j", $start) . " - " . date("j, Y", $end);
                }
                else //All day event spanning multiple months
                {
                    $date = date("M j", $start) . " - " . date("M j, Y", $end);
                }
            }
            else // All day event spanning multiple years
            {
                $date = date("M j, Y", $start) . " - " . date("M j, Y", $end);
            }
        }
    }
    else //Time range event
    {
        if(date("M j, Y", $start) == date("M j, Y", $end)) //Single day range event
        {
            $date = date("M j, Y (g:i a", $start) . " - " . date("g:i a)", $end);
        }
        else //Range event spanning multiple days
        {
            if (date("Y", $start) == date("Y", $end))
            {
                if (date("M", $start) == date("M", $end)) //Range event spanning multiple days in same month
                {
                    $date = date("M j (g:i a)", $start) . " - " . date("M j (g:i a), Y", $end);
                }
                else //Range  event spanning multiple months
                {
                    $date = date("M j (g:i a)", $start) . " - " . date("M j (g:i a), Y", $end);
                }
            }
            else //Range event spanning multiple years
            {
                $date = date("M j, Y (g:i a)", $start) . " - " . date("M j, Y (g:i a)", $end);
            }
        }
    }

    return $date;
}

/******************************************************************/
/* Create a definition list of the events in human-readable form. */
/* You can use CSS to style the list.                             */
/*                                                                */
/* Params:                                                        */
/* $client - GData $httpClient created above                      */
/*  $feed - The GData feed to read                                */
/******************************************************************/
function listEvents($client, $feed)
{
    $gdataCal = new Zend_Gdata_Calendar($client);
    $eventFeed = $gdataCal->getCalendarEventFeed($feed);

    $html = $html . "<dl>\n";

    foreach ($eventFeed as $event)
    {
        foreach ($event->when as $when)
        {
            $start = strtotime($when->startTime);
            $end = strtotime($when->endTime);
        }
        $date = formatDate($start, $end);

        foreach ($event->where as $where)
        {
            $location = $where->valueString;
        }

        $title = $event->title->text;
        if ($location != "")
        {
            $title = "<a href=\"" . $location . "\" target=\"_blank\">" . $title . "</a>";
        }
        $content = formatText($event->content->text);

        $html = $html . " <dt><span class=\"eventdate\">" . $date . "</span><span class=\"eventtitle\"> - " . $title . "</span></dd>\n";
        $html = $html . " <dd><span class=\"eventcontent\">" . $content . quot;</span></dd>\n";
    }

    $html = $html . "</dl>\n";

    return $html;
}
?>
<html>
    <body>
<?php
Echo listEvents($httpClient, "http://www.google.com/calendar/feeds/yourfeedhere");
?>
    </body>
</html>