diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d6cf093..8c1036f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -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 | diff --git a/cmd/tia-love/main.go b/cmd/tia-love/main.go index a3f3611..d0376a4 100644 --- a/cmd/tia-love/main.go +++ b/cmd/tia-love/main.go @@ -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) } } diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 7708a57..5e3f8b8 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -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) diff --git a/go.mod b/go.mod index 53ee0c6..a35e9ef 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 7566ce5..8f52457 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/app/app.go b/internal/app/app.go index 9c616a9..2aca5f8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) diff --git a/internal/app/home.go b/internal/app/home.go index 6181a61..d81a76f 100644 --- a/internal/app/home.go +++ b/internal/app/home.go @@ -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) } } diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 86c1133..0255270 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -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 { diff --git a/internal/docker/client.go b/internal/docker/client.go index 7130dfa..d26dd5d 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -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 { diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 0000000..dfb7354 --- /dev/null +++ b/internal/i18n/i18n.go @@ -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] +} diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go new file mode 100644 index 0000000..4898da0 --- /dev/null +++ b/internal/i18n/i18n_test.go @@ -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) +} diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml new file mode 100644 index 0000000..104902e --- /dev/null +++ b/internal/i18n/locales/en.yaml @@ -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." diff --git a/internal/i18n/locales/id.yaml b/internal/i18n/locales/id.yaml new file mode 100644 index 0000000..94b426b --- /dev/null +++ b/internal/i18n/locales/id.yaml @@ -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."