diff --git a/backend/migrations/20250619000001-add-recurring-parent-id.js b/backend/migrations/20250619000001-add-recurring-parent-id.js index 419e6ed..c592739 100644 --- a/backend/migrations/20250619000001-add-recurring-parent-id.js +++ b/backend/migrations/20250619000001-add-recurring-parent-id.js @@ -47,4 +47,4 @@ module.exports = { await queryInterface.removeIndex('tasks', ['recurring_parent_id']); await queryInterface.removeColumn('tasks', 'recurring_parent_id'); }, -}; \ No newline at end of file +}; diff --git a/backend/migrations/20250619000002-add-project-image-url.js b/backend/migrations/20250619000002-add-project-image-url.js index 35270d6..0150e36 100644 --- a/backend/migrations/20250619000002-add-project-image-url.js +++ b/backend/migrations/20250619000002-add-project-image-url.js @@ -18,4 +18,4 @@ module.exports = { down: async (queryInterface, Sequelize) => { await queryInterface.removeColumn('projects', 'image_url'); }, -}; \ No newline at end of file +}; diff --git a/backend/migrations/20250620000001-add-task-intelligence-enabled.js b/backend/migrations/20250620000001-add-task-intelligence-enabled.js index 2e49d8d..7bfab68 100644 --- a/backend/migrations/20250620000001-add-task-intelligence-enabled.js +++ b/backend/migrations/20250620000001-add-task-intelligence-enabled.js @@ -19,4 +19,4 @@ module.exports = { down: async (queryInterface, Sequelize) => { await queryInterface.removeColumn('users', 'task_intelligence_enabled'); }, -}; \ No newline at end of file +}; diff --git a/backend/migrations/20250620000002-add-auto-suggest-next-actions-enabled.js b/backend/migrations/20250620000002-add-auto-suggest-next-actions-enabled.js index b8bc796..2539f0a 100644 --- a/backend/migrations/20250620000002-add-auto-suggest-next-actions-enabled.js +++ b/backend/migrations/20250620000002-add-auto-suggest-next-actions-enabled.js @@ -22,4 +22,4 @@ module.exports = { 'auto_suggest_next_actions_enabled' ); }, -}; \ No newline at end of file +}; diff --git a/backend/migrations/20250621221841-add-completed-at-to-tasks.js b/backend/migrations/20250621221841-add-completed-at-to-tasks.js index c9b751b..6de0df0 100644 --- a/backend/migrations/20250621221841-add-completed-at-to-tasks.js +++ b/backend/migrations/20250621221841-add-completed-at-to-tasks.js @@ -21,4 +21,4 @@ module.exports = { await queryInterface.removeIndex('tasks', ['completed_at']); await queryInterface.removeColumn('tasks', 'completed_at'); }, -}; \ No newline at end of file +}; diff --git a/backend/migrations/20250621223000-create-calendar-tokens.js b/backend/migrations/20250621223000-create-calendar-tokens.js index 836bd3f..f3dfcf7 100644 --- a/backend/migrations/20250621223000-create-calendar-tokens.js +++ b/backend/migrations/20250621223000-create-calendar-tokens.js @@ -75,4 +75,4 @@ module.exports = { down: async (queryInterface, Sequelize) => { await queryInterface.dropTable('calendar_tokens'); }, -}; \ No newline at end of file +}; diff --git a/backend/migrations/20250622000001-create-task-events.js b/backend/migrations/20250622000001-create-task-events.js index 0d03f1d..daf06b2 100644 --- a/backend/migrations/20250622000001-create-task-events.js +++ b/backend/migrations/20250622000001-create-task-events.js @@ -62,13 +62,25 @@ module.exports = { await safeAddIndex(queryInterface, 'task_events', ['user_id']); await safeAddIndex(queryInterface, 'task_events', ['event_type']); await safeAddIndex(queryInterface, 'task_events', ['created_at']); - await safeAddIndex(queryInterface, 'task_events', ['task_id', 'event_type']); - await safeAddIndex(queryInterface, 'task_events', ['task_id', 'created_at']); + await safeAddIndex(queryInterface, 'task_events', [ + 'task_id', + 'event_type', + ]); + await safeAddIndex(queryInterface, 'task_events', [ + 'task_id', + 'created_at', + ]); }, async down(queryInterface, Sequelize) { - await queryInterface.removeIndex('task_events', ['task_id', 'created_at']); - await queryInterface.removeIndex('task_events', ['task_id', 'event_type']); + await queryInterface.removeIndex('task_events', [ + 'task_id', + 'created_at', + ]); + await queryInterface.removeIndex('task_events', [ + 'task_id', + 'event_type', + ]); await queryInterface.removeIndex('task_events', ['created_at']); await queryInterface.removeIndex('task_events', ['event_type']); await queryInterface.removeIndex('task_events', ['user_id']); @@ -76,4 +88,4 @@ module.exports = { await queryInterface.dropTable('task_events'); }, -}; \ No newline at end of file +}; diff --git a/backend/migrations/20250622053925-add-pomodoro-enabled-to-users.js b/backend/migrations/20250622053925-add-pomodoro-enabled-to-users.js index c426d1e..6e095d9 100644 --- a/backend/migrations/20250622053925-add-pomodoro-enabled-to-users.js +++ b/backend/migrations/20250622053925-add-pomodoro-enabled-to-users.js @@ -19,4 +19,4 @@ module.exports = { async down(queryInterface, Sequelize) { await queryInterface.removeColumn('users', 'pomodoro_enabled'); }, -}; \ No newline at end of file +}; diff --git a/backend/migrations/20250623000001-add-uuid-to-tasks.js b/backend/migrations/20250623000001-add-uuid-to-tasks.js index 15f94ea..45bd7dd 100644 --- a/backend/migrations/20250623000001-add-uuid-to-tasks.js +++ b/backend/migrations/20250623000001-add-uuid-to-tasks.js @@ -5,7 +5,6 @@ const { safeAddColumns, safeAddIndex } = require('../utils/migration-utils'); module.exports = { async up(queryInterface, Sequelize) { - await safeAddColumns(queryInterface, 'tasks', [ { name: 'uuid', @@ -36,7 +35,6 @@ module.exports = { }, async down(queryInterface, Sequelize) { - await queryInterface.removeIndex('tasks', 'tasks_uuid_unique'); await queryInterface.removeColumn('tasks', 'uuid'); diff --git a/backend/migrations/20250623000003-create-notes-tags-table.js b/backend/migrations/20250623000003-create-notes-tags-table.js index 5639e62..de7ffc6 100644 --- a/backend/migrations/20250623000003-create-notes-tags-table.js +++ b/backend/migrations/20250623000003-create-notes-tags-table.js @@ -2,7 +2,6 @@ module.exports = { async up(queryInterface, Sequelize) { - const tables = await queryInterface.showAllTables(); if (!tables.includes('notes_tags')) { await queryInterface.createTable('notes_tags', { diff --git a/backend/migrations/20250623000004-add-timestamps-to-notes-tags.js b/backend/migrations/20250623000004-add-timestamps-to-notes-tags.js index 038ee79..6810b0e 100644 --- a/backend/migrations/20250623000004-add-timestamps-to-notes-tags.js +++ b/backend/migrations/20250623000004-add-timestamps-to-notes-tags.js @@ -28,4 +28,4 @@ module.exports = { await queryInterface.removeColumn('notes_tags', 'created_at'); await queryInterface.removeColumn('notes_tags', 'updated_at'); }, -}; \ No newline at end of file +}; diff --git a/backend/migrations/20250623000005-add-timestamps-to-projects-tags.js b/backend/migrations/20250623000005-add-timestamps-to-projects-tags.js index 6ab3f90..ec85c82 100644 --- a/backend/migrations/20250623000005-add-timestamps-to-projects-tags.js +++ b/backend/migrations/20250623000005-add-timestamps-to-projects-tags.js @@ -4,7 +4,6 @@ const { safeCreateTable, safeAddColumns } = require('../utils/migration-utils'); module.exports = { async up(queryInterface, Sequelize) { - const tableExists = await queryInterface .showAllTables() .then((tables) => tables.includes('projects_tags')); @@ -49,7 +48,6 @@ module.exports = { name: 'projects_tags_pkey', }); } else { - await safeAddColumns(queryInterface, 'projects_tags', [ { name: 'created_at', @@ -72,12 +70,9 @@ module.exports = { }, async down(queryInterface, Sequelize) { - try { await queryInterface.removeColumn('projects_tags', 'created_at'); await queryInterface.removeColumn('projects_tags', 'updated_at'); - } catch (error) { - - } + } catch (error) {} }, -}; \ No newline at end of file +}; diff --git a/backend/migrations/20250711000001-add-suggestion-metadata-to-inbox-items.js b/backend/migrations/20250711000001-add-suggestion-metadata-to-inbox-items.js index f0fd6de..ec3f2e6 100644 --- a/backend/migrations/20250711000001-add-suggestion-metadata-to-inbox-items.js +++ b/backend/migrations/20250711000001-add-suggestion-metadata-to-inbox-items.js @@ -10,7 +10,7 @@ module.exports = { definition: { type: Sequelize.STRING, allowNull: true, - comment: 'AI suggested item type: task, note, or null' + comment: 'AI suggested item type: task, note, or null', }, }, { @@ -18,7 +18,8 @@ module.exports = { definition: { type: Sequelize.STRING, allowNull: true, - comment: 'Reason for suggestion: verb_detected, bookmark_tag, etc.' + comment: + 'Reason for suggestion: verb_detected, bookmark_tag, etc.', }, }, { @@ -26,7 +27,7 @@ module.exports = { definition: { type: Sequelize.JSON, allowNull: true, - comment: 'Array of parsed hashtags from content' + comment: 'Array of parsed hashtags from content', }, }, { @@ -34,7 +35,7 @@ module.exports = { definition: { type: Sequelize.JSON, allowNull: true, - comment: 'Array of parsed project references from content' + comment: 'Array of parsed project references from content', }, }, { @@ -42,7 +43,7 @@ module.exports = { definition: { type: Sequelize.STRING, allowNull: true, - comment: 'Content with tags and project references removed' + comment: 'Content with tags and project references removed', }, }, ]); @@ -54,5 +55,5 @@ module.exports = { await queryInterface.removeColumn('inbox_items', 'parsed_tags'); await queryInterface.removeColumn('inbox_items', 'parsed_projects'); await queryInterface.removeColumn('inbox_items', 'cleaned_content'); - } -}; \ No newline at end of file + }, +}; diff --git a/backend/migrations/20250713072131-add-productivity-assistant-columns.js b/backend/migrations/20250713072131-add-productivity-assistant-columns.js index 4d61ca7..0d664b8 100644 --- a/backend/migrations/20250713072131-add-productivity-assistant-columns.js +++ b/backend/migrations/20250713072131-add-productivity-assistant-columns.js @@ -3,7 +3,6 @@ module.exports = { async up(queryInterface, Sequelize) { try { - const tableInfo = await queryInterface.describeTable('users'); const columnsToAdd = [ @@ -41,7 +40,6 @@ module.exports = { }, async down(queryInterface, Sequelize) { - await queryInterface.removeColumn( 'users', 'productivity_assistant_enabled' diff --git a/backend/routes/inbox.js b/backend/routes/inbox.js index a643974..5a97505 100644 --- a/backend/routes/inbox.js +++ b/backend/routes/inbox.js @@ -176,7 +176,9 @@ router.post('/inbox/analyze-text', async (req, res) => { const { content } = req.body; if (!content || typeof content !== 'string') { - return res.status(400).json({ error: 'Content is required and must be a string' }); + return res + .status(400) + .json({ error: 'Content is required and must be a string' }); } // Process the text using the inbox processing service diff --git a/backend/routes/projects.js b/backend/routes/projects.js index 30c0f63..e9542f3 100644 --- a/backend/routes/projects.js +++ b/backend/routes/projects.js @@ -234,7 +234,13 @@ router.get('/project/:id', async (req, res) => { { model: Note, required: false, - attributes: ['id', 'title', 'content', 'created_at', 'updated_at'], + attributes: [ + 'id', + 'title', + 'content', + 'created_at', + 'updated_at', + ], }, { model: Area, required: false, attributes: ['id', 'name'] }, { @@ -250,19 +256,25 @@ router.get('/project/:id', async (req, res) => { } const projectJson = project.toJSON(); - + // Normalize task data to match frontend expectations - const normalizedTasks = projectJson.Tasks ? projectJson.Tasks.map(task => { - const normalizedTask = { - ...task, - tags: task.Tags || [], // Normalize Tags to tags for each task - due_date: task.due_date ? (typeof task.due_date === 'string' ? task.due_date.split('T')[0] : task.due_date.toISOString().split('T')[0]) : null - }; - // Remove the original Tags property to avoid confusion - delete normalizedTask.Tags; - return normalizedTask; - }) : []; - + const normalizedTasks = projectJson.Tasks + ? projectJson.Tasks.map((task) => { + const normalizedTask = { + ...task, + tags: task.Tags || [], // Normalize Tags to tags for each task + due_date: task.due_date + ? typeof task.due_date === 'string' + ? task.due_date.split('T')[0] + : task.due_date.toISOString().split('T')[0] + : null, + }; + // Remove the original Tags property to avoid confusion + delete normalizedTask.Tags; + return normalizedTask; + }) + : []; + const result = { ...projectJson, tags: projectJson.Tags || [], // Normalize Tags to tags diff --git a/backend/routes/tags.js b/backend/routes/tags.js index 27f5936..a8db10f 100644 --- a/backend/routes/tags.js +++ b/backend/routes/tags.js @@ -22,11 +22,14 @@ router.get('/tag/:identifier', async (req, res) => { try { const identifier = req.params.identifier; let whereClause; - + // Check if identifier is a number (ID) or string (name) if (/^\d+$/.test(identifier)) { // It's a numeric ID - whereClause = { id: parseInt(identifier), user_id: req.currentUser.id }; + whereClause = { + id: parseInt(identifier), + user_id: req.currentUser.id, + }; } else { // It's a tag name - decode URI component to handle special characters const tagName = decodeURIComponent(identifier); @@ -80,11 +83,14 @@ router.patch('/tag/:identifier', async (req, res) => { try { const identifier = req.params.identifier; let whereClause; - + // Check if identifier is a number (ID) or string (name) if (/^\d+$/.test(identifier)) { // It's a numeric ID - whereClause = { id: parseInt(identifier), user_id: req.currentUser.id }; + whereClause = { + id: parseInt(identifier), + user_id: req.currentUser.id, + }; } else { // It's a tag name - decode URI component to handle special characters const tagName = decodeURIComponent(identifier); @@ -126,11 +132,14 @@ router.delete('/tag/:identifier', async (req, res) => { try { const identifier = req.params.identifier; let whereClause; - + // Check if identifier is a number (ID) or string (name) if (/^\d+$/.test(identifier)) { // It's a numeric ID - whereClause = { id: parseInt(identifier), user_id: req.currentUser.id }; + whereClause = { + id: parseInt(identifier), + user_id: req.currentUser.id, + }; } else { // It's a tag name - decode URI component to handle special characters const tagName = decodeURIComponent(identifier); diff --git a/backend/routes/users.js b/backend/routes/users.js index 8799b5b..9f55ba0 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -433,12 +433,13 @@ router.put('/profile/today-settings', async (req, res) => { profileUpdates.productivity_assistant_enabled = showProductivity; } if (showNextTaskSuggestion !== undefined) { - profileUpdates.next_task_suggestion_enabled = showNextTaskSuggestion; + profileUpdates.next_task_suggestion_enabled = + showNextTaskSuggestion; } - await user.update({ + await user.update({ today_settings: todaySettings, - ...profileUpdates + ...profileUpdates, }); res.json({ diff --git a/backend/services/inboxProcessingService.js b/backend/services/inboxProcessingService.js index a831dd1..253aa6a 100644 --- a/backend/services/inboxProcessingService.js +++ b/backend/services/inboxProcessingService.js @@ -13,27 +13,50 @@ class InboxProcessingService { */ static isActionVerb(word) { if (!word || typeof word !== 'string') return false; - + try { const doc = nlp(word.toLowerCase()); const verbs = doc.verbs(); - + if (verbs.length === 0) return false; - + // Check if it's an action verb (not auxiliary/linking verbs when used alone) const text = verbs.text().toLowerCase(); - + // Allow "do" when it's part of an action phrase like "do something" if (text === 'do') { // Check the original word context to see if it's followed by a noun/action return true; // For now, allow "do" - could refine this logic later } - - const auxiliaryVerbs = ['be', 'is', 'am', 'are', 'was', 'were', 'being', 'been', - 'have', 'has', 'had', 'having', 'does', 'did', 'doing', - 'will', 'would', 'shall', 'should', 'may', 'might', 'can', - 'could', 'must', 'ought']; - + + const auxiliaryVerbs = [ + 'be', + 'is', + 'am', + 'are', + 'was', + 'were', + 'being', + 'been', + 'have', + 'has', + 'had', + 'having', + 'does', + 'did', + 'doing', + 'will', + 'would', + 'shall', + 'should', + 'may', + 'might', + 'can', + 'could', + 'must', + 'ought', + ]; + return !auxiliaryVerbs.includes(text); } catch (error) { console.error('Error checking verb:', error); @@ -51,11 +74,11 @@ class InboxProcessingService { let currentToken = ''; let inQuotes = false; let i = 0; - + while (i < text.length) { const char = text[i]; - - if (char === '"' && (i === 0 || text[i-1] === '+')) { + + if (char === '"' && (i === 0 || text[i - 1] === '+')) { // Start of a quoted string after + inQuotes = true; currentToken += char; @@ -75,12 +98,12 @@ class InboxProcessingService { } i++; } - + // Add final token if (currentToken) { tokens.push(currentToken); } - + return tokens; } @@ -92,11 +115,11 @@ class InboxProcessingService { static parseHashtags(text) { const trimmedText = text.trim(); const matches = []; - + // Split text into words const words = trimmedText.split(/\s+/); if (words.length === 0) return matches; - + // Find all consecutive groups of tags/projects let i = 0; while (i < words.length) { @@ -104,27 +127,35 @@ class InboxProcessingService { if (words[i].startsWith('#') || words[i].startsWith('+')) { // Found start of a group, collect all consecutive tags/projects let groupEnd = i; - while (groupEnd < words.length && (words[groupEnd].startsWith('#') || words[groupEnd].startsWith('+'))) { + while ( + groupEnd < words.length && + (words[groupEnd].startsWith('#') || + words[groupEnd].startsWith('+')) + ) { groupEnd++; } - + // Process all hashtags in this group for (let j = i; j < groupEnd; j++) { if (words[j].startsWith('#')) { const tagName = words[j].substring(1); - if (tagName && /^[a-zA-Z0-9_-]+$/.test(tagName) && !matches.includes(tagName)) { + if ( + tagName && + /^[a-zA-Z0-9_-]+$/.test(tagName) && + !matches.includes(tagName) + ) { matches.push(tagName); } } } - + // Skip to end of this group i = groupEnd; } else { i++; } } - + return matches; } @@ -136,10 +167,10 @@ class InboxProcessingService { static parseProjectRefs(text) { const trimmedText = text.trim(); const matches = []; - + // Tokenize the text handling quoted strings properly const tokens = this.tokenizeText(trimmedText); - + // Find consecutive groups of tags/projects let i = 0; while (i < tokens.length) { @@ -147,33 +178,40 @@ class InboxProcessingService { if (tokens[i].startsWith('#') || tokens[i].startsWith('+')) { // Found start of a group, collect all consecutive tags/projects let groupEnd = i; - while (groupEnd < tokens.length && (tokens[groupEnd].startsWith('#') || tokens[groupEnd].startsWith('+'))) { + while ( + groupEnd < tokens.length && + (tokens[groupEnd].startsWith('#') || + tokens[groupEnd].startsWith('+')) + ) { groupEnd++; } - + // Process all project references in this group for (let j = i; j < groupEnd; j++) { if (tokens[j].startsWith('+')) { let projectName = tokens[j].substring(1); - + // Handle quoted project names - if (projectName.startsWith('"') && projectName.endsWith('"')) { + if ( + projectName.startsWith('"') && + projectName.endsWith('"') + ) { projectName = projectName.slice(1, -1); } - + if (projectName && !matches.includes(projectName)) { matches.push(projectName); } } } - + // Skip to end of this group i = groupEnd; } else { i++; } } - + return matches; } @@ -186,13 +224,16 @@ class InboxProcessingService { const trimmedText = text.trim(); const tokens = this.tokenizeText(trimmedText); const cleanedTokens = []; - + let i = 0; while (i < tokens.length) { // Check if current token starts a tag/project group if (tokens[i].startsWith('#') || tokens[i].startsWith('+')) { // Skip this entire consecutive group - while (i < tokens.length && (tokens[i].startsWith('#') || tokens[i].startsWith('+'))) { + while ( + i < tokens.length && + (tokens[i].startsWith('#') || tokens[i].startsWith('+')) + ) { i++; } } else { @@ -201,7 +242,7 @@ class InboxProcessingService { i++; } } - + return cleanedTokens.join(' ').trim(); } @@ -212,11 +253,11 @@ class InboxProcessingService { */ static startsWithVerb(text) { if (!text.trim()) return false; - + try { const firstWord = text.trim().split(/\s+/)[0]; if (!firstWord) return false; - + return this.isActionVerb(firstWord); } catch (error) { console.error('Error checking if text starts with verb:', error); @@ -244,38 +285,40 @@ class InboxProcessingService { */ static generateSuggestion(content, tags, projects, cleanedContent) { const hasProject = projects.length > 0; - const hasBookmarkTag = tags.some(tag => tag.toLowerCase() === 'bookmark'); + const hasBookmarkTag = tags.some( + (tag) => tag.toLowerCase() === 'bookmark' + ); const textStartsWithVerb = this.startsWithVerb(cleanedContent); const containsUrl = this.containsUrl(content); - + if (!hasProject) { return { type: null, reason: null }; } - + // Suggest note for bookmark items with project (explicit bookmark tag) if (hasBookmarkTag) { return { type: 'note', - reason: 'bookmark_tag' + reason: 'bookmark_tag', }; } - + // Suggest note for URLs with project (auto-bookmark) if (containsUrl) { return { type: 'note', - reason: 'url_detected' + reason: 'url_detected', }; } - + // Suggest task for items with project that start with a verb if (textStartsWithVerb) { return { type: 'task', - reason: 'verb_detected' + reason: 'verb_detected', }; } - + return { type: null, reason: null }; } @@ -289,18 +332,23 @@ class InboxProcessingService { const tags = this.parseHashtags(content); const projects = this.parseProjectRefs(content); const cleanedContent = this.cleanTextFromTagsAndProjects(content); - + // Generate suggestion - const suggestion = this.generateSuggestion(content, tags, projects, cleanedContent); - + const suggestion = this.generateSuggestion( + content, + tags, + projects, + cleanedContent + ); + return { parsed_tags: tags, parsed_projects: projects, cleaned_content: cleanedContent, suggested_type: suggestion.type, - suggested_reason: suggestion.reason + suggested_reason: suggestion.reason, }; } } -module.exports = InboxProcessingService; \ No newline at end of file +module.exports = InboxProcessingService; diff --git a/backend/services/telegramApi.js b/backend/services/telegramApi.js index 0adf167..14afb65 100644 --- a/backend/services/telegramApi.js +++ b/backend/services/telegramApi.js @@ -2,7 +2,7 @@ async function getBotInfo(token) { return new Promise((resolve, reject) => { const url = `https://api.telegram.org/bot${token}/getMe`; - + const options = { method: 'GET', headers: { @@ -12,18 +12,21 @@ async function getBotInfo(token) { const req = require('https').request(url, options, (res) => { let data = ''; - + res.on('data', (chunk) => { data += chunk; }); - + res.on('end', () => { try { const response = JSON.parse(data); if (response.ok) { resolve(response.result); } else { - console.error('Telegram API error:', response.description); + console.error( + 'Telegram API error:', + response.description + ); resolve(null); } } catch (error) { @@ -42,4 +45,4 @@ async function getBotInfo(token) { }); } -module.exports = { getBotInfo }; \ No newline at end of file +module.exports = { getBotInfo }; diff --git a/frontend/components/Areas.tsx b/frontend/components/Areas.tsx index 858d0f6..5c4b6f2 100644 --- a/frontend/components/Areas.tsx +++ b/frontend/components/Areas.tsx @@ -109,9 +109,7 @@ const Areas: React.FC = () => { {/* Areas Header */}
-

- {t('areas.title')} -

+

{t('areas.title')}

{/* Areas List */} diff --git a/frontend/components/Icons/TelegramIcon.tsx b/frontend/components/Icons/TelegramIcon.tsx index e1da951..475ef0d 100644 --- a/frontend/components/Icons/TelegramIcon.tsx +++ b/frontend/components/Icons/TelegramIcon.tsx @@ -5,7 +5,10 @@ interface TelegramIconProps { title?: string; } -const TelegramIcon: React.FC = ({ className = "h-5 w-5", title }) => { +const TelegramIcon: React.FC = ({ + className = 'h-5 w-5', + title, +}) => { return ( = ({ className = "h-5 w-5", titl ); }; -export default TelegramIcon; \ No newline at end of file +export default TelegramIcon; diff --git a/frontend/components/Inbox/InboxItemDetail.tsx b/frontend/components/Inbox/InboxItemDetail.tsx index f72319b..636d6db 100644 --- a/frontend/components/Inbox/InboxItemDetail.tsx +++ b/frontend/components/Inbox/InboxItemDetail.tsx @@ -48,11 +48,11 @@ const InboxItemDetail: React.FC = ({ const parseHashtags = (text: string): string[] => { const trimmedText = text.trim(); const matches: string[] = []; - + // Split text into words const words = trimmedText.split(/\s+/); if (words.length === 0) return matches; - + // Find all consecutive groups of tags/projects let i = 0; while (i < words.length) { @@ -60,27 +60,35 @@ const InboxItemDetail: React.FC = ({ if (words[i].startsWith('#') || words[i].startsWith('+')) { // Found start of a group, collect all consecutive tags/projects let groupEnd = i; - while (groupEnd < words.length && (words[groupEnd].startsWith('#') || words[groupEnd].startsWith('+'))) { + while ( + groupEnd < words.length && + (words[groupEnd].startsWith('#') || + words[groupEnd].startsWith('+')) + ) { groupEnd++; } - + // Process all hashtags in this group for (let j = i; j < groupEnd; j++) { if (words[j].startsWith('#')) { const tagName = words[j].substring(1); - if (tagName && /^[a-zA-Z0-9_-]+$/.test(tagName) && !matches.includes(tagName)) { + if ( + tagName && + /^[a-zA-Z0-9_-]+$/.test(tagName) && + !matches.includes(tagName) + ) { matches.push(tagName); } } } - + // Skip to end of this group i = groupEnd; } else { i++; } } - + return matches; }; @@ -88,10 +96,10 @@ const InboxItemDetail: React.FC = ({ const parseProjectRefs = (text: string): string[] => { const trimmedText = text.trim(); const matches: string[] = []; - + // Tokenize the text handling quoted strings properly const tokens = tokenizeText(trimmedText); - + // Find consecutive groups of tags/projects let i = 0; while (i < tokens.length) { @@ -99,47 +107,54 @@ const InboxItemDetail: React.FC = ({ if (tokens[i].startsWith('#') || tokens[i].startsWith('+')) { // Found start of a group, collect all consecutive tags/projects let groupEnd = i; - while (groupEnd < tokens.length && (tokens[groupEnd].startsWith('#') || tokens[groupEnd].startsWith('+'))) { + while ( + groupEnd < tokens.length && + (tokens[groupEnd].startsWith('#') || + tokens[groupEnd].startsWith('+')) + ) { groupEnd++; } - + // Process all project references in this group for (let j = i; j < groupEnd; j++) { if (tokens[j].startsWith('+')) { let projectName = tokens[j].substring(1); - + // Handle quoted project names - if (projectName.startsWith('"') && projectName.endsWith('"')) { + if ( + projectName.startsWith('"') && + projectName.endsWith('"') + ) { projectName = projectName.slice(1, -1); } - + if (projectName && !matches.includes(projectName)) { matches.push(projectName); } } } - + // Skip to end of this group i = groupEnd; } else { i++; } } - + return matches; }; - + // Helper function to tokenize text handling quoted strings const tokenizeText = (text: string): string[] => { const tokens: string[] = []; let currentToken = ''; let inQuotes = false; let i = 0; - + while (i < text.length) { const char = text[i]; - - if (char === '"' && (i === 0 || text[i-1] === '+')) { + + if (char === '"' && (i === 0 || text[i - 1] === '+')) { // Start of a quoted string after + inQuotes = true; currentToken += char; @@ -159,12 +174,12 @@ const InboxItemDetail: React.FC = ({ } i++; } - + // Add final token if (currentToken) { tokens.push(currentToken); } - + return tokens; }; @@ -173,13 +188,16 @@ const InboxItemDetail: React.FC = ({ const trimmedText = text.trim(); const tokens = tokenizeText(trimmedText); const cleanedTokens: string[] = []; - + let i = 0; while (i < tokens.length) { // Check if current token starts a tag/project group if (tokens[i].startsWith('#') || tokens[i].startsWith('+')) { // Skip this entire consecutive group - while (i < tokens.length && (tokens[i].startsWith('#') || tokens[i].startsWith('+'))) { + while ( + i < tokens.length && + (tokens[i].startsWith('#') || tokens[i].startsWith('+')) + ) { i++; } } else { @@ -188,7 +206,7 @@ const InboxItemDetail: React.FC = ({ i++; } } - + return cleanedTokens.join(' ').trim(); }; @@ -196,8 +214,6 @@ const InboxItemDetail: React.FC = ({ const projectRefs = parseProjectRefs(item.content); const cleanedContent = cleanTextFromTagsAndProjects(item.content); - - const handleConvertToTask = () => { // Convert hashtags to Tag objects const taskTags = hashtags.map((hashtagName) => { @@ -214,7 +230,8 @@ const InboxItemDetail: React.FC = ({ // Look for an existing project with the first project reference name const projectName = projectRefs[0]; const matchingProject = projects.find( - (project) => project.name.toLowerCase() === projectName.toLowerCase() + (project) => + project.name.toLowerCase() === projectName.toLowerCase() ); if (matchingProject) { projectId = matchingProject.id; @@ -321,29 +338,30 @@ const InboxItemDetail: React.FC = ({ const tagObjects = [...hashtagTags, ...bookmarkTag]; // Use cleaned content for note title if no URL title was extracted - const finalTitle = title === content ? (cleanedContent || item.content) : title; + const finalTitle = + title === content ? cleanedContent || item.content : title; const finalContent = cleanedContent || item.content; - + // Find the project to assign (use first project reference if any) let projectId = undefined; if (projectRefs.length > 0) { // Look for an existing project with the first project reference name const projectName = projectRefs[0]; const matchingProject = projects.find( - (project) => project.name.toLowerCase() === projectName.toLowerCase() + (project) => + project.name.toLowerCase() === projectName.toLowerCase() ); if (matchingProject) { projectId = matchingProject.id; } } - + const newNote: Note = { title: finalTitle, content: finalContent, tags: tagObjects, project_id: projectId, }; - if (item.id !== undefined) { openNoteModal(newNote, item.id); @@ -385,12 +403,12 @@ const InboxItemDetail: React.FC = ({ {projectRefs.join(', ')} )} - + {/* Add spacing between project and tags */} {projectRefs.length > 0 && hashtags.length > 0 && ( )} - + {/* Tags display */} {hashtags.length > 0 && (
@@ -400,7 +418,6 @@ const InboxItemDetail: React.FC = ({ )}
)} -
diff --git a/frontend/components/Inbox/InboxItems.tsx b/frontend/components/Inbox/InboxItems.tsx index f115ab1..f692c0c 100644 --- a/frontend/components/Inbox/InboxItems.tsx +++ b/frontend/components/Inbox/InboxItems.tsx @@ -36,7 +36,7 @@ const InboxItems: React.FC = () => { const [isProjectModalOpen, setIsProjectModalOpen] = useState(false); const [isNoteModalOpen, setIsNoteModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false); - const [isInfoExpanded, setIsInfoExpanded] = useState(false); + const [isInfoExpanded, setIsInfoExpanded] = useState(false); // Data for modals const [taskToEdit, setTaskToEdit] = useState(null); @@ -57,7 +57,7 @@ const InboxItems: React.FC = () => { useEffect(() => { // Initial data loading loadInboxItemsToStore(true); - + // Load projects initially const loadInitialProjects = async () => { try { @@ -230,7 +230,7 @@ const InboxItems: React.FC = () => { setCurrentConversionItemId(inboxItemId); } - // Projects should already be loaded from initial useEffect, + // Projects should already be loaded from initial useEffect, // but refresh them if they're empty as a fallback if (projects.length === 0) { try { @@ -238,7 +238,9 @@ const InboxItems: React.FC = () => { setProjects(Array.isArray(projectData) ? projectData : []); } catch (error) { console.error('Failed to load projects:', error); - showErrorToast(t('project.loadError', 'Failed to load projects')); + showErrorToast( + t('project.loadError', 'Failed to load projects') + ); setProjects([]); } } @@ -356,32 +358,63 @@ const InboxItems: React.FC = () => {
-

{t('inbox.title')}

+

+ {t('inbox.title')} +

{/* Info section below title row */} -
{/* Large low-opacity info icon */}
- - + +
@@ -445,7 +478,9 @@ const InboxItems: React.FC = () => { } onSave={handleSaveTask} onDelete={async () => {}} // No need to delete since it's a new task - projects={Array.isArray(projects) ? projects : []} + projects={ + Array.isArray(projects) ? projects : [] + } onCreateProject={handleCreateProject} /> ); @@ -472,7 +507,10 @@ const InboxItems: React.FC = () => { /> ); } catch (error) { - console.error('ProjectModal rendering error:', error); + console.error( + 'ProjectModal rendering error:', + error + ); return null; } })()} @@ -489,7 +527,9 @@ const InboxItems: React.FC = () => { }} onSave={handleSaveNote} note={noteToEdit} - projects={Array.isArray(projects) ? projects : []} + projects={ + Array.isArray(projects) ? projects : [] + } onCreateProject={handleCreateProject} /> ); diff --git a/frontend/components/Inbox/InboxModal.tsx b/frontend/components/Inbox/InboxModal.tsx index 0cee264..0818a47 100644 --- a/frontend/components/Inbox/InboxModal.tsx +++ b/frontend/components/Inbox/InboxModal.tsx @@ -63,7 +63,7 @@ const InboxModal: React.FC = ({ top: 0, }); // const [urlPreview, setUrlPreview] = useState(null); - + // Real-time text analysis state const [analysisResult, setAnalysisResult] = useState<{ parsed_tags: string[]; @@ -81,11 +81,11 @@ const InboxModal: React.FC = ({ const parseHashtags = (text: string): string[] => { const trimmedText = text.trim(); const matches: string[] = []; - + // Split text into words const words = trimmedText.split(/\s+/); if (words.length === 0) return matches; - + // Find all consecutive groups of tags/projects let i = 0; while (i < words.length) { @@ -93,27 +93,35 @@ const InboxModal: React.FC = ({ if (words[i].startsWith('#') || words[i].startsWith('+')) { // Found start of a group, collect all consecutive tags/projects let groupEnd = i; - while (groupEnd < words.length && (words[groupEnd].startsWith('#') || words[groupEnd].startsWith('+'))) { + while ( + groupEnd < words.length && + (words[groupEnd].startsWith('#') || + words[groupEnd].startsWith('+')) + ) { groupEnd++; } - + // Process all hashtags in this group for (let j = i; j < groupEnd; j++) { if (words[j].startsWith('#')) { const tagName = words[j].substring(1); - if (tagName && /^[a-zA-Z0-9_-]+$/.test(tagName) && !matches.includes(tagName)) { + if ( + tagName && + /^[a-zA-Z0-9_-]+$/.test(tagName) && + !matches.includes(tagName) + ) { matches.push(tagName); } } } - + // Skip to end of this group i = groupEnd; } else { i++; } } - + return matches; }; @@ -121,10 +129,10 @@ const InboxModal: React.FC = ({ const parseProjectRefs = (text: string): string[] => { const trimmedText = text.trim(); const matches: string[] = []; - + // Tokenize the text handling quoted strings properly const tokens = tokenizeText(trimmedText); - + // Find consecutive groups of tags/projects let i = 0; while (i < tokens.length) { @@ -132,47 +140,54 @@ const InboxModal: React.FC = ({ if (tokens[i].startsWith('#') || tokens[i].startsWith('+')) { // Found start of a group, collect all consecutive tags/projects let groupEnd = i; - while (groupEnd < tokens.length && (tokens[groupEnd].startsWith('#') || tokens[groupEnd].startsWith('+'))) { + while ( + groupEnd < tokens.length && + (tokens[groupEnd].startsWith('#') || + tokens[groupEnd].startsWith('+')) + ) { groupEnd++; } - + // Process all project references in this group for (let j = i; j < groupEnd; j++) { if (tokens[j].startsWith('+')) { let projectName = tokens[j].substring(1); - + // Handle quoted project names - if (projectName.startsWith('"') && projectName.endsWith('"')) { + if ( + projectName.startsWith('"') && + projectName.endsWith('"') + ) { projectName = projectName.slice(1, -1); } - + if (projectName && !matches.includes(projectName)) { matches.push(projectName); } } } - + // Skip to end of this group i = groupEnd; } else { i++; } } - + return matches; }; - + // Helper function to tokenize text handling quoted strings const tokenizeText = (text: string): string[] => { const tokens: string[] = []; let currentToken = ''; let inQuotes = false; let i = 0; - + while (i < text.length) { const char = text[i]; - - if (char === '"' && (i === 0 || text[i-1] === '+')) { + + if (char === '"' && (i === 0 || text[i - 1] === '+')) { // Start of a quoted string after + inQuotes = true; currentToken += char; @@ -192,12 +207,12 @@ const InboxModal: React.FC = ({ } i++; } - + // Add final token if (currentToken) { tokens.push(currentToken); } - + return tokens; }; @@ -206,33 +221,36 @@ const InboxModal: React.FC = ({ const beforeCursor = text.substring(0, position); const afterCursor = text.substring(position); const hashtagMatch = beforeCursor.match(/#([a-zA-Z0-9_]*)$/); - + if (!hashtagMatch) return ''; - + // Check if hashtag is at start or end position const hashtagStart = beforeCursor.lastIndexOf('#'); const textBeforeHashtag = text.substring(0, hashtagStart).trim(); const textAfterCursor = afterCursor.trim(); - + // Check if we're at the very end (no text after cursor) if (textAfterCursor === '') { return hashtagMatch[1]; } - + // Check if we're at the very beginning if (textBeforeHashtag === '') { return hashtagMatch[1]; } - + // Check if we're in a consecutive group of tags/projects at the beginning - const wordsBeforeHashtag = textBeforeHashtag.split(/\s+/).filter(word => word.length > 0); - const allWordsAreTagsOrProjects = wordsBeforeHashtag.every(word => - word.startsWith('#') || word.startsWith('+')); - + const wordsBeforeHashtag = textBeforeHashtag + .split(/\s+/) + .filter((word) => word.length > 0); + const allWordsAreTagsOrProjects = wordsBeforeHashtag.every( + (word) => word.startsWith('#') || word.startsWith('+') + ); + if (allWordsAreTagsOrProjects) { return hashtagMatch[1]; } - + return ''; }; @@ -241,44 +259,51 @@ const InboxModal: React.FC = ({ const beforeCursor = text.substring(0, position); const afterCursor = text.substring(position); // Match both quoted and unquoted project references - const projectMatch = beforeCursor.match(/\+(?:"([^"]*)"|([a-zA-Z0-9_\s]*))$/); - + const projectMatch = beforeCursor.match( + /\+(?:"([^"]*)"|([a-zA-Z0-9_\s]*))$/ + ); + if (!projectMatch) return ''; - + // Get the project name (from quoted or unquoted match) const projectQuery = projectMatch[1] || projectMatch[2] || ''; - + // Check if project ref is at start or end position const projectStart = beforeCursor.lastIndexOf('+'); const textBeforeProject = text.substring(0, projectStart).trim(); const textAfterCursor = afterCursor.trim(); - + // Check if we're at the very end (no text after cursor) if (textAfterCursor === '') { return projectQuery; } - + // Check if we're at the very beginning if (textBeforeProject === '') { return projectQuery; } - + // Check if we're in a consecutive group of tags/projects at the beginning - const wordsBeforeProject = textBeforeProject.split(/\s+/).filter(word => word.length > 0); - const allWordsAreTagsOrProjects = wordsBeforeProject.every(word => - word.startsWith('#') || word.startsWith('+')); - + const wordsBeforeProject = textBeforeProject + .split(/\s+/) + .filter((word) => word.length > 0); + const allWordsAreTagsOrProjects = wordsBeforeProject.every( + (word) => word.startsWith('#') || word.startsWith('+') + ); + if (allWordsAreTagsOrProjects) { return projectQuery; } - + return ''; }; // Helper function to remove a tag from the input text const removeTagFromText = (tagToRemove: string) => { const words = inputText.trim().split(/\s+/); - const filteredWords = words.filter(word => word !== `#${tagToRemove}`); + const filteredWords = words.filter( + (word) => word !== `#${tagToRemove}` + ); const newText = filteredWords.join(' ').trim(); setInputText(newText); if (nameInputRef.current) { @@ -289,7 +314,9 @@ const InboxModal: React.FC = ({ // Helper function to remove a project from the input text const removeProjectFromText = (projectToRemove: string) => { const words = inputText.trim().split(/\s+/); - const filteredWords = words.filter(word => word !== `+${projectToRemove}`); + const filteredWords = words.filter( + (word) => word !== `+${projectToRemove}` + ); const newText = filteredWords.join(' ').trim(); setInputText(newText); if (nameInputRef.current) { @@ -349,36 +376,47 @@ const InboxModal: React.FC = ({ if (hashtagMatch) { const hashtagStart = beforeCursor.lastIndexOf('#'); - const textBeforeHashtag = inputText.substring(0, hashtagStart).trim(); + const textBeforeHashtag = inputText + .substring(0, hashtagStart) + .trim(); const textAfterCursor = afterCursor.trim(); - + // Check if we're at the very end, very beginning, or in a consecutive group at start let showDropdown = false; - + if (textAfterCursor === '' || textBeforeHashtag === '') { showDropdown = true; } else { // Check if we're in a consecutive group of tags/projects at the beginning - const wordsBeforeHashtag = textBeforeHashtag.split(/\s+/).filter(word => word.length > 0); - const allWordsAreTagsOrProjects = wordsBeforeHashtag.every(word => - word.startsWith('#') || word.startsWith('+')); + const wordsBeforeHashtag = textBeforeHashtag + .split(/\s+/) + .filter((word) => word.length > 0); + const allWordsAreTagsOrProjects = wordsBeforeHashtag.every( + (word) => word.startsWith('#') || word.startsWith('+') + ); if (allWordsAreTagsOrProjects) { showDropdown = true; } } - + if (showDropdown) { // Create temp element for text up to hashtag start const tempToHashtag = document.createElement('span'); tempToHashtag.style.visibility = 'hidden'; tempToHashtag.style.position = 'absolute'; tempToHashtag.style.fontSize = getComputedStyle(input).fontSize; - tempToHashtag.style.fontFamily = getComputedStyle(input).fontFamily; - tempToHashtag.style.fontWeight = getComputedStyle(input).fontWeight; - tempToHashtag.textContent = inputText.substring(0, hashtagStart); + tempToHashtag.style.fontFamily = + getComputedStyle(input).fontFamily; + tempToHashtag.style.fontWeight = + getComputedStyle(input).fontWeight; + tempToHashtag.textContent = inputText.substring( + 0, + hashtagStart + ); document.body.appendChild(tempToHashtag); - const hashtagOffset = tempToHashtag.getBoundingClientRect().width; + const hashtagOffset = + tempToHashtag.getBoundingClientRect().width; document.body.removeChild(tempToHashtag); return { @@ -390,36 +428,47 @@ const InboxModal: React.FC = ({ if (projectMatch) { const projectStart = beforeCursor.lastIndexOf('+'); - const textBeforeProject = inputText.substring(0, projectStart).trim(); + const textBeforeProject = inputText + .substring(0, projectStart) + .trim(); const textAfterCursor = afterCursor.trim(); - + // Check if we're at the very end, very beginning, or in a consecutive group at start let showDropdown = false; - + if (textAfterCursor === '' || textBeforeProject === '') { showDropdown = true; } else { // Check if we're in a consecutive group of tags/projects at the beginning - const wordsBeforeProject = textBeforeProject.split(/\s+/).filter(word => word.length > 0); - const allWordsAreTagsOrProjects = wordsBeforeProject.every(word => - word.startsWith('#') || word.startsWith('+')); + const wordsBeforeProject = textBeforeProject + .split(/\s+/) + .filter((word) => word.length > 0); + const allWordsAreTagsOrProjects = wordsBeforeProject.every( + (word) => word.startsWith('#') || word.startsWith('+') + ); if (allWordsAreTagsOrProjects) { showDropdown = true; } } - + if (showDropdown) { // Create temp element for text up to project start const tempToProject = document.createElement('span'); tempToProject.style.visibility = 'hidden'; tempToProject.style.position = 'absolute'; tempToProject.style.fontSize = getComputedStyle(input).fontSize; - tempToProject.style.fontFamily = getComputedStyle(input).fontFamily; - tempToProject.style.fontWeight = getComputedStyle(input).fontWeight; - tempToProject.textContent = inputText.substring(0, projectStart); + tempToProject.style.fontFamily = + getComputedStyle(input).fontFamily; + tempToProject.style.fontWeight = + getComputedStyle(input).fontWeight; + tempToProject.textContent = inputText.substring( + 0, + projectStart + ); document.body.appendChild(tempToProject); - const projectOffset = tempToProject.getBoundingClientRect().width; + const projectOffset = + tempToProject.getBoundingClientRect().width; document.body.removeChild(tempToProject); return { @@ -444,7 +493,9 @@ const InboxModal: React.FC = ({ const loadProjects = async () => { try { const projectsData = await fetchProjects(); - setProjects(Array.isArray(projectsData) ? projectsData : []); + setProjects( + Array.isArray(projectsData) ? projectsData : [] + ); } catch (error) { console.error('Failed to load projects:', error); setProjects([]); @@ -484,7 +535,10 @@ const InboxModal: React.FC = ({ setCurrentProjectQuery(projectQuery); // Only show suggestions if hashtag/project is at start or end - if ((newText.charAt(newCursorPosition - 1) === '#' || hashtagQuery) && hashtagQuery !== '') { + if ( + (newText.charAt(newCursorPosition - 1) === '#' || hashtagQuery) && + hashtagQuery !== '' + ) { // Hide project suggestions when showing tag suggestions setShowProjectSuggestions(false); setFilteredProjects([]); @@ -507,7 +561,10 @@ const InboxModal: React.FC = ({ setFilteredTags(filtered); setShowTagSuggestions(true); - } else if ((newText.charAt(newCursorPosition - 1) === '+' || projectQuery) && projectQuery !== '') { + } else if ( + (newText.charAt(newCursorPosition - 1) === '+' || projectQuery) && + projectQuery !== '' + ) { // Hide tag suggestions when showing project suggestions setShowTagSuggestions(false); setFilteredTags([]); @@ -543,30 +600,36 @@ const InboxModal: React.FC = ({ // Use analysis result if available, otherwise fall back to local parsing if (analysisResult) { const explicitTags = analysisResult.parsed_tags; - + // Auto-add bookmark if text contains URL or backend suggests URL note - const isUrlContent = isUrl(text.trim()) || analysisResult.suggested_reason === 'url_detected'; + const isUrlContent = + isUrl(text.trim()) || + analysisResult.suggested_reason === 'url_detected'; if (isUrlContent) { - const hasBookmarkTag = explicitTags.some(tag => tag.toLowerCase() === 'bookmark'); + const hasBookmarkTag = explicitTags.some( + (tag) => tag.toLowerCase() === 'bookmark' + ); if (!hasBookmarkTag) { return [...explicitTags, 'bookmark']; } } - + return explicitTags; } - + // Fallback to local parsing const explicitTags = parseHashtags(text); - + // Auto-add bookmark if text contains URL and bookmark tag isn't already present if (isUrl(text.trim())) { - const hasBookmarkTag = explicitTags.some(tag => tag.toLowerCase() === 'bookmark'); + const hasBookmarkTag = explicitTags.some( + (tag) => tag.toLowerCase() === 'bookmark' + ); if (!hasBookmarkTag) { return [...explicitTags, 'bookmark']; } } - + return explicitTags; }; @@ -576,7 +639,7 @@ const InboxModal: React.FC = ({ if (analysisResult) { return analysisResult.parsed_projects; } - + // Fallback to local parsing return parseProjectRefs(text); }; @@ -587,13 +650,20 @@ const InboxModal: React.FC = ({ if (analysisResult) { return analysisResult.cleaned_content; } - + // Fallback to local cleaning (simplified version) - return text.replace(/#[a-zA-Z0-9_-]+/g, '').replace(/\+\S+/g, '').trim(); + return text + .replace(/#[a-zA-Z0-9_-]+/g, '') + .replace(/\+\S+/g, '') + .trim(); }; // Helper function to get suggestion - const getSuggestion = (): { type: 'note' | 'task' | null; message: string | null; projectName: string | null } => { + const getSuggestion = (): { + type: 'note' | 'task' | null; + message: string | null; + projectName: string | null; + } => { if (!analysisResult || !analysisResult.suggested_type) { return { type: null, message: null, projectName: null }; } @@ -603,21 +673,22 @@ const InboxModal: React.FC = ({ if (type === 'note') { // Check if this is a URL (bookmark) note - const isUrlNote = analysisResult.suggested_reason === 'url_detected'; - const message = isUrlNote + const isUrlNote = + analysisResult.suggested_reason === 'url_detected'; + const message = isUrlNote ? `This item will be saved as a bookmark note for ${projectName}.` : `This item will be saved for later processing as it looks like a note for ${projectName}.`; - + return { type: 'note', message, - projectName + projectName, }; } else if (type === 'task') { return { type: 'task', message: `This item looks like a task and will be created under project ${projectName}.`, - projectName + projectName, }; } @@ -682,24 +753,29 @@ const InboxModal: React.FC = ({ if (hashtagMatch) { const hashtagStart = beforeCursor.lastIndexOf('#'); - const textBeforeHashtag = inputText.substring(0, hashtagStart).trim(); + const textBeforeHashtag = inputText + .substring(0, hashtagStart) + .trim(); const textAfterCursor = afterCursor.trim(); - + // Check if we're at the very end, very beginning, or in a consecutive group at start let allowReplacement = false; - + if (textAfterCursor === '' || textBeforeHashtag === '') { allowReplacement = true; } else { // Check if we're in a consecutive group of tags/projects at the beginning - const wordsBeforeHashtag = textBeforeHashtag.split(/\s+/).filter(word => word.length > 0); - const allWordsAreTagsOrProjects = wordsBeforeHashtag.every(word => - word.startsWith('#') || word.startsWith('+')); + const wordsBeforeHashtag = textBeforeHashtag + .split(/\s+/) + .filter((word) => word.length > 0); + const allWordsAreTagsOrProjects = wordsBeforeHashtag.every( + (word) => word.startsWith('#') || word.startsWith('+') + ); if (allWordsAreTagsOrProjects) { allowReplacement = true; } } - + if (allowReplacement) { const newText = beforeCursor.replace(/#([a-zA-Z0-9_]*)$/, `#${tagName}`) + @@ -731,37 +807,46 @@ const InboxModal: React.FC = ({ const beforeCursor = inputText.substring(0, cursorPosition); const afterCursor = inputText.substring(cursorPosition); // Match both quoted and unquoted project references - const projectMatch = beforeCursor.match(/\+(?:"([^"]*)"|([a-zA-Z0-9_\s]*))$/); + const projectMatch = beforeCursor.match( + /\+(?:"([^"]*)"|([a-zA-Z0-9_\s]*))$/ + ); if (projectMatch) { const projectStart = beforeCursor.lastIndexOf('+'); - const textBeforeProject = inputText.substring(0, projectStart).trim(); + const textBeforeProject = inputText + .substring(0, projectStart) + .trim(); const textAfterCursor = afterCursor.trim(); - + // Check if we're at the very end, very beginning, or in a consecutive group at start let allowReplacement = false; - + if (textAfterCursor === '' || textBeforeProject === '') { allowReplacement = true; } else { // Check if we're in a consecutive group of tags/projects at the beginning - const wordsBeforeProject = textBeforeProject.split(/\s+/).filter(word => word.length > 0); - const allWordsAreTagsOrProjects = wordsBeforeProject.every(word => - word.startsWith('#') || word.startsWith('+')); + const wordsBeforeProject = textBeforeProject + .split(/\s+/) + .filter((word) => word.length > 0); + const allWordsAreTagsOrProjects = wordsBeforeProject.every( + (word) => word.startsWith('#') || word.startsWith('+') + ); if (allWordsAreTagsOrProjects) { allowReplacement = true; } } - + if (allowReplacement) { // Automatically add quotes if project name contains spaces - const formattedProjectName = projectName.includes(' ') - ? `"${projectName}"` + const formattedProjectName = projectName.includes(' ') + ? `"${projectName}"` : projectName; - + const newText = - beforeCursor.replace(/\+(?:"([^"]*)"|([a-zA-Z0-9_\s]*))$/, `+${formattedProjectName}`) + - afterCursor; + beforeCursor.replace( + /\+(?:"([^"]*)"|([a-zA-Z0-9_\s]*))$/, + `+${formattedProjectName}` + ) + afterCursor; setInputText(newText); setShowProjectSuggestions(false); setFilteredProjects([]); @@ -789,13 +874,16 @@ const InboxModal: React.FC = ({ const trimmedText = text.trim(); const tokens = tokenizeText(trimmedText); const cleanedTokens: string[] = []; - + let i = 0; while (i < tokens.length) { // Check if current token starts a tag/project group if (tokens[i].startsWith('#') || tokens[i].startsWith('+')) { // Skip this entire consecutive group - while (i < tokens.length && (tokens[i].startsWith('#') || tokens[i].startsWith('+'))) { + while ( + i < tokens.length && + (tokens[i].startsWith('#') || tokens[i].startsWith('+')) + ) { i++; } } else { @@ -804,7 +892,7 @@ const InboxModal: React.FC = ({ i++; } } - + return cleanedTokens.join(' ').trim(); }; @@ -831,249 +919,296 @@ const InboxModal: React.FC = ({ // Create missing projects automatically const createMissingProjects = async (text: string): Promise => { const projectsInText = getAllProjects(text); - const existingProjectNames = projects.map((project) => project.name.toLowerCase()); + const existingProjectNames = projects.map((project) => + project.name.toLowerCase() + ); const missingProjects = projectsInText.filter( - (projectName) => !existingProjectNames.includes(projectName.toLowerCase()) + (projectName) => + !existingProjectNames.includes(projectName.toLowerCase()) ); for (const projectName of missingProjects) { try { - const newProject = await createProject({ name: projectName, active: true }); + const newProject = await createProject({ + name: projectName, + active: true, + }); // Update the local projects state setProjects([...projects, newProject]); } catch (error) { - console.error(`Failed to create project "${projectName}":`, error); + console.error( + `Failed to create project "${projectName}":`, + error + ); // Don't fail the entire operation if project creation fails } } }; - const handleSubmit = useCallback(async (forceInbox = false) => { - console.log('HandleSubmit called with forceInbox:', forceInbox); - if (!inputText.trim() || isSaving) return; + const handleSubmit = useCallback( + async (forceInbox = false) => { + console.log('HandleSubmit called with forceInbox:', forceInbox); + if (!inputText.trim() || isSaving) return; - setIsSaving(true); + setIsSaving(true); - try { - // Check if suggestions are present first, even in edit mode (unless forced to inbox mode) - console.log('Checking task suggestion:', { suggestedType: analysisResult?.suggested_type, forceInbox }); - if (analysisResult?.suggested_type === 'task' && !forceInbox) { - console.log('Taking task creation path'); - // Auto-convert to task using the same logic as convert to task action - await createMissingTags(inputText.trim()); - await createMissingProjects(inputText.trim()); - - const cleanedText = getCleanedContent(inputText.trim()); - - // Convert parsed tags to Tag objects - const taskTags = analysisResult.parsed_tags.map((tagName) => { - // Find existing tag or create a placeholder for new tag - const existingTag = tags.find( - (tag) => tag.name.toLowerCase() === tagName.toLowerCase() - ); - return existingTag || { name: tagName }; + try { + // Check if suggestions are present first, even in edit mode (unless forced to inbox mode) + console.log('Checking task suggestion:', { + suggestedType: analysisResult?.suggested_type, + forceInbox, }); + if (analysisResult?.suggested_type === 'task' && !forceInbox) { + console.log('Taking task creation path'); + // Auto-convert to task using the same logic as convert to task action + await createMissingTags(inputText.trim()); + await createMissingProjects(inputText.trim()); - // Find the project to assign (use first project reference if any) - let projectId = undefined; - if (analysisResult.parsed_projects.length > 0) { - // Look for an existing project with the first project reference name - const projectName = analysisResult.parsed_projects[0]; - const matchingProject = projects.find( - (project) => project.name.toLowerCase() === projectName.toLowerCase() - ); - if (matchingProject) { - projectId = matchingProject.id; - } - } + const cleanedText = getCleanedContent(inputText.trim()); - const newTask: Task = { - name: cleanedText, - status: 'not_started', - priority: 'medium', - tags: taskTags, - project_id: projectId, - }; - - try { - await onSave(newTask); - showSuccessToast(t('task.createSuccess')); - - // If in edit mode, we need to mark the original inbox item as processed - if (editMode && onConvertToTask) { - await onConvertToTask(); - } - - setInputText(''); - handleClose(); - return; - } catch (error: any) { - if (isAuthError(error)) { - return; - } - throw error; - } - } - - // Check if it's a note suggestion (bookmark + project) (unless forced to inbox mode) - console.log('Checking note suggestion:', { suggestedType: analysisResult?.suggested_type, forceInbox }); - if (analysisResult?.suggested_type === 'note' && !forceInbox) { - console.log('Taking note creation path'); - // Auto-convert to note using similar logic - await createMissingTags(inputText.trim()); - await createMissingProjects(inputText.trim()); - - const cleanedText = getCleanedContent(inputText.trim()); - - // Convert parsed tags to Tag objects and include bookmark tag - const hashtagTags = analysisResult.parsed_tags.map((tagName) => { - const existingTag = tags.find( - (tag) => tag.name.toLowerCase() === tagName.toLowerCase() - ); - return existingTag || { name: tagName }; - }); - - // Add bookmark tag for URLs or when suggested reason is url_detected - const isUrlContent = isUrl(inputText.trim()) || analysisResult.suggested_reason === 'url_detected'; - const bookmarkTag = isUrlContent ? [{ name: 'bookmark' }] : []; - - // Make sure we don't duplicate bookmark tag if it's already in parsed tags - const hasBookmarkInParsed = hashtagTags.some(tag => tag.name.toLowerCase() === 'bookmark'); - const finalBookmarkTag = hasBookmarkInParsed ? [] : bookmarkTag; - - const taskTags = [...hashtagTags, ...finalBookmarkTag]; - - // Find the project to assign - let projectId = undefined; - if (analysisResult.parsed_projects.length > 0) { - const projectName = analysisResult.parsed_projects[0]; - const matchingProject = projects.find( - (project) => project.name.toLowerCase() === projectName.toLowerCase() - ); - if (matchingProject) { - projectId = matchingProject.id; - } - } - - const newNote: Note = { - title: cleanedText || inputText.trim(), - content: inputText.trim(), - tags: taskTags, - project_id: projectId, - }; - - try { - if (onSaveNote) { - await onSaveNote(newNote); - showSuccessToast(t('note.createSuccess', 'Note created successfully')); - - // If in edit mode, we need to mark the original inbox item as processed - if (editMode && onConvertToNote) { - await onConvertToNote(); + // Convert parsed tags to Tag objects + const taskTags = analysisResult.parsed_tags.map( + (tagName) => { + // Find existing tag or create a placeholder for new tag + const existingTag = tags.find( + (tag) => + tag.name.toLowerCase() === + tagName.toLowerCase() + ); + return existingTag || { name: tagName }; } - + ); + + // Find the project to assign (use first project reference if any) + let projectId = undefined; + if (analysisResult.parsed_projects.length > 0) { + // Look for an existing project with the first project reference name + const projectName = analysisResult.parsed_projects[0]; + const matchingProject = projects.find( + (project) => + project.name.toLowerCase() === + projectName.toLowerCase() + ); + if (matchingProject) { + projectId = matchingProject.id; + } + } + + const newTask: Task = { + name: cleanedText, + status: 'not_started', + priority: 'medium', + tags: taskTags, + project_id: projectId, + }; + + try { + await onSave(newTask); + showSuccessToast(t('task.createSuccess')); + + // If in edit mode, we need to mark the original inbox item as processed + if (editMode && onConvertToTask) { + await onConvertToTask(); + } + setInputText(''); handleClose(); return; - } else { - // If no note creation handler, fall back to inbox mode - console.log('No note creation handler, falling back to inbox'); + } catch (error: any) { + if (isAuthError(error)) { + return; + } + throw error; } - } catch (error: any) { - console.error('Error in note creation flow:', error); - if (isAuthError(error)) { - return; + } + + // Check if it's a note suggestion (bookmark + project) (unless forced to inbox mode) + console.log('Checking note suggestion:', { + suggestedType: analysisResult?.suggested_type, + forceInbox, + }); + if (analysisResult?.suggested_type === 'note' && !forceInbox) { + console.log('Taking note creation path'); + // Auto-convert to note using similar logic + await createMissingTags(inputText.trim()); + await createMissingProjects(inputText.trim()); + + const cleanedText = getCleanedContent(inputText.trim()); + + // Convert parsed tags to Tag objects and include bookmark tag + const hashtagTags = analysisResult.parsed_tags.map( + (tagName) => { + const existingTag = tags.find( + (tag) => + tag.name.toLowerCase() === + tagName.toLowerCase() + ); + return existingTag || { name: tagName }; + } + ); + + // Add bookmark tag for URLs or when suggested reason is url_detected + const isUrlContent = + isUrl(inputText.trim()) || + analysisResult.suggested_reason === 'url_detected'; + const bookmarkTag = isUrlContent + ? [{ name: 'bookmark' }] + : []; + + // Make sure we don't duplicate bookmark tag if it's already in parsed tags + const hasBookmarkInParsed = hashtagTags.some( + (tag) => tag.name.toLowerCase() === 'bookmark' + ); + const finalBookmarkTag = hasBookmarkInParsed + ? [] + : bookmarkTag; + + const taskTags = [...hashtagTags, ...finalBookmarkTag]; + + // Find the project to assign + let projectId = undefined; + if (analysisResult.parsed_projects.length > 0) { + const projectName = analysisResult.parsed_projects[0]; + const matchingProject = projects.find( + (project) => + project.name.toLowerCase() === + projectName.toLowerCase() + ); + if (matchingProject) { + projectId = matchingProject.id; + } } - throw error; - } - } - - if (editMode && onEdit) { - // For edit mode, store the original text with tags/projects - await onEdit(inputText.trim()); - setIsClosing(true); - setTimeout(() => { - onClose(); - setIsClosing(false); - }, 300); - return; // Exit early to prevent creating duplicates - } - const effectiveSaveMode = saveMode; + const newNote: Note = { + title: cleanedText || inputText.trim(), + content: inputText.trim(), + tags: taskTags, + project_id: projectId, + }; - if (effectiveSaveMode === 'task') { - // For task mode, create missing tags and projects, then clean the text - await createMissingTags(inputText.trim()); - await createMissingProjects(inputText.trim()); - - const cleanedText = cleanTextFromTagsAndProjects(inputText.trim()); - const newTask: Task = { - name: cleanedText, - status: 'not_started', - }; + try { + if (onSaveNote) { + await onSaveNote(newNote); + showSuccessToast( + t( + 'note.createSuccess', + 'Note created successfully' + ) + ); - try { - await onSave(newTask); - showSuccessToast(t('task.createSuccess')); - setInputText(''); - handleClose(); - } catch (error: any) { - // If it's an auth error, don't show error toast (user will be redirected) - if (isAuthError(error)) { - return; + // If in edit mode, we need to mark the original inbox item as processed + if (editMode && onConvertToNote) { + await onConvertToNote(); + } + + setInputText(''); + handleClose(); + return; + } else { + // If no note creation handler, fall back to inbox mode + console.log( + 'No note creation handler, falling back to inbox' + ); + } + } catch (error: any) { + console.error('Error in note creation flow:', error); + if (isAuthError(error)) { + return; + } + throw error; } - throw error; } - } else { - console.log('Taking inbox creation path'); - try { - // For inbox mode, store the original text with tags/projects - // Tags and projects will be created and assigned when the item is processed later - await createInboxItemWithStore(inputText.trim()); - showSuccessToast(t('inbox.itemAdded')); - - handleClose(); - } catch (error) { - console.error('Failed to create inbox item:', error); - showErrorToast(t('inbox.addError')); - setIsSaving(false); + if (editMode && onEdit) { + // For edit mode, store the original text with tags/projects + await onEdit(inputText.trim()); + setIsClosing(true); + setTimeout(() => { + onClose(); + setIsClosing(false); + }, 300); + return; // Exit early to prevent creating duplicates } + + const effectiveSaveMode = saveMode; + + if (effectiveSaveMode === 'task') { + // For task mode, create missing tags and projects, then clean the text + await createMissingTags(inputText.trim()); + await createMissingProjects(inputText.trim()); + + const cleanedText = cleanTextFromTagsAndProjects( + inputText.trim() + ); + const newTask: Task = { + name: cleanedText, + status: 'not_started', + }; + + try { + await onSave(newTask); + showSuccessToast(t('task.createSuccess')); + setInputText(''); + handleClose(); + } catch (error: any) { + // If it's an auth error, don't show error toast (user will be redirected) + if (isAuthError(error)) { + return; + } + throw error; + } + } else { + console.log('Taking inbox creation path'); + try { + // For inbox mode, store the original text with tags/projects + // Tags and projects will be created and assigned when the item is processed later + await createInboxItemWithStore(inputText.trim()); + + showSuccessToast(t('inbox.itemAdded')); + + handleClose(); + } catch (error) { + console.error('Failed to create inbox item:', error); + showErrorToast(t('inbox.addError')); + setIsSaving(false); + } + } + } catch (error) { + console.error('Failed to save:', error); + if (editMode) { + showErrorToast(t('inbox.updateError')); + } else { + showErrorToast( + saveMode === 'task' + ? t('task.createError') + : t('inbox.addError') + ); + } + } finally { + setIsSaving(false); } - } catch (error) { - console.error('Failed to save:', error); - if (editMode) { - showErrorToast(t('inbox.updateError')); - } else { - showErrorToast( - saveMode === 'task' - ? t('task.createError') - : t('inbox.addError') - ); - } - } finally { - setIsSaving(false); - } - }, [ - inputText, - isSaving, - editMode, - onEdit, - saveMode, - onSave, - showSuccessToast, - showErrorToast, - t, - onClose, - tags, - setTags, - projects, - setProjects, - analysisResult, - createMissingTags, - createMissingProjects, - getCleanedContent, - ]); + }, + [ + inputText, + isSaving, + editMode, + onEdit, + saveMode, + onSave, + showSuccessToast, + showErrorToast, + t, + onClose, + tags, + setTags, + projects, + setProjects, + analysisResult, + createMissingTags, + createMissingProjects, + getCleanedContent, + ] + ); const handleClose = useCallback(() => { setIsClosing(true); @@ -1172,7 +1307,10 @@ const InboxModal: React.FC = ({ e.currentTarget.selectionStart || 0; setCursorPosition(pos); // Update dropdown position if showing suggestions - if (showTagSuggestions || showProjectSuggestions) { + if ( + showTagSuggestions || + showProjectSuggestions + ) { const position = calculateDropdownPosition( e.currentTarget, @@ -1186,7 +1324,10 @@ const InboxModal: React.FC = ({ e.currentTarget.selectionStart || 0; setCursorPosition(pos); // Update dropdown position if showing suggestions - if (showTagSuggestions || showProjectSuggestions) { + if ( + showTagSuggestions || + showProjectSuggestions + ) { const position = calculateDropdownPosition( e.currentTarget, @@ -1200,7 +1341,10 @@ const InboxModal: React.FC = ({ e.currentTarget.selectionStart || 0; setCursorPosition(pos); // Update dropdown position if showing suggestions - if (showTagSuggestions || showProjectSuggestions) { + if ( + showTagSuggestions || + showProjectSuggestions + ) { const position = calculateDropdownPosition( e.currentTarget, @@ -1213,14 +1357,22 @@ const InboxModal: React.FC = ({ className="w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white focus:outline-none shadow-sm py-2" placeholder={t('inbox.captureThought')} onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey && !isSaving) { + if ( + e.key === 'Enter' && + !e.shiftKey && + !isSaving + ) { // If suggestions are showing and there are filtered options, let the user navigate - if ((showTagSuggestions && filteredTags.length > 0) || - (showProjectSuggestions && filteredProjects.length > 0)) { + if ( + (showTagSuggestions && + filteredTags.length > 0) || + (showProjectSuggestions && + filteredProjects.length > 0) + ) { // Don't submit, let the user select from suggestions return; } - + // Otherwise, submit the form e.preventDefault(); handleSubmit(); @@ -1257,10 +1409,16 @@ const InboxModal: React.FC = ({ e.stopPropagation() } > - {tagName} + { + tagName + } - ))} + {filteredProjects.map( + (project, index) => ( + + ) + )}
)} {/* Intelligent Suggestion */} {(() => { const suggestion = getSuggestion(); - return suggestion.type && suggestion.message ? ( + return suggestion.type && + suggestion.message ? (
{/* AI Stars Icon */} - + @@ -1430,15 +1614,22 @@ const InboxModal: React.FC = ({ {suggestion.message}

- or + + or +
diff --git a/frontend/components/Navbar.tsx b/frontend/components/Navbar.tsx index 000d70c..9ac1a2f 100644 --- a/frontend/components/Navbar.tsx +++ b/frontend/components/Navbar.tsx @@ -118,7 +118,9 @@ const Navbar: React.FC = ({