9 directories, 58 files

tinai

Home / tinai
import { createElementFromHTML } from './util/dom-utils.js';
import { escapeHtml } from './util/format-utils.js';
import { SelectionManager } from './util/selection-manager.js';
import { customConfirm, shared_copies_note } from './util/confirm-dialog.js';

/**
 * Class ToolsIndex
 * Manages the secondary navigation index for tools.
 * Renders the list of tool entries and the fixed "New ..." trigger buttons at
 * the top. Supports the scratchpad-style selection mode: Select toggles
 * checkboxes on the entries, Bookmark toggles their bookmark state, and
 * Delete removes the selected tool conversations after confirmation.
 */
class ToolsIndex {

	#storage;
	#app_callbacks;
	#selection;

	div_index_list;
	btn_tools_index_select;
	btn_tools_index_favorite;
	btn_tools_index_delete;

	BREAKPOINT_MOBILE;

	/**
	 * Initializes the ToolsIndex instance with storage, callbacks, and element references.
	 * @param {Storage} storage_instance - Storage manager instance.
	 * @param {object} app_callbacks - Application callback methods.
	 * @param {object} elements - DOM element references.
	 * @param {object} breakpoints - Responsive breakpoint constants.
	 */
	constructor(storage_instance, app_callbacks, elements, breakpoints) {
		this.#storage = storage_instance;
		this.#app_callbacks = app_callbacks;

		this.div_index_list = elements.div_index_list;
		this.btn_tools_index_select = elements.btn_tools_index_select || elements.btn_conversation_index_select;
		this.btn_tools_index_favorite = elements.btn_tools_index_favorite || elements.btn_conversation_index_favorite;
		this.btn_tools_index_delete = elements.btn_tools_index_delete || elements.btn_conversation_index_delete;

		this.BREAKPOINT_MOBILE = breakpoints.BREAKPOINT_MOBILE;

		this.#selection = new SelectionManager(() => this._update_selection_ui());
	}

	//region Tool Actions

	/**
	 * Creates a new Ranking tool entry.
	 */
	create_new_ranking() {
		this.#app_callbacks.create_new_ranking?.();
	}

	/**
	 * Creates a new Colors Palettes tool entry.
	 */
	create_new_palettes() {
		this.#app_callbacks.create_new_palettes?.();
	}

	/**
	 * Creates a new Sticky Notes tool entry.
	 */
	create_new_sticky_notes() {
		this.#app_callbacks.create_new_sticky_notes?.();
	}

