WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
package text
|
||||
|
||||
// KnowledgeDisplayName picks the most informative human label for a
|
||||
// knowledge entry. File-uploads use FileName; URL / text entries fall
|
||||
// back to Title; the ID is the last-resort placeholder so a table cell
|
||||
// is never empty. Single source for the ordering - `weknora doc list`
|
||||
// and `weknora search docs` both call this so a Knowledge renders
|
||||
// identically in either command.
|
||||
//
|
||||
// Takes the three fields directly rather than `sdk.Knowledge` so this
|
||||
// package stays free of SDK imports.
|
||||
func KnowledgeDisplayName(fileName, title, id string) string {
|
||||
if fileName != "" {
|
||||
return fileName
|
||||
}
|
||||
if title != "" {
|
||||
return title
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package text
|
||||
|
||||
import "strings"
|
||||
|
||||
// ContainsFold reports whether any of fields contains needle, comparing
|
||||
// case-insensitively. Avoids the inline `strings.Contains(strings.ToLower
|
||||
// (field), needle)` triple-pattern when callers want to OR-match across
|
||||
// several columns of the same record. The caller passes the needle in
|
||||
// lowercase form so we don't lowercase the same string per call site.
|
||||
func ContainsFold(lowerNeedle string, fields ...string) bool {
|
||||
for _, f := range fields {
|
||||
if strings.Contains(strings.ToLower(f), lowerNeedle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package text
|
||||
|
||||
import "strings"
|
||||
|
||||
// OneLine collapses newlines/carriage-returns/tabs in s to single spaces,
|
||||
// then truncates to maxDisplayWidth columns (UTF-8 safe via Truncate).
|
||||
// Use for human-readable preview rows where multiline content would
|
||||
// break tabular layout.
|
||||
func OneLine(maxDisplayWidth int, s string) string {
|
||||
return Truncate(maxDisplayWidth, lineReplacer.Replace(s))
|
||||
}
|
||||
|
||||
var lineReplacer = strings.NewReplacer("\n", " ", "\r", " ", "\t", " ")
|
||||
@@ -0,0 +1,29 @@
|
||||
package text_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/text"
|
||||
)
|
||||
|
||||
func TestOneLine(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
max int
|
||||
in, want string
|
||||
}{
|
||||
{"empty", 10, "", ""},
|
||||
{"no-collapse", 10, "hello", "hello"},
|
||||
{"collapse-newline", 20, "hello\nworld", "hello world"},
|
||||
{"collapse-cr-tab", 20, "hello\r\tworld", "hello world"},
|
||||
{"truncate", 8, "hello world long", "hello w…"},
|
||||
{"truncate-after-collapse", 8, "hello\nworld\nlong", "hello w…"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := text.OneLine(c.max, c.in); got != c.want {
|
||||
t.Errorf("OneLine(%d, %q) = %q, want %q", c.max, c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Package text holds string helpers for human-readable CLI output.
|
||||
// All functions are pure (no I/O, no time.Now), making them trivially testable.
|
||||
package text
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Pluralize returns "<n> <thing>" or "<n> <thing>s". Simple suffix "s" only;
|
||||
// irregular forms (person/people) are not supported.
|
||||
func Pluralize(n int, thing string) string {
|
||||
if n == 1 {
|
||||
return fmt.Sprintf("%d %s", n, thing)
|
||||
}
|
||||
return fmt.Sprintf("%d %ss", n, thing)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package text_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/text"
|
||||
)
|
||||
|
||||
func TestPluralize(t *testing.T) {
|
||||
tests := []struct {
|
||||
n int
|
||||
thing string
|
||||
want string
|
||||
}{
|
||||
{0, "doc", "0 docs"},
|
||||
{1, "doc", "1 doc"},
|
||||
{2, "doc", "2 docs"},
|
||||
{5, "chunk", "5 chunks"},
|
||||
{1, "chunk", "1 chunk"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := text.Pluralize(tt.n, tt.thing)
|
||||
if got != tt.want {
|
||||
t.Errorf("Pluralize(%d, %q) = %q, want %q", tt.n, tt.thing, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package text
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FuzzyAgo formats t relative to now as a short relative-time string.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// FuzzyAgo(now, now-5m) → "about 5 minutes ago"
|
||||
// FuzzyAgo(now, now-3h) → "about 3 hours ago"
|
||||
// FuzzyAgo(now, now-2d) → "about 2 days ago"
|
||||
// FuzzyAgo(now, now+5m) → "about 5 minutes from now"
|
||||
func FuzzyAgo(now, t time.Time) string {
|
||||
d := now.Sub(t)
|
||||
suffix := "ago"
|
||||
if d < 0 {
|
||||
d = -d
|
||||
suffix = "from now"
|
||||
}
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "less than a minute " + suffix
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("about %s %s", Pluralize(int(math.Round(d.Minutes())), "minute"), suffix)
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("about %s %s", Pluralize(int(math.Round(d.Hours())), "hour"), suffix)
|
||||
case d < 30*24*time.Hour:
|
||||
return fmt.Sprintf("about %s %s", Pluralize(int(math.Round(d.Hours()/24)), "day"), suffix)
|
||||
case d < 365*24*time.Hour:
|
||||
return fmt.Sprintf("about %s %s", Pluralize(int(math.Round(d.Hours()/(24*30))), "month"), suffix)
|
||||
default:
|
||||
return fmt.Sprintf("about %s %s", Pluralize(int(math.Round(d.Hours()/(24*365))), "year"), suffix)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package text
|
||||
|
||||
import "time"
|
||||
|
||||
// FuzzyAgoStr is the string-input variant of FuzzyAgo for SDK types that
|
||||
// carry timestamps as RFC3339 strings (sdk.Chunk.UpdatedAt, sdk.Session.
|
||||
// UpdatedAt, ...) rather than time.Time.
|
||||
//
|
||||
// Behavior:
|
||||
// - ts == "" → "-" (server has no timestamp; render placeholder)
|
||||
// - parse error → ts (surface the raw value rather than hide it)
|
||||
// - parse OK → FuzzyAgo(now, parsed)
|
||||
//
|
||||
// The parse-error fallback is deliberate: if the server starts emitting a
|
||||
// new format we want it visible in the table, not silently replaced by "-".
|
||||
func FuzzyAgoStr(now time.Time, ts string) string {
|
||||
if ts == "" {
|
||||
return "-"
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, ts)
|
||||
if err != nil {
|
||||
return ts
|
||||
}
|
||||
return FuzzyAgo(now, t)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package text_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/text"
|
||||
)
|
||||
|
||||
func TestFuzzyAgoStr(t *testing.T) {
|
||||
now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC)
|
||||
tests := []struct {
|
||||
name string
|
||||
ts string
|
||||
want string
|
||||
}{
|
||||
{"empty renders dash", "", "-"},
|
||||
{"valid RFC3339 5m ago", now.Add(-5 * time.Minute).Format(time.RFC3339), "about 5 minutes ago"},
|
||||
{"valid RFC3339 2d ago", now.Add(-2 * 24 * time.Hour).Format(time.RFC3339), "about 2 days ago"},
|
||||
{"unparseable returned verbatim", "not-a-date", "not-a-date"},
|
||||
{"wrong format returned verbatim", "2026-05-15 12:00:00", "2026-05-15 12:00:00"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := text.FuzzyAgoStr(now, tt.ts)
|
||||
if got != tt.want {
|
||||
t.Errorf("FuzzyAgoStr(%q) = %q, want %q", tt.ts, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package text_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/text"
|
||||
)
|
||||
|
||||
func TestFuzzyAgo(t *testing.T) {
|
||||
now := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC)
|
||||
tests := []struct {
|
||||
name string
|
||||
t time.Time
|
||||
want string
|
||||
}{
|
||||
{"5 minutes ago", now.Add(-5 * time.Minute), "about 5 minutes ago"},
|
||||
{"1 hour ago", now.Add(-1 * time.Hour), "about 1 hour ago"},
|
||||
{"3 hours ago", now.Add(-3 * time.Hour), "about 3 hours ago"},
|
||||
{"2 days ago", now.Add(-2 * 24 * time.Hour), "about 2 days ago"},
|
||||
{"30 days ago", now.Add(-30 * 24 * time.Hour), "about 1 month ago"},
|
||||
{"5 minutes from now", now.Add(5 * time.Minute), "about 5 minutes from now"},
|
||||
{"less than 1m ago", now.Add(-30 * time.Second), "less than a minute ago"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := text.FuzzyAgo(now, tt.t)
|
||||
if got != tt.want {
|
||||
t.Errorf("FuzzyAgo(%s) = %q, want %q", tt.name, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package text
|
||||
|
||||
import "github.com/mattn/go-runewidth"
|
||||
|
||||
const ellipsis = "…"
|
||||
|
||||
// Truncate cuts s to at most maxWidth display columns (CJK / emoji occupy
|
||||
// 2 columns; ASCII 1), appending "…" if truncated. Display width, not rune count.
|
||||
//
|
||||
// Edge cases:
|
||||
//
|
||||
// maxWidth <= 0 -> ""
|
||||
// s already fits -> s returned unchanged
|
||||
// ellipsis is 1 column, so truncate budget = maxWidth - 1
|
||||
func Truncate(maxWidth int, s string) string {
|
||||
if maxWidth <= 0 {
|
||||
return ""
|
||||
}
|
||||
if runewidth.StringWidth(s) <= maxWidth {
|
||||
return s
|
||||
}
|
||||
if maxWidth == 1 {
|
||||
return ellipsis
|
||||
}
|
||||
budget := maxWidth - 1 // reserve for ellipsis
|
||||
w := 0
|
||||
for i, r := range s {
|
||||
rw := runewidth.RuneWidth(r)
|
||||
if w+rw > budget {
|
||||
return s[:i] + ellipsis
|
||||
}
|
||||
w += rw
|
||||
}
|
||||
return s + ellipsis
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package text_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/text"
|
||||
)
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
maxWidth int
|
||||
s string
|
||||
want string
|
||||
}{
|
||||
{"no truncate ASCII", 20, "hello", "hello"},
|
||||
{"truncate ASCII", 5, "hello world", "hell…"},
|
||||
{"no truncate CJK", 8, "中文测试", "中文测试"},
|
||||
{"truncate CJK", 5, "中文测试", "中文…"},
|
||||
{"empty", 5, "", ""},
|
||||
{"single char fits", 1, "a", "a"},
|
||||
{"truncate to width 1", 1, "abc", "…"},
|
||||
{"maxWidth zero", 0, "x", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := text.Truncate(tt.maxWidth, tt.s)
|
||||
if got != tt.want {
|
||||
t.Errorf("Truncate(%d, %q) = %q, want %q", tt.maxWidth, tt.s, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user