A Multiple Text Document Editor in JavaScript

Below is a multiple text document editor implemented in JavaScript. To see the code in action, copy it into an .html file and open that file in a web browser that runs JavaScript. Or, for an online version, visit https://thiscouldbebetter.neocities.org/multipletextdocumenteditor.html.


<html>

<style>

.bordered {
	border: 1px solid;
}

.number {
	width: 64px;
}
 
</style>
<body>

<! -- ui -->

<div>
	<div id="divProjectFile" class="bordered">
		<label>Project:</label>
		<div>
			<label>Name:</label>
			<input id="inputProjectName" onchange="inputProjectName_Changed(this);"></input>
		</div>
		<button onclick="buttonProjectSave_Clicked();">Save</button>
		<button onclick="buttonProjectLoad_Clicked();">Load:</button>
		<input id="inputFileProjectToLoad" type="file"></input>
		<button onclick="buttonProjectNew_Clicked();">New</button>
	</div>

	<div id="divDocumentList" class="bordered">
		<label>Documents:</label>
		<div>
			<button onclick="buttonDocumentNew_Clicked();">New</button>		
			<button onclick="buttonDocumentExistingAdd_Clicked();">Add Existing:</button>
			<input id="inputFileDocumentExistingToAdd" type="file"></input>
			<button onclick="buttonDocumentSelectedRemove_Clicked();">Remove Selected</button>
		</div>
		<select id="selectDocumentsInProject" style="width:100%" size="10" onchange="selectDocumentsInProject_Changed(this);"></select>
	</div>

	<div id="divDocumentSelected" class="bordered">
		<div><label>Document Selected:</label></div>
		<div>
			<label>Name:</label>
			<input id="inputDocumentSelectedName" onchange="inputDocumentSelectedName_Changed(this);"></input>
			<button onclick="buttonDocumentSelectedSave_Clicked();">Save</button>
			<label>Cursor:</label>
			<label>Row:</label>
			<input id="inputCursorRow" class="number" type="number" onchange="inputCursorColumnOrRow_Changed(this);"></input>
			<label>Column:</label>
			<input id="inputCursorColumn" class="number" type="number" onchange="inputCursorColumnOrRow_Changed(this);"></input>

		</div>
		<div><label>Contents:</label></div>
		<div><textarea id="textareaDocumentSelectedContents" style="width:100%" rows="20" onchange="textareaDocumentSelectedContents_Changed(this);" onkeydown="textareaDocumentSelectedContents_CursorMoved(this);" onmousedown="textareaDocumentSelectedContents_CursorMoved(this);"></textarea></div>
	</div>

	<div id="divSearch" class="bordered">
		<div>
			<button onclick="buttonSearch_Clicked();">Search for:</button>
			<input id="inputTextToSearchFor"></input>
			<input id="checkboxSearchMatchCase" type="checkbox">Match Case</input>
		</div>
		<div>
			<div><label>Results:</label></div>
			<select id="selectSearchResults" size="8" style="width:100%" onchange="selectSearchResults_Changed(this);"></select>
		</div>
		
	</div>

	</div>

</div>

<!-- ui ends -->

<script type="text/javascript">

// ui events

function buttonDocumentExistingAdd_Clicked()
{
	var project = Globals.Instance.session.project;

	var inputFileDocumentExistingToAdd = document.getElementById
	(
		"inputFileDocumentExistingToAdd"
	);
	var fileToLoad = inputFileDocumentExistingToAdd.files[0];
	if (fileToLoad == null)
	{
		alert("Please choose a file with the accompanying 'Choose File' button first.");
	}
	else
	{
		var documentAdded = FileHelper.loadFileAsText
		(
			fileToLoad,
			buttonDocumentExistingAdd_Clicked_FileLoaded // callback
		);
	}
}

function buttonDocumentExistingAdd_Clicked_FileLoaded(fileLoadedName, fileLoadedContents)
{
	var project = Globals.Instance.session.project;

	var documentToAdd = new Document
	(
		fileLoadedName,
		fileLoadedContents
	);
	project.documentAdd(documentToAdd);

	project.domUpdate();
}

function buttonDocumentNew_Clicked()
{
	var project = Globals.Instance.session.project;

	project.documentNew();

	project.domUpdate();
}

function buttonDocumentSelectedRemove_Clicked()
{
	var project = Globals.Instance.session.project;
	
	var documentSelected = project.documentSelected();
	if (documentSelected != null)
	{
		project.documentRemove(documentSelected);
	}

	project.domUpdate();
}

function buttonDocumentSelectedSave_Clicked()
{
	var project = Globals.Instance.session.project;
	
	var documentSelected = project.documentSelected();
	if (documentSelected != null)
	{
		FileHelper.saveTextAsFile
		(
			documentSelected.contents, documentSelected.name
		);
	}

	project.domUpdate();
}

function buttonProjectLoad_Clicked()
{
	var project = Globals.Instance.session.project;

	var inputFileProjectToLoad = document.getElementById
	(
		"inputFileProjectToLoad"
	);
	var fileToLoad = inputFileProjectToLoad.files[0];
	if (fileToLoad == null)
	{
		alert("Please choose a file with the accompanying 'Choose File' button first.");
	}
	else
	{
		FileHelper.loadFileAsBytes
		(
			fileToLoad,
			buttonProjectLoad_Clicked_FileLoaded // callback
		);
	}
}

