Compare commits
72 Commits
main
...
codex/mobi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3146e535f | ||
|
|
97f6a8c2b4 | ||
|
|
456d6f9887 | ||
|
|
814b8ad586 | ||
|
|
c76ef01eba | ||
|
|
453c68a7a3 | ||
|
|
a367293d9a | ||
|
|
8eaf5a530d | ||
|
|
a4c3c00567 | ||
|
|
72ea59de0b | ||
|
|
e6e616267b | ||
|
|
7a82caa80c | ||
|
|
8fc27e15da | ||
|
|
66dc461c0f | ||
|
|
7e0914c147 | ||
|
|
b7ab2041f6 | ||
|
|
2fb5b96313 | ||
|
|
e7fada7805 | ||
|
|
72f526e3f8 | ||
|
|
0f33e8958b | ||
|
|
fc24575af0 | ||
|
|
217481dcee | ||
|
|
36560a7b8c | ||
|
|
8af4652f56 | ||
|
|
6540d70f34 | ||
|
|
68723c0e96 | ||
|
|
987bb3dfc3 | ||
|
|
d60b7b5140 | ||
|
|
34044a66db | ||
|
|
a337ddc9ea | ||
|
|
1861721e9a | ||
|
|
4b3c06946c | ||
|
|
0b50144194 | ||
|
|
6589b46cb7 | ||
|
|
9eb86ab626 | ||
|
|
63235b83a4 | ||
|
|
d08da1c7bf | ||
|
|
6cf7b4b118 | ||
|
|
bb88528020 | ||
|
|
bb1ab0df2c | ||
|
|
428097f35d | ||
|
|
a41be6f58d | ||
|
|
111aa4fdd1 | ||
|
|
4db990a901 | ||
|
|
19e017b4c6 | ||
|
|
46aa43c1cd | ||
|
|
1f66fbda59 | ||
|
|
5da79cac74 | ||
|
|
0efad2211a | ||
|
|
518b61201b | ||
|
|
610e8c9703 | ||
|
|
38805b0eaf | ||
|
|
bd8e498505 | ||
|
|
f7eea2d997 | ||
|
|
035adceeae | ||
|
|
3ff99adb3d | ||
|
|
b6f227abb2 | ||
|
|
39b1118dc1 | ||
|
|
a46cb21e2f | ||
|
|
3b94faf034 | ||
|
|
165ec6f2b0 | ||
|
|
601306afb5 | ||
|
|
16de4e529b | ||
|
|
82545acbaa | ||
|
|
a122a11d46 | ||
|
|
5afd1ca62a | ||
|
|
e0887c6d71 | ||
|
|
ef544006d8 | ||
|
|
30d4d55b4a | ||
|
|
a2da4c0563 | ||
|
|
b9311ee1f2 | ||
|
|
9063b47252 |
@@ -28,11 +28,16 @@ require_main_branch() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||||
if [ "$CURRENT_BRANCH" != "main" ]; then
|
case "$CURRENT_BRANCH" in
|
||||||
echo "❌ Commits must happen on main. Current branch: $CURRENT_BRANCH"
|
main|codex/mobile)
|
||||||
echo " Merge or cherry-pick your work onto main, then commit there."
|
return 0
|
||||||
exit 1
|
;;
|
||||||
fi
|
*)
|
||||||
|
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
|
require_main_branch
|
||||||
@@ -50,7 +55,7 @@ fi
|
|||||||
|
|
||||||
# Unit tests
|
# Unit tests
|
||||||
echo " → tests..."
|
echo " → tests..."
|
||||||
pnpm exec vitest run --silent
|
pnpm test -- --silent
|
||||||
|
|
||||||
echo "✅ Pre-commit passed"
|
echo "✅ Pre-commit passed"
|
||||||
|
|
||||||
|
|||||||
41
apps/mobile/.gitignore
vendored
Normal file
41
apps/mobile/.gitignore
vendored
Normal 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
19
apps/mobile/App.tsx
Normal 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
37
apps/mobile/app.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
apps/mobile/assets/adaptive-icon.png
Normal file
BIN
apps/mobile/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/mobile/assets/favicon.png
Normal file
BIN
apps/mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/mobile/assets/icon.png
Normal file
BIN
apps/mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
apps/mobile/assets/splash-icon.png
Normal file
BIN
apps/mobile/assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
9
apps/mobile/index.ts
Normal file
9
apps/mobile/index.ts
Normal 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);
|
||||||
50
apps/mobile/metro.config.js
Normal file
50
apps/mobile/metro.config.js
Normal 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
44
apps/mobile/package.json
Normal 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
|
||||||
|
}
|
||||||
148
apps/mobile/src/MobileAiPanel.tsx
Normal file
148
apps/mobile/src/MobileAiPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
197
apps/mobile/src/MobileAiSettingsPanel.tsx
Normal file
197
apps/mobile/src/MobileAiSettingsPanel.tsx
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
1109
apps/mobile/src/MobileApp.tsx
Normal file
1109
apps/mobile/src/MobileApp.tsx
Normal file
File diff suppressed because it is too large
Load Diff
482
apps/mobile/src/MobileEditablePropertyPickers.tsx
Normal file
482
apps/mobile/src/MobileEditablePropertyPickers.tsx
Normal 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)
|
||||||
|
}
|
||||||
126
apps/mobile/src/MobileEditorAdapter.tsx
Normal file
126
apps/mobile/src/MobileEditorAdapter.tsx
Normal 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()
|
||||||
|
}
|
||||||
81
apps/mobile/src/MobileEditorBreadcrumb.tsx
Normal file
81
apps/mobile/src/MobileEditorBreadcrumb.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
apps/mobile/src/MobileEditorWikilinkSuggestions.tsx
Normal file
57
apps/mobile/src/MobileEditorWikilinkSuggestions.tsx
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
53
apps/mobile/src/MobileGitSyncStatusCard.tsx
Normal file
53
apps/mobile/src/MobileGitSyncStatusCard.tsx
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
477
apps/mobile/src/MobilePropertiesPanel.tsx
Normal file
477
apps/mobile/src/MobilePropertiesPanel.tsx
Normal 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))
|
||||||
|
}
|
||||||
62
apps/mobile/src/MobileRawEditor.tsx
Normal file
62
apps/mobile/src/MobileRawEditor.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
apps/mobile/src/MobileRelationshipNotePicker.tsx
Normal file
73
apps/mobile/src/MobileRelationshipNotePicker.tsx
Normal 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()
|
||||||
|
}
|
||||||
45
apps/mobile/src/MobileVaultManagementCard.tsx
Normal file
45
apps/mobile/src/MobileVaultManagementCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
apps/mobile/src/MobileVaultRemotePrompt.tsx
Normal file
73
apps/mobile/src/MobileVaultRemotePrompt.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
apps/mobile/src/NamedIcon.tsx
Normal file
36
apps/mobile/src/NamedIcon.tsx
Normal 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" />
|
||||||
|
}
|
||||||
37
apps/mobile/src/SwipeSurface.tsx
Normal file
37
apps/mobile/src/SwipeSurface.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
apps/mobile/src/compactGestures.test.ts
Normal file
25
apps/mobile/src/compactGestures.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
36
apps/mobile/src/compactGestures.ts
Normal file
36
apps/mobile/src/compactGestures.ts
Normal 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
|
||||||
|
}
|
||||||
60
apps/mobile/src/compactNavigation.test.ts
Normal file
60
apps/mobile/src/compactNavigation.test.ts
Normal 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
|
||||||
|
}
|
||||||
55
apps/mobile/src/compactNavigation.ts
Normal file
55
apps/mobile/src/compactNavigation.ts
Normal 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
|
||||||
|
}
|
||||||
20
apps/mobile/src/demoData.test.ts
Normal file
20
apps/mobile/src/demoData.test.ts
Normal 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
242
apps/mobile/src/demoData.ts
Normal 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)
|
||||||
58
apps/mobile/src/mobileAiClient.test.ts
Normal file
58
apps/mobile/src/mobileAiClient.test.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
45
apps/mobile/src/mobileAiClient.ts
Normal file
45
apps/mobile/src/mobileAiClient.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { MobileNote } from './mobileNoteProjection'
|
||||||
|
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 : ''
|
||||||
|
}
|
||||||
25
apps/mobile/src/mobileAiProviderSecretStorage.test.ts
Normal file
25
apps/mobile/src/mobileAiProviderSecretStorage.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
29
apps/mobile/src/mobileAiProviderSecretStorage.ts
Normal file
29
apps/mobile/src/mobileAiProviderSecretStorage.ts
Normal 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(':')
|
||||||
|
}
|
||||||
66
apps/mobile/src/mobileAiSettings.test.ts
Normal file
66
apps/mobile/src/mobileAiSettings.test.ts
Normal 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',
|
||||||
|
}
|
||||||
|
}
|
||||||
166
apps/mobile/src/mobileAiSettings.ts
Normal file
166
apps/mobile/src/mobileAiSettings.ts
Normal 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'
|
||||||
|
}
|
||||||
34
apps/mobile/src/mobileAiSettingsStorage.test.ts
Normal file
34
apps/mobile/src/mobileAiSettingsStorage.test.ts
Normal 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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
82
apps/mobile/src/mobileAiSettingsStorage.ts
Normal file
82
apps/mobile/src/mobileAiSettingsStorage.ts
Normal 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`
|
||||||
|
}
|
||||||
60
apps/mobile/src/mobileAppStateStorage.test.ts
Normal file
60
apps/mobile/src/mobileAppStateStorage.test.ts
Normal 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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
126
apps/mobile/src/mobileAppStateStorage.ts
Normal file
126
apps/mobile/src/mobileAppStateStorage.ts
Normal 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
|
||||||
|
}
|
||||||
125
apps/mobile/src/mobileAutosaveQueue.test.ts
Normal file
125
apps/mobile/src/mobileAutosaveQueue.test.ts
Normal 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
|
||||||
|
}
|
||||||
137
apps/mobile/src/mobileAutosaveQueue.ts
Normal file
137
apps/mobile/src/mobileAutosaveQueue.ts
Normal 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
|
||||||
|
}
|
||||||
128
apps/mobile/src/mobileCoreFlowSmoke.test.ts
Normal file
128
apps/mobile/src/mobileCoreFlowSmoke.test.ts
Normal 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
|
||||||
|
}
|
||||||
16
apps/mobile/src/mobileDemoVault.test.ts
Normal file
16
apps/mobile/src/mobileDemoVault.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
116
apps/mobile/src/mobileDemoVault.ts
Normal file
116
apps/mobile/src/mobileDemoVault.ts
Normal 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)))
|
||||||
|
}
|
||||||
20
apps/mobile/src/mobileDemoVaultRawNote.ts
Normal file
20
apps/mobile/src/mobileDemoVaultRawNote.ts
Normal 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),
|
||||||
|
})
|
||||||
|
}
|
||||||
5
apps/mobile/src/mobileDemoVaultSeedPolicy.ts
Normal file
5
apps/mobile/src/mobileDemoVaultSeedPolicy.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { MobileVaultMetadata } from './mobileVaultMetadata'
|
||||||
|
|
||||||
|
export function shouldSeedDemoVault(vaultMetadata: MobileVaultMetadata) {
|
||||||
|
return !vaultMetadata.remoteUrl?.trim()
|
||||||
|
}
|
||||||
71
apps/mobile/src/mobileDerivedRelationships.test.ts
Normal file
71
apps/mobile/src/mobileDerivedRelationships.test.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
68
apps/mobile/src/mobileDerivedRelationships.ts
Normal file
68
apps/mobile/src/mobileDerivedRelationships.ts
Normal 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())
|
||||||
|
}
|
||||||
114
apps/mobile/src/mobileEditorDocument.test.ts
Normal file
114
apps/mobile/src/mobileEditorDocument.test.ts
Normal 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 <mobile></h1><p>Use TenTap & keep markdown durable</p><ul><li>Escape "quotes"</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>',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
89
apps/mobile/src/mobileEditorDocument.ts
Normal file
89
apps/mobile/src/mobileEditorDocument.ts
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
305
apps/mobile/src/mobileEditorDraft.test.ts
Normal file
305
apps/mobile/src/mobileEditorDraft.test.ts
Normal 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 <tags> & "quotes", !? and non 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&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 = <string>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 & 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\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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
67
apps/mobile/src/mobileEditorDraft.ts
Normal file
67
apps/mobile/src/mobileEditorDraft.ts
Normal 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
|
||||||
|
}
|
||||||
48
apps/mobile/src/mobileEditorDraftSave.test.ts
Normal file
48
apps/mobile/src/mobileEditorDraftSave.test.ts
Normal 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
|
||||||
|
}
|
||||||
36
apps/mobile/src/mobileEditorDraftSave.ts
Normal file
36
apps/mobile/src/mobileEditorDraftSave.ts
Normal 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`
|
||||||
|
}
|
||||||
315
apps/mobile/src/mobileEditorHtmlMarkdown.ts
Normal file
315
apps/mobile/src/mobileEditorHtmlMarkdown.ts
Normal 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 `})`
|
||||||
|
}
|
||||||
|
|
||||||
|
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, '')
|
||||||
|
}
|
||||||
53
apps/mobile/src/mobileEditorMessages.test.ts
Normal file
53
apps/mobile/src/mobileEditorMessages.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
88
apps/mobile/src/mobileEditorMessages.ts
Normal file
88
apps/mobile/src/mobileEditorMessages.ts
Normal 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')
|
||||||
|
}
|
||||||
28
apps/mobile/src/mobileEditorSaveState.test.ts
Normal file
28
apps/mobile/src/mobileEditorSaveState.test.ts
Normal 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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
53
apps/mobile/src/mobileEditorSaveState.ts
Normal file
53
apps/mobile/src/mobileEditorSaveState.ts
Normal 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' }
|
||||||
|
}
|
||||||
62
apps/mobile/src/mobileEditorTableMarkdown.ts
Normal file
62
apps/mobile/src/mobileEditorTableMarkdown.ts
Normal 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, '')
|
||||||
|
}
|
||||||
150
apps/mobile/src/mobileEditorWebViewSetup.ts
Normal file
150
apps/mobile/src/mobileEditorWebViewSetup.ts
Normal 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;
|
||||||
|
}
|
||||||
|
`
|
||||||
310
apps/mobile/src/mobileExpoGitFileSystem.ts
Normal file
310
apps/mobile/src/mobileExpoGitFileSystem.ts
Normal 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 })
|
||||||
|
}
|
||||||
6
apps/mobile/src/mobileExpoNativeGitModule.ts
Normal file
6
apps/mobile/src/mobileExpoNativeGitModule.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { requireOptionalNativeModule } from 'expo-modules-core'
|
||||||
|
import { loadMobileGitNativeModule } from './mobileNativeGitModule'
|
||||||
|
|
||||||
|
export function loadExpoMobileGitNativeModule() {
|
||||||
|
return loadMobileGitNativeModule({ loadModule: requireOptionalNativeModule })
|
||||||
|
}
|
||||||
124
apps/mobile/src/mobileExpoVaultStorage.test.ts
Normal file
124
apps/mobile/src/mobileExpoVaultStorage.test.ts
Normal 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('/'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
208
apps/mobile/src/mobileExpoVaultStorage.ts
Normal file
208
apps/mobile/src/mobileExpoVaultStorage.ts
Normal 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
|
||||||
|
}
|
||||||
87
apps/mobile/src/mobileGitAuthentication.test.ts
Normal file
87
apps/mobile/src/mobileGitAuthentication.test.ts
Normal 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')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
55
apps/mobile/src/mobileGitAuthentication.ts
Normal file
55
apps/mobile/src/mobileGitAuthentication.ts
Normal 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
|
||||||
|
}
|
||||||
62
apps/mobile/src/mobileGitCredentialStateForVault.test.ts
Normal file
62
apps/mobile/src/mobileGitCredentialStateForVault.test.ts
Normal 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 () => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/mobile/src/mobileGitCredentialStateForVault.ts
Normal file
19
apps/mobile/src/mobileGitCredentialStateForVault.ts
Normal 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)
|
||||||
|
}
|
||||||
73
apps/mobile/src/mobileGitCredentialStorage.test.ts
Normal file
73
apps/mobile/src/mobileGitCredentialStorage.test.ts
Normal 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' }
|
||||||
|
}
|
||||||
148
apps/mobile/src/mobileGitCredentialStorage.ts
Normal file
148
apps/mobile/src/mobileGitCredentialStorage.ts
Normal 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)
|
||||||
|
}
|
||||||
62
apps/mobile/src/mobileGitHubOAuth.test.ts
Normal file
62
apps/mobile/src/mobileGitHubOAuth.test.ts
Normal 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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
98
apps/mobile/src/mobileGitHubOAuth.ts
Normal file
98
apps/mobile/src/mobileGitHubOAuth.ts
Normal 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}.`
|
||||||
|
}
|
||||||
15
apps/mobile/src/mobileGitHubOAuthClientId.ts
Normal file
15
apps/mobile/src/mobileGitHubOAuthClientId.ts
Normal 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' }
|
||||||
|
}
|
||||||
20
apps/mobile/src/mobileGitHubOAuthEnvironment.test.ts
Normal file
20
apps/mobile/src/mobileGitHubOAuthEnvironment.test.ts
Normal 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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
26
apps/mobile/src/mobileGitHubOAuthEnvironment.ts
Normal file
26
apps/mobile/src/mobileGitHubOAuthEnvironment.ts
Normal 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)
|
||||||
|
}
|
||||||
65
apps/mobile/src/mobileGitHubOAuthFlow.test.ts
Normal file
65
apps/mobile/src/mobileGitHubOAuthFlow.test.ts
Normal 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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
68
apps/mobile/src/mobileGitHubOAuthFlow.ts
Normal file
68
apps/mobile/src/mobileGitHubOAuthFlow.ts
Normal 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' }
|
||||||
|
}
|
||||||
70
apps/mobile/src/mobileGitPrimaryAction.test.ts
Normal file
70
apps/mobile/src/mobileGitPrimaryAction.test.ts
Normal 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',
|
||||||
|
}
|
||||||
|
}
|
||||||
52
apps/mobile/src/mobileGitPrimaryAction.ts
Normal file
52
apps/mobile/src/mobileGitPrimaryAction.ts
Normal 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',
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/mobile/src/mobileGitRemote.test.ts
Normal file
46
apps/mobile/src/mobileGitRemote.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
71
apps/mobile/src/mobileGitRemote.ts
Normal file
71
apps/mobile/src/mobileGitRemote.ts
Normal 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
|
||||||
|
}
|
||||||
100
apps/mobile/src/mobileGitSyncFlowAction.test.ts
Normal file
100
apps/mobile/src/mobileGitSyncFlowAction.test.ts
Normal 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',
|
||||||
|
}
|
||||||
|
}
|
||||||
142
apps/mobile/src/mobileGitSyncFlowAction.ts
Normal file
142
apps/mobile/src/mobileGitSyncFlowAction.ts
Normal 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))
|
||||||
|
}
|
||||||
88
apps/mobile/src/mobileGitSyncPlan.test.ts
Normal file
88
apps/mobile/src/mobileGitSyncPlan.test.ts
Normal 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
|
||||||
|
}
|
||||||
118
apps/mobile/src/mobileGitSyncPlan.ts
Normal file
118
apps/mobile/src/mobileGitSyncPlan.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
53
apps/mobile/src/mobileGitSyncRuntimePlan.test.ts
Normal file
53
apps/mobile/src/mobileGitSyncRuntimePlan.test.ts
Normal 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',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
35
apps/mobile/src/mobileGitSyncRuntimePlan.ts
Normal file
35
apps/mobile/src/mobileGitSyncRuntimePlan.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
84
apps/mobile/src/mobileGitSyncStatus.test.ts
Normal file
84
apps/mobile/src/mobileGitSyncStatus.test.ts
Normal 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',
|
||||||
|
}
|
||||||
|
}
|
||||||
55
apps/mobile/src/mobileGitSyncStatus.ts
Normal file
55
apps/mobile/src/mobileGitSyncStatus.ts
Normal 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)
|
||||||
|
}
|
||||||
52
apps/mobile/src/mobileGitTransport.test.ts
Normal file
52
apps/mobile/src/mobileGitTransport.test.ts
Normal 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',
|
||||||
|
}
|
||||||
|
}
|
||||||
62
apps/mobile/src/mobileGitTransport.ts
Normal file
62
apps/mobile/src/mobileGitTransport.ts
Normal 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',
|
||||||
|
})
|
||||||
|
}
|
||||||
12
apps/mobile/src/mobileHtmlEntities.test.ts
Normal file
12
apps/mobile/src/mobileHtmlEntities.test.ts
Normal 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 B <C> ! ? &',
|
||||||
|
}),
|
||||||
|
).toBe('A B <C> ! ? &')
|
||||||
|
})
|
||||||
|
})
|
||||||
24
apps/mobile/src/mobileHtmlEntities.ts
Normal file
24
apps/mobile/src/mobileHtmlEntities.ts
Normal 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(/ /g, ' ')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/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
|
||||||
|
}
|
||||||
|
}
|
||||||
161
apps/mobile/src/mobileIsomorphicGitTransport.test.ts
Normal file
161
apps/mobile/src/mobileIsomorphicGitTransport.test.ts
Normal 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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
256
apps/mobile/src/mobileIsomorphicGitTransport.ts
Normal file
256
apps/mobile/src/mobileIsomorphicGitTransport.ts
Normal 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,
|
||||||
|
}
|
||||||
6
apps/mobile/src/mobileNativeAiProviderSecretStorage.ts
Normal file
6
apps/mobile/src/mobileNativeAiProviderSecretStorage.ts
Normal 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
Reference in New Issue
Block a user