Compare commits

..

86 Commits

Author SHA1 Message Date
lucaronin
e3146e535f fix: make mobile type picker searchable 2026-05-14 11:21:59 +02:00
lucaronin
97f6a8c2b4 fix: repair mobile wikilink autocomplete 2026-05-14 11:16:43 +02:00
lucaronin
456d6f9887 fix: route mobile tab indent through tentap 2026-05-14 11:07:02 +02:00
lucaronin
814b8ad586 fix: make tablet panel drags live 2026-05-14 11:02:14 +02:00
lucaronin
c76ef01eba feat: add rich mobile wikilink autocomplete 2026-05-13 17:15:06 +02:00
lucaronin
453c68a7a3 fix: raise mobile parity quality 2026-05-13 17:04:18 +02:00
lucaronin
a367293d9a feat: add mobile git sync transport 2026-05-12 16:06:17 +02:00
lucaronin
8eaf5a530d fix: refine mobile properties quality 2026-05-12 15:31:14 +02:00
lucaronin
a4c3c00567 fix: tighten mobile quality boundaries 2026-05-05 23:15:06 +02:00
lucaronin
72ea59de0b feat: add mobile workflow affordances 2026-05-05 20:29:56 +02:00
lucaronin
e6e616267b feat: add local mobile workflow parity 2026-05-05 17:03:44 +02:00
lucaronin
7a82caa80c feat: refine mobile prototype editing flows 2026-05-05 16:16:29 +02:00
lucaronin
8fc27e15da refactor: pass auth metadata to mobile git native layer 2026-05-05 14:42:06 +02:00
lucaronin
66dc461c0f feat: resolve mobile git native module 2026-05-05 14:36:18 +02:00
lucaronin
7e0914c147 fix: decode mobile editor html entities 2026-05-05 14:24:24 +02:00
lucaronin
b7ab2041f6 feat: serialize mobile editor dividers 2026-05-05 14:19:59 +02:00
lucaronin
2fb5b96313 refactor: include vault directory in mobile git requests 2026-05-05 14:14:30 +02:00
lucaronin
e7fada7805 feat: add native mobile git transport adapter 2026-05-05 13:58:14 +02:00
lucaronin
72f526e3f8 fix: block unsafe mobile editor links 2026-05-05 13:54:00 +02:00
lucaronin
0f33e8958b feat: add mobile git transport boundary 2026-05-05 13:50:04 +02:00
lucaronin
fc24575af0 feat: add mobile vault management card 2026-05-05 13:39:50 +02:00
lucaronin
217481dcee refactor: split mobile remote prompt styles 2026-05-05 13:35:38 +02:00
lucaronin
36560a7b8c feat: surface mobile github oauth config 2026-05-05 13:31:00 +02:00
lucaronin
8af4652f56 feat: add mobile vault remote setup 2026-05-05 13:25:58 +02:00
lucaronin
6540d70f34 feat: wire mobile git auth action 2026-05-05 13:21:08 +02:00
lucaronin
68723c0e96 feat: add mobile github oauth boundary 2026-05-05 13:13:11 +02:00
lucaronin
987bb3dfc3 feat: add mobile git credential storage 2026-05-05 13:07:03 +02:00
lucaronin
d60b7b5140 feat: show mobile git sync status 2026-05-05 12:59:33 +02:00
lucaronin
34044a66db feat: model mobile git sync plan 2026-05-05 12:51:26 +02:00
lucaronin
a337ddc9ea feat: add expandable mobile property pickers 2026-05-05 12:46:24 +02:00
lucaronin
1861721e9a feat: serialize mobile editor tables 2026-05-05 12:28:18 +02:00
lucaronin
4b3c06946c feat: serialize safe mobile editor images 2026-05-05 12:23:13 +02:00
lucaronin
0b50144194 chore: add mobile development client path 2026-05-05 12:14:15 +02:00
lucaronin
6589b46cb7 test: cover mobile core note flow 2026-05-05 11:10:43 +02:00
lucaronin
9eb86ab626 feat: expand mobile editor markdown serialization 2026-05-05 10:53:05 +02:00
lucaronin
63235b83a4 feat: edit mobile note properties 2026-05-05 10:48:17 +02:00
lucaronin
d08da1c7bf feat: save mobile note frontmatter 2026-05-05 10:32:10 +02:00
lucaronin
6cf7b4b118 feat: write mobile note frontmatter 2026-05-05 10:27:56 +02:00
lucaronin
bb88528020 feat: read mobile note frontmatter 2026-05-05 00:58:50 +02:00
lucaronin
bb1ab0df2c docs: mark mobile storage phase complete 2026-05-05 00:53:53 +02:00
lucaronin
428097f35d feat: show mobile vault load retry 2026-05-05 00:48:09 +02:00
lucaronin
a41be6f58d feat: load mobile vault runtime from metadata 2026-05-05 00:43:05 +02:00
lucaronin
111aa4fdd1 feat: persist mobile vault metadata 2026-05-05 00:38:35 +02:00
lucaronin
4db990a901 feat: delete local mobile notes 2026-05-05 00:33:59 +02:00
lucaronin
19e017b4c6 feat: persist mobile vault selection 2026-05-05 00:24:47 +02:00
lucaronin
46aa43c1cd feat: prompt for mobile note titles 2026-05-05 00:20:33 +02:00
lucaronin
1f66fbda59 feat: show mobile note create state 2026-05-04 19:05:13 +02:00
lucaronin
5da79cac74 feat: create local mobile notes 2026-05-04 19:00:09 +02:00
lucaronin
0efad2211a feat: refresh mobile notes after save 2026-05-04 18:54:01 +02:00
lucaronin
518b61201b feat: debounce mobile editor autosave 2026-05-04 18:48:06 +02:00
lucaronin
610e8c9703 feat: wire mobile editor save state 2026-05-04 11:14:43 +02:00
lucaronin
38805b0eaf feat: add mobile editor draft save 2026-05-04 11:05:42 +02:00
lucaronin
bd8e498505 feat: expand mobile editor serialization 2026-05-04 11:01:33 +02:00
lucaronin
f7eea2d997 feat: seed mobile app vault 2026-05-04 10:46:52 +02:00
lucaronin
035adceeae feat: add expo mobile vault storage 2026-05-04 10:38:18 +02:00
lucaronin
3ff99adb3d feat: add mobile vault storage boundary 2026-05-04 10:24:05 +02:00
lucaronin
b6f227abb2 feat: serialize mobile editor drafts 2026-05-04 10:18:47 +02:00
lucaronin
39b1118dc1 feat: add mobile editor draft boundary 2026-05-04 10:09:07 +02:00
lucaronin
a46cb21e2f feat: spike tentap mobile editor 2026-05-04 09:57:50 +02:00
lucaronin
3b94faf034 refactor: add mobile editor adapter 2026-05-04 09:46:22 +02:00
lucaronin
165ec6f2b0 feat: add mobile vault config model 2026-05-04 09:41:06 +02:00
lucaronin
601306afb5 feat: add mobile git auth strategy parsing 2026-05-04 09:34:48 +02:00
lucaronin
16de4e529b feat: add mobile compact swipe gestures 2026-05-04 09:30:13 +02:00
lucaronin
82545acbaa test: include mobile app in root test runner 2026-05-04 00:52:08 +02:00
lucaronin
a122a11d46 feat: add mobile vault repository boundary 2026-05-04 00:45:21 +02:00
lucaronin
5afd1ca62a refactor: separate mobile note projection 2026-05-04 00:42:40 +02:00
lucaronin
e0887c6d71 test: cover mobile compact navigation 2026-05-04 00:38:49 +02:00
lucaronin
ef544006d8 feat: scaffold expo mobile app 2026-05-04 00:35:24 +02:00
lucaronin
30d4d55b4a refactor: share note title helpers 2026-05-04 00:13:29 +02:00
lucaronin
a2da4c0563 refactor: share wikilink markdown utilities 2026-05-04 00:06:50 +02:00
lucaronin
b9311ee1f2 refactor: extract shared markdown package 2026-05-03 23:56:24 +02:00
lucaronin
9063b47252 docs: define mobile strategy 2026-05-03 23:42:38 +02:00
lucaronin
c7b6832cb1 fix: harden stale note rename and open flows 2026-05-03 19:51:15 +02:00
lucaronin
05a9aacd1e fix(git): preserve unicode path output 2026-05-03 19:24:27 +02:00
lucaronin
701ebe3f07 fix: hide toolbar during ime composition 2026-05-03 18:36:40 +02:00
lucaronin
f49b8f60dc fix: guard fresh-note heading paste 2026-05-03 18:27:26 +02:00
lucaronin
39615f49f3 fix: guard appimage colrv1 emoji font startup 2026-05-03 18:06:42 +02:00
lucaronin
3d7a0ee936 fix: guard stale checklist toggles 2026-05-03 17:42:04 +02:00
lucaronin
9faee02e04 test: observe hardened autosave requests 2026-05-03 17:25:31 +02:00
lucaronin
dfff3a848b fix: resolve codacy critical security findings 2026-05-03 17:25:31 +02:00
lucaronin
aaf0336733 feat: add direct AI model providers 2026-05-03 16:23:15 +02:00
lucaronin
5b906cb3a4 fix(inspector): show blank frontmatter properties 2026-05-03 15:05:37 +02:00
choi woo-nyong
0a40f12a6a fix: ko-KR 사이드바 Views 그룹 번역을 "조회수"에서 "보기"로 바로잡아 (#514)
sidebar.group.views 키가 "조회수"로 오역되어 있어 기능과 무관한 표현이었다

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-03 12:53:51 +02:00
lucaronin
033759598a fix: improve editor block controls 2026-05-03 12:28:00 +02:00
lucaronin
871b62fbd3 feat: add markdown whiteboards 2026-05-03 12:17:20 +02:00
lucaronin
aa310b25df fix(ai): expand agent vault paths before spawn 2026-05-03 11:57:15 +02:00
369 changed files with 33454 additions and 2231 deletions

View File

@@ -28,11 +28,16 @@ require_main_branch() {
fi
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$CURRENT_BRANCH" != "main" ]; then
echo "❌ Commits must happen on main. Current branch: $CURRENT_BRANCH"
echo " Merge or cherry-pick your work onto main, then commit there."
exit 1
fi
case "$CURRENT_BRANCH" in
main|codex/mobile)
return 0
;;
*)
echo "❌ Commits must happen on main. Current branch: $CURRENT_BRANCH"
echo " Merge or cherry-pick your work onto main, then commit there."
exit 1
;;
esac
}
require_main_branch
@@ -50,7 +55,7 @@ fi
# Unit tests
echo " → tests..."
pnpm exec vitest run --silent
pnpm test -- --silent
echo "✅ Pre-commit passed"

41
apps/mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

19
apps/mobile/App.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { StatusBar } from 'expo-status-bar'
import { StyleSheet } from 'react-native'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { MobileApp } from './src/MobileApp'
export default function App() {
return (
<GestureHandlerRootView style={styles.root}>
<MobileApp />
<StatusBar style="auto" />
</GestureHandlerRootView>
)
}
const styles = StyleSheet.create({
root: {
flex: 1,
},
})

37
apps/mobile/app.json Normal file
View File

@@ -0,0 +1,37 @@
{
"expo": {
"name": "Tolaria",
"slug": "tolaria-mobile",
"scheme": "tolaria",
"version": "0.1.0",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"bundleIdentifier": "com.tolaria.mobile.dev",
"supportsTablet": true
},
"android": {
"package": "com.tolaria.mobile.dev",
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-secure-store",
"expo-web-browser"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
apps/mobile/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

9
apps/mobile/index.ts Normal file
View File

@@ -0,0 +1,9 @@
import 'react-native-gesture-handler'
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

View File

@@ -0,0 +1,50 @@
const path = require('node:path')
const { getDefaultConfig } = require('expo/metro-config')
const projectRoot = __dirname
const workspaceRoot = path.resolve(projectRoot, '../..')
const config = getDefaultConfig(projectRoot)
const mobileNodeModules = path.resolve(projectRoot, 'node_modules')
const workspaceNodeModules = path.resolve(workspaceRoot, 'node_modules')
const mobileReactRoot = path.resolve(mobileNodeModules, 'react')
const mobileReactDomRoot = path.resolve(mobileNodeModules, 'react-dom')
config.watchFolders = [workspaceRoot]
config.resolver.nodeModulesPaths = [
mobileNodeModules,
workspaceNodeModules,
]
config.resolver.extraNodeModules = {
...config.resolver.extraNodeModules,
react: mobileReactRoot,
'react-dom': mobileReactDomRoot,
'react-native': path.resolve(workspaceNodeModules, 'react-native'),
}
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (moduleName === 'react' || moduleName.startsWith('react/')) {
return context.resolveRequest(
{ ...context, originModulePath: path.join(mobileReactRoot, 'index.js') },
moduleName,
platform,
)
}
if (moduleName === 'react-dom' || moduleName.startsWith('react-dom/')) {
return context.resolveRequest(
{ ...context, originModulePath: path.join(mobileReactDomRoot, 'index.js') },
moduleName,
platform,
)
}
if (moduleName === 'isomorphic-git') {
return {
filePath: path.resolve(workspaceNodeModules, 'isomorphic-git/index.js'),
type: 'sourceFile',
}
}
return context.resolveRequest(context, moduleName, platform)
}
module.exports = config

44
apps/mobile/package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "@tolaria/mobile",
"version": "0.1.0",
"main": "index.ts",
"scripts": {
"start": "expo start",
"start:dev-client": "expo start --dev-client",
"android": "expo start --android",
"ios": "expo start --ios",
"ios:dev-client": "expo run:ios --port 8091",
"test": "vitest run --config vitest.config.ts",
"typecheck": "tsc --noEmit -p tsconfig.json",
"web": "expo start --web"
},
"dependencies": {
"@10play/tentap-editor": "^1.0.1",
"@tolaria/markdown": "workspace:*",
"buffer": "^6.0.3",
"expo": "~54.0.34",
"expo-auth-session": "~7.0.11",
"expo-dev-client": "~6.0.21",
"expo-file-system": "19.0.22",
"expo-modules-core": "3.0.30",
"expo-secure-store": "~15.0.8",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.11",
"isomorphic-git": "^1.37.6",
"phosphor-react-native": "^3.0.6",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-safe-area-context": "^5.6.2",
"react-native-svg": "^15.12.1",
"react-native-webview": "13.15.0"
},
"devDependencies": {
"@types/react": "~19.1.17",
"@types/react-dom": "~19.1.11",
"typescript": "~5.9.3",
"vitest": "^4.0.18"
},
"private": true
}

View File

@@ -0,0 +1,148 @@
import { CaretLeft, GearSix, PaperPlaneTilt, Robot } from 'phosphor-react-native'
import { useState } from 'react'
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'
import type { MobileNote } from './mobileNoteProjection'
import type { MobileAiProvider } from './mobileAiSettings'
import { styles } from './styles'
import { colors } from './theme'
export function MobileAiPanel({
note,
onClose,
onOpenSettings,
onSendPrompt,
provider,
}: {
note: MobileNote
onClose?: () => void
onOpenSettings: () => void
onSendPrompt: (prompt: string, provider: MobileAiProvider) => Promise<string>
provider: MobileAiProvider | null
}) {
const [failed, setFailed] = useState(false)
const [isSending, setIsSending] = useState(false)
const [prompt, setPrompt] = useState('')
const [response, setResponse] = useState('')
const sendPrompt = () => {
if (!provider || prompt.trim().length === 0) return
setFailed(false)
setIsSending(true)
void onSendPrompt(prompt, provider)
.then(setResponse)
.catch(() => setFailed(true))
.finally(() => setIsSending(false))
}
return (
<View style={styles.properties}>
<AiToolbar onClose={onClose} onOpenSettings={onOpenSettings} />
{provider ? (
<AiChatSurface
failed={failed}
isSending={isSending}
note={note}
onChangePrompt={setPrompt}
onSend={sendPrompt}
prompt={prompt}
provider={provider}
response={response}
/>
) : (
<AiEmptyState onOpenSettings={onOpenSettings} />
)}
</View>
)
}
function AiToolbar({
onClose,
onOpenSettings,
}: {
onClose?: () => void
onOpenSettings: () => void
}) {
return (
<View style={styles.toolbar}>
<Text style={styles.propertiesTitle}>AI</Text>
<View style={styles.toolbarSpacer} />
<Pressable onPress={onOpenSettings} style={({ pressed }) => [styles.iconButton, pressed ? styles.pressed : null]}>
<GearSix size={23} color={colors.textSoft} />
</Pressable>
{onClose ? (
<Pressable onPress={onClose} style={({ pressed }) => [styles.iconButton, pressed ? styles.pressed : null]}>
<CaretLeft size={23} color={colors.textSoft} />
</Pressable>
) : null}
</View>
)
}
function AiEmptyState({ onOpenSettings }: { onOpenSettings: () => void }) {
return (
<View style={styles.aiEmptyState}>
<Robot color={colors.iconMuted} size={26} />
<Text style={styles.aiEmptyTitle}>No API model configured</Text>
<Text style={styles.aiEmptyDescription}>Add an API model in Settings before using the AI panel.</Text>
<Pressable onPress={onOpenSettings} style={({ pressed }) => [styles.aiSettingsButton, pressed ? styles.pressed : null]}>
<GearSix color="#ffffff" size={17} />
<Text style={styles.aiSettingsButtonText}>Open Settings</Text>
</Pressable>
</View>
)
}
function AiChatSurface({
failed,
isSending,
note,
onChangePrompt,
onSend,
prompt,
provider,
response,
}: {
failed: boolean
isSending: boolean
note: MobileNote
onChangePrompt: (value: string) => void
onSend: () => void
prompt: string
provider: MobileAiProvider
response: string
}) {
const canSend = prompt.trim().length > 0 && !isSending
return (
<ScrollView contentContainerStyle={styles.aiContent}>
<View style={styles.aiContextCard}>
<Text style={styles.aiContextTitle}>{provider.name} · {provider.modelId}</Text>
<Text numberOfLines={2} style={styles.aiContextDetail}>{note.title}</Text>
</View>
{response ? <Text style={styles.aiResponse}>{response}</Text> : <Text style={styles.aiEmptyDescription}>Ask about the active note. API models run in chat mode only.</Text>}
{failed ? <Text style={styles.propertyError}>AI request failed.</Text> : null}
<TextInput
multiline
onChangeText={onChangePrompt}
placeholder={`Ask about ${note.title}`}
placeholderTextColor={colors.mutedText}
style={styles.aiPrompt}
textAlignVertical="top"
value={prompt}
/>
<Pressable
disabled={!canSend}
onPress={onSend}
style={({ pressed }) => [
styles.aiSendButton,
!canSend ? styles.composeButtonDisabled : null,
pressed ? styles.pressed : null,
]}
>
<PaperPlaneTilt color="#ffffff" size={18} weight="fill" />
<Text style={styles.aiSendButtonText}>{isSending ? 'Sending' : 'Send'}</Text>
</Pressable>
</ScrollView>
)
}

View File