function buttonProjectLoad_Clicked_FileLoaded(fileLoadedName, fileLoadedContentsAsBytes)
{
	var projectAsTar = TarFile.fromBytes(fileLoadedName, fileLoadedContentsAsBytes);
	var documentsAsTarEntries = projectAsTar.entries;
	var documents = [];

	for (var i = 0; i < documentsAsTarEntries.length; i++)
	{
		var documentAsTarEntry = documentsAsTarEntries[i];
		var documentName = documentAsTarEntry.header.fileName;
		var documentContentsAsBytes = documentAsTarEntry.dataAsBytes;
		var documentContentsAsString = ByteHelper.bytesToStringUTF8(documentContentsAsBytes);
		var document = new Document(documentName, documentContentsAsString);
		documents.push(document);
	}

	var projectLoaded = new Project
	(
		fileLoadedName,
		documents
	);

	Globals.Instance.session.project = projectLoaded;

	projectLoaded.domUpdate();
}

function buttonProjectNew_Clicked()
{
	var projectNew = new Project
	(
		"Untitled.tar", []
	);

	Globals.Instance.session.project = projectNew;

	projectNew.domUpdate();

}


function buttonProjectSave_Clicked()
{
	var project = Globals.Instance.session.project;
	
	var projectAsTarFile = project.toTarFile();
	var projectAsBytes = projectAsTarFile.toBytes();
	FileHelper.saveBytesAsFile(projectAsBytes, project.name);
}

function buttonSearch_Clicked()
{
	var project = Globals.Instance.session.project;
	
	var inputTextToSearchFor = document.getElementById
	(
		"inputTextToSearchFor"
	);
	var textToSearchFor = inputTextToSearchFor.value;

	var checkboxSearchMatchCase = document.getElementById
	(
		"checkboxSearchMatchCase"
	);
	var matchCase = checkboxSearchMatchCase.checked;

	project.searchForText(textToSearchFor, matchCase);

	project.domUpdate();
}

function inputDocumentSelectedName_Changed(inputDocumentSelectedName)
{
	var project = Globals.Instance.session.project;

	var documentSelected = project.documentSelected();
	if (documentSelected != null)
	{
		var valueFromDOM = inputDocumentSelectedName.value;
		documentSelected.name = valueFromDOM;
	}

	//project.domUpdate();	
}

function inputCursorColumnOrRow_Changed()
{
	var project = Globals.Instance.session.project;

	var documentSelected = project.documentSelected();
	if (documentSelected != null)
	{
		var inputCursorColumn = document.getElementById("inputCursorColumn");
		var inputCursorRow = document.getElementById("inputCursorRow");

		documentSelected.cursorPos.x = Math.floor(inputCursorColumn.value);
		documentSelected.cursorPos.y = Math.floor(inputCursorRow.value);

		project.domUpdate_Cursor_Place();
	}
}

function inputProjectName_Changed(inputProjectName)
{
	var project = Globals.Instance.session.project;

	var valueFromDOM = inputProjectName.value;
	documentSelected.name = valueFromDOM;

	//project.domUpdate();	
}

function selectDocumentsInProject_Changed(selectDocumentsInProject)
{
	var project = Globals.Instance.session.project;

	var documentIndex = selectDocumentsInProject.selectedIndex;
	project.documentIndexSelected = documentIndex;

	project.domUpdate();
}

function selectSearchResults_Changed(selectSearchResults)
{
	var project = Globals.Instance.session.project;

	var searchResultSelectedIndex = selectSearchResults.selectedIndex;
	var searchResultSelected = project.searchResults[searchResultSelectedIndex];
	if (searchResultSelected != null)
	{
		var documentName = searchResultSelected.documentName;
		var documentToSelect = project.documents[documentName];
		var documentIndex = project.documents.indexOf(documentToSelect);
		project.documentIndexSelected = documentIndex;
		documentToSelect.cursorPos.overwriteWith(searchResultSelected.posInDocument);
		project.domUpdate_Cursor_Place();
	}	


}

function textareaDocumentSelectedContents_Changed(textareaDocumentSelectedContents)
{
	var project = Globals.Instance.session.project;

	var documentSelected = project.documentSelected();
	if (documentSelected != null)
	{
		var valueFromDOM = textareaDocumentSelectedContents.value;
		documentSelected.contents = valueFromDOM;
	}

	//project.domUpdate();
}

function textareaDocumentSelectedContents_CursorMoved(textareaDocumentSelectedContents)
{
	var project = Globals.Instance.session.project;

	var documentSelected = project.documentSelected();
	if (documentSelected != null)
	{
		var text = textareaDocumentSelectedContents.value;
		var cursorOffsetInChars = textareaDocumentSelectedContents.selectionEnd;

		var cursorPosNew = Document.stringAndCharOffsetToCursorPos
		(
			text,
			cursorOffsetInChars
		);

		documentSelected.cursorPos.overwriteWith(cursorPosNew);

		project.domUpdate_Cursor();
	}
}

// main

function main()
{
	var welcomeDocumentContents = "";

	for (var i = 0; i < 32; i++)
	{
		welcomeDocumentContents += "Welcome to the multiple document editor!\n";
	}

	var projectDemo = new Project
	(
		"Welcome.tar",
		[
			new Document
			(
				"Welcome.txt", 
				welcomeDocumentContents
			),
			new Document
			(
				"Welcome2.txt", 
				welcomeDocumentContents
			)

		]
	);
	Globals.Instance.initialize(projectDemo);	
}

// extensions

