Compare commits

...

311 Commits

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

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-03 12:53:51 +02:00
lucaronin
033759598a fix: improve editor block controls 2026-05-03 12:28:00 +02:00
lucaronin
871b62fbd3 feat: add markdown whiteboards 2026-05-03 12:17:20 +02:00
lucaronin
aa310b25df fix(ai): expand agent vault paths before spawn 2026-05-03 11:57:15 +02:00
lucaronin
dbb12e96fe feat(editor): add code block copy action 2026-05-03 11:34:56 +02:00
lucaronin
c795f118d0 fix(ui): preserve tooltip positioning under zoom 2026-05-03 11:01:49 +02:00
lucaronin
d96d6efd57 fix: support automatic rtl editor direction 2026-05-03 10:51:45 +02:00
lucaronin
67623d848b fix(layout): keep resize handles above panes 2026-05-03 10:24:16 +02:00
lucaronin
45d72297b3 test: cover image asset URLs on tab swap 2026-05-03 04:14:02 +02:00
lucaronin
4f86b97e8d fix(ai): cap agent composer height 2026-05-02 20:41:08 +02:00
lucaronin
b6dd6189a2 fix(linux): locate appimage mcp resources 2026-05-02 20:08:11 +02:00
lucaronin
ba07bafc97 fix: expose codex mcp tools reliably 2026-05-02 19:47:33 +02:00
lucaronin
6d86770abf fix: compact large ai agent context 2026-05-02 19:24:52 +02:00
lucaronin
e23ba0ae27 fix: normalize windows wikilink targets 2026-05-02 19:01:00 +02:00
lucaronin
7a97f24a0e fix: detect codex windows npm shims 2026-05-02 18:41:38 +02:00
lucaronin
20f34ab9d1 fix: preserve ai panel delete shortcuts 2026-05-02 18:12:37 +02:00
lucaronin
19b31cc96a fix: recover when active vault disappears 2026-05-02 17:53:13 +02:00
lucaronin
e5ed863d5d fix: refine sidebar folder rows 2026-05-02 17:21:58 +02:00
lucaronin
4f7f5a31e4 fix(linux): choose matching wayland preload 2026-05-02 17:01:57 +02:00
lucaronin
439e2b7f66 fix: prefetch tauri nsis tools in release ci 2026-05-02 16:45:59 +02:00
lucaronin
048d27243d fix: refine slash command menu 2026-05-02 16:39:43 +02:00
lucaronin
58e129cdbc fix: stabilize editor block dragging 2026-05-02 12:18:59 +02:00
lucaronin
713b486750 fix: render release history tabs as pills 2026-05-02 11:25:39 +02:00
lucaronin
7d66e2f8e1 fix: guard table handle add selection 2026-05-02 11:16:09 +02:00
lucaronin
cd6847d59e feat: refine titlebar navigation controls 2026-05-02 10:56:36 +02:00
lucaronin
74c9841ad7 fix(ui): refine icon picker appearance 2026-05-02 10:16:24 +02:00
lucaronin
ba41854f0e fix: ignore malformed reload entries 2026-05-02 10:00:50 +02:00
lucaronin
6532ee125a fix(vault): prevent gitignored folder scan freezes 2026-05-02 09:28:51 +02:00
lucaronin
c9698b853e fix(notes): serialize immediate note creation writes 2026-05-02 03:56:48 +02:00
lucaronin
db16f2ff5e fix(views): ignore stale saved-view deletes 2026-05-02 03:25:02 +02:00
lucaronin
1e5d83d4a3 refactor(commands): share app command manifest 2026-05-02 02:57:45 +02:00
lucaronin
062de2656e refactor(frontmatter): share canonical key rules 2026-05-02 02:16:13 +02:00
lucaronin
65d14ebf6f fix(editor): theme code blocks in light mode 2026-05-02 01:47:42 +02:00
lucaronin
0b5149ba8d fix(ai): preserve gui path for cli agents 2026-05-02 01:26:34 +02:00
lucaronin
c23a3c1caf fix: add sidebar section bottom spacing 2026-05-02 01:00:06 +02:00
lucaronin
44e2870697 fix(editor): preserve pasted markdown raw toggles 2026-05-02 00:50:21 +02:00
lucaronin
802b6b77d9 fix: align macOS header controls 2026-05-02 00:35:14 +02:00
lucaronin
bb09fcfd6f fix: repair blocknote core patch 2026-05-01 23:45:38 +02:00
lucaronin
c8ac12f3dc feat: cache note content and parsed editor blocks 2026-05-01 23:14:36 +02:00
lucaronin
fbfda2f15c fix(types): render css type colors 2026-05-01 21:02:42 +02:00
lucaronin
c4c7737e83 fix(types): guard root type filename collisions 2026-05-01 20:33:07 +02:00
lucaronin
c24c60b433 fix(editor): guard stale block side menu actions 2026-05-01 19:58:35 +02:00
lucaronin
21532de941 fix: remove legacy startup loading skeleton 2026-05-01 19:11:45 +02:00
lucaronin
f92efd72df fix: keep note windows editable 2026-05-01 18:36:48 +02:00
lucaronin
6e41328f21 fix: tolerate colliding frontmatter properties 2026-05-01 18:09:48 +02:00
lucaronin
5b2b4cd569 fix: recognize type frontmatter case-insensitively 2026-05-01 17:45:14 +02:00
Jarek Krochmalski
494aeb9533 feat: add Polish (pl-PL) translation (#475)
Add complete Polish locale covering all 525 translation keys:
- New pl-PL.json catalog with full Polish translations
- Register pl-PL in APP_LOCALES and LOCALE_DEFINITIONS in i18n.ts
- Add locale.plPL label to en.json and all existing locale catalogs
- Add pl-PL target to lara.yaml for Lara CLI sync
- Add "polish" to language settings search keywords

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-01 15:02:18 +02:00
lucaronin
8272e68934 fix: ignore stale rich editor beforeinput views 2026-05-01 13:33:39 +02:00
lucaronin
7ba20c1e83 fix: guard stale table handle drags 2026-05-01 12:55:23 +02:00
lucaronin
3384b9acd0 fix: make editor left block handle draggable 2026-05-01 12:15:14 +02:00
lucaronin
8b36ff6b00 fix: avoid active editor remount on unrelated vault refresh 2026-05-01 11:40:14 +02:00
lucaronin
acb35cb5df docs: add/update ADR for Tauri frontend readiness watchdog (guard — from commit 1c010dd) 2026-05-01 08:03:02 +02:00
lucaronin
a8bda0a11b fix: allow linux window controls 2026-05-01 07:01:58 +02:00
lucaronin
4acbe17e1e refactor: consolidate sidebar item interactions 2026-05-01 06:34:25 +02:00
lucaronin
09759d204c test: cover traditional chinese wikilinks 2026-05-01 06:11:09 +02:00
lucaronin
7a01ef6952 fix: recover formula note parse failures 2026-05-01 05:44:22 +02:00
lucaronin
1c010ddc22 fix: recover inert macos webview startup 2026-05-01 04:58:44 +02:00
lucaronin
0df5c7882d ci: bound vitest coverage parallelism 2026-05-01 04:32:51 +02:00
lucaronin
2d138f1564 ci: bound frontend coverage parallelism 2026-05-01 04:19:39 +02:00
lucaronin
c9ee81c5dc test: cover locale-stable note subtitles 2026-05-01 04:06:02 +02:00
lucaronin
04fa74e8d8 fix: keep mcp setup dialog scrollable 2026-05-01 03:47:13 +02:00
lucaronin
b42ed35596 refactor: consolidate blocknote stale guards 2026-05-01 03:18:19 +02:00
lucaronin
75e1a82ff0 test: cover windows type creation 2026-05-01 02:42:20 +02:00
lucaronin
2af2156526 test: cover windows typed note creation 2026-05-01 02:24:07 +02:00
lucaronin
568ada8992 fix: sanitize appimage git subprocess env 2026-05-01 02:01:49 +02:00
lucaronin
0987946cff fix: surface codex final replies 2026-05-01 01:29:46 +02:00
lucaronin
81b03f05b6 chore: ratchet codescene thresholds 2026-05-01 00:59:32 +02:00
lucaronin
ee71a00c6f feat: add paste without formatting command 2026-05-01 00:59:32 +02:00
lucaronin
adcaa8a387 fix: align ai agent permission modes 2026-04-30 21:18:15 +02:00
lucaronin
4a03f1675e fix: parse crlf multibyte headings safely 2026-04-30 20:18:31 +02:00
lucaronin
50c304fce5 fix: reduce autosave pressure while typing 2026-04-30 19:50:44 +02:00
lucaronin
aa4d4fd12e fix: revert sidebar navigation arrows 2026-04-30 19:33:04 +02:00
lucaronin
b31f0b57da fix: keep macos startup responsive 2026-04-30 18:58:48 +02:00
lucaronin
d3a577b488 fix: copy mcp config through native clipboard 2026-04-30 18:22:14 +02:00
lucaronin
fbb0927401 fix: preserve mermaid svg styles under tauri csp 2026-04-30 17:48:19 +02:00
lucaronin
687d6403a3 fix: remove rename view menu action 2026-04-30 17:28:19 +02:00
lucaronin
1738dd2405 fix: persist claude power user shell access 2026-04-30 16:45:10 +02:00
lucaronin
8c286a4856 fix: stabilize linux appimage webkit rendering 2026-04-30 16:17:37 +02:00
lucaronin
0331b40ae2 fix: persist layout panel widths 2026-04-30 15:53:32 +02:00
lucaronin
d53c3fa7e5 fix: detect linuxbrew ai agent binaries 2026-04-30 15:01:35 +02:00
lucaronin
3c14d60be7 fix: stream gemini cli replies 2026-04-30 14:16:27 +02:00
lucaronin
765a16a4e8 fix: ignore stale BlockNote side menu blocks 2026-04-30 14:02:48 +02:00
lucaronin
886ded9575 fix: canonicalize simplified chinese ui language 2026-04-30 13:34:46 +02:00
lucaronin
831f6eb72b fix: guard uninitialized BlockNote suggestion updates 2026-04-30 13:16:55 +02:00
lucaronin
45efb08b23 fix: persist supported ui language settings 2026-04-30 12:58:26 +02:00
lucaronin
fdc7d8fd84 fix: guard note suggestions during reload 2026-04-30 12:38:23 +02:00
lucaronin
690ace2d1f fix: resolve linux mcp server resources 2026-04-30 12:22:29 +02:00
lucaronin
a267a00716 test: cover native menu event dispatch 2026-04-30 11:57:23 +02:00
lucaronin
536cab3491 fix: align view customization with type actions 2026-04-30 11:39:25 +02:00
lucaronin
e49af5855b fix: ignore stale vault refreshes after note switch 2026-04-30 10:54:16 +02:00
lucaronin
9012cd901b test: include multibyte search smoke in pre-push 2026-04-30 10:37:39 +02:00
lucaronin
0d6e3853f2 fix: make search snippets unicode-safe 2026-04-30 10:23:54 +02:00
lucaronin
8754b1bc77 docs: add ADRs for vault-root navigation and safe analytics events (guard — from commits ec83e9b, 18b4f9d) 2026-04-30 08:03:04 +02:00
lucaronin
ec5751711f feat: improve type sidebar actions 2026-04-30 07:48:34 +02:00
lucaronin
0995c90cc0 fix: show pointer cursor on interactive controls 2026-04-30 07:14:05 +02:00
lucaronin
dede400296 fix: surface desktop menu update feedback 2026-04-30 06:46:45 +02:00
lucaronin
65ff44eb50 feat: open inline images in lightbox 2026-04-30 06:24:38 +02:00
lucaronin
2c06c8dcc2 fix: disable unsafe shiki highlighting on old webkit 2026-04-30 06:06:36 +02:00
lucaronin
bc56eec35e fix: block frontmatter writes for attachments 2026-04-30 05:37:58 +02:00
lucaronin
388d389ba4 fix: disable unsafe editor link opens 2026-04-30 05:21:42 +02:00
lucaronin
a1a4cb50d0 feat: add sidebar navigation arrows 2026-04-30 04:52:28 +02:00
lucaronin
a7ed9adaae fix: ignore stale code block controls 2026-04-30 04:33:19 +02:00
lucaronin
f8f08f4160 feat: reuse type appearance controls for views 2026-04-30 04:14:47 +02:00
lucaronin
c0afae2296 fix: repair blocknote patch metadata 2026-04-30 03:47:26 +02:00
lucaronin
176f983dc9 fix: render opencode text parts after tools 2026-04-30 03:37:31 +02:00
lucaronin
01a87ed478 fix: ignore stale suggestion menu close 2026-04-30 03:13:15 +02:00
lucaronin
18b4f9d078 feat: retrofit safe product analytics events 2026-04-30 02:58:36 +02:00
lucaronin
fd3afc2493 feat: configure all notes file visibility 2026-04-30 02:17:49 +02:00
lucaronin
0ad207e318 fix: reject unsafe editor serialization 2026-04-30 01:47:21 +02:00
lucaronin
2e40945c2d fix: guard blocknote popover references 2026-04-30 01:20:12 +02:00
lucaronin
54d53b882f fix: dedupe native menu state sync 2026-04-30 00:58:07 +02:00
lucaronin
848d718373 fix: repair empty editor block swaps 2026-04-30 00:42:46 +02:00
lucaronin
ec83e9b839 feat: show vault root in folder tree 2026-04-30 00:18:33 +02:00
lucaronin
ce859aa0e7 fix: stabilize inbox organize navigation 2026-04-29 23:40:28 +02:00
lucaronin
1f4889fe66 fix: keep vault loading shell visible 2026-04-29 23:12:33 +02:00
lucaronin
2a2ea1b8ef fix: uncap wide editor content 2026-04-29 22:41:05 +02:00
lucaronin
b2a50d9870 fix: remove wide note max width 2026-04-29 22:24:54 +02:00
d 🔹
cca72a9d6a fix: pass 'en-US' to word-count toLocaleString in note subtitles (#438)
Subtitle helpers used the system locale to format word counts, while
adjacent date formatting in the same file already pins 'en-US'. On
locales like de-DE this produced '1.200 words' alongside English copy
('words', 'Created') and English-formatted dates, and broke
`noteListHelpers extra coverage` plus two SearchPanel cases on any
machine with a non-comma thousands separator.

Pass 'en-US' explicitly in both formatSubtitle and formatSearchSubtitle
so the number separator matches the surrounding English text and the
existing date convention in the file.

Closes #429

Co-authored-by: voidborne-d <voidborne@agentmail.to>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-29 22:08:56 +02:00
Alexandre ETEOCLE
ef0e85b053 ci: add rpm bundle to linux release workflows
Adds RPM generation, validation, intermediate artifact upload, and final GitHub Release publish patterns for Linux alpha and stable workflows.
2026-04-29 22:04:34 +02:00
lucaronin
2624d32b50 fix: keep large vault startup responsive 2026-04-29 21:47:29 +02:00
Luca Rossi
9c42da5745 Revise README for platform support and clarity
Updated README to reflect support for macOS, Windows, and Linux. Enhanced clarity in download instructions and removed outdated Gemini CLI setup section.
2026-04-29 21:42:21 +02:00
Muescha
454acb5ded doc: README add installation via Homebrew (#338)
Updated installation instructions via Homebrew ( https://formulae.brew.sh/cask/tolaria ) and added a section for getting started with Tolaria.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-29 21:40:01 +02:00
lucaronin
2a691f68db fix: keep vault asset previews available after switches 2026-04-29 21:33:20 +02:00
lucaronin
c4de154410 test: stabilize wikilink smoke insertion 2026-04-29 21:29:17 +02:00
lucaronin
670aae9f2e fix: create type documents at vault root 2026-04-29 21:29:17 +02:00
hhungxun
f3b4e5e61d fix: preload Wayland client for Linux AppImage (#405)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-29 21:26:50 +02:00
Khanh Thanh
4c4f01af43 Add Vietnamese language support and update locale files (#421)
- Added 'vi' (Vietnamese) to the list of supported app locales in i18n.ts.
- Updated locale definitions to include Vietnamese translations for various keys.
- Modified existing locale files (de-DE, en, es-419, es-ES, fr-FR, it-IT, ja-JP, ko-KR, pt-BR, pt-PT, ru-RU, zh-CN) to include Vietnamese translations for Vietnamese and ensure consistency.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-29 21:21:24 +02:00
rubinette
79d7eb9795 feat: add comprehensive Traditional Chinese (zh-TW) locale support (#419)
- Add Traditional Chinese (zh-TW) locale support throughout the app
- Add and register a comprehensive zh-TW translation file
- Update tests to cover normalization and handling of zh-TW and its aliases
- Refine locale registry to include zh-TW definition and improve language keyword handling
- Extend locale-related keywords and display options in en.json for zh-TW locale

Signed-off-by: rubinette <rubinette@hey.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-29 21:18:17 +02:00
lucaronin
d1ed826714 fix: suppress watcher refresh after new note writes 2026-04-29 20:45:07 +02:00
lucaronin
2e8286d2b3 fix: normalize Windows verbatim note paths 2026-04-29 20:10:43 +02:00
lucaronin
cb9ebaadad fix: keep manual selection after inbox organize 2026-04-29 19:53:33 +02:00
lucaronin
c295c9f2b5 feat: add note width modes 2026-04-29 19:26:24 +02:00
lucaronin
a4b11089c0 fix: debounce rich editor serialization 2026-04-29 18:19:35 +02:00
lucaronin
6f29542528 fix: render mermaid diagrams from notes 2026-04-29 17:14:28 +02:00
lucaronin
dcd0d73848 fix: open vault files externally 2026-04-29 14:25:15 +02:00
lucaronin
0e8c8fb61a docs: add product analytics workflow 2026-04-29 14:24:12 +02:00
lucaronin
42bdd559ef fix: move mcp config copy to settings 2026-04-29 13:51:28 +02:00
lucaronin
3a6c97272d fix: allow pdf asset previews in csp 2026-04-29 13:32:29 +02:00
lucaronin
f53a3746d3 fix: localize recent UI copy 2026-04-29 13:25:22 +02:00
lucaronin
23145a05f5 feat: preview pdf files in app 2026-04-29 13:14:44 +02:00
lucaronin
5b86b0b50d fix: validate active note cache refresh 2026-04-29 12:09:58 +02:00
lucaronin
dc1b1783b6 docs: add localization developer workflow 2026-04-29 12:09:58 +02:00
lucaronin
3dc520abea feat: add safe note open cache 2026-04-29 12:09:58 +02:00
lucaronin
792b3009dd fix: keep saved view reorder UI native 2026-04-29 11:51:22 +02:00
lucaronin
6ee7f219f5 feat: add gemini cli agent support 2026-04-29 11:16:52 +02:00
lucaronin
3db1ed9dc5 fix: split ai permission tooltips 2026-04-29 10:44:20 +02:00
lucaronin
7a7ef5d9ca fix: improve ai permission toggle contrast 2026-04-29 10:35:19 +02:00
Luca Rossi
4c42cf4d52 Updated release download link 2026-04-29 10:30:53 +02:00
lucaronin
405b711e16 revert: restore editor side menu ordering 2026-04-29 10:06:03 +02:00
lucaronin
83705e26f5 docs: add ADRs for gitignored visibility and saved view ordering (guard — from commits d2c15b8, fd92ff5) 2026-04-29 08:03:17 +02:00
lucaronin
434927160c fix: stabilize radix overlay presence 2026-04-29 07:45:02 +02:00
lucaronin
ec2e04a464 test: wait for date picker smoke note load 2026-04-29 06:55:07 +02:00
lucaronin
a17ec689a3 fix: tolerate stale vault watcher listeners 2026-04-29 06:36:39 +02:00
lucaronin
eda4d380e0 fix: let disk decide type file conflicts 2026-04-29 06:09:31 +02:00
lucaronin
f9b155ba6e fix: repair malformed editor block entries 2026-04-29 05:27:39 +02:00
lucaronin
4a62008b1f fix: clamp stale raw editor selections 2026-04-29 05:05:15 +02:00
lucaronin
a08cee7965 refactor: share cli agent runtime scaffold 2026-04-29 04:47:16 +02:00
lucaronin
a7c480af62 fix: normalize stale vault entry metadata 2026-04-29 04:33:18 +02:00
lucaronin
f4790d5763 fix: detect Windows npm ai agent shims 2026-04-29 04:01:53 +02:00
lucaronin
ed52a9e800 fix: preserve frontmatter date picker days 2026-04-29 03:56:44 +02:00
lucaronin
2fcd808f09 fix: detect Windows npm ai agent shims 2026-04-29 03:53:54 +02:00
lucaronin
9d15286f7d fix: stop orphaned mcp server processes 2026-04-29 03:29:16 +02:00
lucaronin
537bd0f3ec fix: normalize Windows vault image paths 2026-04-29 03:02:57 +02:00
lucaronin
648241604f feat: add theme mode command actions 2026-04-29 02:35:52 +02:00
lucaronin
c6f3d9d945 fix: create type files in type folder 2026-04-29 02:24:01 +02:00
lucaronin
65e54f108a fix: render saved view colors 2026-04-29 01:40:16 +02:00
lucaronin
58f8a774b5 chore: ratchet codescene thresholds 2026-04-29 01:26:33 +02:00
lucaronin
0bf280d3ec fix: satisfy ai permission mode clippy 2026-04-29 01:26:33 +02:00
lucaronin
d2d4746d5c feat: add vault ai agent permission modes 2026-04-29 01:26:33 +02:00
lucaronin
91db7c0098 fix: pause saves without active vault 2026-04-29 00:19:47 +02:00
lucaronin
87908d982f fix: allow guidance open path 2026-04-28 23:56:08 +02:00
lucaronin
40544fc551 fix: detect nvm ai agent binaries 2026-04-28 23:39:48 +02:00
lucaronin
f34fdb0289 fix: guard stale suggestion menu actions 2026-04-28 23:29:09 +02:00
lucaronin
e601a42f41 fix: surface ai agent final replies 2026-04-28 23:00:06 +02:00
lucaronin
de627c2478 fix: normalize missing vault metadata 2026-04-28 22:52:07 +02:00
lucaronin
46d9aaed48 fix: dedupe editor toolbar state closes 2026-04-28 21:57:26 +02:00
lucaronin
6b96b5caf6 fix: reset editor selection before note swaps 2026-04-28 21:31:54 +02:00
lucaronin
595c48ca59 fix: apply settings theme changes immediately 2026-04-28 21:16:30 +02:00
lucaronin
fd92ff5e9b feat: allow manual saved view ordering 2026-04-28 20:41:07 +02:00
lucaronin
a39b346a75 fix: detect mise node for mcp setup 2026-04-28 20:28:56 +02:00
lucaronin
deac10caa2 fix: detect nvm pi installs 2026-04-28 19:54:39 +02:00
lucaronin
0e1e0ff47a fix: preserve ai composer line breaks 2026-04-28 19:32:08 +02:00
lucaronin
a645edbc19 fix: repair malformed editor block ids 2026-04-28 18:56:36 +02:00
lucaronin
48f1fb0b50 feat: add gemini cli external ai setup 2026-04-28 18:30:14 +02:00
lucaronin
1aede09834 fix: preserve wikilinks inside markdown tables 2026-04-28 18:00:15 +02:00
lucaronin
d51b76f992 fix: restrict sentry releases to stable builds 2026-04-28 17:17:23 +02:00
lucaronin
8e66fb4c19 fix: preserve table math markdown 2026-04-28 16:47:52 +02:00
lucaronin
a2843a0cf3 fix: persist immediate note creation before open 2026-04-28 15:01:32 +02:00
lucaronin
38fb8a7370 feat: copy mcp config from app 2026-04-28 14:03:10 +02:00
lucaronin
be5a8bb300 fix: make editor left handle draggable 2026-04-28 12:31:13 +02:00
lucaronin
ce040c75fd test: cover table hover stability 2026-04-28 10:46:06 +02:00
lucaronin
ced89a42bd fix: render mermaid diagrams with lightbox 2026-04-28 10:32:37 +02:00
lucaronin
20a960551f fix: update fallback vault git email 2026-04-28 10:08:18 +02:00
lucaronin
2e331315e8 fix: refine Italian locale labels 2026-04-28 09:43:44 +02:00
lucaronin
d2c15b8469 feat: hide gitignored vault content 2026-04-28 09:07:21 +02:00
lucaronin
2fe6c5b580 fix: respect selected ai agent in right panel 2026-04-28 07:36:22 +02:00
lucaronin
2a224f78f8 refactor: simplify note mutation flushing 2026-04-28 06:25:39 +02:00
lucaronin
b85d54bb5d fix: preserve ai agent final result text 2026-04-28 06:17:33 +02:00
lucaronin
9c05e17d37 fix: sanitize custom view filenames 2026-04-28 05:39:41 +02:00
lucaronin
cf6ad71fff ci: de-duplicate coverage-backed test lane 2026-04-28 04:53:39 +02:00
lucaronin
db82aee172 refactor: consolidate ai agent session flow 2026-04-28 04:20:40 +02:00
lucaronin
c72d833624 feat: add pi ai agent support 2026-04-28 03:41:04 +02:00
lucaronin
a3ca78fced fix: guard git init parent folders 2026-04-28 02:38:32 +02:00
lucaronin
e912ab9a9f fix: use selected vault for mcp bridge 2026-04-28 02:26:14 +02:00
lucaronin
49717dc671 feat: add opencode ai agent option 2026-04-28 01:26:21 +02:00
lucaronin
d9a0a04ddb chore: ratchet codescene thresholds 2026-04-28 01:21:59 +02:00
lucaronin
0561fe68b1 fix: restore editor block handle reordering 2026-04-28 01:09:47 +02:00
lucaronin
2f078fab11 feat: watch active vault filesystem changes 2026-04-27 23:54:50 +02:00
lucaronin
98fde6571a feat: render mermaid note diagrams 2026-04-27 22:14:23 +02:00
lucaronin
a5c4b8fa20 fix: hide ai agent subprocess windows 2026-04-27 21:21:00 +02:00
lucaronin
1301653b81 fix: avoid tooltip dropdown popper nesting 2026-04-27 20:58:31 +02:00
lucaronin
ee90b05159 fix: guard inline input types 2026-04-27 20:36:59 +02:00
lucaronin
b6c11b1468 fix: close claude print-mode stdin 2026-04-27 19:25:53 +02:00
lucaronin
2fbdafa6f2 fix: set git identity before remote connect 2026-04-27 18:59:23 +02:00
lucaronin
39f44b86aa fix: copy fenced code blocks exactly 2026-04-27 18:31:51 +02:00
lucaronin
cb3274cdbc fix: harden windows mcp setup 2026-04-27 17:51:25 +02:00
lucaronin
76d8bba332 fix: remove hidden Claude permission bypass 2026-04-27 16:46:32 +02:00
lucaronin
86853cf337 fix: allow Codex to edit active vault 2026-04-27 16:07:05 +02:00
lucaronin
9a292d2476 fix: map releases to Sentry 2026-04-27 15:27:31 +02:00
lucaronin
4a22b02810 test: harden locale catalog validation 2026-04-27 14:48:49 +02:00
github-actions[bot]
e4270cda3b Merge branch 'main' into main 2026-04-27 12:45:03 +00:00
lucaronin
9cb0de37a5 fix: parse crlf frontmatter 2026-04-27 14:31:51 +02:00
github-actions[bot]
2a794cb373 Merge branch 'main' into main 2026-04-27 11:37:50 +00:00
lucaronin
324af6b271 fix: normalize folder filtering across path separators 2026-04-27 13:27:20 +02:00
Disheng Qiu
926db0eeb5 feat: add lara-powered app localization 2026-04-27 13:06:59 +02:00
lucaronin
b1a97834c9 fix: keep raw editor full width 2026-04-27 11:54:15 +02:00
lucaronin
da13cb38ac fix: satisfy editor find command contract 2026-04-27 10:57:58 +02:00
lucaronin
dbf8ba5f40 feat: add editor find and replace 2026-04-27 10:57:58 +02:00
lucaronin
3e8a6e5d7c fix: render inline math while typing 2026-04-27 10:44:35 +02:00
789 changed files with 83494 additions and 9963 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=10.0
AVERAGE_THRESHOLD=9.88
AVERAGE_THRESHOLD=9.92

View File

@@ -12,3 +12,7 @@ VITE_SENTRY_DSN=
# PostHog (https://posthog.com → Project → Settings → Project API Key)
VITE_POSTHOG_KEY=
VITE_POSTHOG_HOST=https://eu.i.posthog.com
# Lara CLI (https://github.com/translated/lara-cli)
LARA_ACCESS_KEY_ID=
LARA_ACCESS_KEY_SECRET=

137
.github/scripts/prefetch-tauri-nsis.ps1 vendored Normal file
View 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."

View File

@@ -65,22 +65,18 @@ jobs:
- name: Vite build check
run: pnpm build
# ── 1. Tests ──────────────────────────────────────────────────────────
- name: Run frontend tests
run: pnpm test
# ── 1. Coverage-backed tests ──────────────────────────────────────────
# The coverage commands run the same frontend and Rust test suites, so keep
# them as the canonical test lane instead of running every suite twice.
- name: Bundle MCP server resources (required by Tauri build)
run: node scripts/bundle-mcp-server.mjs
- name: Run Rust tests
run: cargo test --manifest-path=src-tauri/Cargo.toml
# ── 2. Coverage (enforced — fails build if thresholds not met) ────────
- name: Frontend coverage (≥70% lines/functions/branches/statements)
# ── 2. Tests + coverage (enforced — fails build if thresholds not met) ─
- name: Frontend tests + coverage (≥70% lines/functions/branches/statements)
run: pnpm test:coverage
# Thresholds configured in vite.config.ts — exits non-zero if coverage drops
- name: Rust coverage (≥85% lines)
- name: Rust tests + coverage (≥85% lines)
run: |
cargo llvm-cov \
--manifest-path src-tauri/Cargo.toml \

View File

@@ -229,6 +229,7 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
@@ -273,7 +274,8 @@ jobs:
wget \
patchelf \
build-essential \
file
file \
rpm
- name: Setup pnpm
uses: pnpm/action-setup@v4
@@ -320,11 +322,12 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
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: |
@@ -332,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
@@ -339,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
@@ -354,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
@@ -396,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
@@ -431,6 +446,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
@@ -661,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

View File

@@ -288,6 +288,7 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
@@ -327,7 +328,8 @@ jobs:
wget \
patchelf \
build-essential \
file
file \
rpm
- name: Setup pnpm
uses: pnpm/action-setup@v4
@@ -374,11 +376,12 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
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: |
@@ -386,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
@@ -393,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
@@ -408,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
@@ -450,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
@@ -485,6 +500,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
@@ -712,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

1
.gitignore vendored
View File

@@ -70,5 +70,6 @@ CODE-HEALTH-REPORT.md
*.key.pub
# Local environment variables (never commit)
.env
.env.local
.env.*.local

View File

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

View File

@@ -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 2030 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 (12 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"
```

View File

@@ -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
@@ -27,13 +27,25 @@ You can find some Loom walkthroughs below — they are short and to the point:
- 🔬 **Open source** — Tolaria is free and open source. I built this for [myself](https://x.com/lucaronin) and for sharing it with others.
- 📋 **Standards-based** — Notes are markdown files with YAML frontmatter. No proprietary formats, no locked-in data. Everything works with standard tools if you decide to move away from Tolaria.
- 🔍 **Types as lenses, not schemas** — Types in Tolaria are navigation aids, not enforcement mechanisms. There's no required fields, no validation, just helpful categories for finding notes.
- 🪄**AI-first but not AI-only** — A vault of files works very well with AI agents, but you are free to use whatever you want. We support Claude Code and Codex CLI (for now), but you can edit the vault with any AI you want. We provide an AGENTS file for your agents to figure out.
- 🪄**AI-first but not AI-only** — A vault of files works very well with AI agents, but you are free to use whatever you want. We support Claude Code, Codex CLI, and Gemini CLI setup paths, but you can edit the vault with any AI you want. We provide an AGENTS file for your agents to figure out.
- ⌨️ **Keyboard-first** — Tolaria is designed for power-users who want to use keyboard as much as possible. A lot of how we designed the Editor and the Command Palette is based on this.
- 💪 **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.

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

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

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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

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

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

View File

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

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

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,81 @@
import { Archive, Code, PencilSimpleLine, Star, Tray } from 'phosphor-react-native'
import type { ReactNode } from 'react'
import { Pressable, Text, View } from 'react-native'
import type { MobileNote } from './demoData'
import type { MobileEditorSaveState } from './mobileEditorSaveState'
import { styles } from './styles'
import { colors } from './theme'
export function MobileEditorBreadcrumb({
isRawMode,
note,
onToggleArchive,
onToggleFavorite,
onToggleRawMode,
saveState,
}: {
isRawMode: boolean
note: MobileNote
onToggleArchive: () => void
onToggleFavorite: () => void
onToggleRawMode: () => void
saveState: MobileEditorSaveState
}) {
const ArchiveIcon = note.archived ? Tray : Archive
const RawIcon = isRawMode ? PencilSimpleLine : Code
return (
<View style={styles.editorBreadcrumb}>
<Text numberOfLines={1} style={styles.editorBreadcrumbText}>{note.type}</Text>
<Text style={styles.editorBreadcrumbDivider}>/</Text>
<Text numberOfLines={1} style={styles.editorBreadcrumbTitle}>{note.id}</Text>
<Text style={[styles.editorSaveState, saveStateStyle(saveState)]}>{saveState.label}</Text>
<BreadcrumbButton label={isRawMode ? 'Rich editor' : 'Raw editor'} onPress={onToggleRawMode}>
<RawIcon color={isRawMode ? colors.primary : colors.textSoft} size={18} />
</BreadcrumbButton>
<BreadcrumbButton label={note.favorite ? 'Remove favorite' : 'Add favorite'} onPress={onToggleFavorite}>
<Star color={note.favorite ? colors.primary : colors.textSoft} size={18} weight={note.favorite ? 'fill' : 'regular'} />
</BreadcrumbButton>
<BreadcrumbButton label={note.archived ? 'Move to inbox' : 'Archive'} onPress={onToggleArchive}>
<ArchiveIcon color={colors.textSoft} size={18} />
</BreadcrumbButton>
</View>
)
}
function saveStateStyle(saveState: MobileEditorSaveState) {
switch (saveState.state) {
case 'blocked':
return styles.editorSaveState_blocked
case 'failed':
return styles.editorSaveState_failed
case 'queued':
return styles.editorSaveState_queued
case 'saved':
return styles.editorSaveState_saved
case 'saving':
return styles.editorSaveState_saving
default:
return styles.editorSaveState_idle
}
}
function BreadcrumbButton({
children,
label,
onPress,
}: {
children: ReactNode
label: string
onPress: () => void
}) {
return (
<Pressable
accessibilityLabel={label}
onPress={onPress}
style={({ pressed }) => [styles.editorBreadcrumbButton, pressed ? styles.pressed : null]}
>
{children}
</Pressable>
)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,62 @@
import { useMemo, useState } from 'react'
import { Pressable, Text, TextInput, View } from 'react-native'
import type { MobileNote } from './mobileNoteProjection'
import { activeMobileWikilinkQuery, insertMobileWikilink, mobileNoteSuggestions } from './mobileWikilinkAutocomplete'
import { styles } from './styles'
export function MobileRawEditor({
notes,
note,
onRawMarkdownChange,
}: {
notes: MobileNote[]
note: MobileNote
onRawMarkdownChange: (markdown: string) => void
}) {
const [draft, setDraft] = useState(note.content)
const [cursor, setCursor] = useState(note.content.length)
const activeQuery = useMemo(() => activeMobileWikilinkQuery({ cursor, markdown: draft }), [cursor, draft])
const suggestions = useMemo(
() => activeQuery ? mobileNoteSuggestions({ excludeNoteId: note.id, notes, query: activeQuery.query }) : [],
[activeQuery, note.id, notes],
)
return (
<View style={styles.rawEditorContent}>
<TextInput
autoCapitalize="none"
autoCorrect={false}
multiline
onChangeText={(markdown) => {
setDraft(markdown)
onRawMarkdownChange(markdown)
}}
onSelectionChange={(event) => setCursor(event.nativeEvent.selection.start)}
scrollEnabled
spellCheck={false}
style={styles.rawEditorInput}
textAlignVertical="top"
value={draft}
/>
{suggestions.length > 0 && activeQuery ? (
<View style={styles.rawEditorSuggestionMenu}>
{suggestions.map((suggestion) => (
<Pressable
key={suggestion.id}
onPress={() => {
const nextDraft = insertMobileWikilink({ markdown: draft, note: suggestion, query: activeQuery })
setDraft(nextDraft)
setCursor(activeQuery.start + suggestion.id.length + suggestion.title.length + 5)
onRawMarkdownChange(nextDraft)
}}
style={({ pressed }) => [styles.rawEditorSuggestion, pressed ? styles.pressed : null]}
>
<Text style={styles.rawEditorSuggestionTitle}>{suggestion.title}</Text>
<Text style={styles.rawEditorSuggestionMeta}>{suggestion.id}</Text>
</Pressable>
))}
</View>
) : null}
</View>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,58 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { sendMobileAiRequest } from './mobileAiClient'
import type { MobileNote } from './mobileNoteProjection'
describe('mobile AI client', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('sends an OpenAI-compatible chat completion request with note context', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
json: async () => ({ choices: [{ message: { content: 'Answer' } }] }),
ok: true,
} as Response)
await expect(sendMobileAiRequest({
apiKey: 'key',
note: note(),
prompt: 'Summarize',
provider: {
baseUrl: 'https://api.example.com/v1/',
id: 'provider',
kind: 'open_ai_compatible',
modelId: 'model',
name: 'Provider',
},
})).resolves.toBe('Answer')
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/v1/chat/completions', expect.objectContaining({
method: 'POST',
}))
})
})
function note(): MobileNote {
return {
archived: false,
backlinks: [],
belongsTo: [],
content: '# Workflow\n\nBody',
customProperties: {},
date: '',
favorite: false,
favoriteIndex: null,
has: [],
icon: 'file-text',
id: 'workflow',
modified: '',
outgoingLinks: [],
relatedTo: [],
relationships: {},
snippet: '',
tags: [],
title: 'Workflow',
type: 'Note',
words: 1,
}
}

View File

@@ -0,0 +1,45 @@
import type { MobileNote } from './mobileNoteProjection'
import type { MobileAiProvider } from './mobileAiSettings'
export type MobileAiRequest = {
apiKey: string
note: MobileNote
prompt: string
provider: MobileAiProvider
}
export async function sendMobileAiRequest(request: MobileAiRequest) {
const response = await fetch(`${request.provider.baseUrl.replace(/\/$/, '')}/chat/completions`, {
body: JSON.stringify({
messages: [
{
content: [
'You are helping with a Tolaria markdown note.',
'Use concise answers and preserve [[wikilink]] syntax when referencing notes.',
`Active note: ${request.note.title}`,
request.note.content,
].join('\n\n'),
role: 'system',
},
{ content: request.prompt, role: 'user' },
],
model: request.provider.modelId,
}),
headers: {
Authorization: `Bearer ${request.apiKey}`,
'Content-Type': 'application/json',
},
method: 'POST',
})
if (!response.ok) {
throw new Error(`AI request failed with ${response.status}`)
}
return extractAssistantMessage(await response.json())
}
function extractAssistantMessage(payload: unknown) {
const content = (payload as { choices?: Array<{ message?: { content?: unknown } }> }).choices?.[0]?.message?.content
return typeof content === 'string' ? content : ''
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { mobileDerivedRelationshipGroups } from './mobileDerivedRelationships'
import type { MobileNote } from './mobileNoteProjection'
describe('mobile derived relationships', () => {
it('derives read-only inverse relationship groups from other notes', () => {
const current = note({ id: 'project/tolaria', title: 'Tolaria' })
const child = note({ belongsTo: ['[[project/tolaria|Tolaria]]'], id: 'essay', title: 'Essay' })
const related = note({ id: 'release', relatedTo: ['Tolaria'], title: 'Release' })
const custom = note({ id: 'owner', relationships: { owner: ['[[project/tolaria|Tolaria]]'] }, title: 'Owner' })
expect(mobileDerivedRelationshipGroups({ note: current, notes: [current, child, related, custom] })).toEqual([
{ label: 'Contains', targets: ['[[essay|Essay]]'] },
{ label: 'Related from', targets: ['[[release|Release]]'] },
{ label: 'Owner from', targets: ['[[owner|Owner]]'] },
])
})
it('deduplicates inverse targets inside a group', () => {
const current = note({ id: 'project/tolaria', title: 'Tolaria' })
const source = note({
belongsTo: ['[[project/tolaria|Tolaria]]', 'Tolaria'],
id: 'essay',
title: 'Essay',
})
expect(mobileDerivedRelationshipGroups({ note: current, notes: [current, source] })).toEqual([
{ label: 'Contains', targets: ['[[essay|Essay]]'] },
])
})
})
function note({
belongsTo = [],
has = [],
id,
relatedTo = [],
relationships = {},
title,
}: {
belongsTo?: string[]
has?: string[]
id: string
relatedTo?: string[]
relationships?: Record<string, string[]>
title: string
}): MobileNote {
return {
archived: false,
backlinks: [],
belongsTo,
content: `# ${title}`,
customProperties: {},
date: '',
favorite: false,
favoriteIndex: null,
has,
icon: 'file-text',
id,
modified: '',
outgoingLinks: [],
relatedTo,
relationships,
snippet: '',
status: undefined,
tags: [],
title,
type: 'Note',
words: 1,
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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