diff --git a/app/_layout.tsx b/app/_layout.tsx index 6f304b7..470db3a 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -103,6 +103,10 @@ function RootLayoutNav() { name="trash" options={{ title: 'Trash', headerShown: false }} /> + ); diff --git a/app/acronyms.tsx b/app/acronyms.tsx new file mode 100644 index 0000000..c0abf2c --- /dev/null +++ b/app/acronyms.tsx @@ -0,0 +1,272 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + Pressable, + FlatList, + ActivityIndicator, +} from 'react-native'; +import { useRouter } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Colors } from '../constants/colors'; +import { + ValidatedTerm, + loadValidatedTerms, + deleteValidatedTerm, + deleteAllValidatedTerms, +} from '../services/validatedTerms'; +import { showConfirm, showAlert } from '../utils/alert'; + +export default function AcronymsScreen() { + const router = useRouter(); + const [terms, setTerms] = useState([]); + const [loading, setLoading] = useState(true); + const [deleting, setDeleting] = useState(null); + + const load = useCallback(async () => { + try { + const data = await loadValidatedTerms(); + setTerms(data); + } catch { + showAlert('Error', 'Could not load saved terms.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const handleDelete = (term: ValidatedTerm) => { + showConfirm( + 'Remove term', + `Remove "${term.original_term}" β†’ "${term.corrected_term}"? It will no longer be used in future transcriptions.`, + async () => { + setDeleting(term.original_term); + try { + await deleteValidatedTerm(term.original_term); + setTerms((prev) => prev.filter((t) => t.original_term !== term.original_term)); + } catch { + showAlert('Error', 'Could not remove the term.'); + } finally { + setDeleting(null); + } + } + ); + }; + + const handleClearAll = () => { + if (terms.length === 0) return; + showConfirm( + 'Clear all terms', + 'Remove all saved corrections? This cannot be undone.', + async () => { + try { + await deleteAllValidatedTerms(); + setTerms([]); + } catch { + showAlert('Error', 'Could not clear terms.'); + } + } + ); + }; + + return ( + + + router.back()} + style={({ pressed }) => [styles.backPressable, pressed && { opacity: 0.6 }]} + > + Back + + Saved Terms + {terms.length > 0 ? ( + [styles.clearPressable, pressed && { opacity: 0.6 }]} + > + Clear all + + ) : ( + + )} + + + {loading ? ( + + + + ) : terms.length === 0 ? ( + + πŸ”€ + No saved corrections + + When the AI flags uncertain terms during transcription and you confirm the correct + spelling, those corrections are saved here and reused automatically in future notes. + + + ) : ( + item.original_term} + contentContainerStyle={styles.listContent} + ListHeaderComponent={ + + {terms.length} saved {terms.length === 1 ? 'correction' : 'corrections'} + + } + renderItem={({ item }) => ( + + + + {item.original_term} + β†’ + {item.corrected_term} + + + handleDelete(item)} + disabled={deleting === item.original_term} + style={({ pressed }) => [ + styles.deleteButton, + pressed && { opacity: 0.6 }, + deleting === item.original_term && { opacity: 0.4 }, + ]} + accessibilityLabel={`Remove correction for ${item.original_term}`} + > + Remove + + + )} + ItemSeparatorComponent={() => } + /> + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.background, + }, + topBar: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 20, + paddingVertical: 14, + borderBottomWidth: 1, + borderBottomColor: Colors.borderLight, + }, + backPressable: { + paddingVertical: 4, + minWidth: 56, + }, + backText: { + color: Colors.primary, + fontSize: 16, + fontWeight: '600', + }, + topTitle: { + fontSize: 17, + fontWeight: '600', + color: Colors.text, + }, + clearPressable: { + paddingVertical: 4, + minWidth: 72, + alignItems: 'flex-end', + }, + clearText: { + color: Colors.error, + fontSize: 15, + fontWeight: '500', + }, + centered: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 40, + }, + emptyIcon: { + fontSize: 48, + marginBottom: 16, + }, + emptyTitle: { + fontSize: 18, + fontWeight: '700', + color: Colors.text, + marginBottom: 10, + textAlign: 'center', + }, + emptyDesc: { + fontSize: 14, + color: Colors.textTertiary, + textAlign: 'center', + lineHeight: 22, + }, + listContent: { + padding: 20, + paddingBottom: 48, + }, + listHeader: { + fontSize: 13, + fontWeight: '600', + color: Colors.textTertiary, + textTransform: 'uppercase', + letterSpacing: 0.6, + marginBottom: 16, + }, + termRow: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: Colors.surface, + borderRadius: 14, + paddingHorizontal: 16, + paddingVertical: 14, + borderWidth: 1, + borderColor: Colors.borderLight, + }, + termTexts: { + flex: 1, + }, + termPair: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + gap: 8, + }, + originalText: { + fontSize: 14, + color: Colors.textSecondary, + fontStyle: 'italic', + }, + arrow: { + fontSize: 14, + color: Colors.textTertiary, + }, + correctedText: { + fontSize: 15, + fontWeight: '600', + color: Colors.text, + }, + deleteButton: { + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 8, + backgroundColor: Colors.errorLight, + marginLeft: 12, + }, + deleteText: { + fontSize: 12, + color: Colors.error, + fontWeight: '600', + }, + separator: { + height: 8, + }, +}); diff --git a/app/index.tsx b/app/index.tsx index 0a76a77..945ec9b 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,27 +1,48 @@ -import React, { useMemo, useState } from 'react'; -import { View, Text, StyleSheet, Pressable, Image } from 'react-native'; +import React, { useMemo, useState, useEffect, useCallback } from 'react'; +import { View, Text, StyleSheet, Pressable, Image, Modal } from 'react-native'; import { useRouter } from 'expo-router'; import { useNotes } from '../contexts/NotesContext'; import { useTags } from '../contexts/TagsContext'; +import { usePreferences } from '../contexts/PreferencesContext'; import { NoteGrid } from '../components/NoteGrid'; import { SearchBar } from '../components/SearchBar'; import { RecordButton } from '../components/RecordButton'; import { TagFilterBar } from '../components/TagFilterBar'; import { Colors } from '../constants/colors'; -import { Note } from '../types'; +import { Note, NoteSort } from '../types'; import { SafeAreaView } from 'react-native-safe-area-context'; import * as tagsService from '../services/tags'; +const SORT_OPTIONS: { value: NoteSort; label: string }[] = [ + { value: 'date_desc', label: 'Newest first' }, + { value: 'date_asc', label: 'Oldest first' }, + { value: 'title_asc', label: 'Title Aβ†’Z' }, + { value: 'title_desc', label: 'Title Zβ†’A' }, + { value: 'manual', label: 'Manual order' }, +]; + export default function HomeScreen() { const router = useRouter(); - const { filteredNotes, loading, searchQuery, setSearchQuery, fetchNotes } = + const { filteredNotes, loading, searchQuery, setSearchQuery, fetchNotes, sort, setSort, setManualOrder } = useNotes(); const { tags, refreshNoteTagsMap } = useTags(); + const { defaultTagId } = usePreferences(); const [selectedTagId, setSelectedTagId] = useState(null); const [tagNoteIds, setTagNoteIds] = useState(null); + const [showSortPicker, setShowSortPicker] = useState(false); + const [reorderMode, setReorderMode] = useState(false); + + // Apply default tag filter on first load + useEffect(() => { + if (defaultTagId && tags.length > 0) { + handleTagSelect(defaultTagId); + } + // Only run once when tags become available + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultTagId, tags.length > 0]); - const handleTagSelect = async (tagId: string | null) => { + const handleTagSelect = useCallback(async (tagId: string | null) => { setSelectedTagId(tagId); if (tagId === null) { setTagNoteIds(null); @@ -33,6 +54,12 @@ export default function HomeScreen() { setTagNoteIds([]); } } + }, []); + + const handleSortChange = (newSort: NoteSort) => { + setSort(newSort); + setShowSortPicker(false); + if (newSort !== 'manual') setReorderMode(false); }; const displayedNotes = useMemo(() => { @@ -64,45 +91,77 @@ export default function HomeScreen() { router.push('/recordings'); }; + const currentSortLabel = SORT_OPTIONS.find((o) => o.value === sort)?.label ?? 'Sort'; + return ( - + VoiceKeeper - - {displayedNotes.length > 0 - ? `${displayedNotes.length} note${displayedNotes.length !== 1 ? 's' : ''}` - : 'No notes yet'} - + setShowSortPicker(true)} + style={({ pressed }) => [styles.subtitleRow, pressed && { opacity: 0.6 }]} + accessibilityLabel="Sort notes" + > + + {displayedNotes.length > 0 + ? `${displayedNotes.length} note${displayedNotes.length !== 1 ? 's' : ''}` + : 'No notes yet'} + + Β· ↕ {currentSortLabel} + + {sort === 'manual' && ( + setReorderMode((v) => !v)} + style={({ pressed }) => [ + styles.iconButton, + reorderMode && styles.iconButtonActive, + pressed && styles.iconButtonPressed, + ]} + accessibilityLabel="Toggle reorder mode" + > + + + + + + + )} [ - styles.headerActionButton, - pressed && { opacity: 0.6 }, + styles.iconButton, + pressed && styles.iconButtonPressed, ]} accessibilityLabel="Recordings" > - Recordings + + + + + + + [ - styles.settingsButton, - pressed && styles.settingsButtonPressed, - ]} - accessibilityLabel="Settings" - > - - - - - - + onPress={handleSettings} + style={({ pressed }) => [ + styles.iconButton, + pressed && styles.iconButtonPressed, + ]} + accessibilityLabel="Settings" + > + + + + + + @@ -121,10 +180,43 @@ export default function HomeScreen() { onNotePress={handleNotePress} onRefresh={handleRefresh} hasActiveFilter={selectedTagId !== null} + draggable={sort === 'manual'} + reorderMode={reorderMode} + onReorder={setManualOrder} /> + + {/* Sort picker modal */} + setShowSortPicker(false)} + > + setShowSortPicker(false)}> + + Sort notes + {SORT_OPTIONS.map((opt) => ( + [ + styles.sortOption, + sort === opt.value && styles.sortOptionActive, + pressed && { opacity: 0.7 }, + ]} + onPress={() => handleSortChange(opt.value)} + > + + {opt.label} + + {sort === opt.value && βœ“} + + ))} + + + ); } @@ -163,24 +255,26 @@ const styles = StyleSheet.create({ color: Colors.textTertiary, marginTop: 2, }, - headerActions: { + headerLeft: { + flex: 1, + marginRight: 12, + }, + subtitleRow: { flexDirection: 'row', alignItems: 'center', - gap: 10, - }, - headerActionButton: { - paddingHorizontal: 14, - paddingVertical: 8, - borderRadius: 20, - backgroundColor: Colors.surface, - ...Colors.shadow.sm, + marginTop: 2, }, - recordingsButtonText: { - fontSize: 14, - fontWeight: '600', + sortIndicator: { + fontSize: 13, color: Colors.primary, + fontWeight: '600', + }, + headerActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, }, - settingsButton: { + iconButton: { width: 44, height: 44, borderRadius: 22, @@ -189,25 +283,95 @@ const styles = StyleSheet.create({ alignItems: 'center', ...Colors.shadow.sm, }, - settingsButtonPressed: { + iconButtonPressed: { backgroundColor: Colors.surfaceHover, transform: [{ scale: 0.95 }], }, + iconButtonActive: { + backgroundColor: Colors.primarySubtle, + borderWidth: 1.5, + borderColor: Colors.primary, + }, + reorderIconContainer: { + width: 20, + height: 20, + justifyContent: 'center', + gap: 3, + }, + reorderBar: { + height: 2.5, + borderRadius: 1.5, + backgroundColor: Colors.textSecondary, + }, + reorderBarActive: { + backgroundColor: Colors.primary, + }, + waveformContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 2.5, + height: 20, + }, + waveBar: { + width: 2.5, + borderRadius: 1.5, + backgroundColor: Colors.primary, + }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.4)', + justifyContent: 'flex-end', + }, + sortSheet: { + backgroundColor: Colors.background, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + padding: 24, + paddingBottom: 40, + }, + sortSheetTitle: { + fontSize: 16, + fontWeight: '700', + color: Colors.text, + marginBottom: 16, + }, + sortOption: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 14, + paddingHorizontal: 12, + borderRadius: 12, + }, + sortOptionActive: { + backgroundColor: Colors.primarySubtle, + }, + sortOptionText: { + fontSize: 16, + color: Colors.text, + }, + sortOptionTextActive: { + color: Colors.primary, + fontWeight: '600', + }, + sortCheckmark: { + fontSize: 16, + color: Colors.primary, + fontWeight: '700', + }, settingsIconContainer: { width: 20, height: 20, justifyContent: 'center', alignItems: 'center', + gap: 3, }, gearDot: { width: 4, height: 4, borderRadius: 2, backgroundColor: Colors.textSecondary, - marginVertical: 1.5, }, - gearDot2: {}, - gearDot3: {}, listContainer: { flex: 1, }, diff --git a/app/note-create.tsx b/app/note-create.tsx index a408b02..4512985 100644 --- a/app/note-create.tsx +++ b/app/note-create.tsx @@ -15,18 +15,28 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Colors } from '../constants/colors'; import { createNote } from '../services/notes'; import { useNotes } from '../contexts/NotesContext'; +import { useTags } from '../contexts/TagsContext'; +import { TagChip } from '../components/TagChip'; import { showAlert } from '../utils/alert'; export default function NoteCreateScreen() { const router = useRouter(); const { fetchNotes } = useNotes(); + const { tags, addTagToNote } = useTags(); const [title, setTitle] = useState(''); const [content, setContent] = useState(''); const [saving, setSaving] = useState(false); + const [selectedTagIds, setSelectedTagIds] = useState([]); const contentRef = useRef(null); + const toggleTag = (tagId: string) => { + setSelectedTagIds((prev) => + prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId] + ); + }; + const handleSave = async () => { const trimmedTitle = title.trim(); const trimmedContent = content.trim(); @@ -38,13 +48,14 @@ export default function NoteCreateScreen() { setSaving(true); try { - await createNote({ + const note = await createNote({ title: trimmedTitle || 'Untitled note', formatted_text: trimmedContent, raw_transcription: null, format_type: 'paragraph', source: 'text', }); + await Promise.all(selectedTagIds.map((tagId) => addTagToNote(note.id, tagId).catch(() => {}))); await fetchNotes(); router.back(); } catch (err: any) { @@ -116,6 +127,26 @@ export default function NoteCreateScreen() { textAlignVertical="top" autoFocus /> + + {tags.length > 0 && ( + + Tags + + {tags.map((tag) => { + const selected = selectedTagIds.includes(tag.id); + return ( + toggleTag(tag.id)} + style={[styles.tagPill, selected && styles.tagPillSelected]} + > + + + ); + })} + + + )} @@ -188,7 +219,32 @@ const styles = StyleSheet.create({ fontSize: 16, color: Colors.text, lineHeight: 24, - minHeight: 300, + minHeight: 200, padding: 0, }, + tagSection: { + marginTop: 24, + borderTopWidth: 1, + borderTopColor: Colors.border, + paddingTop: 16, + }, + tagLabel: { + fontSize: 13, + fontWeight: '600', + color: Colors.textTertiary, + marginBottom: 10, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + tagRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + tagPill: { + opacity: 0.45, + }, + tagPillSelected: { + opacity: 1, + }, }); diff --git a/app/note/[id].tsx b/app/note/[id].tsx index 70ce604..70ce2e8 100644 --- a/app/note/[id].tsx +++ b/app/note/[id].tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect, useRef } from 'react'; +import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; import { View, Text, @@ -6,6 +6,9 @@ import { StyleSheet, Pressable, ScrollView, + KeyboardAvoidingView, + Platform, + Share, ActivityIndicator, } from 'react-native'; import Markdown from 'react-native-markdown-display'; @@ -21,15 +24,38 @@ import { Colors } from '../../constants/colors'; import { FORMAT_OPTIONS } from '../../constants/formats'; import { formatDate } from '../../utils/titleGenerator'; import { formatTranscription } from '../../services/processing'; +import { loadValidatedTerms } from '../../services/validatedTerms'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Tag, FormatType } from '../../types'; import { AudioPlaybackBar } from '../../components/AudioPlaybackBar'; // Parses action_items markdown into a structured list with tappable checkboxes. // Toggling a checkbox updates the underlying markdown text and persists via onTextChange. -function ActionItemsList({ text, onTextChange }: { text: string; onTextChange?: (updated: string) => void }) { +// When allowAdd is true, shows an "+ Add item" button at the bottom. +function ActionItemsList({ + text, + onTextChange, + allowAdd = false, +}: { + text: string; + onTextChange?: (updated: string) => void; + allowAdd?: boolean; +}) { + const [newItemText, setNewItemText] = useState(''); + const [showInput, setShowInput] = useState(false); const lines = text.split('\n'); + // Parse items preserving original line index for mutations + const parsed = lines.map((line, index) => ({ + line, + index, + unchecked: line.match(/^-\s+\[\s\]\s+(.+)/), + checked: line.match(/^-\s+\[x\]\s+(.+)/i), + })); + const uncheckedItems = parsed.filter((p) => p.unchecked); + const checkedItems = parsed.filter((p) => p.checked); + const otherLines = parsed.filter((p) => !p.unchecked && !p.checked && p.line.trim()); + const toggle = (lineIndex: number) => { const updated = lines.map((line, i) => { if (i !== lineIndex) return line; @@ -40,28 +66,81 @@ function ActionItemsList({ text, onTextChange }: { text: string; onTextChange?: onTextChange?.(updated); }; + const removeItem = (lineIndex: number) => { + const updated = lines.filter((_, i) => i !== lineIndex).join('\n'); + onTextChange?.(updated); + }; + + const addItem = () => { + const trimmed = newItemText.trim(); + if (!trimmed) { setShowInput(false); return; } + const newLine = `- [ ] ${trimmed}`; + const updated = text.trimEnd() + '\n' + newLine; + onTextChange?.(updated); + setNewItemText(''); + setShowInput(false); + }; + + const renderItem = (item: (typeof parsed)[0]) => { + const isChecked = !!item.checked; + const label = (item.unchecked ?? item.checked)![1]; + return ( + + toggle(item.index)} + > + {isChecked && βœ“} + + + {label} + + removeItem(item.index)} style={actionStyles.removeButton} hitSlop={8}> + βœ• + + + ); + }; + return ( - {lines.map((line, i) => { - const unchecked = line.match(/^-\s+\[\s\]\s+(.+)/); - const checked = line.match(/^-\s+\[x\]\s+(.+)/i); - if (unchecked || checked) { - const isChecked = !!checked; - const label = (unchecked ?? checked)![1]; - return ( - toggle(i)}> - - {isChecked && βœ“} - - - {label} - + {otherLines.map((item) => ( + {item.line} + ))} + {uncheckedItems.map(renderItem)} + {checkedItems.length > 0 && ( + <> + + {checkedItems.map(renderItem)} + + )} + {allowAdd && ( + showInput ? ( + + + + Add - ); - } - if (!line.trim()) return null; - return {line}; - })} + { setShowInput(false); setNewItemText(''); }} style={actionStyles.addCancel}> + βœ• + + + ) : ( + setShowInput(true)}> + + Add item + + ) + )} ); } @@ -105,6 +184,66 @@ const actionStyles = StyleSheet.create({ color: Colors.textTertiary, textDecorationLine: 'line-through', }, + removeButton: { + paddingHorizontal: 6, + paddingVertical: 4, + }, + removeText: { + fontSize: 13, + color: Colors.textTertiary, + }, + divider: { + height: 1, + backgroundColor: Colors.borderLight, + marginVertical: 12, + }, + addButton: { + marginTop: 12, + paddingVertical: 8, + paddingHorizontal: 4, + alignSelf: 'flex-start', + }, + addButtonText: { + fontSize: 15, + color: Colors.primary, + fontWeight: '600', + }, + addRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 12, + gap: 8, + }, + addInput: { + flex: 1, + borderWidth: 1.5, + borderColor: Colors.primary, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 8, + fontSize: 15, + color: Colors.text, + backgroundColor: Colors.surface, + }, + addConfirm: { + paddingHorizontal: 12, + paddingVertical: 8, + backgroundColor: Colors.primary, + borderRadius: 10, + }, + addConfirmText: { + color: '#fff', + fontWeight: '700', + fontSize: 14, + }, + addCancel: { + paddingHorizontal: 8, + paddingVertical: 8, + }, + addCancelText: { + color: Colors.textTertiary, + fontSize: 16, + }, }); export default function NoteDetailScreen() { @@ -117,23 +256,43 @@ export default function NoteDetailScreen() { const note = useMemo(() => notes.find((n) => n.id === id), [notes, id]); - const [isEditing, setIsEditing] = useState(false); + // action_items notes use checkbox UI as primary β€” start in view mode. + // Other notes start in edit mode without auto-focus (#9). + const [isEditing, setIsEditing] = useState(() => note?.format_type !== 'action_items'); const [editTitle, setEditTitle] = useState(note?.title ?? ''); const [editText, setEditText] = useState(note?.formatted_text ?? ''); const [editFormatType, setEditFormatType] = useState(note?.format_type ?? 'paragraph'); const [saving, setSaving] = useState(false); + const scrollRef = useRef(null); const titleInputRef = useRef(null); + const contentInputRef = useRef(null); const [showFormatPicker, setShowFormatPicker] = useState(false); const [selectedFormat, setSelectedFormat] = useState(null); const [reformatInstructions, setReformatInstructions] = useState(''); const [reformatting, setReformatting] = useState(false); + // Sync edit state when note loads (handles navigation to a different note) useEffect(() => { - if (isEditing) { - setTimeout(() => titleInputRef.current?.focus(), 50); + if (note) { + setEditTitle(note.title); + setEditText(note.formatted_text); + setEditFormatType(note.format_type); + setIsEditing(note.format_type !== 'action_items'); + } + }, [note?.id]); + + const handleShare = useCallback(async () => { + if (!note) return; + try { + await Share.share({ + title: note.title, + message: `${note.title}\n\n${note.formatted_text}`, + }); + } catch { + // User dismissed share sheet β€” not an error } - }, [isEditing]); + }, [note]); const [noteTags, setNoteTags] = useState([]); const [tagPickerVisible, setTagPickerVisible] = useState(false); @@ -217,17 +376,24 @@ export default function NoteDetailScreen() { setShowFormatPicker(false); setReformatInstructions(''); setSelectedFormat(null); + // Dismiss keyboard + titleInputRef.current?.blur(); + contentInputRef.current?.blur(); }; const handleReformat = async (formatType: FormatType) => { setSelectedFormat(formatType); setReformatting(true); try { + const validatedTerms = await loadValidatedTerms(); const result = await formatTranscription( editText, formatType, formatType === 'custom' ? customExample || undefined : undefined, reformatInstructions || undefined, + false, + [], + validatedTerms.length > 0 ? validatedTerms : undefined, ); setEditTitle(result.title); setEditText(result.formatted_text); @@ -272,6 +438,11 @@ export default function NoteDetailScreen() { return ( + router.back()} @@ -322,12 +493,16 @@ export default function NoteDetailScreen() { ) : ( <> { - setEditTitle(note.title); - setEditText(note.formatted_text); - setEditFormatType(note.format_type); - setIsEditing(true); - }} + onPress={handleShare} + style={({ pressed }) => [ + styles.actionButton, + pressed && { opacity: 0.6 }, + ]} + > + Share + + setIsEditing(true)} style={({ pressed }) => [ styles.actionButton, pressed && { opacity: 0.6 }, @@ -359,8 +534,10 @@ export default function NoteDetailScreen() { @@ -380,8 +557,11 @@ export default function NoteDetailScreen() { placeholderTextColor={Colors.textTertiary} selectTextOnFocus returnKeyType="next" + multiline + onSubmitEditing={() => contentInputRef.current?.focus()} /> {/* Inline format picker */} @@ -447,6 +628,7 @@ export default function NoteDetailScreen() { updateNote(note.id, { formatted_text: updated })} + allowAdd /> ) : ( {note.formatted_text} @@ -501,6 +683,7 @@ export default function NoteDetailScreen() { onToggle={handleToggleTag} onCreateTag={handleCreateTag} /> + ); } @@ -566,6 +749,9 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: Colors.background, }, + keyboardAvoid: { + flex: 1, + }, centered: { flex: 1, justifyContent: 'center', @@ -633,6 +819,11 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '700', }, + shareText: { + color: Colors.primary, + fontSize: 16, + fontWeight: '500', + }, archiveText: { color: Colors.warning, fontSize: 16, @@ -678,6 +869,7 @@ const styles = StyleSheet.create({ borderBottomColor: Colors.primary, paddingBottom: 10, letterSpacing: -0.3, + lineHeight: 34, }, textInput: { fontSize: 16, diff --git a/app/recording/[id].tsx b/app/recording/[id].tsx index 49a61bf..c8f60db 100644 --- a/app/recording/[id].tsx +++ b/app/recording/[id].tsx @@ -11,11 +11,13 @@ import { useNotes } from '../../contexts/NotesContext'; import { usePreferences } from '../../contexts/PreferencesContext'; import { useTags } from '../../contexts/TagsContext'; import { transcribeRecording, formatTranscription } from '../../services/processing'; +import { loadValidatedTerms, saveValidatedTerms } from '../../services/validatedTerms'; import { Colors } from '../../constants/colors'; import { FORMAT_OPTIONS } from '../../constants/formats'; -import { FormatType, RecordingStatus } from '../../types'; +import { FormatType, RecordingStatus, UncertainTerm } from '../../types'; import { showAlert, showConfirm } from '../../utils/alert'; import { AudioPlaybackBar } from '../../components/AudioPlaybackBar'; +import { AcronymValidationModal } from '../../components/AcronymValidationModal'; // Status badge colors const STATUS_COLORS: Record = { @@ -65,6 +67,11 @@ export default function RecordingDetailScreen() { const [saving, setSaving] = useState(false); const [reformatInstructions, setReformatInstructions] = useState(customInstructions ?? ''); + // Acronym validation state + const [pendingTerms, setPendingTerms] = useState([]); + const [showValidationModal, setShowValidationModal] = useState(false); + const [pendingFormatType, setPendingFormatType] = useState(null); + // E4-S3: Auto-advance guard β€” prevents double-fire in React StrictMode const autoAdvanceRef = useRef(false); @@ -85,8 +92,18 @@ export default function RecordingDetailScreen() { setActionError(null); try { await updateRecording(id, { status: 'transcribing' }); - const rawTranscription = await transcribeRecording(recording.localUri); - await updateRecording(id, { status: 'transcribed', rawTranscription }); + const existingTermsForTranscribe = await loadValidatedTerms(); + const { transcription: rawTranscription, uncertainTerms } = + await transcribeRecording(recording.localUri, existingTermsForTranscribe.length > 0 ? existingTermsForTranscribe : undefined); + await updateRecording(id, { status: 'transcribed', rawTranscription, uncertainTerms }); + + // If there are uncertain terms, pause for user validation before formatting + if (uncertainTerms.length > 0) { + setPendingTerms(uncertainTerms); + setPendingFormatType((formatTypeParam as FormatType) ?? defaultFormat); + setShowValidationModal(true); + return; // formatting continues after modal confirmation + } // Step 2: Format (using the format type from navigation params) const formatType = (formatTypeParam as FormatType) ?? defaultFormat; @@ -99,13 +116,15 @@ export default function RecordingDetailScreen() { currentTags = tags.map((t) => t.name); } + const existingTerms = await loadValidatedTerms(); const result = await formatTranscription( rawTranscription, formatType, customExample || undefined, customInstructions || undefined, autotaggingEnabled, - currentTags + currentTags, + existingTerms ); await updateRecording(id, { @@ -150,15 +169,25 @@ export default function RecordingDetailScreen() { setActionError(null); try { await updateRecording(id, { status: 'transcribing' }); - const rawTranscription = await transcribeRecording(recording.localUri); - await updateRecording(id, { status: 'transcribed', rawTranscription }); + const existingTermsForTranscribe = await loadValidatedTerms(); + const { transcription: rawTranscription, uncertainTerms } = + await transcribeRecording(recording.localUri, existingTermsForTranscribe.length > 0 ? existingTermsForTranscribe : undefined); + await updateRecording(id, { status: 'transcribed', rawTranscription, uncertainTerms }); + if (uncertainTerms.length > 0) { + setPendingTerms(uncertainTerms); + setPendingFormatType(selectedFormat); + setShowValidationModal(true); + } } catch (err: any) { await updateRecording(id, { status: 'pending' }); setActionError(err.message || 'Transcription failed. Please try again.'); } }; - const handleFormat = async (formatType: FormatType) => { + const handleFormat = async ( + formatType: FormatType, + confirmedTerms?: Array<{ original_term: string; corrected_term: string }> + ) => { setShowFormatPicker(false); setActionError(null); try { @@ -174,13 +203,22 @@ export default function RecordingDetailScreen() { currentTags = tagList.map((t) => t.name); } + const existingTerms = await loadValidatedTerms(); + // Merge confirmed terms with existing (confirmed take precedence via upsert) + const allValidatedTerms = confirmedTerms + ? [...existingTerms.filter( + (e) => !confirmedTerms.some((c) => c.original_term === e.original_term) + ), ...confirmedTerms] + : existingTerms; + const result = await formatTranscription( recording.rawTranscription!, formatType, customExample || undefined, reformatInstructions || undefined, autotaggingEnabled, - currentTags + currentTags, + allValidatedTerms ); await updateRecording(id, { @@ -195,6 +233,31 @@ export default function RecordingDetailScreen() { } }; + const handleValidationConfirm = async ( + validated: Array<{ original_term: string; corrected_term: string }> + ) => { + setShowValidationModal(false); + setPendingTerms([]); + // Persist the validated terms for future use + try { + await saveValidatedTerms(validated); + } catch { + // Non-fatal: continue even if persistence fails + } + // Proceed with formatting using the confirmed corrections + const fmt = pendingFormatType ?? selectedFormat; + setPendingFormatType(null); + await handleFormat(fmt, validated); + }; + + const handleValidationSkip = () => { + setShowValidationModal(false); + setPendingTerms([]); + const fmt = pendingFormatType ?? selectedFormat; + setPendingFormatType(null); + handleFormat(fmt); + }; + const handleSaveAsNote = async () => { if (!recording.formattedText || !recording.formattedTitle) return; setSaving(true); @@ -265,6 +328,12 @@ export default function RecordingDetailScreen() { return ( + {/* Top bar */} router.back()} style={styles.topButton}> diff --git a/app/settings.tsx b/app/settings.tsx index 5497953..7beefb2 100644 --- a/app/settings.tsx +++ b/app/settings.tsx @@ -41,11 +41,13 @@ export default function SettingsScreen() { customExample, customInstructions, autotaggingEnabled, + defaultTagId, isAdmin, setDefaultFormat, setCustomExample, setCustomInstructions, setAutotaggingEnabled, + setDefaultTagId, } = usePreferences(); const { tags, createTag, deleteTag } = useTags(); @@ -401,6 +403,22 @@ export default function SettingsScreen() { β€Ί + [ + styles.navLink, + pressed && { opacity: 0.7 }, + ]} + onPress={() => router.push('/acronyms')} + > + + πŸ”€ + + Saved Terms + Corrections reused in future transcriptions + + β€Ί + + {/* Autotagging */} @@ -455,6 +473,63 @@ export default function SettingsScreen() { )} + {/* Default tag selector */} + {tags.length > 0 && ( + + Default filter + + The app opens with this tag pre-selected. + + + setDefaultTagId(null)} + > + + None + + {defaultTagId === null && ( + βœ“ + )} + + {tags.map((tag) => ( + setDefaultTagId(tag.id)} + > + + + {tag.name} + + {defaultTagId === tag.id && ( + βœ“ + )} + + ))} + + + )} + {/* Create new tag */} ) => void; + onSkip: () => void; +} + +export const AcronymValidationModal: React.FC = ({ + visible, + terms, + onConfirm, + onSkip, +}) => { + const [entries, setEntries] = useState([]); + + useEffect(() => { + const seen = new Set(); + setEntries( + terms + .filter((t) => { + if (seen.has(t.original)) return false; + seen.add(t.original); + return true; + }) + .map((t) => ({ + original: t.original, + corrected: t.suggestion ?? t.original, + skip: false, + })) + ); + }, [terms]); + + const updateCorrected = (index: number, value: string) => { + setEntries((prev) => + prev.map((e, i) => (i === index ? { ...e, corrected: value, skip: false } : e)) + ); + }; + + const toggleSkip = (index: number) => { + setEntries((prev) => + prev.map((e, i) => (i === index ? { ...e, skip: !e.skip } : e)) + ); + }; + + const handleConfirm = () => { + const validated = entries + .filter((e) => !e.skip && e.corrected.trim() && e.corrected.trim() !== e.original) + .map((e) => ({ original_term: e.original, corrected_term: e.corrected.trim() })); + onConfirm(validated); + }; + + if (terms.length === 0) return null; + + return ( + + + + + Check these terms + + The transcription may have misrecognised these words. Correct them to + improve this note and future ones. + + + + {entries.map((entry, i) => ( + + + Heard as: + "{entry.original}" + toggleSkip(i)} style={styles.skipButton}> + {entry.skip ? 'Undo skip' : 'Skip'} + + + {!entry.skip && ( + updateCorrected(i, v)} + placeholder="Correct spelling..." + placeholderTextColor={Colors.textTertiary} + autoCapitalize="none" + autoCorrect={false} + /> + )} + + ))} + + + + [styles.confirmButton, pressed && { opacity: 0.85 }]} + onPress={handleConfirm} + > + Save & Continue + + [styles.skipAllButton, pressed && { opacity: 0.7 }]} + onPress={onSkip} + > + Skip all + + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: 'flex-end', + backgroundColor: 'rgba(0,0,0,0.4)', + }, + sheet: { + backgroundColor: Colors.background, + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + paddingHorizontal: 24, + paddingBottom: 40, + maxHeight: '80%', + }, + handle: { + width: 40, + height: 4, + borderRadius: 2, + backgroundColor: Colors.borderLight, + alignSelf: 'center', + marginTop: 12, + marginBottom: 20, + }, + title: { + fontSize: 20, + fontWeight: '700', + color: Colors.text, + marginBottom: 6, + }, + subtitle: { + fontSize: 14, + color: Colors.textTertiary, + lineHeight: 20, + marginBottom: 20, + }, + termsList: { + flexGrow: 0, + marginBottom: 20, + }, + termRow: { + backgroundColor: Colors.surface, + borderRadius: 14, + padding: 14, + marginBottom: 10, + borderWidth: 1, + borderColor: Colors.borderLight, + }, + termRowSkipped: { + opacity: 0.5, + }, + termHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 10, + gap: 6, + }, + originalLabel: { + fontSize: 12, + color: Colors.textTertiary, + fontWeight: '500', + }, + originalText: { + fontSize: 13, + color: Colors.textSecondary, + fontStyle: 'italic', + flex: 1, + }, + skipButton: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 8, + backgroundColor: Colors.surfaceHover, + }, + skipText: { + fontSize: 12, + color: Colors.textTertiary, + fontWeight: '500', + }, + correctedInput: { + borderWidth: 1.5, + borderColor: Colors.primary, + borderRadius: 10, + padding: 10, + fontSize: 15, + color: Colors.text, + backgroundColor: Colors.background, + fontWeight: '500', + }, + actions: { + gap: 10, + }, + confirmButton: { + backgroundColor: Colors.primary, + borderRadius: 14, + paddingVertical: 16, + alignItems: 'center', + }, + confirmText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '700', + }, + skipAllButton: { + paddingVertical: 12, + alignItems: 'center', + }, + skipAllText: { + color: Colors.textTertiary, + fontSize: 14, + fontWeight: '500', + }, +}); diff --git a/components/DraggableNoteList.tsx b/components/DraggableNoteList.tsx new file mode 100644 index 0000000..c2d6dc9 --- /dev/null +++ b/components/DraggableNoteList.tsx @@ -0,0 +1,199 @@ +import React, { useRef, useState, useCallback } from 'react'; +import { + View, + ScrollView, + PanResponder, + Animated, + StyleSheet, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Note } from '../types'; +import { NoteCard } from './NoteCard'; +import { Colors } from '../constants/colors'; + +interface DraggableNoteListProps { + notes: Note[]; + onNotePress: (note: Note) => void; + onReorder: (newIds: string[]) => void; + onRefresh: () => void; + loading: boolean; + reorderMode?: boolean; +} + +export const DraggableNoteList: React.FC = ({ + notes, + onNotePress, + onReorder, + onRefresh, + loading, + reorderMode = false, +}) => { + const insets = useSafeAreaInsets(); + const scrollRef = useRef(null); + const scrollOffset = useRef(0); + + // Per-item heights accumulated from onLayout + const itemHeights = useRef([]); + + // Active drag state + const [draggingIndex, setDraggingIndex] = useState(null); + const [scrollEnabled, setScrollEnabled] = useState(true); + const dragTranslateY = useRef(new Animated.Value(0)).current; + const activeDeltaY = useRef(0); + + const getItemTop = (index: number) => + itemHeights.current.slice(0, index).reduce((s, h) => s + h, 0); + + // Determine where to insert the dragged item based on its current midpoint Y + const getInsertIndex = (startIndex: number, deltaY: number): number => { + const movedTop = getItemTop(startIndex) + deltaY; + const movedMid = movedTop + (itemHeights.current[startIndex] ?? 80) / 2; + + let cumY = 0; + for (let i = 0; i < notes.length; i++) { + if (i === startIndex) { + cumY += itemHeights.current[i] ?? 80; + continue; + } + const h = itemHeights.current[i] ?? 80; + if (movedMid < cumY + h / 2) return i; + cumY += h; + } + return notes.length - 1; + }; + + const createPanResponder = useCallback( + (noteIndex: number) => + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onStartShouldSetPanResponderCapture: () => true, + onMoveShouldSetPanResponder: () => true, + onMoveShouldSetPanResponderCapture: () => true, + onPanResponderGrant: () => { + dragTranslateY.setValue(0); + activeDeltaY.current = 0; + setDraggingIndex(noteIndex); + setScrollEnabled(false); + }, + onPanResponderMove: (_, gesture) => { + activeDeltaY.current = gesture.dy; + dragTranslateY.setValue(gesture.dy); + }, + onPanResponderRelease: () => { + const delta = activeDeltaY.current; + const insertIdx = getInsertIndex(noteIndex, delta); + + if (insertIdx !== noteIndex) { + const reordered = [...notes]; + const [moved] = reordered.splice(noteIndex, 1); + reordered.splice(insertIdx, 0, moved); + onReorder(reordered.map((n) => n.id)); + } + + dragTranslateY.setValue(0); + setDraggingIndex(null); + setScrollEnabled(true); + }, + onPanResponderTerminate: () => { + dragTranslateY.setValue(0); + setDraggingIndex(null); + setScrollEnabled(true); + }, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [notes, onReorder] + ); + + return ( + { scrollOffset.current = e.nativeEvent.contentOffset.y; }} + scrollEventThrottle={16} + contentContainerStyle={[styles.content, { paddingBottom: 120 + insets.bottom }]} + > + {notes.map((note, index) => { + const isDragging = draggingIndex === index; + const panResponder = createPanResponder(index); + + return ( + { + itemHeights.current[index] = e.nativeEvent.layout.height; + }} + style={styles.itemWrapper} + > + + + + + + {reorderMode && ( + + + + + + )} + + + + ); + })} + + ); +}; + +const styles = StyleSheet.create({ + content: { + padding: 20, + }, + itemWrapper: { + marginBottom: 12, + }, + animatedItem: { + backgroundColor: 'transparent', + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + cardWrapper: { + flex: 1, + }, + dragHandle: { + width: 32, + alignItems: 'center', + justifyContent: 'center', + gap: 4, + paddingVertical: 20, + paddingHorizontal: 4, + }, + dragHandleActive: { + opacity: 0.5, + }, + dragBar: { + width: 18, + height: 2.5, + borderRadius: 1.5, + backgroundColor: Colors.textTertiary, + }, +}); diff --git a/components/NoteCard.tsx b/components/NoteCard.tsx index 6fe0416..be86474 100644 --- a/components/NoteCard.tsx +++ b/components/NoteCard.tsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { useState } from 'react'; import { View, Text, StyleSheet, Pressable } from 'react-native'; +import * as Clipboard from 'expo-clipboard'; import { Note } from '../types'; import { TagChip } from './TagChip'; import { Colors } from '../constants/colors'; @@ -14,11 +15,21 @@ interface NoteCardProps { export const NoteCard: React.FC = ({ note, onPress }) => { const { noteTagsMap } = useTags(); const noteTags = (noteTagsMap[note.id] ?? []).slice(0, 3); + const [copied, setCopied] = useState(false); + + const handleLongPress = async () => { + const text = note.title ? `${note.title}\n\n${note.formatted_text}` : note.formatted_text; + await Clipboard.setStringAsync(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; return ( [styles.card, pressed && styles.cardPressed]} + style={({ pressed }) => [styles.card, pressed && styles.cardPressed, copied && styles.cardCopied]} onPress={() => onPress(note)} + onLongPress={handleLongPress} + delayLongPress={500} accessibilityRole="button" accessibilityLabel={`Note: ${note.title}`} > @@ -38,6 +49,11 @@ export const NoteCard: React.FC = ({ note, onPress }) => { {truncateText(note.formatted_text, 180)} + {copied && ( + + Copied! + + )} ); }; @@ -54,6 +70,24 @@ const styles = StyleSheet.create({ backgroundColor: Colors.surfaceHover, transform: [{ scale: 0.98 }], }, + cardCopied: { + borderColor: Colors.primary, + borderWidth: 1.5, + }, + copiedBadge: { + position: 'absolute', + top: 10, + right: 12, + backgroundColor: Colors.primary, + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 4, + }, + copiedText: { + color: '#fff', + fontSize: 12, + fontWeight: '600', + }, header: { flexDirection: 'row', alignItems: 'center', diff --git a/components/NoteGrid.tsx b/components/NoteGrid.tsx index fd4a45c..71f4115 100644 --- a/components/NoteGrid.tsx +++ b/components/NoteGrid.tsx @@ -10,6 +10,7 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Note } from '../types'; import { NoteCard } from './NoteCard'; +import { DraggableNoteList } from './DraggableNoteList'; import { Colors } from '../constants/colors'; interface NoteGridProps { @@ -18,6 +19,9 @@ interface NoteGridProps { onNotePress: (note: Note) => void; onRefresh: () => void; hasActiveFilter?: boolean; + draggable?: boolean; + reorderMode?: boolean; + onReorder?: (newIds: string[]) => void; } export const NoteGrid: React.FC = ({ @@ -26,6 +30,9 @@ export const NoteGrid: React.FC = ({ onNotePress, onRefresh, hasActiveFilter = false, + draggable = false, + reorderMode = false, + onReorder, }) => { const insets = useSafeAreaInsets(); @@ -63,6 +70,19 @@ export const NoteGrid: React.FC = ({ ); } + if (draggable && onReorder) { + return ( + + ); + } + return ( Promise; deleteNotePermanently: (id: string) => Promise; setSearchQuery: (query: string) => void; + setSort: (sort: NoteSort) => void; + setManualOrder: (ids: string[]) => void; } const NotesContext = createContext(undefined); @@ -41,24 +49,41 @@ export const NotesProvider: React.FC<{ children: ReactNode }> = ({ notes: [], loading: false, searchQuery: '', + sort: 'manual', + manualOrder: [], }); + // Restore persisted sort and manual order on mount + useEffect(() => { + Promise.all([ + AsyncStorage.getItem(SORT_KEY), + AsyncStorage.getItem(MANUAL_ORDER_KEY), + ]).then(([savedSort, savedOrder]) => { + setState((prev) => ({ + ...prev, + ...(savedSort ? { sort: savedSort as NoteSort } : {}), + ...(savedOrder ? { manualOrder: JSON.parse(savedOrder) as string[] } : {}), + })); + }).catch(() => {}); + }, []); + const fetchNotes = useCallback(async () => { setState((prev) => ({ ...prev, loading: true })); try { - const notes = await notesService.fetchNotes(); + const notes = await notesService.fetchNotes(state.sort); setState((prev) => ({ ...prev, notes, loading: false })); } catch (error) { console.error('Failed to fetch notes:', error); setState((prev) => ({ ...prev, loading: false })); } - }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.sort]); useEffect(() => { if (session) { fetchNotes(); } else { - setState({ notes: [], loading: false, searchQuery: '' }); + setState({ notes: [], loading: false, searchQuery: '', sort: 'manual', manualOrder: [] }); } }, [session, fetchNotes]); @@ -153,16 +178,38 @@ export const NotesProvider: React.FC<{ children: ReactNode }> = ({ setState((prev) => ({ ...prev, searchQuery: query })); }, []); + const setSort = useCallback((sort: NoteSort) => { + setState((prev) => ({ ...prev, sort })); + AsyncStorage.setItem(SORT_KEY, sort).catch(() => {}); + }, []); + + const setManualOrder = useCallback((ids: string[]) => { + setState((prev) => ({ ...prev, manualOrder: ids })); + AsyncStorage.setItem(MANUAL_ORDER_KEY, JSON.stringify(ids)).catch(() => {}); + }, []); + const filteredNotes = useMemo(() => { - if (!state.searchQuery.trim()) return state.notes; - const q = state.searchQuery.toLowerCase(); - return state.notes.filter( - (note) => - note.title.toLowerCase().includes(q) || - note.formatted_text.toLowerCase().includes(q) || - note.raw_transcription?.toLowerCase().includes(q) - ); - }, [state.notes, state.searchQuery]); + const base = (() => { + if (!state.searchQuery.trim()) return state.notes; + const q = state.searchQuery.toLowerCase(); + return state.notes.filter( + (note) => + note.title.toLowerCase().includes(q) || + note.formatted_text.toLowerCase().includes(q) || + note.raw_transcription?.toLowerCase().includes(q) + ); + })(); + + if (state.sort !== 'manual' || (state.manualOrder ?? []).length === 0) return base; + + // Apply manual order: known IDs first (in saved order), then new notes appended + const orderMap = new Map(state.manualOrder.map((id, i) => [id, i])); + const known = base + .filter((n) => orderMap.has(n.id)) + .sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0)); + const newNotes = base.filter((n) => !orderMap.has(n.id)); + return [...known, ...newNotes]; + }, [state.notes, state.searchQuery, state.sort, state.manualOrder]); return ( = ({ restoreNote, deleteNotePermanently, setSearchQuery, + setSort, + setManualOrder, }} > {children} diff --git a/contexts/PreferencesContext.tsx b/contexts/PreferencesContext.tsx index 5542d98..eba6aca 100644 --- a/contexts/PreferencesContext.tsx +++ b/contexts/PreferencesContext.tsx @@ -16,6 +16,7 @@ interface PreferencesState { customExample: string; customInstructions: string; autotaggingEnabled: boolean; + defaultTagId: string | null; isAdmin: boolean; tier: 'free' | 'unlimited'; loading: boolean; @@ -26,6 +27,7 @@ interface PreferencesContextType extends PreferencesState { setCustomExample: (example: string) => Promise; setCustomInstructions: (instructions: string) => Promise; setAutotaggingEnabled: (enabled: boolean) => Promise; + setDefaultTagId: (tagId: string | null) => Promise; } const PreferencesContext = createContext( @@ -41,6 +43,7 @@ export const PreferencesProvider: React.FC<{ children: ReactNode }> = ({ customExample: '', customInstructions: '', autotaggingEnabled: false, + defaultTagId: null, isAdmin: false, tier: 'free', loading: true, @@ -50,7 +53,7 @@ export const PreferencesProvider: React.FC<{ children: ReactNode }> = ({ if (session) { loadPreferences(); } else { - setState({ defaultFormat: DEFAULT_FORMAT, customExample: '', customInstructions: '', autotaggingEnabled: false, isAdmin: false, tier: 'free', loading: false }); + setState({ defaultFormat: DEFAULT_FORMAT, customExample: '', customInstructions: '', autotaggingEnabled: false, defaultTagId: null, isAdmin: false, tier: 'free', loading: false }); } }, [session]); @@ -62,6 +65,7 @@ export const PreferencesProvider: React.FC<{ children: ReactNode }> = ({ customExample: prefs?.custom_example ?? '', customInstructions: prefs?.custom_instructions ?? '', autotaggingEnabled: prefs?.autotagging_enabled ?? false, + defaultTagId: prefs?.default_tag_id ?? null, isAdmin: prefs?.is_admin ?? false, tier: prefs?.tier ?? 'free', loading: false, @@ -116,6 +120,17 @@ export const PreferencesProvider: React.FC<{ children: ReactNode }> = ({ } }, [state.autotaggingEnabled]); + const setDefaultTagId = useCallback(async (tagId: string | null) => { + const previous = state.defaultTagId; + setState((prev) => ({ ...prev, defaultTagId: tagId })); + try { + await preferencesService.setDefaultTagId(tagId); + } catch (error) { + setState((prev) => ({ ...prev, defaultTagId: previous })); + throw error; + } + }, [state.defaultTagId]); + return ( = ({ setCustomExample, setCustomInstructions, setAutotaggingEnabled, + setDefaultTagId, }} > {children} diff --git a/package-lock.json b/package-lock.json index b45b0d5..438ac05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@supabase/supabase-js": "^2.45.0", "expo": "~54.0.0", "expo-audio": "~1.1.1", + "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", "expo-file-system": "~18.0.12", "expo-keep-awake": "~15.0.8", @@ -20,6 +21,7 @@ "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", "expo-status-bar": "~3.0.9", + "expo-updates": "~29.0.16", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", @@ -5777,6 +5779,17 @@ "react-native": "*" } }, + "node_modules/expo-clipboard": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz", + "integrity": "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-constants": { "version": "18.0.13", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", @@ -5791,6 +5804,12 @@ "react-native": "*" } }, + "node_modules/expo-eas-client": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-1.0.8.tgz", + "integrity": "sha512-5or11NJhSeDoHHI6zyvQDW2cz/yFyE+1Cz8NTs5NK8JzC7J0JrkUgptWtxyfB6Xs/21YRNifd3qgbBN3hfKVgA==", + "license": "MIT" + }, "node_modules/expo-file-system": { "version": "18.0.12", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.0.12.tgz", @@ -5818,6 +5837,12 @@ "react-native": "*" } }, + "node_modules/expo-json-utils": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz", + "integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==", + "license": "MIT" + }, "node_modules/expo-keep-awake": { "version": "15.0.8", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", @@ -5842,6 +5867,19 @@ "react-native": "*" } }, + "node_modules/expo-manifests": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-1.0.10.tgz", + "integrity": "sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ==", + "license": "MIT", + "dependencies": { + "@expo/config": "~12.0.11", + "expo-json-utils": "~0.15.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "3.0.24", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.24.tgz", @@ -5985,6 +6023,57 @@ "react-native": "*" } }, + "node_modules/expo-structured-headers": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/expo-structured-headers/-/expo-structured-headers-5.0.0.tgz", + "integrity": "sha512-RmrBtnSphk5REmZGV+lcdgdpxyzio5rJw8CXviHE6qH5pKQQ83fhMEcigvrkBdsn2Efw2EODp4Yxl1/fqMvOZw==", + "license": "MIT" + }, + "node_modules/expo-updates": { + "version": "29.0.16", + "resolved": "https://registry.npmjs.org/expo-updates/-/expo-updates-29.0.16.tgz", + "integrity": "sha512-E9/fxRz/Eurtc7hxeI/6ZPyHH3To9Xoccm1kXoICZTRojmuTo+dx0Xv53UHyHn4G5zGMezyaKF2Qtj3AKcT93w==", + "license": "MIT", + "dependencies": { + "@expo/code-signing-certificates": "^0.0.6", + "@expo/plist": "^0.4.8", + "@expo/spawn-async": "^1.7.2", + "arg": "4.1.0", + "chalk": "^4.1.2", + "debug": "^4.3.4", + "expo-eas-client": "~1.0.8", + "expo-manifests": "~1.0.10", + "expo-structured-headers": "~5.0.0", + "expo-updates-interface": "~2.0.0", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "ignore": "^5.3.1", + "resolve-from": "^5.0.0" + }, + "bin": { + "expo-updates": "bin/cli.js" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-updates-interface": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz", + "integrity": "sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-updates/node_modules/arg": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz", + "integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==", + "license": "MIT" + }, "node_modules/expo/node_modules/@expo/cli": { "version": "54.0.23", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.23.tgz", diff --git a/package.json b/package.json index 31c99f0..cf962e3 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@supabase/supabase-js": "^2.45.0", "expo": "~54.0.0", "expo-audio": "~1.1.1", + "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", "expo-file-system": "~18.0.12", "expo-keep-awake": "~15.0.8", @@ -43,6 +44,7 @@ "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", "expo-status-bar": "~3.0.9", + "expo-updates": "~29.0.16", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", diff --git a/services/notes.ts b/services/notes.ts index 8085172..42d1455 100644 --- a/services/notes.ts +++ b/services/notes.ts @@ -1,13 +1,30 @@ import { supabase } from './supabase'; -import { Note, CreateNoteInput, UpdateNoteInput } from '../types'; +import { Note, CreateNoteInput, UpdateNoteInput, NoteSort } from '../types'; -export const fetchNotes = async (): Promise => { - const { data, error } = await supabase +export const fetchNotes = async (sort: NoteSort = 'date_desc'): Promise => { + let query = supabase .from('notes') .select('*') .is('deleted_at', null) - .is('archived_at', null) - .order('created_at', { ascending: false }); + .is('archived_at', null); + + switch (sort) { + case 'date_asc': + query = query.order('created_at', { ascending: true }).order('id', { ascending: true }); + break; + case 'title_asc': + query = query.order('title', { ascending: true }).order('id', { ascending: true }); + break; + case 'title_desc': + query = query.order('title', { ascending: false }).order('id', { ascending: true }); + break; + case 'manual': + case 'date_desc': + default: + query = query.order('created_at', { ascending: false }).order('id', { ascending: true }); + } + + const { data, error } = await query; if (error) throw error; return data || []; }; diff --git a/services/preferences.ts b/services/preferences.ts index e3b0c00..27a3b6f 100644 --- a/services/preferences.ts +++ b/services/preferences.ts @@ -93,3 +93,19 @@ export const setAutotaggingEnabled = async (enabled: boolean): Promise => if (error) throw error; }; + +export const setDefaultTagId = async (tagId: string | null): Promise => { + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) throw new Error('Not authenticated'); + + const { error } = await supabase + .from('user_preferences') + .upsert( + { user_id: user.id, default_tag_id: tagId }, + { onConflict: 'user_id' } + ); + + if (error) throw error; +}; diff --git a/services/processing.ts b/services/processing.ts index e037ff8..4388f9b 100644 --- a/services/processing.ts +++ b/services/processing.ts @@ -1,7 +1,12 @@ import { Platform } from 'react-native'; import * as FileSystem from 'expo-file-system/legacy'; import { supabase } from './supabase'; -import { ProcessingResult, FormatType } from '../types'; +import { ProcessingResult, FormatType, UncertainTerm } from '../types'; + +export interface TranscribeResult { + transcription: string; + uncertainTerms: UncertainTerm[]; +} const WHISPER_MAX_BYTES = 25 * 1024 * 1024; // 25 MB β€” OpenAI Whisper API limit const CHUNK_BYTES = 24 * 1024 * 1024; // 24 MB per chunk (1 MB safety margin) @@ -17,8 +22,9 @@ function sliceBlob(blob: Blob): Blob[] { } export const transcribeRecording = async ( - localUri: string -): Promise => { + localUri: string, + knownTerms?: Array<{ original_term: string; corrected_term: string }> +): Promise => { const { data: { user }, error: userError } = await supabase.auth.getUser(); if (userError || !user) throw new Error('Not authenticated'); @@ -28,7 +34,7 @@ export const transcribeRecording = async ( const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL; const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ?? ''; - const sendChunk = async (formData: FormData): Promise => { + const sendChunk = async (formData: FormData): Promise => { const result = await fetch( `${supabaseUrl}/functions/v1/process-recording`, { @@ -47,9 +53,14 @@ export const transcribeRecording = async ( ); } const data = await result.json(); - return data.transcription as string; + return { + transcription: data.transcription as string, + uncertainTerms: (data.uncertain_terms as UncertainTerm[]) ?? [], + }; }; + const knownTermsJson = knownTerms && knownTerms.length > 0 ? JSON.stringify(knownTerms) : null; + if (Platform.OS === 'web') { const response = await fetch(localUri); const blob = await response.blob(); @@ -58,19 +69,22 @@ export const transcribeRecording = async ( const formData = new FormData(); formData.append('audio', blob as any, 'recording.webm'); formData.append('mode', 'transcribe_only'); + if (knownTermsJson) formData.append('known_terms', knownTermsJson); return sendChunk(formData); } // File exceeds 25 MB: split, transcribe each chunk, join results + // Uncertain terms from fragment chunks are meaningless β€” skip them const chunks = sliceBlob(blob); const parts: string[] = []; for (let i = 0; i < chunks.length; i++) { const formData = new FormData(); formData.append('audio', chunks[i] as any, `recording-part${i + 1}.webm`); formData.append('mode', 'transcribe_only'); - parts.push(await sendChunk(formData)); + const { transcription } = await sendChunk(formData); + parts.push(transcription); } - return parts.join(' '); + return { transcription: parts.join(' '), uncertainTerms: [] }; } // Native: check file size before deciding whether to split @@ -81,6 +95,7 @@ export const transcribeRecording = async ( const formData = new FormData(); formData.append('audio', { uri: localUri, type: 'audio/m4a', name: 'recording.m4a' } as any); formData.append('mode', 'transcribe_only'); + if (knownTermsJson) formData.append('known_terms', knownTermsJson); return sendChunk(formData); } @@ -111,9 +126,11 @@ export const transcribeRecording = async ( const formData = new FormData(); formData.append('audio', { uri: tempUris[j], type: 'audio/m4a', name: `recording-part${j + 1}.m4a` } as any); formData.append('mode', 'transcribe_only'); - parts.push(await sendChunk(formData)); + const { transcription } = await sendChunk(formData); + parts.push(transcription); } - return parts.join(' '); + // Uncertain terms from fragment chunks are meaningless β€” skip them + return { transcription: parts.join(' '), uncertainTerms: [] }; } finally { await Promise.all(tempUris.map(uri => FileSystem.deleteAsync(uri, { idempotent: true }))); } @@ -125,7 +142,8 @@ export const formatTranscription = async ( customExample?: string, customInstructions?: string, autotaggingEnabled?: boolean, - userTagNames?: string[] + userTagNames?: string[], + validatedTerms?: Array<{ original_term: string; corrected_term: string }> ): Promise => { const { data: { user }, error: userError } = await supabase.auth.getUser(); if (userError || !user) throw new Error('Not authenticated'); @@ -148,6 +166,9 @@ export const formatTranscription = async ( formData.append('autotagging_enabled', 'true'); formData.append('user_tags', JSON.stringify(userTagNames ?? [])); } + if (validatedTerms && validatedTerms.length > 0) { + formData.append('validated_terms', JSON.stringify(validatedTerms)); + } const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL; const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY; @@ -181,9 +202,10 @@ export const processRecording = async ( customInstructions?: string, autotaggingEnabled?: boolean, userTagNames?: string[] -): Promise => { +): Promise => { // Step 1: transcribe - const rawTranscription = await transcribeRecording(audioUri); + const { transcription: rawTranscription, uncertainTerms } = + await transcribeRecording(audioUri); // Step 2: format const result = await formatTranscription( @@ -195,5 +217,5 @@ export const processRecording = async ( userTagNames ); - return result; + return { ...result, uncertainTerms }; }; diff --git a/services/validatedTerms.ts b/services/validatedTerms.ts new file mode 100644 index 0000000..1ae3ed4 --- /dev/null +++ b/services/validatedTerms.ts @@ -0,0 +1,49 @@ +import { supabase } from './supabase'; + +export interface ValidatedTerm { + original_term: string; + corrected_term: string; +} + +export const loadValidatedTerms = async (): Promise => { + const { data, error } = await supabase + .from('user_validated_terms') + .select('original_term, corrected_term'); + if (error) throw error; + return data ?? []; +}; + +export const saveValidatedTerms = async (terms: ValidatedTerm[]): Promise => { + if (terms.length === 0) return; + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) throw new Error('Not authenticated'); + + const rows = terms.map((t) => ({ + user_id: user.id, + original_term: t.original_term, + corrected_term: t.corrected_term, + })); + + const { error } = await supabase + .from('user_validated_terms') + .upsert(rows, { onConflict: 'user_id,original_term' }); + if (error) throw error; +}; + +export const deleteValidatedTerm = async (originalTerm: string): Promise => { + const { error } = await supabase + .from('user_validated_terms') + .delete() + .eq('original_term', originalTerm); + if (error) throw error; +}; + +export const deleteAllValidatedTerms = async (): Promise => { + const { error } = await supabase + .from('user_validated_terms') + .delete() + .neq('original_term', ''); + if (error) throw error; +}; diff --git a/supabase/functions/process-recording/index.ts b/supabase/functions/process-recording/index.ts index fb25208..14b3921 100644 --- a/supabase/functions/process-recording/index.ts +++ b/supabase/functions/process-recording/index.ts @@ -59,7 +59,81 @@ Good title: "App to benchmark LLM providers" β€” Bad title: "Je veux crΓ©er une Always respond in the same language as the transcription, unless the user instructs otherwise. Respond ONLY with a valid JSON object with exactly two fields: "title" (string) and "content" (string containing the formatted note in markdown).`; -function buildSystemPrompt(formatType: string, customExample?: string, customInstructions?: string): string { +async function detectUncertainTerms( + transcription: string, + knownTerms?: Array<{ original_term: string; corrected_term: string }> +): Promise> { + try { + let systemContent = + 'Identify words or phrases in this transcription that are likely misrecognized by speech-to-text: acronyms, technical terms, proper nouns, brand names, or phonetically ambiguous words. Only flag terms with genuine uncertainty β€” not common words. For each uncertain term, provide the original as transcribed and your best suggestion (or null if you cannot guess). Return 0 to 5 terms maximum.'; + + if (knownTerms && knownTerms.length > 0) { + const knownList = knownTerms + .map((t) => ` - "${t.original_term}" is already known to mean "${t.corrected_term}"`) + .join('\n'); + systemContent += `\n\nDo NOT flag any of the following β€” they are already confirmed corrections:\n${knownList}`; + } + + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + Authorization: `Bearer ${Deno.env.get('OPENAI_API_KEY')}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-5-nano', + reasoning_effort: 'minimal', + messages: [ + { + role: 'system', + content: systemContent, + }, + { role: 'user', content: transcription }, + ], + response_format: { + type: 'json_schema', + json_schema: { + name: 'uncertain_terms', + strict: true, + schema: { + type: 'object', + properties: { + uncertain_terms: { + type: 'array', + items: { + type: 'object', + properties: { + original: { type: 'string' }, + suggestion: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + }, + required: ['original', 'suggestion'], + additionalProperties: false, + }, + }, + }, + required: ['uncertain_terms'], + additionalProperties: false, + }, + }, + }, + }), + }); + if (!response.ok) return []; + const result = await response.json(); + const content = result.choices[0].message.content; + const parsed = typeof content === 'string' ? JSON.parse(content) : content; + return parsed.uncertain_terms ?? []; + } catch { + return []; + } +} + +function buildSystemPrompt( + formatType: string, + customExample?: string, + customInstructions?: string, + validatedTerms?: Array<{ original_term: string; corrected_term: string }> +): string { let prompt: string; if (formatType === 'custom' && customExample) { @@ -68,6 +142,14 @@ function buildSystemPrompt(formatType: string, customExample?: string, customIns prompt = `${SYSTEM_PROMPTS[formatType] || SYSTEM_PROMPTS['bullet_list']}\n\n${SHARED_SUFFIX}`; } + // Inject validated term corrections so the LLM replaces misrecognized words in the transcription + if (validatedTerms && validatedTerms.length > 0) { + const termsList = validatedTerms + .map((t) => ` - "${t.original_term}" β†’ "${t.corrected_term}"`) + .join('\n'); + prompt += `\n\nThe transcription may contain speech-to-text errors. Replace every occurrence of the left-hand spelling with the right-hand correct spelling when you encounter it in the transcription:\n${termsList}`; + } + // Append user custom instructions if provided (applies to all formats) if (customInstructions && customInstructions.trim()) { prompt += `\n\nAdditional user instructions (follow these closely): ${customInstructions.trim()}`; @@ -111,9 +193,10 @@ async function formatTranscription( transcription: string, formatType: string, customExample?: string, - customInstructions?: string + customInstructions?: string, + validatedTerms?: Array<{ original_term: string; corrected_term: string }> ): Promise<{ title: string; content: string }> { - const systemPrompt = buildSystemPrompt(formatType, customExample, customInstructions); + const systemPrompt = buildSystemPrompt(formatType, customExample, customInstructions, validatedTerms); const response = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', @@ -162,7 +245,7 @@ async function suggestTags( { role: 'system', content: - 'Given this note content, select 0 to 3 relevant tags from the provided list. Only use tags from the list. If no tags are relevant, return an empty array.', + 'Given this note content, select at most 1 relevant tag from the provided list. Only use tags from the list. If no tags are relevant, return an empty array.', }, { role: 'user', @@ -179,7 +262,7 @@ async function suggestTags( tags: { type: 'array', items: { type: 'string' }, - maxItems: 3, + maxItems: 1, }, }, required: ['tags'], @@ -287,9 +370,16 @@ serve(async (req: Request) => { { status: 413, headers: { 'Content-Type': 'application/json', ...corsHeaders } } ); } + const knownTermsRaw = formData.get('known_terms') as string | null; + let knownTerms: Array<{ original_term: string; corrected_term: string }> = []; + if (knownTermsRaw) { + try { knownTerms = JSON.parse(knownTermsRaw); } catch { knownTerms = []; } + } + const transcription = await transcribeAudio(audioFile); + const uncertainTerms = await detectUncertainTerms(transcription, knownTerms.length > 0 ? knownTerms : undefined); return new Response( - JSON.stringify({ transcription }), + JSON.stringify({ transcription, uncertain_terms: uncertainTerms }), { status: 200, headers: { 'Content-Type': 'application/json', ...corsHeaders } } ); } @@ -324,11 +414,18 @@ serve(async (req: Request) => { const fmtSafeExample = fmtCustomExample ? fmtCustomExample.slice(0, MAX_CUSTOM_EXAMPLE_LENGTH) : null; const fmtSafeInstructions = fmtCustomInstructions ? fmtCustomInstructions.slice(0, MAX_CUSTOM_INSTRUCTIONS_LENGTH) : null; + const fmtValidatedTermsRaw = formData.get('validated_terms') as string | null; + let fmtValidatedTerms: Array<{ original_term: string; corrected_term: string }> = []; + if (fmtValidatedTermsRaw) { + try { fmtValidatedTerms = JSON.parse(fmtValidatedTermsRaw); } catch { fmtValidatedTerms = []; } + } + const formatted = await formatTranscription( rawTranscription, fmtFormatType, fmtSafeExample || undefined, - fmtSafeInstructions || undefined + fmtSafeInstructions || undefined, + fmtValidatedTerms.length > 0 ? fmtValidatedTerms : undefined ); let fmtSuggestedTags: string[] = []; diff --git a/supabase/migrations/019_default_tag.sql b/supabase/migrations/019_default_tag.sql new file mode 100644 index 0000000..7667b77 --- /dev/null +++ b/supabase/migrations/019_default_tag.sql @@ -0,0 +1,3 @@ +-- Add default_tag_id preference so users can land on their most-used filter +ALTER TABLE user_preferences + ADD COLUMN IF NOT EXISTS default_tag_id uuid REFERENCES tags(id) ON DELETE SET NULL; diff --git a/supabase/migrations/020_validated_terms.sql b/supabase/migrations/020_validated_terms.sql new file mode 100644 index 0000000..990fb40 --- /dev/null +++ b/supabase/migrations/020_validated_terms.sql @@ -0,0 +1,17 @@ +-- Store user-validated term corrections from transcriptions +-- Allows the app to reuse confirmed term spellings in future prompts +CREATE TABLE IF NOT EXISTS user_validated_terms ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL, + original_term text NOT NULL, + corrected_term text NOT NULL, + created_at timestamptz DEFAULT now(), + UNIQUE(user_id, original_term) +); + +ALTER TABLE user_validated_terms ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can manage their own validated terms" + ON user_validated_terms FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); diff --git a/types/index.ts b/types/index.ts index aad2d47..d274400 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1,5 +1,7 @@ export type FormatType = 'bullet_list' | 'paragraph' | 'action_items' | 'meeting_notes' | 'custom'; +export type NoteSort = 'date_desc' | 'date_asc' | 'title_asc' | 'title_desc' | 'manual'; + export type RecordingStatus = | 'pending' // audio saved locally, not yet transcribed | 'transcribing' // transcription API call in progress @@ -8,6 +10,11 @@ export type RecordingStatus = | 'formatted' // formattedTitle + formattedText populated | 'saved'; // note created in Supabase, noteId populated +export interface UncertainTerm { + original: string; + suggestion: string | null; +} + export interface Recording { id: string; // uuid v4, generated on device localUri: string; // absolute path in documentDirectory/recordings/ @@ -16,6 +23,7 @@ export interface Recording { createdAt: string; // ISO 8601 timestamp status: RecordingStatus; rawTranscription?: string; // set after Transcribe step + uncertainTerms?: UncertainTerm[]; // set after Transcribe step (acronym validation) formattedTitle?: string; // set after Format step formattedText?: string; // set after Format step formatType?: FormatType; // set when Format is triggered @@ -47,6 +55,7 @@ export interface UserPreferences { custom_example: string | null; custom_instructions: string | null; autotagging_enabled: boolean; + default_tag_id: string | null; updated_at: string; is_admin?: boolean; }