From 97d958a400fb6f8ba5d91a57c658f648ea1cb7f2 Mon Sep 17 00:00:00 2001 From: Golwhit Date: Sat, 27 Dec 2025 15:05:42 -0600 Subject: [PATCH] minor changes added voice to search. one touch highlight --- backend/server.ts | 60 +++++++++++- frontend/index.html | 227 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 274 insertions(+), 13 deletions(-) diff --git a/backend/server.ts b/backend/server.ts index f04d78e..233496a 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -103,13 +103,27 @@ const searchWithinFolder = async (driveId: string, itemId: string, query: string return (data.value || []) as DriveItem[]; }; -const filterByCategory = (items: DriveItem[], category?: string) => { - if (!category) return items; +const filterByCategory = (items: DriveItem[], category?: string, pdfOnly: boolean = true) => { + // First filter: show PDF files and Word documents (which will be converted to PDF) + let filtered = items; + if (pdfOnly) { + filtered = items.filter((i) => { + if (i.folder) return false; + const name = i.name.toLowerCase(); + const mimeType = i.file?.mimeType?.toLowerCase() || ''; + // Include PDFs and Word documents + return name.endsWith('.pdf') || mimeType.includes('pdf') || + name.endsWith('.doc') || name.endsWith('.docx') || mimeType.includes('word'); + }); + } + + // Second filter: category filtering (if specified) + if (!category) return filtered; const nameHas = (name: string, words: string[]) => words.some((w) => name.includes(w)); const lc = (s: string) => (s || '').toLowerCase(); const contractWords = ['contract', 'estimate', 'proposal', 'bid', 'award', 'agreement', 'sow']; const planWords = ['plan', 'drawing', 'dwg', 'pdf', 'sheet', 'layout']; - return items.filter((i) => { + return filtered.filter((i) => { const n = lc(i.name); if (category === 'contracts') return nameHas(n, contractWords); if (category === 'plans') return nameHas(n, planWords); @@ -143,7 +157,9 @@ app.get('/api/job-files', async (c) => { items = await listChildrenRecursive(driveId, itemId, 0, 5, token); } - const filtered = filterByCategory(items, category); + // Filter to show only PDFs by default (can be disabled with pdfOnly=false query param) + const pdfOnly = c.req.query('pdfOnly') !== 'false'; + const filtered = filterByCategory(items, category, pdfOnly); const mapped = filtered.map((i) => ({ id: i.id, @@ -196,6 +212,42 @@ app.get('/api/job-file-content', async (c) => { } }); +// Convert Word documents to PDF for display +app.get('/api/job-file-pdf', async (c) => { + try { + const token = getGraphToken(c); + if (!token) return c.json({ error: 'GRAPH_TOKEN missing' }, 500); + const driveId = c.req.query('driveId'); + const itemId = c.req.query('itemId'); + if (!driveId || !itemId) return c.json({ error: 'driveId and itemId required' }, 400); + + // Use Graph API to convert to PDF format + const res = await fetch(`https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content?format=pdf`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + const text = await res.text(); + logLine('logs/error.log', `/api/job-file-pdf conversion failed: ${res.status} - ${text}`); + return c.json({ error: `PDF conversion failed: ${res.status}` }, res.status); + } + const headers: Record = { + 'Content-Type': 'application/pdf', + }; + const cd = res.headers.get('content-disposition'); + if (cd) { + // Replace .docx/.doc extension with .pdf in filename + headers['Content-Disposition'] = cd.replace(/\.(docx?)/gi, '.pdf'); + } + logLine('logs/server.log', `Successfully converted document to PDF: driveId=${driveId}, itemId=${itemId}`); + return new Response(res.body, { status: 200, headers }); + } catch (err) { + const message = (err as Error)?.message || String(err); + const status = (err as any)?.status || 500; + logLine('logs/error.log', `/api/job-file-pdf error: ${message}`); + return c.json({ error: message }, status); + } +}); + // Mutation logging endpoint app.post('/log-change', async (c) => { try { diff --git a/frontend/index.html b/frontend/index.html index e8836ba..b833e4f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -59,7 +59,17 @@
- +
+ + +
@@ -531,9 +541,179 @@ } function escapeHtml(str){ return String(str).replaceAll('&','&').replaceAll('<','<').replaceAll('>','>'); } + // --- Voice Search --- + const voiceSearchBtn = document.getElementById('voiceSearchBtn'); + const micIcon = document.getElementById('micIcon'); + const micRecordingIcon = document.getElementById('micRecordingIcon'); + let recognition = null; + let isListening = false; + let hasPermission = false; + + // Check if we're on HTTPS or localhost (required for mic access) + const isSecureContext = window.isSecureContext || location.protocol === 'https:' || location.hostname === 'localhost'; + if (!isSecureContext) { + console.warn('⚠️ Voice search requires HTTPS or localhost'); + voiceSearchBtn.style.display = 'none'; + } + + // Request microphone permission explicitly + async function requestMicPermission() { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + // Stop the stream immediately, we just wanted to request permission + stream.getTracks().forEach(track => track.stop()); + hasPermission = true; + console.log('✅ Microphone permission granted'); + return true; + } catch (err) { + console.error('❌ Microphone permission denied:', err); + alert('🎤 Please allow microphone access to use voice search.\n\nClick the microphone icon in your browser\'s address bar to enable it.'); + return false; + } + } + + // Check if browser supports speech recognition + if (isSecureContext && ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window)) { + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + recognition = new SpeechRecognition(); + recognition.continuous = true; // Changed to true to keep listening + recognition.interimResults = true; + recognition.lang = 'en-US'; + recognition.maxAlternatives = 1; + + let autoStopTimer = null; + let silenceTimer = null; + + recognition.onstart = () => { + isListening = true; + + // Auto-stop after 10 seconds as a safety measure + autoStopTimer = setTimeout(() => { + if (isListening) { + recognition.stop(); + } + }, 10000); + }; + + recognition.onresult = (event) => { + // Clear any existing silence timer + if (silenceTimer) { + clearTimeout(silenceTimer); + silenceTimer = null; + } + + const last = event.results.length - 1; + const transcript = event.results[last][0].transcript; + const isFinal = event.results[last].isFinal; + + searchBox.value = transcript.trim(); + + // Trigger search with results + applyFiltersAndRender(); + + // Stop after final result with short delay + if (isFinal) { + silenceTimer = setTimeout(() => { + recognition.stop(); + }, 1500); + } + }; + + recognition.onspeechstart = () => {}; + recognition.onspeechend = () => {}; + recognition.onaudiostart = () => {}; + recognition.onaudioend = () => {}; + recognition.onsoundstart = () => {}; + recognition.onsoundend = () => {}; + + recognition.onerror = (event) => { + // Clear timers + if (autoStopTimer) clearTimeout(autoStopTimer); + if (silenceTimer) clearTimeout(silenceTimer); + + isListening = false; + + if (event.error === 'not-allowed' || event.error === 'permission-denied') { + alert('🎤 Microphone access denied.\n\nPlease:\n1. Click the microphone icon in your browser\'s address bar\n2. Allow microphone access\n3. Refresh the page'); + hasPermission = false; + } else if (event.error === 'audio-capture') { + alert('🎤 Microphone error. Please check:\n1. Your microphone is connected\n2. It\'s not being used by another app\n3. Browser has microphone access'); + } + }; + + recognition.onend = () => { + // Clear timers + if (autoStopTimer) clearTimeout(autoStopTimer); + if (silenceTimer) clearTimeout(silenceTimer); + + isListening = false; + }; + + voiceSearchBtn.addEventListener('click', async () => { + if (isListening) { + recognition.stop(); + return; + } + + // Request permission first time + if (!hasPermission) { + const granted = await requestMicPermission(); + if (!granted) { + return; + } + } + + // Reset state + isListening = false; + + try { + recognition.start(); + } catch (err) { + if (err.message && (err.message.includes('already started') || err.name === 'InvalidStateError')) { + recognition.stop(); + setTimeout(() => { + try { + recognition.start(); + } catch (e) { + alert('Voice recognition error. Please refresh the page and try again.'); + } + }, 300); + } else { + alert('Could not start voice recognition: ' + err.message); + } + } + }); + } else { + // Hide voice button if not supported + if (voiceSearchBtn) voiceSearchBtn.style.display = 'none'; + console.warn('⚠️ Speech recognition not supported in this browser'); + } + // --- Filters --- filterToggle.addEventListener('click',()=>{ filtersPanel.classList.toggle('hidden'); filterToggle.textContent = filtersPanel.classList.contains('hidden')?'Show Filters':'Hide Filters'; }); - searchBox.addEventListener('input',()=>applyFiltersAndRender()); + + // Select all text on first click, normal behavior on second click + let searchBoxSelected = false; + searchBox.addEventListener('focus', () => { + if (!searchBoxSelected) { + searchBox.select(); + searchBoxSelected = true; + } + }); + searchBox.addEventListener('mousedown', () => { + if (searchBoxSelected) { + searchBoxSelected = false; + } + }); + searchBox.addEventListener('blur', () => { + searchBoxSelected = false; + }); + + searchBox.addEventListener('input',()=>{ + // Remove periods added by voice dictation + searchBox.value = searchBox.value.replace(/\./g, ''); + applyFiltersAndRender(); + }); document.querySelectorAll('.filter-division').forEach(cb=>cb.addEventListener('change',()=>applyFiltersAndRender())); document.querySelectorAll('.filter-active').forEach(cb=>{ cb.addEventListener('change',()=>{ if(cb.checked) document.querySelectorAll('.filter-active').forEach(o=>{if(o!==cb)o.checked=false;}); applyFiltersAndRender(); @@ -968,6 +1148,7 @@ const fileListContainer = document.getElementById('fileListContainer'); const categoryKeywords = { + managerInfo: ['manager info', 'manager_info', 'managerinfo'], contracts: ['contract', 'estimate', 'proposal', 'bid', 'award', 'agreement', 'sow'], plans: ['plan', 'drawing', 'dwg', 'pdf', 'sheet', 'layout'], submittals: ['submittal', 'submittals', 'submit'], @@ -994,6 +1175,7 @@ const categorize = (name = '') => { const n = name.toLowerCase(); + if (categoryKeywords.managerInfo.some((w) => n.includes(w))) return 'managerInfo'; if (categoryKeywords.contracts.some((w) => n.includes(w))) return 'contracts'; if (categoryKeywords.submittals.some((w) => n.includes(w))) return 'submittals'; if (categoryKeywords.plans.some((w) => n.includes(w))) return 'plans'; @@ -1058,6 +1240,7 @@ if (ext === 'pdf' || type.includes('pdf')) return 'PDF'; + // Show Word icon with PDF badge to indicate it will be converted if (['doc', 'docx'].includes(ext) || type.includes('word')) return 'W'; @@ -1082,7 +1265,10 @@ const canPreviewInline = (name, contentType) => { const ext = (name || '').toLowerCase().split('.').pop(); const type = (contentType || '').toLowerCase(); - return ext === 'pdf' || type.includes('pdf') || ['jpg', 'jpeg', 'png', 'gif', 'svg', 'bmp'].includes(ext) || type.includes('image'); + // Allow Word docs to be previewed (will be converted to PDF) + return ext === 'pdf' || type.includes('pdf') || + ['doc', 'docx'].includes(ext) || type.includes('word') || + ['jpg', 'jpeg', 'png', 'gif', 'svg', 'bmp'].includes(ext) || type.includes('image'); }; function openFileInViewer(url, name) { @@ -1287,10 +1473,15 @@ iframeViewer.classList.add('hidden'); pdfViewer.classList.add('hidden'); iframeLoader.classList.remove('hidden'); + + // Detect if this is a Word document + const ext = (name || '').toLowerCase().split('.').pop(); + const isWordDoc = ['doc', 'docx'].includes(ext) || (contentType || '').toLowerCase().includes('word'); + iframeLoader.innerHTML = `
-
Loading preview...
+
${isWordDoc ? 'Converting to PDF...' : 'Loading preview...'}
`; @@ -1301,19 +1492,26 @@ } try { - const res = await fetch(`/api/job-file-content?driveId=${encodeURIComponent(driveId)}&itemId=${encodeURIComponent(itemId)}`, { + // Use PDF conversion endpoint for Word documents + const endpoint = isWordDoc + ? `/api/job-file-pdf?driveId=${encodeURIComponent(driveId)}&itemId=${encodeURIComponent(itemId)}` + : `/api/job-file-content?driveId=${encodeURIComponent(driveId)}&itemId=${encodeURIComponent(itemId)}`; + + const res = await fetch(endpoint, { headers: { 'x-graph-token': graphToken } }); if (!res.ok) throw new Error(`Preview fetch failed: ${res.status}`); const blob = await res.blob(); currentPreviewUrl = URL.createObjectURL(blob); - const isPdf = (contentType || '').toLowerCase().includes('pdf') || (name || '').toLowerCase().endsWith('.pdf'); + // Word docs are converted to PDF, so treat them as PDFs + const isPdf = isWordDoc || (contentType || '').toLowerCase().includes('pdf') || (name || '').toLowerCase().endsWith('.pdf'); if (isPdf && window['pdfjsLib']) { const data = await blob.arrayBuffer(); pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; const doc = await pdfjsLib.getDocument({ data }).promise; - currentPdf = { doc, page: 1, pages: doc.numPages, scale: DEFAULT_PDF_SCALE }; + const initialScale = isWordDoc ? 1.4 : DEFAULT_PDF_SCALE; + currentPdf = { doc, page: 1, pages: doc.numPages, scale: initialScale }; pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 }; pdfCanvas.style.transform = 'translate(0px, 0px)'; await renderPdfPage(); @@ -1338,8 +1536,18 @@ function renderFileGroups(folderLink) { const term = (fileListState.filter || '').toLowerCase().trim(); const filtered = fileListState.items - .filter((f) => !f.isFolder && f.name.toLowerCase().includes(term)); - const groups = { contracts: [], submittals: [], plans: [], other: [] }; + .filter((f) => { + // Show PDF files and Word documents (which will be converted to PDF) + if (f.isFolder) return false; + const name = f.name.toLowerCase(); + const contentType = (f.contentType || '').toLowerCase(); + const isPdfOrWord = name.endsWith('.pdf') || contentType.includes('pdf') || + name.endsWith('.doc') || name.endsWith('.docx') || contentType.includes('word'); + if (!isPdfOrWord) return false; + // Apply search term filter if present + return name.includes(term); + }); + const groups = { managerInfo: [], contracts: [], submittals: [], plans: [], other: [] }; filtered.forEach((f) => groups[categorize(f.name)].push(f)); const renderGroup = (title, list) => { @@ -1378,6 +1586,7 @@ `; }; const groupsMarkup = ` + ${renderGroup('Manager Info', groups.managerInfo)} ${renderGroup('Contracts / Estimates', groups.contracts)} ${renderGroup('Submittals', groups.submittals)} ${renderGroup('Plans', groups.plans)}