feat(i18n): katalog id/en — semua string UI via T()/Tf(), fallback en, TIA_LANG sementara + e2e English
CI / test (push) Successful in 24s
CI / test (push) Successful in 24s
This commit is contained in:
1 parent
76cdb0b5bb
commit
19ae0d1c29
13 files changed
+540
-76
No files matched your search
@@ -25,6 +25,7 @@ Repo referensi hanya ada di `reference/` (git-ignored).
|
||||
| harmonica | v0.2.0 | MIT |
|
||||
| Docker SDK for Go (client) | v28.5.2+incompatible | Apache-2.0 |
|
||||
| docker/go-connections | v0.8.2 | Apache-2.0 |
|
||||
| go-yaml v3 | v3.0.1 | MIT |
|
||||
| purego *(transitif oto — driver ALSA)* | v0.11.0 | Apache-2.0 |
|
||||
| jfreymuth/pulse *(transitif oto — driver PulseAudio pure-Go)* | v0.1.3 | MIT |
|
||||
|
||||
|
||||
+14
-9
@@ -10,18 +10,23 @@ import (
|
||||
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/app"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/cli"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n"
|
||||
)
|
||||
|
||||
const usage = `tia — Terminal Is A My Way (Just Work)
|
||||
func usageText() string {
|
||||
return fmt.Sprintf(`tia — Terminal Is A My Way (Just Work)
|
||||
|
||||
Pemakaian:
|
||||
tia-love Buka TUI interaktif
|
||||
tia-love doctor Periksa kesehatan sistem (docker, audio, stack)
|
||||
tia-love version Tampilkan versi & build info
|
||||
%s
|
||||
tia-love %s
|
||||
tia-love doctor %s
|
||||
tia-love version %s
|
||||
|
||||
Subcommand CLI lain (convert, ps, up, down, update)
|
||||
menyusul di Phase 2–3 — lihat docs/PRD.md §F-028.
|
||||
`
|
||||
%s
|
||||
`,
|
||||
i18n.T("usage.usage"), i18n.T("usage.open"),
|
||||
i18n.T("usage.doctor"), i18n.T("usage.version"),
|
||||
i18n.T("usage.more"))
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
@@ -32,7 +37,7 @@ func main() {
|
||||
case "doctor":
|
||||
os.Exit(cli.Doctor(os.Stdout))
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "perintah tidak dikenal: %q\n\n%s", os.Args[1], usage)
|
||||
fmt.Fprintf(os.Stderr, "%s\n\n%s", i18n.Tf("cli.unknownCommand", os.Args[1]), usageText())
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
+18
-1
@@ -58,12 +58,17 @@ type tui struct {
|
||||
waited bool
|
||||
}
|
||||
|
||||
func startTUI(t *testing.T) *tui {
|
||||
func startTUI(t *testing.T) *tui { return startTUIEnv(t) }
|
||||
|
||||
func startTUIEnv(t *testing.T, env ...string) *tui {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("e2e pty tidak didukung di Windows")
|
||||
}
|
||||
cmd := exec.Command(binPath)
|
||||
if len(env) > 0 {
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
}
|
||||
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: rows, Cols: cols})
|
||||
if err != nil {
|
||||
t.Fatalf("start pty: %v", err)
|
||||
@@ -211,6 +216,18 @@ func TestDoctorCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnglishLanguage(t *testing.T) {
|
||||
u := startTUIEnv(t, "TIA_LANG=en")
|
||||
u.waitContains("· home", 5*time.Second)
|
||||
u.waitContains("Clock", 5*time.Second)
|
||||
u.send("1")
|
||||
u.waitContains("· containers", 3*time.Second)
|
||||
u.send("q")
|
||||
if code := u.waitExit(5 * time.Second); code != 0 {
|
||||
t.Fatalf("exit code %d, mau 0", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardNavigation(t *testing.T) {
|
||||
u := startTUI(t)
|
||||
u.waitContains("beranda", 5*time.Second)
|
||||
|
||||
@@ -9,6 +9,7 @@ require (
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/ebitengine/oto/v3 v3.5.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -33,6 +34,7 @@ require (
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/jfreymuth/pulse v0.1.3 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.30 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
|
||||
@@ -38,6 +38,7 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.2.0 h1:BewD/umNgVnoczglOpX8eRMyEy5t5iPlu5AIpnWDONc=
|
||||
github.com/containerd/log v0.2.0/go.mod h1:/M7L7CXKcPTfNC74XzaK+5H5KbO5+4lJVpuVI6vRLoM=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
@@ -67,6 +68,10 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePC
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
|
||||
github.com/jfreymuth/pulse v0.1.3 h1:bc5TdxiB8E+2INnFjFWWgyfgXtz2IyNNNCX+Wt/ZD14=
|
||||
github.com/jfreymuth/pulse v0.1.3/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
|
||||
@@ -91,6 +96,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
|
||||
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
@@ -141,5 +148,10 @@ google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
+28
-21
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/audio"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/sysinfo"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/animation"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components"
|
||||
@@ -133,7 +134,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
m.loadErr = msg.err
|
||||
m.toast.Show("✖ [ GAGAL ] Docker", msg.err.Error())
|
||||
m.toast.Show(i18n.T("toast.dockerError"), msg.err.Error())
|
||||
m.sound.Play(audio.NotifyError)
|
||||
} else {
|
||||
m.loadErr = nil
|
||||
@@ -189,8 +190,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.sound.Play(audio.Click)
|
||||
return m, tea.Batch(loadContainers(m.cl), loadInfo(m.cl))
|
||||
case "t": // demo toast (sampai modal asli datang)
|
||||
m.toast.Show("✔ [ SUKSES ] Konversi Berhasil Disimpan",
|
||||
"Stack 'web-app' tersimpan di ~/.tia/stacks/web-app/")
|
||||
m.toast.Show(i18n.T("toast.demoTitle"), i18n.T("toast.demoBody"))
|
||||
m.sound.Play(audio.NotifySuccess)
|
||||
case "j", "down":
|
||||
if len(m.containers) > 0 && m.cursor < len(m.containers)-1 {
|
||||
@@ -306,6 +306,13 @@ func truncate(s string, w int) string {
|
||||
return string(r[:w-1]) + "…"
|
||||
}
|
||||
|
||||
// field — label kolom (padding tetap agar nilai sejajar).
|
||||
func field(label string) string { return fieldW(label, 7) }
|
||||
|
||||
func fieldW(label string, w int) string {
|
||||
return text(fmt.Sprintf("%-*s", w, label))
|
||||
}
|
||||
|
||||
// panel — kotak gaya mirador: judul di border atas (kiri), meta (kanan),
|
||||
// border redup; konten otomatis dipangkas/padded selebar panel.
|
||||
func panel(title, meta string, lines []string, w, h int, focused bool) string {
|
||||
@@ -362,13 +369,13 @@ func (m Model) View() tea.View {
|
||||
var body, footer string
|
||||
switch m.view {
|
||||
case viewHome:
|
||||
header += dim(" · beranda")
|
||||
header += dim(" · " + i18n.T("nav.home"))
|
||||
body = m.homeBody()
|
||||
footer = m.footerLine("[1/enter] kontainer · [t] toast · [a] reduced-motion · [q] keluar")
|
||||
footer = m.footerLine(i18n.T("hint.footerHome"))
|
||||
default:
|
||||
header += dim(" · kontainer")
|
||||
header += dim(" · " + i18n.T("nav.containers"))
|
||||
body = lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView())
|
||||
footer = m.footerLine("[j/k] pilih · [r] muat ulang · [0/esc] beranda · [q] keluar")
|
||||
footer = m.footerLine(i18n.T("hint.footerContainers"))
|
||||
}
|
||||
|
||||
parts := []string{}
|
||||
@@ -388,24 +395,24 @@ func (m Model) footerLine(hints string) string {
|
||||
if m.animating {
|
||||
anim = fmt.Sprintf("%d", m.fps)
|
||||
}
|
||||
return dim(truncate(fmt.Sprintf("%s animasi %s fps · audio: %s", hints, anim, m.sound.Status()), m.width))
|
||||
return dim(truncate(i18n.Tf("footer.status", hints, anim, m.sound.Status()), m.width))
|
||||
}
|
||||
|
||||
func (m Model) sidebarView() string {
|
||||
w, h := m.leftWidth(), m.bodyHeight()
|
||||
title, meta := "Kontainer", fmt.Sprintf("(%d)", len(m.containers))
|
||||
title, meta := i18n.T("section.containers"), fmt.Sprintf("(%d)", len(m.containers))
|
||||
if m.loading {
|
||||
meta = "memuat…"
|
||||
meta = i18n.T("state.loading")
|
||||
}
|
||||
|
||||
var lines []string
|
||||
switch {
|
||||
case m.loadErr != nil:
|
||||
lines = append(lines, red("GAGAL memuat"), dim("tekan r untuk coba lagi"))
|
||||
lines = append(lines, red(i18n.T("error.loadFailed")), dim(i18n.T("hint.retry")))
|
||||
case m.loading:
|
||||
lines = append(lines, dim("memuat…"))
|
||||
lines = append(lines, dim(i18n.T("state.loading")))
|
||||
case len(m.containers) == 0:
|
||||
lines = append(lines, dim("belum ada kontainer"))
|
||||
lines = append(lines, dim(i18n.T("state.noContainers")))
|
||||
default:
|
||||
rows := h - 2
|
||||
start := m.scrollStart()
|
||||
@@ -425,25 +432,25 @@ func (m Model) sidebarView() string {
|
||||
|
||||
func (m Model) contentView() string {
|
||||
w, h := m.width-m.leftWidth(), m.bodyHeight()
|
||||
title, meta := "Detail", ""
|
||||
title, meta := i18n.T("section.detail"), ""
|
||||
|
||||
var lines []string
|
||||
switch {
|
||||
case m.loadErr != nil:
|
||||
lines = append(lines, red("Gagal memuat daftar kontainer"), "", dim(truncate(m.loadErr.Error(), w-6)))
|
||||
lines = append(lines, red(i18n.T("error.loadContainers")), "", dim(truncate(m.loadErr.Error(), w-6)))
|
||||
case len(m.containers) == 0:
|
||||
lines = append(lines, dim("Belum ada kontainer — jalankan sesuatu dengan Docker, lalu tekan r."))
|
||||
lines = append(lines, dim(i18n.T("hint.noContainers")))
|
||||
default:
|
||||
c := m.containers[min(m.cursor, len(m.containers)-1)]
|
||||
glyph, color := stateStyle(c.State)
|
||||
title, meta = c.Name, c.State
|
||||
lines = append(lines,
|
||||
text("ID ")+dim(c.ID),
|
||||
text("Image ")+dim(truncate(c.Image, w-20)),
|
||||
text("State ")+styled(color, glyph+" "+c.State),
|
||||
text("Status ")+dim(truncate(c.Status, w-20)),
|
||||
field(i18n.T("field.id"))+dim(c.ID),
|
||||
field(i18n.T("field.image"))+dim(truncate(c.Image, w-20)),
|
||||
field(i18n.T("field.state"))+styled(color, glyph+" "+i18n.TOr("state."+c.State, c.State)),
|
||||
field(i18n.T("field.status"))+dim(truncate(c.Status, w-20)),
|
||||
"",
|
||||
dim("Tab detail (Log · Statistik · Konfigurasi) menyusul di Phase 2."),
|
||||
dim(i18n.T("hint.tabsComing")),
|
||||
)
|
||||
}
|
||||
return panel(title, meta, lines, w, h, false)
|
||||
|
||||
+28
-35
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components"
|
||||
)
|
||||
|
||||
@@ -38,9 +39,9 @@ func (m Model) homeBody() string {
|
||||
k1 := int(float64(W) * 0.55)
|
||||
k2 := W - k1
|
||||
row2 := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
panel("Kontainer", fmt.Sprintf("%d total", len(m.containers)),
|
||||
panel(i18n.T("section.containers"), i18n.Tf("fmt.countTotal", len(m.containers)),
|
||||
m.miniContainerLines(row2H-2), k1, row2H, false),
|
||||
panel("Aktivitas", "Phase 2", activityLines(), k2, row2H, false),
|
||||
panel(i18n.T("section.activity"), i18n.Tf("fmt.phase", "2"), activityLines(), k2, row2H, false),
|
||||
)
|
||||
|
||||
return row1 + "\n" + row2
|
||||
@@ -52,31 +53,32 @@ func (m Model) clockPanel(w, h int) string {
|
||||
for i, r := range rows {
|
||||
rows[i] = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#cba6f7")).Render(r)
|
||||
}
|
||||
lines := append(rows, "", dim(" "+hariID(now)+", "+tanggalID(now)+" · "+now.Format("MST")))
|
||||
tanggal := fmt.Sprintf("%d %s %d", now.Day(), i18n.MonthName(now), now.Year())
|
||||
lines := append(rows, "", dim(" "+i18n.WeekdayName(now)+", "+tanggal+" · "+now.Format("MST")))
|
||||
if len(lines) > h-2 {
|
||||
lines = lines[:h-2]
|
||||
}
|
||||
return panel("Jam", "", lines, w, h, false)
|
||||
return panel(i18n.T("section.clock"), "", lines, w, h, false)
|
||||
}
|
||||
|
||||
func (m Model) dockerPanel(w, h int) string {
|
||||
var lines []string
|
||||
switch {
|
||||
case m.engineErr != nil:
|
||||
lines = append(lines, red("GAGAL"), "", dim(truncate(m.engineErr.Error(), w-6)))
|
||||
lines = append(lines, red(i18n.T("error.fail")), "", dim(truncate(m.engineErr.Error(), w-6)))
|
||||
case m.engine.ServerVersion == "":
|
||||
lines = append(lines, dim("memuat…"))
|
||||
lines = append(lines, dim(i18n.T("state.loading")))
|
||||
default:
|
||||
lines = append(lines,
|
||||
text("Engine ")+dim(m.engine.ServerVersion),
|
||||
text("API ")+dim(m.engine.APIVersion),
|
||||
text("Kontainer ")+dim(fmt.Sprintf("%d jalan / %d total", m.engine.Running, m.engine.Containers)),
|
||||
text("Image ")+dim(fmt.Sprintf("%d", m.engine.Images)),
|
||||
fieldW(i18n.T("field.engine"), 9)+dim(m.engine.ServerVersion),
|
||||
fieldW(i18n.T("field.api"), 9)+dim(m.engine.APIVersion),
|
||||
fieldW(i18n.T("section.containers"), 9)+dim(i18n.Tf("fmt.dockerContainers", m.engine.Running, m.engine.Containers)),
|
||||
fieldW(i18n.T("field.image"), 9)+dim(fmt.Sprintf("%d", m.engine.Images)),
|
||||
"",
|
||||
styled("#a6e3a1", "● terhubung"),
|
||||
styled("#a6e3a1", i18n.T("state.connected")),
|
||||
)
|
||||
}
|
||||
return panel("Docker", "lokal", lines, w, h, false)
|
||||
return panel("Docker", i18n.T("host.local"), lines, w, h, false)
|
||||
}
|
||||
|
||||
func (m Model) hostPanel(w, h int) string {
|
||||
@@ -90,21 +92,21 @@ func (m Model) hostPanel(w, h int) string {
|
||||
pct = int((s.MemTotal - s.MemAvail) * 100 / s.MemTotal)
|
||||
}
|
||||
lines := []string{
|
||||
text("Host ") + dim(m.hostname),
|
||||
text("CPU ") + dim(fmt.Sprintf("%d core · load %.2f", cpus, s.Load1)),
|
||||
text("RAM ") + dim(fmt.Sprintf("%3d%% ", pct)) + memBar(pct, 10) +
|
||||
fieldW(i18n.T("field.host"), 9) + dim(m.hostname),
|
||||
fieldW(i18n.T("field.cpu"), 9) + dim(i18n.Tf("fmt.coreLoad", cpus, s.Load1)),
|
||||
fieldW(i18n.T("field.ram"), 9) + dim(i18n.Tf("fmt.ramUsage", pct)) + memBar(pct, 10) +
|
||||
dim(fmt.Sprintf(" %s/%s", humanKBs(s.MemTotal-s.MemAvail), humanKBs(s.MemTotal))),
|
||||
text("Uptime ") + dim(humanDuration(s.Uptime)),
|
||||
fieldW(i18n.T("field.uptime"), 9) + dim(humanDuration(s.Uptime)),
|
||||
}
|
||||
return panel("Host", "", lines, w, h, false)
|
||||
return panel(i18n.T("section.host"), "", lines, w, h, false)
|
||||
}
|
||||
|
||||
func (m Model) miniContainerLines(maxLines int) []string {
|
||||
if m.loading && len(m.containers) == 0 {
|
||||
return []string{dim("memuat…")}
|
||||
return []string{dim(i18n.T("state.loading"))}
|
||||
}
|
||||
if len(m.containers) == 0 {
|
||||
return []string{dim("belum ada kontainer — tekan 1")}
|
||||
return []string{dim(i18n.T("hint.miniNoContainers"))}
|
||||
}
|
||||
max := maxLines
|
||||
if len(m.containers) > max {
|
||||
@@ -117,31 +119,22 @@ func (m Model) miniContainerLines(maxLines int) []string {
|
||||
lines = append(lines, styled(color, glyph)+" "+text(truncate(c.Name, 24))+" "+dim(truncate(c.Image, 32)))
|
||||
}
|
||||
if extra := len(m.containers) - len(lines); extra > 0 {
|
||||
lines = append(lines, dim(fmt.Sprintf("… %d lainnya (tekan 1)", extra)))
|
||||
lines = append(lines, dim(i18n.Tf("fmt.listMore", extra)))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func activityLines() []string {
|
||||
return []string{
|
||||
dim("Listener Docker events menyusul (Phase 2)."),
|
||||
dim(i18n.T("activity.coming")),
|
||||
"",
|
||||
dim("Panel ini akan menampilkan 5 aktivitas terbaru:"),
|
||||
dim("kontainer start/stop, image di-pull, error terakhir."),
|
||||
dim(i18n.T("activity.willShow")),
|
||||
dim(i18n.T("activity.examples")),
|
||||
}
|
||||
}
|
||||
|
||||
// --- small formatting helpers ---
|
||||
|
||||
var hari = [...]string{"Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"}
|
||||
var bulan = [...]string{"Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"}
|
||||
|
||||
func hariID(t time.Time) string { return hari[int(t.Weekday())] }
|
||||
|
||||
func tanggalID(t time.Time) string {
|
||||
return fmt.Sprintf("%d %s %d", t.Day(), bulan[int(t.Month())-1], t.Year())
|
||||
}
|
||||
|
||||
func humanKB(kb uint64) string {
|
||||
f := float64(kb) * 1024
|
||||
units := []string{"B", "KB", "MB", "GB", "TB"}
|
||||
@@ -167,11 +160,11 @@ func humanDuration(sec float64) string {
|
||||
mins := int(d.Minutes()) % 60
|
||||
switch {
|
||||
case days > 0:
|
||||
return fmt.Sprintf("%d hari %d jam", days, hours)
|
||||
return i18n.Tf("duration.days", days, hours)
|
||||
case hours > 0:
|
||||
return fmt.Sprintf("%d jam %d mnt", hours, mins)
|
||||
return i18n.Tf("duration.hours", hours, mins)
|
||||
default:
|
||||
return fmt.Sprintf("%d mnt", mins)
|
||||
return i18n.Tf("duration.minutes", mins)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,13 +13,11 @@ import (
|
||||
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/audio"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker"
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n"
|
||||
)
|
||||
|
||||
// Doctor memeriksa kesehatan sistem dan menulis laporan ke w.
|
||||
// Kode keluar: 0 = sehat (warning boleh), 1 = ada kegagalan fatal.
|
||||
//
|
||||
// shortcut: teks masih hardcode Bahasa Indonesia — pindah ke katalog i18n
|
||||
// saat F-023 masuk (Phase 1).
|
||||
func Doctor(w io.Writer) int {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -29,18 +27,20 @@ func Doctor(w io.Writer) int {
|
||||
// Docker — dependency inti.
|
||||
switch cl, err := docker.New(); {
|
||||
case err != nil:
|
||||
fmt.Fprintf(w, "docker : GAGAL %v\n", err)
|
||||
fmt.Fprintf(w, "docker : %s %v\n", i18n.T("error.fail"), err)
|
||||
fail++
|
||||
default:
|
||||
defer cl.Close()
|
||||
if err := cl.Ping(ctx); err != nil {
|
||||
fmt.Fprintf(w, "docker : GAGAL daemon tidak merespons: %v\n", err)
|
||||
fmt.Fprintf(w, "docker : %s %s\n", i18n.T("error.fail"),
|
||||
i18n.Tf("doctor.dockerDaemonUnreachable", err))
|
||||
fail++
|
||||
} else if info, err := cl.Info(ctx); err != nil {
|
||||
fmt.Fprintf(w, "docker : PERINGATAN %v\n", err)
|
||||
fmt.Fprintf(w, "docker : %s %v\n", i18n.T("error.warn"), err)
|
||||
} else {
|
||||
fmt.Fprintf(w, "docker : OK engine %s (API %s) · %d/%d kontainer berjalan · %d image\n",
|
||||
info.ServerVersion, info.APIVersion, info.Running, info.Containers, info.Images)
|
||||
fmt.Fprintf(w, "docker : %s %s\n", i18n.T("error.ok"),
|
||||
i18n.Tf("doctor.dockerOk", info.ServerVersion, info.APIVersion,
|
||||
info.Running, info.Containers, info.Images))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func Doctor(w io.Writer) int {
|
||||
if st, err := os.Stat(stacks); err == nil && st.IsDir() {
|
||||
fmt.Fprintf(w, "stacks : %s\n", stacks)
|
||||
} else {
|
||||
fmt.Fprintf(w, "stacks : %s (belum ada — dibuat saat onboarding)\n", stacks)
|
||||
fmt.Fprintf(w, "stacks : %s\n", i18n.Tf("doctor.stacksMissing", stacks))
|
||||
}
|
||||
|
||||
if fail > 0 {
|
||||
|
||||
@@ -76,7 +76,7 @@ type Container struct {
|
||||
func (c *Client) ListContainers(ctx context.Context) ([]Container, error) {
|
||||
list, err := c.cli.ContainerList(ctx, container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker: daftar kontainer: %w", err)
|
||||
return nil, fmt.Errorf("docker: container list: %w", err)
|
||||
}
|
||||
out := make([]Container, 0, len(list))
|
||||
for _, ct := range list {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// Package i18n — katalog bahasa (F-023). Bahasa Indonesia default,
|
||||
// fallback otomatis ke English bila kunci belum ada di katalog aktif.
|
||||
// Katalog di-embed ke binary (tetap single binary).
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
//go:embed locales/*.yaml
|
||||
var locales embed.FS
|
||||
|
||||
const (
|
||||
DefaultLang = "id"
|
||||
FallbackLang = "en"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
active = DefaultLang
|
||||
catalogs = map[string]map[string]string{}
|
||||
)
|
||||
|
||||
func init() {
|
||||
for _, lang := range []string{DefaultLang, FallbackLang} {
|
||||
if c, err := load(lang); err == nil {
|
||||
catalogs[lang] = c
|
||||
}
|
||||
}
|
||||
if l := os.Getenv("TIA_LANG"); l != "" {
|
||||
active = l
|
||||
}
|
||||
}
|
||||
|
||||
// shortcut: bahasa hanya dari TIA_LANG + default id — akan dibaca dari
|
||||
// config.yaml (ui.language) begitu internal/config masuk (Phase 1).
|
||||
func load(lang string) (map[string]string, error) {
|
||||
b, err := locales.ReadFile("locales/" + lang + ".yaml")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := yaml.Unmarshal(b, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]string{}
|
||||
flatten("", raw, out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func flatten(prefix string, in map[string]any, out map[string]string) {
|
||||
for k, v := range in {
|
||||
key := k
|
||||
if prefix != "" {
|
||||
key = prefix + "." + k
|
||||
}
|
||||
switch vv := v.(type) {
|
||||
case map[string]any:
|
||||
flatten(key, vv, out)
|
||||
case string:
|
||||
out[key] = vv
|
||||
default:
|
||||
out[key] = fmt.Sprint(vv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetLang mengganti bahasa aktif (dipakai wizard & layar Pengaturan).
|
||||
func SetLang(lang string) {
|
||||
mu.Lock()
|
||||
active = lang
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
func Lang() string {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return active
|
||||
}
|
||||
|
||||
// T — terjemahan; fallback: bahasa aktif → en → key itu sendiri.
|
||||
func T(key string) string {
|
||||
mu.RLock()
|
||||
lang := active
|
||||
mu.RUnlock()
|
||||
if v, ok := catalogs[lang][key]; ok {
|
||||
return v
|
||||
}
|
||||
if v, ok := catalogs[FallbackLang][key]; ok {
|
||||
return v
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// Tf — T + fmt.Sprintf.
|
||||
func Tf(key string, args ...any) string {
|
||||
s := T(key)
|
||||
if len(args) == 0 {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf(s, args...)
|
||||
}
|
||||
|
||||
// TOr — T dengan fallback eksplisit (mis. nama state Docker apa adanya).
|
||||
func TOr(key, fallback string) string {
|
||||
mu.RLock()
|
||||
lang := active
|
||||
mu.RUnlock()
|
||||
if v, ok := catalogs[lang][key]; ok {
|
||||
return v
|
||||
}
|
||||
if v, ok := catalogs[FallbackLang][key]; ok {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Keys — semua kunci pada katalog lang (untuk tes paritas katalog).
|
||||
func Keys(lang string) []string {
|
||||
out := make([]string, 0, len(catalogs[lang]))
|
||||
for k := range catalogs[lang] {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// --- nama hari & bulan (locale data) ---
|
||||
|
||||
var weekdays = map[string][]string{
|
||||
"id": {"Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"},
|
||||
"en": {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"},
|
||||
}
|
||||
|
||||
var months = map[string][]string{
|
||||
"id": {"Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"},
|
||||
"en": {"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"},
|
||||
}
|
||||
|
||||
func WeekdayName(t time.Time) string { return pick(weekdays, int(t.Weekday())) }
|
||||
func MonthName(t time.Time) string { return pick(months, int(t.Month())-1) }
|
||||
|
||||
func pick(m map[string][]string, i int) string {
|
||||
mu.RLock()
|
||||
lang := active
|
||||
mu.RUnlock()
|
||||
arr, ok := m[lang]
|
||||
if !ok {
|
||||
arr = m[FallbackLang]
|
||||
}
|
||||
if i < 0 || i >= len(arr) {
|
||||
return ""
|
||||
}
|
||||
return arr[i]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Paritas katalog: setiap kunci harus ada di kedua bahasa.
|
||||
func TestCatalogParity(t *testing.T) {
|
||||
id := Keys("id")
|
||||
en := Keys("en")
|
||||
if len(id) == 0 || len(en) == 0 {
|
||||
t.Fatalf("katalog tidak termuat: id=%d en=%d", len(id), len(en))
|
||||
}
|
||||
inEN := map[string]bool{}
|
||||
for _, k := range en {
|
||||
inEN[k] = true
|
||||
}
|
||||
inID := map[string]bool{}
|
||||
for _, k := range id {
|
||||
inID[k] = true
|
||||
}
|
||||
for _, k := range id {
|
||||
if !inEN[k] {
|
||||
t.Errorf("kunci %q ada di id tapi tidak di en", k)
|
||||
}
|
||||
}
|
||||
for _, k := range en {
|
||||
if !inID[k] {
|
||||
t.Errorf("kunci %q ada di en tapi tidak di id", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTFallback(t *testing.T) {
|
||||
SetLang("id")
|
||||
if got := T("nav.home"); got != "beranda" {
|
||||
t.Fatalf("id nav.home = %q", got)
|
||||
}
|
||||
SetLang("en")
|
||||
if got := T("nav.home"); got != "home" {
|
||||
t.Fatalf("en nav.home = %q", got)
|
||||
}
|
||||
// fallback: kunci tak dikenal → key itu sendiri
|
||||
if got := T("tidak.ada"); got != "tidak.ada" {
|
||||
t.Fatalf("fallback = %q", got)
|
||||
}
|
||||
if got := TOr("state.tidakada", "raw"); got != "raw" {
|
||||
t.Fatalf("TOr = %q", got)
|
||||
}
|
||||
SetLang(DefaultLang)
|
||||
}
|
||||
|
||||
func TestTf(t *testing.T) {
|
||||
SetLang("id")
|
||||
got := Tf("fmt.countTotal", 7)
|
||||
if got != "7 total" {
|
||||
t.Fatalf("Tf = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocaleDates(t *testing.T) {
|
||||
d := time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC) // Kamis
|
||||
SetLang("id")
|
||||
if WeekdayName(d) != "Kamis" || MonthName(d) != "Oktober" {
|
||||
t.Fatalf("id: %s %s", WeekdayName(d), MonthName(d))
|
||||
}
|
||||
SetLang("en")
|
||||
if WeekdayName(d) != "Thursday" || MonthName(d) != "October" {
|
||||
t.Fatalf("en: %s %s", WeekdayName(d), MonthName(d))
|
||||
}
|
||||
SetLang(DefaultLang)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
# English catalog (fallback) — F-023
|
||||
# Rule: technical terms (Env, Image, Volume, Stack, Shell, CPU, RAM, PID, Log,
|
||||
# Docker, API) are not translated.
|
||||
|
||||
nav:
|
||||
home: "home"
|
||||
containers: "containers"
|
||||
|
||||
section:
|
||||
clock: "Clock"
|
||||
host: "Host"
|
||||
containers: "Containers"
|
||||
activity: "Activity"
|
||||
detail: "Detail"
|
||||
|
||||
host:
|
||||
local: "local"
|
||||
|
||||
field:
|
||||
id: "ID"
|
||||
image: "Image"
|
||||
state: "State"
|
||||
status: "Status"
|
||||
host: "Host"
|
||||
cpu: "CPU"
|
||||
ram: "RAM"
|
||||
uptime: "Uptime"
|
||||
engine: "Engine"
|
||||
api: "API"
|
||||
|
||||
state:
|
||||
loading: "loading…"
|
||||
noContainers: "no containers yet"
|
||||
connected: "● connected"
|
||||
running: "running"
|
||||
exited: "stopped"
|
||||
paused: "paused"
|
||||
restarting: "restarting"
|
||||
created: "created"
|
||||
dead: "dead"
|
||||
|
||||
error:
|
||||
fail: "FAILED"
|
||||
warn: "WARNING"
|
||||
ok: "OK"
|
||||
loadFailed: "Failed to load"
|
||||
loadContainers: "Failed to load container list"
|
||||
|
||||
hint:
|
||||
retry: "press r to retry"
|
||||
noContainers: "No containers yet — run something with Docker, then press r."
|
||||
miniNoContainers: "no containers yet — press 1"
|
||||
tabsComing: "Detail tabs (Logs · Stats · Config) coming in Phase 2."
|
||||
footerHome: "[1/enter] containers · [t] toast · [a] reduced-motion · [q] quit"
|
||||
footerContainers: "[j/k] select · [r] reload · [0/esc] home · [q] quit"
|
||||
|
||||
footer:
|
||||
status: "%s animation %s fps · audio: %s"
|
||||
|
||||
toast:
|
||||
demoTitle: "✔ [ SUCCESS ] Conversion Saved"
|
||||
demoBody: "Stack 'web-app' saved to ~/.tia/stacks/web-app/"
|
||||
dockerError: "✖ [ FAILED ] Docker"
|
||||
|
||||
activity:
|
||||
coming: "Docker events listener coming (Phase 2)."
|
||||
willShow: "This panel will show the 5 most recent events:"
|
||||
examples: "container start/stop, image pulls, latest error."
|
||||
|
||||
fmt:
|
||||
countTotal: "%d total"
|
||||
dockerContainers: "%d running / %d total"
|
||||
coreLoad: "%d core · load %.2f"
|
||||
ramUsage: "%3d%% "
|
||||
listMore: "… %d more (press 1)"
|
||||
phase: "Phase %s"
|
||||
|
||||
duration:
|
||||
days: "%d days %d hours"
|
||||
hours: "%d hours %d mins"
|
||||
minutes: "%d mins"
|
||||
|
||||
doctor:
|
||||
dockerDaemonUnreachable: "daemon not responding: %v"
|
||||
dockerOk: "engine %s (API %s) · %d/%d containers running · %d images"
|
||||
stacksMissing: "%s (missing — created during onboarding)"
|
||||
|
||||
cli:
|
||||
unknownCommand: "unknown command: %q"
|
||||
|
||||
usage:
|
||||
usage: "Usage:"
|
||||
open: "Open the interactive TUI"
|
||||
doctor: "Check system health (docker, audio, stacks)"
|
||||
version: "Show version & build info"
|
||||
more: "Other CLI subcommands (convert, ps, up, down, update) arriving in Phase 2–3 — see docs/PRD.md §F-028."
|
||||
@@ -0,0 +1,96 @@
|
||||
# Katalog Bahasa Indonesia (default) — F-023
|
||||
# Aturan: istilah teknis (Env, Image, Volume, Stack, Shell, CPU, RAM, PID, Log,
|
||||
# Docker, API) tidak diterjemahkan.
|
||||
|
||||
nav:
|
||||
home: "beranda"
|
||||
containers: "kontainer"
|
||||
|
||||
section:
|
||||
clock: "Jam"
|
||||
host: "Host"
|
||||
containers: "Kontainer"
|
||||
activity: "Aktivitas"
|
||||
detail: "Detail"
|
||||
|
||||
host:
|
||||
local: "lokal"
|
||||
|
||||
field:
|
||||
id: "ID"
|
||||
image: "Image"
|
||||
state: "State"
|
||||
status: "Status"
|
||||
host: "Host"
|
||||
cpu: "CPU"
|
||||
ram: "RAM"
|
||||
uptime: "Uptime"
|
||||
engine: "Engine"
|
||||
api: "API"
|
||||
|
||||
state:
|
||||
loading: "memuat…"
|
||||
noContainers: "belum ada kontainer"
|
||||
connected: "● terhubung"
|
||||
running: "berjalan"
|
||||
exited: "berhenti"
|
||||
paused: "jeda"
|
||||
restarting: "memulai ulang"
|
||||
created: "dibuat"
|
||||
dead: "mati"
|
||||
|
||||
error:
|
||||
fail: "GAGAL"
|
||||
warn: "PERINGATAN"
|
||||
ok: "OK"
|
||||
loadFailed: "GAGAL memuat"
|
||||
loadContainers: "Gagal memuat daftar kontainer"
|
||||
|
||||
hint:
|
||||
retry: "tekan r untuk coba lagi"
|
||||
noContainers: "Belum ada kontainer — jalankan sesuatu dengan Docker, lalu tekan r."
|
||||
miniNoContainers: "belum ada kontainer — tekan 1"
|
||||
tabsComing: "Tab detail (Log · Statistik · Konfigurasi) menyusul di Phase 2."
|
||||
footerHome: "[1/enter] kontainer · [t] toast · [a] reduced-motion · [q] keluar"
|
||||
footerContainers: "[j/k] pilih · [r] muat ulang · [0/esc] beranda · [q] keluar"
|
||||
|
||||
footer:
|
||||
status: "%s animasi %s fps · audio: %s"
|
||||
|
||||
toast:
|
||||
demoTitle: "✔ [ SUKSES ] Konversi Berhasil Disimpan"
|
||||
demoBody: "Stack 'web-app' tersimpan di ~/.tia/stacks/web-app/"
|
||||
dockerError: "✖ [ GAGAL ] Docker"
|
||||
|
||||
activity:
|
||||
coming: "Listener Docker events menyusul (Phase 2)."
|
||||
willShow: "Panel ini akan menampilkan 5 aktivitas terbaru:"
|
||||
examples: "kontainer start/stop, image di-pull, error terakhir."
|
||||
|
||||
fmt:
|
||||
countTotal: "%d total"
|
||||
dockerContainers: "%d jalan / %d total"
|
||||
coreLoad: "%d core · load %.2f"
|
||||
ramUsage: "%3d%% "
|
||||
listMore: "… %d lainnya (tekan 1)"
|
||||
phase: "Phase %s"
|
||||
|
||||
duration:
|
||||
days: "%d hari %d jam"
|
||||
hours: "%d jam %d mnt"
|
||||
minutes: "%d mnt"
|
||||
|
||||
doctor:
|
||||
dockerDaemonUnreachable: "daemon tidak merespons: %v"
|
||||
dockerOk: "engine %s (API %s) · %d/%d kontainer berjalan · %d image"
|
||||
stacksMissing: "%s (belum ada — dibuat saat onboarding)"
|
||||
|
||||
cli:
|
||||
unknownCommand: "perintah tidak dikenal: %q"
|
||||
|
||||
usage:
|
||||
usage: "Pemakaian:"
|
||||
open: "Buka TUI interaktif"
|
||||
doctor: "Periksa kesehatan sistem (docker, audio, stack)"
|
||||
version: "Tampilkan versi & build info"
|
||||
more: "Subcommand CLI lain (convert, ps, up, down, update) menyusul di Phase 2–3 — lihat docs/PRD.md §F-028."
|
||||
Reference in new issue
Block a user