Index: /main/trunk/model-interfaces-dev/atea/macron-restoration/src/App.vue
===================================================================
--- /main/trunk/model-interfaces-dev/atea/macron-restoration/src/App.vue	(revision 35722)
+++ /main/trunk/model-interfaces-dev/atea/macron-restoration/src/App.vue	(revision 35723)
@@ -12,14 +12,14 @@
 
     <div class="paper content">
-        <DirectInput />
+        <tab-bar :tabs="tabs" v-model="selectedTab" />
+        <hr class="divider" />
+
+        <div class="inner-content" v-if="selectedTab === 'direct'">
+            <direct-input />
+        </div>
+        <div class="inner-content" v-if="selectedTab === 'file'">
+            <file-input />
+        </div>
     </div>
-
-    <!-- <ul id="transcription-list" class="list-view content">
-        <transition-group name="transcription-list">
-            <li class="list-item transcription-list-item" v-for="[id, transcription] in transcriptions" :key="id">
-                <TranscriptionItem :transcription="transcription" />
-            </li>
-        </transition-group>
-    </ul> -->
 </template>
 
@@ -55,5 +55,10 @@
 
 .content {
+    padding: 0;
     margin: 1em;
+}
+
+.inner-content {
+    padding: 1em;
 }
 
@@ -79,13 +84,30 @@
 import { mapState } from "vuex";
 import DirectInput from "./components/DirectInput.vue"
+import FileInput from "./components/FileInput.vue"
+import TabBar from "./components/TabBar.vue"
 
 export default {
     name: "App",
     components: {
-        DirectInput
+        DirectInput,
+        FileInput,
+        TabBar
     },
-    computed: mapState({
-        translations: state => state.translations
-    })
+    data() {
+        return {
+            selectedTab: "direct"
+        }
+    },
+    computed: {
+        tabs() {
+            return [
+                { label: this.translations.get("DirectInput"), value: "direct" },
+                { label: this.translations.get("FileInput"), value: "file" }
+            ]
+        },
+        ...mapState({
+            translations: state => state.translations
+        })
+    }
 }
 </script>