@@ -0,0 +1,197 @@
import { CaretLeft, Trash } from 'phosphor-react-native'
import { useState } from 'react'
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'
import {
mobileAiProviderPresets,
type MobileAiProvider,
type MobileAiProviderDraft,
type MobileAiProviderKind,
type MobileAiSettings,
} from './mobileAiSettings'
import { styles } from './styles'
import { colors } from './theme'
const providerKinds: MobileAiProviderKind[] = ['open_ai', 'anthropic', 'gemini', 'open_router', 'open_ai_compatible']
export function MobileAiSettingsPanel({
failed,
isSaving,
onAddProvider,
onClose,
onRemoveProvider,
settings,
}: {
failed: boolean
isSaving: boolean
onAddProvider: (draft: MobileAiProviderDraft) => Promise<boolean>
onClose?: () => void
onRemoveProvider: (providerId: string) => Promise<boolean>
settings: MobileAiSettings
}) {
return (
<View style={styles.properties}>
<SettingsToolbar onClose={onClose} />
<ScrollView contentContainerStyle={styles.aiSettingsContent}>
<Text style={styles.aiSettingsSectionTitle}>API models</Text>
<Text style={styles.aiSettingsDescription}>API keys are saved locally on this device and are not written to vault settings.</Text>
<ProviderList providers={settings.providers} onRemoveProvider={onRemoveProvider} />
<ProviderDraftForm failed={failed} isSaving={isSaving} onAddProvider={onAddProvider} />
</ScrollView>
</View>
)
}
function ProviderDraftForm({
failed,
isSaving,
onAddProvider,
}: {
failed: boolean
isSaving: boolean
onAddProvider: (draft: MobileAiProviderDraft) => Promise<boolean>
}) {
const [draft, setDraft] = useState<MobileAiProviderDraft>(() => initialDraft('open_ai'))
const canSave = isProviderDraftReady(draft)
const isDisabled = !canSave || isSaving
const submitProvider = () => {
if (isDisabled) return
void onAddProvider(draft).then((saved) => {
if (saved) {
setDraft(initialDraft(draft.kind))
}
})
}
return (
<>
<ProviderKindPicker value={draft.kind} onChange={(kind) => setDraft(initialDraft(kind))} />
<TextInput
onChangeText={(modelId) => setDraft((current) => ({ ...current, modelId }))}
placeholder={mobileAiProviderPresets[draft.kind].placeholder}
placeholderTextColor={colors.mutedText}
style={styles.aiInput}
value={draft.modelId}
/>
<TextInput
onChangeText={(apiKey) => setDraft((current) => ({ ...current, apiKey }))}
placeholder="API key"
placeholderTextColor={colors.mutedText}
secureTextEntry
style={styles.aiInput}
value={draft.apiKey}
/>
<ProviderSaveError failed={failed} />
<ProviderSaveButton disabled={isDisabled} isSaving={isSaving} onPress={submitProvider} />
</>
)
}
function ProviderSaveError({ failed }: { failed: boolean }) {
return failed ? <Text style={styles.propertyError}>Could not save AI settings.</Text> : null
}
function ProviderSaveButton({
disabled,
isSaving,
onPress,
}: {
disabled: boolean
isSaving: boolean
onPress: () => void
}) {
return (
<Pressable
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.aiSettingsButton,
disabled ? styles.composeButtonDisabled : null,
pressed ? styles.pressed : null,
]}
>
<Text style={styles.aiSettingsButtonText}>{isSaving ? 'Saving' : 'Add API model'}</Text>
</Pressable>
)
}
function isProviderDraftReady(draft: MobileAiProviderDraft) {
return draft.modelId.trim().length > 0 && draft.apiKey.trim().length > 0
}
function SettingsToolbar({ onClose }: { onClose?: () => void }) {
return (
<View style={styles.toolbar}>
<Text style={styles.propertiesTitle}>Settings</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 ProviderList({
onRemoveProvider,
providers,
}: {
onRemoveProvider: (providerId: string) => void
providers: MobileAiProvider[]
}) {
if (providers.length === 0) {
return <Text style={styles.aiSettingsEmpty}>No API models configured.</Text>
}
return (
<View style={styles.aiProviderList}>
{providers.map((provider) => (
<View key={provider.id} style={styles.aiProviderRow}>
<View style={styles.aiProviderText}>
<Text style={styles.aiProviderTitle}>{provider.name}</Text>
<Text numberOfLines={1} style={styles.aiProviderDetail}>{provider.modelId}</Text>
</View>
<Pressable onPress={() => onRemoveProvider(provider.id)} style={({ pressed }) => [styles.iconButton, pressed ? styles.pressed : null]}>
<Trash size={18} color={colors.textSoft} />
</Pressable>
</View>
))}
</View>
)
}
function ProviderKindPicker({
onChange,
value,
}: {
onChange: (value: MobileAiProviderKind) => void
value: MobileAiProviderKind
}) {
return (
<View style={styles.aiProviderKindRow}>
{providerKinds.map((kind) => (
<Pressable
key={kind}
onPress={() => onChange(kind)}
style={({ pressed }) => [
styles.aiProviderKindChip,
value === kind ? styles.aiProviderKindChipSelected : null,
pressed ? styles.pressed : null,
]}
>
<Text style={[styles.aiProviderKindText, value === kind ? styles.aiProviderKindTextSelected : null]}>{mobileAiProviderPresets[kind].name}</Text>
</Pressable>
))}
</View>
)
}
function initialDraft(kind: MobileAiProviderKind): MobileAiProviderDraft {
return {
apiKey: '',
kind,
modelId: '',
name: mobileAiProviderPresets[kind].name,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,482 @@
import { CaretDown, CaretRight } from 'phosphor-react-native'
import { useState, type ReactNode } from 'react'
import { Pressable, Text, TextInput, View, type StyleProp, type ViewStyle } from 'react-native'
import type { MobileNote } from './demoData'
import { NamedIcon, type IconName } from './NamedIcon'
import {
formatMobileNoteTags,
isMobileNotePropertySelected,
mobileNoteIconOptions,
mobileNoteStatusOptions,
mobileNoteTagOptions,
mobileNoteTypeOptions,
parseMobileNoteTags,
toggleMobileNoteTag,
type MobileNotePropertyPatch,
} from './mobileNoteProperties'
import { filterMobilePropertyComboOptions, resolveMobilePropertyComboValue } from './mobilePropertyCombo'
import { mobilePropertyDisplayValue, type MobilePropertyPickerKey } from './mobilePropertyPicker'
import { styles } from './styles'
import { colors } from './theme'
export function MobileEditablePropertyPickers({
disabled,
note,
onChangeProperties,
onSelectPicker,
openPicker,
}: {
disabled: boolean
note: MobileNote
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
onSelectPicker: (selected: MobilePropertyPickerKey) => void
openPicker: MobilePropertyPickerKey | null
}) {
const today = formatMobilePropertyDate(new Date())
return (
<>
<EditableTextProperty
disabled={disabled}
isOpen={openPicker === 'type'}
key={`type:${note.id}:${note.type}`}
label="Type"
placeholder="Type"
suggestions={mobileNoteTypeOptions}
value={note.type}
onCommit={(type) => onChangeProperties?.({ type })}
onOpen={() => onSelectPicker('type')}
variant="combo"
/>
<EditableTextProperty
disabled={disabled}
isOpen={openPicker === 'status'}
key={`status:${note.id}:${note.status ?? ''}`}
label="Status"
placeholder="Status"
suggestions={mobileNoteStatusOptions}
value={note.status ?? ''}
onCommit={(status) => onChangeProperties?.({ status })}
onOpen={() => onSelectPicker('status')}
/>
<EditableTextProperty
disabled={disabled}
isOpen={openPicker === 'date'}
key={`date:${note.id}:${note.date}`}
label="Date"
placeholder="Date"
suggestions={[today, '']}
value={note.date}
onCommit={(date) => onChangeProperties?.({ date })}
onOpen={() => onSelectPicker('date')}
/>
<IconProperty
disabled={disabled}
isOpen={openPicker === 'icon'}
note={note}
onChangeProperties={onChangeProperties}
onOpen={() => onSelectPicker('icon')}
/>
<TagsProperty
disabled={disabled}
isOpen={openPicker === 'tags'}
key={`tags:${note.id}:${formatMobileNoteTags(note.tags)}`}
note={note}
onChangeProperties={onChangeProperties}
onOpen={() => onSelectPicker('tags')}
/>
</>
)
}
function EditableTextProperty({
disabled,
isOpen,
label,
onCommit,
onOpen,
placeholder,
suggestions,
value,
variant = 'chips',
}: {
disabled: boolean
isOpen: boolean
label: string
onCommit: (value: string) => void
onOpen: () => void
placeholder: string
suggestions: readonly string[]
value: string
variant?: 'chips' | 'combo'
}) {
const [draft, setDraft] = useState(value)
const commitDraft = () => {
const next = variant === 'combo'
? resolveMobilePropertyComboValue({ options: suggestions, value: draft })
: draft
commitTextValue({ current: value, next, onCommit, onSettled: setDraft })
}
return (
<PropertyPickerSection
disabled={disabled}
isOpen={isOpen}
label={label}
value={mobilePropertyDisplayValue({ value })}
onOpen={onOpen}
>
<View style={styles.propertyPickerOptions}>
<TextInput
autoCapitalize="sentences"
autoFocus={variant === 'combo'}
editable={!disabled}
keyboardType="default"
onBlur={commitDraft}
onChangeText={setDraft}
onSubmitEditing={commitDraft}
placeholder={placeholder}
placeholderTextColor={colors.mutedText}
returnKeyType="done"
selectTextOnFocus={variant === 'combo'}
style={styles.propertyTextInput}
value={draft}
/>
{variant === 'combo'
? (
<PropertyComboOptions
disabled={disabled}
query={draft}
suggestions={suggestions}
value={value}
onSelect={(selected) => {
setDraft(selected)
onCommit(selected)
}}
/>
)
: (
<PropertyChipOptions>
{suggestions.map((option) => (
<PropertyTextChip
disabled={disabled}
key={option || 'none'}
option={option}
value={value}
onSelect={(selected) => {
setDraft(selected)
onCommit(selected)
}}
/>
))}
</PropertyChipOptions>
)}
</View>
</PropertyPickerSection>
)
}
function IconProperty({
disabled,
isOpen,
note,
onChangeProperties,
onOpen,
}: {
disabled: boolean
isOpen: boolean
note: MobileNote
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
onOpen: () => void
}) {
return (
<PropertyPickerSection
disabled={disabled}
isOpen={isOpen}
label="Icon"
value={mobilePropertyDisplayValue({ value: note.icon })}
onOpen={onOpen}
>
<PropertyChipOptions>
{mobileNoteIconOptions.map((option) => (
<PropertyIconChip
disabled={disabled}
key={option}
option={option}
value={note.icon}
onSelect={(icon) => onChangeProperties?.({ icon })}
/>
))}
</PropertyChipOptions>
</PropertyPickerSection>
)
}
function TagsProperty({
disabled,
isOpen,
note,
onChangeProperties,
onOpen,
}: {
disabled: boolean
isOpen: boolean
note: MobileNote
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
onOpen: () => void
}) {
const [draft, setDraft] = useState(formatMobileNoteTags(note.tags))
const commitDraft = () => onChangeProperties?.({ tags: parseMobileNoteTags(draft) })
return (
<PropertyPickerSection
disabled={disabled}
isOpen={isOpen}
label="Tags"
value={note.tags.length > 0 ? formatMobileNoteTags(note.tags) : 'None'}
onOpen={onOpen}
>
<View style={styles.propertyPickerOptions}>
<TextInput
autoCapitalize="none"
editable={!disabled}
keyboardType="default"
onBlur={commitDraft}
onChangeText={setDraft}
onSubmitEditing={commitDraft}
placeholder="Tags"
placeholderTextColor={colors.mutedText}
returnKeyType="done"
style={styles.propertyTextInput}
value={draft}
/>
<PropertyChipOptions>
{mobileNoteTagOptions.map((option) => (
<PropertyTextChip
disabled={disabled}
key={option}
option={option}
value={note.tags}
onSelect={(tag) => {
const tags = toggleMobileNoteTag(note.tags, tag)
setDraft(formatMobileNoteTags(tags))
onChangeProperties?.({ tags })
}}
/>
))}
</PropertyChipOptions>
</View>
</PropertyPickerSection>
)
}
function PropertyPickerSection({
children,
disabled,
isOpen,
label,
onOpen,
value,
}: {
children: ReactNode
disabled: boolean
isOpen: boolean
label: string
onOpen: () => void
value: string
}) {
return (
<>
<PropertyPickerRow disabled={disabled} isOpen={isOpen} label={label} value={value} onPress={onOpen} />
{isOpen ? children : null}
</>
)
}
function PropertyPickerRow({
disabled,
isOpen,
label,
onPress,
value,
}: {
disabled: boolean
isOpen: boolean
label: string
onPress: () => void
value: string
}) {
const Caret = isOpen ? CaretDown : CaretRight
return (
<Pressable
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [styles.propertyRow, disabled ? styles.propertyDisabled : null, pressed ? styles.pressed : null]}
>
<Text style={styles.propertyLabel}>{label}</Text>
<Text numberOfLines={1} style={styles.propertyValue}>{value}</Text>
<Caret size={16} color={colors.textSoft} />
</Pressable>
)
}
function PropertyIconChip({
disabled,
onSelect,
option,
value,
}: {
disabled: boolean
onSelect: (option: string) => void
option: string
value: string | undefined
}) {
const isSelected = isMobileNotePropertySelected({ current: value, option })
return (
<SelectablePropertyChip
disabled={disabled}
isSelected={isSelected}
onPress={() => onSelect(option)}
style={styles.propertyIconChip}
>
<NamedIcon color={isSelected ? colors.primary : colors.textSoft} name={option as IconName} size={20} />
</SelectablePropertyChip>
)
}
function PropertyChipOptions({ children }: { children: ReactNode }) {
return (
<View style={styles.propertyChipRow}>
{children}
</View>
)
}
function PropertyComboOptions({
disabled,
onSelect,
query,
suggestions,
value,
}: {
disabled: boolean
onSelect: (option: string) => void
query: string
suggestions: readonly string[]
value: string
}) {
const options = filterMobilePropertyComboOptions({ options: suggestions, query })
return (
<View style={styles.propertyComboBox}>
{options.map((option) => {
const isSelected = isMobileNotePropertySelected({ current: value, option })
return (
<Pressable
disabled={disabled}
key={option || 'none'}
onPress={() => onSelect(option)}
style={({ pressed }) => [
styles.propertyComboOption,
isSelected ? styles.propertyComboOptionSelected : null,
disabled ? styles.propertyDisabled : null,
pressed ? styles.pressed : null,
]}
>
<Text style={[styles.propertyComboOptionText, isSelected ? styles.propertyComboOptionTextSelected : null]}>
{option || 'None'}
</Text>
</Pressable>
)
})}
{options.length === 0 ? <Text style={styles.propertyComboEmpty}>No matching types.</Text> : null}
</View>
)
}
function PropertyTextChip({
disabled,
onSelect,
option,
value,
}: {
disabled: boolean
onSelect: (option: string) => void
option: string
value: readonly string[] | string | undefined
}) {
const isSelected = Array.isArray(value)
? value.includes(option)
: isMobileNotePropertySelected({ current: typeof value === 'string' ? value : undefined, option })
return (
<SelectablePropertyChip
disabled={disabled}
isSelected={isSelected}
onPress={() => onSelect(option)}
style={styles.propertyChip}
>
<Text style={[styles.propertyChipText, isSelected ? styles.propertyChipTextSelected : null]}>
{option || 'None'}
</Text>
</SelectablePropertyChip>
)
}
function SelectablePropertyChip({
children,
disabled,
isSelected,
onPress,
style,
}: {
children: ReactNode
disabled: boolean
isSelected: boolean
onPress: () => void
style: StyleProp<ViewStyle>
}) {
return (
<Pressable
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
style,
isSelected ? styles.propertyChipSelected : null,
disabled ? styles.propertyDisabled : null,
pressed ? styles.pressed : null,
]}
>
{children}
</Pressable>
)
}
function commitTextValue({
current,
next,
onCommit,
onSettled,
}: {
current: string
next: string
onCommit: (value: string) => void
onSettled?: (value: string) => void
}) {
const normalized = next.trim()
onSettled?.(normalized)
if (normalized !== current) {
onCommit(normalized)
}
}
function formatMobilePropertyDate(date: Date) {
return new Intl.DateTimeFormat(undefined, {
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(date)
}

View File

@@ -0,0 +1,126 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { KeyboardAvoidingView, Platform, View } from 'react-native'
import type { WebViewMessageEvent } from 'react-native-webview'
import { RichText, Toolbar, useEditorBridge } from '@10play/tentap-editor'
import { MobileEditorWikilinkSuggestions } from './MobileEditorWikilinkSuggestions'
import { parseEditorMessage, type MobileEditorWikilinkFrame } from './mobileEditorMessages'
import type { MobileNote } from './mobileNoteProjection'
import { createMobileEditorDraft, type MobileEditorDraft } from './mobileEditorDraft'
import {
createMobileEditorDocument,
createMobileEditorHtml,
} from './mobileEditorDocument'
import { resolveMobileRelationshipNote } from './mobileRelationshipRefs'
import { mobileEditorBridgeExtensions } from './mobileWikilinkEditorBridge'
import { mobileEditorCss, mobileEditorSetupScript } from './mobileEditorWebViewSetup'
import { styles } from './styles'
export function MobileEditorAdapter({
notes,
note,
onCreateNote,
onDraftChange,
onOpenNote,
}: {
notes: MobileNote[]
note: MobileNote
onCreateNote: () => void
onDraftChange?: (draft: MobileEditorDraft) => void
onOpenNote?: (noteId: string) => void
}) {
const document = useMemo(() => createMobileEditorDocument(note), [note])
const initialContent = useMemo(() => createMobileEditorHtml(document), [document])
const [wikilinkQuery, setWikilinkQuery] = useState<string | null>(null)
const [wikilinkFrame, setWikilinkFrame] = useState<MobileEditorWikilinkFrame | null>(null)
const draftTargetRef = useRef({ note, onDraftChange })
useEffect(() => {
draftTargetRef.current = { note, onDraftChange }
}, [note, onDraftChange])
const editor = useEditorBridge({
avoidIosKeyboard: true,
bridgeExtensions: mobileEditorBridgeExtensions,
initialContent,
onChange: () => {
const draftTarget = draftTargetRef.current
void editor.getHTML().then((editorHtml) => {
draftTarget.onDraftChange?.(createMobileEditorDraft({ editorHtml, note: draftTarget.note }))
})
},
})
const handleMessage = (event: WebViewMessageEvent) => {
const message = parseEditorMessage(event.nativeEvent.data)
if (!message) return
if (message.type === 'shortcut' && message.command === 'fileNewNote') {
onCreateNote()
return
}
if (message.type === 'wikilinkQuery') {
setWikilinkQuery(message.query)
setWikilinkFrame(message.frame)
return
}
if (message.type === 'listIndent') {
handleListIndent({ direction: message.direction, editor })
return
}
if (message.type !== 'openWikilink') return
const targetNote = resolveMobileRelationshipNote({ notes, target: message.target })
if (targetNote) {
onOpenNote?.(targetNote.id)
}
}
useEffect(() => {
const timer = setTimeout(() => {
applyMobileEditorWebViewSetup(editor)
}, 250)
return () => clearTimeout(timer)
}, [editor, note.id])
return (
<View style={styles.editorAdapterContent}>
<View style={styles.tentapEditor}>
<RichText key={note.id} editor={editor} onLoad={() => applyMobileEditorWebViewSetup(editor)} onMessage={handleMessage} />
</View>
<MobileEditorWikilinkSuggestions
excludeNoteId={note.id}
frame={wikilinkFrame}
notes={notes}
onSelectNote={(targetNote) => {
editor.insertWikilink({ label: targetNote.title, target: targetNote.id })
setWikilinkQuery(null)
setWikilinkFrame(null)
}}
query={wikilinkQuery}
/>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.tentapToolbar}
>
<Toolbar editor={editor} />
</KeyboardAvoidingView>
</View>
)
}
function applyMobileEditorWebViewSetup(editor: ReturnType<typeof useEditorBridge>) {
editor.injectCSS(mobileEditorCss, 'tolaria-mobile-editor')
editor.injectJS(mobileEditorSetupScript)
}
function handleListIndent({
direction,
editor,
}: {
direction: 'in' | 'out'
editor: ReturnType<typeof useEditorBridge>
}) {
if (direction === 'in') {
editor.sink()
return
}
editor.lift()
}

View File

@@ -0,0 +1,81 @@
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'
import type { MobileEditorSaveState } from './mobileEditorSaveState'
import { styles } from './styles'
import { colors } from './theme'
export function MobileEditorBreadcrumb({
isRawMode,
note,
onToggleArchive,
onToggleFavorite,
onToggleRawMode,
saveState,
}: {
isRawMode: boolean
note: MobileNote
onToggleArchive: () => void
onToggleFavorite: () => void
onToggleRawMode: () => void
saveState: MobileEditorSaveState
}) {
const ArchiveIcon = note.archived ? Tray : Archive
const RawIcon = isRawMode ? PencilSimpleLine : Code
return (
<View style={styles.editorBreadcrumb}>
<Text numberOfLines={1} style={styles.editorBreadcrumbText}>{note.type}</Text>
<Text style={styles.editorBreadcrumbDivider}>/</Text>
<Text numberOfLines={1} style={styles.editorBreadcrumbTitle}>{note.id}</Text>
<Text style={[styles.editorSaveState, saveStateStyle(saveState)]}>{saveState.label}</Text>
<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>
</View>
)
}
function saveStateStyle(saveState: MobileEditorSaveState) {
switch (saveState.state) {
case 'blocked':
return styles.editorSaveState_blocked
case 'failed':
return styles.editorSaveState_failed
case 'queued':
return styles.editorSaveState_queued
case 'saved':
return styles.editorSaveState_saved
case 'saving':
return styles.editorSaveState_saving
default:
return styles.editorSaveState_idle
}
}
function BreadcrumbButton({
children,
label,
onPress,
}: {
children: ReactNode
label: string
onPress: () => void
}) {
return (
<Pressable
accessibilityLabel={label}
onPress={onPress}
style={({ pressed }) => [styles.editorBreadcrumbButton, pressed ? styles.pressed : null]}
>
{children}
</Pressable>
)
}

View File

@@ -0,0 +1,57 @@
import { useMemo } from 'react'
import { Pressable, Text, View } from 'react-native'
import type { MobileEditorWikilinkFrame } from './mobileEditorMessages'
import type { MobileNote } from './mobileNoteProjection'
import { mobileNoteSuggestions } from './mobileWikilinkAutocomplete'
import { styles } from './styles'
export function MobileEditorWikilinkSuggestions({
frame,
excludeNoteId,
notes,
onSelectNote,
query,
}: {
frame: MobileEditorWikilinkFrame | null
excludeNoteId: string
notes: MobileNote[]
onSelectNote: (note: MobileNote) => void
query: string | null
}) {
const suggestions = useMemo(() => {
return query === null
? []
: mobileNoteSuggestions({ excludeNoteId, notes, query })
}, [excludeNoteId, notes, query])
if (query === null || suggestions.length === 0) {
return null
}
return (
<View style={[styles.rawEditorSuggestionMenu, suggestionMenuPosition(frame)]}>
{suggestions.map((suggestion) => (
<Pressable
key={suggestion.id}
onPress={() => onSelectNote(suggestion)}
style={({ pressed }) => [styles.rawEditorSuggestion, pressed ? styles.pressed : null]}
>
<Text style={styles.rawEditorSuggestionTitle}>{suggestion.title}</Text>
<Text style={styles.rawEditorSuggestionMeta}>{suggestion.id}</Text>
</Pressable>
))}
</View>
)
}
function suggestionMenuPosition(frame: MobileEditorWikilinkFrame | null) {
if (!frame) {
return null
}
return {
bottom: undefined,
left: Math.max(16, frame.left),
top: Math.max(16, frame.bottom + 8),
}
}

View File

@@ -0,0 +1,53 @@
import { GitBranch, WarningCircle } from 'phosphor-react-native'
import { Pressable, Text, View } from 'react-native'
import { mobileGitSyncStatusView, type MobileGitSyncStatusTone } from './mobileGitSyncStatus'
import type { MobileGitSyncPlan } from './mobileGitSyncPlan'
import { styles } from './styles'
import { colors } from './theme'
export function MobileGitSyncStatusCard({
onPrimaryAction,
plan,
}: {
onPrimaryAction?: () => void
plan: MobileGitSyncPlan
}) {
const status = mobileGitSyncStatusView(plan)
if (!status) {
return null
}
const Icon = status.tone === 'warning' || status.tone === 'attention' ? WarningCircle : GitBranch
return (
<View style={[styles.gitSyncStatus, gitSyncToneStyle(status.tone)]}>
<Icon size={18} color={colors.textSoft} />
<View style={styles.gitSyncStatusCopy}>
<Text style={styles.gitSyncStatusLabel}>{status.label}</Text>
<Text style={styles.gitSyncStatusDetail}>{status.detail}</Text>
</View>
{status.actionLabel ? (
<Pressable
accessibilityLabel={status.actionLabel}
onPress={onPrimaryAction}
style={({ pressed }) => pressed ? styles.pressed : null}
>
<Text style={styles.gitSyncStatusAction}>{status.actionLabel}</Text>
</Pressable>
) : null}
</View>
)
}
function gitSyncToneStyle(tone: MobileGitSyncStatusTone) {
switch (tone) {
case 'attention':
return styles.gitSyncStatus_attention
case 'neutral':
return styles.gitSyncStatus_neutral
case 'positive':
return styles.gitSyncStatus_positive
case 'warning':
return styles.gitSyncStatus_warning
}
}

View File

@@ -0,0 +1,477 @@
import { CaretLeft, Plus, X } from 'phosphor-react-native'
import { useState, type ReactNode } from 'react'
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'
import type { MobileNote } from './demoData'
import { mobileDerivedRelationshipGroups } from './mobileDerivedRelationships'
import { MobileEditablePropertyPickers } from './MobileEditablePropertyPickers'
import { MobileRelationshipNotePicker } from './MobileRelationshipNotePicker'
import type { MobileNotePropertyPatch } from './mobileNoteProperties'
import { nextMobilePropertyPicker, type MobilePropertyPickerKey } from './mobilePropertyPicker'
import {
canonicalMobileRelationshipRef,
filterMobileRelationshipRef,
hasMobileRelationshipRef,
mobileRelationshipDisplayLabel,
mobileWikilinkForNote,
resolveMobileRelationshipNote,
uniqueMobileRelationshipRefs,
} from './mobileRelationshipRefs'
import { mobileRelationshipAppearance } from './mobileTypeAppearance'
import { styles } from './styles'
import { colors } from './theme'
export function MobilePropertiesPanel({
failed = false,
isSaving = false,
notes = [],
note,
onChangeProperties,
onClose,
onOpenNote,
}: {
failed?: boolean
isSaving?: boolean
notes?: MobileNote[]
note: MobileNote
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
onClose?: () => void
onOpenNote?: (noteId: string) => void
}) {
const [openPicker, setOpenPicker] = useState<MobilePropertyPickerKey | null>(null)
const selectPicker = (selected: MobilePropertyPickerKey) => {
setOpenPicker((current) => nextMobilePropertyPicker({ current, selected }))
}
return (
<View style={styles.properties}>
<PanelToolbar onClose={onClose} />
<ScrollView contentContainerStyle={styles.propertiesContent}>
{failed ? <Text style={styles.propertyError}>Could not save property.</Text> : null}
<PropertySection title="System">
<MobileEditablePropertyPickers
disabled={isSaving}
note={note}
openPicker={openPicker}
onChangeProperties={onChangeProperties}
onSelectPicker={selectPicker}
/>
</PropertySection>
<PropertySection title="Relationships">
<EditableRelationships note={note} notes={notes} onChangeProperties={onChangeProperties} onOpenNote={onOpenNote} />
<DerivedRelationships note={note} notes={notes} onOpenNote={onOpenNote} />
</PropertySection>
<CustomProperties note={note} onChangeProperties={onChangeProperties} />
<PropertySection title="Info">
<BacklinkGroup backlinks={note.backlinks} onOpenNote={onOpenNote} />
<PropertyRow label="Words" value={String(note.words)} />
<PropertyRow label="Modified" value={note.modified} />
</PropertySection>
<PropertySection title="History">
<Text style={styles.historyItem}>eb373865c - Updated 1 note</Text>
<Text style={styles.historyItem}>5e853fdfe - Updated 1 note</Text>
</PropertySection>
</ScrollView>
</View>
)
}
function PropertySection({
children,
title,
}: {
children: ReactNode
title: string
}) {
return (
<View style={styles.propertySection}>
<Text style={styles.propertyGroupTitle}>{title}</Text>
{children}
</View>
)
}
function EditableRelationships({
note,
notes,
onChangeProperties,
onOpenNote,
}: {
note: MobileNote
notes: MobileNote[]
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
onOpenNote?: (noteId: string) => void
}) {
return (
<>
<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}
onDeleteGroup={() => onChangeProperties?.({
relationships: removeRelationshipKey({ key, relationships: note.relationships }),
removedRelationshipKeys: [key],
})}
targets={targets}
onChangeTargets={(nextTargets) => onChangeProperties?.({
relationships: { ...note.relationships, [key]: nextTargets },
removedRelationshipKeys: nextTargets.length === 0 ? [key] : undefined,
})}
onOpenNote={onOpenNote}
/>
))}
<AddRelationshipGroup
note={note}
notes={notes}
onAdd={(key, target) => onChangeProperties?.({ relationships: { ...note.relationships, [key]: [target] } })}
/>
</>
)
}
function DerivedRelationships({
note,
notes,
onOpenNote,
}: {
note: MobileNote
notes: MobileNote[]
onOpenNote?: (noteId: string) => void
}) {
const groups = mobileDerivedRelationshipGroups({ note, notes })
return (
<>
{groups.map((group) => (
<RelationshipGroup
key={group.label}
label={group.label}
notes={notes}
targets={group.targets}
onOpenNote={onOpenNote}
/>
))}
</>
)
}
function RelationshipGroup({
label,
notes,
onChangeTargets,
onDeleteGroup,
onOpenNote,
targets,
writableTargets = targets,
}: {
label: string
notes: MobileNote[]
onChangeTargets?: (targets: string[]) => void
onDeleteGroup?: () => void
onOpenNote?: (noteId: string) => void
targets: string[]
writableTargets?: string[]
}) {
const [isAdding, setIsAdding] = useState(false)
const uniqueTargets = uniqueMobileRelationshipRefs(targets)
const addTarget = (selectedNote: MobileNote) => {
const target = canonicalMobileRelationshipRef({ notes, value: mobileWikilinkForNote(selectedNote) })
if (!target) return
onChangeTargets?.(uniqueMobileRelationshipRefs([...writableTargets, target]))
setIsAdding(false)
}
return (
<View style={styles.relationshipGroup}>
<RelationshipHeader
canAdd={Boolean(onChangeTargets)}
canDelete={Boolean(onDeleteGroup)}
label={label}
onAdd={() => setIsAdding(true)}
onDelete={onDeleteGroup}
/>
<View style={styles.relationshipChipRow}>
{uniqueTargets.length === 0 ? <Text style={styles.relationshipEmpty}>None</Text> : null}
{uniqueTargets.map((target) => (
<RelationshipChip
key={target}
note={resolveMobileRelationshipNote({ notes, target })}
onRemove={hasMobileRelationshipRef({ target, values: writableTargets }) && onChangeTargets
? () => onChangeTargets(filterMobileRelationshipRef({ target, values: writableTargets }))
: undefined}
target={target}
onOpenNote={onOpenNote}
/>
))}
</View>
<MobileRelationshipNotePicker
notes={notes}
title={`Add ${label}`}
visible={isAdding}
onClose={() => setIsAdding(false)}
onSelectNote={addTarget}
/>
</View>
)
}
function RelationshipHeader({
canAdd,
canDelete,
label,
onAdd,
onDelete,
}: {
canAdd: boolean
canDelete: boolean
label: string
onAdd: () => void
onDelete?: () => void
}) {
return (
<View style={styles.relationshipHeader}>
<Text style={styles.propertyGroupTitle}>{label}</Text>
<View style={styles.relationshipHeaderActions}>
{canDelete ? (
<Pressable onPress={onDelete} style={({ pressed }) => [styles.relationshipAddButton, pressed ? styles.pressed : null]}>
<X color={colors.textSoft} size={14} />
</Pressable>
) : null}
{canAdd ? (
<Pressable onPress={onAdd} style={({ pressed }) => [styles.relationshipAddButton, pressed ? styles.pressed : null]}>
<Plus color={colors.textSoft} size={14} />
</Pressable>
) : null}
</View>
</View>
)
}
function AddRelationshipGroup({
note,
notes,
onAdd,
}: {
note: MobileNote
notes: MobileNote[]
onAdd: (key: string, target: string) => void
}) {
const [name, setName] = useState('')
const [pendingKey, setPendingKey] = useState<string | null>(null)
const openTargetPicker = () => {
const key = relationshipKeyFromLabel(name)
if (key && !note.relationships[key]) {
setPendingKey(key)
}
}
return (
<View style={styles.relationshipGroup}>
<TextInput
autoCapitalize="none"
autoCorrect={false}
onChangeText={setName}
onSubmitEditing={openTargetPicker}
placeholder="+ Add relationship"
placeholderTextColor={colors.mutedText}
style={styles.relationshipInput}
value={name}
/>
<MobileRelationshipNotePicker
notes={notes}
title={`Add ${formatRelationshipLabel(pendingKey ?? 'relationship')}`}
visible={Boolean(pendingKey)}
onClose={() => setPendingKey(null)}
onSelectNote={(targetNote) => {
if (!pendingKey) return
onAdd(pendingKey, mobileWikilinkForNote(targetNote))
setName('')
setPendingKey(null)
}}
/>
</View>
)
}
function CustomProperties({
note,
onChangeProperties,
}: {
note: MobileNote
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
}) {
const entries = Object.entries(note.customProperties)
return (
<PropertySection title="Custom properties">
{entries.length === 0 ? <Text style={styles.relationshipEmpty}>None</Text> : null}
{entries.map(([key, value]) => (
<PropertyRow
key={key}
label={formatRelationshipLabel(key)}
value={value}
onDelete={() => onChangeProperties?.({
customProperties: removeCustomPropertyKey({ customProperties: note.customProperties, key }),
removedCustomPropertyKeys: [key],
})}
/>
))}
</PropertySection>
)
}
function BacklinkGroup({
backlinks,
onOpenNote,
}: {
backlinks: MobileNote['backlinks']
onOpenNote?: (noteId: string) => void
}) {
if (backlinks.length === 0) {
return null
}
return (
<View style={styles.relationshipGroup}>
<Text style={styles.propertyGroupTitle}>Linked from</Text>
<View style={styles.relationshipChipRow}>
{backlinks.map((backlink) => (
<RelationshipChip
key={backlink.id}
note={backlink}
target={backlink.title}
onOpenNote={onOpenNote}
/>
))}
</View>
</View>
)
}
function RelationshipChip({
note,
onOpenNote,
onRemove,
target,
}: {
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 ?? mobileRelationshipDisplayLabel(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,
{ backgroundColor: appearance.backgroundColor, borderColor: appearance.borderColor },
pressed ? styles.pressed : null,
]}
>
{content}
</Pressable>
) : (
<View style={[styles.relationshipChip, { backgroundColor: appearance.backgroundColor, borderColor: appearance.borderColor }]}>{content}</View>
)
}
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 PanelToolbar({ onClose }: { onClose?: () => void }) {
return (
<View style={styles.toolbar}>
<Text style={styles.propertiesTitle}>Properties</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 PropertyRow({
label,
onDelete,
value,
}: {
label: string
onDelete?: () => void
value: string
}) {
return (
<View style={styles.propertyRow}>
<Text style={styles.propertyLabel}>{label}</Text>
<Text style={styles.propertyValue}>{value}</Text>
{onDelete ? (
<Pressable onPress={onDelete} style={styles.relationshipRemoveButton}>
<X color={colors.textSoft} size={14} />
</Pressable>
) : null}
</View>
)
}
function removeRelationshipKey({
key,
relationships,
}: {
key: string
relationships: Record<string, string[]>
}) {
return Object.fromEntries(Object.entries(relationships).filter(([relationshipKey]) => relationshipKey !== key))
}
function removeCustomPropertyKey({
customProperties,
key,
}: {
customProperties: Record<string, string>
key: string
}) {
return Object.fromEntries(Object.entries(customProperties).filter(([propertyKey]) => propertyKey !== key))
}

View File

@@ -0,0 +1,62 @@
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}>
<TextInput
autoCapitalize="none"
autoCorrect={false}
multiline
onChangeText={(markdown) => {
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>
)
}

View File

@@ -0,0 +1,73 @@
import { X } from 'phosphor-react-native'
import { useMemo, useState } from 'react'
import { Modal, Pressable, Text, TextInput, View, type GestureResponderEvent } from 'react-native'
import type { MobileNote } from './mobileNoteProjection'
import { mobileNoteSuggestions } from './mobileWikilinkAutocomplete'
import { styles } from './styles'
import { colors } from './theme'
export function MobileRelationshipNotePicker({
notes,
onClose,
onSelectNote,
title,
visible,
}: {
notes: MobileNote[]
onClose: () => void
onSelectNote: (note: MobileNote) => void
title: string
visible: boolean
}) {
const [query, setQuery] = useState('')
const suggestions = useMemo(
() => query.trim() ? mobileNoteSuggestions({ notes, query }) : [],
[notes, query],
)
return (
<Modal animationType="fade" transparent visible={visible} onRequestClose={onClose}>
<Pressable style={styles.relationshipPickerOverlay} onPress={onClose}>
<Pressable style={styles.relationshipPickerPanel} onPress={stopPressPropagation}>
<View style={styles.relationshipPickerHeader}>
<Text style={styles.relationshipPickerTitle}>{title}</Text>
<Pressable onPress={onClose} style={styles.relationshipPickerClose}>
<X color={colors.textSoft} size={18} />
</Pressable>
</View>
<TextInput
autoCapitalize="none"
autoCorrect={false}
autoFocus
onChangeText={setQuery}
placeholder="Search notes"
placeholderTextColor={colors.mutedText}
style={styles.relationshipPickerInput}
value={query}
/>
<View style={styles.relationshipPickerResults}>
{query.trim() ? null : <Text style={styles.relationshipPickerEmpty}>Type to find a note.</Text>}
{query.trim() && suggestions.length === 0 ? <Text style={styles.relationshipPickerEmpty}>No matching notes.</Text> : null}
{suggestions.map((suggestion) => (
<Pressable
key={suggestion.id}
onPress={() => {
onSelectNote(suggestion)
setQuery('')
}}
style={({ pressed }) => [styles.relationshipPickerResult, pressed ? styles.pressed : null]}
>
<Text numberOfLines={1} style={styles.relationshipPickerResultTitle}>{suggestion.title}</Text>
<Text numberOfLines={1} style={styles.relationshipPickerResultMeta}>{suggestion.type}</Text>
</Pressable>
))}
</View>
</Pressable>
</Pressable>
</Modal>
)
}
function stopPressPropagation(event: GestureResponderEvent) {
event.stopPropagation()
}

View File

@@ -0,0 +1,45 @@
import { Pressable, Text, View } from 'react-native'
import { GitBranch, HardDrive, SlidersHorizontal } from 'phosphor-react-native'
import { styles } from './styles'
import { colors } from './theme'
import type { MobileVaultMetadata } from './mobileVaultMetadata'
import { createMobileVaultManagementSummary } from './mobileVaultManagementSummary'
export function MobileVaultManagementCard({
onOpenRemoteSetup,
vault,
}: {
onOpenRemoteSetup: () => void
vault: MobileVaultMetadata
}) {
const summary = createMobileVaultManagementSummary(vault)
return (
<View style={styles.vaultManagementCard}>
<View style={styles.vaultManagementHeader}>
<View style={styles.vaultManagementIcon}>
<HardDrive size={18} color={colors.primary} />
</View>
<View style={styles.vaultManagementTitleGroup}>
<Text numberOfLines={1} style={styles.vaultManagementTitle}>{summary.name}</Text>
<Text style={styles.vaultManagementDetail}>{summary.storageLabel}</Text>
</View>
</View>
<View style={styles.vaultManagementRemoteRow}>
<GitBranch size={17} color={colors.iconMuted} />
<View style={styles.vaultManagementRemoteText}>
<Text style={styles.vaultManagementRemoteLabel}>{summary.remoteLabel}</Text>
<Text numberOfLines={1} style={styles.vaultManagementDetail}>{summary.remoteDetail}</Text>
</View>
</View>
<Pressable
accessibilityLabel="Configure Git remote"
onPress={onOpenRemoteSetup}
style={({ pressed }) => [styles.vaultManagementAction, pressed ? styles.pressed : null]}
>
<SlidersHorizontal size={17} color={colors.primary} />
<Text style={styles.vaultManagementActionText}>{summary.actionLabel}</Text>
</Pressable>
</View>
)
}

View File

@@ -0,0 +1,73 @@
import { Pressable, Text, TextInput, View } from 'react-native'
import { styles } from './styles'
export function MobileVaultRemotePrompt({
failed,
hasGitHubOAuthClientId,
isSaving,
onCancel,
onChangeRemoteUrl,
onSubmit,
remoteUrl,
}: {
failed: boolean
hasGitHubOAuthClientId: boolean
isSaving: boolean
onCancel: () => void
onChangeRemoteUrl: (remoteUrl: string) => void
onSubmit: () => void
remoteUrl: string
}) {
return (
<View style={styles.remotePrompt}>
<Text style={styles.remotePromptTitle}>Git remote</Text>
<TextInput
accessibilityLabel="Git remote URL"
autoCapitalize="none"
autoCorrect={false}
editable={!isSaving}
onChangeText={onChangeRemoteUrl}
onSubmitEditing={onSubmit}
placeholder="https://github.com/owner/repo.git"
returnKeyType="done"
style={styles.remotePromptInput}
value={remoteUrl}
/>
{!hasGitHubOAuthClientId ? (
<Text style={styles.remotePromptError}>GitHub login needs EXPO_PUBLIC_GITHUB_OAUTH_CLIENT_ID</Text>
) : null}
{failed ? <Text style={styles.remotePromptError}>Enter a valid Git remote URL</Text> : null}
<View style={styles.remotePromptActions}>
<PromptButton label="Cancel" onPress={onCancel} />
<PromptButton label={isSaving ? 'Saving' : 'Save'} onPress={onSubmit} disabled={isSaving} primary />
</View>
</View>
)
}
function PromptButton({
disabled,
label,
onPress,
primary,
}: {
disabled?: boolean
label: string
onPress: () => void
primary?: boolean
}) {
return (
<Pressable
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.remotePromptAction,
primary ? styles.remotePromptActionPrimary : null,
disabled ? styles.remotePromptActionDisabled : null,
pressed ? styles.pressed : null,
]}
>
<Text style={[styles.remotePromptActionText, primary ? styles.remotePromptActionTextPrimary : null]}>{label}</Text>
</Pressable>
)
}

