Old Notes

Creating a meeting notice in Outlook from the Notes client

Another task in our migration to Outlook as the mail client is creating Outlook calendar entries and meeting notices directly from the Notes client. Fortunately, it’s been two years since I wrote about how to do this in the UI in Notes, so I don’t feel like that was wasted time. I was exciting to solve the problem and… oddly enough, solving this one was fun as well. It helped that creating an iCal entry is far simpler than the gyrations we had to go through to create one in Notes. As noted previously, there  aren’t a whole lot of required values to generate in order to have an ICS file that you can open in the UI as a meeting notice/calendar invite.

BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART:20170622T211500
DTEND:20170622T221500
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;CN="Required Person/Company";RSVP=TRUE:mailto:Required_Person@company.com
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:external_person@SecondCompany.com
SUMMARY:2017 Q2 QPR: Agribusiness Competitiveness Enhancement via file
UID:AC1804D765C782CD8525814500073F3720170620T104326
END:VEVENT
END:VCALENDAR

Now, keep in mind that this iCal file is a mere fragment. If you sent that file to someone, they get the same behaviour you get — it thinks they’re the meeting organizer and doesn’t save it to their calendar unless they send the ‘update’. The key parameter we leave off is that we don’t set METHOD, since setting that to PUBLISH or REQUEST proved problematic in the Outlook client. If we leave it off, Outlook will allow us to treat it like a brand new calendar entry we’ve created, except that the send button will say ‘Send Update’.

So, let’s review those values in our fragment…

Objects

First, the calendar and event objects are encapsulated. Nothing fancy there.

BEGIN:VCALENDAR
BEGIN:VEVENT
END:VEVENT
END:VCALENDAR

Meeting times

Then we have our start and end times, formatted with date first (YYYYMMDD) then a separator (T) and then the time (HHMMSS). You can include time zone information, but we’re creating this in Outlook and allowing the UI to finish everything for us. So, if the user wants to change the time zone, they can do that in Outlook.

DTSTART:20170622T211500
DTEND:20170622T221500

Attendees

The one required value for our needs in the attendees is the mailto value. Without that, it won’t know who to send the invite to and it simply ignores any other item in that list.

ATTENDEE;ROLE=CHAIR;PARTSTAT=ACCEPTED;CN="Meeting Chair/Company";RSVP=TRUE:mailto:Meeting_Chair@company.com
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;CN="Required Person/Company";RSVP=TRUE:mailto:Required_Person@company.com
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:external_person@SecondCompany.com

ROLE is not required and can be CHAIR, REQ-PARTICIPANT (required participant), OPT-PARTICIPANT (optional participant) or even NON-PARTICIPANT (for FYI only).

PARTSTAT is not required. There are several values for an attendee in a VEVENT for their participant status, but we’re only concerned with two. Either “NEEDS-ACTION” for attendees that we don’t know when we create the meeting notice whether they’ve agreed to attend or “ACCEPTED” which we’d typically only use for the person creating the meeting notice.

CN is, of course, familiar to us as Notes developers, but it applies here to whatever will be displayed as the attendee name. In my experience, Outlook can parse the abbreviated name and display just the attendee’s common name. That might be our Outlook configuration, but I would assume it’s common.

RSVP would be either true or false, indicating whether you want a response from the attendee. In my case, we always want it from the attendees, other than the current user.

Title and description

I got fooled by this one. In my sample ICS files, I thought there was just an odd carriage return, but the DESCRIPTION value is basically the body or details of the event, while the SUMMARY is what appears in the subject line for the meeting.

SUMMARY:2017 Q2 QPR: Agribusiness Competitiveness Enhancement via file

Meeting ID

I’m guessing that Outlook computes the unique meeting ID itself, but in my code, I generate from the Notes document’s unique ID and then, in order to ensure that subsequent meetings concerning the same document get different IDs, I’m appending a creation time-stamp.

UID:AC1804D765C782CD8525814500073F3720170620T104326

So, the agent I wrote that generates the new meeting notice is pretty straight-forward. The getEmailAddress function was described and detailed in a prior blog post and my Utilities script library only provides the logging functions here. Like my mailto agent, this one relies on the creation of a file in the Notes data directory and opening it using a browser.

The agent

First, you can look over the main part of the agent…

%REM
	Agent (Send Calendar Invites)
	Created Jun 20, 2017 by David Navarre/DAI
	Description: This Agent creates a calendar invite, listing participants and optional participants
%END REM
Option Public
Option Declare
Use "Utilities"

Dim session As NotesSession
Sub Initialize
	Dim ws As New NotesUIWorkspace
'	Dim thisdb As NotesDatabase declared in Utilities script library '
	Dim uidoc As NotesUIDocument
	Dim qprdoc As NotesDocument
	Dim history As NotesRichTextItem
	Dim chairName As NotesName
	Dim recipientName As NotesName
	Dim projectName As Variant
	Dim fiscalYearAndQuarter As Variant
	Dim participants As Variant
	Dim participantsOptional As Variant
	Dim subject As String
	Dim answer As Variant
	Dim reason As String
	Dim unid As String
	
	On Error GoTo errorhandler
	
	set session = New NotesSession
	Call StartAgentLogging ( session )

	If ( openAddressBooks () ) Then
		agentLog.Logaction("Address books opened")
	End If

	Set thisdb = session.CurrentDatabase
	Set uidoc = ws.CurrentDocument

	reason = "This will create a meeting invite for you to send to participants."
	If uidoc.Editmode Then
		reason = reason + Chr$(10) + "The QPR will switch to read-only mode."
		reason = reason + Chr$(10) + "If you close and re-open it, you can edit it again."
	End If
	reason = reason + Chr$(10) + "Continue?"
	answer = ws.Prompt ( PROMPT_YESNO, "Continue?", reason )
	If answer = 0 Then
		Exit Sub
	End If	
	
	If uidoc.Editmode Then
		Call uidoc.Save()
		uidoc.Editmode = False
		Set qprDoc = uidoc.Document
		unid = qprDoc.Universalid
		Call uidoc.Close(True)
		Set qprDoc = thisdb.Getdocumentbyunid(unid)
		Set uidoc = ws.Editdocument(False, qprDoc, True)
	Else
		Set qprDoc = uidoc.Document
	End If
		
	Dim fileName As String
	Dim dataDirectoryPath As String
	Dim url As String
	Dim fileNumber As Integer
	
	fileNumber = 1
	
	dataDirectoryPath = session.Getenvironmentstring("Directory", True)
	fileName = dataDirectoryPath & "\QPRInvite.ics"
	
	Open fileName For Output As fileNumber
	
	Print # fileNumber, {BEGIN:VCALENDAR}
	Print # fileNumber, {BEGIN:VEVENT}
	Print # fileNumber, {DTSTART:} & getMeetingTime ( "Start", qprDoc ) '20170620T211500
	Print # fileNumber, {DTEND:}  & getMeetingTime ( "End", qprDoc ) '20170620T221500

	' Chair '
	Set chairName = New NotesName ( session.Effectiveusername )
	' when you send the invite from Outlook, it makes you the chair '
	' this line is here to show how you would format an attendee line for the chair '
	' Print # fileNumber, {ATTENDEE;ROLE=CHAIR;PARTSTAT=ACCEPTED;CN="} & chairName.Abbreviated & {";RSVP=TRUE:mailto:} & getEmailAddress ( chairName.Abbreviated ) '
	' Required participants '
	participants = qprDoc.Getitemvalue ( "Participants" )
	ForAll entry In participants
		Set recipientName = New NotesName ( entry )
		If Not ( chairName.Abbreviated = recipientName.Abbreviated ) Then
			Print # fileNumber, {ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;CN="} & recipientName.Abbreviated & {";RSVP=TRUE:mailto:} & getEmailAddress ( recipientName.Abbreviated )
		End If
	End ForAll
	' Optional participants '
	participantsOptional = qprDoc.Getitemvalue ( "ParticipantsOptional" )
	ForAll entry In participantsOptional
		Set recipientName = New NotesName ( entry )
		Print # fileNumber, {ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;CN="} & recipientName.Abbreviated & {";RSVP=TRUE:mailto:} & getEmailAddress ( recipientName.Abbreviated )
	End ForAll

	projectName = qprDoc.Getitemvalue("ProjectName") 
	fiscalYearAndQuarter = qprDoc.Getitemvalue("FiscalYearAndQuarter") 
	subject = fiscalYearAndQuarter(0) & " QPR: " & projectName (0)
	Print # fileNumber, {DESCRIPTION:} & subject ' this is the body of the message
	Print # fileNumber, {SUMMARY:} & subject ' this is the meeting name

	' assign a unique ID to meeting using the unid of the document with the current date-time appended '
	' in case user creates multiple meetings for the same QPR '
	Print # fileNumber, {UID:} & qprdoc.Universalid & getMeetingTime ( "Now", qprDoc ) 

	Print # fileNumber, {END:VEVENT}
	Print # fileNumber, {END:VCALENDAR}
	
	Close # fileNumber
	
	url = "file:///" & fileName
	Call ws.Urlopen(url)
	
	Set history = qprDoc.Getfirstitem("History")
	Call history.Appendtext(Now & " - Meeting notice created by " & session.Commonusername)
	Call history.Addnewline(1, True)
	Call qprDoc.Replaceitemvalue("NoticeFlag", 1)
	Call qprDoc.Save(True, False)

