<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <id>https://blog.elephantgris.space/</id>
  <title type="text">Gray Space</title>
  <subtitle type="text">Blog</subtitle>
  <updated>2026-09-22T00:00:00.000Z</updated>
  <author><name>Chris Gray</name></author>
  <link rel="alternate" href="https://blog.elephantgris.space/"/>
  <link rel="self" href="https://blog.elephantgris.space/atom.xml"/>
  <generator uri="https://github.com/CuteLeaf/Firefly">Firefly v6.16.8</generator>
    <entry>
      <id>https://blog.elephantgris.space/posts/8f0fc83d5f501213/</id>
      <title type="text">Firefly Obsidian 文章同步功能开发</title>
      <published>2026-09-02T00:00:00.000Z</published>
      <updated>2026-09-22T00:00:00.000Z</updated>
      <author><name>Chris Gray</name></author>
      <link rel="alternate" href="https://blog.elephantgris.space/posts/8f0fc83d5f501213/"/>
      <summary type="text">以 Obsidian 知识库作为文章唯一内容源，将指定文档单向同步到 Firefly 的文章目录。</summary>
      <content type="html"><![CDATA[<section><h1>需求分析<a href="#需求分析"><span>#</span></a></h1><p>博客文章首先在 Obsidian 中撰写，过去一篇文章准备发布时，需要先复制到 Firefly 的 <code>src/content/posts</code> 目录，再把 frontmatter 改写成博客所需的。该流程容易漏掉正文或元数据，或是内容出现更新时需要手动在两侧更新。</p></section>
<section><h1>解决方案<a href="#解决方案"><span>#</span></a></h1><section><h2>基本流程<a href="#基本流程"><span>#</span></a></h2><p>改造后的流程只保留一个内容源：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>Obsidian 中撰写并标记文章</span></div></div><div><div><div>2</div></div><div><span><span>              </span></span><span>↓</span></div></div><div><div><div>3</div></div><div><span><span>        </span></span><span>pnpm sync-post</span></div></div><div><div><div>4</div></div><div><span><span>              </span></span><span>↓</span></div></div><div><div><div>5</div></div><div><span>src/content/posts/&lt;category&gt;/&lt;title&gt;.md</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>Obsidian 保存可继续编辑的原文，博客目录只保存生成的发布副本。所有待同步文章会先完成字段、slug 和路径校验；检查通过后，缺失的文章被创建，已经存在的文章只有在同步字段或正文发生变化时才会更新。</p><p>标记文章和同步博客属于两个不同阶段。</p><p>Obsidian 端只负责准备发布元数据。这样可以在写作时决定文章是否发布，并自动填充日期、slug 等过程字段，但不需要知道博客仓库位于哪里，也不负责解释 Astro 的内容结构。</p><p>博客端负责扫描、字段转换和文件写入。由博客端脚本统一校验更直接，也避免把博客构建逻辑塞进 Obsidian 端。</p><p>两步之间以 frontmatter 为接口：只要源文档满足字段约定，博客脚本就能完成后续工作。</p></section></section>
<section><h1>功能设计<a href="#功能设计"><span>#</span></a></h1><section><h2>配置知识库路径<a href="#配置知识库路径"><span>#</span></a></h2><p>在博客项目根目录配置 <code>.env</code> ，指定 Obsidian 知识库的绝对路径：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>OBSIDIAN_VAULT_ROOT=/absolute/path/to/Obsidian</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h2>标记博客文章<a href="#标记博客文章"><span>#</span></a></h2><p>需要发布的文档使用 <code>blog: true</code> 标记，并维护以 <code>blog_</code> 开头的博客字段：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>---</span></div></div><div><div><div>2</div></div><div><span>blog</span><span>: </span><span>true</span></div></div><div><div><div>3</div></div><div><span>blog_title</span><span>: </span><span>Firefly Obsidian 文章同步功能开发</span></div></div><div><div><div>4</div></div><div><span>blog_description</span><span>: </span><span>以 Obsidian 作为唯一内容源，将指定文档同步到 Firefly。</span></div></div><div><div><div>5</div></div><div><span>blog_category</span><span>: </span><span>开发</span></div></div><div><div><div>6</div></div><div><span>blog_published</span><span>: </span><span>2026-09-02</span></div></div><div><div><div>7</div></div><div><span>blog_slug</span><span>: </span><span>8f0fc83d5f501213</span></div></div><div><div><div>8</div></div><div><span>blog_series</span><span>: </span><span>Firefly 改造计划</span></div></div><div><div><div>9</div></div><div><span>tags</span><span>:</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>- </span><span>Firefly</span></div></div><div><div><div>11</div></div><div><span><span>  </span></span><span>- </span><span>Obsidian</span></div></div><div><div><div>12</div></div><div><span>---</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>这些字段可以由 Obsidian 端的 QuickAdd 插件 Macro 调用脚本插入：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>module</span><span>.</span><span>exports</span><span> </span><span>=</span><span> </span><span>async</span><span><span> ({ </span><span>app</span><span> }) </span></span><span>=&gt;</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>const</span><span> </span><span>file</span><span> </span><span>=</span><span><span> </span><span>app</span><span>.</span></span><span>workspace</span><span>.</span><span>getActiveFile</span><span>();</span></div></div><div><div><div>3</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span><span>file</span><span> </span></span><span>||</span><span><span> </span><span>file</span><span>.</span></span><span>extension</span><span> </span><span>!==</span><span> </span><span>"md"</span><span>) {</span></div></div><div><div><div>4</div></div><div><span>    </span><span>throw</span><span> </span><span>new</span><span> </span><span>Error</span><span>(</span><span>"Please open your note for blog."</span><span>);</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>  </span><span>await</span><span><span> </span><span>app</span><span>.</span></span><span>fileManager</span><span>.</span><span>processFrontMatter</span><span><span>(</span><span>file</span><span>, (</span><span>frontmatter</span><span>) </span></span><span>=&gt;</span><span> {</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>frontmatter</span><span>.</span><span>blog</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>  </span><span>const</span><span> </span><span>defaults</span><span> </span><span>=</span><span> {</span></div></div><div><div><div>11</div></div><div><span>    </span><span>blog_title</span><span>:</span><span> </span><span>""</span><span>,</span></div></div><div><div><div>12</div></div><div><span>    </span><span>blog_description</span><span>:</span><span> </span><span>""</span><span>,</span></div></div><div><div><div>13</div></div><div><span>    </span><span>blog_category</span><span>:</span><span> </span><span>""</span><span>,</span></div></div><div><div><div>14</div></div><div><span>    </span><span>blog_published</span><span>:</span><span><span> </span><span>createBlogPublished</span><span>,</span></span></div></div><div><div><div>15</div></div><div><span>    </span><span>blog_slug</span><span>:</span><span><span> </span><span>createBlogSlug</span><span>,</span></span></div></div><div><div><div>16</div></div><div><span>    </span><span>blog_series</span><span>:</span><span> </span><span>""</span><span>,</span></div></div><div><div><div>17</div></div><div><span><span>  </span></span><span>};</span></div></div><div><div><div>18</div></div><div>
</div></div><div><div><div>19</div></div><div><span>  </span><span>for</span><span> (</span><span>const</span><span> [</span><span>key</span><span>, </span><span>value</span><span>] </span><span>of</span><span><span> </span><span>Object</span><span>.</span></span><span>entries</span><span><span>(</span><span>defaults</span><span>)) {</span></span></div></div><div><div><div>20</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span><span>Object</span><span>.</span></span><span>hasOwn</span><span><span>(</span><span>frontmatter</span><span>, </span><span>key</span><span>)) {</span></span></div></div><div><div><div>21</div></div><div><span><span>    </span></span><span>frontmatter</span><span>[</span><span>key</span><span>] </span><span>=</span></div></div><div><div><div>22</div></div><div><span>      </span><span>typeof</span><span><span> </span><span>value</span><span> </span></span><span>===</span><span> </span><span>"function"</span><span> </span><span>?</span><span> </span><span>value</span><span>() </span><span>:</span><span><span> </span><span>value</span><span>;</span></span></div></div><div><div><div>23</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>24</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>25</div></div><div><span><span>  </span></span><span>});</span></div></div><div><div><div>26</div></div><div>
</div></div><div><div><div>27</div></div><div><span>  </span><span>new</span><span> </span><span>Notice</span><span>(</span><span>"Blog frontmatter updated!"</span><span>);</span></div></div><div><div><div>28</div></div><div><span>};</span></div></div><div><div><div>29</div></div><div>
</div></div><div><div><div>30</div></div><div><span>function</span><span> </span><span>createBlogPublished</span><span>() {</span></div></div><div><div><div>31</div></div><div><span>  </span><span>const</span><span> </span><span>today</span><span> </span><span>=</span><span> </span><span>new</span><span> </span><span>Date</span><span>();</span></div></div><div><div><div>32</div></div><div><span>  </span><span>const</span><span> </span><span>year</span><span> </span><span>=</span><span><span> </span><span>today</span><span>.</span></span><span>getFullYear</span><span>();</span></div></div><div><div><div>33</div></div><div><span>  </span><span>const</span><span> </span><span>month</span><span> </span><span>=</span><span> </span><span>String</span><span><span>(</span><span>today</span><span>.</span></span><span>getMonth</span><span>() </span><span>+</span><span> </span><span>1</span><span>).</span><span>padStart</span><span>(</span><span>2</span><span>, </span><span>"0"</span><span>);</span></div></div><div><div><div>34</div></div><div><span>  </span><span>const</span><span> </span><span>day</span><span> </span><span>=</span><span> </span><span>String</span><span><span>(</span><span>today</span><span>.</span></span><span>getDate</span><span>()).</span><span>padStart</span><span>(</span><span>2</span><span>, </span><span>"0"</span><span>);</span></div></div><div><div><div>35</div></div><div>
</div></div><div><div><div>36</div></div><div><span>  </span><span>return</span><span> </span><span>`</span><span>${</span><span>year</span><span>}</span><span>-</span><span>${</span><span>month</span><span>}</span><span>-</span><span>${</span><span>day</span><span>}</span><span>`</span><span>;</span></div></div><div><div><div>37</div></div><div><span>}</span></div></div><div><div><div>38</div></div><div>
</div></div><div><div><div>39</div></div><div><span>function</span><span> </span><span>createBlogSlug</span><span>() {</span></div></div><div><div><div>40</div></div><div><span>  </span><span>return</span><span> </span><span>require</span><span>(</span><span>"node:crypto"</span><span>).</span><span>randomBytes</span><span>(</span><span>8</span><span>).</span><span>toString</span><span>(</span><span>"hex"</span><span>);</span></div></div><div><div><div>41</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div></section><section><h2>同步博客文章<a href="#同步博客文章"><span>#</span></a></h2><p><code>scripts/sync-post.ts</code> 的入口只组织四个阶段：完整读取源（Obsidian 端）文章、完整读取目标（Firefly 端）文章、检查两端冲突、按变化写入目标文件。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>async</span><span> </span><span>function</span><span> </span><span>main</span><span>()</span><span>:</span><span> </span><span>Promise</span><span><span>&lt;</span><span>void</span><span>&gt; {</span></span></div></div><div><div><div>2</div></div><div><span>  </span><span>const</span><span> </span><span>vaultRootPath</span><span> </span><span>=</span><span> </span><span>readVaultRootPath</span><span>();</span></div></div><div><div><div>3</div></div><div><span>  </span><span>await</span><span><span> </span><span>fs</span><span>.</span></span><span>access</span><span><span>(</span><span>vaultRootPath</span><span>);</span></span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>console</span><span>.</span><span>log</span><span>(</span><span>"Reading source and target posts..."</span><span>);</span></div></div><div><div><div>6</div></div><div><span>  </span><span>const</span><span> </span><span>sourcePosts</span><span> </span><span>=</span><span> </span><span>await</span><span> </span><span>readSourcePosts</span><span><span>(</span><span>vaultRootPath</span><span>);</span></span></div></div><div><div><div>7</div></div><div><span>  </span><span>const</span><span> </span><span>targetPostsBySlug</span><span> </span><span>=</span><span> </span><span>await</span><span> </span><span>readTargetPosts</span><span>();</span></div></div><div><div><div>8</div></div><div><span>  </span><span>assertNoPostSyncConflicts</span><span><span>(</span><span>sourcePosts</span><span>, </span><span>targetPostsBySlug</span><span>);</span></span></div></div><div><div><div>9</div></div><div><span>  </span><span>await</span><span> </span><span>syncPosts</span><span><span>(</span><span>sourcePosts</span><span>, </span><span>targetPostsBySlug</span><span>);</span></span></div></div><div><div><div>10</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>在博客项目根目录运行：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>pnpm</span><span> </span><span>sync-post</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>上面的源文档将写入：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>src/content/posts/开发/Firefly Obsidian 文章同步功能开发.md</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>目标文章只保留博客需要的 frontmatter，正文则来自源文档：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>---</span></div></div><div><div><div>2</div></div><div><span>title</span><span>: </span><span>Firefly Obsidian 文章同步功能开发</span></div></div><div><div><div>3</div></div><div><span>published</span><span>: </span><span>'2026-09-02'</span></div></div><div><div><div>4</div></div><div><span>description</span><span>: </span><span>以 Obsidian 作为唯一内容源，将指定文档同步到 Firefly。</span></div></div><div><div><div>5</div></div><div><span>category</span><span>: </span><span>开发</span></div></div><div><div><div>6</div></div><div><span>series</span><span>: </span><span>Firefly 改造计划</span></div></div><div><div><div>7</div></div><div><span>slug</span><span>: </span><span>8f0fc83d5f501213</span></div></div><div><div><div>8</div></div><div><span>tags</span><span>:</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>- </span><span>Firefly</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>- </span><span>Obsidian</span></div></div><div><div><div>11</div></div><div><span>updated</span><span>: </span><span>'2026-09-02'</span></div></div><div><div><div>12</div></div><div><span>---</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h2>扫描带有 Blog 标记的文档<a href="#扫描带有-blog-标记的文档"><span>#</span></a></h2><p>扫描源（Obsidian 知识库）中的 Markdown 文档，同时跳过 Obsidian 配置和回收站目录：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>filePaths</span><span> </span><span>=</span><span> </span><span>await</span><span> </span><span>glob</span><span>(</span><span>"**/*.md"</span><span>, {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>absolute</span><span>:</span><span> </span><span>true</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>cwd</span><span>:</span><span><span> </span><span>vaultRootPath</span><span>,</span></span></div></div><div><div><div>4</div></div><div><span>  </span><span>ignore</span><span>:</span><span> [</span><span>".obsidian/**"</span><span>, </span><span>".trash/**"</span><span>],</span></div></div><div><div><div>5</div></div><div><span>  </span><span>nodir</span><span>:</span><span> </span><span>true</span><span>,</span></div></div><div><div><div>6</div></div><div><span>});</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>源端完整扫描后会分批读取文件，每批最多并行读取 32 个，避免大量云盘文件串行读取时长时间没有响应，也避免一次性并发读取给云盘造成过大压力。读取失败时直接报告具体文件路径。</p><p>读取源文档的 frontmatter 和正文，只保留 <code>blog</code> 等于 <code>true</code> 的文档：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>matterFile</span><span> </span><span>=</span><span> </span><span>matter</span><span><span>(</span><span>source</span><span>);</span></span></div></div><div><div><div>2</div></div><div><span>if</span><span><span> (</span><span>matterFile</span><span>.</span></span><span>data</span><span>.</span><span>blog</span><span> </span><span>!==</span><span> </span><span>true</span><span>) {</span></div></div><div><div><div>3</div></div><div><span>  </span><span>continue</span><span>;</span></div></div><div><div><div>4</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h2>校验并转换 Frontmatter<a href="#校验并转换-frontmatter"><span>#</span></a></h2><p>源字段与目标字段存在映射关系，同时应满足相应的校验约束：</p>

<table><thead><tr><th>Obsidian 字段</th><th>Firefly 字段</th><th>约束</th></tr></thead><tbody><tr><td><code>blog_title</code></td><td><code>title</code></td><td>非空字符串</td></tr><tr><td><code>blog_description</code></td><td><code>description</code></td><td>可缺失或为空</td></tr><tr><td><code>blog_category</code></td><td><code>category</code></td><td>非空字符串</td></tr><tr><td><code>blog_published</code></td><td><code>published</code></td><td><code>YYYY-MM-DD</code></td></tr><tr><td><code>blog_slug</code></td><td><code>slug</code></td><td>非空并复用博客 slug 规则</td></tr><tr><td><code>blog_series</code></td><td><code>series</code></td><td>可缺失或为空</td></tr><tr><td><code>tags</code></td><td><code>tags</code></td><td>字符串数组，默认为空数组</td></tr></tbody></table><p>源端和目标端的字段名不同，但转换后使用同一个 <code>syncFrontmatterSchema</code>。<code>sourceFrontmatterSchema</code> 只负责校验 <code>blog_*</code> 字段并完成名称映射，后续比较和写入统一使用转换后的结构。</p></section><section><h2>用 Slug 确认身份，用路径确认位置<a href="#用-slug-确认身份用路径确认位置"><span>#</span></a></h2><p><code>title</code> 和 <code>category</code> 可能变化，不能单独作为文章的 ID； <code>slug</code> 是业务无关的，正常情况下不会变更，可以用于确认文章的身份，视为文章 ID，用于关联两端的相同文章。</p><p>理论上只有相同文章才会写入同一博客端路径，但原则上不同文章也可以配置出相同的路径（仅与 <code>title</code> 与 <code>category</code> 相关），这可能导致原有文章被覆盖的情况。因此同步前仅检查 <code>slug</code> 还不够，还需要建立反向的路径 -&gt; slug 索引，并按以下规则处理：</p>

<table><thead><tr><th>源文章与博客文章的关系</th><th>结果</th></tr></thead><tbody><tr><td>slug 不存在，目标路径未占用</td><td>新建文章</td></tr><tr><td>slug 相同，目标路径相同</td><td>原地更新</td></tr><tr><td>slug 相同，目标路径不同</td><td>报错，避免保留两个副本</td></tr><tr><td>slug 不同，目标路径相同</td><td>报错，避免覆盖另一篇文章</td></tr></tbody></table></section><section><h2>写入目标文章<a href="#写入目标文章"><span>#</span></a></h2><p>同步以 slug 关联两端文章，并比较转换后的 frontmatter 和 Markdown 正文。比较时不包含目标文章已有的 <code>updated</code> 字段：</p>

<table><thead><tr><th>情况</th><th>写入行为</th><th><code>updated</code></th></tr></thead><tbody><tr><td>目标文章不存在</td><td>创建新文章</td><td>与 <code>published</code> 相同</td></tr><tr><td>目标文章存在，内容没有变化</td><td>不写入文件</td><td>保留原值</td></tr><tr><td>目标文章存在，frontmatter 或正文发生变化</td><td>使用源文档覆盖</td><td>更新为同步当天日期</td></tr></tbody></table><p>因此重复运行同步命令不会主动覆盖未变化的文章，也不会无故刷新文件修改时间和 <code>updated</code>。发生变化时，博客副本中额外添加但源端没有对应字段的 frontmatter 仍不会保留。</p><p>同步完成后输出本次创建、更新和未变化的文章数量：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>Created: 1, updated: 2, unchanged: 3.</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.elephantgris.space/posts/069ba0f6eb66dbf0/</id>
      <title type="text">Firefly 文章 slug 自动生成功能开发</title>
      <published>2026-08-30T00:00:00.000Z</published>
      <updated>2026-09-04T00:00:00.000Z</updated>
      <author><name>Chris Gray</name></author>
      <link rel="alternate" href="https://blog.elephantgris.space/posts/069ba0f6eb66dbf0/"/>
      <summary type="text">文章 slug 依赖标题拼音，标题变化后链接容易失配；新增随机 slug 模式，在开发阶段补全缺失值，并在新建文章时按分类与标题创建文件、生成并校验唯一 slug。</summary>
      <content type="html"><![CDATA[<section><h1>需求描述<a href="#需求描述"><span>#</span></a></h1><p>当前 Firefly 的文章 <code>slug</code> 仅支持依赖文章标题的拼音转写的生成模式，该模式与文章标题强耦合，文章标题修改会导致其与链接不一致。希望新增一种业务无关的 <code>slug</code> 模式，同时支持自动检测并补全缺失 <code>slug</code> 的文章。</p></section>
<section><h1>功能设计<a href="#功能设计"><span>#</span></a></h1><section><h2>新增 <code>Slug</code> 模式配置<a href="#新增-slug-模式配置"><span>#</span></a></h2><p>在文章内容页配置中增加 <code>slug</code> 模式配置项：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>post</span><span>: {</span></span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>slug</span><span>: </span><span>"random"</span><span>,</span></div></div><div><div><div>3</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>配置类型同步新增：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>slug</span><span>: </span></span><span>"pinyin"</span><span> </span><span>|</span><span> </span><span>"random"</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>其他模块通过 <code>PostSlugMode</code> 复用该配置项的派生类型，避免再次定义相同的模式范围。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>export</span><span> </span><span>type</span><span> </span><span>PostSlugMode</span><span> </span><span>=</span><span> </span><span>SiteConfig</span><span>[</span><span>"post"</span><span>][</span><span>"slug"</span><span>];</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h2>生成业务无关的随机 Slug<a href="#生成业务无关的随机-slug"><span>#</span></a></h2><p><code>random</code> 模式使用 Node.js 的 <code>crypto.randomBytes</code> 生成 8 字节随机数，再转换为 16 位小写十六进制字符串。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>function</span><span> </span><span>createRandomSlug</span><span>()</span><span>:</span><span> </span><span>string</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>return</span><span> </span><span>randomBytes</span><span>(</span><span>8</span><span>).</span><span>toString</span><span>(</span><span>"hex"</span><span>);</span></div></div><div><div><div>3</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>随机值不读取文章标题、正文、分类或路径，因此这些业务内容发生变化时不会影响已写入的 slug。</p></section><section><h2>统一 Slug 校验<a href="#统一-slug-校验"><span>#</span></a></h2><p>随机 slug 和拼音 slug 的格式不同，因此公共校验只要求 slug 是非空字符串：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>export</span><span> </span><span>const</span><span> </span><span>postSlugSchema</span><span> </span><span>=</span><span><span> </span><span>z</span><span>.</span></span><span>string</span><span>().</span><span>trim</span><span>().</span><span>min</span><span>(</span><span>1</span><span>);</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>文章扫描、新建文章和 Obsidian 文章同步复用同一规则，避免不同脚本分别定义 slug 格式。</p></section><section><h2><code>setup</code> 生命周期阶段补全 Slug<a href="#setup-生命周期阶段补全-slug"><span>#</span></a></h2><p>通过 Astro Integration 接入配置生命周期：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>integrations</span><span>: [</span></span></div></div><div><div><div>2</div></div><div><span>  </span><span>postSlugIntegration</span><span><span>(</span><span>siteConfig</span><span>.</span></span><span>post</span><span>.</span><span>slug</span><span>),</span></div></div><div><div><div>3</div></div><div><span>]</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>该 Integration 在 <code>astro:config:setup</code> 阶段检查文章，仅允许 <code>dev</code> 命令写入缺失的 slug：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>builtSlugs</span><span> </span><span>=</span><span> </span><span>await</span><span> </span><span>buildPostSlugs</span><span>({</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>mode</span><span>,</span></div></div><div><div><div>3</div></div><div><span>  </span><span>write</span><span>:</span><span><span> </span><span>command</span><span> </span></span><span>===</span><span> </span><span>"dev"</span><span>,</span></div></div><div><div><div>4</div></div><div><span>});</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>运行 <code>pnpm dev</code> 时，缺少 <code>slug</code> 的文章会生成随机 slug 并写回 frontmatter。已有非空 <code>slug</code> 不会重新生成。运行构建、检查、Astro 同步或预览命令时只进行校验，如果仍有文章缺少 <code>slug</code> ，流程会报错提醒。</p></section><section><h2>创建文章时直接生成随机 Slug<a href="#创建文章时直接生成随机-slug"><span>#</span></a></h2><p>新建文章命令接收必填的分类和可选的标题：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>pnpm</span><span> </span><span>new-post</span><span> </span><span>&lt;category&gt;</span><span> [title]</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>标题为空时使用 <code>buildPublishedDate()</code> 生成的发布日期，随后直接以分类和标题生成 <code>src/content/posts/&lt;category&gt;/&lt;title&gt;.md</code>。当前配置为 <code>random</code> 时，新文章会立即得到 16 位随机 slug。写入文件前还会检查该值是否已被其他文章占用：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>published</span><span> </span><span>=</span><span> </span><span>buildPublishedDate</span><span>();</span></div></div><div><div><div>2</div></div><div><span>const</span><span> </span><span>title</span><span> </span><span>=</span><span><span> </span><span>titleArgument</span><span> </span></span><span>||</span><span><span> </span><span>published</span><span>;</span></span></div></div><div><div><div>3</div></div><div><span>const</span><span> </span><span>filePath</span><span> </span><span>=</span><span> </span><span>buildPostFilePath</span><span><span>(</span><span>category</span><span>, </span><span>title</span><span>);</span></span></div></div><div><div><div>4</div></div><div><span>const</span><span> </span><span>slug</span><span> </span><span>=</span><span> </span><span>createPostSlug</span><span><span>(</span><span>title</span><span>, </span><span>siteConfig</span><span>.</span></span><span>post</span><span>.</span><span>slug</span><span>);</span></div></div><div><div><div>5</div></div><div><span>await</span><span> </span><span>assertPostSlugAvailable</span><span><span>(</span><span>slug</span><span>);</span></span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>检查通过后，随机 slug 与其他 frontmatter 字段一同写入新文章，使文章从创建时开始就使用稳定的访问路径。文件名由标题决定，访问路径由 slug 决定，二者互不替代。</p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.elephantgris.space/posts/37c1d86c687d3b5f/</id>
      <title type="text">Firefly Footer 组件页面置底布局功能开发【已过时】</title>
      <published>2026-08-29T00:00:00.000Z</published>
      <updated>2026-09-14T00:00:00.000Z</updated>
      <author><name>Chris Gray</name></author>
      <link rel="alternate" href="https://blog.elephantgris.space/posts/37c1d86c687d3b5f/"/>
      <summary type="text">短页面的 Footer 会紧跟正文而无法贴近视口底部；通过逐层继承最小高度、调整响应式 Grid 行结构并恢复移动端 Grid 布局，使 Footer 在短页面置底、长页面随内容延伸。</summary>
      <content type="html"><![CDATA[<section><h1>需求描述<a href="#需求描述"><span>#</span></a></h1><p>当前 Firefly 中 Footer 组件的高度原本由文章内容决定，文章较短时，Footer 会紧跟正文显示。希望修改为始终保持在当前窗口的底部，使得短页面的 Footer 位于视口底部，长页面的 Footer 仍位于全部内容之后。</p></section>
<section><h1>功能设计<a href="#功能设计"><span>#</span></a></h1><section><h2>传递内容面板的最小高度<a href="#传递内容面板的最小高度"><span>#</span></a></h2><p><code>.content-panel</code> 已根据内容顶部位置计算视口内的剩余高度，但由于 <code>.content-panel</code> 与 <code>#main-grid</code> 之间还有一层容器， <code>min-height</code> 不会默认传递给子元素。进而导致 Footer 组件无法利用视口内的剩余空间。因此，需要将最小高度逐层传递到实际负责布局的 <code>#main-grid</code> 。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>"relative min-h-[inherit] ..."</span><span>&gt;</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>&lt;</span><span>div</span><span> </span><span>id</span><span>=</span><span>"main-grid"</span><span> </span><span>class</span><span>=</span><span>"... min-h-[inherit] ..."</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ul>
<li>第一处 <code>min-h-[inherit]</code>：中间容器继承 <code>.content-panel</code> 的最小高度。</li>
<li>第二处 <code>min-h-[inherit]</code>：<code>#main-grid</code> 继续继承中间容器的最小高度。</li>
</ul><p>这里使用最小高度而不是固定高度，目的是使短页面至少占满视口剩余区域，长页面仍可随正文自然增长。</p></section><section><h2>使用 Grid 分配剩余高度并定位 Footer<a href="#使用-grid-分配剩余高度并定位-footer"><span>#</span></a></h2><p>仅让 <code>#main-grid</code> 占满视口剩余高度，还不会自动把 Footer 组件推到底部。普通内容仍会从顶部依次排列，并将多余空间留在 Footer 组件之后。同时，移动端底部组件是可选内容，侧边栏数量也会随页面和配置变化，如果完全依赖 Grid 自动排列，Footer 可能进入用于吸收剩余空间的行。因此，需要同时定义响应式 Grid 行结构，并明确 Footer 在其中的行位置。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>&lt;</span><span>div</span></div></div><div><div><div>2</div></div><div><span>  </span><span>id</span><span>=</span><span>"main-grid"</span></div></div><div><div><div>3</div></div><div><span>  </span><span>class</span><span>=</span><span>"... grid-rows-[auto_1fr_auto] md:grid-rows-[1fr_auto] ..."</span></div></div><div><div><div>4</div></div><div><span>&gt;</span></div></div><div><div><div>5</div></div><div><span><span>  </span></span><span>...</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>&lt;</span><span>div</span><span> </span><span>class</span><span>=</span><span>{</span><span><span>`</span><span>${</span></span><span>footerClassName</span><span><span>}</span><span> row-start-3 md:row-start-2`</span></span><span>}</span><span>&gt;</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>&lt;</span><span>Footer</span><span>&gt;&lt;/</span><span>Footer</span><span>&gt;</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>&lt;/</span><span>div</span><span>&gt;</span></div></div><div><div><div>9</div></div><div><span>&lt;/</span><span>div</span><span>&gt;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>移动端分为三行：正文使用第一行，移动端底部组件或剩余空间使用第二行，Footer 使用第三行。第二行的 <code>1fr</code> 会吸收正文之外的剩余高度，<code>row-start-3</code> 则确保 Footer 始终从第三行开始。</p><p>平板和桌面端分为两行：正文与侧边栏使用第一行，Footer 使用第二行。第一行的 <code>1fr</code> 吸收剩余高度，第二行的 <code>auto</code> 仅保留 Footer 实际需要的高度，<code>md:row-start-2</code> 则将 Footer 固定在第二行。</p><p>原有的 <code>footerClassName</code> 继续负责 Footer 的列位置。当正文较短时， <code>1fr</code> 将 Footer 推到视口底部；正文较长时，Grid 随内容增长，Footer 继续排列在完整内容之后。</p></section><section><h2>恢复移动端 Grid 布局<a href="#恢复移动端-grid-布局"><span>#</span></a></h2><p>移动端原有样式将 <code>.mobile-no-sidebar</code> 强制设为 <code>display: block !important</code>，会覆盖 <code>#main-grid</code> 的 <code>display: grid</code>。如果继续保留，前面设置的 Grid 行结构、<code>1fr</code> 和 Footer 行号都会失效。因此，需要删除冲突的 <code>display</code> 声明，并继续保留原有宽度规则。</p><p>更新后的移动端样式如下：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>.mobile-no-sidebar</span><span> {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>width: </span><span><span>100</span><span>%</span></span><span> </span><span>!important</span><span>;</span></div></div><div><div><div>3</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>删除覆盖后，移动端的 <code>grid-rows-[auto_1fr_auto]</code>、<code>1fr</code> 和 <code>row-start-3</code> 才能生效，其他移动端宽度设置保持不变。</p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.elephantgris.space/posts/339b7202f83f6b9b/</id>
      <title type="text">Firefly 头像组件圆形旋转效果功能开发</title>
      <published>2026-08-28T00:00:00.000Z</published>
      <updated>2026-09-04T00:00:00.000Z</updated>
      <author><name>Chris Gray</name></author>
      <link rel="alternate" href="https://blog.elephantgris.space/posts/339b7202f83f6b9b/"/>
      <summary type="text">侧边栏头像原为静态方形，缺少悬停反馈；通过正方形约束和圆形裁切统一外观，并为内部图片添加悬停旋转与移出归位动画。</summary>
      <content type="html"><![CDATA[<section><h1>需求描述<a href="#需求描述"><span>#</span></a></h1><p>Firefly 侧边栏的个人资料头像当前为正方形静态效果，希望改为鼠标悬停时旋转一圈，移开时反向旋转归位的效果。为适配该效果，头像应同步修改为圆形，尺寸也可以有所调整。</p></section>
<section><h1>功能设计<a href="#功能设计"><span>#</span></a></h1><section><h2>自适应圆形头像组件<a href="#自适应圆形头像组件"><span>#</span></a></h2><p><code>src/components/widget/Profile.astro</code> 的头像组件保留响应式最大宽度，增加正方形约束和圆形裁切：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>class="group block relative mx-auto mt-1 lg:mt-0 mb-3</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>max-w-36 lg:max-w-48 aspect-square overflow-hidden rounded-full active:scale-95"</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ul>
<li><code>aspect-square</code> ：容器高度始终等于宽度，即使源图片不是正方形。</li>
<li><code>overflow-hidden rounded-full</code>：将容器及内部内容裁为圆形。</li>
</ul></section><section><h2>悬停旋转<a href="#悬停旋转"><span>#</span></a></h2><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>:global(</span><span>.profile-avatar-image</span><span>) {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>transition: transform </span><span><span>1</span><span>s</span></span><span><span> </span><span>ease-out</span><span>;</span></span></div></div><div><div><div>3</div></div><div><span>}</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span><span>.group</span><span>:hover</span></span><span> :global(</span><span>.profile-avatar-image</span><span>) {</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>transform: </span><span>rotate</span><span>(</span><span><span>360</span><span>deg</span></span><span>);</span></div></div><div><div><div>7</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><ul>
<li><code>transform 1s ease-out</code> ：设置动画为一秒，效果为以慢速结束的过渡动画。</li>
<li><code>rotate(360deg)</code> ：旋转 360 度。</li>
</ul><p>该元素为 <code>ImageWrapper</code> 生成的图片包装元素的内部元素，故样式需要使用 <code>:global()</code> 才能命中内部组件 <code>profile-avatar-image</code> 。</p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.elephantgris.space/posts/04fe8e2f6415ee6e/</id>
      <title type="text">Obsidian Kanban Bases View 卡片排序功能开发</title>
      <published>2026-08-21T00:00:00.000Z</published>
      <updated>2026-08-21T00:00:00.000Z</updated>
      <author><name>Chris Gray</name></author>
      <link rel="alternate" href="https://blog.elephantgris.space/posts/04fe8e2f6415ee6e/"/>
      <summary type="text">Kanban Bases View 的拖动顺序只能隐式保存，无法写入笔记或供其他视图复用；新增可选 Card order 属性，按序号排序并在同列、跨列拖动后回写连续序号。</summary>
      <content type="html"><![CDATA[<section><h1>需求描述<a href="#需求描述"><span>#</span></a></h1><p>Obsidian Bases 可以通过 Sort 设置卡片的查询顺序，但原生 Card 视图不能通过拖动任意调整卡片顺序。</p><p>Kanban Bases View 支持拖动卡片，但该顺序无法存储在显式属性或被其他视图复用。</p><p>本次开发的目标是在保留原有隐式排序方式的基础上，新增一个可选的 <code>Card order</code> 属性：用户需要可见序号时，将拖动结果写入笔记；不配置该属性时，继续使用原有的隐式配置，不增加笔记字段。</p></section>
<section><h1>功能设计<a href="#功能设计"><span>#</span></a></h1><section><h2>新增可选的 Card order 属性<a href="#新增可选的-card-order-属性"><span>#</span></a></h2><p>在视图设置中新增一个可选属性。未选择时维持原有的 <code>cardOrders</code> 隐式存储；选择后，该属性成为卡片顺序的读取和写入位置。两种模式之间不迁移或清理数据。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>private</span><span> </span><span>cardOrderPropertyId</span><span>: </span><span>BasesPropertyId</span><span> </span></span><span>|</span><span> </span><span>null</span><span> </span><span>=</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span><span>private</span><span> </span></span><span>loadConfig</span><span>(): </span><span>void</span><span> {</span></div></div><div><div><div>4</div></div><div><span>  </span><span>this</span><span>.</span><span>cardOrderPropertyId</span><span> </span><span>=</span><span> </span><span>this</span><span>.</span><span>config</span><span>.</span><span>getAsPropertyId</span><span>(</span><span>'cardOrderProperty'</span><span>);</span></div></div><div><div><div>5</div></div><div><span>}</span></div></div><div><div><div>6</div></div><div>
</div></div><div><div><div>7</div></div><div><span>{</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>displayName</span><span>: </span><span>'Card order'</span><span>,</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>type</span><span>: </span><span>'property'</span><span>,</span></div></div><div><div><div>10</div></div><div><span><span>  </span></span><span>key</span><span>: </span><span>'cardOrderProperty'</span><span>,</span></div></div><div><div><div>11</div></div><div><span><span>  </span></span><span>filter</span><span>: (</span><span>prop</span><span>:</span><span> </span><span>string</span><span>) </span><span>=&gt;</span><span><span> </span><span>prop</span><span>.</span></span><span>startsWith</span><span>(</span><span>'note.'</span><span>),</span></div></div><div><div><div>12</div></div><div><span><span>  </span></span><span>placeholder</span><span>: </span><span>'Optional: card order property'</span><span>,</span></div></div><div><div><div>13</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h2>校验显式属性<a href="#校验显式属性"><span>#</span></a></h2><p>Card order 必须是可写的 <code>note.*</code> 属性，并且不能与 Group by 或 Swimlane by 使用同一属性。否则重新编号可能改变卡片所属的列或泳道，插件会停止渲染并显示配置错误。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>if</span><span> (</span></div></div><div><div><div>2</div></div><div><span>  </span><span>this</span><span>.</span><span>cardOrderPropertyId</span><span> </span><span>&amp;&amp;</span></div></div><div><div><div>3</div></div><div><span><span>  </span></span><span>(</span><span>!</span><span>this</span><span>.</span><span>cardOrderPropertyId</span><span>.</span><span>startsWith</span><span>(</span><span>'note.'</span><span>) </span><span>||</span></div></div><div><div><div>4</div></div><div><span>    </span><span>this</span><span>.</span><span>cardOrderPropertyId</span><span> </span><span>===</span><span> </span><span>this</span><span>.</span><span>groupByPropertyId</span><span> </span><span>||</span></div></div><div><div><div>5</div></div><div><span>    </span><span>this</span><span>.</span><span>cardOrderPropertyId</span><span> </span><span>===</span><span> </span><span>this</span><span>.</span><span>swimlaneByPropertyId</span><span>)</span></div></div><div><div><div>6</div></div><div><span>) {</span></div></div><div><div><div>7</div></div><div><span>  </span><span>this</span><span>.</span><span>fullReset</span><span>();</span></div></div><div><div><div>8</div></div><div><span>  </span><span>this</span><span>.</span><span>containerEl</span><span>.</span><span>createDiv</span><span>({</span></div></div><div><div><div>9</div></div><div><span>    </span><span>text</span><span>:</span><span> </span><span>EMPTY_STATE_MESSAGES</span><span>.</span><span>CARD_ORDER_PROPERTY_INVALID</span><span>,</span></div></div><div><div><div>10</div></div><div><span>    </span><span>cls</span><span>:</span><span> </span><span>CSS_CLASSES</span><span>.</span><span>EMPTY_STATE</span><span>,</span></div></div><div><div><div>11</div></div><div><span><span>  </span></span><span>});</span></div></div><div><div><div>12</div></div><div><span>  </span><span>return</span><span>;</span></div></div><div><div><div>13</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h2>按显式序号排列卡片<a href="#按显式序号排列卡片"><span>#</span></a></h2><p>显式属性接受有限数字和可转换为有限数字的字符串，按数值升序排列。空值和非法值没有显式顺序，统一放在已有序号之后。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>export</span><span> </span><span>function</span><span> </span><span>readCardOrderValue</span><span><span>(</span><span>value</span></span><span>:</span><span> </span><span>unknown</span><span>)</span><span>:</span><span> </span><span>number</span><span> </span><span>|</span><span> </span><span>null</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>if</span><span> (</span><span>typeof</span><span><span> </span><span>value</span><span> </span></span><span>===</span><span> </span><span>'number'</span><span>) </span><span>return</span><span><span> </span><span>Number</span><span>.</span></span><span>isFinite</span><span><span>(</span><span>value</span><span>) </span></span><span>?</span><span><span> </span><span>value</span><span> </span></span><span>:</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>3</div></div><div><span>  </span><span>if</span><span> (</span><span>typeof</span><span><span> </span><span>value</span><span> </span></span><span>!==</span><span> </span><span>'string'</span><span>) </span><span>return</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>4</div></div><div><span>  </span><span>const</span><span> </span><span>trimmed</span><span> </span><span>=</span><span><span> </span><span>value</span><span>.</span></span><span>trim</span><span>();</span></div></div><div><div><div>5</div></div><div><span>  </span><span>if</span><span><span> (</span><span>trimmed</span><span> </span></span><span>===</span><span> </span><span>''</span><span>) </span><span>return</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>6</div></div><div><span>  </span><span>const</span><span> </span><span>numeric</span><span> </span><span>=</span><span> </span><span>Number</span><span><span>(</span><span>trimmed</span><span>);</span></span></div></div><div><div><div>7</div></div><div><span>  </span><span>return</span><span><span> </span><span>Number</span><span>.</span></span><span>isFinite</span><span><span>(</span><span>numeric</span><span>) </span></span><span>?</span><span><span> </span><span>numeric</span><span> </span></span><span>:</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>8</div></div><div><span>}</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>export</span><span> </span><span>function</span><span> </span><span>compareCardOrderValues</span><span><span>(</span><span>a</span></span><span>:</span><span> </span><span>number</span><span> </span><span>|</span><span> </span><span>null</span><span><span>, </span><span>b</span></span><span>:</span><span> </span><span>number</span><span> </span><span>|</span><span> </span><span>null</span><span>)</span><span>:</span><span> </span><span>number</span><span> {</span></div></div><div><div><div>11</div></div><div><span>  </span><span>if</span><span><span> (</span><span>a</span><span> </span></span><span>===</span><span> </span><span>null</span><span>) </span><span>return</span><span><span> </span><span>b</span><span> </span></span><span>===</span><span> </span><span>null</span><span> </span><span>?</span><span> </span><span>0</span><span> </span><span>:</span><span> </span><span>1</span><span>;</span></div></div><div><div><div>12</div></div><div><span>  </span><span>if</span><span><span> (</span><span>b</span><span> </span></span><span>===</span><span> </span><span>null</span><span>) </span><span>return</span><span> </span><span>-</span><span>1</span><span>;</span></div></div><div><div><div>13</div></div><div><span>  </span><span>return</span><span><span> </span><span>a</span><span> </span></span><span>-</span><span><span> </span><span>b</span><span>;</span></span></div></div><div><div><div>14</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>每个列或“泳道 + 列”单元格分别调用该比较逻辑：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>private</span><span> </span></span><span>sortCardEntriesByOrderProperty</span><span><span>(</span><span>entries</span><span>: </span><span>BasesEntry</span><span>[]): </span><span>BasesEntry</span><span>[] {</span></span></div></div><div><div><div>2</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span>this</span><span>.</span><span>cardOrderPropertyId</span><span>) </span><span>return</span><span><span> </span><span>entries</span><span>;</span></span></div></div><div><div><div>3</div></div><div><span>  </span><span>return</span><span> [</span><span>...</span><span><span>entries</span><span>].</span></span><span>sort</span><span><span>((</span><span>a</span><span>, </span><span>b</span><span>) </span></span><span>=&gt;</span></div></div><div><div><div>4</div></div><div><span>    </span><span>compareCardOrderValues</span><span>(</span></div></div><div><div><div>5</div></div><div><span>      </span><span>readCardOrderValue</span><span><span>(</span><span>a</span><span>.</span></span><span>getValue</span><span>(</span><span>this</span><span>.</span><span>cardOrderPropertyId</span><span>)?.</span><span>toString</span><span>()),</span></div></div><div><div><div>6</div></div><div><span>      </span><span>readCardOrderValue</span><span><span>(</span><span>b</span><span>.</span></span><span>getValue</span><span>(</span><span>this</span><span>.</span><span>cardOrderPropertyId</span><span>)?.</span><span>toString</span><span>()),</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>),</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>);</span></div></div><div><div><div>9</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h2>拖动后写回显式序号<a href="#拖动后写回显式序号"><span>#</span></a></h2><p>同列拖动后，显式模式不再写入 <code>cardOrders</code>，而是按照当前 DOM 顺序更新选定属性。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>paths</span><span> </span><span>=</span><span> </span><span>getColumnPaths</span><span><span>(</span><span>evt</span><span>.</span></span><span>to</span><span>);</span></div></div><div><div><div>2</div></div><div><span>if</span><span> (</span><span>this</span><span>.</span><span>cardOrderPropertyId</span><span>) {</span></div></div><div><div><div>3</div></div><div><span>  </span><span>await</span><span> </span><span>this</span><span>.</span><span>writeVisibleCardOrder</span><span><span>(</span><span>paths</span><span>);</span></span></div></div><div><div><div>4</div></div><div><span>} </span><span>else</span><span> {</span></div></div><div><div><div>5</div></div><div><span>  </span><span>this</span><span>.</span><span>_prefs</span><span>.</span><span>cardOrders</span><span><span>[</span><span>newKey</span><span>] </span></span><span>=</span><span><span> </span><span>paths</span><span>;</span></span></div></div><div><div><div>6</div></div><div><span>  </span><span>this</span><span>.</span><span>_persistPrefs</span><span>();</span></div></div><div><div><div>7</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>写回值使用从 1 开始的连续整数。已有值正确时不重复修改；<code>skipPath</code> 用于跳过已经与分组属性一起写入的跨列移动卡片。</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>private</span><span> </span><span>async</span><span> </span></span><span>writeVisibleCardOrder</span><span><span>(</span><span>paths</span><span>: </span><span>string</span><span>[], </span><span>skipPath</span></span><span>?:</span><span><span> </span><span>string</span><span>): </span></span><span>Promise</span><span><span>&lt;</span><span>void</span><span>&gt;</span></span><span> {</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>const </span><span>propertyId</span><span> </span><span>=</span><span> </span><span>this</span><span>.</span><span>cardOrderPropertyId</span><span>;</span></div></div><div><div><div>3</div></div><div><span>  </span><span>if</span><span><span> (!</span><span>propertyId</span><span>) return;</span></span></div></div><div><div><div>4</div></div><div><span>  </span><span>if</span><span> (!propertyId.startsWith(</span><span>'note.'</span><span>)) {</span></div></div><div><div><div>5</div></div><div><span>    </span><span>throw</span><span> </span><span>new</span><span> </span><span>Error</span><span>(</span><span>`Card order property must be a writable note property: </span><span>${</span><span>propertyId</span><span>}</span><span>`</span><span>);</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>7</div></div><div><span>  </span><span>if</span><span> (!this.app?.fileManager) {</span></div></div><div><div><div>8</div></div><div><span>    </span><span>throw</span><span> </span><span>new</span><span> </span><span>Error</span><span>(</span><span>'File manager not available'</span><span>);</span></div></div><div><div><div>9</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>10</div></div><div>
</div></div><div><div><div>11</div></div><div><span><span>  </span></span><span>const </span><span>propertyName</span><span> </span><span>=</span><span> </span><span>parsePropertyId</span><span><span>(</span><span>propertyId</span><span>).</span></span><span>name</span><span>;</span></div></div><div><div><div>12</div></div><div><span><span>  </span></span><span>const </span><span>updates</span><span> </span><span>=</span><span><span> </span><span>paths</span><span>.</span></span><span>flatMap</span><span><span>((</span><span>path</span><span>, </span><span>index</span><span>) </span></span><span>=&gt;</span><span> {</span></div></div><div><div><div>13</div></div><div><span>    </span><span>if</span><span><span> (</span><span>path</span><span> </span></span><span>===</span><span><span> </span><span>skipPath</span><span>) </span></span><span>return</span><span> [];</span></div></div><div><div><div>14</div></div><div><span>    </span><span>const</span><span> </span><span>entry</span><span> </span><span>=</span><span> </span><span>this</span><span>.</span><span>_entryMap</span><span>.</span><span>get</span><span><span>(</span><span>path</span><span>);</span></span></div></div><div><div><div>15</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span><span>entry</span><span>) </span></span><span>throw</span><span> </span><span>new</span><span> </span><span>Error</span><span>(</span><span>`Entry not found for card order: </span><span>${</span><span>path</span><span>}</span><span>`</span><span>);</span></div></div><div><div><div>16</div></div><div><span>    </span><span>const</span><span> </span><span>order</span><span> </span><span>=</span><span><span> </span><span>index</span><span> </span></span><span>+</span><span> </span><span>1</span><span>;</span></div></div><div><div><div>17</div></div><div><span>    </span><span>if</span><span> (</span><span>readCardOrderValue</span><span><span>(</span><span>entry</span><span>.</span></span><span>getValue</span><span><span>(</span><span>propertyId</span><span>)?.</span></span><span>toString</span><span>()) </span><span>===</span><span><span> </span><span>order</span><span>) </span></span><span>return</span><span> [];</span></div></div><div><div><div>18</div></div><div><span>    </span><span>return</span><span> [</span></div></div><div><div><div>19</div></div><div><span>      </span><span>this</span><span>.</span><span>app</span><span>.</span><span>fileManager</span><span>.</span><span>processFrontMatter</span><span><span>(</span><span>entry</span><span>.</span></span><span>file</span><span><span>, (</span><span>frontmatter</span></span><span>:</span><span> </span><span>Record</span><span><span>&lt;</span><span>string</span><span>, </span><span>unknown</span><span>&gt;) </span></span><span>=&gt;</span><span> {</span></div></div><div><div><div>20</div></div><div><span><span>        </span></span><span>frontmatter</span><span>[</span><span>propertyName</span><span>] </span><span>=</span><span><span> </span><span>order</span><span>;</span></span></div></div><div><div><div>21</div></div><div><span><span>      </span></span><span>}),</span></div></div><div><div><div>22</div></div><div><span><span>    </span></span><span>];</span></div></div><div><div><div>23</div></div><div><span><span>  </span></span><span>});</span></div></div><div><div><div>24</div></div><div><span><span>  </span></span><span>await Promise.all(updates);</span></div></div><div><div><div>25</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div><p>跨列拖动新增两步处理：先将移动卡片的新分组值和目标序号写入同一次 frontmatter 更新，再重新编号来源单元格和目标单元格。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>const</span><span> </span><span>cardOrderPropertyName</span><span> </span><span>=</span><span> </span><span>this</span><span>.</span><span>cardOrderPropertyId</span></div></div><div><div><div>2</div></div><div><span>  </span><span>?</span><span> </span><span>parsePropertyId</span><span>(</span><span>this</span><span>.</span><span>cardOrderPropertyId</span><span>).</span><span>name</span></div></div><div><div><div>3</div></div><div><span>  </span><span>:</span><span> </span><span>null</span><span>;</span></div></div><div><div><div>4</div></div><div><span>const</span><span> </span><span>cardOrder</span><span> </span><span>=</span><span><span> </span><span>newPaths</span><span>.</span></span><span>indexOf</span><span><span>(</span><span>entryPath</span><span>) </span></span><span>+</span><span> </span><span>1</span><span>;</span></div></div><div><div><div>5</div></div><div>
</div></div><div><div><div>6</div></div><div><span>if</span><span><span> (</span><span>cardOrderPropertyName</span><span>) </span><span>frontmatter</span><span>[</span><span>cardOrderPropertyName</span><span>] </span></span><span>=</span><span><span> </span><span>cardOrder</span><span>;</span></span></div></div><div><div><div>7</div></div><div>
</div></div><div><div><div>8</div></div><div><span>if</span><span> (</span><span>this</span><span>.</span><span>cardOrderPropertyId</span><span>) {</span></div></div><div><div><div>9</div></div><div><span>  </span><span>await</span><span> </span><span>Promise</span><span>.</span><span>all</span><span>([</span></div></div><div><div><div>10</div></div><div><span>    </span><span>this</span><span>.</span><span>writeVisibleCardOrder</span><span><span>(</span><span>oldPaths</span><span>),</span></span></div></div><div><div><div>11</div></div><div><span>    </span><span>this</span><span>.</span><span>writeVisibleCardOrder</span><span><span>(</span><span>newPaths</span><span>, </span><span>entryPath</span><span>),</span></span></div></div><div><div><div>12</div></div><div><span><span>  </span></span><span>]);</span></div></div><div><div><div>13</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h2>允许 Base Sort 下保存拖动顺序<a href="#允许-base-sort-下保存拖动顺序"><span>#</span></a></h2><p>原实现检测到 Base Sort 后会取消同列拖动，并用 Notice 提示用户清除 Sort。本次移除了 <code>sortActive</code>、<code>hasActiveSort()</code> 和该提示。现在显式属性始终决定卡片顺序；未配置显式属性时，已有的隐式拖动顺序也不再被 Base Sort 跳过。</p></section></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.elephantgris.space/posts/fd7c9a19fff336c3/</id>
      <title type="text">蛤诞百年祭</title>
      <published>2026-08-17T00:00:00.000Z</published>
      <updated>2026-08-30T00:00:00.000Z</updated>
      <author><name>Chris Gray</name></author>
      <link rel="alternate" href="https://blog.elephantgris.space/posts/fd7c9a19fff336c3/"/>
      <summary type="text">曾经的我以为，膜蛤会是一辈子的事情。</summary>
      <content type="html"><![CDATA[<p>曾经的我以为，膜蛤会是一辈子的事情。</p>
<p></p><figure><img src="https://chrisgray.oss-cn-beijing.aliyuncs.com/Imageshack/20260827141527470.jpeg" alt="汇文 2015 闭幕式-1" /><figcaption>汇文 2015 闭幕式-1</figcaption></figure><p></p>
<p>2015 年在汇文闭幕式上，我第一次看到有关长者的视频，那个时候的我还不懂，为什么上至组委学团，下至代表，都在大谈什么膜法；那个时候我还不懂，为什么大家看着江主席谈笑风生的场面会嘻嘻哈哈，说着什么“提高姿势水平”“这是坠吼的”之类半懂不懂的话；那个时候的我还不懂，原来这就是最后一届汇文中国。</p>
<p></p><figure><img src="https://chrisgray.oss-cn-beijing.aliyuncs.com/Imageshack/20260827141533091.jpeg" alt="汇文 2015 闭幕式-2" /><figcaption>汇文 2015 闭幕式-2</figcaption></figure><p></p>
<p>后来我成为了一名成熟的膜法师，并且觉得自己一辈子都会是膜法师。我会熟练地吟诵蛤三篇，尽管现在只会讲两句“跑得快”与“谈笑风生”；我会认真研读《葛底斯堡演说》，尽管现在只记得 “of the people, by the people, for the people”；我会张口“苟利社稷”，闭口“历史的进程”，尽管现在看到评论区的神秘接龙只会淡淡一笑；我会在每年的 817 的时候与《盗墓笔记》十年之约的粉丝们在 QQ 空间里争个你死我活，尽管现在“长白山见”与“长者续命”都已是昨日黄花。</p>
<p></p><figure><img src="https://chrisgray.oss-cn-beijing.aliyuncs.com/Imageshack/20260827140350501.png" alt="98 年抗洪" /><figcaption>98 年抗洪</figcaption></figure><p></p>
<p>刚读完《长者传》那会儿，对他的事迹不能说是倒背如流，也可算是如数家珍。精通八国语言、在夏威夷弹吉他、合唱毕业歌、建立社会主义市场经济体制、军队一律不得经商……有迹可循的是，“膜蛤文化”最初也许是对政治人物的某种“戏谑”，但真正能够出圈的，也许就是他的某种个人魅力，也许就是他真的做了一点“微小的贡献”。</p>
<p>你我皆是凡人，没有永生的长者，亦没有一辈子的蛤丝。江能泽民，亦可赛艇，江主席，再见。</p>]]></content>
    </entry>
    <entry>
      <id>https://blog.elephantgris.space/posts/1512e01217fe64c7/</id>
      <title type="text">基于 SSH 反向端口转发的服务器联网代理</title>
      <published>2026-08-08T00:00:00.000Z</published>
      <updated>2026-08-08T00:00:00.000Z</updated>
      <author><name>Chris Gray</name></author>
      <link rel="alternate" href="https://blog.elephantgris.space/posts/1512e01217fe64c7/"/>
      <summary type="text">服务器无法直接联网，apt 和 uv 无法更新依赖；通过 SSH 反向端口转发复用本机 Clash 代理，并为 apt 与环境变量配置本地代理端口。</summary>
      <content type="html"><![CDATA[<section><h1>需求描述<a href="#需求描述"><span>#</span></a></h1><p>当前服务器本身无法直接访问互联网，但存在稳定的联网下载需求，主要包括以下两类场景：</p><ol>
<li>使用 <code>apt</code> 更新系统源、安装系统依赖；</li>
<li>使用 <code>uv</code> 安装 Python、安装项目依赖。</li>
</ol><p>本机具备可用的代理环境，代理软件为 Clash Verge，代理端口为 <code>7897</code> 。因此目标是让服务器通过 SSH 隧道复用本机的代理出口访问互联网。</p></section>
<section><h1>解决方案<a href="#解决方案"><span>#</span></a></h1><section><h2>整体流程<a href="#整体流程"><span>#</span></a></h2><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>服务器 apt / uv</span></div></div><div><div><div>2</div></div><div><span><span>  </span></span><span>↓</span></div></div><div><div><div>3</div></div><div><span>服务器 127.0.0.1:7897</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>↓</span></div></div><div><div><div>5</div></div><div><span>SSH 反向端口转发</span></div></div><div><div><div>6</div></div><div><span><span>  </span></span><span>↓</span></div></div><div><div><div>7</div></div><div><span>本机 Clash Verge 127.0.0.1:7897</span></div></div><div><div><div>8</div></div><div><span><span>  </span></span><span>↓</span></div></div><div><div><div>9</div></div><div><span>互联网</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h2>建立 SSH 反向端口转发<a href="#建立-ssh-反向端口转发"><span>#</span></a></h2><p>在本机执行以下命令：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>ssh</span><span> </span><span>-R</span><span> </span><span>127.0.0.1:7897:127.0.0.1:7897</span><span> </span><span>\</span></div></div><div><div><div>2</div></div><div><span>  </span><span>user@server_ip</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>执行 <code>expect</code> 脚本：</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>#!/usr/bin/expect -f</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>set</span><span> </span><span>timeout</span><span> </span><span>-1</span></div></div><div><div><div>4</div></div><div>
</div></div><div><div><div>5</div></div><div><span>set</span><span> </span><span>SSH_USER</span><span> </span><span>"root"</span></div></div><div><div><div>6</div></div><div><span>set</span><span> </span><span>HOST</span><span> </span><span>"10.130.10.166"</span></div></div><div><div><div>7</div></div><div><span>set</span><span> </span><span>PORT</span><span> </span><span>"30412"</span></div></div><div><div><div>8</div></div><div><span>set</span><span> </span><span>PASSWORD</span><span> </span><span>""</span></div></div><div><div><div>9</div></div><div>
</div></div><div><div><div>10</div></div><div><span>spawn</span><span> </span><span>ssh</span><span> </span><span>\</span></div></div><div><div><div>11</div></div><div><span>  </span><span>-p</span><span> </span><span>$PORT</span><span> </span><span>\</span></div></div><div><div><div>12</div></div><div><span>  </span><span>-R</span><span> </span><span>127.0.0.1:7897:127.0.0.1:7897</span><span> </span><span>\</span></div></div><div><div><div>13</div></div><div><span>  </span><span>$SSH_USER</span><span>@</span><span>$HOST</span></div></div><div><div><div>14</div></div><div>
</div></div><div><div><div>15</div></div><div><span>expect</span><span> </span><span>{</span></div></div><div><div><div>16</div></div><div><span>  </span><span>"*yes/no*"</span><span> </span><span>{</span></div></div><div><div><div>17</div></div><div><span>    </span><span>send</span><span> </span><span>--</span><span> </span><span>"yes\r"</span></div></div><div><div><div>18</div></div><div><span>    </span><span>exp_continue</span></div></div><div><div><div>19</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>20</div></div><div><span>  </span><span>-re</span><span> </span><span>{.</span><span>*</span><span>assword.</span><span>*</span><span>}</span><span> </span><span>{</span></div></div><div><div><div>21</div></div><div><span>    </span><span>send</span><span> </span><span>--</span><span> </span><span>"</span><span>$PASSWORD</span><span>\r"</span></div></div><div><div><div>22</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>23</div></div><div><span>}</span></div></div><div><div><div>24</div></div><div>
</div></div><div><div><div>25</div></div><div><span>interact</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div></section><section><h2>测试代理链路<a href="#测试代理链路"><span>#</span></a></h2><p>建立隧道后，在服务器上测试代理是否可用：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>curl</span><span> </span><span>-I</span><span> </span><span>-x</span><span> </span><span>http://127.0.0.1:7897</span><span> </span><span>https://pypi.org</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h2>配置 <code>apt</code> 代理<a href="#配置-apt-代理"><span>#</span></a></h2><p>在服务器上创建或覆盖 <code>apt</code> 代理配置：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>sudo</span><span> </span><span>tee</span><span> </span><span>/etc/apt/apt.conf.d/95proxy</span><span> &gt; </span><span>/dev/null</span><span> &lt;&lt;</span><span>'EOF'</span></div></div><div><div><div>2</div></div><div><span>Acquire::http::Proxy "http://127.0.0.1:7897";</span></div></div><div><div><div>3</div></div><div><span>Acquire::https::Proxy "http://127.0.0.1:7897";</span></div></div><div><div><div>4</div></div><div><span>EOF</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>然后执行：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>sudo</span><span> </span><span>apt</span><span> </span><span>update</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h2>配置 <code>uv</code> 代理<a href="#配置-uv-代理"><span>#</span></a></h2><p><code>uv</code> 可以直接使用环境变量中的 HTTP/HTTPS 代理配置。建议写入服务器的 <code>/etc/environment</code> ：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>cat</span><span> &gt;&gt; </span><span>~/.bashrc</span><span> &lt;&lt;</span><span>'EOF'</span></div></div><div><div><div>2</div></div><div>
</div></div><div><div><div>3</div></div><div><span>export HTTP_PROXY=http://127.0.0.1:7897</span></div></div><div><div><div>4</div></div><div><span>export HTTPS_PROXY=http://127.0.0.1:7897</span></div></div><div><div><div>5</div></div><div><span>export ALL_PROXY=http://127.0.0.1:7897</span></div></div><div><div><div>6</div></div><div><span>export NO_PROXY=localhost,127.0.0.1,::1</span></div></div><div><div><div>7</div></div><div><span>EOF</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>然后执行</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>source</span><span> </span><span>~/.bashrc</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.elephantgris.space/posts/e38f2b01a758df66/</id>
      <title type="text">Codex TRACE 日志写盘问题修复</title>
      <published>2026-07-08T00:00:00.000Z</published>
      <updated>2026-07-08T00:00:00.000Z</updated>
      <author><name>Chris Gray</name></author>
      <link rel="alternate" href="https://blog.elephantgris.space/posts/e38f2b01a758df66/"/>
      <summary type="text">Codex 桌面端持续写入大量 TRACE 日志，造成 SQLite WAL 频繁写盘；通过 BEFORE INSERT 触发器忽略 TRACE，保留其他级别日志并支持回滚。</summary>
      <content type="html"><![CDATA[<section><h1>需求描述<a href="#需求描述"><span>#</span></a></h1><p>Codex 桌面端会将大量 WebSocket 底层事件以 TRACE 级别持续写入 <code>~/.codex/logs_2.sqlite</code>。活跃会话中，TRACE 占新增日志的绝大多数，并引发频繁的 SQLite 预写日志（WAL）写入和旧日志清理。</p><p>目标是在不影响 INFO、DEBUG、WARN 和 ERROR 日志的前提下停止 TRACE 日志落盘，并确保方案可验证、可回滚。</p></section>
<section><h1>解决方案<a href="#解决方案"><span>#</span></a></h1><p>在 <code>logs</code> 表上创建 <code>BEFORE INSERT</code>（插入前）触发器，仅忽略 TRACE 级日志：</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>CREATE</span><span> </span><span>TRIGGER</span><span> </span><span>block_trace_logs</span></div></div><div><div><div>2</div></div><div><span>BEFORE</span><span> </span><span>INSERT</span><span> </span><span>ON</span><span> logs</span></div></div><div><div><div>3</div></div><div><span>WHEN</span><span> </span><span>NEW</span><span>.</span><span>level</span><span><span> </span><span>=</span><span> </span></span><span>'TRACE'</span></div></div><div><div><div>4</div></div><div><span>BEGIN</span></div></div><div><div><div>5</div></div><div><span>  </span><span>SELECT</span><span> RAISE(</span><span>IGNORE</span><span>);</span></div></div><div><div><div>6</div></div><div><span>END</span><span>;</span></div></div></code></pre><div><div></div><div></div></div></figure></div><p>实施前已备份数据库并通过 <code>PRAGMA quick_check</code> 完整性检查。安装后连续验证未发现新增 TRACE，其他级别日志正常写入，Codex 活跃窗口的进程写入量明显下降。该方案只阻止 TRACE 日志落盘，不会停止上游日志生成；Codex 更新后需确认该触发器是否仍存在。</p><p>回滚命令：</p><div><figure><figcaption><span></span><span>Terminal window</span></figcaption><pre><code><div><div><div>1</div></div><div><span>sqlite3</span><span> </span><span>~/.codex/logs_2.sqlite</span><span> </span><span>"DROP TRIGGER IF EXISTS block_trace_logs;"</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section>]]></content>
    </entry>
    <entry>
      <id>https://blog.elephantgris.space/posts/9ab89d31cf793aab/</id>
      <title type="text">Obsidian Douban 插件图片上传 PicGo 功能修复</title>
      <published>2026-03-27T00:00:00.000Z</published>
      <updated>2026-03-27T00:00:00.000Z</updated>
      <author><name>Chris Gray</name></author>
      <link rel="alternate" href="https://blog.elephantgris.space/posts/9ab89d31cf793aab/"/>
      <summary type="text">豆瓣图片因 Referer 校验无法下载，部分 WebP 又无法通过 Electron 剪贴板上传；补充请求头、修正高清图地址、等待剪贴板写入，并在 WebP 解析失败时转为 PNG。</summary>
      <content type="html"><![CDATA[<section><h1>需求描述<a href="#需求描述"><span>#</span></a></h1><ol>
<li>豆瓣图片访问策略发生变化，访问 doubanio 图片链接时需要附带有效的 Referer 请求头，否则会被拒绝访问。</li>
<li>插件原有的 PicGo 上传链路依赖系统剪贴板，部分 webp 在写入剪贴板时无法被当前 Electron 运行时直接解析，最终导致上传失败。</li>
</ol></section>
<section><h1>功能设计<a href="#功能设计"><span>#</span></a></h1><section><h2>图片请求新增 Header <code>Referer</code><a href="#图片请求新增-header-referer"><span>#</span></a></h2><p>图片请求时，使用豆瓣条目页地址作为 <code>Referer</code>，并在原有请求头基础上清理不适用于图片请求的字段，保证图片能够正常下载。</p></section><section><h2>高清图地址改为使用远端文件名生成<a href="#高清图地址改为使用远端文件名生成"><span>#</span></a></h2><p>高清图地址生成时，不再使用本地附件文件名拼接，而是从原始图片 URL 中提取远端文件名，避免生成错误的高清图地址。</p></section><section><h2>剪贴板写入新增显式等待<a href="#剪贴板写入新增显式等待"><span>#</span></a></h2><p>保留原有 PicGo 剪贴板上传流程，仅在上传前显式等待剪贴板写入完成，避免写入未完成时提前上传。</p><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span>.</span><span>then</span><span>(</span><span>async</span><span><span> (</span><span>buffer</span><span>) </span></span><span>=&gt;</span><span> {</span></div></div><div><div><div>2</div></div><div><span>  </span><span>if</span><span> (</span><span>!</span><span><span>buffer</span><span> </span></span><span>||</span><span><span> </span><span>buffer</span><span>.</span></span><span>byteLength</span><span> </span><span>==</span><span> </span><span>0</span><span>) {</span></div></div><div><div><div>3</div></div><div><span>    </span><span>throw</span><span> </span><span>new</span><span> </span><span>Error</span><span><span>(</span><span>i18nHelper</span><span>.</span></span><span>getMessage</span><span>(</span><span>'130109'</span><span>));</span></div></div><div><div><div>4</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>5</div></div><div><span>  </span><span>await</span><span><span> </span><span>ClipboardUtil</span><span>.</span></span><span>writeImage</span><span><span>(</span><span>buffer</span><span>);</span></span></div></div><div><div><div>6</div></div><div><span>  </span><span>return</span><span> </span><span>await</span><span> </span><span>this</span><span>.</span><span>uploadClipboardFile</span><span><span>(</span><span>context</span><span>);</span></span></div></div><div><div><div>7</div></div><div><span>})</span></div></div></code></pre><div><div></div><div></div></div></figure></div></section><section><h2>Webp 格式图片处理<a href="#webp-格式图片处理"><span>#</span></a></h2><p>仍优先使用原有方式写入剪贴板；仅当 webp 图片无法被 Electron 直接解析时，再通过 canvas 转为 png 后写入，保证对原有逻辑改动最小。</p><div><div><div><figure><figcaption></figcaption><pre><code><div><div><div>1</div></div><div><span><span>private</span><span> </span><span>static</span><span> </span><span>async</span><span> </span></span><span>createNativeImageFromWebp</span><span><span>(</span><span>data</span><span>: </span><span>ArrayBuffer</span><span>) {</span></span></div></div><div><div><div>2</div></div><div><span>  </span><span>const</span><span> { </span><span>nativeImage</span><span> } </span><span>=</span><span> </span><span>require</span><span>(</span><span>'electron'</span><span>);</span></div></div><div><div><div>3</div></div><div><span>  </span><span>const</span><span> </span><span>imageElement</span><span> </span><span>=</span><span> </span><span>await</span><span> </span><span>this</span><span>.</span><span>loadImage</span><span>(</span><span>URL</span><span>.</span><span>createObjectURL</span><span>(</span><span>new</span><span> </span><span>Blob</span><span><span>([</span><span>data</span><span>], {</span></span><span>type</span><span>:</span><span> </span><span>'image/webp'</span><span>})));</span></div></div><div><div><div>4</div></div><div><span>  </span><span>try</span><span> {</span></div></div><div><div><div>5</div></div><div><span>    </span><span>const</span><span> </span><span>canvas</span><span> </span><span>=</span><span><span> </span><span>document</span><span>.</span></span><span>createElement</span><span>(</span><span>'canvas'</span><span>);</span></div></div><div><div><div>6</div></div><div><span><span>    </span></span><span>canvas</span><span>.</span><span>width</span><span> </span><span>=</span><span><span> </span><span>imageElement</span><span>.</span></span><span>naturalWidth</span><span> </span><span>||</span><span><span> </span><span>imageElement</span><span>.</span></span><span>width</span><span>;</span></div></div><div><div><div>7</div></div><div><span><span>    </span></span><span>canvas</span><span>.</span><span>height</span><span> </span><span>=</span><span><span> </span><span>imageElement</span><span>.</span></span><span>naturalHeight</span><span> </span><span>||</span><span><span> </span><span>imageElement</span><span>.</span></span><span>height</span><span>;</span></div></div><div><div><div>8</div></div><div><span>    </span><span>const</span><span> </span><span>context</span><span> </span><span>=</span><span><span> </span><span>canvas</span><span>.</span></span><span>getContext</span><span>(</span><span>'2d'</span><span>);</span></div></div><div><div><div>9</div></div><div><span>    </span><span>if</span><span> (</span><span>!</span><span><span>context</span><span>) {</span></span></div></div><div><div><div>10</div></div><div><span>      </span><span>throw</span><span> </span><span>new</span><span> </span><span>Error</span><span><span>(</span><span>i18nHelper</span><span>.</span></span><span>getMessage</span><span>(</span><span>'130110'</span><span>));</span></div></div><div><div><div>11</div></div><div><span><span>    </span></span><span>}</span></div></div><div><div><div>12</div></div><div><span><span>    </span></span><span>context</span><span>.</span><span>drawImage</span><span><span>(</span><span>imageElement</span><span>, </span></span><span>0</span><span>, </span><span>0</span><span>);</span></div></div><div><div><div>13</div></div><div><span>    </span><span>return</span><span><span> </span><span>nativeImage</span><span>.</span></span><span>createFromDataURL</span><span><span>(</span><span>canvas</span><span>.</span></span><span>toDataURL</span><span>(</span><span>'image/png'</span><span>));</span></div></div><div><div><div>14</div></div><div><span><span>  </span></span><span>} </span><span>finally</span><span> {</span></div></div><div><div><div>15</div></div><div><span><span>    </span></span><span>imageElement</span><span>.</span><span>src</span><span> </span><span>=</span><span> </span><span>''</span><span>;</span></div></div><div><div><div>16</div></div><div><span><span>  </span></span><span>}</span></div></div><div><div><div>17</div></div><div><span>}</span></div></div></code></pre><div><div></div><div></div></div></figure><div></div></div><span>展开</span><span>收起</span></div></div></section></section>]]></content>
    </entry>
</feed>