Index: in/trunk/model-interfaces-dev/atea/macron-restoration/src/components/AudioUpload.vue
===================================================================
--- /main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/AudioUpload.vue	(revision 35722)
+++ 	(revision )
@@ -1,216 +1,0 @@
-<template>
-    <!-- Contains the file input, transcribe button and transcription progress indicator -->
-    <div class="audio-file-picker">
-        <div class="input-bar">
-            <div class="text-input-sl" style="cursor: pointer;" @click="openFilePicker">
-                <span class="text-placeholder material-icons" v-if="!anyFiles">&#xe226;</span> <!-- attach_file -->
-                <span class="text-placeholder" v-if="!anyFiles">{{ translations.get("AudioUpload_SelectFileText") }}</span>
-                <span v-if="anyFiles">{{ getFileNameList }}</span>
-            </div>
-
-            <input ref="audioFileInput" type="file" @input="onFilesChanged"
-                accept="audio/wav" multiple :disabled="isTranscribing" />
-
-            <button class="btn-primary" type="submit" :disabled="!anyFiles || isTranscribing" @click="doTranscription">
-                <span class="material-icons">&#xEA3E;</span> <!-- history_edu -->
-                <span>{{ translations.get("AudioUpload_TranscribeFiles") }}</span>
-            </button>
-        </div>
-
-        <div v-if="isTranscribing" class="progress-bar-container">
-            <div class="progress-bar-value progress-bar-indeterminate" />
-        </div>
-
-        <ul class="list-view" v-if="failures.size > 0">
-            <li v-for="[id, failure] in failures" :key="failure.id" class="list-view__item transcription-error-container">
-                <div>
-                    {{ translations.get("AudioUpload_TranscriptionFailed_Message") }} <i v-if="failure.fileName">{{ failure.fileName }}</i><br />
-                    <span v-if="failure.message">{{ translations.get("AudioUpload_TranscriptionFailed_Reason") }}: {{ failure.message }}</span>
-                </div>
-
-                <button class="btn-fab theme-flat" @click="dismissFailure(id)">
-                    <span class="material-icons">&#xE14C;</span> <!-- clear -->
-                </button>
-            </li>
-        </ul>
-    </div>
-</template>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped lang="scss">
-.audio-file-picker {
-    display: flex;
-    gap: 0.5em;
-    flex-direction: column;
-
-    input {
-        display: none;
-    }
-
-    .text-container {
-        cursor: pointer;
-    }
-}
-
-.input-bar {
-    display: grid;
-    align-items: stretch;
-    gap: 0.5em;
-    grid-template-columns: 1fr auto;
-}
-
-.transcription-error-container {
-    display: grid;
-    gap: 0.5em;
-    grid-template-columns: 1fr auto;
-    align-items: center;
-
-    border-left: 3px solid red;
-}
-</style>
-
-<script>
-import { mapState } from "vuex";
-import TranscribeService from "../js/MacronRestorationModule";
-import Util, { log } from "../js/Util";
-import { TranscriptionViewModel } from "../main"
-
-class TranscriptionViewFailure {
-    /**
-     * @param {String} fileName The name of the file for which this failure occured.
-     * @param {String} message The reason for this failure.
-     */
-    constructor(fileName, message) {
-        /** @type {String} The UUID of this failure. */
-        this.id = Util.generateUuid();
-
-        /** @type {String | null} The name of the file for which this failure occured. */
-        this.fileName = fileName;
-
-        /** @type {String} The reason for the failure. */
-        this.message = message;
-    }
-}
-
-const transcribeService = new TranscribeService();
-
-export default {
-    name: "AudioUpload",
-    data() {
-        return {
-            /** @type {File[]} */
-            files: [],
-            /** @type {Map<String, TranscriptionViewFailure>} */
-            failures: new Map(),
-            canTranscribe: false,
-            isTranscribing: false
-        }
-    },
-    computed: mapState({
-        translations: state => state.translations,
-        anyFiles() {
-            return this.files?.length > 0;
-        },
-        getFileNameList() {
-            let fileNameList = "";
-
-            for (const file of this.files) {
-                fileNameList += file.name + ", ";
-            }
-
-            return fileNameList.slice(0, fileNameList.length - 2);
-        }
-    }),
-    emits: [ "newTranscription" ],
-    methods: {
-        openFilePicker() {
-            this.$refs.audioFileInput.click();
-        },
-        onFilesChanged() {
-            this.files = [];
-            const files = this.$refs.audioFileInput.files;
-
-            if (files?.length !== undefined && files?.length > 0) {
-                for (const file of files) {
-                    this.files.push(file);
-                }
-            }
-        },
-        async doTranscription() {
-            this.isTranscribing = true;
-
-            await this.getTranscriptions(this.files);
-
-            this.files = []; // Clear the file list, as there is no reason the user would want to transcribe the same file multiple times over
-            this.isTranscribing = false;
-            // TODO: Push files to queue. If currently transcribing, good to go; it'll pull off it.
-            // Else call getTranscriptions();
-            // Then, we don't have to worry about preventing the user from transcribing multiple items.
-        },
-        /**
-         * Dismisses a failure
-         * @param {String} id The UUID of the failure.
-         */
-        dismissFailure(id) {
-            this.failures.delete(id);
-        },
-        /**
-         * Gets the transcription of each submitted audio file.
-         * @param {File[]} files The files to transcribe.
-         */
-        async getTranscriptions(files) {
-            const validFiles = [];
-
-            // Skip any files that have already been transcribed
-            for (const file of files) {
-                if (this.$store.getters.hasTranscriptionOfFile(file)) {
-                    this.createFailure(file.name, this.translations.get("AudioUpload_AlreadyTranscribed"));
-                }
-                else {
-                    validFiles.push(file);
-                }
-            }
-
-            if (validFiles.length === 0) {
-                return;
-            }
-
-            try {
-                for await (const batch of transcribeService.batchTranscribeFiles(validFiles)) {
-                    for (const t of batch) {
-                        if (!t.success) {
-                            this.createFailure(t.file_name, t.log);
-                            continue;
-                        }
-
-                        const f = files.find(f => f.name === t.file_name);
-                        if (f === undefined) {
-                            this.createFailure(t.file_name, this.translations.get("ErrorTryAgain"));
-                            continue;
-                        }
-
-                        const tvm = new TranscriptionViewModel(t, f);
-
-                        try {
-                            this.$store.commit("rawTranscriptionAdd", tvm);
-                        }
-                        catch (TranscriptionExistsError) {
-                            this.createFailure(t.file_name, this.translations.get("AudioUpload_AlreadyTranscribed"));
-                        }
-                    }
-                }
-            }
-            catch (e) {
-                log("Failed to transcribe files", "error");
-                log(e);
-
-                this.createFailure(e.fileName, e.message)
-            }
-        },
-        createFailure(fileName, reason) {
-            const failure = new TranscriptionViewFailure(fileName, reason);
-            this.failures.set(failure.id, failure);
-        }
-    }
-}
-</script>
Index: /main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/DirectInput.vue
===================================================================
--- /main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/DirectInput.vue	(revision 35722)
+++ /main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/DirectInput.vue	(revision 35723)
@@ -3,9 +3,24 @@
     <textarea class="text-input input-area" @input="onTextInput"
         v-model="input" :placeholder="translations.get('DirectInput_InputPlaceholder')" />
+
     <div class="text-container">
-        {{ output }}
+        <span v-if="errorState" class="material-icons mdi-m error-text">error</span>
+        <span v-if="errorState" class="error-text">Something went wrong! Please try modifying your input text.</span>
+
+        <!-- eslint-disable-next-line vue/require-v-for-key -->
+        <span v-for="word in validRestored" :class="{ 'highlight': word.macronised && this.showMacronisedWords }">{{ word.w }}&nbsp;</span>
     </div>
 
