在已經有 NixOS 設定的情況下,裝新的電腦。

  1. 用 nano 修改 /etc/nixos/configuration.nix,裝好 vim、wget 跟 git
  2. setup 並啟用 flakes
  3. git clone NixOS 設定檔到 home directory 底下
  4. rename /etc/nixos
  5. 建立 soft link /etc/nixos 到 step 3 clone 的 repo
  6. 複製原本的 hardware-configuration.nix 到對應位置

什麼是 ADR?

  • Architecture Decision Records(ADR,架構決策紀錄)是 ThoughtWorks 推廣的一種輕量文件,用來記錄:「我們為什麼在當時做了這個架構決定」
  • 是決策的歷史紀錄
  • ADR 記的是「選擇」,不是「結果」。

ADR 解決什麼問題?

  • 記錄當下是如何做決策的,供未來參考
  • 避免未來的自己心中冒出:「當初是在想什麼?」的疑問
  • 一個決策一頁,10 分鐘寫完

一份 ADR 的基本結構

  1. Title
    用一句話說決定是什麼
  2. Status
    • Proposed(提案)
    • Accepted
    • Deprecated
    • Superseded(被取代)
  3. Context(背景)
    問題是什麼?限制是什麼?
  4. Decision
    決定怎麼做
  5. Consequences
    好處、壞處、風險、未來影響

Markdown Template

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# System ADR-N Title

## Status

Proposed(提案)/ Accepted / Deprecated / Superseded(被取代)

## Context



## Decision



## Consequences



## Related Resources (optional)



---


什麼時候值得寫 ADR?

有以下情況之一就值得寫:

  • 有取捨
  • 有爭議
  • 未來可能後悔
  • 未來的自己可能會問「為什麼」

