WinBatch Tech Support Home

Database Search

If you can't find the information using the categories below, post a question over in our WinBatch Tech Support Forum.

TechHome

NetwareX Extender

Can't find the information you are looking for here? Then leave a message over on our WinBatch Tech Support Forum.

NDS Disk Space Ownership


;===================================================================================================
; program:				NDS Disk Space Ownership
;
; language:				WinBatch
; extenders:			NetwareX			WWNWX34I.DLL
;
; author:				Michael Harris
;							(with many thanks to Detlev Dalitz for sample program at www.hpdd.de/hpdd/htm/dd390000.htm
;                     and to Steffen Fraas (sfraas@zoo.co.uk) for his SyntaxChecker)
;
; created:				4/23/2003		last updated: 6/19/2003
;
; description:
;							Program to obtain disk space ownership names and amounts from Novell Storage Services (NSS) volumes
;							on which Netware is not set (by default, for performance reasons) to collect/maintain such information.
;
;							NOTE: Offered as is.  Use at your own risk.  Not responsible for losses/damage arising from its use.
;							Be sure to review it thoroughly, adjust it to your situation and test it before using it.
;
;							NOTE: Designed to be run interactively or batch mode... but batch mode has not been tested.
;
;							NOTE: Although can be run against a context (i.e. all volumes within a context),
;							only keeps a single set of totals (needs to be enhanced to keep both volume specific
;							and grand totals).... and would likely take a long time to run against multiple volumes.
;
;							Traditional file system (TFS) volumes (Netware 3, 4), by default, collect/maintain
;							volume level disk space ownership names and amounts info, which can apparently
;							be quickly and conveniently obtained for reporting purposes via a single/few volume level Netware calls.
;							Novell Storage Services (NSS) volumes (Netware 5, 6), by default, do not collect/maintain
;							such info, for performance reasons.  Our Novell consultant recommended against turning it on.
;
;							This program is a brute force solution for occassionally obtaining such info for NSS volumes,
;							by literally navigating volume(s) entire directory structures or specific subdirectory tree, 
;							querying and totaling file ownership and size for all files.  Not pretty, and can take hours on large volumes
;							(though does not appear to badly degrade the server or net, despite solid net traffic lights),
;							but it works.
;
;							The program uses an array (which is a persistent global variable), 
;						   each row of which holds three items,  to hold:
;							  - in row 0: program name, maximum usera allowed value, current user count
;							    (using row 0 for this help minimize the number of explicit variables that must be passed to functions)
;							    (this does, unfortunately, make the logic more difficult to read)
;							  - in rows 1 to maximumusers: ownername, total space used, total file count
;							    (row 1 is preloaded with a fictitious ".(None).(None)" ownername, for optimization purposes
;							  - in row maximumusers+1: previous ownername encountered, previous owner's position (index) in the array
;							    (this maximumusers+1 row is used for optimization purposes
;							     and helps minimize the number of explicit variables that must be passed to functions)
;							    (this does, unfortunately, make the logic more difficult to read)
;
;							Program logic overview:
;							- housekeeping and array allocation
;							- array initialization
;							- causes the WinBatch interpreter to scan all the user defined functions,
;							  which allows the user defined functions to be placed below the main logic
;							- queries the user for a target context (can do multiple volumes, but not recommended),
;							  volume or subdirectory within a specific volume
;							- obtains a list of volume(s) from NDS that satisfy the user specification
;							- sorts the volume list
;							- starts the output file (writes date/time stamped report header lines)
;							- initializes array (see discussion above as to contents/use of array)
;							  (this includes use of rows 0, 1 and maximumusers+1 for special/optimization purposes)
;							- for each volume in the volume list
;							  - concatenate the NDS tree name and the volumepath (and ":\" root if processing entire volume)
;							  - map a drive letter to the volume root or volume:\directorypath
;							  - if mapping fails, continue with next volume (next iteration of the per volume processing)
;							  - recursively descend/process the drive letter's directory tree
;							    - processing files in each directory
;							      (when a new file owner is encountered, a row for the new is added to the array)
;							      (files with no owner are totaled for fictitious ".(None).(None)" owner)
;							      (optimization used.... ".(None).(None)" placed in row 1 of array to speed hitting it
;							       and previous ownername and location (index) in array maintained in row maximumuser+1
;							       for use in quickly testing/utilizing that information when current file owner
;							       is same as previous file's owner)
;							    - recursively descending into/processing subdirectories in each directory
;							- sort and write the user totals out in ownercontext ownername sequence,
;							  obtaining and adding each owner's first and last name
;							- at program user's option, also import the tab delimited output file into Excel
;							  and save it out as an Excel spreadsheet (currently accomplished via feeding keystrokes
;							  to Excel... but there is a better way to do it... via OLE/ODBC/.... WinBatch extension.
;
;							NOTE: The program uses "typeless" NDS path names
;							and reverses them for human readability sake.
;
;							NOTE: Reporting of inability to descend into a (corrupt?/illegally named?) directory
;							should be improved.  Currently, program reports the problem and waits for user
;							to click OK before skipping that bad directory and proceeding.
;							Should have it quietly generate a report line (in row(s) at tail end of array or elsewhere)
;							for eventual inclusion in the report.... so that errors appear in report
;							but do not pause program execution until user oks/resumes processing.
;
;
;
; possible enhancements/optimizations to be pursued:
;  - rename/prefix all user defined functions whose names don't start with "udf" so that they do
;  - report starting/ending/elapsed time
;  - per volume totals plus grand totals... (or else remove/discourage ability to process multiple volumes,
;     due to excessive time that might take?)
;  - use of additional "standard/housekeeping" array for passing dialogtitle, etc. easily/concisely
;     to all functions (or is there such as thing as globals that don't have to be passed?)
;     (actually, DialogTitle is stored in UserArray[0,0] in this program.... but should probably
;      develop a standard array/arrayname for this or find workaround)
;    (any way to avoid need to explicitely reference arrays (globals) when calling functions?)
;  - restart/resume capability?, to help deal with long run times on NSS volumes? 
;     (would require some sort of "recursive find but don't process" to find starting point,
;      then turn on process from that point forward)
;  - or when do search loop, loop backward from latest to first.... as latest is most likely?
;    (keep in mind that special case optimizations "(None)" and "last/most recent" are already implemented)
;  (and/or load the array with all user accounts from NDS (with zeros counters), plus necessary pseudo accounts,
;   in sequence, and then employ binary sort for faster hits) 
;  - some alternative to typeless variables/array entries.... to reduce type determination/conversion
;    processing when know will be string or number?  (binary buffers?)
;  - turn errormode off and on selectively, rather than setting/leaving off throughout program?
;  - for stabler/safer import of tab delimited output file into Excel,
;    investigate use of other WinBatch extender (OLE/ODBC/etc.) rather than feeding keystrokes to Excel
;    .... and consider whether to do additional formatting, column totals, etc.
;         (original thought was to keep output plain/minimalist... for possible use by other programs
;          and to let those viewing reports in Excel rework them as they saw fit)
;  - consider whether is worth retaining the delimited text file once it has been imported into Excel....
;    if not, could delete it (so as to not clutter up the reports directory
;    and confuse users with choice between identically named (except for file type) text and Excel files.
;
;
;
; output files location:
;							Currently written to the following location, for group/shared use:
;								\R006_server\V006\groups\Isd_techservices\WinBatch\Reports
;							with a file name:
;								"NDS Disk Space Ownership On %CurrentTimeYmdHmsWithBlanks%.txt
;===================================================================================================


;============================ SET DEFAULT VALUES AND ENVIRONMENT HERE ==============================
; default Values 
NDSTree = '\\ACME_TREE\'
UNC = '.ACME'													; command line argument #1 (param1)
ReportPath = 'F:\Winbatch\'								; location for reports during testing
;ReportPath = '\\R006_server\V006\groups\Isd_techservices\WinBatch\Reports\'
DialogTitle = 'NDS Disk Space Ownership'
MappedDrive = "L:"
SpreadsheetDelimiter = @TAB
MaximumUsers = 2000											; max number of file owners (users) (estimate high!)
																	; (should pad/estimate high)
																	; (owner "" returned if owner no longer exists)
																	; NOTE: Arrays (such as following one) are global, 
																	; i.e. their address is passed rather than their contents
																	; and their contents persist across function calls/returns
																	; They apparently must be explicitely passed to functions,
																	; but do not have to be explicitely returned from functions.
UserArray = ArrDimension((MaximumUsers + 2), 3) 	; allocate the user/owner array, with three
																	; items (username, spaceused, filecount) per user
																	; NOTE: First (0th) row in array is used to pass info between functions 
																	;       UserArray[0,0] is used for DialogTitle text
 																   ;       UserArray[0,1] is used for MaximumUsers value
																	;       UserArray[0,2] is used for UserCount value
																	;       Second (1st) through MaximumUsers row is used to store owners encountered
																	;       (note: fictious ".(None).(None)" user will be pre-loaded into second (1st) row
																	;        for optimization purposes... and usercount value in first (0th) row set 
																	;        will be set accordingly)
																	;       Last (MaximumUsers+1) row is used to keep track of most recent owner dealt with
OutputFileNameArray = ArrDimension(1)					; used to return/pass output/report file's name
																	;---------------------------------------------------------------------------------------------------
; error handling
ErrorMode(@OFF)												; tolerate errors, necessary for NDS processing
; generic file system related
IntControl(5, 1, 0, 0, 0)									; process system and hidden directories and files
; Netware/NDS related
AddExtender('WWNWX34I.DLL')								; load NetwareX extender (Netware/NDS API)
nwSetOptions(1, @TRUE)										; tell NetwareX to return typless names from NDS
; make user defined functions (UDFs below) defined/known to WinBatch interpreter
; (otherwise, must place them here, ahead of the code that calls/invokes them)
Gosub DefineUDFs  											; interpreter scans UDFs then returns here
;																	; so that UDFs can be placed below main code
;===================================================================================================


; main program logic starts here

; open box and report program start
MessageLine = "Running..."
BoxOpen(DialogTitle, MessageLine)

; assure valid number of command line arguments (0 for interactive, 1 for batch/command line)
If ((param0 < 0) || (param0 > 1))
	; invalid number, report and terminate the program
	MessageLine = StrCat("ERROR: Invalid number of command line arguments = ", param0)
	Pause(DialogTitle, MessageLine)
	Exit
Endif

; get valid base object specification for NDS search filter from user or command line
; (NDS tree plus UNC (.context, .volume.context, or .volume.context:filesystempath))
BaseObjectSpec = udfGetValidUNC(NDSTree, UNC, DialogTitle, param0)
; NOTE: at this point BaseObjectSpec has the tree name prefixed on it

; get list of volume(s) within/that satisfy base object specification
; one (if filesystem path was supplied) or many
If (ItemCount(BaseObjectSpec, ":") == 2)
	; two items in NDSTreeUNCEntered, separated by ":", means file system path was supplied
	; which means some portion of the file system of only one volume will be processed
	; so store combined .volume.context:filesystempath as single item in volume list
	; (note that later ItemCount of this, using @TAB as delimiter, will correctly return count of 1 item)
	VolumeList = BaseObjectSpec
	; but remove the NDS tree name prefix, to facilitate sorting (we'll re-prefix later when mapping drive)
	VolumeList = StrReplace(VolumeList, NDSTree, "")
Else
	; only one item was found in NDSTreeUNCEntered, which means no file system path
	; was supplied..... which means one or more entire volumes will be processed
   ; so use what was entered as base object specification for search of NDS for volumes
	; (which may return one or more volumes)
	; (volume object name(s) in list will be typless, distinguished, tab delimited, unsorted)
	; get the list of volumes
	VolumeList = udfGetVolumeList(BaseObjectSpec)
	; (note that volume names in list do not have NDS tree name prefix....
	;  as are not returned by nwSearchObjects......
	;  we'll re-prefix later when mapping drives)
	; sort the volume list
	VolumeList = udfSortNDSObjectList(VolumeList)
Endif
; diagnostic, if needed
;DiagnosticLine = StrCat("VolumeList = ", VolumeList)
;Message(DialogTitle, DiagnosticLine)

; create, open and write header lines/records to the output file
; (doing this here, to protect against processing volume(s), only to find couldn't output file)
ReportFileHandle = udfStartOutputFile(ReportPath, DialogTitle, BaseObjectSpec, SpreadsheetDelimiter, OutputFileNameArray)
; diagnostic, if needed
;DiagnosticLine = StrCat("After udfStartOutputFile... ReportPath = ", ReportPath, " ReportFileHandle = ", ReportFileHandle)
;Message(DialogTitle, DiagnosticLine)

; process all the volumes specified/found
; determine number of volume objects returned (contained in VolumeList)
NumberOfVolumes = ItemCount(VolumeList, @TAB)
; if running interactively, report number of volumes specified or found in the search scope
If (param0 == 0)
	MessageLine = StrCat("Number of volumes specified/found = ", NumberOfVolumes, @CR)
	MessageLine = StrCat(MessageLine, "Please wait while file is produced....")
	BoxText(MessageLine)
Endif
; 
; initialize user array (for each user, contains username, spaceused, filecount)
; (user array is a global (address, rather than contents get passed, so persists across function calls)
; initialize user array header 
; (row 0, special case used to pass program name, maximumusers and current user count between functions)
UserArray[0,0] = DialogTitle				; program name/dialog title for use in displaying messages
UserArray[0,1] = MaximumUsers				; maximum number of users/owners (and number of user rows in array)
UserArray[0,2] = 0							; current number of users/owners encountered, start with count of 0
;
; initialize main part of array (row 1 to maximumusers, where users/owners encountered are stored and summed)
; (may not be necessary for single report.... but keep to be safe.... and to have if enhance to do per volume reports)
For UserIndex = 1 to (MaximumUsers) by 1
	UserArray[UserIndex,0] = "" 			; empty username
	UserArray[UserIndex,1] = 0.0 			; zero spaceused (floating point, to handle large amounts)
	UserArray[UserIndex,2] = 0 			; zero filecount
Next
;
; as a significant number of our volumes at our installation have many files that have no owners
; (due to having been mass copied from elsewhere), optimize/speed things a bit
; by preloading the pseudo account ".(None).(None)" as the first owner (row 1),
; so that the for loop based search (or other code that knows its location) will always 
; encounter it first, rather than looping through all previously encountered owners
UserArray[0,2] = 1							; adjust array header (first (0th) row) usercount to 1 to reflect this)
UserArray[1,0] = ".(None).(None)" 		; add pseudo account ".(None).(None)" as first owner entry in array (second (1st) row)
UserArray[1,1] = 0.0 						; set its spaceused total to 0
UserArray[1,2] = 0 							; set its files count to 0
;
; as any given file will often prove to be owned by the owner of the previously encountered file,
; another optimization is used down in udfAddToUserArray....
; if the current file's owner is same as the previous file's owner, uses saved previous owner's
; array index to directly address and add the current file's size to and increment the file count of
; that previous owner, rather than using a for loop to sequentially search the array for the previous owner.
; To make this work, need to initialize the "previous file owner name & index" trailer record/row (row maximumusers+1)
; at the end of the array to a "name of fictitious owner who could never have possibly been encountered before" here
; (should not be "", as files without owners return that)
UserArray[(MaximumUsers + 1), 0] = "Fictitious Previous Owner Name" 

; loop through and process each volume
; (any per volume initialization/totals/etc. needed? ... would be if were to enhance to do per volume reports)
For VolumeListIndex = 1 to NumberOfVolumes by 1
	; process current volume
	; extract current volume or volume/path from tab delimited list
	VolumePath = ItemExtract(VolumeListIndex, VolumeList, @TAB)
  ; prefix it with the NDS Tree name, 
  ; as nwSearchObjects doesn't return it as part of names of found objects (volumes)
  VolumePath = StrCat(NDSTree, VolumePath)
	; assure that ":\filesystempath" is present (add ":\" root file system path if not, i.e. if processing entire volume)
	If (ItemCount(BaseObjectSpec, ":") == 1)
   	; no file system path present, so add ":\"
		VolumePath = StrCat(VolumePath, ":\")
	Endif
	; map root mapped drive letter to volume:\filesystempath directory of volume
	nwMap(VolumePath, MappedDrive, 0)
   ReturnCode = LastError()
	If (ReturnCode != 0)
		; map was not successful, report it
		; ? need to enhance this to report it via line in output report,
		;   for batch or unattended use... so won't pause/hang a run if map attempt fails?
		DiagnosticLine = StrCat("Was unable to map VolumePath:  ", VolumePath, "  ReturnCode = ", ReturnCode)
		Message(DialogTitle, DiagnosticLine)
		; skip to beginning of loop (i.e. skip this volume)
		Continue
	Endif
	; report volume processing start if running interactively
	If (param0 == 0)
		; report that are processing current volume
		ProgressLine = StrCat(MessageLine, @CR, "Processing volumepath:  ", VolumePath)
		BoxText(ProgressLine)
	Endif
	; diagnostic, if needed
	;DiagnosticLine = StrCat("Preparing to process folder tree for MappedDrive ", MappedDrive)
	;Message(DialogTitle, DiagnosticLine)
	; process directories/folders for current volume or volumepath
	udfProcessFolderTree(MappedDrive, UserArray)
	; any per volume totals, etc.????
Next

; all volumes/directories/files have been processed
; write the user totals out
udfWriteOutputFile(ReportFileHandle, DialogTitle, SpreadsheetDelimiter, UserArray)

; close the output file
FileClose(ReportFileHandle)

; if running interactively, close "please wait" box and provide ending message to user
If (param0 == 0)
	BoxShut()
	MessageLine = StrCat("DONE.", @CR)
	MessageLine = StrCat(MessageLine, "See delimited text file:", @CR, OutputFileNameArray[0])
	Message(DialogTitle, MessageLine)
Endif

; if running interactively, offer the user the option of also saving the file in spreadsheet file format
If (param0 == 0)
	; ask user if wish to also save the file as a spreadsheet file
	; change output file name file type from .txt to .xls
	SpreadsheetFileName = ItemReplace("xls", ItemCount(OutputFileNameArray[0], "."), OutputFileNameArray[0], ".")
	MessageLine = "Also save as spreadsheet file (same name, with .xls file type)?"
	MessageLine = StrCat(MessageLine, @CR, SpreadsheetFileName)
	YesNoAnswer = AskYesNo(DialogTitle, MessageLine)
	If (YesNoAnswer == @YES)
		; save the file out as a spreadsheet by importing it into Excel
		SaveFileAsSpreadsheet(OutputFileNameArray, SpreadsheetFileName, DialogTitle)
	Endif
Endif

; end program
Exit

; main program logic end here






;============================ DEFINE USER DEFINED FUNCTIONS (UDFs) =================================
:DefineUDFs 													; label for interpreter's gosub fallthru of UDFs
;																	; so that UDFs can be placed below main code
;                                                  ; (interpreter must apparently scan user defined 
;                                                  ;  functions before they can be called..... doing this
;                                                  ;  would not be necessary if user defined functions
;                                                  ;  were placed ahead of/above main program logic
;                                                  ;  as interpreter would then do a fall-thru scan
;                                                  ;  before hitting and executing the main logic
;===================================================================================================
; note: Function variables are local, subroutine variables are global.
;       The user array allocated at the top of the program is global (address is passed, not contents) 
;        (must explicitely pass it, but don't have to return it.... contents persist across functions).
;
;---------------------------------------------------------------------------------------------------
;
#DefineFunction udfGetValidUNC(NDSTree, UNC, DialogTitle, param0)
; get valid base object specification for NDS search filter
; (NDS tree plus user supplied UNC (.context, .volume.context, or .volume.context:filesystempath))

; being run interactively or batch/command line ?
If (param0 == 0)
	; interactively (no command line arguments were supplied)
	UNCEntered = ""
	NDSTreeUNCEntered = ""
	ResultCode = 1
	; loop till get valid base object specification from user 
	While ((ResultCode != 0) || (IsNumber(UNCEntered)) || (StrSub(UNCEntered, 1, 1) != "."))
		MessageLine = "NDS .context, .volume.context or .volume.context:filesystempath "
		MessageLine = StrCat(MessageLine, "within ", NDSTree, " ?")
		MessageLine = StrCat(MessageLine, @CR, "CAUTION: Multiple volumes will be slow.")
		UNCEntered = AskLine(DialogTitle, MessageLine, UNC)
		; concatenate NDS tree plus user supplied UNC (.context, .volume.context, or .volume.context:filesystempath)
		NDSTreeUNCEntered = StrCat(NDSTree, UNCEntered)
		; verify that .context or .volume.context portion of UNC supplied exists
		If (ItemCount(NDSTreeUNCEntered, ":") == 1)
			; only one item, no optional ":filesystempath" present in UNC supplied
			; verify that it exists?
			nwGetObjInfo(NDSTreeUNCEntered, 2)
			ResultCode = LastError()
		Else
			; optional ":filesystempath" present in UNC supplied
			; verify that it (full .volume.context:filesystempath) exists
			nwGetDirInfo(NDSTreeUNCEntered, 0, "", "", 0)
			ResultCode = LastError()
		Endif
		If ((ResultCode != 0) || (IsNumber(UNCEntered)) || (StrSub(UNCEntered, 1, 1) != "."))
			MessageLine = "ERROR: NDS .context, .volume.context or .volume.context:filesystempath not found."
			MessageLine = StrCat(MessageLine, "You entered = ", UNCEntered)
			Message(DialogTitle, MessageLine)
		Endif
	Endwhile
Else
	; batch/command line execution (not interactive)
	; UNC (.context, .volume.context or .volume.context:filesystempath) supplied as first command line argument
	UNCEntered = param1
	; concatenate NDS tree plus supplied UNC (.context, .volume.context, or .volume.context:filesystempath)
	NDSTreeUNCEntered = StrCat(NDSTree, UNCEntered)
	; verify that .context or .volume.context portion of UNC supplied exists
	If (ItemCount(NDSTreeUNCEntered, ":") == 1)
		; only one item, no optional ":filesystempath" present in UNC supplied
		; exists as an NDS object?
		nwGetObjInfo(NDSTreeUNCEntered, 2)
		ResultCode = LastError()
	Else
		; optional ":filesystempath" present in UNC supplied
		; full .volume.context:filesystempath exists?
		nwGetDirInfo(NDSTreeUNCEntered, 0, "", "", 0)
		ResultCode = LastError()
	Endif
	If ((ResultCode != 0) || (IsNumber(UNCEntered)) || (StrSub(UNCEntered, 1, 1) != "."))
		; for batch/command line run, should error message go to console or to report file?
		; for now, display it on the console
		MessageLine = "ERROR: NDS .context, .volume.context or .volume.context:filesystempath  "
		MessageLine = StrCat(MessageLine, NDSTreeUNCEntered, " not found!  Abending!")
		Message(DialogTitle, MessageLine)
		; end the program
		Exit
	Endif
Endif
	
; return valid base object specification for NDS search
Return (NDSTreeUNCEntered)
#EndFunction
;
;----------------------------------------------------------------------------------------------------
;
#DefineFunction udfGetVolumeList(BaseObjectSpec)
; get (unsorted) list from NDS of volume objects that satisfy the base object specification

; set up a search filter
; (is cumulative... use *FREE_BUFFER* to reset/deactivate)
; (source of attribute names was the NDS Snoop program....
;  see www.novell.com/coolsolutions/tools/1005.html)
; (another source of attribute names is the output displayed by the example
;  WinBatch program supplied in the nwGetObjValue writeup in the NetwareX help)
nwSearchFilter(BaseObjectSpec, 'BASECLS','',0,0,0)
nwSearchFilter(BaseObjectSpec, 'ANAME','Volume',0,0,0)
nwSearchFilter(BaseObjectSpec, 'END','',0,0,0)
; set up search flags (combine/add various bit flag values)
; value of 2 says search scope is base-object and all its subordinates
Flags = 2
; tell WinBatch's NetwareX extensions to return typeless names from NDS
nwSetOptions(1,@TRUE)
; do the search and get the result code
VolumeListDistinguishedTabbed = nwSearchObjects(BaseObjectSpec, '', '', Flags, '')
ResultCode = LastError()
; verify that search was ok
If (ResultCode != 0)
	; diagnostic (if needed) report result code
	MessageLine = StrCat("Volumes search failed with result code = ", ResultCode)
	;Message(DialogTitle, MessageLine)
	Message("NDS Disk Space Ownership", MessageLine)
	; End the program
	Exit
Endif
; reset/deactivate the search filter (don't need when done with nwSearchObjects)
nwSearchFilter('', '*FREE_BUFFER*', '', 0, 0, 0)
; diagnostic (if needed)
;Message("NDS Disk Space Ownership", VolumeListDistinguishedTabbed)
; diagnostic program termination (if needed)
;exit
; return list of volume objects found
Return (VolumeListDistinguishedTabbed)
#EndFunction
;
;---------------------------------------------------------------------------------------------------
;
#DefineFunction udfSortNDSObjectList(NDSObjectList)
; Sort the list (comes from NDS in unsorted sequence) by
; a) flipping the NDS names from ".object.container..org" to ".org.container..object",
TempNDSObjectList = NDSObjectList
TempNDSObjectList = PadAndReverseNDSNames(TempNDSObjectList)
; b) sorting the list of flipped names (into organizational chart sequence)
TempNDSObjectList = ItemSort(TempNDSObjectList, @TAB)
; diagnostic (if needed)
; Message(DialogTitle, UserListDistinguishedTabbed)
; c) reflipping the names back to ".object.container..org"
TempNDSObjectList = UnpadAndReverseNDSNames(TempNDSObjectList)
; diagnostic (if needed)
;Message(DialogTitle, ObjectListDistinguishedTabbed)
; diagnostic program termination (if needed)
;exit
;return sorted list of NDS objects
Return (TempNDSObjectList)
#EndFunction
;
;---------------------------------------------------------------------------------------------------
;
#DefineFunction PadAndReverseNDSName(NDSName)
; Inserts a blank (space) name segment (' ') between object name segment and remaining path segments
; (so that objects will sort out ahead of subcontainers) (empty segment will be removed later)
; and reverses all segments of each (now padded) NDS name in list
; from ".object..container...org" to ".org...container..object"
; The result is useful for sorting into organizational chart order....
; but must then be unpadded and reversed again before can be used to query NDS objects. 
NDSName = ItemInsert(' ', 2, NDSName, '.')
ReversedNDSName = NDSName
NumberOfSegments = ItemCount(NDSName, '.')
For SegmentIndex = 1 to NumberOfSegments by 1
	Segment = ItemExtract(SegmentIndex, NDSName, '.')
	ReversedNDSName = ItemReplace(Segment, (NumberOfSegments - SegmentIndex + 1), ReversedNDSName, '.')
Next
Return (ReversedNDSName)
#EndFunction
;
;---------------------------------------------------------------------------------------------------
;
#DefineFunction PadAndReverseNDSNames(ListOfNDSNames)
; Inserts a blank (space) name segment (' ') between object name segment and remaining path segments
; (so that objects will sort out ahead of subcontainers) (empty segment will be removed later)
; and reverses all segments of each (now padded) NDS name in list
; from ".object..container...org" to ".org...container..object"
; The result is useful for sorting into organizational chart order....
; but must then be unpadded and reversed again before can be used to query NDS objects. 
NumberOfNames = ItemCount(ListOfNDSNames, @TAB)
; Process names, a name at a time
ListofReversedNDSNames = ListofNDSNames
For ListIndex = 1 to NumberOfNames by 1
	NDSName = ItemExtract(ListIndex, ListOfReversedNDSNames, @TAB)
	NDSName = ItemInsert(' ', 2, NDSName, '.')
	ReversedNDSName = NDSName
	NumberOfSegments = ItemCount(NDSName, '.')
	For SegmentIndex = 1 to NumberOfSegments by 1
		Segment = ItemExtract(SegmentIndex, NDSName, '.')
		ReversedNDSName = ItemReplace(Segment, (NumberOfSegments - SegmentIndex + 1), ReversedNDSName, '.')
	Next
	ListOfReversedNDSNames = ItemReplace(ReversedNDSName, ListIndex, ListOfReversedNDSNames, @TAB)
Next
Return (ListOfReversedNDSNames)
#EndFunction
;
;---------------------------------------------------------------------------------------------------
;
#DefineFunction UnpadAndReverseNDSNames(ListOfReversedNDSNames)
; Reverses all segments of each (now padded) reversed NDS name in list
; from ".org...container..object" back to ".object..container...org"  
; and removes the blank (space) name segment (' ') from between object name segment and remaining 
; path segments (was there so that objects would sort out ahead of subcontainers).
; The result is a list of object names, in organizational chart order, but with names 
; in regular ".object..container...org" NDS notation, so can be used to call NDS for object info. 
NumberOfNames = ItemCount(ListOfReversedNDSNames, @TAB)
; Process names, a name at a time
ListofNDSNames = ListOfReversedNDSNames
For ListIndex = 1 to NumberOfNames by 1
	ReversedNDSName = ItemExtract(ListIndex, ListOfNDSNames, @TAB)
	NDSName = ReversedNDSName
	NumberOfSegments = ItemCount(ReversedNDSName, '.')
	For SegmentIndex = 1 to NumberOfSegments by 1
		Segment = ItemExtract(SegmentIndex, ReversedNDSName, '.')
		NDSName = ItemReplace(Segment, (NumberOfSegments - SegmentIndex + 1), NDSName, '.')
		Next
	NDSName = ItemRemove(3, NDSName, '.')
	ListOfNDSNames = ItemReplace(NDSName, ListIndex, ListOfNDSNames, @TAB)
Next
Return (ListOfNDSNames)
#EndFunction
;
;---------------------------------------------------------------------------------------------------
;
#DefineFunction udfStartOutputFile(ReportPath, DialogTitle, BaseObjectSpec, SpreadsheetDelimiter, OutputFileNameArray)
; create a meaningfully named date/time stamped output file, open it, write header lines/records
; and return its file handle number

; get current date/time
CurrentTimeYmdHms = TimeYmdHms()

; semi-reverse the baseobjectspec from
; //treename/.volume...context:filesyspath  to //treename/.context...volume:filesyspath
; for human oriented sortability and readability in file name and report header
TreeName = ItemExtract(1, BaseObjectSpec, ".")
VolumeToContextPath = StrReplace(BaseObjectSpec, TreeName, "")
VolumeToContextPath = ItemRemove(1, VolumeToContextPath, ".")
VolumeToContextPath = ItemExtract(1, VolumeToContextPath, ":")
ItmCnt = ItemCount(VolumeToContextPath, ".")
ContextToVolumePath = VolumeToContextPath
For Index = 1 to ItmCnt by 1
	Item = ItemExtract(Index, VolumeToContextPath, ".")
	ContextToVolumePath = ItemReplace(Item, (ItmCnt - Index + 1), ContextToVolumePath, ".")
Next
If (StrScan(BaseObjectSpec, ":", 1, @FWDSCAN) == 0)
	FileSysPath = ""
Else
	FileSysPath = ItemExtract(-1, BaseObjectSpec, ":")
Endif
SemiReversedBaseObjectSpec = StrCat(TreeName, ContextToVolumePath, " ", FileSysPath)

; generate output file name and open file (will go where ReportPath (see top of program) variable specifies)
; replace periods and optional colon and slashes in baseobjectspec with blanks
SemiReversedBaseObjectSpec = StrReplace(SemiReversedBaseObjectSpec, "\\", "")
SemiReversedBaseObjectSpec = StrReplace(SemiReversedBaseObjectSpec, "\", " ")
SemiReversedBaseObjectSpec = StrReplace(SemiReversedBaseObjectSpec, ".", " ")
; replace colons in time with blanks
CurrentTimeYmdHmsWithBlanks = StrReplace(CurrentTimeYmdHms, ':', ' ')
OutputFileName = ReportPath
OutputFileName = StrCat(OutputFileName, DialogTitle, " For ", SemiReversedBaseObjectSpec, "On ") 
OutputFileName = StrCat(OutputFileName, CurrentTimeYmdHmsWithBlanks, '.txt') 
; to be safe, delete file by that name if already exists (not likely, but best to be safe
; in case change file naming strategy (though "WRITE" should zero out and overwrite existing file)
If (FileExist(OutputFileName))
	FileDelete(OutputFileName)
Endif
; create/open the file
ReportFileHandle = FileOpen(OutputFileName, 'WRITE')
; successful create/open of file ?
If (ReportFileHandle == 0)
	; create/open was not successful
	MessageLine = StrCat('File open/create of ', OutputFileName, ' failed')
	Message(DialogTitle, MessageLine)
	;terminate the program
	Exit
Endif

; Write a report header record (with title/info spread across several columns,
; so as to not mess up the autoadjusted width of columns when this
; file is imported into Excel via Data | Get External Data | Import Text File.
; (note that width of title (including trailing spaces) causes Excel to
;  adjust column widths accordingly during data import of this file)
;xxxxOutputFileName = StrCat(OutputFileName, 'NDS Users Home Directory Space Limits And Usage On ') 
;xxxxOutputFileName = StrCat(OutputFileName, CurrentTimeYmdHmsWithBlanks, '.txt') 
OutputLine = ""
OutputLine = StrCat(OutputLine, DialogTitle, " for ", SemiReversedBaseObjectSpec, SpreadsheetDelimiter) 
OutputLine = StrCat(OutputLine, "", SpreadsheetDelimiter) 
OutputLine = StrCat(OutputLine, "", SpreadsheetDelimiter) 
OutputLine = StrCat(OutputLine, "", SpreadsheetDelimiter) 
OutputLine = StrCat(OutputLine, "(produced: ", CurrentTimeYmdHms, ")") 
;a CR-LF is appended at the end of the line by the write
FileWrite(ReportFileHandle, OutputLine)

; Write a blank header record to separate header title from column heading line
OutputLine = ""
FileWrite(ReportFileHandle, OutputLine)

; Write a header record with field descriptions delimited by tabs
; (note that width of title (including trailing spaces) causes Excel to
;  adjust column widths accordingly during data import of this file,
;  but that later detail (user) records having even wider fields will further widen them))
OutputLine = ""
OutputLine = StrCat(OutputLine, "NDS Path (reversed) ", SpreadsheetDelimiter)
OutputLine = StrCat(OutputLine, "User ", SpreadsheetDelimiter)
OutputLine = StrCat(OutputLine, "Last Name ", SpreadsheetDelimiter)
OutputLine = StrCat(OutputLine, "First Name ", SpreadsheetDelimiter)
OutputLine = StrCat(OutputLine, "Space Used (MB) ", SpreadsheetDelimiter)
OutputLine = StrCat(OutputLine, "File Count")
;a CR-LF is appended at the end of the line by the write
FileWrite(ReportFileHandle, OutputLine)

; Write a blank header record to separate header from user lines/records
OutputLine = ""
FileWrite(ReportFileHandle, OutputLine)

; pass output file name back, via global array, for later display in message to user
; to facilitate user knowing the (automatically generated) name of the report file
OutputFileNameArray[0] = OutputFileName

Return (ReportFileHandle)
#EndFunction
;
;---------------------------------------------------------------------------------------------------
;
#DefineFunction udfWriteOutputFile(ReportFileHandle, DialogTitle, SpreadsheetDelimiter, UserArray)
; copy the user array into a list.... investigate array sorts later)
; reverse/pad user NDS names to .org.container.. .username, for sortability (and readability)

; precompute MegaByte constant value
MegaByte = 1024 * 1024

; diagnostic, if needed
; show the users records/rows in the array
;For UserIndex = 0 to UserArray[0,2]
;	MessageLine = StrCat(UserArray[UserIndex,0], " ", UserArray[UserIndex,1], " ", UserArray[UserIndex,2], @CR)
;	Message(DialogTitle, MessageLine)
;Next

; convert user (owner names and space totals) array to single cumulative user list (containing tab delimited strings)
; for readability and performance (?) reasons, store current user count from user array to meaningfully spelled 
; non-array variable
UserCount = UserArray[0,2]
; initialize the cumulative user list to be empty
UserList = ""
; extract user array rows and accumulate/append them into the cumulative user list
For UserIndex = 1 to UserCount by 1
	; Extract User simple name from full typeless name (period delimited, with leading period)
	UserFullTypelessName = UserArray[UserIndex,0]
	User = ItemExtract(2, UserFullTypelessName, ".")
	; Generate a reversed (root-to-container) version of user object's NDS path
	; Remove leading period (empty item and its period delimiter, in terms of period delimited list)
	NDSPath = ItemRemove(1, UserFullTypelessName, ".")
	; Remove User simple name item (and its period delimeter)
	NDSPath = ItemRemove(1, NDSPath, ".")
	; Determine number items (number of NDS path segments) remaining
	ItmCnt = ItemCount(NDSPath, ".")
	; Swap/reverse the remaining items (path segments)
	; Initialize reversed name with proper number of items and delimiters by brute force cheat
	; of copying the "to be reversed" list into it
	ReversedNDSPath = NDSPath
	; now swap/reverse the segments of the name
	For Index = 1 to ItmCnt by 1
		; This could be done as a single line, but is broken out for readability and troubleshooting
		Item = ItemExtract(Index, NDSPath, ".")
		ReversedNDSPath = ItemReplace(Item, (ItmCnt - Index + 1), ReversedNDSPath, ".")
	Next
	; Get user's Surname (last name) from NDS
	Surname = nwGetObjValue(UserFullTypelessName, 'Surname', "", "", 1)
	; Protect against/flag (as "(None)") empty name (i.e. if returned a 0)
	If (Surname == 0)
		Surname = '(None)'
	Endif
	; Get user's Given Name (first name) from NDS
	GivenName = nwGetObjValue(UserFullTypelessName, 'Given Name', "", "", 1)
	If (GivenName == 0)
		GivenName = '(None)'
	Endif
	; Get user's total space used from the user array
	SpaceUsedInMB = Int((UserArray[UserIndex,1] / MegaByte) + 0.5)
	; construct the user's "record", consisting of the above fields, delimited by tabs
   UserRecord = StrCat(ReversedNDSPath, @TAB, User, @TAB, Surname, @TAB, GivenName, @TAB, SpaceUsedInMB, @TAB, UserArray[UserIndex,2], @CR)
	; concatenate/append the user's "record" to the end of the cumulative user list
   UserList = StrCat(UserList, UserRecord)
Next

; diagnostic, if needed
;DiagTitle = StrCat("unsorted user list")
;Message(DiagTitle, UserList)

; sort the user list
UserList = ItemSort(UserList, @CR)

; diagnostic, if needed
;DiagTitle = StrCat("sorted user list")
;Message(DiagTitle, UserList)

; remove what is now an empty first item/record from the list (artifact of last user entry ending with @CR delimiter)
UserList = ItemRemove(1, UserList, @CR)

; diagnostic, if needed
;DiagTitle = StrCat("sorted user list minus empty first item/record")
;Message(DiagTitle, UserList)

; write out the per user file space usage and file count
For UserIndex = 1 to UserCount by 1
	; extract current user's fields from user list and build output line from them
	OutputLine = ItemExtract(UserIndex, UserList, @CR)
	; write the line out to the file
	; (a CR-LF is appended at the end of the line by the write)
	FileWrite(ReportFileHandle, OutputLine)
Next

Return 
#EndFunction
;
;---------------------------------------------------------------------------------------------------
;
;
;---------------------------------------------------------------------------------------------------
;
#DefineFunction udfProcessFolderTree(sFolder, UserArray)
; WARNING: This function employs a recursive call of itself to accomplish recursive descent of directories
; save current drive/folder location
sCurrentFolder = DirGet()
; change current drive/directory to supplied location
; For first invocation, will be to volume root or subdirectory specified by user
; For successive (recursive) self-invocations, will be to lower level subdirectories
DirChange(sFolder)
; did directory change work? (i.e. detect/report/skip corrupted subdirectory
; (how should this ultimately be handled..... add error line to report instead of pausing/reporting
;  to the user via the console?)
ReturnCode = LastError()
If (ReturnCode != 0)
	; directory change was not successful, report it
	DiagnosticLine = StrCat("At  ", sCurrentFolder, "  Unsuccessful directory change to  ", sFolder)
	Message("udfProcessFolderTree", DiagnosticLine)
Else
	; diagnostic, if needed
	;DiagnosticLine = StrCat("Preparing to process folder sFolder = ", sFolder)
	;Display(1, "udfProcessFolderTree", DiagnosticLine)
	;Message("udfProcessFolderTree", DiagnosticLine)
	; process current directory's files
	; (remember, userarray is global... must be explicitely passed (address is passed, not contents), 
	;  but doesn't have to be explicitely returned)
	; process current directory's files
	udfProcessFolderFiles(sFolder, UserArray)
	; get list and count of subdirectories within current directory
	; (need to error check?)
	sFolderList = DirItemize("*.*")
	iFolderCount = ItemCount(sFolderList, @TAB)
	; process current directory's subdirectories
	For FolderIndex = 1 to iFolderCount
		; extract current subdirectory from folder list
		sThisFolder = ItemExtract(FolderIndex, sFolderList, @TAB)
		; recursively descend into/process current subdirectory
		udfProcessFolderTree(sThisFolder, UserArray)							;RECURSIVE
	Next
Endif
; recursively ascend
DirChange(sCurrentFolder)

Return
#EndFunction
;
;---------------------------------------------------------------------------------------------------
;
#DefineFunction udfProcessFolderFiles(sFolder, UserArray)
; get list and count of files in current directory
; (need to error check fileitemize?)
sFileList = FileItemize("*.*")
iFileCount = ItemCount(sFileList, @TAB)

; diagnostic, if needed
;	DiagnosticLine = StrCat("sFolder = ", sFolder, "Preparing to process files sFileList = ", sFileList, " iFileCount = ", iFileCount)
;	Message(UserArray[0,0], DiagnosticLine)

For FileIndex = 1 to iFileCount by 1
	; extract current file name from file list
	sThisFile = ItemExtract(FileIndex, sFileList, @TAB)
	; as are using NetwareX function, need full drivepathfilename, so obtain it 
	sThisDriveFile = FileFullname(sThisFile)
	; diagnostic, if needed
	;DiagnosticLine = StrCat("sFolder = ", sFolder, " sThisFile = ", sThisFile, " sThisDriveFile = ", sThisDriveFile)
	;Message(UserArray[0,0], DiagnosticLine)
	; process current file
	udfProcessFile(sThisDriveFile, UserArray)
Next

Return
#EndFunction
;
;---------------------------------------------------------------------------------------------------
;
#DefineFunction udfProcessFile(sDriveFileName, UserArray)
; get file's owner's user object name (will be in .user.context..org format)
; (note: Files whose owners no longer exist or whose owner info was lost 
;        during a mass copy (example: server migration) will have empty ("") owners....
;        It is also possible for odd (non-NDS?) owner names/values to be present,
;        such as 060B0398201B3C4NPI398201_P3+30c, which returns "" as though empty)
OwnerObjectName = nwGetFileInfo(sDriveFileName, 2, "", "", 0)

; diagnostic, if needed
;DiagnosticLine = StrCat("OwnerObjectName = X", OwnerObjectName, "X ResultCode = ", ResultCode)
;Message("udfProcessFile", DiagnosticLine)
; Note: an empty owner (example: owner account has been deleted)
;       comes back as an empty string and a ResultCode of 0

; protect against possibility that file no longer exists (i.e. was deleted/renamed during time of this run)
ResultCode = LastError()
If (ResultCode == 0)
	; file exists
	; if ownerobjectname is empty (""), owner no longer exists, so substitute ".(None).(None)" for context and name
	If (OwnerObjectName == "")
		; substitute
		OwnerObjectName = ".(None).(None)"
	Else
		; if ownerobjectname is Netware "[Supervisor]" (old NW3/4 bindery term?), substitute ".[Supervisor].(None)" for context
		; (or need to generalize this for all "only contains one period/segment" ownerobjectnames?)
		If (OwnerObjectName == ".[Supervisor]")
			; substitute
			OwnerObjectName = ".[Supervisor].(None)"
		Endif
	Endif
	; get the file's size
	Size = FileSize(sDriveFileName)
	; force it to floating point (as file sizes larger than 2GB will be in floating point)
	Size = Size + 0.0
	; diagnostic, if needed
	;DiagnosticLine = StrCat("udfProcessFile OwnerObjectName = ", OwnerObjectName, " Size = ", Size)
	;Message("udfProcessFile", DiagnosticLine)
	;
	; add the file's size to its creator's/owner's total size and count it
	udfAddToUserArray(OwnerObjectName, Size, UserArray)
	; diagnostic, if needed
	;DiagnosticLine = StrCat("after returning from udfAddToUserList, UserList = ", UserList)
	;Message("udfProcessFile", DiagnosticLine)
EndIf

Return
#EndFunction
;
;===================================================================================================
;
#DefineFunction udfAddToUserArray(OwnerObjectName, Size, UserArray)
; an entry (row) may or may not exist for owner
; so
; try to locate the owner entry in the used portion of the user array
; (note: current UserCount is stored/maintained in first (0th) row of array)
; if not, add newly encountered owner to the array
;
; as files within a given directory are often going to be owned by the same owner,
; optimize this by checking to see if current file's owner is same as that of previous file
; and if it is, adjust the starting value of the following for loop to start
; at that owner's entry in the array, so won't have to loop through entire array to that point
; (note: last/previously encountered owner is maintained in the maxusers+1 row of the array
;
; current owner same as last/previously encountered owner?
If (OwnerObjectName == UserArray[(UserArray[0,1] + 1), 0])
	; yes, current owner same as last/previously encountered
	; so set index to last/previously's index (so don't have to loop through array to find user)
	UserIndex = UserArray[(UserArray[0,1] + 1), 1] 
Else
	; no, current owner is different than last/previously encountered
	; so loop through array to find current owner's position (index) in the array
	For UserIndex = 1 to UserArray[0,2] by 1
		; found current owner in the array?
		If (OwnerObjectName == UserArray[UserIndex,0])
			; matches user name already in array, so exit the loop
			; this will leave userindex pointing at owner's location in array
			Break
		Endif
	Next
Endif

; was owner already present in array (as determined by last/previous check or by looping search)?
If (UserIndex > UserArray[0,2])
	; No, didn't find user/owner entry in the currently used portion of array
	; Assure haven't maxed out/consumed the user portion of the array (i.e. that have room to add another user/owner)
	If (UserIndex == UserArray[0,1])
		; have exceeded MaximumUsers amount, report it and abend
		; (for batch/command line execution, need to output to file instead of console?)
		MessageLine = StrCat("Exceeded UserArray maximum size of ", UserArray[0,1])
		Message(UserArray[0,0], MessageLine)
		Exit
	Else
		; still have space in the array to add another user/owner (have not exceeded maximumusers)
		; index is pointing at first unused entry following last added user entry in the array
		; so no matching owner/user was found in array,
		; so go ahead and add/start a new entry for this user, in the unused entry
		UserArray[UserIndex,0] = OwnerObjectName		; owner/user name
		UserArray[UserIndex,1] = Size + 0.0				; space used (0.0 forces floating point)
		UserArray[UserIndex,2] = 1							; file count
		; and set UserCount value in header row to current UserIndex
		; (or could just increment it by one)
		UserArray[0,2] = UserIndex
		Endif
Else
	; Yes, did find user/owner entry in the currently used portion of array
	; so increase total file size and file count per current file for the user
	UserArray[UserIndex,1] = UserArray[UserIndex,1] + Size + 0.0	; (0.0 forces floating point)
	UserArray[UserIndex,2] = UserArray[UserIndex,2] + 1
Endif

; set last/previous owner name and index (maintained in row maximumusers+1 of array)
; to that of current owner, for use in next optimized "same as previous owner?" test
UserArray[(UserArray[0,1] + 1), 0] = OwnerObjectName
UserArray[(UserArray[0,1] + 1), 1] = UserIndex 
; 
; diagnostic, if needed
;DiagnosticLine = StrCat("UserIndex = ", UserIndex)
;DiagnosticLine = StrCat(DiagnosticLine, " user name = X", UserArray[UserIndex,0], "X ") 
;DiagnosticLine = StrCat(DiagnosticLine, " space used = ", UserArray[UserIndex,1]) 
;DiagnosticLine = StrCat(DiagnosticLine, " file count = ", UserArray[UserIndex,2]) 
;Message(UserArray[0,0], DiagnosticLine)

Return
#EndFunction
;
;
;===================================================================================================
;
#DefineFunction SaveFileAsSpreadsheet(OutputFileNameArray, SpreadsheetFileName, DialogTitle)
; note: might be better to do the following via OLE extender
;       for reliability, timing/speed, additional formatting, column totals, etc. sake?
;
; start the spreadhsheet program and wait for it to be ready
Run("excel.exe", "")
TimeDelay(5)
WinWaitExist("~Microsoft Excel", 60)

; start a new sheet (to be safe, in case Excel is already being used)
; File | New | OK and wait for it to be ready
SendKeysTo("~Microsoft Excel", "!fn{ENTER}")
TimeDelay(5)

; import the delimited text file (doing this, instead of opening the file,
; causes the column widths to be adjusted according to incoming data values maximum width)
; Data | Get External Data | Import Text File
; (copy/paste the output file name into the entry field, as paste is faster than typein)
ClipPut(OutputFileNameArray[0])
SendKeysTo("~Microsoft Excel", "!ddt^v{ENTER}")
TimeDelay(10)

; supply the import the next, next, next, ... that it needs
SendKeysTo("~Microsoft Excel", "{ENTER}{ENTER}{ENTER}{ENTER}{ENTER}")
TimeDelay(10)

; save the sheet out as spreadsheet format file
; (copy/paste the spreadsheet file name into the entry field, as paste is faster than typein)
ClipPut(SpreadsheetFileName)
SendKeysTo("~Microsoft Excel", "!fa^v{ENTER}")
TimeDelay(10)

; let the user know the sheet has been saved and can now be viewed, etc. as they wish
MessageLine = "Spreadsheet has been saved.  View or close as you wish."
Message(DialogTitle, MessageLine)

Return
#EndFunction
;
;===================================================================================================
Return   														; return from interpreter's gosub fallthru of UDFs
;																	; so that UDFs can be placed below main code
;===================================================================================================


Article ID:   W16056
File Created: 2004:03:30:15:42:38
Last Updated: 2004:03:30:15:42:38