Compare commits

..

584 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
lucaronin
8141644729 fix: revert note width modes 2026-04-27 10:13:55 +02:00
lucaronin
0d88c75718 Merge branches 'main', 'main', 'main' and 'main' of https://github.com/refactoringhq/tolaria 2026-04-27 09:42:36 +02:00
lucaronin
f8721f2a1b feat: add note width modes 2026-04-27 05:34:11 +02:00
lucaronin
fb39c6679a fix: handle invalid Windows save paths 2026-04-27 04:04:04 +02:00
lucaronin
f1bed131bf fix: refresh type state after type changes 2026-04-27 03:25:24 +02:00
lucaronin
257cf6ed02 fix: keep theme tooltip inside window 2026-04-27 02:20:28 +02:00
lucaronin
908daafaa1 feat: add basic file actions 2026-04-27 01:45:16 +02:00
lucaronin
d15437face test: cover consecutive IME composer input 2026-04-27 01:05:34 +02:00
lucaronin
30281f879d chore: ratchet codescene thresholds 2026-04-27 00:49:39 +02:00
lucaronin
4ef6edfb10 feat: preview vault images in app 2026-04-27 00:36:24 +02:00
lucaronin
918419e371 fix: respect macos control text bindings 2026-04-26 23:22:53 +02:00
lucaronin
2704002973 fix: remove note drag and drop flows 2026-04-26 22:51:08 +02:00
lucaronin
9265b8f117 fix: stabilize AI composer text editing 2026-04-26 22:11:54 +02:00
lucaronin
943fa854e1 fix: satisfy localization build 2026-04-26 19:39:56 +02:00
lucaronin
a2f8ba72a2 fix: complete app localization pass 2026-04-26 19:37:21 +02:00
lucaronin
1d546dde3d fix: allow note drops to insert wikilinks 2026-04-26 18:15:22 +02:00
lucaronin
63877e61ec fix: store window state in logical points 2026-04-26 17:54:41 +02:00
lucaronin
8896f1d451 fix: restore saved window size after launch 2026-04-26 17:54:41 +02:00
lucaronin
f523099854 feat: add vault loading skeleton 2026-04-26 17:35:09 +02:00
lucaronin
15f38f096f feat: support non-git vaults 2026-04-26 17:08:14 +02:00
lucaronin
cdd2ddec6c feat: persist main window placement 2026-04-26 15:45:34 +02:00
lucaronin
292d3739df fix: prevent editor handling note wikilink drops 2026-04-26 14:37:11 +02:00
lucaronin
1136c1948e fix: clarify macos release artifact names 2026-04-26 14:29:47 +02:00
lucaronin
8406042db1 fix: support native folder drops in AI inputs 2026-04-26 13:59:39 +02:00
lucaronin
f07cbbe0eb test: reflect cloned getting started vault in smoke 2026-04-26 12:28:01 +02:00
lucaronin
768da9f955 fix: prevent bottom bar wrap at narrow widths 2026-04-26 12:12:05 +02:00
lucaronin
f58e9d8930 fix: allow editor runtime styles under CSP 2026-04-26 11:31:41 +02:00
lucaronin
133242eff5 fix: keep compact status bar on one row 2026-04-26 10:33:16 +02:00
lucaronin
aebd2bea4a fix: refresh language settings draft 2026-04-26 09:46:11 +02:00
lucaronin
60238fbe78 fix: persist native language settings 2026-04-26 09:17:28 +02:00
lucaronin
5336236e79 test: cover native command wrappers 2026-04-26 08:39:56 +02:00
lucaronin
f9719f11b5 feat: add app localization foundation 2026-04-26 08:18:47 +02:00
lucaronin
e4083ded1a fix: detect claude cli on windows 2026-04-26 07:34:27 +02:00
lucaronin
65a89f102a fix: detect codex from macos shell paths 2026-04-26 06:48:35 +02:00
lucaronin
5b34ec2980 feat: publish intel mac builds 2026-04-26 06:07:33 +02:00
lucaronin
e579979829 fix: preserve composed ai input 2026-04-26 05:24:05 +02:00
lucaronin
c7edc71a97 feat: add note layout preference 2026-04-26 04:41:18 +02:00
lucaronin
5fd8f8fb40 feat: add markdown math support 2026-04-26 03:52:10 +02:00
lucaronin
694419d442 fix: disable native text suggestions 2026-04-26 03:01:10 +02:00
lucaronin
e82fb2a5c7 test: cover table cell paste regression 2026-04-26 02:58:46 +02:00
lucaronin
39234dcf52 fix: block unsafe editor links 2026-04-26 02:16:20 +02:00
lucaronin
36e8a62284 fix: isolate blocknote side-menu clicks 2026-04-26 01:20:11 +02:00
lucaronin
d622b91b81 docs: ratchet codescene thresholds 2026-04-26 00:31:55 +02:00
lucaronin
5a546b9a8b feat: add generic MCP config support 2026-04-26 00:15:22 +02:00
lucaronin
b8c1a094b6 fix: resolve ime selection typing 2026-04-25 23:50:04 +02:00
lucaronin
c2c56cfca5 fix: harden inline wikilink ime handling 2026-04-25 23:46:31 +02:00
lucaronin
449a62e31f fix: address unicode slug type check 2026-04-25 23:11:50 +02:00
lucaronin
7d6fd5cc51 fix: preserve unicode note title slugs 2026-04-25 23:08:30 +02:00
lucaronin
75ed6460ac feat: drop notes into editors as wikilinks 2026-04-25 22:31:57 +02:00
lucaronin
a3096c596f fix: reflow status bar at narrow widths 2026-04-25 21:59:10 +02:00
lucaronin
6105c7527c refactor: fix hook dependency lint warnings 2026-04-25 21:26:25 +02:00
lucaronin
92eee0d470 fix: keep editor selection stable around media blocks 2026-04-25 21:21:41 +02:00
lucaronin
f14fda09d5 fix: accept finder folder drops in ai composer 2026-04-25 20:41:56 +02:00
lucaronin
31d12d8cc8 test: stabilize smoke web server 2026-04-25 19:51:41 +02:00
lucaronin
6a002f0dc0 fix: guard missing inline input types 2026-04-25 19:23:00 +02:00
lucaronin
9d30d1165f fix: harden linux appimage startup 2026-04-25 18:54:38 +02:00
lucaronin
b709acd58a fix: type delete confirmation shortcuts 2026-04-25 18:23:37 +02:00
lucaronin
080e3498a7 fix: submit delete confirmation on enter 2026-04-25 18:20:32 +02:00
lucaronin
51010ca578 fix: show manual update feedback 2026-04-25 17:15:19 +02:00
lucaronin
15bc3a4701 fix: disable command palette text correction 2026-04-25 16:49:48 +02:00
lucaronin
3ebfa642fa fix: allow runtime stylesheet injection 2026-04-25 16:06:17 +02:00
lucaronin
8207b87c33 fix: include linux signatures in stable release 2026-04-25 14:39:19 +02:00
lucaronin
602a099ac3 fix: polish contribute dialog copy 2026-04-25 13:54:39 +02:00
lucaronin
2315122c4a fix: clear stale editor selection on note switch 2026-04-25 13:27:33 +02:00
lucaronin
ab4fcdbaae ci: include linux alpha updater signatures 2026-04-25 12:45:31 +02:00
lucaronin
a56cb8c287 fix: allow raw editor styles under CSP 2026-04-25 12:03:54 +02:00
lucaronin
b93e6d491a ci: test linux alpha release artifacts 2026-04-25 11:59:21 +02:00
lucaronin
3152ff2c6e fix: allow optional pending diff loaders 2026-04-25 11:46:59 +02:00
lucaronin
dd018cf98b fix: queue diff opening until tab activation 2026-04-25 11:46:59 +02:00
lucaronin
d0d9d90096 test: stabilize H1 wikilink smoke 2026-04-25 11:20:14 +02:00
lucaronin
944efada94 fix: load note windows without vault scan 2026-04-25 10:53:09 +02:00
lucaronin
ef4ad256e3 feat: add Refactoring support card 2026-04-25 10:31:26 +02:00
lucaronin
c427cd2492 fix: guard native file drops 2026-04-25 10:07:43 +02:00
lucaronin
151e993ab7 chore: ratchet codescene thresholds 2026-04-25 02:29:08 +02:00
lucaronin
60554d0b96 fix: handle IME composition in AI composer 2026-04-25 02:29:08 +02:00
lucaronin
4929f11b6d fix: harden windows release and git subprocesses 2026-04-25 00:58:49 +02:00
lucaronin
3f4e5b585f fix: refresh image asset scope after attachment writes 2026-04-25 00:19:57 +02:00
lucaronin
cd49c81bd3 feat: add status bar theme toggle 2026-04-24 23:58:26 +02:00
lucaronin
b9f3dc9e1a fix: refine contribute panel actions 2026-04-24 23:44:02 +02:00
lucaronin
122aba7878 fix: load current note in new window 2026-04-24 23:29:44 +02:00
lucaronin
05896bde19 fix: restore editor external link toolbar actions 2026-04-24 23:25:22 +02:00
lucaronin
87a933f39c fix: replace silent catch blocks with warnings 2026-04-24 22:44:36 +02:00
lucaronin
54c0efa3e4 feat: add dark mode foundation 2026-04-24 22:28:07 +02:00
lucaronin
bdde0c8943 fix: bound note prefetch cache by size 2026-04-24 21:53:47 +02:00
lucaronin
a474303eb9 fix: preserve back history after cmd-click wikilinks 2026-04-24 21:15:27 +02:00
lucaronin
4701485ee5 fix: keep empty title clicks in h1 2026-04-24 20:13:34 +02:00
lucaronin
135a8a0adf fix: handle nested release artifacts 2026-04-24 19:48:56 +02:00
lucaronin
0f5d7d5340 fix: restore folder boundary validation 2026-04-24 19:23:55 +02:00
lucaronin
d6b3c0aef3 feat: add windows desktop release support 2026-04-24 19:23:55 +02:00
lucaronin
f896c01829 fix: publish linux stable release artifacts 2026-04-24 18:53:43 +02:00
lucaronin
c3cff0c461 feat: replace feedback with contribute modal 2026-04-24 17:45:43 +02:00
lucaronin
8c7f9238cb fix: gate macos shortcut constants for linux clippy 2026-04-24 16:45:05 +02:00
lucaronin
38acebba7c feat: add linux desktop support 2026-04-24 16:25:36 +02:00
lucaronin
622977aeb8 fix: restore clone modal invoke typing 2026-04-24 15:18:50 +02:00
lucaronin
9ce1c6f854 refactor: restore clone modal code health 2026-04-24 15:13:24 +02:00
lucaronin
46865638cd fix: harden clone git repo flow 2026-04-24 14:56:17 +02:00
lucaronin
564ee0a387 test: cover native command wrappers 2026-04-24 14:44:01 +02:00
lucaronin
11655c0283 fix: harden inbox auto-advance merge 2026-04-24 14:35:54 +02:00
github-actions[bot]
c24fd30266 Merge branch 'main' into main 2026-04-24 12:04:53 +00:00
lucaronin
daa1e3e985 fix: keep ai composer responsive after image paste 2026-04-24 13:52:58 +02:00
github-actions[bot]
44c7f43e57 Merge branch 'main' into main 2026-04-24 11:43:53 +00:00
lucaronin
f2c83be4b3 fix: format MCP config tests 2026-04-24 13:32:30 +02:00
lucaronin
6104402bfb fix: write Claude Code MCP config to .claude.json 2026-04-24 13:22:33 +02:00
github-actions[bot]
b3744e3bf5 Merge branch 'main' into main 2026-04-24 10:43:45 +00:00
lucaronin
67c8598b87 fix: keep clone flows off the UI thread 2026-04-24 12:34:00 +02:00
github-actions[bot]
52620c80dc Merge branch 'main' into main 2026-04-24 10:27:33 +00:00
lucaronin
7e386ff70c docs: move contributing guide to repo root 2026-04-24 12:27:17 +02:00
github-actions[bot]
aba5550b8e Merge branch 'main' into main 2026-04-24 10:07:41 +00:00
Luca Rossi
309b816e6d Add custom funding URL to FUNDING.yml 2026-04-24 12:07:27 +02:00
github-actions[bot]
f552d8abff Merge branch 'main' into main 2026-04-24 10:02:39 +00:00
lucaronin
9f6779e9f1 docs: add contributing guide 2026-04-24 11:46:35 +02:00
github-actions[bot]
bddf5106bb Merge branch 'main' into main 2026-04-24 06:31:23 +00:00
lucaronin
ba5c165016 test: harden macos titlebar coverage 2026-04-24 08:20:30 +02:00
github-actions[bot]
39415c350f Merge branch 'main' into main 2026-04-24 06:14:23 +00:00
Brian Lee
499e481df8 Merge branches 'main', 'main', 'main' and 'main' of https://github.com/refactoringhq/tolaria 2026-04-24 15:07:00 +09:00
Brian Lee
843cedd14e fix: align auto-advance test bridge types 2026-04-24 15:06:13 +09:00
Brian Lee
05156ca793 feat: add inbox auto-advance setting 2026-04-24 15:04:08 +09:00
lucaronin
2308b269c8 docs: add ADRs for cache concurrency and git signing fallback (guard — from commits 42bb6c4, 6dbcc33) 2026-04-24 08:03:58 +02:00
lucaronin
b7f482bf27 fix: respect macos header double click action 2026-04-24 07:47:53 +02:00
lucaronin
33d7404d6c fix: keep blocknote tables editable after focus changes 2026-04-24 07:02:40 +02:00
lucaronin
0ecac2ab79 fix: allow unicode type filenames 2026-04-24 04:01:43 +02:00
lucaronin
7c9f2b0dda fix: guard editor selection churn around inline actions 2026-04-24 03:25:50 +02:00
lucaronin
24e317cfa6 fix: handle unreadable non-utf8 files gracefully 2026-04-24 03:02:03 +02:00
lucaronin
66c183fa61 fix: keep IME composition stable in editors 2026-04-24 02:50:12 +02:00
lucaronin
dacf9bb19b fix: guard note loading without active vault 2026-04-24 02:16:49 +02:00
lucaronin
3c92c200ab fix: sync onboarding vault handoff 2026-04-24 01:45:11 +02:00
lucaronin
61c3a3e21b docs: ratchet codescene thresholds 2026-04-24 01:39:28 +02:00
lucaronin
2e7c94d159 fix: register created onboarding vaults 2026-04-24 01:26:22 +02:00
lucaronin
98ade7411a fix: avoid duplicate onboarding vault writes 2026-04-24 01:23:16 +02:00
lucaronin
18776341f8 fix: persist onboarding vault selections 2026-04-24 01:08:13 +02:00
lucaronin
57bbcdbaef fix: format cache hardening helpers 2026-04-24 00:00:10 +02:00
lucaronin
42bb6c4b18 fix: harden vault cache updates 2026-04-24 00:00:10 +02:00
lucaronin
070cd3ac6d test: stabilize telemetry onboarding smoke 2026-04-23 23:11:46 +02:00
lucaronin
accc6270d3 fix: restore vault images and default folders 2026-04-23 23:11:46 +02:00
lucaronin
941f12aa1b test: clear vitest cache before coverage 2026-04-23 22:45:22 +02:00
lucaronin
4710a2d0a9 fix: reuse local smoke server by default 2026-04-23 22:27:38 +02:00
lucaronin
65a421215a fix: satisfy arrow ligature type checks 2026-04-23 22:14:20 +02:00
lucaronin
4ed0289989 Revert "test: simplify main entrypoint setup"
This reverts commit 4ad32faa7b.
2026-04-23 22:14:11 +02:00
lucaronin
4ad32faa7b test: simplify main entrypoint setup 2026-04-23 22:10:02 +02:00
lucaronin
c9623d5ca8 feat: add arrow ligatures in both editors 2026-04-23 22:06:01 +02:00
lucaronin
77286457bf fix: anchor settings default agent dropdown 2026-04-23 20:09:49 +02:00
lucaronin
5b8aa8da0d fix: guard pull refresh during unsaved editor edits 2026-04-23 19:34:15 +02:00
lucaronin
21b6d8984a fix: support symlinked vault notes 2026-04-23 18:36:45 +02:00
lucaronin
11fc123492 fix: guard folder picker after update install 2026-04-23 18:16:43 +02:00
lucaronin
d98fa94e14 fix: retry commits when signing helper is missing 2026-04-23 17:27:53 +02:00
lucaronin
add3c4075f fix: thread vault image path through tab swaps 2026-04-23 16:35:27 +02:00
lucaronin
fb0d550eb2 fix: make starter vault images portable 2026-04-23 16:31:36 +02:00
lucaronin
6adf7fd53b fix: capture react root errors in sentry 2026-04-23 15:34:39 +02:00
lucaronin
3b4d9599d2 fix: finish empty Claude Code runs 2026-04-23 14:15:15 +02:00
lucaronin
bb1b18686f fix: stabilize smoke dev server watch 2026-04-23 13:45:24 +02:00
lucaronin
6dbcc334bf fix: create repos despite commit signing 2026-04-23 13:26:01 +02:00
lucaronin
4944365ed7 fix: detect local Claude Code installs 2026-04-23 12:24:26 +02:00
lucaronin
c4c6bb6c57 fix: restore folder row toggle 2026-04-23 09:27:17 +02:00
lucaronin
35dd2dd47b docs: add/update ADR for note retargeting (guard — from commit 2c9361d7) 2026-04-23 08:07:56 +02:00
lucaronin
89141bae14 chore: ratchet codescene thresholds 2026-04-23 00:53:45 +02:00
lucaronin
2b9294f884 style: format note move backend 2026-04-23 00:41:04 +02:00
lucaronin
a756af54ec fix: keep note drop targets active during drag 2026-04-23 00:27:09 +02:00
lucaronin
b01b156dfb fix: align folder rows with sidebar items 2026-04-23 00:21:20 +02:00
lucaronin
2c9361d704 feat: retarget notes to types and folders 2026-04-22 23:56:00 +02:00
lucaronin
cea71a4fd9 Merge branch 'main' of https://github.com/refactoringhq/tolaria 2026-04-22 22:54:40 +02:00
lucaronin
8b73ef5836 feat: add sidebar folder rename and delete actions 2026-04-22 22:49:16 +02:00
lucaronin
a6a727a4c0 fix: align inspector create-type typing 2026-04-22 22:42:04 +02:00
lucaronin
bf13eed3ab fix: handle filename collisions in create flows 2026-04-22 22:37:01 +02:00
lucaronin
a27a70e552 fix: allow PostHog and Google Fonts in app CSP 2026-04-22 22:24:45 +02:00
lucaronin
ca92bf4f77 fix: restore keyboard shortcut typing 2026-04-22 21:22:35 +02:00
lucaronin
9fda0a67ec fix: make note renames crash-safe 2026-04-22 21:18:38 +02:00
lucaronin
0ffb7c65a9 Ignore history shortcuts while editing 2026-04-22 21:06:27 +02:00
lucaronin
633d9f1496 Add security policy and private reporting guidance 2026-04-22 20:46:24 +02:00
lucaronin
ba3d413b94 fix: recover from missing note paths 2026-04-22 20:18:26 +02:00
lucaronin
f3286922ad fix: satisfy clippy for MCP status 2026-04-22 19:28:58 +02:00
lucaronin
b0b476c9e5 fix: harden external AI tool setup 2026-04-22 19:18:52 +02:00
lucaronin
dac696b752 fix: guard toolbar popover anchors 2026-04-22 17:57:30 +02:00
lucaronin
cda87ee6c0 feat: add sidebar folder rename and delete actions 2026-04-22 17:16:56 +02:00
lucaronin
33324a5c79 fix: harden mcp bridge and vault boundaries 2026-04-22 17:14:42 +02:00
lucaronin
f28626bbfe fix: finish claude agent streams 2026-04-22 15:59:12 +02:00
lucaronin
1bc2e3d1e0 docs: add ADRs for startup and linkify lifecycles (guard — from commits 59268acd, bf442646) 2026-04-22 15:09:45 +02:00
lucaronin
8e6bdab3e6 docs: ratchet codescene thresholds 2026-04-22 00:31:54 +02:00
lucaronin
d52362ad9b fix: repair tiptap patch integrity 2026-04-22 00:21:46 +02:00
lucaronin
bf442646f5 fix: silence duplicate linkify init warnings 2026-04-22 00:05:32 +02:00
lucaronin
1b3a444368 fix: show welcome on fresh install without missing vault 2026-04-21 23:34:07 +02:00
lucaronin
c4da7f1f2f fix: narrow raw mode sync override 2026-04-21 19:04:02 +02:00
lucaronin
c5fe291a9a fix: sync inspector edits into raw editor 2026-04-21 19:04:02 +02:00
lucaronin
59268acd04 fix: guard empty vault path during startup 2026-04-21 19:04:01 +02:00
lucaronin
3e4e2ebdc6 refactor: simplify breadcrumb and note-list search ui 2026-04-21 19:03:11 +02:00
Luca Rossi
05375258a2 Rename 'Documentation' section to 'Tech Docs' 2026-04-21 18:06:30 +02:00
Luca Rossi
0c0354bfb8 Updated README badges 2026-04-21 18:05:54 +02:00
lucaronin
6023e95d5b fix: serialize vitest coverage files 2026-04-21 17:38:44 +02:00
lucaronin
88d5694353 fix: retry flaky vitest coverage teardown 2026-04-21 17:35:00 +02:00
lucaronin
db6b38843b fix: keep vitest coverage shards off workspace 2026-04-21 17:21:35 +02:00
lucaronin
ad9901bbf5 fix: stabilize coverage suite reruns 2026-04-21 17:08:43 +02:00
lucaronin
b785c53ef9 test: increase strategic coverage 2026-04-21 16:54:52 +02:00
lucaronin
8ae1ade220 test: cover live sidebar type picker sync 2026-04-21 15:16:39 +02:00
lucaronin
35b035643e fix: adapt save reload callback 2026-04-21 15:09:27 +02:00
lucaronin
d3d663af0b fix: reload vault entries on tauri startup 2026-04-21 15:02:42 +02:00
lucaronin
8f624350d8 test: cover journal vault scan 2026-04-21 12:37:46 +02:00
lucaronin
0e54173834 test: cover explicit journal visibility 2026-04-21 12:27:06 +02:00
lucaronin
a6f1524a78 ci: reject placeholder telemetry secrets 2026-04-21 11:06:16 +02:00
lucaronin
4cd5baeb55 fix: sync sidebar types with live vault state 2026-04-21 10:45:39 +02:00
lucaronin
fb1265e088 test: stabilize note list search timing 2026-04-21 10:14:18 +02:00
lucaronin
ca89662231 fix: sync raw editor frontmatter state 2026-04-21 10:06:54 +02:00
lucaronin
97f285e3cb ci: upload coverage to codecov 2026-04-21 10:06:54 +02:00
Luca Rossi
4bd4ef43dc Added release badge in README 2026-04-21 09:43:40 +02:00
Luca Rossi
be29880a3e Added walkthroughs to the README 2026-04-21 09:22:51 +02:00
lucaronin
7be5519f61 fix: sort alpha releases chronologically 2026-04-21 09:10:16 +02:00
lucaronin
8cefbe949a fix: refine derived inverse relationship labels 2026-04-21 09:08:25 +02:00
lucaronin
dd0a55b8e8 docs: add ADR for external vault refresh after pulls and agent edits (guard — from commit 0020ade6) 2026-04-21 08:03:02 +02:00
lucaronin
610276b1bc docs: ratchet codescene thresholds 2026-04-21 00:56:59 +02:00
lucaronin
e6c968e37e fix: satisfy note list keyboard build 2026-04-21 00:50:44 +02:00
lucaronin
367a66f32a fix: debounce note list search 2026-04-21 00:47:45 +02:00
lucaronin
6a4046915c fix: harden startup telemetry urls 2026-04-20 23:38:41 +02:00
lucaronin
ec8b637c84 fix: hide uncloned getting started vault 2026-04-20 23:04:34 +02:00
lucaronin
3ee42fb856 test: align create-note smoke placeholder 2026-04-20 22:13:19 +02:00
lucaronin
eacf05a9f8 fix: reload notes after agent edits 2026-04-20 22:04:38 +02:00
lucaronin
a199fad803 fix: widen editor focus block typing 2026-04-20 18:38:51 +02:00
lucaronin
e956c806e5 fix: keep editor sync from clobbering live inserts 2026-04-20 18:35:50 +02:00
lucaronin
7a6e3863ad fix: reopen active note after pull updates 2026-04-20 16:04:11 +02:00
lucaronin
e0e2dc4063 test: add pull refresh regression 2026-04-20 15:53:56 +02:00
lucaronin
0020ade6d7 fix: tighten pulled note refresh flow 2026-04-20 14:43:52 +02:00
lucaronin
503e482c7d fix: normalize inverse relationship labels 2026-04-20 12:49:57 +02:00
lucaronin
0d0e6af4c9 fix: keep vault menu remove action in row 2026-04-20 12:19:10 +02:00
lucaronin
dbaf361878 test: fix starter-vault app shell coverage 2026-04-20 11:32:44 +02:00
lucaronin
d3741c82f5 test: fix starter-vault shell regression 2026-04-20 11:20:27 +02:00
lucaronin
b90d2f8612 fix: reorder welcome onboarding actions 2026-04-20 11:04:29 +02:00
lucaronin
9536efee0e fix: persist AI panel chats across reopen 2026-04-20 10:28:48 +02:00
lucaronin
c86a6ab9c5 docs: add/update ADR for starter vault local-first remote flow (guard — from commit 374b4f66) 2026-04-20 10:28:00 +02:00
Luca Rossi
1d7c027700 Updated Tolaria Icon 2026-04-20 10:25:46 +02:00
lucaronin
74e50c64bb fix: hide stale getting started vault entries 2026-04-20 01:02:08 +02:00
lucaronin
8b97ef711f feat: refresh welcome screen branding 2026-04-20 00:45:14 +02:00
lucaronin
08855cee92 fix: defer git history during note switching 2026-04-20 00:09:52 +02:00
lucaronin
03042bb49c fix: normalize shipped telemetry secrets 2026-04-19 23:27:57 +02:00
lucaronin
cee4bef179 fix: harden shipped telemetry config 2026-04-19 23:16:34 +02:00
lucaronin
f223d4f654 refactor: curate demo-vault-v2 fixture 2026-04-19 22:26:35 +02:00
lucaronin
2f693c4476 fix: dedupe new type palette action 2026-04-19 21:38:26 +02:00
lucaronin
44f0c7cb74 feat: open favorites in neighborhood mode 2026-04-19 21:27:32 +02:00
lucaronin
ea14f1a389 fix: use rendered release note html 2026-04-19 20:53:39 +02:00
lucaronin
db21ae4708 feat: improve release history page 2026-04-19 20:37:53 +02:00
lucaronin
8019c4fb2d fix: defer missing vault startup state 2026-04-19 20:08:10 +02:00
lucaronin
0d552e1370 fix: satisfy note-open perf build checks 2026-04-19 19:34:05 +02:00
lucaronin
977f5593fa fix: stop note navigation from scaling with vault size 2026-04-19 19:30:58 +02:00
lucaronin
261a5befc7 chore: ratchet codescene thresholds 2026-04-19 18:56:47 +02:00
lucaronin
8d3cce7b49 fix: satisfy neighborhood history build checks 2026-04-19 18:50:08 +02:00
lucaronin
e0594820f0 fix: unwind neighborhood history on escape 2026-04-19 18:46:10 +02:00
lucaronin
b98d41212e fix: stop seeding title during property init 2026-04-19 18:18:35 +02:00
lucaronin
098dc421cb test: add isolated vitest coverage runner 2026-04-19 18:09:58 +02:00
lucaronin
326ccb08e1 test: stabilize vitest coverage artifacts 2026-04-19 18:03:58 +02:00
lucaronin
c2bbdb0085 test: stabilize git default branch in helpers 2026-04-19 17:52:58 +02:00
lucaronin
039f9e447f fix: cache inspector link lookups 2026-04-19 17:35:08 +02:00
lucaronin
8581a1b1e6 fix: organize seeded claude shim files 2026-04-19 17:08:45 +02:00
Luca Rossi
2c5cb593d3 Removed old Project spec reverence 2026-04-19 17:05:30 +02:00
Luca Rossi
348b627029 Updated README 2026-04-19 16:44:50 +02:00
lucaronin
f5cc5ffb4f fix: satisfy clippy and stabilize app startup test 2026-04-19 16:28:02 +02:00
lucaronin
374b4f6686 feat: keep getting started vaults local by default 2026-04-19 16:18:35 +02:00
lucaronin
36c20f34f4 fix: hide unavailable getting started vault 2026-04-19 15:21:59 +02:00
Luca Rossi
8551d819bd Deleted old unused font 2026-04-19 15:16:43 +02:00
lucaronin
d5497d7252 Match getting started subtitle copy 2026-04-19 14:30:07 +02:00
lucaronin
046afe0e67 Simplify Getting Started vault subtitle 2026-04-19 14:23:05 +02:00
lucaronin
cb1cc4b394 fix: include inverse neighborhood groups 2026-04-19 14:13:10 +02:00
lucaronin
00cb938766 fix: add cmd o quick-open alias 2026-04-19 13:47:02 +02:00
lucaronin
80c13272f3 fix: keep neighborhood reverse matches path-aware 2026-04-19 12:53:07 +02:00
lucaronin
9534449307 fix: hide absent neighborhood groups 2026-04-19 12:26:31 +02:00
lucaronin
2d5361b2e9 fix: keep keyboard note switching snappy 2026-04-19 11:49:48 +02:00
lucaronin
b22da65d44 fix: keep note switches instant 2026-04-19 11:29:04 +02:00
lucaronin
862af864d3 fix: preserve image selection while hovering toolbar 2026-04-19 10:48:45 +02:00
lucaronin
9ecefc90d5 feat: add stable landing page download link 2026-04-19 04:44:17 +02:00
lucaronin
9693fe24c9 fix: restore type filter icon colors 2026-04-19 04:15:12 +02:00
lucaronin
92ea32c0b8 feat: add neighborhood browsing mode 2026-04-19 03:37:33 +02:00
lucaronin
2e2bd2f3b3 fix: preserve native welcome vault creation 2026-04-19 02:37:29 +02:00
lucaronin
3556d934ee test: format vault command assertions 2026-04-19 01:55:39 +02:00
lucaronin
feef938174 fix: align fresh vault seed files 2026-04-19 01:33:53 +02:00
lucaronin
67a9930ea1 feat: add empty vault creation flow 2026-04-19 01:06:40 +02:00
lucaronin
aed4d05f1d docs: ratchet codescene thresholds 2026-04-19 00:25:35 +02:00
lucaronin
cac7f12c6d fix: stop duplicate native quick prompt paste 2026-04-19 00:18:42 +02:00
lucaronin
72b5b52140 feat: add view note-list controls 2026-04-18 20:31:25 +02:00
2657 changed files with 133719 additions and 42003 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.86
AVERAGE_THRESHOLD=9.7
HOTSPOT_THRESHOLD=10.0
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=

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
# These are supported funding model platforms
custom: https://refactoring.fm/

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