exiting:
	Exit Sub
errorhandler:' report all errors in a messagebox '
	reason = "Error #" & CStr (Err) & " (" & Error & ") on line " & CStr (Erl)
	MessageBox reason, 16, "Error"
	Call agentLog.LogAction ( reason )
	Resume exiting ' transfers control to the exiting label	
End Sub

getMeetingTime

The getMeetingTime function just returns the requested date-time in the format YYYYMMDDTHHMMSS, so it can be included in the creation of the ICS file.

%REM
	Function getMeetingTime
	Description: This Function returns a string in the format YYYYMMDDTHHMMSS
		If it is the start time, the values from the source document are used 		-- 20170622T211500
		If it is the end time, it is adjusted one hour later					 	-- 20170622T221500
		If it is the "Now" time, it returns a string for the current date and time	-- 20170620T094326
%END REM
Function getMeetingTime ( startOrEnd As String, qprDoc As NotesDocument ) As String
	Dim thisNotesDateTime As NotesDateTime
	Dim qprDate As Variant
	Dim qprTime As Variant
	Dim timeString, dateString As Variant
	Dim reason As String	

	On Error Goto errorhandler

	Set qprDate = qprDoc.Getfirstitem("QPRDate")
	Set qprTime = qprDoc.Getfirstitem("QPRTime")
	dateString = qprDate.Text
	timeString = qprTime.Text
	Set thisNotesDateTime = New NotesDateTime ( dateString & " " & timeString )
	Select Case startOrEnd
		Case "End"
			Call thisNotesDateTime.AdjustHour (1)
		Case "Now"
			Set thisNotesDateTime = New NotesDateTime ( Now )
		Case else	
			' keep thisNotesDateTime as set on the source document '
	End Select
	dateString = thisNotesDateTime.DateOnly
	timeString = thisNotesDateTime.TimeOnly
	getMeetingTime = CStr ( Year ( dateString ) )
	getMeetingTime = getMeetingTime & Right$ ( "0" & CStr ( Month ( dateString ) ), 2 )
	getMeetingTime = getMeetingTime & Right$ ( "0" & CStr ( Day ( dateString ) ), 2 )
	getMeetingTime = getMeetingTime & "T"
	getMeetingTime = getMeetingTime & Right$ ( "0" & CStr ( Hour ( timeString ) ), 2 )
	getMeetingTime = getMeetingTime & Right$ ( "0" & CStr ( Minute ( timeString ) ), 2 )
	getMeetingTime = getMeetingTime & Right$ ( "0" & CStr ( Second ( timeString ) ), 2 )

exiting:
	Call agentLog.LogAction ( "-------" ) 
	Call agentLog.LogAction ( "-------" ) 
	Exit Function
errorhandler:' report all errors in a messagebox '
	reason = "Function getMeetingTime: "
	reason = reason & "Error #" & Cstr (Err) & " (" & Error & ") on line " & Cstr (Erl)
	Messagebox reason, 16, "Error"
	Call agentLog.LogAction ( reason )
	Resume exiting
End Function

While this did take me a few days to sort out, I’m pretty happy with the result. Our configuration has users sharing one “migration” mail file, so that users who are already on Outlook still retain a mail file and can send email. Unfortunately, that means any email from them that we create in the UI is going to have values pointing back to the “migration” mail file. I spent my first few days on this trying to spoof the mail.box by changing Principal, ReplyTo, $InetAddress and Chair when sending via Notes calendaring. While changing Chair did make it appear to come from the current user, it always displayed the email address from the “migration” mail file. It might have been getting caught in our spam filter on the way to Outlook, as my test user on Notes was still receiving the notices. Nonetheless, by switching to using Outlook as the UI, it not only took away that problem, but was far simpler and future-proofed my application. As I look at these tools I’ve created in LotusScript to generate mail messages and calendar entries, I know that it’s but a short step to doing them in server-side Javascript or maybe in Java.

There is hope for the Notes gurus of old. We just have to keep learning!

iCal RFC (documentation?)

Categories: Old Notes, Utilities | Tags: , , , , , | Leave a comment

Getting email addresses from the Notes address book

As we work to get our Notes applications functioning smoothly with our Outlook mail, I’m finding ways to keep the close binding between Notes applications and the user’s mail. It’s so much easier to click a button to generate an email associated to a particular Notes document than to copy-paste a document link.

We’ve got a Notes form for the Quarterly Project Report. Each quarter, various key members on the project are supposed to have a call to review the project. The form itself is ponderous, having something like 700 fields, but (using hide-whens) can be distilled down to a manageable number for the meeting’s agenda. The Notes names of the expected participants are computed from other documents within the database, though the fields are editable. In switching from using Notes mail to generate the meeting notice to Outlook, I ended up switching to using iCal.

It turns out that iCal is a far simpler way to initiate the meeting notice in the UI. All I need is something like this in an ICS file to have it open in my Outlook client as a meeting notice for me to send (as an update, but more on that in another post)

BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART:20170622T211500
DTEND:20170622T221500
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;CN="David Navarre/Company";RSVP=TRUE:mailto:David_Navarre@company.com
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;CN="Another Person/Company";RSVP=TRUE:mailto:Another_Person@company.com
ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:external_person@SecondCompany.com
DESCRIPTION: 
SUMMARY:2017 Q2 QPR: Agribusiness Competitiveness Enhancement via file
UID:AC1804D765C782CD8525814500073F37
END:VEVENT
END:VCALENDAR

One of the hurdles is that we need to have email addresses as well as names for all of the attendees. If you leave the mailto blank or put the Notes name there, it ignores it. Since our address book is still available in Notes and contains everyone’s email address, I thought I’d just google how to do it. Sadly, it wasn’t out there, so I took a few minutes and modified some script in the help documentation (Examples: AddressBooks property) to create a function to do the lookup.

%REM
	Function getEmailAddress
	Description: This Function returns a string that is the email address from the address books

	IMPORTANT: all address books are stored in the NotesDatabase list, addressBookList, which must be called BEFORE calling the getEmailAddress function

%END REM
Function getEmailAddress ( recipientName As String ) As String
	Dim view As NotesView
	Dim doc As NotesDocument
	Dim internetAddress As Variant
	Dim found As Boolean
	Dim reason As String	

	On Error Goto errorhandler

	' if already an internet address, just return that value '
	If ( InStr ( recipientName, "@" ) ) Then
		getEmailAddress = recipientName
		Exit function
	End If

	getEmailAddress = ""
	found = False
	
	ForAll addressBook In addressBookList
		' all address books are stored in the NotesDatabase list, addressBookList, which must be called BEFORE calling the getEmailAddress function '
		' check every Domino Directory, until found '
		If ( addressBook.IsPublicAddressBook ) And ( Not found ) Then
			' look up name in the VIMPeople view of address book '
			Set view = addressBook.GetView( "($VIMPeople)" )
			If not ( view Is Nothing ) Then
				Set doc = view.GetDocumentByKey( recipientName )
				' if person is found, get their internet addrress and stop '
				If Not ( doc Is Nothing ) Then
					internetAddress = doc.Getitemvalue("InternetAddress")
					If ( internetAddress (0) <> "" ) Then
						getEmailAddress = internetAddress (0)
						found = True
						Exit ForAll
					End If
				End If
			End If 
		End If
	End ForAll
	' if found is still False, the person was not found '
	If Not found Then
		MessageBox ( "Unable to locate " & recipientName & " in the address book, using " & recipientName & " as their email addresss" )
		getEmailAddress = recipientName
	End If

exiting:
	Exit Function
errorhandler:' report all errors in a messagebox '
	reason = "Function getEmailAddress: "
	reason = reason & "Error #" & Cstr (Err) & " (" & Error & ") on line " & Cstr (Erl)
	Messagebox reason, 16, "Error"
	Resume exiting
End Function

Hope someone else finds this useful….

Addendum

Dim addressBookList List As NotesDatabase

As Ben pointed out, re-opening every address book every time you want the email address is incredibly inefficient. So, declare a global variable for the address books and use the following function to open them. The code in getEmailAddress now reflects this….

%REM
	Function openAddressBooks
	Description: This Function assigns all address books to a NotesDatabase list and opens them
%END REM
Function openAddressBooks ( ) As Boolean
	Dim reason As String	

	On Error Goto errorhandler

	openAddressBooks = False
	
	ForAll addressBook In session.Addressbooks
		' open every Domino Directory '
		If ( addressBook.IsPublicAddressBook ) Then
			Set addressBookList (addressBook.FileName) = addressBook
			Call addressBookList (addressBook.FileName).Open( "", "" )
		End If
	End ForAll
	
	openAddressBooks = True

exiting:
	Call agentLog.LogAction ( "-------" ) 
	Call agentLog.LogAction ( "-------" ) 
	Exit Function
errorhandler:' report all errors in a messagebox '
	reason = "Error #" & Cstr (Err) & " (" & Error & ") on line " & Cstr (Erl)
	Messagebox reason, 16, "Error"
	Call agentLog.LogAction ( reason )
	Resume exiting
End Function
Categories: Old Notes, Utilities | Tags: , , , , , , | 3 Comments

Progammatically opening a mailto link from the Notes client

Our company has been transitioning from Notes mail to Outlook for a little while. One of the hurdles for me has been getting Notes to send mail when the user no longer has a Notes mail file and also wants to send any editable emails via the Outlook client. Today, I’d like to have a look at my latest solution to opening those editable emails in the Outlook client.

If you try using @URLOpen or ws.URLOpen, you’ll find that despite having set the default email to Outlook, Notes insists on opening a Notes document to send the mail. If you put the mailto link into a browser, it opens in the default mail client, Outlook, formatted properly. If you programmatically open a http link, it opens in the default browser. Yet, frustratingly, it won’t take that same mailto link that works in the browser and open it from LotusScript or a formula. So, we have to cheat.

mailto syntax

As a quick review, mailto links are actually very simple.

There are five components to the link, but none are required.

mailto:person@company.com Simply list the recipients email addresses. Outlook seems to prefer that you separate them with semi-colons, though most syntax guides suggest commas.

The other four are passed as parameters in the query string. So, before you use any of them, you have to use a question mark to separate the query string from the URL, then separate each parameter with an ampersand (&).

cc=joe@company.com or bcc=jill@company.com You can add carbon-copy folks or blind-copy folks in the same way.

subject=That%20issue The subject line should be encoded so that there are no spaces and any unusual characters pass through to the email rather than disrupt the syntax of the query string.

body=The%20contents%20of%20email The body is, of course, the most important thing to us and we can simply compute the values to be used. Links back to Notes documents to be opened in the Notes client can even be used, so long as you provide a proper notes:// URL.

Hard coded example

If we create a HTML document, store it locally and open it programmatically, it will execute any javascript we’ve got on the page as browsers ought to do. So, we can make it open the mailto link if our page has the following:

<script type="text/javascript">
subject = "Change Order for your approval";
body = "Your approval has been requested for changes made... ";
body = body + "%0A%0A";
body = body + "Please review these changes and approve, provide comments, or request more time to review within five business days of this notification. Otherwise, the change will be considered approved as per DAI policy.";
body = body + "%0A%0A";
body = body + "The pending change approval form and links to draft documents can be found here: ";
body = body + "%0A";
body = body + "Notes:///852580E9007624A0/0/B82A2F4ABF0D56818525808400601DBE";
	
mailtoString = "mailto:david_navarre@company.com?subject=" + subject + "&body=" + body;

window.open(mailtoString)</script>

Sample code

So, all we have to do is generate the mailtoString, put it into a new HTML document and open it via NotesUIWorkspace.URLOpen to get our document to open and popup the Outlook client, populated with the correct information.

Sub Click(Source As Button)
	Dim ws As New NotesUIWorkspace
	Dim session As New NotesSession
	Dim thisdb As NotesDatabase
	Dim uidoc As NotesUIDocument
	Dim approver As Variant
	Dim approverNames As String
	Dim i As Integer

	Set thisdb = session.CurrentDatabase
	Set uidoc = ws.CurrentDocument
	Set doc = uidoc.Document

	For i = 1 To 6
		approver = doc.getItemValue( "Approver"&i )
		If i = 1 Then
			approverNames = approver (0)
		Else
			If ( approver (0) <> "" ) Then
				approverNames = approverNames & ";" & approver (0)
			End If
		End If
	Next
	Dim subject As String
	Dim body As String
	Dim mailtoString As String
	Dim changeOrderNumber As Variant

	changeOrderNumber = doc.getItemValue ("CONum" )
	subject = "Change Order " & changeOrderNumber (0) & " for your approval in " & thisdb.Title
	subject = Replace ( subject, " ", "%20" )
	body = "Your approval has been requested for changes made using " & changeOrderNumber (0) & " in " & thisdb.Title
	body = body & "%0A%0A"
	body = body & "Please review these changes and approve, provide comments, or request more time to review within five business days of this notification. Otherwise, the change will be considered approved as per DAI policy."
	body = body & "%0A%0A"
	body = body & "The pending change approval form and links to draft documents can be found here: "
	body = body & "%0A"
	body = body & "Notes:///" & thisdb.ReplicaID & "/0/" & doc.UniversalID
	body = Replace ( body, " ", "%20" ) 

	mailtoString = "mailto:" & approverNames & "?subject=" & subject & "&body=" & body

	Dim fileName As String
	Dim dataDirectoryPath As String
	Dim url As String
	Dim fileNumber As Integer

	fileNumber = 1

	dataDirectoryPath = session.Getenvironmentstring("Directory", True)
	fileName = dataDirectoryPath & "\mailto.htm"

	Open fileName For Output As fileNumber

	Print # fileNumber, {<script type="text/javascript">}
	Print # fileNumber, {mailtoString = "} & mailtoString {"}
	Print # fileNumber, {window.open(mailtoString)</script>}

	Close # fileNumber

	url = "file:///" & fileName
	Call ws.Urlopen(url)

End Sub
Categories: Client-Side Javascript, Old Notes | Tags: , , , | 3 Comments

An agent to change field values to help in #XPages

With our transition to XPages, I’ve been finding more and more often that I don’t have a form interface in the Notes client to just change one field value on the back end. Too often, on the front end, in XPages, there’s a value I simply haven’t exposed for editing or don’t even display. I often don’t add the fields to the back end Notes form because it really doesn’t add much value. So, when I want to change one field, or change one field on multiple documents, I do it with an agent. Heck, over the years, we all have. We just usually did it in a very static manner – writing a quick @formula to change the value of a specific field. That requires changing the design to create the agent and then delete the agent – or leaving a mess behind that slowly grows your agent list with more and more single-use agents.

So, I wrote an agent that allowed me to replace a text field by naming the field and the value. Then, I found I wanted one for numbers as well. So, for a few weeks, I had two agents. Then, I realized the folly and wrote an agent that detects the field being updated (or asking you if the field doesn’t exist). I only did it for text, numbers and dates, so I imagine it could be extended. It’s enough for me. Since I had occasion to share it this week with another developer, I thought I’d also post it here to share via the blogosphere.

While there’s nothing brilliant about it, it sure is useful.

%REM
	Agent Change Field Value
	Created Jun 19, 2015 by David Navarre/DAI
	Description: This Agent allows the user to name a field and change the value
		It checks the field type on the first document selected and
		handles strings, numbers and dates differently
%END REM
Option Public
Option Declare
Use "Utilities"
Dim ws As NotesUIWorkspace
Dim newDate As NotesDateTime
Dim newvalue As Variant
Dim fieldname As Variant
Dim change As String
Sub Initialize
	Dim session As New NotesSession
	' thisdb declared in Utilities '
	Dim ndc As NotesDocumentCollection
	Dim itemdoc As NotesDocument
	Dim itemToChange As NotesItem
	Dim numericValue As Double
	Dim itemType As Long
	Dim selectedType (2) As String
	Dim choice As Variant
	Dim reason As String

	On Error GoTo errorhandler

	Set ws = New NotesUIWorkspace
	Set thisdb = session.CurrentDatabase

	selectedType (0) = "Date"
	selectedType (1) = "Text"
	selectedType (2) = "Numbers"

	Call StartAgentLogging (session )

	fieldname = ws.Prompt ( PROMPT_OKCANCELEDIT, "Field Name", "Enter the name of the field to change" )
	If IsEmpty ( fieldname ) Then
		Exit Sub
	End If

	newvalue = ws.Prompt ( PROMPT_OKCANCELEDIT, fieldname, "Enter the new value for " & fieldname )
	If IsEmpty ( newvalue ) Then
		Exit Sub
	End If

	' get the collection before issuing the confirmation, so we can determine field type '
	' from the first document selected, assuming it is the same on the rest '
	Set ndc = thisdb.UnprocessedDocuments
	Set itemdoc = ndc.GetFirstDocument
	Call agentLog.LogAction ( "Items: " & ndc.Count )

	If ( itemdoc.Hasitem(fieldname) ) Then
		Set itemToChange = itemdoc.Getfirstitem(fieldname)
		itemType = itemToChange.Type
	Else
		choice = ws.Prompt(PROMPT_OKCANCELLIST, "Select field type", "Field " & fieldname & " does not exist on the first document. Select field type to create", "Text", selectedType )
		If IsEmpty ( choice ) Then
			MessageBox "Action cancelled"
			Exit Sub
		End If
		Select Case choice
		Case "Date"
			itemType = 1024
		Case "Text"
			itemType = 1280
		Case "Number"
			itemType = 768
		End Select
	End If	

	If Confirm ( itemType ) Then
		While Not itemdoc Is Nothing
			Select Case itemType
			Case 1024  ' DATETIMES '
				Call itemdoc.ReplaceItemValue ( fieldname, newDate )
			Case 1280  ' TEXT '
				Call itemdoc.ReplaceItemValue ( fieldname, newValue )
			Case 768  ' NUMBERS '
				' if the value supplied is an integer, save it that way '
				If ( CInt ( CDbl ( newValue ) ) = CInt ( newValue ) ) Then
					Call itemdoc.ReplaceItemValue ( fieldname, CInt ( newValue ) )
				else
					Call itemdoc.ReplaceItemValue ( fieldname, CDbl ( newValue ) )
				End If
			End Select

			Call agentLog.LogAction ( change )
			Call itemdoc.Save ( True, False )

			Set itemdoc = ndc.GetNextDocument ( itemdoc )
		Wend
		MessageBox change & Chr$(10) & "Successful on " & ndc.Count & " documents"
	End If

