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