View File

@@ -0,0 +1,36 @@
import {
Archive,
Books,
Drop,
FileText,
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' | 'robot' | 'star' | 'sun' | 'tray' | 'wrench'
const iconByName = {
archive: Archive,
books: Books,
drop: Drop,
'file-text': FileText,
flag: Flag,
'git-branch': GitBranch,
'pen-nib': PenNib,
robot: Robot,
star: Star,
sun: Sun,
tray: Tray,
wrench: Wrench,
} as const
export function NamedIcon({ color, name, size }: { color: string; name: IconName; size: number }) {
const Icon = iconByName[name]
return <Icon color={color} size={size} weight="regular" />
}

View File

@@ -0,0 +1,37 @@
import { View } from 'react-native'
import {
PanGestureHandler,
State,
type PanGestureHandlerStateChangeEvent,
} from 'react-native-gesture-handler'
import { compactSwipeEvent, detectHorizontalSwipe } from './compactGestures'
import type { CompactNavigationEvent, CompactPanel } from './compactNavigation'
import { styles } from './styles'
export function SwipeSurface({
children,
panel,
onNavigate,
}: {
children: React.ReactNode
panel: CompactPanel
onNavigate: (event: CompactNavigationEvent) => void
}) {
const handleStateChange = (event: PanGestureHandlerStateChangeEvent) => {
if (event.nativeEvent.state !== State.END) {
return
}
const direction = detectHorizontalSwipe(event.nativeEvent)
const navigationEvent = direction ? compactSwipeEvent(panel, direction) : null
if (navigationEvent) {
onNavigate(navigationEvent)
}
}
return (
<PanGestureHandler activeOffsetX={[-18, 18]} failOffsetY={[-24, 24]} onHandlerStateChange={handleStateChange}>
<View style={styles.swipeSurface}>{children}</View>
</PanGestureHandler>
)
}

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { compactSwipeEvent, detectHorizontalSwipe } from './compactGestures'
describe('compact mobile gestures', () => {
it('ignores short slow horizontal drags', () => {
expect(detectHorizontalSwipe({ translationX: 24, velocityX: 90 })).toBeNull()
})
it('detects committed left and right swipes', () => {
expect(detectHorizontalSwipe({ translationX: -72, velocityX: -120 })).toBe('left')
expect(detectHorizontalSwipe({ translationX: 20, velocityX: 520 })).toBe('right')
})
it('maps the requested Bear-style compact panel swipes', () => {
expect(compactSwipeEvent('list', 'left')).toEqual({ type: 'openSidebar' })
expect(compactSwipeEvent('sidebar', 'right')).toEqual({ type: 'closeSidebar' })
expect(compactSwipeEvent('note', 'right')).toEqual({ type: 'openProperties' })
expect(compactSwipeEvent('properties', 'left')).toEqual({ type: 'closeProperties' })
})
it('does not invent transitions for unsupported panel directions', () => {
expect(compactSwipeEvent('list', 'right')).toBeNull()
expect(compactSwipeEvent('note', 'left')).toBeNull()
})
})

View File

@@ -0,0 +1,36 @@
import type { CompactNavigationEvent, CompactPanel } from './compactNavigation'
export type SwipeDirection = 'left' | 'right'
export type SwipeSample = {
translationX: number
velocityX: number
}
const MIN_TRANSLATION = 56
const MIN_VELOCITY = 420
const compactGestureEvents: Partial<Record<`${CompactPanel}:${SwipeDirection}`, CompactNavigationEvent>> = {
'list:left': { type: 'openSidebar' },
'sidebar:right': { type: 'closeSidebar' },
'note:right': { type: 'openProperties' },
'properties:left': { type: 'closeProperties' },
}
export function detectHorizontalSwipe(sample: SwipeSample): SwipeDirection | null {
if (!isCommittedSwipe(sample)) {
return null
}
return sample.translationX < 0 ? 'left' : 'right'
}
export function compactSwipeEvent(
panel: CompactPanel,
direction: SwipeDirection,
): CompactNavigationEvent | null {
return compactGestureEvents[`${panel}:${direction}`] ?? null
}
function isCommittedSwipe(sample: SwipeSample) {
return Math.abs(sample.translationX) >= MIN_TRANSLATION || Math.abs(sample.velocityX) >= MIN_VELOCITY
}

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import {
createCompactNavigationState,
transitionCompactNavigation,
type CompactNavigationState,
} from './compactNavigation'
describe('compact mobile navigation', () => {
it('starts on the note list with the first note selected', () => {
expect(createCompactNavigationState('workflow')).toEqual({
panel: 'list',
selectedNoteId: 'workflow',
})
})
it('opens a selected note from the list', () => {
const next = transitionCompactNavigation(createCompactNavigationState('workflow'), {
type: 'selectNote',
noteId: 'release',
})
expect(next).toEqual({
panel: 'note',
selectedNoteId: 'release',
})
})
it('returns from sidebar and note surfaces to the list', () => {
expect(panelAfter({ type: 'openSidebar' })).toBe('sidebar')
expect(panelAfter({ type: 'closeSidebar' }, 'sidebar')).toBe('list')
expect(panelAfter({ type: 'backToList' }, 'note')).toBe('list')
})
it('opens and closes note properties without changing the selected note', () => {
const noteState: CompactNavigationState = {
panel: 'note',
selectedNoteId: 'workflow',
}
const propertiesState = transitionCompactNavigation(noteState, { type: 'openProperties' })
const closedState = transitionCompactNavigation(propertiesState, { type: 'closeProperties' })
expect(propertiesState).toEqual({
panel: 'properties',
selectedNoteId: 'workflow',
})
expect(closedState).toEqual({
panel: 'note',
selectedNoteId: 'workflow',
})
})
})
function panelAfter(
event: Parameters<typeof transitionCompactNavigation>[1],
panel: CompactNavigationState['panel'] = 'list',
) {
const state = transitionCompactNavigation({ panel, selectedNoteId: 'workflow' }, event)
return state.panel
}

View File

@@ -0,0 +1,55 @@
export type CompactPanel = 'sidebar' | 'list' | 'note' | 'properties'
export type CompactNavigationState = {
panel: CompactPanel
selectedNoteId: string
}
export type CompactNavigationEvent =
| { type: 'backToList' }
| { type: 'closeProperties' }
| { type: 'closeSidebar' }
| { type: 'openProperties' }
| { type: 'openSidebar' }
| { type: 'selectNote'; noteId: string }
export function createCompactNavigationState(initialNoteId: string): CompactNavigationState {
return {
panel: 'list',
selectedNoteId: initialNoteId,
}
}
export function transitionCompactNavigation(
state: CompactNavigationState,
event: CompactNavigationEvent,
): CompactNavigationState {
if (event.type === 'selectNote') {
return { panel: 'note', selectedNoteId: event.noteId }
}
return {
...state,
panel: nextPanel(state.panel, event),
}
}
function nextPanel(currentPanel: CompactPanel, event: Exclude<CompactNavigationEvent, { type: 'selectNote' }>) {
if (event.type === 'openSidebar') {
return 'sidebar'
}
if (event.type === 'closeSidebar' || event.type === 'backToList') {
return 'list'
}
if (event.type === 'openProperties') {
return 'properties'
}
if (event.type === 'closeProperties') {
return 'note'
}
return currentPanel
}

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { notes } from './demoData'
import { createMobileSidebarSections } from './mobileSidebarNavigation'
describe('mobile demo data', () => {
it('derives note titles and snippets through shared markdown utilities', () => {
expect(notes[0].title).toBe('Workflow Orchestration Essay')
expect(notes[0].snippet).toContain('The current narrative / temptation')
expect(notes[0].words).toBeGreaterThan(20)
})
it('keeps the initial sidebar focused on inbox', () => {
const sidebarSections = createMobileSidebarSections(notes)
expect(sidebarSections[0].items[0]).toMatchObject({
label: 'Inbox',
selection: { kind: 'library', id: 'inbox' },
})
})
})

242
apps/mobile/src/demoData.ts Normal file
View File

@@ -0,0 +1,242 @@
import { projectMobileNotes, type MobileNote, type MobileNoteSource } from './mobileNoteProjection'
import { createFixtureMobileVaultRepository } from './mobileVaultRepository'
export type { MobileNote } from './mobileNoteProjection'
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',
icon: 'pen-nib',
date: 'May 13, 2026',
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',
'',
'- The current narrative / temptation: everything routed through an LLM.',
'- A real example (Tolaria + OpenClaw): OpenClaw does a lot for me in product development.',
'- The cost of AI everywhere: expensive, slow, and unpredictable.',
'- Where orchestration wins: observability, human-in-the-loop approvals, reliability.',
'',
'This connects to [[mobile-roadmap|the mobile roadmap]] and the [[workflow-orchestration-checklist]].',
].join('\n'),
},
{
archived: false,
belongsTo: ['Tolaria MVP'],
customProperties: { platform: 'iPad first' },
favorite: true,
favoriteIndex: 1,
has: ['workflow'],
id: 'mobile-roadmap',
type: 'Project',
icon: 'wrench',
date: 'May 5, 2026',
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',
'',
'Local-first workflow parity comes before cloud sync.',
'',
'- Sidebar navigation by type',
'- Raw editor for direct markdown edits',
'- Wikilinks like [[workflow]] and [[release]]',
].join('\n'),
},
{
archived: false,
belongsTo: ['workflow'],
customProperties: {},
has: [],
id: 'workflow-orchestration-checklist',
type: 'Note',
icon: 'file-text',
date: 'May 5, 2026',
modified: '2h ago',
filename: 'workflow-orchestration-checklist.md',
relatedTo: ['workflow'],
relationships: {},
tags: ['mobile'],
content: [
'---',
'title: Workflow Orchestration Checklist',
'type: Note',
'status: Draft',
'tags: [mobile]',
'belongs_to: [workflow]',
'related_to: [workflow]',
'---',
'',
'# Workflow Orchestration Checklist',
'',
'- Validate breadcrumbs',
'- Validate [[workflow]] wikilinks',
'- Validate relationships panel',
].join('\n'),
},
{
archived: false,
belongsTo: ['Tolaria MVP'],
customProperties: {},
has: [],
id: 'release',
type: 'Release Note',
icon: 'flag',
date: 'May 2, 2026',
modified: '12h ago',
filename: 'release.md',
relatedTo: ['workflow', 'mobile-roadmap'],
relationships: {},
tags: ['Release', 'Stable'],
content: [
'---',
'title: v2026-05-02',
'type: Release Note',
'status: Done',
'tags: [Release, Stable]',
'belongs_to: [Tolaria MVP]',
'related_to: [workflow, mobile-roadmap]',
'---',
'',
'# v2026-05-02',
'',
'Another Tolaria release in the bag. This one is focused on performance, bug fixes, and lower-friction note workflows.',
'',
'Follow-up work lives in [[mobile-roadmap]].',
].join('\n'),
},
{
archived: false,
belongsTo: ['Tolaria MVP'],
customProperties: {},
has: ['resources'],
id: 'migration',
type: 'Project',
icon: 'git-branch',
date: 'Apr 28, 2026',
modified: '1d ago',
filename: 'migration.md',
relatedTo: ['mobile-roadmap'],
relationships: {},
tags: ['Project', 'Resources'],
content: [
'---',
'title: Tolaria <> Obsidian migration proposal',
'type: Project',
'status: Active',
'tags: [Project, Resources]',
'belongs_to: [Tolaria MVP]',
'related_to: [mobile-roadmap]',
'has: [resources]',
'---',
'',
'# Tolaria <> Obsidian migration proposal',
'',
'Obsidian vaults are already close to Tolaria ideal substrate: local Markdown, portable attachments, and git-backed history.',
].join('\n'),
},
{
archived: false,
belongsTo: ['migration'],
customProperties: {},
has: [],
id: 'resources',
type: 'Resource',
icon: 'books',
date: 'Apr 20, 2026',
modified: '3d ago',
filename: 'resources.md',
relatedTo: ['migration', 'mobile-roadmap'],
relationships: {},
tags: ['Resources', 'mobile'],
content: [
'---',
'title: Mobile Resources',
'type: Resource',
'status: Active',
'tags: [Resources, mobile]',
'belongs_to: [migration]',
'related_to: [migration, mobile-roadmap]',
'---',
'',
'# Mobile Resources',
'',
'References for [[mobile-roadmap]] and local vault workflow QA.',
].join('\n'),
},
{
archived: true,
belongsTo: [],
customProperties: {},
has: [],
id: 'old-mobile-spike',
type: 'Note',
icon: 'archive',
date: 'Mar 30, 2026',
modified: 'last month',
filename: 'old-mobile-spike.md',
relatedTo: ['mobile-roadmap'],
relationships: {},
tags: ['mobile'],
content: [
'---',
'title: Old Mobile Spike',
'type: Note',
'archived: true',
'tags: [mobile]',
'related_to: [mobile-roadmap]',
'---',
'',
'# Old Mobile Spike',
'',
'Archived prototype notes kept around for comparison with [[mobile-roadmap]].',
].join('\n'),
},
]
export const notes: MobileNote[] = projectMobileNotes(demoNoteSources)
export const demoVaultRepository = createFixtureMobileVaultRepository(demoNoteSources)

View File

@@ -0,0 +1,58 @@
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',
note: note(),
prompt: 'Summarize',
provider: {
baseUrl: 'https://api.example.com/v1/',
id: 'provider',
kind: 'open_ai_compatible',
modelId: 'model',
name: 'Provider',
},
})).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,
}
}

View File