@@ -10,6 +10,7 @@ Il workflow `ci.yml` esegue i seguenti check automatici:
### 2. Test Coverage
- Frontend: vitest con coverage reporting
- Upload automatico su Codecov dai report LCOV frontend + Rust
- Threshold configurabile in `vitest.config.ts`
### 3. Code Health (CodeScene)
@@ -40,6 +41,11 @@ CODESCENE_PROJECT_ID=<your-project-id>
Il PAT di CodeScene è lo stesso che usi localmente (~/.codescene/token).
Il project ID lo trovi nella dashboard CodeScene.
### Codecov Setup
- Installa/attiva il repo in Codecov una volta sola tramite GitHub App / import del repository.
- Nessun `CODECOV_TOKEN` richiesto in GitHub Actions: `ci.yml` usa OIDC (`id-token: write` + `use_oidc: true`).
- Il workflow carica `coverage/lcov.info` (Vitest) e `coverage/rust.lcov` (cargo-llvm-cov).
### Telemetry Secrets For Release Builds
Aggiungi anche questi secrets per i workflow `release.yml` e `release-stable.yml`:
@@ -97,8 +103,11 @@ codescene delta-analysis --base-revision origin/main
## Workflow Triggers
- **Push**: su `main` e branch `experiment/*`
- **Push**: su `main`
- **Pull Request**: verso `main`
- **Manuale**: `workflow_dispatch`
Nota: l'upload a Codecov gira su push a `main` e sulle PR dello stesso repository. Le PR da fork saltano l'upload per evitare problemi di permessi OIDC.
## Status Checks