function ArrayExtensions()
{
	// extension class
}
{
	Array.prototype.addLookups = function(keyName)
	{
		for (var i = 0; i < this.length; i++)
		{
			var element = this[i];
			var key = element[keyName];
			this[key] = element;
		}

		return this;
	}

	Array.prototype.remove = function(elementToRemove)
	{
		this.splice(this.indexOf(elementToRemove), 1);
	}
}

function StringExtensions()
{
	// extension class
}
{
	String.prototype.padLeft = function(lengthToPadTo, charToPadWith)
	{
		var returnValue = this;

		while (returnValue.length < lengthToPadTo)
		{
			returnValue = charToPadWith + returnValue;
		}

		return returnValue;
	}


	String.prototype.padRight = function(lengthToPadTo, charToPadWith)
	{
		var returnValue = this;

		while (returnValue.length < lengthToPadTo)
		{
			returnValue += charToPadWith;
		}

		return returnValue;
	}
}

// classes

function ByteHelper()
{
	// static class
}
{
	ByteHelper.stringUTF8ToBytes = function(stringToConvert)
	{
		var bytes = [];

		for (var i = 0; i < stringToConvert.length; i++)
		{
			var byte = stringToConvert.charCodeAt(i);
			bytes.push(byte);
		} 

		return bytes;	
	}

	ByteHelper.bytesToStringUTF8 = function(bytesToConvert)
	{
		var returnValue = "";

		for (var i = 0; i < bytesToConvert.length; i++)
		{
			var byte = bytesToConvert[i];
			var byteAsChar = String.fromCharCode(byte);
			returnValue += byteAsChar
		}

		return returnValue;
	}
}

function ByteStream(bytes)
{
	this.bytes = bytes;  

	this.byteIndexCurrent = 0;
}
{
	// constants

	ByteStream.BitsPerByte = 8;
	ByteStream.BitsPerByteTimesTwo = ByteStream.BitsPerByte * 2;
	ByteStream.BitsPerByteTimesThree = ByteStream.BitsPerByte * 3;

	// instance methods

	ByteStream.prototype.hasMoreBytes = function()
	{
		return (this.byteIndexCurrent < this.bytes.length);
	}
	
	ByteStream.prototype.readBytes = function(numberOfBytesToRead)
	{
		var returnValue = [];

		for (var b = 0; b < numberOfBytesToRead; b++)
		{
			returnValue[b] = this.readByte();
		}

		return returnValue;
	}

	ByteStream.prototype.readByte = function()
	{
		var returnValue = this.bytes[this.byteIndexCurrent];

		this.byteIndexCurrent++;

		return returnValue;
	}

	ByteStream.prototype.readString = function(lengthOfString)
	{
		var returnValue = "";

		for (var i = 0; i < lengthOfString; i++)
		{
			var byte = this.readByte();

			if (byte != 0)
			{
				var byteAsChar = String.fromCharCode(byte);
				returnValue += byteAsChar;
			}
		}

		return returnValue;
	}

	ByteStream.prototype.writeBytes = function(bytesToWrite)
	{
		for (var b = 0; b < bytesToWrite.length; b++)
		{
			this.bytes.push(bytesToWrite[b]);
		}

		this.byteIndexCurrent = this.bytes.length;
	}

	ByteStream.prototype.writeByte = function(byteToWrite)
	{
		this.bytes.push(byteToWrite);

		this.byteIndexCurrent++;
	}

	ByteStream.prototype.writeString = function(stringToWrite, lengthPadded)
	{	
		for (var i = 0; i < stringToWrite.length; i++)
		{
			var charAsByte = stringToWrite.charCodeAt(i);
			this.writeByte(charAsByte);
		}
		
		var numberOfPaddingChars = lengthPadded - stringToWrite.length;
		for (var i = 0; i < numberOfPaddingChars; i++)
		{
			this.writeByte(0);
		}
	}
}

function Coords(x, y)
{
	this.x = x;
	this.y = y;
}
{
	Coords.prototype.overwriteWith = function(other)
	{
		this.x = other.x;
		this.y = other.y;
		return this;
	}

	Coords.prototype.toString = function()
	{
		return this.y + ":" + this.x;
	}
}

function Document(name, contents)
{
	this.name = name;
	this.contents = contents;

	this.cursorPos = new Coords(0, 0);
}
{
	// static methods

	Document.stringAndCharOffsetToCursorPos = function(text, cursorOffsetInChars)
	{
		var newline = "\n";
		var newlinesSoFar = 0;
		var offsetCurrent = null;

		while (true)
		{
			var offsetOfNewline = text.indexOf(newline, offsetCurrent);
			if (offsetOfNewline == -1 || offsetOfNewline >= cursorOffsetInChars)
			{
				break;
			}
			offsetCurrent = offsetOfNewline + 1;
			newlinesSoFar++;
		}

		var returnValue = new Coords
		(
			cursorOffsetInChars - offsetCurrent,
			newlinesSoFar + 1
		);

		return returnValue;
	}

	Document.stringAndCursorPosToCharOffset = function(text, cursorPos)
	{
		var newline = "\n";
		var newlinesSoFar = 0;
		var offsetCurrent = null;
	
		while (offsetCurrent < text.length && newlinesSoFar < cursorPos.y - 1)
		{
			var offsetOfNewline = text.indexOf(newline, offsetCurrent);
			offsetCurrent = offsetOfNewline + 1;
			newlinesSoFar++;
		}

		var returnValue = offsetCurrent + cursorPos.x;

		return returnValue;
	}

}