@@ -0,0 +1,45 @@
import type { MobileNote } from './mobileNoteProjection'
import type { MobileAiProvider } from './mobileAiSettings'
export type MobileAiRequest = {
apiKey: string
note: MobileNote
prompt: string
provider: MobileAiProvider
}
export async function sendMobileAiRequest(request: MobileAiRequest) {
const response = await fetch(`${request.provider.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.provider.modelId,
}),
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 : ''
}

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { createMobileAiProviderSecretStorage } from './mobileAiProviderSecretStorage'
describe('mobile AI provider secret storage', () => {
it('stores API keys in secure provider-scoped records', async () => {
const secrets = new Map<string, string>()
const storage = createMobileAiProviderSecretStorage({
deleteItemAsync: async (key) => {
secrets.delete(key)
},
getItemAsync: async (key) => secrets.get(key) ?? null,
setItemAsync: async (key, value) => {
secrets.set(key, value)
},
})
await storage.saveApiKey('Open-AI', 'secret')
expect(await storage.loadApiKey('open-ai')).toBe('secret')
await storage.removeApiKey('open-ai')
expect(await storage.loadApiKey('open-ai')).toBeNull()
})
})

View File

@@ -0,0 +1,29 @@
export type MobileAiProviderSecretStore = {
deleteItemAsync: (key: string) => Promise<void>
getItemAsync: (key: string) => Promise<string | null>
setItemAsync: (key: string, value: string) => Promise<void>
}
export type MobileAiProviderSecretStorage = {
loadApiKey: (providerId: string) => Promise<string | null>
removeApiKey: (providerId: string) => Promise<void>
saveApiKey: (providerId: string, apiKey: string) => Promise<void>
}
export function createMobileAiProviderSecretStorage(
secureStore: MobileAiProviderSecretStore,
): MobileAiProviderSecretStorage {
return {
loadApiKey: async (providerId) => secureStore.getItemAsync(providerKey(providerId)),
removeApiKey: async (providerId) => {
await secureStore.deleteItemAsync(providerKey(providerId))
},
saveApiKey: async (providerId, apiKey) => {
await secureStore.setItemAsync(providerKey(providerId), apiKey)
},
}
}
function providerKey(providerId: string) {
return ['tolaria', 'ai-provider', providerId.trim().toLowerCase(), 'api-key'].join(':')
}

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import {
buildMobileAiProvider,
normalizeMobileAiSettings,
removeMobileAiProvider,
selectedMobileAiProvider,
upsertMobileAiProvider,
} from './mobileAiSettings'
describe('mobile AI settings', () => {
it('builds API providers from presets without storing API keys', () => {
expect(buildMobileAiProvider({
draft: {
apiKey: 'secret',
kind: 'open_ai',
modelId: ' gpt-4.1-mini ',
name: '',
},
providerId: 'open-ai',
})).toEqual({
baseUrl: 'https://api.openai.com/v1',
id: 'open-ai',
kind: 'open_ai',
modelId: 'gpt-4.1-mini',
name: 'OpenAI',
})
})
it('normalizes persisted settings and selected provider', () => {
const settings = normalizeMobileAiSettings({
defaultProviderId: 'open-ai',
providers: [{
baseUrl: 'https://api.openai.com/v1',
id: 'open-ai',
kind: 'open_ai',
modelId: 'gpt-4.1-mini',
name: 'OpenAI',
}],
})
expect(selectedMobileAiProvider(settings)?.id).toBe('open-ai')
})
it('upserts and removes providers while maintaining the default target', () => {
const settings = upsertMobileAiProvider({
provider: provider('one'),
settings: { defaultProviderId: null, providers: [] },
})
expect(settings.defaultProviderId).toBe('one')
expect(removeMobileAiProvider({ providerId: 'one', settings })).toEqual({
defaultProviderId: null,
providers: [],
})
})
})
function provider(id: string) {
return {
baseUrl: 'https://api.openai.com/v1',
id,
kind: 'open_ai' as const,
modelId: 'gpt-4.1-mini',
name: 'OpenAI',
}
}

View File

@@ -0,0 +1,166 @@
export type MobileAiProviderKind = 'anthropic' | 'gemini' | 'open_ai' | 'open_ai_compatible' | 'open_router'
export type MobileAiProvider = {
baseUrl: string
id: string
kind: MobileAiProviderKind
modelId: string
name: string
}
export type MobileAiSettings = {
defaultProviderId: string | null
providers: MobileAiProvider[]
}
export type MobileAiProviderDraft = {
apiKey: string
kind: MobileAiProviderKind
modelId: string
name: string
}
export const defaultMobileAiSettings: MobileAiSettings = {
defaultProviderId: null,
providers: [],
}
export const mobileAiProviderPresets: Record<MobileAiProviderKind, { baseUrl: string; name: string; placeholder: string }> = {
anthropic: {
baseUrl: 'https://api.anthropic.com/v1',
name: 'Anthropic',
placeholder: 'claude-3-5-sonnet-latest',
},
gemini: {
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
name: 'Gemini',
placeholder: 'gemini-2.5-flash',
},
open_ai: {
baseUrl: 'https://api.openai.com/v1',
name: 'OpenAI',
placeholder: 'gpt-4.1-mini',
},
open_ai_compatible: {
baseUrl: 'https://api.example.com/v1',
name: 'Custom provider',
placeholder: 'model-id',
},
open_router: {
baseUrl: 'https://openrouter.ai/api/v1',
name: 'OpenRouter',
placeholder: 'openai/gpt-4.1-mini',
},
}
export function buildMobileAiProvider({
draft,
providerId,
}: {
draft: MobileAiProviderDraft
providerId: string
}): MobileAiProvider {
const preset = mobileAiProviderPresets[draft.kind]
return {
baseUrl: preset.baseUrl,
id: providerId,
kind: draft.kind,
modelId: draft.modelId.trim(),
name: draft.name.trim() || preset.name,
}
}
export function normalizeMobileAiSettings(value: unknown): MobileAiSettings {
if (!isSettingsRecord(value)) {
return defaultMobileAiSettings
}
const providers = value.providers.flatMap(normalizeMobileAiProvider)
return {
defaultProviderId: defaultProviderId({ providers, value: value.defaultProviderId }),
providers,
}
}
export function selectedMobileAiProvider(settings: MobileAiSettings) {
return settings.providers.find((provider) => provider.id === settings.defaultProviderId) ?? settings.providers[0] ?? null
}
export function upsertMobileAiProvider({
provider,
settings,
}: {
provider: MobileAiProvider
settings: MobileAiSettings
}): MobileAiSettings {
const providers = [provider, ...settings.providers.filter((item) => item.id !== provider.id)]
return { defaultProviderId: provider.id, providers }
}
export function removeMobileAiProvider({
providerId,
settings,
}: {
providerId: string
settings: MobileAiSettings
}): MobileAiSettings {
const providers = settings.providers.filter((provider) => provider.id !== providerId)
return {
defaultProviderId: settings.defaultProviderId === providerId ? providers[0]?.id ?? null : settings.defaultProviderId,
providers,
}
}
function defaultProviderId({
providers,
value,
}: {
providers: MobileAiProvider[]
value: unknown
}) {
return typeof value === 'string' && providers.some((provider) => provider.id === value)
? value
: providers[0]?.id ?? null
}
function normalizeMobileAiProvider(value: unknown): MobileAiProvider[] {
if (!isProviderRecord(value)) {
return []
}
return [{
baseUrl: value.baseUrl.trim(),
id: value.id.trim(),
kind: value.kind,
modelId: value.modelId.trim(),
name: value.name.trim(),
}]
}
function isSettingsRecord(value: unknown): value is { defaultProviderId?: unknown; providers: unknown[] } {
return typeof value === 'object'
&& value !== null
&& Array.isArray((value as { providers?: unknown }).providers)
}
function isProviderRecord(value: unknown): value is MobileAiProvider {
return typeof value === 'object'
&& value !== null
&& hasText((value as MobileAiProvider).baseUrl)
&& hasText((value as MobileAiProvider).id)
&& isProviderKind((value as MobileAiProvider).kind)
&& hasText((value as MobileAiProvider).modelId)
&& hasText((value as MobileAiProvider).name)
}
function hasText(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0
}
function isProviderKind(value: unknown): value is MobileAiProviderKind {
return value === 'anthropic'
|| value === 'gemini'
|| value === 'open_ai'
|| value === 'open_ai_compatible'
|| value === 'open_router'
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { createMobileAiSettingsStorage } from './mobileAiSettingsStorage'
describe('mobile AI settings storage', () => {
it('loads default settings when no file exists and saves normalized settings', async () => {
const files = new Map<string, string>()
const storage = createMobileAiSettingsStorage({
documentDirectory: 'file:///docs',
getInfoAsync: async (uri) => ({ exists: files.has(uri) }),
makeDirectoryAsync: async (uri) => {
files.set(uri, '')
},
readAsStringAsync: async (uri) => files.get(uri) ?? '',
writeAsStringAsync: async (uri, content) => {
files.set(uri, content)
},
})
expect(await storage.load()).toEqual({ defaultProviderId: null, providers: [] })
await storage.save({
defaultProviderId: 'provider',
providers: [{
baseUrl: 'https://api.openai.com/v1',
id: 'provider',
kind: 'open_ai',
modelId: 'gpt-4.1-mini',
name: 'OpenAI',
}],
})
expect(await storage.load()).toMatchObject({ defaultProviderId: 'provider' })
})
})

View File

@@ -0,0 +1,82 @@
import { defaultMobileAiSettings, normalizeMobileAiSettings, type MobileAiSettings } from './mobileAiSettings'
export type MobileAiSettingsFileInfo = {
exists: boolean
isDirectory?: boolean
}
export type MobileAiSettingsFileSystem = {
documentDirectory: string | null
getInfoAsync: (uri: string) => Promise<MobileAiSettingsFileInfo>
makeDirectoryAsync: (uri: string, options: { intermediates: true }) => Promise<void>
readAsStringAsync: (uri: string) => Promise<string>
writeAsStringAsync: (uri: string, content: string) => Promise<void>
}
export type MobileAiSettingsStorage = {
load: () => Promise<MobileAiSettings>
save: (settings: MobileAiSettings) => Promise<void>
}
export function createMobileAiSettingsStorage(
fileSystem: MobileAiSettingsFileSystem,
): MobileAiSettingsStorage {
return {
load: async () => loadMobileAiSettings(fileSystem),
save: async (settings) => saveMobileAiSettings({ fileSystem, settings }),
}
}
async function loadMobileAiSettings(fileSystem: MobileAiSettingsFileSystem) {
const fileUri = settingsFileUri(fileSystem)
const info = await fileSystem.getInfoAsync(fileUri)
if (!info.exists || info.isDirectory) {
return defaultMobileAiSettings
}
return parseMobileAiSettings(await fileSystem.readAsStringAsync(fileUri))
}
async function saveMobileAiSettings({
fileSystem,
settings,
}: {
fileSystem: MobileAiSettingsFileSystem
settings: MobileAiSettings
}) {
await ensureDirectory({ fileSystem, uri: settingsRootUri(fileSystem) })
await fileSystem.writeAsStringAsync(settingsFileUri(fileSystem), JSON.stringify(settings))
}
function parseMobileAiSettings(content: string) {
try {
return normalizeMobileAiSettings(JSON.parse(content))
} catch {
return defaultMobileAiSettings
}
}
async function ensureDirectory({
fileSystem,
uri,
}: {
fileSystem: MobileAiSettingsFileSystem
uri: string
}) {
const info = await fileSystem.getInfoAsync(uri)
if (!info.exists) {
await fileSystem.makeDirectoryAsync(uri, { intermediates: true })
}
}
function settingsFileUri(fileSystem: MobileAiSettingsFileSystem) {
return `${settingsRootUri(fileSystem)}/ai-settings.json`
}
function settingsRootUri(fileSystem: MobileAiSettingsFileSystem) {
if (!fileSystem.documentDirectory) {
throw new Error('Expo FileSystem documentDirectory is unavailable')
}
return `${fileSystem.documentDirectory.replace(/\/+$/, '')}/state`
}

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import {
createMobileAppStateStorage,
type MobileAppStateFileSystem,
} from './mobileAppStateStorage'
describe('mobile app state storage', () => {
it('returns default state when no app state file exists', async () => {
const storage = createMobileAppStateStorage(createMemoryAppStateFileSystem())
await expect(storage.load('personal')).resolves.toEqual({
activeVaultId: 'personal',
selectedNoteId: null,
})
})
it('persists and restores selected note id for the active vault', async () => {
const fileSystem = createMemoryAppStateFileSystem()
const storage = createMobileAppStateStorage(fileSystem)
await storage.save({ activeVaultId: 'personal', selectedNoteId: 'workflow' })
await expect(storage.load('personal')).resolves.toEqual({
activeVaultId: 'personal',
selectedNoteId: 'workflow',
})
})
it('ignores corrupt or mismatched state files', async () => {
const fileSystem = createMemoryAppStateFileSystem({
'file:///docs/state/app-state.json': '{"activeVaultId":"other","selectedNoteId":"workflow"}',
})
const storage = createMobileAppStateStorage(fileSystem)
await expect(storage.load('personal')).resolves.toEqual({
activeVaultId: 'personal',
selectedNoteId: null,
})
})
})
function createMemoryAppStateFileSystem(files: Record<string, string> = {}): MobileAppStateFileSystem {
const fileByUri = new Map(Object.entries(files))
const directoryUris = new Set(['file:///docs'])
return {
documentDirectory: 'file:///docs/',
getInfoAsync: async (uri) => ({
exists: fileByUri.has(uri) || directoryUris.has(uri),
isDirectory: directoryUris.has(uri),
}),
makeDirectoryAsync: async (uri) => {
directoryUris.add(uri)
},
readAsStringAsync: async (uri) => fileByUri.get(uri) ?? '',
writeAsStringAsync: async (uri, content) => {
fileByUri.set(uri, content)
},
}
}

View File

@@ -0,0 +1,126 @@
export type MobileAppState = {
activeVaultId: string
selectedNoteId: string | null
}
export type MobileAppStateFileInfo = {
exists: boolean
isDirectory?: boolean
}
export type MobileAppStateFileSystem = {
documentDirectory: string | null
getInfoAsync: (uri: string) => Promise<MobileAppStateFileInfo>
makeDirectoryAsync: (uri: string, options: { intermediates: true }) => Promise<void>
readAsStringAsync: (uri: string) => Promise<string>
writeAsStringAsync: (uri: string, content: string) => Promise<void>
}
export type MobileAppStateStorage = {
load: (activeVaultId: string) => Promise<MobileAppState>
save: (state: MobileAppState) => Promise<void>
}
export function createMobileAppStateStorage(
fileSystem: MobileAppStateFileSystem,
): MobileAppStateStorage {
return {
load: async (activeVaultId) => loadMobileAppState({ activeVaultId, fileSystem }),
save: async (state) => saveMobileAppState({ fileSystem, state }),
}
}
async function loadMobileAppState({
activeVaultId,
fileSystem,
}: {
activeVaultId: string
fileSystem: MobileAppStateFileSystem
}) {
const fileUri = appStateFileUri(fileSystem)
const info = await fileSystem.getInfoAsync(fileUri)
if (!info.exists || info.isDirectory) {
return defaultMobileAppState(activeVaultId)
}
return parseMobileAppState({
activeVaultId,
content: await fileSystem.readAsStringAsync(fileUri),
})
}
async function saveMobileAppState({
fileSystem,
state,
}: {
fileSystem: MobileAppStateFileSystem
state: MobileAppState
}) {
const rootUri = appStateRootUri(fileSystem)
await ensureDirectory({ fileSystem, uri: rootUri })
await fileSystem.writeAsStringAsync(appStateFileUri(fileSystem), JSON.stringify(state))
}
function parseMobileAppState({
activeVaultId,
content,
}: {
activeVaultId: string
content: string
}) {
try {
return coerceMobileAppState({ activeVaultId, value: JSON.parse(content) })
} catch {
return defaultMobileAppState(activeVaultId)
}
}
function coerceMobileAppState({
activeVaultId,
value,
}: {
activeVaultId: string
value: unknown
}): MobileAppState {
if (!isStateRecord(value) || value.activeVaultId !== activeVaultId) {
return defaultMobileAppState(activeVaultId)
}
return {
activeVaultId,
selectedNoteId: typeof value.selectedNoteId === 'string' ? value.selectedNoteId : null,
}
}
function defaultMobileAppState(activeVaultId: string): MobileAppState {
return { activeVaultId, selectedNoteId: null }
}
async function ensureDirectory({
fileSystem,
uri,
}: {
fileSystem: MobileAppStateFileSystem
uri: string
}) {
const info = await fileSystem.getInfoAsync(uri)
if (!info.exists) {
await fileSystem.makeDirectoryAsync(uri, { intermediates: true })
}
}
function appStateFileUri(fileSystem: MobileAppStateFileSystem) {
return `${appStateRootUri(fileSystem)}/app-state.json`
}
function appStateRootUri(fileSystem: MobileAppStateFileSystem) {
if (!fileSystem.documentDirectory) {
throw new Error('Expo FileSystem documentDirectory is unavailable')
}
return `${fileSystem.documentDirectory.replace(/\/+$/, '')}/state`
}
function isStateRecord(value: unknown): value is { activeVaultId?: unknown; selectedNoteId?: unknown } {
return typeof value === 'object' && value !== null
}

View File

@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest'
import { createMobileEditorDraft, type MobileEditorDraft } from './mobileEditorDraft'
import { createMobileAutosaveQueue, type MobileAutosaveScheduler } from './mobileAutosaveQueue'
import type { MobileEditorSaveState } from './mobileEditorSaveState'
describe('mobile autosave queue', () => {
it('debounces drafts for the same note', async () => {
const scheduler = createManualScheduler()
const savedDrafts: MobileEditorDraft[] = []
const states: string[] = []
const queue = createMobileAutosaveQueue({
delayMs: 300,
scheduler,
onStateChange: (_noteId, state) => states.push(state.state),
saveDraft: async (draft) => {
savedDrafts.push(draft)
return { status: 'saved', path: `${draft.noteId}.md` }
},
})
queue.enqueue(draftFor('workflow', '<h1>Workflow</h1><p>First</p>'))
queue.enqueue(draftFor('workflow', '<h1>Workflow</h1><p>Second</p>'))
await scheduler.flush()
expect(savedDrafts).toHaveLength(1)
expect(savedDrafts[0]).toMatchObject({ canonicalMarkdown: '# Workflow\n\nSecond' })
expect(states).toEqual(['queued', 'queued', 'saving', 'saved'])
})
it('ignores stale save results when a newer draft was queued', async () => {
const scheduler = createManualScheduler()
const savedMarkdown: string[] = []
const states: MobileEditorSaveState[] = []
let resolveFirstSave: () => void = () => {
throw new Error('First save was not scheduled')
}
const queue = createMobileAutosaveQueue({
delayMs: 300,
scheduler,
onSavedDraft: (draft) => savedMarkdown.push(draft.canonicalMarkdown),
onStateChange: (_noteId, state) => states.push(state),
saveDraft: (draft) =>
draftMarkdown(draft).includes('First')
? new Promise((resolve) => {
resolveFirstSave = () => resolve({ status: 'saved', path: `${draft.noteId}.md` })
})
: Promise.resolve({ status: 'saved', path: `${draft.noteId}.md` }),
})
queue.enqueue(draftFor('workflow', '<h1>Workflow</h1><p>First</p>'))
await scheduler.flush()
queue.enqueue(draftFor('workflow', '<h1>Workflow</h1><p>Second</p>'))
resolveFirstSave()
await Promise.resolve()
await scheduler.flush()
expect(states.map((state) => state.state)).toEqual(['queued', 'saving', 'queued', 'saving', 'saved'])
expect(savedMarkdown).toEqual(['# Workflow\n\nSecond'])
})
it('can cancel pending draft saves', async () => {
const scheduler = createManualScheduler()
const savedDrafts: MobileEditorDraft[] = []
const queue = createMobileAutosaveQueue({
delayMs: 300,
scheduler,
onStateChange: () => {},
saveDraft: async (draft) => {
savedDrafts.push(draft)
return { status: 'saved', path: `${draft.noteId}.md` }
},
})
queue.enqueue(draftFor('workflow', '<h1>Workflow</h1><p>First</p>'))
queue.cancelAll()
await scheduler.flush()
expect(savedDrafts).toEqual([])
})
})
function draftFor(noteId: string, editorHtml: string) {
return createMobileEditorDraft({
note: { id: noteId, title: 'Workflow', content: '# Workflow' },
editorHtml,
})
}
function draftMarkdown(draft: MobileEditorDraft) {
if (!draft.persistable) {
throw new Error('Expected persistable test draft')
}
return draft.canonicalMarkdown
}
function createManualScheduler() {
type ManualTimer = {
callback: () => void
active: boolean
}
const timers: ManualTimer[] = []
const scheduler: MobileAutosaveScheduler & { flush: () => Promise<void> } = {
set: (callback) => {
const timer = { callback, active: true }
timers.push(timer)
return timer
},
clear: (timer) => {
const manualTimer = timer as unknown as ManualTimer
manualTimer.active = false
},
flush: async () => {
const activeTimers = timers.splice(0).filter((timer) => timer.active)
for (const timer of activeTimers) {
timer.callback()
}
await Promise.resolve()
},
}
return scheduler
}

View File

@@ -0,0 +1,137 @@
import type { MobileEditorDraft } from './mobileEditorDraft'
import type { MobileEditorDraftSaveResult } from './mobileEditorDraftSave'
import {
queuedMobileEditorSaveState,
saveResultState,
savingMobileEditorSaveState,
failedMobileEditorSaveState,
type MobileEditorSaveState,
} from './mobileEditorSaveState'
export type MobileAutosaveTimer = unknown
export type MobileAutosaveScheduler = {
set: (callback: () => void, delayMs: number) => MobileAutosaveTimer
clear: (timer: MobileAutosaveTimer) => void
}
export type MobileAutosaveQueue = {
enqueue: (draft: MobileEditorDraft) => void
cancelAll: () => void
}
export type SavedMobileEditorDraft = Extract<MobileEditorDraft, { persistable: true }>
export function createMobileAutosaveQueue({
delayMs,
onSavedDraft,
onStateChange,
saveDraft,
scheduler = nativeScheduler,
}: {
delayMs: number
onSavedDraft?: (draft: SavedMobileEditorDraft) => void
onStateChange: (noteId: string, state: MobileEditorSaveState) => void
saveDraft: (draft: MobileEditorDraft) => Promise<MobileEditorDraftSaveResult>
scheduler?: MobileAutosaveScheduler
}): MobileAutosaveQueue {
const generationByNoteId = new Map<string, number>()
const timerByNoteId = new Map<string, MobileAutosaveTimer>()
return {
enqueue: (draft) => {
const generation = nextGeneration({ draft, generationByNoteId })
clearPendingTimer({ draft, scheduler, timerByNoteId })
onStateChange(draft.noteId, queuedMobileEditorSaveState)
timerByNoteId.set(
draft.noteId,
scheduler.set(() => {
timerByNoteId.delete(draft.noteId)
void saveLatestDraft({ draft, generation, generationByNoteId, onSavedDraft, onStateChange, saveDraft })
}, delayMs),
)
},
cancelAll: () => {
for (const timer of timerByNoteId.values()) {
scheduler.clear(timer)
}
timerByNoteId.clear()
},
}
}
const nativeScheduler: MobileAutosaveScheduler = {
set: (callback, delayMs) => setTimeout(callback, delayMs),
clear: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
}
async function saveLatestDraft({
draft,
generation,
generationByNoteId,
onSavedDraft,
onStateChange,
saveDraft,
}: {
draft: MobileEditorDraft
generation: number
generationByNoteId: Map<string, number>
onSavedDraft?: (draft: SavedMobileEditorDraft) => void
onStateChange: (noteId: string, state: MobileEditorSaveState) => void
saveDraft: (draft: MobileEditorDraft) => Promise<MobileEditorDraftSaveResult>
}) {
onStateChange(draft.noteId, savingMobileEditorSaveState)
try {
const result = await saveDraft(draft)
if (isLatestGeneration({ draft, generation, generationByNoteId })) {
onStateChange(draft.noteId, saveResultState(result))
if (result.status === 'saved' && draft.persistable) {
onSavedDraft?.(draft)
}
}
} catch {
if (isLatestGeneration({ draft, generation, generationByNoteId })) {
onStateChange(draft.noteId, failedMobileEditorSaveState)
}
}
}
function nextGeneration({
draft,
generationByNoteId,
}: {
draft: MobileEditorDraft
generationByNoteId: Map<string, number>
}) {
const generation = (generationByNoteId.get(draft.noteId) ?? 0) + 1
generationByNoteId.set(draft.noteId, generation)
return generation
}
function clearPendingTimer({
draft,
scheduler,
timerByNoteId,
}: {
draft: MobileEditorDraft
scheduler: MobileAutosaveScheduler
timerByNoteId: Map<string, MobileAutosaveTimer>
}) {
const timer = timerByNoteId.get(draft.noteId)
if (timer) {
scheduler.clear(timer)
}
}
function isLatestGeneration({
draft,
generation,
generationByNoteId,
}: {
draft: MobileEditorDraft
generation: number
generationByNoteId: Map<string, number>
}) {
return generationByNoteId.get(draft.noteId) === generation
}

View File

@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest'
import { createMobileEditorDraft } from './mobileEditorDraft'
import { saveMobileEditorDraft } from './mobileEditorDraftSave'
import { createMobileNoteFile } from './mobileNoteCreate'
import { saveMobileNoteFrontmatter } from './mobileNoteFrontmatterSave'
import { createMobileVaultConfig, type MobileVaultConfig } from './mobileVaultConfig'
import { createStoredMobileVaultRepository } from './mobileVaultRepository'
import { createMemoryMobileVaultStorage, type MobileVaultStorageDriver } from './mobileVaultStorage'
describe('mobile core flow smoke', () => {
it('creates, opens, edits, updates properties, and deletes an app-local note', async () => {
const vault = createVault()
const storage = createMemoryMobileVaultStorage([])
const noteId = await createNote({ storage, vault })
const openedNote = await readNote({ noteId, storage, vault })
expect(openedNote).toMatchObject({ id: noteId, title: 'Morning Plan', type: 'Note' })
await saveEditorContent({ note: openedNote, storage, vault })
await expect(storage.readMarkdownFile(vault, `${noteId}.md`)).resolves.toBe([
'---',
'title: Morning Plan',
'type: Note',
'created: 2026-05-05T08:00:00.000Z',
'---',
'# Morning Plan',
'',
'Edited agenda',
'',
'> Follow up',
].join('\n'))
await saveMobileNoteFrontmatter({
metadata: {
date: '5 May 2026',
icon: 'wrench',
status: 'Active',
tags: ['Tolaria MVP', 'mobile'],
type: 'Project',
},
noteId,
storage,
vault,
})
await expect(readNote({ noteId, storage, vault })).resolves.toMatchObject({
date: '5 May 2026',
icon: 'wrench',
status: 'Active',
tags: ['Tolaria MVP', 'mobile'],
type: 'Project',
})
await repository({ storage, vault }).deleteNote(noteId)
await expect(repository({ storage, vault }).readNote(noteId)).resolves.toBeNull()
})
})
async function createNote({
storage,
vault,
}: {
storage: MobileVaultStorageDriver
vault: MobileVaultConfig
}) {
const file = createMobileNoteFile({
now: new Date('2026-05-05T08:00:00.000Z'),
title: 'Morning Plan',
})
await storage.writeMarkdownFile(vault, file.path, file.content)
return file.path.replace(/\.md$/, '')
}
async function readNote({
noteId,
storage,
vault,
}: {
noteId: string
storage: MobileVaultStorageDriver
vault: MobileVaultConfig
}) {
const note = await repository({ storage, vault }).readNote(noteId)
if (!note) {
throw new Error(`Expected note ${noteId}`)
}
return note
}
async function saveEditorContent({
note,
storage,
vault,
}: {
note: Awaited<ReturnType<typeof readNote>>
storage: MobileVaultStorageDriver
vault: MobileVaultConfig
}) {
await saveMobileEditorDraft({
draft: createMobileEditorDraft({
note,
editorHtml: '<h1>Morning Plan</h1><p>Edited agenda</p><blockquote><p>Follow up</p></blockquote>',
}),
storage,
vault,
})
}
function repository({
storage,
vault,
}: {
storage: MobileVaultStorageDriver
vault: MobileVaultConfig
}) {
return createStoredMobileVaultRepository({ storage, vault })
}
function createVault() {
const result = createMobileVaultConfig({ id: 'personal', name: 'Personal Journal' })
if (!result.ok) {
throw new Error(result.error)
}
return result.config
}

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { shouldSeedDemoVault } from './mobileDemoVaultSeedPolicy'
describe('mobile demo vault loading', () => {
it('does not seed demo notes into remote-backed vaults', () => {
expect(shouldSeedDemoVault({
id: 'personal',
name: 'Personal Journal',
remoteUrl: 'https://github.com/refactoringhq/tolaria.git',
})).toBe(false)
})
it('keeps local-only vaults seeded for first-run simulator QA', () => {
expect(shouldSeedDemoVault({ id: 'personal', name: 'Personal Journal' })).toBe(true)
})
})

View File

@@ -0,0 +1,116 @@
import { demoNoteSources } from './demoData'
import type { MobileEditorDraft } from './mobileEditorDraft'
import { saveMobileEditorDraft } from './mobileEditorDraftSave'
import { saveMobileNoteFrontmatter } from './mobileNoteFrontmatterSave'
import type { WritableMobileNoteFrontmatter } from './mobileNoteFrontmatterWrite'
import { createMobileNoteFile } from './mobileNoteCreate'
import {
createMobileVaultConfigFromMetadata,
defaultMobileVaultMetadata,
type MobileVaultMetadata,
} from './mobileVaultMetadata'
import { createNativeMobileVaultStorage } from './mobileNativeVaultStorage'
import { createStoredMobileVaultRepository } from './mobileVaultRepository'
import { seedMobileVaultIfEmpty } from './mobileVaultSeed'
import type { MobileVaultFile } from './mobileVaultStorage'
import { shouldSeedDemoVault } from './mobileDemoVaultSeedPolicy'
export async function loadDemoVaultNotes(vaultMetadata = defaultMobileVaultMetadata) {
const storage = createNativeMobileVaultStorage()
const demoVault = createDemoVaultConfig(vaultMetadata)
if (shouldSeedDemoVault(vaultMetadata)) {
await seedMobileVaultIfEmpty({ files: demoVaultFiles(), storage, vault: demoVault })
await addMissingDemoVaultFiles({ files: demoVaultFiles(), storage, vault: demoVault })
}
return createStoredMobileVaultRepository({ storage, vault: demoVault }).listNotes()
}
export function saveDemoVaultDraft(draft: MobileEditorDraft, vaultMetadata = defaultMobileVaultMetadata) {
return saveMobileEditorDraft({
draft,
storage: createNativeMobileVaultStorage(),
vault: createDemoVaultConfig(vaultMetadata),
})
}
export async function createDemoVaultNote({
title,
vaultMetadata = defaultMobileVaultMetadata,
}: {
title?: string
vaultMetadata?: MobileVaultMetadata
} = {}) {
const storage = createNativeMobileVaultStorage()
const demoVault = createDemoVaultConfig(vaultMetadata)
const file = createMobileNoteFile({ title })
await storage.writeMarkdownFile(demoVault, file.path, file.content)
return createStoredMobileVaultRepository({ storage, vault: demoVault }).readNote(file.path.replace(/\.md$/, ''))
}
export async function deleteDemoVaultNote(noteId: string, vaultMetadata = defaultMobileVaultMetadata) {
const storage = createNativeMobileVaultStorage()
await createStoredMobileVaultRepository({
storage,
vault: createDemoVaultConfig(vaultMetadata),
}).deleteNote(noteId)
}
export function saveDemoVaultNoteFrontmatter({
metadata,
noteId,
vaultMetadata = defaultMobileVaultMetadata,
}: {
metadata: WritableMobileNoteFrontmatter
noteId: string
vaultMetadata?: MobileVaultMetadata
}) {
return saveDemoVaultDocumentChange({
vaultMetadata,
write: ({ storage, vault }) => saveMobileNoteFrontmatter({ metadata, noteId, storage, vault }),
})
}
function demoVaultFiles(): MobileVaultFile[] {
return demoNoteSources.map((source) => ({
path: source.filename,
content: source.content,
}))
}
function createDemoVaultConfig(vaultMetadata: MobileVaultMetadata) {
return createMobileVaultConfigFromMetadata(vaultMetadata)
}
function createDemoVaultStorageContext(vaultMetadata: MobileVaultMetadata) {
return {
storage: createNativeMobileVaultStorage(),
vault: createDemoVaultConfig(vaultMetadata),
}
}
function saveDemoVaultDocumentChange<T>({
vaultMetadata,
write,
}: {
vaultMetadata: MobileVaultMetadata
write: (context: ReturnType<typeof createDemoVaultStorageContext>) => T
}) {
return write(createDemoVaultStorageContext(vaultMetadata))
}
async function addMissingDemoVaultFiles({
files,
storage,
vault,
}: {
files: MobileVaultFile[]
storage: ReturnType<typeof createNativeMobileVaultStorage>
vault: ReturnType<typeof createDemoVaultConfig>
}) {
const existingPaths = new Set((await storage.listMarkdownFiles(vault)).map((file) => file.path))
const missingFiles = files.filter((file) => !existingPaths.has(file.path))
await Promise.all(missingFiles.map((file) => storage.writeMarkdownFile(vault, file.path, file.content)))
}

View File

@@ -0,0 +1,20 @@
import { createNativeMobileVaultStorage } from './mobileNativeVaultStorage'
import { saveMobileRawNote } from './mobileRawNoteSave'
import { createMobileVaultConfigFromMetadata, defaultMobileVaultMetadata, type MobileVaultMetadata } from './mobileVaultMetadata'
export function saveDemoVaultRawNote({
content,
noteId,
vaultMetadata = defaultMobileVaultMetadata,
}: {
content: string
noteId: string
vaultMetadata?: MobileVaultMetadata
}) {
return saveMobileRawNote({
content,
noteId,
storage: createNativeMobileVaultStorage(),
vault: createMobileVaultConfigFromMetadata(vaultMetadata),
})
}

View File

@@ -0,0 +1,5 @@
import type { MobileVaultMetadata } from './mobileVaultMetadata'
export function shouldSeedDemoVault(vaultMetadata: MobileVaultMetadata) {
return !vaultMetadata.remoteUrl?.trim()
}

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { mobileDerivedRelationshipGroups } from './mobileDerivedRelationships'
import type { MobileNote } from './mobileNoteProjection'
describe('mobile derived relationships', () => {
it('derives read-only inverse relationship groups from other notes', () => {
const current = note({ id: 'project/tolaria', title: 'Tolaria' })
const child = note({ belongsTo: ['[[project/tolaria|Tolaria]]'], id: 'essay', title: 'Essay' })
const related = note({ id: 'release', relatedTo: ['Tolaria'], title: 'Release' })
const custom = note({ id: 'owner', relationships: { owner: ['[[project/tolaria|Tolaria]]'] }, title: 'Owner' })
expect(mobileDerivedRelationshipGroups({ note: current, notes: [current, child, related, custom] })).toEqual([
{ label: 'Contains', targets: ['[[essay|Essay]]'] },
{ label: 'Related from', targets: ['[[release|Release]]'] },
{ label: 'Owner from', targets: ['[[owner|Owner]]'] },
])
})
it('deduplicates inverse targets inside a group', () => {
const current = note({ id: 'project/tolaria', title: 'Tolaria' })
const source = note({
belongsTo: ['[[project/tolaria|Tolaria]]', 'Tolaria'],
id: 'essay',
title: 'Essay',
})
expect(mobileDerivedRelationshipGroups({ note: current, notes: [current, source] })).toEqual([
{ label: 'Contains', targets: ['[[essay|Essay]]'] },
])
})
})
function note({
belongsTo = [],
has = [],
id,
relatedTo = [],
relationships = {},
title,
}: {
belongsTo?: string[]
has?: string[]
id: string
relatedTo?: string[]
relationships?: Record<string, 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: '',
status: undefined,
tags: [],
title,
type: 'Note',
words: 1,
}
}

View File

@@ -0,0 +1,68 @@
import type { MobileNote } from './mobileNoteProjection'
import { hasMobileRelationshipRef, mobileWikilinkForNote, uniqueMobileRelationshipRefs } from './mobileRelationshipRefs'
export type MobileDerivedRelationshipGroup = {
label: string
targets: string[]
}
export function mobileDerivedRelationshipGroups({
note,
notes,
}: {
note: MobileNote
notes: MobileNote[]
}) {
const groups = new Map<string, string[]>()
for (const source of notes) {
if (source.id !== note.id) {
appendDerivedGroups({ groups, note, source })
}
}
return [...groups.entries()]
.map(([label, targets]) => ({ label, targets: uniqueMobileRelationshipRefs(targets) }))
.filter((group) => group.targets.length > 0)
}
function appendDerivedGroups({
groups,
note,
source,
}: {
groups: Map<string, string[]>
note: MobileNote
source: MobileNote
}) {
appendIfLinked({ groups, label: 'Contains', note, source, values: source.belongsTo })
appendIfLinked({ groups, label: 'Related from', note, source, values: source.relatedTo })
appendIfLinked({ groups, label: 'Part of', note, source, values: source.has })
for (const [key, values] of Object.entries(source.relationships)) {
appendIfLinked({ groups, label: `${formatRelationshipLabel(key)} from`, note, source, values })
}
}
function appendIfLinked({
groups,
label,
note,
source,
values,
}: {
groups: Map<string, string[]>
label: string
note: MobileNote
source: MobileNote
values: string[]
}) {
if (
hasMobileRelationshipRef({ target: mobileWikilinkForNote(note), values })
|| hasMobileRelationshipRef({ target: note.title, values })
) {
groups.set(label, [...groups.get(label) ?? [], mobileWikilinkForNote(source)])
}
}
function formatRelationshipLabel(key: string) {
return key.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase())
}

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import { createMobileEditorDocument, createMobileEditorHtml } from './mobileEditorDocument'
describe('mobile editor document', () => {
it('strips frontmatter and title heading from the displayed editor body', () => {
expect(
createMobileEditorDocument({
id: 'workflow',
title: 'Workflow Orchestration Essay',
content: [
'---',
'type: Essay',
'---',
'',
'# Workflow Orchestration Essay',
'',
'The current narrative: everything routed through an LLM.',
].join('\n'),
}),
).toEqual({
leadingTitle: 'Workflow Orchestration Essay',
blocks: [
{
id: '0:The current narrative: everything routed through an LLM.',
kind: 'paragraph',
text: 'The current narrative: everything routed through an LLM.',
},
],
})
})
it('keeps colon paragraphs instead of treating them as frontmatter', () => {
const document = createMobileEditorDocument({
id: 'monday',
title: 'Notes for Monday',
content: '# Notes for Monday\n\nBottom line up front: ship the smallest useful slice.',
})
expect(document.blocks).toEqual([
{
id: '0:Bottom line up front: ship the smallest useful slice.',
kind: 'paragraph',
text: 'Bottom line up front: ship the smallest useful slice.',
},
])
})
it('normalizes markdown bullets for the native placeholder surface', () => {
const document = createMobileEditorDocument({
id: 'plan',
title: 'Plan',
content: '# Plan\n\n- Sidebar\n* Note list',
})
expect(document.blocks).toEqual([
{
id: '0:- Sidebar',
kind: 'bullet',
text: 'Sidebar',
},
{
id: '1:* Note list',
kind: 'bullet',
text: 'Note list',
},
])
})
it('creates escaped HTML for TenTap initial content', () => {
const html = createMobileEditorHtml({
leadingTitle: 'Tolaria <mobile>',
blocks: [
{
id: '0:Use TenTap',
kind: 'paragraph',
text: 'Use TenTap & keep markdown durable',
},
{
id: '1:- Escape quotes',
kind: 'bullet',
text: 'Escape "quotes"',
},
],
})
expect(html).toBe(
'<h1>Tolaria &lt;mobile&gt;</h1><p>Use TenTap &amp; keep markdown durable</p><ul><li>Escape &quot;quotes&quot;</li></ul>',
)
})
it('does not reinsert an H1 after the note body no longer starts with the title heading', () => {
const document = createMobileEditorDocument({
id: 'untitled',
title: 'Untitled',
content: 'Body without a leading heading',
})
expect(document.leadingTitle).toBeNull()
expect(createMobileEditorHtml(document)).toBe('<p>Body without a leading heading</p>')
})
it('renders wikilinks as clickable rich links in the editor HTML', () => {
const document = createMobileEditorDocument({
id: 'links',
title: 'Links',
content: '# Links\n\nSee [[mobile-roadmap|Mobile Roadmap]] next.',
})
expect(createMobileEditorHtml(document)).toContain(
'<a data-tolaria-wikilink="true" href="tolaria-note:mobile-roadmap">Mobile Roadmap</a>',
)
})
})

View File

@@ -0,0 +1,89 @@
import { splitFrontmatter } from '@tolaria/markdown'
export type MobileEditorBlock = {
id: string
kind: 'bullet' | 'paragraph'
text: string
}
export type MobileEditorDocument = {
leadingTitle: string | null
blocks: MobileEditorBlock[]
}
export type MobileEditorDocumentInput = {
id: string
title: string
content: string
}
export function createMobileEditorDocument(input: MobileEditorDocumentInput): MobileEditorDocument {
const [, body] = splitFrontmatter(input.content)
return {
leadingTitle: leadingTitle({ body, title: input.title }),
blocks: createBlocks({ body, title: input.title }),
}
}
export function createMobileEditorHtml(document: MobileEditorDocument) {
return `${titleHtml(document.leadingTitle)}${document.blocks.map(blockToHtml).join('') || '<p></p>'}`
}
function leadingTitle({ body, title }: { body: string; title: string }) {
const firstLine = body.split('\n').find((line) => line.trim().length > 0)?.trim()
return firstLine && isTitleHeading({ line: firstLine, title }) ? title : null
}
function createBlocks({ body, title }: { body: string; title: string }) {
return body
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !isTitleHeading({ line, title }))
.map((line, index) => createBlock({ index, line }))
}
function createBlock({ index, line }: { index: number; line: string }): MobileEditorBlock {
const bulletText = bulletContent({ line })
return {
id: `${index}:${line}`,
kind: bulletText ? 'bullet' : 'paragraph',
text: bulletText ?? line,
}
}
function bulletContent({ line }: { line: string }) {
const match = /^[-*]\s+(.+)$/.exec(line)
return match?.[1] ?? null
}
function isTitleHeading({ line, title }: { line: string; title: string }) {
return line === `# ${title}`
}
function blockToHtml(block: MobileEditorBlock) {
const text = inlineTextToHtml(block.text)
return block.kind === 'bullet' ? `<ul><li>${text}</li></ul>` : `<p>${text}</p>`
}
function titleHtml(title: string | null) {
return title ? `<h1>${escapeHtml({ value: title })}</h1>` : ''
}
function inlineTextToHtml(value: string) {
return escapeHtml({ value }).replace(/\[\[([^[\]]+?)\]\]/g, (_match, inner: string) => {
const [target, alias] = inner.split('|')
const label = alias?.trim() || target.trim()
return `<a data-tolaria-wikilink="true" href="tolaria-note:${encodeURIComponent(target.trim())}">${label}</a>`
})
}
function escapeHtml({ value }: { value: string }) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}