View File

@@ -3,6 +3,13 @@ name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
id-token: write
env:
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
@@ -58,30 +65,39 @@ 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 \
--ignore-filename-regex 'lib\.rs|main\.rs|menu\.rs' \
--lcov \
--output-path coverage/rust.lcov \
--fail-under-lines 85
# cargo-llvm-cov exits non-zero if line coverage drops below 85%
# lib.rs/main.rs/menu.rs are Tauri boilerplate -- not meaningfully unit-testable.
- name: Upload coverage to Codecov
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: codecov/codecov-action@v5
with:
use_oidc: true
fail_ci_if_error: true
disable_search: true
files: ./coverage/lcov.info,./coverage/rust.lcov
verbose: true
# OIDC avoids long-lived CODECOV_TOKEN secrets.
# ── 3. Code Health (CodeScene — Hotspot + Average Code Health gates) ──
# Enforces minimum floors on BOTH hotspot and average code health.
# Thresholds come from .codescene-thresholds so CI and local hooks match.
@@ -147,3 +163,63 @@ jobs:
- name: Format check (Rust)
run: cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check
linux-build:
name: Linux build verification
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install Tauri Linux system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
build-essential \
file
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Frontend build
run: pnpm build
- name: Cargo check
run: cargo check --manifest-path=src-tauri/Cargo.toml
- name: Clippy
run: cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings

View File

@@ -50,7 +50,7 @@ jobs:
echo "### Stable version: \`$DISPLAY_VERSION\`" >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# Phase 2: Build each architecture in parallel
# Phase 2: Build release bundles in parallel
# ─────────────────────────────────────────────────────────────
build:
name: Build (${{ matrix.arch }})
@@ -62,6 +62,8 @@ jobs:
include:
- arch: aarch64
target: aarch64-apple-darwin
- arch: x86_64
target: x86_64-apple-darwin
steps:
- uses: actions/checkout@v4
@@ -127,6 +129,95 @@ jobs:
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Validate telemetry env
env:
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
python3 <<'PY'
import os
import re
import sys
from urllib.parse import urlparse
DISALLOWED_PLACEHOLDERS = {
"",
"-",
"_",
"false",
"true",
"null",
"undefined",
"none",
"disabled",
}
def normalize(name: str) -> str:
value = os.getenv(name, "").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1].strip()
return value
def normalize_http_like(value: str) -> str:
if "://" in value:
return value
return f"https://{value}"
def normalize_hostname(hostname: str) -> str:
normalized = hostname.strip().rstrip('.').lower()
if normalized.startswith('[') and normalized.endswith(']'):
normalized = normalized[1:-1]
return normalized
def is_ip_address(hostname: str) -> bool:
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname):
return all(0 <= int(part) <= 255 for part in hostname.split('.'))
return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None
def is_allowed_hostname(hostname: str) -> bool:
normalized = normalize_hostname(hostname)
if not normalized or normalized in DISALLOWED_PLACEHOLDERS:
return False
if normalized == 'localhost':
return True
return '.' in normalized or is_ip_address(normalized)
def is_http_url(value: str) -> bool:
parsed = urlparse(normalize_http_like(value))
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
values = {
name: normalize(name)
for name in (
"VITE_SENTRY_DSN",
"SENTRY_DSN",
"VITE_POSTHOG_KEY",
"VITE_POSTHOG_HOST",
)
}
errors = []
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
value = values[name]
if value.lower() in DISALLOWED_PLACEHOLDERS:
errors.append(f"{name} must be set to a real value, not a placeholder")
elif not is_http_url(value):
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS:
errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder")
if errors:
print("Telemetry env validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
raise SystemExit(1)
print("Telemetry env validation passed.")
PY
- name: Build Tauri app (with signing + notarization)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -138,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 }}
@@ -160,12 +252,258 @@ jobs:
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
retention-days: 1
build-linux:
name: Build (linux-x86_64)
needs: version
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install Tauri Linux system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
curl \
wget \
patchelf \
build-essential \
file \
rpm
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached bundle artifacts
run: |
rm -rf src-tauri/target/x86_64-unknown-linux-gnu/release/bundle
- name: Set version
run: |
VERSION="${{ needs.version.outputs.version }}"
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Build Tauri app (Linux bundles)
env:
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,rpm,appimage
- name: Validate Linux bundles
run: |
shopt -s nullglob
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
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
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, deb or rpm bundle."
exit 1
fi
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Linux build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Linux bundles
uses: actions/upload-artifact@v4
with:
name: linux-x86_64-bundles
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
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
if-no-files-found: error
retention-days: 1
build-windows:
name: Build (windows-x86_64)
needs: version
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~\.cargo\registry
~\.cargo\git
src-tauri\target
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
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
- name: Clear cached Windows bundle artifacts
shell: pwsh
run: |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
- name: Set version
shell: pwsh
run: |
$version = "${{ needs.version.outputs.version }}"
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
$tauri.version = $version
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
- name: Validate Windows release env
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
run: |
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
if [ -z "${!name}" ]; then
echo "::error::$name is required to build signed Windows updater artifacts."
exit 1
fi
done
- name: Build Tauri app (Windows bundles)
env:
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-pc-windows-msvc --bundles nsis
- name: Validate Windows bundles
shell: bash
run: |
shopt -s nullglob
installers=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
)
signatures=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
)
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Windows build produced no installable NSIS or MSI bundle."
exit 1
fi
for installer in "${installers[@]}"; do
if [[ "$(basename "$installer")" != *"${{ needs.version.outputs.version }}"* ]]; then
echo "::error::Windows build produced an installer for a different version: $(basename "$installer")"
exit 1
fi
done
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Windows build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Windows bundles
uses: actions/upload-artifact@v4
with:
name: windows-x86_64-bundles
path: |
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
if-no-files-found: error
retention-days: 1
# ─────────────────────────────────────────────────────────────
# Phase 3: Publish GitHub Release
# ─────────────────────────────────────────────────────────────
release:
name: GitHub Release (stable)
needs: [version, build]
needs: [version, build, build-linux, build-windows]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -177,6 +515,54 @@ jobs:
- name: Download all artifacts
uses: actions/download-artifact@v4
- name: Normalize macOS release artifact names
run: |
normalize_macos_artifacts() {
local arch="$1"
local normalized_updater="$2"
local normalized_dmg="$3"
local updater_dir="updater-${arch}"
local updater_file
updater_file=$(find "$updater_dir" -maxdepth 1 -name "*.app.tar.gz" -print -quit)
if [ -z "$updater_file" ]; then
echo "::error::Missing macOS updater artifact in ${updater_dir}" >&2
return 1
fi
local sig_file="${updater_file}.sig"
if [ ! -f "$sig_file" ]; then
echo "::error::Missing macOS updater signature for ${updater_file}" >&2
return 1
fi
local normalized_sig="${normalized_updater}.sig"
if [ "$updater_file" != "$normalized_updater" ]; then
mv "$updater_file" "$normalized_updater"
fi
if [ "$sig_file" != "$normalized_sig" ]; then
mv "$sig_file" "$normalized_sig"
fi
local dmg_dir="dmg-${arch}"
local dmg_file
dmg_file=$(find "$dmg_dir" -maxdepth 1 -name "*.dmg" -print -quit)
if [ -z "$dmg_file" ]; then
echo "::error::Missing macOS DMG artifact in ${dmg_dir}" >&2
return 1
fi
if [ "$dmg_file" != "$normalized_dmg" ]; then
mv "$dmg_file" "$normalized_dmg"
fi
}
normalize_macos_artifacts aarch64 \
"updater-aarch64/Tolaria_${{ needs.version.outputs.version }}_macOS_Silicon.app.tar.gz" \
"dmg-aarch64/Tolaria_${{ needs.version.outputs.version }}_macOS_Silicon.dmg"
normalize_macos_artifacts x86_64 \
"updater-x86_64/Tolaria_${{ needs.version.outputs.version }}_macOS_Intel.app.tar.gz" \
"dmg-x86_64/Tolaria_${{ needs.version.outputs.version }}_macOS_Intel.dmg"
- name: Generate release notes
run: |
PREV_TAG=$(git tag --list 'stable-v*' --sort=-version:refname | grep -vx "${{ needs.version.outputs.tag }}" | head -n 1 || echo "")
@@ -193,7 +579,7 @@ jobs:
echo "---"
echo "**Stable release — manually promoted from \`main\`**"
echo ""
echo "**Requires Apple Silicon (M1/M2/M3)**"
echo "**Includes macOS (Apple Silicon and Intel), Windows x64, and Linux x64 bundles**"
echo ""
echo "*Built from \`$(git rev-parse --short ${{ needs.version.outputs.tag }})\` on $(date -u +%Y-%m-%d)*"
} > release_notes.md
@@ -206,8 +592,42 @@ jobs:
REPO_NAME="${REPO#*/}"
PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/"
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
find_required() {
local patterns=("$@")
for pattern in "${patterns[@]}"; do
set -- $pattern
if [ -e "$1" ]; then
printf '%s\n' "$1"
return 0
fi
done
echo "::error::Missing required artifact matching one of: ${patterns[*]}" >&2
return 1
}
ARM_SIG_FILE=$(find_required "updater-aarch64/*.app.tar.gz.sig")
ARM_UPDATER_FILE="${ARM_SIG_FILE%.sig}"
ARM_SIG=$(cat "$ARM_SIG_FILE")
ARM_TARBALL=$(basename "$ARM_UPDATER_FILE")
ARM_DMG=$(basename "$(find_required "dmg-aarch64/*.dmg")")
INTEL_SIG_FILE=$(find_required "updater-x86_64/*.app.tar.gz.sig")
INTEL_UPDATER_FILE="${INTEL_SIG_FILE%.sig}"
INTEL_SIG=$(cat "$INTEL_SIG_FILE")
INTEL_TARBALL=$(basename "$INTEL_UPDATER_FILE")
INTEL_DMG=$(basename "$(find_required "dmg-x86_64/*.dmg")")
LINUX_SIG_FILE=$(find_required "linux-x86_64-bundles/*/*.AppImage.sig" "linux-x86_64-bundles/*/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*/*.deb.sig" "linux-x86_64-bundles/*.AppImage.sig" "linux-x86_64-bundles/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*.deb.sig")
LINUX_UPDATER_FILE="${LINUX_SIG_FILE%.sig}"
LINUX_SIG=$(cat "$LINUX_SIG_FILE")
LINUX_UPDATER=$(basename "$LINUX_UPDATER_FILE")
LINUX_DOWNLOAD=$(basename "$(find_required "linux-x86_64-bundles/*/*.AppImage" "linux-x86_64-bundles/*/*.deb" "linux-x86_64-bundles/*/*.AppImage.tar.gz" "linux-x86_64-bundles/*.AppImage" "linux-x86_64-bundles/*.deb" "linux-x86_64-bundles/*.AppImage.tar.gz")")
WINDOWS_SIG_FILE=$(find_required "windows-x86_64-bundles/*/*-setup.exe.sig" "windows-x86_64-bundles/*/*.msi.sig" "windows-x86_64-bundles/*/*.nsis.zip.sig" "windows-x86_64-bundles/*/*.msi.zip.sig" "windows-x86_64-bundles/*-setup.exe.sig" "windows-x86_64-bundles/*.msi.sig" "windows-x86_64-bundles/*.nsis.zip.sig" "windows-x86_64-bundles/*.msi.zip.sig")
WINDOWS_UPDATER_FILE="${WINDOWS_SIG_FILE%.sig}"
WINDOWS_SIG=$(cat "$WINDOWS_SIG_FILE")
WINDOWS_UPDATER=$(basename "$WINDOWS_UPDATER_FILE")
WINDOWS_DOWNLOAD=$(basename "$(find_required "windows-x86_64-bundles/*/*-setup.exe" "windows-x86_64-bundles/*/*.msi" "windows-x86_64-bundles/*/*.nsis.zip" "windows-x86_64-bundles/*/*.msi.zip" "windows-x86_64-bundles/*-setup.exe" "windows-x86_64-bundles/*.msi" "windows-x86_64-bundles/*.nsis.zip" "windows-x86_64-bundles/*.msi.zip")")
cat > stable-latest.json << EOF
{
@@ -217,7 +637,23 @@ jobs:
"platforms": {
"darwin-aarch64": {
"signature": "${ARM_SIG}",
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}"
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}",
"dmg_url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_DMG}"
},
"darwin-x86_64": {
"signature": "${INTEL_SIG}",
"url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_TARBALL}",
"dmg_url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_DMG}"
},
"linux-x86_64": {
"signature": "${LINUX_SIG}",
"url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_UPDATER}",
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_DOWNLOAD}"
},
"windows-x86_64": {
"signature": "${WINDOWS_SIG}",
"url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_UPDATER}",
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_DOWNLOAD}"
}
}
}
@@ -236,6 +672,35 @@ jobs:
dmg-aarch64/*.dmg
updater-aarch64/*.app.tar.gz
updater-aarch64/*.app.tar.gz.sig
dmg-x86_64/*.dmg
updater-x86_64/*.app.tar.gz
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
linux-x86_64-bundles/*/*.AppImage.tar.gz.sig
windows-x86_64-bundles/*.exe
windows-x86_64-bundles/*.exe.sig
windows-x86_64-bundles/*.msi
windows-x86_64-bundles/*.msi.sig
windows-x86_64-bundles/*.zip
windows-x86_64-bundles/*.zip.sig
windows-x86_64-bundles/*/*.exe
windows-x86_64-bundles/*/*.exe.sig
windows-x86_64-bundles/*/*.msi
windows-x86_64-bundles/*/*.msi.sig
windows-x86_64-bundles/*/*.zip
windows-x86_64-bundles/*/*.zip.sig
stable-latest.json
# ─────────────────────────────────────────────────────────────
@@ -253,67 +718,29 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Build release history page
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p _site/alpha _site/stable
gh api repos/${{ github.repository }}/releases --paginate > _site/releases.json
gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
curl -fsSL "${PAGES_URL}/alpha/latest.json" -o _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "stable-latest.json" --output _site/stable/latest.json || echo '{}' > _site/stable/latest.json
bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/index.html
mkdir -p _site/download
cp _site/stable/download/index.html _site/download/index.html
cp _site/alpha/latest.json _site/latest.json
cp _site/alpha/latest.json _site/latest-canary.json
cat > _site/index.html << 'HTMLEOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tolaria — Release History</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #F7F6F3; color: #37352F; line-height: 1.6; padding: 2rem; max-width: 720px; margin: 0 auto; }
h1 { font-size: 1.75rem; font-weight: 600; margin-bottom: 0.5rem; }
.subtitle { color: #787774; margin-bottom: 2rem; }
.release { background: #fff; border: 1px solid #E9E9E7; border-radius: 8px; padding: 1.25rem 1.5rem; margin-bottom: 1rem; }
.release h2 { font-size: 1.125rem; font-weight: 600; margin-bottom: 0.25rem; }
.release .meta { font-size: 0.8125rem; color: #787774; margin-bottom: 0.75rem; }
.release .body { font-size: 0.875rem; white-space: pre-wrap; }
.release .downloads { margin-top: 0.75rem; display: flex; gap: 0.5rem; flex-wrap: wrap; }
.release .downloads a { display: inline-block; padding: 0.375rem 0.75rem; background: #155DFF; color: #fff; border-radius: 6px; text-decoration: none; font-size: 0.8125rem; font-weight: 500; }
.release .downloads a:hover { background: #1248CC; }
.alpha { border-left: 3px solid #f59e0b; }
.empty { color: #787774; text-align: center; padding: 3rem; }
</style>
</head>
<body>
<h1>Tolaria Release History</h1>
<p class="subtitle">Alpha builds update on every push to main. Stable builds appear when a stable-vYYYY.M.D tag is promoted.</p>
<div id="releases"></div>
<script>
fetch('releases.json').then(r=>r.json()).then(releases=>{
const el=document.getElementById('releases');
if(!releases.length){el.innerHTML='<p class="empty">No releases yet.</p>';return;}
releases.forEach(r=>{
const date=new Date(r.published_at).toLocaleDateString('en-US',{year:'numeric',month:'long',day:'numeric'});
const dmgs=(r.assets||[]).filter(a=>a.name.endsWith('.dmg'));
const links=dmgs.map(a=>'<a href="'+a.browser_download_url+'">'+a.name+'</a>').join('');
const body=(r.body||'').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const div=document.createElement('div');
div.className='release'+(r.prerelease?' alpha':'');
div.innerHTML='<h2>'+(r.name||r.tag_name)+'</h2><div class="meta">'+date+' · '+r.tag_name+(r.prerelease?' · <strong>Alpha</strong>':'')+'</div><div class="body">'+body+'</div>'+(links?'<div class="downloads">'+links+'</div>':'');
el.appendChild(div);
});
});
</script>
</body>
</html>
HTMLEOF
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:

View File

@@ -42,6 +42,21 @@ jobs:
output = subprocess.check_output(command, text=True).strip()
return [line for line in output.splitlines() if line]
alpha_pattern = re.compile(r"^alpha-v(\d{4}\.\d{1,2}\.\d{1,2})-alpha\.(\d+)$")
def parse_alpha_tag(tag: str) -> tuple[str, int] | None:
match = alpha_pattern.fullmatch(tag)
if not match:
return None
calendar_version, sequence = match.groups()
return calendar_version, int(sequence)
def alpha_version(calendar_version: str, sequence: int) -> str:
return f"{calendar_version}-alpha.{sequence}"
def alpha_tag(calendar_version: str, sequence: int) -> str:
return f"alpha-v{calendar_version}-alpha.{sequence:04d}"
existing_tags = [
tag for tag in lines(["git", "tag", "--points-at", "HEAD"])
if tag.startswith("alpha-v")
@@ -49,7 +64,8 @@ jobs:
if existing_tags:
tag = existing_tags[0]
version = tag.removeprefix("alpha-v")
parsed = parse_alpha_tag(tag)
version = alpha_version(*parsed) if parsed is not None else tag.removeprefix("alpha-v")
else:
today = datetime.now(timezone.utc).date()
stable_date = None
@@ -71,8 +87,8 @@ jobs:
calendar_version = f"{alpha_date.year}.{alpha_date.month}.{alpha_date.day}"
sequence = len(lines(["git", "tag", "--list", f"alpha-v{calendar_version}-alpha.*"])) + 1
version = f"{calendar_version}-alpha.{sequence}"
tag = f"alpha-v{version}"
version = alpha_version(calendar_version, sequence)
tag = alpha_tag(calendar_version, sequence)
display_match = re.fullmatch(r"(\d{4})\.(\d{1,2})\.(\d{1,2})-alpha\.(\d+)", version)
display_version = (
@@ -105,6 +121,8 @@ jobs:
include:
- arch: aarch64
target: aarch64-apple-darwin
- arch: x86_64
target: x86_64-apple-darwin
steps:
- uses: actions/checkout@v4
@@ -170,6 +188,95 @@ jobs:
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Validate telemetry env
env:
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
python3 <<'PY'
import os
import re
import sys
from urllib.parse import urlparse
DISALLOWED_PLACEHOLDERS = {
"",
"-",
"_",
"false",
"true",
"null",
"undefined",
"none",
"disabled",
}
def normalize(name: str) -> str:
value = os.getenv(name, "").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1].strip()
return value
def normalize_http_like(value: str) -> str:
if "://" in value:
return value
return f"https://{value}"
def normalize_hostname(hostname: str) -> str:
normalized = hostname.strip().rstrip('.').lower()
if normalized.startswith('[') and normalized.endswith(']'):
normalized = normalized[1:-1]
return normalized
def is_ip_address(hostname: str) -> bool:
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname):
return all(0 <= int(part) <= 255 for part in hostname.split('.'))
return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None
def is_allowed_hostname(hostname: str) -> bool:
normalized = normalize_hostname(hostname)
if not normalized or normalized in DISALLOWED_PLACEHOLDERS:
return False
if normalized == 'localhost':
return True
return '.' in normalized or is_ip_address(normalized)
def is_http_url(value: str) -> bool:
parsed = urlparse(normalize_http_like(value))
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
values = {
name: normalize(name)
for name in (
"VITE_SENTRY_DSN",
"SENTRY_DSN",
"VITE_POSTHOG_KEY",
"VITE_POSTHOG_HOST",
)
}
errors = []
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
value = values[name]
if value.lower() in DISALLOWED_PLACEHOLDERS:
errors.append(f"{name} must be set to a real value, not a placeholder")
elif not is_http_url(value):
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS:
errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder")
if errors:
print("Telemetry env validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
raise SystemExit(1)
print("Telemetry env validation passed.")
PY
- name: Build Tauri app (with signing + notarization)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -181,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 }}
@@ -198,13 +306,258 @@ jobs:
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
retention-days: 1
build-linux:
name: Build (linux-x86_64)
needs: version
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install Tauri Linux system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
curl \
wget \
patchelf \
build-essential \
file \
rpm
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached bundle artifacts
run: |
rm -rf src-tauri/target/x86_64-unknown-linux-gnu/release/bundle
- name: Set version
run: |
VERSION="${{ needs.version.outputs.version }}"
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Build Tauri app (Linux bundles)
env:
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,rpm,appimage
- name: Validate Linux bundles
run: |
shopt -s nullglob
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
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
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, deb or rpm bundle."
exit 1
fi
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Linux build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Linux bundles
uses: actions/upload-artifact@v4
with:
name: linux-x86_64-bundles
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
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
if-no-files-found: error
retention-days: 1
build-windows:
name: Build (windows-x86_64)
needs: version
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~\.cargo\registry
~\.cargo\git
src-tauri\target
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
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
- name: Clear cached Windows bundle artifacts
shell: pwsh
run: |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
- name: Set version
shell: pwsh
run: |
$version = "${{ needs.version.outputs.version }}"
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
$tauri.version = $version
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
- name: Validate Windows release env
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
run: |
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
if [ -z "${!name}" ]; then
echo "::error::$name is required to build signed Windows updater artifacts."
exit 1
fi
done
- name: Build Tauri app (Windows bundles)
env:
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-pc-windows-msvc --bundles nsis
- name: Validate Windows bundles
shell: bash
run: |
shopt -s nullglob
installers=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
)
signatures=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
)
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Windows build produced no installable NSIS or MSI bundle."
exit 1
fi
for installer in "${installers[@]}"; do
if [[ "$(basename "$installer")" != *"${{ needs.version.outputs.version }}"* ]]; then
echo "::error::Windows build produced an installer for a different version: $(basename "$installer")"
exit 1
fi
done
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Windows build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Windows bundles
uses: actions/upload-artifact@v4
with:
name: windows-x86_64-bundles
path: |
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
if-no-files-found: error
retention-days: 1
# ─────────────────────────────────────────────────────────────
# Phase 3: Publish GitHub Release
# No lipo/re-signing — use the per-arch artifacts directly
# ─────────────────────────────────────────────────────────────
release:
name: GitHub Release (alpha)
needs: [version, build]
needs: [version, build, build-linux, build-windows]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -216,9 +569,60 @@ jobs:
- name: Download all artifacts
uses: actions/download-artifact@v4
- name: Normalize macOS updater artifact names
run: |
normalize_updater() {
local arch="$1"
local normalized_updater="$2"
local artifact_dir="updater-${arch}"
local updater_file
updater_file=$(find "$artifact_dir" -maxdepth 1 -name "*.app.tar.gz" -print -quit)
if [ -z "$updater_file" ]; then
echo "::error::Missing macOS updater artifact in ${artifact_dir}" >&2
return 1
fi
local sig_file="${updater_file}.sig"
if [ ! -f "$sig_file" ]; then
echo "::error::Missing macOS updater signature for ${updater_file}" >&2
return 1
fi
local normalized_sig="${normalized_updater}.sig"
if [ "$updater_file" != "$normalized_updater" ]; then
mv "$updater_file" "$normalized_updater"
fi
if [ "$sig_file" != "$normalized_sig" ]; then
mv "$sig_file" "$normalized_sig"
fi
}
normalize_updater aarch64 "updater-aarch64/Tolaria_${{ needs.version.outputs.version }}_macOS_Silicon.app.tar.gz"
normalize_updater x86_64 "updater-x86_64/Tolaria_${{ needs.version.outputs.version }}_macOS_Intel.app.tar.gz"
- name: Generate release notes
run: |
PREV_TAG=$(git tag --list 'alpha-v*' --sort=-version:refname | head -n 1 || echo "")
PREV_TAG=$(python3 <<'PY'
import re
import subprocess
current_tag = '${{ needs.version.outputs.tag }}'
pattern = re.compile(r'^alpha-v(\d{4})\.(\d{1,2})\.(\d{1,2})-alpha\.(\d+)$')
output = subprocess.check_output(['git', 'tag', '--list', 'alpha-v*'], text=True).strip()
tags = [line for line in output.splitlines() if line and line != current_tag]
parsed_tags = []
for tag in tags:
match = pattern.fullmatch(tag)
if not match:
continue
year, month, day, sequence = map(int, match.groups())
parsed_tags.append(((year, month, day, sequence), tag))
print(max(parsed_tags)[1] if parsed_tags else '')
PY
)
if [ -z "$PREV_TAG" ]; then
NOTES=$(git log --oneline --no-merges -20)
else
@@ -232,7 +636,7 @@ jobs:
echo "---"
echo "**Alpha build — updated on every push to \`main\`**"
echo ""
echo "**Requires Apple Silicon (M1/M2/M3)**"
echo "**Includes macOS (Apple Silicon and Intel), Linux x64, and Windows x64 bundles**"
echo ""
echo "*Built from \`$(git rev-parse --short HEAD)\` on $(date -u +%Y-%m-%d)*"
} > release_notes.md
@@ -245,8 +649,38 @@ jobs:
REPO_NAME="${REPO#*/}"
PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/"
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
find_required() {
for pattern in "$@"; do
set -- $pattern
if [ -e "$1" ]; then
printf '%s\n' "$1"
return 0
fi
done
return 1
}
ARM_SIG_FILE=$(find_required "updater-aarch64/*.app.tar.gz.sig")
ARM_UPDATER_FILE="${ARM_SIG_FILE%.sig}"
ARM_SIG=$(cat "$ARM_SIG_FILE")
ARM_UPDATER=$(basename "$ARM_UPDATER_FILE")
INTEL_SIG_FILE=$(find_required "updater-x86_64/*.app.tar.gz.sig")
INTEL_UPDATER_FILE="${INTEL_SIG_FILE%.sig}"
INTEL_SIG=$(cat "$INTEL_SIG_FILE")
INTEL_UPDATER=$(basename "$INTEL_UPDATER_FILE")
LINUX_SIG_FILE=$(find_required "linux-x86_64-bundles/*/*.AppImage.sig" "linux-x86_64-bundles/*/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*/*.deb.sig" "linux-x86_64-bundles/*.AppImage.sig" "linux-x86_64-bundles/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*.deb.sig")
LINUX_UPDATER_FILE="${LINUX_SIG_FILE%.sig}"
LINUX_SIG=$(cat "$LINUX_SIG_FILE")
LINUX_UPDATER=$(basename "$LINUX_UPDATER_FILE")
LINUX_DOWNLOAD=$(basename "$(find_required "linux-x86_64-bundles/*/*.AppImage" "linux-x86_64-bundles/*/*.deb" "linux-x86_64-bundles/*/*.AppImage.tar.gz" "linux-x86_64-bundles/*.AppImage" "linux-x86_64-bundles/*.deb" "linux-x86_64-bundles/*.AppImage.tar.gz")")
WINDOWS_SIG_FILE=$(find_required "windows-x86_64-bundles/*/*-setup.exe.sig" "windows-x86_64-bundles/*/*.msi.sig" "windows-x86_64-bundles/*/*.nsis.zip.sig" "windows-x86_64-bundles/*/*.msi.zip.sig" "windows-x86_64-bundles/*-setup.exe.sig" "windows-x86_64-bundles/*.msi.sig" "windows-x86_64-bundles/*.nsis.zip.sig" "windows-x86_64-bundles/*.msi.zip.sig")
WINDOWS_UPDATER_FILE="${WINDOWS_SIG_FILE%.sig}"
WINDOWS_SIG=$(cat "$WINDOWS_SIG_FILE")
WINDOWS_UPDATER=$(basename "$WINDOWS_UPDATER_FILE")
WINDOWS_DOWNLOAD=$(basename "$(find_required "windows-x86_64-bundles/*/*-setup.exe" "windows-x86_64-bundles/*/*.msi" "windows-x86_64-bundles/*/*.nsis.zip" "windows-x86_64-bundles/*/*.msi.zip" "windows-x86_64-bundles/*-setup.exe" "windows-x86_64-bundles/*.msi" "windows-x86_64-bundles/*.nsis.zip" "windows-x86_64-bundles/*.msi.zip")")
cat > alpha-latest.json << EOF
{
@@ -256,7 +690,23 @@ jobs:
"platforms": {
"darwin-aarch64": {
"signature": "${ARM_SIG}",
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}"
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_UPDATER}",
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_UPDATER}"
},
"darwin-x86_64": {
"signature": "${INTEL_SIG}",
"url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_UPDATER}",
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_UPDATER}"
},
"linux-x86_64": {
"signature": "${LINUX_SIG}",
"url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_UPDATER}",
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_DOWNLOAD}"
},
"windows-x86_64": {
"signature": "${WINDOWS_SIG}",
"url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_UPDATER}",
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_DOWNLOAD}"
}
}
}
@@ -274,6 +724,34 @@ jobs:
files: |
updater-aarch64/*.app.tar.gz
updater-aarch64/*.app.tar.gz.sig
updater-x86_64/*.app.tar.gz
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
linux-x86_64-bundles/*/*.AppImage.tar.gz.sig
windows-x86_64-bundles/*.exe
windows-x86_64-bundles/*.exe.sig
windows-x86_64-bundles/*.msi
windows-x86_64-bundles/*.msi.sig
windows-x86_64-bundles/*.zip
windows-x86_64-bundles/*.zip.sig
windows-x86_64-bundles/*/*.exe
windows-x86_64-bundles/*/*.exe.sig
windows-x86_64-bundles/*/*.msi
windows-x86_64-bundles/*/*.msi.sig
windows-x86_64-bundles/*/*.zip
windows-x86_64-bundles/*/*.zip.sig
alpha-latest.json
# ─────────────────────────────────────────────────────────────
@@ -291,67 +769,29 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Build release history page
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p _site/alpha _site/stable
gh api repos/${{ github.repository }}/releases --paginate > _site/releases.json
gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "alpha-latest.json" --output _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
curl -fsSL "${PAGES_URL}/stable/latest.json" -o _site/stable/latest.json || echo '{}' > _site/stable/latest.json
bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/index.html
mkdir -p _site/download
cp _site/stable/download/index.html _site/download/index.html
cp _site/alpha/latest.json _site/latest.json
cp _site/alpha/latest.json _site/latest-canary.json
cat > _site/index.html << 'HTMLEOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tolaria — Release History</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #F7F6F3; color: #37352F; line-height: 1.6; padding: 2rem; max-width: 720px; margin: 0 auto; }
h1 { font-size: 1.75rem; font-weight: 600; margin-bottom: 0.5rem; }
.subtitle { color: #787774; margin-bottom: 2rem; }
.release { background: #fff; border: 1px solid #E9E9E7; border-radius: 8px; padding: 1.25rem 1.5rem; margin-bottom: 1rem; }
.release h2 { font-size: 1.125rem; font-weight: 600; margin-bottom: 0.25rem; }
.release .meta { font-size: 0.8125rem; color: #787774; margin-bottom: 0.75rem; }
.release .body { font-size: 0.875rem; white-space: pre-wrap; }
.release .downloads { margin-top: 0.75rem; display: flex; gap: 0.5rem; flex-wrap: wrap; }
.release .downloads a { display: inline-block; padding: 0.375rem 0.75rem; background: #155DFF; color: #fff; border-radius: 6px; text-decoration: none; font-size: 0.8125rem; font-weight: 500; }
.release .downloads a:hover { background: #1248CC; }
.alpha { border-left: 3px solid #f59e0b; }
.empty { color: #787774; text-align: center; padding: 3rem; }
</style>
</head>
<body>
<h1>Tolaria Release History</h1>
<p class="subtitle">Alpha builds update on every push to main. Stable builds appear when a stable-vYYYY.M.D tag is promoted.</p>
<div id="releases"></div>
<script>
fetch('releases.json').then(r=>r.json()).then(releases=>{
const el=document.getElementById('releases');
if(!releases.length){el.innerHTML='<p class="empty">No releases yet.</p>';return;}
releases.forEach(r=>{
const date=new Date(r.published_at).toLocaleDateString('en-US',{year:'numeric',month:'long',day:'numeric'});
const dmgs=(r.assets||[]).filter(a=>a.name.endsWith('.dmg'));
const links=dmgs.map(a=>'<a href="'+a.browser_download_url+'">'+a.name+'</a>').join('');
const body=(r.body||'').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const div=document.createElement('div');
div.className='release'+(r.prerelease?' alpha':'');
div.innerHTML='<h2>'+(r.name||r.tag_name)+'</h2><div class="meta">'+date+' · '+r.tag_name+(r.prerelease?' · <strong>Alpha</strong>':'')+'</div><div class="body">'+body+'</div>'+(links?'<div class="downloads">'+links+'</div>':'');
el.appendChild(div);
});
});
</script>
</body>
</html>
HTMLEOF
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:

2
.gitignore vendored
View File

@@ -32,6 +32,7 @@ dist-ssr
# Demo vault and helper scripts
demo-vault/
generated-fixtures/
select_demo_notes*.py
final_selection.py
@@ -69,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

@@ -1,6 +1,6 @@
# AGENTS.md — Tolaria App
> Quick links: [Project Spec](docs/PROJECT-SPEC.md) · [Architecture](docs/ARCHITECTURE.md) · [Abstractions](docs/ABSTRACTIONS.md) · [Wireframes](ui-design.pen)
> Quick links: [Architecture](docs/ARCHITECTURE.md) · [Abstractions](docs/ABSTRACTIONS.md) · [Wireframes](ui-design.pen)
---
@@ -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"
```

49
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,49 @@
# Contributing to Tolaria
Thanks for being here! Tolaria is still early, and every bug report, idea, and contribution genuinely helps shape the app.
## 🗳️ Where to share what
To keep things clean:
- 🐛 Bugs → GitHub Issues
- 💡 Feature requests / ideas → Canny • <https://tolaria.canny.io/>
If you have a feature idea, please check Canny first and upvote it if it already exists.
## 📥 Pull requests are welcome
PRs are very welcome.
A few things to keep in mind before opening one:
- Bug fixes are always great
- Small improvements are great too
- For bigger features, please check Canny first before building
- Try to avoid things that are already marked **in progress**
- Requests marked **planned** are usually great contribution targets
- Keep PRs small, focused, and easy to review
- Include a short explanation of the problem and your solution
- Follow the dev process described in Tolarias `AGENTS.md` (tests, code health, etc.)
- Avoid bundling unrelated refactors into the same PR
If you want to contribute a feature, the best place to start is here: <https://tolaria.canny.io/>
## 📋 What makes a good bug report
If you open a bug report on GitHub, it really helps to include:
- your Tolaria version
- your OS version
- steps to reproduce
- what you expected to happen
- what actually happened
- screenshots or screen recordings if useful
The clearer the report, the easier it is for us to reproduce and fix it.
## 🙏 Thank you
Tolaria is getting better because people care enough to try it, report whats broken, suggest whats missing, and contribute improvements.
That means a lot. Thanks for helping build it.

Binary file not shown.

164
README.md
View File

@@ -1,102 +1,112 @@
[![CodeScene Hotspot Code Health](https://codescene.io/projects/76865/status-badges/hotspot-code-health)](https://codescene.io/projects/76865) [![CodeScene Average Code Health](https://codescene.io/projects/76865/status-badges/average-code-health)](https://codescene.io/projects/76865)
# Tolaria App
![Latest stable](https://img.shields.io/github/v/release/refactoringhq/tolaria?display_name=tag) [![CI](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml) [![Build](https://github.com/refactoringhq/tolaria/actions/workflows/release.yml/badge.svg?branch=main)](https://github.com/refactoringhq/tolaria/actions/workflows/release.yml) [![Codecov](https://codecov.io/gh/refactoringhq/tolaria/graph/badge.svg?branch=main)](https://codecov.io/gh/refactoringhq/tolaria) [![CodeScene Hotspot Code Health](https://codescene.io/projects/76865/status-badges/hotspot-code-health)](https://codescene.io/projects/76865)
Personal knowledge and life management desktop app built with Tauri v2 + React + TypeScript + BlockNote.
# 💧 Tolaria
## Documentation
Tolaria is a desktop app for macOS, Windows, and Linux for managing **markdown knowledge bases**. People use it for a variety of use cases:
- 📐 [ARCHITECTURE.md](docs/ARCHITECTURE.md) — System design, tech stack, data flow
- 🧩 [ABSTRACTIONS.md](docs/ABSTRACTIONS.md) — Core abstractions and models
- 🚀 [GETTING-STARTED.md](docs/GETTING-STARTED.md) — How to navigate the codebase
- 🎨 [THEMING.md](docs/THEMING.md) — Theme system and customization
* Operate second brains and personal knowledge
* Organize company docs as context for AI
* Store OpenClaw/assistants memory and procedures
## Quick Start
Personally, I use it to **run my life** (hey 👋 [Luca here](http://x.com/lucaronin)). I have a massive workspace of 10,000+ notes, which are the result of my [Refactoring](https://refactoring.fm/) work + a ton of personal journaling and *second braining*.
<img width="1000" height="656" alt="1776506856823-CleanShot_2026-04-18_at_12 06 57_2x" src="https://github.com/user-attachments/assets/8aeafb0a-b236-43c2-a083-ec111f903c38" />
## Walkthroughs
You can find some Loom walkthroughs below — they are short and to the point:
- [How I Organize My Own Tolaria Workspace](https://www.loom.com/share/bb3aaffa238b4be0bd62e4464bca2528)
- [My Inbox Workflow](https://www.loom.com/share/dffda263317b4fa8b47b59cdf9330571)
- [How I Save Web Resources to Tolaria](https://www.loom.com/share/8a3c1776f801402ebbf4d7b0f31e9882)
## Principles
- 📑 **Files-first** — Your notes are plain markdown files. They're portable, work with any editor, and require no export step. Your data belongs to you, not to any app.
- 🔌 **Git-first** — Every vault is a git repository. You get full version history, the ability to use any git remote, and zero dependency on Tolaria servers.
- 🛜 **Offline-first, zero lock-in** — No accounts, no subscriptions, no cloud dependencies. Your vault works completely offline and always will. If you stop using Tolaria, you lose nothing.
- 🔬 **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, 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.
## Installation
### 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.
## Open source and local setup
Tolaria is open source and built with Tauri, React, and TypeScript. If you want to run or contribute to the app locally, here is [how to get started](https://github.com/refactoringhq/tolaria/blob/main/docs/GETTING-STARTED.md). You can also find the gist below 👇
### Prerequisites
- Node.js 20+
- pnpm 8+
- Rust (latest stable)
- macOS (for development)
- Rust stable
- macOS or Linux for development
### Setup
#### Linux system dependencies
Tauri 2 on Linux requires WebKit2GTK 4.1 and GTK 3:
- Arch / Manjaro:
```bash
sudo pacman -S --needed webkit2gtk-4.1 base-devel curl wget file openssl \
appmenu-gtk-module libappindicator-gtk3 librsvg
```
- Debian / Ubuntu (22.04+):
```bash
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file \
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
libsoup-3.0-dev patchelf
```
- Fedora 38+:
```bash
sudo dnf install webkit2gtk4.1-devel openssl-devel curl wget file \
libappindicator-gtk3-devel librsvg2-devel
```
The bundled MCP server still spawns the system `node` binary at runtime on Linux, so install Node from your distro package manager if you want the external AI tooling flow.
### Quick start
```bash
# Install dependencies
pnpm install
# Run dev server
pnpm dev
```
# Open in browser (mock mode)
open http://localhost:5173
Open `http://localhost:5173` for the browser-based mock mode, or run the native desktop app with:
# Or run in Tauri
```bash
pnpm tauri dev
```
### Testing
## Tech Docs
```bash
# Frontend tests
pnpm test
- 📐 [ARCHITECTURE.md](docs/ARCHITECTURE.md) — System design, tech stack, data flow
- 🧩 [ABSTRACTIONS.md](docs/ABSTRACTIONS.md) — Core abstractions and models
- 🚀 [GETTING-STARTED.md](docs/GETTING-STARTED.md) — How to navigate the codebase
- 📚 [ADRs](docs/adr) — Architecture Decision Records
# Backend tests
cargo test
## Security
# Coverage
pnpm test:coverage
# E2E tests
pnpm test:e2e
```
### Code Quality
```bash
# Lint
pnpm lint
# Rust checks
cargo clippy
cargo fmt --check
# CodeScene (via Claude Code)
claude 'Check code health with CodeScene MCP'
```
## Development Workflow
See [AGENTS.md](AGENTS.md) for coding guidelines and workflow. [CLAUDE.md](CLAUDE.md) remains as a compatibility shim for Claude Code.
**Key principles:**
- Small, atomic commits
- Test as you go
- Visual verification mandatory
- Documentation updated with code changes
## CI/CD
GitHub Actions runs on every push to `main`:
- ✅ Tests (frontend + Rust)
- 📊 Coverage (70% threshold)
- 🎨 Lint & format
- ⚠️ Documentation check
See [.github/SETUP.md](.github/SETUP.md) for CI/CD configuration.
## Git Hooks
Husky installs the git hooks from `.husky/` during `pnpm install`. The repo enforces `main`-only commits and pushes; see [.github/HOOKS.md](.github/HOOKS.md) for details.
If you believe you have found a security issue, please report it privately as described in [SECURITY.md](./SECURITY.md).
## License
Tolaria is licensed under the GNU Affero General Public License, version 3 or any later version (AGPL-3.0-or-later).
See [LICENSE](LICENSE) for the full license text.
## Trademarks
The Tolaria name and logo are not licensed under the AGPL.
See [trademarks.md](trademarks.md) for the trademark policy.
Tolaria is licensed under AGPL-3.0-or-later. The Tolaria name and logo remain covered by the projects trademark policy.

55
SECURITY.md Normal file
View File

@@ -0,0 +1,55 @@
# Security Policy
Thanks for helping keep Tolaria safe.
If you believe you have found a security vulnerability, **please do not open a public GitHub issue**. Report it privately instead.
## Supported versions
We currently support security fixes for:
| Version | Supported |
| --- | --- |
| Latest stable release | ✅ |
| `main` branch | Best effort |
| Older releases / prereleases | ❌ |
## Reporting a vulnerability
Please email **luca@refactoring.club** with the subject line **`[Tolaria Security]`**.
Include as much of the following as you can:
- a short description of the issue
- reproduction steps or a proof of concept
- affected version / commit, if known
- impact assessment
- any suggested mitigation
If the issue involves sensitive user data, credentials, or a working exploit, keep the report private and do not post details publicly.
## What to expect
We will try to:
- acknowledge receipt within a few business days
- reproduce and assess the report
- work on a fix or mitigation if the issue is valid
- coordinate public disclosure after users have had a reasonable chance to update
## Disclosure guidelines
Please give us a reasonable amount of time to investigate and ship a fix before publishing details.
We appreciate responsible disclosure and good-faith research.
## Out of scope
The following are generally out of scope unless they demonstrate a real security impact:
- missing best-practice headers or hardening with no practical exploit
- self-XSS or editor behavior that requires unrealistic user actions
- reports that only affect unsupported old builds
- purely theoretical issues with no plausible attack path
If you are unsure whether something qualifies, please still report it privately.

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',
}
}

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