function FileHelper()
{
	// static class
}
{
    	FileHelper.loadFileAsBytes = function(fileToLoad, callback)
	{   
		var fileReader = new FileReader();
		fileReader.onload = function(fileLoadedEvent)
		{
			var fileLoadedAsBinaryString = 
				fileLoadedEvent.target.result;
			var fileLoadedAsBytes = 
				ByteHelper.stringUTF8ToBytes(fileLoadedAsBinaryString);
			callback(fileToLoad.name, fileLoadedAsBytes);
		}
 
		fileReader.readAsBinaryString(fileToLoad);
	}

	FileHelper.loadFileAsText = function(fileToLoad, callback)
	{
		var fileReader = new FileReader();
		fileReader.onload = function(fileLoadedEvent) 
		{
			var textFromFileLoaded = fileLoadedEvent.target.result;
			callback(fileToLoad.name, textFromFileLoaded);
		};
		fileReader.readAsText(fileToLoad);
	}
 
	FileHelper.saveBytesAsFile = function(bytesToWrite, fileNameToSaveAs)
	{
		var bytesToWriteAsArrayBuffer = new ArrayBuffer(bytesToWrite.length);
		var bytesToWriteAsUIntArray = new Uint8Array(bytesToWriteAsArrayBuffer);
		for (var i = 0; i < bytesToWrite.length; i++) 
		{
			bytesToWriteAsUIntArray[i] = bytesToWrite[i];
		}
 
		var bytesToWriteAsBlob = new Blob
		(
			[ bytesToWriteAsArrayBuffer ], 
			{ type:"application/type" }
		);
 
		var downloadLink = document.createElement("a");
		downloadLink.download = fileNameToSaveAs;
		downloadLink.href = window.URL.createObjectURL(bytesToWriteAsBlob);
		downloadLink.click();
	}

	FileHelper.saveTextAsFile = function(textToSave, fileNameToSaveAs)
	{
		var textToSaveAsBlob = new Blob([textToSave], {type:"text/plain"});
		var textToSaveAsURL = window.URL.createObjectURL(textToSaveAsBlob);

		var downloadLink = document.createElement("a");
		downloadLink.download = fileNameToSaveAs;
		downloadLink.href = textToSaveAsURL;
		downloadLink.click();
	}
}

function Globals()
{
	// do nothing
}
{
	Globals.Instance = new Globals();

	Globals.prototype.initialize = function(project)
	{
		this.session = new Session(project);
		this.domUpdate();
	}

	// dom 
	
	Globals.prototype.domUpdate = function()
	{
		this.session.domUpdate();	
	}
}

