fix(obsidian-sync): 文件名 slug 不再把中文替换成下划线 + rune 级截断 + 撞名消歧
根因(kanban t_5f9c56ef):ObsidianSyncer.PushToObsidian 生成的镜像文件名全废 —
sanitizeFilename() 逐个 rune 判断 "是否 ASCII",非 ASCII(含全部中文)一律替换为 _,
于是 "修复了respond重复声明问题" 变成 "____respond______.md";实测 mc vault
小唯/07-Wiki/织忆 1628 个文件 + 小唯/02-Memory 59 个文件全是这种名字,Obsidian 里无法按名浏览。
改动:
- sanitizeFilename: 只替换文件系统非法字符(/ \ : * ? " < > | 控制符等),
保留 Unicode 字母/数字(中文正常入名);连续非法字符折叠成一个 _;trim 首尾 " ._-"
- 去掉 content[:30] 的字节切片(会把多字节字符切半个 → 乱码),改用 truncateRunes()
- truncate() 从字节计数改为 rune 计数(H1 标题行同样会被切出乱码)
- 撞名消歧:同一批推送里 base 重名时补 8 位内容哈希后缀,不再静默互相覆盖
- 删除已无用的 minz()
顺带修复(原本就让整个 routes 包测试编译不过,不修则无法验证):
- routes_test.go: selfoptimize.NewGapDetector() 缺参 → (nil, nil)
- routes_test.go: 删掉引用已移除的 SSEBus/SSEMessage/PushMemoryCommitted/PushGapFound 的用例,
改为按当前 WebSocket API 验证全部 Push* helper 不 panic
- routes_test.go: TestTriggers_List 期望 4 → 8(triggers.go 实际注册 8 个)
验证:go build ./... 通过;go vet ./internal/api/routes/ 通过;
go test ./internal/api/routes/ 全绿(此前 build failed)。
新增 obsidian_test.go 锁死回归:中文标题不得出现连续下划线、截断结果必须合法 UTF-8、同名不覆盖。
实际落盘文件名样例:
LanceDB 已设天花板_不会再膨胀到 35G.md / 修复了 respond 重复声明问题.md /
同一段开头的内容 abcdefghijklmnopqrstu.md / 同一段开头的内容 abcdefghijklmnopqrstu-4bad72a0.md
⚠️ 未部署:需重建 /home/muc/bin/zhiyid-new 并重启 zhiyid.service 才生效(另开任务卡跟踪)。
This commit is contained in:
parent
3872294045
commit
e9a07cd678
|
|
@ -2,6 +2,8 @@
|
||||||
package routes
|
package routes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
|
@ -11,6 +13,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
@ -52,14 +55,26 @@ func (s *ObsidianSyncer) PushToObsidian(memories []map[string]interface{}, folde
|
||||||
}
|
}
|
||||||
|
|
||||||
count := 0
|
count := 0
|
||||||
|
used := make(map[string]int, len(memories))
|
||||||
for _, mem := range memories {
|
for _, mem := range memories {
|
||||||
content := strVal(mem["content"])
|
content := strVal(mem["content"])
|
||||||
if len(content) < 5 {
|
if len(content) < 5 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := sanitizeFilename(content[:minz(len(content), 30)]) + ".md"
|
// 2026-09-10 修复:旧实现 content[:30] 按字节切 + sanitizeFilename 把非 ASCII
|
||||||
filepath := filepath.Join(targetDir, filename)
|
// 全替换成 '_',中文标题直接变 ____123.md(07-Wiki/织忆 1628 个乱码文件名成因),
|
||||||
|
// 且不同记忆撞名会静默互相覆盖。改为 rune 级截断 + 保留 Unicode + 撞名加短哈希。
|
||||||
|
base := sanitizeFilename(truncateRunes(content, 30))
|
||||||
|
if base == "" {
|
||||||
|
base = "untitled"
|
||||||
|
}
|
||||||
|
if used[base] > 0 {
|
||||||
|
base = base + "-" + shortHash(content)
|
||||||
|
}
|
||||||
|
used[base]++
|
||||||
|
filename := base + ".md"
|
||||||
|
target := filepath.Join(targetDir, filename)
|
||||||
|
|
||||||
mdContent := fmt.Sprintf(`---
|
mdContent := fmt.Sprintf(`---
|
||||||
id: %s
|
id: %s
|
||||||
|
|
@ -80,7 +95,7 @@ sync_time: %s
|
||||||
truncate(content, 60),
|
truncate(content, 60),
|
||||||
content)
|
content)
|
||||||
|
|
||||||
if err := os.WriteFile(filepath, []byte(mdContent), 0644); err != nil {
|
if err := os.WriteFile(target, []byte(mdContent), 0644); err != nil {
|
||||||
log.Printf("[obsidian] 写入失败 %s: %v", filename, err)
|
log.Printf("[obsidian] 写入失败 %s: %v", filename, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -206,29 +221,59 @@ func (s *ObsidianSyncer) StatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// ─── 辅助 ─────────────────────────────────────────────────
|
// ─── 辅助 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// sanitizeFilename 把标题转成文件系统安全的文件名片段,并保留 Unicode(中文)可读性。
|
||||||
|
//
|
||||||
|
// 2026-09-10 修复:旧实现把所有非 ASCII rune 逐个替换为 '_',于是中文标题全变成
|
||||||
|
// `____123.md`,在 Obsidian 里无法按名浏览(07-Wiki/织忆 1628 个 legacy 文件的成因)。
|
||||||
|
// 现在只替换文件系统非法字符(/ \ : * ? " < > | 控制符等),连续的非法字符折叠成一个 '_'。
|
||||||
|
// 返回空串表示内容里没有任何可用的安全字符,由调用方兜底。
|
||||||
func sanitizeFilename(s string) string {
|
func sanitizeFilename(s string) string {
|
||||||
s = strings.Map(func(r rune) rune {
|
var b strings.Builder
|
||||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
prevIllegal := false
|
||||||
(r >= '0' && r <= '9') || r == '_' || r == '-' || r == ' ' {
|
for _, r := range s {
|
||||||
return r
|
if unicode.IsLetter(r) || unicode.IsDigit(r) ||
|
||||||
|
r == '-' || r == '_' || r == ' ' || r == '.' {
|
||||||
|
prevIllegal = false
|
||||||
|
b.WriteRune(r)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
return '_'
|
// 文件系统非法字符(/ \ : * ? " < > | 控制符等)+ 其它符号 → 折叠成一个 '_'
|
||||||
}, s)
|
if !prevIllegal {
|
||||||
return strings.TrimSpace(s)
|
b.WriteRune('_')
|
||||||
|
prevIllegal = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(b.String(), " ._-")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shortHash 取内容摘要前 8 位十六进制,仅用于同名文件消歧,不做安全用途。
|
||||||
|
func shortHash(s string) string {
|
||||||
|
sum := sha256.Sum256([]byte(s))
|
||||||
|
return hex.EncodeToString(sum[:4])
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateRunes 按 rune(而非字节)截断,避免把多字节字符切掉一半产生乱码。
|
||||||
|
func truncateRunes(s string, maxRunes int) string {
|
||||||
|
if maxRunes <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for i := range s {
|
||||||
|
if n == maxRunes {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncate 展示用截断,按 rune 计数(2026-09-10 从字节切改为 rune 切,避免乱码)。
|
||||||
func truncate(s string, maxLen int) string {
|
func truncate(s string, maxLen int) string {
|
||||||
if len(s) <= maxLen {
|
t := truncateRunes(s, maxLen)
|
||||||
|
if len(t) == len(s) {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
return s[:maxLen] + "..."
|
return t + "..."
|
||||||
}
|
|
||||||
|
|
||||||
func minz(a, b int) int {
|
|
||||||
if a < b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func floatVal(v interface{}) float64 {
|
func floatVal(v interface{}) float64 {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
// 织忆 MemoryWeave — Obsidian 同步文件名/slug 单元测试
|
||||||
|
// 2026-09-10 新增(kanban t_5f9c56ef):锁死「中文标题不许变下划线」这个回归。
|
||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSanitizeFilename_KeepsChinese(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"修复了respond重复声明问题", "修复了respond重复声明问题"},
|
||||||
|
{"110GB 磁盘占用排查", "110GB 磁盘占用排查"},
|
||||||
|
{"E5.3 Web UI 单文件React+静态文件中间件", "E5.3 Web UI 单文件React_静态文件中间件"},
|
||||||
|
{"a/b\\c:d*e?f\"g<h>i|j", "a_b_c_d_e_f_g_h_i_j"},
|
||||||
|
{" 前后空格 ", "前后空格"},
|
||||||
|
{"...隐藏点开头...", "隐藏点开头"},
|
||||||
|
{"///", ""},
|
||||||
|
{"", ""},
|
||||||
|
{"测试::连续非法字符::折叠", "测试_连续非法字符_折叠"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := sanitizeFilename(c.in); got != c.want {
|
||||||
|
t.Errorf("sanitizeFilename(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回归锁:旧实现(2026-09-10 前)会把中文全部换成 '_',这条用例就是当时的现场。
|
||||||
|
func TestSanitizeFilename_RegressionNoUnderscoreSoup(t *testing.T) {
|
||||||
|
got := sanitizeFilename("____112GB_____")
|
||||||
|
if strings.Contains(got, "____") {
|
||||||
|
t.Fatalf("中文标题不该出现连续下划线乱码: %q", got)
|
||||||
|
}
|
||||||
|
old := sanitizeFilename("修复了respond重复声明问题")
|
||||||
|
if strings.Contains(old, "_") {
|
||||||
|
t.Fatalf("中文/字母不该被替换成下划线: %q", old)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateRunes_NoBrokenUTF8(t *testing.T) {
|
||||||
|
s := "修复了respond重复声明问题,这是很长的中文内容用来测试截断"
|
||||||
|
got := truncateRunes(s, 7)
|
||||||
|
if got != "修复了resp" {
|
||||||
|
t.Fatalf("truncateRunes = %q", got)
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(got) {
|
||||||
|
t.Fatal("截断结果不是合法 UTF-8(多字节字符被切半)")
|
||||||
|
}
|
||||||
|
if truncateRunes(s, 0) != "" || truncateRunes(s, -1) != "" {
|
||||||
|
t.Fatal("maxRunes<=0 应返回空串")
|
||||||
|
}
|
||||||
|
if truncateRunes("短", 10) != "短" {
|
||||||
|
t.Fatal("短串应原样返回")
|
||||||
|
}
|
||||||
|
// 关键回归:旧实现 content[:30] 是字节切,中文会切出半个字符(utf8.RuneError)
|
||||||
|
if got := truncateRunes("中文标题测试内容很长很长很长很长很长很长", 8); !utf8.ValidString(got) {
|
||||||
|
t.Fatalf("rune 截断后仍不是合法 UTF-8: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncate_RuneCounted(t *testing.T) {
|
||||||
|
s := strings.Repeat("记", 40) // 120 字节;旧实现 s[:10] 只能得到 3 个半截汉字
|
||||||
|
got := truncate(s, 10)
|
||||||
|
want := strings.Repeat("记", 10) + "..."
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("truncate = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(got) {
|
||||||
|
t.Fatal("truncate 结果不是合法 UTF-8")
|
||||||
|
}
|
||||||
|
if truncate("短内容", 60) != "短内容" {
|
||||||
|
t.Fatal("未超长应原样返回")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShortHash_Deterministic(t *testing.T) {
|
||||||
|
a, b := shortHash("同一段内容"), shortHash("同一段内容")
|
||||||
|
if a != b {
|
||||||
|
t.Fatal("shortHash 必须稳定")
|
||||||
|
}
|
||||||
|
if len(a) != 8 {
|
||||||
|
t.Fatalf("shortHash 长度应为 8,得到 %d", len(a))
|
||||||
|
}
|
||||||
|
if shortHash("内容甲") == shortHash("内容乙") {
|
||||||
|
t.Fatal("不同内容不该同哈希")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 端到端:真的落盘,检查文件名可读、无乱码、同名不互相覆盖。
|
||||||
|
func TestPushToObsidian_ReadableFilenames(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := NewObsidianSyncer(dir, nil)
|
||||||
|
|
||||||
|
dupPrefix := "同一段开头的内容 abcdefghijklmnopqrstuvwxyz"
|
||||||
|
mems := []map[string]interface{}{
|
||||||
|
{"id": "mem_1", "category": "distilled", "quality_score": 0.9,
|
||||||
|
"content": "修复了 respond 重复声明问题"},
|
||||||
|
{"id": "mem_2", "category": "distilled", "quality_score": 0.8,
|
||||||
|
"content": "LanceDB 已设天花板,不会再膨胀到 35G"},
|
||||||
|
// 前 30 个 rune 完全相同 → 必须加哈希后缀而不是覆盖
|
||||||
|
{"id": "mem_3", "category": "distilled", "content": dupPrefix + " 变体一"},
|
||||||
|
{"id": "mem_4", "category": "distilled", "content": dupPrefix + " 变体二"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.PushToObsidian(mems, "mirror"); err != nil {
|
||||||
|
t.Fatalf("PushToObsidian 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(filepath.Join(dir, "mirror"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(entries) != len(mems) {
|
||||||
|
t.Fatalf("应写入 %d 个文件(含撞名消歧),实际 %d 个:%v", len(mems), len(entries), names(entries))
|
||||||
|
}
|
||||||
|
t.Logf("实际落盘文件名: %v", names(entries))
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
n := e.Name()
|
||||||
|
if strings.Contains(n, "____") {
|
||||||
|
t.Errorf("文件名出现下划线乱码: %q", n)
|
||||||
|
}
|
||||||
|
if !strings.ContainsAny(n, "修复了重复声明问题磁盘排查天花板膨胀同一段") {
|
||||||
|
t.Errorf("文件名丢失中文标题: %q", n)
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(n) {
|
||||||
|
t.Errorf("文件名不是合法 UTF-8: %q", n)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, "mirror", n))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !utf8.Valid(data) {
|
||||||
|
t.Errorf("文件内容不是合法 UTF-8: %q", n)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "由织忆 MemoryWeave 同步") {
|
||||||
|
t.Errorf("缺少同步 footer: %q", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 撞名消歧:两条同前缀记忆都要落盘(4 个文件已证明没互相覆盖)
|
||||||
|
var suffixed int
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.Contains(e.Name(), dupPrefix[:12]) && strings.Contains(e.Name(), "-") {
|
||||||
|
suffixed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if suffixed != 1 {
|
||||||
|
t.Errorf("应恰好 1 个文件带哈希后缀消歧,实际 %d:%v", suffixed, names(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func names(entries []os.DirEntry) []string {
|
||||||
|
out := make([]string, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
out = append(out, e.Name())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
@ -128,7 +128,7 @@ func TestConflictAPI_Resolve_Validation(t *testing.T) {
|
||||||
// ─── 知识缺口路由 ────────────────────────────────────────
|
// ─── 知识缺口路由 ────────────────────────────────────────
|
||||||
|
|
||||||
func TestGapAPI_List(t *testing.T) {
|
func TestGapAPI_List(t *testing.T) {
|
||||||
gd := selfoptimize.NewGapDetector()
|
gd := selfoptimize.NewGapDetector(nil, nil)
|
||||||
ai := NewGapAPI(gd)
|
ai := NewGapAPI(gd)
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/v1/gaps", nil)
|
req := httptest.NewRequest("GET", "/api/v1/gaps", nil)
|
||||||
|
|
@ -141,7 +141,7 @@ func TestGapAPI_List(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGapAPI_Detect(t *testing.T) {
|
func TestGapAPI_Detect(t *testing.T) {
|
||||||
gd := selfoptimize.NewGapDetector()
|
gd := selfoptimize.NewGapDetector(nil, nil)
|
||||||
ai := NewGapAPI(gd)
|
ai := NewGapAPI(gd)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{"topic": "test_gap"})
|
body, _ := json.Marshal(map[string]string{"topic": "test_gap"})
|
||||||
|
|
@ -199,8 +199,8 @@ func TestTriggers_List(t *testing.T) {
|
||||||
}
|
}
|
||||||
var resp map[string]interface{}
|
var resp map[string]interface{}
|
||||||
json.NewDecoder(w.Body).Decode(&resp)
|
json.NewDecoder(w.Body).Decode(&resp)
|
||||||
if resp["count"].(float64) != 4 {
|
if resp["count"].(float64) != 8 {
|
||||||
t.Errorf("expected 4 triggers, got %v", resp["count"])
|
t.Errorf("expected 8 triggers, got %v", resp["count"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,18 +306,19 @@ func TestAuxFunctions(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── SSE 管理器 ───────────────────────────────────────────
|
// ─── WebSocket 事件推送 ───────────────────────────────────
|
||||||
|
|
||||||
func TestSSEManager_Push(t *testing.T) {
|
func TestWSPushHelpers_NoPanic(t *testing.T) {
|
||||||
// SSEBus.Push should not panic with no clients
|
// 所有推送 helper 在无订阅者时都不该 panic
|
||||||
SSEBus.Push(SSEMessage{Type: "test", Payload: "hello"})
|
// 2026-09-10 修复:原用例引用的 SSEBus/SSEMessage/PushMemoryCommitted/PushGapFound
|
||||||
}
|
// 已随 SSE→WebSocket 重构删除,改用当前 API(PushGapDetected/PushConflict* 新签名)。
|
||||||
|
PushPrefetch("agent", []interface{}{})
|
||||||
func TestSSEPushHelpers(t *testing.T) {
|
PushGapDetected("agent", "topic", "knowledge", "补一下")
|
||||||
// All push helpers should not panic
|
PushGapFilled("agent", 1)
|
||||||
PushMemoryCommitted("agent", "ns", "mem1")
|
PushMemoryUpdated("mem1", 2, "test")
|
||||||
PushConflictDetected("Docker", "facts conflict")
|
PushConflictDetected("agent", "c1", "Docker", map[string]string{"a": "b"})
|
||||||
PushGapFound("topic", "A")
|
PushConflictResolved("agent", "c1", "keep_left")
|
||||||
PushConsolidationDone("done")
|
PushConsolidationDone("done")
|
||||||
PushConflictResolved("c1", "keep_left")
|
PushQualityDrop("agent", "mem1", 0.2, "补证据")
|
||||||
|
PushDistillationComplete(1, 0)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue