Compare commits
231 Commits
alpha-v202
...
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 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=10.0
|
||||
AVERAGE_THRESHOLD=9.91
|
||||
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 \
|
||||
patchelf \
|
||||
build-essential \
|
||||
file
|
||||
file \
|
||||
rpm
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -326,7 +327,7 @@ jobs:
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
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
|
||||
run: |
|
||||
@@ -334,6 +335,7 @@ jobs:
|
||||
installers=(
|
||||
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/rpm/*.rpm
|
||||
)
|
||||
signatures=(
|
||||
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
|
||||
)
|
||||
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
|
||||
fi
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
@@ -356,6 +358,7 @@ jobs:
|
||||
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.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.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
|
||||
@@ -398,6 +401,16 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ 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
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -664,12 +677,14 @@ jobs:
|
||||
updater-x86_64/*.app.tar.gz.sig
|
||||
linux-x86_64-bundles/*.deb
|
||||
linux-x86_64-bundles/*.deb.sig
|
||||
linux-x86_64-bundles/*.rpm
|
||||
linux-x86_64-bundles/*.AppImage
|
||||
linux-x86_64-bundles/*.AppImage.sig
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz.sig
|
||||
linux-x86_64-bundles/*/*.deb
|
||||
linux-x86_64-bundles/*/*.deb.sig
|
||||
linux-x86_64-bundles/*/*.rpm
|
||||
linux-x86_64-bundles/*/*.AppImage
|
||||
linux-x86_64-bundles/*/*.AppImage.sig
|
||||
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 \
|
||||
patchelf \
|
||||
build-essential \
|
||||
file
|
||||
file \
|
||||
rpm
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -380,7 +381,7 @@ jobs:
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
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
|
||||
run: |
|
||||
@@ -388,6 +389,7 @@ jobs:
|
||||
installers=(
|
||||
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/rpm/*.rpm
|
||||
)
|
||||
signatures=(
|
||||
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
|
||||
)
|
||||
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
|
||||
fi
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
@@ -410,6 +412,7 @@ jobs:
|
||||
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.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.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
|
||||
@@ -452,6 +455,16 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ 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
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -715,12 +728,14 @@ jobs:
|
||||
updater-x86_64/*.app.tar.gz.sig
|
||||
linux-x86_64-bundles/*.deb
|
||||
linux-x86_64-bundles/*.deb.sig
|
||||
linux-x86_64-bundles/*.rpm
|
||||
linux-x86_64-bundles/*.AppImage
|
||||
linux-x86_64-bundles/*.AppImage.sig
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz.sig
|
||||
linux-x86_64-bundles/*/*.deb
|
||||
linux-x86_64-bundles/*/*.deb.sig
|
||||
linux-x86_64-bundles/*/*.rpm
|
||||
linux-x86_64-bundles/*/*.AppImage
|
||||
linux-x86_64-bundles/*/*.AppImage.sig
|
||||
linux-x86_64-bundles/*/*.AppImage.tar.gz
|
||||
|
||||
@@ -28,11 +28,16 @@ require_main_branch() {
|
||||
fi
|
||||
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [ "$CURRENT_BRANCH" != "main" ]; then
|
||||
echo "❌ Commits must happen on main. Current branch: $CURRENT_BRANCH"
|
||||
echo " Merge or cherry-pick your work onto main, then commit there."
|
||||
exit 1
|
||||
fi
|
||||
case "$CURRENT_BRANCH" in
|
||||
main|codex/mobile)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
echo "❌ Commits must happen on main. Current branch: $CURRENT_BRANCH"
|
||||
echo " Merge or cherry-pick your work onto main, then commit there."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
require_main_branch
|
||||
@@ -50,7 +55,7 @@ fi
|
||||
|
||||
# Unit tests
|
||||
echo " → tests..."
|
||||
pnpm exec vitest run --silent
|
||||
pnpm test -- --silent
|
||||
|
||||
echo "✅ Pre-commit passed"
|
||||
|
||||
|
||||
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.
|
||||
- Commit every 20–30 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
|
||||
- **⛔ 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
After both phases pass, add a **completion comment** to the Todoist task before running `/laputa-done`. The comment must include:
|
||||
- What was implemented (1–2 lines)
|
||||
After both phases pass, add a **completion comment** to the Todoist task. The comment must include:
|
||||
- What was implemented (a few lines covering logic and UX/UI)
|
||||
- QA: what was tested and how (Playwright / native screenshot / osascript)
|
||||
- Refactoring: any files refactored to meet the CodeScene gate (or "none needed")
|
||||
- ADRs: any new/updated ADRs (or "none")
|
||||
- Docs: any updated docs (ARCHITECTURE.md, ABSTRACTIONS.md, etc.) (or "none")
|
||||
- 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
|
||||
@@ -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.
|
||||
|
||||
### 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)
|
||||
|
||||
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.
|
||||
- **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
|
||||
|
||||
**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")`
|
||||
- `app.set_menu()` replaces the ENTIRE menu bar — include all submenus
|
||||
- `mock-tauri.ts` silently swallows Tauri calls — not a substitute for native testing
|
||||
|
||||
### QA scripts
|
||||
|
||||
```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/shortcut.sh "command" "s"
|
||||
```
|
||||
|
||||
24
README.md
24
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
# 💧 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
|
||||
* Organize company docs as context for AI
|
||||
@@ -31,9 +31,21 @@ You can find some Loom walkthroughs below — they are short and to the point:
|
||||
- ⌨️ **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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -71,12 +83,6 @@ Tauri 2 on Linux requires WebKit2GTK 4.1 and GTK 3:
|
||||
|
||||
The bundled MCP server still spawns the system `node` binary at runtime on Linux, so install Node from your distro package manager if you want the external AI tooling flow.
|
||||
|
||||
### Gemini CLI setup
|
||||
|
||||
Tolaria can register the active vault's MCP server in `~/.gemini/settings.json` from the status bar or command palette action "Set Up External AI Tools". The generated entry is scoped to the selected vault with `VAULT_PATH` and requires Node.js 18+ plus a separately installed and signed-in Gemini CLI.
|
||||
|
||||
Use "Restore Tolaria AI Guidance" if you also want Tolaria to create a non-destructive `GEMINI.md` compatibility shim in the vault root. The shim points Gemini back to the shared `AGENTS.md` instructions and is not written over custom `GEMINI.md` content.
|
||||
|
||||
### Quick start
|
||||
|
||||
```bash
|
||||
|
||||
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