diff --git a/.gitignore b/.gitignore index 3bfa3977..de85f8ce 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,9 @@ Thumbs.db # Optional Editor configurations .vscode/ .idea/ -studyplan.db \ No newline at end of file +studyplan.db + +# Tesseract OCR language data cache (downloaded at runtime) +backend/.tesseract-cache/ +*.traineddata +.gstack/ diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 00000000..cb2fd5de --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,88 @@ +# Changes — Image Task Extraction + Priority Board + +This document summarizes everything added/changed in this branch, for review and for the PR description. + +## Summary + +Two features, plus two bug fixes found while building them: + +1. **Image task extraction** — upload a photo/screenshot (WhatsApp message, LMS page, handwritten note, timetable) and get structured tasks extracted automatically, the same way pasted text already works. +2. **Priority Board** — the top navbar "Tasks" link now opens a dedicated view that groups every task into High / Medium / Low priority columns, each with a consistent color. The calendar's deadline dots now use the same priority colors, and clicking a calendar date offers a way to jump straight to that day's tasks in the board. +3. **Fix:** toast notifications (`Toast.show()` / `Toast.confirm()`) had no CSS anywhere in the project — they rendered as unstyled text at the bottom of the page instead of floating notifications, and never got removed from the DOM after their timeout (no animation existed to trigger the removal listener). +4. **Fix:** the right-hand "Smart Paste" panel had `overflow: hidden` with no scroll — once its content grew taller than the fixed app height, whatever was below the fold (like the "Add to planner" button) became completely unreachable. + +--- + +## 1. Image Task Extraction + +**Flow:** `Image upload → Gemini 2.5 Flash (multimodal vision) → structured tasks`, with automatic fallback to **Tesseract.js OCR + the existing heuristic parser** if Gemini fails 5 times in a row or no API key is configured. The fallback reason is always reported back to the UI. + +### New files +- `backend/controllers/imageExtract.controller.js` — handles the upload, the 5-retry Gemini vision loop, and the OCR fallback path. +- `backend/routers/imageExtract.router.js` — `POST /api/extract/image`, multer memory-storage upload (PNG/JPEG, 8MB max). +- `backend/utils/nlpTextExtractor.js` — the existing ~200-line regex/heuristic NLP fallback, **moved out of `server.js`** (not rewritten) so both the text route and the new image route can share it without duplicating it. +- `js/utils/imageExtract.js` — client-side upload UI logic (drag/drop, file picker, progress states, OCR-preview panel for fallback mode). + +### Files touched +- `server.js` — two additive lines to mount the new router, plus the `nlpTextExtractor.js` extraction described above. No behavior changes (verified byte-identical output before/after the move). +- `index.html` — new "Upload a photo or screenshot" section in the Smart Paste panel, separate from the existing text-paste drop zone (which is untouched). +- `js/app.js` — one import + one init call for the new module. +- `css/index.css` — new styles for the image upload UI, additive only. +- `package.json` / `package-lock.json` — added `multer` and `tesseract.js`. +- `.gitignore` — Tesseract's downloaded OCR language data (`*.traineddata`, `backend/.tesseract-cache/`) is excluded so it doesn't get committed. + +### Testing performed +- Verified Gemini vision extraction end-to-end with real images (WhatsApp-style, notice-board style), including hashtag (`#urgent`) preservation. +- Verified the "no API key" fallback path skips straight to OCR. +- Verified the "5 failed attempts" fallback path (simulated with an invalid key) retries exactly 5 times, then falls back to OCR with a clear, human-readable reason. +- Regression-tested the existing text-paste `/api/extract` route — unchanged output before/after the `nlpTextExtractor.js` move. + +--- + +## 2. Priority Board + +### New UI +- Clicking **Tasks** in the top navbar (previously duplicated the sidebar's "All Tasks" view) now opens a **Task Priority Board**: three columns (High / Medium / Low), each with a colored header/dot and a running count, cards color-coded to match. +- Sidebar and Smart Paste panel stay visible — only the main calendar area is replaced. +- **Colors are consistent app-wide:** red = high, amber = medium, green = low (reusing the existing `--color-text-danger` / `--color-text-warning` / `--color-text-success` design tokens). + +### Calendar integration +- Calendar day dots now reflect task **priority** instead of subject color, so newly extracted/added tasks (from text or image) are automatically highlighted by urgency the moment they're added. +- The calendar legend was updated to match (High/Medium/Low priority instead of the old "Study session"/"Deadline" labels). +- Clicking a calendar date keeps its existing behavior (in-place task list + "mark day complete") **and** adds a new **"View in Task Board →"** button that jumps to the board pre-filtered to that date, with a "Clear filter" option. + +### Files touched +- `index.html` — new `#priority-board-section` markup, updated calendar legend. +- `js/app.js` — `showPriorityBoard()` / `hidePriorityBoard()` / `renderPriorityBoard()`, a `PRIORITY_COLORS` helper, the navbar "Tasks" handler rewired to the new view, and `hidePriorityBoard()` calls added to every other view-switch handler so views stay mutually exclusive. +- `css/index.css` — new priority-board styles, additive only. + +### Testing performed +- Seeded high/medium/low priority tasks and confirmed calendar dot colors and board columns match. +- Confirmed checking a task complete in the board updates counts live. +- Confirmed date-click → "View in Task Board" → correct date-filtered results → "Clear filter" round-trips correctly. +- Regression-tested Calendar, All Tasks (sidebar), Focus Mode, and Profile views — all correctly hide the board when navigated away from, no console errors. + +--- + +## 3. Bug fix: toast notifications had no CSS + +`js/utils/toast.js` (`Toast.show()`, `Toast.confirm()`) existed and was already used throughout the app, but `.toast-container`, `.toast-notification`, `.custom-confirm-backdrop`, and `.custom-confirm-modal` had **zero matching CSS anywhere in `css/index.css`**. Symptoms: +- Toasts rendered as plain unstyled text at the very bottom of the page instead of a floating notification. +- `.toast-hiding` had no animation, so the `animationend` listener that removes a toast from the DOM never fired — toasts leaked and could stack up indefinitely. + +Fixed with additive CSS only: fixed-position bottom-right toast container, colored by type, slide-in/out animation, and confirm-dialog backdrop/modal styling (`fadeOut`/`slideUp`/`slideDown` keyframes, which were referenced by `toast.js` but never defined). + +## 4. Bug fix: Smart Paste panel had no scroll + +`.panel` (the right-hand Smart Paste column) had `overflow: hidden` with a fixed-height parent (`.app { height: 720px }`). Once panel content grew past that height (easy to do now with the new image-upload section), anything below the fold — including the "Add to planner" button — became unreachable with no way to scroll to it. Fixed by changing `.panel` to `overflow-y: auto` (all three duplicate copies of the rule in the file, including the two dark-mode variants) and making `.panel-header` sticky so "Smart Paste" stays visible while scrolling. + +--- + +## New dependencies + +- `multer` — multipart file upload handling for `/api/extract/image`. +- `tesseract.js` — OCR fallback when Gemini vision is unavailable. + +## Not touched + +Everything else — auth, CSV/ICS export, subjects, existing task CRUD, focus mode timer, streaks — is unchanged. The one non-additive touch to pre-existing code is the `nlpTextExtractor.js` extraction from `server.js` (a verified behavior-preserving move, not a rewrite). diff --git a/backend/controllers/imageExtract.controller.js b/backend/controllers/imageExtract.controller.js new file mode 100644 index 00000000..8cc65e23 --- /dev/null +++ b/backend/controllers/imageExtract.controller.js @@ -0,0 +1,166 @@ +const path = require('path'); +const { GoogleGenAI } = require('@google/genai'); +const Tesseract = require('tesseract.js'); +const { nlpExtractTasksFromText } = require('../utils/nlpTextExtractor.js'); + +// Keeps Tesseract's downloaded language data (~5MB) out of the project +// root; it's cached here after the first OCR run instead of re-downloading +// per request. +const TESSERACT_CACHE_PATH = path.join(__dirname, '..', '.tesseract-cache'); + +// Own Gemini client instance — reads the same process.env populated by +// dotenv at server startup. Kept separate from server.js's own `ai` +// instance so this controller has no import-time dependency on server.js +// (avoids any risk of circular requires touching the existing routes). +const ai = process.env.GEMINI_API_KEY + ? new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }) + : null; + +const MAX_VISION_ATTEMPTS = 5; + +function buildVisionPrompt() { + return ` +You are an AI study planner assistant. The attached image is a photo or screenshot a student took of an assignment notice — this could be a WhatsApp/LMS screenshot, a printed notice board photo, a handwritten note, or a timetable. The layout may be messy, rotated, or informal. + +Read the image and extract ALL tasks and deadlines you can find. +Return ONLY a raw JSON array (no markdown, no backticks, no explanation). +Each object must have: title (string), subject_name (string), due_at (ISO 8601 datetime), notes (string), confidence_score (number 0-100), priority ("low"|"medium"|"high"), icon (emoji). +If a date is ambiguous or missing, make a reasonable guess and lower the confidence_score accordingly. +IMPORTANT: Do not strip hashtags from the task description! If the image contains hashtag labels (e.g. #urgent, #Group), include them at the end of the 'title' field. +Ignore chat UI chrome such as timestamps, read receipts, sender names, and profile pictures — focus only on assignment/deadline content. +If the image contains no discernible tasks, return an empty JSON array []. +`; +} + +function parseModelJson(rawText) { + let text = (typeof rawText === 'function' ? rawText() : rawText || '').trim(); + if (text.startsWith('```')) text = text.replace(/```json|```/g, '').trim(); + const data = JSON.parse(text); + if (!Array.isArray(data)) throw new Error('Model response was not a JSON array'); + return data; +} + +async function tryGeminiVisionExtraction(buffer, mimeType) { + const prompt = buildVisionPrompt(); + let lastError = null; + + for (let attempt = 1; attempt <= MAX_VISION_ATTEMPTS; attempt++) { + try { + const response = await ai.models.generateContent({ + model: 'gemini-2.5-flash', + contents: [ + { text: prompt }, + { inlineData: { mimeType, data: buffer.toString('base64') } }, + ], + }); + + const tasks = parseModelJson(response.text); + return { tasks, attempts: attempt, error: null }; + } catch (e) { + lastError = e; + console.error(`Image extraction attempt ${attempt}/${MAX_VISION_ATTEMPTS} failed:`, e.message); + } + } + + return { tasks: null, attempts: MAX_VISION_ATTEMPTS, error: lastError }; +} + +function friendlyErrorMessage(error) { + const raw = error?.message || 'unknown error'; + try { + const parsed = JSON.parse(raw); + return parsed?.error?.message || raw; + } catch { + return raw; + } +} + +function cleanOcrText(rawText) { + return String(rawText || '') + .replace(/\r\n/g, '\n') + .replace(/[^\S\n]+/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +async function runOcrFallback(buffer) { + const { data } = await Tesseract.recognize(buffer, 'eng', { cachePath: TESSERACT_CACHE_PATH }); + return cleanOcrText(data.text); +} + +async function extractFromImage(req, res) { + if (!req.file) { + return res.status(400).json({ error: 'No image file provided' }); + } + + const { buffer, mimetype } = req.file; + + try { + if (ai) { + const { tasks, attempts, error } = await tryGeminiVisionExtraction(buffer, mimetype); + + if (tasks) { + return res.json({ + success: true, + method: 'gemini-vision', + attempts, + fallbackUsed: false, + tasks, + }); + } + + // 5 attempts exhausted — fall back to OCR, and report why. + return await respondWithOcrFallback(res, buffer, { + attempts, + reason: `Gemini vision extraction failed after ${attempts} attempt(s). Last error: ${friendlyErrorMessage(error)}`, + }); + } + + // No Gemini key configured at all — go straight to OCR fallback. + return await respondWithOcrFallback(res, buffer, { + attempts: 0, + reason: 'GEMINI_API_KEY is not configured, so image extraction cannot use Gemini vision. Falling back to local OCR + heuristic parsing.', + }); + } catch (e) { + console.error('Unexpected error in image extraction:', e); + return res.status(500).json({ error: 'Unexpected server error during image extraction' }); + } +} + +async function respondWithOcrFallback(res, buffer, { attempts, reason }) { + try { + const ocrText = await runOcrFallback(buffer); + + const tasks = ocrText.length > 5 + ? nlpExtractTasksFromText(ocrText) + : [{ + title: 'Could not read any text from this image', + subject_name: 'General', + due_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + notes: 'OCR found little to no readable text — please try a clearer photo or enter this task manually.', + icon: '❓', + confidence_score: 10, + priority: 'medium', + }]; + + return res.json({ + success: true, + method: 'ocr-fallback', + attempts, + fallbackUsed: true, + fallbackReason: reason, + ocrText, + tasks, + }); + } catch (ocrError) { + console.error('OCR fallback failed:', ocrError); + return res.status(500).json({ + success: false, + fallbackUsed: true, + fallbackReason: `${reason} OCR fallback also failed: ${ocrError.message}`, + error: 'Image extraction failed on both Gemini vision and the OCR fallback.', + }); + } +} + +module.exports = { extractFromImage }; diff --git a/backend/routers/imageExtract.router.js b/backend/routers/imageExtract.router.js new file mode 100644 index 00000000..d8fd238b --- /dev/null +++ b/backend/routers/imageExtract.router.js @@ -0,0 +1,38 @@ +const express = require('express'); +const multer = require('multer'); +const { extractFromImage } = require('../controllers/imageExtract.controller.js'); + +const router = express.Router(); + +// Memory storage only — the buffer is used in-process for the Gemini/OCR +// call and discarded when the request ends, so there are no temp files +// written to disk that would need cleanup. +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 8 * 1024 * 1024 }, // 8MB + fileFilter: (req, file, cb) => { + const allowed = ['image/png', 'image/jpeg']; + if (allowed.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Only PNG and JPEG/JPG images are supported')); + } + }, +}); + +router.post('/extract/image', (req, res, next) => { + upload.single('image')(req, res, (err) => { + if (err instanceof multer.MulterError) { + const message = err.code === 'LIMIT_FILE_SIZE' + ? 'Image is too large (max 8MB)' + : err.message; + return res.status(400).json({ error: message }); + } + if (err) { + return res.status(400).json({ error: err.message }); + } + next(); + }); +}, extractFromImage); + +module.exports = router; diff --git a/backend/utils/nlpTextExtractor.js b/backend/utils/nlpTextExtractor.js new file mode 100644 index 00000000..e461f744 --- /dev/null +++ b/backend/utils/nlpTextExtractor.js @@ -0,0 +1,230 @@ +// ============================================================ +// INLINE NLP FALLBACK (moved verbatim from server.js so it can be +// reused by both the text extraction route and the image extraction +// route without duplicating ~200 lines of regex heuristics) +// ============================================================ + +function nlpAddDays(date, n) { + const d = new Date(date); d.setDate(d.getDate() + n); return d; +} +function nlpStartOf(date) { + const d = new Date(date); d.setHours(23, 59, 0, 0); return d; +} +function nlpWithTime(dateStr, t) { + const d = new Date(dateStr); + if (t) d.setHours(t.hours, t.minutes, 0, 0); + return d.toISOString(); +} + +function nlpExtractTime(lower) { + if (/\bmidnight\b/.test(lower)) return { hours: 0, minutes: 0 }; + if (/\bnoon\b/.test(lower)) return { hours: 12, minutes: 0 }; + let m = lower.match(/\b(\d{1,2}):(\d{2})\s*(am|pm)?\b/); + if (m) { + let h = parseInt(m[1]); const min = parseInt(m[2]); const mer = m[3]; + if (mer === 'pm' && h < 12) h += 12; + if (mer === 'am' && h === 12) h = 0; + return { hours: h, minutes: min }; + } + m = lower.match(/\b(\d{1,2})\s*(am|pm)\b/); + if (m) { + let h = parseInt(m[1]); const mer = m[2]; + if (mer === 'pm' && h < 12) h += 12; + if (mer === 'am' && h === 12) h = 0; + return { hours: h, minutes: 0 }; + } + return null; +} + +const NLP_WEEKDAYS = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday']; +const NLP_MONTHS = { + jan:0,january:0,feb:1,february:1,mar:2,march:2,apr:3,april:3,may:4, + jun:5,june:5,jul:6,july:6,aug:7,august:7,sep:8,september:8, + oct:9,october:9,nov:10,november:10,dec:11,december:11 +}; + +function nlpResolveYear(now, month, day, explicitYear = null) { + if (explicitYear) return new Date(explicitYear, month, day); + const c = new Date(now.getFullYear(), month, day); + if (c < now) c.setFullYear(c.getFullYear() + 1); + return c; +} + +function nlpExtractDate(text, now = new Date()) { + const lower = text.toLowerCase(); + const time = nlpExtractTime(lower); + + if (/\btoday\b/.test(lower)) return nlpWithTime(nlpStartOf(now).toISOString(), time); + if (/\btonight\b/.test(lower)) return nlpWithTime(nlpStartOf(now).toISOString(), time); + if (/\bday after tomorrow\b/.test(lower)) return nlpWithTime(nlpStartOf(nlpAddDays(now, 2)).toISOString(), time); + if (/\btomorrow\b/.test(lower)) return nlpWithTime(nlpStartOf(nlpAddDays(now, 1)).toISOString(), time); + + let m = lower.match(/\bin\s+(\d+|a|an|one|two|three|four|five|six|seven)\s+(day|days|week|weeks|month|months)\b/); + if (m) { + const wm = { a:1,an:1,one:1,two:2,three:3,four:4,five:5,six:6,seven:7 }; + const n = isNaN(m[1]) ? (wm[m[1]] || 1) : parseInt(m[1]); + let d = new Date(now); + if (m[2].startsWith('day')) d = nlpAddDays(d, n); + else if (m[2].startsWith('week')) d = nlpAddDays(d, n * 7); + else d.setMonth(d.getMonth() + n); + return nlpWithTime(nlpStartOf(d).toISOString(), time); + } + + const wdPattern = new RegExp(`\\b(next|this|on|coming)?\\s*(${NLP_WEEKDAYS.join('|')})\\b`); + m = lower.match(wdPattern); + if (m) { + const target = NLP_WEEKDAYS.indexOf(m[2]); + const cur = now.getDay(); + let diff = target - cur; + if (diff <= 0) diff += 7; + return nlpWithTime(nlpStartOf(nlpAddDays(now, diff)).toISOString(), time); + } + + const monthNames = Object.keys(NLP_MONTHS).join('|'); + const ord = `(\\d{1,2})(?:st|nd|rd|th)?`; + m = lower.match(new RegExp(`\\b(${monthNames})\\s+${ord}\\b`)); + if (m) return nlpWithTime(nlpStartOf(nlpResolveYear(now, NLP_MONTHS[m[1]], parseInt(m[2]))).toISOString(), time); + m = lower.match(new RegExp(`\\b${ord}\\s+(${monthNames})\\b`)); + if (m) return nlpWithTime(nlpStartOf(nlpResolveYear(now, NLP_MONTHS[m[2]], parseInt(m[1]))).toISOString(), time); + + m = lower.match(/\b(\d{1,2})[\/\-\.](\d{1,2})(?:[\/\-\.](\d{2,4}))?\b/); + if (m) { + const day = parseInt(m[1]), month = parseInt(m[2]) - 1; + const yr = m[3] ? (m[3].length === 2 ? 2000 + parseInt(m[3]) : parseInt(m[3])) : null; + return nlpWithTime(nlpStartOf(nlpResolveYear(now, month, day, yr)).toISOString(), time); + } + + if (/\bend of (the\s+)?week\b/.test(lower) || /\bby (the\s+)?weekend\b/.test(lower)) { + const d = new Date(now); d.setDate(d.getDate() + (7 - d.getDay())); + return nlpStartOf(d).toISOString(); + } + if (/\bend of (the\s+)?month\b/.test(lower)) { + return nlpStartOf(new Date(now.getFullYear(), now.getMonth() + 1, 0)).toISOString(); + } + if (/\bend of (the\s+)?year\b/.test(lower)) { + return nlpStartOf(new Date(now.getFullYear(), 11, 31)).toISOString(); + } + if (/\bnext month\b/.test(lower)) { + const d = new Date(now); d.setMonth(d.getMonth() + 1); + return nlpStartOf(d).toISOString(); + } + + return null; +} + +const NLP_SUBJECT_KEYWORDS = { + 'Computer Science': ['cs','computer science','programming','code','coding','algorithm','data structure','software','python','java','javascript','html','database','sql','network','operating system','os','web','scheduling','lab report'], + 'Mathematics': ['maths','math','mathematics','calculus','algebra','statistics','probability','theorem','equation','integral','derivative','matrix','vector','problem set','pset','worksheet','integration'], + 'English Lit': ['english','literature','essay','novel','poem','poetry','shakespeare','writing','prose','narrative','analysis','literary','book report','reading','thesis','draft','revision'], + 'Physics': ['physics','mechanics','thermodynamics','optics','velocity','acceleration','force','energy','momentum','lab','experiment','wave','circuit','resistance','voltage'], +}; + +function nlpDetectSubject(text) { + const lower = text.toLowerCase(); + const scores = {}; + for (const [sub, kws] of Object.entries(NLP_SUBJECT_KEYWORDS)) { + scores[sub] = 0; + const subjectWords = sub.toLowerCase().split(/\s+/).filter(Boolean); + if (lower.includes(sub.toLowerCase())) scores[sub] += 12; + if (subjectWords.length > 1 && subjectWords.every(word => lower.includes(word))) scores[sub] += 8; + for (const kw of kws) { + const re = new RegExp(`\\b${kw.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')}\\b`, 'gi'); + const hits = lower.match(re); + if (hits) scores[sub] += hits.length * (kw.length > 5 ? 2 : 1); + } + } + if (/\benglish\s+lit\b|\blit\s+assignment\b/.test(lower)) scores['English Lit'] += 10; + if (/\bmaths?\b|\bmathematics\b/.test(lower)) scores['Mathematics'] += 10; + if (/\bphysics\b/.test(lower)) scores['Physics'] += 10; + const best = Object.entries(scores).sort((a, b) => b[1] - a[1])[0]; + return best && best[1] > 0 ? best[0] : null; +} + +const NLP_TASK_VERBS = ['submit','finish','complete','hand in','turn in','upload','send','write','prepare','present','review','read','study','draft','revise']; + +function nlpSplitSegments(text) { + return text.split(/[\n\r]+|(?<=\.)\s+(?=[A-Z])|\d+[.)]\s*/) + .map(s => s.trim()).filter(s => s.length > 8); +} + +function nlpTaskScore(seg) { + const lower = seg.toLowerCase(); + let s = 0; + for (const v of NLP_TASK_VERBS) if (lower.includes(v)) { s += 30; break; } + const sigs = ['due','deadline','by','before','submit','tomorrow','tonight','next','today','week','month', + 'monday','tuesday','wednesday','thursday','friday','saturday','sunday', + /\d+\/\d+/, /\d{1,2}(st|nd|rd|th)/]; + for (const sig of sigs) if (sig instanceof RegExp ? sig.test(lower) : lower.includes(sig)) { s += 25; break; } + if (seg.length > 15 && seg.length < 300) s += 15; + if (nlpDetectSubject(seg)) s += 20; + const fillers = ['hi ','hello','hey ','dear ','regards','thanks','sincerely']; + for (const f of fillers) if (lower.startsWith(f)) { s -= 40; break; } + return Math.max(0, Math.min(100, s)); +} + +function nlpCleanTitle(seg) { + const labelMatch = seg.match(/#[\w-]+/g); + let cleaned = seg + .replace(/^(please|kindly|remember to|don't forget to|make sure to)\s+/i, '') + .replace(/\s+(by|before|due|on|at)\s+.*/i, '') + .trim().substring(0, 80); + + if (labelMatch) { + // Re-append labels so frontend can still extract them + labelMatch.forEach(l => { + if (!cleaned.includes(l)) { + cleaned += ' ' + l; + } + }); + } + return cleaned; +} + +function nlpFallbackDate() { + const d = new Date(); d.setDate(d.getDate() + 7); d.setHours(23, 59, 0, 0); + return d.toISOString(); +} + +function nlpExtractTasksFromText(text) { + const now = new Date(); + const segs = nlpSplitSegments(text); + const results = []; + const seen = new Set(); + + for (const seg of segs) { + if (nlpTaskScore(seg) < 30) continue; + const due_at = nlpExtractDate(seg, now) || nlpFallbackDate(); + const subjectName = nlpDetectSubject(seg); + const rawTitle = nlpCleanTitle(seg); + if (!rawTitle || rawTitle.length < 4) continue; + const key = rawTitle.toLowerCase().substring(0, 20); + if (seen.has(key)) continue; + seen.add(key); + const confidence = Math.min(95, nlpTaskScore(seg) + (nlpExtractDate(seg, now) ? 10 : 0) + (subjectName ? 10 : 0)); + results.push({ + title: rawTitle.charAt(0).toUpperCase() + rawTitle.slice(1), + subject_name: subjectName || 'General', + due_at, + notes: 'Extracted by heuristic NLP parser.', + icon: { 'Computer Science':'💻','Mathematics':'📐','English Lit':'📖','Physics':'⚗️' }[subjectName] || '📚', + confidence_score: confidence, + priority: confidence > 70 ? 'high' : 'medium', + }); + } + + if (results.length === 0 && text.trim().length > 5) { + results.push({ + title: text.trim().substring(0, 60), + subject_name: 'General', + due_at: nlpFallbackDate(), + notes: 'Could not parse details — please edit manually.', + icon: '❓', + confidence_score: 30, + priority: 'medium', + }); + } + + return results.slice(0, 10); +} + +module.exports = { nlpExtractTasksFromText }; diff --git a/css/index.css b/css/index.css index c4da7b8f..2a5f08bd 100644 --- a/css/index.css +++ b/css/index.css @@ -1017,7 +1017,17 @@ body { display: flex; flex-direction: column; background: var(--color-background-secondary); - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; +} + +.panel::-webkit-scrollbar { + width: 6px; +} + +.panel::-webkit-scrollbar-thumb { + background: var(--color-border-secondary); + border-radius: 10px; } .panel.panel-collapsed { @@ -1040,6 +1050,9 @@ body { border-bottom: 1px solid var(--color-border-tertiary); background: var(--color-background-primary); flex-shrink: 0; + position: sticky; + top: 0; + z-index: 5; display: flex; align-items: flex-start; gap: 10px; @@ -2721,7 +2734,8 @@ body { display: flex; flex-direction: column; background: var(--color-background-secondary); - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; } .panel-header { @@ -2729,6 +2743,9 @@ body { border-bottom: 1px solid var(--color-border-tertiary); background: var(--color-background-primary); flex-shrink: 0; + position: sticky; + top: 0; + z-index: 5; } .panel-title { @@ -4143,7 +4160,8 @@ body { display: flex; flex-direction: column; background: var(--color-background-secondary); - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; } .panel.panel-collapsed { @@ -4177,6 +4195,9 @@ body { border-bottom: 1px solid var(--color-border-tertiary); background: var(--color-background-primary); flex-shrink: 0; + position: sticky; + top: 0; + z-index: 5; display: flex; align-items: flex-start; gap: 10px; @@ -5260,6 +5281,484 @@ body { .subject-sidebar-item:hover .delete-subject-btn { opacity: 1; } +/* ================= IMAGE EXTRACTION (Smart Paste from photo/screenshot) ================= */ + +.image-extract-section { + margin-top: 14px; +} + +.image-extract-divider { + display: flex; + align-items: center; + text-align: center; + color: var(--color-text-tertiary); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 12px 0; +} + +.image-extract-divider::before, +.image-extract-divider::after { + content: ""; + flex: 1; + border-bottom: 1px solid var(--color-border-tertiary); +} + +.image-extract-divider span { + padding: 0 10px; +} + +.image-drop-zone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 20px 14px; + border: 1.5px dashed var(--color-border-secondary); + border-radius: var(--border-radius-md); + background: var(--color-background-secondary); + text-align: center; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.image-drop-zone--dragover { + border-color: var(--color-text-info); + background: var(--color-background-info); +} + +.image-drop-icon { + font-size: 24px; +} + +.image-drop-label { + font-weight: 600; + color: var(--color-text-primary); + font-size: 14px; +} + +.image-drop-hint { + font-size: 12px; + color: var(--color-text-tertiary); + max-width: 260px; +} + +.image-preview-card { + display: flex; + align-items: center; + gap: 10px; + margin-top: 10px; + padding: 8px; + border: 1px solid var(--color-border-tertiary); + border-radius: var(--border-radius-md); + background: var(--color-background-primary); +} + +.image-preview-thumb { + width: 48px; + height: 48px; + object-fit: cover; + border-radius: var(--border-radius-sm); + flex: 0 0 auto; + background: var(--color-background-tertiary); +} + +.image-preview-info { + display: flex; + align-items: center; + justify-content: space-between; + flex: 1; + gap: 8px; + min-width: 0; +} + +.image-preview-name { + font-size: 12px; + color: var(--color-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.image-extract-btn { + width: 100%; + margin-top: 10px; +} + +.image-extract-status { + margin-top: 10px; + padding: 8px 10px; + border-radius: var(--border-radius-sm); + font-size: 12px; + line-height: 1.5; + background: var(--color-background-info); + color: var(--color-text-info); + border: 1px solid var(--color-border-info); +} + +.image-extract-status[data-tone="success"] { + background: var(--color-background-success); + color: var(--color-text-success); + border-color: var(--color-border-success); +} + +.image-extract-status[data-tone="warning"] { + background: var(--color-background-warning); + color: var(--color-text-warning); + border-color: var(--color-border-warning, var(--color-border-secondary)); +} + +.image-extract-status[data-tone="error"] { + background: var(--color-background-danger); + color: var(--color-text-danger); + border-color: var(--color-border-danger); +} + +.image-ocr-preview { + margin-top: 10px; +} + +.image-ocr-toggle { + background: none; + border: none; + color: var(--color-text-info); + font-size: 12px; + font-weight: 600; + cursor: pointer; + padding: 4px 0; +} + +.image-ocr-text { + margin-top: 6px; + padding: 10px; + border-radius: var(--border-radius-sm); + background: var(--color-background-tertiary); + color: var(--color-text-secondary); + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + max-height: 160px; + overflow-y: auto; +} + +/* ================= TOAST NOTIFICATIONS ================= */ +/* Toast.show()/Toast.confirm() (js/utils/toast.js) had no matching CSS + anywhere in this file, so toasts rendered as unstyled inline content + at the bottom of the page instead of a floating overlay, and — since + .toast-hiding had no animation to fire 'animationend' — were never + actually removed from the DOM after their timeout. */ + +.toast-container { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 10000; + display: flex; + flex-direction: column; + gap: 10px; + max-width: 360px; + pointer-events: none; +} + +.toast-notification { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 14px; + border-radius: var(--border-radius-md); + background: var(--color-background-primary); + border: 1px solid var(--color-border-tertiary); + box-shadow: var(--shadow-lg); + pointer-events: auto; + animation: toastSlideIn 0.25s ease-out; +} + +.toast-notification.toast-success { + border-color: var(--color-border-success); + background: var(--color-background-success); +} + +.toast-notification.toast-error { + border-color: var(--color-border-danger); + background: var(--color-background-danger); +} + +.toast-notification.toast-warning { + border-color: var(--color-border-secondary); + background: var(--color-background-warning); +} + +.toast-notification.toast-info { + border-color: var(--color-border-info); + background: var(--color-background-info); +} + +.toast-icon { + font-size: 16px; + line-height: 1.4; + flex-shrink: 0; +} + +.toast-message { + flex: 1; + font-size: 13px; + font-weight: 500; + color: var(--color-text-primary); + line-height: 1.4; +} + +.toast-close { + flex-shrink: 0; + background: none; + border: none; + font-size: 16px; + line-height: 1; + color: var(--color-text-tertiary); + cursor: pointer; + padding: 0; +} + +.toast-close:hover { + color: var(--color-text-primary); +} + +.toast-notification.toast-hiding { + animation: toastSlideOut 0.2s ease-in forwards; +} + +@keyframes toastSlideIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes toastSlideOut { + from { opacity: 1; transform: translateY(0); max-height: 100px; margin-top: 0; } + to { opacity: 0; transform: translateY(10px); max-height: 0; margin-top: -10px; } +} + +/* Toast.confirm() modal — same missing-CSS gap as the toasts above. */ +.custom-confirm-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 10001; + display: flex; + align-items: center; + justify-content: center; +} + +.custom-confirm-modal { + background: var(--color-background-primary); + border-radius: var(--border-radius-lg); + box-shadow: var(--shadow-lg); + width: 320px; + max-width: 90vw; + padding: 20px; +} + +@keyframes fadeOut { + from { opacity: 1; } + to { opacity: 0; } +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes slideDown { + from { opacity: 1; transform: translateY(0); } + to { opacity: 0; transform: translateY(16px); } +} + +/* ================= PRIORITY BOARD (navbar "Tasks") ================= */ + +.priority-board-section { + flex: 1; + overflow-y: auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.priority-board-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + gap: 12px; +} + +.priority-board-title { + font-size: 24px; + font-weight: 700; + color: var(--color-text-primary); +} + +.priority-board-subtitle { + margin: 8px 0 0; + color: var(--color-text-secondary); + line-height: 1.6; +} + +.priority-board-date-filter { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-radius: var(--border-radius-sm); + background: var(--color-background-info); + color: var(--color-text-info); + border: 1px solid var(--color-border-info); + font-size: 13px; +} + +.priority-board-columns { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; + flex: 1; + min-height: 0; +} + +@media (max-width: 900px) { + .priority-board-columns { + grid-template-columns: 1fr; + } +} + +.priority-column { + display: flex; + flex-direction: column; + background: var(--color-background-primary); + border: 1px solid var(--color-border-tertiary); + border-radius: var(--border-radius-md); + box-shadow: var(--shadow-sm); + min-height: 0; + overflow: hidden; +} + +.priority-column-header { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 16px; + font-size: 14px; + font-weight: 700; + color: var(--color-text-primary); + border-bottom: 1px solid var(--color-border-tertiary); + flex-shrink: 0; +} + +.priority-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.priority-column-high .priority-dot { background: var(--color-text-danger); } +.priority-column-medium .priority-dot { background: var(--color-text-warning); } +.priority-column-low .priority-dot { background: var(--color-text-success); } + +.priority-column-high { border-top: 3px solid var(--color-text-danger); } +.priority-column-medium { border-top: 3px solid var(--color-text-warning); } +.priority-column-low { border-top: 3px solid var(--color-text-success); } + +.priority-count { + margin-left: auto; + font-size: 12px; + font-weight: 700; + background: var(--color-background-tertiary); + color: var(--color-text-secondary); + padding: 2px 8px; + border-radius: 12px; +} + +.priority-column-list { + overflow-y: auto; + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.priority-column-list::-webkit-scrollbar { + width: 6px; +} + +.priority-column-list::-webkit-scrollbar-thumb { + background: var(--color-border-secondary); + border-radius: 10px; +} + +.priority-column-empty { + color: var(--color-text-tertiary); + font-size: 13px; + text-align: center; + padding: 20px 8px; +} + +.priority-task-card { + border: 1px solid var(--color-border-tertiary); + border-left: 3px solid var(--color-border-tertiary); + border-radius: var(--border-radius-sm); + padding: 10px 12px; + background: var(--color-background-secondary); +} + +.priority-task-top { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.priority-task-check { + appearance: none; + -webkit-appearance: none; + width: 18px; + height: 18px; + border-radius: 5px; + border: 2px solid var(--color-border-secondary); + background: var(--color-background-primary); + cursor: pointer; + flex: 0 0 auto; + margin-top: 1px; +} + +.priority-task-check:hover { + border-color: #16a34a; +} + +.priority-task-title { + font-size: 13px; + font-weight: 600; + color: var(--color-text-primary); + line-height: 1.4; +} + +.priority-task-meta { + margin-top: 6px; + padding-left: 26px; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + font-size: 11px; + color: var(--color-text-tertiary); +} + +.priority-task-subject { + font-weight: 600; + color: var(--color-text-secondary); +} + +/* ================= REVIEW TABLE ================= */ + .task-id { font-size: 11.5px; font-family: 'SF Mono', 'Fira Code', monospace; @@ -5387,4 +5886,4 @@ body { } .review-table-wrap tbody tr:last-child { border-bottom: none; } .review-table-wrap tbody tr:hover { background: var(--color-background-secondary); } - .review-table-wrap td { padding: 14px 16px; vertical-align: middle; } \ No newline at end of file + .review-table-wrap td { padding: 14px 16px; vertical-align: middle; } diff --git a/index.html b/index.html index 7248734b..85d3444a 100644 --- a/index.html +++ b/index.html @@ -216,8 +216,9 @@

