diff --git a/frontend/js/dragdrop.js b/frontend/js/dragdrop.js index 4ccca93..b4be816 100644 --- a/frontend/js/dragdrop.js +++ b/frontend/js/dragdrop.js @@ -13,6 +13,7 @@ import { t } from './i18n.js'; import { showToast } from './ui.js'; import { refreshSidebarTreePreservingState } from './sidebar.js'; import { readDataTransferEntries, handleVaultDropUpload } from './upload.js'; +import booksLM from './bookslm.js'; // --------------------------------------------------------------------------- // State @@ -52,15 +53,14 @@ function getDropTarget(el) { // Directory tree item const dirItem = closest('.tree-item[data-path]', el); if (dirItem) { - // Only directories are valid targets - const icon = dirItem.querySelector('[data-lucide]'); - const isDir = icon && (icon.getAttribute('data-lucide') === 'folder' - || icon.getAttribute('data-lucide') === 'chevron-right' - || icon.getAttribute('data-lucide') === 'chevron-down'); - // Heuristic: if the tree item has a child container sibling, it's a directory + const isExplicitDir = dirItem.dataset.type === 'directory'; + const icon = dirItem.querySelector('[data-lucide], i'); + const iconName = icon?.getAttribute('data-lucide'); + const isFolderIcon = iconName === 'folder' || iconName === 'chevron-right' || iconName === 'chevron-down'; const hasChildren = dirItem.nextElementSibling && dirItem.nextElementSibling.classList.contains('tree-children'); - if (isDir || hasChildren) { + + if (isExplicitDir || isFolderIcon || hasChildren) { return { vault: dirItem.dataset.vault, targetDir: dirItem.dataset.path, @@ -149,9 +149,10 @@ function onDragStart(e) { if (!vault || path === undefined) return; // Determine type: directory or file + const isExplicitDir = treeItem.dataset.type === 'directory'; const hasChildren = treeItem.nextElementSibling && treeItem.nextElementSibling.classList.contains('tree-children'); - const type = hasChildren ? 'directory' : 'file'; + const type = (isExplicitDir || hasChildren) ? 'directory' : 'file'; dragState = { vault, path, type, element: treeItem }; @@ -159,6 +160,9 @@ function onDragStart(e) { e.dataTransfer.effectAllowed = 'move'; // Prefix with 'tree:' so pane drop zones can distinguish from tab drags e.dataTransfer.setData('text/plain', 'tree:' + JSON.stringify({ vault, path })); + e.dataTransfer.setData('tree:vault', vault); + e.dataTransfer.setData('tree:path', path); + e.dataTransfer.setData('tree:type', type); // Visual: reduce opacity of source treeItem.classList.add(DRAG_SOURCE_CLASS); @@ -170,10 +174,12 @@ function onDragStart(e) { if (item.classList.contains('vault-item')) { item.classList.add(DRAG_VALID_TARGET_CLASS); } else { - const icon = item.querySelector('[data-lucide]'); + const icon = item.querySelector('[data-lucide], i'); + const iconName = icon?.getAttribute('data-lucide'); + const isFolder = item.dataset.type === 'directory' || iconName === 'folder' || iconName === 'chevron-right' || iconName === 'chevron-down'; const hasChildContainer = item.nextElementSibling && item.nextElementSibling.classList.contains('tree-children'); - if (icon && (icon.getAttribute('data-lucide') === 'folder' || hasChildContainer)) { + if (isFolder || hasChildContainer) { item.classList.add(DRAG_VALID_TARGET_CLASS); } } @@ -436,16 +442,25 @@ export function initGlobalDropOverlay() { if (zoneType === 'vault') { const activeVault = window.state?.activeVault || (document.querySelector('.tree-item.vault-item')?.dataset?.vault); if (activeVault) { - await handleVaultDropUpload(activeVault, '', items); + // Pass null for targetDir so user is prompted to choose destination folder + await handleVaultDropUpload(activeVault, null, items); } else { showToast('Aucun vault sélectionné', 'error'); } } else if (zoneType === 'ai') { - // Dispatch or pass to BooksLM assistant - if (window.BooksLMInstance) { - window.BooksLMInstance.handleExternalDroppedItems(items); + // Automatically open assistant if not already open and pass items + if (booksLM) { + if (!booksLM._isOpen) { + await booksLM.openForCurrentContext(); + } + await booksLM.handleExternalDroppedItems(items); + } else if (window.BooksLMInstance) { + if (!window.BooksLMInstance._isOpen) { + await window.BooksLMInstance.openForCurrentContext(); + } + await window.BooksLMInstance.handleExternalDroppedItems(items); } else { - showToast("Assistant IA non initialisé", 'warning'); + showToast("Assistant IA non disponible", 'warning'); } } }); diff --git a/frontend/js/sidebar.js b/frontend/js/sidebar.js index b4146e0..46e8fad 100644 --- a/frontend/js/sidebar.js +++ b/frontend/js/sidebar.js @@ -332,7 +332,7 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) { } else { // New item — create it if (item.type === "directory") { - const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]); + const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "directory" }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]); attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false); attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false })); fragment.appendChild(dirItem); @@ -365,7 +365,7 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) { } else { const fileIconName = getFileIcon(item.name); const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name; - const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]); + const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]); attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false); attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false })); fileItem.addEventListener("click", () => { @@ -626,7 +626,7 @@ async function loadDirectory(vaultName, dirPath, container) { } if (item.type === "directory") { - const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]); + const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "directory" }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]); attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false); attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false })); fragment.appendChild(dirItem); @@ -660,7 +660,7 @@ async function loadDirectory(vaultName, dirPath, container) { } else { const fileIconName = getFileIcon(item.name); const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name; - const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]); + const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]); attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false); attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false })); fileItem.addEventListener("click", () => { diff --git a/frontend/js/upload.js b/frontend/js/upload.js index 737ab94..2236cf8 100644 --- a/frontend/js/upload.js +++ b/frontend/js/upload.js @@ -181,14 +181,22 @@ export async function uploadBatch(vault, targetDir, items, options = {}) { export async function handleVaultDropUpload(vault, targetDir, items) { if (!items || !items.length) return; + // If target directory is not explicitly passed or if dropping on vault root, + // allow user to choose or confirm the target folder + let chosenTargetDir = targetDir || ''; + if (targetDir === undefined || targetDir === null) { + const picked = await promptTargetFolder(vault, chosenTargetDir, items); + if (picked === null) return; // User cancelled + chosenTargetDir = picked; + } + const fileCount = items.filter((x) => !x.isDir).length; - const dirCount = items.filter((x) => x.isDir).length; - const destName = targetDir ? `${vault}/${targetDir}` : vault; + const destName = chosenTargetDir ? `${vault}/${chosenTargetDir}` : vault; showToast(`${t('upload.uploading', { count: fileCount }) || `Uploading ${fileCount} file(s) to ${destName}...`}`, 'info'); try { - const res = await uploadBatch(vault, targetDir, items); + const res = await uploadBatch(vault, chosenTargetDir, items); if (res && res.success) { showToast(t('upload.success', { count: res.uploaded.length }) || `Imported ${res.uploaded.length} file(s) successfully`, 'success'); } else if (res && res.uploaded && res.uploaded.length > 0) { @@ -203,3 +211,92 @@ export async function handleVaultDropUpload(vault, targetDir, items) { showToast(`${t('upload.error') || 'Upload failed'}: ${err.message || err}`, 'error'); } } + +/** + * Display a folder picker modal for choosing the upload destination folder. + * + * @param {string} vault - Vault name + * @param {string} currentDir - Initial folder path + * @param {Array} items - Items to upload + * @returns {Promise} Selected folder path (empty for root) or null if cancelled. + */ +export async function promptTargetFolder(vault, currentDir = '', items = []) { + let folders = []; + try { + const res = await api(`/api/vault/${encodeURIComponent(vault)}/paths?limit=5000`); + if (res && Array.isArray(res.paths)) { + folders = res.paths + .filter((p) => p.type === 'directory') + .map((p) => p.path) + .sort(); + } + } catch (e) { + console.warn('Could not fetch vault directory paths:', e); + } + + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.className = 'obsigate-modal-overlay active'; + overlay.style.zIndex = '100000'; + + const fileCount = items.filter((x) => !x.isDir).length; + const dirCount = items.filter((x) => x.isDir).length; + + let optionsHtml = ``; + for (const folder of folders) { + const selected = folder === currentDir ? 'selected' : ''; + optionsHtml += ``; + } + + overlay.innerHTML = ` +
+ + +
+ `; + + document.body.appendChild(overlay); + + const close = (val) => { + overlay.remove(); + resolve(val); + }; + + overlay.querySelector('.modal-close').addEventListener('click', () => close(null)); + overlay.querySelector('.modal-cancel-btn').addEventListener('click', () => close(null)); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(null); + }); + + overlay.querySelector('.modal-confirm-btn').addEventListener('click', () => { + const select = overlay.querySelector('#obsigate-upload-folder-select'); + close(select ? select.value : ''); + }); + }); +} + +function escapeHtml(str) { + return String(str || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/frontend/locales/en.json b/frontend/locales/en.json index fd5177e..1963aae 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -641,6 +641,11 @@ "upload.success": "Successfully imported {count} file(s)", "upload.partial": "Imported {count} file(s) ({errors} errors)", "upload.error": "Upload failed", + "upload.choose_target_folder": "Choose destination folder", + "upload.vault_root": "Vault root", + "upload.target_directory": "Destination directory in", + "upload.import_summary": "Items to import: {files} file(s), {dirs} folder(s)", + "upload.import_button": "Import", "editor.cancel": "Cancel", "editor.close": "Close", "editor.delete_error": "Delete error", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 32584d4..869f86e 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -641,6 +641,11 @@ "upload.success": "{count} fichier(s) importé(s) avec succès", "upload.partial": "{count} fichier(s) importé(s) ({errors} erreurs)", "upload.error": "Échec de l'importation", + "upload.choose_target_folder": "Choisir le dossier de destination", + "upload.vault_root": "Racine du vault", + "upload.target_directory": "Dossier de destination dans", + "upload.import_summary": "Éléments à importer : {files} fichier(s), {dirs} dossier(s)", + "upload.import_button": "Importer", "editor.cancel": "Annuler", "editor.close": "Fermer", "editor.delete_error": "Erreur de suppression",