Merge pull request #2015 from ksylvan/kayvan/i18n-fixes-2026-02-19

Implement comprehensive i18n support across all plugins and tools
This commit is contained in:
Kayvan Sylvan 2026-02-19 14:17:12 -08:00 committed by GitHub
commit ab50a4a392
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 5821 additions and 4950 deletions

View file

@ -0,0 +1,7 @@
### PR [#2015](https://github.com/danielmiessler/Fabric/pull/2015) by [ksylvan](https://github.com/ksylvan): Implement comprehensive i18n support across all plugins and tools
- Implement comprehensive internationalization (i18n) support across all AI vendor plugins, tools, and template plugins.
- Update localization files for multiple languages with new translation keys.
- Replace hardcoded strings in Spotify and YouTube tools with localized equivalents.
- Add unit tests for localized error handling in Ollama.
- Standardize setup questions using the new i18n translation framework.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -13,6 +13,7 @@ import (
"github.com/anthropics/anthropic-sdk-go/option"
"github.com/danielmiessler/fabric/internal/chat"
"github.com/danielmiessler/fabric/internal/domain"
"github.com/danielmiessler/fabric/internal/i18n"
debuglog "github.com/danielmiessler/fabric/internal/log"
"github.com/danielmiessler/fabric/internal/plugins"
)
@ -194,7 +195,7 @@ func (an *Client) SendStream(
}
if stream.Err() != nil {
fmt.Fprintf(os.Stderr, "Messages stream error: %v\n", stream.Err())
fmt.Fprintf(os.Stderr, i18n.T("anthropic_stream_error"), stream.Err())
}
close(channel)
return

View file

