[{"content":" Single pawn # The King should be in front of his Pawn, with at least one intervening square. Advance the King as far as possible with the safety of the pawn, and never to advance the Pawn until it is essential to its own safety. Other Pawn Endings # ℹ️ Don\u0026#39;t push pawns prematurely don\u0026rsquo;t push the pawn prematurely, which may send it to the opponent\u0026rsquo;s King, and you can\u0026rsquo;t protect it. In this position, if white pushes the f-pawn, the game draws because ... Kf7. White is better to move his King and force the Black king to the corner, with help from g6 push, controlling the f7 square .\n(FEN: 4k3/6p1/4K3/5PP1/8/8/8/8 w - - 1 1 ) Two pawns vs one # In this example, white cannot push the pawn, which results in exchange and draw. He need to first drive the Black king out of the way, before pushing his f pawn.\nEmbedded chessboard from lichess.org\nAnother example of 2v1 in the above study.(see 2v1#2 in the same study) White should first move the King closer to the pawn, before pushing. Otherwise, Black can push and force pawn trades, then Black\u0026rsquo;s King is in a good position to draw.\nThree against two # 💡 Advance pawn with no opposition Whenever there\u0026rsquo;s no reason against it, advance the pawn that has no Pawn opposition it in pawn end games. In this example, push any pawn wins, but push the f pawn follows the principle.\nEmbedded chessboard from lichess.org\nℹ️ Act immediately on the side where you have superior forces Here, White\u0026rsquo;s idea is to first act on the King side, pushing both pawns deep, locking Black\u0026rsquo;s King to defend on King\u0026rsquo;s side. Then, White\u0026rsquo;s King is free to go to Queen side and win there.\nEmbedded chessboard from lichess.org\n","date":"16 January 2021","externalUrl":null,"permalink":"/posts/chess-fundamentals/first-principles/","section":"Posts","summary":"Single pawn # The King should be in front of his Pawn, with at least one intervening square. Advance the King as far as possible with the safety of the pawn, and never to advance the Pawn until it is essential to its own safety. Other Pawn Endings # ℹ️ Don't push pawns prematurely don’t push the pawn prematurely, which may send it to the opponent’s King, and you can’t protect it. In this position, if white pushes the f-pawn, the game draws because ... Kf7. White is better to move his King and force the Black king to the corner, with help from g6 push, controlling the f7 square .\n","title":"1. First Principles: Endings, Middle-game and Openings","type":"posts"},{"content":"Imbalances: the static and dynamic differences.\nList of imbalances # Minor pieces: the interplay between Bishops and Knights Pawn structure: doubled/isolated/backward/passed pawns, islands. Space Material Files and squares: files, ranks and diagonals act as pathway, while squares act as homes. Development: this is a temporary balance. Initiative: also temporary. ⚠️ Don\u0026#39;t look at individual moves! Try to understand the position, before starting calculation on individual moves. Bishop vs Knights # It\u0026rsquo;s the most important imbalance of the game.\nBasic rules of minor pieces # Both worth 3 points. It\u0026rsquo;s up to you to manipulate the position to make your piece more valuable. Bishops are best in open position, where pawns don\u0026rsquo;t block their diagonals. Bishops are strong in end games where both sides have passed pawns: the long range ability to control. \u0026ldquo;Bad\u0026rdquo; bishop is the one having same color with your center pawn. However bad Bishop can still be active. Bishop\u0026rsquo;s weakness is it can only control one color. A pair of bishops is very strong. Knights love closed positions and locked pawns. The ability to jump over other pieces make them valuable. Knights are usually better in the center. Knights needs secure, advanced homes to be effective. (6th rank is the best!) Knights are superior in end game if pawns are on one side. Best way to fight Knights is to remove their advanced support points. Example games # Silman-Gross: Quiet position # In this position, the imbalance is Bishop vs Knight. So, white\u0026rsquo;s plans are:\nLimiting black\u0026rsquo;s Knight\u0026rsquo;s advance points, by b-pawn pushes. Moving his Bishop to a better diagonal(e3). Embedded chessboard from lichess.org\nThe key here is to understand the position, stick to the plan(improving the pieces), and not get distracted.\nℹ️ Tips on Bishop vs Knight If you have Bishop, try limit advanced support points of Knight. If you have Knight, try to create advanced homes for it. Understand the imbalance, then make the position more valuable to your side. Don\u0026rsquo;t focus on individual moves before having a plan. Fisher-Taimanov: Act quick # In this position, the imbalances are: 1. Bishop vs Knight and 2.Pawn majority on Queen side\nBlack is attacking h4. But if we focus on imbalance, we\u0026rsquo;ll see that, Black\u0026rsquo;s Knight has a perfect home at c5, where:\nIt\u0026rsquo;s a hole, and a light square, so White\u0026rsquo;s dark bishop has no way to kick it away. It blocks white\u0026rsquo;s pawns on Queen side(and attacks them), making his pawn majority useless. With the vision of such imbalance, White\u0026rsquo;s priority is to stop the Knight, use his queen side majority. White\u0026rsquo;s idea is to create a passed pawn.(Majority, and Black\u0026rsquo;s pawn is pinned) Sacrificing h4 on King\u0026rsquo;s side is not big deal: white\u0026rsquo;s Queen\u0026rsquo;s side can win him this game.\nALl the imbalances shows white should act on queen side, instead of defending h4:\nKnight vs Bishop: Stop Knight from getting to c5. Knight vs Bishop: White\u0026rsquo;s Bishop is supporting Queen side\u0026rsquo;s pawn push. Pawn majority: White has pawn majority on Queen side, and there\u0026rsquo;s chance to attack. ℹ️ Identify your strength and act quick Find your strength by looking at the imbalance, and use it before it goes away. Sometimes, that means act immediately, even sacrificing on other parts. Also, find your opponent\u0026rsquo;s key plan by checking imbalances, and stop it. Rooks belong behind passed pawns! Embedded chessboard from lichess.org\nThe game between C players # This game has lots of doubious moves, but is a good lesson on imbalance, and how not to play.\nEmbedded chessboard from lichess.org\nChoice: Close position or take away Knight\u0026rsquo;s square # No rule is correct all the time. For example, in a BvN game, a move that takes away Knight\u0026rsquo;s square may also close the position. What do you do?\nChess.com lesson\nCorrect play is NOT to kick the Knight: it closes the position and dimnishes the Bishops. Black should open us the position instead, by f7-f6 to support e6-e5.\nThe game with annotation: We can see that, once the position is open, White\u0026rsquo;s Knight becomes a weak point, requiring defending, while Black\u0026rsquo;s Bishops+Rooks are all activated. Same position, Silman v 1700, with incorrect play(kicking the Knight): The 1700 student is too obsessed with checkmate threats, instead of trying to slowly improve the position.\nℹ️ Take away from 1700 game Always assume your opponent will play the best response. If you have pair of bishops, opening up the position is more important than kicking the Knight. Limiting the Knight when you have a Bishop pair # In the Hort\u0026rsquo;s game, white has pair of bishop. Black\u0026rsquo;s Knight has no chance on the Queenside, and white follows up with g5 and g4 to lock the King side.\nLimiting the Knight\n(FEN: 4n3/1b3k1p/p2p2p1/3Pp3/2P3P1/6PP/2BB4/6K1 w - - 0 1) Which piece to keep? # In this position, black has 3 choices:\n1... Rc5 for a R+N v R+B endgame 1... Nxe4 for a pure Rook endgame 1... Rd4 for a Knight vs Bishop endgame ℹ️ Bishop are better in pawn race endings Embedded chessboard from lichess.org\n","date":"15 January 2021","externalUrl":null,"permalink":"/posts/amateurs-mind/imbalances/","section":"Posts","summary":"Imbalances: the static and dynamic differences.\nList of imbalances # Minor pieces: the interplay between Bishops and Knights Pawn structure: doubled/isolated/backward/passed pawns, islands. Space Material Files and squares: files, ranks and diagonals act as pathway, while squares act as homes. Development: this is a temporary balance. Initiative: also temporary. ⚠️ Don't look at individual moves! Try to understand the position, before starting calculation on individual moves. Bishop vs Knights # It’s the most important imbalance of the game.\n","title":"Imbalances and Bishop vs Knight","type":"posts"},{"content":"Amateurs often have no clue of how to use center/space advantage, instead keen on forcing continuations, and unjustified attacks.\nRules of center # Rule 1. Full pawn center gives control and space. # A full pawn center gives its owner territory and control over key central squares.\nRule 2. Owning a center is responsibility. # Owning a full pawn center is a responsibility. You must make it indestructible, and you will cramp your opponent.\nRule 3. Don\u0026rsquo;t advance center too early. # Don\u0026rsquo;t advance the center too early! Every pawn move weakens some squares.\nIn the following example, advancing e-pawn allows the Knight jump to d5 and f5.\nAdvancing the e-pawn weakens d5 and f5\n(FEN: 8/4n3/8/8/3PPP2/2P3P1/8/8) Rule 4. Attack your opponent\u0026rsquo;s full pawn center. # One of the most common cases of allowing a \u0026ldquo;string\u0026rdquo; center in order to attack it is in the Alekhine\u0026rsquo;s Defense.\nEmbedded chessboard from lichess.org\nRule 5. If center pawn get traded, the open file is good for Rooks to use. # In this example, the open e-file is nice for a Rook.\n1. e4 e6 2. d4 d5 3. exd5 exd5 Open center\n(FEN: rnbqkbnr/ppp2ppp/8/3p4/3P4/8/PPP2PPP/RNBQKBNR w KQkq - 0 4) Rule 6. If the center gets locked, then the play switches to wings. # In this example, it\u0026rsquo;s easy to see that the center is a dead zone, and play should switch the wings.\n1. d4 Nf6 2. c4 c5 3. d5 e5 4. Nc3 d6 5. e4\nLocked center\n(FEN: rnbqkb1r/pp3ppp/3p1n2/2pPp3/2P1P3/2N5/PP3PPP/R1BQKBNR b KQkq - 0 5) Rule 7. With a locked center, play on the wing where your pawns point to. # The pawns point to the area where you have more space, and that\u0026rsquo;s where you want to control.\nIn the diagram below, Black\u0026rsquo;s pawn points to kingside. (c7-d6-e5). While whit\u0026rsquo;s pawns points to both sides.\nℹ️ In general, push the pawn that stands next to your most advanced pawn For black, push \u0026hellip;f7-f5 next to the most advanced e5 pawn.(gaining space and opening a file for the Rook).\nFor white, he should push a pawn to side-byside with his d5 pawn. c4-c5 is good: gaining space on queenside, and prepare to rip open files. Pushing f2-f4 would be bad, after \u0026hellip;exf4, Black is very active, and e-pawn would be backward.\nBlack\u0026#39;s pawn points to kingside, White\u0026#39;s pawn points both sides.\n(FEN: r1bq1rk1/pppnnpbp/3p2p1/3Pp3/2P1P3/2NN4/PP2BPPP/R1BQ1RK1) Rule 8. An open center allows you attack with pieces. A closed center means you must attack with pawns. # Rules of space # When you have more spcae, your pieces are easier to move/coordinate. However, as you advance pawns, some squares becomes weak. It\u0026rsquo;s important to not over-extend too early.\nRule 1. Avoid exchanges when you have more space. # In the example below, black\u0026rsquo;s pieces are cramped and cannot even move. However, if we exchange and remove the pieces and only leave pawns, black would be perfectly fine.\nCramped up with no where to go\n(FEN: rnb2qr1/2p1nkb1/ppPp1p1p/3PpPp1/PP2P1PP/2NN2Q1/4BK1R/2B4R) Rule 2. If you have less space, try to exchange to get room for your pieces. # Rule 3. A spatial plus is permanent, long-term advantage. Don\u0026rsquo;t rush to use it. # Games # 1750-Silman: passive White play not utilizing center/space. # In this game, white(1750) has better space, but he was playing too passive, reacting to imaginary one-move threats with no plan on his own.\nℹ️ Tips from the game Passive planless play leads to loss. Play on the center \u0026gt; wings. Know what the opponent is planning, but don\u0026rsquo;t allow yourself become mesmerized by his ideas. Your plans should prove stronger than theirs. Don\u0026rsquo;t make pointless one-move attacks!!! Always expect your opponent to see your threats. Embedded chessboard from lichess.org\n2000-Silman: Modern defense: bad pawn push # In this game, White has a strong center, but played aimless and careless, opening up the position while giving Black the bishop pair, costing him the game.\nℹ️ Tips Have a plan before your development, so you know where to best put your pieces. Carefully analyze the position, instead of checking random moves. Embedded chessboard from lichess.org\n1600-Silman: Modern defense 2. # ℹ️ Tips Don\u0026rsquo;t think moving (pawns) forward is good. It weakens squares. Games are often won by taking space and restricting opponent. You don\u0026rsquo;t need to attack like crazy to win. The side with space advantage should avoid trading, while side with less space should seek exchanges. Fischer-Gheorghiu, 1970 # White takes control of the center, limiting Black\u0026rsquo;s pieces with no good forward moves, slowly improving position of his own pieces and eventually won.\nEmbedded chessboard from lichess.org\nReti-Carls: King-side space. # Reti won the position by: avoiding unnecessary exchanges, then making use of h-file and open whenever he wants.\nDue to the space advantage, White can double/triple Rook/Queen on h-file, while black can\u0026rsquo;t do the same. Once white has enough pressure, he opens the h-file and breaks through. This example shows how to use space advantage.\nEmbedded chessboard from lichess.org\n1700-Silman: White wrongly closes the h-file and lose. # In the same position, the student closes the h-file on the first move. (engine evaluation jumped from +2.x points to 0).\nℹ️ Space advantage means nothing if position is **closed** Silman-Barkan, 1981: how White gains space everywhere. # In this example, White already has space advantage on Queenside. However, he sees the chance to gain space on center and King side, and did so by some excellent moves. Once he has the space advantage everywhere, he can improve his pieces, move then to strike hard with greater mobility.\n1500-Silman: He failed to stop Black\u0026rsquo;s counterplay at center, allowed black to gain space on King side and storm there, allowing the game becomes a race. 1650-Silman: Identified white should stop Black\u0026rsquo;s plan to break at center. ","date":"17 January 2021","externalUrl":null,"permalink":"/posts/amateurs-mind/center-and-space/","section":"Posts","summary":"Amateurs often have no clue of how to use center/space advantage, instead keen on forcing continuations, and unjustified attacks.\nRules of center # Rule 1. Full pawn center gives control and space. # A full pawn center gives its owner territory and control over key central squares.\n","title":"The Center, Territory and Space","type":"posts"},{"content":"Knowning which pawn structure is \u0026ldquo;weak\u0026rdquo; is not enough: one need to know how to attack them, or making use of the weaknesses.\nIn general, the formular to attack weak pawns(isolated, backward) is to control the square in front of the pawn so it can\u0026rsquo;t advance. (the squares are usually weak since no pawn can defend it). Then pile up on the weakness.\nEvery structure has its pros and cons.\nGeneral Rules # Double pawns # ✅ Positive Extra open files, and increased square control.(especially on center files.) ❌ Negative Reduced flexibility, and vulnerable to attack. (the leading pawn is usually weaker) Example: In the following position, white is happy for a Bishop trade and double the pawn on e-file. Black should play Bb6, welcoming exchange and open a-file as well.\nAfter Be3, white invities Black to trade to open the f-file and double pawns on e-file.\n(FEN: r1bqk2r/ppp2ppp/2np1n2/2b1p3/2B1P3/2NPBN2/PPP2PPP/R2Q1RK1 b) Isolated Pawns # ✅ Positive The creation of such pawn provides half-open file to use. Central isolated pawns provide central control and open file for Rooks. Owner of such pawns should play dynamically. ❌ Negative Cannot be protected by other pawns, vulnerable if on an open file. The formular to attack isolated pawns is to fixate it, by controlling the weak square in front of it. Trade all minor pieces, pile up Rooks and Queens on it to pin the pawn, then attack with a friendly pawn. Example on how to attack isolated pawn with R+Q Black can win the fixed and isolated d-pawn by 1...e5.\n(FEN: 6k1/p2q1p2/1p2p1p1/3r3p/3P3P/P1Q3P1/1P3P2/3R2K1 b) If you have an isolated pawn, try to exchange Rooks! Then the enemey won\u0026rsquo;t have enough forces to win the pawn.\nBackward pawns # ✅ Positive It guards other pawns. The advanced pawn can block enemy pieces and control squares. The backward pawn is not weak if the square in front of it is well-defended. ❌ Negative It\u0026rsquo;s weak if it\u0026rsquo;s sitting on a open file and cannot advance. Attack should control the square directly in front of the pawn, turning the pawn into an immobile target. One way is to exchange its defenders. Example: Square in front of the backward pawn as well as the pawn is well-defended, so the pawn is not a weakness.\nWhite to play and has nothing. d6 is not weak because d5 is controlled well by Black.\n(FEN: 2rr2k1/1bqnbppp/p2p1n2/1p2p3/4P3/1PN2N2/PBPQ1PPP/2RR1BK1 w) Hanging pawns # Haning pawns is a pair of pawns on same rank on c/d files(or e-f files).\nIt can be weak if pawns are immobile and attacked. It can be strong since it provides control of central squares and space.\nThe side has hainging pawns should play dynamically and protect them.\nPassed pawns # A passed pawn is very strong if the owner has play elsewhere: it can be used as endgame insurance.\nThe square in front of the passed pawn is the most important square on the board. Whoever controls the square dominates the game.\nGames # 1800-Silman: Black uses hanging pawns for dynamic potential. # ℹ️ Tips Any kind of weak pawn must first be contained(blocked) before it\u0026rsquo;s attacked Don\u0026rsquo;t just react to opponent\u0026rsquo;s plans. Find an idea and follow it yourself. Embedded chessboard from lichess.org\nTriple pawn gives victory # In this example, White has triple pawn but it gives control of squares, also provides spaces and open files that white can use.\nEmbedded chessboard from lichess.org\nBackward pawn: fight for square in front of it! # In this example, black has a backward pawn on d6, and he should fight for d5(in front of the pawn). However, he traded away his light square bishop, losing an important defender of that square, and allowed white to dominate d5.\nℹ️ Tips The weak square in front of a backward pawn is often a greater problem than the pawn itself. You can play to win a square by trading off its defenders. Embedded chessboard from lichess.org\n1650-Silman: Pawn structure and close/open position choice. # In this example, white has choice to close or open the position, and he closed it incorrectly, just to kick the black knight (to a not-so-bad position). Then with center closed, white should play on the wing, but he didn\u0026rsquo;t.\nℹ️ Tips In closed positions you play where your pawns point to. (less important in open positions) Identify a target, build up your pieces to attack it. Don\u0026rsquo;t just react to your opponent\u0026rsquo;s plan. Don\u0026rsquo;t attack something for no reason. Your opponent always sees it. Only attack if the resulted position is better for you. Embedded chessboard from lichess.org\n","date":"24 January 2021","externalUrl":null,"permalink":"/posts/amateurs-mind/pawns/","section":"Posts","summary":"Knowning which pawn structure is “weak” is not enough: one need to know how to attack them, or making use of the weaknesses.\nIn general, the formular to attack weak pawns(isolated, backward) is to control the square in front of the pawn so it can’t advance. (the squares are usually weak since no pawn can defend it). Then pile up on the weakness.\nEvery structure has its pros and cons.\n","title":"Pawn Structure","type":"posts"},{"content":" Lichess study of this chapter # Embedded chessboard from lichess.org\nThe initiative # White has the move, which means initiative. He tries to first control the center, or obtain some positional advantage, so it\u0026rsquo;s possible for him to keep on harrassing the enemy.\nHe only relinquishes the initiative when he gets for some material advantage in return.\nDiract attack en masse # ℹ️ Attack en masse Attacks must be carried on with sufficient force to gurantee its success. Threatend attack # When there\u0026rsquo;s no change to directly attack the king, we can demonstrate something on one side, drawing opponent force there, then use greater mobility of our force to switch to the other side and break through.\nSee Capablanca vs Blanco, 1913\nCutting pieces off the scene of action # Sometimes we can cut a piece (usually Bishop or Knight) from the scene of actual conflict. See Winter vs Capablanca, 1919.\n","date":"16 January 2021","externalUrl":null,"permalink":"/posts/chess-fundamentals/general-theory/","section":"Posts","summary":"Lichess study of this chapter # Embedded chessboard from lichess.org\nThe initiative # White has the move, which means initiative. He tries to first control the center, or obtain some positional advantage, so it’s possible for him to keep on harrassing the enemy.\nHe only relinquishes the initiative when he gets for some material advantage in return.\n","title":"4. General Theory","type":"posts"},{"content":" Lichess study of this chapter # Embedded chessboard from lichess.org\nThe sudden attack from a different side # Similar to general theory, the idea is to first attacking on one side, then, with greater mobility of the pieces, quickly transfer attack to the other side and break through, before the opponent can respond.\nRooks and pawns endings # The rooks and pawns endings are difficult. Usually, the idea is to forcing opponent to defend a weak point, removing their mobility, and use our own greater mobility to switch side and attack.\n","date":"16 January 2021","externalUrl":null,"permalink":"/posts/chess-fundamentals/end-game-strategy/","section":"Posts","summary":"Lichess study of this chapter # Embedded chessboard from lichess.org\nThe sudden attack from a different side # Similar to general theory, the idea is to first attacking on one side, then, with greater mobility of the pieces, quickly transfer attack to the other side and break through, before the opponent can respond.\n","title":"5. End Game Strategy","type":"posts"},{"content":"","date":"April 6, 2026","externalUrl":null,"permalink":"/zh/tags/fzf-lua/","section":"Tags","summary":"","title":"Fzf-Lua","type":"tags"},{"content":"","date":"April 6, 2026","externalUrl":null,"permalink":"/zh/tags/treesitter/","section":"Tags","summary":"","title":"Treesitter","type":"tags"},{"content":"太久没写代码，重拾 Neovim 时发现不仅肌肉记忆有些生疏，而且因为插件生态的“疯狂内卷”，原来的配置报出了一连串的错误。于是和 AI 结结实实地“折腾”了一番，完成了一次 Neovim 架构的现代化升级。\n这篇博客是我和 AI 聊天复盘的摘要记录，方便以后如果又断档了，可以快速接上思路。\n1. 认知转换：从“极客生存手册”到“现代 IDE” # 在重新梳理快捷键和工作流时，AI 敏锐地指出我之前的旧文档思维还停留在原生 Vim 时代。而我现在的配置，其实已经是一个极其现代化的 IDE，工具其实在主动配合直觉：\n搜索与查找：从敲命令退化到了全屏互动（全面拥抱 fzf-lua，支持代码预览和悬浮窗）。 精准跳转：不再依赖盲按 j/k 或者原生的标记，而是依靠视觉反馈的 Leap (s 瞬移) 和 Flash (S 语法树节点选择)。 多文件管理：用 Grapple (\u0026lt;leader\u0026gt;ma 打标, \u0026lt;leader\u0026gt;n 轮切) 替代了需要心智负担的数字 Buffer 管理，只把核心文件钉在书签里。 大纲与排错：用 Trouble 和 Aerial 侧边栏替代了底部的纯文本 Quickfix 列表。 2. 踩坑纪实：Treesitter 0.10+ 断代大迁移 # 这次遇到了 Neovim 社区目前最大的一个“断层更新”巨坑。因为我使用了 Neovim 0.12.0，核心 API 的变化导致了连环爆炸：\nattempt to call method 'range' (a nil value) 原因：Neovim 核心废弃了旧的 range API，而旧版 (master 分支) 的 nvim-treesitter 还没跟上，导致搜索 Markdown 或打开历史文件时崩溃。 解决：将 nvim-treesitter 及其相关生态插件全面切换到新的 main 分支。 module 'nvim-treesitter.configs' not found 原因：切换到 main 分支后，官方进行了架构重构，直接删除了内置的 configs 模块。 解决：将 textobjects 等子插件从原先的嵌套配置中剥离，使用独立的 setup() 调用。 ; 和 , 重复跳转失效 原因：repeatable_move 模块路径变更，且劫持原生 f / F / t / T 的函数必须使用 _expr 后缀并开启表达式计算。 解决：重写 keymaps.set，使用 builtin_f_expr 搭配 { expr = true } 完美复活了原有的肌肉记忆。 底层的 GLIBC 版本冲突 原因：main 分支不再自带解析器编译器，需要系统安装 tree-sitter CLI。但通过 npm 安装的预编译版本极其激进，要求 GLIBC_2.39，与本地 Ubuntu 22.04 环境（GLIBC 2.35）不兼容而直接崩溃。 解决：绕过包管理器，直接下载 GitHub 上的静态编译原生二进制文件 tree-sitter-linux-x64 放入本地 PATH 中，完美修复。 3. 体验跃升：代码折叠与终极面包屑导航 # 在解决了报错之后，我们还顺手升级了两项极大地提升编码幸福感的功能：\n开启原生 Treesitter 代码折叠 # 彻底抛弃了厚重的第三方折叠插件，直接启用了 Neovim 0.10+ 基于 C 语言底层原生实现的代码折叠。\n配置：vim.o.foldmethod = \u0026quot;expr\u0026quot; 和 vim.o.foldexpr = \u0026quot;v:lua.vim.treesitter.foldexpr()\u0026quot; 体验：使用 za (切换)、zM (全折叠)、zR (全展开) 可以基于语法语义（如类、方法块）极其精准地折叠代码，彻底告别以前靠缩进乱折叠的时代。 引入 Dropbar.nvim：降维打击的面包屑 # 彻底移除了停更且严重挤占底部 Lualine 状态栏横向空间的 nvim-navic，换上了纯键盘党的最爱 dropbar.nvim。\n它在窗口顶部安静且优雅地显示 文件 \u0026gt; 类 \u0026gt; 方法。 按下 \u0026lt;leader\u0026gt;xd，顶部面包屑的各个层级会像 Leap 一样亮起字母提示标签。 敲击对应的字母，就能直接弹出该层级的下拉菜单，支持 j/k 上下移动，更支持直接按 / 模糊搜索变量或函数！配合 \u0026lt;leader\u0026gt;ds（Telescope 文档符号搜索），长文件内空降简直无敌。 4. 插件的“折腾税” # 最后，我们还顺手修复了 render-markdown 插件因为作者规范化 API（把旧的 RenderMarkdownToggle 强行改成了子命令格式的 RenderMarkdown toggle）导致的失效问题。\n在这个疯狂内卷的 Neovim 开源生态里，享受极致性能和现代 IDE 体验的代价，就是偶尔需要支付这样的“折腾税”。不过好在熬过阵痛期后，手里的工具确实变得越来越趁手了。有了这篇摘要和整理好的生存指南，下次再拿起来应该就不慌了！\n","date":"April 6, 2026","externalUrl":null,"permalink":"/zh/posts/coding/neovim-chat-summary-2026/","section":"文章","summary":"太久没写代码，重拾 Neovim 时发现不仅肌肉记忆有些生疏，而且因为插件生态的“疯狂内卷”，原来的配置报出了一连串的错误。于是和 AI 结结实实地“折腾”了一番，完成了一次 Neovim 架构的现代化升级。\n这篇博客是我和 AI 聊天复盘的摘要记录，方便以后如果又断档了，可以快速接上思路。\n1. 认知转换：从“极客生存手册”到“现代 IDE” # 在重新梳理快捷键和工作流时，AI 敏锐地指出我之前的旧文档思维还停留在原生 Vim 时代。而我现在的配置，其实已经是一个极其现代化的 IDE，工具其实在主动配合直觉：\n搜索与查找：从敲命令退化到了全屏互动（全面拥抱 fzf-lua，支持代码预览和悬浮窗）。 精准跳转：不再依赖盲按 j/k 或者原生的标记，而是依靠视觉反馈的 Leap (s 瞬移) 和 Flash (S 语法树节点选择)。 多文件管理：用 Grapple (\u003cleader\u003ema 打标, \u003cleader\u003en 轮切) 替代了需要心智负担的数字 Buffer 管理，只把核心文件钉在书签里。 大纲与排错：用 Trouble 和 Aerial 侧边栏替代了底部的纯文本 Quickfix 列表。 2. 踩坑纪实：Treesitter 0.10+ 断代大迁移 # 这次遇到了 Neovim 社区目前最大的一个“断层更新”巨坑。因为我使用了 Neovim 0.12.0，核心 API 的变化导致了连环爆炸：\n","title":"与 AI 的 Neovim 现代化折腾纪实 (2026版)","type":"posts"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/tags/docker/","section":"Tags","summary":"","title":"Docker","type":"tags"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/tags/ffmpeg/","section":"Tags","summary":"","title":"Ffmpeg","type":"tags"},{"content":"A daily LLM-powered health check flagged that 8 out of 10 cameras had crash counts in the hundreds. The root cause turned out to be two baby monitor cameras, a go2rtc reconnect window, and a vaapi cascade failure — none of which were directly obvious. Here\u0026rsquo;s how we found it and fixed it.\nHow the Issue Was Found # I\u0026rsquo;ve been building a daily home health agent — a scheduled script that queries all home services (Frigate, Home Assistant, Paperless, the arr stack) and passes the data to a local LLM for analysis. The idea: instead of manually checking dashboards, get a morning summary that flags anything unusual.\nThe Frigate check queries /api/stats and looks at per-camera crash counts. One morning, the report came back with this:\n1 2 3 4 5 6 nanit_cam1: 2228 crashes today nanit_cam2: 2228 crashes today backyard: 847 crashes today front_door: 391 crashes today side_a: 203 crashes today ... Without the health check, I wouldn\u0026rsquo;t have noticed — Frigate itself never restarted, the web UI still showed cameras as \u0026ldquo;online,\u0026rdquo; and there were no obvious alerts.\nRoot Cause: A Crash Cascade # Tracing it back, the chain of events every morning at 9am:\nCron stops the nanit container (baby monitors only needed overnight) The nanit RTMP publisher disconnects from go2rtc go2rtc holds the nanit RTSP streams alive for ~37 minutes (its internal reconnect window) At ~09:37, go2rtc gives up and returns 404 Not Found for both nanit streams Frigate\u0026rsquo;s ffmpeg crashes on the 404; the watchdog immediately restarts it → tight ~10s crash loop Both nanit ffmpeg processes crash-looping simultaneously overwhelm the Intel iGPU vaapi context All other cameras — which use the same vaapi device — crash with Failed to sync surface errors The non-nanit cameras self-recover over 5–6 hours as their ffmpeg processes restart one by one Frigate itself had 0 restarts. Everything was happening inside the ffmpeg subprocesses. The crash counts were accurate — 2228 crashes is roughly one every ~13 seconds over 8 hours, which matches a 10-second watchdog cycle.\nThe RTMP Architecture # The nanit baby monitors use a third-party container (ghcr.io/gregory-m/nanit) that authenticates with the Nanit cloud and runs an RTMP server on host port 1935. go2rtc (embedded in Frigate) pulls from it as a client:\n1 2 3 4 5 6 7 8 9 10 Nanit camera hardware │ (proprietary protocol → cloud auth) ▼ nanit container ← RTMP server on :1935 │ ▼ go2rtc ← pulls rtmp://10.0.10.11:1935/local/\u0026lt;uid\u0026gt; │ RTSP ▼ Frigate ffmpeg The fix needed to ensure go2rtc always had a valid RTMP stream to pull from, even when the nanit container was stopped.\nFailed Approaches # go2rtc ffmpeg: fallback source # go2rtc supports multiple sources per stream — primary, then fallback if the primary fails. The plan: add a fallback that generates a black screen via ffmpeg.\n1 2 3 nanit_cam1: - rtmp://10.0.10.11:1935/local/\u0026lt;stream-uid-1\u0026gt; - \u0026#34;ffmpeg:-re -f lavfi -i color=black:size=640x480:rate=1 -c:v libx264 -preset ultrafast {output}\u0026#34; Problem 1: Frigate preprocesses {...} as env var templates. {output} → Invalid substitution found. Fixed with {{output}} (double-braces escape).\nProblem 2: go2rtc\u0026rsquo;s ffmpeg: source doesn\u0026rsquo;t shell-split arguments. The entire string after ffmpeg: is passed as a single token. Result: Error opening input file -re.\nNeither {output} nor #video shorthand variants worked.\nexec:ffmpeg with GO2RTC_ALLOW_ARBITRARY_EXEC=true # go2rtc has an exec: source type for running arbitrary commands. With the env var set and correct syntax in config, the exec: line appeared correctly in /dev/shm/go2rtc.yaml — but the ffmpeg process never actually started. No error, no process in ps aux. Cause unclear; possibly a silent startup failure.\nPlaceholder container on a separate port # Created a nanit-placeholder container with two linuxserver/ffmpeg instances pushing black frames to go2rtc at a different path. Then use go2rtc\u0026rsquo;s fallback: primary on port 1935, backup on port 1936.\nProblem: go2rtc doesn\u0026rsquo;t auto-switch back to the primary source once it has latched onto a fallback. It stays on the backup until the session drops. Forcing a switch back requires restarting go2rtc, which interrupts all other cameras for 2–3 seconds. Not acceptable.\nPlaceholder container pushing to go2rtc RTMP # The fallback container tried to push to rtmp://frigate:1935/... over the frigate_default Docker bridge network. Got Connection refused.\ngo2rtc\u0026rsquo;s RTMP port is only reachable from network_mode: host containers (like the real nanit container), not from bridge network containers. The mechanism: go2rtc\u0026rsquo;s RTMP port is bound to the host interface, not the Docker bridge.\nThe Fix: Same-Port Swap via mediamtx # The key insight: if the placeholder takes over the same port (1935), go2rtc never notices the swap. No fallback logic needed, no reconnect triggers, no disruption to other cameras.\n1 2 3 4 5 6 nanit container ← 19:00–09:00, RTMP server on host :1935 or nanit-placeholder ← 09:00–19:00, mediamtx RTMP server on host :1935 │ ▼ go2rtc ← always pulls rtmp://10.0.10.11:1935/local/\u0026lt;uid\u0026gt; The placeholder uses mediamtx (bluenviron/mediamtx:latest-ffmpeg) with ffmpeg generating black H264 frames:\ndocker-compose.yml:\n1 2 3 4 5 6 7 8 services: nanit-placeholder: image: bluenviron/mediamtx:latest-ffmpeg container_name: nanit-placeholder restart: \u0026#34;no\u0026#34; network_mode: host volumes: - ./mediamtx.yml:/mediamtx.yml mediamtx.yml:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 rtsp: no rtmp: yes rtmpAddress: :1935 paths: \u0026#34;local/\u0026lt;stream-uid-1\u0026gt;\u0026#34;: runOnInit: ffmpeg -re -f lavfi -i color=c=black:s=640x480:r=1 -c:v libx264 -preset ultrafast -profile:v baseline -level 3.0 -g 1 -pix_fmt yuv420p -f flv rtmp://localhost:1935/local/\u0026lt;stream-uid-1\u0026gt; runOnInitRestart: yes \u0026#34;local/\u0026lt;stream-uid-2\u0026gt;\u0026#34;: runOnInit: ffmpeg -re -f lavfi -i color=c=black:s=640x480:r=1 -c:v libx264 -preset ultrafast -profile:v baseline -level 3.0 -g 1 -pix_fmt yuv420p -f flv rtmp://localhost:1935/local/\u0026lt;stream-uid-2\u0026gt; runOnInitRestart: yes A few ffmpeg parameters that matter:\nFlag Reason -profile:v baseline -level 3.0 Maximum H264 compatibility; prevents vaapi decode errors -g 1 Keyframe every frame; ensures clean stream start, no \u0026ldquo;Invalid data\u0026rdquo; at RTSP relay no -tune stillimage This tune produces non-standard H264 structure that breaks RTSP relay after ~40 seconds Cron on yang@debian.lan:\n1 2 3 4 5 0 9 * * * docker compose -f /home/yang/docker/nanit/docker-compose.yml stop \\ \u0026amp;\u0026amp; docker compose -f /home/yang/docker/nanit-placeholder/docker-compose.yml up -d 0 19 * * * docker compose -f /home/yang/docker/nanit-placeholder/docker-compose.yml down \\ \u0026amp;\u0026amp; docker compose -f /home/yang/docker/nanit/docker-compose.yml up -d There\u0026rsquo;s a ~2–3 second gap during the swap while go2rtc reconnects. Not a problem — Frigate recovers immediately with no crash loop because it\u0026rsquo;s only a momentary disconnect, not a sustained 404.\nResult # After the fix, crash counts dropped to zero across all cameras. The non-nanit cameras that had been spending hours recovering from vaapi cascade failures are now stable all day.\nThe go2rtc config stayed minimal — single source per stream, no fallback:\n1 2 3 4 5 6 go2rtc: streams: nanit_cam1: - rtmp://10.0.10.11:1935/local/\u0026lt;stream-uid-1\u0026gt; nanit_cam2: - rtmp://10.0.10.11:1935/local/\u0026lt;stream-uid-2\u0026gt; Takeaway # The health check made this visible. Without per-camera crash counts in the daily summary, this would have continued silently — Frigate showing green while the camera ffmpeg processes churned through thousands of crash/restart cycles every morning, and the non-nanit cameras spending half the day recovering.\nThe fix itself is straightforward once the root cause is clear. The hard part was getting there.\n","date":"3 April 2026","externalUrl":null,"permalink":"/posts/frigate-nanit-crash-fix/","section":"Posts","summary":"A daily LLM-powered health check flagged that 8 out of 10 cameras had crash counts in the hundreds. The root cause turned out to be two baby monitor cameras, a go2rtc reconnect window, and a vaapi cascade failure — none of which were directly obvious. Here’s how we found it and fixed it.\nHow the Issue Was Found # I’ve been building a daily home health agent — a scheduled script that queries all home services (Frigate, Home Assistant, Paperless, the arr stack) and passes the data to a local LLM for analysis. The idea: instead of manually checking dashboards, get a morning summary that flags anything unusual.\n","title":"Fixing a Camera Crash Cascade: How an LLM Health Check Found a Hidden Frigate Bug","type":"posts"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/tags/frigate/","section":"Tags","summary":"","title":"Frigate","type":"tags"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/tags/go2rtc/","section":"Tags","summary":"","title":"Go2rtc","type":"tags"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/categories/homelab/","section":"Categories","summary":"","title":"Homelab","type":"categories"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/tags/nvr/","section":"Tags","summary":"","title":"Nvr","type":"tags"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/tags/self-hosted/","section":"Tags","summary":"","title":"Self-Hosted","type":"tags"},{"content":"","date":"3 April 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"A personal knowledge base — chess studies, coding notes, homelab projects, and more.\n","date":"3 April 2026","externalUrl":null,"permalink":"/","section":"Yang's Notes","summary":"A personal knowledge base — chess studies, coding notes, homelab projects, and more.\n","title":"Yang's Notes","type":"page"},{"content":"When running Claude Code inside WSL, it\u0026rsquo;s easy to miss when it\u0026rsquo;s waiting for your input — especially if you\u0026rsquo;ve switched to another window while it works. This post documents the notification system I set up: Windows toast notifications that fire when Claude stops and when it needs permission, with click-to-focus on the exact WezTerm pane.\nHow It Works # Claude Code has a hooks system in ~/.claude/settings.json. Two events are useful here:\nStop — fires when Claude finishes a response and is waiting for input. The hook payload includes last_assistant_message, cwd, and transcript_path. PermissionRequest — fires when Claude needs approval to run a tool (Bash command, file write, etc.). The payload includes tool_name and tool_input. Both hooks run shell commands asynchronously (async: true) so they never block Claude.\n1 2 3 4 5 6 { \u0026#34;hooks\u0026#34;: { \u0026#34;PermissionRequest\u0026#34;: [{ \u0026#34;hooks\u0026#34;: [{ \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;bash ~/.claude/notify-permission.sh\u0026#34;, \u0026#34;async\u0026#34;: true }] }], \u0026#34;Stop\u0026#34;: [{ \u0026#34;hooks\u0026#34;: [{ \u0026#34;type\u0026#34;: \u0026#34;command\u0026#34;, \u0026#34;command\u0026#34;: \u0026#34;bash ~/.claude/notify-stop.sh\u0026#34;, \u0026#34;async\u0026#34;: true }] }] } } The Scripts # ~/.claude/notify-stop.sh # Extracts the title, message body, and cwd from the hook\u0026rsquo;s stdin JSON, then launches a PowerShell notification:\n1 2 3 4 5 6 7 8 9 10 11 12 #!/bin/bash INPUT=$(cat) TITLE=$(echo \u0026#34;$INPUT\u0026#34; | jq -r \u0026#39;\u0026#34;Claude [\u0026#34; + (.cwd | split(\u0026#34;/\u0026#34;) | last) + \u0026#34;]\u0026#34;\u0026#39;) BODY=$(echo \u0026#34;$INPUT\u0026#34; | jq -r \u0026#39;.last_assistant_message // \u0026#34;\u0026#34; | .[0:200]\u0026#39;) CWD=$(echo \u0026#34;$INPUT\u0026#34; | jq -r \u0026#39;.cwd\u0026#39;) jq -n --arg t \u0026#34;$TITLE\u0026#34; --arg b \u0026#34;$BODY\u0026#34; --arg c \u0026#34;$CWD\u0026#34; \\ \u0026#39;{title:$t,body:$b,cwd:$c}\u0026#39; \u0026gt; /tmp/claude-notif.json JSON_WIN=$(wslpath -w /tmp/claude-notif.json) PS1_WIN=$(wslpath -w ~/.claude/notify-stop.ps1) powershell.exe -ExecutionPolicy Bypass -File \u0026#34;$PS1_WIN\u0026#34; \u0026#34;$JSON_WIN\u0026#34; ~/.claude/notify-permission.sh # Same structure, but builds the body from the tool name and its key argument:\n1 2 3 4 5 6 7 BODY=$(echo \u0026#34;$INPUT\u0026#34; | jq -r \u0026#39; (.tool_name) + \u0026#34;: \u0026#34; + ( if .tool_name == \u0026#34;Bash\u0026#34; then .tool_input.command elif (.tool_name == \u0026#34;Write\u0026#34; or .tool_name == \u0026#34;Edit\u0026#34;) then .tool_input.file_path else (.tool_input | tostring) end ) | .[0:200]\u0026#39;) This gives notifications like Bash: git status or Edit: src/main.go.\n~/.claude/notify-stop.ps1 # The PowerShell script does four things:\nSkip if already focused — check if the target WezTerm window is already the foreground window; if so, exit silently. Show balloon notification — Windows system tray balloon with title and body. On click: find the right pane — query wezterm.exe cli list --format json, filter by cwd, get the pane ID and window title. Activate and raise the window — activate-pane switches to the right tab, then Win32 APIs bring the window to front. The Hard Parts # Environment variables don\u0026rsquo;t cross WSL→PowerShell # You can\u0026rsquo;t set a bash variable and read it with $Env:VAR in PowerShell. The fix: write a JSON file from bash (using jq -n) and read it with ConvertFrom-Json in PowerShell.\nFinding the right WezTerm window # wezterm cli list --format json returns each pane\u0026rsquo;s cwd as a file:// URI (e.g. file://pc/home/huyang/workdir). Matching against the hook\u0026rsquo;s cwd (/home/huyang/workdir) uses EndsWith:\n1 Where-Object { $_.cwd -and $_.cwd.EndsWith($script:cwd) } We do this lookup at click time, not at notification time, to get the freshest window title.\nWindow title encoding mismatch # WezTerm prefixes the active pane title with ⠂ (a braille dot, U+2802) while Claude is working. When piped through PowerShell\u0026rsquo;s ConvertFrom-Json, this arrives as Γ£│ (UTF-8 bytes misread as cp1252). Win32 GetWindowText returns ? for the same character.\nThe fix: strip all leading non-ASCII and non-alphanumeric characters from both strings before comparing:\n1 2 3 4 5 public static string StripPrefix(string s) { int i = 0; while (i \u0026lt; s.Length \u0026amp;\u0026amp; (s[i] \u0026gt; 127 || !char.IsLetterOrDigit(s[i]))) i++; return s.Substring(i); } SetForegroundWindow is blocked by Windows # Windows blocks foreground-window stealing from background processes. keybd_event(Alt) tricks help in some cases, but the reliable fix is AttachThreadInput — attach your thread to the current foreground thread before calling SetForegroundWindow:\n1 2 3 4 5 6 7 var fg = GetForegroundWindow(); uint fgThread; GetWindowThreadProcessId(fg, out fgThread); uint myThread = GetCurrentThreadId(); AttachThreadInput(myThread, fgThread, true); BringWindowToTop(found); SetForegroundWindow(found); AttachThreadInput(myThread, fgThread, false); Result # Claude finishes a response → Windows notification showing the last message and which project it\u0026rsquo;s from Claude needs permission → notification showing the exact command/file Clicking either notification focuses the correct WezTerm window and pane No notification if you\u0026rsquo;re already looking at that window ","date":"24 March 2026","externalUrl":null,"permalink":"/posts/claude-code-wsl-notifications/","section":"Posts","summary":"When running Claude Code inside WSL, it’s easy to miss when it’s waiting for your input — especially if you’ve switched to another window while it works. This post documents the notification system I set up: Windows toast notifications that fire when Claude stops and when it needs permission, with click-to-focus on the exact WezTerm pane.\nHow It Works # Claude Code has a hooks system in ~/.claude/settings.json. Two events are useful here:\n","title":"Claude Code WSL Notifications with WezTerm Focus","type":"posts"},{"content":"","date":"24 March 2026","externalUrl":null,"permalink":"/tags/claude-code/","section":"Tags","summary":"","title":"Claude-Code","type":"tags"},{"content":"","date":"24 March 2026","externalUrl":null,"permalink":"/categories/dev-tools/","section":"Categories","summary":"","title":"Dev Tools","type":"categories"},{"content":"","date":"24 March 2026","externalUrl":null,"permalink":"/tags/hooks/","section":"Tags","summary":"","title":"Hooks","type":"tags"},{"content":"","date":"24 March 2026","externalUrl":null,"permalink":"/tags/powershell/","section":"Tags","summary":"","title":"Powershell","type":"tags"},{"content":"","date":"24 March 2026","externalUrl":null,"permalink":"/tags/wezterm/","section":"Tags","summary":"","title":"Wezterm","type":"tags"},{"content":"","date":"24 March 2026","externalUrl":null,"permalink":"/tags/wsl/","section":"Tags","summary":"","title":"Wsl","type":"tags"},{"content":"","date":"19 March 2026","externalUrl":null,"permalink":"/tags/openvino/","section":"Tags","summary":"","title":"Openvino","type":"tags"},{"content":"","date":"19 March 2026","externalUrl":null,"permalink":"/tags/surveillance/","section":"Tags","summary":"","title":"Surveillance","type":"tags"},{"content":"Replacing Frigate\u0026rsquo;s default SSD MobileNet detector with YOLOv9t (tiny) running on the Intel N97\u0026rsquo;s integrated GPU via OpenVINO. Covers model export, correct Frigate config, and a critical gotcha that causes 100% false positives if you get it wrong.\nSetup # Server: Intel N97 (Debian 13), 8 camera streams Frigate: 0.17, running in Docker Detector: OpenVINO GPU (/dev/dri/renderD128) Previous model: SSD MobileNet v2 (built-in, 300×300) New model: YOLOv9t ONNX (320×320, 8.3 MB) Why YOLOv9t? # The default SSD MobileNet v2 bundled with Frigate\u0026rsquo;s OpenVINO image is fast and lightweight, but accuracy suffers on partially occluded objects and objects at the edges of the frame. YOLOv9t (tiny) offers meaningfully better detection quality with a similar computational footprint — at 320×320 input and ~18ms inference on the N97 iGPU, it handles 8 concurrent camera streams comfortably.\nExporting the Model # YOLOv9t isn\u0026rsquo;t bundled with Frigate, so you export it yourself using the Ultralytics Docker image. Run this on the host:\n1 2 3 4 5 mkdir -p /home/yang/docker/frigate/config/model_cache docker run --rm \\ -v /home/yang/docker/frigate/config/model_cache:/output \\ ultralytics/ultralytics:latest \\ bash -c \u0026#39;cd /output \u0026amp;\u0026amp; yolo export model=yolov9t.pt format=onnx imgsz=320\u0026#39; This downloads the pretrained YOLOv9t weights, exports to ONNX with 320×320 input size, and saves yolov9t.onnx (8.3 MB) into your Frigate config directory — which is mounted at /config inside the container.\nFrigate Config # The /config mount makes the model available at /config/model_cache/yolov9t.onnx. The labelmap for YOLO models (80-class COCO) is already bundled in the Frigate container at /labelmap/coco-80.txt.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 detectors: ov: type: openvino device: GPU model: path: /config/model_cache/yolov9t.onnx model_type: yolo-generic input_tensor: nchw input_pixel_format: bgr input_dtype: float width: 320 height: 320 labelmap_path: /labelmap/coco-80.txt The Critical Gotcha # Do not use model_type: yolov9. It looks like the right value, but it\u0026rsquo;s invalid in Frigate 0.17. The valid options are: dfine, rfdetr, ssd, yolox, yolonas, yolo-generic.\nUsing yolov9 triggers a config validation error and puts Frigate into safe mode, where it silently falls back to a CPU-based TFLite model. That model produces near-100% confidence scores on everything — trees detected as people, empty driveways full of cars. It looks like the new model is wildly broken, but the new model hasn\u0026rsquo;t even loaded.\nThe correct value for any Ultralytics YOLO model (v8, v9, etc.) is yolo-generic.\nOutput Tensor Format # The exported ONNX model outputs shape [1, 84, 2100]:\n84 channels = 4 bbox coordinates (xywh, pixel space) + 80 class scores 2100 = number of anchor points at 320×320 Frigate\u0026rsquo;s yolo-generic handler transposes and post-processes this correctly, including coordinate normalization and NMS. No custom post-processing needed.\nInference Speed # After switching, the OpenVINO detector runs at ~18ms per inference — well within budget for 8 camera streams at 1–5 FPS each.\n1 2 Detector: ov inference_speed: 18.5 ms API Score Display Bug # In Frigate 0.17, the top_score field at the top level of the events API returns null for events detected with custom models. The actual scores are nested under data.score and data.top_score. This is a display bug — detection is working correctly, and the real confidence values (e.g. 0.75–0.88 for parked cars) are there if you look at the full event JSON.\nThresholds # The previous per-camera min_score and threshold overrides were tuned for SSD\u0026rsquo;s confidence distribution. With YOLOv9t being more accurate overall, we reset everything to Frigate\u0026rsquo;s defaults (min_score: 0.5, threshold: 0.7) and kept only the per-camera masks. Adjust from there based on observed behavior.\n","date":"19 March 2026","externalUrl":null,"permalink":"/posts/frigate-yolov9t-openvino/","section":"Posts","summary":"Replacing Frigate’s default SSD MobileNet detector with YOLOv9t (tiny) running on the Intel N97’s integrated GPU via OpenVINO. Covers model export, correct Frigate config, and a critical gotcha that causes 100% false positives if you get it wrong.\nSetup # Server: Intel N97 (Debian 13), 8 camera streams Frigate: 0.17, running in Docker Detector: OpenVINO GPU (/dev/dri/renderD128) Previous model: SSD MobileNet v2 (built-in, 300×300) New model: YOLOv9t ONNX (320×320, 8.3 MB) Why YOLOv9t? # The default SSD MobileNet v2 bundled with Frigate’s OpenVINO image is fast and lightweight, but accuracy suffers on partially occluded objects and objects at the edges of the frame. YOLOv9t (tiny) offers meaningfully better detection quality with a similar computational footprint — at 320×320 input and ~18ms inference on the N97 iGPU, it handles 8 concurrent camera streams comfortably.\n","title":"Switching Frigate to YOLOv9t with OpenVINO on Intel N97","type":"posts"},{"content":"","date":"19 March 2026","externalUrl":null,"permalink":"/tags/yolo/","section":"Tags","summary":"","title":"Yolo","type":"tags"},{"content":"","date":"18 March 2026","externalUrl":null,"permalink":"/tags/debian/","section":"Tags","summary":"","title":"Debian","type":"tags"},{"content":"Setting up Frigate NVR on a dedicated Debian server (Intel N97) to replace a traditional NVR. Covers Docker compose, go2rtc stream config, hardware acceleration, HA integration, push notifications, and zone-based alerting. Traffic between VLANs goes through the main router (UCG Ultra).\nHardware \u0026amp; Context # Server: Intel N97 mini PC (debian.lan, 10.0.10.11), Debian 13 Cameras: 8 Reolink PoE cameras on camera VLAN (10.0.40.0/24), 2 Nanit monitors on IoT VLAN (10.0.20.0/24) Existing NVR: kept running for continuous recording; Frigate handles detection and event clips only Home Assistant: on IoT VLAN (10.0.20.10), MQTT broker already running Storage Design # Frigate recordings go to a dedicated NAS share — no local NVMe waste for surveillance video.\nOn Synology DSM: create shared folder surveillance, NFS export to 10.0.10.11 with Map all users to admin squash. This avoids UID mismatch issues since the Frigate container UID (1001) has no corresponding NAS user.\nMount on debian:\n1 2 sudo mkdir -p /mnt/nas-surveillance sudo mount -t nfs 10.0.10.10:/volume1/surveillance /mnt/nas-surveillance Add to /etc/fstab:\n1 10.0.10.10:/volume1/surveillance /mnt/nas-surveillance nfs vers=3,rw,_netdev,nofail,soft,rsize=65536,wsize=65536,timeo=300 0 0 Docker Compose # 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # /home/yang/docker/frigate/docker-compose.yml services: frigate: container_name: frigate image: ghcr.io/blakeblackshear/frigate:stable restart: unless-stopped shm_size: \u0026#34;256mb\u0026#34; devices: - /dev/dri/renderD128:/dev/dri/renderD128 volumes: - /etc/localtime:/etc/localtime:ro - ./config:/config - /mnt/nas-surveillance/frigate:/media/frigate ports: - \u0026#34;5000:5000\u0026#34; - \u0026#34;8554:8554\u0026#34; - \u0026#34;8555:8555/tcp\u0026#34; - \u0026#34;8555:8555/udp\u0026#34; env_file: docker-compose.env 1 2 3 4 5 # docker-compose.env FRIGATE_RTSP_PASSWORD=... FRIGATE_DOORBELL_PASSWORD=... # doorbell has a different password FRIGATE_MQTT_USER=... FRIGATE_MQTT_PASSWORD=... Credentials are referenced in config.yml as {FRIGATE_VARIABLE_NAME} — Frigate resolves these from container environment variables, keeping passwords out of the config file itself.\nshm_size: 256mb — shared memory for frame buffers. 8 cameras at 5fps detection streams needs roughly 20–30 MB; 256 MB is a safe headroom.\n/dev/dri/renderD128 — Intel iGPU for hardware-accelerated video decoding (VAAPI). No privileged: true needed; passing the device directly is sufficient.\nconfig.yml # MQTT \u0026amp; Hardware # 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 mqtt: enabled: true host: 10.0.20.10 # HA\u0026#39;s IP (IoT VLAN) port: 1883 user: \u0026#34;{FRIGATE_MQTT_USER}\u0026#34; password: \u0026#34;{FRIGATE_MQTT_PASSWORD}\u0026#34; ffmpeg: hwaccel_args: preset-vaapi detectors: ov: type: openvino device: GPU model: path: /openvino-model/ssdlite_mobilenet_v2.xml labelmap_path: /openvino-model/coco_91cl_bkgr.txt width: 300 height: 300 ffmpeg vs detector acceleration: preset-vaapi handles video decoding (H.264/H.265 frames → raw pixels) using the Intel iGPU\u0026rsquo;s media engine. The OpenVINO detector handles inference (running the object detection model) using the iGPU\u0026rsquo;s execution units. Both run on the same Intel N97 iGPU but use different hardware blocks, so they coexist without contention.\nWhy OpenVINO over CPU? The N97 supports OpenVINO natively. OpenVINO inference on GPU is 3–5× faster than CPU, at no hardware cost. The default model (ssdlite_mobilenet_v2) is the same — only the execution backend changes.\nModel path must be at the top level. The model: block is global config, not nested under detectors. Putting path inside detectors.ov.model silently results in a None path and a startup crash.\nMQTT host is HA\u0026rsquo;s IP (10.0.20.10). Traffic routes through the UCG Ultra.\nRecording # 1 2 3 4 5 6 7 8 record: enabled: true alerts: retain: days: 7 detections: retain: days: 7 No retain.days at the top level means no continuous recording — only event clips are saved. The existing NVR handles 24/7 recording; Frigate stores short tagged clips around detection events. These are complementary, not duplicates.\ngo2rtc Streams — Pitfall # Frigate uses go2rtc internally for stream management. Each camera needs two named streams: one for recording (main, high-res) and one for detection (sub-stream, low-res). The key mistake to avoid:\nWrong — both streams bundled under one name:\n1 2 3 4 5 go2rtc: streams: front: - rtsp://admin:pass@10.0.40.x:554/h264Preview_01_main - rtsp://admin:pass@10.0.40.x:554/h264Preview_01_sub go2rtc treats multiple sources as fallbacks — it picks one. Referencing front_sub elsewhere returns 404.\nCorrect — separate named streams:\n1 2 3 4 5 6 go2rtc: streams: front: - \u0026#34;rtsp://admin:{FRIGATE_RTSP_PASSWORD}@10.0.40.x:554/h264Preview_01_main\u0026#34; front_sub: - \u0026#34;rtsp://admin:{FRIGATE_RTSP_PASSWORD}@10.0.40.x:554/h264Preview_01_sub\u0026#34; Then cameras reference them:\n1 2 3 4 5 6 7 8 cameras: front: ffmpeg: inputs: - path: rtsp://127.0.0.1:8554/front_sub roles: [detect] - path: rtsp://127.0.0.1:8554/front roles: [record] Reolink RTSP URLs # Standard Reolink cameras:\nMain: rtsp://admin:pass@IP:554/h264Preview_01_main Sub: rtsp://admin:pass@IP:554/h264Preview_01_sub Newer models (CX810) use H.265:\nrtsp://admin:pass@IP:554/h265Preview_01_main Reolink Duo has two lenses — Preview_01 and Preview_02. In practice only one stream is usefully exposed; treat it as a single camera.\nCamera Config # 1 2 3 4 5 6 7 8 9 10 11 12 13 cameras: front: ffmpeg: inputs: - path: rtsp://127.0.0.1:8554/front_sub roles: [detect] - path: rtsp://127.0.0.1:8554/front roles: [record] detect: enabled: true # must be explicit — defaults to false in 0.17 width: 640 height: 360 # match actual sub-stream resolution fps: 5 detect.enabled: true is required. In Frigate 0.17 it defaults to false. When streams fail at startup (e.g. before go2rtc connects), Frigate auto-disables detection and does not re-enable it — even after streams recover. Always set it explicitly.\nMatch width/height to the actual sub-stream resolution. Frigate resizes frames to this resolution before running detection. A mismatch causes stretching which distorts object shapes and hurts detection accuracy — especially for distant or small subjects. Use ffprobe on the sub-stream to check:\n1 2 3 4 docker exec frigate /usr/lib/ffmpeg/7.0/bin/ffprobe \\ -v error -select_streams v:0 \\ -show_entries stream=width,height -of csv=p=0 \\ rtsp://127.0.0.1:8554/front_sub My camera sub-stream resolutions:\nCamera Sub-stream Notes Reolink standard (front, backyard, side_a/b, cx810) 640×360 16:9 Reolink E1 640×360 PTZ — no zones Reolink doorbell 480×640 Portrait orientation — width/height swapped Reolink Duo (duo_a) 1536×576 Ultra-wide 8:3; detect at 1280×480 Detection Tuning # Default thresholds: min_score: 0.5, threshold: 0.7. The threshold is the running average confidence across the tracking window — intermittent low-confidence detections may not reach it.\nFor wide-angle or distant cameras, lower thresholds and higher detect resolution help:\n1 2 3 4 5 6 7 8 9 10 11 duo_a: detect: enabled: true width: 1280 # default 640 — more pixels for distant subjects height: 720 fps: 5 objects: filters: person: min_score: 0.45 threshold: 0.55 Zones \u0026amp; Alerting # Frigate 0.17 splits events into alerts (high-priority) and detections (low-priority). By default, all person and car detections anywhere in frame are alerts. Zones let you restrict this.\nHow it works # Zone polygons are drawn in the Frigate UI (visual editor) and saved to config.yml automatically A zone\u0026rsquo;s objects list restricts which labels activate it loitering_time requires an object to remain in the zone for N seconds before the zone is considered \u0026ldquo;active\u0026rdquo; required_zones under review.alerts gates which events become alerts The correct placement for required_zones:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 cameras: duo_a: review: alerts: required_zones: - frontyard-parking-zone - person-driveway-loister-zone zones: frontyard-parking-zone: coordinates: \u0026#34;...\u0026#34; loitering_time: 180 # 3 minutes objects: - car person-driveway-loister-zone: coordinates: \u0026#34;...\u0026#34; loitering_time: 30 # 30 seconds objects: - person required_zones goes under cameras.\u0026lt;name\u0026gt;.review.alerts — not under objects.filters (that key doesn\u0026rsquo;t exist and causes a config error).\nResult # Event Outcome Car passing through Detection only, no alert Car parked in zone 3+ min Alert Person walking through Detection only, no alert Person lingering in zone 30+ sec Alert Home Assistant Integration # Frigate Integration # Install via HA: Settings → Integrations → Add → Frigate\nPoint it at Frigate\u0026rsquo;s main LAN IP: http://10.0.10.11:5000\nPush Notifications # HA automation that fires on new person detection with a snapshot:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 alias: Frigate person notification triggers: - trigger: mqtt topic: frigate/events conditions: - condition: template value_template: \u0026gt; {{ trigger.payload_json.type == \u0026#39;new\u0026#39; and trigger.payload_json.after.label == \u0026#39;person\u0026#39; and trigger.payload_json.after.camera in [\u0026#39;doorbell\u0026#39;, \u0026#39;e1\u0026#39;] }} actions: - delay: \u0026#34;00:00:02\u0026#34; - action: notify.mobile_app_yangs_iphone data: title: \u0026#34;{{ trigger.payload_json.after.camera | title }}: Person detected\u0026#34; message: \u0026#34;{{ now().strftime(\u0026#39;%H:%M\u0026#39;) }}\u0026#34; data: image: \u0026#34;http://10.0.10.11:5000/api/events/{{ trigger.payload_json.after.id }}/snapshot.jpg\u0026#34; url: \u0026#34;http://frigate.tailnet:5000/review?camera={{ trigger.payload_json.after.camera }}\u0026#34; push: sound: name: default Why type == 'new' not type == 'end'? Triggering on end gives a complete clip but delays the notification — and never fires if the person stays in frame indefinitely. new fires immediately; the 2-second delay gives Frigate time to generate the first snapshot.\nThe snapshot URL uses Frigate\u0026rsquo;s LAN IP (10.0.10.11); HA fetches it via the main router. The tap URL uses the Tailscale hostname for remote access.\nLessons # go2rtc stream names must be unique per stream quality. Bundling main+sub under one name makes the sub stream unreachable via go2rtc\u0026rsquo;s RTSP server.\ndetect.enabled defaults to false in Frigate 0.17. Set it explicitly in every camera config. If streams fail at startup, Frigate disables detection and doesn\u0026rsquo;t recover automatically.\nrequired_zones belongs under review.alerts, not objects.filters. Putting it under filters causes a config validation error.\nRTSP must be explicitly enabled on some cameras. Newer Reolink models (CX810) have RTSP disabled by default in their web UI. connection refused on port 554 is the symptom.\nH.265 cameras need different RTSP paths. Use h265Preview_01_main instead of h264Preview_01_main for H.265 cameras.\nLive view and detection are independent. The browser gets video via WebRTC directly from go2rtc. Even if detection is broken, live view works. A working live stream does not confirm detection is running.\nHA↔Frigate connectivity goes through the main router. Make sure the UCG Ultra firewall allows IoT VLAN → main LAN traffic on port 5000 and 1935.\n","date":"18 March 2026","externalUrl":null,"permalink":"/posts/frigate-setup/","section":"Posts","summary":"Setting up Frigate NVR on a dedicated Debian server (Intel N97) to replace a traditional NVR. Covers Docker compose, go2rtc stream config, hardware acceleration, HA integration, push notifications, and zone-based alerting. Traffic between VLANs goes through the main router (UCG Ultra).\nHardware \u0026 Context # Server: Intel N97 mini PC (debian.lan, 10.0.10.11), Debian 13 Cameras: 8 Reolink PoE cameras on camera VLAN (10.0.40.0/24), 2 Nanit monitors on IoT VLAN (10.0.20.0/24) Existing NVR: kept running for continuous recording; Frigate handles detection and event clips only Home Assistant: on IoT VLAN (10.0.20.10), MQTT broker already running Storage Design # Frigate recordings go to a dedicated NAS share — no local NVMe waste for surveillance video.\n","title":"Frigate NVR Setup: From Docker to HA Notifications","type":"posts"},{"content":"","date":"18 March 2026","externalUrl":null,"permalink":"/tags/home-assistant/","section":"Tags","summary":"","title":"Home-Assistant","type":"tags"},{"content":"","date":"12 March 2026","externalUrl":null,"permalink":"/tags/ai/","section":"Tags","summary":"","title":"Ai","type":"tags"},{"content":"This post is a complete runbook for integrating AI-powered auto-tagging and classification into paperless-ngx using paperless-ai and a locally-running Ollama instance. The setup uses a local LLM to read document text and automatically populate metadata fields — title, document type, tags, correspondent, date, and custom fields.\nHardware and Architecture # NAS (Synology DS1621+, 10.0.10.10): runs paperless-ngx on port 5656 Desktop PC: Windows with WSL2, Docker Desktop, RTX 4090 Goal: AI auto-tagging/classification using a local LLM, zero cloud dependency The key architecture decision is a pull model: paperless-ai runs in WSL2 Docker, polls the paperless-ngx API for documents tagged ai-pending, processes them with Ollama, and writes metadata back. This is the correct approach for a desktop that is not on 24/7 — the NAS holds the queue and the desktop drains it when available.\n1 2 3 4 5 6 7 paperless-ngx (NAS) ↑ ↓ (REST API) paperless-ai (WSL2 Docker) ↑ ↓ (HTTP) Ollama (Windows native) ↑ RTX 4090 (GPU) Ollama runs natively on Windows (not in WSL) for best GPU access. From inside a Docker container in WSL2, it is reachable via the special hostname host.docker.internal.\nPrerequisites # paperless-ngx running and accessible via API Docker Desktop installed on Windows with WSL2 integration enabled Ollama installed on Windows Step 1 — Set Up Ollama to Listen on All Interfaces # By default, Ollama only listens on 127.0.0.1, making it unreachable from WSL2 Docker containers. You must set a Windows system environment variable.\nOpen System Properties → Advanced → Environment Variables Under System variables, click New Variable name: OLLAMA_HOST Variable value: 0.0.0.0 Click OK, then restart Ollama (kill the tray icon and relaunch) Verify from WSL2:\n1 curl http://$(ip route | awk \u0026#39;/default/ {print $3}\u0026#39;):11434/api/tags From inside a Docker container, Ollama is reachable at host.docker.internal:11434.\nStep 2 — Pull the Right Model # The model must support Ollama structured output (the format / JSON schema parameter). This uses constrained token-level decoding to enforce JSON output — not all models support it.\nCritical: qwen3-vl:8b (the vision-language variant) does not support structured output. When you pass a format schema, Ollama silently returns an empty response string. This failure is silent and hard to diagnose.\nUse qwen3:8b (the base model) instead:\n1 2 # Run in PowerShell on Windows ollama pull qwen3:8b Test structured output works:\n1 2 3 4 5 6 curl http://localhost:11434/api/generate -d \u0026#39;{ \u0026#34;model\u0026#34;: \u0026#34;qwen3:8b\u0026#34;, \u0026#34;format\u0026#34;: {\u0026#34;type\u0026#34;: \u0026#34;object\u0026#34;, \u0026#34;properties\u0026#34;: {\u0026#34;title\u0026#34;: {\u0026#34;type\u0026#34;: \u0026#34;string\u0026#34;}}, \u0026#34;required\u0026#34;: [\u0026#34;title\u0026#34;]}, \u0026#34;prompt\u0026#34;: \u0026#34;Return a JSON object with a title field set to hello world.\u0026#34;, \u0026#34;stream\u0026#34;: false }\u0026#39; The response field should be a non-empty JSON string. If it is \u0026quot;\u0026quot;, the model does not support structured output.\nStep 3 — Create Tags in paperless-ngx # Create two tags in paperless-ngx (Settings → Tags):\nTag Purpose ai-pending Input filter — documents with this tag will be processed by paperless-ai ai-processed Output marker — paperless-ai adds this after successful processing Set the matching algorithm for both tags to None (they are assigned by workflows and paperless-ai, not by auto-matching rules).\nNote the tag IDs from the API (you will not need them explicitly, but useful for verification):\n1 2 curl -s http://10.0.10.10:5656/api/tags/ \\ -H \u0026#34;Authorization: Token \u0026lt;YOUR_TOKEN\u0026gt;\u0026#34; | python3 -m json.tool | grep -A3 \u0026#34;ai-pending\u0026#34; Step 4 — Create Workflows in paperless-ngx # paperless-ai never removes tags — it only adds them. The ai-pending tag must be removed after processing via a workflow. Set up two workflows in paperless-ngx (Settings → Workflows):\nWorkflow 1: \u0026ldquo;AI Processing Queue\u0026rdquo; # Trigger: Document Added Action: Assign tag ai-pending This ensures every newly added document enters the AI processing queue automatically.\nWorkflow 2: \u0026ldquo;Remove ai-pending after AI processed\u0026rdquo; # Trigger: Document Updated — has tag ai-processed Action: Remove tag ai-pending This cleans up the queue marker after paperless-ai finishes. Without this workflow, the tag ai-pending stays on every document and Ollama would reprocess them forever.\nStep 5 — Create the paperless-ai Project Files # Create a directory for the project:\n1 2 mkdir -p ~/repo/paperless-ai cd ~/repo/paperless-ai docker-compose.yml # 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 services: paperless-ai: image: clusterzx/paperless-ai container_name: paperless-ai restart: unless-stopped user: \u0026#34;0:0\u0026#34; env_file: - .env ports: - \u0026#34;3000:3000\u0026#34; volumes: - paperless-ai_data:/app/data volumes: paperless-ai_data: The user: \u0026quot;0:0\u0026quot; directive is essential. paperless-ai writes config and a SQLite database inside /app/data. With Docker Desktop on WSL2, permission mapping issues cause the node user (default) to be unable to create files in the volume — running as root eliminates these problems entirely.\n.env # 1 2 3 4 5 6 7 8 9 10 11 12 PAPERLESS_API_URL=http://10.0.10.10:5656/api PAPERLESS_API_TOKEN=\u0026lt;YOUR_TOKEN\u0026gt; PAPERLESS_USERNAME=yang AI_PROVIDER=ollama OLLAMA_API_URL=http://host.docker.internal:11434 OLLAMA_MODEL=qwen3:8b SCAN_INTERVAL=*/30 * * * * PROCESS_PREDEFINED_DOCUMENTS=yes TAGS=ai-pending ADD_AI_PROCESSED_TAG=yes AI_PROCESSED_TAG_NAME=ai-processed USE_EXISTING_DATA=yes Key settings explained:\nTAGS=ai-pending — paperless-ai only processes documents that have this tag SCAN_INTERVAL=*/30 * * * * — poll paperless-ngx every 30 minutes PROCESS_PREDEFINED_DOCUMENTS=yes — process documents that already exist (not just new ones) ADD_AI_PROCESSED_TAG=yes — add ai-processed tag after processing (required for the cleanup workflow) USE_EXISTING_DATA=yes — do not overwrite AI results with original empty fields Step 6 — Write the System Prompt # paperless-ai sends document text to Ollama with your custom system prompt. The prompt is read from /app/data/PROMPT.md inside the container (or set via the web UI at http://localhost:3000).\nThe prompt should define:\nWhat document types exist (use consistent naming) What topic tags are available What custom fields to fill in Explicit rules for edge cases Key lessons from prompt engineering for this setup:\nSpecify all valid values explicitly — do not let the model invent document types or tags Forbid reserved tags explicitly — if you have status tags managed by humans, list them as absolutely forbidden Require string types for custom fields — paperless-ai expects all custom field values as strings; tell the model: \u0026ldquo;All custom field values must be strings (in quotes) or null. Write \u0026quot;2017.08\u0026quot; not 2017.08\u0026rdquo; Give clear examples for ambiguous cases — e.g., \u0026ldquo;Medical bills → use type 发票收据 + tag #医疗, NOT type 医疗记录\u0026rdquo; Example partial prompt structure:\n1 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 You are a document classification assistant for a personal family archive. ## Document Types (choose exactly one) - Invoice/Receipt (发票收据): bills, invoices, receipts - Tax Document (税务文件): W-2, 1099, tax returns - Immigration Document (移民文件): visas, passports, I-94 ... ## Tags (assign all that apply from this list only) - #insurance (#保险) - #medical (#医疗) - #financial (#财务) ... ## Custom Fields - Amount: numeric amount as string, e.g. \u0026#34;2017.08\u0026#34; or null - Bill Period: statement period end date, YYYY-MM-DD format or null - Expiry Date: expiration date, YYYY-MM-DD format or null - Document Year: year as string, e.g. \u0026#34;2024\u0026#34; or null - Account / Policy Number: account or policy number as string or null ## Rules - All custom field values must be strings (in quotes) or null - Medical bills → type: 发票收据, tag: #医疗 — do NOT use type 医疗记录 - NEVER assign under any circumstances: #待处理 #重要 #归档 — these are reserved human-only status tags and must NEVER appear in your output Step 7 — Start the Container # 1 2 cd ~/repo/paperless-ai docker compose up -d Open the web UI at http://localhost:3000 to verify the configuration. The UI allows reviewing and editing settings, and triggering a manual scan.\nImportant: After using the web UI to save settings, the authoritative configuration is stored in /app/data/.env inside the Docker volume. The docker-compose.env file sets initial environment variables; the UI writes its own config file which takes precedence for some settings. If you edit .env and need the container to pick up changes, use docker compose up -d (not docker compose restart — the restart command does not re-read env files).\nTroubleshooting # Docker daemon not running # 1 Error response from daemon: dial unix /var/run/docker.sock: no such file or directory Start Docker Desktop on Windows. Consider enabling \u0026ldquo;Start on login\u0026rdquo; in Docker Desktop settings.\nOllama not reachable from WSL2 # 1 connect ETIMEDOUT 10.255.255.254:11434 This means OLLAMA_HOST=0.0.0.0 is not set or Ollama was not restarted after setting it. Verify Ollama is listening:\n1 2 # In PowerShell netstat -ano | findstr 11434 The local address should show 0.0.0.0:11434, not 127.0.0.1:11434.\n.env changes not picked up # docker compose restart does not re-read the env_file. Always use:\n1 docker compose up -d This recreates the container with the new environment.\nStructured output returns empty response # 1 No response data from Ollama API The model does not support Ollama\u0026rsquo;s format parameter. Check which model is running:\n1 curl http://localhost:11434/api/tags Switch from any *-vl variant to the base model. Replace qwen3-vl:8b with qwen3:8b.\nCustom field value type error # 1 TypeError: customField.value?.trim is not a function The AI returned a numeric value (e.g., 2017.08) where paperless-ai expected a string (\u0026quot;2017.08\u0026quot;). Add this rule to your system prompt: \u0026ldquo;All custom field values must be strings (in quotes) or null.\u0026rdquo;\n# in custom field name causes env parsing error # 1 SyntaxError: Unterminated string in JSON at position 44 A custom field named something like Account / Policy # contains #, which is treated as a comment character in .env file parsing. Rename the field in paperless-ngx to avoid # — e.g., Account / Policy Number. Use the API to rename:\n1 2 3 4 curl -X PATCH http://10.0.10.10:5656/api/custom_fields/5/ \\ -H \u0026#34;Authorization: Token \u0026lt;YOUR_TOKEN\u0026gt;\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;name\u0026#34;: \u0026#34;Account / Policy Number\u0026#34;}\u0026#39; ai-pending tag not removed after processing # The tag stays on documents after AI processing. This means the cleanup workflow is not set up or not triggering. Verify:\nWorkflow \u0026ldquo;Remove ai-pending after AI processed\u0026rdquo; exists in Settings → Workflows The trigger is: Document Updated, condition: has tag ai-processed The action is: Remove tag ai-pending Remember: paperless-ai source code merges tags and never removes any. Removal requires the workflow.\nsed corrupting unrelated env vars # If you use sed to edit /app/data/.env inside the container, be careful with substring matches. For example:\n1 sed -i \u0026#39;s/CUSTOM_FIELDS=.*/NEW_VALUE/\u0026#39; /app/data/.env This will also match ACTIVATE_CUSTOM_FIELDS= because CUSTOM_FIELDS is a substring. Use Python with an anchored pattern instead:\n1 2 3 4 5 6 python3 -c \u0026#34; import re, sys content = open(\u0026#39;/app/data/.env\u0026#39;).read() content = re.sub(r\u0026#39;^CUSTOM_FIELDS=.*\u0026#39;, \u0026#39;CUSTOM_FIELDS=NEW_VALUE\u0026#39;, content, flags=re.MULTILINE) open(\u0026#39;/app/data/.env\u0026#39;, \u0026#39;w\u0026#39;).write(content) \u0026#34; AI assigning forbidden status tags # The model occasionally assigns tags you have reserved for human use. Strengthen the prohibition in the prompt:\n1 2 3 NEVER assign under any circumstances: #待处理 #重要 #归档 — these are reserved human-only status tags and must NEVER appear in your output under any circumstances, for any document type, regardless of content. How paperless-ai Works Internally # Understanding the internals helps when debugging:\npaperless-ai polls paperless-ngx API for documents with tag ai-pending For each document, it fetches the full text content It sends the text + system prompt to Ollama with format: jsonSchema parameter Ollama uses constrained decoding (enforced at the token-sampling level) to produce valid JSON paperless-ai parses the response: title, document_type, tags, correspondent, document_date, language, custom_fields It calls paperlessService.updateDocument() which merges tags: [...new Set([...currentDoc.tags, ...updates.tags])] — it never removes tags It adds the ai-processed tag to signal completion The paperless-ngx workflow detects ai-processed and removes ai-pending File Permissions Deep Dive # The user: \u0026quot;0:0\u0026quot; setting in docker-compose deserves explanation. paperless-ai\u0026rsquo;s base image runs as the node user. The named Docker volume\u0026rsquo;s root directory is owned by root:root with permissions 755. The node user can read and traverse the directory but cannot create new files in it (the application writes config atomically: create temp file, then rename — both require write permission to the directory). Running as root bypasses all of this.\nAn alternative approach — switching to a bind mount — fails on WSL2/Docker Desktop because the uid/gid mapping between WSL2 and Windows causes SQLite to be unable to create database files.\nDaily Usage # paperless-ai processes documents on startup and then every 30 minutes per SCAN_INTERVAL Monitor and trigger manual scans at http://localhost:3000 The web UI shows processing history and current queue status To bulk-remove tags in paperless-ngx: list view → select one document → \u0026ldquo;Select all X documents\u0026rdquo; appears → Actions → Edit Tags Summary # Component Location Notes paperless-ngx NAS 10.0.10.10:5656 Document storage and API paperless-ai WSL2 Docker, port 3000 Orchestrates AI processing Ollama Windows native, port 11434 LLM inference with GPU Model qwen3:8b Base model, not VL variant Trigger tag ai-pending Added by paperless-ngx workflow Completion tag ai-processed Added by paperless-ai The entire pipeline is self-hosted, GPU-accelerated, and requires no cloud services. Documents are processed locally with full privacy.\nPost-Setup Fixes # \u0026ldquo;Restrict to existing document types\u0026rdquo; setting does nothing (bug) # paperless-ai has a UI toggle to restrict document type assignment to existing types only. As of 2026-03, this is a confirmed bug (#834, #799): getOrCreateDocumentType() in services/paperlessService.js has no restriction guard, while getOrCreateCorrespondent() correctly implements it. A fix was submitted in PR #865 but closed as stale without merging.\nWorkaround: copy paperlessService.js out of the container, patch it, and bind-mount it back.\n1 docker cp paperless-ai:/app/services/paperlessService.js ./paperlessService.js In paperlessService.js, change the function signature and add the guard (mirror of how getOrCreateCorrespondent works):\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 // Before: async getOrCreateDocumentType(name) { // After: async getOrCreateDocumentType(name, options = {}) { const restrictToExistingDocumentTypes = options.restrictToExistingDocumentTypes === true || (options.restrictToExistingDocumentTypes === undefined \u0026amp;\u0026amp; process.env.RESTRICT_TO_EXISTING_DOCUMENT_TYPES === \u0026#39;yes\u0026#39;); // ... after the existingDocType search, before the create call: if (restrictToExistingDocumentTypes) { console.log(`[DEBUG] Document type \u0026#34;${name}\u0026#34; does not exist and restrictions are enabled, returning null`); return null; } Then bind-mount the patched file in docker-compose.yml:\n1 2 3 volumes: - paperless-ai_data:/app/data - ./paperlessService.js:/app/services/paperlessService.js:ro Recreate the container: docker compose up -d. The bind mount survives image updates. Check if the upstream bug gets fixed before removing it.\nAI setting correspondent to the string \u0026ldquo;null\u0026rdquo; # The model outputs \u0026quot;null\u0026quot; as a string when no correspondent is known. Paperless-ai creates a correspondent literally named null. Fix: update the prompt to clarify the correspondent field accepts either a name string or JSON null (not the word \u0026ldquo;null\u0026rdquo;). Also make the JSON template show null unquoted:\n1 2 3 \u0026#34;correspondent\u0026#34;: \u0026#34;Name or null\u0026#34;, ← template hint ... ## Correspondent: JSON null if unclear — never the string \u0026#34;null\u0026#34; Clean up any null correspondents via API:\n1 2 3 4 5 # Find and delete curl -s http://NAS:5656/api/correspondents/ -H \u0026#34;Authorization: Token TOKEN\u0026#34; | \\ python3 -c \u0026#34;import sys,json; [print(c[\u0026#39;id\u0026#39;], c[\u0026#39;name\u0026#39;]) for c in json.load(sys.stdin)[\u0026#39;results\u0026#39;]]\u0026#34; curl -X DELETE http://NAS:5656/api/correspondents/ID/ -H \u0026#34;Authorization: Token TOKEN\u0026#34; Taxonomy design: document types # After running the system for a while, 发票收据 (Invoices \u0026amp; Receipts) became a catch-all for things that aren\u0026rsquo;t actual invoices — hotel booking confirmations, repair estimates, project quotes, and permit passes. The solution was to add two targeted types rather than keep forcing everything into a single bucket.\nAdded two new document types:\nType Use case 行程预订 (Travel \u0026amp; Booking) Hotel/flight confirmations, event tickets, passes, permits (SNO-PARK, etc.) 报价估价 (Quotes \u0026amp; Estimates) Repair estimates, construction quotes, project proposals — anything not yet paid Deleted all zero-doc AI-created English types (Estimate, Invoice, Quote, repair_estimate, Travel Itinerary, Technical Manual, Product Manual, manual — ids 20–27).\nReclassified ~9 documents: hotel confirmations → 行程预订, airline itineraries → 行程预订, vehicle/home repair estimates → 报价估价, work authorization → 合同协议.\nThe updated prompt rules that were added to reduce misclassification:\n1 2 3 - 估价/报价（未付款）→ \u0026#34;报价估价\u0026#34;；已付发票 → \u0026#34;发票收据\u0026#34; - 酒店/机票确认单 → \u0026#34;行程预订\u0026#34;（不是\u0026#34;发票收据\u0026#34;） - 施工授权书、装修合同 → \u0026#34;合同协议\u0026#34;（不是\u0026#34;发票收据\u0026#34;） Updating the system prompt without the web UI # The paperless-ai web UI is the normal way to update the system prompt, but it\u0026rsquo;s inconvenient for iterative editing. The prompt is stored as SYSTEM_PROMPT in the container\u0026rsquo;s /app/data/.env (inside the Docker volume) using \\n-escaped single-line format.\nCaveat: dotenv v16 treats # after a \\n sequence as a comment in unquoted values, so a prompt containing ## Section headers gets silently truncated. The fix is to wrap the value in double quotes when writing.\nWorkflow: keep the prompt in PROMPT.md alongside docker-compose.yml, and run a helper script to push it into the container:\n1 2 3 4 5 6 7 8 9 10 11 12 # update-prompt.sh — run after editing PROMPT.md docker cp PROMPT.md paperless-ai:/tmp/PROMPT.md docker exec paperless-ai node -e \u0026#34; const fs = require(\u0026#39;fs\u0026#39;), dotenv = require(\u0026#39;dotenv\u0026#39;); const prompt = fs.readFileSync(\u0026#39;/tmp/PROMPT.md\u0026#39;, \u0026#39;utf8\u0026#39;).trimEnd(); const escaped = prompt.replace(/\\\\\\\\/g,\u0026#39;\\\\\\\\\\\\\\\\\u0026#39;).replace(/\\\u0026#34;/g,\u0026#39;\\\\\\\\\\\u0026#34;\u0026#39;).replace(/\\n/g,\u0026#39;\\\\\\\\n\u0026#39;); let env = fs.readFileSync(\u0026#39;/app/data/.env\u0026#39;,\u0026#39;utf8\u0026#39;); fs.writeFileSync(\u0026#39;/app/data/.env\u0026#39;, env.replace(/^SYSTEM_PROMPT=.*$/m,\u0026#39;SYSTEM_PROMPT=\\\u0026#34;\u0026#39;+escaped+\u0026#39;\\\u0026#34;\u0026#39;)); const val = dotenv.parse(fs.readFileSync(\u0026#39;/app/data/.env\u0026#39;)).SYSTEM_PROMPT; console.log(\u0026#39;Updated:\u0026#39;, val?.length, \u0026#39;chars\u0026#39;); \u0026#34; docker restart paperless-ai The script lives at ~/repo/paperless-ai/update-prompt.sh and is executable.\n","date":"12 March 2026","externalUrl":null,"permalink":"/posts/paperless-ai-setup/","section":"Posts","summary":"This post is a complete runbook for integrating AI-powered auto-tagging and classification into paperless-ngx using paperless-ai and a locally-running Ollama instance. The setup uses a local LLM to read document text and automatically populate metadata fields — title, document type, tags, correspondent, date, and custom fields.\nHardware and Architecture # NAS (Synology DS1621+, 10.0.10.10): runs paperless-ngx on port 5656 Desktop PC: Windows with WSL2, Docker Desktop, RTX 4090 Goal: AI auto-tagging/classification using a local LLM, zero cloud dependency The key architecture decision is a pull model: paperless-ai runs in WSL2 Docker, polls the paperless-ngx API for documents tagged ai-pending, processes them with Ollama, and writes metadata back. This is the correct approach for a desktop that is not on 24/7 — the NAS holds the queue and the desktop drains it when available.\n","title":"AI-Powered Document Classification with paperless-ai and Ollama","type":"posts"},{"content":"","date":"12 March 2026","externalUrl":null,"permalink":"/tags/llm/","section":"Tags","summary":"","title":"Llm","type":"tags"},{"content":"","date":"12 March 2026","externalUrl":null,"permalink":"/tags/media/","section":"Tags","summary":"","title":"Media","type":"tags"},{"content":"","date":"12 March 2026","externalUrl":null,"permalink":"/tags/nas/","section":"Tags","summary":"","title":"Nas","type":"tags"},{"content":"","date":"12 March 2026","externalUrl":null,"permalink":"/tags/ollama/","section":"Tags","summary":"","title":"Ollama","type":"tags"},{"content":"How to structure local lecture/course videos (without an official TMDB/TVDB entry) in Plex as a proper TV Show with seasons, episode titles, and custom descriptions set via the Plex API.\nThe example here is Jonathan Biss\u0026rsquo;s Exploring Beethoven\u0026rsquo;s Piano Sonatas — a 5-part Coursera course from the Curtis Institute of Music, stored on a Synology NAS.\nThe Problem # Plex\u0026rsquo;s default scrapers rely on TMDB or TVDB. Local lecture videos with no database entry either show up as a mess of unmatched files, or get incorrectly matched to something unrelated.\nThe solution: treat the course as a TV Show, use Plex\u0026rsquo;s Personal Media Shows agent, and push custom metadata via the Plex API.\nStep 1: Reorganize Files into Season/Episode Structure # Plex\u0026rsquo;s TV Show scanner expects files named with SxxExx:\n1 2 3 4 5 6 7 Exploring Beethoven\u0026#39;s Piano Sonatas/ ├── Season 01/ │ ├── Exploring Beethoven\u0026#39;s Piano Sonatas - S01E01 - Lecture 1, Part 1.mp4 │ ├── Exploring Beethoven\u0026#39;s Piano Sonatas - S01E01 - Lecture 1, Part 1.en.srt │ └── ... ├── Season 02/ └── ... Each \u0026ldquo;lecture\u0026rdquo; maps to a Season; each \u0026ldquo;part\u0026rdquo; maps to an Episode. Subtitle files go alongside the video with .en.srt suffix so Plex auto-detects them as English.\nI wrote a small shell script to rename and move everything in one go. It supports --dry-run to preview changes first:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 #!/bin/bash BASE=\u0026#34;/volume1/data/media/courses/Exploring Beethoven\u0026#39;s Piano Sonatas\u0026#34; SHOW=\u0026#34;Exploring Beethoven\u0026#39;s Piano Sonatas\u0026#34; DRY_RUN=false [[ \u0026#34;$1\u0026#34; == \u0026#34;--dry-run\u0026#34; ]] \u0026amp;\u0026amp; DRY_RUN=true for file in \u0026#34;$BASE\u0026#34;/*.mp4; do filename=$(basename \u0026#34;$file\u0026#34;) S=$(echo \u0026#34;$filename\u0026#34; | sed \u0026#39;s/^\\([0-9]*\\) - .*/\\1/\u0026#39;) E=$(echo \u0026#34;$filename\u0026#34; | sed \u0026#39;s/^[0-9]* - \\([0-9]*\\) - .*/\\1/\u0026#39;) TITLE=$(echo \u0026#34;$filename\u0026#34; | sed \u0026#39;s/^[0-9]* - [0-9]* - //\u0026#39; | sed \u0026#39;s/ ([^)]*)\\.[^.]*$//\u0026#39;) SS=$(printf \u0026#34;%02d\u0026#34; \u0026#34;$S\u0026#34;); EE=$(printf \u0026#34;%02d\u0026#34; \u0026#34;$E\u0026#34;) mkdir -p \u0026#34;$BASE/Season $SS\u0026#34; mv \u0026#34;$file\u0026#34; \u0026#34;$BASE/Season $SS/${SHOW} - S${SS}E${EE} - ${TITLE}.mp4\u0026#34; done # Move subtitles from srts/ subfolder for file in \u0026#34;$BASE/srts\u0026#34;/*.srt; do filename=$(basename \u0026#34;$file\u0026#34;) S=$(echo \u0026#34;$filename\u0026#34; | sed \u0026#39;s/^\\([0-9]*\\) - .*/\\1/\u0026#39;) E=$(echo \u0026#34;$filename\u0026#34; | sed \u0026#39;s/^[0-9]* - \\([0-9]*\\) - .*/\\1/\u0026#39;) TITLE=$(echo \u0026#34;$filename\u0026#34; | sed \u0026#39;s/^[0-9]* - [0-9]* - //\u0026#39; | sed \u0026#39;s/ ([^)]*)\\.[^.]*$//\u0026#39;) SS=$(printf \u0026#34;%02d\u0026#34; \u0026#34;$S\u0026#34;); EE=$(printf \u0026#34;%02d\u0026#34; \u0026#34;$E\u0026#34;) mv \u0026#34;$file\u0026#34; \u0026#34;$BASE/Season $SS/${SHOW} - S${SS}E${EE} - ${TITLE}.en.srt\u0026#34; done Step 2: Configure the Plex Library # In Plex, add a new library:\nType: TV Shows Folder: point to the show\u0026rsquo;s root folder (or a courses/ parent) Advanced → Agent: Personal Media Shows Advanced: enable \u0026ldquo;Prefer local metadata\u0026rdquo; Scan the library. Plex will pick up all seasons and episodes automatically from the file names.\nStep 3: Set Descriptions via the Plex API # Personal Media Shows doesn\u0026rsquo;t pull descriptions from anywhere. You can edit them manually in the Plex UI, or push them programmatically via the API — much better when you have multiple seasons.\nFinding your Plex token # Open Plex Web → play any item → \u0026ldquo;\u0026hellip;\u0026rdquo; → \u0026ldquo;Get Info\u0026rdquo; → \u0026ldquo;View XML\u0026rdquo;. The URL contains X-Plex-Token=XXXXX.\nKey API calls # 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 import urllib.request, urllib.parse, json BASE = \u0026#34;http://plex.nas:32400\u0026#34; TOKEN = \u0026#34;your-token-here\u0026#34; def plex_get(path, params=None): url = BASE + path if params: url += \u0026#34;?\u0026#34; + urllib.parse.urlencode(params) req = urllib.request.Request(url) req.add_header(\u0026#34;X-Plex-Token\u0026#34;, TOKEN) req.add_header(\u0026#34;Accept\u0026#34;, \u0026#34;application/json\u0026#34;) with urllib.request.urlopen(req) as r: return json.loads(r.read()) # List libraries sections = plex_get(\u0026#34;/library/sections\u0026#34;) # List shows in a library (key=7 in my case) shows = plex_get(\u0026#34;/library/sections/7/all\u0026#34;) # Update show summary (type=2) or season summary (type=3) def set_summary(section_key, rating_key, item_type, summary): params = { \u0026#34;type\u0026#34;: item_type, \u0026#34;id\u0026#34;: rating_key, \u0026#34;summary.value\u0026#34;: summary, \u0026#34;summary.locked\u0026#34;: 1, } url = f\u0026#34;{BASE}/library/sections/{section_key}/all?\u0026#34; + urllib.parse.urlencode(params) req = urllib.request.Request(url, method=\u0026#34;PUT\u0026#34;) req.add_header(\u0026#34;X-Plex-Token\u0026#34;, TOKEN) urllib.request.urlopen(req) summary.locked=1 prevents Plex from overwriting your text on the next metadata refresh.\nFull script # The complete script discovers the show and all seasons automatically, then pushes pre-written descriptions for each:\n1 2 3 4 5 6 7 section_key, show_key = find_show(base_url, token, SHOW_TITLE) seasons = get_seasons(base_url, token, show_key) set_summary(section_key, show_key, 2, SHOW_SUMMARY) # show for season in seasons: idx = season[\u0026#34;index\u0026#34;] set_summary(section_key, season[\u0026#34;ratingKey\u0026#34;], 3, SEASON_SUMMARIES[idx]) Notes # If Plex auto-matches to a wrong show, either use \u0026ldquo;Fix Incorrect Match → None\u0026rdquo; in the UI, or switch the library agent to Personal Media Shows and re-scan. The show\u0026rsquo;s display title in Plex may differ from the folder name depending on what metadata was previously matched. Discover the actual title via the API (/library/sections/{key}/all) before searching. Poster artwork: drop a poster.jpg in the show root folder and Plex will pick it up automatically. ","date":"12 March 2026","externalUrl":null,"permalink":"/posts/plex-local-media-metadata/","section":"Posts","summary":"How to structure local lecture/course videos (without an official TMDB/TVDB entry) in Plex as a proper TV Show with seasons, episode titles, and custom descriptions set via the Plex API.\nThe example here is Jonathan Biss’s Exploring Beethoven’s Piano Sonatas — a 5-part Coursera course from the Curtis Institute of Music, stored on a Synology NAS.\nThe Problem # Plex’s default scrapers rely on TMDB or TVDB. Local lecture videos with no database entry either show up as a mess of unmatched files, or get incorrectly matched to something unrelated.\n","title":"Organizing Local Lecture Videos in Plex with Proper Metadata","type":"posts"},{"content":"","date":"12 March 2026","externalUrl":null,"permalink":"/tags/paperless/","section":"Tags","summary":"","title":"Paperless","type":"tags"},{"content":"","date":"12 March 2026","externalUrl":null,"permalink":"/tags/plex/","section":"Tags","summary":"","title":"Plex","type":"tags"},{"content":"","date":"12 March 2026","externalUrl":null,"permalink":"/tags/synology/","section":"Tags","summary":"","title":"Synology","type":"tags"},{"content":"","date":"11 March 2026","externalUrl":null,"permalink":"/tags/airprint/","section":"Tags","summary":"","title":"Airprint","type":"tags"},{"content":"Runbook for setting up AirPrint on a Synology NAS so iOS/macOS devices can print to a USB or network printer over the local network. Uses a Docker CUPS container and Synology\u0026rsquo;s built-in avahi (mDNS) daemon for service discovery.\nArchitecture # 1 2 3 4 5 6 7 8 9 10 11 iPhone │ mDNS discovery (_ipp._tcp) ▼ Synology avahi-daemon (eth4, port 5353) │ reads service files from /etc/avahi/services/ │ CUPS Docker container (host network, port 631) │ generates /etc/avahi/services/AirPrint-*.service │ proxies print jobs to printer ▼ Printer (e.g. socket://10.0.20.50:9100) Key design decisions:\nContainer uses network_mode: host — required for mDNS broadcast to work Container mounts /etc/avahi/services directly so Synology\u0026rsquo;s avahi picks up its service files Synology\u0026rsquo;s own CUPS must be disabled to free port 631 Prerequisites # Synology DSM with Docker (Container Manager) installed SSH access to NAS Printer reachable from NAS (USB or network socket) Disable Synology\u0026rsquo;s built-in CUPS print server (via DSM package or synoservicectl) Setup # 1. Create directory structure # 1 mkdir -p /volume1/docker/cups/config 2. docker-compose.yaml # Create /volume1/docker/cups/docker-compose.yaml:\n1 2 3 4 5 6 7 8 9 10 11 12 13 version: \u0026#39;3\u0026#39; services: cups-airprint: image: tigerj/cups-airprint:latest container_name: cups-airprint network_mode: \u0026#34;host\u0026#34; environment: - CUPSADMIN=admin - CUPSPASSWORD=yourpassword volumes: - /volume1/docker/cups/config:/config - /etc/avahi/services:/services # let container manage avahi service files restart: unless-stopped 3. Start the container # 1 2 cd /volume1/docker/cups docker-compose up -d 4. Add your printer via CUPS web UI # Open http://\u0026lt;nas-ip\u0026gt;:631 in a browser, log in with the admin credentials, and add your printer:\nAdministration → Add Printer For a network printer: socket://10.0.x.x:9100 Set Shared: Yes Choose the appropriate PPD/driver (Gutenprint works well for most Brother/HP printers) The container\u0026rsquo;s printer-update.sh watches for CUPS printer changes and automatically regenerates the avahi service file in /etc/avahi/services/.\n5. Verify mDNS broadcast # 1 2 avahi-browse -a -t | grep -i airprint # should show: AirPrint \u0026lt;PrinterName\u0026gt; @ Synology _ipp._tcp local 6. Verify CUPS is accessible # 1 2 curl http://\u0026lt;nas-ip\u0026gt;:631/printers/ # should list your printer as Idle Debugging Notes # Container uses wrong volume mount after yaml edit # If you edit docker-compose.yaml to change the /services mount, you must recreate (not just restart) the container for it to take effect:\n1 docker-compose down \u0026amp;\u0026amp; docker-compose up -d Verify the active mounts:\n1 2 docker inspect cups-airprint --format \u0026#39;{{json .Mounts}}\u0026#39; # Source for /services destination should be /etc/avahi/services Printer not showing on iOS # Check in order:\navahi-browse -a -t | grep ipp — is the service being advertised? curl http://nas-ip:631/printers/ — is CUPS serving the printer? Firewall — DSM firewall may block port 631 from device subnets docker inspect cups-airprint — confirm network_mode is host Force iOS to re-scan: toggle Wi-Fi off/on, or open an app\u0026rsquo;s print dialog avahi only broadcasts on one interface # Check /etc/avahi/avahi-daemon.conf — Synology locks avahi to allow-interfaces=eth4 (the main LAN port). This is correct for a single-NIC setup. If your LAN is on a different interface, update that line (but DSM may reset it on upgrade).\nPort 631 already in use # Synology\u0026rsquo;s CUPS may still be running. Stop it:\n1 2 synoservicectl --stop cups synoservicectl --stop cups-lpd # if present My Setup # Item Value NAS Synology DS1621+ NAS IP 10.0.10.10 Printer Brother HL-2270DW Printer IP socket://10.0.20.50:9100 Driver Brother HL-2250DN - CUPS+Gutenprint v5.2.11 Docker image tigerj/cups-airprint:latest avahi interface eth4 Notes # The URF=none TXT record in the avahi service file is expected for older printers using traditional CUPS drivers — iOS will still discover and use the printer The avahi service file is auto-regenerated on container start and on any CUPS printer state change, so no manual editing is needed docker-compose.yaml is the source of truth — always recreate (not restart) after volume changes ","date":"11 March 2026","externalUrl":null,"permalink":"/posts/airprint-nas-setup/","section":"Posts","summary":"Runbook for setting up AirPrint on a Synology NAS so iOS/macOS devices can print to a USB or network printer over the local network. Uses a Docker CUPS container and Synology’s built-in avahi (mDNS) daemon for service discovery.\nArchitecture # 1 2 3 4 5 6 7 8 9 10 11 iPhone │ mDNS discovery (_ipp._tcp) ▼ Synology avahi-daemon (eth4, port 5353) │ reads service files from /etc/avahi/services/ │ CUPS Docker container (host network, port 631) │ generates /etc/avahi/services/AirPrint-*.service │ proxies print jobs to printer ▼ Printer (e.g. socket://10.0.20.50:9100) Key design decisions:\n","title":"AirPrint on Synology NAS via CUPS Docker","type":"posts"},{"content":"","date":"11 March 2026","externalUrl":null,"permalink":"/tags/cups/","section":"Tags","summary":"","title":"Cups","type":"tags"},{"content":"","date":"11 March 2026","externalUrl":null,"permalink":"/tags/document-management/","section":"Tags","summary":"","title":"Document-Management","type":"tags"},{"content":"","date":"11 March 2026","externalUrl":null,"permalink":"/tags/google-drive/","section":"Tags","summary":"","title":"Google-Drive","type":"tags"},{"content":"Runbook and design journal for migrating ~400 personal documents from a folder-based Google Drive system into Paperless-ngx on a Synology NAS. Covers taxonomy design, bulk import from Google Takeout, ML classifier setup, and ongoing intake workflow.\nProblem Statement # For years my \u0026ldquo;document management\u0026rdquo; was a manually maintained folder tree on Google Drive:\n1 2 3 4 5 6 7 8 9 10 10 - 文书材料/ 10 - 证件材料/身份证件/ 30 - 移民文档/ 30 - Tax Filing/ 40 - Finance/ 50 - 车辆注册/ 60 - 住房买房/ 80 - Medical/ 20 - 家装住房信息/ 80 - 旅行计划/ This worked well enough for filing but poorly for retrieval. Finding \u0026ldquo;what insurance forms did I have in 2022?\u0026rdquo; meant navigating six folders and guessing what I named things. Paperless-ngx offers full-text search, OCR, and an ML classifier that learns from your own labeling — a meaningfully better system for a document archive that spans immigration paperwork, tax filings, mortgage docs, and medical records across 10+ years.\nArchitecture # 1 2 3 4 5 6 7 8 9 Google Takeout .zip │ migrate.py (classify + upload) ▼ Paperless-ngx (Docker, 10.0.10.10:5656) │ REST API POST /api/documents/post_document/ │ OCR + full-text index │ ML classifier (re-trains on labeled corpus) ▼ Synology NAS storage (/volume1/docker/paperless/) The NAS runs Paperless-ngx in Docker via Container Manager. All storage (documents, database, Redis) is mounted to /volume1/docker/paperless/.\nTaxonomy Design # Getting the taxonomy right before bulk import is important — it\u0026rsquo;s the training signal for the ML classifier. Wrong labels in 400 documents teach the wrong thing.\nDocument Types # The goal was types that are mutually exclusive and exhaustive for the documents I actually have. Chinese names throughout — the ML classifier learns from your labels regardless of language.\nID Name What goes here 1 发票收据 Invoices, receipts, paid orders (completed transactions only) 3 操作手册 Product manuals, user guides, assembly instructions 4 活动通告 Event invitations, announcements (non-booking) 5 参考资料 Reference material, price lists, brochures 6 设备信息 Equipment specs, device records, warranties 7 日程课表 Recurring schedules, class calendars, timetables 8 金融账单 Bank/investment/brokerage statements 9 税务文件 Tax returns, W-2, 1099, 1098, HSA 10 身份证件 Passports, driver\u0026rsquo;s license, ID cards 11 合同协议 Contracts, agreements, leases, work authorizations 12 医疗记录 Medical records, prescriptions, lab reports 13 证明证书 Certificates, proof letters, notarizations 14 移民文件 I-797, I-94, I-20, EAD, green card 15 签证申请 Visa applications (US, China, Canada, etc.) 16 工资单 Pay stubs 17 房产文件 Mortgage, permits, property tax, HOA 18 车辆文件 Car leases, DMV, registration 28 行程预订 Hotel/flight booking confirmations, event tickets, passes, permits (SNO-PARK, etc.) 29 报价估价 Repair estimates, construction quotes, project proposals (pre-payment) Key design decisions:\nNo English names needed — Paperless ML learns from whatever you use 发票收据 = paid transactions only — Hotel confirmations go to 行程预订; repair estimates go to 报价估价; only completed-payment docs go here 行程预订 vs 发票收据 — A hotel confirmation is a booking, not a receipt; the receipt comes when you check out. Tickets and passes live here too 报价估价 vs 发票收据 — Unpaid estimates and quotes are pre-purchase; once paid and invoiced they become 发票收据 设备信息 ≠ 参考资料 — Equipment records (serial numbers, warranties) are structurally different from reference material (price lists, brochures) 活动通告 ≠ 日程课表 — Event notices are one-time announcements; schedules are recurring reference docs 金融账单 merges bank + investment — Both are periodic statements; the correspondent (HSBC vs Vanguard) distinguishes them if needed 税务文件 covers all tax docs — No need to split W-2 vs 1099 vs return at the type level; tags and correspondents carry that nuance 移民文件 vs 签证申请 — Maintained status documents (I-797, EAD) vs active application packages are genuinely different workflows 医疗账单 → 发票收据 + #医疗 tag — Medical bills are receipts; the tag provides the \u0026ldquo;medical\u0026rdquo; dimension without a redundant type Tags # Tags handle cross-cutting dimensions that don\u0026rsquo;t belong in doc types:\nTag Purpose #保险 Insurance-related #医疗 Medical topic #教育 Education #财务 Finance topic #房产 Real estate #旅行 Travel #车辆 Vehicle #移民 Immigration topic #签证 Visa topic #税务 Tax topic #待处理 Inbox / needs review #重要 Important, time-sensitive #归档 Archived, no action needed Year tags were considered and rejected. Initial plan included #2016 through #2026 on every document. After reflection: year tags add noise to the ML training signal, and a \u0026ldquo;Document Year\u0026rdquo; custom field covers the use case more cleanly (filterable, sortable, not cluttering the tag cloud).\nCustom Fields # ID Name Type Usage 1 Amount float Invoice/bill amounts 2 Bill Period date Statement period end date 3 到期日期 date Expiry date (documents, visas) 4 Document Year integer Tax year, document year for historical docs 5 保单/账号 string Policy number, account number Correspondents # Set up for entities that appear frequently enough to be useful as filters:\nID Name 4 Total Vision Campbell 5 IRS 6 California FTB 7 Google LLC 8 USCIS 9 US Dept of State 10 Vanguard 11 HSBC 12 County of Santa Clara Pre-Import: Disable ML Matching # Before bulk importing 400 documents, disable Auto/ML matching on all document types, tags, and correspondents. If auto-matching is active during import, Paperless may attempt to re-classify documents using a half-trained model and overwrite your carefully assigned metadata.\n1 2 3 4 5 6 7 8 9 10 11 12 # Disable matching on all document types curl -s http://10.0.10.10:5656/api/document_types/?page_size=50 \\ -H \u0026#34;Authorization: Token YOUR_TOKEN\u0026#34; | jq \u0026#39;.results[] | .id\u0026#39; | \\ while read id; do curl -s -X PATCH http://10.0.10.10:5656/api/document_types/$id/ \\ -H \u0026#34;Authorization: Token YOUR_TOKEN\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;matching_algorithm\u0026#34;: 0}\u0026#39; done # Same for tags and correspondents # matching_algorithm: 0=None, 1=Any, 2=All, 3=Literal, 4=Regex, 6=Auto/ML Re-enable after import with \u0026quot;matching_algorithm\u0026quot;: 6 for types, tags, and correspondents where you want ML suggestions.\nException: #待处理 should stay at 0 (None) permanently. It\u0026rsquo;s a status tag applied by workflow, not a content category — the ML has no business guessing it.\nMigration Script # migrate.py handles classification and upload in one pass. It reads directly from the Google Takeout .zip without extracting everything first.\nClassification Logic # Each file is classified by its folder path using a priority-ordered chain of if checks:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 def classify(rel_path: str) -\u0026gt; dict: p = rel_path filename = Path(p).name year = extract_year(Path(p).parts) # from any path component corr = corr_from_filename(filename) # keyword match on filename if \u0026#34;10 - 身份证件\u0026#34; in p: return result(DOCTYPE[\u0026#34;身份证件\u0026#34;], [TAG[\u0026#34;移民\u0026#34;]]) if \u0026#34;30 - 移民文档\u0026#34; in p: return result(DOCTYPE[\u0026#34;移民文件\u0026#34;], [TAG[\u0026#34;移民\u0026#34;]]) if \u0026#34;30 - Tax Filing\u0026#34; in p: tax_tags = [TAG[\u0026#34;税务\u0026#34;]] if re.search(r\u0026#39;w[\\-\\s]?2\\b\u0026#39;, fl): return result(DOCTYPE[\u0026#34;税务文件\u0026#34;], tax_tags, corr or CORR[\u0026#34;Google LLC\u0026#34;]) if re.search(r\u0026#39;(f?1099|f?1098|f?5498|1095)\u0026#39;, fl): return result(DOCTYPE[\u0026#34;税务文件\u0026#34;], tax_tags, corr) return result(DOCTYPE[\u0026#34;税务文件\u0026#34;], tax_tags) # ... more rules ... return result(None, [TAG[\u0026#34;待处理\u0026#34;]]) # fallback: inbox Year is extracted from any path component matching \\b(20[12]\\d)\\b — so 30 - Tax Filing/2022/W2.pdf gets year=2022 automatically.\nCorrespondent is inferred from filename keywords first (google, hsbc, vanguard, irs, ftb), then overridden by folder-specific rules.\nUpload # 1 2 3 4 5 6 7 resp = requests.post( f\u0026#34;{API_BASE}/documents/post_document/\u0026#34;, headers={\u0026#34;Authorization\u0026#34;: f\u0026#34;Token {API_TOKEN}\u0026#34;}, files={\u0026#34;document\u0026#34;: (filename, data)}, data=form_data, # list of (key, value) tuples for repeated fields timeout=120, ) Tags must be sent as repeated form fields (not a JSON array):\n1 2 for tag_id in meta[\u0026#34;tags\u0026#34;]: form_data.append((\u0026#34;tags\u0026#34;, tag_id)) Custom fields use JSON-encoded dict with string keys:\n1 form_data.append((\u0026#34;custom_fields\u0026#34;, json.dumps({str(YEAR_FIELD_ID): year}))) File Type Filtering # Skip non-document files that made it into the Takeout:\n1 2 3 4 5 6 SKIP_EXTENSIONS = { \u0026#34;.html\u0026#34;, \u0026#34;.csv\u0026#34;, \u0026#34;.qfx\u0026#34;, \u0026#34;.gsheet\u0026#34;, \u0026#34;.gdoc\u0026#34;, \u0026#34;.gslides\u0026#34;, \u0026#34;.gdraw\u0026#34;, \u0026#34;.gmap\u0026#34;, \u0026#34;.java\u0026#34;, \u0026#34;.bin\u0026#34;, \u0026#34;.db\u0026#34;, \u0026#34;.exe\u0026#34;, \u0026#34;.7z\u0026#34;, \u0026#34;.rar\u0026#34;, \u0026#34;.gshortcut\u0026#34;, \u0026#34;.pptx\u0026#34;, \u0026#34;.xls\u0026#34;, \u0026#34;.xlsx\u0026#34;, \u0026#34;.tar\u0026#34;, \u0026#34;.doc\u0026#34;, \u0026#34;.docx\u0026#34;, # Paperless can\u0026#39;t OCR these reliably } .doc/.docx are technically supported by Paperless but unreliable for OCR; export to PDF first if you care about full-text search on those.\nDry Run # 1 python3 migrate.py --dry-run Output:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 ================================================================= IMPORT PREVIEW — 358 files to import ================================================================= By document type: 税务文件 89 files 移民文件 72 files 金融账单 61 files ... Skipped (non-document files): 47 ext:.html 18 ext:.csv 12 ... Sample mappings (first 30): 10 - 文书材料/30 - Tax Filing/2022/W2-Google.pdf → 税务文件 | tags:[\u0026#39;税务\u0026#39;] | corr:Google LLC | year:2022 title: W2-Google Execute # 1 python3 migrate.py --execute --yes The --yes flag skips the confirmation prompt (necessary when running over SSH where input() hangs). Paperless deduplicates by content hash, so re-running is safe.\nResults # 358 documents imported across 17 document types (19 after taxonomy expansion; see below). Upload took ~25 minutes including OCR processing time. Zero errors after fixing the .doc/.docx exclusions.\nML Classifier Training # After all documents were OCR-processed and the #待处理 tag bulk-removed:\n1 2 3 # Run from NAS with docker permissions /usr/local/bin/docker exec paperless-webserver-1 \\ python3 manage.py document_create_classifier With ~400 labeled documents, the classifier has a solid training set for the most common document types (税务文件, 移民文件, 金融账单 each have 50-90 examples). Rarer types like 操作手册 or 活动通告 will improve as more documents are added.\nWait until OCR is complete before training. The classifier trains on the OCR\u0026rsquo;d content, not the raw file. Running too early means training on empty or partial text.\nInbox Workflow # New documents (uploaded manually, scanned via mobile app, or imported via email) automatically get tagged #待处理:\nSettings → Workflows → Add Workflow:\nName: New Document Inbox Trigger: Document Added (type 2) Action: Assign Tag #待处理 (action type 1) This gives you a reliable inbox view without relying on ML guessing. The workflow fires on every new document regardless of source.\n#待处理 has matching_algorithm: 0 (None) — it is never assigned by ML, only by workflow. This keeps it clean as a status signal.\nClear the inbox by removing #待处理 after reviewing a document.\nOngoing Usage Patterns # Intake sources # Source Method Paper documents Scan with mobile app (e.g. Scanner Pro), upload to Paperless Email attachments Paperless email inbox (IMAP polling configured separately) Downloaded PDFs Drag-drop to Paperless UI or consume folder Consume folder SMB share from NAS, accessible from Windows/Mac Review workflow # Open saved search: tag:#待处理 For each document: verify type, add any missing tags, fix title if needed Remove #待处理 — document moves out of inbox Add #重要 for anything time-sensitive or expiring soon Finding documents # Full-text search handles most cases (e.g. \u0026ldquo;EAD renewal 2023\u0026rdquo;) Filter by document type + correspondent for statements (金融账单 + Vanguard) Filter by correspondent + Document Year custom field for tax docs Tag #归档 on anything fully processed and unlikely to need action Credential backup # Store Paperless admin password and API token in your password manager (Bitwarden). The API token lives in Settings → Tokens; regenerate and update any scripts if rotated.\nDebugging Notes # 待处理 appearing on all imported docs # If you ran the ML classifier before disabling Auto/ML on #待处理, it may have learned to tag everything. Bulk-remove via API:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import requests API_BASE = \u0026#34;http://10.0.10.10:5656/api\u0026#34; API_TOKEN = \u0026#34;YOUR_TOKEN\u0026#34; TAG_ID = 7 # #待处理 headers = {\u0026#34;Authorization\u0026#34;: f\u0026#34;Token {API_TOKEN}\u0026#34;, \u0026#34;Content-Type\u0026#34;: \u0026#34;application/json\u0026#34;} # Get all docs with this tag r = requests.get(f\u0026#34;{API_BASE}/documents/?tags__id__all={TAG_ID}\u0026amp;page_size=500\u0026#34;, headers=headers) doc_ids = [d[\u0026#34;id\u0026#34;] for d in r.json()[\u0026#34;results\u0026#34;]] print(f\u0026#34;Total docs to clean: {len(doc_ids)}\u0026#34;) # Bulk remove tag r = requests.post(f\u0026#34;{API_BASE}/documents/bulk_edit/\u0026#34;, headers=headers, json={\u0026#34;documents\u0026#34;: doc_ids, \u0026#34;method\u0026#34;: \u0026#34;remove_tag\u0026#34;, \u0026#34;parameters\u0026#34;: {\u0026#34;tag\u0026#34;: TAG_ID}}) print(r.status_code, r.text) Document type auto-assigned incorrectly after import # ML may assign wrong types if it trained on an imbalanced or noisy corpus. Check the document, correct manually, then retrain:\n1 2 /usr/local/bin/docker exec paperless-webserver-1 \\ python3 manage.py document_create_classifier Each manual correction feeds back into the training set.\ncustom_fields format error # Paperless expects custom fields as a JSON-encoded dict with string keys:\n1 2 3 4 5 # ✓ Correct json.dumps({\u0026#34;4\u0026#34;: 2024}) # ✗ Wrong — causes 400 error json.dumps([{\u0026#34;field\u0026#34;: 4, \u0026#34;value\u0026#34;: 2024}]) Port conflicts # If Paperless is unreachable, check that no other service is using port 5656 on the NAS. DSM firewall may also block it from certain subnets — check Control Panel → Security → Firewall.\nMy Setup # Item Value NAS Synology DS1621+ NAS IP 10.0.10.10 Paperless port 5656 Docker image ghcr.io/paperless-ngx/paperless-ngx:latest Documents imported 358 Import source Google Takeout (single .zip, ~2 GB) Languages Chinese + English (OCR configured for both) Training corpus ~400 labeled docs across 19 types Notes # The post_document endpoint is async — Paperless queues the document for OCR and returns immediately with {\u0026quot;result\u0026quot;: \u0026quot;OK\u0026quot;}. The document won\u0026rsquo;t be searchable until OCR completes (usually seconds to a minute per doc, depending on NAS load) Paperless deduplicates by SHA256 of the file content — safe to re-run the import script; duplicates are silently skipped Google Takeout exports Google Docs/Sheets as their native format by default; to get PDFs, use the \u0026ldquo;Export format: PDF\u0026rdquo; option when creating the Takeout The document_create_classifier command prints No updates since last training if the training set hasn\u0026rsquo;t changed since the last run — this is normal, not an error ","date":"11 March 2026","externalUrl":null,"permalink":"/posts/paperless-ngx-migration/","section":"Posts","summary":"Runbook and design journal for migrating ~400 personal documents from a folder-based Google Drive system into Paperless-ngx on a Synology NAS. Covers taxonomy design, bulk import from Google Takeout, ML classifier setup, and ongoing intake workflow.\nProblem Statement # For years my “document management” was a manually maintained folder tree on Google Drive:\n1 2 3 4 5 6 7 8 9 10 10 - 文书材料/ 10 - 证件材料/身份证件/ 30 - 移民文档/ 30 - Tax Filing/ 40 - Finance/ 50 - 车辆注册/ 60 - 住房买房/ 80 - Medical/ 20 - 家装住房信息/ 80 - 旅行计划/ This worked well enough for filing but poorly for retrieval. Finding “what insurance forms did I have in 2022?” meant navigating six folders and guessing what I named things. Paperless-ngx offers full-text search, OCR, and an ML classifier that learns from your own labeling — a meaningfully better system for a document archive that spans immigration paperwork, tax filings, mortgage docs, and medical records across 10+ years.\n","title":"Paperless-ngx: Migrating a Decade of Documents from Google Drive","type":"posts"},{"content":"","date":"11 March 2026","externalUrl":null,"permalink":"/tags/printing/","section":"Tags","summary":"","title":"Printing","type":"tags"},{"content":"","date":"9 March 2026","externalUrl":null,"permalink":"/tags/immich/","section":"Tags","summary":"","title":"Immich","type":"tags"},{"content":"","date":"9 March 2026","externalUrl":null,"permalink":"/tags/photos/","section":"Tags","summary":"","title":"Photos","type":"tags"},{"content":"Personal runbook for migrating a family photo library from Synology Photos to a self-hosted Immich instance. Covers bulk upload, Google Takeout import, and album reconstruction via the Synology PostgreSQL database.\nSetup # Source: Synology NAS running Synology Photos (multiple users) Destination: Immich self-hosted on the same NAS Upload tool: immich-go v0.31+ Client: WSL2 on Windows, SSH access to NAS Album script: custom Python (migrate_albums.py) using Immich REST API Phase 1: Photo Uploads # Strategy # Two sources per user:\nGoogle Takeout — photos before the cutoff date (when Google Photos was primary) Synology folder — photos after the cutoff date (when Synology became primary) The cutoff date is when you switched from Google Photos to Synology as your primary photo storage. Photos before that date live in Google Photos at full resolution; after that date, full-resolution is on Synology.\nimmich-go from-folder upload # Run on the NAS directly — avoids copying large libraries (100 GB+) over the network.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 ssh nas cd /path/to/immich-docker nohup ./immich-go upload from-folder \\ --server=http://\u0026lt;nas-ip\u0026gt;:2283 \\ --api-key=\u0026lt;user-api-key\u0026gt; \\ --concurrent-tasks=6 \\ --manage-raw-jpeg=StackCoverJPG \\ --manage-burst=Stack \\ --pause-immich-jobs=true \\ --session-tag \\ --on-errors=continue \\ /path/to/photos \\ \u0026gt; upload.log 2\u0026gt;\u0026amp;1 \u0026amp; Key flags:\nFlag Purpose --manage-raw-jpeg=StackCoverJPG Stack RAW+JPEG pairs, JPEG as cover --manage-burst=Stack Stack burst photo sequences --pause-immich-jobs=true Pause ML jobs during upload (faster) --session-tag Tag all uploads with session ID for tracking --concurrent-tasks=6 Parallelism — tune to your NAS CPU --on-errors=continue Don\u0026rsquo;t abort on individual file failures To upload a folder directly as an album:\n1 2 3 4 5 6 ./immich-go upload from-folder \\ --server=http://\u0026lt;nas-ip\u0026gt;:2283 \\ --api-key=\u0026lt;user-api-key\u0026gt; \\ --into-album=\u0026#34;Album Name\u0026#34; \\ /path/to/folder \\ \u0026gt; upload.log 2\u0026gt;\u0026amp;1 \u0026amp; Google Takeout import # Download the Takeout archive(s), then:\n1 2 3 4 5 ./immich-go upload from-google-photos \\ --server=http://\u0026lt;nas-ip\u0026gt;:2283 \\ --api-key=\u0026lt;user-api-key\u0026gt; \\ --create-albums \\ /path/to/takeout-*.zip Use --date-range=\u0026lt;start\u0026gt;,\u0026lt;cutoff\u0026gt; to limit to photos before the cutoff date (avoids importing lower-resolution Google-compressed copies of photos you already have on Synology).\nPhase 2: Album Reconstruction # Synology Photos stores album membership in its PostgreSQL database. After photos are in Immich, you reconstruct albums via the Immich REST API using a script that reads a TSV export from Synology\u0026rsquo;s DB.\nExport album data from Synology DB # Synology Photos uses PostgreSQL. Database: synofoto. Must run as postgres user (peer auth — no password, but requires sudo).\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # On NAS terminal: sudo su -s /bin/sh postgres -c \u0026#34;psql synofoto -A -F$\u0026#39;\\t\u0026#39; -t -c \\\u0026#34; SELECT a.id, a.name, a.shared, ui.name, u.id_user, ui2.name, f.name, u.filename, u.takentime, u.duplicate_hash FROM public.album a JOIN public.normal_album na ON na.id = a.id JOIN public.many_item_has_many_normal_album mia ON mia.id_normal_album = na.id JOIN public.item i ON i.id = mia.id_item JOIN public.unit u ON u.id_item = i.id JOIN public.folder f ON f.id = u.id_folder JOIN public.user_info ui ON ui.id = a.id_user JOIN public.user_info ui2 ON ui2.id = u.id_user ORDER BY a.id, f.name, u.filename; \\\u0026#34;\u0026#34; \u0026gt; /volume1/homes/\u0026lt;admin-user\u0026gt;/album_export.tsv # Copy to workstation: scp nas:/volume1/homes/\u0026lt;admin-user\u0026gt;/album_export.tsv ./album_export.tsv Note: Old psql on Synology does not support --csv. Use -A -F$'\\t' -t for tab-separated output.\nKey tables:\nTable Purpose album Album metadata (name, owner, shared flag) normal_album Marks album as regular (non-smart) type many_item_has_many_normal_album Album ↔ photo membership item Photo item (logical, parent of unit) unit Physical file (filename, takentime, folder FK) folder Folder path user_info User display names Physical path from DB fields:\nPersonal space: /volume1/homes/\u0026lt;username\u0026gt;/Photos\u0026lt;folder_path\u0026gt;/\u0026lt;filename\u0026gt; Shared space: /volume1/photo\u0026lt;folder_path\u0026gt;/\u0026lt;filename\u0026gt; TSV columns (10 fields, tab-separated):\n1 album_id album_name shared owner file_owner_id file_owner folder_path filename takentime duplicate_hash Album migration script # migrate_albums.py reads the TSV and recreates all albums in Immich:\nFor each photo, call POST /api/search/metadata with originalFileName If multiple filename matches, pick closest by takentime (unix timestamp from Synology DB) Create album via POST /api/albums under the album owner\u0026rsquo;s account Add assets via PUT /api/albums/{id}/assets (batches of 100) Share album via PUT /api/albums/{id}/users with family members (editor role) Config block at top of script:\n1 2 3 4 5 6 7 8 9 10 11 IMMICH_URL = \u0026#34;http://\u0026lt;nas-ip\u0026gt;:2283\u0026#34; API_KEYS = { \u0026#34;user1\u0026#34;: \u0026#34;\u0026lt;api-key\u0026gt;\u0026#34;, # Get from Immich UI → avatar → Account Settings → API Keys \u0026#34;user2\u0026#34;: \u0026#34;\u0026lt;api-key\u0026gt;\u0026#34;, } IMMICH_USER_IDS = { \u0026#34;user1\u0026#34;: \u0026#34;\u0026lt;immich-uuid\u0026gt;\u0026#34;, # From GET /api/users or Immich admin panel \u0026#34;user2\u0026#34;: \u0026#34;\u0026lt;immich-uuid\u0026gt;\u0026#34;, } Usage:\n1 2 3 4 5 6 7 # Preview — safe, makes no changes python3 migrate_albums.py --dry-run python3 migrate_albums.py --dry-run --album \u0026#34;My Album\u0026#34; # Run python3 migrate_albums.py --album \u0026#34;My Album\u0026#34; python3 migrate_albums.py Important notes:\nScript checks for existing album name before creating — safe if re-run, but avoid running twice as it would create duplicates if the check is bypassed Photos with no configured API key are skipped and reported; re-run after adding the key Unmatched photos (not yet uploaded) are printed but don\u0026rsquo;t block other albums Search is scoped per Immich user, so photo lookup must use the file owner\u0026rsquo;s API key, not the album owner\u0026rsquo;s Verification # After migration, verify album counts match between Synology and Immich: query Synology DB for COUNT(*) per album, then compare against GET /api/albums/{id} assetCount field.\nLessons Learned # Run immich-go on the NAS — no network bottleneck; critical for 100 GB+ libraries Pause Immich ML jobs during upload — --pause-immich-jobs=true speeds up import significantly immich-go handles duplicates — safe to re-run; already-uploaded files are detected and skipped Each Immich user needs their own API key — album membership search must use the file owner\u0026rsquo;s key (search results are user-scoped) Filename + takentime matching is reliable — originalFileName search plus unix timestamp disambiguation works well for Synology Photos datasets Old psql on Synology — no --csv flag; use -A -F$'\\t' -t for TSV --session-tag in immich-go — tags all uploads with a session ID, useful for auditing which files came from which run Album script idempotency — checks existing album names before creating; unmatched photos are non-blocking Reference # Immich API docs immich-go docs Full working artifacts (scripts, TSV export, logs): private NAS repo at nas:/volume1/homes/\u0026lt;admin-user\u0026gt;/repos/photo-migration.git ","date":"9 March 2026","externalUrl":null,"permalink":"/posts/synology-to-immich-migration/","section":"Posts","summary":"Personal runbook for migrating a family photo library from Synology Photos to a self-hosted Immich instance. Covers bulk upload, Google Takeout import, and album reconstruction via the Synology PostgreSQL database.\nSetup # Source: Synology NAS running Synology Photos (multiple users) Destination: Immich self-hosted on the same NAS Upload tool: immich-go v0.31+ Client: WSL2 on Windows, SSH access to NAS Album script: custom Python (migrate_albums.py) using Immich REST API Phase 1: Photo Uploads # Strategy # Two sources per user:\n","title":"Synology Photos → Immich Migration Runbook","type":"posts"},{"content":"","date":"16 May 2024","externalUrl":null,"permalink":"/tags/newborn/","section":"Tags","summary":"","title":"Newborn","type":"tags"},{"content":"","date":"16 May 2024","externalUrl":null,"permalink":"/categories/parenting/","section":"Categories","summary":"","title":"Parenting","type":"categories"},{"content":"","date":"16 May 2024","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"},{"content":"","date":"16 May 2024","externalUrl":null,"permalink":"/tags/sleep-training/","section":"Tags","summary":"","title":"Sleep Training","type":"tags"},{"content":"","date":"16 May 2024","externalUrl":null,"permalink":"/series/sleep-training/","section":"Series","summary":"","title":"Sleep training for babies","type":"series"},{"content":" Four Month Sleep Regression # New sleep cycle with 5 different stages\nREM: Dreaming sleep Stage 1: very light/drowsy sleep Stage 2: Light sleep Stage 3/4: Deep restorative sleep Light sleep almost every hour or two throughout the night. They need to learn how to go back to sleep after the light/wake stage.\nThe 7-step guide\nFill the tank Check the engine Set the cruise control Top off the tank Hit the brakes Plan your rest stops Unload the baggage 1. Fill the tank # Maintaining adequate feedings during the day.\nGrowth spurts are common, and we need to feed based on hunger cues. If not fed enough during the day, they\u0026rsquo;ll NEED calories during the night. We need to prevent \u0026ldquo;reverse cycling\u0026rdquo;:\nNight feedings --\u0026gt; Not eating well during day time --\u0026gt; More feedings needed at night 2. Check the engine # Examine the nightly routine\nGive baby their own space\nCreate an environment conductive to falling asleep\nEliminate light use sound machine keep temperature between 68-78 F Establish a predictable bedtime routine\nShoot for 7-8pm bedtime\n3. Set the cruise control # Allowing your baby to put herself to sleep\nThe S.I.T.B.A.C.K. method # Incrasing level of intervension\nS: Stop\u0026hellip; wait watch observe I: Increase the sound machine level T: Touch the baby\u0026rsquo;s chest B: Binky: offer the pacifier A: Add rocking of baby\u0026rsquo;s body C: Cuddle. Pick up the baby and rock gently. K: time to feed. 3-4 months strategy # Baby at this age requires more than SITBACK. We can try to reduce the level of intervension gradually. If baby can\u0026rsquo;t fall asleep after 10-15 minutes, go back and increase the level of intervension.\nIntervention method descending Feeding, rocking or bouncing to sleep, then put to crib Feeding to sleep and gently waking him slightly before placing in crib Rocking baby to sleep in arms, then gently waking slightly before placing in crib Rocking baby\u0026rsquo;s body side-to-side until asleep while he\u0026rsquo;s in the crib Placking your hand on baby\u0026rsquo;s chest until asleep while in crib Calming baby with rocking or hand on chest, then removing before baby is asleep Placing baby in bed awake, allow him to fuss for 5 minutes, go in and pick him up until calm, then lay down again. Repeat the cycle. Placing baby in bed awake, and intervening only when baby cries out. Placing baby in bed awake, allowing him to fall asleep independetly w/o intervention 4. Top off the tank # Consider a dream feed\nBetween 9:30pm to 11pm.\n5. Hit the brakes # Planning to stop night wakings\nGive a few minutes allowing the baby to fall abck to sleep Use SITBACK Avoid \u0026ldquo;quickfixes\u0026rdquo; like rocking, feeding, etc. Try SITBACK at 1 night waking, and use \u0026ldquo;quickfixes\u0026rdquo; for the other wakings.\nSITBACK for 4 months # Do not skip steps or rush through them.\nStep Time S: Stop\u0026hellip; wait watch observe 5-8 min I: Increase the sound machine level 1-2 min T: Touch the baby\u0026rsquo;s chest 2 min B: Binky: offer the pacifier 2 min A: Add rocking of baby\u0026rsquo;s body, allowing their head bobble gently 2 min C: Cuddle. Pick up the baby and rock gently. Until calms, or 2 mins and move to K K: time to feed. 6. Plan your rest stops # Handling naps\n3.5 - 4.5 hours, no more than 5 hours\nWatching wake windows (90-120 mins) Creating the environment: as dark as possible Helping the baby to prepare with a routine Focusing on the first nap, which is the easist ","date":"16 May 2024","externalUrl":null,"permalink":"/posts/parenting/sleep-training-baby/","section":"Posts","summary":"Four Month Sleep Regression # New sleep cycle with 5 different stages\nREM: Dreaming sleep Stage 1: very light/drowsy sleep Stage 2: Light sleep Stage 3/4: Deep restorative sleep Light sleep almost every hour or two throughout the night. They need to learn how to go back to sleep after the light/wake stage.\nThe 7-step guide\nFill the tank Check the engine Set the cruise control Top off the tank Hit the brakes Plan your rest stops Unload the baggage 1. Fill the tank # Maintaining adequate feedings during the day.\n","title":"Sleep Training for Months 3-4","type":"posts"},{"content":" Prepare the little one # B-E-S-T # B - Building confidence: talk about \u0026ldquo;good\u0026rdquo; things about sleep. 可以表扬他们睡 的很好，或者跟他们说自己睡的好，变强壮了，etc. 产生正面影响\nE - expectations: 明确表达你对他们的期望，希望他们做什么。 \u0026ldquo;I\u0026rsquo;m going to give you lots of kisses, and you\u0026rsquo;re going to lay down on your bed, close your eyes and have a good sleep. \u0026quot;\nS - simulate: practice bedtime. \u0026ldquo;show me how to close your eyes?\u0026rdquo;; talk to stuffed animals.\nT - Tanks: fill in the 3 tanks before bedtime.\nBefore you say goodnight # Daytime affects bedtime # Timing # bedtime between 7-8 pm is best. but if children sleeps well, other time is fine.\nAvg 20-30 minutes to sleep. We need to be intentional about their \u0026ldquo;end of day\u0026rdquo;.\nAbout 30 minutes BEFORE bedtime routine\nAvoid screen time.\nObserve their behavior, looking for sleepy queues.\nSome overtired signs\nBig spike in energy level fussiness clumnsiness Connection helps cooperation\nBedtime routine # Implement SAD when kids refuse to start bedtime routine\nTips # 15-20 minutes undivided attention; siblings may need to be included anticipate future requests make it enjoyable and fun don\u0026rsquo;t rush it make sure the child is set up for B-E-S-T Kiss lovey or stuffed animals A bedtime book: helps children visualize\nA bedtime chart: visual reminder for what comes next\nAfter goodnight # interaction with you is their goal, try to make it boring.\nVery few things coming out of your mouth, only two types\nReassuring message # Short, offering support to help children. With three parts:\nTruth: Mommy is here\nAffirmations: You\u0026rsquo;re safe, and you\u0026rsquo;re loved\nPhysical expectation and reason: close your eyes and close your mouth. your body needs sleep\n\u0026ldquo;close your eyes\u0026rdquo; \u0026gt; \u0026ldquo;go to sleep\u0026rdquo; : make the command concrete and actionable.\nYour version:\nAffirmations: \u0026ldquo;you\u0026rsquo;re okay\u0026rdquo;/\u0026ldquo;I believe in you\u0026rdquo;/\u0026ldquo;you\u0026rsquo;re trying so hard\u0026rdquo;\nRedirecting message # Used sparingly, only when you\u0026rsquo;re trying to \u0026ldquo;cool\u0026rdquo; down things.\nDon\u0026rsquo;t reward children with shouting/angry/frustrated. BE boring.\nExample:\nGoal: It\u0026rsquo;s time to sleep\nPhysical expectation: You need to lay down in your bed and close your eyes\nWarning: If you don\u0026rsquo;t, I will need to leave.\nlittle-by-little # Stay at each row for 2 nights, then move down to make progress.\nThe Recipe # Need assurance Gets out of bed Temperature rising Repeat reassuring messages and soothing touch Walk to bed and say \u0026ldquo;Stay in bed and close your eyes\u0026rdquo; Say Redirecting Message and leave if necessary. Provide Bedtime Boosts1 after 30s, 3min, 5min, then 10min Walk to bed and say \u0026ldquo;Stay in bed and close your eyes\u0026rdquo;. Increase Bedtime Boost if needed. Say Redirecting Message and leave if necessary. Resume Bedtime boosts when cooperating Bedtime Boosts # Before you leave: \u0026ldquo;I\u0026rsquo;m going to leave. Shut your mouth and you eyes. I\u0026rsquo;ll come back to check in 30 seconds\u0026rdquo; If he stays, come back and say reassuring message + \u0026ldquo;I\u0026rsquo;ll go to the bathroom and comeback to check on you if you stay quietly in bed.\u0026rdquo; (any activity that they know won\u0026rsquo;t be long) Tips # Make progress little-by-little, don\u0026rsquo;t be too aggressive. Stay on each row 2 nights min, maybe 3.\nBoth parents participate\nMid-night wakings: Walk them back to bed, reassuring messsages + bedtime boosts\nHandling 4-6 am # If children wakes before 6, treat it like night time, see mid-night wakings in the tips section.\nWhen it\u0026rsquo;s past 6am, switch to day mode:\nTurn on lights, open curtains \u0026ldquo;Good morning!\u0026rdquo; start your day Good Morning # Let the party begin!\nDescribe what they did well to reinforce: \u0026ldquo;You stayed in your bed the whole night!\u0026rdquo; / \u0026ldquo;Hooray!\u0026rdquo; / \u0026ldquo;sleeping is making me strong, can I see your muscles? Sleep is making your body strong too\u0026rdquo; There could be challenges:\nIf child wakes up grumpy: influence their mood by showing them morning is good and you\u0026rsquo;re excited.\nIf children don\u0026rsquo;t like start \u0026ldquo;excited\u0026rdquo;: give time to slowly wake up, but be clear about the boundary between sleep and day.\nNaps # Preparing for naps # Setup the room exactly like the nights, and keep it dark like nights.\nPerfect time for nap:\nAbout 6 hours wake window before nap About 5 hours wake window after nap Wake windows can be a bit flexible for this age: ~30 minutes range to wake/nap/bedtime\nOffer a naptime routine just like bedtime: reading, etc help children unwind.\nlittle-by-little for naps # Same plan: mirroring what you\u0026rsquo;re doing at night time during nap.\nIf staying in the room is not feasible or not good? We can jump to the bottom of the chart (out of the room w/ Bedtime Boosts)\nAttempt a nap for 75-90 minutes. End nap time at 90 minutes mark.\nIf no nap is taken # Oops nap: offer 20-30 min \u0026ldquo;oops nap\u0026rdquo; opportunity which allows them fall asleep \u0026ldquo;by accident\u0026rdquo; like short car trip, etc. Do it before 4pm.\nMake bedtime early if \u0026ldquo;oops nap\u0026rdquo; doesn\u0026rsquo;t happen.\nShort naps # Short nap: naps shorter than 90 min for 2yr, 60min for 3yr+\nGive them 15-25 minutes to fall back to sleep before going into the room. If they can\u0026rsquo;t get back to sleep, end nap time.\nEvaluate if short nap continues:\nenvironment activities prior to naps wake windows The kid\u0026rsquo;s nap needs Beyond sleep training # Don\u0026rsquo;t be over-stressed over following the routines an schedules: children can \u0026ldquo;stretch\u0026rdquo;, be flexible and are able to handle changes. Thrive in life and \u0026ldquo;stretch\u0026rdquo;.\nGetting back on track # Routine broken due to events (travels, etc).\nGive a few days grace period for kids to o back. Words be good as gold, show there\u0026rsquo;s no \u0026ldquo;alternatives\u0026rdquo; Set up child with B-E-S-T check bedtime routine Return to \u0026ldquo;little-by-little\u0026rdquo; method Be consistent with your days. Extra considerations # Adding a sibling # Avoid major life changes 3 months before/after newborn arrival to reduce stress.\nThree things to do for the toddler\nGet outside if possible: exposure to daylight, fresh air. Fill the attention tank: set aside 15 min face-to-face time with the toddler everyday. (\u0026ldquo;special time\u0026rdquo;) Let the child lead the way. Maintain a consistent bedtime routine. Pooping at sleep time # Give 30 min play time after eating Give privacy time BEFORE routines to allow kids to poop prior to bedtime. Transitioning from a crib to a bed # It\u0026rsquo;s strongly recommended to keep children in a crib.\nℹ️ Bedtime Boosts A bedtime boost is a way to offer reassurance while rewarding positive behavior. \u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","date":"11 May 2024","externalUrl":null,"permalink":"/posts/parenting/sleep-training-toddler/","section":"Posts","summary":"Prepare the little one # B-E-S-T # B - Building confidence: talk about “good” things about sleep. 可以表扬他们睡 的很好，或者跟他们说自己睡的好，变强壮了，etc. 产生正面影响\nE - expectations: 明确表达你对他们的期望，希望他们做什么。 “I’m going to give you lots of kisses, and you’re going to lay down on your bed, close your eyes and have a good sleep. \"\n","title":"Sleep Training for the Toddler","type":"posts"},{"content":"","date":"11 May 2024","externalUrl":null,"permalink":"/tags/toddler/","section":"Tags","summary":"","title":"Toddler","type":"tags"},{"content":"","date":"7 May 2024","externalUrl":null,"permalink":"/categories/coding/","section":"Categories","summary":"","title":"Coding","type":"categories"},{"content":"","date":"7 May 2024","externalUrl":null,"permalink":"/tags/neovim/","section":"Tags","summary":"","title":"Neovim","type":"tags"},{"content":"Neovim comes with lots of useful plugins, and built-in features, compared to Vim. It\u0026rsquo;s lua configs is also much more readable and powerful compared to Vim. Here\u0026rsquo;s some useful tips for coding in Nvim.\nSearching(files, text, diagnostics, help) # Similar to techniques mentioned in search-in-vim, we can search for text inside files, or looking for files by filtering filenames. In Neovim, we use fzf-lua which is very similar to fzf.vim.\nUsing FZF picker\nFZF\u0026rsquo;s UI consists with a picker (fuzzy search a list of entries) and a preview window. In picker, use c-j/k/n/p(or c-u/d) to move. Tab/S-Tab to select files. The default action (Enter) is to edit or send to qflist, depending on number of selections. If want to edit multiple files, use c-e.\nAnother useful command is \u0026ldquo;resume\u0026rdquo;(\u0026lt;Leader\u0026gt;sr), which picks up the previous FZF search.\nFind files with fuzzy matching # If you know which file you want to open, using the files picker and fuzzy search. I created different keymaps for looking for files in different scopes.\nUse Keymap Details Files in PWD \u0026lt;Leader\u0026gt;f PWD Recent files (MRU) \u0026lt;Leader\u0026gt;o Very useful to search through previously opened files Sibling files of buffer \u0026lt;Leader\u0026gt;s. Also includes files in subfolders Project-wide files (git) \u0026lt;Leader\u0026gt;gf Only sees git files Any path starting with buffer dir \u0026lt;Leader\u0026gt;sf Can modify path before press Enter Search text # If you want to find some text among files, grep is the way to go. fzf allow you to grep then fuzzy search the results for refinement. Similar to find files, there are different keymaps for grepping in different scopes.\nGrep in files # Use Keymap Details Files in PWD \u0026lt;leader\u0026gt;/ Use keyword -- glob to filter files. (! for nagative patterns) Git root \u0026lt;leader\u0026gt;g/ Same as above current Word/WORD \u0026lt;leader\u0026gt;w/W w also works in visual mode live grep: Both keymaps uses \u0026ldquo;live grep\u0026rdquo;, meaning each keystroke would run a new ripgrep command and feed the result. this means we can test our grep expressions on-the-fly.\nglob: In live grep, globs can be added with -- after keywords. Example: -- *.lua !*.md lib/**/*.c.\n* means any character ** means any folders (recursively) ! means negative matching. So, our example expression *.lua !*.md lib/**/*.c means, grep on lua files, and all c files under lib/, but excluding any markdown files.\nFuzzy search buffer lines # Besides real grep, two convenient methods allows fuzzy-searching lines in buffers:\nUse Keymap Details Search current buffer lines \u0026lt;leader\u0026gt;sb [S]earch [B]uffer lines Search all open buffers \u0026lt;leader\u0026gt;so [S]earch [O]pen buffer lines Grep git repo stuff # Git repository contains other useful info that we usually want to search for, like (current buffer) commits, branches, etc. FZF provides a convenient way to search through them.\nThe most useful one would be search commit history of current buffer, via the :FzfLua bcommits command. I\u0026rsquo;ve created two keymaps for them:\nUse Keymap Details Search commits containing current buffer \u0026lt;leader\u0026gt;gb [G]it [B]uffer commits Search all commits \u0026lt;leader\u0026gt;gc [G]it [C]ommits Search vim help # FZF also is convenient to search for help tags and keymaps. (through sh and sk followed by \u0026lt;leader\u0026gt;). There\u0026rsquo;s also one for Man pages with \u0026lt;leader\u0026gt;sm.\nTreesitter # Treesitter parses files into code objects, which can not only provide better highlighting, but also brings a whole new set of motion objects, that can use in both operation-pending mode and visual mode. This allows a much easier and more accurate text manipulation.\nTreesitter objects for selection and motion # I\u0026rsquo;ve defined text objects with the following letters:\nc: Classes m: Methods/functions definitions f: Function calls i: Conditional (if) statements l: Loops a: Args =: assignments All objects support jumps to prev/next via [ and ]. Upper case goes to the end while lower case goes to the beginning. Here\u0026rsquo;s a few examples:\n[c: start of prev class ]L: end of net loop [f: start of prev function call A more powerful way to use TS objects is via selection like other text objects like words, paragraphs, etc. Visual selection is one way to do it, but it\u0026rsquo;s more powerful with operation-pending mode. Some examples:\ncr=: [C]hange [R]hs of [=] caa: [C]hange a [A]rgument cii: [C]hange [i]n [I]f: This is very useful, depending on cursor position. It selects the \u0026ldquo;if\u0026rdquo; condition, or selects the conditional body when cursor is inside the body. daf: Delete a function call. I also have Flash plugin to quick select a block. Triggered by S or R.\nS: select a block from current cursor position R: select a remote block. Type the letters in the remote block, then colored labels will appear to allow you to select blocks. [x is another special and useful hotkey. If the cursor is deeply nested several levels down, the current scope will show at top of the screen. [x jumps to the start of a \u0026ldquo;upper\u0026rdquo; scope.\nSee it in action:\nOutlines # Treesitter also provides more accurate outlines of documents. I installed aerial.nvim for showing outlines.\nTo toggle aerial window on the side: \u0026lt;leader\u0026gt;a (\u0026lt;leader\u0026gt;A stays in aerial window) To jump to next/prev outline item: \u0026lt;leader\u0026gt;{ and \u0026lt;leader\u0026gt;} LSP and diagnostics # Neovim supports LSP natively. It brings diagnostics, signature help, documentation, code actions and formatting capabilities.\nDiagnostics # Diagnostics shows info, warning or errors of the code. My default setting will show diagnostics icons on the gutter, and virtual text in each line.\nWork with diagnostics # Use Keymap Details Previous diagnostics [d Next diagnostics ]d Toggle diagnostics \u0026lt;leader\u0026gt;dt Toggles virtual text Show floating box \u0026lt;leader\u0026gt;df Shows float box with details Fzf search diagnostics \u0026lt;leader\u0026gt;sd Searches document diagnostics Once at a diagnostics point, you may see code actions at the cursor position, which may provide useful fixes to the issues.\nShow code actions \u0026lt;leader\u0026gt;ca Some LSP may not provide useful ones You can also put diagnostics to quickfix list so you can process with commands like :cdo / :cfdo or :ldo / :lfdo\nSend to quickfix list \u0026lt;leader\u0026gt;dq Can then use :cdo or :cfdo to process Send to location list \u0026lt;leader\u0026gt;dl Accessing diagnostics with trouble.nvim # Buffer Diagnostics \u0026lt;leader\u0026gt;xX Toggle the trouble window Workspace Diagnostics \u0026lt;leader\u0026gt;xx Quickfix list \u0026lt;leader\u0026gt;xq Rename and code actions # Rename \u0026lt;leader\u0026gt;rn Rename via LSP API Code action \u0026lt;leader\u0026gt;ca Auto complete # keymaps # Use Keymap Description Prev/Next \u0026lt;C-j\u0026gt; / \u0026lt;C-k\u0026gt; Close \u0026lt;C-c\u0026gt; Scroll \u0026lt;C-b\u0026gt; / \u0026lt;C-f\u0026gt; Confirm \u0026lt;C-y\u0026gt; Not CR or TAB Move next/prev location \u0026lt;C-h\u0026gt; / \u0026lt;C-l\u0026gt; Work in INSERT mode, Useful in snippets Next choice \u0026lt;C-e\u0026gt; For choice node in snippets Snippets # Luasnip is the plugin to provide snippets to auto completion.\nMy own snippets are created and located at dotfiles/nvim/lua/snippets/, filenames needs to match the filetype.\nI also created helper snippets for lua so that creating new snippets skeleton is super fast. The snippets can be triggered with:\nsnipf: For complex snippets with i nodes, etc. snipt: For simple text snippets (no expansion locations or dynamic stuff) To create snippets, create a [ft].lua file, then use the snippets above to quickly add new content.\nMy Snippets # Markdown\nsc for Hugo shortcodes. Plugin details # Auto complete is provided by nvim-cmp. It take completion choices from configurable sources, like LSP, snippets, path, buffer, etc.\nA list of cmp sources can be found at nvim-cmp Wiki\nFormatting # Auto format # I use conform.nvim to format files. Auto cmds are created to format on save for certain file types. I currently use a whitelisted approach instead of let formatters to format files by default, as I don\u0026rsquo;t want to break stuff, and manually triggering formatting is not that hard with the keymap:\nUse Keymap Description Manually trigger formatting ,f Calls Conform Format injected code ,mf Like code blocks in Markdown conform.nvim is a \u0026ldquo;middleman\u0026rdquo; that calls external formatters (and fallback to LSP), so you need to configure formatters for different filetypes, and install the formatters separately, following their own doc.\nSee :h conform-formatters\nFormatter setup # Prettierd config to format markdown with 80 column width By default, prettier(and prettierd) will not break lines for markdown files. I needed to create a config override for prettierd to force it.\nCreate a custom config file with this single line and save it to .config/nvim/utils/linter-config/.prettierd.toml 1 proseWrap = \u0026#34;always\u0026#34; Set environment variable for prettierd in conform\u0026rsquo;s formatters section: 1 2 3 4 5 6 7 8 formatters = { -- Always wrap markdown files to text width prettierd = { env = { PRETTIERD_DEFAULT_CONFIG = vim.fn.expand(\u0026#34;~/.config/nvim/utils/linter-config/.prettierrc.toml\u0026#34;), }, }, } Jumps with leap and grapple # Leap within the reach of your eyes # To quickly jump in the current view, use leap. It\u0026rsquo;s like using a mouse: jump to where your eye is looking at. (in contrast to regular Vim motions that moves over \u0026ldquo;text objects\u0026rdquo; that has some meaning)\nHow to use\nStarts with s, then type two letters. You\u0026rsquo;ll either jump to there already, or need to press a label key. Since the label is displayed from beginning, typing the labels won\u0026rsquo;t slow you down.\nAnother important tip is using gs jump to the other window, which saves the window move.\nSome special characters when using leap:\n\u0026lt;space\u0026gt; end of line, \u0026lt;space\u0026gt;\u0026lt;space\u0026gt;: blank lines All types of brackets are considered equivalent Remote operation # It\u0026rsquo;s quite often that we want to modify a remote location like yank something and put it where the cursor is. Normally, we\u0026rsquo;ll have to:\nMove to the remote location Perform the operation (yank/delete/etc.) Jump back with c-o Paste or continue typing Steps 1 and 3 can be saved with remote operation. When in operation-pending mode, press \u0026ldquo;r\u0026rdquo; to trigger it. Now the new process is\nWant to yank something remotely, press y, then r to enter remote mode. Start typing to match the remote location, and press the label key. Now the cursor is in the remote location Type the motion like aw, i{, etc. Operation is done on the remote text and now you\u0026rsquo;re back where you started. Syntactical selections with flash # When writing code, you can easily select a syntactical block, with the help from treesitter and flash.\nFeature Keymap Comment Select a block containing current cursor S Shows labels around different levels of blocks Select a block remotely R After press R, start typing the remote location text, and labels will appear for selecting blocks around that location. Jump between files # When working on some projects, we often jump between a few important files. Examples like {build, test, header, impl, reference}. Switching buffers is still not fast enough: buf list is usually noisy with other stuff. Even with fzf, we need to type words for filtering each time we switch.\nGrapple allows marking files with tags, and cycle through them. It\u0026rsquo;s like global marks, but with names, project scoping, and better UI. Here\u0026rsquo;s the common workflow. We also have status bar integration to show all tags and active status.\nUse Keymap Details Add tag \u0026lt;Leader\u0026gt;ma Can enter an optional name for the tag. Remove a tag(untag) \u0026lt;Leader\u0026gt;md Untag the current file. Move to next tag \u0026lt;Leader\u0026gt;n I only mapped foward-move Show all tags and select \u0026lt;Leader\u0026gt;mk Provides single-key hotkeys to jump File management # oil.nvim allows you edit your file system like a vim buffer. On save, it applies the changes to the file system. It much more convenient than doing shell commands to create folders, move files, etc.\noil.nvim is configured to be the default file explorer in nvim and can be triggered by nvim . in shell, or like :e . in cmdline.\nWorking with Git # Two plugins are spciefically for git:\ngitsigns: Gutter signs for diff, with helpers to stage/unstage and show inline diff, blame, etc. Fugitive: classic plugin. fzf.lua also provides pickers for search git commit history.\nInline diff and staging # When editing files, gitsigns makes it easy to:\nJump through all hunks of changes in the file ([h, ]h) Toggle inline diff (against staged version, or last commit) (hd, hD, hp) See blame info (hb) Stage/unstage hunks (hs, hS, hu) All keymaps are under \u0026lt;leader\u0026gt;h (hunk)\nUse Keymap Details Move between changes/hunks [h / ]h Hunks are changed blocks compared to staged version. Preview hunk diff \u0026lt;Leader\u0026gt;hp Inline preview of diff of this hunk. Diff the buffer(side-by-side) \u0026lt;Leader\u0026gt;hd / Leader\u0026gt;hD d: diff against staged version; D: against last commit(~) Quit diff view \u0026lt;Leader\u0026gt;hq Stage buffer/hunk \u0026lt;Leader\u0026gt;hS/hs Upper case for buffer, lower for hunk. Reset buffer/hunk \u0026lt;Leader\u0026gt;hR/hr Upper case for buffer, lower for hunk. Unstage hunk \u0026lt;Leader\u0026gt;hu Blame line \u0026lt;Leader\u0026gt;hb shows full blame in float window, with commit, hunk, etc. Toggle blame inlay \u0026lt;Leader\u0026gt;htb Shows blame of curline in normal mode Toggle deleted hunk display \u0026lt;Leader\u0026gt;htd Show/hide deleted hunks Stage files and Create commit # Staging buffers while editing using gitsigns is convenient. Another way to see the full status of the project and do stage/unstage, see diff and create commits are using Fugitive\u0026rsquo;s \u0026ldquo;status\u0026rdquo; view.\nOpen git status with \u0026lt;leader\u0026gt;gs Move between files using (, ) Toggle diff of file under cursor with = Toggle stage with -. (or use s, u) hard reset a file with X Create a commit using cc Handling Merge Conflicts # Explore git history with Fugitive # Commits / logs History files Diff Misc # Preview markdown files # ","date":"7 May 2024","externalUrl":null,"permalink":"/posts/coding/neovim-workflow/","section":"Posts","summary":"Neovim comes with lots of useful plugins, and built-in features, compared to Vim. It’s lua configs is also much more readable and powerful compared to Vim. Here’s some useful tips for coding in Nvim.\nSearching(files, text, diagnostics, help) # Similar to techniques mentioned in search-in-vim, we can search for text inside files, or looking for files by filtering filenames. In Neovim, we use fzf-lua which is very similar to fzf.vim.\n","title":"Neovim Workflow","type":"posts"},{"content":"","date":"7 May 2024","externalUrl":null,"permalink":"/tags/vim/","section":"Tags","summary":"","title":"Vim","type":"tags"},{"content":"搜索是开发中最常用也是最重要的操作之一. Vim提供了非常高效和方便的搜索功能. 这篇笔记记录了一些常用的搜索命令,例子以及自定义的key mapping\n使用ripgrep搜索文件内容 (:Rg2 or ,gg, ,gw/,gW ) 使用git-grep搜索git branch/commit, 以及使用fzf显示git grep的结果 (:Ggrep) 使用fzf/coc list搜索当前文件/buffers中的lines, 实现快速定位/跳转 使用quickfix lists快速访问上述搜索的结果, 以及利用:cdo/:cfdo等命令对结果进行批量操作. General searching inside files # Ripgrep with fzf # fzf.vim provides a :Rg command to call ripgrep and search the current directory. However, a more useful variant is built to support specify path and pass other parameters to ripgrep, I used this snippet and mapped this to shortcut \u0026lt;Leader\u0026gt;gg.\n1 2 3 4 5 6 7 8 9 10 11 12 13 \u0026#34; https://github.com/junegunn/fzf.vim/issues/837#issuecomment-1179386300 \u0026#34; Search with ripgrep and fzf with folder specs. Defult starts in git root folder \u0026#34; Examples: \u0026#34; :Rg2 -w \u0026#34;apple\u0026#34; ./folder_test \u0026#34; :Rg2 -w \u0026#34;apple\u0026#34; -g \u0026#34;!themes/*\u0026#34; -g \u0026#34;*.css\u0026#34; \u0026#34; :Rg2 --type=js \u0026#34;apple\u0026#34; \u0026#34; :Rg2 --fixed-strings \u0026#34;apple\u0026#34; \u0026#34; :Rg2 -e -foo command! -bang -nargs=* Rg2 \\ call fzf#vim#grep( \\ \u0026#34;rg --column --line-number --no-heading --color=always --smart-case \u0026#34;.\u0026lt;q-args\u0026gt;, \\ 1, {\u0026#39;dir\u0026#39;: system(\u0026#39;git -C \u0026#39;.expand(\u0026#39;%:p:h\u0026#39;).\u0026#39; rev-parse --show-toplevel 2\u0026gt; /dev/null\u0026#39;)[:-2]}, \\ \u0026lt;bang\u0026gt;0) Ripgrep options for search:\n-w: word search -g: glob to include/exclude(start the pattern with !) files/dirs. --type: filter by file type. Example: --type=js The results are present with fzf, where you can do fuzzy serach:\n\u0026ldquo;!pattern\u0026rdquo;: exclude results that has the pattern string \u0026ldquo;pattern\u0026rdquo;: fuzzy match and only include results that has the pattern string Select results with tab, or c-a to select all, then enter to open, or c-q to build quickfix list.\nTips:\nUse \u0026lt;C-r\u0026gt;\u0026lt;C-w\u0026gt; to insert under cursor, or \u0026lt;C-r\u0026gt;\u0026lt;C-a\u0026gt; for WORD. In tmux, you need to input c-a twice. Searching inside the current file # Fzf provides a convenient way to filter lines of all open buffers or current buffer, with commands :Lines and :BLines. Then just type the word you want to search, and quickly go to the line by enter.\nYou can also build a quickfix list from the result, by first \u0026lt;C-a\u0026gt; to select all, then \u0026lt;C-q\u0026gt; to build the quickfix list from fzf UI.\nCustom keymaps are created for quick access to the buffer searches:\n\u0026lt;Leader\u0026gt;l*: Search ALL open buffers \u0026lt;Leader\u0026gt;/: Search current buffer. (Like / but with fzf) \u0026lt;Leader\u0026gt;lw: Search the current word under cursor in all open buffers. CocSearch # Pros: intuitive to use, results in a buffer window nicely formatted Cons: Not integrated with quickfix list, can\u0026rsquo;t easily filter results or move fast among results. (compared to fzf or coc-list grep) Arguments for ripgrep (can be added by using -A in CocSearch comman)\n-g {GLOB}: Include or exclude files/directories that matches the glob. Precede glob with ! to exlude. -w \u0026ndash;word-regexp: Only show matches surrounded by word boundaries. -x \u0026ndash;line-regexp: Only show matches surrounded by line boundaries. Example: search a string but ignore keyboards/ folder and exlude .mk files.\n1 :CocSearch STM32F411 ../../.. -g !keyboards/ -g !*.mk Searching git repositories # In git repo, somtimes we want to search stuff beyond the current working directory or version. git grep is a useful tool to search in other branches/commits.\nRaw Ggrep with Fugitive # fugitive.vim plugin provides Vim integration to use git grep in VIM. The results are sent to quickfix list directly.\nSome search query examples:\n1 2 3 4 5 :Ggrep \u0026#34;ripgrep\u0026#34; HEAD~ :Ggrep \u0026#34;ripgrep\u0026#34; master :Ggrep -w \u0026#34;coc\u0026#34; HEAD~ -- \u0026#34;*.md\u0026#34; :Ggrep --cached findme -- \u0026#34;*.[ch]\u0026#34; :Ggrep --untracked findme See section below about quickfix lists for more details on how to access and use the results.\nUsing fzf to view the results # caveat: if searching is on a different branch or commit, FZF\u0026rsquo;s preview can\u0026rsquo;t show the file. Using fugitive\u0026rsquo;s Ggrep command will be more useful.\nSnippets borrwed from fzf.vim\u0026rsquo;s README.\n1 2 3 4 command! -bang -nargs=* GGrep \\ call fzf#vim#grep( \\ \u0026#39;git grep --line-number -- \u0026#39;.fzf#shellescape(\u0026lt;q-args\u0026gt;), \\ fzf#vim#with_preview({\u0026#39;dir\u0026#39;: systemlist(\u0026#39;git rev-parse --show-toplevel\u0026#39;)[0]}), \u0026lt;bang\u0026gt;0) Working with search results in quickfix list # Search results are saved in the quickfix list, which can be opened with :cw, and explored with ]q/[q ( with vim-unimparied plugin mappings ), or using j/k by opening the quickfix list. The quickfix list can also be explored/searched using coc-list with custom mapping \u0026lt;leader\u0026gt;lq.\nYou may have started a search on something like :vimgrep /\\\u0026lt;read_file\\\u0026gt;/ *.c, working through the quickfix list, then found you need to search something else like :vimgrep /\\\u0026lt;msg\\\u0026gt;/ *.c. When you\u0026rsquo;re done with the new search results, you can use :col[der] to go back to the previous quickfix list and continue with the old results.\nCoc-lists also provides a quick search list for the quickfix list, can be accessed with :CocList quickfix, or custom mapping \u0026lt;Leader\u0026gt;lq.\nUseful resources\n:h quickfix :h cdo :h cfdo :h ccl - Close the quickfix window :h col[der] - Go to the older quickfix list :h cnew - Go to the newer quickfix list :h chi - Show history of quickfix lists (max 10) Mappings # I created the following mappings to use cocsearch\n\u0026lt;Leader\u0026gt;gg: In normal mode, it enters :Rg2 and waits for input. In visual mode, it fills the argument with the visual selection. (spaces are escaped) \u0026lt;Leader\u0026gt;g{w|W}: Search the word/WORD under cursor. Combine this with the shortcut %% (expands to current file\u0026rsquo;s folder), we can easily start searching.\n","date":"9 May 2021","externalUrl":null,"permalink":"/posts/search-in-vim/","section":"Posts","summary":"搜索是开发中最常用也是最重要的操作之一. Vim提供了非常高效和方便的搜索功能. 这篇笔记记录了一些常用的搜索命令,例子以及自定义的key mapping\n使用ripgrep搜索文件内容 (:Rg2 or ,gg, ,gw/,gW ) 使用git-grep搜索git branch/commit, 以及使用fzf显示git grep的结果 (:Ggrep) 使用fzf/coc list搜索当前文件/buffers中的lines, 实现快速定位/跳转 使用quickfix lists快速访问上述搜索的结果, 以及利用:cdo/:cfdo等命令对结果进行批量操作. General searching inside files # Ripgrep with fzf # fzf.vim provides a :Rg command to call ripgrep and search the current directory. However, a more useful variant is built to support specify path and pass other parameters to ripgrep, I used this snippet and mapped this to shortcut \u003cLeader\u003egg.\n","title":"Search in Vim","type":"posts"},{"content":" Search and work with many files # Searching # For searching words / symbols within files, see search-in-vim\nTo quick find and open files by filename, I use fzf and coc-list, and the follwing key bindings.\nGeneral search\nTo open files in the working directory ( not current file/buffer dir), use :Files or ,lc ( list current)\nI also created a quick command ,lf (list file) to start from current buffer\u0026rsquo;s folder, and you can type the path before start the search.\nLastly, there\u0026rsquo;s :Buffers / ,b to list opened buffers list to quickly jump between buffers. It\u0026rsquo;s very useful if lots of files are opened. However, using quickfix lists or markers, or :Lines / ,l* to precisly jump to a line may often be more efficient.\nGit related\nTo open files in current git repository, use :GFiles or ,gc ( git content ). This uses the files list of git ls-files which respects .gitignore.\nTo only check files that are modified (in git status results), use GFiles? or ,gs (git status).\nJumping between buffers # Move between buffers\nBuffers are numbered and you can use n \u0026lt;C-^\u0026gt; to quickly jump to the buffer with number n. To jump to previous buffer, simply use \u0026lt;C-^\u0026gt;.\nSometimes, \u0026lt;C-o\u0026gt; / \u0026lt;C-i\u0026gt; to move back/forward in jumplist can also be useful for moving between buffers, but with less precision.\nIf there\u0026rsquo;s only a few buffers and the ones you want to move to is next to each other, I mapped \u0026lt;C-p\u0026gt; / \u0026lt;C-n\u0026gt; to move to previous/next buffer.\nMoving with search\nWhen you need to work with some symbols, usually a search is done and a quickfix list would be available ( see search-in-vim for more details). With that, you can use the quickfix list to help move between search result locations, which is even faster. See how to work with quickfix list for more details\nMoving within a large buffer # Markers\nIf there\u0026rsquo;re a few \u0026ldquo;hot\u0026rdquo; spots in a buffer that you visit frequently, using marks would make jumping to those locations a breeze. When jumping, 'a jumps to start of the line, `a jumps to the exact location. (:h mark-motions)\n\u0026lsquo;a - \u0026lsquo;z lowercase marks, valid within one file \u0026lsquo;A - \u0026lsquo;Z uppercase marks, also called file marks, valid between files motions\ng; / g,: Go to previous/next change list location. [ and ] jumps: Square brackets followed by a character jumps in code blocks. { } ( ) Brackets: Jump to previous/next unmatched bracket. This is useful to jump to start/end of the current code block in some languages. m / M Functions: Jump to previous/next function start/end. m means start. M means end. This works with files in Java\u0026rsquo;s structure, with a class and methods defined inside it. #: #if #else #endif macros. Jump to previous/next if/else/endif macro lines. *: C comment blockes. Jump to the start/end of C comment block /* or */. ( / ): Jump a sentence. Jumping words (w/b) and paragraphs ({/}) is used a lot in coding. While jumping by sentence is useful when working with large text, like writing documents or long comments. Quickfix List\nQuickfix list isn\u0026rsquo;t only used for serach results: it is also used by make command to list errors/warnings with locations of this issues. Jumping withing the file using the quickfix list can quickly locate and address errors reported from those sources.\nOutline\nGit Merge # Buffers # {count}Ctrl-^: Jump to buffer number count. Very useful with tabline where buffer\u0026rsquo;s numbers are shown on top. Insert mode # Motions\nCtrl-c: Enter normal mode. Ctrl``O: Temporarily switch to normal mode for a single motion. C-x + C-e/C-y: Scroll windows up/down. Ctrl-x enters scroll mode, and susequent c-e or c-y scrolls the screen without moving the cursor. Deletions\nCtrl``U: Delete all entered characteres in the current line. If there\u0026rsquo;re no newly entered chars, delete to beginning of cursor. C-w: Delete a word backwards. C-h: Delete a character Insertions\nC-i or Tab: insert a tab C-r: Insert contents of a register. Some special registers C-t/C-d: Insert/delete indent. 0 C-d delete all indentations. Completion\nC-x enters the completion mode, and subsequent input triggers completion for different types\nC-L: Whole lines C-F: file names C-N: keywords in the buffer C-K: Dictionary C-]: Tags C-D: Definitions C-O: omnifunc ","date":"15 April 2021","externalUrl":null,"permalink":"/posts/vim-tips/","section":"Posts","summary":"Search and work with many files # Searching # For searching words / symbols within files, see search-in-vim\nTo quick find and open files by filename, I use fzf and coc-list, and the follwing key bindings.\nGeneral search\nTo open files in the working directory ( not current file/buffer dir), use :Files or ,lc ( list current)\n","title":"Vim Tips","type":"posts"},{"content":"","date":"11 April 2021","externalUrl":null,"permalink":"/categories/keyboard-design/","section":"Categories","summary":"","title":"Keyboard Design","type":"categories"},{"content":" Feature highlights # Initially, I wanted to build a split board. However, since I already have a split (crkbd), making a Arteus/Reviuge-like board makes more sense.\nThe design is using aggressive stagger (same column stagger like my split design), and 15 degree angle of columns.\nOther features:\nOLED status display RGB underglow Audio Encoder Design considerations for STM32 # I\u0026rsquo;ll be using F411 due to lack of F072 stock.\nTips for setup a stm chip in qmk: https://discord.com/channels/440868230475677696/440870965728116754/839978277489082370\nSnippet to find keyboards that uses a certain chip:\n1 2 3 4 5 6 7 8 9 10 % git grep \u0026#39;MCU\\s*=\\s*STM32F411\u0026#39; keyboards/ keyboards/handwired/onekey/blackpill_f411/rules.mk:MCU = STM32F411 keyboards/handwired/onekey/blackpill_f411_tinyuf2/rules.mk:MCU = STM32F411 keyboards/handwired/pill60/blackpill_f411/rules.mk:MCU = STM32F411 keyboards/handwired/riblee_f411/rules.mk:MCU = STM32F411 keyboards/matrix/m20add/rules.mk:MCU = STM32F411 keyboards/matrix/noah/rules.mk:MCU = STM32F411 keyboards/tkw/grandiceps/rules.mk:MCU = STM32F411 keyboards/tkw/stoutgat/v2/f411/rules.mk:MCU = STM32F411 keyboards/zvecr/zv48/f411/rules.mk:MCU = STM32F411 DMA channels for peripherals # Peripherals like I2C, SPI and ADC, DAC uses DMA channels. PWM also can use DMA for better performance, for example, for LED dreivers. STM32F072 has 7 DMA channels(details can be found in reference doc). When choosing peripherals, we need to choose carefully so that DMA channels won\u0026rsquo;t collide.\nQMK uses DMA for the following:\nWS2812 LED: pwm driver uses DMA+PWM. Audio: DAC driver uses DMA. PWM driver doesn\u0026rsquo;t use DMA. OLED: Uses I2C which uses DMA channels. F072 DMA details # F072 only has one DMA controller(DMA1), which has 1 channel, with 7 streams.\nI2C1: uses DMA streams 6/7(or 2/3). (Requires magic boot code to configure.) DAC1/2: uses DMA streams 3 and 4. SPI1: streams 2/3. SPI2: streams 4/5 or 6/7. TIM3_CH1: stream 4 TIM1_CH1: stream 2 F411 DMA details # F411 has 2 DMA controllers, each has 8 channels and each channel with 8 streams.\nSummary of DMA usage in my design:\n|\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;|\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;|\u0026mdash;\u0026mdash;-|\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;|\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-|\nFeature QMK Driver Pins Peripheral DMA channels OLED I2C PB6/7 I2C1 DMA1-Chan1-Stream5/6 Audio PWM PA8 TIM1_CH1 + TIM6 GPT N/A RGB LED PWM PB1 TIM3_CH4 DMA1-Chan5-Stream2 RGB (alt) SPI1 PB5 SPI1 DMA2-Chan3-Stream2/3 \u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash; \u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash; \u0026mdash;\u0026mdash;- \u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash; \u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;- Audio Driver # Two options: DAC and PWM. DAC would occupy one or two DMA channels. PWM doesn\u0026rsquo;t use DMA.\nAudio with DAC # Using two pins can provides higher voltage amplitude and louder volume.\nPins(A4/A5). ref, ref2 1 2 3 4 5 6 7 8 // Audio configuration #define AUDIO_PIN A4 #define AUDIO_PIN_ALT A5 #define AUDIO_PIN_ALT_AS_NEGATIVE #define A4_AUDIO #ifndef STARTUP_SONG # define STARTUP_SONG SONG(STARTUP_SOUND) #endif // STARTUP_SONG Audio with PWM # Use any timer\u0026rsquo;s PWM to output square wave. This only support single pin mode. We use TIM3_CH1 as example. (use tim6 for audio state timer)\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 //halconf.h: #define HAL_USE_PWM TRUE #define HAL_USE_PAL TRUE #define HAL_USE_GPT TRUE #include_next \u0026lt;halconf.h\u0026gt; // mcuconf.h: #include_next \u0026lt;mcuconf.h\u0026gt; #undef STM32_PWM_USE_TIM1 #define STM32_PWM_USE_TIM1 TRUE #undef STM32_GPT_USE_TIM6 #define STM32_GPT_USE_TIM6 TRUE //config.h: // Use pin C6(TIM3_CH1) PWM #define AUDIO_PIN A8 #define AUDIO_PWM_PAL_MODE 1 #define AUDIO_PWM_DRIVER PWMD1 #define AUDIO_PWM_CHANNEL 1 #define AUDIO_STATE_TIMER GPTD6 OLED # I2C: Use I2C1(pins B6/7). ref\nNote that, pull up resistors(4.7kOhm) are needed for I2C pins.\nMagic fix # For F072, extra configuration is needed to use I2C1 correctly. For details, see discord, discord2\nExample code # 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // example config: https://github.com/qmk/qmk_firmware/blob/master/keyboards/xelus/kangaroo/config.h //set I2C1_SCL_PAL_MODE and I2C1_SDA_PAL_MODE to 1. (PINs B6/7) #define I2C1_TIMINGR_SCLDEL 3U #define I2C1_TIMINGR_SDADEL 1U #define I2C1_TIMINGR_SCLH 3U #define I2C1_TIMINGR_SCLL 9U //Copy board_init() from https://github.com/qmk/qmk_firmware/blob/master/keyboards/xelus/kangaroo/kangaroo.c : void board_init(void) { SYSCFG-\u0026gt;CFGR1 |= SYSCFG_CFGR1_I2C1_DMA_RMP; SYSCFG-\u0026gt;CFGR1 \u0026amp;= ~(SYSCFG_CFGR1_SPI2_DMA_RMP); } //The problem is that platforms/chibios/GENERIC_STM32_F072XB/configs/mcuconf.h contains: #define STM32_I2C_I2C1_RX_DMA_STREAM STM32_DMA_STREAM_ID(1, 7) #define STM32_I2C_I2C1_TX_DMA_STREAM STM32_DMA_STREAM_ID(1, 6) //But this is actually an alternate configuration for I2C1 DMA streams, and it needs to be enabled by setting the SYSCFG_CFGR1_I2C1_DMA_RMP bit. //The SYSCFG_CFGR1_SPI2_DMA_RMP clearing part might not actually be needed (the power-on state for this bit should be 0), but you can include it just to be sure. Backlight RGB: # LED: use WS2812S RGB driver using PWM # Uses PWM and DMA. Here I\u0026rsquo;m using TIM3_CH4 on pin B1 as output, and connect it with pull up resistor to 5V. (B1 is a 5v tolerant pin)\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 // rules.mk RGBLIGHT_DRIVER = WS2812 // config.h #define RGB_DI_PIN B1 #define WS2812_PWM_DRIVER PWMD3 #define WS2812_PWM_CHANNEL 4 #define WS2812_EXTERNAL_PULLUP // Set B1 to tim3 ch4 #define WS2812_PWM_PAL_MODE 2 // DMA request mapped on this DMA channel only if the corresponding remapping // bit is cleared in the SYSCFG_CFGR1 register. For more details, please refer // to Section 9.1.1: SYSCFG configuration register 1 (SYSCFG_CFGR1) on page165. #define WS2812_DMA_STREAM STM32_DMA1_STREAM2 #define WS2812_DMA_CHANNEL 5 If using pin B15(tim15) (F072)\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 // rules.mk RGBLIGHT_DRIVER = WS2812 // config.h #define RGB_DI_PIN B15 #define WS2812_EXTERNAL_PULLUP // using TIM15 channel 2 #define WS2812_PWM_DRIVER PWMD15 #define WS2812_PWM_CHANNEL 2 // Set B15 to tim15 ch2 #define WS2812_PWM_PAL_MODE 1 // DMA setup needs to be verified. #define WS2812_DMA_STREAM STM32_DMA1_STREAM5 //mcuconf.h if using tim15 //#define STM32_TIM15_SUPPRESS_ISR RGB driver SPI # Use SPI2(B15). ref spi. This requires other spi pins unused.(miso and sck, e.g. B13 and B14)\n1 2 3 4 5 #define WS2812_SPI SPID2 // Pins 15/14/13 #define WS2812_SPI_MOSI_PAL_MODE 0 #define WS2812_SPI_MOSO_PAL_MODE 0 #define WS2812_SPI_SCK_PAL_MODE 0 Note: Extra setup is needed for F072 B15 pin. details\nThe fix is to define #define WS2812_SPI_USE_CIRCULAR_BUFFER. The flag is only available in develop branch. Anoth\nComponent choices # Use this site to search JLCPCB parts for SMD assembly service availability and price.\nSummary:\n|\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-|\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;|\u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash;|\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;-|\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;|\nComponent Model JLCPCB PN Count Notes MCU STM32F072CBU6 C92504 1 Regulator XC6206P332MR C5446 1 3.3V fixed output BJT MMBT3904 C20526 1 for reset circuit, need add resistors diode 1N4148 C81598 for both switches and reset button. Fuse JK-MSMD050 C369167 For USB bus voltage protection Schottky Diode SS14 C2480 between voltage regulartor Buzzer KLJ-1625 C201041 smd pizeo buzzer \u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;- \u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash; \u0026mdash;\u0026mdash;\u0026mdash;\u0026ndash; \u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;- MCU # I\u0026rsquo;m using STM32 MCU, which is more powerful than the atmega on pro micros. The atmega32u4 is 8 bit processor and only has 32K memory size. In comparison, the Arm chips are 32bit, and has more program memory(F072C8 has 64K, F072CB has 128K, F411 has 256K/521K flash and 128K SRAM).\nInitially I wanted to use STM32F072CBU6, which is also used in Ferris. However, it became OOS and I had to pick another chip. I found STM32F411 whixh id more powerful.\nOne caveat is that F4 series doesn\u0026rsquo;t have built-in eeprom emulator. An external eeprom chip is needed, otherwise some features are not usable, like bootmagic, on-board settings(default layers, for example).\nReset button circuit # To reset(and enter bootloader), we need to trigger both NRST and BOOT0 pins. This guide suggested 3 variants and I\u0026rsquo;m using the middle one for simplicity. For this circuit, we need a transistor. I\u0026rsquo;m not using the prebiased one shown there (50V, 100mA, 2.2kOhm and 47kOhm) since it\u0026rsquo;s extended part and has $3 extra charge. Instead I\u0026rsquo;m using a 40V, 200mA NPN transistor and adding external resistors.\nFuse # Ferris used \u0026ldquo;Polymeric 15V 1A 100ms 750mΩ 1206 PTC Resettable Fuses RoHS\u0026rdquo;, however the model is OOS, so I searched on LCSC and found C369167 which has similar spec. For the diode, Ferris used C435473, and I\u0026rsquo;m using the same.\nSchottky Diode # Ferris used RB060MM-30, which is 30V vr, 490mV vf. I use SS14 which is basic part in JLCPCB. It is \u0026ldquo;40V 1A 550mV @ 1A\u0026rdquo; which is pretty close.\nResistors and capacitors # I\u0026rsquo;m using all 0603 footprint.\nTODO # Some changes to apply to next design:\nAdd 4.7k pull up to i2c pins. Use 8Mhz crystal instead of 25MHz. ref ","date":"11 April 2021","externalUrl":null,"permalink":"/posts/keyboard-pcb-design/","section":"Posts","summary":"Feature highlights # Initially, I wanted to build a split board. However, since I already have a split (crkbd), making a Arteus/Reviuge-like board makes more sense.\nThe design is using aggressive stagger (same column stagger like my split design), and 15 degree angle of columns.\nOther features:\nOLED status display RGB underglow Audio Encoder Design considerations for STM32 # I’ll be using F411 due to lack of F072 stock.\n","title":"Keyboard Pcb Design","type":"posts"},{"content":"","date":"24 January 2021","externalUrl":null,"permalink":"/books/amateurs-mind/","section":"Books","summary":"","title":"Amateur's Mind","type":"books"},{"content":"","date":"24 January 2021","externalUrl":null,"permalink":"/books/","section":"Books","summary":"","title":"Books","type":"books"},{"content":"","date":"24 January 2021","externalUrl":null,"permalink":"/categories/bookstudy/","section":"Categories","summary":"","title":"Bookstudy","type":"categories"},{"content":"","date":"24 January 2021","externalUrl":null,"permalink":"/tags/pawn/","section":"Tags","summary":"","title":"Pawn","type":"tags"},{"content":"","date":"24 January 2021","externalUrl":null,"permalink":"/tags/positional/","section":"Tags","summary":"","title":"Positional","type":"tags"},{"content":"","date":"17 January 2021","externalUrl":null,"permalink":"/tags/imbalance/","section":"Tags","summary":"","title":"Imbalance","type":"tags"},{"content":"","date":"16 January 2021","externalUrl":null,"permalink":"/books/chess-fundamentals/","section":"Books","summary":"","title":"Chess Fundamentals","type":"books"},{"content":"","date":"16 January 2021","externalUrl":null,"permalink":"/tags/end-game/","section":"Tags","summary":"","title":"End Game","type":"tags"},{"content":"","date":"16 January 2021","externalUrl":null,"permalink":"/tags/fundamental/","section":"Tags","summary":"","title":"Fundamental","type":"tags"}]