function Project(name, documents)
{
	this.name = name;
	this.documents = documents.addLookups("name");

	if (this.documents.length > 0)
	{
		this.documentIndexSelected = 0;
	}

	this.searchResults = [];
}
{
	Project.prototype.documentAdd = function(documentToAdd)
	{
		this.documents.push(documentToAdd);
		this.documents[documentToAdd.name] = documentToAdd;
		this.documentIndexSelected = this.documents.length - 1;
	}

	Project.prototype.documentNew = function()
	{
		var documentNew = new Document("Untitled.txt", "");
		this.documentAdd(documentNew);
	}

	Project.prototype.documentRemove = function(documentToRemove)
	{
		this.documents.remove(documentToRemove);
		delete this.documents[documentToRemove.name];
		if (this.documents.length == 0)
		{
			this.documentIndexSelected = null;
		}
	}

	Project.prototype.documentSelected = function()
	{
		return (this.documentIndexSelected == null ? null : this.documents[this.documentIndexSelected]);
	}

	Project.prototype.searchForText = function(textToSearchFor, matchCase)
	{
		this.searchResults.length = 0;

		if (matchCase == false)
		{
			textToSearchFor = textToSearchFor.toLowerCase();
		}

		for (var i = 0; i < this.documents.length; i++)
		{
			var documentToSearch = this.documents[i];
			var documentContents = documentToSearch.contents;

			if (matchCase == false)
			{
				documentContents = documentContents.toLowerCase();
			}

			var indexOfMatchInContents = -1;
			while (true)
			{
				indexOfMatchInContents = documentContents.indexOf
				(
					textToSearchFor,
					indexOfMatchInContents + 1
				);

				if (indexOfMatchInContents >= 0)
				{
					var matchPos = Document.stringAndCharOffsetToCursorPos
					(
						documentContents,
						indexOfMatchInContents
					);

					var newline = "\n";

					var lineWithMatchStart = documentContents.lastIndexOf(newline, indexOfMatchInContents);
					var lineWithMatchEnd = documentContents.indexOf(newline, indexOfMatchInContents);

					if (lineWithMatchStart == -1)
					{
						lineWithMatchStart = 0;
					}

					if (lineWithMatchEnd == -1)
					{
						lineWithMatchEnd = null;
					}

					var lineWithMatch = documentToSearch.contents.substring 
					(
						// Not the same as ".substr"!
						lineWithMatchStart, lineWithMatchEnd
					);

					var result = new SearchResult
					(
						documentToSearch.name, 
						matchPos,
						lineWithMatch
					)
					this.searchResults.push(result);
				}
				else
				{
					break;
				}
			}
		}
	}

	// dom

	Project.prototype.domUpdate = function()
	{
		var inputProjectName = 
			document.getElementById("inputProjectName");

		inputProjectName.value = this.name;

		var selectDocumentsInProject = 
			document.getElementById("selectDocumentsInProject");

		selectDocumentsInProject.options.length = 0;		

		for (var i = 0; i < this.documents.length; i++)
		{
			var _document = this.documents[i];
			var documentAsOption = document.createElement("option");
			documentAsOption.innerHTML = _document.name;
			selectDocumentsInProject.appendChild(documentAsOption);
		}

		var documentSelected = this.documentSelected();

		var inputDocumentSelectedName = 
			document.getElementById("inputDocumentSelectedName");
		var textareaDocumentSelectedContents = 
			document.getElementById("textareaDocumentSelectedContents");

		if (documentSelected == null)
		{
			inputDocumentSelectedName.value = "";
			textareaDocumentSelectedContents.value = "";
		}
		else
		{
			selectDocumentsInProject.selectedIndex = 
				this.documentIndexSelected;
			inputDocumentSelectedName.value = 
				documentSelected.name;
			textareaDocumentSelectedContents.value 
				= documentSelected.contents;
		}

		this.domUpdate_Cursor();

		this.domUpdate_Search();
	}

	Project.prototype.domUpdate_Cursor = function()
	{
		var inputCursorColumn = 
			document.getElementById("inputCursorColumn");
		var inputCursorRow = 
			document.getElementById("inputCursorRow");

		var documentSelected = this.documentSelected();

		if (documentSelected == null)
		{
			inputCursorRow.value = "";
			inputCursorColumn.value = "";
		}
		else
		{
			inputCursorRow.value = documentSelected.cursorPos.y;
			inputCursorColumn.value = documentSelected.cursorPos.x;
		}
	}

	Project.prototype.domUpdate_Cursor_Place = function()
	{	
		var documentSelected = this.documentSelected();

		if (documentSelected != null)
		{
			var cursorOffsetInChars = Document.stringAndCursorPosToCharOffset
			(
				documentSelected.contents,
				documentSelected.cursorPos
			);

			var textareaDocumentSelectedContents = 
				document.getElementById("textareaDocumentSelectedContents");

			textareaDocumentSelectedContents.selectionStart = cursorOffsetInChars; 
			textareaDocumentSelectedContents.selectionEnd = cursorOffsetInChars;
			textareaDocumentSelectedContents.focus();
		}
	}

	Project.prototype.domUpdate_Search = function()
	{
		var selectSearchResults = document.getElementById("selectSearchResults");
		selectSearchResults.innerHTML = "";

		for (var i = 0; i < this.searchResults.length; i++)
		{
			var searchResult = this.searchResults[i];
			var searchResultAsOption = document.createElement("option");
			searchResultAsOption.innerHTML = searchResult.toString();
			selectSearchResults.appendChild(searchResultAsOption);
		}
	}

	// tar

	Project.prototype.toTarFile = function()
	{
		var returnValue = TarFile.new();

		for (var i = 0; i < this.documents.length; i++)
		{
			var _document = this.documents[i];

			var documentContents = _document.contents;
			var documentContentsAsBytes = ByteHelper.stringUTF8ToBytes
			(
				documentContents
			);

			var documentAsTarFileEntry = TarFileEntry.fileNew
			(
				_document.name,
				documentContentsAsBytes	
			);
			returnValue.entries.push(documentAsTarFileEntry);
		}

		return returnValue;
	}
}

function SearchResult(documentName, posInDocument, lineContainingMatch)
{
	this.documentName = documentName;
	this.posInDocument = posInDocument;
	this.lineContainingMatch = lineContainingMatch;
}
{
	SearchResult.prototype.toString = function()
	{
		return this.documentName 
		+ " - " + this.posInDocument.toString() 
		+ " - " + this.lineContainingMatch;
	}
}


function Session(project)
{
	this.project = project;
}
{
	// dom

	Session.prototype.domUpdate = function()
	{
		this.project.domUpdate();
	}
}

