MOSS MVP

I've moved my blog to http://blog.falchionconsulting.com!. Please update your links. This blog is no longer in use--you can find all posts and comments at my new blog; I will no longer be posting to this site and comments have been disabled.

Wednesday, June 25, 2008

Trace Log Settings

It's been a little while since I've released any new commands so I decided to throw one together real quick.  Fellow MVP, Matthew McDermott, was working on a scripted install and had asked if I had anything to set the path for the ULS logs.  Unfortunately I didn't but a quick disassemble of the central admin page showed that the code to do this was really simple so I decided to throw a command together: gl-tracelog.

The code necessary to set the trace log properties is real simple - you just get a SPDiagnosticsService object via the static "Local" property and then set the necessary properties and call update:

   1: using System.Collections.Specialized;
   2: using System.Text;
   3: using Lapointe.SharePoint.STSADM.Commands.SPValidators;
   4: using Microsoft.SharePoint.Administration;
   5: using Lapointe.SharePoint.STSADM.Commands.OperationHelpers;
   6:  
   7: namespace Lapointe.SharePoint.STSADM.Commands.Logging
   8: {
   9:     public class TraceLog : SPOperation
  10:     {
  11:         /// <summary>
  12:         /// Initializes a new instance of the <see cref="TraceLog"/> class.
  13:         /// </summary>
  14:         public TraceLog()
  15:         {
  16:             SPDiagnosticsService diagnosticsService = SPDiagnosticsService.Local;
  17:  
  18:             SPParamCollection parameters = new SPParamCollection();
  19:             parameters.Add(new SPParam("logdirectory", "log", false, diagnosticsService.LogLocation, new SPNonEmptyValidator()));
  20:             parameters.Add(new SPParam("logfilecount", "num", false, diagnosticsService.LogsToKeep.ToString() , new SPIntRangeValidator(0, 1024)));
  21:             parameters.Add(new SPParam("logfileminutes", "min", false, diagnosticsService.LogCutInterval.ToString(), new SPIntRangeValidator(0, 1440)));
  22:  
  23:             StringBuilder sb = new StringBuilder();
  24:             sb.Append("\r\n\r\nSets the log file location (note that the location must exist on each server) and the maximum number of log files to maintain and how long to capture events to a single file.\r\n\r\nParameters:");
  25:             sb.Append("\r\n\t[-logdirectory <log file location>]");
  26:             sb.Append("\r\n\t[-logfilecount <number of log files to create (0-1024)>]");
  27:             sb.Append("\r\n\t[-logfileminutes <number of minutes to use a log file (0-1440)>]");
  28:             Init(parameters, sb.ToString());
  29:         }
  30:  
  31:         #region ISPStsadmCommand Members
  32:  
  33:         /// <summary>
  34:         /// Gets the help message.
  35:         /// </summary>
  36:         /// <param name="command">The command.</param>
  37:         /// <returns></returns>
  38:         public override string GetHelpMessage(string command)
  39:         {
  40:             return HelpMessage;
  41:         }
  42:  
  43:         /// <summary>
  44:         /// Runs the specified command.
  45:         /// </summary>
  46:         /// <param name="command">The command.</param>
  47:         /// <param name="keyValues">The key values.</param>
  48:         /// <param name="output">The output.</param>
  49:         /// <returns></returns>
  50:         public override int Execute(string command, StringDictionary keyValues, out string output)
  51:         {
  52:             output = string.Empty;
  53:  
  54:             string logDirectory = Params["logdirectory"].Value;
  55:             int logFileCount = int.Parse(Params["logfilecount"].Value);
  56:             int logFileMinutes = int.Parse(Params["logfileminutes"].Value);
  57:  
  58:             SPDiagnosticsService local = SPDiagnosticsService.Local;
  59:             local.LogLocation = logDirectory;
  60:             local.LogsToKeep = logFileCount;
  61:             local.LogCutInterval = logFileMinutes;
  62:             local.Update();
  63:  
  64:             return OUTPUT_SUCCESS;
  65:         }
  66:  
  67:         #endregion
  68:     }
  69: }

The syntax for the command is shown below:

c:\>stsadm -help gl-tracelog

stsadm -o gl-tracelog


Sets the log file location (note that the location must exist on each server) and the maximum number of log files to maintain and how long to capture events to a single file.

Parameters:
        [-logdirectory <log file location>]
        [-logfilecount <number of log files to create (0-1024)>]
        [-logfileminutes <number of minutes to use a log file (0-1440)>]

Here's an example of how to use the command to set each property:

stsadm -o gl-tracelog -logdirectory c:\moss\logs -logfilecount 100 -logfileminutes 30

One question someone might have is why I would want to change the location of the log files?  There are two reasons to do this, performance and disk capacity.  It's often a best practice to keep your C drive dedicated to the operating system and application installs and then to create another another drive for your log files (ULS logs and IIS logs for instance).  Doing this will allow each drive to perform with less contention and will allow log files to grow without the risk of bringing down the operating system (let your C drive fill up and see how long your SharePoint server continues to run - eventually you won't even be able to log in to the machine).  Joel Oleson has a good post on this that I'd highly recommend everyone read: SharePoint Disk Allocation and Disk I/O.

Friday, June 20, 2008

WSS Build of My STSADM Extensions

If you're a WSS v3 user this will hopefully make you happy - I've reworked my custom extensions so that there is now a WSS specific build.  This means that you can download the source and debug in a pure WSS environment and you will also no longer be plagued by commands being available but not working either at all (because they're just for MOSS) or only partially because I had one small MOSS specific feature that's not necessary for WSS.

If you look at the top of my blog (or in the right nav) you will now see an additional download option for a WSS WSP file.  If you're a WSS only environment you'll want to install this (make sure you retract any previous installation of my commands - because this file is a different name it won't force you to do so but you will definitely want to).

When you deploy the extensions there will now be three files for the MOSS install and two files for the WSS install:

  • Lapointe.SharePoint.STSADM.Commands.dll (MOSS and WSS - goes in the GAC)
  • stsadmcommands.moss.lapointe.xml (MOSS only)
  • stsadmcommands.wss.lapointe.xml (MOSS and WSS)

If you download the source you will see that the original "Debug" and "Release" configurations have been replaced with those specific to either MOSS or WSS:

Build Configurations

All I'm doing is using a simple conditional compilation symbol, "MOSS", which I use to wrap all MOSS specific code within.  I'm hoping to find some time to also rework the index of commands that I have which is really just a jumbled mess.  This should make it easier to see what's available.

I hope people will find this rework beneficial and, as always, your feedback is most welcome and appreciated!

Thursday, June 19, 2008

Moving Databases, the Easy Way!

I've seen this come up a lot so I figured I'd write a short post about it.  A lot of people have been asking how to move their databases to a new server and to do this the way most people are prescribing you've got a lot of reading and a lot of steps to perform.  The following two post provide just a couple of examples of the numerous steps and issues to consider: http://techacid.spaces.live.com/blog/cns!6D62FC28E76BE4B!230.entry and http://blogs.technet.com/corybu/archive/2007/06/01/detaching-databases-in-moss-2007-environments.aspx.

But that's all just way too much work - who wants to deal with all those steps and what if something goes wrong and, oh, I have to rebuild my configuration database because you can't move it using the typical approaches.  Shouldn't there be an easier way?  Well, there is - use SQL Aliasing.  SQL Aliasing basically just involves installing the SQL Server Configuration Manager on each server and configuring an Alias to point to your SQL Server and the good news is that Microsoft now recognizes this as a supported configuration for SharePoint.

So say you have an existing farm with all the databases on a server called MOSSSQL1 and you want to move all the databases to MOSSSQL2.  To do this you can either install the SQL Server Configuration Manager on each server or if you have MDAC installed (which you should) then simply open up c:\windows\system32\cliconfg.exe (recommended over installing the SQL Configuration Manager).  Go to the Alias tab and click "Add".  In the dialog that appears you can then configure your alias (Alias Name=MOSSSQL1, Server=MOSSSQL2 if using Named Pipes or the IP address if using TCP/IP).  Then click "OK" to save the alias (be sure to do this during off hours as once you click OK SharePoint will now start looking to your new server for the databases).

Using cliconfg.exe

Using SQL Server Configuration Manager

Once you have the alias configured you can now simply detach all the databases from your old server and then copy and re-attach the databases to the new server.  If you're using Kerberos make sure you remember to create your SPNs for the new server.  Keep in mind though that using this approach will mean that all of your databases will need to be moved (even non-SharePoint databases if you are connecting to those databases from any of your SharePoint servers).  You can get around this if you use aliases prior to building your farm (keep reading).  One thing to watch out for is your permissions - make sure you take note of what permissions each database has as some may change with the re-attach.

That's it - your done!  Wasn't that easier than all the other crap you'd have to deal with otherwise?  But wait, there's more!  If you are in a position where you haven't yet built your farm then why not preemptively configure aliases?  So perhaps you have just one SQL Server today but you anticipate the need to eventually distribute the databases across servers in the future to address future performance needs or maybe you just want to isolate the SharePoint databases from other databases in a shared server situation?  Simply create the aliases that you anticipate needing and point them all to the same server for now - then when you need to move a database group you can just change the alias and move the databases in that group.  If you want ultimate flexibility (perhaps you just don't have a clue as to how you will need to distribute your databases in the future) then create an alias for each database (not sure I'd take it this far myself but it would give you the most flexibility in allowing you to very easily move any one or more databases to any other server with ease).

I've been using this approach for years with both 2003 and 2007 and it's always worked great for me and hopefully others will benefit as well.

Tuesday, June 17, 2008

Presetting Lookup Field Values via a Link

On a recent project I had a need to relate one list to another.  The first list was a document library which stored project specs and the second was a list that contained user provided scores that were used to rank the projects.  One simple requirement I had was to be able to provide a link to enter a score for a given project and have the lookup field corresponding to the project preset with the value of the field passed in.  I also had to hide certain fields from from the input form based on certain business rules.

To accomplish these goals I created a custom content type which contained my fields necessary for tracking the scores.  The individual fields are not important but note the values of the FormTemplates' elements:

   1: <ContentType ID="0x01001E7ED379D9C6A5458B184D277D8452DA" Name="Project Score" Group="Custom Content Types" Version="12">
   2:   <Folder TargetName="_cts/Project Score" />
   3:   <XmlDocuments>
   4:     <XmlDocument NamespaceURI="http://schemas.microsoft.com/sharepoint/v3/contenttype/forms">
   5:       <FormTemplates xmlns="http://schemas.microsoft.com/sharepoint/v3/contenttype/forms">
   6:         <Display>ListForm</Display>
   7:         <Edit>ProjectScoreListForm</Edit>
   8:         <New>ProjectScoreListForm</New>
   9:       </FormTemplates>
  10:     </XmlDocument>
  11:   </XmlDocuments>
  12:   <FieldRefs>
  13:     <FieldRef ID="{76497df6-2ed1-4da6-bff7-6e370c1963ab}" Name="Project" />
  14:     <FieldRef ID="{e797a104-a6b2-4d8a-8c81-e3a85522cca5}" Name="NeedMoreInfo" />
  15:     <FieldRef ID="{6cf08cc1-1d8e-4234-87f9-9f70a9366f0c}" Name="StrategicAlignment" />
  16:     <FieldRef ID="{51da6c79-7385-463d-8cf9-f44cb43f3cc2}" Name="DirectPayback" />
  17:     <FieldRef ID="{e7617985-7eec-4225-b2fd-3dfc5f96816c}" Name="ProcessImpact" />
  18:     <FieldRef ID="{3cd5a678-1223-47b2-9e4a-d4f3e7ac1109}" Name="TechnicalArchitecture" />
  19:     <FieldRef ID="{885b8c33-7f8e-4564-bd0a-3d1d145a5cc7}" Name="Risk" />
  20:     <FieldRef ID="{0f94f88f-e8b8-4589-b45c-63bd38dc2a91}" Name="TotalScore" ShowInEditForm="False" ShowInNewForm="False" />
  21:     <FieldRef ID="{73094a66-235c-4d77-9900-4501ea8fe622}" Name="PercentAligned" ShowInEditForm="False" ShowInNewForm="False" />
  22:     <FieldRef ID="{52578FC3-1F01-4f4d-B016-94CCBCF428CF}" Name="_Comments" />
  23:   </FieldRefs>
  24: </ContentType>

By specifying a custom value for the "Edit" and "New" elements I was able to use a custom delegate control.  To create the custom delegate control I copied the RenderingTemplate control with the ID of ListForm from the 12\Template\ControlTemplates\DefaultTemplates.ascx file and put it into a new file.  I then modified it to use a custom ListFieldIterator that I created.  The following is the delegate control:

   1: <%@Control Language="C#" %>
   2: <%@Assembly Name="Microsoft.SharePoint, Version=12.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>
   3: <%@Register TagPrefix="SharePoint" Assembly="Microsoft.SharePoint, Version=12.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" namespace="Microsoft.SharePoint.WebControls"%>
   4: <%@Register TagPrefix="wssuc" TagName="ToolBar" src="/_controltemplates/ToolBar.ascx" %>
   5: <%@Register TagPrefix="wssuc" TagName="ToolBarButton" src="/_controltemplates/ToolBarButton.ascx" %>
   6: <%@Assembly Name="Demo, Version=1.0.0.0, Culture=neutral, PublicKeyToken=f801e7437a76c41d" %>
   7: <%@Register TagPrefix="Demo" Assembly="Demo, Version=1.0.0.0, Culture=neutral, PublicKeyToken=f801e7437a76c41d" namespace="Demo"%>
   8:  
   9: <SharePoint:RenderingTemplate ID="ProjectScoreListForm" runat="server">
  10:     <Template>
  11:         <SPAN id='part1'>
  12:             <SharePoint:InformationBar ID="InformationBar2" runat="server"/>
  13:             <wssuc:ToolBar CssClass="ms-formtoolbar" id="toolBarTbltop" RightButtonSeparator="&nbsp;" runat="server">
  14:                     <Template_RightButtons>
  15:                         <SharePoint:NextPageButton runat="server"/>
  16:                         <SharePoint:SaveButton runat="server"/>
  17:                         <SharePoint:GoBackButton runat="server"/>
  18:                     </Template_RightButtons>
  19:             </wssuc:ToolBar>
  20:             <SharePoint:FormToolBar ID="FormToolBar2" runat="server"/>
  21:             <TABLE class="ms-formtable" style="margin-top: 8px;" border=0 cellpadding=0 cellspacing=0 width=100%&gt;
  22:             &lt;SharePoint:ChangeContentType ID="ChangeContentType2" runat="server"/>
  23:             <SharePoint:FolderFormFields ID="FolderFormFields1" runat="server"/>
  24:             
  25:             <Demo:ProjectScoreListFieldIterator runat="server"/>
  26:             
  27:             <SharePoint:ApprovalStatus ID="ApprovalStatus2" runat="server"/>
  28:             <SharePoint:FormComponent ID="FormComponent2" TemplateName="AttachmentRows" runat="server"/>
  29:             </TABLE>
  30:             <table cellpadding=0 cellspacing=0 width=100%&gt;&lt;tr><td class="ms-formline"><IMG SRC="/_layouts/images/blank.gif" width=1 height=1 alt=""></td></tr></table>
  31:             <TABLE cellpadding=0 cellspacing=0 width=100% style="padding-top: 7px"><tr><td width=100%&gt;
  32:             &lt;SharePoint:ItemHiddenVersion ID="ItemHiddenVersion2" runat="server"/>
  33:             <SharePoint:ParentInformationField ID="ParentInformationField1" runat="server"/>
  34:             <SharePoint:InitContentType ID="InitContentType2" runat="server"/>
  35:             <wssuc:ToolBar CssClass="ms-formtoolbar" id="toolBarTbl" RightButtonSeparator="&nbsp;" runat="server">
  36:                     <Template_Buttons>
  37:                         <SharePoint:CreatedModifiedInfo runat="server"/>
  38:                     </Template_Buttons>
  39:                     <Template_RightButtons>
  40:                         <SharePoint:SaveButton runat="server"/>
  41:                         <SharePoint:GoBackButton runat="server"/>
  42:                     </Template_RightButtons>
  43:             </wssuc:ToolBar>
  44:             </td></tr></TABLE>
  45:         </SPAN>
  46:         <SharePoint:AttachmentUpload ID="AttachmentUpload1" runat="server"/>
  47:     </Template>
  48: </SharePoint:RenderingTemplate>

The following is the custom list field iterator that I built (minus the business logic):

   1: using Microsoft.SharePoint;
   2: using Microsoft.SharePoint.WebControls;
   3:  
   4: namespace Demo
   5: {
   6:     public class ProjectScoreListFieldIterator : ListFieldIterator
   7:     {
   8:         /// <summary>
   9:         /// Creates the child controls.
  10:         /// </summary>
  11:         protected override void CreateChildControls()
  12:         {
  13:             base.CreateChildControls();
  14:  
  15:             // Add the javascript necessary for setting the project lookup field to the passed in value.
  16:             const string script1 = @"<script type=""text/javascript"">
  17:                 _spBodyOnLoadFunctionNames.push(""fillDefaultValues"");
  18:                  
  19:                 function fillDefaultValues() {
  20:                   var qs = location.search.substring(1, location.search.length);
  21:                   var args = qs.split(""&"");
  22:                   var vals = new Object();
  23:                   for (var i=0; i < args.length; i++) {
  24:                     var nameVal = args[i].split(""="");
  25:                     var temp = unescape(nameVal[1]).split('+');
  26:                     nameVal[1] = temp.join(' ');
  27:                     vals[nameVal[0]] = nameVal[1];
  28:                   }  
  29:                   setLookupFromFieldName(""Project"", vals[""projectId""]);
  30:                 }
  31:                  
  32:                 function setLookupFromFieldName(fieldName, value) {
  33:                   if (value == undefined) return;
  34:                   var theSelect = getTagFromIdentifierAndTitle(""select"",""Lookup"",fieldName);
  35:                   
  36:                 // if theSelect is null, it means that the target list has more than
  37:                 // 20 items, and the Lookup is being rendered with an input element
  38:                   
  39:                   if (theSelect == null) { 
  40:                     var theInput = getTagFromIdentifierAndTitle(""input"","""",fieldName);
  41:                     ShowDropdown(theInput.id); //this function is provided by SharePoint 
  42:                     var opt=document.getElementById(theInput.opt);
  43:                     setSelectedOption(opt, value);
  44:                     OptLoseFocus(opt); //this function is provided by SharePoint 
  45:                   } else {
  46:                     setSelectedOption(theSelect, value);
  47:                   }
  48:                 }
  49:                  
  50:                 function setSelectedOption(select, value) {
  51:                   var opts = select.options;
  52:                   var l = opts.length;
  53:                   if (select == null) return;
  54:                   for (var i=0; i < l; i++) {
  55:                     if (opts[i].value == value) {
  56:                       select.selectedIndex = i;
  57:                       return true;
  58:                     }
  59:                   }
  60:                   return false;
  61:                 }
  62:                  
  63:                 function getTagFromIdentifierAndTitle(tagName, identifier, title) {
  64:                   var len = identifier.length;
  65:                   var tags = document.getElementsByTagName(tagName);
  66:                   for (var i=0; i < tags.length; i++) {
  67:                     var tempString = tags[i].id;
  68:                     if (tags[i].title == title && (identifier == """" || tempString.indexOf(identifier) == tempString.length - len)) {
  69:                       return tags[i];
  70:                     }
  71:                   }
  72:                   return null;
  73:                 }
  74:                 </script>
  75:                 ";
  76:  
  77:             Page.ClientScript.RegisterClientScriptBlock(GetType(), "SetProjectDefault", script1, false);
  78:         }
  79:  
  80:         /// <summary>
  81:         /// Determines whether the passed in field is excluded from the form.
  82:         /// </summary>
  83:         /// <param name="field">The field.</param>
  84:         /// <returns>
  85:         ///     <c>true</c> if <c>true</c> if the field is excluded; otherwise, <c>false</c>.
  86:         /// </returns>
  87:         protected override bool IsFieldExcluded(SPField field)
  88:         {
  89:             // TODO: Custom business logic
  90:             
  91:             return base.IsFieldExcluded(field);
  92:         }
  93:     }
  94: }

I got the javascript from here: http://www.sharepoint-tips.com/2007/06/using-javascript-to-manipulate-list.html (thanks Ishai for the link).  So all I'm basically doing is just dumping out some simple javascript which looks for a "projectId" parameter in the query string and sets the value of the lookup based on that value.  You can then also override the IsFieldExcluded method to hide certain fields based on your business logic (keep in mind that this will run for every field in the list so make sure your being mindful of performance).

Once I had the input forms customized and deployed I then wanted to be able to modify a view to include a link directly to the form with the project ID preset.  Note the status bar:

image

I could have done this several different ways including adding a hidden field with a custom display pattern that can then be added to the view (this would be the better way to go if the same link is needed in multiple places and you want to be able to deploy it easily via a Feature) but because I was looking for a real simple solution and didn't need reuse of the field I decided to simply modify the CAML of the view (though I may end up changing this later to be a new field).  To do this I used SPCAMLEditor (available on CodePlex) to simply change the ViewBody element to the following:

   1: <ViewBody>
   2:   <HTML><![CDATA[<TR class="]]></HTML>
   3:   <GetVar Name="AlternateStyle" />
   4:   <HTML><![CDATA[">]]></HTML>
   5:   <IfEqual>
   6:     <Expr1>
   7:       <GetVar Name="AlternateStyle" />
   8:     </Expr1>
   9:     <Expr2>ms-alternating</Expr2>
  10:     <Then>
  11:       <SetVar Scope="Request" Name="AlternateStyle" />
  12:     </Then>
  13:     <Else>
  14:       <SetVar Scope="Request" Name="AlternateStyle">ms-alternating</SetVar>
  15:     </Else>
  16:   </IfEqual>
  17:   <Fields>
  18:     <HTML><![CDATA[<TD Class="]]></HTML>
  19:     <FieldSwitch>
  20:       <Expr>
  21:         <Property Select="ClassInfo" />
  22:       </Expr>
  23:       <Case Value="Menu">
  24:         <HTML><![CDATA[ms-vb-title" height="100%]]></HTML>
  25:       </Case>
  26:       <Case Value="Icon">ms-vb-icon</Case>
  27:       <Default>
  28:         <FieldSwitch>
  29:           <Expr>
  30:             <Property Select="Type" />
  31:             <PresenceEnabled />
  32:           </Expr>
  33:           <Case Value="UserTRUE">ms-vb-user</Case>
  34:           <Case Value="UserMultiTRUE">ms-vb-user</Case>
  35:           <Default>ms-vb2</Default>
  36:         </FieldSwitch>
  37:       </Default>
  38:     </FieldSwitch>
  39:     <HTML><![CDATA[">]]></HTML>
  40:     <Field />
  41:     <FieldSwitch>
  42:       <Expr>
  43:         <Property Select="Name" />
  44:       </Expr>
  45:       <Case Value="LinkFilenameNoMenu">
  46:         <HTML><![CDATA[&nbsp;&nbsp;<em><nobr>(<a href="]]></HTML>
  47:         <HttpVDir />
  48:         <HTML><![CDATA[/Lists/ProjectScores/NewForm.aspx?projectId=]]></HTML>
  49:         <ID />
  50:         <HTML><![CDATA[">Score Project</a>)</nobr></em>]]></HTML>
  51:       </Case>
  52:     </FieldSwitch>
  53:     <HTML><![CDATA[</TD>]]></HTML>
  54:   </Fields>
  55:   <HTML><![CDATA[</TR>]]></HTML>
  56: </ViewBody>

The main point of interest is the last FieldSwitch element which looks for a field named LinkFilenameNoMenu (which is the internal field name for the Project field that I'm using) and then adds some additional HTML if a match is found.  In this case I'm using the HttpVDir element and the ID element to construct a simple link tag to the NewForm.aspx file for the scores list (I know, I should be using CSS for the styling).  The result is that I can now click straight to the project scores list while looking at the project documents list and have the project I'm interesting in scoring automatically selected.

If I end up changing this to use a hidden field I'll post the CAML for that in an update.

Update 6/18/2008: Well that didn't take me long to change my mind - I decided to go ahead and add a list field that contains the link rather than modify the view so I pulled the code that I had in the ViewBody element above and added a new field, LinkFilenameAndScore to the ViewFields element.  The LinkFilenameAndScore field is defined as follows:

   1: <Field     ID="{5C5F6D5F-82E6-49de-AC8F-09A50AFA4BA1}" 
   2:         ReadOnly="TRUE" 
   3:         Type="Computed" 
   4:         Name="LinkFilenameAndScore" 
   5:         DisplayName="Name" 
   6:         DisplayNameSrcField="FileLeafRef" 
   7:         Filterable="FALSE" 
   8:         AuthoringInfo="(linked to document with add score link)" 
   9:         StaticName="LinkFilenameAndScore" 
  10:         FromBaseType="TRUE">
  11:   <FieldRefs>
  12:     <FieldRef Name="FileLeafRef" />
  13:     <FieldRef Name="LinkFilenameNoMenu" />
  14:   </FieldRefs>
  15:   <DisplayPattern>
  16:       <Field Name="LinkFilenameNoMenu" />
  17:       <HTML><![CDATA[&nbsp;&nbsp;<em><nobr>(<a href="]]></HTML>
  18:       <HttpVDir />
  19:       <HTML><![CDATA[/Lists/ProjectScores/NewForm.aspx?projectId=]]></HTML>
  20:       <ID />
  21:       <HTML><![CDATA[">Score Project</a>)</nobr></em>]]></HTML>
  22:   </DisplayPattern>
  23: </Field>

I then used the following line of code to add the field to the list (where fieldSchema is a local variable containing the above XML and list is an SPList object representing the documents list):

list.Fields.AddFieldAsXml(fieldSchema, false, SPAddFieldOptions.AddToNoContentType | SPAddFieldOptions.AddFieldInternalNameHint);

Note the SPAddFieldOptions.AddFieldInternalNameHint enum value that is passed in - this tells SharePoint to use the value of the Name attribute as the internal name.  If you omit this it will use the display name (this took me longer than I'd like to admit to figure out).

One I had this field as part of the list I simply had to add the field to the view and I was done except that now the end-user can easily create a new view that contains this same link.