From 72ea59de0b9548eb818893504ac8644c5df59c80 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 5 May 2026 20:27:12 +0200 Subject: [PATCH] feat: add mobile workflow affordances --- apps/mobile/src/MobileAiPanel.tsx | 194 +++++++++++++ apps/mobile/src/MobileApp.tsx | 58 +++- apps/mobile/src/MobileEditorBreadcrumb.tsx | 7 +- apps/mobile/src/MobilePropertiesPanel.tsx | 265 +++++++++++++++++- apps/mobile/src/MobileRawEditor.tsx | 33 ++- apps/mobile/src/NamedIcon.tsx | 6 +- apps/mobile/src/demoData.ts | 27 ++ apps/mobile/src/mobileAiClient.test.ts | 53 ++++ apps/mobile/src/mobileAiClient.ts | 45 +++ apps/mobile/src/mobileNoteFrontmatter.test.ts | 25 ++ apps/mobile/src/mobileNoteFrontmatter.ts | 83 +++++- .../src/mobileNoteFrontmatterWrite.test.ts | 28 ++ apps/mobile/src/mobileNoteFrontmatterWrite.ts | 60 +++- apps/mobile/src/mobileNoteProjection.ts | 12 + apps/mobile/src/mobileNoteProperties.test.ts | 7 + apps/mobile/src/mobileNoteProperties.ts | 12 +- apps/mobile/src/mobileRawNoteProjection.ts | 12 +- .../mobile/src/mobileShortcutCommands.test.ts | 1 + apps/mobile/src/mobileShortcutCommands.ts | 2 + .../src/mobileSidebarNavigation.test.ts | 28 +- apps/mobile/src/mobileSidebarNavigation.ts | 46 ++- apps/mobile/src/mobileTypeAppearance.ts | 48 ++++ apps/mobile/src/mobileVaultRepository.ts | 4 + apps/mobile/src/mobileVaultRuntime.test.ts | 4 + apps/mobile/src/mobileViewFilters.ts | 177 ++++++++++++ .../src/mobileWikilinkAutocomplete.test.ts | 52 ++++ apps/mobile/src/mobileWikilinkAutocomplete.ts | 71 +++++ apps/mobile/src/styles.ts | 8 + apps/mobile/src/styles/aiStyles.ts | 50 ++++ .../mobile/src/styles/customPropertyStyles.ts | 42 +++ .../src/styles/rawEditorSuggestionStyles.ts | 33 +++ .../src/styles/relationshipEditingStyles.ts | 44 +++ apps/mobile/src/styles/relationshipStyles.ts | 9 + docs/MOBILE_PROGRESS.md | 26 +- 34 files changed, 1519 insertions(+), 53 deletions(-) create mode 100644 apps/mobile/src/MobileAiPanel.tsx create mode 100644 apps/mobile/src/mobileAiClient.test.ts create mode 100644 apps/mobile/src/mobileAiClient.ts create mode 100644 apps/mobile/src/mobileTypeAppearance.ts create mode 100644 apps/mobile/src/mobileViewFilters.ts create mode 100644 apps/mobile/src/mobileWikilinkAutocomplete.test.ts create mode 100644 apps/mobile/src/mobileWikilinkAutocomplete.ts create mode 100644 apps/mobile/src/styles/aiStyles.ts create mode 100644 apps/mobile/src/styles/customPropertyStyles.ts create mode 100644 apps/mobile/src/styles/rawEditorSuggestionStyles.ts create mode 100644 apps/mobile/src/styles/relationshipEditingStyles.ts diff --git a/apps/mobile/src/MobileAiPanel.tsx b/apps/mobile/src/MobileAiPanel.tsx new file mode 100644 index 00000000..7385b964 --- /dev/null +++ b/apps/mobile/src/MobileAiPanel.tsx @@ -0,0 +1,194 @@ +import { CaretLeft, PaperPlaneTilt } from 'phosphor-react-native' +import { useState } from 'react' +import { Pressable, ScrollView, Text, TextInput, View } from 'react-native' +import type { MobileNote } from './mobileNoteProjection' +import { sendMobileAiRequest } from './mobileAiClient' +import { styles } from './styles' +import { colors } from './theme' + +export function MobileAiPanel({ + note, + onClose, +}: { + note: MobileNote + onClose?: () => void +}) { + const [apiKey, setApiKey] = useState('') + const [baseUrl, setBaseUrl] = useState('https://api.openai.com/v1') + const [failed, setFailed] = useState(false) + const [isSending, setIsSending] = useState(false) + const [model, setModel] = useState('') + const [prompt, setPrompt] = useState('') + const [response, setResponse] = useState('') + + const canSend = apiKey.trim().length > 0 && baseUrl.trim().length > 0 && model.trim().length > 0 && prompt.trim().length > 0 + const sendPrompt = () => { + setFailed(false) + setIsSending(true) + void sendMobileAiRequest({ apiKey, baseUrl, model, note, prompt }) + .then(setResponse) + .catch(() => setFailed(true)) + .finally(() => setIsSending(false)) + } + + return ( + + + + Model + + + + + + ) +} + +function AiToolbar({ onClose }: { onClose?: () => void }) { + return ( + + AI + + {onClose ? ( + [styles.iconButton, pressed ? styles.pressed : null]}> + + + ) : null} + + ) +} + +function AiModelFields({ + apiKey, + baseUrl, + model, + onChangeApiKey, + onChangeBaseUrl, + onChangeModel, +}: { + apiKey: string + baseUrl: string + model: string + onChangeApiKey: (value: string) => void + onChangeBaseUrl: (value: string) => void + onChangeModel: (value: string) => void +}) { + return ( + <> + + + + + ) +} + +function AiPromptComposer({ + canSend, + isSending, + note, + onChangePrompt, + onSend, + prompt, +}: { + canSend: boolean + isSending: boolean + note: MobileNote + onChangePrompt: (value: string) => void + onSend: () => void + prompt: string +}) { + return ( + <> + Prompt + + + + ) +} + +function AiInput({ + onChangeText, + placeholder, + secureTextEntry = false, + value, +}: { + onChangeText: (value: string) => void + placeholder: string + secureTextEntry?: boolean + value: string +}) { + return ( + + ) +} + +function AiSendButton({ + canSend, + isSending, + onSend, +}: { + canSend: boolean + isSending: boolean + onSend: () => void +}) { + return ( + [ + styles.aiSendButton, + !canSend || isSending ? styles.composeButtonDisabled : null, + pressed ? styles.pressed : null, + ]} + > + + {isSending ? 'Sending' : 'Send'} + + ) +} + +function AiResult({ + failed, + response, +}: { + failed: boolean + response: string +}) { + return ( + <> + {failed ? AI request failed. : null} + {response ? {response} : null} + + ) +} diff --git a/apps/mobile/src/MobileApp.tsx b/apps/mobile/src/MobileApp.tsx index 9ddeb8fe..7037b654 100644 --- a/apps/mobile/src/MobileApp.tsx +++ b/apps/mobile/src/MobileApp.tsx @@ -15,6 +15,8 @@ import { List, MagnifyingGlass, PencilSimple, + Robot, + Star, Trash, } from 'phosphor-react-native' import { MobileNote, notes as fallbackNotes } from './demoData' @@ -48,6 +50,7 @@ import { styles } from './styles' import { colors } from './theme' import { MobileEditorBreadcrumb } from './MobileEditorBreadcrumb' import { MobilePropertiesPanel } from './MobilePropertiesPanel' +import { MobileAiPanel } from './MobileAiPanel' import { MobileVaultManagementCard } from './MobileVaultManagementCard' import { MobileVaultRemotePrompt } from './MobileVaultRemotePrompt' import { useMobileNoteCreateFlow } from './useMobileNoteCreateFlow' @@ -78,6 +81,7 @@ import { mobileSidebarTitle, type MobileSidebarSelection, } from './mobileSidebarNavigation' +import { mobileTypeAppearance } from './mobileTypeAppearance' export function MobileApp() { const { width } = useWindowDimensions() @@ -92,6 +96,7 @@ export function MobileApp() { const [availableNotes, setAvailableNotes] = useState(fallbackNotes) const [compactNavigation, setCompactNavigation] = useState(() => createCompactNavigationState(fallbackNotes[0].id)) const [editorModeByNoteId, setEditorModeByNoteId] = useState>({}) + const [rightPanel, setRightPanel] = useState<'ai' | 'properties'>('properties') const [sidebarSelection, setSidebarSelection] = useState(defaultMobileSidebarSelection) const [saveStateByNoteId, setSaveStateByNoteId] = useState>({}) const sidebarSections = useMemo(() => createMobileSidebarSections(availableNotes), [availableNotes]) @@ -215,6 +220,12 @@ export function MobileApp() { setAvailableNotes((notes) => notes.map((note) => (note.id === selectedNote.id ? { ...note, archived } : note))) propertiesFlow.saveProperties({ archived }) }, [propertiesFlow, selectedNote.archived, selectedNote.id]) + const toggleSelectedFavorite = useCallback(() => { + const favorite = !selectedNote.favorite + const favoriteIndex = favorite ? availableNotes.filter((note) => note.favorite).length : null + setAvailableNotes((notes) => notes.map((note) => (note.id === selectedNote.id ? { ...note, favorite, favoriteIndex } : note))) + propertiesFlow.saveProperties({ favorite, favoriteIndex }) + }, [availableNotes, propertiesFlow, selectedNote.favorite, selectedNote.id]) return ( @@ -243,15 +254,19 @@ export function MobileApp() { /> setRightPanel('ai')} + onOpenProperties={() => setRightPanel('properties')} onRawMarkdownChange={saveRawMarkdown} onToggleArchive={toggleSelectedArchive} onToggleEditorMode={toggleEditorMode} + onToggleFavorite={toggleSelectedFavorite} /> - {showsProperties ? ( + {showsProperties && rightPanel === 'properties' ? ( ) : null} + {showsProperties && rightPanel === 'ai' ? : null} ) : ( void onDeleteNote?: () => void onDraftChange: (draft: MobileEditorDraft) => void + onOpenAi?: () => void onRawMarkdownChange: (markdown: string) => void onCreateNote: () => void onGitSyncAction: () => void @@ -340,6 +358,7 @@ type CompactShellProps = { onSelectSidebar: (selection: MobileSidebarSelection) => void onToggleArchive: () => void onToggleEditorMode: () => void + onToggleFavorite: () => void propertiesFailed: boolean isSavingProperties: boolean selectedNoteId: string @@ -384,15 +403,18 @@ function CompactEditorPanel(props: CompactShellProps) { props.onNavigate({ type: 'backToList' })} onOpenProperties={() => props.onNavigate({ type: 'openProperties' })} onToggleArchive={props.onToggleArchive} onToggleEditorMode={props.onToggleEditorMode} + onToggleFavorite={props.onToggleFavorite} /> ) @@ -501,7 +523,9 @@ function SidebarPanel({ } function sidebarItemKey(selection: MobileSidebarSelection) { - return selection.kind === 'type' ? `type:${selection.type}` : `library:${selection.id}` + if (selection.kind === 'type') return `type:${selection.type}` + if (selection.kind === 'view') return `view:${selection.id}` + return `library:${selection.id}` } function NoteListPanel({ @@ -556,6 +580,7 @@ function NoteListPanel({ > {item.title} + {item.favorite ? : null} {item.snippet} @@ -564,6 +589,7 @@ function NoteListPanel({ Created {item.date} + {item.tags.slice(0, 2).map((tag) => )} @@ -613,26 +639,32 @@ function selectLoadedNote( function EditorPanel({ editorMode, + notes, note, saveState, onDeleteNote, onDraftChange, + onOpenAi, onBack, onOpenProperties, onRawMarkdownChange, onToggleArchive, onToggleEditorMode, + onToggleFavorite, }: { editorMode: 'raw' | 'rich' + notes: MobileNote[] note: MobileNote saveState?: MobileEditorSaveState onDeleteNote?: () => void onDraftChange?: (draft: MobileEditorDraft) => void + onOpenAi?: () => void onBack?: () => void onOpenProperties?: () => void onRawMarkdownChange: (markdown: string) => void onToggleArchive: () => void onToggleEditorMode: () => void + onToggleFavorite: () => void }) { return ( @@ -643,14 +675,16 @@ function EditorPanel({ note={note} saveState={saveState ?? idleMobileEditorSaveState} onToggleArchive={onToggleArchive} + onToggleFavorite={onToggleFavorite} onToggleRawMode={onToggleEditorMode} /> {onOpenProperties ? } onPress={onOpenProperties} /> : null} + {onOpenAi ? } onPress={onOpenAi} /> : null} {onDeleteNote ? } onPress={onDeleteNote} /> : null} } /> {editorMode === 'raw' - ? + ? : } ) @@ -671,3 +705,21 @@ function IconButton({ icon, onPress }: { icon: React.ReactNode; onPress?: () => function Tag({ label }: { label: string }) { return {label} } + +function TypeChip({ type }: { type: string }) { + const appearance = mobileTypeAppearance(type) + return ( + + {type} + + ) +} diff --git a/apps/mobile/src/MobileEditorBreadcrumb.tsx b/apps/mobile/src/MobileEditorBreadcrumb.tsx index 6a8ca114..25a91d60 100644 --- a/apps/mobile/src/MobileEditorBreadcrumb.tsx +++ b/apps/mobile/src/MobileEditorBreadcrumb.tsx @@ -1,4 +1,4 @@ -import { Archive, Code, PencilSimpleLine, Tray } from 'phosphor-react-native' +import { Archive, Code, PencilSimpleLine, Star, Tray } from 'phosphor-react-native' import type { ReactNode } from 'react' import { Pressable, Text, View } from 'react-native' import type { MobileNote } from './demoData' @@ -10,12 +10,14 @@ export function MobileEditorBreadcrumb({ isRawMode, note, onToggleArchive, + onToggleFavorite, onToggleRawMode, saveState, }: { isRawMode: boolean note: MobileNote onToggleArchive: () => void + onToggleFavorite: () => void onToggleRawMode: () => void saveState: MobileEditorSaveState }) { @@ -31,6 +33,9 @@ export function MobileEditorBreadcrumb({ + + + diff --git a/apps/mobile/src/MobilePropertiesPanel.tsx b/apps/mobile/src/MobilePropertiesPanel.tsx index cbe48135..86bc5e4c 100644 --- a/apps/mobile/src/MobilePropertiesPanel.tsx +++ b/apps/mobile/src/MobilePropertiesPanel.tsx @@ -1,10 +1,12 @@ -import { CaretLeft } from 'phosphor-react-native' +import { CaretLeft, Plus, X } from 'phosphor-react-native' import { useState } from 'react' -import { Pressable, ScrollView, Text, View } from 'react-native' +import { Pressable, ScrollView, Text, TextInput, View } from 'react-native' import type { MobileNote } from './demoData' import { MobileEditablePropertyPickers } from './MobileEditablePropertyPickers' import type { MobileNotePropertyPatch } from './mobileNoteProperties' import { nextMobilePropertyPicker, type MobilePropertyPickerKey } from './mobilePropertyPicker' +import { mobileNoteSuggestions } from './mobileWikilinkAutocomplete' +import { mobileRelationshipAppearance } from './mobileTypeAppearance' import { styles } from './styles' import { colors } from './theme' @@ -42,9 +44,46 @@ export function MobilePropertiesPanel({ onChangeProperties={onChangeProperties} onSelectPicker={selectPicker} /> - - - + onChangeProperties?.({ belongsTo })} + onOpenNote={onOpenNote} + /> + onChangeProperties?.({ relatedTo })} + onOpenNote={onOpenNote} + /> + onChangeProperties?.({ has })} + onOpenNote={onOpenNote} + /> + {Object.entries(note.relationships).map(([key, targets]) => ( + onChangeProperties?.({ + relationships: { ...note.relationships, [key]: nextTargets }, + removedRelationshipKeys: nextTargets.length === 0 ? [key] : undefined, + })} + onOpenNote={onOpenNote} + /> + ))} + onChangeProperties?.({ relationships: { ...note.relationships, [key]: [] } })} + /> + @@ -59,32 +98,198 @@ export function MobilePropertiesPanel({ function RelationshipGroup({ label, notes, + onChangeTargets, onOpenNote, targets, + writableTargets = targets, }: { label: string notes: MobileNote[] + onChangeTargets?: (targets: string[]) => void onOpenNote?: (noteId: string) => void targets: string[] + writableTargets?: string[] }) { + const [query, setQuery] = useState('') + const [isAdding, setIsAdding] = useState(false) const uniqueTargets = [...new Set(targets)] - if (uniqueTargets.length === 0) { - return null + const suggestions = isAdding ? mobileNoteSuggestions({ notes, query }) : [] + const addTarget = (target: string) => { + onChangeTargets?.([...writableTargets, target]) + setQuery('') + setIsAdding(false) } return ( - {label} + setIsAdding(true)} /> {uniqueTargets.map((target) => ( onChangeTargets(writableTargets.filter((item) => item !== target)) : undefined} target={target} onOpenNote={onOpenNote} /> ))} + {isAdding ? : null} + + ) +} + +function RelationshipHeader({ + canAdd, + label, + onAdd, +}: { + canAdd: boolean + label: string + onAdd: () => void +}) { + return ( + + {label} + {canAdd ? ( + [styles.relationshipAddButton, pressed ? styles.pressed : null]}> + + + ) : null} + + ) +} + +function RelationshipAddBox({ + onAddTarget, + onChangeQuery, + query, + suggestions, +}: { + onAddTarget: (target: string) => void + onChangeQuery: (query: string) => void + query: string + suggestions: MobileNote[] +}) { + return ( + + { + if (query.trim().length > 0) { + onAddTarget(query.trim()) + } + }} + placeholder="Add note" + placeholderTextColor={colors.mutedText} + style={styles.relationshipInput} + value={query} + /> + {suggestions.map((suggestion) => ( + onAddTarget(suggestion.id)} + style={({ pressed }) => [styles.relationshipSuggestion, pressed ? styles.pressed : null]} + > + {suggestion.title} + + ))} + + ) +} + +function AddRelationshipGroup({ + note, + onAdd, +}: { + note: MobileNote + onAdd: (key: string) => void +}) { + const [name, setName] = useState('') + + return ( + + { + const key = relationshipKeyFromLabel(name) + if (key && !note.relationships[key]) { + onAdd(key) + setName('') + } + }} + placeholder="+ Add relationship" + placeholderTextColor={colors.mutedText} + style={styles.relationshipInput} + value={name} + /> + + ) +} + +function CustomProperties({ + note, + onChangeProperties, +}: { + note: MobileNote + onChangeProperties?: (patch: MobileNotePropertyPatch) => void +}) { + const [draftKey, setDraftKey] = useState('') + const [draftValue, setDraftValue] = useState('') + const entries = Object.entries(note.customProperties) + + return ( + + Custom properties + {entries.map(([key, value]) => ( + + {formatRelationshipLabel(key)} + onChangeProperties?.({ customProperties: { ...note.customProperties, [key]: nextValue } })} + style={styles.customPropertyValue} + value={value} + /> + { + const rest = customPropertiesWithoutKey({ key, properties: note.customProperties }) + onChangeProperties?.({ customProperties: rest, removedCustomPropertyKeys: [key] }) + }} + style={({ pressed }) => [styles.relationshipRemoveButton, pressed ? styles.pressed : null]} + > + + + + ))} + + + { + const key = relationshipKeyFromLabel(draftKey) + if (key && draftValue.trim().length > 0) { + onChangeProperties?.({ customProperties: { ...note.customProperties, [key]: draftValue.trim() } }) + setDraftKey('') + setDraftValue('') + } + }} + placeholder="Value" + placeholderTextColor={colors.mutedText} + style={styles.customPropertyAddInput} + value={draftValue} + /> + ) } @@ -120,23 +325,41 @@ function BacklinkGroup({ function RelationshipChip({ note, onOpenNote, + onRemove, target, }: { - note?: { id: string; title: string } + note?: { id: string; title: string; type?: string } onOpenNote?: (noteId: string) => void + onRemove?: () => void target: string }) { + const appearance = mobileRelationshipAppearance(note?.type) const chip = {note?.title ?? target} + const content = ( + <> + {chip} + {onRemove ? ( + + + + ) : null} + + ) + return note && onOpenNote ? ( onOpenNote(note.id)} - style={({ pressed }) => [styles.relationshipChip, pressed ? styles.pressed : null]} + style={({ pressed }) => [ + styles.relationshipChip, + { backgroundColor: appearance.backgroundColor, borderColor: appearance.borderColor }, + pressed ? styles.pressed : null, + ]} > - {chip} + {content} ) : ( - {chip} + {content} ) } @@ -155,6 +378,24 @@ function normalizeRelationshipTarget(target: string) { return target.trim().toLowerCase() } +function formatRelationshipLabel(key: string) { + return key.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase()) +} + +function relationshipKeyFromLabel(label: string) { + return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') +} + +function customPropertiesWithoutKey({ + key, + properties, +}: { + key: string + properties: Record +}) { + return Object.fromEntries(Object.entries(properties).filter(([candidate]) => candidate !== key)) +} + function PanelToolbar({ onClose }: { onClose?: () => void }) { return ( diff --git a/apps/mobile/src/MobileRawEditor.tsx b/apps/mobile/src/MobileRawEditor.tsx index 3d5553dc..9a6c7b2d 100644 --- a/apps/mobile/src/MobileRawEditor.tsx +++ b/apps/mobile/src/MobileRawEditor.tsx @@ -1,16 +1,25 @@ -import { useState } from 'react' -import { TextInput, View } from 'react-native' +import { useMemo, useState } from 'react' +import { Pressable, Text, TextInput, View } from 'react-native' import type { MobileNote } from './mobileNoteProjection' +import { activeMobileWikilinkQuery, insertMobileWikilink, mobileNoteSuggestions } from './mobileWikilinkAutocomplete' import { styles } from './styles' export function MobileRawEditor({ + notes, note, onRawMarkdownChange, }: { + notes: MobileNote[] note: MobileNote onRawMarkdownChange: (markdown: string) => void }) { const [draft, setDraft] = useState(note.content) + const [cursor, setCursor] = useState(note.content.length) + const activeQuery = useMemo(() => activeMobileWikilinkQuery({ cursor, markdown: draft }), [cursor, draft]) + const suggestions = useMemo( + () => activeQuery ? mobileNoteSuggestions({ excludeNoteId: note.id, notes, query: activeQuery.query }) : [], + [activeQuery, note.id, notes], + ) return ( @@ -22,12 +31,32 @@ export function MobileRawEditor({ setDraft(markdown) onRawMarkdownChange(markdown) }} + onSelectionChange={(event) => setCursor(event.nativeEvent.selection.start)} scrollEnabled spellCheck={false} style={styles.rawEditorInput} textAlignVertical="top" value={draft} /> + {suggestions.length > 0 && activeQuery ? ( + + {suggestions.map((suggestion) => ( + { + const nextDraft = insertMobileWikilink({ markdown: draft, note: suggestion, query: activeQuery }) + setDraft(nextDraft) + setCursor(activeQuery.start + suggestion.id.length + suggestion.title.length + 5) + onRawMarkdownChange(nextDraft) + }} + style={({ pressed }) => [styles.rawEditorSuggestion, pressed ? styles.pressed : null]} + > + {suggestion.title} + {suggestion.id} + + ))} + + ) : null} ) } diff --git a/apps/mobile/src/NamedIcon.tsx b/apps/mobile/src/NamedIcon.tsx index 8cf50d23..b6f193cf 100644 --- a/apps/mobile/src/NamedIcon.tsx +++ b/apps/mobile/src/NamedIcon.tsx @@ -6,12 +6,14 @@ import { Flag, GitBranch, PenNib, + Robot, + Star, Sun, Tray, Wrench, } from 'phosphor-react-native' -export type IconName = 'archive' | 'books' | 'drop' | 'file-text' | 'flag' | 'git-branch' | 'pen-nib' | 'sun' | 'tray' | 'wrench' +export type IconName = 'archive' | 'books' | 'drop' | 'file-text' | 'flag' | 'git-branch' | 'pen-nib' | 'robot' | 'star' | 'sun' | 'tray' | 'wrench' const iconByName = { archive: Archive, @@ -21,6 +23,8 @@ const iconByName = { flag: Flag, 'git-branch': GitBranch, 'pen-nib': PenNib, + robot: Robot, + star: Star, sun: Sun, tray: Tray, wrench: Wrench, diff --git a/apps/mobile/src/demoData.ts b/apps/mobile/src/demoData.ts index 0fc015ee..75e065d2 100644 --- a/apps/mobile/src/demoData.ts +++ b/apps/mobile/src/demoData.ts @@ -7,6 +7,9 @@ export const demoNoteSources: MobileNoteSource[] = [ { archived: false, belongsTo: ['Tolaria MVP'], + customProperties: { review_stage: 'Draft outline' }, + favorite: true, + favoriteIndex: 0, has: ['workflow-orchestration-checklist'], id: 'workflow', type: 'Essay', @@ -15,16 +18,22 @@ export const demoNoteSources: MobileNoteSource[] = [ modified: '6h ago', filename: 'workflow.md', relatedTo: ['release', 'mobile-roadmap'], + relationships: { people: ['Malte Ubl'], topics: ['workflow orchestration'] }, tags: ['Design Inspiration', 'Tolaria MVP'], content: [ '---', 'title: Workflow Orchestration Essay', + '_favorite: true', + '_favorite_index: 0', 'type: Essay', 'status: Draft', 'tags: [Design Inspiration, Tolaria MVP]', 'belongs_to: [Tolaria MVP]', 'related_to: [release, mobile-roadmap]', 'has: [workflow-orchestration-checklist]', + 'people: [Malte Ubl]', + 'review_stage: Draft outline', + 'topics: [workflow orchestration]', '---', '', '# Workflow Orchestration Essay', @@ -40,6 +49,9 @@ export const demoNoteSources: MobileNoteSource[] = [ { archived: false, belongsTo: ['Tolaria MVP'], + customProperties: { platform: 'iPad first' }, + favorite: true, + favoriteIndex: 1, has: ['workflow'], id: 'mobile-roadmap', type: 'Project', @@ -48,16 +60,21 @@ export const demoNoteSources: MobileNoteSource[] = [ modified: '1h ago', filename: 'mobile-roadmap.md', relatedTo: ['release'], + relationships: { depends_on: ['workflow-orchestration-checklist'] }, tags: ['Tolaria MVP', 'mobile'], content: [ '---', 'title: Mobile Roadmap', + '_favorite: true', + '_favorite_index: 1', 'type: Project', 'status: Active', 'tags: [Tolaria MVP, mobile]', 'belongs_to: [Tolaria MVP]', 'related_to: [release]', 'has: [workflow]', + 'depends_on: [workflow-orchestration-checklist]', + 'platform: iPad first', '---', '', '# Mobile Roadmap', @@ -72,6 +89,7 @@ export const demoNoteSources: MobileNoteSource[] = [ { archived: false, belongsTo: ['workflow'], + customProperties: {}, has: [], id: 'workflow-orchestration-checklist', type: 'Note', @@ -80,6 +98,7 @@ export const demoNoteSources: MobileNoteSource[] = [ modified: '2h ago', filename: 'workflow-orchestration-checklist.md', relatedTo: ['workflow'], + relationships: {}, tags: ['mobile'], content: [ '---', @@ -101,6 +120,7 @@ export const demoNoteSources: MobileNoteSource[] = [ { archived: false, belongsTo: ['Tolaria MVP'], + customProperties: {}, has: [], id: 'release', type: 'Release Note', @@ -109,6 +129,7 @@ export const demoNoteSources: MobileNoteSource[] = [ modified: '12h ago', filename: 'release.md', relatedTo: ['workflow', 'mobile-roadmap'], + relationships: {}, tags: ['Release', 'Stable'], content: [ '---', @@ -130,6 +151,7 @@ export const demoNoteSources: MobileNoteSource[] = [ { archived: false, belongsTo: ['Tolaria MVP'], + customProperties: {}, has: ['resources'], id: 'migration', type: 'Project', @@ -138,6 +160,7 @@ export const demoNoteSources: MobileNoteSource[] = [ modified: '1d ago', filename: 'migration.md', relatedTo: ['mobile-roadmap'], + relationships: {}, tags: ['Project', 'Resources'], content: [ '---', @@ -158,6 +181,7 @@ export const demoNoteSources: MobileNoteSource[] = [ { archived: false, belongsTo: ['migration'], + customProperties: {}, has: [], id: 'resources', type: 'Resource', @@ -166,6 +190,7 @@ export const demoNoteSources: MobileNoteSource[] = [ modified: '3d ago', filename: 'resources.md', relatedTo: ['migration', 'mobile-roadmap'], + relationships: {}, tags: ['Resources', 'mobile'], content: [ '---', @@ -185,6 +210,7 @@ export const demoNoteSources: MobileNoteSource[] = [ { archived: true, belongsTo: [], + customProperties: {}, has: [], id: 'old-mobile-spike', type: 'Note', @@ -193,6 +219,7 @@ export const demoNoteSources: MobileNoteSource[] = [ modified: 'last month', filename: 'old-mobile-spike.md', relatedTo: ['mobile-roadmap'], + relationships: {}, tags: ['mobile'], content: [ '---', diff --git a/apps/mobile/src/mobileAiClient.test.ts b/apps/mobile/src/mobileAiClient.test.ts new file mode 100644 index 00000000..07084242 --- /dev/null +++ b/apps/mobile/src/mobileAiClient.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { sendMobileAiRequest } from './mobileAiClient' +import type { MobileNote } from './mobileNoteProjection' + +describe('mobile AI client', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('sends an OpenAI-compatible chat completion request with note context', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + json: async () => ({ choices: [{ message: { content: 'Answer' } }] }), + ok: true, + } as Response) + + await expect(sendMobileAiRequest({ + apiKey: 'key', + baseUrl: 'https://api.example.com/v1/', + model: 'model', + note: note(), + prompt: 'Summarize', + })).resolves.toBe('Answer') + + expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/v1/chat/completions', expect.objectContaining({ + method: 'POST', + })) + }) +}) + +function note(): MobileNote { + return { + archived: false, + backlinks: [], + belongsTo: [], + content: '# Workflow\n\nBody', + customProperties: {}, + date: '', + favorite: false, + favoriteIndex: null, + has: [], + icon: 'file-text', + id: 'workflow', + modified: '', + outgoingLinks: [], + relatedTo: [], + relationships: {}, + snippet: '', + tags: [], + title: 'Workflow', + type: 'Note', + words: 1, + } +} diff --git a/apps/mobile/src/mobileAiClient.ts b/apps/mobile/src/mobileAiClient.ts new file mode 100644 index 00000000..374d4804 --- /dev/null +++ b/apps/mobile/src/mobileAiClient.ts @@ -0,0 +1,45 @@ +import type { MobileNote } from './mobileNoteProjection' + +export type MobileAiRequest = { + apiKey: string + baseUrl: string + model: string + note: MobileNote + prompt: string +} + +export async function sendMobileAiRequest(request: MobileAiRequest) { + const response = await fetch(`${request.baseUrl.replace(/\/$/, '')}/chat/completions`, { + body: JSON.stringify({ + messages: [ + { + content: [ + 'You are helping with a Tolaria markdown note.', + 'Use concise answers and preserve [[wikilink]] syntax when referencing notes.', + `Active note: ${request.note.title}`, + request.note.content, + ].join('\n\n'), + role: 'system', + }, + { content: request.prompt, role: 'user' }, + ], + model: request.model, + }), + headers: { + Authorization: `Bearer ${request.apiKey}`, + 'Content-Type': 'application/json', + }, + method: 'POST', + }) + + if (!response.ok) { + throw new Error(`AI request failed with ${response.status}`) + } + + return extractAssistantMessage(await response.json()) +} + +function extractAssistantMessage(payload: unknown) { + const content = (payload as { choices?: Array<{ message?: { content?: unknown } }> }).choices?.[0]?.message?.content + return typeof content === 'string' ? content : '' +} diff --git a/apps/mobile/src/mobileNoteFrontmatter.test.ts b/apps/mobile/src/mobileNoteFrontmatter.test.ts index f4fd39c2..b718f9f2 100644 --- a/apps/mobile/src/mobileNoteFrontmatter.test.ts +++ b/apps/mobile/src/mobileNoteFrontmatter.test.ts @@ -18,10 +18,14 @@ describe('mobile note frontmatter', () => { ].join('\n'))).toEqual({ archived: true, belongsTo: ['Tolaria MVP'], + customProperties: {}, date: '2026-05-05', + favorite: undefined, + favoriteIndex: undefined, has: ['release'], icon: 'pen-nib', relatedTo: ['workflow'], + relationships: {}, status: 'Active', tags: [], type: 'Essay', @@ -32,10 +36,14 @@ describe('mobile note frontmatter', () => { expect(readMobileNoteFrontmatter('---\ntags: [Tolaria MVP, "mobile"]\n---\n# Note')).toEqual({ archived: undefined, belongsTo: [], + customProperties: {}, date: undefined, + favorite: undefined, + favoriteIndex: undefined, has: [], icon: undefined, relatedTo: [], + relationships: {}, status: undefined, tags: ['Tolaria MVP', 'mobile'], type: undefined, @@ -46,10 +54,14 @@ describe('mobile note frontmatter', () => { expect(readMobileNoteFrontmatter('# Note')).toEqual({ archived: undefined, belongsTo: [], + customProperties: {}, date: undefined, + favorite: undefined, + favoriteIndex: undefined, has: [], icon: undefined, relatedTo: [], + relationships: {}, status: undefined, tags: [], type: undefined, @@ -60,13 +72,26 @@ describe('mobile note frontmatter', () => { expect(readMobileNoteFrontmatter('---\narchived: false\nrelated_to: workflow\ntags: mobile\n---\n# Note')).toEqual({ archived: undefined, belongsTo: [], + customProperties: {}, date: undefined, + favorite: undefined, + favoriteIndex: undefined, has: [], icon: undefined, relatedTo: [], + relationships: {}, status: undefined, tags: [], type: undefined, }) }) + + it('reads favorites, custom scalar properties, and custom relationships', () => { + expect(readMobileNoteFrontmatter('---\n_favorite: true\n_favorite_index: 2\npeople: [Luca]\nreview_stage: Draft\n---\n# Note')).toMatchObject({ + customProperties: { review_stage: 'Draft' }, + favorite: true, + favoriteIndex: 2, + relationships: { people: ['Luca'] }, + }) + }) }) diff --git a/apps/mobile/src/mobileNoteFrontmatter.ts b/apps/mobile/src/mobileNoteFrontmatter.ts index 1429c290..aa13ff9f 100644 --- a/apps/mobile/src/mobileNoteFrontmatter.ts +++ b/apps/mobile/src/mobileNoteFrontmatter.ts @@ -3,15 +3,38 @@ import { splitFrontmatter } from '@tolaria/markdown' export type MobileNoteFrontmatter = { archived?: boolean belongsTo: string[] + customProperties: Record date?: string + favorite?: boolean + favoriteIndex?: number has: string[] icon?: string relatedTo: string[] + relationships: Record status?: string tags: string[] type?: string } +type FrontmatterEntry = readonly [FrontmatterKey, FrontmatterValue] +type FrontmatterKey = string +type FrontmatterLine = string +type FrontmatterValue = string + +const supportedKeys = new Set([ + '_favorite', + '_favorite_index', + 'archived', + 'belongs_to', + 'date', + 'has', + 'icon', + 'related_to', + 'status', + 'tags', + 'type', +]) + export function readMobileNoteFrontmatter(content: string): MobileNoteFrontmatter { const [frontmatter] = splitFrontmatter(content) const lines = frontmatterLines(frontmatter) @@ -19,10 +42,14 @@ export function readMobileNoteFrontmatter(content: string): MobileNoteFrontmatte return { archived: readField({ key: 'archived', lines, parse: parseBooleanField }), belongsTo: readField({ key: 'belongs_to', lines, parse: parseListField }), + customProperties: readCustomProperties(lines), date: readField({ key: 'date', lines, parse: parseScalarField }), + favorite: readField({ key: '_favorite', lines, parse: parseBooleanField }), + favoriteIndex: readField({ key: '_favorite_index', lines, parse: parseNumberField }), has: readField({ key: 'has', lines, parse: parseListField }), icon: readField({ key: 'icon', lines, parse: parseScalarField }), relatedTo: readField({ key: 'related_to', lines, parse: parseListField }), + relationships: readCustomRelationships(lines), status: readField({ key: 'status', lines, parse: parseScalarField }), tags: readField({ key: 'tags', lines, parse: parseListField }), type: readField({ key: 'type', lines, parse: parseScalarField }), @@ -35,37 +62,69 @@ function readField({ parse, }: { key: string - lines: string[] - parse: (value: string | undefined) => T + lines: FrontmatterLine[] + parse: (value: FrontmatterValue | undefined) => T }) { const value = readRawField({ key, lines }) return parse(value) } -function parseScalarField(value: string | undefined) { +function parseScalarField(value: FrontmatterValue | undefined) { return value && !value.startsWith('[') ? unquote(value) : undefined } -function parseBooleanField(value: string | undefined) { +function parseBooleanField(value: FrontmatterValue | undefined) { return value === 'true' ? true : undefined } -function parseListField(value: string | undefined) { +function parseNumberField(value: FrontmatterValue | undefined) { + const parsed = value ? Number(value) : Number.NaN + return Number.isFinite(parsed) ? parsed : undefined +} + +function parseListField(value: FrontmatterValue | undefined) { return value?.startsWith('[') ? readInlineList(value) : [] } +function readCustomProperties(lines: FrontmatterLine[]) { + return Object.fromEntries( + frontmatterEntries(lines) + .filter(([key, value]) => !supportedKeys.has(key) && !value.startsWith('[')) + .map(([key, value]) => [key, unquote(value)]), + ) +} + +function readCustomRelationships(lines: FrontmatterLine[]) { + return Object.fromEntries( + frontmatterEntries(lines) + .filter(([key, value]) => !supportedKeys.has(key) && value.startsWith('[')) + .map(([key, value]) => [key, readInlineList(value)]), + ) +} + +function frontmatterEntries(lines: FrontmatterLine[]): FrontmatterEntry[] { + return lines + .map((line) => { + const separator = line.indexOf(':') + return separator > 0 + ? [line.slice(0, separator).trim(), line.slice(separator + 1).trim()] as const + : null + }) + .filter(isEntry) +} + function readRawField({ key, lines, }: { - key: string - lines: string[] + key: FrontmatterKey + lines: FrontmatterLine[] }) { const prefix = `${key}:` return lines.find((line) => line.startsWith(prefix))?.slice(prefix.length).trim() } -function readInlineList(value: string) { +function readInlineList(value: FrontmatterValue) { return value .replace(/^\[|\]$/g, '') .split(',') @@ -73,13 +132,17 @@ function readInlineList(value: string) { .filter(Boolean) } -function frontmatterLines(frontmatter: string) { +function frontmatterLines(frontmatter: string): FrontmatterLine[] { return frontmatter .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line && line !== '---') } -function unquote(value: string) { +function unquote(value: FrontmatterValue) { return value.replace(/^['"]|['"]$/g, '') } + +function isEntry(value: T | null): value is T { + return value !== null +} diff --git a/apps/mobile/src/mobileNoteFrontmatterWrite.test.ts b/apps/mobile/src/mobileNoteFrontmatterWrite.test.ts index 196a0abb..c32ccbbc 100644 --- a/apps/mobile/src/mobileNoteFrontmatterWrite.test.ts +++ b/apps/mobile/src/mobileNoteFrontmatterWrite.test.ts @@ -8,16 +8,22 @@ describe('mobile note frontmatter write', () => { metadata: { archived: true, belongsTo: ['Tolaria MVP'], + customProperties: { review_stage: 'Draft' }, date: '2026-05-05', + favorite: true, + favoriteIndex: 0, has: ['Release Notes'], icon: 'pen-nib', relatedTo: ['workflow'], + relationships: { people: ['Luca'] }, status: 'Draft', tags: ['Tolaria MVP', 'mobile'], type: 'Essay', }, })).toBe([ '---', + '_favorite: true', + '_favorite_index: 0', 'archived: true', 'type: Essay', 'status: Draft', @@ -27,6 +33,8 @@ describe('mobile note frontmatter write', () => { 'related_to: [workflow]', 'has: [Release Notes]', 'tags: [Tolaria MVP, mobile]', + 'review_stage: Draft', + 'people: [Luca]', '---', '# Workflow', '', @@ -79,4 +87,24 @@ describe('mobile note frontmatter write', () => { }, })).toBe('---\nstatus: "Needs: Review"\ntags: [AI/ML, "needs, comma"]\n---\n# Workflow') }) + + it('updates dynamic metadata without duplicating old lines', () => { + expect(writeMobileNoteFrontmatter({ + content: '---\npeople: [Old]\nreview_stage: Old\n---\n# Workflow', + metadata: { + customProperties: { review_stage: 'Ready' }, + relationships: { people: ['New'] }, + }, + })).toBe('---\nreview_stage: Ready\npeople: [New]\n---\n# Workflow') + }) + + it('removes dynamic metadata when removal keys are provided', () => { + expect(writeMobileNoteFrontmatter({ + content: '---\npeople: [Old]\nreview_stage: Old\n---\n# Workflow', + metadata: { + removedCustomPropertyKeys: ['review_stage'], + removedRelationshipKeys: ['people'], + }, + })).toBe('# Workflow') + }) }) diff --git a/apps/mobile/src/mobileNoteFrontmatterWrite.ts b/apps/mobile/src/mobileNoteFrontmatterWrite.ts index 420737d0..dbae8ce5 100644 --- a/apps/mobile/src/mobileNoteFrontmatterWrite.ts +++ b/apps/mobile/src/mobileNoteFrontmatterWrite.ts @@ -3,16 +3,22 @@ import { splitFrontmatter } from '@tolaria/markdown' export type WritableMobileNoteFrontmatter = { archived?: boolean belongsTo?: string[] + customProperties?: Record date?: string + favorite?: boolean + favoriteIndex?: number | null has?: string[] icon?: string relatedTo?: string[] + removedCustomPropertyKeys?: string[] + removedRelationshipKeys?: string[] + relationships?: Record status?: string tags?: string[] type?: string } -const writableKeys = new Set(['archived', 'belongs_to', 'date', 'has', 'icon', 'related_to', 'status', 'tags', 'type']) +const writableKeys = new Set(['_favorite', '_favorite_index', 'archived', 'belongs_to', 'date', 'has', 'icon', 'related_to', 'status', 'tags', 'type']) export function writeMobileNoteFrontmatter({ content, @@ -23,7 +29,7 @@ export function writeMobileNoteFrontmatter({ }) { const [frontmatter, body] = splitFrontmatter(content) const lines = [ - ...unknownFrontmatterLines(frontmatter), + ...unknownFrontmatterLines({ frontmatter, metadata }), ...supportedFrontmatterLines(metadata), ] @@ -32,6 +38,8 @@ export function writeMobileNoteFrontmatter({ function supportedFrontmatterLines(metadata: WritableMobileNoteFrontmatter) { return [ + booleanLine({ key: '_favorite', value: metadata.favorite }), + numberLine({ key: '_favorite_index', value: metadata.favoriteIndex }), booleanLine({ key: 'archived', value: metadata.archived }), scalarLine({ key: 'type', value: metadata.type }), scalarLine({ key: 'status', value: metadata.status }), @@ -41,20 +49,38 @@ function supportedFrontmatterLines(metadata: WritableMobileNoteFrontmatter) { listLine({ key: 'related_to', values: metadata.relatedTo }), listLine({ key: 'has', values: metadata.has }), listLine({ key: 'tags', values: metadata.tags }), + ...customPropertyLines(metadata.customProperties), + ...customRelationshipLines(metadata.relationships), ].filter(isText) } -function unknownFrontmatterLines(frontmatter: string) { +function unknownFrontmatterLines({ + frontmatter, + metadata, +}: { + frontmatter: string + metadata: WritableMobileNoteFrontmatter +}) { + const dynamicKeys = new Set([ + ...Object.keys(metadata.customProperties ?? {}), + ...Object.keys(metadata.relationships ?? {}), + ...(metadata.removedCustomPropertyKeys ?? []), + ...(metadata.removedRelationshipKeys ?? []), + ]) return frontmatter .split(/\r?\n/) .map((line) => line.trim()) - .filter(isUnknownFrontmatterLine) + .filter((line) => isUnknownFrontmatterLine({ dynamicKeys, line })) } function scalarLine({ key, value }: { key: string; value: string | undefined }) { return isText(value) ? `${key}: ${yamlValue(value.trim())}` : null } +function numberLine({ key, value }: { key: string; value: number | null | undefined }) { + return typeof value === 'number' && Number.isFinite(value) ? `${key}: ${value}` : null +} + function booleanLine({ key, value }: { key: string; value: boolean | undefined }) { return value ? `${key}: true` : null } @@ -68,8 +94,30 @@ function yamlValue(value: string) { return /^[A-Za-z0-9 _/-]+$/.test(value) ? value : JSON.stringify(value) } -function isUnknownFrontmatterLine(line: string) { - return isText(line) && line !== '---' && !writableKeys.has(line.split(':', 1)[0]) +function isUnknownFrontmatterLine({ + dynamicKeys, + line, +}: { + dynamicKeys: Set + line: string +}) { + const key = line.split(':', 1)[0] + return isText(line) && line !== '---' && !writableKeys.has(key) && !dynamicKeys.has(key) +} + +function customPropertyLines(properties: Record | undefined) { + return frontmatterRecordEntries(properties).map(([key, value]) => scalarLine({ key, value })) +} + +function customRelationshipLines(relationships: Record | undefined) { + return frontmatterRecordEntries(relationships).map(([key, values]) => listLine({ key, values })) +} + +function frontmatterRecordEntries(record: Record | undefined) { + return Object.entries(record ?? {}) + .map(([key, value]) => [key.trim(), value] as const) + .filter(([key]) => isText(key) && !writableKeys.has(key)) + .sort(([left], [right]) => left.localeCompare(right)) } function isText(value: string | null | undefined): value is string { diff --git a/apps/mobile/src/mobileNoteProjection.ts b/apps/mobile/src/mobileNoteProjection.ts index b9ebf14a..14d03c61 100644 --- a/apps/mobile/src/mobileNoteProjection.ts +++ b/apps/mobile/src/mobileNoteProjection.ts @@ -8,6 +8,9 @@ export type MobileNoteRelationship = { export type MobileNoteSource = { archived?: boolean belongsTo?: string[] + customProperties?: Record + favorite?: boolean + favoriteIndex?: number | null id: string type: string has?: string[] @@ -17,6 +20,7 @@ export type MobileNoteSource = { filename: string content: string relatedTo?: string[] + relationships?: Record status?: string tags: string[] } @@ -25,9 +29,13 @@ export type MobileNote = Omit & { archived: boolean backlinks: MobileNoteRelationship[] belongsTo: string[] + customProperties: Record + favorite: boolean + favoriteIndex: number | null has: string[] outgoingLinks: string[] relatedTo: string[] + relationships: Record title: string snippet: string words: number @@ -43,6 +51,9 @@ export function projectMobileNote(source: MobileNoteSource): MobileNote { id: source.id, archived: source.archived ?? false, belongsTo: source.belongsTo ?? [], + customProperties: source.customProperties ?? {}, + favorite: source.favorite ?? false, + favoriteIndex: source.favoriteIndex ?? null, type: source.type, has: source.has ?? [], icon: source.icon, @@ -50,6 +61,7 @@ export function projectMobileNote(source: MobileNoteSource): MobileNote { modified: source.modified, content: source.content, relatedTo: source.relatedTo ?? [], + relationships: source.relationships ?? {}, status: source.status, tags: source.tags, backlinks: [], diff --git a/apps/mobile/src/mobileNoteProperties.test.ts b/apps/mobile/src/mobileNoteProperties.test.ts index fda77f7c..37ecf2f6 100644 --- a/apps/mobile/src/mobileNoteProperties.test.ts +++ b/apps/mobile/src/mobileNoteProperties.test.ts @@ -25,10 +25,17 @@ describe('createMobileNoteFrontmatterPatch', () => { })).toEqual({ archived: false, belongsTo: ['Tolaria MVP'], + customProperties: { review_stage: 'Draft outline' }, date: 'May 5, 2026', + favorite: true, + favoriteIndex: 0, has: [], icon: 'file-text', relatedTo: ['release'], + relationships: { + people: ['Malte Ubl'], + topics: ['workflow orchestration'], + }, status: 'Draft', tags: ['Tolaria'], type: 'Project', diff --git a/apps/mobile/src/mobileNoteProperties.ts b/apps/mobile/src/mobileNoteProperties.ts index 93d22c2a..aeebd629 100644 --- a/apps/mobile/src/mobileNoteProperties.ts +++ b/apps/mobile/src/mobileNoteProperties.ts @@ -15,17 +15,27 @@ export function createMobileNoteFrontmatterPatch({ note: MobileNote patch: MobileNotePropertyPatch }): WritableMobileNoteFrontmatter { - return { + const metadata: WritableMobileNoteFrontmatter = { archived: patch.archived ?? note.archived, belongsTo: patch.belongsTo ?? note.belongsTo, + customProperties: patch.customProperties ?? note.customProperties, date: patch.date ?? note.date, + favorite: patch.favorite ?? note.favorite, + favoriteIndex: patch.favoriteIndex ?? note.favoriteIndex, has: patch.has ?? note.has, icon: patch.icon ?? note.icon, relatedTo: patch.relatedTo ?? note.relatedTo, + relationships: patch.relationships ?? note.relationships, status: patch.status ?? note.status, tags: patch.tags ?? note.tags, type: patch.type ?? note.type, } + + return { + ...metadata, + ...(patch.removedCustomPropertyKeys ? { removedCustomPropertyKeys: patch.removedCustomPropertyKeys } : {}), + ...(patch.removedRelationshipKeys ? { removedRelationshipKeys: patch.removedRelationshipKeys } : {}), + } } export function isMobileNotePropertySelected({ diff --git a/apps/mobile/src/mobileRawNoteProjection.ts b/apps/mobile/src/mobileRawNoteProjection.ts index 72ef10ad..11ecf6c7 100644 --- a/apps/mobile/src/mobileRawNoteProjection.ts +++ b/apps/mobile/src/mobileRawNoteProjection.ts @@ -16,17 +16,21 @@ export function applyMobileRawNoteContent({ return { archived: metadata.archived ?? note.archived, - belongsTo: metadata.belongsTo.length > 0 ? metadata.belongsTo : note.belongsTo, + belongsTo: metadata.belongsTo, content: updatedContent, + customProperties: metadata.customProperties, date: metadata.date ?? note.date, + favorite: metadata.favorite ?? note.favorite, + favoriteIndex: metadata.favoriteIndex ?? note.favoriteIndex, filename: `${note.id}.md`, - has: metadata.has.length > 0 ? metadata.has : note.has, + has: metadata.has, icon: metadata.icon ?? note.icon, id: note.id, modified: note.id === noteId ? 'Saved now' : note.modified, - relatedTo: metadata.relatedTo.length > 0 ? metadata.relatedTo : note.relatedTo, + relatedTo: metadata.relatedTo, + relationships: metadata.relationships, status: metadata.status ?? note.status, - tags: metadata.tags.length > 0 ? metadata.tags : note.tags, + tags: metadata.tags, type: metadata.type ?? note.type, } }) diff --git a/apps/mobile/src/mobileShortcutCommands.test.ts b/apps/mobile/src/mobileShortcutCommands.test.ts index 14ce000c..75162489 100644 --- a/apps/mobile/src/mobileShortcutCommands.test.ts +++ b/apps/mobile/src/mobileShortcutCommands.test.ts @@ -16,6 +16,7 @@ describe('mobile shortcut commands', () => { expect(mobileShortcutCommandFromKeyPress({ key: '\\', metaKey: true })).toBe('editToggleRawEditor') expect(mobileShortcutCommandFromKeyPress({ key: 'i', metaKey: true, shiftKey: true })).toBe('viewToggleProperties') expect(mobileShortcutCommandFromKeyPress({ key: 'Backspace', metaKey: true })).toBe('noteDelete') + expect(mobileShortcutCommandFromKeyPress({ key: 'd', metaKey: true })).toBe('noteToggleFavorite') }) it('ignores unmodified and alternative-modified keys', () => { diff --git a/apps/mobile/src/mobileShortcutCommands.ts b/apps/mobile/src/mobileShortcutCommands.ts index 715ddd2c..3d0a34db 100644 --- a/apps/mobile/src/mobileShortcutCommands.ts +++ b/apps/mobile/src/mobileShortcutCommands.ts @@ -2,6 +2,7 @@ export type MobileShortcutCommand = | 'editToggleRawEditor' | 'fileNewNote' | 'noteDelete' + | 'noteToggleFavorite' | 'viewAll' | 'viewEditorList' | 'viewEditorOnly' @@ -30,6 +31,7 @@ export const mobileShortcutBindings: MobileShortcutBinding[] = [ { accelerator: 'CmdOrCtrl+Shift+I', command: 'viewToggleProperties' }, { accelerator: 'CmdOrCtrl+Left', command: 'viewGoBack' }, { accelerator: 'CmdOrCtrl+Backspace', command: 'noteDelete' }, + { accelerator: 'CmdOrCtrl+D', command: 'noteToggleFavorite' }, ] export function mobileShortcutCommandFromKeyPress(event: MobileShortcutKeyPress) { diff --git a/apps/mobile/src/mobileSidebarNavigation.test.ts b/apps/mobile/src/mobileSidebarNavigation.test.ts index 64946bb9..af661238 100644 --- a/apps/mobile/src/mobileSidebarNavigation.test.ts +++ b/apps/mobile/src/mobileSidebarNavigation.test.ts @@ -16,6 +16,17 @@ describe('mobile sidebar navigation', () => { expect(filterNotesForSidebarSelection({ notes, selection: { kind: 'type', type: 'Project' } }).map((item) => item.id)).toEqual(['active']) }) + it('filters favorites and saved nested views', () => { + const notes = [ + note({ favorite: true, id: 'essay', status: 'Active', tags: ['mobile'], type: 'Essay' }), + note({ id: 'draft', relatedTo: ['mobile-roadmap'], status: 'Draft', type: 'Note' }), + note({ id: 'ignored', status: 'Done', type: 'Project' }), + ] + + expect(filterNotesForSidebarSelection({ notes, selection: { kind: 'library', id: 'favorites' } }).map((item) => item.id)).toEqual(['essay']) + expect(filterNotesForSidebarSelection({ notes, selection: { kind: 'view', id: 'active-drafts' } }).map((item) => item.id)).toEqual(['draft']) + }) + it('builds sidebar sections from current local notes', () => { const sections = createMobileSidebarSections([ note({ id: 'draft', status: 'Draft', type: 'Essay' }), @@ -27,8 +38,9 @@ describe('mobile sidebar navigation', () => { ['Inbox', 1], ['All Notes', 2], ['Archive', 1], + ['Favorites', 0], ]) - expect(sections[1].items.map((item) => item.label)).toEqual(['Essays', 'Projects']) + expect(sections[2].items.map((item) => item.label)).toEqual(['Essays', 'Projects']) }) it('formats list titles for library and type selections', () => { @@ -39,13 +51,19 @@ describe('mobile sidebar navigation', () => { function note({ archived = false, + favorite = false, id, + relatedTo = [], status, + tags = [], type, }: { archived?: boolean + favorite?: boolean id: string + relatedTo?: string[] status?: string + tags?: string[] type: string }): MobileNote { return { @@ -53,16 +71,20 @@ function note({ backlinks: [], belongsTo: [], content: `# ${id}`, + customProperties: {}, date: '', + favorite, + favoriteIndex: null, has: [], icon: 'file-text', id, modified: '', outgoingLinks: [], - relatedTo: [], + relatedTo, + relationships: {}, snippet: '', status, - tags: [], + tags, title: id, type, words: 1, diff --git a/apps/mobile/src/mobileSidebarNavigation.ts b/apps/mobile/src/mobileSidebarNavigation.ts index 609da197..ee3dd8ec 100644 --- a/apps/mobile/src/mobileSidebarNavigation.ts +++ b/apps/mobile/src/mobileSidebarNavigation.ts @@ -1,9 +1,11 @@ import type { IconName } from './NamedIcon' import type { MobileNote } from './mobileNoteProjection' +import { evaluateMobileView, mobileViewDefinitions } from './mobileViewFilters' export type MobileSidebarSelection = - | { kind: 'library'; id: 'all' | 'archive' | 'inbox' } + | { kind: 'library'; id: 'all' | 'archive' | 'favorites' | 'inbox' } | { kind: 'type'; type: string } + | { kind: 'view'; id: string } export type MobileSidebarItem = { count: number @@ -27,8 +29,18 @@ export function createMobileSidebarSections(notes: MobileNote[]): MobileSidebarS libraryItem({ count: inboxNotes(notes).length, icon: 'tray', id: 'inbox', label: 'Inbox' }), libraryItem({ count: activeNotes(notes).length, icon: 'file-text', id: 'all', label: 'All Notes' }), libraryItem({ count: archivedNotes(notes).length, icon: 'archive', id: 'archive', label: 'Archive' }), + libraryItem({ count: favoriteNotes(notes).length, icon: 'star', id: 'favorites', label: 'Favorites' }), ], }, + { + title: 'Views', + items: mobileViewDefinitions.map((view) => ({ + count: evaluateMobileView({ notes: activeNotes(notes), view }).length, + icon: view.icon as IconName, + label: view.name, + selection: { kind: 'view', id: view.id }, + })), + }, { title: 'Types', items: noteTypes(notes).map((type) => ({ @@ -52,18 +64,33 @@ export function filterNotesForSidebarSelection({ return activeNotes(notes).filter((note) => note.type === selection.type) } + if (selection.kind === 'view') { + const view = mobileViewDefinitions.find((definition) => definition.id === selection.id) + return view ? evaluateMobileView({ notes: activeNotes(notes), view }) : [] + } + switch (selection.id) { case 'all': return activeNotes(notes) case 'archive': return archivedNotes(notes) + case 'favorites': + return favoriteNotes(notes) default: return inboxNotes(notes) } } export function mobileSidebarTitle(selection: MobileSidebarSelection) { - return selection.kind === 'type' ? pluralTypeLabel(selection.type) : libraryTitle(selection.id) + if (selection.kind === 'type') { + return pluralTypeLabel(selection.type) + } + + if (selection.kind === 'view') { + return mobileViewDefinitions.find((definition) => definition.id === selection.id)?.name ?? 'View' + } + + return libraryTitle(selection.id) } export function isMobileSidebarSelectionActive({ @@ -85,7 +112,7 @@ function libraryItem({ }: { count: number icon: IconName - id: 'all' | 'archive' | 'inbox' + id: 'all' | 'archive' | 'favorites' | 'inbox' label: string }): MobileSidebarItem { return { @@ -108,12 +135,21 @@ function archivedNotes(notes: MobileNote[]) { return notes.filter((note) => note.archived) } +function favoriteNotes(notes: MobileNote[]) { + return activeNotes(notes) + .filter((note) => note.favorite) + .sort((left, right) => (left.favoriteIndex ?? Number.MAX_SAFE_INTEGER) - (right.favoriteIndex ?? Number.MAX_SAFE_INTEGER)) +} + function noteTypes(notes: MobileNote[]) { return [...new Set(activeNotes(notes).map((note) => note.type))].sort() } -function libraryTitle(id: 'all' | 'archive' | 'inbox') { - return id === 'all' ? 'All Notes' : id === 'archive' ? 'Archive' : 'Inbox' +function libraryTitle(id: 'all' | 'archive' | 'favorites' | 'inbox') { + if (id === 'all') return 'All Notes' + if (id === 'archive') return 'Archive' + if (id === 'favorites') return 'Favorites' + return 'Inbox' } function pluralTypeLabel(type: string) { diff --git a/apps/mobile/src/mobileTypeAppearance.ts b/apps/mobile/src/mobileTypeAppearance.ts new file mode 100644 index 00000000..39f7bef1 --- /dev/null +++ b/apps/mobile/src/mobileTypeAppearance.ts @@ -0,0 +1,48 @@ +export type MobileChipAppearance = { + backgroundColor: string + borderColor: string + color: string +} + +const neutralAppearance: MobileChipAppearance = { + backgroundColor: '#f6f5f2', + borderColor: '#dedbd4', + color: '#66615a', +} + +const appearances: Record = { + Essay: { + backgroundColor: '#eaf6ee', + borderColor: '#b8dec5', + color: '#3f7f5f', + }, + Evergreen: { + backgroundColor: '#edf6e4', + borderColor: '#c8dfaa', + color: '#5f7f36', + }, + Note: neutralAppearance, + Project: { + backgroundColor: '#eef3ff', + borderColor: '#c7d8ff', + color: '#356fd6', + }, + Resource: { + backgroundColor: '#f4edff', + borderColor: '#ddcaff', + color: '#7a54b8', + }, + 'Release Note': { + backgroundColor: '#fff2e6', + borderColor: '#f3cfaa', + color: '#a56620', + }, +} + +export function mobileTypeAppearance(type: string) { + return appearances[type] ?? neutralAppearance +} + +export function mobileRelationshipAppearance(type?: string) { + return type ? mobileTypeAppearance(type) : neutralAppearance +} diff --git a/apps/mobile/src/mobileVaultRepository.ts b/apps/mobile/src/mobileVaultRepository.ts index f4d3af5e..b6e5cb23 100644 --- a/apps/mobile/src/mobileVaultRepository.ts +++ b/apps/mobile/src/mobileVaultRepository.ts @@ -60,6 +60,9 @@ function fileToSource(file: MobileVaultFile): MobileNoteSource { id: fileId(file.path), archived: metadata.archived ?? false, belongsTo: metadata.belongsTo, + customProperties: metadata.customProperties, + favorite: metadata.favorite ?? false, + favoriteIndex: metadata.favoriteIndex ?? null, type: metadata.type ?? 'Note', has: metadata.has, icon: metadata.icon ?? 'file-text', @@ -68,6 +71,7 @@ function fileToSource(file: MobileVaultFile): MobileNoteSource { filename: file.path, content: file.content, relatedTo: metadata.relatedTo, + relationships: metadata.relationships, status: metadata.status, tags: metadata.tags, } diff --git a/apps/mobile/src/mobileVaultRuntime.test.ts b/apps/mobile/src/mobileVaultRuntime.test.ts index 71a66373..68f8ee73 100644 --- a/apps/mobile/src/mobileVaultRuntime.test.ts +++ b/apps/mobile/src/mobileVaultRuntime.test.ts @@ -56,13 +56,17 @@ function note({ id }: { id: string }): MobileNote { backlinks: [], belongsTo: [], content: '', + customProperties: {}, date: 'today', + favorite: false, + favoriteIndex: null, has: [], icon: 'essay', id, modified: 'today', outgoingLinks: [], relatedTo: [], + relationships: {}, snippet: '', tags: [], title: id, diff --git a/apps/mobile/src/mobileViewFilters.ts b/apps/mobile/src/mobileViewFilters.ts new file mode 100644 index 00000000..94df75b2 --- /dev/null +++ b/apps/mobile/src/mobileViewFilters.ts @@ -0,0 +1,177 @@ +import type { MobileNote } from './mobileNoteProjection' + +export type MobileFilterOp = + | 'after' + | 'any_of' + | 'before' + | 'contains' + | 'equals' + | 'is_empty' + | 'is_not_empty' + | 'none_of' + | 'not_contains' + | 'not_equals' + +export type MobileFilterCondition = { + field: string + op: MobileFilterOp + value?: string | string[] +} + +export type MobileFilterGroup = { all: MobileFilterNode[] } | { any: MobileFilterNode[] } +export type MobileFilterNode = MobileFilterCondition | MobileFilterGroup + +export type MobileViewDefinition = { + color: string + filters: MobileFilterGroup + icon: string + id: string + name: string +} + +export const mobileViewDefinitions: MobileViewDefinition[] = [ + { + color: '#3f7f5f', + filters: { + all: [ + { field: 'favorite', op: 'equals', value: 'true' }, + { any: [{ field: 'type', op: 'equals', value: 'Essay' }, { field: 'tags', op: 'contains', value: 'mobile' }] }, + ], + }, + icon: 'sun', + id: 'favorite-mobile-work', + name: 'Favorite mobile work', + }, + { + color: '#356fd6', + filters: { + all: [ + { field: 'archived', op: 'not_equals', value: 'true' }, + { any: [{ field: 'status', op: 'equals', value: 'Draft' }, { field: 'related_to', op: 'contains', value: 'mobile-roadmap' }] }, + ], + }, + icon: 'git-branch', + id: 'active-drafts', + name: 'Active drafts', + }, +] + +export function evaluateMobileView({ + notes, + view, +}: { + notes: MobileNote[] + view: MobileViewDefinition +}) { + return notes.filter((note) => evaluateGroup({ group: view.filters, note })) +} + +type MobileFieldValue = string | string[] | undefined +type OperationMatcher = (value: MobileFieldValue, expected: string | string[] | undefined) => boolean + +const operationMatchers: Record = { + after: (value, expected) => scalarValue(value) > scalarValue(expected), + any_of: (value, expected) => arrayValues(expected).some((item) => arrayValues(value).some((actual) => wikilinkEquals(actual, item))), + before: (value, expected) => scalarValue(value) < scalarValue(expected), + contains: (value, expected) => arrayValues(value).some((item) => containsValue({ actual: item, expected: scalarValue(expected) })), + equals: (value, expected) => scalarValue(value) === scalarValue(expected), + is_empty: (value) => arrayValues(value).length === 0 || arrayValues(value).every((item) => item.length === 0), + is_not_empty: (value) => arrayValues(value).some((item) => item.length > 0), + none_of: (value, expected) => !operationMatchers.any_of(value, expected), + not_contains: (value, expected) => !operationMatchers.contains(value, expected), + not_equals: (value, expected) => !operationMatchers.equals(value, expected), +} + +function evaluateGroup({ + group, + note, +}: { + group: MobileFilterGroup + note: MobileNote +}): boolean { + return 'all' in group + ? group.all.every((node) => evaluateNode({ node, note })) + : group.any.some((node) => evaluateNode({ node, note })) +} + +function evaluateNode({ + node, + note, +}: { + node: MobileFilterNode + note: MobileNote +}) { + return isFilterGroup(node) ? evaluateGroup({ group: node, note }) : evaluateCondition({ condition: node, note }) +} + +function evaluateCondition({ + condition, + note, +}: { + condition: MobileFilterCondition + note: MobileNote +}) { + return operationMatchers[condition.op](valueForField({ field: condition.field, note }), condition.value) +} + +function valueForField({ + field, + note, +}: { + field: string + note: MobileNote +}) { + return standardFieldValues(note)[field.toLowerCase()] ?? note.relationships[field] ?? note.customProperties[field] ?? '' +} + +function standardFieldValues(note: MobileNote): Record { + return { + archived: String(note.archived), + belongs_to: note.belongsTo, + favorite: String(note.favorite), + has: note.has, + related_to: note.relatedTo, + status: note.status ?? '', + tags: note.tags, + type: note.type, + } +} + +function containsValue({ + actual, + expected, +}: { + actual: string + expected: string +}) { + return expected.startsWith('[[') ? wikilinkEquals(actual, expected) : wikilinkStem(actual).includes(wikilinkStem(expected)) +} + +function wikilinkEquals(left: string, right: string) { + return wikilinkParts(left).some((part) => wikilinkParts(right).includes(part)) +} + +function wikilinkParts(value: string) { + const [target, alias] = wikilinkStem(value).split('|') + return [target, alias].filter(isText) +} + +function wikilinkStem(value: string) { + return value.trim().replace(/^\[\[|\]\]$/g, '').toLowerCase() +} + +function scalarValue(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] ?? '' : value ?? '' +} + +function arrayValues(value: string | string[] | undefined) { + return Array.isArray(value) ? value : isText(value) ? [value] : [] +} + +function isFilterGroup(node: MobileFilterNode): node is MobileFilterGroup { + return 'all' in node || 'any' in node +} + +function isText(value: string | undefined): value is string { + return typeof value === 'string' && value.trim().length > 0 +} diff --git a/apps/mobile/src/mobileWikilinkAutocomplete.test.ts b/apps/mobile/src/mobileWikilinkAutocomplete.test.ts new file mode 100644 index 00000000..77d1525a --- /dev/null +++ b/apps/mobile/src/mobileWikilinkAutocomplete.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { activeMobileWikilinkQuery, insertMobileWikilink, mobileNoteSuggestions } from './mobileWikilinkAutocomplete' +import type { MobileNote } from './mobileNoteProjection' + +describe('mobile wikilink autocomplete', () => { + it('detects an active wikilink trigger before the cursor', () => { + expect(activeMobileWikilinkQuery({ cursor: 12, markdown: 'See [[mobile' })).toEqual({ + end: 12, + query: 'mobile', + start: 4, + }) + }) + + it('suggests notes by title or id and inserts canonical aliases', () => { + const notes = [note({ id: 'mobile-roadmap', title: 'Mobile Roadmap' }), note({ id: 'workflow', title: 'Workflow Essay' })] + const query = activeMobileWikilinkQuery({ cursor: 12, markdown: 'See [[mobile' }) + + expect(mobileNoteSuggestions({ notes, query: 'road' }).map((item) => item.id)).toEqual(['mobile-roadmap']) + expect(query ? insertMobileWikilink({ markdown: 'See [[mobile', note: notes[0], query }) : '').toBe('See [[mobile-roadmap|Mobile Roadmap]]') + }) +}) + +function note({ + id, + title, +}: { + id: string + title: string +}): MobileNote { + return { + archived: false, + backlinks: [], + belongsTo: [], + content: `# ${title}`, + customProperties: {}, + date: '', + favorite: false, + favoriteIndex: null, + has: [], + icon: 'file-text', + id, + modified: '', + outgoingLinks: [], + relatedTo: [], + relationships: {}, + snippet: '', + tags: [], + title, + type: 'Note', + words: 1, + } +} diff --git a/apps/mobile/src/mobileWikilinkAutocomplete.ts b/apps/mobile/src/mobileWikilinkAutocomplete.ts new file mode 100644 index 00000000..f9905944 --- /dev/null +++ b/apps/mobile/src/mobileWikilinkAutocomplete.ts @@ -0,0 +1,71 @@ +import type { MobileNote } from './mobileNoteProjection' + +export type MobileWikilinkQuery = { + end: number + query: string + start: number +} + +const maxSuggestions = 6 + +export function activeMobileWikilinkQuery({ + cursor, + markdown, +}: { + cursor: number + markdown: string +}): MobileWikilinkQuery | null { + const prefix = markdown.slice(0, cursor) + const start = prefix.lastIndexOf('[[') + if (start < 0 || prefix.slice(start).includes(']]')) { + return null + } + + const query = prefix.slice(start + 2) + return query.includes('\n') ? null : { end: cursor, query, start } +} + +export function mobileNoteSuggestions({ + excludeNoteId, + notes, + query, +}: { + excludeNoteId?: string + notes: MobileNote[] + query: string +}) { + const normalizedQuery = normalizeSuggestionText(query) + return notes + .filter((note) => note.id !== excludeNoteId) + .filter((note) => matchesSuggestion({ note, normalizedQuery })) + .slice(0, maxSuggestions) +} + +export function insertMobileWikilink({ + markdown, + note, + query, +}: { + markdown: string + note: MobileNote + query: MobileWikilinkQuery +}) { + const wikilink = `[[${note.id}|${note.title}]]` + return `${markdown.slice(0, query.start)}${wikilink}${markdown.slice(query.end)}` +} + +function matchesSuggestion({ + note, + normalizedQuery, +}: { + note: MobileNote + normalizedQuery: string +}) { + return normalizedQuery.length === 0 + || normalizeSuggestionText(note.id).includes(normalizedQuery) + || normalizeSuggestionText(note.title).includes(normalizedQuery) +} + +function normalizeSuggestionText(value: string) { + return value.trim().toLowerCase() +} diff --git a/apps/mobile/src/styles.ts b/apps/mobile/src/styles.ts index bcab5e38..9d96fcba 100644 --- a/apps/mobile/src/styles.ts +++ b/apps/mobile/src/styles.ts @@ -1,11 +1,15 @@ +import { aiStyles } from './styles/aiStyles' import { breadcrumbStyles } from './styles/breadcrumbStyles' import { commonStyles } from './styles/commonStyles' +import { customPropertyStyles } from './styles/customPropertyStyles' import { editorSaveStateStyles } from './styles/editorSaveStateStyles' import { editorStyles } from './styles/editorStyles' import { gitSyncStyles } from './styles/gitSyncStyles' import { noteListStyles } from './styles/noteListStyles' import { propertiesStyles } from './styles/propertiesStyles' import { propertyChipStyles } from './styles/propertyChipStyles' +import { rawEditorSuggestionStyles } from './styles/rawEditorSuggestionStyles' +import { relationshipEditingStyles } from './styles/relationshipEditingStyles' import { relationshipStyles } from './styles/relationshipStyles' import { remotePromptStyles } from './styles/remotePromptStyles' import { sidebarStyles } from './styles/sidebarStyles' @@ -13,14 +17,18 @@ import { vaultManagementStyles } from './styles/vaultManagementStyles' import { vaultLoadStyles } from './styles/vaultLoadStyles' export const styles = { + ...aiStyles, ...breadcrumbStyles, ...commonStyles, + ...customPropertyStyles, ...editorStyles, ...editorSaveStateStyles, ...gitSyncStyles, ...noteListStyles, ...propertiesStyles, ...propertyChipStyles, + ...rawEditorSuggestionStyles, + ...relationshipEditingStyles, ...relationshipStyles, ...remotePromptStyles, ...sidebarStyles, diff --git a/apps/mobile/src/styles/aiStyles.ts b/apps/mobile/src/styles/aiStyles.ts new file mode 100644 index 00000000..3eb0d695 --- /dev/null +++ b/apps/mobile/src/styles/aiStyles.ts @@ -0,0 +1,50 @@ +import { StyleSheet } from 'react-native' +import { colors, spacing } from '../theme' + +export const aiStyles = StyleSheet.create({ + aiContent: { + gap: spacing.sm, + padding: spacing.lg, + }, + aiInput: { + minHeight: 38, + borderColor: colors.border, + borderRadius: 10, + borderWidth: StyleSheet.hairlineWidth, + color: colors.text, + fontSize: 14, + paddingHorizontal: spacing.md, + backgroundColor: colors.canvas, + }, + aiPrompt: { + minHeight: 126, + borderColor: colors.border, + borderRadius: 12, + borderWidth: StyleSheet.hairlineWidth, + color: colors.text, + fontSize: 15, + lineHeight: 21, + padding: spacing.md, + backgroundColor: colors.canvas, + }, + aiSendButton: { + minHeight: 42, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + borderRadius: 999, + backgroundColor: colors.primary, + }, + aiSendButtonText: { + color: '#ffffff', + fontSize: 14, + fontWeight: '700', + }, + aiResponse: { + marginTop: spacing.md, + color: colors.text, + fontSize: 15, + lineHeight: 22, + }, +}) diff --git a/apps/mobile/src/styles/customPropertyStyles.ts b/apps/mobile/src/styles/customPropertyStyles.ts new file mode 100644 index 00000000..5fd96c6b --- /dev/null +++ b/apps/mobile/src/styles/customPropertyStyles.ts @@ -0,0 +1,42 @@ +import { StyleSheet } from 'react-native' +import { colors, spacing } from '../theme' + +export const customPropertyStyles = StyleSheet.create({ + customPropertyRow: { + minHeight: 38, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + borderBottomColor: colors.border, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + customPropertyKey: { + width: 90, + color: colors.mutedText, + fontSize: 13, + fontWeight: '600', + }, + customPropertyValue: { + flex: 1, + color: colors.text, + fontSize: 14, + fontWeight: '600', + textAlign: 'right', + }, + customPropertyAddRow: { + flexDirection: 'row', + gap: spacing.sm, + marginTop: spacing.sm, + }, + customPropertyAddInput: { + minHeight: 36, + flex: 1, + borderColor: colors.border, + borderRadius: 10, + borderWidth: StyleSheet.hairlineWidth, + color: colors.text, + fontSize: 13, + paddingHorizontal: spacing.sm, + backgroundColor: colors.canvas, + }, +}) diff --git a/apps/mobile/src/styles/rawEditorSuggestionStyles.ts b/apps/mobile/src/styles/rawEditorSuggestionStyles.ts new file mode 100644 index 00000000..ecca3725 --- /dev/null +++ b/apps/mobile/src/styles/rawEditorSuggestionStyles.ts @@ -0,0 +1,33 @@ +import { StyleSheet } from 'react-native' +import { colors, spacing } from '../theme' + +export const rawEditorSuggestionStyles = StyleSheet.create({ + rawEditorSuggestionMenu: { + position: 'absolute', + right: spacing.xl, + bottom: spacing.xl, + left: spacing.xl, + borderColor: colors.border, + borderRadius: 12, + borderWidth: StyleSheet.hairlineWidth, + overflow: 'hidden', + backgroundColor: colors.canvas, + }, + rawEditorSuggestion: { + minHeight: 46, + justifyContent: 'center', + borderBottomColor: colors.border, + borderBottomWidth: StyleSheet.hairlineWidth, + paddingHorizontal: spacing.md, + }, + rawEditorSuggestionTitle: { + color: colors.text, + fontSize: 14, + fontWeight: '700', + }, + rawEditorSuggestionMeta: { + marginTop: 2, + color: colors.mutedText, + fontSize: 12, + }, +}) diff --git a/apps/mobile/src/styles/relationshipEditingStyles.ts b/apps/mobile/src/styles/relationshipEditingStyles.ts new file mode 100644 index 00000000..1edd45be --- /dev/null +++ b/apps/mobile/src/styles/relationshipEditingStyles.ts @@ -0,0 +1,44 @@ +import { StyleSheet } from 'react-native' +import { colors, spacing } from '../theme' + +export const relationshipEditingStyles = StyleSheet.create({ + relationshipHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + relationshipAddButton: { + width: 26, + height: 26, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 13, + backgroundColor: colors.canvas, + }, + relationshipAddBox: { + marginTop: spacing.sm, + gap: spacing.xs, + }, + relationshipInput: { + minHeight: 36, + borderColor: colors.border, + borderRadius: 10, + borderWidth: StyleSheet.hairlineWidth, + color: colors.text, + fontSize: 14, + paddingHorizontal: spacing.md, + backgroundColor: colors.canvas, + }, + relationshipSuggestion: { + minHeight: 32, + justifyContent: 'center', + borderRadius: 8, + paddingHorizontal: spacing.md, + backgroundColor: colors.primarySoft, + }, + relationshipSuggestionText: { + color: colors.text, + fontSize: 13, + fontWeight: '600', + }, +}) diff --git a/apps/mobile/src/styles/relationshipStyles.ts b/apps/mobile/src/styles/relationshipStyles.ts index 5b1ac94c..1f7c6ea5 100644 --- a/apps/mobile/src/styles/relationshipStyles.ts +++ b/apps/mobile/src/styles/relationshipStyles.ts @@ -13,6 +13,9 @@ export const relationshipStyles = StyleSheet.create({ }, relationshipChip: { minHeight: 30, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, justifyContent: 'center', borderColor: colors.border, borderRadius: 999, @@ -25,4 +28,10 @@ export const relationshipStyles = StyleSheet.create({ fontSize: 13, fontWeight: '700', }, + relationshipRemoveButton: { + width: 18, + height: 18, + alignItems: 'center', + justifyContent: 'center', + }, }) diff --git a/docs/MOBILE_PROGRESS.md b/docs/MOBILE_PROGRESS.md index 35f8b639..bcd6d84b 100644 --- a/docs/MOBILE_PROGRESS.md +++ b/docs/MOBILE_PROGRESS.md @@ -8,7 +8,7 @@ This file is the resumable working log for Tolaria mobile. The strategy and road - Branch: `codex/mobile` - Active phase: Phase 4 - Editor V1 -- Active slice: Git credential/auth boundaries and editor durability +- Active slice: Local workflow parity, relationships, views, favorites, and AI panel - Push policy: commit locally; do not push unless explicitly requested - Validation target: iPad/iOS simulator first @@ -112,14 +112,23 @@ This file is the resumable working log for Tolaria mobile. The strategy and road - Upgraded the mobile properties prototype so type, status, date, and tags can be entered from the keyboard with quick chips as shortcuts, while icon editing stays chip-based. - Removed the now-unused title prompt component and kept the compose-button disabled state with the note-list styles. - Split new breadcrumb styles out of the shared toolbar styles and removed stale editor/property style entries so the CodeScene pre-commit safeguard stays green. +- Added first-class mobile metadata projection for `_favorite`, `_favorite_index`, custom scalar properties, and dynamic relationship frontmatter fields. +- Added editable mobile relationship groups with inline note autocomplete, removable relationship chips, addable custom relationship groups, and add/remove custom scalar properties. +- Added raw Markdown wikilink autocomplete that detects active `[[...` queries, suggests local notes, and inserts canonical aliased wikilinks. +- Added Favorites to the mobile sidebar and breadcrumb star toggle, including the desktop-aligned `CmdOrCtrl+D` shortcut command. +- Added mobile saved views with nested `all` / `any` filter groups and desktop-like relationship/wikilink comparison semantics. +- Added desktop-inspired type/relationship chip coloring for the mobile note list and properties panel. +- Added an API-key-only AI side panel using an OpenAI-compatible `/chat/completions` request path, with no agents and no local models. +- Set the booted iPad simulator keyboard preferences to Italian (`it_IT@sw=QWERTY;hw=Automatic`). ## Next Action -Continue Phase 4 with editor durability: +Continue Phase 4 with local workflow parity and native QA: -1. Continue TenTap Markdown serialization coverage for any editor output observed in simulator QA. -2. Implement the native Git module behind `createNativeMobileGitTransport`, preferably Rust/libgit2 unless Expo native-module constraints block it. -3. Retry the iOS development-client build after installing an iOS 26.2 simulator runtime in Xcode. +1. Install a simulator runtime matching the active Xcode SDK, or switch Xcode to one matching the installed iOS 17.5/18.6 runtimes, then retry the iOS development-client build. +2. Run iPad simulator QA over sidebar filters, favorites, editable properties, relationship add/remove, raw wikilink autocomplete, saved views, and AI panel input states. +3. Continue TenTap Markdown serialization coverage for any editor output observed in simulator QA. +4. Implement the native Git module behind `createNativeMobileGitTransport`, preferably Rust/libgit2 unless Expo native-module constraints block it. ## Verification Log @@ -178,6 +187,13 @@ Continue Phase 4 with editor durability: - `xcrun simctl boot 40724AA3-A793-41D8-9C66-79745DA28DE4` booted `iPad Pro 13-inch (M4)`. - `pnpm --filter @tolaria/mobile exec expo start --ios` installed/opened Expo Go and launched Tolaria on the booted iPad simulator. - Simulator screenshot captured at `/tmp/tolaria-mobile-ipad.png`; the Tolaria shell renders behind Expo Go's first-run tools modal. +- `pnpm --filter @tolaria/mobile typecheck` passed after local workflow parity additions. +- `VITEST_MAX_THREADS=1 VITEST_MIN_THREADS=1 pnpm --filter @tolaria/mobile test` passed after local workflow parity additions: 52 files / 172 tests. +- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export-workflow` passed after local workflow parity additions. +- CodeScene touched-file checks passed at `10.0` for mobile metadata, sidebar navigation, raw wikilink autocomplete, saved view filters, AI client/panel, type appearance, style modules, and touched app/panel components; non-scorable fixture/style aggregation files returned no score. +- CodeScene pre-commit safeguard passed for the current local change set. +- iPad simulator keyboard preference now reads `KeyboardsCurrentAndNext = (it_IT@sw=QWERTY;hw=Automatic, it_IT@sw=QWERTY;hw=Automatic)`. +- Native simulator QA is currently blocked by local platform tooling: Xcode has only the iOS 26.2 SDK installed, while CoreSimulator has iOS 17.5 and 18.6 runtimes, so `expo run:ios --device "iPad Pro 13-inch (M4)"` fails with xcodebuild error 70 because no matching destination is eligible. - `pnpm --filter @tolaria/mobile test` passed after adding gesture support: 5 files / 15 tests. - `pnpm --filter @tolaria/mobile typecheck` passed after adding gesture support. - CodeScene after gesture support: `apps/mobile/src/compactGestures.ts`, `apps/mobile/src/compactGestures.test.ts`, `apps/mobile/src/SwipeSurface.tsx`, touched app/root files, and touched style module scored `10`; `apps/mobile/index.ts` returned no scorable code.