feat: add mobile workflow affordances
This commit is contained in:
194
apps/mobile/src/MobileAiPanel.tsx
Normal file
194
apps/mobile/src/MobileAiPanel.tsx
Normal file
@@ -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 (
|
||||
<View style={styles.properties}>
|
||||
<AiToolbar onClose={onClose} />
|
||||
<ScrollView contentContainerStyle={styles.aiContent}>
|
||||
<Text style={styles.propertyGroupTitle}>Model</Text>
|
||||
<AiModelFields
|
||||
apiKey={apiKey}
|
||||
baseUrl={baseUrl}
|
||||
model={model}
|
||||
onChangeApiKey={setApiKey}
|
||||
onChangeBaseUrl={setBaseUrl}
|
||||
onChangeModel={setModel}
|
||||
/>
|
||||
<AiPromptComposer
|
||||
canSend={canSend}
|
||||
isSending={isSending}
|
||||
note={note}
|
||||
onChangePrompt={setPrompt}
|
||||
onSend={sendPrompt}
|
||||
prompt={prompt}
|
||||
/>
|
||||
<AiResult failed={failed} response={response} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function AiToolbar({ onClose }: { onClose?: () => void }) {
|
||||
return (
|
||||
<View style={styles.toolbar}>
|
||||
<Text style={styles.propertiesTitle}>AI</Text>
|
||||
<View style={styles.toolbarSpacer} />
|
||||
{onClose ? (
|
||||
<Pressable onPress={onClose} style={({ pressed }) => [styles.iconButton, pressed ? styles.pressed : null]}>
|
||||
<CaretLeft size={23} color={colors.textSoft} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<AiInput onChangeText={onChangeBaseUrl} placeholder="Base URL" value={baseUrl} />
|
||||
<AiInput onChangeText={onChangeModel} placeholder="Model" value={model} />
|
||||
<AiInput onChangeText={onChangeApiKey} placeholder="API key" secureTextEntry value={apiKey} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AiPromptComposer({
|
||||
canSend,
|
||||
isSending,
|
||||
note,
|
||||
onChangePrompt,
|
||||
onSend,
|
||||
prompt,
|
||||
}: {
|
||||
canSend: boolean
|
||||
isSending: boolean
|
||||
note: MobileNote
|
||||
onChangePrompt: (value: string) => void
|
||||
onSend: () => void
|
||||
prompt: string
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Text style={styles.propertyGroupTitle}>Prompt</Text>
|
||||
<TextInput
|
||||
multiline
|
||||
onChangeText={onChangePrompt}
|
||||
placeholder={`Ask about ${note.title}`}
|
||||
placeholderTextColor={colors.mutedText}
|
||||
style={styles.aiPrompt}
|
||||
textAlignVertical="top"
|
||||
value={prompt}
|
||||
/>
|
||||
<AiSendButton canSend={canSend} isSending={isSending} onSend={onSend} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AiInput({
|
||||
onChangeText,
|
||||
placeholder,
|
||||
secureTextEntry = false,
|
||||
value,
|
||||
}: {
|
||||
onChangeText: (value: string) => void
|
||||
placeholder: string
|
||||
secureTextEntry?: boolean
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.mutedText}
|
||||
secureTextEntry={secureTextEntry}
|
||||
style={styles.aiInput}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AiSendButton({
|
||||
canSend,
|
||||
isSending,
|
||||
onSend,
|
||||
}: {
|
||||
canSend: boolean
|
||||
isSending: boolean
|
||||
onSend: () => void
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
disabled={!canSend || isSending}
|
||||
onPress={onSend}
|
||||
style={({ pressed }) => [
|
||||
styles.aiSendButton,
|
||||
!canSend || isSending ? styles.composeButtonDisabled : null,
|
||||
pressed ? styles.pressed : null,
|
||||
]}
|
||||
>
|
||||
<PaperPlaneTilt color="#ffffff" size={18} weight="fill" />
|
||||
<Text style={styles.aiSendButtonText}>{isSending ? 'Sending' : 'Send'}</Text>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
function AiResult({
|
||||
failed,
|
||||
response,
|
||||
}: {
|
||||
failed: boolean
|
||||
response: string
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{failed ? <Text style={styles.propertyError}>AI request failed.</Text> : null}
|
||||
{response ? <Text style={styles.aiResponse}>{response}</Text> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<Record<string, 'raw' | 'rich'>>({})
|
||||
const [rightPanel, setRightPanel] = useState<'ai' | 'properties'>('properties')
|
||||
const [sidebarSelection, setSidebarSelection] = useState<MobileSidebarSelection>(defaultMobileSidebarSelection)
|
||||
const [saveStateByNoteId, setSaveStateByNoteId] = useState<Record<string, MobileEditorSaveState>>({})
|
||||
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 (
|
||||
<SafeAreaProvider>
|
||||
@@ -243,15 +254,19 @@ export function MobileApp() {
|
||||
/>
|
||||
<EditorPanel
|
||||
editorMode={selectedEditorMode}
|
||||
notes={availableNotes}
|
||||
note={selectedNote}
|
||||
saveState={selectedSaveState}
|
||||
onDeleteNote={deleteFlow.canDelete ? deleteFlow.deleteSelectedNote : undefined}
|
||||
onDraftChange={saveDraft}
|
||||
onOpenAi={() => setRightPanel('ai')}
|
||||
onOpenProperties={() => setRightPanel('properties')}
|
||||
onRawMarkdownChange={saveRawMarkdown}
|
||||
onToggleArchive={toggleSelectedArchive}
|
||||
onToggleEditorMode={toggleEditorMode}
|
||||
onToggleFavorite={toggleSelectedFavorite}
|
||||
/>
|
||||
{showsProperties ? (
|
||||
{showsProperties && rightPanel === 'properties' ? (
|
||||
<MobilePropertiesPanel
|
||||
failed={propertiesFlow.failed}
|
||||
isSaving={propertiesFlow.isSaving}
|
||||
@@ -261,6 +276,7 @@ export function MobileApp() {
|
||||
onOpenNote={selectNoteId}
|
||||
/>
|
||||
) : null}
|
||||
{showsProperties && rightPanel === 'ai' ? <MobileAiPanel note={selectedNote} /> : null}
|
||||
</View>
|
||||
) : (
|
||||
<CompactShell
|
||||
@@ -283,6 +299,7 @@ export function MobileApp() {
|
||||
onSelectSidebar={selectSidebar}
|
||||
onToggleArchive={toggleSelectedArchive}
|
||||
onToggleEditorMode={toggleEditorMode}
|
||||
onToggleFavorite={toggleSelectedFavorite}
|
||||
createNoteFailed={createFlow.failed}
|
||||
isCreatingNote={createFlow.isCreating}
|
||||
runtimeLoadFailed={runtimeLoader.failed}
|
||||
@@ -330,6 +347,7 @@ type CompactShellProps = {
|
||||
onNavigate: (event: CompactNavigationEvent) => 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) {
|
||||
<SwipeSurface panel="note" onNavigate={props.onNavigate}>
|
||||
<EditorPanel
|
||||
editorMode={props.editorMode}
|
||||
notes={props.allNotes}
|
||||
note={props.note}
|
||||
saveState={props.saveState}
|
||||
onDeleteNote={props.onDeleteNote}
|
||||
onDraftChange={props.onDraftChange}
|
||||
onOpenAi={props.onOpenAi}
|
||||
onRawMarkdownChange={props.onRawMarkdownChange}
|
||||
onBack={() => props.onNavigate({ type: 'backToList' })}
|
||||
onOpenProperties={() => props.onNavigate({ type: 'openProperties' })}
|
||||
onToggleArchive={props.onToggleArchive}
|
||||
onToggleEditorMode={props.onToggleEditorMode}
|
||||
onToggleFavorite={props.onToggleFavorite}
|
||||
/>
|
||||
</SwipeSurface>
|
||||
)
|
||||
@@ -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({
|
||||
>
|
||||
<View style={styles.noteRowHeader}>
|
||||
<Text style={styles.noteTitle}>{item.title}</Text>
|
||||
{item.favorite ? <Star color={colors.primary} size={16} weight="fill" /> : null}
|
||||
<NamedIcon name={item.icon as IconName} size={18} color={colors.primary} />
|
||||
</View>
|
||||
<Text numberOfLines={2} style={styles.noteSnippet}>{item.snippet}</Text>
|
||||
@@ -564,6 +589,7 @@ function NoteListPanel({
|
||||
<Text style={styles.noteMeta}>Created {item.date}</Text>
|
||||
</View>
|
||||
<View style={styles.tagRow}>
|
||||
<TypeChip type={item.type} />
|
||||
{item.tags.slice(0, 2).map((tag) => <Tag key={tag} label={tag} />)}
|
||||
</View>
|
||||
</Pressable>
|
||||
@@ -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 (
|
||||
<View style={styles.editor}>
|
||||
@@ -643,14 +675,16 @@ function EditorPanel({
|
||||
note={note}
|
||||
saveState={saveState ?? idleMobileEditorSaveState}
|
||||
onToggleArchive={onToggleArchive}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleRawMode={onToggleEditorMode}
|
||||
/>
|
||||
{onOpenProperties ? <IconButton icon={<Info size={23} color={colors.textSoft} />} onPress={onOpenProperties} /> : null}
|
||||
{onOpenAi ? <IconButton icon={<Robot size={23} color={colors.textSoft} />} onPress={onOpenAi} /> : null}
|
||||
{onDeleteNote ? <IconButton icon={<Trash size={23} color={colors.textSoft} />} onPress={onDeleteNote} /> : null}
|
||||
<IconButton icon={<DotsThreeVertical size={23} color={colors.textSoft} />} />
|
||||
</Toolbar>
|
||||
{editorMode === 'raw'
|
||||
? <MobileRawEditor key={note.id} note={note} onRawMarkdownChange={onRawMarkdownChange} />
|
||||
? <MobileRawEditor key={note.id} notes={notes} note={note} onRawMarkdownChange={onRawMarkdownChange} />
|
||||
: <MobileEditorAdapter note={note} onDraftChange={onDraftChange} />}
|
||||
</View>
|
||||
)
|
||||
@@ -671,3 +705,21 @@ function IconButton({ icon, onPress }: { icon: React.ReactNode; onPress?: () =>
|
||||
function Tag({ label }: { label: string }) {
|
||||
return <Text style={styles.tag}>{label}</Text>
|
||||
}
|
||||
|
||||
function TypeChip({ type }: { type: string }) {
|
||||
const appearance = mobileTypeAppearance(type)
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
styles.tag,
|
||||
{
|
||||
backgroundColor: appearance.backgroundColor,
|
||||
borderColor: appearance.borderColor,
|
||||
color: appearance.color,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{type}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<BreadcrumbButton label={isRawMode ? 'Rich editor' : 'Raw editor'} onPress={onToggleRawMode}>
|
||||
<RawIcon color={isRawMode ? colors.primary : colors.textSoft} size={18} />
|
||||
</BreadcrumbButton>
|
||||
<BreadcrumbButton label={note.favorite ? 'Remove favorite' : 'Add favorite'} onPress={onToggleFavorite}>
|
||||
<Star color={note.favorite ? colors.primary : colors.textSoft} size={18} weight={note.favorite ? 'fill' : 'regular'} />
|
||||
</BreadcrumbButton>
|
||||
<BreadcrumbButton label={note.archived ? 'Move to inbox' : 'Archive'} onPress={onToggleArchive}>
|
||||
<ArchiveIcon color={colors.textSoft} size={18} />
|
||||
</BreadcrumbButton>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<RelationshipGroup label="Belongs to" notes={notes} targets={note.belongsTo} onOpenNote={onOpenNote} />
|
||||
<RelationshipGroup label="Related to" notes={notes} targets={[...note.relatedTo, ...note.outgoingLinks]} onOpenNote={onOpenNote} />
|
||||
<RelationshipGroup label="Has" notes={notes} targets={note.has} onOpenNote={onOpenNote} />
|
||||
<RelationshipGroup
|
||||
label="Belongs to"
|
||||
notes={notes}
|
||||
targets={note.belongsTo}
|
||||
onChangeTargets={(belongsTo) => onChangeProperties?.({ belongsTo })}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
<RelationshipGroup
|
||||
label="Related to"
|
||||
notes={notes}
|
||||
targets={[...note.relatedTo, ...note.outgoingLinks]}
|
||||
writableTargets={note.relatedTo}
|
||||
onChangeTargets={(relatedTo) => onChangeProperties?.({ relatedTo })}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
<RelationshipGroup
|
||||
label="Has"
|
||||
notes={notes}
|
||||
targets={note.has}
|
||||
onChangeTargets={(has) => onChangeProperties?.({ has })}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
{Object.entries(note.relationships).map(([key, targets]) => (
|
||||
<RelationshipGroup
|
||||
key={key}
|
||||
label={formatRelationshipLabel(key)}
|
||||
notes={notes}
|
||||
targets={targets}
|
||||
onChangeTargets={(nextTargets) => onChangeProperties?.({
|
||||
relationships: { ...note.relationships, [key]: nextTargets },
|
||||
removedRelationshipKeys: nextTargets.length === 0 ? [key] : undefined,
|
||||
})}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
))}
|
||||
<AddRelationshipGroup
|
||||
note={note}
|
||||
onAdd={(key) => onChangeProperties?.({ relationships: { ...note.relationships, [key]: [] } })}
|
||||
/>
|
||||
<CustomProperties note={note} onChangeProperties={onChangeProperties} />
|
||||
<BacklinkGroup backlinks={note.backlinks} onOpenNote={onOpenNote} />
|
||||
<PropertyRow label="Words" value={String(note.words)} />
|
||||
<PropertyRow label="Modified" value={note.modified} />
|
||||
@@ -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 (
|
||||
<View style={styles.relationshipGroup}>
|
||||
<Text style={styles.propertyGroupTitle}>{label}</Text>
|
||||
<RelationshipHeader canAdd={Boolean(onChangeTargets)} label={label} onAdd={() => setIsAdding(true)} />
|
||||
<View style={styles.relationshipChipRow}>
|
||||
{uniqueTargets.map((target) => (
|
||||
<RelationshipChip
|
||||
key={target}
|
||||
note={findRelationshipNote({ notes, target })}
|
||||
onRemove={writableTargets.includes(target) && onChangeTargets ? () => onChangeTargets(writableTargets.filter((item) => item !== target)) : undefined}
|
||||
target={target}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{isAdding ? <RelationshipAddBox onAddTarget={addTarget} onChangeQuery={setQuery} query={query} suggestions={suggestions} /> : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function RelationshipHeader({
|
||||
canAdd,
|
||||
label,
|
||||
onAdd,
|
||||
}: {
|
||||
canAdd: boolean
|
||||
label: string
|
||||
onAdd: () => void
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.relationshipHeader}>
|
||||
<Text style={styles.propertyGroupTitle}>{label}</Text>
|
||||
{canAdd ? (
|
||||
<Pressable onPress={onAdd} style={({ pressed }) => [styles.relationshipAddButton, pressed ? styles.pressed : null]}>
|
||||
<Plus color={colors.textSoft} size={14} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function RelationshipAddBox({
|
||||
onAddTarget,
|
||||
onChangeQuery,
|
||||
query,
|
||||
suggestions,
|
||||
}: {
|
||||
onAddTarget: (target: string) => void
|
||||
onChangeQuery: (query: string) => void
|
||||
query: string
|
||||
suggestions: MobileNote[]
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.relationshipAddBox}>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onChangeText={onChangeQuery}
|
||||
onSubmitEditing={() => {
|
||||
if (query.trim().length > 0) {
|
||||
onAddTarget(query.trim())
|
||||
}
|
||||
}}
|
||||
placeholder="Add note"
|
||||
placeholderTextColor={colors.mutedText}
|
||||
style={styles.relationshipInput}
|
||||
value={query}
|
||||
/>
|
||||
{suggestions.map((suggestion) => (
|
||||
<Pressable
|
||||
key={suggestion.id}
|
||||
onPress={() => onAddTarget(suggestion.id)}
|
||||
style={({ pressed }) => [styles.relationshipSuggestion, pressed ? styles.pressed : null]}
|
||||
>
|
||||
<Text style={styles.relationshipSuggestionText}>{suggestion.title}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function AddRelationshipGroup({
|
||||
note,
|
||||
onAdd,
|
||||
}: {
|
||||
note: MobileNote
|
||||
onAdd: (key: string) => void
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
|
||||
return (
|
||||
<View style={styles.relationshipGroup}>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onChangeText={setName}
|
||||
onSubmitEditing={() => {
|
||||
const key = relationshipKeyFromLabel(name)
|
||||
if (key && !note.relationships[key]) {
|
||||
onAdd(key)
|
||||
setName('')
|
||||
}
|
||||
}}
|
||||
placeholder="+ Add relationship"
|
||||
placeholderTextColor={colors.mutedText}
|
||||
style={styles.relationshipInput}
|
||||
value={name}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={styles.relationshipGroup}>
|
||||
<Text style={styles.propertyGroupTitle}>Custom properties</Text>
|
||||
{entries.map(([key, value]) => (
|
||||
<View key={key} style={styles.customPropertyRow}>
|
||||
<Text style={styles.customPropertyKey}>{formatRelationshipLabel(key)}</Text>
|
||||
<TextInput
|
||||
onChangeText={(nextValue) => onChangeProperties?.({ customProperties: { ...note.customProperties, [key]: nextValue } })}
|
||||
style={styles.customPropertyValue}
|
||||
value={value}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
const rest = customPropertiesWithoutKey({ key, properties: note.customProperties })
|
||||
onChangeProperties?.({ customProperties: rest, removedCustomPropertyKeys: [key] })
|
||||
}}
|
||||
style={({ pressed }) => [styles.relationshipRemoveButton, pressed ? styles.pressed : null]}
|
||||
>
|
||||
<X color={colors.mutedText} size={13} />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
<View style={styles.customPropertyAddRow}>
|
||||
<TextInput
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onChangeText={setDraftKey}
|
||||
placeholder="Property"
|
||||
placeholderTextColor={colors.mutedText}
|
||||
style={styles.customPropertyAddInput}
|
||||
value={draftKey}
|
||||
/>
|
||||
<TextInput
|
||||
onChangeText={setDraftValue}
|
||||
onSubmitEditing={() => {
|
||||
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}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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 = <Text style={styles.relationshipChipText}>{note?.title ?? target}</Text>
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{chip}
|
||||
{onRemove ? (
|
||||
<Pressable onPress={onRemove} style={styles.relationshipRemoveButton}>
|
||||
<X color={appearance.color} size={12} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
|
||||
return note && onOpenNote ? (
|
||||
<Pressable
|
||||
onPress={() => 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}
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={styles.relationshipChip}>{chip}</View>
|
||||
<View style={[styles.relationshipChip, { backgroundColor: appearance.backgroundColor, borderColor: appearance.borderColor }]}>{content}</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, string>
|
||||
}) {
|
||||
return Object.fromEntries(Object.entries(properties).filter(([candidate]) => candidate !== key))
|
||||
}
|
||||
|
||||
function PanelToolbar({ onClose }: { onClose?: () => void }) {
|
||||
return (
|
||||
<View style={styles.toolbar}>
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.rawEditorContent}>
|
||||
@@ -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 ? (
|
||||
<View style={styles.rawEditorSuggestionMenu}>
|
||||
{suggestions.map((suggestion) => (
|
||||
<Pressable
|
||||
key={suggestion.id}
|
||||
onPress={() => {
|
||||
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]}
|
||||
>
|
||||
<Text style={styles.rawEditorSuggestionTitle}>{suggestion.title}</Text>
|
||||
<Text style={styles.rawEditorSuggestionMeta}>{suggestion.id}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
'---',
|
||||
|
||||
53
apps/mobile/src/mobileAiClient.test.ts
Normal file
53
apps/mobile/src/mobileAiClient.test.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
45
apps/mobile/src/mobileAiClient.ts
Normal file
45
apps/mobile/src/mobileAiClient.ts
Normal file
@@ -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 : ''
|
||||
}
|
||||
@@ -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'] },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,15 +3,38 @@ import { splitFrontmatter } from '@tolaria/markdown'
|
||||
export type MobileNoteFrontmatter = {
|
||||
archived?: boolean
|
||||
belongsTo: string[]
|
||||
customProperties: Record<string, string>
|
||||
date?: string
|
||||
favorite?: boolean
|
||||
favoriteIndex?: number
|
||||
has: string[]
|
||||
icon?: string
|
||||
relatedTo: string[]
|
||||
relationships: Record<string, string[]>
|
||||
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<T>({
|
||||
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<T>(value: T | null): value is T {
|
||||
return value !== null
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,16 +3,22 @@ import { splitFrontmatter } from '@tolaria/markdown'
|
||||
export type WritableMobileNoteFrontmatter = {
|
||||
archived?: boolean
|
||||
belongsTo?: string[]
|
||||
customProperties?: Record<string, string>
|
||||
date?: string
|
||||
favorite?: boolean
|
||||
favoriteIndex?: number | null
|
||||
has?: string[]
|
||||
icon?: string
|
||||
relatedTo?: string[]
|
||||
removedCustomPropertyKeys?: string[]
|
||||
removedRelationshipKeys?: string[]
|
||||
relationships?: Record<string, string[]>
|
||||
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<string>
|
||||
line: string
|
||||
}) {
|
||||
const key = line.split(':', 1)[0]
|
||||
return isText(line) && line !== '---' && !writableKeys.has(key) && !dynamicKeys.has(key)
|
||||
}
|
||||
|
||||
function customPropertyLines(properties: Record<string, string> | undefined) {
|
||||
return frontmatterRecordEntries(properties).map(([key, value]) => scalarLine({ key, value }))
|
||||
}
|
||||
|
||||
function customRelationshipLines(relationships: Record<string, string[]> | undefined) {
|
||||
return frontmatterRecordEntries(relationships).map(([key, values]) => listLine({ key, values }))
|
||||
}
|
||||
|
||||
function frontmatterRecordEntries<T>(record: Record<string, T> | 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 {
|
||||
|
||||
@@ -8,6 +8,9 @@ export type MobileNoteRelationship = {
|
||||
export type MobileNoteSource = {
|
||||
archived?: boolean
|
||||
belongsTo?: string[]
|
||||
customProperties?: Record<string, string>
|
||||
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<string, string[]>
|
||||
status?: string
|
||||
tags: string[]
|
||||
}
|
||||
@@ -25,9 +29,13 @@ export type MobileNote = Omit<MobileNoteSource, 'filename'> & {
|
||||
archived: boolean
|
||||
backlinks: MobileNoteRelationship[]
|
||||
belongsTo: string[]
|
||||
customProperties: Record<string, string>
|
||||
favorite: boolean
|
||||
favoriteIndex: number | null
|
||||
has: string[]
|
||||
outgoingLinks: string[]
|
||||
relatedTo: string[]
|
||||
relationships: Record<string, string[]>
|
||||
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: [],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
48
apps/mobile/src/mobileTypeAppearance.ts
Normal file
48
apps/mobile/src/mobileTypeAppearance.ts
Normal file
@@ -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<string, MobileChipAppearance> = {
|
||||
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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
177
apps/mobile/src/mobileViewFilters.ts
Normal file
177
apps/mobile/src/mobileViewFilters.ts
Normal file
@@ -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<MobileFilterOp, OperationMatcher> = {
|
||||
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<string, MobileFieldValue> {
|
||||
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
|
||||
}
|
||||
52
apps/mobile/src/mobileWikilinkAutocomplete.test.ts
Normal file
52
apps/mobile/src/mobileWikilinkAutocomplete.test.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
71
apps/mobile/src/mobileWikilinkAutocomplete.ts
Normal file
71
apps/mobile/src/mobileWikilinkAutocomplete.ts
Normal file
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
50
apps/mobile/src/styles/aiStyles.ts
Normal file
50
apps/mobile/src/styles/aiStyles.ts
Normal file
@@ -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,
|
||||
},
|
||||
})
|
||||
42
apps/mobile/src/styles/customPropertyStyles.ts
Normal file
42
apps/mobile/src/styles/customPropertyStyles.ts
Normal file
@@ -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,
|
||||
},
|
||||
})
|
||||
33
apps/mobile/src/styles/rawEditorSuggestionStyles.ts
Normal file
33
apps/mobile/src/styles/rawEditorSuggestionStyles.ts
Normal file
@@ -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,
|
||||
},
|
||||
})
|
||||
44
apps/mobile/src/styles/relationshipEditingStyles.ts
Normal file
44
apps/mobile/src/styles/relationshipEditingStyles.ts
Normal file
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user