exiting:
	Call agentLog.LogAction ( "-------" )
	Call agentLog.LogAction ( "-------" )
	Exit Sub
errorhandler:' report all errors in a messagebox '
	reason = "Error #" & CStr (Err) & " (" & Error & ") on line " & CStr (Erl)
	MessageBox reason, 16, "Error"
	Call agentLog.LogAction ( reason )
	Resume exiting

End Sub
%REM
	Function ConfirmValue
	Description: This function displays a confirmation dialog based on the field type
%END REM
Function Confirm ( itemType As Long ) As Boolean

	Select Case itemType
		Case 1024  ' DATETIMES '
			Set newDate = New NotesDateTime ( newValue )
			change = "Change date in " & fieldname & " to " & newDate.Dateonly
		Case 1280  ' TEXT '
			change = "Change text in " & fieldname & " to " & newValue
		Case 768  ' NUMBERS '
			change = "Change number in " & fieldname & " to " & newValue
		Case 1  ' RICHTEXT '
			Confirm = False
			MessageBox "Cannot change rich text using this agent"
			Exit Function
		Case Else
			Confirm = False
			MessageBox "Cannot change " & fieldname & " using this agent" & Chr$(10) & "Field type: " & itemType
			Exit Function
	End Select 

	Confirm = ws.Prompt ( PROMPT_YESNO, "Confirmation", change & "?" )

End Function

Oh, and the relevant snippet of the Utilities script library….

%REM
    Library Utilities
    Created Mar 29, 2012 by David Navarre/DAI
    Description: Some database utilities
%END REM
Option Public
Option Declare

Dim thisdb As NotesDatabase
Dim agentLog As NotesLog
Sub Initialize

End Sub

Sub StartAgentLogging ( session As NotesSession )
    ' this module starts agent logging '
    ' 29 Mar 12, David Navarre '
    Dim title As String
    Dim agent As NotesAgent

    Set agentLog = session.CreateLog ("Agent log")
    Set agent = session.Currentagent
    Call agentLog.OpenAgentLog
    Call agentLog.LogAction ( "Log Open" )

End Sub
Categories: Old Notes, Utilities | Tags: , , , , , | 2 Comments

Creating a meeting in the UI in #IBMNotes

When we tout the advantages of using Notes, one of the key points we always mention is the tight integration between Notes applications and Notes mail. Part of this ought to be tight integration with calendaring and scheduling. Oddly, I’ve never written an application that has any interface with the user’s calendar. This had to change. Our users wanted to be able to create a meeting notice from the Quarterly Project Review (QPR) document for that review, taking up the dates and participants from that document seamlessly into the notice. I scratched my head because I’d never even tried it, though I was sure it had to be easy.

So, I looked at what form was used by the meetings I attend. I must have clicked on a proposed meeting because I chose ‘Notice’. I just added a button on the QPR form to create and send Notice documents to the chair and each of the attendees. That looked like it worked because it would show up in people’s inboxes and they could click to accept or decline. Of course, it would disappear once that happened. Oops.

Not only that, but since many of the legacy Notes apps here use formula language to create messages in the UI for users to complete when requesting approvals, my users told me they really wanted to be able to edit the notice. I groused because I find that process inefficient. Users can choose to never send the email or change it in ways that are unexpected. I like approval requests to go silently or to allow the user to enter some additional text, but not to give them full control.

Fortunately, I figured out that I ought to using the Appointment form.

Determining the solution

As I learned more about their requirements, I realized that with all the things they wanted to be able to change, I should give in and simply open in the UI. The fact that any form in the mail template is endlessly complicated was a big incentive as well.

So, I searched the internet to see if anyone else had done this and only came up with a formula language method, which mostly worked, but not quite. Then, taking the formula language code as my example, I built a LotusScript agent that does a nice job of it.

I thought that I could first create the document as a NotesDocument object and then open it using the editDocument method of the NotesUIWorkspace object. I’m not sure if it was because I didn’t set the right fields or not enough fields, but it simply didn’t work when I tried it that way. So, I went the full monty and simply opened it as a NotesUIDocument right from the start.

Interestingly, when all I did was insert names into the required (EnterSendTo) and optional (EnterCopyTo) fields, we realized that you didn’t get to see their schedules to find the right time for the meeting. One of the testers found that clicking on the highlighted ‘Required’ would make them appear. So, checking that link reveals some curious formula language coding:

FIELD EnterSendTo:= @Trim(EnterSendTo);
FIELD EnterCopyTo:= @Trim(EnterCopyTo);
FIELD EnterBlindCopyTo:= @Trim(EnterBlindCopyTo);
@Command([MailAddress];"EnterSendTo";"EnterCopyTo";"EnterBlindCopyTo");
@Command([EditGotoField]; "EnterSendTo");
@Command([EditInsertText]; " ");
@Command([EditGotoField]; "EnterCopyTo");
@Command([EditInsertText]; " ");
@If(EnterBlindCopyTo!="" & @GetProfileField("CalendarProfile"; "showCalBCC") = "1";@Command([EditGotoField]; "EnterBlindCopyTo");"");
@If(EnterBlindCopyTo!="" & @GetProfileField("CalendarProfile"; "showCalBCC") = "1";@Command([EditInsertText]; " ");"");
@PostedCommand([ViewRefreshFields])

So, it’s quirky. Using @Trim, I can understand, but why would it insert the blank space into the two fields? Then I noticed an event on each field.

Sub Onchange(Source As Field)
	Call csEventObj.onChange(FIELD_INVITEES_CHANGED, ITEM_REQUIRED )
	Call csEventObj.UpdateScheduler( ITEM_REQUIRED, ROLE_REQUIRED, APPFLAG_NEW )
	
	If Not (cseventobj.m_note.IsNewNote) Then
		cseventobj.NeedsOLPTran = True
	End If
End Sub

So, the link runs formula language that kicks off the onChange event, which does additional processing. So, when coding one’s agent to create the appointment in the UI, just repeat what the formula language does, only in script. Thus, lines 90-94 in my agent make sense.

The final quirk is with my original QPR document. If the user was in read mode, the agent ran beautifully, but if they were in edit mode, I needed to make sure I had the values on the back end AND that the code didn’t ‘get confused’ with which uidoc was which. Perhaps it was something convoluted with my code, but I found it best if I put the QPR document back into read mode. Then, to avoid issues with how I’m recording the ‘history’ (noting on the QPR that someone created a meeting notice), I decided to close and reopen it in read only mode. Allowing it to be edited was creating confusion, since it threw odd prompts and might generate rep-save conflicts. As such, I’m doing an odd dance with values and objects on lines 50-55.

The agent

%REM
	Agent Send Calendar Invites
	Created Dec 23, 2014 by David Navarre/DAI
	Description: This Agent creates a calendar invite, listing participants and optional participants
%END REM
Option Public
Option Declare
Use "Utilities"
Sub Initialize
	Dim session As New NotesSession
	Dim ws As New NotesUIWorkspace
	Dim thisdb As NotesDatabase
	Dim maildb As New NotesDatabase ( "", "" )
	Dim uidoc As NotesUIDocument
	Dim memoUIdoc As NotesUIDocument
	Dim qprDoc As NotesDocument
	Dim history As NotesRichTextItem
	Dim recipientName As NotesName
	Dim qprDate As Variant
	Dim qprTime As Variant
	Dim projectName As Variant
	Dim fiscalYearAndQuarter As Variant
	Dim participants As Variant
	Dim participantsOptional As Variant
	Dim timeString, dateString As Variant
	Dim answer As Variant
	Dim reason As String
	Dim unid As String

	On Error GoTo errorhandler
	
	Call StartAgentLogging ( session )

	Set thisdb = session.Currentdatabase

	Set uidoc = ws.Currentdocument

	reason = "This will create a meeting invite for you to send to participants."
	If uidoc.Editmode Then
		reason = reason + Chr$(10) + "The QPR will switch to read-only mode."
		reason = reason + Chr$(10) + "If you close and re-open it, you can edit it again."
	End If
	reason = reason + Chr$(10) + "Continue?"
	answer = ws.Prompt ( PROMPT_YESNO, "Continue?", reason )
	If answer = 0 Then
		Exit Sub
	End If	
		
	If uidoc.Editmode Then
		Call uidoc.Save()
		uidoc.Editmode = False
		Set qprDoc = uidoc.Document
		unid = qprDoc.Universalid
		Call uidoc.Close(True)
		Set qprDoc = thisdb.Getdocumentbyunid(unid)
		Set uidoc = ws.Editdocument(False, qprDoc, True)
	Else
		Set qprDoc = uidoc.Document
	End If
	
	Set qprDate = qprDoc.Getfirstitem("QPRDate")
	Set qprTime = qprDoc.Getfirstitem("QPRTime")
	timeString = qprTime.Text
	dateString = qprDate.Text
	Dim qprStartTime As New NotesDateTime ( timeString )

	Call maildb.Openmail()
	Set memoUIdoc = ws.Composedocument(maildb.Server, maildb.Filepath, "Appointment")
	projectName = qprDoc.Getitemvalue("ProjectName") 
	fiscalYearAndQuarter = qprDoc.Getitemvalue("FiscalYearAndQuarter") 
	Call memoUIdoc.Fieldsettext("Subject", fiscalYearAndQuarter(0) & " QPR: " & projectName (0) )

	Call memoUIdoc.Fieldsettext("STARTDATE", dateString )
	Call memoUIdoc.Fieldsettext("STARTTIME", timeString )
	Call memoUIdoc.Fieldsettext("ENDDATE", dateString )
	Call qprStartTime.Adjusthour(1, False)
	Call memoUIdoc.Fieldsettext("ENDTIME", qprStartTime.Timeonly)

	participants = qprDoc.Getitemvalue ( "Participants" )
	ForAll entry In participants
		Set recipientName = New NotesName ( entry )
		Call memoUIdoc.Fieldappendtext("EnterSendTo", recipientName.Abbreviated & Chr$(10) ) 
	End ForAll
	participantsOptional = qprDoc.Getitemvalue ( "ParticipantsOptional" )
	ForAll entry In participantsOptional
		Set recipientName = New NotesName ( entry )
		Call memoUIdoc.Fieldappendtext("EnterCopyTo", recipientName.Abbreviated & Chr$(10) ) 
	End ForAll

	Call memoUIdoc.Gotofield("EnterSendTo")
	Call memoUIdoc.Inserttext(" ")
	Call memoUIdoc.Gotofield("EnterCopyTo")
	Call memoUIdoc.Inserttext(" ")
	Call memoUIdoc.Gotonextfield()
		
	Set history = qprDoc.Getfirstitem("History")
	Call history.Appendtext(Now & " - Meeting notice created by " & session.Commonusername)
	Call history.Addnewline(1, True)
	Call qprDoc.Replaceitemvalue("NoticeFlag", 1)
	Call qprDoc.Save(True, False)
	