View File

@@ -0,0 +1,305 @@
import { describe, expect, it } from 'vitest'
import { createMobileEditorDraft } from './mobileEditorDraft'
describe('mobile editor draft', () => {
it('serializes supported TenTap HTML into canonical Markdown', () => {
expect(
createMobileEditorDraft({
note: {
id: 'workflow',
title: 'Workflow',
content: '# Workflow\n\nOriginal markdown',
},
editorHtml: '<h1>Workflow</h1><p>Edited content</p><ul><li>First</li><li>Second</li></ul>',
}),
).toEqual({
noteId: 'workflow',
sourceMarkdown: '# Workflow\n\nOriginal markdown',
editorHtml: '<h1>Workflow</h1><p>Edited content</p><ul><li>First</li><li>Second</li></ul>',
persistable: true,
canonicalMarkdown: '# Workflow\n\nEdited content\n\n- First\n- Second',
})
})
it('preserves source frontmatter outside the edited body', () => {
const draft = createMobileEditorDraft({
note: {
id: 'workflow',
title: 'Workflow',
content: '---\ntype: Essay\n---\n\n# Workflow\n\nOriginal markdown',
},
editorHtml: '<h1>Workflow</h1><p>Edited content</p>',
})
expect(draft).toMatchObject({
persistable: true,
canonicalMarkdown: '---\ntype: Essay\n---\n# Workflow\n\nEdited content',
})
})
it('decodes escaped text before writing Markdown', () => {
const draft = createMobileEditorDraft({
note: {
id: 'symbols',
title: 'Symbols',
content: '# Symbols',
},
editorHtml: '<h1>Symbols</h1><p>Use &lt;tags&gt; &amp; &quot;quotes&quot;, &#33;&#x3f; and non&nbsp;breaking space</p>',
})
expect(draft).toMatchObject({
persistable: true,
canonicalMarkdown: '# Symbols\n\nUse <tags> & "quotes", !? and non breaking space',
})
})
it('serializes headings, ordered lists, and inline marks', () => {
const draft = createMobileEditorDraft({
note: {
id: 'formatting',
title: 'Formatting',
content: '# Formatting',
},
editorHtml: [
'<h2>Section</h2>',
'<p>Use <strong>bold</strong>, <em>emphasis</em>, <code>code</code>, and <a href="https://tolaria.app">links</a>.</p>',
'<ol><li>First</li><li>Second</li></ol>',
].join(''),
})
expect(draft).toMatchObject({
persistable: true,
canonicalMarkdown: [
'## Section',
'',
'Use **bold**, *emphasis*, `code`, and [links](https://tolaria.app).',
'',
'1. First',
'1. Second',
].join('\n'),
})
})
it('serializes safe link destinations and decodes escaped link URLs', () => {
const draft = createMobileEditorDraft({
note: {
id: 'links',
title: 'Links',
content: '# Links',
},
editorHtml: [
'<p><a href="https://tolaria.app?ref=notes&amp;device=ios">Website</a></p>',
'<p><a href="mailto:hello@tolaria.app">Email</a></p>',
'<p><a href="notes/workflow.md">Relative note</a></p>',
].join(''),
})
expect(draft).toMatchObject({
persistable: true,
canonicalMarkdown: [
'[Website](https://tolaria.app?ref=notes&device=ios)',
'',
'[Email](mailto:hello@tolaria.app)',
'',
'[Relative note](notes/workflow.md)',
].join('\n'),
})
})
it('serializes rich wikilinks back to desktop-compatible wikilink markdown', () => {
const draft = createMobileEditorDraft({
note: {
id: 'links',
title: 'Links',
content: '# Links',
},
editorHtml: '<p>See <a data-tolaria-wikilink="true" href="tolaria-note:mobile-roadmap">Mobile Roadmap</a>.</p>',
})
expect(draft).toMatchObject({
persistable: true,
canonicalMarkdown: 'See [[mobile-roadmap|Mobile Roadmap]].',
})
})
it('serializes blockquotes, code blocks, and strikethrough', () => {
const draft = createMobileEditorDraft({
note: {
id: 'formatting',
title: 'Formatting',
content: '# Formatting',
},
editorHtml: [
'<blockquote><p>Quoted idea</p><p>Second line</p></blockquote>',
'<pre><code class="language-ts">const value = &lt;string&gt;input</code></pre>',
'<p>Keep <s>stale</s> text visible.</p>',
].join(''),
})
expect(draft).toMatchObject({
persistable: true,
canonicalMarkdown: [
'> Quoted idea',
'> Second line',
'',
'```ts',
'const value = <string>input',
'```',
'',
'Keep ~~stale~~ text visible.',
].join('\n'),
})
})
it('serializes horizontal rules from TenTap HTML', () => {
const draft = createMobileEditorDraft({
note: {
id: 'break',
title: 'Break',
content: '# Break',
},
editorHtml: '<p>Before</p><hr><p>After</p>',
})
expect(draft).toMatchObject({
persistable: true,
canonicalMarkdown: 'Before\n\n---\n\nAfter',
})
})
it('serializes TenTap-style task list items', () => {
const draft = createMobileEditorDraft({
note: {
id: 'tasks',
title: 'Tasks',
content: '# Tasks',
},
editorHtml: [
'<ul data-type="taskList">',
'<li data-checked="true"><label><input type="checkbox" checked=""></label><div><p>Done</p></div></li>',
'<li data-checked="false"><label><input type="checkbox"></label><div><p>Todo</p></div></li>',
'</ul>',
].join(''),
})
expect(draft).toMatchObject({
persistable: true,
canonicalMarkdown: '- [x] Done\n- [ ] Todo',
})
})
it('blocks unsupported HTML instead of persisting unknown editor output', () => {
expect(
createMobileEditorDraft({
note: {
id: 'workflow',
title: 'Workflow',
content: '# Workflow\n\nOriginal markdown',
},
editorHtml: '<figure><figcaption>Not yet supported</figcaption></figure>',
}),
).toEqual({
noteId: 'workflow',
sourceMarkdown: '# Workflow\n\nOriginal markdown',
editorHtml: '<figure><figcaption>Not yet supported</figcaption></figure>',
persistable: false,
blockedReason: 'unsupportedEditorHtml',
})
})
it('blocks unsafe link destinations instead of persisting risky Markdown', () => {
expect(
createMobileEditorDraft({
note: {
id: 'links',
title: 'Links',
content: '# Links',
},
editorHtml: '<p><a href="javascript:alert(1)">Unsafe</a></p>',
}),
).toMatchObject({
noteId: 'links',
persistable: false,
blockedReason: 'unsupportedEditorHtml',
})
})
it('serializes simple TenTap tables as Markdown tables', () => {
expect(
createMobileEditorDraft({
note: {
id: 'table',
title: 'Table',
content: '# Table',
},
editorHtml: [
'<table><tbody>',
'<tr><th><p>Name</p></th><th><p>Status</p></th></tr>',
'<tr><td><p>Tolaria</p></td><td><p>Ready &amp; synced</p></td></tr>',
'<tr><td><p>Pipe</p></td><td><p>A | B</p></td></tr>',
'</tbody></table>',
].join(''),
}),
).toMatchObject({
noteId: 'table',
persistable: true,
canonicalMarkdown: [
'| Name | Status |',
'| --- | --- |',
'| Tolaria | Ready & synced |',
'| Pipe | A \\| B |',
].join('\n'),
})
})
it('blocks malformed tables instead of guessing columns', () => {
expect(
createMobileEditorDraft({
note: {
id: 'table',
title: 'Table',
content: '# Table',
},
editorHtml: '<table><tbody><tr><td>Name</td><td>Status</td></tr><tr><td>Tolaria</td></tr></tbody></table>',
}),
).toMatchObject({
noteId: 'table',
persistable: false,
blockedReason: 'unsupportedEditorHtml',
})
})
it('serializes safe image attachments inside supported blocks', () => {
expect(
createMobileEditorDraft({
note: {
id: 'image',
title: 'Image',
content: '# Image',
},
editorHtml: '<p>Before</p><p><img src="attachments/sketch.png" alt="Interface sketch"></p><p>After</p>',
}),
).toMatchObject({
noteId: 'image',
persistable: true,
canonicalMarkdown: 'Before\n\n![Interface sketch](attachments/sketch.png)\n\nAfter',
})
})
it('blocks transient or unsafe image sources inside otherwise supported blocks', () => {
expect(
createMobileEditorDraft({
note: {
id: 'image',
title: 'Image',
content: '# Image',
},
editorHtml: '<p><img src="blob:https://tolaria.app/preview" alt="Attachment"></p>',
}),
).toMatchObject({
noteId: 'image',
persistable: false,
blockedReason: 'unsupportedEditorHtml',
})
})
})

View File

@@ -0,0 +1,67 @@
import { splitFrontmatter } from '@tolaria/markdown'
import type { MobileEditorDocumentInput } from './mobileEditorDocument'
import { serializeSupportedMobileEditorHtml } from './mobileEditorHtmlMarkdown'
export type MobileEditorDraft =
| {
noteId: string
sourceMarkdown: string
editorHtml: string
persistable: true
canonicalMarkdown: string
}
| {
noteId: string
sourceMarkdown: string
editorHtml: string
persistable: false
blockedReason: 'unsupportedEditorHtml'
}
export function createMobileEditorDraft({
editorHtml,
note,
}: {
editorHtml: string
note: MobileEditorDocumentInput
}): MobileEditorDraft {
const markdownBody = serializeSupportedMobileEditorHtml({ editorHtml })
if (!markdownBody) {
return createBlockedDraft({ editorHtml, note })
}
return {
noteId: note.id,
sourceMarkdown: note.content,
editorHtml,
persistable: true,
canonicalMarkdown: withFrontmatter({ markdownBody, sourceMarkdown: note.content }),
}
}
function createBlockedDraft({
editorHtml,
note,
}: {
editorHtml: string
note: MobileEditorDocumentInput
}): MobileEditorDraft {
return {
noteId: note.id,
sourceMarkdown: note.content,
editorHtml,
persistable: false,
blockedReason: 'unsupportedEditorHtml',
}
}
function withFrontmatter({
markdownBody,
sourceMarkdown,
}: {
markdownBody: string
sourceMarkdown: string
}) {
const [frontmatter] = splitFrontmatter(sourceMarkdown)
return frontmatter ? `${frontmatter}${markdownBody}` : markdownBody
}

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { createMobileEditorDraft } from './mobileEditorDraft'
import { saveMobileEditorDraft } from './mobileEditorDraftSave'
import { createMobileVaultConfig } from './mobileVaultConfig'
import { createMemoryMobileVaultStorage } from './mobileVaultStorage'
const vault = createVault()
describe('mobile editor draft save', () => {
it('writes persistable editor drafts as canonical Markdown', async () => {
const storage = createMemoryMobileVaultStorage([])
const draft = createMobileEditorDraft({
note: { id: 'notes/workflow', title: 'Workflow', content: '# Workflow' },
editorHtml: '<h1>Workflow</h1><p>Edited</p>',
})
await expect(saveMobileEditorDraft({ draft, storage, vault })).resolves.toEqual({
status: 'saved',
path: 'notes/workflow.md',
})
await expect(storage.readMarkdownFile(vault, 'notes/workflow.md')).resolves.toBe('# Workflow\n\nEdited')
})
it('does not write blocked editor drafts', async () => {
const storage = createMemoryMobileVaultStorage([])
const draft = createMobileEditorDraft({
note: { id: 'workflow', title: 'Workflow', content: '# Workflow' },
editorHtml: '<figure><figcaption>Unsupported</figcaption></figure>',
})
await expect(saveMobileEditorDraft({ draft, storage, vault })).resolves.toEqual({
status: 'blocked',
reason: 'unsupportedEditorHtml',
})
await expect(storage.listMarkdownFiles(vault)).resolves.toEqual([])
})
})
function createVault() {
const result = createMobileVaultConfig({ id: 'personal', name: 'Personal Journal' })
if (!result.ok) {
throw new Error(result.error)
}
return result.config
}

View File

@@ -0,0 +1,36 @@
import type { MobileEditorDraft } from './mobileEditorDraft'
import type { MobileVaultConfig } from './mobileVaultConfig'
import type { MobileVaultStorageDriver } from './mobileVaultStorage'
export type MobileEditorDraftSaveResult =
| {
status: 'saved'
path: string
}
| {
status: 'blocked'
reason: 'unsupportedEditorHtml'
}
export async function saveMobileEditorDraft({
draft,
storage,
vault,
}: {
draft: MobileEditorDraft
storage: MobileVaultStorageDriver
vault: MobileVaultConfig
}): Promise<MobileEditorDraftSaveResult> {
if (!draft.persistable) {
return { status: 'blocked', reason: draft.blockedReason }
}
const path = draftPath(draft)
await storage.writeMarkdownFile(vault, path, draft.canonicalMarkdown)
return { status: 'saved', path }
}
function draftPath(draft: MobileEditorDraft) {
return `${draft.noteId}.md`
}

View File

@@ -0,0 +1,315 @@
import {
canSerializeMobileEditorTable,
isMobileEditorTableBlock,
mobileEditorTableMarkdown,
} from './mobileEditorTableMarkdown'
import { decodeMobileHtmlEntities } from './mobileHtmlEntities'
type EditorHtmlInput = {
editorHtml: string
}
type HtmlInput = {
html: string
}
type ListItemInput = HtmlInput & {
ordered: boolean
}
const supportedHtmlTags = new Set([
'a',
'b',
'blockquote',
'br',
'code',
'del',
'div',
'em',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'img',
'input',
'label',
'li',
'ol',
'p',
'pre',
's',
'strike',
'strong',
'table',
'tbody',
'td',
'th',
'thead',
'tr',
'ul',
])
export function serializeSupportedMobileEditorHtml(input: EditorHtmlInput) {
const html = normalizeBlockSpacing(input)
const blocks = html.match(/<(h[1-6]|p|ul|ol|blockquote|pre|table)(?:\s[^>]*)?>[\s\S]*?<\/\1>|<hr(?:\s[^>]*)?\s*\/?>/gi)
if (!blocks) {
return null
}
if (!canSerializeBlocks({ blocks, html })) {
return null
}
return blocks.map((block) => serializeBlock({ html: block })).join('\n\n')
}
function canSerializeBlocks({
blocks,
html,
}: {
blocks: RegExpMatchArray
html: string
}) {
return blocks.join('') === html && !blocks.some((block) => blocksUnsafeEditorOutput({ html: block }))
}
function serializeBlock(input: HtmlInput) {
const headingLevel = headingMarkdownLevel(input)
if (headingLevel) {
return `${'#'.repeat(headingLevel)} ${inlineMarkdown(input)}`
}
if (isListBlock(input)) {
return listItemMarkdown(input).join('\n')
}
if (isBlockquote(input)) {
return blockquoteMarkdown(input)
}
if (isCodeBlock(input)) {
return codeBlockMarkdown(input)
}
if (isHorizontalRule(input)) {
return '---'
}
if (isMobileEditorTableBlock(input)) {
return mobileEditorTableMarkdown(input)
}
return inlineMarkdown(input)
}
function normalizeBlockSpacing(input: EditorHtmlInput) {
return input.editorHtml.trim().replace(/>\s+</g, '><')
}
function headingMarkdownLevel(input: HtmlInput) {
const match = input.html.match(/^<h([1-6])/i)
return match ? Number(match[1]) : null
}
function isListBlock(input: HtmlInput) {
return input.html.match(/^<(ul|ol)/i)
}
function isBlockquote(input: HtmlInput) {
return input.html.match(/^<blockquote/i)
}
function isCodeBlock(input: HtmlInput) {
return input.html.match(/^<pre/i)
}
function isHorizontalRule(input: HtmlInput) {
return input.html.match(/^<hr/i)
}
function listItemMarkdown(input: HtmlInput) {
const ordered = input.html.match(/^<ol/i)
return [...input.html.matchAll(/<li(?:\s[^>]*)?>([\s\S]*?)<\/li>/gi)].map((match) =>
formatListItem({ ordered: Boolean(ordered), html: match[0] }),
)
}
function formatListItem(input: ListItemInput) {
const taskMarker = markdownTaskMarker(input)
const prefix = taskMarker ? `- ${taskMarker}` : input.ordered ? '1.' : '-'
return `${prefix} ${inlineMarkdown(input)}`
}
function markdownTaskMarker(input: HtmlInput) {
if (input.html.match(/data-checked=["']true/i) || input.html.match(/<input[^>]+checked/i)) {
return '[x]'
}
if (input.html.match(/data-checked=["']false/i) || input.html.match(/<input[^>]+type=["']checkbox/i)) {
return '[ ]'
}
return null
}
function blockquoteMarkdown(input: HtmlInput) {
const paragraphLines = [...input.html.matchAll(/<p(?:\s[^>]*)?>([\s\S]*?)<\/p>/gi)].map((match) =>
inlineMarkdown({ html: match[0] }),
)
const lines = paragraphLines.length > 0 ? paragraphLines : [inlineMarkdown(input)]
return lines.map((line) => `> ${line}`).join('\n')
}
function codeBlockMarkdown(input: HtmlInput) {
return [
`\`\`\`${codeBlockLanguage(input)}`,
decodeMobileHtmlEntities({ text: codeBlockText(input) }).trimEnd(),
'```',
].join('\n')
}
function codeBlockLanguage(input: HtmlInput) {
return input.html.match(/language-([A-Za-z0-9_-]+)/)?.[1] ?? ''
}
function codeBlockText(input: HtmlInput) {
const code = input.html.match(/<code(?:\s[^>]*)?>([\s\S]*?)<\/code>/i)?.[1] ?? input.html
return stripRemainingTags(code.replace(/<br\s*\/?>/gi, '\n'))
}
function inlineMarkdown(input: HtmlInput) {
return decodeMobileHtmlEntities({ text: stripRemainingTags(markInlineHtml(input)).trim() })
}
function markInlineHtml(input: HtmlInput) {
return input.html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<img\b[^>]*>/gi, (tag) => imageMarkdown({ tag }) ?? tag)
.replace(/<(strong|b)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '**$2**')
.replace(/<(em|i)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '*$2*')
.replace(/<(s|strike|del)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '~~$2~~')
.replace(/<code(?:\s[^>]*)?>([\s\S]*?)<\/code>/gi, '`$1`')
.replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, (tag, label) => linkMarkdown({ tag, label }) ?? tag)
}
function containsUnsupportedTag(input: HtmlInput) {
return [...input.html.matchAll(/<\/?([a-z0-9-]+)/gi)]
.some((match) => !supportedHtmlTags.has(match[1].toLowerCase()))
}
function blocksUnsafeEditorOutput(input: HtmlInput) {
return containsUnsupportedTag(input) || containsUnsafeImage(input) || containsUnsafeLink(input) || containsUnsafeTable(input)
}
function containsUnsafeImage(input: HtmlInput) {
return [...input.html.matchAll(/<img\b[^>]*>/gi)].some((match) => !imageMarkdown({ tag: match[0] }))
}
function containsUnsafeLink(input: HtmlInput) {
return [...input.html.matchAll(/<a\b[^>]*>/gi)].some((match) => !linkMarkdown({ tag: match[0], label: '' }))
}
function containsUnsafeTable(input: HtmlInput) {
return Boolean(isMobileEditorTableBlock(input)) && !canSerializeMobileEditorTable(input)
}
function imageMarkdown(input: { tag: string }) {
const src = imageSource(input)
if (!src) {
return null
}
const alt = htmlAttribute({ tag: input.tag, name: 'alt' }) ?? ''
return `![${decodeMobileHtmlEntities({ text: alt })}](${decodeMobileHtmlEntities({ text: src })})`
}
function imageSource(input: { tag: string }) {
const src = htmlAttribute({ tag: input.tag, name: 'src' })
return src && isPersistableImageSource({ src }) ? src : null
}
function linkMarkdown(input: { tag: string; label: string }) {
const wikilink = wikilinkMarkdown(input)
if (wikilink) {
return wikilink
}
const href = linkDestination(input)
return href ? `[${input.label}](${href})` : null
}
function wikilinkMarkdown(input: { tag: string; label: string }) {
const href = htmlAttribute({ tag: input.tag, name: 'href' })
if (!href?.startsWith('tolaria-note:')) {
return null
}
const target = decodeURIComponent(href.slice('tolaria-note:'.length)).trim()
const label = decodeMobileHtmlEntities({ text: input.label }).trim()
if (!target) {
return null
}
return label && label !== target ? `[[${target}|${label}]]` : `[[${target}]]`
}
function linkDestination(input: { tag: string }) {
const href = htmlAttribute({ tag: input.tag, name: 'href' })
return href && isPersistableLinkDestination({ href }) ? decodeMobileHtmlEntities({ text: href }) : null
}
function htmlAttribute(input: { tag: string; name: string }) {
const match = input.tag.match(new RegExp(`${input.name}=["']([^"']+)["']`, 'i'))
return match?.[1] ?? null
}
function isPersistableImageSource(input: { src: string }) {
if (input.src.match(/[\n\r]/)) {
return false
}
return isRemoteImageSource(input) || isRelativeImageSource(input)
}
function isRemoteImageSource(input: { src: string }) {
return input.src.startsWith('https://') || input.src.startsWith('http://')
}
function isRelativeImageSource(input: { src: string }) {
return !input.src.startsWith('/') && !input.src.startsWith('//') && !input.src.match(/^[A-Za-z][A-Za-z0-9+.-]*:/)
}
function isPersistableLinkDestination(input: { href: string }) {
const href = decodeMobileHtmlEntities({ text: input.href })
if (href.match(/[\n\r]/)) {
return false
}
return isRemoteLinkDestination({ href }) || isMailLinkDestination({ href }) || isRelativeLinkDestination({ href }) || isWikilinkDestination({ href })
}
function isRemoteLinkDestination(input: { href: string }) {
return input.href.startsWith('https://') || input.href.startsWith('http://')
}
function isMailLinkDestination(input: { href: string }) {
return input.href.startsWith('mailto:')
}
function isRelativeLinkDestination(input: { href: string }) {
return !input.href.startsWith('/') && !input.href.startsWith('//') && !input.href.match(/^[A-Za-z][A-Za-z0-9+.-]*:/)
}
function isWikilinkDestination(input: { href: string }) {
return input.href.startsWith('tolaria-note:') && input.href.length > 'tolaria-note:'.length
}
function stripRemainingTags(value: string) {
return value.replace(/<[^>]+>/g, '')
}

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { parseEditorMessage } from './mobileEditorMessages'
describe('mobile editor messages', () => {
it('parses empty wikilink queries with cursor geometry', () => {
expect(parseEditorMessage(JSON.stringify({
frame: { bottom: 124, left: 48 },
query: '',
type: 'wikilinkQuery',
}))).toEqual({
frame: { bottom: 124, left: 48 },
query: '',
type: 'wikilinkQuery',
})
})
it('ignores invalid wikilink query geometry', () => {
expect(parseEditorMessage(JSON.stringify({
frame: { bottom: '124', left: 48 },
query: 'roadmap',
type: 'wikilinkQuery',
}))).toEqual({
frame: null,
query: 'roadmap',
type: 'wikilinkQuery',
})
})
it('parses hardware Tab list indentation messages', () => {
expect(parseEditorMessage(JSON.stringify({
direction: 'in',
type: 'listIndent',
}))).toEqual({
direction: 'in',
type: 'listIndent',
})
expect(parseEditorMessage(JSON.stringify({
direction: 'out',
type: 'listIndent',
}))).toEqual({
direction: 'out',
type: 'listIndent',
})
})
it('rejects malformed list indentation messages', () => {
expect(parseEditorMessage(JSON.stringify({
direction: 'sideways',
type: 'listIndent',
}))).toBeNull()
})
})

View File

@@ -0,0 +1,88 @@
export type MobileEditorMessage =
| { target: string; type: 'openWikilink' }
| { command: 'fileNewNote'; type: 'shortcut' }
| { direction: 'in' | 'out'; type: 'listIndent' }
| { frame: MobileEditorWikilinkFrame | null; query: string | null; type: 'wikilinkQuery' }
export type MobileEditorWikilinkFrame = {
bottom: number
left: number
}
export function parseEditorMessage(data: string): MobileEditorMessage | null {
try {
return normalizeEditorMessage(JSON.parse(data))
} catch {
return null
}
}
function normalizeEditorMessage(value: unknown): MobileEditorMessage | null {
if (!isMessageRecord(value)) {
return null
}
if (isWikilinkQueryMessage(value)) {
return { frame: wikilinkFrame(value.frame), query: value.query, type: 'wikilinkQuery' }
}
if (isListIndentMessage(value)) {
return { direction: value.direction, type: 'listIndent' }
}
if (value.type === 'openWikilink' && typeof value.target === 'string') {
return { target: value.target, type: 'openWikilink' }
}
if (value.type === 'shortcut' && value.command === 'fileNewNote') {
return { command: 'fileNewNote', type: 'shortcut' }
}
return null
}
function isMessageRecord(value: unknown): value is {
command?: unknown
direction?: unknown
frame?: unknown
query?: unknown
target?: unknown
type?: unknown
} {
return typeof value === 'object' && value !== null
}
function isWikilinkQueryMessage(value: {
frame?: unknown
query?: unknown
type?: unknown
}): value is {
frame?: unknown
query: string | null
type: 'wikilinkQuery'
} {
return value.type === 'wikilinkQuery'
&& (typeof value.query === 'string' || value.query === null)
}
function wikilinkFrame(value: unknown): MobileEditorWikilinkFrame | null {
if (!isFrameRecord(value)) {
return null
}
return { bottom: value.bottom, left: value.left }
}
function isFrameRecord(value: unknown): value is MobileEditorWikilinkFrame {
return typeof value === 'object'
&& value !== null
&& typeof (value as { bottom?: unknown }).bottom === 'number'
&& typeof (value as { left?: unknown }).left === 'number'
}
function isListIndentMessage(value: {
direction?: unknown
type?: unknown
}): value is {
direction: 'in' | 'out'
type: 'listIndent'
} {
return value.type === 'listIndent'
&& (value.direction === 'in' || value.direction === 'out')
}

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import {
failedMobileEditorSaveState,
idleMobileEditorSaveState,
queuedMobileEditorSaveState,
saveResultState,
savingMobileEditorSaveState,
} from './mobileEditorSaveState'
describe('mobile editor save state', () => {
it('provides stable labels for direct save states', () => {
expect(idleMobileEditorSaveState.label).toBe('Ready')
expect(queuedMobileEditorSaveState.label).toBe('Edited')
expect(savingMobileEditorSaveState.label).toBe('Saving')
expect(failedMobileEditorSaveState.label).toBe('Save failed')
})
it('derives visible state from save results', () => {
expect(saveResultState({ status: 'saved', path: 'workflow.md' })).toEqual({
state: 'saved',
label: 'Saved',
})
expect(saveResultState({ status: 'blocked', reason: 'unsupportedEditorHtml' })).toEqual({
state: 'blocked',
label: 'Blocked',
})
})
})

View File

@@ -0,0 +1,53 @@
import type { MobileEditorDraftSaveResult } from './mobileEditorDraftSave'
export type MobileEditorSaveState =
| {
state: 'idle'
label: 'Ready'
}
| {
state: 'queued'
label: 'Edited'
}
| {
state: 'saving'
label: 'Saving'
}
| {
state: 'saved'
label: 'Saved'
}
| {
state: 'blocked'
label: 'Blocked'
}
| {
state: 'failed'
label: 'Save failed'
}
export const idleMobileEditorSaveState: MobileEditorSaveState = {
state: 'idle',
label: 'Ready',
}
export const queuedMobileEditorSaveState: MobileEditorSaveState = {
state: 'queued',
label: 'Edited',
}
export const savingMobileEditorSaveState: MobileEditorSaveState = {
state: 'saving',
label: 'Saving',
}
export const failedMobileEditorSaveState: MobileEditorSaveState = {
state: 'failed',
label: 'Save failed',
}
export function saveResultState(result: MobileEditorDraftSaveResult): MobileEditorSaveState {
return result.status === 'saved'
? { state: 'saved', label: 'Saved' }
: { state: 'blocked', label: 'Blocked' }
}

View File

@@ -0,0 +1,62 @@
import { decodeMobileHtmlEntities } from './mobileHtmlEntities'
type HtmlInput = {
html: string
}
type TableRow = {
cells: string[]
}
export function isMobileEditorTableBlock(input: HtmlInput) {
return input.html.match(/^<table/i)
}
export function canSerializeMobileEditorTable(input: HtmlInput) {
const rows = tableRows(input)
const columnCount = rows[0]?.cells.length ?? 0
return columnCount > 0 && rows.every((row) => row.cells.length === columnCount)
}
export function mobileEditorTableMarkdown(input: HtmlInput) {
const rows = tableRows(input)
const header = rows[0]?.cells ?? []
const body = rows.slice(1).map(tableRowMarkdown)
return [
tableRowMarkdown({ cells: header }),
tableSeparator({ columnCount: header.length }),
...body,
].join('\n')
}
function tableRows(input: HtmlInput) {
return [...input.html.matchAll(/<tr(?:\s[^>]*)?>([\s\S]*?)<\/tr>/gi)]
.map((match) => tableRow({ html: match[1] }))
}
function tableRow(input: HtmlInput): TableRow {
return {
cells: [...input.html.matchAll(/<t[hd](?:\s[^>]*)?>([\s\S]*?)<\/t[hd]>/gi)]
.map((match) => tableCellMarkdown({ html: match[1] })),
}
}
function tableCellMarkdown(input: HtmlInput) {
return decodeMobileHtmlEntities({ text: stripCellTags(input).trim() })
.replace(/\s+/g, ' ')
.replace(/\|/g, '\\|')
}
function tableRowMarkdown(input: TableRow) {
return `| ${input.cells.join(' | ')} |`
}
function tableSeparator(input: { columnCount: number }) {
return tableRowMarkdown({ cells: Array.from({ length: input.columnCount }, () => '---') })
}
function stripCellTags(input: HtmlInput) {
return input.html.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]+>/g, '')
}