-    <button class="btn-primary right-column" :disabled="!canDownload" @click="downloadAsText">Download</button>
+    <div class="flex">
+        <input type="checkbox" id="i-preserve-existing-macrons" v-model="preserveExistingMacrons" />
+        <label for="i-preserve-existing-macrons">{{ translations.get('DirectInput_PreserveExistingMacrons') }}</label>
+    </div>
+
+    <div class="flex">
+        <input type="checkbox" id="i-show-macronised-words" v-model="showMacronisedWords" />
+        <label for="i-show-macronised-words">{{ translations.get('DirectInput_ShowMacronisedWords') }}</label>
+    </div>
+
+    <button class="btn-primary right-column" :disabled="!canDownload" @click="downloadAsText">{{ translations.get('DirectInput_Download') }}</button>
 </div>
 </template>
@@ -15,5 +30,5 @@
     display: grid;
     grid-template-columns: 1fr 1fr;
-    grid-template-rows: auto auto;
+    grid-template-rows: 1fr auto auto;
     gap: 1em;
 }
@@ -25,6 +40,20 @@
 }
 
+.flex {
+    display: flex;
+    align-items: center;
+    gap: 0.5em;
+}
+
 .right-column {
     grid-column: 2;
+}
+
+.highlight {
+    background-color: var(--highlighted-word-bg);
+}
+
+.error-text {
+    color: rgb(185, 3, 3);
 }
 </style>
@@ -33,4 +62,5 @@
 import { mapState } from "vuex";
 import { saveAs } from "file-saver"
+import { log } from "../js/Util"
 import MacronRestorationModule from "../js/MacronRestorationModule"
 