@ -9,6 +9,7 @@ import (
"net/url"
"strings"
"github.com/danielmiessler/fabric/internal/i18n"
"github.com/danielmiessler/fabric/internal/plugins"
"github.com/danielmiessler/fabric/internal/plugins/ai/openai"
openaiapi "github.com/openai/openai-go"
@ -20,9 +21,9 @@ func NewClient() (ret *Client) {
ret = &Client{}
ret.Client = openai.NewClientCompatible("Azure", "", ret.configure)
ret.ApiDeployments = ret.AddSetupQuestionCustom("deployments", true,
"Enter your Azure deployments (comma separated)")
i18n.T("azure_deployments_question"))
ret.ApiVersion = ret.AddSetupQuestionCustom("API Version", false,
"Enter the Azure API version (optional)")
i18n.T("azure_api_version_question"))
return
}
@ -42,12 +43,12 @@ func (oi *Client) configure() error {
apiKey := strings.TrimSpace(oi.ApiKey.Value)
if apiKey == "" {
return fmt.Errorf("Azure API key is required")
return fmt.Errorf("%s", i18n.T("azure_api_key_required"))
}
baseURL := strings.TrimSpace(oi.ApiBaseURL.Value)
if baseURL == "" {
return fmt.Errorf("Azure API base URL is required")
return fmt.Errorf("%s", i18n.T("azure_base_url_required"))
}
apiVersion := strings.TrimSpace(oi.ApiVersion.Value)
@ -101,7 +102,7 @@ func azureDeploymentMiddleware(req *http.Request, next option.MiddlewareNext) (*
// Extract model/deployment name from request body
deploymentName, err := extractDeploymentFromBody(req)
if err != nil {
return nil, fmt.Errorf("failed to extract deployment name: %w", err)
return nil, fmt.Errorf("%s: %w", i18n.T("azure_failed_extract_deployment"), err)
}
// Transform path: /chat/completions -> /deployments/{name}/chat/completions
@ -117,7 +118,7 @@ func azureDeploymentMiddleware(req *http.Request, next option.MiddlewareNext) (*
// and restores the body for subsequent use
func extractDeploymentFromBody(req *http.Request) (string, error) {
if req.Body == nil {
return "", fmt.Errorf("request body is nil")
return "", fmt.Errorf("%s", i18n.T("azure_request_body_nil"))
}
bodyBytes, err := io.ReadAll(req.Body)
@ -135,7 +136,7 @@ func extractDeploymentFromBody(req *http.Request) (string, error) {
}
if payload.Model == "" {
return "", fmt.Errorf("model field is empty or missing in request body")
return "", fmt.Errorf("%s", i18n.T("azure_model_field_empty"))
}
return payload.Model, nil

View file

@ -13,6 +13,7 @@ import (
"github.com/danielmiessler/fabric/internal/chat"
"github.com/danielmiessler/fabric/internal/domain"
"github.com/danielmiessler/fabric/internal/i18n"
debuglog "github.com/danielmiessler/fabric/internal/log"
"github.com/danielmiessler/fabric/internal/plugins"
ollamaapi "github.com/ollama/ollama/api"
@ -27,12 +28,12 @@ func NewClient() (ret *Client) {
ret.PluginBase = plugins.NewVendorPluginBase(vendorName, ret.configure)
ret.ApiUrl = ret.AddSetupQuestionCustom("API URL", true,
"Enter your Ollama URL (as a reminder, it is usually http://localhost:11434')")
fmt.Sprintf(i18n.T("lmstudio_api_url_question"), vendorName, defaultBaseUrl))
ret.ApiUrl.Value = defaultBaseUrl
ret.ApiKey = ret.PluginBase.AddSetupQuestion("API key", false)
ret.ApiKey.Value = ""
ret.ApiHttpTimeout = ret.AddSetupQuestionCustom("HTTP Timeout", true,
"Specify HTTP timeout duration for Ollama requests (e.g. 30s, 5m, 1h)")
i18n.T("ollama_http_timeout_question"))
ret.ApiHttpTimeout.Value = "20m"
return
@ -67,7 +68,7 @@ func (o *Client) IsConfigured() bool {
func (o *Client) configure() (err error) {
if o.apiUrl, err = url.Parse(o.ApiUrl.Value); err != nil {
fmt.Printf("cannot parse URL: %s: %v\n", o.ApiUrl.Value, err)
fmt.Printf("%s\n", fmt.Sprintf(i18n.T("ollama_cannot_parse_url"), o.ApiUrl.Value, err))
return
}
@ -78,7 +79,7 @@ func (o *Client) configure() (err error) {
if err == nil && o.ApiHttpTimeout.Value != "" {
timeout = parsed
} else if o.ApiHttpTimeout.Value != "" {
fmt.Printf("Invalid HTTP timeout format (%q), using default (20m): %v\n", o.ApiHttpTimeout.Value, err)
fmt.Printf("%s\n", fmt.Sprintf(i18n.T("ollama_invalid_http_timeout_using_default"), o.ApiHttpTimeout.Value, err))
}
}
@ -152,7 +153,7 @@ func (o *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, o
}
if err = o.client.Chat(ctx, &req, respFunc); err != nil {
debuglog.Debug(debuglog.Basic, "Ollama chat request failed: %v\n", err)
debuglog.Debug(debuglog.Basic, "%s\n", fmt.Sprintf(i18n.T("ollama_chat_request_failed"), err))
}
return
}
@ -225,11 +226,11 @@ func (o *Client) loadImageBytes(ctx context.Context, imageURL string) (ret []byt
if strings.HasPrefix(imageURL, "data:") {
parts := strings.SplitN(imageURL, ",", 2)
if len(parts) != 2 {
err = fmt.Errorf("invalid data URL format")
err = fmt.Errorf("%s", i18n.T("ollama_invalid_data_url_format"))
return
}
if ret, err = base64.StdEncoding.DecodeString(parts[1]); err != nil {
err = fmt.Errorf("failed to decode data URL: %w", err)
err = fmt.Errorf("%s", fmt.Sprintf(i18n.T("ollama_failed_decode_data_url"), err))
}
return
}
@ -247,7 +248,7 @@ func (o *Client) loadImageBytes(ctx context.Context, imageURL string) (ret []byt
defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
err = fmt.Errorf("failed to fetch image %s: %s", imageURL, resp.Status)
err = fmt.Errorf("%s", fmt.Sprintf(i18n.T("ollama_failed_fetch_image"), imageURL, resp.Status))
return
}

View file

@ -0,0 +1,62 @@
package ollama
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/danielmiessler/fabric/internal/i18n"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoadImageBytes_DataURLValidationErrorsAreLocalized(t *testing.T) {
_, err := i18n.Init("en")
require.NoError(t, err)
client := &Client{}
_, err = client.loadImageBytes(context.Background(), "data:image/png;base64")
require.Error(t, err)
assert.Equal(t, i18n.T("ollama_invalid_data_url_format"), err.Error())
_, err = client.loadImageBytes(context.Background(), "data:image/png;base64,%%%%")
require.Error(t, err)
assert.True(t, strings.HasPrefix(err.Error(), strings.Split(i18n.T("ollama_failed_decode_data_url"), "%v")[0]))
}
func TestLoadImageBytes_HTTPFetchErrorIsLocalized(t *testing.T) {
_, err := i18n.Init("en")
require.NoError(t, err)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(server.Close)
client := &Client{httpClient: server.Client()}
_, err = client.loadImageBytes(context.Background(), server.URL+"/image.png")
require.Error(t, err)
assert.Equal(t,
fmt.Sprintf(i18n.T("ollama_failed_fetch_image"), server.URL+"/image.png", "500 Internal Server Error"),
err.Error(),
)
}
func TestLoadImageBytes_DataURLSuccess(t *testing.T) {
_, err := i18n.Init("en")
require.NoError(t, err)
client := &Client{}
expected := []byte("hello world")
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(expected)
got, err := client.loadImageBytes(context.Background(), dataURL)
require.NoError(t, err)
assert.Equal(t, expected, got)
}

View file

@ -12,6 +12,7 @@ import (
"strings"
"sync"
"github.com/danielmiessler/fabric/internal/i18n"
debuglog "github.com/danielmiessler/fabric/internal/log"
openai "github.com/openai/openai-go"
@ -53,12 +54,12 @@ func (o *Client) TranscribeFile(ctx context.Context, filePath, model string, spl
}
if !slices.Contains(AllowedTranscriptionModels, model) {
return "", fmt.Errorf("model '%s' is not supported for transcription", model)
return "", fmt.Errorf("%s", fmt.Sprintf(i18n.T("openai_audio_model_not_supported_for_transcription"), model))
}
ext := strings.ToLower(filepath.Ext(filePath))
if _, ok := allowedAudioExtensions[ext]; !ok {
return "", fmt.Errorf("unsupported audio format '%s'", ext)
return "", fmt.Errorf("%s", fmt.Sprintf(i18n.T("openai_audio_unsupported_audio_format"), ext))
}
info, err := os.Stat(filePath)
@ -70,9 +71,9 @@ func (o *Client) TranscribeFile(ctx context.Context, filePath, model string, spl
var cleanup func()
if info.Size() > MaxAudioFileSize {
if !split {
return "", fmt.Errorf("file %s exceeds 25MB limit; use --split-media-file to enable automatic splitting", filePath)
return "", fmt.Errorf("%s", fmt.Sprintf(i18n.T("openai_audio_file_exceeds_limit_enable_split"), filePath))
}
debuglog.Log("File %s is larger than the size limit... breaking it up into chunks...\n", filePath)
debuglog.Log("%s\n", fmt.Sprintf(i18n.T("openai_audio_file_exceeds_limit_splitting"), filePath))
if files, cleanup, err = splitAudioFile(filePath, ext, MaxAudioFileSize); err != nil {
return "", err
}
@ -88,7 +89,7 @@ func (o *Client) TranscribeFile(ctx context.Context, filePath, model string, spl
wg.Add(1)
go func(index int, filePath string) {
defer wg.Done()
debuglog.Log("Using model %s to transcribe part %d (file name: %s)...\n", model, index+1, filePath)
debuglog.Log("%s\n", fmt.Sprintf(i18n.T("openai_audio_using_model_to_transcribe_part"), model, index+1, filePath))
chunk, openErr := os.Open(filePath)
if openErr != nil {
@ -140,7 +141,7 @@ func (o *Client) TranscribeFile(ctx context.Context, filePath, model string, spl
// It returns the list of chunk file paths and a cleanup function.
func splitAudioFile(src, ext string, maxSize int64) (files []string, cleanup func(), err error) {
if _, err = exec.LookPath("ffmpeg"); err != nil {
return nil, nil, fmt.Errorf("ffmpeg not found: please install it")
return nil, nil, fmt.Errorf("%s", i18n.T("openai_audio_ffmpeg_not_found_install"))
}
var dir string
@ -152,12 +153,12 @@ func splitAudioFile(src, ext string, maxSize int64) (files []string, cleanup fun
segmentTime := 600 // start with 10 minutes
for {
pattern := filepath.Join(dir, "chunk-%03d"+ext)
debuglog.Log("Running ffmpeg to split audio into %d-second chunks...\n", segmentTime)
debuglog.Log("%s\n", fmt.Sprintf(i18n.T("openai_audio_running_ffmpeg_split_chunks"), segmentTime))
cmd := exec.Command("ffmpeg", "-y", "-i", src, "-f", "segment", "-segment_time", fmt.Sprintf("%d", segmentTime), "-c", "copy", pattern)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err = cmd.Run(); err != nil {
return nil, cleanup, fmt.Errorf("ffmpeg failed: %v: %s", err, stderr.String())
return nil, cleanup, fmt.Errorf("%s", fmt.Sprintf(i18n.T("openai_audio_ffmpeg_failed"), err, stderr.String()))
}
if files, err = filepath.Glob(filepath.Join(dir, "chunk-*"+ext)); err != nil {
@ -183,7 +184,7 @@ func splitAudioFile(src, ext string, maxSize int64) (files []string, cleanup fun
_ = os.Remove(f)
}
if segmentTime <= 1 {
return nil, cleanup, fmt.Errorf("unable to split file into acceptable size chunks")
return nil, cleanup, fmt.Errorf("%s", i18n.T("openai_audio_unable_to_split_acceptable_size_chunks"))
}
segmentTime /= 2
}

View file

@ -0,0 +1,64 @@
package openai
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/danielmiessler/fabric/internal/i18n"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTranscribeFile_ValidationErrorsAreLocalized(t *testing.T) {
_, err := i18n.Init("en")
require.NoError(t, err)
client := &Client{}
audioFile, err := os.CreateTemp("", "transcribe-valid-*.mp3")
require.NoError(t, err)
require.NoError(t, audioFile.Close())
t.Cleanup(func() { _ = os.Remove(audioFile.Name()) })
_, err = client.TranscribeFile(context.Background(), audioFile.Name(), "not-a-model", false)
require.Error(t, err)
assert.Equal(t,
fmt.Sprintf(i18n.T("openai_audio_model_not_supported_for_transcription"), "not-a-model"),
err.Error(),
)
unsupportedFile, err := os.CreateTemp("", "transcribe-invalid-*.txt")
require.NoError(t, err)
require.NoError(t, unsupportedFile.Close())
t.Cleanup(func() { _ = os.Remove(unsupportedFile.Name()) })
_, err = client.TranscribeFile(context.Background(), unsupportedFile.Name(), AllowedTranscriptionModels[0], false)
require.Error(t, err)
assert.Equal(t,
fmt.Sprintf(i18n.T("openai_audio_unsupported_audio_format"), filepath.Ext(unsupportedFile.Name())),
err.Error(),
)
}
func TestTranscribeFile_FileSizeLimitErrorIsLocalized(t *testing.T) {
_, err := i18n.Init("en")
require.NoError(t, err)
client := &Client{}
largeFile, err := os.CreateTemp("", "transcribe-large-*.mp3")
require.NoError(t, err)
t.Cleanup(func() { _ = os.Remove(largeFile.Name()) })
require.NoError(t, largeFile.Truncate(MaxAudioFileSize+1))
require.NoError(t, largeFile.Close())
_, err = client.TranscribeFile(context.Background(), largeFile.Name(), AllowedTranscriptionModels[0], false)
require.Error(t, err)
assert.Equal(t,
fmt.Sprintf(i18n.T("openai_audio_file_exceeds_limit_enable_split"), largeFile.Name()),
err.Error(),
)
}

View file

@ -12,6 +12,7 @@ import (
"strings"
"github.com/danielmiessler/fabric/internal/domain"
"github.com/danielmiessler/fabric/internal/i18n"
"github.com/openai/openai-go/packages/param"
"github.com/openai/openai-go/responses"
)
@ -115,23 +116,23 @@ func (o *Client) extractAndSaveImages(resp *responses.Response, opts *domain.Cha
// Decode base64 image data
imageData, err := base64.StdEncoding.DecodeString(imageCall.Result)
if err != nil {
return fmt.Errorf("failed to decode image data: %w", err)
return fmt.Errorf("%s", fmt.Sprintf(i18n.T("openai_image_failed_to_decode_image_data"), err))
}
// Ensure directory exists
dir := filepath.Dir(opts.ImageFile)
if dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
return fmt.Errorf("%s", fmt.Sprintf(i18n.T("openai_image_failed_to_create_directory"), dir, err))
}
}
// Save image to file
if err := os.WriteFile(opts.ImageFile, imageData, 0644); err != nil {
return fmt.Errorf("failed to save image to %s: %w", opts.ImageFile, err)
return fmt.Errorf("%s", fmt.Sprintf(i18n.T("openai_image_failed_to_save_image"), opts.ImageFile, err))
}
fmt.Printf("Image saved to: %s\n", opts.ImageFile)
fmt.Printf("%s\n", fmt.Sprintf(i18n.T("openai_image_saved_to"), opts.ImageFile))
return nil
}
}

View file

@ -7,12 +7,12 @@ import (
"strings"
"sync"
"github.com/danielmiessler/fabric/internal/chat"
"github.com/danielmiessler/fabric/internal/domain"
"github.com/danielmiessler/fabric/internal/i18n"
debuglog "github.com/danielmiessler/fabric/internal/log"
"github.com/danielmiessler/fabric/internal/plugins"
perplexity "github.com/sgaunet/perplexity-go/v2"
"github.com/danielmiessler/fabric/internal/chat"
)
const (
@ -46,7 +46,7 @@ func (c *Client) Configure() error {
if apiKeyFromEnv != "" {
c.APIKey.Value = apiKeyFromEnv
} else {
return fmt.Errorf("%s API key not configured. Please set the %s environment variable or run 'fabric --setup %s'", providerName, envKey, providerName)
return fmt.Errorf(i18n.T("perplexity_api_key_not_configured"), providerName, envKey, providerName)
}
}
c.client = perplexity.NewClient(c.APIKey.Value)
@ -62,7 +62,7 @@ func (c *Client) ListModels() ([]string, error) {
func (c *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, opts *domain.ChatOptions) (string, error) {
if c.client == nil {
if err := c.Configure(); err != nil {
return "", fmt.Errorf("failed to configure Perplexity client: %w", err)
return "", fmt.Errorf(i18n.T("perplexity_failed_configure"), err)
}
}
@ -101,7 +101,7 @@ func (c *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, o
// Corrected: Use SendCompletionRequest method from perplexity-go library
resp, err := c.client.SendCompletionRequest(request) // Pass request directly
if err != nil {
return "", fmt.Errorf("perplexity API request failed: %w", err) // Corrected capitalization
return "", fmt.Errorf(i18n.T("perplexity_api_request_failed"), err)
}
var content strings.Builder
@ -110,7 +110,7 @@ func (c *Client) Send(ctx context.Context, msgs []*chat.ChatCompletionMessage, o
// Append citations if available
citations := resp.GetCitations()
if len(citations) > 0 {
content.WriteString("\n\n# CITATIONS\n\n")
content.WriteString(i18n.T("perplexity_citations_header"))
for i, citation := range citations {
content.WriteString(fmt.Sprintf("- [%d] %s\n", i+1, citation))
}
@ -123,7 +123,7 @@ func (c *Client) SendStream(msgs []*chat.ChatCompletionMessage, opts *domain.Cha
if c.client == nil {
if err := c.Configure(); err != nil {
close(channel) // Ensure channel is closed on error
return fmt.Errorf("failed to configure Perplexity client: %w", err)
return fmt.Errorf(i18n.T("perplexity_failed_configure"), err)
}
}
@ -170,7 +170,7 @@ func (c *Client) SendStream(msgs []*chat.ChatCompletionMessage, opts *domain.Cha
if err != nil {
// Log error, can't send to string channel directly.
// Consider a mechanism to propagate this error if needed.
debuglog.Log("perplexity streaming error: %v\n", err)
debuglog.Log(i18n.T("perplexity_streaming_error"), err)
// If the error occurs during stream setup, the channel might not have been closed by the receiver loop.
// However, closing it here might cause a panic if the receiver loop also tries to close it.
// close(channel) // Caution: Uncommenting this may cause panic, as channel is closed in the receiver goroutine.
@ -216,7 +216,7 @@ func (c *Client) SendStream(msgs []*chat.ChatCompletionMessage, opts *domain.Cha
citations := lastResponse.GetCitations()
if len(citations) > 0 {
var citationsText strings.Builder
citationsText.WriteString("\n\n# CITATIONS\n\n")
citationsText.WriteString(i18n.T("perplexity_citations_header"))
for i, citation := range citations {
citationsText.WriteString(fmt.Sprintf("- [%d] %s\n", i+1, citation))
}

View file

@ -8,6 +8,7 @@ import (
"strings"
"sync"
"github.com/danielmiessler/fabric/internal/i18n"
"github.com/danielmiessler/fabric/internal/plugins"
)
@ -74,8 +75,7 @@ func (o *VendorsManager) FindByName(name string) Vendor {
func (o *VendorsManager) readModels() (err error) {
if len(o.Vendors) == 0 {
err = fmt.Errorf("no AI vendors configured to read models from. Please configure at least one AI vendor")
err = fmt.Errorf("%s", i18n.T("vendors_no_ai_vendors_configured_read_models"))
return
}
@ -138,7 +138,7 @@ func (o *VendorsManager) Setup() (ret map[string]Vendor, err error) {
func (o *VendorsManager) SetupVendor(vendorName string, configuredVendors map[string]Vendor) (err error) {
vendor := o.FindByName(vendorName)
if vendor == nil {
err = fmt.Errorf("vendor %s not found", vendorName)
err = fmt.Errorf("%s", fmt.Sprintf(i18n.T("vendor_not_found"), vendorName))
return
}
o.setupVendorTo(vendor, configuredVendors)
@ -147,11 +147,11 @@ func (o *VendorsManager) SetupVendor(vendorName string, configuredVendors map[st
func (o *VendorsManager) setupVendorTo(vendor Vendor, configuredVendors map[string]Vendor) {
if vendorErr := vendor.Setup(); vendorErr == nil {
fmt.Printf("[%v] configured\n", vendor.GetName())
fmt.Printf("%s\n", fmt.Sprintf(i18n.T("plugin_setup_configured"), vendor.GetName()))
configuredVendors[strings.ToLower(vendor.GetName())] = vendor
} else {
delete(configuredVendors, strings.ToLower(vendor.GetName()))
fmt.Printf("[%v] skipped\n", vendor.GetName())
fmt.Printf("%s", fmt.Sprintf(i18n.T("plugin_setup_skipped"), vendor.GetName()))
}
}

View file

@ -5,6 +5,8 @@ import (
"fmt"
"strconv"
"time"
"github.com/danielmiessler/fabric/internal/i18n"
)
// DateTimePlugin handles time and date operations
@ -94,7 +96,7 @@ func (p *DateTimePlugin) Apply(operation string, value string) (string, error) {
return p.handleRelative(now, value)
default:
return "", fmt.Errorf("datetime: unknown operation %q (see plugin documentation for supported operations)", operation)
return "", fmt.Errorf(i18n.T("template_datetime_error_unknown_operation"), operation)
}
}
@ -102,7 +104,7 @@ func (p *DateTimePlugin) handleRelative(now time.Time, value string) (string, er
debugf("DateTime: handling relative time value=%q", value)
if value == "" {
return "", fmt.Errorf("datetime: relative time requires a value (e.g., -1h, -1d, -1w)")
return "", fmt.Errorf("%s", i18n.T("template_datetime_error_relative_requires_value"))
}
// Try standard duration first (hours, minutes)
@ -114,7 +116,7 @@ func (p *DateTimePlugin) handleRelative(now time.Time, value string) (string, er
// Handle date units
if len(value) < 2 {
return "", fmt.Errorf("datetime: invalid relative format (use: -1h, 2d, -3w, 1m, -1y)")
return "", fmt.Errorf("%s", i18n.T("template_datetime_error_invalid_relative_format"))
}
unit := value[len(value)-1:]
@ -122,7 +124,7 @@ func (p *DateTimePlugin) handleRelative(now time.Time, value string) (string, er
num, err := strconv.Atoi(numStr)
if err != nil {
return "", fmt.Errorf("datetime: invalid number in relative time: %q", value)
return "", fmt.Errorf(i18n.T("template_datetime_error_invalid_number"), value)
}
var result string
@ -136,7 +138,7 @@ func (p *DateTimePlugin) handleRelative(now time.Time, value string) (string, er
case "y":
result = now.AddDate(num, 0, 0).Format("2006-01-02")
default:
return "", fmt.Errorf("datetime: invalid unit %q (use: h,m for time or d,w,m,y for date)", unit)
return "", fmt.Errorf(i18n.T("template_datetime_error_invalid_unit"), unit)
}
debugf("DateTime: relative unit=%q num=%d result=%q", unit, num, result)

View file

@ -6,6 +6,8 @@ import (
"os"
"os/user"
"runtime"
"github.com/danielmiessler/fabric/internal/i18n"
)
// SysPlugin provides access to system-level information.
@ -29,7 +31,7 @@ func (p *SysPlugin) Apply(operation string, value string) (string, error) {
hostname, err := os.Hostname()
if err != nil {
debugf("Sys: hostname error: %v", err)
return "", fmt.Errorf("sys: hostname error: %v", err)
return "", fmt.Errorf(i18n.T("template_sys_error_hostname"), err)
}
debugf("Sys: hostname=%q", hostname)
return hostname, nil
@ -38,7 +40,7 @@ func (p *SysPlugin) Apply(operation string, value string) (string, error) {
currentUser, err := user.Current()
if err != nil {
debugf("Sys: user error: %v", err)
return "", fmt.Errorf("sys: user error: %v", err)
return "", fmt.Errorf(i18n.T("template_sys_error_user"), err)
}
debugf("Sys: user=%q", currentUser.Username)
return currentUser.Username, nil
@ -56,7 +58,7 @@ func (p *SysPlugin) Apply(operation string, value string) (string, error) {
case "env":
if value == "" {
debugf("Sys: env error: missing variable name")
return "", fmt.Errorf("sys: env operation requires a variable name")
return "", fmt.Errorf("%s", i18n.T("template_sys_error_env_requires_var"))
}
result := os.Getenv(value)
debugf("Sys: env %q=%q", value, result)
@ -66,7 +68,7 @@ func (p *SysPlugin) Apply(operation string, value string) (string, error) {
dir, err := os.Getwd()
if err != nil {
debugf("Sys: pwd error: %v", err)
return "", fmt.Errorf("sys: pwd error: %v", err)
return "", fmt.Errorf(i18n.T("template_sys_error_pwd"), err)
}
debugf("Sys: pwd=%q", dir)
return dir, nil
@ -75,13 +77,13 @@ func (p *SysPlugin) Apply(operation string, value string) (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
debugf("Sys: home error: %v", err)
return "", fmt.Errorf("sys: home error: %v", err)
return "", fmt.Errorf(i18n.T("template_sys_error_home"), err)
}
debugf("Sys: home=%q", homeDir)
return homeDir, nil
default:
debugf("Sys: unknown operation %q", operation)
return "", fmt.Errorf("sys: unknown operation %q (supported: hostname, user, os, arch, env, pwd, home)", operation)
return "", fmt.Errorf(i18n.T("template_sys_error_unknown_operation"), operation)
}
}

View file

@ -45,7 +45,7 @@ func (jc *Client) ScrapeQuestion(question string) (ret string, err error) {
func (jc *Client) request(requestURL string) (ret string, err error) {
var req *http.Request
if req, err = http.NewRequest("GET", requestURL, nil); err != nil {
err = fmt.Errorf("error creating request: %w", err)
err = fmt.Errorf("%s", fmt.Sprintf(i18n.T("jina_error_creating_request"), err))
return
}
@ -57,14 +57,14 @@ func (jc *Client) request(requestURL string) (ret string, err error) {
client := &http.Client{}
var resp *http.Response
if resp, err = client.Do(req); err != nil {
err = fmt.Errorf("error sending request: %w", err)
err = fmt.Errorf("%s", fmt.Sprintf(i18n.T("jina_error_sending_request"), err))
return
}
defer resp.Body.Close()
var body []byte
if body, err = io.ReadAll(resp.Body); err != nil {
err = fmt.Errorf("error reading response body: %w", err)
err = fmt.Errorf("%s", fmt.Sprintf(i18n.T("jina_error_reading_response_body"), err))
return
}
ret = string(body)

View file

@ -50,8 +50,8 @@ func NewSpotify() *Spotify {
EnvNamePrefix: plugins.BuildEnvVariablePrefix(label),
}
ret.ClientId = ret.AddSetupQuestion("Client ID", false)
ret.ClientSecret = ret.AddSetupQuestion("Client Secret", false)
ret.ClientId = ret.AddSetupQuestionWithEnvName("Client ID", false, i18n.T("spotify_client_id_question"))
ret.ClientSecret = ret.AddSetupQuestionWithEnvName("Client Secret", false, i18n.T("spotify_client_secret_question"))
return ret
}
@ -475,48 +475,48 @@ func (s *Spotify) FormatMetadataAsText(metadata any) string {
switch m := metadata.(type) {
case *ShowMetadata:
sb.WriteString("# Spotify Podcast/Show\n\n")
sb.WriteString(fmt.Sprintf("**Title**: %s\n", m.Name))
sb.WriteString(fmt.Sprintf("**Publisher**: %s\n", m.Publisher))
sb.WriteString(fmt.Sprintf("**Total Episodes**: %d\n", m.TotalEpisodes))
sb.WriteString(i18n.T("spotify_show_header") + "\n\n")
sb.WriteString(fmt.Sprintf(i18n.T("spotify_title_label")+"\n", m.Name))
sb.WriteString(fmt.Sprintf(i18n.T("spotify_publisher_label")+"\n", m.Publisher))
sb.WriteString(fmt.Sprintf(i18n.T("spotify_total_episodes_label")+"\n", m.TotalEpisodes))
if len(m.Languages) > 0 {
sb.WriteString(fmt.Sprintf("**Languages**: %s\n", strings.Join(m.Languages, ", ")))
sb.WriteString(fmt.Sprintf(i18n.T("spotify_languages_label")+"\n", strings.Join(m.Languages, ", ")))
}
sb.WriteString(fmt.Sprintf("**Media Type**: %s\n", m.MediaType))
sb.WriteString(fmt.Sprintf("**URL**: %s\n\n", m.ExternalURL))
sb.WriteString("## Description\n\n")
sb.WriteString(fmt.Sprintf(i18n.T("spotify_media_type_label")+"\n", m.MediaType))
sb.WriteString(fmt.Sprintf(i18n.T("spotify_url_label")+"\n\n", m.ExternalURL))
sb.WriteString(i18n.T("spotify_description_header") + "\n\n")
sb.WriteString(m.Description)
sb.WriteString("\n")
case *EpisodeMetadata:
sb.WriteString("# Spotify Episode\n\n")
sb.WriteString(fmt.Sprintf("**Title**: %s\n", m.Name))
sb.WriteString(fmt.Sprintf("**Show**: %s\n", m.ShowName))
sb.WriteString(fmt.Sprintf("**Release Date**: %s\n", m.ReleaseDate))
sb.WriteString(fmt.Sprintf("**Duration**: %d minutes\n", m.DurationMinutes))
sb.WriteString(fmt.Sprintf("**Language**: %s\n", m.Language))
sb.WriteString(fmt.Sprintf("**Explicit**: %v\n", m.Explicit))
sb.WriteString(fmt.Sprintf("**URL**: %s\n", m.ExternalURL))
sb.WriteString(i18n.T("spotify_episode_header") + "\n\n")
sb.WriteString(fmt.Sprintf(i18n.T("spotify_title_label")+"\n", m.Name))
sb.WriteString(fmt.Sprintf(i18n.T("spotify_show_name_label")+"\n", m.ShowName))
sb.WriteString(fmt.Sprintf(i18n.T("spotify_release_date_label")+"\n", m.ReleaseDate))
sb.WriteString(fmt.Sprintf(i18n.T("spotify_duration_label")+"\n", m.DurationMinutes))
sb.WriteString(fmt.Sprintf(i18n.T("spotify_language_field_label")+"\n", m.Language))
sb.WriteString(fmt.Sprintf(i18n.T("spotify_explicit_label")+"\n", m.Explicit))
sb.WriteString(fmt.Sprintf(i18n.T("spotify_url_label")+"\n", m.ExternalURL))
if m.AudioPreviewURL != "" {
sb.WriteString(fmt.Sprintf("**Audio Preview**: %s\n", m.AudioPreviewURL))
sb.WriteString(fmt.Sprintf(i18n.T("spotify_audio_preview_label")+"\n", m.AudioPreviewURL))
}
sb.WriteString("\n## Description\n\n")
sb.WriteString("\n" + i18n.T("spotify_description_header") + "\n\n")
sb.WriteString(m.Description)
sb.WriteString("\n")
case *SearchResult:
sb.WriteString("# Spotify Search Results\n\n")
sb.WriteString(i18n.T("spotify_search_results_header") + "\n\n")
for i, show := range m.Shows {
sb.WriteString(fmt.Sprintf("## %d. %s\n", i+1, show.Name))
sb.WriteString(fmt.Sprintf("- **Publisher**: %s\n", show.Publisher))
sb.WriteString(fmt.Sprintf("- **Episodes**: %d\n", show.TotalEpisodes))
sb.WriteString(fmt.Sprintf("- **URL**: %s\n", show.ExternalURL))
sb.WriteString(fmt.Sprintf(i18n.T("spotify_search_publisher_label")+"\n", show.Publisher))
sb.WriteString(fmt.Sprintf(i18n.T("spotify_search_episodes_label")+"\n", show.TotalEpisodes))
sb.WriteString(fmt.Sprintf(i18n.T("spotify_search_url_label")+"\n", show.ExternalURL))
// Truncate description for search results
desc := show.Description
if len(desc) > 200 {
desc = desc[:200] + "..."
}
sb.WriteString(fmt.Sprintf("- **Description**: %s\n\n", desc))
sb.WriteString(fmt.Sprintf(i18n.T("spotify_search_description_label")+"\n\n", desc))
}
}

View file

@ -506,7 +506,7 @@ func (o *YouTube) GrabComments(videoId string) (ret []string, err error) {
call := o.service.CommentThreads.List([]string{"snippet", "replies"}).VideoId(videoId).TextFormat("plainText").MaxResults(100)
var response *youtube.CommentThreadListResponse
if response, err = call.Do(); err != nil {
log.Printf("Failed to fetch comments: %v", err)
log.Printf(i18n.T("youtube_failed_fetch_comments"), err)
return
}
@ -677,7 +677,7 @@ func (o *YouTube) FetchAndSavePlaylist(playlistID, filename string) (err error)
return
}
fmt.Println("Playlist saved to", filename)
fmt.Printf("%s\n", fmt.Sprintf(i18n.T("youtube_playlist_saved_to"), filename))
return
}
@ -688,8 +688,8 @@ func (o *YouTube) FetchAndPrintPlaylist(playlistID string) (err error) {
return
}
fmt.Printf("Playlist: %s\n", playlistID)
fmt.Printf("VideoId: Title\n")
fmt.Printf("%s\n", fmt.Sprintf(i18n.T("youtube_playlist_header"), playlistID))
fmt.Printf("%s\n", i18n.T("youtube_video_id_title_header"))
for _, video := range videos {
fmt.Printf("%s: %s\n", video.Id, video.Title)
}
@ -831,7 +831,7 @@ func (o *YouTube) GrabByFlags() (ret *VideoInfo, err error) {
flag.Parse()
if flag.NArg() == 0 {
log.Fatal("Error: No URL provided.")
log.Fatalf("%s", i18n.T("youtube_no_url_provided"))
}
url := flag.Arg(0)

View file

@ -5,6 +5,7 @@ import (
"sort"
"strings"
"github.com/danielmiessler/fabric/internal/i18n"
"github.com/samber/lo"
)
@ -98,7 +99,7 @@ func (o *GroupsItemsSelector[I]) GetGroupAndItemByItemNumber(number int) (group
}
if !found {
err = fmt.Errorf("number %d is out of range", number)
err = fmt.Errorf("%s", fmt.Sprintf(i18n.T("groups_items_number_out_of_range"), number))
}
return
}