first attempt with adding and downloading message attachments

This commit is contained in:
DJ2LS 2024-02-06 20:09:20 +01:00
parent 463a1766f1
commit 8d62550775
9 changed files with 206 additions and 121 deletions

View file

@ -2,13 +2,13 @@
<div class="row justify-content-start mb-2"> <div class="row justify-content-start mb-2">
<div :class="messageWidthClass"> <div :class="messageWidthClass">
<div class="card bg-light border-0 text-dark"> <div class="card bg-light border-0 text-dark">
<div class="card-header" v-if="getFileContent['filesize'] !== 0">
<p class="card-text"> <div v-for="attachment in message.attachments" :key="attachment.id" class="card-header">
{{ getFileContent["filename"] }} | <div class="btn-group w-100" role="group">
{{ getFileContent["filesize"] }} Bytes | <button class="btn btn-light text-truncate" disabled>{{ attachment.name }}</button>
{{ getFileContent["filetype"] }} <button @click="downloadAttachment(attachment.hash_sha512, attachment.name)" class="btn btn-light w-25"><i class="bi bi-download strong"></i></button>
</p>
</div> </div>
</div>
<div class="card-body"> <div class="card-body">
<p class="card-text">{{ message.body }}</p> <p class="card-text">{{ message.body }}</p>
@ -33,14 +33,7 @@
<i class="bi bi-info-circle"></i> <i class="bi bi-info-circle"></i>
</button> </button>
<button
disabled
v-if="getFileContent['filesize'] !== 0"
class="btn btn-outline-secondary border-0 me-1"
@click="downloadAttachment"
>
<i class="bi bi-download"></i>
</button>
<button class="btn btn-outline-secondary border-0" @click="deleteMessage"> <button class="btn btn-outline-secondary border-0" @click="deleteMessage">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
@ -61,7 +54,6 @@ import { atob_FD } from "../js/freedata";
import { setActivePinia } from "pinia"; import { setActivePinia } from "pinia";
import pinia from "../store/index"; import pinia from "../store/index";
setActivePinia(pinia); setActivePinia(pinia);
import { saveAs } from "file-saver";
import { useChatStore } from "../store/chatStore.js"; import { useChatStore } from "../store/chatStore.js";
const chat = useChatStore(pinia); const chat = useChatStore(pinia);
@ -80,40 +72,42 @@ export default {
deleteMessage() { deleteMessage() {
deleteMessageFromDB(this.message.id); deleteMessageFromDB(this.message.id);
}, },
async downloadAttachment() { async downloadAttachment(hash_sha512, fileName) {
try { try {
// reset file store const jsondata = await getMessageAttachment(hash_sha512);
chat.downloadFileFromDB = []; const byteCharacters = atob(jsondata.data);
const byteArrays = [];
const attachment = await getMessageAttachment(this.message.id); for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const blob = new Blob([atob_FD(attachment[2])], { const slice = byteCharacters.slice(offset, offset + 512);
type: `${attachment[1]};charset=utf-8`, const byteNumbers = new Array(slice.length);
}); for (let i = 0; i < slice.length; i++) {
window.focus(); byteNumbers[i] = slice.charCodeAt(i);
saveAs(blob, attachment[0]); }
const byteArray = new Uint8Array(byteNumbers);
byteArrays.push(byteArray);
}
const blob = new Blob(byteArrays, { type: jsondata.type });
const url = URL.createObjectURL(blob);
// Creating a temporary anchor element to download the file
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
// Cleanup
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
} catch (error) { } catch (error) {
console.error("Failed to download attachment:", error); console.error("Failed to download the attachment:", error);
} }
}, },
}, },
computed: { computed: {
getFileContent() {
if (this.message.attachments.length <= 0) {
return { filename: "", filesize: 0, filetype: "" };
}
try {
var filename = Object.keys(this.message._attachments)[0];
var filesize = this.message._attachments[filename]["length"];
var filetype = filename.split(".")[1];
return { filename: filename, filesize: filesize, filetype: filetype };
} catch (e) {
console.log("file not loaded from database - empty?");
// we are only checking against filesize for displaying attachments
return { filesize: 0 };
}
},
messageWidthClass() { messageWidthClass() {
// Calculate a Bootstrap grid class based on message length // Calculate a Bootstrap grid class based on message length
// Adjust the logic as needed to fit your requirements // Adjust the logic as needed to fit your requirements

View file

@ -2,14 +2,6 @@
<div class="row justify-content-end mb-2"> <div class="row justify-content-end mb-2">
<!-- control area --> <!-- control area -->
<div class="col-auto p-0 m-0"> <div class="col-auto p-0 m-0">
<button
disabled
v-if="getFileContent['filesize'] !== 0"
class="btn btn-outline-secondary border-0 me-1"
@click="downloadAttachment"
>
<i class="bi bi-download"></i>
</button>
<button <button
class="btn btn-outline-secondary border-0 me-1" class="btn btn-outline-secondary border-0 me-1"
@ -32,17 +24,17 @@
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
</div> </div>
<!-- message area --> <!-- message area -->
<div :class="messageWidthClass"> <div :class="messageWidthClass">
<div class="card bg-secondary text-white"> <div class="card bg-secondary text-white">
<div class="card-header" v-if="getFileContent['filesize'] !== 0"> <div v-for="attachment in message.attachments" :key="attachment.id" class="card-header">
<p class="card-text"> <div class="btn-group w-100" role="group">
{{ getFileContent["filename"] }} | <button class="btn btn-light text-truncate" disabled>{{ attachment.name }}</button>
{{ getFileContent["filesize"] }} Bytes | <button @click="downloadAttachment(attachment.hash_sha512, attachment.name)" class="btn btn-light w-25"><i class="bi bi-download strong"></i></button>
{{ getFileContent["filetype"] }}
</p>
</div> </div>
</div>
<div class="card-body"> <div class="card-body">
<p class="card-text">{{ message.body }}</p> <p class="card-text">{{ message.body }}</p>
@ -101,7 +93,6 @@ import {
import { setActivePinia } from "pinia"; import { setActivePinia } from "pinia";
import pinia from "../store/index"; import pinia from "../store/index";
setActivePinia(pinia); setActivePinia(pinia);
import { saveAs } from "file-saver";
import { useChatStore } from "../store/chatStore.js"; import { useChatStore } from "../store/chatStore.js";
const chat = useChatStore(pinia); const chat = useChatStore(pinia);
@ -126,42 +117,41 @@ export default {
//console.log(this.infoModal) //console.log(this.infoModal)
//this.infoModal.show() //this.infoModal.show()
}, },
async downloadAttachment() { async downloadAttachment(hash_sha512, fileName) {
try { try {
// reset file store const jsondata = await getMessageAttachment(hash_sha512);
chat.downloadFileFromDB = []; const byteCharacters = atob(jsondata.data);
const byteArrays = [];
const attachment = await getMessageAttachment(this.message.id); for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const blob = new Blob([atob_FD(attachment[2])], { const slice = byteCharacters.slice(offset, offset + 512);
type: `${attachment[1]};charset=utf-8`, const byteNumbers = new Array(slice.length);
}); for (let i = 0; i < slice.length; i++) {
saveAs(blob, attachment[0]); byteNumbers[i] = slice.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
byteArrays.push(byteArray);
}
const blob = new Blob(byteArrays, { type: jsondata.type });
const url = URL.createObjectURL(blob);
// Creating a temporary anchor element to download the file
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
// Cleanup
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
} catch (error) { } catch (error) {
console.error("Failed to download attachment:", error); console.error("Failed to download the attachment:", error);
} }
}, },
}, },
computed: { computed: {
getFileContent() {
if (this.message.attachments.length <= 0) {
return { filename: "", filesize: 0, filetype: "" };
}
var filename = Object.keys(this.message._attachments)[0];
var filesize = this.message._attachments[filename]["length"];
var filetype = filename.split(".")[1];
// ensure filesize is 0 for hiding message header if no data is available
if (
typeof filename === "undefined" ||
filename === "" ||
filename === "-"
) {
filesize = 0;
}
return { filename: filename, filesize: filesize, filetype: filetype };
},
messageWidthClass() { messageWidthClass() {
// Calculate a Bootstrap grid class based on message length // Calculate a Bootstrap grid class based on message length
// Adjust the logic as needed to fit your requirements // Adjust the logic as needed to fit your requirements

View file

@ -44,34 +44,72 @@ chat.inputText += detail.unicode
const chatModuleMessage=ref(null); const chatModuleMessage=ref(null);
// Function to trigger the hidden file input
function triggerFileInput() {
fileInput.value.click();
}
function transmitNewMessage(){ // Use a ref for storing multiple files
const selectedFiles = ref([]);
const fileInput = ref(null);
function handleFileSelection(event) {
// Reset previously selected files
selectedFiles.value = [];
// if no callsign is selected, assume we are using the first one.. // Process each file
if(typeof(chat.selectedCallsign) == 'undefined'){ for (let file of event.target.files) {
const reader = new FileReader();
reader.onload = () => {
// Convert file content to base64
const base64Content = btoa(reader.result); // Adjust this line if necessary
selectedFiles.value.push({
name: file.name,
size: file.size,
type: file.type,
content: base64Content, // Store base64 encoded content
});
};
reader.readAsBinaryString(file); // Read the file content as binary string
}
}function transmitNewMessage() {
// Check if a callsign is selected, default to the first one if not
if (typeof(chat.selectedCallsign) === 'undefined') {
chat.selectedCallsign = Object.keys(chat.callsign_list)[0]; chat.selectedCallsign = Object.keys(chat.callsign_list)[0];
} }
chat.inputText = chat.inputText.trim(); chat.inputText = chat.inputText.trim();
if (chat.inputText.length==0 && chat.inputFileName == "-")
return; // Proceed only if there is text or files selected
if (chat.inputText.length === 0 && selectedFiles.value.length === 0) return;
const attachments = selectedFiles.value.map(file => {
return {
name: file.name,
type: file.type,
data: file.content
};
});
if (chat.selectedCallsign.startsWith("BC-")) { if (chat.selectedCallsign.startsWith("BC-")) {
// Handle broadcast message differently if needed
return "new broadcast" return "new broadcast";
} else { } else {
//newMessage(chat.selectedCallsign, chat.inputText, chat.inputFile, chat.inputFileName, chat.inputFileSize, chat.inputFileType) // If there are attachments, send them along with the message
newMessage(chat.selectedCallsign, chat.inputText) if (attachments.length > 0) {
newMessage(chat.selectedCallsign, chat.inputText, attachments);
} else {
// Send text only if no attachments are selected
newMessage(chat.selectedCallsign, chat.inputText);
}
} }
// finally do a cleanup
//chatModuleMessage.reset(); // Cleanup after sending message
chat.inputText = ''; chat.inputText = '';
chatModuleMessage.value=""; chatModuleMessage.value = "";
// @ts-expect-error selectedFiles.value = []; // Clear selected files after sending
resetFile() // Reset any other states or UI elements as necessary
} }
function resetFile(event){ function resetFile(event){
@ -140,9 +178,9 @@ function calculateTimeNeeded(){
return obj.snr === snrList[i].snr return obj.snr === snrList[i].snr
}) })
calculatedSpeedPerMinutePER0.push(chat.inputFileSize / result.bpm) calculatedSpeedPerMinutePER0.push(totalSize / result.bpm)
calculatedSpeedPerMinutePER25.push(chat.inputFileSize / (result.bpm * 0.75)) calculatedSpeedPerMinutePER25.push(totalSize / (result.bpm * 0.75))
calculatedSpeedPerMinutePER75.push(chat.inputFileSize / (result.bpm * 0.25)) calculatedSpeedPerMinutePER75.push(totalSize / (result.bpm * 0.25))
} }
@ -202,11 +240,11 @@ const speedChartData = computed(() => ({
<!-- trigger file selection modal --> <!-- trigger file selection modal -->
<!--
<button type="button" class="btn btn-outline-secondary border-0 rounded-pill me-1" data-bs-toggle="modal" data-bs-target="#fileSelectionModal"> <button type="button" class="btn btn-outline-secondary border-0 rounded-pill me-1" data-bs-toggle="modal" data-bs-target="#fileSelectionModal">
<i class="bi bi-paperclip" style="font-size: 1.2rem"></i> <i class="bi bi-paperclip" style="font-size: 1.2rem"></i>
</button> </button>
-->
<textarea <textarea
@ -264,20 +302,54 @@ const speedChartData = computed(() => ({
</div> </div>
</div> </div>
<!--
<div class="input-group-text mb-3"> <div class="input-group-text mb-3">
<input class="" type="file" ref="doc" @change="readFile" /> <input class="" type="file" ref="doc" @change="readFile" />
</div> </div>
-->
<div class="container w-100 mb-3">
<!-- Button that user will click to open file dialog -->
<button class="btn btn-primary w-100" @click="triggerFileInput">Attach Files</button>
<!-- Hidden file input -->
<input type="file" multiple ref="fileInput" @change="handleFileSelection" style="display: none;" />
</div>
<div class="container-fluid px-0">
<div class="d-flex flex-row overflow-auto bg-light rounded-3 p-2 border border-1">
<div v-for="(file, index) in selectedFiles" :key="index" class="pe-2">
<div class="card" style=" min-width: 10rem; max-width: 10rem;">
<!-- Card Header with Remove Button -->
<div class="card-header d-flex justify-content-between align-items-center">
<span class="text-truncate">{{ file.name }}</span>
<button class="btn btn-close" @click="removeFile(index)"></button>
</div>
<div class="card-body">
<p class="card-text">...</p>
</div>
<div class="card-footer text-muted">
{{ file.type }}
</div>
<div class="card-footer text-muted">
{{ file.size }} bytes
</div>
</div>
</div>
</div>
</div>
<hr>
<div class="btn-group me-2" role="group" aria-label="Basic outlined example"> <div class="btn-group me-2" role="group" aria-label="Basic outlined example">
<button type="button" class="btn btn-secondary">Type</button> <button type="button" class="btn btn-secondary">total size</button>
<button type="button" class="btn btn-secondary disabled">{{chat.inputFileType}}</button> <button type="button" class="btn btn-secondary disabled">{{chat.inputFileSize}} {{totalSizeFormatted}}</button>
</div>
<div class="btn-group me-2" role="group" aria-label="Basic outlined example">
<button type="button" class="btn btn-secondary">Size</button>
<button type="button" class="btn btn-secondary disabled">{{chat.inputFileSize}}</button>
</div> </div>
<Line :data="speedChartData" /> <Line :data="speedChartData" />

View file

@ -155,10 +155,16 @@ export async function getFreedataMessages() {
processFreedataMessages(res); processFreedataMessages(res);
} }
export async function sendFreedataMessage(destination, body) { export async function getFreedataAttachmentBySha512(data_sha512) {
let res = await apiGet(`/freedata/messages/attachment/${data_sha512}`);
return res;
}
export async function sendFreedataMessage(destination, body, attachments) {
return await apiPost("/freedata/messages", { return await apiPost("/freedata/messages", {
destination: destination, destination: destination,
body: body, body: body,
attachments: attachments,
}); });
} }

View file

@ -10,6 +10,7 @@ import {
sendFreedataMessage, sendFreedataMessage,
deleteFreedataMessage, deleteFreedataMessage,
retransmitFreedataMessage, retransmitFreedataMessage,
getFreedataAttachmentBySha512,
} from "./api"; } from "./api";
interface Message { interface Message {
@ -76,8 +77,9 @@ function createSortedMessagesList(data: {
return callsignMessages; return callsignMessages;
} }
export function newMessage(dxcall, body) { export function newMessage(dxcall, body, attachments) {
sendFreedataMessage(dxcall, body); console.log(attachments)
sendFreedataMessage(dxcall, body, attachments);
} }
/* ------ TEMPORARY DUMMY FUNCTIONS --- */ /* ------ TEMPORARY DUMMY FUNCTIONS --- */
@ -99,6 +101,6 @@ export function requestMessageInfo(id) {
return; return;
} }
export function getMessageAttachment(id) { export async function getMessageAttachment(data_sha512) {
return; return await getFreedataAttachmentBySha512(data_sha512)
} }

View file

@ -6,6 +6,7 @@ def validate_freedata_callsign(callsign):
return re.compile(regexp).match(callsign) is not None return re.compile(regexp).match(callsign) is not None
def validate_message_attachment(attachment): def validate_message_attachment(attachment):
print(attachment)
for field in ['name', 'type', 'data']: for field in ['name', 'type', 'data']:
if field not in attachment: if field not in attachment:
raise ValueError(f"Attachment missing '{field}'") raise ValueError(f"Attachment missing '{field}'")

View file

@ -64,3 +64,18 @@ class DatabaseManagerAttachments(DatabaseManager):
def get_attachments_by_message_id_json(self, message_id): def get_attachments_by_message_id_json(self, message_id):
attachments = self.get_attachments_by_message_id(message_id) attachments = self.get_attachments_by_message_id(message_id)
return json.dumps(attachments) return json.dumps(attachments)
def get_attachment_by_sha512(self, hash_sha512):
session = self.get_thread_scoped_session()
try:
attachment = session.query(Attachment).filter_by(hash_sha512=hash_sha512).first()
if attachment:
return attachment.to_dict() # Assuming you have a to_dict method
else:
self.log(f"No attachment found with SHA-512 hash: {hash_sha512}")
return None
except Exception as e:
self.log(f"Error fetching attachment with SHA-512 hash {hash_sha512}: {e}", isWarning=True)
return None
finally:
session.remove()

View file

@ -114,7 +114,7 @@ class Attachment(Base):
return { return {
'id': self.id, 'id': self.id,
'name': self.name, 'name': self.name,
'data_type': self.data_type, 'type': self.data_type,
'data': self.data, 'data': self.data,
'checksum_crc32': self.checksum_crc32, 'checksum_crc32': self.checksum_crc32,
'hash_sha512' : self.hash_sha512 'hash_sha512' : self.hash_sha512

View file

@ -274,6 +274,11 @@ def get_message_attachments(message_id):
attachments = DatabaseManagerAttachments(app.event_manager).get_attachments_by_message_id_json(message_id) attachments = DatabaseManagerAttachments(app.event_manager).get_attachments_by_message_id_json(message_id)
return api_response(attachments) return api_response(attachments)
@app.route('/freedata/messages/attachment/<string:data_sha512>', methods=['GET'])
def get_message_attachment(data_sha512):
attachment = DatabaseManagerAttachments(app.event_manager).get_attachment_by_sha512(data_sha512)
return api_response(attachment)
@app.route('/freedata/beacons', methods=['GET']) @app.route('/freedata/beacons', methods=['GET'])
def get_all_beacons(): def get_all_beacons():
beacons = DatabaseManagerBeacon(app.event_manager).get_all_beacons() beacons = DatabaseManagerBeacon(app.event_manager).get_all_beacons()