exiting:
	Exit Sub
errorhandler:' report all errors in a messagebox
	reason = "Error #" & CStr (Err) & " (" & Error & ") on line " & CStr (Erl)
	MessageBox reason, 16, "Error"
	Call agentLog.LogAction ( reason )
	Resume exiting ' transfers control to the exiting label
End Sub

Final thoughts

I’m sure I can do this a bit more efficiently, but I’m pretty happy with this first foray into calendaring & scheduling. We’ll probably refine this a little and do more of it in our projects. Users always want to be able to skip re-typing everything and there’s no reason not to handle it for them. Of course, we’re likely to have to revise all of this once we move to Verse, but, as my father always said, “I’ll burn that bridge when I come to it.”

Categories: Old Notes | Tags: , , , | 2 Comments

Configurable notification agent in #OldNotes

Shockingly, when I arrived at my current company, they had basically NO scheduled agents at all. Apparently, someone had decided long ago that scheduled agents were dangerous, that they would overwhelm and crash the servers. So, whenever anything was done, it was done manually. This even extended to user notifications. That is, if I submitted a document for approval, there was some formula language that would populate a new notification message in the client and the user would fill in any extra details before clicking send. I was shocked. As I’ve modified designs, I’ve been adding background notifications and also scheduled agents. Our main project management database, which our field offices use copies of to manage their projects, hasn’t been mine to modify, since it’s already working and there is a team that customizes the design for each field office.

As we’ve been delving further into XPages and as I’ve been spreading the good word about scheduled agents and notifications, we’re now finally putting them into those project management databases. One hurdle though. Our admin team has, quite rightly, limited who can sign agents that will run on production servers.

Concept

Now, I’ve designed dozens or even hundreds of notification and reminder agents in many databases over the decades, but I always designed them from scratch, customizing it to the particular database and the particular recipients. I’d created a basic one and Ariwan Susey, who’s really coming up to speed on LotusScript and XPages, modified it for use in that project management database. This was nice, and Virginia Tauss had started creating copies of it, customized for each notice type. However, every time someone made a change to the half-dozen agents, I had to sign them. Since they were customized for their particular database and the particular recipients, this meant that eventually, I might spend all day signing agents instead of writing code.

Since the agents were almost the same, except for what view they used and who received the message, I realized that if I created a basic agent, they could use configuration documents to customize as many notices as they wanted and I’d never have to sign that configurable agent again!

Configuration Choices

There were a few basic things I knew would be different between each notification: the view, the recipients, the subject, the server to run on and the time to run. After creating some tests, I also realized that I wanted to emulate the scheduling choices of agents themselves and allow the user to select weekly or monthly notifications instead of just daily. I also remembered that sometimes, they would want to mark the document after they sent the notice, so I made that a configuration choice as well. Based on my recent experience in my Excel series (part 1, part 2, and the sample database) and with full-text queries, I realized we could use those full-text queries in these notifications as well.

So, here’s my form:

AutoNotify Configuration

Since I’ve been fiddling with DXL editing of forms lately, let me include the DXL for that third row for your review. The right cell contains a table for displaying the weekday or day of the month choices, with the hide-whens appropriately.

<tablerow>
	<tablecell><par def='4'>Day(s) to run:</par></tablecell>
	<tablecell>
		<par def='5'>
			<field borderstyle='none' lookupeachchar='false' lookupaddressonrefresh='false'
			type='keyword' kind='editable' name='frequency'>
				<keywords helperbutton='false' recalconchange='true' columns='3' ui='radiobutton'>
					<textlist><text>Daily</text><text>Weekly</text><text>Monthly</text></textlist>
				</keywords>
			</field>
		</par>
		<table leftmargin='0' widthtype='fixedleft' refwidth='2.5000in'>
			<tablecolumn width='1in'/><tablecolumn width='1.5000in'/>
			<tablerow>
				<tablecell valign='center' borderwidth='0px'>
					<pardef id='6' spacebefore='1.5' keepwithnext='true' keeptogether='true'>
						<code event='hidewhen'><formula>frequency != "Weekly"</formula></code>
					</pardef>
					<par def='6'>Day of week: </par>
				</tablecell>
				<tablecell valign='center' borderwidth='0px'>
					<pardef id='7' spacebefore='1.5' keepwithnext='true' keeptogether='true'>
						<code event='hidewhen'><formula>frequency != "Weekly"</formula></code>
					</pardef>
					<par def='7'>
						<field usenotesstyle='false' height='0.2500in' width='1in' multiline='true'
						borderstyle='none' lookupeachchar='false' lookupaddressonrefresh='false'
						type='keyword' kind='editable' name='weekdayToRun'>
							<keywords helperbutton='false' columns='1' ui='combobox'>
								<textlist>
									<text>Sunday|1</text>
									<text>Monday|2</text>
									<text>Tuesday|3</text>
									<text>Wednesday|4</text>
									<text>Thursday|5</text>
									<text>Friday|6</text>
									<text>Saturday|7</text>
								</textlist>
							</keywords>
						</field>
					</par>
				</tablecell>
			</tablerow>
			<tablerow>
				<tablecell valign='center' borderwidth='0px'>
					<pardef id='8' keepwithnext='true' keeptogether='true'>
						<code event='hidewhen'><formula>frequency != "Monthly"</formula></code>
					</pardef>
					<par def='8'>Day of month:</par>
				</tablecell>
				<tablecell valign='center' borderwidth='0px'>
					<pardef id='9' keepwithnext='true' keeptogether='true'>
						<code event='hidewhen'><formula>frequency != "Monthly"</formula></code>
					</pardef>
					<par def='9'>
						<field type='number' kind='editable' name='monthdayToRun'>
							<numberformat format='general' digits='2' punctuated='false' parens='false' percent='false'
							bytes='false'/>
							<code event='defaultvalue'><formula>1</formula></code>
							<code event='inputvalidation'><formula>@If ( frequency != "Monthly"; @Success; @ThisValue > 1 
								& @ThisValue < 29; @Success; @Failure ( "Must be in the first 28 days of the month"))</formula>
							</code>
						</field>
					</par>
				</tablecell>
			</tablerow>
		</table>
		<pardef id='10' keepwithnext='true' keeptogether='true'>
			<code event='hidewhen'><formula>frequency != "Monthly"</formula></code>
		</pardef>
		<par def='10'><run><font size='1pt'/></run></par>
	</tablecell>
</tablerow>

As I use the source view more in XPages, I get more and more comfortable with just editing code, and checking appearances occasionally. While I have only done a little of that in forms, I have used it several times in views. When I created this form, my initial design of it was done by creating a single in the normal designer form, then saving it, and re-opening it in DXL. Then I added several fields to a form with cut-and-paste for field names. Using the properties boxes just seemed like it would take so much longer – after all, I had the field names in my notepad already.

The Agent

Our agent is set to run hourly, on every server. If there are no autoNotify documents, it doesn’t do anything, but if there are, it checks each one for whether it runs on that server, on that day and at that hour.

Sub Initialize
Dim session As New NotesSession
' thisdb is declared in my utilities library, so not declared here '
Dim autoNotifyView As NotesView
Dim autoNotifyDoc As NotesDocument
Dim serverToRunOn As Variant
Dim hourToRun As Variant
Dim frequency As Variant
Dim weekdayToRun As Variant
Dim monthdayToRun As Variant
Dim noticeName As Variant
Dim hourNow As Integer
Dim weekdayToday As Integer
Dim monthdayToday As Integer
Dim reason As String

