diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index 3f0f11b..4506592 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -117,6 +117,7 @@ jobs: echo "apns_bundle_id_arn=$(get_arn ${SECRET_PREFIX}/apns-bundle-id)" >> $GITHUB_OUTPUT echo "apns_key_arn=$(get_arn ${SECRET_PREFIX}/apns-key)" >> $GITHUB_OUTPUT echo "scrapingbee_api_key_arn=$(get_arn ${SECRET_PREFIX}/scrapingbee-api-key)" >> $GITHUB_OUTPUT + echo "google_sa_arn=$(get_arn ${SECRET_PREFIX}/google-service-account)" >> $GITHUB_OUTPUT - name: Generate ECS task definition env: @@ -140,18 +141,21 @@ jobs: { "containerPort": 8080, "protocol": "tcp" } ], "environment": [ - { "name": "PORT", "value": "8080" }, - { "name": "GIN_MODE", "value": "${{ env.GIN_MODE }}" }, - { "name": "APP_ENV", "value": "${{ env.DEPLOY_ENV }}" }, - { "name": "APNS_ENV", "value": "${{ env.IS_PROD == 'true' && 'production' || 'sandbox' }}" } + { "name": "PORT", "value": "8080" }, + { "name": "GIN_MODE", "value": "${{ env.GIN_MODE }}" }, + { "name": "APP_ENV", "value": "${{ env.DEPLOY_ENV }}" }, + { "name": "APNS_ENV", "value": "${{ env.IS_PROD == 'true' && 'production' || 'sandbox' }}" }, + { "name": "VERTEX_AI_PROJECT_ID", "value": "rescience-lab-465304" }, + { "name": "VERTEX_AI_LOCATION", "value": "us-central1" } ], "secrets": [ - { "name": "DATABASE_URL", "valueFrom": "${{ steps.secrets.outputs.db_arn }}" }, - { "name": "APNS_KEY_ID", "valueFrom": "${{ steps.secrets.outputs.apns_key_id_arn }}" }, - { "name": "APNS_TEAM_ID", "valueFrom": "${{ steps.secrets.outputs.apns_team_id_arn }}" }, - { "name": "APNS_BUNDLE_ID", "valueFrom": "${{ steps.secrets.outputs.apns_bundle_id_arn }}" }, - { "name": "APNS_KEY", "valueFrom": "${{ steps.secrets.outputs.apns_key_arn }}" }, - { "name": "SCRAPINGBEE_API_KEY", "valueFrom": "${{ steps.secrets.outputs.scrapingbee_api_key_arn }}" } + { "name": "DATABASE_URL", "valueFrom": "${{ steps.secrets.outputs.db_arn }}" }, + { "name": "APNS_KEY_ID", "valueFrom": "${{ steps.secrets.outputs.apns_key_id_arn }}" }, + { "name": "APNS_TEAM_ID", "valueFrom": "${{ steps.secrets.outputs.apns_team_id_arn }}" }, + { "name": "APNS_BUNDLE_ID", "valueFrom": "${{ steps.secrets.outputs.apns_bundle_id_arn }}" }, + { "name": "APNS_KEY", "valueFrom": "${{ steps.secrets.outputs.apns_key_arn }}" }, + { "name": "SCRAPINGBEE_API_KEY", "valueFrom": "${{ steps.secrets.outputs.scrapingbee_api_key_arn }}" }, + { "name": "GOOGLE_SERVICE_ACCOUNT_JSON", "valueFrom": "${{ steps.secrets.outputs.google_sa_arn }}" } ], "readonlyRootFilesystem": true, "linuxParameters": { "initProcessEnabled": true }, diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index adb8802..d777e45 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "log" "time" @@ -48,7 +49,22 @@ func main() { log.Printf("APNs init failed (push disabled): %v", err) } } - cronSvc = service.NewCronService(db.DB, scrapingService, apnsClient) + + // Initialize Vertex AI translator + var translator *service.TranslatorService + if cfg.VertexAIProjectID != "" && cfg.VertexAILocation != "" { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + var err error + translator, err = service.NewTranslatorService(ctx, cfg.VertexAIProjectID, cfg.VertexAILocation) + if err != nil { + log.Printf("Vertex AI translator init failed (translation disabled): %v", err) + } else { + log.Printf("Vertex AI translator initialized (project=%s, location=%s)", cfg.VertexAIProjectID, cfg.VertexAILocation) + } + } + + cronSvc = service.NewCronService(db.DB, scrapingService, apnsClient, translator) cronSvc.Start() defer cronSvc.Stop() diff --git a/backend/go.mod b/backend/go.mod index 26ef8dd..c4f5c5c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,21 +3,35 @@ module github.com/kickwatch/backend go 1.25.5 require ( + cloud.google.com/go v0.121.2 // indirect + cloud.google.com/go/aiplatform v1.90.0 // indirect + cloud.google.com/go/auth v0.16.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.7.0 // indirect + cloud.google.com/go/iam v1.5.2 // indirect + cloud.google.com/go/longrunning v0.6.7 // indirect + cloud.google.com/go/vertexai v0.15.0 // indirect github.com/PuerkitoBio/goquery v1.11.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-gonic/gin v1.11.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/gax-go/v2 v2.14.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect @@ -37,15 +51,28 @@ require ( github.com/robfig/cron/v3 v3.0.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.36.0 // indirect + go.opentelemetry.io/otel/metric v1.36.0 // indirect + go.opentelemetry.io/otel/trace v1.36.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/crypto v0.44.0 // indirect golang.org/x/mod v0.29.0 // indirect golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.38.0 // indirect + google.golang.org/api v0.237.0 // indirect + google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/grpc v1.73.0 // indirect google.golang.org/protobuf v1.36.9 // indirect gorm.io/driver/postgres v1.6.0 // indirect gorm.io/gorm v1.31.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index e41502c..19205ae 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,19 @@ +cloud.google.com/go v0.121.2 h1:v2qQpN6Dx9x2NmwrqlesOt3Ys4ol5/lFZ6Mg1B7OJCg= +cloud.google.com/go v0.121.2/go.mod h1:nRFlrHq39MNVWu+zESP2PosMWA0ryJw8KUBZ2iZpxbw= +cloud.google.com/go/aiplatform v1.90.0 h1:QdNBP8/2HtWYMXZczGd5LsL72lTiMyzliXgBSk7R9HE= +cloud.google.com/go/aiplatform v1.90.0/go.mod h1:ouoFeopVQaYTFwvviZJi17excXiwMGi+HvznNH2B1tw= +cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4= +cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= +cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= +cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= +cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= +cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= +cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= +cloud.google.com/go/vertexai v0.15.0 h1:FRVdUsm07qX9P/19SMDd/RZVwLR9sCm3HN0Ze7wSEpc= +cloud.google.com/go/vertexai v0.15.0/go.mod h1:YTy1fUT3yH57nClxotpyY29T0MhnNUHIyysef8u69ow= github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= @@ -10,12 +26,19 @@ github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= @@ -30,8 +53,14 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= +github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -80,6 +109,18 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2 github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= +go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= +go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= +go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= +go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= +go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= @@ -116,6 +157,8 @@ golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -165,6 +208,8 @@ golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -176,6 +221,16 @@ golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.237.0 h1:MP7XVsGZesOsx3Q8WVa4sUdbrsTvDSOERd3Vh4xj/wc= +google.golang.org/api v0.237.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= +google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78= +google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk= +google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ= +google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 21879fd..bee57af 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -17,6 +17,9 @@ type Config struct { // ScrapingBee configuration ScrapingBeeAPIKey string ScrapingBeeMaxConcurrent int + // Vertex AI configuration + VertexAIProjectID string + VertexAILocation string } func Load() *Config { @@ -47,5 +50,7 @@ func Load() *Config { APNSEnv: apnsEnv, ScrapingBeeAPIKey: os.Getenv("SCRAPINGBEE_API_KEY"), ScrapingBeeMaxConcurrent: maxConcurrent, + VertexAIProjectID: os.Getenv("VERTEX_AI_PROJECT_ID"), + VertexAILocation: os.Getenv("VERTEX_AI_LOCATION"), } } diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index 12b0a02..a35b4d1 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -10,7 +10,9 @@ import ( type Campaign struct { PID string `gorm:"column:pid;primaryKey" json:"pid"` Name string `gorm:"not null" json:"name"` + NameZh string `json:"name_zh"` Blurb string `json:"blurb"` + BlurbZh string `json:"blurb_zh"` PhotoURL string `json:"photo_url"` GoalAmount float64 `json:"goal_amount"` GoalCurrency string `json:"goal_currency"` @@ -21,6 +23,7 @@ type Campaign struct { CategoryName string `json:"category_name"` ProjectURL string `json:"project_url"` CreatorName string `json:"creator_name"` + CreatorNameZh string `json:"creator_name_zh"` PercentFunded float64 `json:"percent_funded"` BackersCount int `gorm:"default:0" json:"backers_count"` Slug string `json:"slug"` @@ -51,6 +54,7 @@ func (s *CampaignSnapshot) BeforeCreate(tx *gorm.DB) error { type Category struct { ID string `gorm:"primaryKey" json:"id"` Name string `gorm:"not null" json:"name"` + NameZh string `json:"name_zh"` ParentID string `json:"parent_id,omitempty"` } diff --git a/backend/internal/service/categories.go b/backend/internal/service/categories.go index 95017a1..ccc260a 100644 --- a/backend/internal/service/categories.go +++ b/backend/internal/service/categories.go @@ -6,44 +6,44 @@ import "github.com/kickwatch/backend/internal/model" // Root IDs and subcategory IDs confirmed from Kickstarter REST API public datasets. var kickstarterCategories = []model.Category{ // Root categories - {ID: "1", Name: "Art"}, - {ID: "3", Name: "Comics"}, - {ID: "4", Name: "Crafts"}, - {ID: "5", Name: "Dance"}, - {ID: "7", Name: "Design"}, - {ID: "9", Name: "Fashion"}, - {ID: "10", Name: "Food"}, - {ID: "11", Name: "Film & Video"}, - {ID: "12", Name: "Games"}, - {ID: "13", Name: "Music"}, - {ID: "14", Name: "Photography"}, - {ID: "16", Name: "Technology"}, - {ID: "17", Name: "Theater"}, - {ID: "18", Name: "Publishing"}, + {ID: "1", Name: "Art", NameZh: "艺术"}, + {ID: "3", Name: "Comics", NameZh: "漫画"}, + {ID: "4", Name: "Crafts", NameZh: "手工艺"}, + {ID: "5", Name: "Dance", NameZh: "舞蹈"}, + {ID: "7", Name: "Design", NameZh: "设计"}, + {ID: "9", Name: "Fashion", NameZh: "时尚"}, + {ID: "10", Name: "Food", NameZh: "美食"}, + {ID: "11", Name: "Film & Video", NameZh: "影视"}, + {ID: "12", Name: "Games", NameZh: "游戏"}, + {ID: "13", Name: "Music", NameZh: "音乐"}, + {ID: "14", Name: "Photography", NameZh: "摄影"}, + {ID: "16", Name: "Technology", NameZh: "科技"}, + {ID: "17", Name: "Theater", NameZh: "戏剧"}, + {ID: "18", Name: "Publishing", NameZh: "出版"}, // Design subcategories - {ID: "28", Name: "Product Design", ParentID: "7"}, + {ID: "28", Name: "Product Design", NameZh: "产品设计", ParentID: "7"}, // Fashion subcategories - {ID: "263", Name: "Apparel", ParentID: "9"}, + {ID: "263", Name: "Apparel", NameZh: "服装", ParentID: "9"}, // Film & Video subcategories - {ID: "29", Name: "Animation", ParentID: "11"}, - {ID: "303", Name: "Television", ParentID: "11"}, + {ID: "29", Name: "Animation", NameZh: "动画", ParentID: "11"}, + {ID: "303", Name: "Television", NameZh: "电视", ParentID: "11"}, // Games subcategories - {ID: "34", Name: "Tabletop Games", ParentID: "12"}, - {ID: "35", Name: "Video Games", ParentID: "12"}, - {ID: "270", Name: "Gaming Hardware", ParentID: "12"}, + {ID: "34", Name: "Tabletop Games", NameZh: "桌游", ParentID: "12"}, + {ID: "35", Name: "Video Games", NameZh: "电子游戏", ParentID: "12"}, + {ID: "270", Name: "Gaming Hardware", NameZh: "游戏硬件", ParentID: "12"}, // Technology subcategories - {ID: "52", Name: "Hardware", ParentID: "16"}, - {ID: "331", Name: "3D Printing", ParentID: "16"}, - {ID: "337", Name: "Gadgets", ParentID: "16"}, - {ID: "339", Name: "Sound", ParentID: "16"}, + {ID: "52", Name: "Hardware", NameZh: "硬件", ParentID: "16"}, + {ID: "331", Name: "3D Printing", NameZh: "3D打印", ParentID: "16"}, + {ID: "337", Name: "Gadgets", NameZh: "数码产品", ParentID: "16"}, + {ID: "339", Name: "Sound", NameZh: "音频设备", ParentID: "16"}, // Publishing subcategories - {ID: "47", Name: "Fiction", ParentID: "18"}, + {ID: "47", Name: "Fiction", NameZh: "小说", ParentID: "18"}, } // crawlCategories defines all category IDs to crawl and their page depth. diff --git a/backend/internal/service/cron.go b/backend/internal/service/cron.go index 51a63ac..39a0e72 100644 --- a/backend/internal/service/cron.go +++ b/backend/internal/service/cron.go @@ -29,14 +29,16 @@ type CronService struct { db *gorm.DB scrapingService *KickstarterScrapingService apnsClient *APNsClient + translator *TranslatorService scheduler *cron.Cron } -func NewCronService(db *gorm.DB, scrapingService *KickstarterScrapingService, apns *APNsClient) *CronService { +func NewCronService(db *gorm.DB, scrapingService *KickstarterScrapingService, apns *APNsClient, translator *TranslatorService) *CronService { return &CronService{ db: db, scrapingService: scrapingService, apnsClient: apns, + translator: translator, scheduler: cron.New(cron.WithLocation(time.UTC)), } } @@ -82,7 +84,7 @@ func (s *CronService) Stop() { func (s *CronService) syncCategories() { result := s.db.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, - DoUpdates: clause.AssignmentColumns([]string{"name", "parent_id"}), + DoUpdates: clause.AssignmentColumns([]string{"name", "name_zh", "parent_id"}), }).Create(&kickstarterCategories) if result.Error != nil { log.Printf("Cron: category sync error: %v", result.Error) @@ -137,12 +139,20 @@ func (s *CronService) RunCrawlNow() error { for i := range campaigns { campaigns[i].LastUpdatedAt = now } + + // Translate campaigns to Chinese using Vertex AI + if s.translator != nil { + if err := s.translator.TranslateCampaigns(campaigns); err != nil { + log.Printf("Cron: translation error sort=%s cat=%s page=%d: %v (continuing without translations)", sortCfg.sort, cat.ID, page, err) + } + } + result := s.db.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "pid"}}, DoUpdates: clause.AssignmentColumns([]string{ - "name", "blurb", "photo_url", "goal_amount", "goal_currency", + "name", "name_zh", "blurb", "blurb_zh", "photo_url", "goal_amount", "goal_currency", "pledged_amount", "deadline", "state", "category_id", "category_name", - "project_url", "creator_name", "percent_funded", "backers_count", + "project_url", "creator_name", "creator_name_zh", "percent_funded", "backers_count", "slug", "last_updated_at", }), }).Create(&campaigns) @@ -210,12 +220,20 @@ func (s *CronService) RunBackfill() error { for i := range campaigns { campaigns[i].LastUpdatedAt = now } + + // Translate campaigns to Chinese using Vertex AI + if s.translator != nil { + if err := s.translator.TranslateCampaigns(campaigns); err != nil { + log.Printf("Backfill: translation error sort=%s cat=%s page=%d: %v (continuing without translations)", sortCfg.sort, cat.ID, page, err) + } + } + result := s.db.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "pid"}}, DoUpdates: clause.AssignmentColumns([]string{ - "name", "blurb", "photo_url", "goal_amount", "goal_currency", + "name", "name_zh", "blurb", "blurb_zh", "photo_url", "goal_amount", "goal_currency", "pledged_amount", "deadline", "state", "category_id", "category_name", - "project_url", "creator_name", "percent_funded", "backers_count", + "project_url", "creator_name", "creator_name_zh", "percent_funded", "backers_count", "slug", "last_updated_at", }), }).Create(&campaigns) diff --git a/backend/internal/service/translator.go b/backend/internal/service/translator.go new file mode 100644 index 0000000..c33f153 --- /dev/null +++ b/backend/internal/service/translator.go @@ -0,0 +1,196 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "strings" + "time" + + "cloud.google.com/go/vertexai/genai" + "github.com/kickwatch/backend/internal/model" + "google.golang.org/api/option" +) + +// TranslatorService handles translation of campaign content using Vertex AI. +type TranslatorService struct { + client *genai.Client + projectID string + location string +} + +// NewTranslatorService creates a new translator service using Vertex AI. +// Credentials are loaded from GOOGLE_SERVICE_ACCOUNT_JSON env var. +// Uses GCP startup credits from Vertex AI. +func NewTranslatorService(ctx context.Context, projectID, location string) (*TranslatorService, error) { + var opts []option.ClientOption + + // Load credentials from JSON string (from AWS Secrets Manager) + if credsJSON := os.Getenv("GOOGLE_SERVICE_ACCOUNT_JSON"); credsJSON != "" { + opts = append(opts, option.WithCredentialsJSON([]byte(credsJSON))) + log.Println("Translator: using Vertex AI credentials from GOOGLE_SERVICE_ACCOUNT_JSON") + } else if credsFile := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS"); credsFile != "" { + opts = append(opts, option.WithCredentialsFile(credsFile)) + log.Printf("Translator: using Vertex AI credentials from file %s", credsFile) + } else { + return nil, fmt.Errorf("no Google credentials found (set GOOGLE_SERVICE_ACCOUNT_JSON or GOOGLE_APPLICATION_CREDENTIALS)") + } + + client, err := genai.NewClient(ctx, projectID, location, opts...) + if err != nil { + return nil, fmt.Errorf("create vertex ai client: %w", err) + } + + log.Printf("Translator: initialized Vertex AI (project=%s, location=%s)", projectID, location) + return &TranslatorService{ + client: client, + projectID: projectID, + location: location, + }, nil +} + +// TranslateCampaigns translates campaign names, blurbs, and creator names to Chinese. +// Uses batch translation to minimize API calls and costs. +// Uses Vertex AI which consumes GCP startup credits. +func (t *TranslatorService) TranslateCampaigns(campaigns []model.Campaign) error { + if len(campaigns) == 0 { + return nil + } + + // Use Gemini 2.0 Flash via Vertex AI (uses startup credits) + model := t.client.GenerativeModel("gemini-2.0-flash-exp") + model.SetTemperature(0.3) // Lower temperature for more consistent translations + + // Batch translate in groups of 10 to avoid token limits + const batchSize = 10 + for i := 0; i < len(campaigns); i += batchSize { + end := i + batchSize + if end > len(campaigns) { + end = len(campaigns) + } + batch := campaigns[i:end] + + if err := t.translateBatch(model, batch); err != nil { + log.Printf("Translator: batch %d-%d error: %v", i, end-1, err) + // Continue with next batch instead of failing entirely + continue + } + + // Rate limiting: small delay between batches + if end < len(campaigns) { + time.Sleep(500 * time.Millisecond) + } + } + + return nil +} + +// translateBatch translates a batch of campaigns using a single Vertex AI API call. +func (t *TranslatorService) translateBatch(model *genai.GenerativeModel, campaigns []model.Campaign) error { + type translationInput struct { + Index int `json:"index"` + Name string `json:"name"` + Blurb string `json:"blurb,omitempty"` + CreatorName string `json:"creator_name,omitempty"` + } + + type translationOutput struct { + Index int `json:"index"` + NameZh string `json:"name_zh"` + BlurbZh string `json:"blurb_zh,omitempty"` + CreatorZh string `json:"creator_zh,omitempty"` + } + + // Build input JSON + inputs := make([]translationInput, len(campaigns)) + for i, c := range campaigns { + inputs[i] = translationInput{ + Index: i, + Name: c.Name, + Blurb: c.Blurb, + CreatorName: c.CreatorName, + } + } + + inputJSON, _ := json.MarshalIndent(inputs, "", " ") + + prompt := fmt.Sprintf(`你是一个专业的英译中翻译助手,专门翻译 Kickstarter 众筹项目信息。 + +请将以下 JSON 数组中的每个项目翻译成中文: +- name: 项目名称(保持简洁有力,符合中文众筹项目命名习惯) +- blurb: 项目简介(翻译要自然流畅,保留原意) +- creator_name: 创作者名称(人名可音译,公司名保留原文或使用常见中文译名) + +**重要规则:** +1. 专业术语保持一致性(如 "3D printing" → "3D打印") +2. 品牌名称保留英文或使用官方中文名 +3. 如果 blurb 或 creator_name 为空,输出也为空 +4. 输出必须是有效的 JSON 数组格式 +5. 保留原文的语气和风格 + +输入 JSON: +%s + +输出格式示例: +[ + { + "index": 0, + "name_zh": "迷你电动往复式细节打磨机", + "blurb_zh": "专业级便携打磨工具,适用于木工、金属加工和精细雕刻", + "creator_zh": "HOZO Design 公司" + } +] + +请直接输出 JSON 数组,不要有其他文字:`, string(inputJSON)) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + resp, err := model.GenerateContent(ctx, genai.Text(prompt)) + if err != nil { + return fmt.Errorf("vertex ai generate content: %w", err) + } + + if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { + return fmt.Errorf("empty response from vertex ai") + } + + // Extract JSON from response + responseText := fmt.Sprintf("%v", resp.Candidates[0].Content.Parts[0]) + + // Clean markdown code blocks if present + responseText = strings.TrimSpace(responseText) + responseText = strings.TrimPrefix(responseText, "```json") + responseText = strings.TrimPrefix(responseText, "```") + responseText = strings.TrimSuffix(responseText, "```") + responseText = strings.TrimSpace(responseText) + + // Parse response + var outputs []translationOutput + if err := json.Unmarshal([]byte(responseText), &outputs); err != nil { + log.Printf("Translator: failed to parse response, raw text:\n%s", responseText) + return fmt.Errorf("parse translation response: %w", err) + } + + // Apply translations back to campaigns + for _, out := range outputs { + if out.Index >= 0 && out.Index < len(campaigns) { + campaigns[out.Index].NameZh = out.NameZh + campaigns[out.Index].BlurbZh = out.BlurbZh + campaigns[out.Index].CreatorNameZh = out.CreatorZh + } + } + + log.Printf("Translator: translated %d campaigns via Vertex AI", len(outputs)) + return nil +} + +// Close releases resources held by the translator. +func (t *TranslatorService) Close() error { + if t.client != nil { + return t.client.Close() + } + return nil +} diff --git a/ios/KickWatch/Sources/Services/APIClient.swift b/ios/KickWatch/Sources/Services/APIClient.swift index 8fa3f61..a195713 100644 --- a/ios/KickWatch/Sources/Services/APIClient.swift +++ b/ios/KickWatch/Sources/Services/APIClient.swift @@ -16,7 +16,9 @@ protocol APIClientProtocol: Sendable { struct CampaignDTO: Codable { let pid: String let name: String + let name_zh: String? let blurb: String? + let blurb_zh: String? let photo_url: String? let goal_amount: Double? let goal_currency: String? @@ -27,18 +29,46 @@ struct CampaignDTO: Codable { let category_id: String? let project_url: String? let creator_name: String? + let creator_name_zh: String? let percent_funded: Double? let backers_count: Int? let slug: String? let velocity_24h: Double? let pledge_delta_24h: Double? let first_seen_at: String? + + // Computed properties for Chinese-first display + var displayName: String { + if let zh = name_zh, !zh.isEmpty { + return zh + } + return name + } + + var displayBlurb: String? { + if let zh = blurb_zh, !zh.isEmpty { + return zh + } + return blurb + } + + var displayCreatorName: String? { + if let zh = creator_name_zh, !zh.isEmpty { + return zh + } + return creator_name + } } struct CategoryDTO: Codable { let id: String let name: String + let name_zh: String? let parent_id: String? + + var displayName: String { + name_zh ?? name + } } struct CampaignListResponse: Codable { diff --git a/ios/KickWatch/Sources/Views/CampaignDetailView.swift b/ios/KickWatch/Sources/Views/CampaignDetailView.swift index d2be4d8..71e189a 100644 --- a/ios/KickWatch/Sources/Views/CampaignDetailView.swift +++ b/ios/KickWatch/Sources/Views/CampaignDetailView.swift @@ -45,9 +45,9 @@ struct CampaignDetailView: View { private var content: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 4) { - Text(campaign.name) + Text(campaign.displayName) .font(.title2).fontWeight(.bold) - if let creator = campaign.creator_name { + if let creator = campaign.displayCreatorName { Text("by \(creator)").font(.subheadline).foregroundStyle(.secondary) } if let cat = campaign.category_name { @@ -60,7 +60,7 @@ struct CampaignDetailView: View { fundingStats .padding(.horizontal) - if let blurb = campaign.blurb, !blurb.isEmpty { + if let blurb = campaign.displayBlurb, !blurb.isEmpty { ExpandableBlurbView(blurb: blurb) .padding(.horizontal) } @@ -202,8 +202,8 @@ struct CampaignDetailView: View { } else { let c = Campaign( pid: campaign.pid, - name: campaign.name, - blurb: campaign.blurb ?? "", + name: campaign.displayName, + blurb: campaign.displayBlurb ?? "", photoURL: campaign.photo_url ?? "", goalAmount: campaign.goal_amount ?? 0, goalCurrency: campaign.goal_currency ?? "USD", @@ -213,7 +213,7 @@ struct CampaignDetailView: View { categoryName: campaign.category_name ?? "", categoryID: campaign.category_id ?? "", projectURL: campaign.project_url ?? "", - creatorName: campaign.creator_name ?? "", + creatorName: campaign.displayCreatorName ?? "", percentFunded: campaign.percent_funded ?? 0, isWatched: true ) diff --git a/ios/KickWatch/Sources/Views/CampaignRowView.swift b/ios/KickWatch/Sources/Views/CampaignRowView.swift index ef9343b..19817b7 100644 --- a/ios/KickWatch/Sources/Views/CampaignRowView.swift +++ b/ios/KickWatch/Sources/Views/CampaignRowView.swift @@ -29,10 +29,10 @@ struct CampaignRowView: View { private var info: some View { VStack(alignment: .leading, spacing: 4) { - Text(campaign.name) + Text(campaign.displayName) .font(.subheadline).fontWeight(.semibold) .lineLimit(2) - if let creator = campaign.creator_name { + if let creator = campaign.displayCreatorName { Text("by \(creator)") .font(.caption).foregroundStyle(.secondary) } @@ -173,8 +173,8 @@ struct CampaignRowView: View { } else { let c = Campaign( pid: campaign.pid, - name: campaign.name, - blurb: campaign.blurb ?? "", + name: campaign.displayName, + blurb: campaign.displayBlurb ?? "", photoURL: campaign.photo_url ?? "", goalAmount: campaign.goal_amount ?? 0, goalCurrency: campaign.goal_currency ?? "USD", @@ -183,7 +183,7 @@ struct CampaignRowView: View { categoryName: campaign.category_name ?? "", categoryID: campaign.category_id ?? "", projectURL: campaign.project_url ?? "", - creatorName: campaign.creator_name ?? "", + creatorName: campaign.displayCreatorName ?? "", percentFunded: campaign.percent_funded ?? 0, isWatched: true ) diff --git a/ios/KickWatch/Sources/Views/DiscoverView.swift b/ios/KickWatch/Sources/Views/DiscoverView.swift index 1d98313..4a7a512 100644 --- a/ios/KickWatch/Sources/Views/DiscoverView.swift +++ b/ios/KickWatch/Sources/Views/DiscoverView.swift @@ -53,7 +53,7 @@ struct DiscoverView: View { Task { await vm.selectCategory(nil) } } ForEach(vm.categories.filter { $0.parent_id == nil }, id: \.id) { cat in - CategoryChip(title: cat.name, isSelected: vm.selectedCategoryID == cat.id) { + CategoryChip(title: cat.displayName, isSelected: vm.selectedCategoryID == cat.id) { lastLoadMorePID = nil // Reset to allow loadMore with new data Task { await vm.selectCategory(cat.id) } } diff --git a/ios/KickWatch/Sources/Views/WatchlistView.swift b/ios/KickWatch/Sources/Views/WatchlistView.swift index fcd734f..75cf3d3 100644 --- a/ios/KickWatch/Sources/Views/WatchlistView.swift +++ b/ios/KickWatch/Sources/Views/WatchlistView.swift @@ -47,12 +47,12 @@ struct WatchlistView: View { private func toCampaignDTO(_ c: Campaign) -> CampaignDTO { CampaignDTO( - pid: c.pid, name: c.name, blurb: c.blurb, photo_url: c.photoURL, - goal_amount: c.goalAmount, goal_currency: c.goalCurrency, + pid: c.pid, name: c.name, name_zh: nil, blurb: c.blurb, blurb_zh: nil, + photo_url: c.photoURL, goal_amount: c.goalAmount, goal_currency: c.goalCurrency, pledged_amount: c.pledgedAmount, deadline: ISO8601DateFormatter().string(from: c.deadline), state: c.state, category_name: c.categoryName, category_id: c.categoryID, - project_url: c.projectURL, creator_name: c.creatorName, + project_url: c.projectURL, creator_name: c.creatorName, creator_name_zh: nil, percent_funded: c.percentFunded, backers_count: nil, slug: nil, velocity_24h: nil, pledge_delta_24h: nil, first_seen_at: nil ) diff --git a/ios/KickWatch/Tests/MockAPIClient.swift b/ios/KickWatch/Tests/MockAPIClient.swift index 59ea344..1f4bcd8 100644 --- a/ios/KickWatch/Tests/MockAPIClient.swift +++ b/ios/KickWatch/Tests/MockAPIClient.swift @@ -102,7 +102,9 @@ final class MockAPIClient: APIClientProtocol, @unchecked Sendable { CampaignDTO( pid: pid, name: name, + name_zh: nil, blurb: nil, + blurb_zh: nil, photo_url: nil, goal_amount: 1000, goal_currency: "USD", @@ -113,6 +115,7 @@ final class MockAPIClient: APIClientProtocol, @unchecked Sendable { category_id: nil, project_url: nil, creator_name: nil, + creator_name_zh: nil, percent_funded: 50, backers_count: 42, slug: nil,