StudyPlan

Su
Mo
Tu
We
Th
Fr
Sa
- Study session - Deadline + High priority + Medium priority + Low priority
@@ -331,6 +332,46 @@

Account overview

+ + + @@ -362,6 +403,38 @@

Account overview

+ +
+
or
+ +
+
📷
+
Upload a photo or screenshot
+
PNG or JPEG — WhatsApp screenshots, LMS captures, handwritten notes, timetables (max 8MB)
+ + +
+ + + + + + + + +
+
diff --git a/js/app.js b/js/app.js index 5f50a0be..20c4cfc5 100644 --- a/js/app.js +++ b/js/app.js @@ -3,8 +3,10 @@ import { extractTasksFromText } from './utils/api.js'; import { initGlobalErrorBoundary } from './utils/errorBoundary.js'; import { analyzeWorkload } from './utils/scheduler.js'; import { Toast } from './utils/toast.js'; +import { initImageExtract } from './utils/imageExtract.js'; initGlobalErrorBoundary(); +initImageExtract(); function getLabelColor(labelStr) { const colors = ['#ef4444', '#f59e0b', '#3b82f6', '#8b5cf6', '#10b981', '#ec4899', '#14b8a6', '#f97316']; @@ -86,7 +88,17 @@ function formatDuration(mins) { let currentMonthDate = new Date(); let selectedDate = null; -let currentView = 'calendar'; // 'calendar', 'all-tasks', 'archived' +let currentView = 'calendar'; // 'calendar', 'all-tasks', 'archived', 'priority-board' +let priorityBoardDate = null; + +const PRIORITY_COLORS = { + high: 'var(--color-text-danger)', + medium: 'var(--color-text-warning)', + low: 'var(--color-text-success)', +}; +function getPriorityColor(priority) { + return PRIORITY_COLORS[priority] || PRIORITY_COLORS.medium; +} const tasksSection = document.getElementById('tasks-section'); const focusSection = document.getElementById('focus-section'); @@ -798,6 +810,7 @@ function showProfileSection() { document.querySelector('.cal-section')?.classList.add('hidden'); document.getElementById('tasks-section')?.classList.add('hidden'); document.getElementById('focus-section')?.classList.add('hidden'); + hidePriorityBoard(); profileSection?.classList.remove('hidden'); topbar?.classList.add('hidden'); renderProfileSection(); @@ -808,6 +821,90 @@ function hideProfileSection() { topbar?.classList.remove('hidden'); } +function showPriorityBoard(dateFilter = null) { + currentView = 'priority-board'; + priorityBoardDate = dateFilter; + hideProfileSection(); + document.querySelector('.cal-section')?.classList.add('hidden'); + document.getElementById('tasks-section')?.classList.add('hidden'); + document.getElementById('focus-section')?.classList.add('hidden'); + document.getElementById('priority-board-section')?.classList.remove('hidden'); + document.querySelectorAll('.sidebar .nav-item').forEach(el => el.classList.remove('active')); + const studyTimeEl = document.getElementById('daily-study-time'); + if (studyTimeEl) studyTimeEl.style.display = 'none'; + renderPriorityBoard(); +} + +function hidePriorityBoard() { + document.getElementById('priority-board-section')?.classList.add('hidden'); +} + +function renderPriorityBoard() { + const section = document.getElementById('priority-board-section'); + if (!section || section.classList.contains('hidden')) return; + + const dateFilterEl = document.getElementById('priority-board-date-filter'); + const dateLabelEl = document.getElementById('priority-board-date-label'); + if (priorityBoardDate) { + dateFilterEl?.classList.remove('hidden'); + if (dateLabelEl) { + dateLabelEl.textContent = priorityBoardDate.toLocaleDateString('en-US', { + weekday: 'short', month: 'short', day: 'numeric', + }); + } + } else { + dateFilterEl?.classList.add('hidden'); + } + + let tasks = store.tasks.filter(t => !t.archived && t.status !== 'Done'); + if (priorityBoardDate) { + tasks = tasks.filter(t => t.due_at && store.isSameCalendarDate(new Date(t.due_at), priorityBoardDate)); + } + + const buckets = { high: [], medium: [], low: [] }; + tasks.forEach(t => { + const p = buckets[t.priority] ? t.priority : 'medium'; + buckets[p].push(t); + }); + + ['high', 'medium', 'low'].forEach(p => { + const listEl = document.getElementById(`priority-list-${p}`); + const countEl = document.getElementById(`priority-count-${p}`); + const items = buckets[p].sort((a, b) => new Date(a.due_at) - new Date(b.due_at)); + + if (countEl) countEl.textContent = items.length; + if (!listEl) return; + + if (items.length === 0) { + listEl.innerHTML = `
No ${p} priority tasks${priorityBoardDate ? ' on this date' : ''}
`; + return; + } + + listEl.innerHTML = items.map(t => { + const sub = store.subjects.find(s => s.id === t.subject_id); + return ` +
+
+ +
${escapeHtml(t.title)}
+
+
+ ${sub ? escapeHtml(sub.name) : 'General'} + ${formatDate(t.due_at)} +
+
+ `; + }).join(''); + }); + + section.querySelectorAll('.priority-task-check').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + store.toggleTaskStatus(btn.dataset.id); + }); + }); +} + function formatDate(dateStr) { if (!dateStr) return 'No Date'; const d = new Date(dateStr); @@ -1223,6 +1320,7 @@ function renderTasks() { const actionBar = `
+
`; const emptyState = dueSoon.length === 0 && completed.length === 0 @@ -1466,6 +1564,15 @@ document.querySelectorAll('.task-item').forEach(taskEl => { store.markPendingTasksForDateCompleted(selectedDate); }); } + + const viewInBoardBtn = document.getElementById('view-in-board-btn'); + if (viewInBoardBtn) { + viewInBoardBtn.addEventListener('click', (e) => { + e.stopPropagation(); + showPriorityBoard(selectedDate); + }); + } + const bulkCompleteBtn = document.getElementById('bulk-complete-btn'); @@ -1559,11 +1666,14 @@ function renderCalendar() { let indicatorHtml = ''; if (dayTasks.length > 0) { + const PRIORITY_RANK = { high: 0, medium: 1, low: 2 }; + const sortedDayTasks = [...dayTasks].sort( + (a, b) => (PRIORITY_RANK[a.priority] ?? 1) - (PRIORITY_RANK[b.priority] ?? 1) + ); indicatorHtml = `
`; - dayTasks.forEach((t, idx) => { + sortedDayTasks.forEach((t, idx) => { if (idx > 2) return; - const sub = store.subjects.find(s => s.id === t.subject_id) || store.subjects[0]; - indicatorHtml += `
`; + indicatorHtml += `
`; }); indicatorHtml += `
`; } @@ -1814,6 +1924,7 @@ store.subscribe(renderFocusTasks); store.subscribe(renderProfileSection); store.subscribe(renderSidebarSubjects); store.subscribe(renderStreak); +store.subscribe(renderPriorityBoard); function updateSidebarActive(id) { document.querySelectorAll('.sidebar .nav-item').forEach(el => el.classList.remove('active')); @@ -1886,6 +1997,7 @@ document.addEventListener('DOMContentLoaded', () => { calendarBtn.addEventListener('click', () => { currentView = 'calendar'; hideProfileSection(); + hidePriorityBoard(); document.querySelector('.cal-section').classList.remove('hidden'); document.getElementById('tasks-section').classList.remove('hidden'); document.getElementById('focus-section').classList.add('hidden'); @@ -1896,6 +2008,7 @@ document.addEventListener('DOMContentLoaded', () => { allTasksBtn.addEventListener('click', () => { currentView = 'all-tasks'; hideProfileSection(); + hidePriorityBoard(); document.querySelector('.cal-section').classList.add('hidden'); document.getElementById('tasks-section').classList.remove('hidden'); document.getElementById('focus-section').classList.add('hidden'); @@ -1906,6 +2019,7 @@ document.addEventListener('DOMContentLoaded', () => { archivedTasksBtn.addEventListener('click', () => { currentView = 'archived'; hideProfileSection(); + hidePriorityBoard(); document.querySelector('.cal-section').classList.add('hidden'); document.getElementById('tasks-section').classList.remove('hidden'); document.getElementById('focus-section').classList.add('hidden'); @@ -1917,6 +2031,7 @@ document.addEventListener('DOMContentLoaded', () => { focusModeBtn.addEventListener('click', () => { currentView = 'focus'; hideProfileSection(); + hidePriorityBoard(); document.querySelector('.cal-section').classList.add('hidden'); document.getElementById('tasks-section').classList.add('hidden'); document.getElementById('focus-section').classList.remove('hidden'); @@ -1947,6 +2062,8 @@ document.addEventListener('DOMContentLoaded', () => { document.getElementById('nav-dashboard').addEventListener('click', (e) => { e.preventDefault(); currentView = 'calendar'; + hideProfileSection(); + hidePriorityBoard(); document.querySelector('.cal-section').classList.remove('hidden'); document.getElementById('tasks-section').classList.remove('hidden'); document.getElementById('focus-section').classList.add('hidden'); @@ -1956,17 +2073,14 @@ document.addEventListener('DOMContentLoaded', () => { document.getElementById('nav-tasks').addEventListener('click', (e) => { e.preventDefault(); - currentView = 'all-tasks'; - document.querySelector('.cal-section').classList.add('hidden'); - document.getElementById('tasks-section').classList.remove('hidden'); - document.getElementById('focus-section').classList.add('hidden'); - updateSidebarActive('all-tasks-btn'); - renderTasks(); + showPriorityBoard(); }); document.getElementById('nav-calendar').addEventListener('click', (e) => { e.preventDefault(); currentView = 'calendar'; + hideProfileSection(); + hidePriorityBoard(); document.querySelector('.cal-section').classList.remove('hidden'); document.getElementById('tasks-section').classList.remove('hidden'); document.getElementById('focus-section').classList.add('hidden'); @@ -1974,6 +2088,14 @@ document.addEventListener('DOMContentLoaded', () => { renderTasks(); }); + const priorityBoardClearDateBtn = document.getElementById('priority-board-clear-date'); + if (priorityBoardClearDateBtn) { + priorityBoardClearDateBtn.addEventListener('click', () => { + priorityBoardDate = null; + renderPriorityBoard(); + }); + } + //NEw Task addition event listeners newTaskBtn.addEventListener('click', () => { diff --git a/js/utils/imageExtract.js b/js/utils/imageExtract.js new file mode 100644 index 00000000..52f95495 --- /dev/null +++ b/js/utils/imageExtract.js @@ -0,0 +1,185 @@ +import { store } from '../store.js'; +import { Toast } from './toast.js'; + +const MAX_FILE_SIZE = 8 * 1024 * 1024; // 8MB — mirrors backend/routers/imageExtract.router.js +const ALLOWED_TYPES = ['image/png', 'image/jpeg']; + +let selectedFile = null; +let objectUrl = null; + +function setStatus(el, message, tone = 'info') { + if (!el) return; + if (!message) { + el.classList.add('hidden'); + el.textContent = ''; + return; + } + el.classList.remove('hidden'); + el.textContent = message; + el.dataset.tone = tone; +} + +function resetSelection(dom) { + selectedFile = null; + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + objectUrl = null; + } + dom.imageUploadInput.value = ''; + dom.imagePreviewCard.classList.add('hidden'); + dom.imagePreviewThumb.src = ''; + dom.imagePreviewName.textContent = ''; + dom.imageExtractBtn.disabled = true; + setStatus(dom.imageExtractStatus, ''); + dom.imageOcrPreview.classList.add('hidden'); + dom.imageOcrText.classList.add('hidden'); + dom.imageOcrText.textContent = ''; + dom.imageOcrToggle.textContent = '▸ Show raw OCR text (fallback mode)'; + dom.imageOcrToggle.setAttribute('aria-expanded', 'false'); +} + +function selectFile(file, dom) { + if (!file) return; + + if (!ALLOWED_TYPES.includes(file.type)) { + Toast.show('Please choose a PNG or JPEG image', 'warning'); + return; + } + if (file.size > MAX_FILE_SIZE) { + Toast.show('Image is too large (max 8MB)', 'warning'); + return; + } + + selectedFile = file; + if (objectUrl) URL.revokeObjectURL(objectUrl); + objectUrl = URL.createObjectURL(file); + + dom.imagePreviewThumb.src = objectUrl; + dom.imagePreviewName.textContent = `${file.name} (${(file.size / 1024).toFixed(0)} KB)`; + dom.imagePreviewCard.classList.remove('hidden'); + dom.imageExtractBtn.disabled = false; + setStatus(dom.imageExtractStatus, ''); +} + +async function runExtraction(dom) { + if (!selectedFile) return; + + dom.imageExtractBtn.disabled = true; + setStatus(dom.imageExtractStatus, 'Reading image and extracting tasks… this can take up to a few seconds.', 'info'); + dom.imageOcrPreview.classList.add('hidden'); + + const formData = new FormData(); + formData.append('image', selectedFile); + + try { + const res = await fetch('/api/extract/image', { + method: 'POST', + body: formData, + }); + + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + setStatus(dom.imageExtractStatus, data.error || 'Image extraction failed. Please try again.', 'error'); + Toast.show(`❌ ${data.error || 'Image extraction failed'}`, 'error'); + dom.imageExtractBtn.disabled = false; + return; + } + + if (data.fallbackUsed) { + setStatus( + dom.imageExtractStatus, + `⚠ Used OCR fallback instead of AI vision. Reason: ${data.fallbackReason}`, + 'warning' + ); + Toast.show('⚠ AI vision unavailable — used OCR fallback instead', 'warning'); + + if (data.ocrText) { + dom.imageOcrPreview.classList.remove('hidden'); + dom.imageOcrText.textContent = data.ocrText; + } + } else { + setStatus(dom.imageExtractStatus, `✅ Extracted ${data.tasks.length} task(s) from the image.`, 'success'); + } + + if (!data.tasks || data.tasks.length === 0) { + Toast.show('No tasks found in this image. Try a clearer photo.', 'warning'); + } else { + // Reuse the exact same extraction preview/edit/"Add to planner" flow + // that text-based Smart Paste already uses. + store.setExtracted(data.tasks); + document.getElementById('extract-preview')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + } catch (e) { + console.error('Image extraction request failed', e); + setStatus(dom.imageExtractStatus, 'Network error. Please try again.', 'error'); + Toast.show('❌ Network error while extracting from image', 'error'); + } finally { + dom.imageExtractBtn.disabled = !selectedFile; + } +} + +export function initImageExtract() { + const dom = { + section: document.getElementById('image-extract-section'), + dropZone: document.getElementById('image-drop-zone'), + imageUploadInput: document.getElementById('image-upload-input'), + imageUploadBtn: document.getElementById('image-upload-btn'), + imagePreviewCard: document.getElementById('image-preview-card'), + imagePreviewThumb: document.getElementById('image-preview-thumb'), + imagePreviewName: document.getElementById('image-preview-name'), + imageRemoveBtn: document.getElementById('image-remove-btn'), + imageExtractBtn: document.getElementById('image-extract-btn'), + imageExtractStatus: document.getElementById('image-extract-status'), + imageOcrPreview: document.getElementById('image-ocr-preview'), + imageOcrToggle: document.getElementById('image-ocr-toggle'), + imageOcrText: document.getElementById('image-ocr-text'), + }; + + // Bail out quietly if the markup isn't present (keeps this module safe + // to import without assuming index.html always has this section). + if (!dom.section || !dom.dropZone || !dom.imageUploadInput) return; + + dom.imageUploadBtn.addEventListener('click', () => dom.imageUploadInput.click()); + + dom.imageUploadInput.addEventListener('change', (e) => { + selectFile(e.target.files?.[0], dom); + }); + + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach((eventName) => { + dom.dropZone.addEventListener(eventName, (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + }); + + ['dragenter', 'dragover'].forEach((eventName) => { + dom.dropZone.addEventListener(eventName, () => { + dom.dropZone.classList.add('image-drop-zone--dragover'); + }); + }); + + ['dragleave', 'drop'].forEach((eventName) => { + dom.dropZone.addEventListener(eventName, () => { + dom.dropZone.classList.remove('image-drop-zone--dragover'); + }); + }); + + dom.dropZone.addEventListener('drop', (e) => { + const file = e.dataTransfer?.files?.[0]; + if (file) selectFile(file, dom); + }); + + dom.imageRemoveBtn.addEventListener('click', () => resetSelection(dom)); + + dom.imageExtractBtn.addEventListener('click', () => runExtraction(dom)); + + dom.imageOcrToggle.addEventListener('click', () => { + const isHidden = dom.imageOcrText.classList.contains('hidden'); + dom.imageOcrText.classList.toggle('hidden', !isHidden); + dom.imageOcrToggle.textContent = isHidden + ? '▾ Hide raw OCR text' + : '▸ Show raw OCR text (fallback mode)'; + dom.imageOcrToggle.setAttribute('aria-expanded', String(isHidden)); + }); +} diff --git a/package-lock.json b/package-lock.json index 0311c7dc..ea0ad597 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,9 @@ "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", - "sqlite3": "^6.0.1" + "multer": "^2.2.0", + "sqlite3": "^6.0.1", + "tesseract.js": "^7.0.0" }, "engines": { "node": ">=20.x" @@ -164,6 +166,12 @@ "node": ">= 14" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -213,6 +221,12 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -267,6 +281,23 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -314,6 +345,21 @@ "node": ">=18" } }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -920,6 +966,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/idb-keyval": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz", + "integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==", + "license": "Apache-2.0" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -967,6 +1019,12 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, "node_modules/isexe": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", @@ -1122,6 +1180,68 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -1279,6 +1399,15 @@ "wrappy": "1" } }, + "node_modules/opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "license": "MIT", + "bin": { + "opencollective-postinstall": "index.js" + } + }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -1476,6 +1605,12 @@ "node": ">= 6" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -1743,6 +1878,14 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -1811,6 +1954,50 @@ "node": ">=6" } }, + "node_modules/tesseract.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz", + "integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "bmp-js": "^0.1.0", + "idb-keyval": "^6.2.0", + "is-url": "^1.2.4", + "node-fetch": "^2.6.9", + "opencollective-postinstall": "^2.0.3", + "regenerator-runtime": "^0.13.3", + "tesseract.js-core": "^7.0.0", + "wasm-feature-detect": "^1.8.0", + "zlibjs": "^0.3.1" + } + }, + "node_modules/tesseract.js-core": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz", + "integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==", + "license": "Apache-2.0" + }, + "node_modules/tesseract.js/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -1837,6 +2024,12 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -1863,6 +2056,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/undici": { "version": "6.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", @@ -1903,6 +2102,12 @@ "node": ">= 0.8" } }, + "node_modules/wasm-feature-detect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", + "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==", + "license": "Apache-2.0" + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -1912,6 +2117,22 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", @@ -1963,6 +2184,15 @@ "engines": { "node": ">=18" } + }, + "node_modules/zlibjs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", + "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", + "license": "MIT", + "engines": { + "node": "*" + } } } } diff --git a/package.json b/package.json index 44d14e03..4ab88a7e 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", - "sqlite3": "^6.0.1" + "multer": "^2.2.0", + "sqlite3": "^6.0.1", + "tesseract.js": "^7.0.0" }, "engines": { "node": ">=20.x" diff --git a/server.js b/server.js index b5267cb0..3967e621 100644 --- a/server.js +++ b/server.js @@ -5,6 +5,7 @@ const { db, initDb } = require('./database'); const { GoogleGenAI } = require('@google/genai'); const path = require('path'); const csvDownloadRouter = require('./backend/routers/csvDownload.router.js'); +const imageExtractRouter = require('./backend/routers/imageExtract.router.js'); const app = express(); app.use(cors()); @@ -33,232 +34,10 @@ const ai = process.env.GEMINI_API_KEY // ============================================================ // INLINE NLP FALLBACK +// Moved to backend/utils/nlpTextExtractor.js so the image-extraction +// route can reuse it without duplicating ~200 lines of regex heuristics. // ============================================================ - -function nlpAddDays(date, n) { - const d = new Date(date); d.setDate(d.getDate() + n); return d; -} -function nlpStartOf(date) { - const d = new Date(date); d.setHours(23, 59, 0, 0); return d; -} -function nlpWithTime(dateStr, t) { - const d = new Date(dateStr); - if (t) d.setHours(t.hours, t.minutes, 0, 0); - return d.toISOString(); -} - -function nlpExtractTime(lower) { - if (/\bmidnight\b/.test(lower)) return { hours: 0, minutes: 0 }; - if (/\bnoon\b/.test(lower)) return { hours: 12, minutes: 0 }; - let m = lower.match(/\b(\d{1,2}):(\d{2})\s*(am|pm)?\b/); - if (m) { - let h = parseInt(m[1]); const min = parseInt(m[2]); const mer = m[3]; - if (mer === 'pm' && h < 12) h += 12; - if (mer === 'am' && h === 12) h = 0; - return { hours: h, minutes: min }; - } - m = lower.match(/\b(\d{1,2})\s*(am|pm)\b/); - if (m) { - let h = parseInt(m[1]); const mer = m[2]; - if (mer === 'pm' && h < 12) h += 12; - if (mer === 'am' && h === 12) h = 0; - return { hours: h, minutes: 0 }; - } - return null; -} - -const NLP_WEEKDAYS = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday']; -const NLP_MONTHS = { - jan:0,january:0,feb:1,february:1,mar:2,march:2,apr:3,april:3,may:4, - jun:5,june:5,jul:6,july:6,aug:7,august:7,sep:8,september:8, - oct:9,october:9,nov:10,november:10,dec:11,december:11 -}; - -function nlpResolveYear(now, month, day, explicitYear = null) { - if (explicitYear) return new Date(explicitYear, month, day); - const c = new Date(now.getFullYear(), month, day); - if (c < now) c.setFullYear(c.getFullYear() + 1); - return c; -} - -function nlpExtractDate(text, now = new Date()) { - const lower = text.toLowerCase(); - const time = nlpExtractTime(lower); - - if (/\btoday\b/.test(lower)) return nlpWithTime(nlpStartOf(now).toISOString(), time); - if (/\btonight\b/.test(lower)) return nlpWithTime(nlpStartOf(now).toISOString(), time); - if (/\bday after tomorrow\b/.test(lower)) return nlpWithTime(nlpStartOf(nlpAddDays(now, 2)).toISOString(), time); - if (/\btomorrow\b/.test(lower)) return nlpWithTime(nlpStartOf(nlpAddDays(now, 1)).toISOString(), time); - - let m = lower.match(/\bin\s+(\d+|a|an|one|two|three|four|five|six|seven)\s+(day|days|week|weeks|month|months)\b/); - if (m) { - const wm = { a:1,an:1,one:1,two:2,three:3,four:4,five:5,six:6,seven:7 }; - const n = isNaN(m[1]) ? (wm[m[1]] || 1) : parseInt(m[1]); - let d = new Date(now); - if (m[2].startsWith('day')) d = nlpAddDays(d, n); - else if (m[2].startsWith('week')) d = nlpAddDays(d, n * 7); - else d.setMonth(d.getMonth() + n); - return nlpWithTime(nlpStartOf(d).toISOString(), time); - } - - const wdPattern = new RegExp(`\\b(next|this|on|coming)?\\s*(${NLP_WEEKDAYS.join('|')})\\b`); - m = lower.match(wdPattern); - if (m) { - const target = NLP_WEEKDAYS.indexOf(m[2]); - const cur = now.getDay(); - let diff = target - cur; - if (diff <= 0) diff += 7; - return nlpWithTime(nlpStartOf(nlpAddDays(now, diff)).toISOString(), time); - } - - const monthNames = Object.keys(NLP_MONTHS).join('|'); - const ord = `(\\d{1,2})(?:st|nd|rd|th)?`; - m = lower.match(new RegExp(`\\b(${monthNames})\\s+${ord}\\b`)); - if (m) return nlpWithTime(nlpStartOf(nlpResolveYear(now, NLP_MONTHS[m[1]], parseInt(m[2]))).toISOString(), time); - m = lower.match(new RegExp(`\\b${ord}\\s+(${monthNames})\\b`)); - if (m) return nlpWithTime(nlpStartOf(nlpResolveYear(now, NLP_MONTHS[m[2]], parseInt(m[1]))).toISOString(), time); - - m = lower.match(/\b(\d{1,2})[\/\-\.](\d{1,2})(?:[\/\-\.](\d{2,4}))?\b/); - if (m) { - const day = parseInt(m[1]), month = parseInt(m[2]) - 1; - const yr = m[3] ? (m[3].length === 2 ? 2000 + parseInt(m[3]) : parseInt(m[3])) : null; - return nlpWithTime(nlpStartOf(nlpResolveYear(now, month, day, yr)).toISOString(), time); - } - - if (/\bend of (the\s+)?week\b/.test(lower) || /\bby (the\s+)?weekend\b/.test(lower)) { - const d = new Date(now); d.setDate(d.getDate() + (7 - d.getDay())); - return nlpStartOf(d).toISOString(); - } - if (/\bend of (the\s+)?month\b/.test(lower)) { - return nlpStartOf(new Date(now.getFullYear(), now.getMonth() + 1, 0)).toISOString(); - } - if (/\bend of (the\s+)?year\b/.test(lower)) { - return nlpStartOf(new Date(now.getFullYear(), 11, 31)).toISOString(); - } - if (/\bnext month\b/.test(lower)) { - const d = new Date(now); d.setMonth(d.getMonth() + 1); - return nlpStartOf(d).toISOString(); - } - - return null; -} - -const NLP_SUBJECT_KEYWORDS = { - 'Computer Science': ['cs','computer science','programming','code','coding','algorithm','data structure','software','python','java','javascript','html','database','sql','network','operating system','os','web','scheduling','lab report'], - 'Mathematics': ['maths','math','mathematics','calculus','algebra','statistics','probability','theorem','equation','integral','derivative','matrix','vector','problem set','pset','worksheet','integration'], - 'English Lit': ['english','literature','essay','novel','poem','poetry','shakespeare','writing','prose','narrative','analysis','literary','book report','reading','thesis','draft','revision'], - 'Physics': ['physics','mechanics','thermodynamics','optics','velocity','acceleration','force','energy','momentum','lab','experiment','wave','circuit','resistance','voltage'], -}; - -function nlpDetectSubject(text) { - const lower = text.toLowerCase(); - const scores = {}; - for (const [sub, kws] of Object.entries(NLP_SUBJECT_KEYWORDS)) { - scores[sub] = 0; - const subjectWords = sub.toLowerCase().split(/\s+/).filter(Boolean); - if (lower.includes(sub.toLowerCase())) scores[sub] += 12; - if (subjectWords.length > 1 && subjectWords.every(word => lower.includes(word))) scores[sub] += 8; - for (const kw of kws) { - const re = new RegExp(`\\b${kw.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')}\\b`, 'gi'); - const hits = lower.match(re); - if (hits) scores[sub] += hits.length * (kw.length > 5 ? 2 : 1); - } - } - if (/\benglish\s+lit\b|\blit\s+assignment\b/.test(lower)) scores['English Lit'] += 10; - if (/\bmaths?\b|\bmathematics\b/.test(lower)) scores['Mathematics'] += 10; - if (/\bphysics\b/.test(lower)) scores['Physics'] += 10; - const best = Object.entries(scores).sort((a, b) => b[1] - a[1])[0]; - return best && best[1] > 0 ? best[0] : null; -} - -const NLP_TASK_VERBS = ['submit','finish','complete','hand in','turn in','upload','send','write','prepare','present','review','read','study','draft','revise']; - -function nlpSplitSegments(text) { - return text.split(/[\n\r]+|(?<=\.)\s+(?=[A-Z])|\d+[.)]\s*/) - .map(s => s.trim()).filter(s => s.length > 8); -} - -function nlpTaskScore(seg) { - const lower = seg.toLowerCase(); - let s = 0; - for (const v of NLP_TASK_VERBS) if (lower.includes(v)) { s += 30; break; } - const sigs = ['due','deadline','by','before','submit','tomorrow','tonight','next','today','week','month', - 'monday','tuesday','wednesday','thursday','friday','saturday','sunday', - /\d+\/\d+/, /\d{1,2}(st|nd|rd|th)/]; - for (const sig of sigs) if (sig instanceof RegExp ? sig.test(lower) : lower.includes(sig)) { s += 25; break; } - if (seg.length > 15 && seg.length < 300) s += 15; - if (nlpDetectSubject(seg)) s += 20; - const fillers = ['hi ','hello','hey ','dear ','regards','thanks','sincerely']; - for (const f of fillers) if (lower.startsWith(f)) { s -= 40; break; } - return Math.max(0, Math.min(100, s)); -} - -function nlpCleanTitle(seg) { - const labelMatch = seg.match(/#[\w-]+/g); - let cleaned = seg - .replace(/^(please|kindly|remember to|don't forget to|make sure to)\s+/i, '') - .replace(/\s+(by|before|due|on|at)\s+.*/i, '') - .trim().substring(0, 80); - - if (labelMatch) { - // Re-append labels so frontend can still extract them - labelMatch.forEach(l => { - if (!cleaned.includes(l)) { - cleaned += ' ' + l; - } - }); - } - return cleaned; -} - -function nlpFallbackDate() { - const d = new Date(); d.setDate(d.getDate() + 7); d.setHours(23, 59, 0, 0); - return d.toISOString(); -} - -function nlpExtractTasksFromText(text) { - const now = new Date(); - const segs = nlpSplitSegments(text); - const results = []; - const seen = new Set(); - - for (const seg of segs) { - if (nlpTaskScore(seg) < 30) continue; - const due_at = nlpExtractDate(seg, now) || nlpFallbackDate(); - const subjectName = nlpDetectSubject(seg); - const rawTitle = nlpCleanTitle(seg); - if (!rawTitle || rawTitle.length < 4) continue; - const key = rawTitle.toLowerCase().substring(0, 20); - if (seen.has(key)) continue; - seen.add(key); - const confidence = Math.min(95, nlpTaskScore(seg) + (nlpExtractDate(seg, now) ? 10 : 0) + (subjectName ? 10 : 0)); - results.push({ - title: rawTitle.charAt(0).toUpperCase() + rawTitle.slice(1), - subject_name: subjectName || 'General', - due_at, - notes: 'Extracted by heuristic NLP parser.', - icon: { 'Computer Science':'💻','Mathematics':'📐','English Lit':'📖','Physics':'⚗️' }[subjectName] || '📚', - confidence_score: confidence, - priority: confidence > 70 ? 'high' : 'medium', - }); - } - - if (results.length === 0 && text.trim().length > 5) { - results.push({ - title: text.trim().substring(0, 60), - subject_name: 'General', - due_at: nlpFallbackDate(), - notes: 'Could not parse details — please edit manually.', - icon: '❓', - confidence_score: 30, - priority: 'medium', - }); - } - - return results.slice(0, 10); -} - -// ============================================================ +const { nlpExtractTasksFromText } = require('./backend/utils/nlpTextExtractor.js'); // ================= SUBJECTS ================= app.get('/api/subjects', (req, res) => { @@ -652,6 +431,7 @@ app.get('/debug/force-error', (req, res, next) => { }); app.use('/api', csvDownloadRouter); +app.use('/api', imageExtractRouter); app.use('/api', (req, res) => { return res.status(404).json({ error: 'API route not found' });