On Error GoTo errorhandler

Set thisdb = session.Currentdatabase
Call StartAgentLogging ( session )

Dim serverName As New NotesName ( thisdb.Server )

' get view of autonotify documents '
Set autoNotifyView = thisdb.Getview("AutoNotify")
Set autoNotifyDoc = autoNotifyView.Getfirstdocument()

While Not autoNotifyDoc Is Nothing
	' check server to run on '
	serverToRunOn = autoNotifyDoc.Getitemvalue("serverToRunOn")
	If ( Ucase ( serverToRunOn (0) ) = Ucase ( serverName.Common ) ) Then
		' check frequency and day '
		frequency = autoNotifyDoc.Getitemvalue("frequency")
		weekdayToRun = autoNotifyDoc.Getitemvalue("weekdayToRun")
		If ( weekdayToRun (0) = "" ) Then
			weekdayToRun (0) = "0"
		End If
		weekdayToday = Weekday ( Today )
		monthdayToRun = autoNotifyDoc.Getitemvalue("monthdayToRun")
		monthdayToday = Day ( Today )
		If ( frequency (0) = "Daily" or ( frequency (0) = "Weekly" And CInt (weekdayToRun (0)) = weekdayToday ) Or ( frequency (0) = "Weekly" And CInt ( monthdayToRun (0) ) = monthdayToday ) ) Then
			' check hour to run '
			hourToRun = autoNotifyDoc.Getitemvalue("schedule")
			hourNow = Hour (Now)
			If ( CInt ( hourToRun (0) ) = hourNow ) Then
				noticeName = autoNotifyDoc.Getitemvalue("NoticeName")
				If ( sendNotices ( autoNotifyDoc ) ) Then
					Call agentLog.LogAction ( noticeName (0) & " sent")
				Else
					Call agentLog.LogAction ( noticeName (0) & " FAILED")
				End If
			End If
		End If
	End If

	Set autoNotifyDoc = autoNotifyView.Getnextdocument(autoNotifyDoc)
Wend
Call agentLog.LogAction ( "Completed" )

exiting:
	Exit Sub
errorhandler:' report all errors in a messagebox '
	reason = "Error #" & CStr (Err) & " (" & Error & ") on line " & CStr (Erl)
	MessageBox reason, 16, "Error"
	Call agentLog.LogAction ( reason )
	Resume exiting ' transfers control to the exiting label

End Sub

The actual notification builds off the values from the configuration document. At MWLUG, speakers recommended making sure to use functions instead of subroutines, partly because functions return a value and partly for forward compatible with other programming languages. So, my sendNotices function is a boolean, indicating success or failure.

The simplest, yet most powerful part of the script is the application of the querystring. By using that, I could create dozens of notifications from a single view, saving myself disk space by avoiding unnecessary view indices.

Ariwan’s great contribution to the basic agent that made it so useful in this configurable design was the use of columnvalues. The agent simply spits out the contents of the view, populating the message with the details of the document regardless of which fields are used. I’d never thought of doing that!

You’ll notice that in the loop, we get a handle to the nextdoc before processing. If the document would be removed from the view by marking one of the fields “Yes” and saving the document, we need to already have a handle to the next document. If we don’t do that, the view won’t be able to find the next document by referring to the current document, as it has no position in the view any more.

Now, since I want each notification to be processed even if I encounter some errors, I added error-handling in the function as well. If I had not, an error would bubble up to the Initialize routine and stop my agent. This way, it only stops that particular notification, but continues to the next one.

Function sendNotices ( autoNotifyDoc As NotesDocument ) As Boolean
Dim viewName As Variant
Dim recipientGroup As Variant
Dim subjectLine As Variant
Dim introText As Variant
Dim queryString As Variant
Dim flagField As Variant

Dim workingView As NotesView
Dim workingCollection As NotesDocumentCollection
Dim doc As NotesDocument
Dim nextdoc As NotesDocument
Dim memo As NotesDocument
Dim body As NotesRichTextItem
Dim reason As String
Dim count As Integer

sendNotices = false

' get viewName '
viewName = autoNotifyDoc.Getitemvalue( "viewName" )
Set workingView = thisdb.Getview ( viewName (0) )
' apply query string, if there is one '
queryString = autoNotifyDoc.Getitemvalue( "queryString" )
If ( queryString (0)<> "" ) Then
	Call workingView.Ftsearch(queryString(0), 0)
End If

Set doc = workingView.Getfirstdocument()

count = 0

Set memo = thisdb.Createdocument()
Set body = memo.Createrichtextitem(&quot;Body&quot;)
memo.Principal = thisdb.Title

' copy the introductory text from the autoNotify document into the email '
introText = autoNotifyDoc.Getitemvalue( "introText" )
Call body.Appendtext ( introText(0) )
Call body.Addnewline(2)

While Not doc Is Nothing
	Set nextdoc = workingView.Getnextdocument(doc)
	count = count + 1
	Call body.Appendtext( CStr ( count ) & "." )
	Call body.Addtab(1)
	ForAll thing In doc.Columnvalues
		If ( IsArray ( thing ) ) Then
			Call body.Appendtext( Implode (thing, ", " ) )
		Else
			Call body.Appendtext( thing )    
		End If
		Call body.Addtab(1)
	End ForAll
	Call body.Appenddoclink(doc, "Open the doc", "Link")
	Call body.Addnewline(1)

	' if field to mark, then modify field and save doc '
	flagField = autoNotifyDoc.Getitemvalue( "flagField" )
	If ( Trim ( flagField (0) ) <> "" ) Then
		Call agentLog.LogAction ( flagField (0) & " field #" & CStr ( count ) )
		Call doc.ReplaceItemValue ( flagField (0), "Yes" )
		Call doc.Save ( True, False )
	End If

	Set doc = nextdoc

Wend

subjectLine = autoNotifyDoc.Getitemvalue( "subjectLine" )
memo.Subject = CStr (count) & " " & subjectLine (0)
recipientGroup = autoNotifyDoc.Getitemvalue("recipientGroup" )
Call memo.Send(False, DetermineKeyword ( recipientGroup(0)) )

sendNotices = True

exiting:
	Exit Function
errorhandler:' report all errors in a messagebox '
	reason = "Error #" & CStr (Err) & " (" & Error & ") on line " & CStr (Erl)
	MessageBox reason, 16, "Error"
	Call agentLog.LogAction ( reason )
	Resume exiting ' transfers control to the exiting label '

End Function

It’s not quite perfect because if the server is down, it won’t run the notification later. I might take that into account in a future version, since many our project servers are in locations where power may not always be 24×7. Similarly, if someone puts too many notifications to run at the same time, the agent could time out, failing to run all of them.

Hopefully, this exercise proves useful to someone else. I can’t believe I spent more than a decade constantly re-writing the same code when I could have saved myself considerable time by just creating a customizable, reusable piece of code back in the day. Live and learn!

Update:

ThingFailsTurns out there was a bug in the code. The simple loop through the columnValues didn’t take into account multi-value fields. So, when the agent ran on a view with a document that had multiple values, it was trying to print a variant as text. So, I added a simple check for IsArray and imploded the multi-value field to build a comma-delimited string. That avoids the type mismatch that our script was throwing when it found those multi-value fields as shown at right in the debugger.

Categories: Old Notes, Utilities | Tags: , , , , | 1 Comment

Showing a database in a panel in #IBMNotes

I’d never understood the whole ‘widgets’ thing that got added to Notes, where you could have additional things in that right-side set of panels. At first, I put Linked In over there, thinking I’d use it to look up people and keep track of discussions. I never did use it and eventually, it would just show me a login box, so when I changed laptops, I didn’t bother putting it back. I tried some RSS feeds, but they were far too chatty for me to ever keep track. However, I found a great use for them this past month: quick access to back-end of XPages databases.

In our environment, we put all the XPages and custom controls into one ‘design’ database and almost all of the data into another. You guessed it, the almost all is the problem. Some of the configuration documents have to reside in the design database, so it knows where the data resides. I want the users to double-click on the icon and simply open the application in XPiNC, rather than accidentally seeing the man behind the curtain. So, I put a link in the home page of our application that opens the views in Notes:

<xp:link escape="true" id="link1" text="Open Notes views" style="position: absolute;z-index:100;top:25px;left:750px;">
	<xp:this.rendered><![CDATA[#{javascript:var roles = context.getUser().getRoles();
	return @IsMember("[WFAdmin]", roles);}]]></xp:this.rendered>
	<xp:this.value><![CDATA[#{javascript:var server:NotesName = session.createName(@Subset ( @DbName(), 1));
		var filepath = database.getFilePath();
		return "Notes://" + server.getCommon() + "/" + filepath + "/TSWFKeywords?OpenView";}]]>
	</xp:this.value>
</xp:link>

That works fine, but you have to navigate back to the home page to get the the link. I could put it everywhere, but as I was looking at the Drag n Drop sidebar per a request from our newly acquired office in the UK, I figured out that creating the XML files and putting them among your widgets was really easy.

Just create a file with an XML extension — it can have any name, so I tend to create them with meaningful names, like “APPS1 Shared Resources Engage.xml” — with a format similar to this:

<?xml version="1.0" encoding="UTF-8"?>
<webcontextConfiguration version="1.1">
<palleteItem contributeTabOnStartup="false" 
contributeToSideshelfOnStartup="true" 
hideThumbnail="false" 
id="APPS1SharedEngage" 
imageUrl="" 
providerId="com.ibm.notes.toolbox.provider.NotesViewPalleteProvider" 
singletonSidebar="false" 
title="ENGAGE SR" 
url="Notes://APPS1/Projects/Philippines/Engage/EngageSharedRes.nsf" 
viewImageUrl="">
<preferences/>
<data TYPE="DEFAULT"/>
</palleteItem>
</webcontextConfiguration>

I know five of the values that you can edit….

Line Meaning
contributeTabOnStartup=”false” If you want it to always open as a regular tab when you open Notes, set this to true. Otherwise, set this to false.
contributeToSideshelfOnStartup=”true” If you want it to always open as a panel on the right, set this to true. If you’d prefer to double-click and have it open in a new window, set this to false.
id=”APPS1SharedEngage” The unique ID for this widget. If you drag-and-drop another XML file with the same ID, it will update the existing one. When you create one using the menus, it supplies a random numeric one, but text also works, so I’d recommend that using meaningful, text ones.
title=”ENGAGE SR” This is what displays on the icon in the sidebar and on the panel header if you load it in a panel.
url=”Notes://APPS1/Projects/Philippines/Engage/EngageSharedRes.nsf” The Notes URL that you want to open. You can specify a view, but when I tried to specify an agent it didn’t execute the agent. I also tried to specify the XPage, but it never loaded. So, either the database or a specific view (with /viewname?OpenView after the filepath)

Then, drag and drop that XML file from your file system onto the My Widgets panel. In the one I have above, it always opens in a panel on startup, so I can access the views quickly and easily. However, we’re going to have 70 of these at any one time once we roll out our design to all of our projects, so I’ll probably not have any load on startup. If you don’t load them, on startup, double-clicking opens them as a new Notes window.

As soon as I finish figuring out how we’re going to configure the Drag n Drop widget, I’ll post on how to do that. While there is documentation on OpenNTF, I think I can provide some more insight and may look at doing some enhancements down the road.

Categories: Old Notes | Tags: , , | Leave a comment

Simple dialog returning a value to your #XPages

Since I’m still new to XPages, I’m always finding things that are a challenge. Most of the time, I don’t know how big a challenge they are because doing things in XPages isn’t the same as doing them in Old Notes.

Business case

On the payment request in our procurement module, the user needs to enter the exchange rate between the local currency and the base currency for approval routing. Sometimes, the payment has already been made in local currency while the exchange rate has changed. They might only know the amounts in the two currencies, but didn’t record the exchange rate at the time.

PaymentRequest

Solution

In discussions, I suggested that it might not be difficult to simply have a popup that allowed the user to enter the amounts and return the value to the payment request in the UI.

Surprisingly to me, I was right. It’s not that hard. My challenge was that I decide to put this exchange rate computer into a custom control so that I’d be able to re-use it. That meant having to figure out how to have the dialog do a partial refresh on the payment request, in order to recompute the total amount in the base currency (USD).

Existing design considerations

Our procurement module was originally designed by the brilliant minds over at Teamwork Solutions, led by Scott Good. It’s very nice, but this wasn’t included in the original requirements gathering. (What percentage of actual requirements do get into the original requirements gathering?)

Each of those line items displayed in the payment request is a separate Notes document, so, in Old Notes terms, this would be like an embedded view, but since we’re in XPages, it’s a repeat. (I am learning to love repeats!) The total payable amounts are ‘computed fields’ in XPages, which is like a computed for display field in Old Notes. As such, they wouldn’t actually save anything to the payment request document. So, we have some non-displayed computed fields with some server-side Javsacript (SSJS) that aggregates all the local amounts from those view entries and puts it into the control on the XPage. As such, when the exchange rate changes, there is a partial refresh.

So, on our exchange rate control, we had a partial refresh for onchange. Simple control, bound to a field on our payment request document.

<xp:inputText id="exchangeRate" value="#{payDoc.Exchange_Rate}" style="text-align:right;width:70.0px;">
	<xp:this.converter>
		<xp:convertNumber type="number"></xp:convertNumber>
	</xp:this.converter>
	<xp:eventHandler event="onchange" submit="true" refreshMode="partial" refreshId="paymentCostInfoPanel">
	</xp:eventHandler>
</xp:inputText>

Design

So, I created my exchangeRateComputer custom control. I decided to make the link part of the custom control so that implementing it on any other XPage would require the least work possible.  I decided to always set the control on the XPage to be named “exchangeRate”, so I don’t have to pass a string with the control name. As an added bonus, I wanted to fill out the local currency field for the user and just let them enter the final base currency amount they’d like to see.

ExchangeRateCalculator

I ran into one problem. I couldn’t get it to do the partial refresh from the exchangeRateComputer custom control. I’d tried some XSP.partialRefreshGet commands but was having problems and realized…. I might want the name of the element to be refreshed to be different on different XPages. Since passing the compositeData value into the XSP.getElementById seemed beyond my capabilities, I decided to simply defer all refreshes to the exchangeRate control itself. So, I ended up adding an onblur partial refresh. When the user clicks on the ‘Apply to payment request button’, it puts focus onto the exchangeRate control, then, closes the dialog. Closing the dialog blurs focus from the exchangeRate control and…. with an added onblur event, performs a partial refresh for me. (The onblur event is identical to the onchange, except for the name.)

<?xml version="1.0" encoding="UTF-8"?>
<xp:view xmlns:xp="http://www.ibm.com/xsp/core" xmlns:xe="http://www.ibm.com/xsp/coreex">
	<xp:link escape="true" text="Compute Exchange Rate" id="computeLink" 
		style="margin-left:5px;">
		<xp:eventHandler event="onclick" submit="false">
			<xp:this.script><![CDATA[XSP.openDialog('#{id:computeDialog}');]]></xp:this.script>
		</xp:eventHandler>
	</xp:link>

	<xe:dialog id="computeDialog" title="Exchange rate calculator">
		<xp:table>
			<xp:tr>
				<xp:td>
				</xp:td>
				<xp:td>
					<xp:label id="instructions" value="Enter the amounts below to calculate an exchange rate">
					</xp:label>
				</xp:td>
			</xp:tr>
			<xp:tr>
				<xp:td style="width:150px;text-align:right;">
					<xp:label id="total_baseCurrencyLabel" value="Total Base Currency">
					</xp:label>
				</xp:td>
				<xp:td>
					<xp:inputText id="total_baseCurrency" style="width:100px;text-align:right;">
						<xp:this.converter>
							<xp:convertNumber type="number"></xp:convertNumber>
						</xp:this.converter>
						<xp:eventHandler event="onchange" submit="true"
							refreshMode="complete">
							<xp:this.action><![CDATA[#{javascript:var base = getComponent("total_baseCurrency").getValue();
var local = getComponent("total_localCurrency").getValue();
rate = local / base;
getComponent("rate").setValue(rate);}]]></xp:this.action>
						</xp:eventHandler>
					</xp:inputText>
				</xp:td>
			</xp:tr>
			<xp:tr>
				<xp:td style="text-align:right;">
					<xp:label id="total_localCurrencyLabel" value="Total Local Currency">
					</xp:label>
				</xp:td>
				<xp:td>
					<xp:inputText id="total_localCurrency"
						defaultValue="#{javascript:compositeData.localCurrency}"
						style="width:100px;text-align:right;">
						<xp:this.converter>
							<xp:convertNumber type="number"></xp:convertNumber>
						</xp:this.converter>
						<xp:this.validators>
							<xp:validateLongRange minimum="1"></xp:validateLongRange>
						</xp:this.validators>
						<xp:eventHandler event="onchange" submit="true"
							refreshMode="complete">
							<xp:this.action><![CDATA[#{javascript:var base = getComponent("total_baseCurrency").getValue();
var local = getComponent("total_localCurrency").getValue();
rate = local / base;
getComponent("rate").setValue(rate);}]]></xp:this.action>
						</xp:eventHandler>
					</xp:inputText>
				</xp:td>
			</xp:tr>
			<xp:tr>
				<xp:td style="text-align:right;">
					<xp:label id="rateLabel" value="Exchange Rate"></xp:label>
				</xp:td>
				<xp:td>
					<xp:text escape="true" id="rate"></xp:text>
				</xp:td>
			</xp:tr>
			<xp:tr>
				<xp:td>
				</xp:td>
				<xp:td>
					<xp:button value="Apply to payment request"
						id="copyButton">
						<xp:eventHandler event="onclick" submit="true"
							refreshMode="complete">
							<xp:this.script><![CDATA[var rate = XSP.getElementById("#{id:rate}"); 
XSP.getElementById("#{id:exchangeRate}").value = rate.innerHTML;
XSP.getElementById("#{id:exchangeRate}").focus();
XSP.closeDialog('#{id:computeDialog}');]]></xp:this.script>
						</xp:eventHandler>
					</xp:button>
					<xp:button value="Cancel" id="cancelButton">
						<xp:eventHandler event="onclick"
							submit="false">
							<xp:this.script><![CDATA[XSP.closeDialog('#{id:computeDialog}');]]></xp:this.script>
						</xp:eventHandler>
					</xp:button>
				</xp:td>
			</xp:tr>
		</xp:table>
	</xe:dialog>
