Compare commits
273 Commits
site
...
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 | ||
|
|
c7b6832cb1 | ||
|
|
05a9aacd1e | ||
|
|
701ebe3f07 | ||
|
|
f49b8f60dc | ||
|
|
39615f49f3 | ||
|
|
3d7a0ee936 | ||
|
|
9faee02e04 | ||
|
|
dfff3a848b | ||
|
|
aaf0336733 | ||
|
|
5b906cb3a4 | ||
|
|
0a40f12a6a | ||
|
|
033759598a | ||
|
|
871b62fbd3 | ||
|
|
aa310b25df | ||
|
|
dbb12e96fe | ||
|
|
c795f118d0 | ||
|
|
d96d6efd57 | ||
|
|
67623d848b | ||
|
|
45d72297b3 | ||
|
|
4f86b97e8d | ||
|
|
b6dd6189a2 | ||
|
|
ba07bafc97 | ||
|
|
6d86770abf | ||
|
|
e23ba0ae27 | ||
|
|
7a97f24a0e | ||
|
|
20f34ab9d1 | ||
|
|
19b31cc96a | ||
|
|
e5ed863d5d | ||
|
|
4f7f5a31e4 | ||
|
|
439e2b7f66 | ||
|
|
048d27243d | ||
|
|
58e129cdbc | ||
|
|
713b486750 | ||
|
|
7d66e2f8e1 | ||
|
|
cd6847d59e | ||
|
|
74c9841ad7 | ||
|
|
ba41854f0e | ||
|
|
6532ee125a | ||
|
|
c9698b853e | ||
|
|
db16f2ff5e | ||
|
|
1e5d83d4a3 | ||
|
|
062de2656e | ||
|
|
65d14ebf6f | ||
|
|
0b5149ba8d | ||
|
|
c23a3c1caf | ||
|
|
44e2870697 | ||
|
|
802b6b77d9 | ||
|
|
bb09fcfd6f | ||
|
|
c8ac12f3dc | ||
|
|
fbfda2f15c | ||
|
|
c4c7737e83 | ||
|
|
c24c60b433 | ||
|
|
21532de941 | ||
|
|
f92efd72df | ||
|
|
6e41328f21 | ||
|
|
5b2b4cd569 | ||
|
|
494aeb9533 | ||
|
|
8272e68934 | ||
|
|
7ba20c1e83 | ||
|
|
3384b9acd0 | ||
|
|
8b36ff6b00 | ||
|
|
acb35cb5df | ||
|
|
a8bda0a11b | ||
|
|
4acbe17e1e | ||
|
|
09759d204c | ||
|
|
7a01ef6952 | ||
|
|
1c010ddc22 | ||
|
|
0df5c7882d | ||
|
|
2d138f1564 | ||
|
|
c9ee81c5dc | ||
|
|
04fa74e8d8 | ||
|
|
b42ed35596 | ||
|
|
75e1a82ff0 | ||
|
|
2af2156526 | ||
|
|
568ada8992 | ||
|
|
0987946cff | ||
|
|
81b03f05b6 | ||
|
|
ee71a00c6f | ||
|
|
adcaa8a387 | ||
|
|
4a03f1675e | ||
|
|
50c304fce5 | ||
|
|
aa4d4fd12e | ||
|
|
b31f0b57da | ||
|
|
d3a577b488 | ||
|
|
fbb0927401 | ||
|
|
687d6403a3 | ||
|
|
1738dd2405 | ||
|
|
8c286a4856 | ||
|
|
0331b40ae2 | ||
|
|
d53c3fa7e5 | ||
|
|
3c14d60be7 | ||
|
|
765a16a4e8 | ||
|
|
886ded9575 | ||
|
|
831f6eb72b | ||
|
|
45efb08b23 | ||
|
|
fdc7d8fd84 | ||
|
|
690ace2d1f | ||
|
|
a267a00716 | ||
|
|
536cab3491 | ||
|
|
e49af5855b | ||
|
|
9012cd901b | ||
|
|
0d6e3853f2 | ||
|
|
8754b1bc77 | ||
|
|
ec5751711f | ||
|
|
0995c90cc0 | ||
|
|
dede400296 | ||
|
|
65ff44eb50 | ||
|
|
2c06c8dcc2 | ||
|
|
bc56eec35e | ||
|
|
388d389ba4 | ||
|
|
a1a4cb50d0 | ||
|
|
a7ed9adaae | ||
|
|
f8f08f4160 | ||
|
|
c0afae2296 | ||
|
|
176f983dc9 | ||
|
|
01a87ed478 | ||
|
|
18b4f9d078 | ||
|
|
fd3afc2493 | ||
|
|
0ad207e318 | ||
|
|
2e40945c2d | ||
|
|
54d53b882f | ||
|
|
848d718373 | ||
|
|
ec83e9b839 | ||
|
|
ce859aa0e7 | ||
|
|
1f4889fe66 | ||
|
|
2a2ea1b8ef | ||
|
|
b2a50d9870 | ||
|
|
cca72a9d6a | ||
|
|
ef0e85b053 | ||
|
|
2624d32b50 | ||
|
|
9c42da5745 | ||
|
|
454acb5ded | ||
|
|
2a691f68db | ||
|
|
c4de154410 | ||
|
|
670aae9f2e | ||
|
|
f3b4e5e61d | ||
|
|
4c4f01af43 | ||
|
|
79d7eb9795 | ||
|
|
d1ed826714 | ||
|
|
2e8286d2b3 | ||
|
|
cb9ebaadad | ||
|
|
c295c9f2b5 | ||
|
|
a4b11089c0 | ||
|
|
6f29542528 | ||
|
|
dcd0d73848 | ||
|
|
0e8c8fb61a | ||
|
|
42bdd559ef | ||
|
|
3a6c97272d | ||
|
|
f53a3746d3 | ||
|
|
23145a05f5 | ||
|
|
5b86b0b50d | ||
|
|
dc1b1783b6 | ||
|
|
3dc520abea | ||
|
|
792b3009dd | ||
|
|
6ee7f219f5 | ||
|
|
3db1ed9dc5 | ||
|
|
7a7ef5d9ca | ||
|
|
4c42cf4d52 | ||
|
|
405b711e16 | ||
|
|
83705e26f5 | ||
|
|
434927160c | ||
|
|
ec2e04a464 | ||
|
|
a17ec689a3 | ||
|
|
eda4d380e0 | ||
|
|
f9b155ba6e | ||
|
|
4a62008b1f | ||
|
|
a08cee7965 | ||
|
|
a7c480af62 | ||
|
|
f4790d5763 | ||
|
|
ed52a9e800 | ||
|
|
2fcd808f09 | ||
|
|
9d15286f7d | ||
|
|
537bd0f3ec | ||
|
|
648241604f | ||
|
|
c6f3d9d945 | ||
|
|
65e54f108a | ||
|
|
58f8a774b5 | ||
|
|
0bf280d3ec | ||
|
|
d2d4746d5c | ||
|
|
91db7c0098 | ||
|
|
87908d982f | ||
|
|
40544fc551 | ||
|
|
f34fdb0289 | ||
|
|
e601a42f41 | ||
|
|
de627c2478 | ||
|
|
46d9aaed48 | ||
|
|
6b96b5caf6 | ||
|
|
595c48ca59 | ||
|
|
fd92ff5e9b | ||
|
|
a39b346a75 | ||
|
|
deac10caa2 | ||
|
|
0e1e0ff47a | ||
|
|
a645edbc19 | ||
|
|
48f1fb0b50 | ||
|
|
1aede09834 | ||
|
|
d51b76f992 | ||
|
|
8e66fb4c19 | ||
|
|
a2843a0cf3 | ||
|
|
38fb8a7370 | ||
|
|
be5a8bb300 | ||
|
|
ce040c75fd |
@@ -1,2 +1,2 @@
|
|||||||
HOTSPOT_THRESHOLD=10.0
|
HOTSPOT_THRESHOLD=10.0
|
||||||
AVERAGE_THRESHOLD=9.89
|
AVERAGE_THRESHOLD=9.92
|
||||||
|
|||||||
137
.github/scripts/prefetch-tauri-nsis.ps1
vendored
Normal file
137
.github/scripts/prefetch-tauri-nsis.ps1
vendored
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$nsisUrl = "https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip"
|
||||||
|
$nsisSha1 = "EF7FF767E5CBD9EDD22ADD3A32C9B8F4500BB10D"
|
||||||
|
$tauriUtilsUrl = "https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/nsis_tauri_utils.dll"
|
||||||
|
$tauriUtilsSha1 = "75197FEE3C6A814FE035788D1C34EAD39349B860"
|
||||||
|
$tauriUtilsRelativePath = "Plugins\x86-unicode\additional\nsis_tauri_utils.dll"
|
||||||
|
|
||||||
|
$nsisRequiredFiles = @(
|
||||||
|
"makensis.exe",
|
||||||
|
"Bin\makensis.exe",
|
||||||
|
"Stubs\lzma-x86-unicode",
|
||||||
|
"Stubs\lzma_solid-x86-unicode",
|
||||||
|
"Include\MUI2.nsh",
|
||||||
|
"Include\FileFunc.nsh",
|
||||||
|
"Include\x64.nsh",
|
||||||
|
"Include\nsDialogs.nsh",
|
||||||
|
"Include\WinMessages.nsh",
|
||||||
|
"Include\Win\COM.nsh",
|
||||||
|
"Include\Win\Propkey.nsh",
|
||||||
|
"Include\Win\RestartManager.nsh"
|
||||||
|
)
|
||||||
|
|
||||||
|
function Get-UpperSha1 {
|
||||||
|
param([Parameter(Mandatory = $true)][string]$Path)
|
||||||
|
|
||||||
|
return (Get-FileHash -Algorithm SHA1 -LiteralPath $Path).Hash.ToUpperInvariant()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-FileSha1 {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Path,
|
||||||
|
[Parameter(Mandatory = $true)][string]$ExpectedSha1
|
||||||
|
)
|
||||||
|
|
||||||
|
return (Test-Path -LiteralPath $Path) -and ((Get-UpperSha1 -Path $Path) -eq $ExpectedSha1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Save-VerifiedDownload {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Uri,
|
||||||
|
[Parameter(Mandatory = $true)][string]$OutFile,
|
||||||
|
[Parameter(Mandatory = $true)][string]$ExpectedSha1
|
||||||
|
)
|
||||||
|
|
||||||
|
$parent = Split-Path -Parent $OutFile
|
||||||
|
New-Item -ItemType Directory -Force -Path $parent | Out-Null
|
||||||
|
|
||||||
|
$tempFile = "$OutFile.download"
|
||||||
|
for ($attempt = 1; $attempt -le 5; $attempt++) {
|
||||||
|
try {
|
||||||
|
Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath $tempFile
|
||||||
|
Invoke-WebRequest -Uri $Uri -OutFile $tempFile -TimeoutSec 120
|
||||||
|
|
||||||
|
$actualSha1 = Get-UpperSha1 -Path $tempFile
|
||||||
|
if ($actualSha1 -ne $ExpectedSha1) {
|
||||||
|
throw "SHA1 mismatch for $Uri. Expected $ExpectedSha1, got $actualSha1."
|
||||||
|
}
|
||||||
|
|
||||||
|
Move-Item -Force -LiteralPath $tempFile -Destination $OutFile
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath $tempFile
|
||||||
|
if ($attempt -eq 5) {
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
|
||||||
|
$delaySeconds = [Math]::Min(30, 5 * $attempt)
|
||||||
|
Write-Warning "Download attempt ${attempt} failed: $($_.Exception.Message). Retrying in ${delaySeconds}s."
|
||||||
|
Start-Sleep -Seconds $delaySeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Find-MissingFile {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Root,
|
||||||
|
[Parameter(Mandatory = $true)][string[]]$RelativePaths
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($relativePath in $RelativePaths) {
|
||||||
|
if (-not (Test-Path -LiteralPath (Join-Path $Root $relativePath))) {
|
||||||
|
return $relativePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
|
||||||
|
throw "LOCALAPPDATA is required to resolve Tauri's Windows tool cache."
|
||||||
|
}
|
||||||
|
|
||||||
|
$tauriToolsPath = Join-Path $env:LOCALAPPDATA "tauri"
|
||||||
|
$nsisPath = Join-Path $tauriToolsPath "NSIS"
|
||||||
|
$downloadRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) {
|
||||||
|
[System.IO.Path]::GetTempPath()
|
||||||
|
} else {
|
||||||
|
$env:RUNNER_TEMP
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $tauriToolsPath | Out-Null
|
||||||
|
|
||||||
|
$missingNsisFile = Find-MissingFile -Root $nsisPath -RelativePaths $nsisRequiredFiles
|
||||||
|
if ($missingNsisFile) {
|
||||||
|
Write-Host "Tauri NSIS cache is missing $missingNsisFile; downloading NSIS 3.11."
|
||||||
|
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath $nsisPath
|
||||||
|
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $tauriToolsPath "nsis-3.11")
|
||||||
|
|
||||||
|
$zipPath = Join-Path $downloadRoot "nsis-3.11.zip"
|
||||||
|
Save-VerifiedDownload -Uri $nsisUrl -OutFile $zipPath -ExpectedSha1 $nsisSha1
|
||||||
|
Expand-Archive -Force -LiteralPath $zipPath -DestinationPath $tauriToolsPath
|
||||||
|
|
||||||
|
$extractedNsisPath = Join-Path $tauriToolsPath "nsis-3.11"
|
||||||
|
if (-not (Test-Path -LiteralPath $extractedNsisPath)) {
|
||||||
|
throw "Downloaded NSIS archive did not contain the expected nsis-3.11 directory."
|
||||||
|
}
|
||||||
|
|
||||||
|
Move-Item -Force -LiteralPath $extractedNsisPath -Destination $nsisPath
|
||||||
|
} else {
|
||||||
|
Write-Host "Tauri NSIS cache already contains NSIS 3.11."
|
||||||
|
}
|
||||||
|
|
||||||
|
$tauriUtilsPath = Join-Path $nsisPath $tauriUtilsRelativePath
|
||||||
|
if (-not (Test-FileSha1 -Path $tauriUtilsPath -ExpectedSha1 $tauriUtilsSha1)) {
|
||||||
|
Write-Host "Downloading Tauri NSIS utility plugin."
|
||||||
|
Save-VerifiedDownload -Uri $tauriUtilsUrl -OutFile $tauriUtilsPath -ExpectedSha1 $tauriUtilsSha1
|
||||||
|
} else {
|
||||||
|
Write-Host "Tauri NSIS utility plugin is already cached."
|
||||||
|
}
|
||||||
|
|
||||||
|
$missingFile = Find-MissingFile -Root $nsisPath -RelativePaths ($nsisRequiredFiles + @($tauriUtilsRelativePath))
|
||||||
|
if ($missingFile) {
|
||||||
|
throw "Tauri NSIS toolchain is incomplete after prefetch; missing $missingFile."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Tauri NSIS toolchain ready at $nsisPath."
|
||||||
21
.github/workflows/release-stable.yml
vendored
21
.github/workflows/release-stable.yml
vendored
@@ -274,7 +274,8 @@ jobs:
|
|||||||
wget \
|
wget \
|
||||||
patchelf \
|
patchelf \
|
||||||
build-essential \
|
build-essential \
|
||||||
file
|
file \
|
||||||
|
rpm
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
@@ -326,7 +327,7 @@ jobs:
|
|||||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||||
run: |
|
run: |
|
||||||
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage
|
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
|
||||||
|
|
||||||
- name: Validate Linux bundles
|
- name: Validate Linux bundles
|
||||||
run: |
|
run: |
|
||||||
@@ -334,6 +335,7 @@ jobs:
|
|||||||
installers=(
|
installers=(
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||||
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
||||||
)
|
)
|
||||||
signatures=(
|
signatures=(
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||||
@@ -341,7 +343,7 @@ jobs:
|
|||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||||
)
|
)
|
||||||
if [ ${#installers[@]} -eq 0 ]; then
|
if [ ${#installers[@]} -eq 0 ]; then
|
||||||
echo "::error::Linux build produced no AppImage or deb bundle."
|
echo "::error::Linux build produced no AppImage, deb or rpm bundle."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ ${#signatures[@]} -eq 0 ]; then
|
if [ ${#signatures[@]} -eq 0 ]; then
|
||||||
@@ -356,6 +358,7 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||||
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
|
||||||
@@ -398,6 +401,16 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||||
|
|
||||||
|
- name: Cache Tauri Windows tools
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~\AppData\Local\tauri
|
||||||
|
key: ${{ runner.os }}-tauri-tools-nsis-3.11-nsis-tauri-utils-0.5.3
|
||||||
|
|
||||||
|
- name: Prefetch Tauri NSIS toolchain
|
||||||
|
shell: pwsh
|
||||||
|
run: ./.github/scripts/prefetch-tauri-nsis.ps1
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -664,12 +677,14 @@ jobs:
|
|||||||
updater-x86_64/*.app.tar.gz.sig
|
updater-x86_64/*.app.tar.gz.sig
|
||||||
linux-x86_64-bundles/*.deb
|
linux-x86_64-bundles/*.deb
|
||||||
linux-x86_64-bundles/*.deb.sig
|
linux-x86_64-bundles/*.deb.sig
|
||||||
|
linux-x86_64-bundles/*.rpm
|
||||||
linux-x86_64-bundles/*.AppImage
|
linux-x86_64-bundles/*.AppImage
|
||||||
linux-x86_64-bundles/*.AppImage.sig
|
linux-x86_64-bundles/*.AppImage.sig
|
||||||
linux-x86_64-bundles/*.AppImage.tar.gz
|
linux-x86_64-bundles/*.AppImage.tar.gz
|
||||||
linux-x86_64-bundles/*.AppImage.tar.gz.sig
|
linux-x86_64-bundles/*.AppImage.tar.gz.sig
|
||||||
linux-x86_64-bundles/*/*.deb
|
linux-x86_64-bundles/*/*.deb
|
||||||
linux-x86_64-bundles/*/*.deb.sig
|
linux-x86_64-bundles/*/*.deb.sig
|
||||||
|
linux-x86_64-bundles/*/*.rpm
|
||||||
linux-x86_64-bundles/*/*.AppImage
|
linux-x86_64-bundles/*/*.AppImage
|
||||||
linux-x86_64-bundles/*/*.AppImage.sig
|
linux-x86_64-bundles/*/*.AppImage.sig
|
||||||
linux-x86_64-bundles/*/*.AppImage.tar.gz
|
linux-x86_64-bundles/*/*.AppImage.tar.gz
|
||||||
|
|||||||
21
.github/workflows/release.yml
vendored
21
.github/workflows/release.yml
vendored
@@ -328,7 +328,8 @@ jobs:
|
|||||||
wget \
|
wget \
|
||||||
patchelf \
|
patchelf \
|
||||||
build-essential \
|
build-essential \
|
||||||
file
|
file \
|
||||||
|
rpm
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
@@ -380,7 +381,7 @@ jobs:
|
|||||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||||
run: |
|
run: |
|
||||||
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage
|
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
|
||||||
|
|
||||||
- name: Validate Linux bundles
|
- name: Validate Linux bundles
|
||||||
run: |
|
run: |
|
||||||
@@ -388,6 +389,7 @@ jobs:
|
|||||||
installers=(
|
installers=(
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||||
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
||||||
)
|
)
|
||||||
signatures=(
|
signatures=(
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||||
@@ -395,7 +397,7 @@ jobs:
|
|||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||||
)
|
)
|
||||||
if [ ${#installers[@]} -eq 0 ]; then
|
if [ ${#installers[@]} -eq 0 ]; then
|
||||||
echo "::error::Linux build produced no AppImage or deb bundle."
|
echo "::error::Linux build produced no AppImage, deb or rpm bundle."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ ${#signatures[@]} -eq 0 ]; then
|
if [ ${#signatures[@]} -eq 0 ]; then
|
||||||
@@ -410,6 +412,7 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||||
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
|
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
|
||||||
@@ -452,6 +455,16 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||||
|
|
||||||
|
- name: Cache Tauri Windows tools
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~\AppData\Local\tauri
|
||||||
|
key: ${{ runner.os }}-tauri-tools-nsis-3.11-nsis-tauri-utils-0.5.3
|
||||||
|
|
||||||
|
- name: Prefetch Tauri NSIS toolchain
|
||||||
|
shell: pwsh
|
||||||
|
run: ./.github/scripts/prefetch-tauri-nsis.ps1
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -715,12 +728,14 @@ jobs:
|
|||||||
updater-x86_64/*.app.tar.gz.sig
|
updater-x86_64/*.app.tar.gz.sig
|
||||||
linux-x86_64-bundles/*.deb
|
linux-x86_64-bundles/*.deb
|
||||||
linux-x86_64-bundles/*.deb.sig
|
linux-x86_64-bundles/*.deb.sig
|
||||||
|
linux-x86_64-bundles/*.rpm
|
||||||
linux-x86_64-bundles/*.AppImage
|
linux-x86_64-bundles/*.AppImage
|
||||||
linux-x86_64-bundles/*.AppImage.sig
|
linux-x86_64-bundles/*.AppImage.sig
|
||||||
linux-x86_64-bundles/*.AppImage.tar.gz
|
linux-x86_64-bundles/*.AppImage.tar.gz
|
||||||
linux-x86_64-bundles/*.AppImage.tar.gz.sig
|
linux-x86_64-bundles/*.AppImage.tar.gz.sig
|
||||||
linux-x86_64-bundles/*/*.deb
|
linux-x86_64-bundles/*/*.deb
|
||||||
linux-x86_64-bundles/*/*.deb.sig
|
linux-x86_64-bundles/*/*.deb.sig
|
||||||
|
linux-x86_64-bundles/*/*.rpm
|
||||||
linux-x86_64-bundles/*/*.AppImage
|
linux-x86_64-bundles/*/*.AppImage
|
||||||
linux-x86_64-bundles/*/*.AppImage.sig
|
linux-x86_64-bundles/*/*.AppImage.sig
|
||||||
linux-x86_64-bundles/*/*.AppImage.tar.gz
|
linux-x86_64-bundles/*/*.AppImage.tar.gz
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
31
AGENTS.md
31
AGENTS.md
@@ -22,7 +22,7 @@ Run `/laputa-next-task` — fetches next task (To Rework first, then Open), move
|
|||||||
- Work on `main` branch — **no branches, no PRs, ever**. Pre-commit and pre-push block work from any other branch.
|
- Work on `main` branch — **no branches, no PRs, ever**. Pre-commit and pre-push block work from any other branch.
|
||||||
- Commit every 20–30 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
|
- Commit every 20–30 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
|
||||||
- **⛔ NEVER use --no-verify**
|
- **⛔ NEVER use --no-verify**
|
||||||
- For UI tasks: open `ui-design.pen` first, study visual language, design in light mode
|
- For UI tasks: open `ui-design.pen` first, study visual language, design in light mode. You don't need Pencil to use it – you can open it as a JSON file.
|
||||||
|
|
||||||
### 1c. When done
|
### 1c. When done
|
||||||
|
|
||||||
@@ -47,16 +47,14 @@ bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/qa-native.png
|
|||||||
|
|
||||||
Use `osascript` for keyboard interactions. Write result as Todoist comment (✅ or ❌). **⚠️ WKWebView:** `osascript keystroke` blocked inside editor — rely on Playwright for text input features.
|
Use `osascript` for keyboard interactions. Write result as Todoist comment (✅ or ❌). **⚠️ WKWebView:** `osascript keystroke` blocked inside editor — rely on Playwright for text input features.
|
||||||
|
|
||||||
After both phases pass, add a **completion comment** to the Todoist task before running `/laputa-done`. The comment must include:
|
After both phases pass, add a **completion comment** to the Todoist task. The comment must include:
|
||||||
- What was implemented (1–2 lines)
|
- What was implemented (a few lines covering logic and UX/UI)
|
||||||
- QA: what was tested and how (Playwright / native screenshot / osascript)
|
- QA: what was tested and how (Playwright / native screenshot / osascript)
|
||||||
- Refactoring: any files refactored to meet the CodeScene gate (or "none needed")
|
- Refactoring: any files refactored to meet the CodeScene gate (or "none needed")
|
||||||
- ADRs: any new/updated ADRs (or "none")
|
- ADRs: any new/updated ADRs (or "none")
|
||||||
- Docs: any updated docs (ARCHITECTURE.md, ABSTRACTIONS.md, etc.) (or "none")
|
- Docs: any updated docs (ARCHITECTURE.md, ABSTRACTIONS.md, etc.) (or "none")
|
||||||
- Code health: final Hotspot and Average scores after push
|
- Code health: final Hotspot and Average scores after push
|
||||||
|
|
||||||
Then run `/laputa-done <task_id>` → moves to In Review, notifies Brian, self-dispatches next task.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Development Process
|
## 2. Development Process
|
||||||
@@ -73,6 +71,22 @@ Red → Green → Refactor → Commit. One cycle per commit. For bugs: write fai
|
|||||||
|
|
||||||
**Test quality (Kent Beck's Desiderata):** Isolated · Deterministic · Fast · Behavioral · Structure-insensitive · Specific · Predictive. Fix flaky tests first. Prefer E2E over unit tests for user flows.
|
**Test quality (Kent Beck's Desiderata):** Isolated · Deterministic · Fast · Behavioral · Structure-insensitive · Specific · Predictive. Fix flaky tests first. Prefer E2E over unit tests for user flows.
|
||||||
|
|
||||||
|
### Localization (mandatory for UI copy)
|
||||||
|
|
||||||
|
All user-facing UI labels/copy must live in `src/lib/locales/en.json` and be translated into every target listed in `lara.yaml`. When adding or changing interface copy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm l10n:translate
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `pnpm l10n:translate:force` only when intentionally regenerating existing translations. Commit `src/lib/locales/*.json`, `lara.yaml`/`lara.lock` changes if produced, and verify placeholders/product names stayed intact.
|
||||||
|
|
||||||
|
### Product analytics (mandatory for meaningful features)
|
||||||
|
|
||||||
|
New features should almost always emit a PostHog event so we can see whether users actually discover and use them. Skip instrumentation only for very small changes where a dedicated event would create noise. Use clear, stable event names, avoid PII or note content, and include only safe metadata that helps evaluate adoption and failures.
|
||||||
|
|
||||||
|
When adding or changing a meaningful user-facing feature, include the event name(s) in the Todoist completion comment alongside QA, docs, and code health. If intentionally not instrumenting a feature, explain why in the completion comment.
|
||||||
|
|
||||||
### Code health (mandatory)
|
### Code health (mandatory)
|
||||||
|
|
||||||
Pre-commit and pre-push hooks enforce **Hotspot Code Health** and **Average Code Health** ≥ thresholds in `.codescene-thresholds`. Both gates block commit/push. Thresholds are a **ratchet** — only go up. When pre-push sees improved remote scores, it updates `.codescene-thresholds`, stages it, and stops so you can commit the new floor with normal verified hooks before pushing again. Never add `// eslint-disable`, `#[allow(...)]`, or `as any`.
|
Pre-commit and pre-push hooks enforce **Hotspot Code Health** and **Average Code Health** ≥ thresholds in `.codescene-thresholds`. Both gates block commit/push. Thresholds are a **ratchet** — only go up. When pre-push sees improved remote scores, it updates `.codescene-thresholds`, stages it, and stops so you can commit the new floor with normal verified hooks before pushing again. Never add `// eslint-disable`, `#[allow(...)]`, or `as any`.
|
||||||
@@ -122,10 +136,6 @@ Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing:
|
|||||||
- **Delete all test notes from disk** when done — do not leave untitled or temporary notes on the filesystem. Run `cd ~/Laputa && git checkout -- . && git clean -fd` to restore the vault to its last committed state.
|
- **Delete all test notes from disk** when done — do not leave untitled or temporary notes on the filesystem. Run `cd ~/Laputa && git checkout -- . && git clean -fd` to restore the vault to its last committed state.
|
||||||
- **Rationale:** test notes pollute the local vault over time, making it a collection of nonsensical untitled files. The vault must stay clean on disk, not just on the remote.
|
- **Rationale:** test notes pollute the local vault over time, making it a collection of nonsensical untitled files. The vault must stay clean on disk, not just on the remote.
|
||||||
|
|
||||||
### UI design
|
|
||||||
|
|
||||||
Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task; on completion merge into `ui-design.pen` and delete it.
|
|
||||||
|
|
||||||
### UI components — mandatory rules
|
### UI components — mandatory rules
|
||||||
|
|
||||||
**Always use shadcn/ui components.** Never use raw HTML form elements (`<input>`, `<select>`, `<button>`, native `<input type="date">`, etc.) for user-facing UI. Every interactive element must use the shadcn/ui equivalent:
|
**Always use shadcn/ui components.** Never use raw HTML form elements (`<input>`, `<select>`, `<button>`, native `<input type="date">`, etc.) for user-facing UI. Every interactive element must use the shadcn/ui equivalent:
|
||||||
@@ -155,10 +165,11 @@ Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task
|
|||||||
- Tauri menu accelerators: `MenuItemBuilder::new(label).accelerator("CmdOrCtrl+1")`
|
- Tauri menu accelerators: `MenuItemBuilder::new(label).accelerator("CmdOrCtrl+1")`
|
||||||
- `app.set_menu()` replaces the ENTIRE menu bar — include all submenus
|
- `app.set_menu()` replaces the ENTIRE menu bar — include all submenus
|
||||||
- `mock-tauri.ts` silently swallows Tauri calls — not a substitute for native testing
|
- `mock-tauri.ts` silently swallows Tauri calls — not a substitute for native testing
|
||||||
|
|
||||||
### QA scripts
|
### QA scripts
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
|
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh Tolaria
|
||||||
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/out.png
|
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/out.png
|
||||||
bash ~/.openclaw/skills/tolaria-qa/scripts/shortcut.sh "command" "s"
|
bash ~/.openclaw/skills/tolaria-qa/scripts/shortcut.sh "command" "s"
|
||||||
```
|
```
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# 💧 Tolaria
|
# 💧 Tolaria
|
||||||
|
|
||||||
Tolaria is a desktop app for Mac and Linux for managing **markdown knowledge bases**. People use it for a variety of use cases:
|
Tolaria is a desktop app for macOS, Windows, and Linux for managing **markdown knowledge bases**. People use it for a variety of use cases:
|
||||||
|
|
||||||
* Operate second brains and personal knowledge
|
* Operate second brains and personal knowledge
|
||||||
* Organize company docs as context for AI
|
* Organize company docs as context for AI
|
||||||
@@ -27,13 +27,25 @@ You can find some Loom walkthroughs below — they are short and to the point:
|
|||||||
- 🔬 **Open source** — Tolaria is free and open source. I built this for [myself](https://x.com/lucaronin) and for sharing it with others.
|
- 🔬 **Open source** — Tolaria is free and open source. I built this for [myself](https://x.com/lucaronin) and for sharing it with others.
|
||||||
- 📋 **Standards-based** — Notes are markdown files with YAML frontmatter. No proprietary formats, no locked-in data. Everything works with standard tools if you decide to move away from Tolaria.
|
- 📋 **Standards-based** — Notes are markdown files with YAML frontmatter. No proprietary formats, no locked-in data. Everything works with standard tools if you decide to move away from Tolaria.
|
||||||
- 🔍 **Types as lenses, not schemas** — Types in Tolaria are navigation aids, not enforcement mechanisms. There's no required fields, no validation, just helpful categories for finding notes.
|
- 🔍 **Types as lenses, not schemas** — Types in Tolaria are navigation aids, not enforcement mechanisms. There's no required fields, no validation, just helpful categories for finding notes.
|
||||||
- 🪄**AI-first but not AI-only** — A vault of files works very well with AI agents, but you are free to use whatever you want. We support Claude Code and Codex CLI (for now), but you can edit the vault with any AI you want. We provide an AGENTS file for your agents to figure out.
|
- 🪄**AI-first but not AI-only** — A vault of files works very well with AI agents, but you are free to use whatever you want. We support Claude Code, Codex CLI, and Gemini CLI setup paths, but you can edit the vault with any AI you want. We provide an AGENTS file for your agents to figure out.
|
||||||
- ⌨️ **Keyboard-first** — Tolaria is designed for power-users who want to use keyboard as much as possible. A lot of how we designed the Editor and the Command Palette is based on this.
|
- ⌨️ **Keyboard-first** — Tolaria is designed for power-users who want to use keyboard as much as possible. A lot of how we designed the Editor and the Command Palette is based on this.
|
||||||
- 💪 **Built from real use** — Tolaria was created for manage my personal vault of 10,000+ notes, and I use it every day. Every feature exists because it solved a real problem.
|
- 💪 **Built from real use** — Tolaria was created for manage my personal vault of 10,000+ notes, and I use it every day. Every feature exists because it solved a real problem.
|
||||||
|
|
||||||
## Getting started
|
## Installation
|
||||||
|
|
||||||
Download the [latest release here](https://github.com/refactoringhq/tolaria/releases/latest/download/Tolaria.app.tar.gz).
|
### Homebrew
|
||||||
|
|
||||||
|
Install via Homebrew on macOS:
|
||||||
|
|
||||||
|
```batch
|
||||||
|
brew install --cask tolaria
|
||||||
|
```
|
||||||
|
|
||||||
|
### Download from releases
|
||||||
|
|
||||||
|
Download the [latest release here](https://refactoringhq.github.io/tolaria/download/) for macOS, Windows, or Linux.
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
When you open Tolaria for the first time you get the chance of cloning the [getting started vault](https://github.com/refactoringhq/tolaria-getting-started) — which gives you a walkthrough of the whole app.
|
When you open Tolaria for the first time you get the chance of cloning the [getting started vault](https://github.com/refactoringhq/tolaria-getting-started) — which gives you a walkthrough of the whole app.
|
||||||
|
|
||||||
|
|||||||
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',
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user