View File

@@ -0,0 +1,150 @@
export const mobileEditorSetupScript = `
document.documentElement.lang = navigator.language || "en";
document.addEventListener("keydown", function(event) {
if (isFileNewShortcut(event)) {
event.preventDefault();
postEditorMessage({ type: "shortcut", command: "fileNewNote" });
return;
}
if (!isTabInsideEditor(event)) return;
event.preventDefault();
postEditorMessage({
type: "listIndent",
direction: event.shiftKey ? "out" : "in"
});
}, true);
document.addEventListener("click", function(event) {
var link = event.target && event.target.closest && event.target.closest("a[href^='tolaria-note:']");
if (!link) return;
event.preventDefault();
postEditorMessage({
type: "openWikilink",
target: decodeURIComponent(String(link.getAttribute("href") || "").replace(/^tolaria-note:/, ""))
});
}, true);
function isFileNewShortcut(event) {
return (event.metaKey || event.ctrlKey)
&& !event.altKey
&& String(event.key).toLowerCase() === "n";
}
function isTabInsideEditor(event) {
if (event.key !== "Tab") return false;
var selection = window.getSelection();
var node = selection && selection.anchorNode;
return Boolean(node && containingEditor(node));
}
function containingEditor(node) {
var editor = document.querySelector(".ProseMirror");
var container = node.nodeType === 1 ? node : node.parentNode;
return editor && container && editor.contains(container);
}
function activeWikilinkQuery() {
var selection = window.getSelection();
if (!isCollapsedTextSelection(selection)) return null;
if (!containingEditor(selection.anchorNode)) return null;
return wikilinkQueryBeforeCursor(selection);
}
function isCollapsedTextSelection(selection) {
return Boolean(selection
&& selection.anchorNode
&& selection.anchorNode.nodeType === 3
&& selection.rangeCount > 0
&& selection.isCollapsed);
}
function wikilinkQueryBeforeCursor(selection) {
var prefix = String(selection.anchorNode.textContent || "").slice(0, selection.anchorOffset);
var start = prefix.lastIndexOf("[[");
if (start < 0) return null;
var query = cleanWikilinkQuery(prefix.slice(start + 2));
if (query === null) return null;
return {
frame: wikilinkQueryFrame(selection),
query: query
};
}
function cleanWikilinkQuery(query) {
return query.indexOf("]]") >= 0 || query.indexOf("\\n") >= 0 ? null : query;
}
function emitWikilinkQuery() {
var activeQuery = activeWikilinkQuery();
postEditorMessage({
type: "wikilinkQuery",
frame: activeQuery ? activeQuery.frame : null,
query: activeQuery ? activeQuery.query : null
});
}
function wikilinkQueryFrame(selection) {
var range = selection.getRangeAt(0).cloneRange();
var rect = range.getBoundingClientRect();
if (hasVisibleFrame(rect)) {
return {
bottom: rect.bottom,
left: rect.left
};
}
return fallbackWikilinkQueryFrame(selection);
}
function fallbackWikilinkQueryFrame(selection) {
var container = selection.anchorNode && selection.anchorNode.parentElement;
var rect = container && container.getBoundingClientRect && container.getBoundingClientRect();
return {
bottom: rect ? rect.bottom : 0,
left: rect ? rect.left : 0
};
}
function hasVisibleFrame(rect) {
if (!rect) return false;
return Boolean(rect.bottom || rect.left);
}
function postEditorMessage(message) {
window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify(message));
}
document.addEventListener("keyup", emitWikilinkQuery, true);
document.addEventListener("mouseup", emitWikilinkQuery, true);
document.addEventListener("selectionchange", emitWikilinkQuery, true);
true;
`
export const mobileEditorCss = `
* {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif !important;
}
html,
body,
#root,
.ProseMirror {
color: #292825;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
font-size: 18px;
line-height: 1.55;
}
.ProseMirror {
padding: 0;
}
.ProseMirror h1 {
font-family: inherit;
font-size: 42px;
font-weight: 760;
letter-spacing: 0;
line-height: 1.08;
margin: 18px 0 28px;
}
.ProseMirror p,
.ProseMirror li,
.ProseMirror blockquote {
font-family: inherit;
}
.ProseMirror a[href^="tolaria-note:"] {
color: #3367f6;
font-weight: 650;
text-decoration: none;
border-radius: 5px;
background: #e8eeff;
padding: 1px 4px;
}
`

View File

@@ -0,0 +1,310 @@
import { Buffer } from 'buffer'
import { createMobileVaultConfigFromMetadata } from './mobileVaultMetadata'
import type { MobileVaultMetadata } from './mobileVaultMetadata'
import type { ExpoMobileVaultFileInfo, ExpoMobileVaultFileSystem } from './mobileExpoVaultStorage'
import type { PromiseFsClient } from 'isomorphic-git'
type MobileGitStat = {
ctime: Date
ctimeMs: number
dev: number
gid: number
ino: number
isDirectory: () => boolean
isFile: () => boolean
isSymbolicLink: () => boolean
mode: number
mtime: Date
mtimeMs: number
size: number
uid: number
}
type MobileGitReadOptions = 'utf8' | { encoding?: 'base64' | 'utf8' }
type MobileGitWriteOptions = { encoding?: 'base64' | 'utf8' }
type MobileGitFileData = string | Uint8Array
type ExistingPathInput = {
fileSystem: ExpoMobileVaultFileSystem
path: string
rootUri: string
}
export type MobileExpoGitFileSystemContext = {
dir: string
fs: PromiseFsClient
}
export function createMobileExpoGitFileSystem({
fileSystem,
vault,
}: {
fileSystem: ExpoMobileVaultFileSystem
vault: MobileVaultMetadata
}): MobileExpoGitFileSystemContext {
const rootUri = mobileExpoGitVaultRootUri({ fileSystem, vault })
const dir = '/vault'
return {
dir,
fs: {
promises: {
chmod: async () => {},
lstat: (path: string) => statPath({ fileSystem, path, rootUri }),
mkdir: (path: string) => mkdirPath({ fileSystem, path, rootUri }),
readFile: (path: string, options?: MobileGitReadOptions) => readFile({ fileSystem, options, path, rootUri }),
readlink: async () => {
throw createFileSystemError('ENOTSUP', 'Symbolic links are not supported in mobile vaults.')
},
readdir: (path: string) => readDirectory({ fileSystem, path, rootUri }),
rmdir: (path: string) => removeDirectory({ fileSystem, path, rootUri }),
stat: (path: string) => statPath({ fileSystem, path, rootUri }),
symlink: async () => {
throw createFileSystemError('ENOTSUP', 'Symbolic links are not supported in mobile vaults.')
},
unlink: (path: string) => unlinkPath({ fileSystem, path, rootUri }),
writeFile: (path: string, data: MobileGitFileData, options?: MobileGitWriteOptions) =>
writeFile({ data, fileSystem, options, path, rootUri }),
},
},
}
}
export function mobileExpoGitVaultRootUri({
fileSystem,
vault,
}: {
fileSystem: ExpoMobileVaultFileSystem
vault: MobileVaultMetadata
}) {
if (!fileSystem.documentDirectory) {
throw new Error('Expo FileSystem documentDirectory is unavailable')
}
const vaultConfig = createMobileVaultConfigFromMetadata(vault)
return appendUri({
root: fileSystem.documentDirectory,
segments: ['vaults', vaultConfig.storage.directoryName || vaultConfig.id],
})
}
async function mkdirPath({
fileSystem,
path,
rootUri,
}: {
fileSystem: ExpoMobileVaultFileSystem
path: string
rootUri: string
}) {
const uri = uriForPath({ path, rootUri })
const info = await fileSystem.getInfoAsync(uri)
if (info.exists && !info.isDirectory) {
throw createFileSystemError('ENOTDIR', `Path is not a directory: ${path}`)
}
if (!info.exists) {
await fileSystem.makeDirectoryAsync(uri, { intermediates: true })
}
}
async function readDirectory({
fileSystem,
path,
rootUri,
}: ExistingPathInput) {
await existingDirectory({ fileSystem, path, rootUri })
return fileSystem.readDirectoryAsync(uriForPath({ path, rootUri }))
}
async function readFile({
fileSystem,
options,
path,
rootUri,
}: {
fileSystem: ExpoMobileVaultFileSystem
options?: MobileGitReadOptions
path: string
rootUri: string
}) {
const info = await existingInfo({ fileSystem, path, rootUri })
if (info.isDirectory) {
throw createFileSystemError('EISDIR', `Path is a directory: ${path}`)
}
const uri = uriForPath({ path, rootUri })
return readAsUtf8(options)
? fileSystem.readAsStringAsync(uri, { encoding: 'utf8' })
: Buffer.from(await fileSystem.readAsStringAsync(uri, { encoding: 'base64' }), 'base64')
}
async function writeFile({
data,
fileSystem,
options,
path,
rootUri,
}: {
data: MobileGitFileData
fileSystem: ExpoMobileVaultFileSystem
options?: MobileGitWriteOptions
path: string
rootUri: string
}) {
await ensureParentDirectory({ fileSystem, path, rootUri })
if (typeof data === 'string' && writeAsUtf8(options)) {
await fileSystem.writeAsStringAsync(uriForPath({ path, rootUri }), data, { encoding: 'utf8' })
return
}
await fileSystem.writeAsStringAsync(
uriForPath({ path, rootUri }),
dataToBase64(data),
{ encoding: 'base64' },
)
}
async function unlinkPath({
fileSystem,
path,
rootUri,
}: ExistingPathInput) {
await existingFile({ fileSystem, path, rootUri })
await fileSystem.deleteAsync(uriForPath({ path, rootUri }))
}
async function removeDirectory({
fileSystem,
path,
rootUri,
}: ExistingPathInput) {
await existingDirectory({ fileSystem, path, rootUri })
await fileSystem.deleteAsync(uriForPath({ path, rootUri }))
}
async function statPath({
fileSystem,
path,
rootUri,
}: {
fileSystem: ExpoMobileVaultFileSystem
path: string
rootUri: string
}): Promise<MobileGitStat> {
return statForInfo(await existingInfo({ fileSystem, path, rootUri }))
}
async function existingInfo({
fileSystem,
path,
rootUri,
}: ExistingPathInput) {
const info = await fileSystem.getInfoAsync(uriForPath({ path, rootUri }))
if (!info.exists) {
throw createFileSystemError('ENOENT', `Path does not exist: ${path}`)
}
return info
}
async function existingDirectory(input: ExistingPathInput) {
const info = await existingInfo(input)
if (!info.isDirectory) {
throw createFileSystemError('ENOTDIR', `Path is not a directory: ${input.path}`)
}
}
async function existingFile(input: ExistingPathInput) {
const info = await existingInfo(input)
if (info.isDirectory) {
throw createFileSystemError('EISDIR', `Path is a directory: ${input.path}`)
}
}
function statForInfo(info: ExpoMobileVaultFileInfo): MobileGitStat {
const modifiedMs = Math.round((info.modificationTime ?? 0) * 1000)
const modified = new Date(modifiedMs)
const isDirectory = info.isDirectory === true
return {
ctime: modified,
ctimeMs: modifiedMs,
dev: 0,
gid: 0,
ino: 0,
isDirectory: () => isDirectory,
isFile: () => !isDirectory,
isSymbolicLink: () => false,
mode: isDirectory ? 0o040000 : 0o100644,
mtime: modified,
mtimeMs: modifiedMs,
size: info.size ?? 0,
uid: 0,
}
}
async function ensureParentDirectory({
fileSystem,
path,
rootUri,
}: {
fileSystem: ExpoMobileVaultFileSystem
path: string
rootUri: string
}) {
const segments = normalizedSegments(path)
const parentSegments = segments.slice(0, -1)
if (parentSegments.length === 0) {
return
}
await fileSystem.makeDirectoryAsync(appendUri({ root: rootUri, segments: parentSegments }), { intermediates: true })
}
function uriForPath({ path, rootUri }: { path: string; rootUri: string }) {
return appendUri({ root: rootUri, segments: normalizedSegments(path) })
}
function normalizedSegments(path: string) {
const pathWithoutRoot = path.replace(/^\/+vault\/?/, '')
const segments = pathWithoutRoot.replaceAll('\\', '/').split('/').filter(Boolean)
if (segments.some(isUnsafeSegment)) {
throw createFileSystemError('EINVAL', `Unsafe mobile Git path: ${path}`)
}
return segments
}
function isUnsafeSegment(segment: string) {
return segment === '.' || segment === '..' || segment.includes('/')
}
function readAsUtf8(options?: MobileGitReadOptions) {
return options === 'utf8' || (typeof options === 'object' && options.encoding === 'utf8')
}
function writeAsUtf8(options?: MobileGitWriteOptions) {
return options?.encoding === 'utf8'
}
function dataToBase64(data: MobileGitFileData) {
return typeof data === 'string'
? Buffer.from(data, 'utf8').toString('base64')
: Buffer.from(data).toString('base64')
}
function appendUri(input: { root: string; segments: string[] }) {
const base = input.root.replace(/\/+$/, '')
const path = input.segments.filter(Boolean).join('/')
return path ? `${base}/${path}` : base
}
function createFileSystemError(code: string, message: string) {
const error = new Error(message)
return Object.assign(error, { code })
}

View File

@@ -0,0 +1,6 @@
import { requireOptionalNativeModule } from 'expo-modules-core'
import { loadMobileGitNativeModule } from './mobileNativeGitModule'
export function loadExpoMobileGitNativeModule() {
return loadMobileGitNativeModule({ loadModule: requireOptionalNativeModule })
}

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest'
import { createExpoMobileVaultStorage, type ExpoMobileVaultFileSystem } from './mobileExpoVaultStorage'
import { createMobileVaultConfig } from './mobileVaultConfig'
const vault = createVault()
describe('Expo mobile vault storage', () => {
it('lists nested markdown files from the app-local vault directory', async () => {
const fileSystem = createFakeFileSystem({
'file:///documents/vaults/personal-journal/zeta.md': '# Zeta',
'file:///documents/vaults/personal-journal/notes/alpha.md': '# Alpha',
'file:///documents/vaults/personal-journal/asset.png': 'binary',
})
await expect(createExpoMobileVaultStorage(fileSystem).listMarkdownFiles(vault)).resolves.toEqual([
{ path: 'notes/alpha.md', content: '# Alpha' },
{ path: 'zeta.md', content: '# Zeta' },
])
})
it('creates parent directories before writing markdown files', async () => {
const fileSystem = createFakeFileSystem({})
const storage = createExpoMobileVaultStorage(fileSystem)
await storage.writeMarkdownFile(vault, 'notes/new.md', '# New')
await expect(storage.readMarkdownFile(vault, 'notes/new.md')).resolves.toBe('# New')
})
it('deletes markdown files idempotently from the app-local vault directory', async () => {
const storage = createExpoMobileVaultStorage(createFakeFileSystem({
'file:///documents/vaults/personal-journal/notes/new.md': '# New',
}))
await storage.deleteMarkdownFile(vault, 'notes/new.md')
await storage.deleteMarkdownFile(vault, 'notes/new.md')
await expect(storage.readMarkdownFile(vault, 'notes/new.md')).resolves.toBeNull()
})
it('rejects paths outside the app-local vault directory', async () => {
const storage = createExpoMobileVaultStorage(createFakeFileSystem({}))
await expect(storage.writeMarkdownFile(vault, '../outside.md', '# Nope')).rejects.toThrow(
'Unsafe mobile vault path',
)
})
})
function createVault() {
const result = createMobileVaultConfig({ id: 'personal', name: 'Personal Journal' })
if (!result.ok) {
throw new Error(result.error)
}
return result.config
}
function createFakeFileSystem(files: Record<string, string>): ExpoMobileVaultFileSystem {
const fileByUri = new Map(Object.entries(files))
const directoryUris = new Set(['file:///documents'])
for (const uri of fileByUri.keys()) {
addParentDirectories(directoryUris, uri)
}
return {
deleteAsync: async (uri) => {
fileByUri.delete(uri)
},
documentDirectory: 'file:///documents',
getInfoAsync: async (uri) => ({
exists: fileByUri.has(uri) || directoryUris.has(uri),
isDirectory: directoryUris.has(uri),
}),
makeDirectoryAsync: async (uri) => {
addDirectory(directoryUris, uri)
},
readAsStringAsync: async (uri) => {
const content = fileByUri.get(uri)
if (content === undefined) {
throw new Error(`Missing fake file: ${uri}`)
}
return content
},
readDirectoryAsync: async (uri) => listDirectoryNames({ directoryUris, fileByUri, uri }),
writeAsStringAsync: async (uri, content) => {
addParentDirectories(directoryUris, uri)
fileByUri.set(uri, content)
},
}
}
function listDirectoryNames(input: {
directoryUris: Set<string>
fileByUri: Map<string, string>
uri: string
}) {
const names = new Set<string>()
const prefix = `${input.uri.replace(/\/+$/, '')}/`
for (const uri of [...input.directoryUris, ...input.fileByUri.keys()]) {
const remaining = uri.startsWith(prefix) ? uri.slice(prefix.length) : ''
const name = remaining.split('/')[0]
if (name) {
names.add(name)
}
}
return [...names].sort()
}
function addParentDirectories(directoryUris: Set<string>, fileUri: string) {
addDirectory(directoryUris, fileUri.split('/').slice(0, -1).join('/'))
}
function addDirectory(directoryUris: Set<string>, uri: string) {
const segments = uri.split('/')
for (const index of segments.keys()) {
if (index > 1) {
directoryUris.add(segments.slice(0, index + 1).join('/'))
}
}
}

View File

@@ -0,0 +1,208 @@
import type { MobileVaultConfig } from './mobileVaultConfig'
import type { MobileVaultFile, MobileVaultStorageDriver } from './mobileVaultStorage'
export type ExpoMobileVaultFileInfo = {
exists: boolean
isDirectory?: boolean
modificationTime?: number
size?: number
}
export type ExpoMobileVaultFileSystem = {
deleteAsync: (uri: string, options?: { idempotent?: boolean }) => Promise<void>
documentDirectory: string | null
getInfoAsync: (uri: string) => Promise<ExpoMobileVaultFileInfo>
makeDirectoryAsync: (uri: string, options: { intermediates: true }) => Promise<void>
readAsStringAsync: (uri: string, options?: ExpoMobileVaultReadOptions) => Promise<string>
readDirectoryAsync: (uri: string) => Promise<string[]>
writeAsStringAsync: (uri: string, content: string, options?: ExpoMobileVaultWriteOptions) => Promise<void>
}
type VaultPathInput = {
path: string
}
export type ExpoMobileVaultReadOptions = {
encoding?: 'base64' | 'utf8'
}
export type ExpoMobileVaultWriteOptions = ExpoMobileVaultReadOptions
type DirectoryListingInput = {
fileSystem: ExpoMobileVaultFileSystem
rootUri: string
directoryPath: string
}
type DirectoryEntryInput = DirectoryListingInput & {
name: string
}
type VaultFileInput = {
fileSystem: ExpoMobileVaultFileSystem
rootUri: string
path: string
}
type DirectoryInput = {
fileSystem: ExpoMobileVaultFileSystem
uri: string
}
export function createExpoMobileVaultStorage(
fileSystem: ExpoMobileVaultFileSystem,
): MobileVaultStorageDriver {
return {
deleteMarkdownFile: (vault, path) => deleteMarkdownFile(fileSystem, vault, path),
listMarkdownFiles: (vault) => listMarkdownFiles(fileSystem, vault),
readMarkdownFile: (vault, path) => readMarkdownFile(fileSystem, vault, path),
writeMarkdownFile: (vault, path, content) => writeMarkdownFile(fileSystem, vault, path, content),
}
}
async function deleteMarkdownFile(
fileSystem: ExpoMobileVaultFileSystem,
vault: MobileVaultConfig,
path: string,
) {
const rootUri = vaultRootUri(fileSystem, vault)
const safePath = normalizeVaultPath({ path })
await fileSystem.deleteAsync(appendUri({ root: rootUri, segments: [safePath] }), { idempotent: true })
}
async function listMarkdownFiles(
fileSystem: ExpoMobileVaultFileSystem,
vault: MobileVaultConfig,
): Promise<MobileVaultFile[]> {
const rootUri = await ensureVaultRoot(fileSystem, vault)
const paths = await listMarkdownPaths({ fileSystem, rootUri, directoryPath: '' })
const files = await Promise.all(paths.map((path) => readVaultFile({ fileSystem, rootUri, path })))
return files.sort((left, right) => left.path.localeCompare(right.path))
}
async function readMarkdownFile(
fileSystem: ExpoMobileVaultFileSystem,
vault: MobileVaultConfig,
path: string,
) {
const rootUri = vaultRootUri(fileSystem, vault)
const safePath = normalizeVaultPath({ path })
const fileUri = appendUri({ root: rootUri, segments: [safePath] })
const info = await fileSystem.getInfoAsync(fileUri)
return info.exists && !info.isDirectory ? fileSystem.readAsStringAsync(fileUri) : null
}
async function writeMarkdownFile(
fileSystem: ExpoMobileVaultFileSystem,
vault: MobileVaultConfig,
path: string,
content: string,
) {
const rootUri = await ensureVaultRoot(fileSystem, vault)
const safePath = normalizeVaultPath({ path })
await ensureParentDirectory({ fileSystem, rootUri, path: safePath })
await fileSystem.writeAsStringAsync(appendUri({ root: rootUri, segments: [safePath] }), content)
}
async function listMarkdownPaths(input: DirectoryListingInput): Promise<string[]> {
const directoryUri = appendUri({ root: input.rootUri, segments: [input.directoryPath] })
const names = await input.fileSystem.readDirectoryAsync(directoryUri)
const paths = await Promise.all(
names.map((name) => listDirectoryEntry({ ...input, name })),
)
return paths.flat()
}
async function listDirectoryEntry(input: DirectoryEntryInput): Promise<string[]> {
if (isUnsafeSegment(input.name)) {
return []
}
const path = input.directoryPath ? `${input.directoryPath}/${input.name}` : input.name
const info = await input.fileSystem.getInfoAsync(appendUri({ root: input.rootUri, segments: [path] }))
if (!info.exists) {
return []
}
if (info.isDirectory) {
return listMarkdownPaths({ fileSystem: input.fileSystem, rootUri: input.rootUri, directoryPath: path })
}
return path.endsWith('.md') ? [path] : []
}
async function readVaultFile(input: VaultFileInput): Promise<MobileVaultFile> {
return {
path: input.path,
content: await input.fileSystem.readAsStringAsync(
appendUri({ root: input.rootUri, segments: [input.path] }),
),
}
}
async function ensureVaultRoot(fileSystem: ExpoMobileVaultFileSystem, vault: MobileVaultConfig) {
const rootUri = vaultRootUri(fileSystem, vault)
await ensureDirectory({ fileSystem, uri: rootUri })
return rootUri
}
async function ensureParentDirectory(input: VaultFileInput) {
const parentPath = input.path.split('/').slice(0, -1).join('/')
if (parentPath) {
await ensureDirectory({
fileSystem: input.fileSystem,
uri: appendUri({ root: input.rootUri, segments: [parentPath] }),
})
}
}
async function ensureDirectory(input: DirectoryInput) {
const info = await input.fileSystem.getInfoAsync(input.uri)
if (info.exists && !info.isDirectory) {
throw new Error(`Mobile vault path is not a directory: ${input.uri}`)
}
if (!info.exists) {
await input.fileSystem.makeDirectoryAsync(input.uri, { intermediates: true })
}
}
function vaultRootUri(fileSystem: ExpoMobileVaultFileSystem, vault: MobileVaultConfig) {
if (!fileSystem.documentDirectory) {
throw new Error('Expo FileSystem documentDirectory is unavailable')
}
return appendUri({
root: fileSystem.documentDirectory,
segments: ['vaults', vault.storage.directoryName || vault.id],
})
}
function normalizeVaultPath(input: VaultPathInput) {
const path = input.path
const segments = path.replaceAll('\\', '/').split('/').filter(Boolean)
if (isUnsafeVaultPath({ path, segments })) {
throw new Error(`Unsafe mobile vault path: ${path}`)
}
return segments.join('/')
}
function isUnsafeVaultPath(input: VaultPathInput & { segments: string[] }) {
return !input.path.endsWith('.md') || input.segments.length === 0 || input.segments.some(isUnsafeSegment)
}
function isUnsafeSegment(segment: string) {
return segment === '.' || segment === '..' || segment.includes('/')
}
function appendUri(input: { root: string; segments: string[] }) {
const base = input.root.replace(/\/+$/, '')
const path = input.segments.filter(Boolean).join('/')
return path ? `${base}/${path}` : base
}

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage'
import { authenticateMobileGitSyncPlan } from './mobileGitAuthentication'
describe('authenticateMobileGitSyncPlan', () => {
it('ignores plans that do not need authentication', async () => {
await expect(authenticateMobileGitSyncPlan({
credentialStorage: noopCredentialStorage(),
createGitHubOAuthSession: failingSession,
now: () => '2026-05-05T12:00:00.000Z',
plan: { primaryAction: null, state: 'localOnly' },
})).resolves.toEqual({ state: 'ignored' })
})
it('connects GitHub auth-required plans through the OAuth session', async () => {
const credentialStorage = memoryCredentialStorage()
await expect(authenticateMobileGitSyncPlan({
credentialStorage,
createGitHubOAuthSession: () => ({
authorize: async () => ({
state: 'succeeded',
token: { accessToken: 'token', tokenType: 'bearer' },
}),
}),
now: () => '2026-05-05T12:00:00.000Z',
plan: {
authStrategy: 'githubOAuth',
host: 'github.com',
primaryAction: 'authenticate',
state: 'authRequired',
},
})).resolves.toEqual({ state: 'connected' })
await expect(credentialStorage.loadState({ host: 'github.com', strategy: 'githubOAuth' }))
.resolves.toEqual({ state: 'available' })
})
it('fails unsupported SSH authentication without starting OAuth', async () => {
await expect(authenticateMobileGitSyncPlan({
credentialStorage: noopCredentialStorage(),
createGitHubOAuthSession: failingSession,
now: () => '2026-05-05T12:00:00.000Z',
plan: {
authStrategy: 'sshKey',
host: 'git.example.com',
primaryAction: 'authenticate',
state: 'authRequired',
},
})).resolves.toEqual({
message: 'SSH credential setup is not available yet.',
state: 'failed',
})
})
})
function memoryCredentialStorage(): MobileGitCredentialStorage {
let isAvailable = false
return {
loadRecord: async () => null,
loadState: async () => isAvailable ? { state: 'available' } : { state: 'missing' },
remove: async () => {
isAvailable = false
},
saveRecord: async () => {
isAvailable = true
},
}
}
function noopCredentialStorage(): MobileGitCredentialStorage {
return {
loadRecord: async () => null,
loadState: async () => ({ state: 'missing' }),
remove: async () => {},
saveRecord: async () => {},
}
}
function failingSession() {
return {
authorize: async () => {
throw new Error('should not start OAuth')
},
}
}

View File

