9 directories, 58 files

tinai

Home / tinai
import { getEl, addSafeEventListener } from './util/dom-utils.js';
import { escapeHtml, generateUUID } from './util/format-utils.js';
import { customConfirm } from './util/confirm-dialog.js';

/**
 * class Attachments
 * Handles the attachments tab: conversation attachments on top and global
 * document management below. Documents are added via file pickers, stored as
 * blobs in IndexedDB, and listed in the global document list.
 */
class Attachments {

	/**
	 * Initializes a new instance of the Attachments class.
	 * @param {Object} storage - Storage instance.
	 * @param {Object} app_callbacks - Application callbacks (renderAttachments).
	 */
	constructor(storage, app_callbacks) {
		this.storage = storage;
		this.app_callbacks = app_callbacks;
	}

	/**
	 * Renders the attachments view.
	 * @returns {string} HTML string for the attachments view.
	 */
	render() {
		const config = this.storage.get_app_config();
		const guid = config[this.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		const conversation = guid ? this.storage.get_conversation(guid) : null;
		const attached_ids = conversation ? (conversation[this.storage.KEY_CONVERSATION_ATTACHMENTS] || []) : [];
		const documents = this.storage.get_app_documents();

		let html = '<div class="div-attachments-split">';

		html += '<div class="div-attachments-conversation">';
		html += '<h4>Conversation Attachments</h4><hr>';
		const attached = documents.filter(doc => attached_ids.includes(doc.id));
		if (attached.length > 0) {
			attached.forEach(doc => {
				html += this._render_document_row(doc, [
					{ class: 'btn-remove-attachment', label: '-', title: 'Remove from conversation' }
				]);
			});
		} else {
			html += '<p class="p-attachments-empty">No Attachments</p>';
		}
		html += '</div>';

		html += '<div class="div-attachments-global" data-simplebar data-simplebar-auto-hide="false">';
		html += '<h4>Global Documents</h4><hr>';
		if (documents.length > 0) {
			documents.forEach(doc => {
				const buttons = [];
				if (!attached_ids.includes(doc.id)) {
					buttons.push({ class: 'btn-add-attachment', label: '+', title: 'Add to conversation' });
				}
				buttons.push({ class: 'btn-delete-document', label: 'X', title: 'Delete from storage' });
				html += this._render_document_row(doc, buttons);
			});
		} else {
			html += '<p class="p-attachments-empty">No Documents</p>';
		}
		html += '</div>';

		html += `
			<div class="div-attachments-actions">
				<button id="btn-add-text-document">Add Text Document</button>
				<button id="btn-add-image">Add Image</button>
			</div>
		`;

		html += '</div>';
		return html;
	}

	/**
	 * Renders a single document row with action buttons.
	 * @param {Object} doc - The document metadata.
	 * @param {Array<Object>} buttons - Buttons to render ({class, label, title}).
	 * @returns {string} HTML string for the row.
	 * @private
	 */
	_render_document_row(doc, buttons) {
		const name = escapeHtml(doc.name || 'Untitled');
		const size = this._format_size(doc.size);
		let buttons_html = '';
		buttons.forEach(button => {
			buttons_html += `<button class="${button.class}" data-doc-id="${escapeHtml(doc.id)}" title="${button.title}">${button.label}</button>`;
		});
		return `
			<div class="div-attachment-row">
				<span class="span-attachment-name" title="${name}">${name}</span>
				<span class="span-attachment-size">${size}</span>
				${buttons_html}
			</div>
		`;
	}

	/**
	 * Formats a byte size for display.
	 * @param {number} bytes - The size in bytes.
	 * @returns {string} Formatted size string.
	 * @private
	 */
	_format_size(bytes) {
		if (!bytes && bytes !== 0) return '';
		if (bytes < 1024) return bytes + ' B';
		if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
		return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
	}

	/**
	 * Attaches event listeners for the attachments view.
	 */
	attachEventListeners() {
		const container = getEl('div-chat-attachments-scroll');
		addSafeEventListener(container, 'click', (event) => this._handle_click(event));

		const text_input = getEl('input-attachment-text');
		addSafeEventListener(text_input, 'change', (event) => {
			void this._import_files(event.target.files);
			text_input.value = '';
		});

		const image_input = getEl('input-attachment-image');
		addSafeEventListener(image_input, 'change', (event) => {
			void this._import_files(event.target.files);
			image_input.value = '';
		});
	}

	/**
	 * Handles clicks within the attachments view via event delegation.
	 * @param {Event} event - The click event.
	 * @private
	 */
	_handle_click(event) {
		const target = event.target.closest('button');
		if (!target) return;
		const doc_id = target.dataset.docId;

		if (target.id === 'btn-add-text-document') {
			getEl('input-attachment-text').click();
		} else if (target.id === 'btn-add-image') {
			getEl('input-attachment-image').click();
		} else if (target.classList.contains('btn-add-attachment') && doc_id) {
			this._add_to_conversation(doc_id);
		} else if (target.classList.contains('btn-remove-attachment') && doc_id) {
			this._remove_from_conversation(doc_id);
		} else if (target.classList.contains('btn-delete-document') && doc_id) {
			void this._delete_document(doc_id);
		}
	}

	/**
	 * Adds a global document to the current conversation's attachments.
	 * @param {string} doc_id - The document id.
	 * @private
	 */
	_add_to_conversation(doc_id) {
		const config = this.storage.get_app_config();
		const guid = config[this.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		if (guid) {
			this.storage.add_conversation_attachment(guid, doc_id);
			this._refresh();
		}
	}

	/**
	 * Removes a document from the current conversation's attachments.
	 * @param {string} doc_id - The document id.
	 * @private
	 */
	_remove_from_conversation(doc_id) {
		const config = this.storage.get_app_config();
		const guid = config[this.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		if (guid) {
			this.storage.remove_conversation_attachment(guid, doc_id);
			this._refresh();
		}
	}

	/**
	 * Deletes a document from storage (and all conversation references) after confirmation.
	 * @param {string} doc_id - The document id.
	 * @private
	 */
	async _delete_document(doc_id) {
		const documents = this.storage.get_app_documents();
		const doc = documents.find(d => d.id === doc_id);
		const name = doc ? doc.name : 'this document';
		if (await customConfirm('Delete Document', `Do you want to delete "${name}" from storage?`)) {
			await this.storage.remove_app_document(doc_id);
			this._refresh();
		}
	}

	/**
	 * Imports selected files into global storage and attaches them to the current conversation.
	 * @param {FileList} files - The selected files.
	 * @private
	 */
	async _import_files(files) {
		const config = this.storage.get_app_config();
		const guid = config[this.storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		for (const file of Array.from(files || [])) {
			const id = generateUUID();
			const meta = {
				id: id,
				name: file.name,
				mime: file.type,
				size: file.size,
				date: new Date().toISOString()
			};
			await this.storage.save_document_blob(id, file);
			this.storage.add_app_document(meta);
			if (guid) {
				this.storage.add_conversation_attachment(guid, id);
			}
		}
		this._refresh();
	}

	/**
	 * Re-renders the attachments view.
	 * @private
	 */
	_refresh() {
		if (this.app_callbacks.renderAttachments) {
			this.app_callbacks.renderAttachments();
		}
	}
}

export default Attachments;
🌐
attachments.js ×
Type: Web, text/plain
7.30 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File