function TarFile(fileName, entries)
{
	this.fileName = fileName;
	this.entries = entries;
}
{
	// constants

	TarFile.ChunkSize = 512;

	// static methods

	TarFile.fromBytes = function(fileName, bytes)
	{
		var reader = new ByteStream(bytes);

		var entries = [];

		var chunkSize = TarFile.ChunkSize;

		var numberOfConsecutiveZeroChunks = 0;

		while (reader.hasMoreBytes() == true)
		{
			var chunkAsBytes = reader.readBytes(chunkSize);

			var areAllBytesInChunkZeroes = true;

			for (var b = 0; b < chunkAsBytes.length; b++)
			{
				if (chunkAsBytes[b] != 0)
				{
					areAllBytesInChunkZeroes = false;
					break;
				}
			}

			if (areAllBytesInChunkZeroes == true)
			{
				numberOfConsecutiveZeroChunks++;

				if (numberOfConsecutiveZeroChunks == 2)
				{
					break;
				}
			}
			else
			{
				numberOfConsecutiveZeroChunks = 0;

				var entry = TarFileEntry.fromBytes(chunkAsBytes, reader);

				entries.push(entry);
			}
		}

		var returnValue = new TarFile
		(
			fileName,
			entries
		);

		return returnValue;
	}
	
	TarFile.new = function(fileName)
	{
		return new TarFile
		(
			fileName,
			[] // entries
		);
	}

	// instance methods
	
	TarFile.prototype.downloadAs = function(fileNameToSaveAs)
	{	
		FileHelper.saveBytesAsFile
		(
			this.toBytes(),
			fileNameToSaveAs
		)
	}	
	
	TarFile.prototype.entriesForDirectories = function()
	{
		var returnValues = [];
		
		for (var i = 0; i < this.entries.length; i++)
		{
			var entry = this.entries[i];
			if (entry.header.typeFlag.name == "Directory")
			{
				returnValues.push(entry);
			}
		}
		
		return returnValues;
	}
	
	TarFile.prototype.toBytes = function()
	{
		var fileAsBytes = [];		

		// hack - For easier debugging.
		var entriesAsByteArrays = [];
		
		for (var i = 0; i < this.entries.length; i++)
		{
			var entry = this.entries[i];
			var entryAsBytes = entry.toBytes();
			entriesAsByteArrays.push(entryAsBytes);
		}		
		
		for (var i = 0; i < entriesAsByteArrays.length; i++)
		{
			var entryAsBytes = entriesAsByteArrays[i];
			fileAsBytes = fileAsBytes.concat(entryAsBytes);
		}
		
		var chunkSize = TarFile.ChunkSize;
		
		var numberOfZeroChunksToWrite = 2;
		
		for (var i = 0; i < numberOfZeroChunksToWrite; i++)
		{
			for (var b = 0; b < chunkSize; b++)
			{
				fileAsBytes.push(0);
			}
		}

		return fileAsBytes;
	}
	
	// strings

	TarFile.prototype.toString = function()
	{
		var newline = "\n";

		var returnValue = "[TarFile]" + newline;

		for (var i = 0; i < this.entries.length; i++)
		{
			var entry = this.entries[i];
			var entryAsString = entry.toString();
			returnValue += entryAsString;
		}

		returnValue += "[/TarFile]" + newline;

		return returnValue;
	}
}

function TarFileEntry(header, dataAsBytes)
{
	this.header = header;
	this.dataAsBytes = dataAsBytes;
}
{
	// methods
	
	// static methods
	
	TarFileEntry.directoryNew = function(directoryName)
	{
		var header = new TarFileEntryHeader.directoryNew(directoryName);
		
		var entry = new TarFileEntry(header, []);
		
		return entry;
	}
	
	TarFileEntry.fileNew = function(fileName, fileContentsAsBytes)
	{
		var header = new TarFileEntryHeader.fileNew(fileName, fileContentsAsBytes);
		
		var entry = new TarFileEntry(header, fileContentsAsBytes);
		
		return entry;
	}
	
	TarFileEntry.fromBytes = function(chunkAsBytes, reader)
	{
		var chunkSize = TarFile.ChunkSize;
	
		var header = TarFileEntryHeader.fromBytes
		(
			chunkAsBytes
		);
	
		var sizeOfDataEntryInBytesUnpadded = header.fileSizeInBytes;	

		var numberOfChunksOccupiedByDataEntry = Math.ceil
		(
			sizeOfDataEntryInBytesUnpadded / chunkSize
		)
	
		var sizeOfDataEntryInBytesPadded = 
			numberOfChunksOccupiedByDataEntry
			* chunkSize;
	
		var dataAsBytes = reader.readBytes
		(
			sizeOfDataEntryInBytesPadded
		).slice
		(
			0, sizeOfDataEntryInBytesUnpadded
		);
	
		var entry = new TarFileEntry(header, dataAsBytes);
		
		return entry;
	}
	
	TarFileEntry.manyFromByteArrays = function
	(
		fileNamePrefix, fileNameSuffix, entriesAsByteArrays
	)
	{
		var returnValues = [];
		
		for (var i = 0; i < entriesAsByteArrays.length; i++)
		{
			var entryAsBytes = entriesAsByteArrays[i];
			var entry = TarFileEntry.fileNew
			(		
				fileNamePrefix + i + fileNameSuffix,
				entryAsBytes
			);
			
			returnValues.push(entry);
		}
		
		return returnValues;
	}
	
	// instance methods

	TarFileEntry.prototype.download = function(event)
	{
		FileHelper.saveBytesAsFile
		(
			this.dataAsBytes,
			this.header.fileName
		);
	}
	
	TarFileEntry.prototype.remove = function(event)
	{
		alert("Not yet implemented!"); // todo
	}
	
	TarFileEntry.prototype.toBytes = function()
	{
		var entryAsBytes = [];
	
		var chunkSize = TarFile.ChunkSize;
	
		var headerAsBytes = this.header.toBytes();
		entryAsBytes = entryAsBytes.concat(headerAsBytes);
		
		entryAsBytes = entryAsBytes.concat(this.dataAsBytes);

		var sizeOfDataEntryInBytesUnpadded = this.header.fileSizeInBytes;	

		var numberOfChunksOccupiedByDataEntry = Math.ceil
		(
			sizeOfDataEntryInBytesUnpadded / chunkSize
		)
	
		var sizeOfDataEntryInBytesPadded = 
			numberOfChunksOccupiedByDataEntry
			* chunkSize;
			
		var numberOfBytesOfPadding = 
			sizeOfDataEntryInBytesPadded - sizeOfDataEntryInBytesUnpadded;
	
		for (var i = 0; i < numberOfBytesOfPadding; i++)
		{
			entryAsBytes.push(0);
		}
		
		return entryAsBytes;
	}	
		
	// strings
	
	TarFileEntry.prototype.toString = function()
	{
		var newline = "\n";

		headerAsString = this.header.toString();

		var dataAsHexadecimalString = ByteHelper.bytesToStringHexadecimal
		(
			this.dataAsBytes
		);

		var returnValue = 
			"[TarFileEntry]" + newline
			+ headerAsString
			+ "[Data]"
			+ dataAsHexadecimalString
			+ "[/Data]" + newline
			+ "[/TarFileEntry]"
			+ newline;

		return returnValue
	}
	
}

