- 發佈於
- 分鐘
- 6
Jot:三秒鐘寫下一個念頭,從任何地方
一個住在 macOS 選單列的快速筆記工具:按 ⇧⌘J、打字、按 Return,筆記存好、面板消失。沒有帳號、沒有網路、沒有「要放哪本筆記本」——順便講清楚中文搜尋為什麼要換分詞器,以及三個要真的打開 app 才看得到的鍵盤 bug。
筆記 app 輸的是摩擦,不是功能
一個念頭出現的時候,通常正在做別的事:在讀一份 PDF、在回一封信、在等一個 build 跑完。
大多數筆記 app 在這一刻要求兩件事:切過去,然後決定這則筆記屬於哪本筆記本、哪個資料夾、哪個標籤。這兩個決定各自只要兩秒,但加起來剛好長到讓人想「等一下再記」。等一下,通常就是沒有。
Jot 只做一件事:把這兩個決定從「寫下來」的路徑上拿掉。
快捷鍵、打字、Return
Jot 住在 macOS 的選單列。在任何 app 裡按 ⇧⌘J,一個浮動面板會出現在螢幕偏上方;打字,按 Return,筆記存好、面板消失。整個過程不切換 app,也沒有人問你要放哪裡。
還有第二個入口:點選單列的圖示,會開一個錨在圖示下方的 popover。兩個入口共用同一份輸入、同一份草稿。
鍵盤就這幾個:
| 按鍵 | 做什麼 |
|---|---|
| ⇧⌘J | 從任何 app 叫出擷取面板(可改) |
| Return | 存檔並關閉 |
| ⇧Return | 換行 |
| Esc | 關閉,草稿留著 |
| ⌘F | 開回顧視窗並開始搜尋 |
| ⌘⌫ | 把正在編輯的筆記移到垃圾桶 |
| ⌘Z | 復原——包括刪除 |
Esc 跟點到外面都不會丟掉打到一半的字,重開 app 也還在。整個 app 沒有任何確認對話框,因為沒有一個動作是回不了頭的:刪除進垃圾桶,垃圾桶在你選的天數之後自己清空,而 ⌘Z 可以馬上把剛刪掉的拿回來。
日誌,不是收件匣
「決定放哪裡」並沒有消失,只是搬到另一個你進去頻率低很多的模式。
回顧視窗是兩欄。左欄列出有筆記的日子,最新的在最上面;右欄是那一天的串流,最早的在最上面,打開時捲到最底——也就是最新那一則的位置。兩欄方向刻意相反:索引要把最常伸手拿的放在手邊,串流則往下流。
要分類的話,在內文打 #工作 就好。標籤在寫入時解析,點一下等於精確的標籤搜尋。從來不是必填,也沒有下拉選單。
中文搜尋為什麼會找不到東西
這是整個專案花最多心思的一段。
SQLite 的 FTS5 預設用 unicode61 分詞器。它切英文切得很好——照空白跟標點切——但它不會切中文。一句中文會被當成一個 token 整個塞進索引,於是搜「租約」只會在某一則筆記剛好整句就是「租約」的時候找到東西。
Jot 改用 trigram 分詞器:索引每三個字元的滑動窗格,任何文字系統都能做真正的子字串搜尋。
代價是三個字元以下的查詢永遠比不到——而兩個字的中文詞,大概是最常被搜的東西。所以短於三個字元的查詢不走索引,退回 LIKE 全掃。在個人筆記的規模下這個掃描感覺不到,而它是「租約」找得到跟找不到的差別。
查詢還有一層分類:整段輸入剛好就是一個標籤時(#work),做的是精確的標籤查詢,這樣 #work 不會把 #workflow 也拖進來;#work meeting 則是普通的文字搜尋。文字查詢會整串包成 FTS5 的 phrase,使用者打的標點跟運算子不會變成查詢語法,也不會變成語法錯誤。
標籤解析另外有幾條規則,為了不讓一般文字長出意外的標籤:
#必須在開頭、或接在空白之後,所以https://x#anchor跟C#不會變成標籤- 至少要含一個字母,所以「issue #1」還是一般文字
- 「字母」是任何文字系統的字母,所以
#工作是標籤
三個要真的打開 app 才看得到的 bug
單元測試目前 60 個,涵蓋 repository、標籤解析、查詢分類、匯出跟在地化——全是不碰 UI 的純邏輯。真正把 app 跑起來、用腳本按快捷鍵、打字、按 Return 之後,抓到三個測試永遠不會抓到的 bug:
- 快速打字會掉字。 一個按鍵落在 SwiftUI 重繪還在進行的當下,會被過期的 binding 值蓋掉;打
jot e2e check #testtag,存下來的是ez。修法是 text view 只接受不是自己發出的寫入。 - ⌘A、⌘C、⌘V、⌘X、⌘Z 在擷取欄位裡沒反應。 AppKit 把這些按鍵透過主選單分派,而一個
LSUIElement(只出現在選單列)的 app 預設沒有主選單——貼上,一個連結進到筆記工具最常見的方式,是壞的。現在裝了一個看不見、但帶著 key equivalent 的選單。 - 別的 app 在前景時,面板永遠拿不到鍵盤。
.nonactivatingPanel這個 flag 會讓 app 留在背景,而背景 app 的面板不會成為 key window,按鍵繼續送給原本在前面的 app。順序也要對:先把面板 order front、再 activate;反過來的話,焦點會交給這個 app 裡已經在最前面的視窗。
第四個不算 bug,但每次啟動都會煩:macOS 會在 com.apple.provenance 記下每個檔案從哪裡來,一個帶著 Documents 資料夾來源的 app,每次啟動都會跳出「想要存取你的 Documents 資料夾」。build script 在簽章之前把這個屬性剝掉。
幾個刻意的選擇
- 全域快捷鍵用 Carbon 的
RegisterEventHotKey,不用NSEvent的全域監聽。 後者需要「輔助使用」權限——對一個筆記 app 來說是一個嚇人的系統提示。前者不用。 - 快捷鍵從一份預先檢查過的清單裡選,不是現場錄。 錄製器要有衝突偵測才值得存在,而那比看起來大得多。清單刻意排除了 ⌘Space、⌃Space、⌥⌘Space 這幾個 macOS 自己保留的組合。
- 資料庫放在 Application Support,不放 Documents 或任何雲端同步的資料夾。 SQLite 的 write-ahead log 跟檔案層級的同步引擎會安靜地互相弄壞對方,這樣丟掉的筆記本救不回來。要拿走就用匯出:Markdown 依天分組,或帶著已解析標籤的 JSON。
- 完全不連網。 沒有帳號、沒有同步、沒有任何網路請求。
- 不需要 Xcode。 Swift 6 加 Command Line Tools 就能建置:SwiftPM 產出二進位,
build.sh包成.app並做 ad-hoc 簽章。唯一的坑是 Command Line Tools 附的Testing.framework不在 SwiftPM 的搜尋路徑上,swift test會說找不到模組,得用test.sh把路徑指過去。
現在還不行的事
- 沒有連結標題、圖片或附件。schema 跟 migration 已經留好位置。
- 沒有簽章、公證跟自動更新。所以請自己 build——本機建的 app 沒有 quarantine 旗標,Gatekeeper 不會管它;下載來的就會。
- 沒有同步。匯出的存在,就是為了讓這不是一個陷阱。
- UI 沒有自動化測試。上面那三個 bug 全靠手動驅動 app 抓出來;
Scripts/裡有一支 CGEvent 點擊器,因為合成點擊點不到 SwiftUI 的 view。
v1,我每天在用;還沒打包給其他人。
拿去用
https://github.com/Grayidea-bit/Jot(MIT)
git clone git@github.com:Grayidea-bit/Jot.git
cd Jot
./build.sh
cp -R build/Jot.app /Applications/
open /Applications/Jot.app
需要 macOS 14 以上。第一次啟動會開回顧視窗的空狀態,因為這個 app 其他的一切,都藏在一個你還沒看過的快捷鍵跟選單列圖示後面。