</xp:view>

The last tweak to it is that my computed exchange rate in the dialog is just a computed field. I struggled a little until a dogpile search revealed that I needed to get that value as innerHTML. If you try getValue() on a computed field, you get bupkis, but if you grab the innerHTML, you’ve got the world in your hands.

It feels like that would be easier in Old Notes, but I hardly care any more. I have a new micro-solution in my toolbox and I expect to re-use this not only for other exchange rate computations, but to re-use the dialog and value-passing in many places. It’s all about building up your toolkit, right?

Categories: Client-Side Javascript, Old Notes, Server-Side Javascript, Xpages, XSP Functions | Tags: , , , , , , , , , , | 1 Comment

Simple view without links in #xpages

One of my co-workers, Neil Enet, asked me the other day if it was possible to set up a view but prohibit the users from opening the documents. I tossed out the idea of using an HTML table with all the HTML for the rows and cells being computed in one column of a view.

"<tr><td>" + approverName + "</td><td>" + approvalType +"</td><td>" + description"</td></tr>"

Unfortunately, that’s pretty clunky and not very satisfying. So, as we talked, I said, “Hey, how about you create an XPage with a view and just don’t make any of the columns clickable?” So, he did.

I asked Neil to comment on it, and he had some great thoughts:

My first XPage. I’ve never competed on a race before, but I imagine the feeling of winning one as being pretty similar to seeing this XPage for the first time. And OK, let’s be honest, it’s a pretty simple XPage. “Simple” might be too much of a word, actually. It’s just a view. ONE view.

The process was extremely easy. I just dragged the View Control, linked it to the view in Notes that I wanted, and voilà. I changed the font size and color of each column, and that felt like an even more awesome achievement.

The funny thing is I’m sure that if I never had the need to create a view like this, where users weren’t able to open documents, this wouldn’t have been so fulfilling. I can see myself saying: “Great, I just created a view, and you can’t open any docs. What’s the big deal about THAT?” But it turns out that that’s exactly what I needed. So simple! And to think that Old Notes didn’t allow me to do this, and that I had to go down the “terrifying” XPages way. Ha!

I can’t think of a better way to start playing with XPages. I now know I can do one, and I know I’m being very naive if I say “XPages is a piece of cake”, but there it is on my system, and I’m sure it will not be the only one.

I’m looking forward to creating more complex XPages, and it’s very very exciting. I might just take a picture of my XPage and put it next to my wife’s here on my desk. And when people walk by and ask me what that is, I’ll answer: “That’s the most simple XPage in the world. But you know what? It’s MY simple XPage.”

<?xml version="1.0" encoding="UTF-8"?>
<xp:view xmlns:xp="http://www.ibm.com/xsp/core">

<xp:viewPanel rows="30" id="viewPanel1">
	<xp:this.facets>
		<xp:pager partialRefresh="true" layout="Previous Group Next" xp:key="headerPager" id="pager1">
		</xp:pager>
	</xp:this.facets>
	<xp:this data>
		<xp:dominoView var="view1" viewName="COPApprovals"></xp:dominoView>
	</xp:this.data>
	<xp:viewColumn columnName="$11" id="viewColumn1" style="font-weight:bold;color:rgb(0,0,160)">
	</xp:viewColumn>
	<xp:viewColumn columnName="$10" id="viewColumn2" style="font-weight:bold;color:rgb(0,64,0)">
	</xp:viewColumn>
	<xp:viewcolumn columnName="$12" id="viewColumn3">
	</xp:viewColumn>
</xp:viewPanel>

</xp:view>

Then, to get users to access it from their Old Notes, using an Outline Entry to open the URL….

targetXpage := "internalApprovals.xsp";

server := @Subset ( @DbName; 1 );
path := @Subset ( @DbName; -1 );

fserver := @Name([CN]; server);
fpath := @ReplaceSubstring(path; "\\"; "/");

url := "notes://" + fserver + "/" + fpath + "/" + targetXpage;

url

And now, Neil has developed his first XPage, users will be able to see status on their documents and everyone will be happy. (Well, I’ll be happier once he goes back in and names the columns in the old view, gives better IDs to the XPage viewColumns, puts it all onto an application layout control and makes everything pretty, but, it works!)

So, if you’re still in fear of XPages, you needn’t be. Go forth and be LOST IN XPAGES with the rest of us!

 

Categories: Old Notes, Xpages | Tags: , , | 2 Comments

Preventing users from opening a document in Notes

As we prepare our pilot of our XPage application, I was reminded that sometimes users might find a way to open a document in the Notes client when we don’t really want them to. While the odds are against them getting to a view or to a Notes document, no system is idiot-proof (idiots are absolutely genius sometimes!) So, I dug out an old script that I’d written back during my time at FAA to prevent users from accidentally opening a Notes database when they should be opening it only via the browser.

So, the users in question need to be able to update the documents, but I want them to do it in XPiNC or in the browser. That means, I can’t go using readernames fields to hide the documents from them and I know that hiding views (either via not including them in an outline or by naming convention) isn’t necessarily going to prevent them from opening my views.

Basically, all we do is check for their roles and if they have the right one, we let them in. Otherwise, they get warned off. I could add some script to this to open the document in the proper XPage, but this is a bare-bones version to help you get started if you have this kind of need.

Sub Queryopen(Source As Notesuidocument, Mode As Integer, Isnewdoc As Variant, Continue As Variant)
Dim session As New NotesSession
Dim db As NotesDatabase
Dim doc As NotesDocument
Dim formName As Variant
Dim OKtoOpen As Boolean
Dim roles As Variant

Set db = session.CurrentDatabase
roles = db.QueryAccessRoles(session.UserName)

Set doc = Source.Document
formName = doc.GetItemValue ( "Form" )

OKtoOpen = False

Forall URoles In roles
	If Ucase(URoles) = "[ADMIN]" Then
		OKtoOpen = True
	End If
End Forall

If OKtoOpen = False Then
	Continue = False
	Messagebox "You are not authorized to access " & formName(0) & " documents via the Notes Client!",48, "Access Error"
End If
End Sub

While I was at it, I also wrote another version to be used to keep users out if they accidentally opened it on the backup or development servers. (Yes, I know production databases don’t belong on development servers, but it has happened here and, I am sure, other places).

Sub Queryopen(Source As Notesuidocument, Mode As Integer, Isnewdoc As Variant, Continue As Variant)
	Dim ws As New NotesUIWorkspace
	Dim session As New NotesSession
	Dim db As NotesDatabase
	Dim appsdb As NotesDatabase
	Dim doc As NotesDocument
	Dim appsdoc As NotesDocument
	Dim serverName As New NotesName ( "" )
	Dim dontOpenServers (1) As String

	dontOpenServers (0) = "DominoDev"
	dontOpenServers (1) = "Backup"

	Set db = session.CurrentDatabase
	Set serverName = New NotesName ( db.Server )

	Forall badServer In dontOpenServers
		If serverName.Common = badServer Then
			Continue = False
			Messagebox "You are attempting to open this document on " & serverName.Common & Chr$(10) & "Trying to open the document on MAIN",48, "Wrong Server"
			Set doc = Source.Document
			Set appsdb = New NotesDatabase ( "MAIN/COMPANY", db.FilePath )
			' if that didn't open it, try again
			If Not ( appsdb.IsOpen ) Then
				Call appsdb.Open ( "MAIN/COMPANY", db.FilePath )
			End If
			' if that didn't open it, try by replicaID
			If Not ( appsdb.IsOpen ) Then
				Call appsdb.OpenByReplicaID ( "MAIN/COMPANY", db.ReplicaID )
			End If
			If Not ( appsdb.IsOpen ) Then
				Messagebox "Could not open the MAIN/COMPANY replica of the database, trying local replica",48, "Failed"
				Call appsdb.OpenByReplicaID ( "", db.ReplicaID )
			End If
			If ( apps1db.IsOpen ) Then
				Set appsdoc = apps1db.GetDocumentByUNID ( doc.UniversalID )
				If Not ( appsdoc Is Nothing ) Then
					Call ws.EditDocument ( False, appsdoc )
				Else
					Messagebox "Opened the database, but could not open the document",48, "Failed"
				End If
			Else
				Messagebox "Could not open the local replica of the database either",48, "Failed"
			End If
		End If
	End Forall
End Sub

These QueryOpens could be placed on individual forms or on subforms that are on those forms. You could put a version in the PostOpen event of the database script, though you have to keep in mind that the PostOpen doesn’t run if the user opens a document via a document link instead of opening a view or the database itself. Come to think of it, I will be putting a version in the PostOpen to prevent unauthorized users from opening the database, but have it quietly open the XPiNC page I want them to open.

I thought about putting this in my Security category, but it’s not really about security. It’s mostly about making sure the user gets the proper experience, by using production replicas or the correct client.

Hope you found something interesting here!

Categories: Old Notes, Utilities | Tags: , , , , , , | 5 Comments

Blog at WordPress.com.

%d bloggers like this: