Beginning PHP5 Skim Throughs, Part 4: Files


Weee, back to the PHP stuff! At this point, I'm mostly just using the Beginning PHP5 as an overgrown road map for what to look at because it is just so outdated!  I skipped chapter 6 since it was all about "writing high quality code", mostly using functions and the like.  😛  Chapter 7 gets into working with files and directories.  Earlier this week I wrote a quick little log viewer for us to use for a new logging mechanism we're doing, so for today's lesson, I'll write a PHP version. Should give me good coverage in dealing with directories and files 🙂

This is a relatively simply project, with 2 PHP files (and some sample logs to play with). The index.php shows the list of the available logs.  I started this page by throwing a little function at the top to nicely format the file sizes.

<?php
	function formatFileSize($sizeInBytes){
		$readableSize = $sizeInBytes/1024;
		$sizeType = "KB";
		
		// if file is bigger than 1 MB, display in megs...
		if ($readableSize/1024 > 1) {
			$readableSize = $readableSize/1024;
			$sizeType = "MB";
		
			// unless it's also bigger than a GB? 
			if ($readableSize/1024 > 1) {
				$readableSize = $readableSize/1024;
				$sizeType = "GB";	
			}
		}
			
		return number_format($readableSize, 2) . " " . $sizeType;
	};
?>

Then in came the HTML.  In the head section, I threw the usual jQuery stuff along with some basic CSS for nice appearances. The the body section is the "meat and potatoes", so to speak, where I had to figure out how to parse a directory and list the files within, but I only wanted the .log files.

<h1>SDG Application Log Viewer</h1>	
<?php
	if (isset($error))
		echo '<div id="error">' . $error . '</div>';
		
	elseif (isset($successs))
		echo '<div id="successs">' . $successs . '</div>';
?>

<div id="logDiv">
	<table id="logsAvailable">
	<thead>
		<tr>
			<th>File Name</th>
			<th>Size</th>
			<th>Last Modified</th>
			<th> </th>
		</tr>
	</thead>
	<tbody>
		<?php
			$d = dir(getcwd());
			
			while (false != ($thisFile = $d->read())) {
				if (strPos($thisFile, ".log")) {
					echo '<tr>'
						. '<td><a href="viewer.php?whichfile=' . $thisFile . '">' . $thisFile . '</a></td>' . "\n"
						. '<td>' . formatFileSize(filesize($thisFile)) . '</td>' . "\n"
						. '<td>' . date("M d, Y h:i A", filemtime($thisFile)) . '</td>' . "\n"
						. '<td>' . "\n"
							. '<a href="viewer.php?whichfile=' . $thisFile . '">View</a> ~' . "\n"
							. '<a href="' . $thisFile . '">Raw</a> ~' . "\n"
							. '<a href="index.php?archive=' . $thisFile . '">Archive</a> ~' . "\n"
							. '<a href="index.php?delete=' . $thisFile . '">Delete</a>' . "\n"
						. '</td>' . "\n"
					. '</tr>' . "\n";
				}
			};
			
			$d->close();
		?>
	</tbody>
	</table>
</div>

<!-- make it pretty with jQuery and DataTables -->
<script type="text/javascript">
	$(document).ready(function(){
		$('#logsAvailable').dataTable({
			"bStateSave": false,
			"bJQueryUI": true,
			"bAutoWidth": true,
			"iDisplayLength": 10,
			"sPaginationType": "full_numbers",
			"aaSorting": [[ 2, "desc" ]],
			"aoColumnDefs": [
				{ "sType": "file-size", "sClass" : "alignRight", "aTargets": [ 1 ] },
				{ "bSortable": false, "sClass" : "alignCenter", "aTargets": [ 3 ] }
			]
		});
	});
</script>

This was followed by the closing body and html tags, of course. Notice that unlike with ColdFusion, you have to remember to close any files and directories you "open" when you're working in PHP. Now ColdFusion does have a FileClose function, but as far as I could tell, you'd only use it for CFSCRIPT – the CFFILE/CFDIRECTORY tags don't need it (and if you try to use it, you get a lovely error).  Also, unlike with CF, the directory looping only gives me file size, no basic details, so I had to use other basic built-in file functions to get that.

I also got sick of writing multiple lines of echo so I used the dot concating to just do a multiple line one. One aggravation I'm finding with PHP is having to stick a . "\n" at the end of every echo/print line so the resulting code when viewing the source of the page is still semi-readable.  Yeah, it's a nit-picky thing, but the resulting output from my CF code keeps my indenting and is readable without having to do all that crap.

Since this was a pretty simply example, I put the archive and delete functions in the index.php.  For the archive, I wanted to copy the existing file then "reset" it with just the first line of the file which has the log headers.  This let me get to try out the file copy, truncate, and file writing functions, as well as reading a single line from a file.  The delete, of course, hits on file deletion.  I put this code at the top of the index.php after the function I created earlier.

if (isset($_GET["archive"])) {
	$fileToArchive = getcwd() . "/" . $_GET["archive"];
	$archiveFile = str_replace('.log', '-' . $_SERVER['REQUEST_TIME'] . '.log', $fileToArchive);
	
	if (is_writable(getcwd())) {
		if (!copy($fileToArchive, $archiveFile))
			$error = "Unable to save archived file " . $archiveFile;
		else {
			$fileHandle = fopen($fileToArchive, "r+b");
			$firstLine = fgets($fileHandle);
			
			if (ftruncate($fileHandle, 0) === FALSE)
				$error = "Unable to truncate main file " . $fileToArchive;
			else
				if(fwrite($fileHandle, $firstLine) === FALSE)
					$error = "Unable to truncate main file " . $fileToArchive;

			fclose($fileHandle);
		}
	}
	else
		$error = "Unable to archive " . $fileToArchive . " as the directory is not writable to php.";
		
	if (!isset($error))
		$error = $_GET["archive"] . " archived successfully!";
}
elseif (isset($_GET["delete"])) {
	$fileToDelete = getcwd() . "/" . $_GET["delete"];
	
	if (!unlink($fileToDelete))
		$error = "Unable to delete file " . $fileToDelete;
	
	if (!isset($error))
		$error = $_GET["delete"] . " deleted successfully!";
}

Obviously, in a fuller version both of these functions would have many more checks beyond the simple ones for "did it work".  But I was focusing mostly on trying the file functions at this point. I've yet to find any sensible explanation for why the file delete function is called "unlink" beyond it being a nod to the Unix C function of the same name.  Unlink in Linux is deleting a name (created with link) – it only deletes the file if is the last name that file has.  So why the PHP function uses this function name versus following its other conventions and using fdelete makes no sense.  I think that may be one of my biggest frustrations with PHP – the complete lack of freaking consistency.  It's like multiple people worked on it with no standards on function naming and at no point did they go "hey, let's keep it like this." *argh*

Anyway, the final bit is the view function. This one reads an individual log and puts it into another DataTable display for easy sorting, filtering, etc.

<?php
	$whichfile = $_GET["whichfile"];
		
	$logfile = getcwd() . "/" . $whichfile;
	
	if(!isset($whichfile) || !file_exists($logfile)) {
		echo "<h2>Um? Hello? Are you trying to hack me? Cause you gave me a crappy file name</h2>" . "\n";
	}
	else {
		echo "<h1>SDG Application Logs: " . $whichfile . "</h1>" . "\n"
			. '<table id="logEntries">' . "\n"
			. "<thead>" . "\n"
			. "<tr>" . "\n";
		
		/* 
			For flexibility, this viewer dynamically grabs the column list in case the log file format changes; 
			It does make some presumptions though: is that the last column is always the description one (for the jQuery), that the first row will
			have the column headers, that the log file is essentially in CSV format, and that the 2 & 3 columns are the date & time, respectively (for the jQuery).
		*/
		$fileHandle = fopen($logfile, "rb");
		
		$columnHeaders = explode(",", fgets($fileHandle));
		$columnCount = count($columnHeaders);
		
		echo "<tr>" . "\n";
		foreach ($columnHeaders as &$thisHeader) {
			echo "<th>" . trim(str_replace('"', '', $thisHeader)) . "</th>" . "\n";
		}

		echo "</tr>" . "\n"
			. '</thead>' . "\n"
			. "<tbody>" . "\n";
			
		$lineNum = 1;
			
		while (($thisRow = fgetcsv($fileHandle, 0, ",", '"')) !== FALSE) {
			if ($lineNum > 1) {
				echo "<tr>" . "\n";
				foreach ($thisRow as &$thisDetail) {
					echo "<td>" . trim(str_replace('"', '', $thisDetail)) . "</td>" . "\n";
				}
				echo "</tr>" . "\n";
			}
			$lineNum++;
		} 
		
		echo "</tbody>" . "\n"
			. "</table>" . "\n";
			
		 fclose($fileHandle);
			
	}
?>

<!-- make it pretty with jQuery and DataTables -->
<script type="text/javascript">
	$(document).ready(function(){
		$('#logEntries').dataTable({
			"bStateSave": false,
			"bJQueryUI": true,
			"bAutoWidth": true,
			"iDisplayLength": 10,
			"sPaginationType": "full_numbers",
			"aaSorting": [[ 1, "desc" ], [ 2, "desc" ]],
			"aoColumnDefs": [
				{ "bSortable": false, "aTargets": [ <?php echo $columnCount-1 ?> ] }
			]
		});
	});
</script>

With this bit, I was able to figure out if a file exists, pull the first line for the columns, then loop the rest of the file.  The fgetcsv function was a helpful find as it can parse each line of the file and parse it to an array while handling the text qualifiers that are usually found with CSVs.  Saved me some headaches since the usual method of doing a comma-list to array (explode) can't handle commas in the data, of course.  I also built it to be fairly flexible in case we change the log format later. The only thing it presumes is that the last column is the error message (and therefore shouldn't be sorted) and the 2 & 3 columns have the date and time respectively.

Hopefully next week our development box will be updated to match our future PHP production environment so I can start at least trying to look at the CakePHP stuff.  Meanwhile, continuing on in my basic stuff 🙂