@@ -0,0 +1,55 @@
import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage'
import type { MobileGitSyncPlan } from './mobileGitSyncPlan'
import { connectMobileGitHubOAuth, type MobileGitHubOAuthSession } from './mobileGitHubOAuthFlow'
export type MobileGitAuthenticationResult =
| {
state: 'connected'
}
| {
state: 'cancelled'
}
| {
message: string
state: 'failed'
}
| {
state: 'ignored'
}
export async function authenticateMobileGitSyncPlan({
credentialStorage,
createGitHubOAuthSession,
now,
plan,
}: {
credentialStorage: MobileGitCredentialStorage
createGitHubOAuthSession: () => MobileGitHubOAuthSession
now: () => string
plan: MobileGitSyncPlan
}): Promise<MobileGitAuthenticationResult> {
if (!canAuthenticate(plan)) {
return { state: 'ignored' }
}
if (authStrategy(plan) !== 'githubOAuth') {
return {
message: 'SSH credential setup is not available yet.',
state: 'failed',
}
}
return connectMobileGitHubOAuth({
credentialStorage,
now,
session: createGitHubOAuthSession(),
})
}
function canAuthenticate(plan: MobileGitSyncPlan) {
return plan.state === 'authRequired' || plan.state === 'failed'
}
function authStrategy(plan: Extract<MobileGitSyncPlan, { state: 'authRequired' | 'failed' }>) {
return plan.state === 'authRequired' ? plan.authStrategy : plan.remote.authStrategy
}

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import {
createMobileGitCredentialRecord,
type MobileGitCredentialStorage,
} from './mobileGitCredentialStorage'
import { loadMobileGitCredentialStateForVault } from './mobileGitCredentialStateForVault'
describe('loadMobileGitCredentialStateForVault', () => {
it('keeps local-only vaults credential-missing without hitting secure storage', async () => {
await expect(loadMobileGitCredentialStateForVault({
credentialStorage: failingCredentialStorage(),
vault: { id: 'personal', name: 'Personal Journal' },
})).resolves.toEqual({ state: 'missing' })
})
it('loads credential state for a remote-backed vault auth requirement', async () => {
const credentialStorage = memoryCredentialStorage()
await credentialStorage.saveRecord(createMobileGitCredentialRecord({
requirement: { host: 'github.com', strategy: 'githubOAuth' },
storedAt: '2026-05-05T12:00:00.000Z',
}))
await expect(loadMobileGitCredentialStateForVault({
credentialStorage,
vault: {
id: 'tolaria',
name: 'Tolaria',
remoteUrl: 'https://github.com/refactoringhq/tolaria.git',
},
})).resolves.toEqual({ state: 'available' })
})
})
function memoryCredentialStorage(): MobileGitCredentialStorage {
const records = new Map<string, ReturnType<typeof createMobileGitCredentialRecord>>()
return {
loadRecord: async (requirement) => records.get(`${requirement.strategy}:${requirement.host}`) ?? null,
loadState: async (requirement) => records.has(`${requirement.strategy}:${requirement.host}`)
? { state: 'available' }
: { state: 'missing' },
remove: async (requirement) => {
records.delete(`${requirement.strategy}:${requirement.host}`)
},
saveRecord: async (record) => {
records.set(`${record.strategy}:${record.host}`, record)
},
}
}
function failingCredentialStorage(): MobileGitCredentialStorage {
return {
loadRecord: async () => {
throw new Error('should not load credentials')
},
loadState: async () => {
throw new Error('should not load credentials')
},
remove: async () => {},
saveRecord: async () => {},
}
}

View File

@@ -0,0 +1,19 @@
import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage'
import type { MobileGitCredentialState } from './mobileGitSyncPlan'
import { createMobileVaultConfig } from './mobileVaultConfig'
import type { MobileVaultMetadata } from './mobileVaultMetadata'
export async function loadMobileGitCredentialStateForVault({
credentialStorage,
vault,
}: {
credentialStorage: MobileGitCredentialStorage
vault: MobileVaultMetadata
}): Promise<MobileGitCredentialState> {
const result = createMobileVaultConfig(vault)
if (!result.ok || result.config.sync.state === 'localOnly') {
return { state: 'missing' }
}
return credentialStorage.loadState(result.config.sync.authRequirement)
}

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import {
createMobileGitCredentialKey,
createMobileGitCredentialRecord,
mobileGitCredentialState,
parseMobileGitCredentialRecord,
serializeMobileGitCredentialRecord,
} from './mobileGitCredentialStorage'
import type { MobileVaultAuthRequirement } from './mobileVaultConfig'
describe('mobile Git credential storage model', () => {
it('creates stable host-normalized secure storage keys', () => {
expect(createMobileGitCredentialKey(githubRequirement(' GitHub.COM '))).toBe(
'tolaria:git-credential:githubOAuth:github.com',
)
})
it('creates a credential presence record for the required auth strategy', () => {
expect(createMobileGitCredentialRecord({
requirement: githubRequirement('github.com'),
storedAt: '2026-05-05T11:00:00.000Z',
})).toEqual({
host: 'github.com',
kind: 'githubOAuthToken',
strategy: 'githubOAuth',
storedAt: '2026-05-05T11:00:00.000Z',
})
expect(createMobileGitCredentialRecord({
requirement: sshRequirement('git.example.com'),
storedAt: '2026-05-05T11:00:00.000Z',
})).toMatchObject({
kind: 'sshKeyReference',
strategy: 'sshKey',
})
})
it('loads available state only when the record matches the required host and strategy', () => {
const record = createMobileGitCredentialRecord({
requirement: githubRequirement('github.com'),
storedAt: '2026-05-05T11:00:00.000Z',
})
expect(mobileGitCredentialState({
record,
requirement: githubRequirement('github.com'),
})).toEqual({ state: 'available' })
expect(mobileGitCredentialState({
record,
requirement: sshRequirement('github.com'),
})).toEqual({ state: 'missing' })
})
it('parses stored records defensively', () => {
const record = createMobileGitCredentialRecord({
requirement: githubRequirement('github.com'),
storedAt: '2026-05-05T11:00:00.000Z',
})
expect(parseMobileGitCredentialRecord(serializeMobileGitCredentialRecord(record))).toEqual(record)
expect(parseMobileGitCredentialRecord('{')).toBeNull()
expect(parseMobileGitCredentialRecord(JSON.stringify({ host: 'github.com' }))).toBeNull()
})
})
function githubRequirement(host: string): MobileVaultAuthRequirement {
return { host, strategy: 'githubOAuth' }
}
function sshRequirement(host: string): MobileVaultAuthRequirement {
return { host, strategy: 'sshKey' }
}

View File

@@ -0,0 +1,148 @@
import type { MobileGitAuthStrategy } from './mobileGitRemote'
import type { MobileGitCredentialState } from './mobileGitSyncPlan'
import type { MobileVaultAuthRequirement } from './mobileVaultConfig'
export type MobileGitCredentialKind = 'githubOAuthToken' | 'sshKeyReference'
export type MobileGitCredentialSecret = {
accessToken: string
scope?: string
tokenType: string
}
export type MobileGitCredentialRecord = {
host: string
kind: MobileGitCredentialKind
secret?: MobileGitCredentialSecret
strategy: MobileGitAuthStrategy
storedAt: string
}
export type MobileGitCredentialStorage = {
loadRecord: (requirement: MobileVaultAuthRequirement) => Promise<MobileGitCredentialRecord | null>
loadState: (requirement: MobileVaultAuthRequirement) => Promise<MobileGitCredentialState>
remove: (requirement: MobileVaultAuthRequirement) => Promise<void>
saveRecord: (record: MobileGitCredentialRecord) => Promise<void>
}
export function createMobileGitCredentialRecord({
requirement,
secret,
storedAt,
}: {
requirement: MobileVaultAuthRequirement
secret?: MobileGitCredentialSecret
storedAt: string
}): MobileGitCredentialRecord {
return {
host: normalizeCredentialHost(requirement.host),
kind: credentialKind(requirement.strategy),
...(secret ? { secret } : {}),
strategy: requirement.strategy,
storedAt,
}
}
export function createMobileGitCredentialKey(requirement: MobileVaultAuthRequirement) {
return [
'tolaria',
'git-credential',
requirement.strategy,
normalizeCredentialHost(requirement.host),
].join(':')
}
export function parseMobileGitCredentialRecord(content: string | null): MobileGitCredentialRecord | null {
if (!content) {
return null
}
try {
return normalizeMobileGitCredentialRecord(JSON.parse(content))
} catch {
return null
}
}
export function mobileGitCredentialState({
record,
requirement,
}: {
record: MobileGitCredentialRecord | null
requirement: MobileVaultAuthRequirement
}): MobileGitCredentialState {
return record && matchesRequirement({ record, requirement })
? { state: 'available' }
: { state: 'missing' }
}
export function serializeMobileGitCredentialRecord(record: MobileGitCredentialRecord) {
return JSON.stringify(record)
}
function normalizeMobileGitCredentialRecord(value: unknown): MobileGitCredentialRecord | null {
if (!isCredentialRecord(value)) {
return null
}
return {
host: normalizeCredentialHost(value.host),
kind: value.kind,
...(isCredentialSecret(value.secret) ? { secret: value.secret } : {}),
strategy: value.strategy,
storedAt: value.storedAt,
}
}
function matchesRequirement({
record,
requirement,
}: {
record: MobileGitCredentialRecord
requirement: MobileVaultAuthRequirement
}) {
return record.strategy === requirement.strategy
&& record.host === normalizeCredentialHost(requirement.host)
&& record.kind === credentialKind(requirement.strategy)
}
function credentialKind(strategy: MobileGitAuthStrategy): MobileGitCredentialKind {
return strategy === 'githubOAuth' ? 'githubOAuthToken' : 'sshKeyReference'
}
function normalizeCredentialHost(host: string) {
return host.trim().toLowerCase()
}
function isCredentialRecord(value: unknown): value is MobileGitCredentialRecord {
return typeof value === 'object'
&& value !== null
&& hasText((value as MobileGitCredentialRecord).host)
&& isCredentialKind((value as MobileGitCredentialRecord).kind)
&& isCredentialStrategy((value as MobileGitCredentialRecord).strategy)
&& hasText((value as MobileGitCredentialRecord).storedAt)
}
function hasText(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0
}
function isCredentialKind(value: unknown): value is MobileGitCredentialKind {
return value === 'githubOAuthToken' || value === 'sshKeyReference'
}
function isCredentialStrategy(value: unknown): value is MobileGitAuthStrategy {
return value === 'githubOAuth' || value === 'sshKey'
}
function isCredentialSecret(value: unknown): value is MobileGitCredentialSecret {
return typeof value === 'object'
&& value !== null
&& hasText((value as MobileGitCredentialSecret).accessToken)
&& hasText((value as MobileGitCredentialSecret).tokenType)
&& isOptionalText((value as MobileGitCredentialSecret).scope)
}
function isOptionalText(value: unknown): value is string | undefined {
return value === undefined || hasText(value)
}

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import {
createMobileGitHubOAuthRequest,
normalizeMobileGitHubAuthorizationResult,
} from './mobileGitHubOAuth'
describe('createMobileGitHubOAuthRequest', () => {
it('builds a PKCE authorization-code request for GitHub OAuth', () => {
expect(createMobileGitHubOAuthRequest({
clientId: ' abc123 ',
redirectUri: ' tolaria://oauth/github ',
})).toEqual({
ok: true,
request: {
clientId: 'abc123',
extraParams: { allow_signup: 'true' },
redirectUri: 'tolaria://oauth/github',
responseType: 'code',
scopes: ['repo'],
usePKCE: true,
},
})
})
it.each([
{ clientId: '', error: 'missingClientId', redirectUri: 'tolaria://oauth/github' },
{ clientId: 'abc123', error: 'missingRedirectUri', redirectUri: '' },
] as const)('rejects $error', ({ clientId, error, redirectUri }) => {
expect(createMobileGitHubOAuthRequest({ clientId, redirectUri })).toEqual({
error,
ok: false,
})
})
})
describe('normalizeMobileGitHubAuthorizationResult', () => {
it('extracts the temporary authorization code', () => {
expect(normalizeMobileGitHubAuthorizationResult({
params: { code: 'temporary-code' },
type: 'success',
})).toEqual({
code: 'temporary-code',
state: 'authorized',
})
})
it('keeps user cancellation separate from auth failure', () => {
expect(normalizeMobileGitHubAuthorizationResult({ type: 'cancel' })).toEqual({
state: 'cancelled',
})
})
it('normalizes provider errors without exposing raw result objects', () => {
expect(normalizeMobileGitHubAuthorizationResult({
params: { error: 'access_denied' },
type: 'error',
})).toEqual({
message: 'access_denied',
state: 'failed',
})
})
})

View File

@@ -0,0 +1,98 @@
export const mobileGitHubOAuthDiscovery = {
authorizationEndpoint: 'https://github.com/login/oauth/authorize',
tokenEndpoint: 'https://github.com/login/oauth/access_token',
}
export const defaultMobileGitHubOAuthScopes = ['repo']
export type MobileGitHubOAuthRequestConfig = {
clientId: string
extraParams: Record<string, string>
redirectUri: string
responseType: 'code'
scopes: string[]
usePKCE: true
}
export type CreateMobileGitHubOAuthRequestResult =
| {
ok: true
request: MobileGitHubOAuthRequestConfig
}
| {
ok: false
error: 'missingClientId' | 'missingRedirectUri'
}
export type MobileGitHubAuthorizationResult =
| {
code: string
state: 'authorized'
}
| {
state: 'cancelled'
}
| {
message: string
state: 'failed'
}
export function createMobileGitHubOAuthRequest({
clientId,
redirectUri,
scopes = defaultMobileGitHubOAuthScopes,
}: {
clientId: string
redirectUri: string
scopes?: string[]
}): CreateMobileGitHubOAuthRequestResult {
if (!clientId.trim()) {
return { error: 'missingClientId', ok: false }
}
if (!redirectUri.trim()) {
return { error: 'missingRedirectUri', ok: false }
}
return {
ok: true,
request: {
clientId: clientId.trim(),
extraParams: { allow_signup: 'true' },
redirectUri: redirectUri.trim(),
responseType: 'code',
scopes,
usePKCE: true,
},
}
}
export function normalizeMobileGitHubAuthorizationResult(result: {
error?: { message?: string } | null
params?: Record<string, string>
type: string
}): MobileGitHubAuthorizationResult {
if (result.type === 'success' && result.params?.code) {
return { code: result.params.code, state: 'authorized' }
}
if (['cancel', 'dismiss'].includes(result.type)) {
return { state: 'cancelled' }
}
return {
message: oauthFailureMessage(result),
state: 'failed',
}
}
function oauthFailureMessage(result: {
error?: { message?: string } | null
params?: Record<string, string>
type: string
}) {
return result.error?.message
?? result.params?.error_description
?? result.params?.error
?? `GitHub OAuth ended with ${result.type}.`
}

View File

@@ -0,0 +1,15 @@
export type MobileGitHubOAuthClientIdState =
| {
clientId: string
state: 'configured'
}
| {
state: 'missing'
}
export function mobileGitHubOAuthClientIdState(
env: Record<string, string | undefined> | undefined,
): MobileGitHubOAuthClientIdState {
const clientId = env?.EXPO_PUBLIC_GITHUB_OAUTH_CLIENT_ID?.trim()
return clientId ? { clientId, state: 'configured' } : { state: 'missing' }
}

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { mobileGitHubOAuthClientIdState } from './mobileGitHubOAuthClientId'
describe('mobileGitHubOAuthClientIdState', () => {
it('detects a configured Expo public GitHub OAuth client id', () => {
expect(mobileGitHubOAuthClientIdState({
EXPO_PUBLIC_GITHUB_OAUTH_CLIENT_ID: ' abc123 ',
})).toEqual({
clientId: 'abc123',
state: 'configured',
})
})
it('reports a missing client id when the env value is absent or blank', () => {
expect(mobileGitHubOAuthClientIdState({})).toEqual({ state: 'missing' })
expect(mobileGitHubOAuthClientIdState({
EXPO_PUBLIC_GITHUB_OAUTH_CLIENT_ID: ' ',
})).toEqual({ state: 'missing' })
})
})

View File

@@ -0,0 +1,26 @@
import { createNativeMobileGitHubOAuthSession } from './mobileNativeGitHubOAuthSession'
import {
mobileGitHubOAuthClientIdState,
type MobileGitHubOAuthClientIdState,
} from './mobileGitHubOAuthClientId'
import type { MobileGitHubOAuthSession } from './mobileGitHubOAuthFlow'
declare const process: { env?: Record<string, string | undefined> } | undefined
export function createNativeMobileGitHubOAuthSessionFromEnvironment(): MobileGitHubOAuthSession {
const clientIdState = currentMobileGitHubOAuthClientIdState()
if (clientIdState.state === 'missing') {
return {
authorize: async () => ({
message: 'GitHub OAuth client ID is not configured.',
state: 'failed',
}),
}
}
return createNativeMobileGitHubOAuthSession({ clientId: clientIdState.clientId })
}
export function currentMobileGitHubOAuthClientIdState(): MobileGitHubOAuthClientIdState {
return mobileGitHubOAuthClientIdState(process?.env)
}

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import {
connectMobileGitHubOAuth,
type MobileGitHubOAuthSession,
} from './mobileGitHubOAuthFlow'
import { createMobileSecureGitCredentialStorage } from './mobileSecureGitCredentialStorage'
import type { MobileSecureStore } from './mobileSecureGitCredentialStorage'
describe('connectMobileGitHubOAuth', () => {
it('stores a GitHub credential record after successful OAuth', async () => {
const secureStore = createMemorySecureStore()
const storage = createMobileSecureGitCredentialStorage(secureStore)
await expect(connectMobileGitHubOAuth({
credentialStorage: storage,
now: () => '2026-05-05T12:00:00.000Z',
session: successfulSession(),
})).resolves.toEqual({ state: 'connected' })
await expect(secureStore.getItemAsync('tolaria:git-credential:githubOAuth:github.com'))
.resolves.toContain('"accessToken":"github-token"')
await expect(storage.loadState({ host: 'github.com', strategy: 'githubOAuth' }))
.resolves.toEqual({ state: 'available' })
})
it('does not store credentials when the user cancels', async () => {
const secureStore = createMemorySecureStore()
await expect(connectMobileGitHubOAuth({
credentialStorage: createMobileSecureGitCredentialStorage(secureStore),
now: () => '2026-05-05T12:00:00.000Z',
session: { authorize: async () => ({ state: 'cancelled' }) },
})).resolves.toEqual({ state: 'cancelled' })
await expect(secureStore.getItemAsync('tolaria:git-credential:githubOAuth:github.com'))
.resolves.toBeNull()
})
})
function successfulSession(): MobileGitHubOAuthSession {
return {
authorize: async () => ({
state: 'succeeded',
token: {
accessToken: 'github-token',
scope: 'repo',
tokenType: 'bearer',
},
}),
}
}
function createMemorySecureStore(): MobileSecureStore {
const values = new Map<string, string>()
return {
deleteItemAsync: async (key) => {
values.delete(key)
},
getItemAsync: async (key) => values.get(key) ?? null,
setItemAsync: async (key, value) => {
values.set(key, value)
},
}
}

View File

@@ -0,0 +1,68 @@
import {
createMobileGitCredentialRecord,
type MobileGitCredentialStorage,
} from './mobileGitCredentialStorage'
import type { MobileVaultAuthRequirement } from './mobileVaultConfig'
export type MobileGitHubOAuthToken = {
accessToken: string
scope?: string
tokenType: string
}
export type MobileGitHubOAuthSessionResult =
| {
state: 'succeeded'
token: MobileGitHubOAuthToken
}
| {
state: 'cancelled'
}
| {
message: string
state: 'failed'
}
export type MobileGitHubOAuthSession = {
authorize: () => Promise<MobileGitHubOAuthSessionResult>
}
export type ConnectMobileGitHubOAuthResult =
| {
state: 'connected'
}
| {
state: 'cancelled'
}
| {
message: string
state: 'failed'
}
export async function connectMobileGitHubOAuth({
credentialStorage,
now,
session,
}: {
credentialStorage: MobileGitCredentialStorage
now: () => string
session: MobileGitHubOAuthSession
}): Promise<ConnectMobileGitHubOAuthResult> {
const result = await session.authorize()
if (result.state !== 'succeeded') {
return result
}
await credentialStorage.saveRecord(createMobileGitCredentialRecord({
requirement: githubRequirement(),
secret: result.token,
storedAt: now(),
}))
return { state: 'connected' }
}
export function githubRequirement(): MobileVaultAuthRequirement {
return { host: 'github.com', strategy: 'githubOAuth' }
}

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import { mobileGitPrimaryActionForPlan } from './mobileGitPrimaryAction'
import type { MobileGitRemote } from './mobileGitRemote'
describe('mobile git primary action', () => {
it('routes missing credentials to authentication', () => {
expect(mobileGitPrimaryActionForPlan({
authStrategy: 'githubOAuth',
host: 'github.com',
primaryAction: 'authenticate',
state: 'authRequired',
})).toEqual({ state: 'authenticate' })
})
it('routes ready pull and push states to transport execution', () => {
expect(mobileGitPrimaryActionForPlan({
canPull: true,
canPush: false,
primaryAction: 'pull',
remote: remote(),
state: 'ready',
})).toMatchObject({ operation: 'pull', state: 'transport' })
expect(mobileGitPrimaryActionForPlan({
canPull: true,
canPush: true,
primaryAction: 'push',
remote: remote(),
state: 'ready',
})).toMatchObject({ operation: 'push', state: 'transport' })
})
it('retries clone failures through authentication and sync failures through transport', () => {
expect(mobileGitPrimaryActionForPlan({
message: 'GitHub authentication failed.',
operation: 'clone',
primaryAction: 'retry',
remote: remote(),
state: 'failed',
})).toEqual({ state: 'authenticate' })
expect(mobileGitPrimaryActionForPlan({
message: 'Pull failed.',
operation: 'pull',
primaryAction: 'retry',
remote: remote(),
state: 'failed',
})).toMatchObject({ operation: 'pull', state: 'transport' })
})
it('ignores local-only and already syncing plans', () => {
expect(mobileGitPrimaryActionForPlan({ primaryAction: null, state: 'localOnly' })).toEqual({ state: 'ignored' })
expect(mobileGitPrimaryActionForPlan({
operation: 'pull',
primaryAction: null,
remote: remote(),
state: 'syncing',
})).toEqual({ state: 'ignored' })
})
})
function remote(): MobileGitRemote {
return {
authStrategy: 'githubOAuth',
host: 'github.com',
owner: 'refactoringhq',
repository: 'tolaria',
url: 'https://github.com/refactoringhq/tolaria.git',
}
}

View File