	/**
	 * Toggles the bookmark status for all currently selected tools, or
	 * toggles the bookmark status of the active tool if no selection.
	 */
	toggle_selected_bookmarks() {
		const selectedGuids = this.#selection.selectedItems;
		if (selectedGuids.length === 0) {
			const config = this.#storage.get_app_config();
			const selected_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
			if (selected_guid) {
				const conversation = this.#storage.get_conversation(selected_guid);
				const type = conversation?.[this.#storage.KEY_CONVERSATION_TYPE];
				if (conversation && this.#is_tool_type(type)) {
					conversation.BOOKMARKED = !(conversation.BOOKMARKED || false);
					this.#storage.save_conversation(selected_guid, conversation);
					this.on_tools_index_updated();
					this.#app_callbacks.on_conversation_updated_main_panel?.();
				}
			}
			return;
		}

		selectedGuids.forEach(guid => {
			const conversation = this.#storage.get_conversation(guid);
			if (conversation) {
				conversation.BOOKMARKED = !(conversation.BOOKMARKED || false);
				this.#storage.save_conversation(guid, conversation);
			}
		});

		this.#selection.clear();
		this._update_selection_ui();
		this.on_tools_index_updated();
		this.#app_callbacks.on_conversation_updated_main_panel?.();
	}

	/**
	 * Alias for toggle_selected_bookmarks to conform to standard index interface.
	 */
	bookmark_selected_responses() {
		this.toggle_selected_bookmarks();
	}

	/**
	 * Deletes all currently selected tool conversations after user confirmation.
	 * The dialog notes when selected tools have shared copies, since deleting
	 * them also deletes those copies and their links.
	 */
	async delete_selected_tools() {
		const selectedGuids = this.#selection.selectedItems;
		const count = selectedGuids.length;
		if (count === 0) return;

		const message = count === 1
			? 'Are you sure you want to delete this tool?'
			: `Are you sure you want to delete ${count} selected tools?`;
		const note = await shared_copies_note(this.#app_callbacks, selectedGuids, 'tool');

		if (await customConfirm('Delete Tools', message, 'Yes', 'No', note)) {
			const config = this.#storage.get_app_config();
			const selected_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];

			if (selectedGuids.includes(selected_guid)) {
				this.#app_callbacks.close_conversation();
			}

			const toDelete = [...selectedGuids];
			toDelete.forEach(guid => {
				if (this.#app_callbacks.abort_request) {
					this.#app_callbacks.abort_request(guid);
				}
				const synced = !!this.#storage.get_conversation(guid)?.[this.#storage.KEY_CONVERSATION_SYNCED];
				this.#storage.index_delete(guid);
				this.#app_callbacks.conversation_deleted?.(guid, synced);
			});

			this.#selection.clear();
			this._update_selection_ui();
			this.on_tools_index_updated();
			this.#app_callbacks.on_conversation_updated_main_panel?.();
		}
	}

	/**
	 * Alias for delete_selected_tools to conform to standard index interface.
	 */
	async delete_selected_responses() {
		await this.delete_selected_tools();
	}

	/**
	 * Toggles the selection mode for tools.
	 */
	toggle_tool_selection_mode() {
		this.#selection.toggleMode();
		this._update_selection_ui();
		this.on_tools_index_updated();
	}

	/**
	 * Alias for toggle_tool_selection_mode to conform to standard index interface.
	 */
	toggle_response_selection_mode() {
		this.toggle_tool_selection_mode();
	}

	/**
	 * Returns whether a conversation type is a tool type.
	 * @param {string} type - The conversation type.
	 * @returns {boolean} True for ranking and colors conversations.
	 */
	#is_tool_type(type) {
		return type === this.#storage.CONVERSATION_TYPE_RANKING
			|| type === this.#storage.CONVERSATION_TYPE_COLORS
			|| type === this.#storage.CONVERSATION_TYPE_STICKY_NOTES;
	}

	//endregion

	//region HTML Generation

	/**
	 * Returns all tool conversation entries from the index.
	 * @returns {Array<Object>}
	 */
	get_tool_conversations() {
		const index = this.#storage.get_app_index() || [];
		return index
			.map(item => ({ guid: item[this.#storage.KEY_INDEX_GUID], conversation: this.#storage.get_conversation(item[this.#storage.KEY_INDEX_GUID]) }))
			.filter(({ conversation }) => this.#is_tool_type(conversation?.[this.#storage.KEY_CONVERSATION_TYPE]));
	}

	/**
	 * Sorts tool entries: bookmarked first, then the rest.
	 * @param {Array<Object>} tools - The tool entries.
	 * @returns {Array<Object>} The sorted entries.
	 */
	#sorted_tools(tools) {
		const bookmarked = tools.filter(item => item.conversation.BOOKMARKED === true);
		const others = tools.filter(item => item.conversation.BOOKMARKED !== true);
		return [...bookmarked, ...others];
	}

	/**
	 * Determines the display title for a tool conversation.
	 * @param {Object} conversation - The conversation object.
	 * @returns {string} The display title.
	 */
	get_tool_title(conversation) {
		if (!conversation) return 'New Tool';
		if (conversation[this.#storage.KEY_CONVERSATION_CUSTOM_TITLE]) {
			return conversation[this.#storage.KEY_CONVERSATION_TITLE] || 'New Tool';
		}
		const history = conversation[this.#storage.KEY_CONVERSATION_HISTORY] || [];
		for (let i = history.length - 1; i >= 0; i--) {
			const item = history[i];
			if (item?.conversationTitle) {
				return item.conversationTitle;
			}
		}
		return conversation[this.#storage.KEY_CONVERSATION_TITLE] || 'New Tool';
	}

	/**
	 * Creates and returns a DOM element for a single tool entry in the index.
	 * @param {Object} conversation - The conversation object.
	 * @param {string} guid - The unique identifier of the tool conversation.
	 * @returns {HTMLElement} The created index item element.
	 */
	create_tool_index_item(conversation, guid) {
		const type = conversation[this.#storage.KEY_CONVERSATION_TYPE];
		const typeIcon = type === this.#storage.CONVERSATION_TYPE_RANKING
			? '&#127942;'
			: type === this.#storage.CONVERSATION_TYPE_COLORS
				? '&#127912;'
				: '&#128203;';
		const isBookmarked = conversation.BOOKMARKED === true;
		const iconClass = isBookmarked ? 'bookmark-icon-active' : 'bookmark-icon-inactive';
		const escapedTitle = escapeHtml(this.get_tool_title(conversation));
		const config = this.#storage.get_app_config();
		const is_selected = guid === config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		const is_loading = this.#app_callbacks.is_request_active ? this.#app_callbacks.is_request_active(guid) : false;
		const isChecked = this.#selection.isSelected(guid);
		const html = `
			<div class="div-index-item div-index-item-flex ${isBookmarked ? 'div-index-item-bookmarked' : ''} ${is_loading ? 'div-index-item-loading' : ''} ${is_selected ? 'div-index-item-selected' : ''}"
				 id="tool-item-${guid}"
				 data-guid="${guid}">
				${this.#selection.isSelectionMode ? `<input type="checkbox" ${isChecked ? 'checked' : ''} class="response-item-checkbox index-item-checkbox-margin">` : ''}
				<span class="index-item-icon as-icon ${iconClass}">&#128278;</span>
				<span class="index-item-icon as-icon">${typeIcon}</span>
				<span class="index-item-text-grow" title="${escapedTitle}">${escapedTitle}</span>
				<span class="index-item-indicator as-icon" style="display: ${is_loading ? 'inline-block' : 'none'};" title="Processing...">&#9679;</span>
			</div>
		`;
		const index_item = createElementFromHTML(html);
		if (!index_item) return document.createElement('div');

		const chk = index_item.querySelector('.response-item-checkbox');
		if (chk) {
			chk.onclick = (e) => {
				e.stopPropagation();
				this.#selection.toggleItem(guid, chk.checked);
				this._update_selection_ui();
			};
		}

		index_item.onclick = () => {
			if (window.innerWidth <= this.BREAKPOINT_MOBILE) {
				this.#app_callbacks.set_i_open(false);
				this.#app_callbacks.apply_panels_layout();
			}
			this.#app_callbacks.update_app_config(this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID, guid);
			this.#app_callbacks.on_conversation_updated_main_panel();
			this.highlight_active_index_item();
		};
		return index_item;
	}

	/**
	 * Creates and returns the DOM element for the "New Ranking" trigger button.
	 * @returns {HTMLElement}
	 */
	create_new_ranking_trigger_item() {
		const html = `
			<button class="btn-new-scratchpad">
				<span class="index-item-icon as-icon" style="margin-right: 0.5em;">&#10133;</span>
				<span>New Ranking</span>
			</button>
		`;
		const div = createElementFromHTML(html);
		if (!div) return document.createElement('button');

		div.onclick = (e) => {
			e.stopPropagation();
			this.create_new_ranking();
		};
		return div;
	}

	/**
	 * Creates and returns the DOM element for the "New Colors Palettes" trigger button.
	 * @returns {HTMLElement}
	 */
	create_new_palettes_trigger_item() {
		const html = `
			<button class="btn-new-scratchpad">
				<span class="index-item-icon as-icon" style="margin-right: 0.5em;">&#10133;</span>
				<span>New Colors Palettes</span>
			</button>
		`;
		const div = createElementFromHTML(html);
		if (!div) return document.createElement('button');

		div.onclick = (e) => {
			e.stopPropagation();
			this.create_new_palettes();
		};
		return div;
	}

	/**
	 * Creates and returns the DOM element for the "New Sticky Notes" trigger button.
	 * @returns {HTMLElement}
	 */
	create_new_sticky_notes_trigger_item() {
		const html = `
			<button class="btn-new-scratchpad">
				<span class="index-item-icon as-icon" style="margin-right: 0.5em;">&#10133;</span>
				<span>New Sticky Notes</span>
			</button>
		`;
		const div = createElementFromHTML(html);
		if (!div) return document.createElement('button');

		div.onclick = (e) => {
			e.stopPropagation();
			this.create_new_sticky_notes();
		};
		return div;
	}

	//endregion

	//region UI Updates

	/**
	 * Updates the loading indicator for a specific tool item in the index.
	 * @param {string} guid - The tool conversation guid.
	 * @param {boolean} isLoading - Whether the request is active.
	 */
	set_tool_loading(guid, isLoading) {
		if (!this.div_index_list) return;
		const itemEl = document.getElementById(`tool-item-${guid}`) || this.div_index_list.querySelector(`[data-guid="${guid}"]`);
		if (itemEl && this.div_index_list.contains(itemEl)) {
			if (isLoading) {
				itemEl.classList.add('div-index-item-loading');
			} else {
				itemEl.classList.remove('div-index-item-loading');
			}
			const indicator = itemEl.querySelector('.index-item-indicator');
			if (indicator) {
				indicator.style.display = isLoading ? 'inline-block' : 'none';
			}
		}
	}

	/**
	 * Updates the tools index selection controls (buttons, active states).
	 * @private
	 */
	_update_selection_ui() {
		const config = this.#storage.get_app_config();
		const selected_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		const selectedConv = selected_guid ? this.#storage.get_conversation(selected_guid) : null;
		const isToolActive = !!(selectedConv && this.#is_tool_type(selectedConv[this.#storage.KEY_CONVERSATION_TYPE]));
		const hasTools = this.get_tool_conversations().length > 0;

		if (this.btn_tools_index_select) {
			this.btn_tools_index_select.disabled = !hasTools;
		}

		if (this.#selection.isSelectionMode) {
			this.#selection.updateControls(
				this.btn_tools_index_select,
				[this.btn_tools_index_favorite, this.btn_tools_index_delete],
				'Select'
			);
		} else {
			if (this.btn_tools_index_select) {
				this.btn_tools_index_select.textContent = 'Select';
			}
			if (this.btn_tools_index_favorite) {
				this.btn_tools_index_favorite.disabled = !isToolActive;
			}
			if (this.btn_tools_index_delete) {
				this.btn_tools_index_delete.disabled = true;
			}
		}
	}

	/**
	 * Highlights the active tool conversation in the index.
	 */
	highlight_active_index_item() {
		if (!this.div_index_list) return;
		const config = this.#storage.get_app_config();
		const selected_guid = config[this.#storage.KEY_CONFIG_SELECTED_CONVERSATION_GUID];

		const indexItems = this.div_index_list.querySelectorAll('.div-index-item');
		indexItems.forEach((item) => {
			if (item.dataset.guid && item.dataset.guid === selected_guid) {
				item.classList.add('div-index-item-selected');
			} else {
				item.classList.remove('div-index-item-selected');
			}
		});
		this._update_selection_ui();
	}

	/**
	 * Re-renders the tools index view in the DOM.
	 */
	on_tools_index_updated() {
		(window.__trace ||= []).push(["tools", Math.round(performance.now())]);
		if (this.div_index_list) {
			this.div_index_list.innerHTML = '';

			this.div_index_list.appendChild(this.create_new_ranking_trigger_item());
			this.div_index_list.appendChild(this.create_new_sticky_notes_trigger_item());
			// colors palettes are experimental: the creation button only
			// shows while the flag is on
			if (this.#app_callbacks.experimental_enabled?.()) {
				this.div_index_list.appendChild(this.create_new_palettes_trigger_item());
			}

			this.get_tool_conversations().forEach(({ guid, conversation }) => {
				this.div_index_list.appendChild(this.create_tool_index_item(conversation, guid));
			});
		}

		this.highlight_active_index_item();
	}

	//endregion

}

export default ToolsIndex;
🌐
tools-index.js ×
Type: Web, text/plain
15.00 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File