function TarFileEntryHeader
(
	fileName,
	fileMode,
	userIDOfOwner,
	userIDOfGroup,
	fileSizeInBytes,
	timeModifiedInUnixFormat,
	checksum,
	typeFlag,
	nameOfLinkedFile,
	uStarIndicator,
	uStarVersion,
	userNameOfOwner,
	groupNameOfOwner,
	deviceNumberMajor,
	deviceNumberMinor,
	filenamePrefix
)
{
	this.fileName = fileName;
	this.fileMode = fileMode;
	this.userIDOfOwner = userIDOfOwner;
	this.userIDOfGroup = userIDOfGroup;
	this.fileSizeInBytes = fileSizeInBytes;
	this.timeModifiedInUnixFormat = timeModifiedInUnixFormat;
	this.checksum = checksum;
	this.typeFlag = typeFlag;
	this.nameOfLinkedFile = nameOfLinkedFile;
	this.uStarIndicator = uStarIndicator;
	this.uStarVersion = uStarVersion;
	this.userNameOfOwner = userNameOfOwner;
	this.groupNameOfOwner = groupNameOfOwner;
	this.deviceNumberMajor = deviceNumberMajor;
	this.deviceNumberMinor = deviceNumberMinor;
	this.filenamePrefix = filenamePrefix;
}
{
	TarFileEntryHeader.SizeInBytes = 500;

	// static methods
	
	TarFileEntryHeader.default = function()
	{
		var now = new Date();
		var unixEpoch = new Date(1970, 1, 1);
		var millisecondsSinceUnixEpoch = now - unixEpoch;
		var secondsSinceUnixEpoch = Math.floor
		(
			millisecondsSinceUnixEpoch / 1000
		);
		var secondsSinceUnixEpochAsStringOctal = 
			secondsSinceUnixEpoch.toString(8).padRight(12, " ");
		var timeModifiedInUnixFormat = []; 
		for (var i = 0; i < secondsSinceUnixEpochAsStringOctal.length; i++)
		{
			var digitAsASCIICode = 
				secondsSinceUnixEpochAsStringOctal.charCodeAt(i);
			timeModifiedInUnixFormat.push(digitAsASCIICode);
		}

		var returnValue = new TarFileEntryHeader
		(
			"".padRight(100, "\0"), // fileName
			"100777 \0", // fileMode
			"0 \0".padLeft(8, " "), // userIDOfOwner
			"0 \0".padLeft(8, " "), // userIDOfGroup
			0, // fileSizeInBytes
			timeModifiedInUnixFormat,
			0, // checksum
			TarFileTypeFlag.Instances.Normal,		
			"".padRight(100, "\0"), // nameOfLinkedFile,
			"".padRight(6, "\0"), // uStarIndicator,
			"".padRight(2, "\0"), // uStarVersion,
			"".padRight(32, "\0"), // userNameOfOwner,
			"".padRight(32, "\0"), // groupNameOfOwner,
			"".padRight(8, "\0"), // deviceNumberMajor,
			"".padRight(8, "\0"), // deviceNumberMinor,
			"".padRight(155, "\0") // filenamePrefix	
		);		
		
		return returnValue;
	}
	
	TarFileEntryHeader.directoryNew = function(directoryName)
	{
		var header = TarFileEntryHeader.default();
		header.fileName = directoryName;
		header.typeFlag = TarFileTypeFlag.Instances.Directory;
		header.fileSizeInBytes = 0;
		header.checksumCalculate();
		
		return header;
	}
	
	TarFileEntryHeader.fileNew = function(fileName, fileContentsAsBytes)
	{
		var header = TarFileEntryHeader.default();
		header.fileName = fileName;
		header.typeFlag = TarFileTypeFlag.Instances.Normal;
		header.fileSizeInBytes = fileContentsAsBytes.length;
		header.checksumCalculate();
		
		return header;
	}

	TarFileEntryHeader.fromBytes = function(bytes)
	{
		var reader = new ByteStream(bytes);

		var fileName = reader.readString(100).trim();
		var fileMode = reader.readString(8);
		var userIDOfOwner = reader.readString(8);
		var userIDOfGroup = reader.readString(8);
		var fileSizeInBytesAsStringOctal = reader.readString(12);
		var timeModifiedInUnixFormat = reader.readBytes(12);
		var checksumAsStringOctal = reader.readString(8);
		var typeFlagValue = reader.readString(1);
		var nameOfLinkedFile = reader.readString(100);
		var uStarIndicator = reader.readString(6);
		var uStarVersion = reader.readString(2);
		var userNameOfOwner = reader.readString(32);
		var groupNameOfOwner = reader.readString(32);
		var deviceNumberMajor = reader.readString(8);
		var deviceNumberMinor = reader.readString(8);
		var filenamePrefix = reader.readString(155);
		var reserved = reader.readBytes(12);

		var fileSizeInBytes = parseInt
		(
			fileSizeInBytesAsStringOctal.trim(), 8
		);
		
		var checksum = parseInt
		(
			checksumAsStringOctal, 8
		);		
		
		var typeFlags = TarFileTypeFlag.Instances._All;
		var typeFlagID = "_" + typeFlagValue;
		var typeFlag = typeFlags[typeFlagID];

		var returnValue = new TarFileEntryHeader
		(
			fileName,
			fileMode,
			userIDOfOwner,
			userIDOfGroup,
			fileSizeInBytes,
			timeModifiedInUnixFormat,
			checksum,
			typeFlag,
			nameOfLinkedFile,
			uStarIndicator,
			uStarVersion,
			userNameOfOwner,
			groupNameOfOwner,
			deviceNumberMajor,
			deviceNumberMinor,
			filenamePrefix
		);

		return returnValue;
	}

	// instance methods
	
	TarFileEntryHeader.prototype.checksumCalculate = function()
	{	
		var thisAsBytes = this.toBytes();
	
		// The checksum is the sum of all bytes in the header,
		// except we obviously can't include the checksum itself.
		// So it's assumed that all 8 of checksum's bytes are spaces (0x20=32).
		// So we need to set this manually.
						
		var offsetOfChecksumInBytes = 148;
		var numberOfBytesInChecksum = 8;
		var presumedValueOfEachChecksumByte = " ".charCodeAt(0);
		for (var i = 0; i < numberOfBytesInChecksum; i++)
		{
			var offsetOfByte = offsetOfChecksumInBytes + i;
			thisAsBytes[offsetOfByte] = presumedValueOfEachChecksumByte;
		}
		
		var checksumSoFar = 0;

		for (var i = 0; i < thisAsBytes.length; i++)
		{
			var byteToAdd = thisAsBytes[i];
			checksumSoFar += byteToAdd;
		}		

		this.checksum = checksumSoFar;
		
		return this.checksum;
	}
	
	TarFileEntryHeader.prototype.toBytes = function()
	{
		var headerAsBytes = [];
		var writer = new ByteStream(headerAsBytes);
		
		var fileSizeInBytesAsStringOctal = (this.fileSizeInBytes.toString(8) + " ").padLeft(12, " ")
		var checksumAsStringOctal = (this.checksum.toString(8) + " \0").padLeft(8, " ");

		writer.writeString(this.fileName, 100);
		writer.writeString(this.fileMode, 8);
		writer.writeString(this.userIDOfOwner, 8);
		writer.writeString(this.userIDOfGroup, 8);
		writer.writeString(fileSizeInBytesAsStringOctal, 12);
		writer.writeBytes(this.timeModifiedInUnixFormat);
		writer.writeString(checksumAsStringOctal, 8);
		writer.writeString(this.typeFlag.value, 1);		
		writer.writeString(this.nameOfLinkedFile, 100);
		writer.writeString(this.uStarIndicator, 6);
		writer.writeString(this.uStarVersion, 2);
		writer.writeString(this.userNameOfOwner, 32);
		writer.writeString(this.groupNameOfOwner, 32);
		writer.writeString(this.deviceNumberMajor, 8);
		writer.writeString(this.deviceNumberMinor, 8);
		writer.writeString(this.filenamePrefix, 155);
		writer.writeString("".padRight(12, "\0")); // reserved

		return headerAsBytes;
	}		
		
	// strings

	TarFileEntryHeader.prototype.toString = function()
	{		
		var newline = "\n";
	
		var returnValue = 
			"[TarFileEntryHeader "
			+ "fileName='" + this.fileName + "' "
			+ "typeFlag='" + (this.typeFlag == null ? "err" : this.typeFlag.name) + "' "
			+ "fileSizeInBytes='" + this.fileSizeInBytes + "' "
			+ "]"
			+ newline;

		return returnValue;
	}
}	

function TarFileTypeFlag(value, name)
{
	this.value = value;
	this.id = "_" + this.value;
	this.name = name;
}
{
	TarFileTypeFlag.Instances = new TarFileTypeFlag_Instances();

	function TarFileTypeFlag_Instances()
	{
		this.Normal 		= new TarFileTypeFlag("0", "Normal");
		this.HardLink 		= new TarFileTypeFlag("1", "Hard Link");
		this.SymbolicLink 	= new TarFileTypeFlag("2", "Symbolic Link");
		this.CharacterSpecial 	= new TarFileTypeFlag("3", "Character Special");
		this.BlockSpecial 	= new TarFileTypeFlag("4", "Block Special");
		this.Directory		= new TarFileTypeFlag("5", "Directory");
		this.FIFO		= new TarFileTypeFlag("6", "FIFO");
		this.ContiguousFile 	= new TarFileTypeFlag("7", "Contiguous File");

		// Additional types not implemented:
		// 'g' - global extended header with meta data (POSIX.1-2001)
		// 'x' - extended header with meta data for the next file in the archive (POSIX.1-2001)
		// 'A'–'Z' - Vendor specific extensions (POSIX.1-1988)
		// [other values] - reserved for future standardization

		this._All = 
		[
			this.Normal,
			this.HardLink,
			this.SymbolicLink,
			this.CharacterSpecial,
			this.BlockSpecial,
			this.Directory,
			this.FIFO,
			this.ContiguousFile,
		];

		for (var i = 0; i < this._All.length; i++)
		{
			var item = this._All[i];
			this._All[item.id] = item;
		}
	}
}
	
// run

main();

</script>

</body>
</html>

Advertisements
This entry was posted in Uncategorized and tagged , , . Bookmark the permalink.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s