diff --git a/cmd/extension/extension_zip.go b/cmd/extension/extension_zip.go index 9b487d4..57b954c 100644 --- a/cmd/extension/extension_zip.go +++ b/cmd/extension/extension_zip.go @@ -162,6 +162,14 @@ var extensionZipCmd = &cobra.Command{ } } + if err := extension.BuildModifier(ext, extDir, extension.BuildModifierConfig{ + AppBackendUrl: getStringOnStringError(cmd.Flags().GetString("overwrite-app-backend-url")), + AppBackendSecret: getStringOnStringError(cmd.Flags().GetString("overwrite-app-backend-secret")), + Version: getStringOnStringError(cmd.Flags().GetString("overwrite-version")), + }); err != nil { + return fmt.Errorf("build modifier: %w", err) + } + fileName := fmt.Sprintf("%s-%s.zip", name, tag) if len(tag) == 0 { fileName = fmt.Sprintf("%s.zip", name) @@ -197,10 +205,17 @@ func init() { extensionRootCmd.AddCommand(extensionZipCmd) extensionZipCmd.Flags().BoolVar(&disableGit, "disable-git", false, "Use the source folder as it is") extensionZipCmd.Flags().BoolVar(&extensionReleaseMode, "release", false, "Release mode (remove app secrets)") + extensionZipCmd.Flags().String("overwrite-app-backend-url", "", "Change all URLs in manifest.xml to this URL") + extensionZipCmd.Flags().String("overwrite-app-backend-secret", "", "Change the secret to this value") + extensionZipCmd.Flags().String("overwrite-version", "", "Change the extension version to this value") extensionZipCmd.Flags().String("output-directory", "", "Output directory for the zip file") extensionZipCmd.Flags().String("git-commit", "", "Commit Hash / Tag to use") } +func getStringOnStringError(val string, _ error) string { + return val +} + func executeHooks(ext extension.Extension, hooks []string, extDir string) error { env := []string{ fmt.Sprintf("EXTENSION_DIR=%s", extDir), diff --git a/extension/_fixtures/istorier.xml b/extension/_fixtures/istorier.xml new file mode 100644 index 0000000..418cbef --- /dev/null +++ b/extension/_fixtures/istorier.xml @@ -0,0 +1,91 @@ + + + + InstoImmersiveElements + + Transform your online store into an unforgettable brand experience. As an incredibly cost-effective alternative to external resources, the app is engineered to boost conversions. + Instorier AS + (c) by Instorier AS + 1.1.0 + Resources/config/plugin.png + Proprietary + + + + https://instorier.apps.shopware.io/app/lifecycle/register + + + + https://instorier.apps.shopware.io/iframe + + + cms_slot + cms_slot_translation + language + sales_channel + sales_channel_domain + customer + newsletter_recipient + order + category_translation + country_state_translation + country_translation + currency_translation + customer_group_translation + locale_translation + media + media_default_folder + media_translation + payment_method_translation + product_manufacturer_translation + product_translation + shipping_method_translation + unit_translation + property_group_translation + property_group_option_translation + sales_channel_translation + sales_channel_type_translation + salutation_translation + plugin_translation + product_stream_translation + state_machine_translation + state_machine_state_translation + cms_page_translation + mail_template_translation + mail_header_footer_translation + document_type_translation + number_range_type_translation + delivery_time_translation + product_search_keyword + product_keyword_dictionary + mail_template_type_translation + promotion_translation + number_range_translation + product_review + seo_url + tax_rule_type_translation + product_cross_selling_translation + import_export_profile_translation + product_sorting_translation + product_feature_set_translation + app_translation + app_action_button_translation + landing_page_translation + app_cms_block_translation + app_script_condition_translation + app_flow_action_translation + tax_provider_translation + theme_translation + + cms_slot + cms_slot_translation + media + media_translation + + cms_slot + cms_slot_translation + + cms_slot + cms_slot_translation + + \ No newline at end of file diff --git a/extension/app.go b/extension/app.go index 2df4576..9a4b7d5 100644 --- a/extension/app.go +++ b/extension/app.go @@ -11,184 +11,9 @@ import ( "github.com/FriendsOfShopware/shopware-cli/version" ) -type translatedXmlNode []struct { - Text string `xml:",chardata"` - Lang string `xml:"lang,attr"` -} - -type appManifest struct { - XMLName xml.Name `xml:"manifest"` - Text string `xml:",chardata"` - Xsi string `xml:"xsi,attr"` - NoNamespaceSchemaLocation string `xml:"noNamespaceSchemaLocation,attr"` - Meta appManifestMeta `xml:"meta"` - Setup struct { - Text string `xml:",chardata"` - RegistrationUrl string `xml:"registrationUrl"` - Secret string `xml:"secret"` - } `xml:"setup"` - Permissions struct { - Text string `xml:",chardata"` - Read string `xml:"read"` - Create string `xml:"create"` - Update string `xml:"update"` - Delete string `xml:"delete"` - } `xml:"permissions"` - Webhooks struct { - Text string `xml:",chardata"` - Webhook struct { - Text string `xml:",chardata"` - Name string `xml:"name,attr"` - URL string `xml:"url,attr"` - Event string `xml:"event,attr"` - } `xml:"webhook"` - } `xml:"webhooks"` - Admin struct { - Text string `xml:",chardata"` - Module []struct { - Text string `xml:",chardata"` - Name string `xml:"name,attr"` - Parent string `xml:"parent,attr"` - Position string `xml:"position,attr"` - Source string `xml:"source,attr"` - Label []struct { - Text string `xml:",chardata"` - Lang string `xml:"lang,attr"` - } `xml:"label"` - } `xml:"module"` - MainModule struct { - Text string `xml:",chardata"` - Source string `xml:"source,attr"` - } `xml:"main-module"` - ActionButton []struct { - Text string `xml:",chardata"` - Action string `xml:"action,attr"` - Entity string `xml:"entity,attr"` - View string `xml:"view,attr"` - URL string `xml:"url,attr"` - Label string `xml:"label"` - } `xml:"action-button"` - } `xml:"admin"` - CustomFields struct { - Text string `xml:",chardata"` - CustomFieldSet struct { - Text string `xml:",chardata"` - Name string `xml:"name"` - Label []struct { - Text string `xml:",chardata"` - Lang string `xml:"lang,attr"` - } `xml:"label"` - RelatedEntities struct { - Text string `xml:",chardata"` - Order string `xml:"order"` - } `xml:"related-entities"` - Fields struct { - Chardata string `xml:",chardata"` - Text struct { - Text string `xml:",chardata"` - Name string `xml:"name,attr"` - Label string `xml:"label"` - Position string `xml:"position"` - Required string `xml:"required"` - HelpText string `xml:"help-text"` - } `xml:"text"` - Float struct { - Text string `xml:",chardata"` - Name string `xml:"name,attr"` - Label []struct { - Text string `xml:",chardata"` - Lang string `xml:"lang,attr"` - } `xml:"label"` - HelpText string `xml:"help-text"` - Position string `xml:"position"` - Placeholder string `xml:"placeholder"` - Min string `xml:"min"` - Max string `xml:"max"` - Steps string `xml:"steps"` - } `xml:"float"` - } `xml:"fields"` - } `xml:"custom-field-set"` - } `xml:"custom-fields"` - Cookies struct { - Text string `xml:",chardata"` - Cookie struct { - Text string `xml:",chardata"` - Cookie string `xml:"cookie"` - SnippetName string `xml:"snippet-name"` - SnippetDescription string `xml:"snippet-description"` - Value string `xml:"value"` - Expiration string `xml:"expiration"` - } `xml:"cookie"` - Group struct { - Text string `xml:",chardata"` - SnippetName string `xml:"snippet-name"` - SnippetDescription string `xml:"snippet-description"` - Entries struct { - Text string `xml:",chardata"` - Cookie struct { - Text string `xml:",chardata"` - Cookie string `xml:"cookie"` - SnippetName string `xml:"snippet-name"` - SnippetDescription string `xml:"snippet-description"` - Value string `xml:"value"` - Expiration string `xml:"expiration"` - } `xml:"cookie"` - } `xml:"entries"` - } `xml:"group"` - } `xml:"cookies"` - Payments struct { - Text string `xml:",chardata"` - PaymentMethod struct { - Text string `xml:",chardata"` - Identifier string `xml:"identifier"` - Name []struct { - Text string `xml:",chardata"` - Lang string `xml:"lang,attr"` - } `xml:"name"` - Description []struct { - Text string `xml:",chardata"` - Lang string `xml:"lang,attr"` - } `xml:"description"` - PayURL string `xml:"pay-url"` - FinalizeURL string `xml:"finalize-url"` - Icon string `xml:"icon"` - } `xml:"payment-method"` - } `xml:"payments"` -} - -type appManifestMeta struct { - Text string `xml:",chardata"` - Name string `xml:"name"` - Label translatedXmlNode `xml:"label"` - Description translatedXmlNode `xml:"description"` - Author string `xml:"author"` - Copyright string `xml:"copyright"` - Version string `xml:"version"` - License string `xml:"license"` - Icon string `xml:"icon"` - Privacy string `xml:"privacy"` - Compatibility string `xml:"compatibility"` - PrivacyPolicyExtensions []struct { - Text string `xml:",chardata"` - Lang string `xml:"lang,attr"` - } `xml:"privacyPolicyExtensions"` -} - -func getTranslatedTextFromXmlNode(node translatedXmlNode, keys []string) string { - for _, n := range node { - for _, key := range keys { - if n.Lang == key { - return n.Text - } - } - } - - return "" -} - type App struct { path string - manifest appManifest + manifest Manifest config *Config } @@ -212,7 +37,7 @@ func newApp(path string) (*App, error) { return nil, fmt.Errorf("newApp: %v", err) } - var manifest appManifest + var manifest Manifest err = xml.Unmarshal(appFile, &manifest) if err != nil { @@ -294,12 +119,12 @@ func (a App) GetMetaData() *extensionMetadata { return &extensionMetadata{ Label: extensionTranslated{ - German: getTranslatedTextFromXmlNode(a.manifest.Meta.Label, german), - English: getTranslatedTextFromXmlNode(a.manifest.Meta.Label, english), + German: a.manifest.Meta.Label.GetValueByLanguage(german), + English: a.manifest.Meta.Label.GetValueByLanguage(english), }, Description: extensionTranslated{ - German: getTranslatedTextFromXmlNode(a.manifest.Meta.Description, german), - English: getTranslatedTextFromXmlNode(a.manifest.Meta.Description, english), + German: a.manifest.Meta.Description.GetValueByLanguage(german), + English: a.manifest.Meta.Description.GetValueByLanguage(english), }, } } diff --git a/extension/asset_test.go b/extension/asset_test.go index 19ac53f..201d69c 100644 --- a/extension/asset_test.go +++ b/extension/asset_test.go @@ -31,8 +31,8 @@ func TestConvertApp(t *testing.T) { app := App{ path: t.TempDir(), config: &Config{}, - manifest: appManifest{ - Meta: appManifestMeta{ + manifest: Manifest{ + Meta: Meta{ Name: "TestApp", }, }, @@ -50,8 +50,8 @@ func TestConvertApp(t *testing.T) { func TestConvertExtraBundlesOfConfig(t *testing.T) { app := App{ path: t.TempDir(), - manifest: appManifest{ - Meta: appManifestMeta{ + manifest: Manifest{ + Meta: Meta{ Name: "TestApp", }, }, @@ -83,8 +83,8 @@ func TestConvertExtraBundlesOfConfig(t *testing.T) { func TestConvertExtraBundlesOfConfigWithOverride(t *testing.T) { app := App{ path: t.TempDir(), - manifest: appManifest{ - Meta: appManifestMeta{ + manifest: Manifest{ + Meta: Meta{ Name: "TestApp", }, }, diff --git a/extension/build_modifier.go b/extension/build_modifier.go new file mode 100644 index 0000000..bdefb99 --- /dev/null +++ b/extension/build_modifier.go @@ -0,0 +1,176 @@ +package extension + +import ( + "encoding/json" + "encoding/xml" + "fmt" + "net/url" + "os" + "path" +) + +type BuildModifierConfig struct { + AppBackendUrl string + AppBackendSecret string + Version string +} + +func BuildModifier(ext Extension, extensionRoot string, config BuildModifierConfig) error { + if (config.AppBackendUrl != "" || config.AppBackendSecret != "" || config.Version != "") && ext.GetType() == TypePlatformApp { + manifestBytes, _ := os.ReadFile(path.Join(extensionRoot, "manifest.xml")) + + var manifest Manifest + + if err := xml.Unmarshal(manifestBytes, &manifest); err != nil { + return fmt.Errorf("could not parse manifest.xml: %w", err) + } + + if config.Version != "" { + manifest.Meta.Version = config.Version + } + + if config.AppBackendSecret != "" && manifest.Setup != nil { + manifest.Setup.Secret = config.AppBackendSecret + } + + if config.AppBackendUrl != "" { + if err := replaceUrlsInManifest(config, manifest); err != nil { + return err + } + } + + newXml, err := xml.MarshalIndent(manifest, "", " ") + + if err != nil { + return fmt.Errorf("could not marshal manifest.xml: %w", err) + } + + if err := os.WriteFile(path.Join(extensionRoot, "manifest.xml"), newXml, os.ModePerm); err != nil { + return fmt.Errorf("could not write manifest.xml: %w", err) + } + } + + if config.Version != "" && ext.GetType() == TypePlatformPlugin { + composerJson, err := os.ReadFile(path.Join(extensionRoot, "composer.json")) + + if err != nil { + return fmt.Errorf("could not read composer.json: %w", err) + } + + var composerJsonStruct map[string]interface{} + + if err := json.Unmarshal(composerJson, &composerJsonStruct); err != nil { + return fmt.Errorf("could not unmarshal composer.json: %w", err) + } + + composerJsonStruct["version"] = config.Version + + newComposerJson, err := json.MarshalIndent(composerJsonStruct, "", " ") + + if err != nil { + return fmt.Errorf("could not marshal composer.json: %w", err) + } + + if err := os.WriteFile(path.Join(extensionRoot, "composer.json"), newComposerJson, os.ModePerm); err != nil { + return fmt.Errorf("could not write manifest.xml: %w", err) + } + } + + return nil +} + +func replaceUrlsInManifest(config BuildModifierConfig, manifest Manifest) error { + newBackendUrl, err := url.Parse(config.AppBackendUrl) + + if err != nil { + return fmt.Errorf("could not parse app backend url: %w", err) + } + + if manifest.Setup != nil { + if err := replaceUrl(&manifest.Setup.RegistrationUrl, newBackendUrl); err != nil { + return fmt.Errorf("could not replace app backend url: %w", err) + } + } + + if manifest.Admin != nil { + if err := replaceUrl(&manifest.Admin.BaseAppUrl, newBackendUrl); err != nil { + return fmt.Errorf("could not replace app backend url: %w", err) + } + + for index, button := range manifest.Admin.ActionButton { + if err := replaceUrl(&button.URL, newBackendUrl); err != nil { + return fmt.Errorf("could not replace action button url on index %d: %w", index, err) + } + } + } + + if manifest.Gateways != nil { + if err := replaceUrl(&manifest.Gateways.Checkout, newBackendUrl); err != nil { + return fmt.Errorf("could not replace checkout gateway url: %w", err) + } + } + + if manifest.Payments != nil { + for _, payment := range manifest.Payments.PaymentMethod { + if err := replaceUrl(&payment.RefundURL, newBackendUrl); err != nil { + return fmt.Errorf("could not replace refund url: %w", err) + } + + if err := replaceUrl(&payment.CaptureURL, newBackendUrl); err != nil { + return fmt.Errorf("could not replace capture url: %w", err) + } + + if err := replaceUrl(&payment.FinalizeURL, newBackendUrl); err != nil { + return fmt.Errorf("could not replace finanlize url: %w", err) + } + + if err := replaceUrl(&payment.PayURL, newBackendUrl); err != nil { + return fmt.Errorf("could not replace pay url: %w", err) + } + + if err := replaceUrl(&payment.RecurringURL, newBackendUrl); err != nil { + return fmt.Errorf("could not replace recurring url: %w", err) + } + + if err := replaceUrl(&payment.ValidateURL, newBackendUrl); err != nil { + return fmt.Errorf("could not replace validate url: %w", err) + } + } + } + + if manifest.Tax != nil { + for _, tax := range manifest.Tax.TaxProvider { + if err := replaceUrl(&tax.ProcessURL, newBackendUrl); err != nil { + return fmt.Errorf("could not replace tax provider url: %w", err) + } + } + } + + if manifest.Webhooks != nil { + for _, webhook := range manifest.Webhooks.Webhook { + if err := replaceUrl(&webhook.URL, newBackendUrl); err != nil { + return fmt.Errorf("could not replace webhook url: %w", err) + } + } + } + return nil +} + +func replaceUrl(registrationUrl *string, backendUrl *url.URL) error { + if registrationUrl == nil || *registrationUrl == "" { + return nil + } + + currentUrl, err := url.Parse(*registrationUrl) + + if err != nil { + return fmt.Errorf("could not parse url: %w", err) + } + + currentUrl.Scheme = backendUrl.Scheme + currentUrl.Host = backendUrl.Host + + *registrationUrl = currentUrl.String() + + return nil +} diff --git a/extension/build_modifier_test.go b/extension/build_modifier_test.go new file mode 100644 index 0000000..aaab3ff --- /dev/null +++ b/extension/build_modifier_test.go @@ -0,0 +1,80 @@ +package extension + +import ( + "encoding/xml" + "github.com/stretchr/testify/assert" + "os" + "path/filepath" + "testing" +) + +const exampleManifest = ` + + + 1.0.0 + + + http://localhost/foo + +` + +func TestSetVersionApp(t *testing.T) { + app := &App{} + + tmpDir := t.TempDir() + + assert.NoError(t, os.WriteFile(filepath.Join(tmpDir, "manifest.xml"), []byte(exampleManifest), 0644)) + + assert.NoError(t, BuildModifier(app, tmpDir, BuildModifierConfig{Version: "5.0.0"})) + + bytes, err := os.ReadFile(filepath.Join(tmpDir, "manifest.xml")) + + assert.NoError(t, err) + + var manifest Manifest + + assert.NoError(t, xml.Unmarshal(bytes, &manifest)) + + assert.Equal(t, "5.0.0", manifest.Meta.Version) +} + +func TestSetRegistration(t *testing.T) { + app := &App{} + + tmpDir := t.TempDir() + + assert.NoError(t, os.WriteFile(filepath.Join(tmpDir, "manifest.xml"), []byte(exampleManifest), 0644)) + + assert.NoError(t, BuildModifier(app, tmpDir, BuildModifierConfig{AppBackendUrl: "https://foo.com"})) + + bytes, err := os.ReadFile(filepath.Join(tmpDir, "manifest.xml")) + + assert.NoError(t, err) + + var manifest Manifest + + assert.NoError(t, xml.Unmarshal(bytes, &manifest)) + + assert.Equal(t, "https://foo.com/foo", manifest.Setup.RegistrationUrl) +} + +func TestSetRegistrationSecret(t *testing.T) { + app := &App{} + + tmpDir := t.TempDir() + + assert.NoError(t, os.WriteFile(filepath.Join(tmpDir, "manifest.xml"), []byte(exampleManifest), 0644)) + + assert.NoError(t, BuildModifier(app, tmpDir, BuildModifierConfig{AppBackendSecret: "secret"})) + + bytes, err := os.ReadFile(filepath.Join(tmpDir, "manifest.xml")) + + assert.NoError(t, err) + + var manifest Manifest + + assert.NoError(t, xml.Unmarshal(bytes, &manifest)) + + assert.Equal(t, "http://localhost/foo", manifest.Setup.RegistrationUrl) + assert.Equal(t, "secret", manifest.Setup.Secret) +} diff --git a/extension/manifest.go b/extension/manifest.go new file mode 100644 index 0000000..48da61e --- /dev/null +++ b/extension/manifest.go @@ -0,0 +1,371 @@ +package extension + +import ( + "encoding/xml" +) + +type Manifest struct { + XMLName xml.Name `xml:"manifest"` + Meta Meta `xml:"meta"` + Setup *Setup `xml:"setup,omitempty"` + Admin *Admin `xml:"admin,omitempty"` + Storefront *Storefront `xml:"storefront,omitempty"` + Permissions *Permissions `xml:"permissions,omitempty"` + AllowedHosts *AllowedHosts `xml:"allowed-hosts,omitempty"` + CustomFields *CustomFields `xml:"custom-fields,omitempty"` + Webhooks *Webhooks `xml:"webhooks,omitempty"` + Cookies *Cookies `xml:"cookies,omitempty"` + Payments *Payments `xml:"payments,omitempty"` + ShippingMethods *ShippingMethods `xml:"shipping-methods,omitempty"` + RuleConditions *RuleConditions `xml:"rule-conditions,omitempty"` + Tax *Tax `xml:"tax,omitempty"` + Gateways *Gateways `xml:"gateways,omitempty"` +} + +type Meta struct { + Name string `xml:"name"` + Label TranslatableString `xml:"label"` + Description TranslatableString `xml:"description,omitempty"` + Author string `xml:"author,omitempty"` + Copyright string `xml:"copyright,omitempty"` + Version string `xml:"version"` + Icon string `xml:"icon,omitempty"` + License string `xml:"license"` + Compatibility string `xml:"compatibility,omitempty"` + Privacy string `xml:"privacy,omitempty"` + PrivacyPolicyExtensions TranslatableString `xml:"privacyPolicyExtensions,omitempty"` +} + +type Setup struct { + RegistrationUrl string `xml:"registrationUrl"` + Secret string `xml:"secret,omitempty"` +} + +type Admin struct { + ActionButton []ActionButton `xml:"action-button,omitempty"` + Module []Module `xml:"module,omitempty"` + MainModule *MainModule `xml:"main-module,omitempty"` + BaseAppUrl string `xml:"base-app-url,omitempty"` +} + +type Storefront struct { + TemplateLoadPriority int `xml:"template-load-priority,omitempty"` +} + +type Permissions struct { + Read []string `xml:"read,omitempty"` + Create []string `xml:"create,omitempty"` + Update []string `xml:"update,omitempty"` + Delete []string `xml:"delete,omitempty"` + Permission []string `xml:"permission,omitempty"` +} + +type AllowedHosts struct { + Host []string `xml:"host"` +} + +type CustomFields struct { + CustomFieldSet []CustomFieldSet `xml:"custom-field-set,omitempty"` +} + +type Webhooks struct { + Webhook []Webhook `xml:"webhook,omitempty"` +} + +type Cookies struct { + Cookie []Cookie `xml:"cookie,omitempty"` + Group []CookieGroup `xml:"group,omitempty"` +} + +type Payments struct { + PaymentMethod []PaymentMethod `xml:"payment-method,omitempty"` +} + +type ShippingMethods struct { + ShippingMethod []ShippingMethod `xml:"shipping-method,omitempty"` +} + +type RuleConditions struct { + RuleCondition []RuleCondition `xml:"rule-condition,omitempty"` +} + +type Tax struct { + TaxProvider []TaxProvider `xml:"tax-provider,omitempty"` +} + +type Gateways struct { + Checkout string `xml:"checkout,omitempty"` +} + +type TranslatableString []struct { + Value string `xml:",chardata"` + Lang string `xml:"lang,attr,omitempty"` +} + +func (t TranslatableString) GetValueByLanguage(lang []string) string { + for _, v := range t { + for _, l := range lang { + if v.Lang == l { + return v.Value + } + } + } + + return "" +} + +type ActionButton struct { + Label TranslatableString `xml:"label"` + Action string `xml:"action,attr"` + Entity string `xml:"entity,attr"` + View string `xml:"view,attr"` + URL string `xml:"url,attr"` +} + +type Module struct { + Label TranslatableString `xml:"label"` + Source string `xml:"source,attr,omitempty"` + Name string `xml:"name,attr"` + Parent string `xml:"parent,attr"` + Position int `xml:"position,attr,omitempty"` +} + +type MainModule struct { + Source string `xml:"source,attr"` +} + +type CustomFieldSet struct { + Name string `xml:"name"` + Label TranslatableString `xml:"label"` + RelatedEntities EntityList `xml:"related-entities"` + Fields CustomFieldList `xml:"fields"` + Global bool `xml:"global,attr,omitempty"` +} + +type EntityList struct { + Product *struct{} `xml:"product,omitempty"` + Order *struct{} `xml:"order,omitempty"` + Category *struct{} `xml:"category,omitempty"` + Customer *struct{} `xml:"customer,omitempty"` + CustomerAddress *struct{} `xml:"customer_address,omitempty"` + Media *struct{} `xml:"media,omitempty"` + ProductManufacturer *struct{} `xml:"product_manufacturer,omitempty"` + SalesChannel *struct{} `xml:"sales_channel,omitempty"` + LandingPage *struct{} `xml:"landing_page,omitempty"` + Promotion *struct{} `xml:"promotion,omitempty"` + ProductStream *struct{} `xml:"product_stream,omitempty"` + PropertyGroup *struct{} `xml:"property_group,omitempty"` + ProductReview *struct{} `xml:"product_review,omitempty"` + EventAction *struct{} `xml:"event_action,omitempty"` + Country *struct{} `xml:"country,omitempty"` + Currency *struct{} `xml:"currency,omitempty"` + CustomerGroup *struct{} `xml:"customer_group,omitempty"` + DeliveryTime *struct{} `xml:"delivery_time,omitempty"` + DocumentBaseConfig *struct{} `xml:"document_base_config,omitempty"` + Language *struct{} `xml:"language,omitempty"` + NumberRange *struct{} `xml:"number_range,omitempty"` + PaymentMethod *struct{} `xml:"payment_method,omitempty"` + Rule *struct{} `xml:"rule,omitempty"` + Salutation *struct{} `xml:"salutation,omitempty"` + ShippingMethod *struct{} `xml:"shipping_method,omitempty"` + Tax *struct{} `xml:"tax,omitempty"` +} + +type CustomFieldList struct { + Int []CustomFieldInt `xml:"int,omitempty"` + Float []CustomFieldFloat `xml:"float,omitempty"` + Text []CustomFieldText `xml:"text,omitempty"` + TextArea []CustomFieldTextArea `xml:"text-area,omitempty"` + Bool []CustomFieldBool `xml:"bool,omitempty"` + Datetime []CustomFieldDatetime `xml:"datetime,omitempty"` + SingleSelect []CustomFieldSingleSelect `xml:"single-select,omitempty"` + MultiSelect []CustomFieldMultiSelect `xml:"multi-select,omitempty"` + SingleEntitySelect []CustomFieldSingleEntitySelect `xml:"single-entity-select,omitempty"` + MultiEntitySelect []CustomFieldMultiEntitySelect `xml:"multi-entity-select,omitempty"` + ColorPicker []CustomFieldColorPicker `xml:"color-picker,omitempty"` + MediaSelection []CustomFieldMedia `xml:"media-selection,omitempty"` + Price []CustomFieldPrice `xml:"price,omitempty"` +} + +type CustomFieldBase struct { + Label TranslatableString `xml:"label"` + HelpText TranslatableString `xml:"help-text,omitempty"` + Required bool `xml:"required,omitempty"` + Position int `xml:"position,omitempty"` + AllowCustomerWrite bool `xml:"allow-customer-write,omitempty"` + AllowCartExpose bool `xml:"allow-cart-expose,omitempty"` +} + +type CustomFieldInt struct { + CustomFieldBase + XMLName xml.Name `xml:"int"` + Name string `xml:"name,attr"` + Placeholder TranslatableString `xml:"placeholder,omitempty"` + Steps int `xml:"steps,omitempty"` + Min int `xml:"min,omitempty"` + Max int `xml:"max,omitempty"` +} + +type CustomFieldFloat struct { + CustomFieldBase + XMLName xml.Name `xml:"float"` + Name string `xml:"name,attr"` + Placeholder TranslatableString `xml:"placeholder,omitempty"` + Steps float64 `xml:"steps,omitempty"` + Min float64 `xml:"min,omitempty"` + Max float64 `xml:"max,omitempty"` +} + +type CustomFieldText struct { + CustomFieldBase + XMLName xml.Name `xml:"text"` + Name string `xml:"name,attr"` + Placeholder TranslatableString `xml:"placeholder,omitempty"` +} + +type CustomFieldTextArea struct { + CustomFieldBase + XMLName xml.Name `xml:"text-area"` + Name string `xml:"name,attr"` + Placeholder TranslatableString `xml:"placeholder,omitempty"` +} + +type CustomFieldBool struct { + CustomFieldBase + XMLName xml.Name `xml:"bool"` + Name string `xml:"name,attr"` +} + +type CustomFieldDatetime struct { + CustomFieldBase + XMLName xml.Name `xml:"datetime"` + Name string `xml:"name,attr"` +} + +type CustomFieldSingleSelect struct { + CustomFieldBase + XMLName xml.Name `xml:"single-select"` + Name string `xml:"name,attr"` + Placeholder TranslatableString `xml:"placeholder,omitempty"` + Options OptionCollection `xml:"options"` +} + +type CustomFieldMultiSelect struct { + CustomFieldSingleSelect + XMLName xml.Name `xml:"multi-select"` +} + +type CustomFieldSingleEntitySelect struct { + CustomFieldBase + XMLName xml.Name `xml:"single-entity-select"` + Name string `xml:"name,attr"` + Placeholder TranslatableString `xml:"placeholder,omitempty"` + Entity string `xml:"entity"` + LabelProperty string `xml:"label-property"` +} + +type CustomFieldMultiEntitySelect struct { + CustomFieldSingleEntitySelect + XMLName xml.Name `xml:"multi-entity-select"` +} + +type CustomFieldColorPicker struct { + CustomFieldBase + XMLName xml.Name `xml:"color-picker"` + Name string `xml:"name,attr"` +} + +type CustomFieldMedia struct { + CustomFieldBase + XMLName xml.Name `xml:"media-selection"` + Name string `xml:"name,attr"` +} + +type CustomFieldPrice struct { + CustomFieldBase + XMLName xml.Name `xml:"price"` + Name string `xml:"name,attr"` +} + +type OptionCollection struct { + Option []Option `xml:"option"` +} + +type Option struct { + Name TranslatableString `xml:"name"` + Value string `xml:"value,attr"` +} + +// Add more specific custom field types here... + +type Webhook struct { + Name string `xml:"name,attr"` + URL string `xml:"url,attr"` + Event string `xml:"event,attr"` + OnlyLiveVersion bool `xml:"onlyLiveVersion,attr,omitempty"` +} + +type Cookie struct { + SnippetName string `xml:"snippet-name"` + SnippetDescription string `xml:"snippet-description,omitempty"` + Cookie string `xml:"cookie"` + Value string `xml:"value,omitempty"` + Expiration int `xml:"expiration,omitempty"` +} + +type CookieGroup struct { + SnippetName string `xml:"snippet-name"` + SnippetDescription string `xml:"snippet-description,omitempty"` + Entries []CookieEntry `xml:"entries>cookie,omitempty"` +} + +type CookieEntry struct { + Cookie +} + +type PaymentMethod struct { + Identifier string `xml:"identifier"` + Name TranslatableString `xml:"name"` + Description TranslatableString `xml:"description,omitempty"` + PayURL string `xml:"pay-url,omitempty"` + FinalizeURL string `xml:"finalize-url,omitempty"` + ValidateURL string `xml:"validate-url,omitempty"` + CaptureURL string `xml:"capture-url,omitempty"` + RefundURL string `xml:"refund-url,omitempty"` + RecurringURL string `xml:"recurring-url,omitempty"` + Icon string `xml:"icon,omitempty"` +} + +type ShippingMethod struct { + Identifier string `xml:"identifier"` + Name TranslatableString `xml:"name"` + Description TranslatableString `xml:"description,omitempty"` + Active bool `xml:"active,omitempty"` + DeliveryTime DeliveryTime `xml:"delivery-time"` + Icon string `xml:"icon,omitempty"` + Position int `xml:"position,omitempty"` + TrackingURL TranslatableString `xml:"tracking-url,omitempty"` +} + +type DeliveryTime struct { + ID string `xml:"id"` + Name TranslatableString `xml:"name"` + Min int `xml:"min"` + Max int `xml:"max"` + Unit string `xml:"unit"` +} + +type RuleCondition struct { + Identifier string `xml:"identifier"` + Name TranslatableString `xml:"name"` + Group string `xml:"group"` + Script string `xml:"script"` + Constraints []CustomFieldList `xml:"constraints"` +} + +type TaxProvider struct { + Identifier string `xml:"identifier"` + Name string `xml:"name"` + Priority int `xml:"priority"` + ProcessURL string `xml:"process-url"` +} diff --git a/extension/manifest_test.go b/extension/manifest_test.go new file mode 100644 index 0000000..22de232 --- /dev/null +++ b/extension/manifest_test.go @@ -0,0 +1,37 @@ +package extension + +import ( + "encoding/xml" + "github.com/stretchr/testify/assert" + "os" + "testing" +) + +func TestManifestRead(t *testing.T) { + bytes, err := os.ReadFile("_fixtures/istorier.xml") + + assert.NoError(t, err) + + manifest := Manifest{} + + assert.NoError(t, xml.Unmarshal(bytes, &manifest)) + + assert.Equal(t, "InstoImmersiveElements", manifest.Meta.Name) + assert.Equal(t, "Immersive Elements", manifest.Meta.Label[0].Value) + assert.Equal(t, "Transform your online store into an unforgettable brand experience. As an incredibly cost-effective alternative to external resources, the app is engineered to boost conversions.", manifest.Meta.Description[0].Value) + assert.Equal(t, "Instorier AS", manifest.Meta.Author) + assert.Equal(t, "(c) by Instorier AS", manifest.Meta.Copyright) + assert.Equal(t, "1.1.0", manifest.Meta.Version) + assert.Equal(t, "Resources/config/plugin.png", manifest.Meta.Icon) + assert.Equal(t, "Proprietary", manifest.Meta.License) + + assert.Equal(t, "https://instorier.apps.shopware.io/app/lifecycle/register", manifest.Setup.RegistrationUrl) + assert.Equal(t, "", manifest.Setup.Secret) + + assert.Equal(t, "https://instorier.apps.shopware.io/iframe", manifest.Admin.BaseAppUrl) + + assert.Len(t, manifest.Permissions.Read, 57) + assert.Len(t, manifest.Permissions.Create, 4) + assert.Len(t, manifest.Permissions.Update, 2) + assert.Len(t, manifest.Permissions.Delete, 2) +} diff --git a/extension/zip.go b/extension/zip.go index 94a00eb..6cfad57 100644 --- a/extension/zip.go +++ b/extension/zip.go @@ -2,7 +2,6 @@ package extension import ( "archive/zip" - "bytes" "context" "encoding/json" "encoding/xml" @@ -505,63 +504,29 @@ func PrepareExtensionForRelease(ctx context.Context, sourceRoot, extensionRoot s manifestPath := filepath.Join(extensionRoot, "manifest.xml") - file, err := os.Open(manifestPath) + bytes, err := os.ReadFile(manifestPath) + if err != nil { - return fmt.Errorf("cannot read manifest file: %w", err) + return err } - defer func() { - _ = file.Close() - }() - - var buf bytes.Buffer - decoder := xml.NewDecoder(file) - encoder := xml.NewEncoder(&buf) - - skip := false + var manifest Manifest - for { - token, err := decoder.Token() - if err == io.EOF { - break - } - if err != nil { - return err - } - - if v, ok := token.(xml.StartElement); ok { - if v.Name.Local == "secret" { - skip = true - continue - } - } - - if v, ok := token.(xml.EndElement); ok { - if v.Name.Local == "secret" { - skip = false - continue - } - } - - if skip { - continue - } - - if err := encoder.EncodeToken(token); err != nil { - return err - } + if err := xml.Unmarshal(bytes, &manifest); err != nil { + return fmt.Errorf("unmarshal manifest failed: %w", err) } - // must call flush, otherwise some elements will be missing - if err := encoder.Flush(); err != nil { - return err + if manifest.Setup != nil { + manifest.Setup.Secret = "" } - newManifest := buf.String() - newManifest = strings.ReplaceAll(newManifest, "xmlns:_xmlns=\"xmlns\" _xmlns:xsi=", "xmlns:xsi=") - newManifest = strings.ReplaceAll(newManifest, "xmlns:_XMLSchema-instance=\"http://www.w3.org/2001/XMLSchema-instance\" _XMLSchema-instance:noNamespaceSchemaLocation=", "xsi:noNamespaceSchemaLocation=") + newManifest, err := xml.MarshalIndent(manifest, "", " ") + + if err != nil { + return fmt.Errorf("cannot marshal manifest failed: %w", err) + } - if err := os.WriteFile(manifestPath, []byte(newManifest), os.ModePerm); err != nil { + if err := os.WriteFile(manifestPath, newManifest, os.ModePerm); err != nil { return err }