Refs

  • PostgreSQL 有 FTS 的功能
  • tsvector 資料 type 來存被處理過、可搜尋的文字內容,通常將原始文字經過拆詞、正規化在加上位置資訊變成可以高效比對的搜尋 index。
  • 做搜尋時就用 tsvector 來搜尋,不是去找原始資料
  • 要產生有中文斷詞的 tsvector 欄位需要額外裝 extension,例如 [zhparser](https://github.com/amutu/zhparser)
  • 用 docker 啟動 postgresql 的話,需要自己 build 含有 zhparser 的 image
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    FROM postgres:17.0-bookworm

    RUN apt-get update && apt-get install -y \
    git \
    wget \
    build-essential \
    postgresql-server-dev-17 \
    && rm -rf /var/lib/apt/lists/*

    RUN wget -q -O - http://www.xunsearch.com/scws/down/scws-1.2.3.tar.bz2 | tar xjf - \
    && cd /scws-1.2.3 \
    && ./configure \
    && make install

    RUN git clone https://github.com/amutu/zhparser.git /zhparser \
    && cd /zhparser \
    && make \
    && make install
  • 安裝 extension
    1
    CREATE EXTENSION IF NOT EXISTS zhparser;
  • 設定中文 text search configuration
    1
    2
    3
    4
    5
    6
    7
    8
    # 建立一個叫 zh 的 text search configuration,並指定 parser 用 zhparser
    CREATE TEXT SEARCH CONFIGURATION zh (PARSER = zhparser);

    # 設定斷出來的詞要用哪個 dictionary 處理
    # n,v,a,i,e,l 代表詞性(part of speech),來自 zhparser 分別表示名詞、動詞、形容詞、成語、嘆詞、習慣用語,這個設定的意思是「這些詞性都要拿來搜尋」
    # simple 是 PostgreSQL 內建的 dictionary,它不做 stemming(不改詞形)、不過濾 stop words、看到什麼詞就存什麼。用 simple 是因為中文不需要像英文字尾變化,我們要的就是「詞」本身
    ALTER TEXT SEARCH CONFIGURATION zh
    ADD MAPPING FOR n,v,a,i,e,l WITH simple;
  • 在要做 search 的 table 加入 tsvector 欄位
    1
    ALTER TABLE documents ADD COLUMN fts tsvector;
  • 更新中英文混合的 tsvector 欄位內容
    1
    2
    3
    4
    5
    6
    7
    8
    9
    UPDATE documents
    SET fts =
    setweight(to_tsvector('zh', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('zh', coalesce(content, '')), 'B') ||
    setweight(to_tsvector('english', coalesce(content, '')), 'B') ||
    -- simple:保險用(防繁中切不好)
    setweight(to_tsvector('simple', coalesce(title, '')), 'C') ||
    setweight(to_tsvector('simple', coalesce(content, '')), 'D');
    • 把 documents 裡的 title + content 分別用中文跟英文斷詞,設定不同權重,合併成 tsvector 存進 fts 這個欄位
    • coalesce(title, '') 表示如果 title 是 NULL 就當成空字串,避免 to_tsvector(NULL) 直接變成 NULL
    • setweight(to_tsvector('zh', coalesce(title, '')), 'A')zh config 切欄位 title 的中文、產生 tsvector、權重設為 A(最高)
    • setweight(to_tsvector('zh', coalesce(content, '')), 'B') 跟上面差在權重是 B(比 A 低)
    • setweight(to_tsvector('english', coalesce(title, '')), 'A') 就是切英文
    • || 是用來合併 tsvector 的
    • 權重會影響搜尋 SQL ORDER BY ts_rank(fts, q) DESC ,權重高的結果會自動排前面
  • 建立 GIN index
    1
    2
    3
    CREATE INDEX documents_fts_idx
    ON documents
    USING GIN (fts);
    • 建個 index 加快搜尋,不然搜尋會慢到死
    • GIN = Generalized Inverted Index
      • 反向索引
      • 不是存一列有什麼文字,而是存「每個詞出現在哪些 rows」
  • 搜尋 query
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    SELECT uuid, title,
    ts_headline(
    'zh',
    content,
    plainto_tsquery('zh', 'Docker 中文搜尋')
    || plainto_tsquery('english', 'Docker 中文搜尋'),
    'MaxWords=100, MinWords=50'
    ) AS snippet
    FROM documents
    WHERE fts @@ (
    plainto_tsquery('zh', 'Docker 中文搜尋')
    || plainto_tsquery('english', 'Docker 中文搜尋')
    )
    ORDER BY ts_rank(
    fts,
    plainto_tsquery('zh', 'Docker 中文搜尋')
    || plainto_tsquery('english', 'Docker 中文搜尋')
    ) DESC
    LIMIT 20
    OFFSET 10;
    • ts_headline 會從 content 內擷取「命中關鍵字附近」的一小段文字
    • fts @@ ( ... ) 是全文搜尋的比對 operator,意思是「這筆文件的索引內容是否符合搜尋條件?」
    • plainto_tsquery('zh', 'Docker 中文搜尋')zh config 把輸入轉成 tsquery
      • 會自動處理空白
    • plainto_tsquery('english', 'Docker 中文搜尋') 同一段輸入用英文規則再解析一次
    • 兩個 plainto_tsquery|| 連接表示 OR
    • ts_rank(fts, tsquery) 是 PostgreSQL 算「相關度分數」,會考量命中幾次、權重跟詞出現的位置。
      • ORDER BY … DESC 排序就能讓相關度高的結果排前面。
      • tsquery 在這裡要再寫一次,因為 WHERE 跟 ORDER BY 是同一層,不能 reuse expression

Ref

OpenWebUI 設定:

在對話把 Heptabase MCP 打開的時候,第一次會 redirect 到 Heptabase 做 authentication,但回到 OpenWebUI 會出現這個 error:

1
OAuth callback failed: invalid_request: 'client_id' and 'client_secret' parameters must not be provided twice

看了下 OpenWebUI 的 code,把 backend/open_webui/utils/oauth.py line 739、742~748 註解起來就能動了~

跑自己編的 OpenWebUI 的方式:

1
bash ./run.sh

這個 script 會 build docker image 並且把 container 跑起來。

目前(2025-12-21)gpt 5.2 model 實驗起來,可以在 Heptabase Journal 加內容、建立新卡片、使用關鍵字提問,但 whiteboard 相關的互動好像不 work。

Open WebUI v0.6.43 可以正常接 Heptabase 的 MCP 了!(2025-12-28 updated)

Ref

去了六個點、找到了四個 cache。一開始是最靠近圓山捷運站的、上次我一眼就看到寶盒但在辦活動、又有工作人員在旁邊就沒拿,這次也有人在旁邊發傳單,暫時先不輕舉妄動XD

接下來就是悠哉的在圓山散步,在昨日世界發現了個蠻棒的平台,平台上有人在拍照,我在附近一直晃來晃去找 cache,最後被蚊子咬得滿頭包……

alt text

接著晃到明日世界,到座標點附近很快就找到啦~這顆非常明顯~經過摩天輪進到自行車道,依著提示也很快找到 Good Old Days

alt text

看地圖在中山橋底下、有個需要攀爬的 cache,不曉得需要爬多高,還是好奇的先去看看了。看一看覺得,嗯,我還是找人一起來在底下幫我掩護(?)好了,我實在有點無法自己那麼顯眼的在那邊爬…… 😅

最後走到福壽宮,雖說困難度是 4,但依照提示的方式找還是蠻快就找到的~

gps

樹德公園是這天最後一個點,走到座標位置看了下提示,四處張望之後心想「一定是這裡!」旁邊有個阿北背對我坐在長椅上,我只好靠近藏寶點、輕手輕腳開手電筒照著照的看半天,繞了一圈又一圈就是沒看到,東看看看西看看,看到阿北都回家了還是沒看到。

照往例,找不到就會翻前面獵友的 log,看著看著覺得一開始認為的藏寶點可能不對。但今天已經找到幾個 cache,有點累了,在想要放棄時,心想「再找最後一次好了!」依照線索看看附近,手電筒照阿照。欸嘿!這裡怎麼有個可疑的小東西咧~輕輕一拉,找到啦!

好吧提示說得沒有錯,是我找錯地方 . _ .

俗話說,獵友的 log 要小心看(俗話並沒有說過這句話)

alt text

用 Home Assistant 跟 n8n 做了一個 Line Pay 自動記帳小工具——收到 Line Pay 付款通之後,會自動記帳到 Firefly 記帳系統。

整體流程

透過 Home Assistant 的 Android app 的 last notification sensor 收到 notification 後打 n8n,再用 n8n parse 訊息內容、打 Firefly api 來紀錄支出。(iOS 不行,因為 iOS 幾乎沒有工具能偵測到「收到 notification」的 event)

Android 收到通知到打 n8n:

flow

n8n workflow:

n8n workflow

webhook 收到 request 後做 decode 跟 parse Line Pay 通知的訊息內容,把 parse 出來的資料拿去打 Firefly API 建立支出的 transaction,最後傳送通知到 telegram。之所以不用 HA 直接打 Firefly API,是因為打 API 不好處理跟顯示 response,單從 HA 介面難以知道有沒有成功。

Android Home Assistant app 開啟 last notification sensor

開啟後可以在 HA 的「設定」=> 「裝置與服務」=> 「實體」找到 entity:Last notification

HA 的 configuration & automation

configuration.yaml 中的 rest_command action 會打 n8n api:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
rest_command:
send_expense_api:
url: "[N8N_WEBHOOK_URL]"
method: POST
headers:
Content-Type: "application/json; charset=utf-8"
Authorization: !secret n8n_token
content_type: "application/json"
payload: >
{
"app": "{{ app_name }}",
"sender": "{{ title }}",
"message": "{{ message | base64_encode }}",
"timestamp": "{{ timestamp }}"
}

HA 會以 UTF-8 編碼後的字串傳送 notification 的內容(例如 LINE\xe9\x8c\xa2\xe5\x8c\x85),這裡有個稍微 tricky 的地方是訊息內容要先經過 base64 encode 再傳送。否則 Line Pay 的通知訊息含有 \n,n8n webhook 接收時似乎會無法正常將訊息內容 parse 為 json,導致 response 422。而且這只能從 HA log 看到 warning log,n8n execution 是連 request 都沒收到,推測是進到 webhook node 前就被回應了。

automations.yaml 的自動化部份:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- id: linepay_auto_record_expense
alias: "LinePay 自動記帳"
trigger:
- platform: state
# last notification 的 entity id
entity_id: sensor.DEVICE_last_notification
condition:
# 只傳送 line 的通知
- condition: template
value_template: >
{{ 'line' in trigger.to_state.attributes['android.appInfo'] }}
action:
- service: rest_command.send_expense_to_n8n_webhook
data:
app_name: "{{ trigger.to_state.attributes['android.appInfo'] }}"
title: "{{ trigger.to_state.attributes['android.title'] }}"
message: "{{ trigger.to_state.attributes['android.text'] }}"
timestamp: "{{ trigger.to_state.attributes.post_time }}"

小結

之所以用 HA app 接通知,沒有別的原因——純粹是本來就有在用 HA,所以要用其他例如 IFTTT 的自動化程式接通知也是可以的~

AWS 台北 region 開了之後,看 EC2 稍微便宜一點點,就想說搬到台北說不定速度也會快一點。沒想到台北用下來 t4g.small 不像東京還有到 2025 年年底的 free tier,只好又搬回去,就這樣搬來又搬去~

假設要從 ap-northeast-1 搬到 ap-east-2,以下是 EC2 instance 搬 region 的操作方式:

  1. stop instance
  2. 由 EC2 instance 產生 AMI
  3. 從 ap-northeast-1 複製 AMI 到 ap-east-2 region
  4. 在 ap-east-2 region 用剛複製的 AMI launch EC2 instance
  5. 搬遷完成確認服務正常後刪除 ap-noetheast-1 region 的 EC2 instance、AMI 跟建立 AMI 時產生的 snapshot

NixOS 上用 Solaar 設定羅技 Master 3S 拇指滾輪來切換前後分頁。

NixOS 上安裝 Solaar

安裝 solaar 並加上 udev rule 讓非 root 也能使用 (ref)

configuration.nix

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
environment.systemPackages = with pkgs; [
solaar
];

services.udev.extraRules = ''
# Allows non-root users to have raw access to Logitech devices.
# Allowing users to write to the device is potentially dangerous
# because they could perform firmware updates.
KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", OPTIONS+="static_node=uinput"

ACTION == "remove", GOTO="solaar_end"
SUBSYSTEM != "hidraw", GOTO="solaar_end"

# USB-connected Logitech receivers and devices
ATTRS{idVendor}=="046d", GOTO="solaar_apply"

# Lenovo nano receiver
ATTRS{idVendor}=="17ef", ATTRS{idProduct}=="6042", GOTO="solaar_apply"

# Bluetooth-connected Logitech devices
KERNELS == "0005:046D:*", GOTO="solaar_apply"

GOTO="solaar_end"

LABEL="solaar_apply"

# Allow any seated user to access the receiver.
# uaccess: modern ACL-enabled udev
TAG+="uaccess"

# Grant members of the "plugdev" group access to receiver (useful for SSH users)
#MODE="0660", GROUP="plugdev"

LABEL="solaar_end"
# vim: ft=udevrules
'';

之後 rebuild

Solaar 設定 MX Master 3S 拇指滾輪

MX Master 3S 設定打開 Thumb Wheel DirectionThumb Wheel Diversion

config

設定 User-defined rules 做換頁:

rule

在 KDE 設定啟動時執行 Solaar

到 KDE 的 System Settings => Autostart 加入 application Solaar,編輯 properties 到 Application tab 在 Arguments 加上 --window=hide 讓 Solaar 啟動的時候不會把視窗也打開 (ref)。

使用情境

  • 使用 Obsidian、安裝 kanban plugin
  • 有多個不同主題的 kanban
  • 每個 kanban 依據 status 分 list,有名稱為 WIP 的 list
  • kanban 的檔名固定為 [主題] Kanban

想達成的目的

在 daily note 或 homepage 顯示所有 kanban 的 WIP list 內容,可以一眼看出目前有哪些正在進行的事項。

迷之音:為什麼不直接用 todo list 就好?

這算是我自己的習慣,todoist 是我每天會開來看有什麼今天得做的事情的 todo list app,拿來放生活瑣事、做完勾掉通常不會回去翻的待辦事項,像是洗衣服、買日常用品等等。todoist 不放偏向「有目標、維持性或比較像 project」的事情,像是自己的 side project、正在閱讀的書籍等等。

嘗試過用 todoist 來管理 side project,但不習慣在 todo list 寫筆記,而且完成勾掉後預設顯示是隱藏、之後不太會想到到 todo list app 翻筆記。後來改用看板管理這類型的事項,發現用得還算順手,因為我自然而然在兩種模式下開啟「待辦清單」,一是有時效性的「我今天得做什麼?」,二是有段空檔時想知道「最近正在進行些什麼?挑個來繼續吧!」,這兩種模式剛好對應到 todoist 跟看板。另外看板可以讓我知道 side project 整體做過些什麼、打算做什麼、正在做什麼,能比較全面的看到整個 project。

Obsidian 使用 dataviewjs query

看板系統我一開始先用 trello,最近用起 obsidian 也就裝了 kanban plugin,因為有換換病跟搬搬病也就把 trello 的東西搬到 obsidian。

我有兩個主要看板,分別是 side project 跟生活中要持續的事,兩個看板都有一個 WIP 清單,表示「正在做」事項。用了幾天想有個統一的地方可以一次看到兩個看板的 WIP 清單內容,於是邊跟 Sonar 聊天邊寫出了下面這段 dataviewjs query:

1
2
3
4
5
6
7
8
9
10
11
12
let pages = dv.pages(); 

for (let page of pages) {
const filePath = page.file.path;
const markdownContent = await dv.io.load(filePath);
const wipSectionRegex = /(?:^|\n)##\s*WIP[^\n]*\n(.*?)(?=\n##\s|\n#\s|$)/gs;
const wipContent = markdownContent.match(wipSectionRegex);

if (wipContent) {
dv.paragraph(wipContent[0].replace(/^## WIP$/gm, `### ${filePath.slice(5, -10)}`));
}
}

就能顯示出多個 kanban 的內容:

須注意的是放這個 script 的 note 不能有 ## WIP,否則整個 obsidian 一開該 note 就會當掉。我懷疑是因為會 infitie loop,但我沒有證據 aka 還懶得深究。解法簡單粗暴:用一般編輯器打開 obsidian 那個 note,把 ## WIP 改掉即可。

這 script 偏 hard code,在不同前提下需要修改才能使用,像是不叫 WIP 的話 regex 要改、用來替換的 heading 3 字串 slice() 位置也要修改。