@@ -41,13 +71,33 @@
     data() {
         return {
-            input: "",
-            output: "",
+            preserveExistingMacrons: true,
+            showMacronisedWords: false,
             transcribeTimeout: null,
-            waitingOnTranscribe: false
+            waitingOnTranscribe: false,
+            errorState: false
         }
     },
     computed: {
         canDownload() {
-            return this.output.length > 0 && !this.waitingOnTranscribe;
+            return this.restored !== null && !this.waitingOnTranscribe;
+        },
+        input: {
+            get() {
+                return this.$store.state.directInput;
+            },
+            set(newValue) {
+                this.$store.commit("setDirectInput", newValue);
+            }
+        },
+        restored: {
+            get() {
+                return this.$store.state.directOutput;
+            },
+            set(newValue) {
+                this.$store.commit("setDirectOutput", newValue);
+            }
+        },
+        validRestored() {
+            return this.restored.filter(w => w.w !== "");
         },
         ...mapState({
@@ -75,12 +125,18 @@
         },
         async updateMacronisedText() {
-            // TODO: Handle error
-            const response = await macroniser.directMacronisation(this.input);
-            this.output = response.fragment2;
-            this.waitingOnTranscribe = false;
+            this.errorState = false;
+
+            try {
+                this.restored = await macroniser.directMacronisation(this.input, this.preserveExistingMacrons);
+                this.waitingOnTranscribe = false;
+            }
+            catch (ex) {
+                this.errorState = true;
+                log(ex, "warn");
+            }
         },
         downloadAsText() {
-            const blob = new Blob([ this.output ], { type: "text/plain;charset=utf-8" });
-            saveAs(blob, "output.txt");
+            const blob = new Blob([ this.restored ], { type: "text/plain;charset=utf-8" });
+            saveAs(blob, "restored.txt");
         }
     }
Index: /main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/FileInput.vue
===================================================================
--- /main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/FileInput.vue	(revision 35722)
+++ /main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/FileInput.vue	(revision 35723)
@@ -1,100 +1,58 @@
 <template>
-    <div class="card">
-        <!-- Header containing info and actions for the transcription -->
-        <div class="transcription__header">
-            <button class="btn-fab" v-on:click="toggleAudio" type="button" :title="translations.get('TranscriptionItem_PlayButtonTooltip')">
-                <span class="material-icons mdi-l play-button" v-if="!isPlaying">play_arrow</span>
-                <span class="material-icons mdi-l play-button" v-if="isPlaying">pause</span>
-            </button>
+<file-upload />
 
-            <span>{{ translations.get("TranscriptionItem_FileName") }}: {{ transcription.fileName }}</span>
+<div class="file-info-list">
+    <div v-for="(fileInfo, index) in fileInfos" :key="fileInfo.id">
+        <div class="info-container">
+            <span class="material-icons mdi-override" v-if="fileInfo.fileType === '.txt'">description</span>
+            <img class="icon-l" src="resources/word_icon.ico" v-if="fileInfo.fileType === '.docx'" />
 
-            <div style="position: relative;">
-                <button class="btn-primary" @mouseover="showDownloadOptions = true" @mouseout="showDownloadOptions = false" type="button">
-                    <span class="material-icons">download</span>
-                    <span>{{ translations.get("TranscriptionItem_Download") }}</span>
-                </button>
+            {{ fileInfo.fileName }}
 
-                <div class="download-popup card" :class="{ 'download-popup-show': showDownloadOptions }"
-                     @mouseover="showDownloadOptions = true" @mouseout="showDownloadOptions = false">
-                    <button @click="downloadAsText" type="button" class="btn-primary theme-flat">
-                        <span class="material-icons">text_snippet</span>
-                        <span>{{ translations.get("TranscriptionItem_DownloadAsText") }}</span>
-                    </button>
-
-                    <button @click="downloadAsJson" type="button" class="btn-primary theme-flat">
-                        <span class="material-icons">integration_instructions</span>
-                        <span>{{ translations.get("TranscriptionItem_DownloadAsJson") }}</span>
-                    </button>
-
-                    <button @click="downloadAsWebvtt" type="button" class="btn-primary theme-flat">
-                        <span class="material-icons">subtitles</span>
-                        <span>{{ translations.get("TranscriptionItem_DownloadAsWebvtt") }}</span>
-                    </button>
-                </div>
-            </div>
-
-            <button class="btn-primary theme-error" @click="remove" type="button">
-                <span class="material-icons">delete</span>
-                <span>{{ translations.get("TranscriptionItem_Remove") }}</span>
+            <button @click="download(fileInfo)" type="button" class="btn-primary theme-flat">
+                <span class="material-icons">download</span>
+                <span>{{ translations.get("FileInput_Download") }}</span>
             </button>
         </div>
 
-        <div class="editor-controls">
-            <audio-time-bar v-model.number="currentPlaybackTime" :audio-length="audioLength" :isDisabled="playbackState.id != transcription.id" />
-        </div>
+        <hr class="divider-s" v-if="index !== fileInfos.length - 1" />
+    </div>
+</div>
 
-        <hr />
+<form ref="formDownload" method="post" target="_blank">
+    <input ref="inputFilePath" type="hidden" name="filepath" />
+    <input ref="inputFileName" type="hidden" name="filename" />
+</form>
 
-        <div class="editor-controls">
-            <TranscriptionItemEditor ref="editor" :transcription="transcription" style="margin-bottom: 1em" :enableEditing="enableEditing" />
-            <toggle-button v-model="enableEditing" :title="translations.get('TranscriptionItemEditor_ToggleEditTooltip')">
-                <span class="material-icons">edit</span>
-            </toggle-button>
-        </div>
-    </div>
 </template>
 
 <style scoped lang="scss">
-.transcription__header {
+.info-container {
     display: grid;
-    gap: 0.5em 0.5em;
-    grid-template-columns: auto 1fr auto auto;
+    grid-template-columns: auto 1fr auto;
     align-items: center;
+
+    gap: 0.5em;
+    padding: 0.5em;
+    transition-duration: var(--transition-duration);
+
+    &:hover {
+        background-color: #DDD;
+    }
 }
 
-.download-popup {
-    display: flex;
-    flex-direction: column;
-    align-items: stretch;
-
-    position: absolute;
-    top: 97%;
-    z-index: 2;
-
-    padding: 2px;
-    margin: 0;
-    width: 14em;
-
-    transition-duration: var(--transition-duration);
-    visibility: hidden;
-    opacity: 0;
+.file-info-list {
+    border: 1px solid #BBB;
+    margin-top: 1em;
 }
 
-.download-popup-show {
-    visibility: visible;
-    opacity: 1;
+.mdi-override {
+    @extend .mdi-l;
+
+    color: var(--bg-color);
 }
 
-.editor-controls {
-    display: grid;
-    align-items: flex-start;
-    grid-template-columns: 1fr auto;
-    margin: 0.5em 0 0.3em 0;
-    gap: 1em;
-}
-
-.rotate-180 {
-    transform: rotate(180deg);
+.icon-l {
+    height: 36px;
 }
 </style>
@@ -102,108 +60,30 @@
 <script>
 import { mapState } from "vuex";
-import { saveAs } from "file-saver"
-import { TranscriptionViewModel } from "../main";
-import AudioPlayback from "../js/AudioPlaybackModule"
-import Util from "../js/Util"
-import AudioTimeBar from "./AudioTimeBar.vue"
-import TranscriptionItemEditor from "./TranscriptionItemEditor.vue"
+import MacronRestorationService from "../js/MacronRestorationModule"
+import FileUpload from "./FileUpload.vue"
+
+const macronRestorer = new MacronRestorationService();
 
 export default {
-    name: "TranscriptionItem",
+    name: "FileInput",
     components: {
-        AudioTimeBar,
-        TranscriptionItemEditor
-    },
-    props: {
-        transcription: TranscriptionViewModel
-    },
-    data() {
-        return {
-            enableEditing: false,
-            showDownloadOptions: false
-        }
+        FileUpload
     },
     computed: {
-        currentPlaybackTime: {
-            get() {
-                return this.$store.getters.transcriptionPlaybackTime(this.transcription.id);
-            },
-            set(value) {
-                this.$store.commit("playbackStateSetTime", { id: this.transcription.id, time: value });
-            }
-        },
-        audioLength() {
-            return this.$store.getters.transcriptionPlaybackLength(this.transcription.id);
-        },
-        isPlaying() {
-            return this.playbackState.isPlaying && this.playbackState.id === this.transcription.id;
-        },
         ...mapState({
             translations: state => state.translations,
-            playbackState: state => state.playbackState
+            fileInfos: state => state.macronisedFileInfos
         })
     },
     methods: {
-        async toggleAudio() {
-            this.isPlaying ? AudioPlayback.pause() : await AudioPlayback.play(this.transcription.id, -1);
-        },
-        remove() {
-            this.$store.commit("rawTranscriptionRemove", this.transcription.id);
-        },
-        downloadAsText() {
-            const fileName = buildDownloadableFileName(this.transcription.fileName, "txt");
-
-            const blob = new Blob([ this.$refs.editor.words.map(w => w.word).join(" ") ], { type: "text/plain;charset=utf-8" });
-            saveAs(blob, fileName);
-        },
-        downloadAsJson() {
-            const fileName = buildDownloadableFileName(this.transcription.fileName, "json");
-            const toDownload = (({ fileName, transcription }) => ({ fileName, transcription }))(this.transcription);
-            toDownload.words = this.$refs.editor.words.map(w => (({ word, startTime, endTime }) => ({ word, startTime, endTime }))(w));
-
-            const blob = new Blob([ JSON.stringify(toDownload, null, 4) ], { type: "application/json;charset=utf-8" });
-            saveAs(blob, fileName);
-        },
-        downloadAsWebvtt() {
-            const fileName = buildDownloadableFileName(this.transcription.fileName, "vtt");
-            const toDownload = buildWebvttFileContents(this.transcription, this.$refs.editor);
-
-            const blob = new Blob([ toDownload ], { type: "text/vtt;charset=utf-8" });
-            saveAs(blob, fileName);
+        download(fileInfo) {
+            this.$refs.inputFilePath.value = fileInfo.filePath;
+            this.$refs.inputFileName.value = fileInfo.fileName;
+            this.$refs.formDownload.submit();
         }
+    },
+    mounted() {
+        this.$refs.formDownload.setAttribute("action", macronRestorer.queryUrl + "Download");
     }
 }
-
-/**
- * Builds a file name for a download.
- * @param {String} transcriptionFileName The name of the transcription that will be downloaded.
- * @param {String} extension The file extension of the download. Do not include a period.
- * @returns {String} The file name.
- */
-function buildDownloadableFileName(transcriptionFileName, extension) {
-    const extensionIndex = transcriptionFileName.lastIndexOf(".");
-    let fileName = transcriptionFileName.slice(0, extensionIndex);
-    fileName += "_transcription." + extension;
-
-    return fileName;
-}
-
-/**
- * Builds a WebVTT file of the given transcription
- * @param {TranscriptionViewModel} transcription The transcription.
- * @returns {String} The WebVTT content.
- */
-function buildWebvttFileContents(transcription, editor) {
-    let contents = "WEBVTT Transcription of " + transcription.fileName + "\n\n";
-
-    for (const word of editor.words) {
-        const startTime = Util.formatSecondsTimeString(word.startTime, true);
-        const endTime = Util.formatSecondsTimeString(word.endTime, true);
-
-        contents += startTime + " --> " + endTime + "\n";
-        contents += "- " + word.word + "\n\n";
-    }
-
-    return contents;
-}
 </script>
Index: /main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/FileUpload.vue
===================================================================
--- /main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/FileUpload.vue	(revision 35723)
+++ /main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/FileUpload.vue	(revision 35723)
@@ -0,0 +1,183 @@
+<template>
+    <div class="audio-file-picker">
+        <div class="input-bar">
+            <div class="text-input-sl" style="cursor: pointer;" @click="openFilePicker">
+                <span class="text-placeholder material-icons" v-if="!anyFiles">attach_file</span>
+                <span class="text-placeholder" v-if="!anyFiles">{{ translations.get("FileUpload_SelectFiles") }}</span>
+                <span v-if="anyFiles">{{ getFileNameList }}</span>
+            </div>
+
+            <input ref="fileInput" type="file" @input="onFilesChanged"
+                accept="application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.oasis.opendocument.text,text/plain"
+                multiple :disabled="isMacronising" />
+
+            <button class="btn-primary" type="submit" :disabled="!anyFiles || isMacronising" @click="doMacronisation">
+                <span class="material-icons">history_edu</span>
+                <span>{{ translations.get("FileUpload_MacroniseFiles") }}</span>
+            </button>
+        </div>
+
+        <div v-if="isMacronising" class="progress-bar-container">
+            <div class="progress-bar-value progress-bar-indeterminate" />
+        </div>
+
+        <ul class="list-view" v-if="failures.size > 0">
+            <li v-for="[id, failure] in failures" :key="failure.id" class="list-view__item transcription-error-container">
+                <div>
+                    {{ translations.get("FileUpload_MacronisationFailed-Message") }} <i v-if="failure.fileName">{{ failure.fileName }}</i><br />
+                    <span v-if="failure.message">{{ translations.get("FileUpload_MacronisationFailed-Reason") }}: {{ failure.message }}</span>
+                </div>
+
+                <button class="btn-fab theme-flat" @click="dismissFailure(id)">
+                    <span class="material-icons">clear</span>
+                </button>
+            </li>
+        </ul>
+    </div>
+</template>
+
+<style scoped lang="scss">
+.audio-file-picker {
+    display: flex;
+    gap: 0.5em;
+    flex-direction: column;
+
+    input {
+        display: none;
+    }
+
+    .text-container {
+        cursor: pointer;
+    }
+}
+
+.input-bar {
+    display: grid;
+    align-items: stretch;
+    gap: 0.5em;
+    grid-template-columns: 1fr auto;
+}
+
+.transcription-error-container {
+    display: grid;
+    gap: 0.5em;
+    grid-template-columns: 1fr auto;
+    align-items: center;
+
+    border-left: 3px solid red;
+}
+</style>
+
+<script>
+import { mapState } from "vuex";
+import MacronRestorationService from "../js/MacronRestorationModule";
+import Util, { log } from "../js/Util";
+
+class MacronisationViewFailure {
+    /**
+     * @param {String} fileName The name of the file for which this failure occured.
+     * @param {String} message The reason for this failure.
+     */
+    constructor(fileName, message) {
+        /** @type {String} The UUID of this failure. */
+        this.id = Util.generateUuid();
+
+        /** @type {String | null} The name of the file for which this failure occured. */
+        this.fileName = fileName;
+
+        /** @type {String} The reason for the failure. */
+        this.message = message;
+    }
+}
+
+const macronService = new MacronRestorationService();
+
+export default {
+    name: "FileUpload",
+    data() {
+        return {
+            /** @type {File[]} */
+            files: [],
+            preserveExistingMacrons: true,
+            /** @type {Map<String, MacronisationViewFailure>} */
+            failures: new Map(),
+            isMacronising: false
+        }
+    },
+    computed: mapState({
+        translations: state => state.translations,
+        anyFiles() {
+            return this.files?.length > 0;
+        },
+        getFileNameList() {
+            let fileNameList = "";
+
+            for (const file of this.files) {
+                fileNameList += file.name + ", ";
+            }
+
+            return fileNameList.slice(0, fileNameList.length - 2);
+        }
+    }),
+    emits: [ "fileMacronised" ],
+    methods: {
+        openFilePicker() {
+            this.$refs.fileInput.click();
+        },
+        onFilesChanged() {
+            this.files = [];
+            const files = this.$refs.fileInput.files;
+
+            if (files?.length !== undefined && files?.length > 0) {
+                for (const file of files) {
+                    this.files.push(file);
+                }
+            }
+        },
+
+        async doMacronisation() {
+            await this.macronise(this.files);
+        },
+        /**
+         * Attempts to macronise each submitted file.
+         * @param {File[]} files The files to macronise.
+         */
+        async macronise(files) {
+            this.isMacronising = true;
+
+            for (const file of files) {
+                try {
+                    const fileInfo = await macronService.fileMacronisation(file, this.preserveExistingMacrons);
+                    this.$store.commit("pushFileInfo", fileInfo);
+                }
+                catch (ex) {
+                    log("Failed to macronise file", "error");
+                    log(ex);
+
+                    this.createFailure(ex.fileName, ex.message);
+                }
+            }
+
+            this.isMacronising = false;
+            this.files = [];
+        },
+
+        /**
+         * Creates a new UI entry indicating that a file failed to be macronised.
+         * @param {String} fileName The name of the file that couldn't be macronised.
+         * @param {String} reason The reason that the file couldn't be macronised.
+         */
+        createFailure(fileName, reason) {
+            const failure = new MacronisationViewFailure(fileName, reason);
+            this.failures.set(failure.id, failure);
+        },
+        /**
+         * Dismisses a failure
+         * @param {String} id The UUID of the failure.
+         */
+        dismissFailure(id) {
+            this.failures.delete(id);
+        }
+    }
+}
+</script>
Index: /main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/TabBar.vue
===================================================================
--- /main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/TabBar.vue	(revision 35723)
+++ /main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/TabBar.vue	(revision 35723)
@@ -0,0 +1,84 @@
+<template>
+<div class="container">
+    <div class="tabs">
+        <div v-for="tab of tabs" :key="tab">
+            <input type="radio" name="tabs" :id="tab.value" :value="tab.value" class="radio" v-model="internalSelected" />
+            <label class="tab" :for="tab.value">{{ tab.label }}</label>
+        </div>
+    </div>
+</div>
+</template>
+
+<style scoped lang="scss">
+.container {
+    display: flex;
+    justify-content: flex-start;
+    padding: 0;
+    width: auto;
+}
+
+.tabs {
+    display: flex;
+}
+
+.radio {
+    display: none;
+
+    &:checked {
+        & + label {
+            color: var(--bg-color);
+            border-bottom: 2px solid var(--bg-color);
+            background-color: rgba(var(--bg-color-raw), 0.05);
+        }
+    }
+}
+
+.tab {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    padding: 0.4em 1em;
+    min-width: 8em;
+    min-height: 3em;
+
+    font-size: 1.25rem;
+    cursor: pointer;
+    border: 2px solid transparent; // Prevents attempting to animate the border height
+
+    transition-duration: 0.15s;
+}
+</style>
+
+<script>
+export default {
+    name: "TabBar",
+    props: {
+        /** @type {Array<{label: String, id: String}} */
+        tabs: Array,
+        modelValue: String
+    },
+    emits: [
+        "update:modelValue"
+    ],
+    computed: {
+        internalSelected: {
+            get() {
+                return this.modelValue;
+            },
+            set(newValue) {
+                this.$emit("update:modelValue", newValue);
+            }
+        }
+    },
+    watch: {
+    },
+    methods: {
+    },
+    beforeMount() {
+        if (this.modelValue === null && this.tabs.length > 0) {
+            this.$emit("update:modelValue", this.tabs[0].value);
+        }
+    }
+}
+</script>
Index: /main/trunk/model-interfaces-dev/atea/macron-restoration/src/js/MacronRestorationModule.js
===================================================================
--- /main/trunk/model-interfaces-dev/atea/macron-restoration/src/js/MacronRestorationModule.js	(revision 35722)
+++ /main/trunk/model-interfaces-dev/atea/macron-restoration/src/js/MacronRestorationModule.js	(revision 35723)
@@ -33,8 +33,8 @@
         /** @type {String} The URL to which query POST requests should be made. */
         if (process.env.NODE_ENV !== "production") {
-            this.queryUrl = "//localhost:8383/macron-restoration/jsp/servlet/DirectInput";
+            this.queryUrl = "//localhost:8383/macron-restoration/jsp/servlet/";
         }
         else {
-            this.queryUrl = "/macron-restoration/jsp/servlet/DirectInput";
+            this.queryUrl = "/macron-restoration/jsp/servlet/";
         }
     }
@@ -43,12 +43,12 @@
      * Performs a request to the macron restoration API to restore the given input text.
      * @param {String} input The input text to macronise
-     * @returns The JSON response from the macron restoration API.
+     * @returns {Promise<{ w: String, macronised?: Boolean }>} The JSON response from the macron restoration API.
      */
-    async directMacronisation(input) {
-        const that = this;
+    async directMacronisation(input, preserveExistingMacrons) {
+        const queryUrl = this.queryUrl + "DirectInput";
 
         try {
             const response = await fetch(
-                that.queryUrl,
+                queryUrl,
                 {
                     method: "POST",
@@ -56,5 +56,5 @@
                         "Content-Type": "application/x-www-form-urlencoded"
                     },
-                    body: `o=json&fragment=${input}`
+                    body: `o=json&fragment=${input}&preserveExistingMacrons=${preserveExistingMacrons}`
                 }
             );
@@ -74,87 +74,22 @@
 
     /**
-     * Performs chunked queries to transcribe the given audio files, returning the data in iterations.
-     * Data is chunked according to which ever occurs first:
-     * A maximum of three files per request, or;
-     * A maximum of 5 MiB before chunking.
+     * Performs a query to macronise the given file.
      *
-     * @param {FileList | File[]} files The files to upload
-     * @returns {AsyncGenerator<TranscriptionModel[]>} The transcribed audio files.
-     * @throws {TranscriptionError} When the transcription request fails to complete.
+     * @param {File} file The file to macronise.
+     * @param {Boolean} preserveExistingMacrons Indicates if existing macrons on the file should be preserved.
+     * @returns {Promise<{ fileName: String, filePath: String, fileType: String }>} Information about the macronised file.
+     * @throws {MacronRestorationError} When the macronisation request fails to complete.
      */
-    async* batchFileMacronisation(files) {
-        let filesToSubmit = [];
-        let fileCounter = 0;
-        let byteCounter = 0;
-
-        for (const file of files) {
-            if (fileCounter === this.MAX_BATCH_COUNT || byteCounter > this.BATCH_BYTE_LIMIT) { // 5 MiB
-                yield await this.fileMacronisation(filesToSubmit);
-                filesToSubmit = [];
-                byteCounter = 0;
-                fileCounter = 0;
-            }
-
-            filesToSubmit[fileCounter++] = file;
-            byteCounter += file.size;
-        }
-
-        if (filesToSubmit.length > 0) {
-            yield await this.fileMacronisation(filesToSubmit);
-        }
-    }
-
-    /**
-     * Performs a query to transcribe the given audio files.
-     *
-     * @param {File[]} files The files to upload.
-     * @returns {Promise<TranscriptionModel[]>} The transcribed audio file.
-     * @throws {TranscriptionError} When the transcription request fails to complete.
-     */
-    async fileMacronisation(files) {
-        if (process.env.NODE_ENV !== "production" || files.some(file => file.name === "akl_mi_pk_0002_offline.wav")) {
-            return await this.fileMacronisationTest(files);
-        }
-        else {
-            return await this.fileMacronisationActual(files);
-        }
-    }
-
-    async fileMacronisationTest(files) {
-        const objects = [];
-
-        for (const file of files) {
-            const object = JSON.parse(this.TEST_JSON);
-            object.file_name = file.name;
-            objects.push(object);
-        }
-
-        return objects;
-    }
-
-    /**
-     * Performs a query to transcribe the given audio files.
-     *
-     * @param {FileList | File[]} files The files to upload.
-     * @returns {Promise<TranscriptionModel[]>} The transcribed audio file.
-     * @throws {TranscriptionError} When the transcription request fails to complete.
-     */
-    async fileMacronisationActual(files) {
-        const that = this;
+    async fileMacronisation(file, preserveExistingMacrons) {
+        const queryUrl = this.queryUrl + "FileUpload";
         const formData = new FormData();
 
-        let audioFileKeys = "";
-        for (let i = 0; i < files.length; i++) {
-            const f = files[i];
-            const key = "audioFile" + i;
-
-            formData.append(key, f, f.name);
-            audioFileKeys += key + "|";
-        }
-        formData.append("audioFileKeys", audioFileKeys);
+        formData.append("o", "json");
+        formData.append("preserveExistingMacrons", preserveExistingMacrons);
+        formData.append(file.name, file, file.name);
 
         try {
             const response = await fetch(
-                that.queryUrl,
+                queryUrl,
                 {
                     method: "POST",
@@ -164,6 +99,6 @@
 
             if (!response.ok) {
-                log(`Transcription API failed with status ${response.status} and message ${response.statusText}`, "error")
-                throw new MacronRestorationError(response.statusText, undefined, response.status);
+                log(`Macronisation API failed with status ${response.status} and message ${response.statusText}`, "error")
+                throw new MacronRestorationError(response.statusText, file.Name, response.status);
             }
 
@@ -171,6 +106,6 @@
         }
         catch (e) {
-            log(`Transcription failed with reason ${e}`, "error");
-            throw new MacronRestorationError(undefined, "Unknown");
+            log(`Macronisation failed with reason ${e}`, "error");
+            throw new MacronRestorationError("Unknown", file.Name, "Unknown");
         }
     }
Index: /main/trunk/model-interfaces-dev/atea/macron-restoration/src/main.js
===================================================================
--- /main/trunk/model-interfaces-dev/atea/macron-restoration/src/main.js	(revision 35722)
+++ /main/trunk/model-interfaces-dev/atea/macron-restoration/src/main.js	(revision 35723)
@@ -1,13 +1,7 @@
 import { createApp } from "vue";
 import { createStore } from "vuex"
-// import AudioPlayback from "./js/AudioPlaybackModule";
+import Util from "./js/Util"
 import App from "./App.vue";
 import ToggleButton from "./components/ToggleButton.vue"
-
-// export class TranscriptionExistsError extends Error {
-//     constructor(message = "", ...args) {
-//         super(message, ...args);
-//     }
-// }
 
 const store = createStore({
@@ -15,5 +9,8 @@
         return {
             /** @type {Map<String, String>} */
-            translations: new Map()
+            translations: new Map(),
+            macronisedFileInfos: [],
+            directInput: null,
+            directOutput: []
         }
     },
@@ -21,12 +18,18 @@
         setTranslations(state, translations) {
             state.translations = translations;
+        },
+        pushFileInfo(state, fileInfo) {
+            state.macronisedFileInfos.push({
+                id: Util.generateUuid(),
+                ...fileInfo
+            });
+        },
+        setDirectInput(state, input) {
+            state.directInput = input;
+        },
+        setDirectOutput(state, output) {
+            state.directOutput = output;
         }
     }
-    // getters: {
-    //     hasTranscriptionOfFile: (state) => (file) => {
-    //         const id = TranscriptionViewModel.getId(file);
-    //         return state.rawTranscriptions.has(id);
-    //     },
-    // }
 });
 
Index: /main/trunk/model-interfaces-dev/atea/macron-restoration/src/styles/_material.scss
===================================================================
--- /main/trunk/model-interfaces-dev/atea/macron-restoration/src/styles/_material.scss	(revision 35722)
+++ /main/trunk/model-interfaces-dev/atea/macron-restoration/src/styles/_material.scss	(revision 35723)
@@ -64,4 +64,9 @@
 }
 
+.divider-s {
+    @extend .divider;
+    margin: 0 2rem;
+}
+
 /* Buttons */
 
@@ -108,5 +113,4 @@
 .text-container {
     display: flex;
-    align-items: flex-start;
     flex-wrap: wrap;
 
@@ -129,5 +133,5 @@
     border-radius: var(--border-radius) var(--border-radius) 0 0;
     border: none;
-    border-bottom: 1px solid #AAA;
+    border-bottom: 2px solid #999;
     outline: none;
     cursor: text;
@@ -163,4 +167,5 @@
     @extend .text-input;
 
+    align-items: center;
     overflow-x: auto;
     overflow-y: hidden;