@@ -0,0 +1,52 @@
import type { MobileGitOperation, MobileGitSyncPlan } from './mobileGitSyncPlan'
import type { MobileGitRemote } from './mobileGitRemote'
export type MobileGitTransportOperation = Exclude<MobileGitOperation, 'clone'>
export type MobileGitPrimaryAction =
| {
state: 'authenticate'
}
| {
operation: MobileGitTransportOperation
remote: MobileGitRemote
state: 'transport'
}
| {
state: 'ignored'
}
export function mobileGitPrimaryActionForPlan(plan: MobileGitSyncPlan): MobileGitPrimaryAction {
if (plan.state === 'authRequired') {
return { state: 'authenticate' }
}
if (plan.state === 'ready') {
return transportAction({
operation: plan.primaryAction,
remote: plan.remote,
})
}
if (plan.state === 'failed') {
return plan.operation === 'clone'
? { state: 'authenticate' }
: transportAction({ operation: plan.operation, remote: plan.remote })
}
return { state: 'ignored' }
}
function transportAction({
operation,
remote,
}: {
operation: MobileGitTransportOperation
remote: MobileGitRemote
}): MobileGitPrimaryAction {
return {
operation,
remote,
state: 'transport',
}
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { parseMobileGitRemote } from './mobileGitRemote'
describe('mobile git remote parsing', () => {
it('uses GitHub OAuth for GitHub HTTPS remotes', () => {
expect(parseMobileGitRemote('https://github.com/refactoringhq/tolaria.git')).toEqual({
url: 'https://github.com/refactoringhq/tolaria.git',
host: 'github.com',
owner: 'refactoringhq',
repository: 'tolaria',
authStrategy: 'githubOAuth',
})
})
it('uses GitHub OAuth for GitHub SSH shorthand remotes', () => {
expect(parseMobileGitRemote('git@github.com:refactoringhq/tolaria.git')).toMatchObject({
host: 'github.com',
owner: 'refactoringhq',
repository: 'tolaria',
authStrategy: 'githubOAuth',
})
})
it('uses SSH keys for non-GitHub SSH remotes', () => {
expect(parseMobileGitRemote('ssh://git@git.example.com/acme/notes.git')).toMatchObject({
host: 'git.example.com',
owner: 'acme',
repository: 'notes',
authStrategy: 'sshKey',
})
})
it('uses SSH keys for non-GitHub HTTPS remotes', () => {
expect(parseMobileGitRemote('https://gitlab.com/acme/notes.git')).toMatchObject({
host: 'gitlab.com',
owner: 'acme',
repository: 'notes',
authStrategy: 'sshKey',
})
})
it('rejects non-remote input', () => {
expect(parseMobileGitRemote('/Users/luca/Laputa')).toBeNull()
expect(parseMobileGitRemote('')).toBeNull()
})
})

View File

@@ -0,0 +1,71 @@
export type MobileGitAuthStrategy = 'githubOAuth' | 'sshKey'
export type MobileGitRemote = {
url: string
host: string
owner: string
repository: string
authStrategy: MobileGitAuthStrategy
}
export function parseMobileGitRemote(url: string): MobileGitRemote | null {
const normalizedUrl = url.trim()
return parseHttpRemote(normalizedUrl) ?? parseScpRemote(normalizedUrl) ?? parseSshRemote(normalizedUrl)
}
function parseHttpRemote(url: string): MobileGitRemote | null {
const parsedUrl = parseUrl(url)
if (!parsedUrl || !['http:', 'https:'].includes(parsedUrl.protocol)) {
return null
}
return createRemote(url, parsedUrl.hostname, pathParts(parsedUrl.pathname))
}
function parseSshRemote(url: string): MobileGitRemote | null {
const parsedUrl = parseUrl(url)
if (!parsedUrl || parsedUrl.protocol !== 'ssh:') {
return null
}
return createRemote(url, parsedUrl.hostname, pathParts(parsedUrl.pathname))
}
function parseScpRemote(url: string): MobileGitRemote | null {
const match = /^git@([^:]+):([^/]+)\/(.+)$/.exec(url)
if (!match) {
return null
}
return createRemote(url, match[1], [match[2], stripGitSuffix(match[3])])
}
function createRemote(url: string, host: string, parts: string[]): MobileGitRemote | null {
if (parts.length < 2) {
return null
}
return {
url,
host,
owner: parts[0],
repository: stripGitSuffix(parts[1]),
authStrategy: host === 'github.com' ? 'githubOAuth' : 'sshKey',
}
}
function parseUrl(url: string) {
try {
return new URL(url)
} catch {
return null
}
}
function pathParts(pathname: string) {
return pathname.split('/').filter(Boolean)
}
function stripGitSuffix(value: string) {
return value.endsWith('.git') ? value.slice(0, -4) : value
}

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import { runMobileGitSyncFlowAction, type MobileGitSyncFlowFailure } from './mobileGitSyncFlowAction'
import type { MobileGitOperation, MobileGitSyncPlan } from './mobileGitSyncPlan'
import type { MobileGitRemote } from './mobileGitRemote'
describe('mobile git sync flow action', () => {
it('ignores actions while another operation is active', () => {
const events: string[] = []
runMobileGitSyncFlowAction({
...baseActionInput(events),
activeOperation: 'pull',
})
expect(events).toEqual([])
})
it('runs ready pull and records transport failures', async () => {
const events: string[] = []
const failures: Array<MobileGitSyncFlowFailure | null> = []
runMobileGitSyncFlowAction({
...baseActionInput(events),
gitSyncPlan: readyPlan(),
gitTransport: {
pull: async () => ({ message: 'Pull failed.', state: 'failed' }),
push: async () => ({ state: 'completed' }),
},
setFailure: (failure) => failures.push(failure),
})
await Promise.resolve()
expect(events).toEqual(['active:pull'])
expect(failures).toEqual([null, { message: 'Pull failed.', operation: 'pull' }])
})
it('routes auth-required plans to the authentication operation', () => {
const events: string[] = []
runMobileGitSyncFlowAction({
...baseActionInput(events),
gitSyncPlan: {
authStrategy: 'githubOAuth',
host: 'github.com',
primaryAction: 'authenticate',
state: 'authRequired',
},
})
expect(events).toEqual(['active:clone'])
})
})
function baseActionInput(events: string[]) {
return {
activeOperation: null,
credentialStorage: {
loadRecord: async () => null,
loadState: async () => ({ state: 'missing' as const }),
remove: async () => {},
saveRecord: async () => {},
},
createGitHubOAuthSession: () => ({
authorize: async () => ({ state: 'cancelled' as const }),
}),
gitSyncPlan: { primaryAction: null, state: 'localOnly' } satisfies MobileGitSyncPlan,
gitTransport: {
pull: async () => ({ state: 'completed' as const }),
push: async () => ({ state: 'completed' as const }),
},
refreshCredentials: () => events.push('refresh'),
setActiveOperation: (operation: MobileGitOperation | null) => {
if (operation) {
events.push(`active:${operation}`)
}
},
setFailure: () => {},
vault: { id: 'personal', name: 'Personal Journal' },
}
}
function readyPlan(): MobileGitSyncPlan {
return {
canPull: true,
canPush: false,
primaryAction: 'pull',
remote: remote(),
state: 'ready',
}
}
function remote(): MobileGitRemote {
return {
authStrategy: 'githubOAuth',
host: 'github.com',
owner: 'refactoringhq',
repository: 'tolaria',
url: 'https://github.com/refactoringhq/tolaria.git',
}
}

View File

@@ -0,0 +1,142 @@
import { authenticateMobileGitSyncPlan } from './mobileGitAuthentication'
import type { MobileGitHubOAuthSession } from './mobileGitHubOAuthFlow'
import { mobileGitPrimaryActionForPlan } from './mobileGitPrimaryAction'
import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage'
import type { MobileGitOperation, MobileGitSyncPlan } from './mobileGitSyncPlan'
import {
runMobileGitTransportOperation,
type MobileGitTransport,
} from './mobileGitTransport'
import type { MobileVaultMetadata } from './mobileVaultMetadata'
export type MobileGitSyncFlowFailure = {
message: string
operation: MobileGitOperation
}
export function runMobileGitSyncFlowAction({
activeOperation,
credentialStorage,
createGitHubOAuthSession,
gitSyncPlan,
gitTransport,
onSynced,
refreshCredentials,
setActiveOperation,
setFailure,
vault,
}: {
activeOperation: MobileGitOperation | null
credentialStorage: MobileGitCredentialStorage
createGitHubOAuthSession: () => MobileGitHubOAuthSession
gitSyncPlan: MobileGitSyncPlan
gitTransport: MobileGitTransport
onSynced?: () => void
refreshCredentials: () => void
setActiveOperation: (operation: MobileGitOperation | null) => void
setFailure: (failure: MobileGitSyncFlowFailure | null) => void
vault: MobileVaultMetadata
}) {
if (activeOperation) {
return
}
const action = mobileGitPrimaryActionForPlan(gitSyncPlan)
if (action.state === 'authenticate') {
runAuthenticationAction({
credentialStorage,
createGitHubOAuthSession,
gitSyncPlan,
refreshCredentials,
setActiveOperation,
setFailure,
})
return
}
if (action.state === 'transport') {
runTransportAction({
gitTransport,
onSynced,
operation: action.operation,
remote: action.remote,
setActiveOperation,
setFailure,
vault,
})
}
}
function runAuthenticationAction({
credentialStorage,
createGitHubOAuthSession,
gitSyncPlan,
refreshCredentials,
setActiveOperation,
setFailure,
}: {
credentialStorage: MobileGitCredentialStorage
createGitHubOAuthSession: () => MobileGitHubOAuthSession
gitSyncPlan: MobileGitSyncPlan
refreshCredentials: () => void
setActiveOperation: (operation: MobileGitOperation | null) => void
setFailure: (failure: MobileGitSyncFlowFailure | null) => void
}) {
setFailure(null)
setActiveOperation('clone')
void authenticateMobileGitSyncPlan({
credentialStorage,
createGitHubOAuthSession,
now: () => new Date().toISOString(),
plan: gitSyncPlan,
})
.then((result) => {
if (result.state === 'connected') {
refreshCredentials()
return
}
if (result.state === 'failed') {
setFailure({ message: result.message, operation: 'clone' })
}
})
.catch(() => setFailure({ message: 'GitHub authentication failed.', operation: 'clone' }))
.finally(() => setActiveOperation(null))
}
function runTransportAction({
gitTransport,
onSynced,
operation,
remote,
setActiveOperation,
setFailure,
vault,
}: {
gitTransport: MobileGitTransport
onSynced?: () => void
operation: 'pull' | 'push'
remote: Parameters<typeof runMobileGitTransportOperation>[0]['remote']
setActiveOperation: (operation: MobileGitOperation | null) => void
setFailure: (failure: MobileGitSyncFlowFailure | null) => void
vault: MobileVaultMetadata
}) {
setFailure(null)
setActiveOperation(operation)
void runMobileGitTransportOperation({
operation,
remote,
transport: gitTransport,
vault,
})
.then((result) => {
if (result.state === 'failed') {
setFailure({ message: result.message, operation })
return
}
onSynced?.()
})
.catch(() => setFailure({ message: 'Mobile Git sync failed.', operation }))
.finally(() => setActiveOperation(null))
}

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import { createMobileVaultConfig, type MobileVaultConfig } from './mobileVaultConfig'
import { createMobileGitSyncPlan } from './mobileGitSyncPlan'
describe('createMobileGitSyncPlan', () => {
it('keeps local-only vaults out of the sync/auth flow', () => {
expect(createMobileGitSyncPlan({
credentials: { state: 'missing' },
sync: vaultConfig().sync,
})).toEqual({
state: 'localOnly',
primaryAction: null,
})
})
it('requires the configured auth strategy before remote sync', () => {
expect(createMobileGitSyncPlan({
credentials: { state: 'missing' },
sync: githubVaultConfig().sync,
})).toMatchObject({
state: 'authRequired',
authStrategy: 'githubOAuth',
host: 'github.com',
primaryAction: 'authenticate',
})
})
it.each([
{ hasLocalChanges: false, canPush: false, primaryAction: 'pull' },
{ hasLocalChanges: true, canPush: true, primaryAction: 'push' },
] as const)('plans $primaryAction as the ready sync action', ({ canPush, hasLocalChanges, primaryAction }) => {
expect(createMobileGitSyncPlan({
credentials: { state: 'available' },
hasLocalChanges,
sync: githubVaultConfig().sync,
})).toMatchObject({
state: 'ready',
canPull: true,
canPush,
primaryAction,
})
})
it('surfaces active and failed operations around the same remote', () => {
const sync = githubVaultConfig().sync
expect(createMobileGitSyncPlan({
credentials: { state: 'available' },
operation: 'pull',
sync,
})).toMatchObject({
state: 'syncing',
operation: 'pull',
primaryAction: null,
})
expect(createMobileGitSyncPlan({
credentials: { state: 'available' },
failure: { message: 'Authentication failed', operation: 'push' },
sync,
})).toMatchObject({
state: 'failed',
message: 'Authentication failed',
operation: 'push',
primaryAction: 'retry',
})
})
})
function vaultConfig(): MobileVaultConfig {
return resultConfig(createMobileVaultConfig({ id: 'local', name: 'Local' }))
}
function githubVaultConfig(): MobileVaultConfig {
return resultConfig(createMobileVaultConfig({
id: 'tolaria',
name: 'Tolaria',
remoteUrl: 'https://github.com/refactoringhq/tolaria.git',
}))
}
function resultConfig(result: ReturnType<typeof createMobileVaultConfig>): MobileVaultConfig {
if (!result.ok) {
throw new Error(result.error)
}
return result.config
}

View File

@@ -0,0 +1,118 @@
import type { MobileGitAuthStrategy, MobileGitRemote } from './mobileGitRemote'
import type { MobileVaultSync } from './mobileVaultConfig'
export type MobileGitCredentialState =
| {
state: 'missing'
}
| {
state: 'available'
}
export type MobileGitOperation = 'clone' | 'pull' | 'push'
export type MobileGitSyncPlan =
| {
state: 'localOnly'
primaryAction: null
}
| {
state: 'authRequired'
authStrategy: MobileGitAuthStrategy
host: string
primaryAction: 'authenticate'
}
| {
state: 'ready'
canPull: true
canPush: boolean
primaryAction: 'pull' | 'push'
remote: MobileGitRemote
}
| {
state: 'syncing'
operation: MobileGitOperation
primaryAction: null
remote: MobileGitRemote
}
| {
state: 'failed'
message: string
operation: MobileGitOperation
primaryAction: 'retry'
remote: MobileGitRemote
}
export function createMobileGitSyncPlan({
credentials,
failure,
hasLocalChanges = false,
operation,
sync,
}: {
credentials: MobileGitCredentialState
failure?: { message: string; operation: MobileGitOperation }
hasLocalChanges?: boolean
operation?: MobileGitOperation
sync: MobileVaultSync
}): MobileGitSyncPlan {
if (sync.state === 'localOnly') {
return { state: 'localOnly', primaryAction: null }
}
if (failure) {
return failedPlan({ failure, remote: sync.remote })
}
if (operation) {
return {
state: 'syncing',
operation,
primaryAction: null,
remote: sync.remote,
}
}
if (credentials.state === 'missing') {
return {
state: 'authRequired',
authStrategy: sync.authRequirement.strategy,
host: sync.authRequirement.host,
primaryAction: 'authenticate',
}
}
return readyPlan({ hasLocalChanges, remote: sync.remote })
}
function readyPlan({
hasLocalChanges,
remote,
}: {
hasLocalChanges: boolean
remote: MobileGitRemote
}): MobileGitSyncPlan {
return {
state: 'ready',
canPull: true,
canPush: hasLocalChanges,
primaryAction: hasLocalChanges ? 'push' : 'pull',
remote,
}
}
function failedPlan({
failure,
remote,
}: {
failure: { message: string; operation: MobileGitOperation }
remote: MobileGitRemote
}): MobileGitSyncPlan {
return {
state: 'failed',
message: failure.message,
operation: failure.operation,
primaryAction: 'retry',
remote,
}
}

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { createMobileGitSyncPlanForVault } from './mobileGitSyncRuntimePlan'
describe('createMobileGitSyncPlanForVault', () => {
it('keeps app-local vaults hidden from Git sync status', () => {
expect(createMobileGitSyncPlanForVault({
vault: { id: 'personal', name: 'Personal Journal' },
})).toEqual({
primaryAction: null,
state: 'localOnly',
})
})
it('requires credentials for remote-backed vaults', () => {
expect(createMobileGitSyncPlanForVault({
vault: {
id: 'tolaria',
name: 'Tolaria',
remoteUrl: 'https://github.com/refactoringhq/tolaria.git',
},
})).toMatchObject({
authStrategy: 'githubOAuth',
host: 'github.com',
primaryAction: 'authenticate',
state: 'authRequired',
})
})
it('plans ready sync when credentials are available', () => {
expect(createMobileGitSyncPlanForVault({
credentials: { state: 'available' },
vault: {
id: 'tolaria',
name: 'Tolaria',
remoteUrl: 'git@git.example.com:refactoringhq/tolaria.git',
},
})).toMatchObject({
canPull: true,
canPush: false,
primaryAction: 'pull',
state: 'ready',
})
})
it('treats invalid remote metadata as local-only until vault management can repair it', () => {
expect(createMobileGitSyncPlanForVault({
vault: { id: 'broken', name: 'Broken', remoteUrl: '/Users/luca/Laputa' },
})).toEqual({
primaryAction: null,
state: 'localOnly',
})
})
})

View File

@@ -0,0 +1,35 @@
import {
createMobileGitSyncPlan,
type MobileGitCredentialState,
type MobileGitOperation,
type MobileGitSyncPlan,
} from './mobileGitSyncPlan'
import { createMobileVaultConfig } from './mobileVaultConfig'
import type { MobileVaultMetadata } from './mobileVaultMetadata'
export function createMobileGitSyncPlanForVault({
credentials = { state: 'missing' },
failure,
hasLocalChanges = false,
operation,
vault,
}: {
credentials?: MobileGitCredentialState
failure?: { message: string; operation: MobileGitOperation }
hasLocalChanges?: boolean
operation?: MobileGitOperation
vault: MobileVaultMetadata
}): MobileGitSyncPlan {
const result = createMobileVaultConfig(vault)
if (!result.ok) {
return { primaryAction: null, state: 'localOnly' }
}
return createMobileGitSyncPlan({
credentials,
failure,
hasLocalChanges,
operation,
sync: result.config.sync,
})
}

View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import type { MobileGitRemote } from './mobileGitRemote'
import { mobileGitSyncStatusView } from './mobileGitSyncStatus'
describe('mobileGitSyncStatusView', () => {
it('hides local-only vaults from sync status chrome', () => {
expect(mobileGitSyncStatusView({ state: 'localOnly', primaryAction: null })).toBeNull()
})
it('summarizes authentication requirements by strategy', () => {
expect(mobileGitSyncStatusView({
authStrategy: 'githubOAuth',
host: 'github.com',
primaryAction: 'authenticate',
state: 'authRequired',
})).toMatchObject({
actionLabel: 'Connect',
detail: 'Connect GitHub for github.com.',
tone: 'attention',
})
expect(mobileGitSyncStatusView({
authStrategy: 'sshKey',
host: 'git.example.com',
primaryAction: 'authenticate',
state: 'authRequired',
})).toMatchObject({
detail: 'Add an SSH key for git.example.com.',
})
})
it.each([
{ canPush: false, actionLabel: 'Pull', detail: 'Vault can pull from remote.', tone: 'positive' },
{ canPush: true, actionLabel: 'Push', detail: 'Local changes are ready to push.', tone: 'warning' },
] as const)('summarizes ready sync state with $actionLabel', ({ actionLabel, canPush, detail, tone }) => {
expect(mobileGitSyncStatusView({
canPull: true,
canPush,
primaryAction: canPush ? 'push' : 'pull',
remote: remote(),
state: 'ready',
})).toMatchObject({
actionLabel,
detail,
label: 'refactoringhq/tolaria',
tone,
})
})
it('summarizes syncing and failed operations', () => {
expect(mobileGitSyncStatusView({
operation: 'pull',
primaryAction: null,
remote: remote(),
state: 'syncing',
})).toMatchObject({
actionLabel: null,
detail: 'Pull in progress.',
tone: 'neutral',
})
expect(mobileGitSyncStatusView({
message: 'Authentication failed',
operation: 'push',
primaryAction: 'retry',
remote: remote(),
state: 'failed',
})).toMatchObject({
actionLabel: 'Retry',
detail: 'Authentication failed',
label: 'Push failed',
})
})
})
function remote(): MobileGitRemote {
return {
authStrategy: 'githubOAuth',
host: 'github.com',
owner: 'refactoringhq',
repository: 'tolaria',
url: 'https://github.com/refactoringhq/tolaria.git',
}
}

View File

@@ -0,0 +1,55 @@
import type { MobileGitSyncPlan } from './mobileGitSyncPlan'
export type MobileGitSyncStatusTone = 'attention' | 'neutral' | 'positive' | 'warning'
export type MobileGitSyncStatusView = {
actionLabel: string | null
detail: string
label: string
tone: MobileGitSyncStatusTone
}
export function mobileGitSyncStatusView(plan: MobileGitSyncPlan): MobileGitSyncStatusView | null {
switch (plan.state) {
case 'localOnly':
return null
case 'authRequired':
return {
actionLabel: 'Connect',
detail: authDetail(plan),
label: 'Git authentication required',
tone: 'attention',
}
case 'ready':
return {
actionLabel: actionLabel(plan.primaryAction),
detail: plan.canPush ? 'Local changes are ready to push.' : 'Vault can pull from remote.',
label: `${plan.remote.owner}/${plan.remote.repository}`,
tone: plan.canPush ? 'warning' : 'positive',
}
case 'syncing':
return {
actionLabel: null,
detail: `${actionLabel(plan.operation)} in progress.`,
label: `${plan.remote.owner}/${plan.remote.repository}`,
tone: 'neutral',
}
case 'failed':
return {
actionLabel: 'Retry',
detail: plan.message,
label: `${actionLabel(plan.operation)} failed`,
tone: 'warning',
}
}
}
function authDetail(plan: Extract<MobileGitSyncPlan, { state: 'authRequired' }>) {
return plan.authStrategy === 'githubOAuth'
? `Connect GitHub for ${plan.host}.`
: `Add an SSH key for ${plan.host}.`
}
function actionLabel(action: 'clone' | 'pull' | 'push') {
return action[0].toUpperCase() + action.slice(1)
}

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import {
createUnavailableMobileGitTransport,
runMobileGitTransportOperation,
type MobileGitTransport,
} from './mobileGitTransport'
import type { MobileGitRemote } from './mobileGitRemote'
describe('mobile git transport', () => {
it('runs the requested transport operation', async () => {
const calls: string[] = []
const transport: MobileGitTransport = {
pull: async () => {
calls.push('pull')
return { state: 'completed' }
},
push: async () => {
calls.push('push')
return { state: 'completed' }
},
}
await runMobileGitTransportOperation({
operation: 'push',
remote: remote(),
transport,
vault: { id: 'personal', name: 'Personal Journal' },
})
expect(calls).toEqual(['push'])
})
it('fails explicitly until a native git implementation is wired', async () => {
await expect(createUnavailableMobileGitTransport().pull({
remote: remote(),
vault: { id: 'personal', name: 'Personal Journal' },
})).resolves.toEqual({
message: 'Mobile Git transport is not available yet.',
state: 'failed',
})
})
})
function remote(): MobileGitRemote {
return {
authStrategy: 'githubOAuth',
host: 'github.com',
owner: 'refactoringhq',
repository: 'tolaria',
url: 'https://github.com/refactoringhq/tolaria.git',
}
}

View File

@@ -0,0 +1,62 @@
import type { MobileGitRemote } from './mobileGitRemote'
import type { MobileVaultMetadata } from './mobileVaultMetadata'
import type { MobileGitTransportOperation } from './mobileGitPrimaryAction'
export type MobileGitTransportRequest = {
remote: MobileGitRemote
vault: MobileVaultMetadata
}
export type MobileGitTransportResult =
| {
state: 'completed'
}
| {
message: string
state: 'failed'
}
export type MobileGitRepositoryStatus =
| {
hasLocalChanges: boolean
isRepository: boolean
state: 'available'
}
| {
message: string
state: 'failed'
}
export type MobileGitTransport = {
pull: (request: MobileGitTransportRequest) => Promise<MobileGitTransportResult>
push: (request: MobileGitTransportRequest) => Promise<MobileGitTransportResult>
status?: (request: MobileGitTransportRequest) => Promise<MobileGitRepositoryStatus>
}
export function createUnavailableMobileGitTransport(): MobileGitTransport {
return {
pull: () => unavailableTransportResult(),
push: () => unavailableTransportResult(),
}
}
export function runMobileGitTransportOperation({
operation,
remote,
transport,
vault,
}: {
operation: MobileGitTransportOperation
remote: MobileGitRemote
transport: MobileGitTransport
vault: MobileVaultMetadata
}) {
return transport[operation]({ remote, vault })
}
function unavailableTransportResult(): Promise<MobileGitTransportResult> {
return Promise.resolve({
message: 'Mobile Git transport is not available yet.',
state: 'failed',
})
}

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { decodeMobileHtmlEntities } from './mobileHtmlEntities'
describe('decodeMobileHtmlEntities', () => {
it('decodes common named, decimal, hexadecimal, and spacing entities', () => {
expect(
decodeMobileHtmlEntities({
text: 'A&nbsp;B &lt;C&gt; &#33; &#x3f; &amp;',
}),
).toBe('A B <C> ! ? &')
})
})

View File

@@ -0,0 +1,24 @@
export function decodeMobileHtmlEntities(input: { text: string }) {
return input.text
.replace(/&#x([0-9a-f]+);/gi, (_, value: string) => numericEntity({ radix: 16, value }))
.replace(/&#([0-9]+);/g, (_, value: string) => numericEntity({ radix: 10, value }))
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
}
function numericEntity(input: { radix: number; value: string }) {
const codePoint = Number.parseInt(input.value, input.radix)
if (!Number.isFinite(codePoint)) {
return input.value
}
try {
return String.fromCodePoint(codePoint)
} catch {
return input.value
}
}

View File

@@ -0,0 +1,161 @@
import { describe, expect, it } from 'vitest'
import type { StatusRow } from 'isomorphic-git'
import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage'
import type { ExpoMobileVaultFileSystem } from './mobileExpoVaultStorage'
import {
createIsomorphicMobileGitTransport,
type MobileIsoGitClient,
} from './mobileIsomorphicGitTransport'
import type { MobileGitTransportRequest } from './mobileGitTransport'
describe('isomorphic mobile git transport', () => {
it('clones a remote vault when the app-local directory is not a git repository', async () => {
const { events, transport } = transportFixture({ hasRepository: false })
await expect(transport.pull(request())).resolves.toEqual({ state: 'completed' })
expect(events).toEqual(['clone:https://github.com/refactoringhq/tolaria.git'])
})
it('pulls an existing app-local repository', async () => {
const { events, transport } = transportFixture({ hasRepository: true })
await expect(transport.pull(request())).resolves.toEqual({ state: 'completed' })
expect(events).toEqual(['pull'])
})
it('autocommits changed and deleted files before pushing', async () => {
const events: string[] = []
const transport = createIsomorphicMobileGitTransport({
credentialStorage: credentialStorage(),
fileSystem: fileSystem({ hasRepository: true }),
gitClient: gitClient({
events,
statusRows: [
['changed.md', 1, 2, 1],
['deleted.md', 1, 0, 1],
],
}),
})
await expect(transport.push(request())).resolves.toEqual({ state: 'completed' })
expect(events).toEqual(['add:changed.md', 'remove:deleted.md', 'commit', 'push'])
})
it('reports dirty repository state for sync planning', async () => {
const transport = createIsomorphicMobileGitTransport({
credentialStorage: credentialStorage(),
fileSystem: fileSystem({ hasRepository: true }),
gitClient: gitClient({ statusRows: [['changed.md', 1, 2, 1]] }),
})
await expect(transport.status?.(request())).resolves.toEqual({
hasLocalChanges: true,
isRepository: true,
state: 'available',
})
})
})
function transportFixture({
hasRepository,
statusRows,
}: {
hasRepository: boolean
statusRows?: StatusRow[]
}) {
const events: string[] = []
const transport = createIsomorphicMobileGitTransport({
credentialStorage: credentialStorage(),
fileSystem: fileSystem({ hasRepository }),
gitClient: gitClient({ events, statusRows }),
})
return { events, transport }
}
function gitClient({
events = [],
statusRows = [],
}: {
events?: string[]
statusRows?: StatusRow[]
} = {}): MobileIsoGitClient {
return {
add: async ({ filepath }) => {
events.push(`add:${filepath}`)
},
clone: async ({ url }) => {
events.push(`clone:${url}`)
},
commit: async () => {
events.push('commit')
return 'commit-oid'
},
pull: async () => {
events.push('pull')
},
push: async () => {
events.push('push')
return { error: null, ok: true }
},
remove: async ({ filepath }) => {
events.push(`remove:${filepath}`)
},
statusMatrix: async () => statusRows,
}
}
function credentialStorage(): MobileGitCredentialStorage {
return {
loadRecord: async () => ({
host: 'github.com',
kind: 'githubOAuthToken',
secret: {
accessToken: 'github-token',
tokenType: 'bearer',
},
strategy: 'githubOAuth',
storedAt: '2026-05-12T12:00:00.000Z',
}),
loadState: async () => ({ state: 'available' }),
remove: async () => {},
saveRecord: async () => {},
}
}
function fileSystem({ hasRepository }: { hasRepository: boolean }): ExpoMobileVaultFileSystem {
return {
deleteAsync: async () => {},
documentDirectory: 'file:///docs/',
getInfoAsync: async (uri) => ({
exists: uri === 'file:///docs/vaults/personal-journal' || (hasRepository && uri.endsWith('/.git')),
isDirectory: true,
modificationTime: 0,
size: 0,
}),
makeDirectoryAsync: async () => {},
readAsStringAsync: async () => '',
readDirectoryAsync: async () => [],
writeAsStringAsync: async () => {},
}
}
function request(): MobileGitTransportRequest {
return {
remote: {
authStrategy: 'githubOAuth',
host: 'github.com',
owner: 'refactoringhq',
repository: 'tolaria',
url: 'https://github.com/refactoringhq/tolaria.git',
},
vault: {
id: 'personal',
name: 'Personal Journal',
remoteUrl: 'https://github.com/refactoringhq/tolaria.git',
},
}
}

View File

@@ -0,0 +1,256 @@
import * as git from 'isomorphic-git'
import http from 'isomorphic-git/http/web'
import type { AuthCallback, FsClient, HttpClient, StatusRow } from 'isomorphic-git'
import type { ExpoMobileVaultFileSystem } from './mobileExpoVaultStorage'
import { createMobileExpoGitFileSystem } from './mobileExpoGitFileSystem'
import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage'
import type { MobileGitRemote } from './mobileGitRemote'
import type {
MobileGitRepositoryStatus,
MobileGitTransport,
MobileGitTransportRequest,
MobileGitTransportResult,
} from './mobileGitTransport'
export type MobileIsoGitClient = {
add: (input: { dir: string; filepath: string; fs: FsClient }) => Promise<void>
clone: (input: { depth?: number; dir: string; fs: FsClient; http: HttpClient; onAuth: AuthCallback; singleBranch: true; url: string }) => Promise<void>
commit: (input: {
author: { email: string; name: string }
dir: string
fs: FsClient
message: string
}) => Promise<string>
pull: (input: {
author: { email: string; name: string }
dir: string
fastForwardOnly: true
fs: FsClient
http: HttpClient
onAuth: AuthCallback
singleBranch: true
}) => Promise<void>
push: (input: { dir: string; fs: FsClient; http: HttpClient; onAuth: AuthCallback }) => Promise<{ ok: boolean; error: string | null }>
remove: (input: { dir: string; filepath: string; fs: FsClient }) => Promise<void>
statusMatrix: (input: { dir: string; fs: FsClient }) => Promise<StatusRow[]>
}
export function createIsomorphicMobileGitTransport({
credentialStorage,
fileSystem,
gitClient = defaultGitClient,
httpClient = http,
}: {
credentialStorage: MobileGitCredentialStorage
fileSystem: ExpoMobileVaultFileSystem
gitClient?: MobileIsoGitClient
httpClient?: HttpClient
}): MobileGitTransport {
return {
pull: (request) => runGitOperation(() => pullOrClone({ credentialStorage, fileSystem, gitClient, httpClient, request })),
push: (request) => runGitOperation(() => commitAndPush({ credentialStorage, fileSystem, gitClient, httpClient, request })),
status: (request) => repositoryStatus({ fileSystem, gitClient, request }),
}
}
async function pullOrClone({
credentialStorage,
fileSystem,
gitClient,
httpClient,
request,
}: {
credentialStorage: MobileGitCredentialStorage
fileSystem: ExpoMobileVaultFileSystem
gitClient: MobileIsoGitClient
httpClient: HttpClient
request: MobileGitTransportRequest
}) {
const context = createMobileExpoGitFileSystem({ fileSystem, vault: request.vault })
const onAuth = await authForRequest({ credentialStorage, remote: request.remote })
if (await isGitRepository(context)) {
await gitClient.pull({
author: mobileGitAuthor(),
dir: context.dir,
fastForwardOnly: true,
fs: context.fs,
http: httpClient,
onAuth,
singleBranch: true,
})
return
}
await gitClient.clone({
depth: 1,
dir: context.dir,
fs: context.fs,
http: httpClient,
onAuth,
singleBranch: true,
url: request.remote.url,
})
}
async function commitAndPush({
credentialStorage,
fileSystem,
gitClient,
httpClient,
request,
}: {
credentialStorage: MobileGitCredentialStorage
fileSystem: ExpoMobileVaultFileSystem
gitClient: MobileIsoGitClient
httpClient: HttpClient
request: MobileGitTransportRequest
}) {
const context = createMobileExpoGitFileSystem({ fileSystem, vault: request.vault })
if (!await isGitRepository(context)) {
throw new Error('Pull the remote vault before pushing mobile changes.')
}
await commitLocalChanges({ context, gitClient })
const result = await gitClient.push({
dir: context.dir,
fs: context.fs,
http: httpClient,
onAuth: await authForRequest({ credentialStorage, remote: request.remote }),
})
if (!result.ok) {
throw new Error(result.error ?? 'Git push failed.')
}
}
async function repositoryStatus({
fileSystem,
gitClient,
request,
}: {
fileSystem: ExpoMobileVaultFileSystem
gitClient: MobileIsoGitClient
request: MobileGitTransportRequest
}): Promise<MobileGitRepositoryStatus> {
try {
const context = createMobileExpoGitFileSystem({ fileSystem, vault: request.vault })
if (!await isGitRepository(context)) {
return { hasLocalChanges: false, isRepository: false, state: 'available' }
}
return {
hasLocalChanges: hasChangedRows(await gitClient.statusMatrix({ dir: context.dir, fs: context.fs })),
isRepository: true,
state: 'available',
}
} catch {
return { message: 'Could not inspect mobile Git repository state.', state: 'failed' }
}
}
async function commitLocalChanges({
context,
gitClient,
}: {
context: MobileExpoGitContext
gitClient: MobileIsoGitClient
}) {
const matrix = await gitClient.statusMatrix({ dir: context.dir, fs: context.fs })
const changedRows = matrix.filter(isChangedRow)
await Promise.all(changedRows.map((row) => stageStatusRow({ context, gitClient, row })))
if (changedRows.length > 0) {
await gitClient.commit({
author: mobileGitAuthor(),
dir: context.dir,
fs: context.fs,
message: `Mobile sync ${new Date().toISOString()}`,
})
}
}
async function stageStatusRow({
context,
gitClient,
row,
}: {
context: MobileExpoGitContext
gitClient: MobileIsoGitClient
row: StatusRow
}) {
const filepath = row[0]
if (row[2] === 0) {
await gitClient.remove({ dir: context.dir, filepath, fs: context.fs })
return
}
await gitClient.add({ dir: context.dir, filepath, fs: context.fs })
}
async function authForRequest({
credentialStorage,
remote,
}: {
credentialStorage: MobileGitCredentialStorage
remote: MobileGitRemote
}): Promise<AuthCallback> {
if (remote.authStrategy !== 'githubOAuth') {
throw new Error('Mobile Git sync currently supports GitHub OAuth remotes only.')
}
const record = await credentialStorage.loadRecord({ host: remote.host, strategy: remote.authStrategy })
if (!record?.secret?.accessToken) {
throw new Error('GitHub credentials are missing. Connect GitHub before syncing.')
}
return () => ({ username: record.secret?.accessToken })
}
async function isGitRepository(context: MobileExpoGitContext) {
try {
await context.fs.promises.stat(`${context.dir}/.git`)
return true
} catch {
return false
}
}
async function runGitOperation(operation: () => Promise<void>): Promise<MobileGitTransportResult> {
try {
await operation()
return { state: 'completed' }
} catch (error) {
return { message: gitFailureMessage(error), state: 'failed' }
}
}
function hasChangedRows(rows: StatusRow[]) {
return rows.some(isChangedRow)
}
function isChangedRow(row: StatusRow) {
return row[1] !== row[2] || row[2] !== row[3]
}
function mobileGitAuthor() {
return {
email: 'mobile@tolaria.local',
name: 'Tolaria Mobile',
}
}
function gitFailureMessage(error: unknown) {
return error instanceof Error ? error.message : 'Mobile Git sync failed.'
}
type MobileExpoGitContext = ReturnType<typeof createMobileExpoGitFileSystem>
const defaultGitClient: MobileIsoGitClient = {
add: git.add,
clone: git.clone,
commit: git.commit,
pull: git.pull,
push: git.push,
remove: git.remove,
statusMatrix: git.statusMatrix,
}

View File

@@ -0,0 +1,6 @@
import * as SecureStore from 'expo-secure-store'
import { createMobileAiProviderSecretStorage } from './mobileAiProviderSecretStorage'
export function createNativeMobileAiProviderSecretStorage() {
return createMobileAiProviderSecretStorage(SecureStore)
}

Some files were not shown because too many files have changed in this diff Show More