├── .babelrc ├── .gitignore ├── LICENSE.txt ├── README.md ├── backends ├── database │ ├── database.go │ └── database_test.go └── yaml │ ├── tests │ ├── chinese.yml │ ├── english.yaml │ └── subdir │ │ ├── german.yml │ │ └── no_language_file.txt │ ├── yaml.go │ └── yaml_test.go ├── controller.go ├── exchange_actions ├── exchange_actions.go ├── exchange_actions_test.go ├── fixtures │ ├── export_all.csv │ ├── export_backend.csv │ └── export_frontend.csv ├── public │ └── imports │ │ ├── import_1.csv │ │ ├── import_2.csv │ │ └── import_3.csv └── views │ └── themes │ └── i18n │ └── actions │ └── index │ └── exchange.tmpl ├── i18n.go ├── i18n_test.go ├── inline_edit ├── inline_edit.go └── views │ └── themes │ └── i18n │ └── assets │ ├── javascripts │ ├── i18n-checker.js │ ├── i18n-inline.js │ └── i18n-inline │ │ ├── 1.poshytip.min.js │ │ ├── 2.jquery.editable.poshytip.min.js │ │ └── 3.i18n-inline.js │ └── stylesheets │ ├── i18n-inline.css │ └── i18n-inline │ └── i18n-inline.scss ├── package.json ├── views └── themes │ └── i18n │ ├── assets │ ├── javascripts │ │ ├── i18n.js │ │ └── i18n │ │ │ └── i18n.js │ └── stylesheets │ │ ├── i18n.css │ │ └── i18n │ │ └── i18n.scss │ └── index.tmpl └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | exchange_actions/public/download* 2 | /public 3 | *.log 4 | *.sqlite3 5 | *.swp 6 | .~lock.* 7 | *.elc 8 | .rbenv-version 9 | tags 10 | TAGS 11 | *# 12 | .#* 13 | *.test 14 | .DS_Store 15 | .otto 16 | .ottoid 17 | gin-bin 18 | *.js.map 19 | node_modules/ -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) The Plant https://theplant.jp 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # I18n 2 | 3 | I18n provides internationalization support for your application, it supports 2 kinds of storages(backends), the database and file system. 4 | 5 | [![GoDoc](https://godoc.org/github.com/qor/i18n?status.svg)](https://godoc.org/github.com/qor/i18n) 6 | 7 | ## Usage 8 | 9 | Initialize I18n with the storage mode. You can use both storages together, the earlier one has higher priority. So in the example, I18n will look up the translation in database first, then continue finding it in the YAML file if not found. 10 | 11 | ```go 12 | import ( 13 | "github.com/jinzhu/gorm" 14 | "github.com/qor/i18n" 15 | "github.com/qor/i18n/backends/database" 16 | "github.com/qor/i18n/backends/yaml" 17 | ) 18 | 19 | func main() { 20 | db, _ := gorm.Open("mysql", "user:password@/dbname?charset=utf8&parseTime=True&loc=Local") 21 | 22 | I18n := i18n.New( 23 | database.New(&db), // load translations from the database 24 | yaml.New(filepath.Join(config.Root, "config/locales")), // load translations from the YAML files in directory `config/locales` 25 | ) 26 | 27 | I18n.T("en-US", "demo.greeting") // Not exist at first 28 | I18n.T("en-US", "demo.hello") // Exists in the yml file 29 | 30 | i18n.Default = "zh-CN" // change the default locale. the original value is "en-US" 31 | } 32 | ``` 33 | 34 | Once a database has been set for I18n, all **untranslated** translations inside `I18n.T()` will be loaded into `translations` table in the database when compiling the application. For example, we have an untranslated `I18n.T("en-US", "demo.greeting")` in the example, so I18n will generate this record in the `translations` table after compiling. 35 | 36 | | locale | key | value | 37 | | --- | --- | --- | 38 | | en-US | demo.greeting |   | 39 | 40 | The YAML file format is 41 | 42 | ```yaml 43 | en-US: 44 | demo: 45 | hello: "Hello, world" 46 | ``` 47 | 48 | ### Use built-in interface for translation management with [QOR Admin](http://github.com/qor/admin) 49 | 50 | I18n has a built-in web interface for translations which is integrated with [QOR Admin](http://github.com/qor/admin). 51 | 52 | ```go 53 | Admin.AddResource(I18n) 54 | ``` 55 | 56 | To let users able to translate between locales in the admin interface, your "User" need to implement these interfaces. 57 | ```go 58 | func (user User) EditableLocales() []string { 59 | return []string{"en-US", "zh-CN"} 60 | } 61 | 62 | func (user User) ViewableLocales() []string { 63 | return []string{"en-US", "zh-CN"} 64 | } 65 | ``` 66 | 67 | Refer the [online demo](http://demo.getqor.com/admin/translations). 68 | 69 | ### Use with Golang templates 70 | 71 | The easy way to use I18n in a template is to define a `t` function and register it as `FuncMap`: 72 | 73 | ```go 74 | func T(key string, value string, args ...interface{}) string { 75 | return I18n.Default(value).T("en-US", key, args...) 76 | } 77 | 78 | // then use it in the template 79 | {{ t "demo.greet" "Hello, {{$1}}" "John" }} // -> Hello, John 80 | ``` 81 | 82 | ### Built-in functions for translations management 83 | 84 | I18n has functions to manage translation directly. 85 | 86 | ```go 87 | // Add Translation 88 | I18n.AddTranslation(&i18n.Translation{Key: "hello-world", Locale: "en-US", Value: "hello world"}) 89 | 90 | // Update Translation 91 | I18n.SaveTranslation(&i18n.Translation{Key: "hello-world", Locale: "en-US", Value: "Hello World"}) 92 | 93 | // Delete Translation 94 | I18n.DeleteTranslation(&i18n.Translation{Key: "hello-world", Locale: "en-US", Value: "Hello World"}) 95 | ``` 96 | 97 | ### Scope and default value 98 | 99 | Call Translation with `Scope` or set default value. 100 | 101 | ```go 102 | // Read Translation with `Scope` 103 | I18n.Scope("home-page").T("zh-CN", "hello-world") // read translation with translation key `home-page.hello-world` 104 | 105 | // Read Translation with `Default Value` 106 | I18n.Default("Default Value").T("zh-CN", "non-existing-key") // Will return default value `Default Value` 107 | ``` 108 | 109 | ### Fallbacks 110 | 111 | I18n has a `Fallbacks` function to register fallbacks. For example, registering `en-GB` as a fallback to `zh-CN`: 112 | 113 | ```go 114 | i18n := New(&backend{}) 115 | i18n.AddTranslation(&Translation{Key: "hello-world", Locale: "en-GB", Value: "Hello World"}) 116 | 117 | fmt.Print(i18n.Fallbacks("en-GB").T("zh-CN", "hello-world")) // "Hello World" 118 | ``` 119 | 120 | **To set fallback [*Locale*](https://en.wikipedia.org/wiki/Locale_(computer_software)) globally** you can use `I18n.FallbackLocales`. This function accepts a `map[string][]string` as parameter. The key is the fallback *Locale* and the `[]string` is the *Locales* that could fallback to the first *Locale*. 121 | 122 | For example, setting `"fr-FR", "de-DE", "zh-CN"` fallback to `en-GB` globally: 123 | 124 | ```go 125 | I18n.FallbackLocales = map[string][]string{"en-GB": []{"fr-FR", "de-DE", "zh-CN"}} 126 | ``` 127 | 128 | ### Interpolation 129 | 130 | I18n utilizes a Golang template to parse translations with an interpolation variable. 131 | 132 | ```go 133 | type User struct { 134 | Name string 135 | } 136 | 137 | I18n.AddTranslation(&i18n.Translation{Key: "hello", Locale: "en-US", Value: "Hello {{.Name}}"}) 138 | 139 | I18n.T("en-US", "hello", User{Name: "Jinzhu"}) //=> Hello Jinzhu 140 | ``` 141 | 142 | ### Pluralization 143 | 144 | I18n utilizes [cldr](https://github.com/theplant/cldr) to achieve pluralization, it provides the functions `p`, `zero`, `one`, `two`, `few`, `many`, `other` for this purpose. Please refer to [cldr documentation](https://github.com/theplant/cldr) for more information. 145 | 146 | ```go 147 | I18n.AddTranslation(&i18n.Translation{Key: "count", Locale: "en-US", Value: "{{p "Count" (one "{{.Count}} item") (other "{{.Count}} items")}}"}) 148 | I18n.T("en-US", "count", map[string]int{"Count": 1}) //=> 1 item 149 | ``` 150 | 151 | ### Ordered Params 152 | 153 | ```go 154 | I18n.AddTranslation(&i18n.Translation{Key: "ordered_params", Locale: "en-US", Value: "{{$1}} {{$2}} {{$1}}"}) 155 | I18n.T("en-US", "ordered_params", "string1", "string2") //=> string1 string2 string1 156 | ``` 157 | 158 | ### Inline Edit 159 | 160 | You could manage translations' data with [QOR Admin](http://github.com/qor/admin) interface (UI) after registering it into [QOR Admin](http://github.com/qor/admin), however we warn you that it is usually quite hard (and error prone!) to *translate a translation* without knowing its context...Fortunately, the *Inline Edit* feature of [QOR Admin](http://github.com/qor/admin) was developed to resolve this problem! 161 | 162 | *Inline Edit* allows administrators to manage translations from the frontend. Similarly to [integrating with Golang Templates](#integrate-with-golang-templates), you need to register a func map for Golang templates to render *inline editable* translations. 163 | 164 | The good thing is we have created a package for you to do this easily, it will generate a `FuncMap`, you just need to use it when parsing your templates: 165 | 166 | ```go 167 | // `I18n` hold translations backends 168 | // `en-US` current locale 169 | // `true` enable inline edit mode or not, if inline edit not enabled, it works just like the funcmap in section "Integrate with Golang Templates" 170 | inline_edit.FuncMap(I18n, "en-US", true) // => map[string]interface{}{ 171 | // "t": func(string, ...interface{}) template.HTML { 172 | // // ... 173 | // }, 174 | // } 175 | ``` 176 | 177 | 178 | 179 | ## License 180 | 181 | Released under the [MIT License](http://opensource.org/licenses/MIT). 182 | -------------------------------------------------------------------------------- /backends/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jinzhu/gorm" 7 | "github.com/qor/i18n" 8 | ) 9 | 10 | // Translation is a struct used to save translations into databae 11 | type Translation struct { 12 | Locale string `sql:"size:12;"` 13 | Key string `sql:"size:4294967295;"` 14 | Value string `sql:"size:4294967295"` 15 | } 16 | 17 | // New new DB backend for I18n 18 | func New(db *gorm.DB) i18n.Backend { 19 | db.AutoMigrate(&Translation{}) 20 | if err := db.Model(&Translation{}).AddUniqueIndex("idx_translations_key_with_locale", "locale", "key").Error; err != nil { 21 | fmt.Printf("Failed to create unique index for translations key & locale, got: %v\n", err.Error()) 22 | } 23 | return &Backend{DB: db} 24 | } 25 | 26 | // Backend DB backend 27 | type Backend struct { 28 | DB *gorm.DB 29 | } 30 | 31 | // LoadTranslations load translations from DB backend 32 | func (backend *Backend) LoadTranslations() (translations []*i18n.Translation) { 33 | backend.DB.Find(&translations) 34 | return translations 35 | } 36 | 37 | // SaveTranslation save translation into DB backend 38 | func (backend *Backend) SaveTranslation(t *i18n.Translation) error { 39 | return backend.DB.Where(Translation{Key: t.Key, Locale: t.Locale}). 40 | Assign(Translation{Value: t.Value}). 41 | FirstOrCreate(&Translation{}).Error 42 | } 43 | 44 | // FindTranslation find translation from DB backend 45 | func (backend *Backend) FindTranslation(t *i18n.Translation) (translation i18n.Translation) { 46 | backend.DB.Where(Translation{Key: t.Key, Locale: t.Locale}).Find(&translation) 47 | return translation 48 | } 49 | 50 | // DeleteTranslation delete translation into DB backend 51 | func (backend *Backend) DeleteTranslation(t *i18n.Translation) error { 52 | return backend.DB.Where(Translation{Key: t.Key, Locale: t.Locale}).Delete(&Translation{}).Error 53 | } 54 | -------------------------------------------------------------------------------- /backends/database/database_test.go: -------------------------------------------------------------------------------- 1 | package database_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/jinzhu/gorm" 7 | "github.com/qor/i18n" 8 | "github.com/qor/i18n/backends/database" 9 | "github.com/qor/qor/test/utils" 10 | ) 11 | 12 | var db *gorm.DB 13 | var backend i18n.Backend 14 | 15 | func init() { 16 | db = utils.TestDB() 17 | db.DropTable(&database.Translation{}) 18 | backend = database.New(db) 19 | } 20 | 21 | func TestTranslations(t *testing.T) { 22 | translation := i18n.Translation{Key: "hello_world", Value: "Hello World", Locale: "zh-CN"} 23 | 24 | backend.SaveTranslation(&translation) 25 | if len(backend.LoadTranslations()) != 1 { 26 | t.Errorf("should has only one translation") 27 | } 28 | 29 | backend.DeleteTranslation(&translation) 30 | if len(backend.LoadTranslations()) != 0 { 31 | t.Errorf("should has none translation") 32 | } 33 | 34 | longText := "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." 35 | 36 | backend.SaveTranslation(&i18n.Translation{Key: longText + "1", Value: longText, Locale: "zh-CN"}) 37 | backend.SaveTranslation(&i18n.Translation{Key: longText + "2", Value: longText, Locale: "zh-CN"}) 38 | 39 | if len(backend.LoadTranslations()) != 2 { 40 | t.Errorf("should has two translations") 41 | } 42 | 43 | backend.DeleteTranslation(&i18n.Translation{Key: longText + "1", Value: longText, Locale: "zh-CN"}) 44 | if len(backend.LoadTranslations()) != 1 { 45 | t.Errorf("should has one translation left") 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /backends/yaml/tests/chinese.yml: -------------------------------------------------------------------------------- 1 | zh-CN: 2 | hello: 你好 3 | user: 4 | name: "用户名" 5 | email: "邮箱" 6 | -------------------------------------------------------------------------------- /backends/yaml/tests/english.yaml: -------------------------------------------------------------------------------- 1 | en: 2 | hello: Hello 3 | user: 4 | name: "User Name" 5 | email: "Email" 6 | -------------------------------------------------------------------------------- /backends/yaml/tests/subdir/german.yml: -------------------------------------------------------------------------------- 1 | de: 2 | hello: Hallo 3 | user: 4 | name: "Benutzername" 5 | email: "E-Mail-Adresse" 6 | -------------------------------------------------------------------------------- /backends/yaml/tests/subdir/no_language_file.txt: -------------------------------------------------------------------------------- 1 | This is not a language YAML file. 2 | -------------------------------------------------------------------------------- /backends/yaml/yaml.go: -------------------------------------------------------------------------------- 1 | package yaml 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/qor/i18n" 13 | "gopkg.in/yaml.v2" 14 | ) 15 | 16 | var _ i18n.Backend = &Backend{} 17 | 18 | // New new YAML backend for I18n 19 | func New(paths ...string) *Backend { 20 | backend := &Backend{} 21 | 22 | var files []string 23 | for _, p := range paths { 24 | if file, err := os.Open(p); err == nil { 25 | defer file.Close() 26 | if fileInfo, err := file.Stat(); err == nil { 27 | if fileInfo.IsDir() { 28 | yamlFiles, _ := filepath.Glob(filepath.Join(p, "*.yaml")) 29 | files = append(files, yamlFiles...) 30 | 31 | ymlFiles, _ := filepath.Glob(filepath.Join(p, "*.yml")) 32 | files = append(files, ymlFiles...) 33 | } else if fileInfo.Mode().IsRegular() { 34 | files = append(files, p) 35 | } 36 | } 37 | } 38 | } 39 | for _, file := range files { 40 | if content, err := ioutil.ReadFile(file); err == nil { 41 | backend.contents = append(backend.contents, content) 42 | } 43 | } 44 | return backend 45 | } 46 | 47 | // NewWithWalk has the same functionality as New but uses filepath.Walk to find all the translation files recursively. 48 | func NewWithWalk(paths ...string) i18n.Backend { 49 | backend := &Backend{} 50 | 51 | var files []string 52 | for _, p := range paths { 53 | filepath.Walk(p, func(path string, fileInfo os.FileInfo, err error) error { 54 | if isYamlFile(fileInfo) { 55 | files = append(files, path) 56 | } 57 | return nil 58 | }) 59 | } 60 | for _, file := range files { 61 | if content, err := ioutil.ReadFile(file); err == nil { 62 | backend.contents = append(backend.contents, content) 63 | } 64 | } 65 | 66 | return backend 67 | } 68 | 69 | func isYamlFile(fileInfo os.FileInfo) bool { 70 | if fileInfo == nil { 71 | return false 72 | } 73 | return fileInfo.Mode().IsRegular() && (strings.HasSuffix(fileInfo.Name(), ".yml") || strings.HasSuffix(fileInfo.Name(), ".yaml")) 74 | } 75 | 76 | func walkFilesystem(fs http.FileSystem, entry http.File, prefix string) [][]byte { 77 | var ( 78 | contents [][]byte 79 | err error 80 | isRoot bool 81 | ) 82 | if entry == nil { 83 | if entry, err = fs.Open("/"); err != nil { 84 | return nil 85 | } 86 | isRoot = true 87 | defer entry.Close() 88 | } 89 | fileInfo, err := entry.Stat() 90 | if err != nil { 91 | return nil 92 | } 93 | if !isRoot { 94 | prefix = prefix + fileInfo.Name() + "/" 95 | } 96 | if fileInfo.IsDir() { 97 | if entries, err := entry.Readdir(-1); err == nil { 98 | for _, e := range entries { 99 | if file, err := fs.Open(prefix + e.Name()); err == nil { 100 | defer file.Close() 101 | contents = append(contents, walkFilesystem(fs, file, prefix)...) 102 | } 103 | } 104 | } 105 | } else if isYamlFile(fileInfo) { 106 | if content, err := ioutil.ReadAll(entry); err == nil { 107 | contents = append(contents, content) 108 | } 109 | } 110 | return contents 111 | } 112 | 113 | // NewWithFilesystem initializes a backend that reads translation files from an http.FileSystem. 114 | func NewWithFilesystem(fss ...http.FileSystem) i18n.Backend { 115 | backend := &Backend{} 116 | 117 | for _, fs := range fss { 118 | backend.contents = append(backend.contents, walkFilesystem(fs, nil, "/")...) 119 | } 120 | return backend 121 | } 122 | 123 | // Backend YAML backend 124 | type Backend struct { 125 | contents [][]byte 126 | } 127 | 128 | func loadTranslationsFromYaml(locale string, value interface{}, scopes []string) (translations []*i18n.Translation) { 129 | switch v := value.(type) { 130 | case yaml.MapSlice: 131 | for _, s := range v { 132 | results := loadTranslationsFromYaml(locale, s.Value, append(scopes, fmt.Sprint(s.Key))) 133 | translations = append(translations, results...) 134 | } 135 | default: 136 | var translation = &i18n.Translation{ 137 | Locale: locale, 138 | Key: strings.Join(scopes, "."), 139 | Value: fmt.Sprint(v), 140 | } 141 | translations = append(translations, translation) 142 | } 143 | return 144 | } 145 | 146 | // LoadYAMLContent load YAML content 147 | func (backend *Backend) LoadYAMLContent(content []byte) (translations []*i18n.Translation, err error) { 148 | var slice yaml.MapSlice 149 | 150 | if err = yaml.Unmarshal(content, &slice); err == nil { 151 | for _, item := range slice { 152 | translations = append(translations, loadTranslationsFromYaml(item.Key.(string) /* locale */, item.Value, []string{})...) 153 | } 154 | } 155 | 156 | return translations, err 157 | } 158 | 159 | // LoadTranslations load translations from YAML backend 160 | func (backend *Backend) LoadTranslations() (translations []*i18n.Translation) { 161 | for _, content := range backend.contents { 162 | if results, err := backend.LoadYAMLContent(content); err == nil { 163 | translations = append(translations, results...) 164 | } else { 165 | panic(err) 166 | } 167 | } 168 | return translations 169 | } 170 | 171 | // SaveTranslation save translation into YAML backend, not implemented 172 | func (backend *Backend) SaveTranslation(t *i18n.Translation) error { 173 | return errors.New("not implemented") 174 | } 175 | 176 | // FindTranslation find translation from backend 177 | func (backend *Backend) FindTranslation(t *i18n.Translation) (translation i18n.Translation) { 178 | return translation //not implemented 179 | } 180 | 181 | // DeleteTranslation delete translation into YAML backend, not implemented 182 | func (backend *Backend) DeleteTranslation(t *i18n.Translation) error { 183 | return errors.New("not implemented") 184 | } 185 | -------------------------------------------------------------------------------- /backends/yaml/yaml_test.go: -------------------------------------------------------------------------------- 1 | package yaml_test 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/qor/i18n" 9 | "github.com/qor/i18n/backends/yaml" 10 | ) 11 | 12 | var values = map[string][][]string{ 13 | "en": { 14 | {"hello", "Hello"}, 15 | {"user.name", "User Name"}, 16 | {"user.email", "Email"}, 17 | }, 18 | "de": { 19 | {"hello", "Hallo"}, 20 | {"user.name", "Benutzername"}, 21 | {"user.email", "E-Mail-Adresse"}, 22 | }, 23 | "zh-CN": { 24 | {"hello", "你好"}, 25 | {"user.name", "用户名"}, 26 | {"user.email", "邮箱"}, 27 | }, 28 | } 29 | 30 | func checkTranslations(translations []*i18n.Translation) error { 31 | for locale, results := range values { 32 | for _, result := range results { 33 | var found bool 34 | for _, translation := range translations { 35 | if (translation.Locale == locale) && (translation.Key == result[0]) && (translation.Value == result[1]) { 36 | found = true 37 | } 38 | } 39 | if !found { 40 | return fmt.Errorf("failed to found translation %v for %v", result[0], locale) 41 | } 42 | } 43 | } 44 | return nil 45 | } 46 | 47 | func TestLoadTranslations(t *testing.T) { 48 | backend := yaml.New("tests", "tests/subdir") 49 | if err := checkTranslations(backend.LoadTranslations()); err != nil { 50 | t.Fatal(err) 51 | } 52 | } 53 | 54 | func TestLoadTranslationsFilesystem(t *testing.T) { 55 | backend := yaml.NewWithFilesystem(http.Dir("./tests")) 56 | if err := checkTranslations(backend.LoadTranslations()); err != nil { 57 | t.Fatal(err) 58 | } 59 | } 60 | 61 | func TestLoadTranslationsWalk(t *testing.T) { 62 | backend := yaml.NewWithWalk("tests") 63 | if err := checkTranslations(backend.LoadTranslations()); err != nil { 64 | t.Fatal(err) 65 | } 66 | } 67 | 68 | var benchmarkResult error 69 | 70 | func BenchmarkLoadTranslations(b *testing.B) { 71 | var backend i18n.Backend 72 | var err error 73 | for i := 0; i < b.N; i++ { 74 | backend = yaml.New("tests", "tests/subdir") 75 | if err = checkTranslations(backend.LoadTranslations()); err != nil { 76 | b.Fatal(err) 77 | } 78 | } 79 | benchmarkResult = err 80 | } 81 | 82 | var benchmarkResult2 error 83 | 84 | func BenchmarkLoadTranslationsWalk(b *testing.B) { 85 | var backend i18n.Backend 86 | var err error 87 | for i := 0; i < b.N; i++ { 88 | backend = yaml.NewWithWalk("tests") 89 | if err = checkTranslations(backend.LoadTranslations()); err != nil { 90 | b.Fatal(err) 91 | } 92 | } 93 | benchmarkResult2 = err 94 | } 95 | 96 | var benchmarkResult3 error 97 | 98 | func BenchmarkLoadTranslationsFilesystem(b *testing.B) { 99 | var backend i18n.Backend 100 | var err error 101 | for i := 0; i < b.N; i++ { 102 | backend = yaml.NewWithFilesystem(http.Dir("./tests")) 103 | if err = checkTranslations(backend.LoadTranslations()); err != nil { 104 | b.Fatal(err) 105 | } 106 | } 107 | benchmarkResult3 = err 108 | } 109 | -------------------------------------------------------------------------------- /controller.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "github.com/qor/admin" 5 | "github.com/qor/qor/utils" 6 | ) 7 | 8 | type i18nController struct { 9 | *I18n 10 | } 11 | 12 | func (controller *i18nController) Index(context *admin.Context) { 13 | context.Execute("index", controller.I18n) 14 | } 15 | 16 | func (controller *i18nController) Update(context *admin.Context) { 17 | form := context.Request.Form 18 | translation := Translation{Key: form.Get("Key"), Locale: form.Get("Locale"), Value: utils.HTMLSanitizer.Sanitize(form.Get("Value"))} 19 | 20 | if err := controller.I18n.SaveTranslation(&translation); err == nil { 21 | context.Writer.Write([]byte("OK")) 22 | } else { 23 | context.Writer.WriteHeader(422) 24 | context.Writer.Write([]byte(err.Error())) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /exchange_actions/exchange_actions.go: -------------------------------------------------------------------------------- 1 | package exchange_actions 2 | 3 | import ( 4 | "encoding/csv" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "runtime/debug" 9 | "sort" 10 | "strings" 11 | "time" 12 | 13 | "github.com/qor/admin" 14 | "github.com/qor/i18n" 15 | "github.com/qor/media/oss" 16 | "github.com/qor/worker" 17 | ) 18 | 19 | type ExportTranslationArgument struct { 20 | Scope string 21 | } 22 | 23 | type ImportTranslationArgument struct { 24 | TranslationsFile oss.OSS 25 | } 26 | 27 | // RegisterExchangeJobs register i18n jobs into worker 28 | func RegisterExchangeJobs(I18n *i18n.I18n, Worker *worker.Worker) { 29 | if I18n.Resource == nil { 30 | debug.PrintStack() 31 | fmt.Println("I18n should be registered into `Admin` before register jobs") 32 | return 33 | } 34 | 35 | Admin := I18n.Resource.GetAdmin() 36 | Admin.RegisterViewPath("github.com/qor/i18n/exchange_actions/views") 37 | 38 | // Export Translations 39 | exportTranslationResource := Admin.NewResource(&ExportTranslationArgument{}) 40 | exportTranslationResource.Meta(&admin.Meta{Name: "Scope", Type: "select_one", Collection: []string{"All", "Backend", "Frontend"}}) 41 | 42 | Worker.RegisterJob(&worker.Job{ 43 | Name: "Export Translations", 44 | Group: "Export/Import Translations From CSV file", 45 | Resource: exportTranslationResource, 46 | Handler: func(arg interface{}, qorJob worker.QorJobInterface) (err error) { 47 | var ( 48 | locales []string 49 | translationKeys []string 50 | translationsMap = map[string]bool{} 51 | filename = fmt.Sprintf("/downloads/translations.%v.csv", time.Now().UnixNano()) 52 | fullFilename = filepath.Join("public", filename) 53 | i18nTranslations = I18n.LoadTranslations() 54 | scope = arg.(*ExportTranslationArgument).Scope 55 | ) 56 | qorJob.AddLog("Exporting translations...") 57 | 58 | // Sort locales 59 | for locale := range i18nTranslations { 60 | locales = append(locales, locale) 61 | } 62 | sort.Strings(locales) 63 | 64 | // Create download file 65 | if _, err = os.Stat(filepath.Dir(fullFilename)); os.IsNotExist(err) { 66 | err = os.MkdirAll(filepath.Dir(fullFilename), os.ModePerm) 67 | } 68 | csvfile, err := os.OpenFile(fullFilename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666) 69 | defer csvfile.Close() 70 | if err != nil { 71 | return err 72 | } 73 | 74 | writer := csv.NewWriter(csvfile) 75 | 76 | // Append Headers 77 | writer.Write(append([]string{"Translation Keys"}, locales...)) 78 | 79 | // Sort translation keys 80 | for _, locale := range locales { 81 | for key := range i18nTranslations[locale] { 82 | translationsMap[key] = true 83 | } 84 | } 85 | 86 | for key := range translationsMap { 87 | translationKeys = append(translationKeys, key) 88 | } 89 | sort.Strings(translationKeys) 90 | 91 | // Write CSV file 92 | var ( 93 | recordCount = len(translationKeys) 94 | perCount = recordCount/20 + 1 95 | processedRecordLogs = []string{} 96 | index = 0 97 | progressCount = 0 98 | ) 99 | for _, translationKey := range translationKeys { 100 | // Filter out translation by scope 101 | index++ 102 | if scope == "Backend" && !strings.HasPrefix(translationKey, "qor_") { 103 | continue 104 | } 105 | if scope == "Frontend" && strings.HasPrefix(translationKey, "qor_") { 106 | continue 107 | } 108 | var translations = []string{translationKey} 109 | for _, locale := range locales { 110 | var value string 111 | if translation := i18nTranslations[locale][translationKey]; translation != nil { 112 | value = translation.Value 113 | } 114 | translations = append(translations, value) 115 | } 116 | writer.Write(translations) 117 | processedRecordLogs = append(processedRecordLogs, fmt.Sprintf("Exported %v\n", strings.Join(translations, ","))) 118 | if index == perCount { 119 | qorJob.AddLog(strings.Join(processedRecordLogs, "")) 120 | processedRecordLogs = []string{} 121 | progressCount++ 122 | qorJob.SetProgress(uint(float32(progressCount) / float32(20) * 100)) 123 | index = 0 124 | } 125 | } 126 | writer.Flush() 127 | 128 | qorJob.SetProgressText(fmt.Sprintf("Download exported translations", filename)) 129 | return 130 | }, 131 | }) 132 | 133 | // Import Translations 134 | 135 | Worker.RegisterJob(&worker.Job{ 136 | Name: "Import Translations", 137 | Group: "Export/Import Translations From CSV file", 138 | Resource: Admin.NewResource(&ImportTranslationArgument{}), 139 | Handler: func(arg interface{}, qorJob worker.QorJobInterface) (err error) { 140 | importTranslationArgument := arg.(*ImportTranslationArgument) 141 | qorJob.AddLog("Importing translations...") 142 | if csvfile, err := os.Open(filepath.Join("public", importTranslationArgument.TranslationsFile.URL())); err == nil { 143 | reader := csv.NewReader(csvfile) 144 | reader.TrimLeadingSpace = true 145 | if records, err := reader.ReadAll(); err == nil { 146 | if len(records) > 1 && len(records[0]) > 1 { 147 | var ( 148 | recordCount = len(records) - 1 149 | perCount = recordCount/20 + 1 150 | processedRecordLogs = []string{} 151 | locales = records[0][1:] 152 | index = 1 153 | ) 154 | for _, values := range records[1:] { 155 | logMsg := "" 156 | for idx, value := range values[1:] { 157 | if value == "" { 158 | if values[0] != "" && locales[idx] != "" { 159 | I18n.DeleteTranslation(&i18n.Translation{ 160 | Key: values[0], 161 | Locale: locales[idx], 162 | }) 163 | logMsg += fmt.Sprintf("%v/%v Deleted %v,%v\n", index, recordCount, locales[idx], values[0]) 164 | } 165 | } else { 166 | I18n.SaveTranslation(&i18n.Translation{ 167 | Key: values[0], 168 | Locale: locales[idx], 169 | Value: value, 170 | }) 171 | logMsg += fmt.Sprintf("%v/%v Imported %v,%v,%v\n", index, recordCount, locales[idx], values[0], value) 172 | } 173 | } 174 | processedRecordLogs = append(processedRecordLogs, logMsg) 175 | if len(processedRecordLogs) == perCount { 176 | qorJob.AddLog(strings.Join(processedRecordLogs, "")) 177 | processedRecordLogs = []string{} 178 | qorJob.SetProgress(uint(float32(index) / float32(recordCount+1) * 100)) 179 | } 180 | index++ 181 | } 182 | qorJob.AddLog(strings.Join(processedRecordLogs, "")) 183 | } 184 | } 185 | qorJob.AddLog("Imported translations") 186 | } 187 | return 188 | }, 189 | }) 190 | } 191 | -------------------------------------------------------------------------------- /exchange_actions/exchange_actions_test.go: -------------------------------------------------------------------------------- 1 | package exchange_actions_test 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "testing" 8 | 9 | "github.com/fatih/color" 10 | "github.com/jinzhu/gorm" 11 | _ "github.com/mattn/go-sqlite3" 12 | "github.com/qor/admin" 13 | "github.com/qor/i18n" 14 | "github.com/qor/i18n/backends/database" 15 | "github.com/qor/i18n/exchange_actions" 16 | "github.com/qor/media" 17 | "github.com/qor/media/oss" 18 | "github.com/qor/qor" 19 | "github.com/qor/qor/test/utils" 20 | "github.com/qor/worker" 21 | ) 22 | 23 | var db *gorm.DB 24 | var Worker *worker.Worker 25 | var I18n *i18n.I18n 26 | 27 | func init() { 28 | db = utils.TestDB() 29 | reset() 30 | } 31 | 32 | func reset() { 33 | db.DropTable(&database.Translation{}) 34 | database.New(db) 35 | Admin := admin.New(&qor.Config{DB: db}) 36 | Worker = worker.New() 37 | Admin.AddResource(Worker) 38 | I18n = i18n.New(database.New(db)) 39 | I18n.SaveTranslation(&i18n.Translation{Key: "qor_admin.title", Value: "title", Locale: "en-US"}) 40 | I18n.SaveTranslation(&i18n.Translation{Key: "qor_admin.subtitle", Value: "subtitle", Locale: "en-US"}) 41 | I18n.SaveTranslation(&i18n.Translation{Key: "qor_admin.description", Value: "description", Locale: "en-US"}) 42 | I18n.SaveTranslation(&i18n.Translation{Key: "header.title", Value: "Header Title", Locale: "en-US"}) 43 | exchange_actions.RegisterExchangeJobs(I18n, Worker) 44 | } 45 | 46 | // Test export translations with scope 47 | type testExportWithScopedCase struct { 48 | Scope string 49 | ExpectExportFile string 50 | } 51 | 52 | func TestExportTranslations(t *testing.T) { 53 | reset() 54 | I18n.SaveTranslation(&i18n.Translation{Key: "header.title", Value: "标题", Locale: "zh-CN"}) 55 | 56 | testCases := []*testExportWithScopedCase{ 57 | &testExportWithScopedCase{Scope: "", ExpectExportFile: "export_all.csv"}, 58 | &testExportWithScopedCase{Scope: "All", ExpectExportFile: "export_all.csv"}, 59 | &testExportWithScopedCase{Scope: "Backend", ExpectExportFile: "export_backend.csv"}, 60 | &testExportWithScopedCase{Scope: "Frontend", ExpectExportFile: "export_frontend.csv"}, 61 | } 62 | 63 | for i, testcase := range testCases { 64 | clearDownloadDir() 65 | for _, job := range Worker.Jobs { 66 | if job.Name == "Export Translations" { 67 | job.Handler(&exchange_actions.ExportTranslationArgument{Scope: testcase.Scope}, job.NewStruct().(worker.QorJobInterface)) 68 | if downloadedFileContent() != loadFixture(testcase.ExpectExportFile) { 69 | t.Errorf(color.RedString(fmt.Sprintf("\nExchange TestCase #%d: Failure (%s)\n", i+1, "export results are incorrect"))) 70 | } else { 71 | color.Green(fmt.Sprintf("Export with scope TestCase #%d: Success\n", i+1)) 72 | } 73 | } 74 | } 75 | } 76 | } 77 | 78 | // Test import translations 79 | type testImportTranslationsCase struct { 80 | ImportFileDesc string 81 | ImportFile string 82 | ExpectZhValues map[string]string 83 | } 84 | 85 | func TestImportTranslations(t *testing.T) { 86 | reset() 87 | testCases := []*testImportTranslationsCase{ 88 | &testImportTranslationsCase{ 89 | ImportFileDesc: "Normal tranlsation file", 90 | ImportFile: "import_1.csv", 91 | ExpectZhValues: map[string]string{"qor_admin.title": "标题", "qor_admin.subtitle": "小标题", "qor_admin.description": "描述", "header.title": "标题"}, 92 | }, 93 | &testImportTranslationsCase{ 94 | ImportFileDesc: "Translation file with missing header.title", 95 | ImportFile: "import_2.csv", 96 | ExpectZhValues: map[string]string{"qor_admin.title": "标题", "qor_admin.subtitle": "小标题", "qor_admin.description": "描述"}, 97 | }, 98 | &testImportTranslationsCase{ 99 | ImportFileDesc: "Translation file with empty column", 100 | ImportFile: "import_3.csv", 101 | ExpectZhValues: map[string]string{"qor_admin.title": "标题", "qor_admin.subtitle": "小标题", "qor_admin.description": "描述", "header.title": "标题"}, 102 | }, 103 | } 104 | 105 | for i, testCase := range testCases { 106 | for _, job := range Worker.Jobs { 107 | if job.Name == "Import Translations" { 108 | job.Handler(&exchange_actions.ImportTranslationArgument{TranslationsFile: oss.OSS{media.Base{Url: "imports/" + testCase.ImportFile}}}, job.NewStruct().(worker.QorJobInterface)) 109 | translations := I18n.LoadTranslations()["zh-CN"] 110 | if len(translations) == 0 { 111 | t.Errorf(color.RedString(fmt.Sprintf("\nImport TestCase #%d: Failure (%s)\n", i+1, "Doesn't have Zh translations"))) 112 | } 113 | for key, translation := range translations { 114 | if testCase.ExpectZhValues[key] != translation.Value { 115 | t.Errorf(color.RedString(fmt.Sprintf("\nImport TestCase #%d: Failure (%s)\n", i+1, "Zh translations not match"))) 116 | } 117 | } 118 | color.Green(fmt.Sprintf("Import TestCase #%d: Success\n", i+1)) 119 | } 120 | } 121 | } 122 | } 123 | 124 | // Helper functions 125 | func clearDownloadDir() { 126 | files, _ := ioutil.ReadDir("./public/downloads") 127 | for _, f := range files { 128 | os.Remove("./public/downloads/" + f.Name()) 129 | } 130 | } 131 | 132 | func downloadedFileContent() string { 133 | files, _ := ioutil.ReadDir("./public/downloads") 134 | for _, f := range files { 135 | if content, err := ioutil.ReadFile("./public/downloads/" + f.Name()); err == nil { 136 | return string(content) 137 | } 138 | } 139 | return "" 140 | } 141 | 142 | func loadFixture(fileName string) string { 143 | if content, err := ioutil.ReadFile("./fixtures/" + fileName); err == nil { 144 | return string(content) 145 | } 146 | return "" 147 | } 148 | -------------------------------------------------------------------------------- /exchange_actions/fixtures/export_all.csv: -------------------------------------------------------------------------------- 1 | Translation Keys,en-US,zh-CN 2 | header.title,Header Title,标题 3 | qor_admin.description,description, 4 | qor_admin.subtitle,subtitle, 5 | qor_admin.title,title, 6 | -------------------------------------------------------------------------------- /exchange_actions/fixtures/export_backend.csv: -------------------------------------------------------------------------------- 1 | Translation Keys,en-US,zh-CN 2 | qor_admin.description,description, 3 | qor_admin.subtitle,subtitle, 4 | qor_admin.title,title, 5 | -------------------------------------------------------------------------------- /exchange_actions/fixtures/export_frontend.csv: -------------------------------------------------------------------------------- 1 | Translation Keys,en-US,zh-CN 2 | header.title,Header Title,标题 3 | -------------------------------------------------------------------------------- /exchange_actions/public/imports/import_1.csv: -------------------------------------------------------------------------------- 1 | Translation Keys,en-US,zh-CN 2 | header.title,Header Title,标题 3 | qor_admin.description,description,描述 4 | qor_admin.subtitle,subtitle,小标题 5 | qor_admin.title,title,标题 6 | -------------------------------------------------------------------------------- /exchange_actions/public/imports/import_2.csv: -------------------------------------------------------------------------------- 1 | Translation Keys,en-US,zh-CN 2 | header.title,Header Title, 3 | qor_admin.description,description,描述 4 | qor_admin.subtitle,subtitle,小标题 5 | qor_admin.title,title,标题 6 | -------------------------------------------------------------------------------- /exchange_actions/public/imports/import_3.csv: -------------------------------------------------------------------------------- 1 | Translation Keys,en-US,zh-CN,, 2 | header.title,Header Title,标题,, 3 | qor_admin.description,description,描述,, 4 | qor_admin.subtitle,subtitle,小标题,, 5 | qor_admin.title,title,标题,, 6 | -------------------------------------------------------------------------------- /exchange_actions/views/themes/i18n/actions/index/exchange.tmpl: -------------------------------------------------------------------------------- 1 |
2 | {{$worker := get_resource "Worker"}} 3 | {{$prefix := .Admin.GetRouter.Prefix}} 4 | 5 | {{$importTranslationURL := (printf "%v/%v/new?job=Import Translations" $prefix $worker.ToParam)}} 6 | 9 | 10 | {{$exportTranslationURL := (printf "%v/%v/new?job=Export Translations" $prefix $worker.ToParam)}} 11 | 14 |
15 | -------------------------------------------------------------------------------- /i18n.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "html/template" 7 | "io/ioutil" 8 | "net/http" 9 | "path/filepath" 10 | "sort" 11 | "strconv" 12 | "strings" 13 | 14 | "github.com/qor/admin" 15 | "github.com/qor/cache" 16 | "github.com/qor/cache/memory" 17 | "github.com/qor/qor" 18 | "github.com/qor/qor/resource" 19 | "github.com/qor/qor/utils" 20 | "github.com/theplant/cldr" 21 | ) 22 | 23 | // Default default locale for i18n 24 | var Default = "en-US" 25 | 26 | // I18n struct that hold all translations 27 | type I18n struct { 28 | Resource *admin.Resource 29 | scope string 30 | value string 31 | Backends []Backend 32 | FallbackLocales map[string][]string 33 | fallbackLocales []string 34 | cacheStore cache.CacheStoreInterface 35 | } 36 | 37 | // ResourceName change display name in qor admin 38 | func (I18n) ResourceName() string { 39 | return "Translation" 40 | } 41 | 42 | // Backend defined methods that needs for translation backend 43 | type Backend interface { 44 | LoadTranslations() []*Translation 45 | SaveTranslation(*Translation) error 46 | FindTranslation(*Translation) Translation 47 | DeleteTranslation(*Translation) error 48 | } 49 | 50 | // Translation is a struct for translations, including Translation Key, Locale, Value 51 | type Translation struct { 52 | Key string 53 | Locale string 54 | Value string 55 | Backend Backend `json:"-"` 56 | } 57 | 58 | // New initialize I18n with backends 59 | func New(backends ...Backend) *I18n { 60 | i18n := &I18n{Backends: backends, cacheStore: memory.New()} 61 | i18n.loadToCacheStore() 62 | return i18n 63 | } 64 | 65 | // SetCacheStore set i18n's cache store 66 | func (i18n *I18n) SetCacheStore(cacheStore cache.CacheStoreInterface) { 67 | i18n.cacheStore = cacheStore 68 | i18n.loadToCacheStore() 69 | } 70 | 71 | func (i18n *I18n) loadToCacheStore() { 72 | backends := i18n.Backends 73 | for i := len(backends) - 1; i >= 0; i-- { 74 | var backend = backends[i] 75 | for _, translation := range backend.LoadTranslations() { 76 | i18n.AddTranslation(translation) 77 | } 78 | } 79 | } 80 | 81 | // LoadTranslations load translations as map `map[locale]map[key]*Translation` 82 | func (i18n *I18n) LoadTranslations() map[string]map[string]*Translation { 83 | var translations = map[string]map[string]*Translation{} 84 | 85 | for i := len(i18n.Backends); i > 0; i-- { 86 | backend := i18n.Backends[i-1] 87 | for _, translation := range backend.LoadTranslations() { 88 | if translations[translation.Locale] == nil { 89 | translations[translation.Locale] = map[string]*Translation{} 90 | } 91 | translations[translation.Locale][translation.Key] = translation 92 | } 93 | } 94 | return translations 95 | } 96 | 97 | // AddTranslation add translation 98 | func (i18n *I18n) AddTranslation(translation *Translation) error { 99 | return i18n.cacheStore.Set(cacheKey(translation.Locale, translation.Key), translation) 100 | } 101 | 102 | // SaveTranslation save translation 103 | func (i18n *I18n) SaveTranslation(translation *Translation) error { 104 | for _, backend := range i18n.Backends { 105 | if backend.SaveTranslation(translation) == nil { 106 | i18n.AddTranslation(translation) 107 | return nil 108 | } 109 | } 110 | 111 | return errors.New("failed to save translation") 112 | } 113 | 114 | // DeleteTranslation delete translation 115 | func (i18n *I18n) DeleteTranslation(translation *Translation) (err error) { 116 | for _, backend := range i18n.Backends { 117 | backend.DeleteTranslation(translation) 118 | } 119 | 120 | return i18n.cacheStore.Delete(cacheKey(translation.Locale, translation.Key)) 121 | } 122 | 123 | // Scope i18n scope 124 | func (i18n *I18n) Scope(scope string) admin.I18n { 125 | return &I18n{cacheStore: i18n.cacheStore, scope: scope, value: i18n.value, Backends: i18n.Backends, Resource: i18n.Resource, FallbackLocales: i18n.FallbackLocales, fallbackLocales: i18n.fallbackLocales} 126 | } 127 | 128 | // Default default value of translation if key is missing 129 | func (i18n *I18n) Default(value string) admin.I18n { 130 | return &I18n{cacheStore: i18n.cacheStore, scope: i18n.scope, value: value, Backends: i18n.Backends, Resource: i18n.Resource, FallbackLocales: i18n.FallbackLocales, fallbackLocales: i18n.fallbackLocales} 131 | } 132 | 133 | // Fallbacks fallback to locale if translation doesn't exist in specified locale 134 | func (i18n *I18n) Fallbacks(locale ...string) admin.I18n { 135 | return &I18n{cacheStore: i18n.cacheStore, scope: i18n.scope, value: i18n.value, Backends: i18n.Backends, Resource: i18n.Resource, FallbackLocales: i18n.FallbackLocales, fallbackLocales: locale} 136 | } 137 | 138 | // T translate with locale, key and arguments 139 | func (i18n *I18n) T(locale, key string, args ...interface{}) template.HTML { 140 | var ( 141 | value = i18n.value 142 | translationKey = key 143 | fallbackLocales = i18n.fallbackLocales 144 | ) 145 | 146 | if locale == "" { 147 | locale = Default 148 | } 149 | 150 | if locales, ok := i18n.FallbackLocales[locale]; ok { 151 | fallbackLocales = append(fallbackLocales, locales...) 152 | } 153 | fallbackLocales = append(fallbackLocales, Default) 154 | 155 | if i18n.scope != "" { 156 | translationKey = strings.Join([]string{i18n.scope, key}, ".") 157 | } 158 | 159 | var translation Translation 160 | if err := i18n.cacheStore.Unmarshal(cacheKey(locale, key), &translation); err != nil || translation.Value == "" { 161 | for _, fallbackLocale := range fallbackLocales { 162 | if err := i18n.cacheStore.Unmarshal(cacheKey(fallbackLocale, key), &translation); err == nil && translation.Value != "" { 163 | break 164 | } 165 | } 166 | 167 | if translation.Value == "" { 168 | // Get default translation if not translated 169 | if err := i18n.cacheStore.Unmarshal(cacheKey(Default, key), &translation); err != nil || translation.Value == "" { 170 | // If not initialized 171 | var defaultBackend Backend 172 | if len(i18n.Backends) > 0 { 173 | defaultBackend = i18n.Backends[0] 174 | } 175 | 176 | translation = Translation{Key: translationKey, Value: value, Locale: locale, Backend: defaultBackend} 177 | if t := defaultBackend.FindTranslation(&translation); t.Value != "" { 178 | translation = t 179 | } else { 180 | i18n.SaveTranslation(&translation) 181 | } 182 | } 183 | } 184 | } 185 | 186 | if translation.Value != "" { 187 | value = translation.Value 188 | } else { 189 | value = key 190 | } 191 | 192 | if str, err := cldr.Parse(locale, value, args...); err == nil { 193 | value = str 194 | } 195 | 196 | return template.HTML(value) 197 | } 198 | 199 | // RenderInlineEditAssets render inline edit html, it is using: http://vitalets.github.io/x-editable/index.html 200 | // You could use Bootstrap or JQuery UI by set isIncludeExtendAssetLib to false and load files by yourself 201 | func RenderInlineEditAssets(isIncludeJQuery bool, isIncludeExtendAssetLib bool) (template.HTML, error) { 202 | for _, gopath := range utils.GOPATH() { 203 | var content string 204 | var hasError bool 205 | 206 | if isIncludeJQuery { 207 | content = `` 208 | } 209 | 210 | if isIncludeExtendAssetLib { 211 | if extendLib, err := ioutil.ReadFile(filepath.Join(gopath, "src/github.com/qor/i18n/views/themes/i18n/inline-edit-libs.tmpl")); err == nil { 212 | content += string(extendLib) 213 | } else { 214 | hasError = true 215 | } 216 | 217 | if css, err := ioutil.ReadFile(filepath.Join(gopath, "src/github.com/qor/i18n/views/themes/i18n/assets/stylesheets/i18n-inline.css")); err == nil { 218 | content += fmt.Sprintf("", string(css)) 219 | } else { 220 | hasError = true 221 | } 222 | 223 | } 224 | 225 | if js, err := ioutil.ReadFile(filepath.Join(gopath, "src/github.com/qor/i18n/views/themes/i18n/assets/javascripts/i18n-inline.js")); err == nil { 226 | content += fmt.Sprintf("", string(js)) 227 | } else { 228 | hasError = true 229 | } 230 | 231 | if !hasError { 232 | return template.HTML(content), nil 233 | } 234 | } 235 | 236 | return template.HTML(""), errors.New("templates not found") 237 | } 238 | 239 | func getLocaleFromContext(context *qor.Context) string { 240 | if locale := utils.GetLocale(context); locale != "" { 241 | return locale 242 | } 243 | 244 | return Default 245 | } 246 | 247 | type availableLocalesInterface interface { 248 | AvailableLocales() []string 249 | } 250 | 251 | type viewableLocalesInterface interface { 252 | ViewableLocales() []string 253 | } 254 | 255 | type editableLocalesInterface interface { 256 | EditableLocales() []string 257 | } 258 | 259 | func getAvailableLocales(req *http.Request, currentUser qor.CurrentUser) []string { 260 | if user, ok := currentUser.(viewableLocalesInterface); ok { 261 | return user.ViewableLocales() 262 | } 263 | 264 | if user, ok := currentUser.(availableLocalesInterface); ok { 265 | return user.AvailableLocales() 266 | } 267 | return []string{Default} 268 | } 269 | 270 | func getEditableLocales(req *http.Request, currentUser qor.CurrentUser) []string { 271 | if user, ok := currentUser.(editableLocalesInterface); ok { 272 | return user.EditableLocales() 273 | } 274 | 275 | if user, ok := currentUser.(availableLocalesInterface); ok { 276 | return user.AvailableLocales() 277 | } 278 | return []string{Default} 279 | } 280 | 281 | // ConfigureQorResource configure qor resource for qor admin 282 | func (i18n *I18n) ConfigureQorResource(res resource.Resourcer) { 283 | if res, ok := res.(*admin.Resource); ok { 284 | i18n.Resource = res 285 | res.UseTheme("i18n") 286 | res.GetAdmin().I18n = i18n 287 | res.SearchAttrs("value") // generate search handler for i18n 288 | 289 | var getPrimaryLocale = func(context *admin.Context) string { 290 | if locale := context.Request.Form.Get("primary_locale"); locale != "" { 291 | return locale 292 | } 293 | if availableLocales := getAvailableLocales(context.Request, context.CurrentUser); len(availableLocales) > 0 { 294 | return availableLocales[0] 295 | } 296 | return "" 297 | } 298 | 299 | var getEditingLocale = func(context *admin.Context) string { 300 | if locale := context.Request.Form.Get("to_locale"); locale != "" { 301 | return locale 302 | } 303 | return getLocaleFromContext(context.Context) 304 | } 305 | 306 | type matchedTranslation struct { 307 | Key string 308 | PrimaryLocale string 309 | PrimaryValue string 310 | EditingLocale string 311 | EditingValue string 312 | } 313 | 314 | res.GetAdmin().RegisterFuncMap("i18n_available_translations", func(context *admin.Context) (results []matchedTranslation) { 315 | var ( 316 | translationsMap = i18n.LoadTranslations() 317 | matchedTranslations = map[string]matchedTranslation{} 318 | keys = []string{} 319 | keyword = strings.ToLower(context.Request.URL.Query().Get("keyword")) 320 | primaryLocale = getPrimaryLocale(context) 321 | editingLocale = getEditingLocale(context) 322 | ) 323 | 324 | var filterTranslations = func(translations map[string]*Translation, isPrimary bool) { 325 | if translations != nil { 326 | for key, translation := range translations { 327 | if (keyword == "") || (strings.Index(strings.ToLower(translation.Key), keyword) != -1 || 328 | strings.Index(strings.ToLower(translation.Value), keyword) != -1) { 329 | if _, ok := matchedTranslations[key]; !ok { 330 | var t = matchedTranslation{ 331 | Key: key, 332 | PrimaryLocale: primaryLocale, 333 | EditingLocale: editingLocale, 334 | EditingValue: translation.Value, 335 | } 336 | 337 | if localeTranslations, ok := translationsMap[primaryLocale]; ok { 338 | if v, ok := localeTranslations[key]; ok { 339 | t.PrimaryValue = v.Value 340 | } 341 | } 342 | 343 | matchedTranslations[key] = t 344 | keys = append(keys, key) 345 | } 346 | } 347 | } 348 | } 349 | } 350 | 351 | filterTranslations(translationsMap[getEditingLocale(context)], false) 352 | if primaryLocale != editingLocale { 353 | filterTranslations(translationsMap[getPrimaryLocale(context)], true) 354 | } 355 | 356 | sort.Strings(keys) 357 | 358 | pagination := context.Searcher.Pagination 359 | pagination.Total = len(keys) 360 | pagination.PerPage, _ = strconv.Atoi(context.Request.URL.Query().Get("per_page")) 361 | pagination.CurrentPage, _ = strconv.Atoi(context.Request.URL.Query().Get("page")) 362 | 363 | if pagination.CurrentPage == 0 { 364 | pagination.CurrentPage = 1 365 | } 366 | 367 | if pagination.PerPage == 0 { 368 | pagination.PerPage = 25 369 | } 370 | 371 | if pagination.CurrentPage > 0 { 372 | pagination.Pages = pagination.Total / pagination.PerPage 373 | } 374 | 375 | context.Searcher.Pagination = pagination 376 | 377 | var paginationKeys []string 378 | if pagination.CurrentPage == -1 { 379 | paginationKeys = keys 380 | } else { 381 | lastIndex := pagination.CurrentPage * pagination.PerPage 382 | if pagination.Total < lastIndex { 383 | lastIndex = pagination.Total 384 | } 385 | 386 | startIndex := (pagination.CurrentPage - 1) * pagination.PerPage 387 | if lastIndex >= startIndex { 388 | paginationKeys = keys[startIndex:lastIndex] 389 | } 390 | } 391 | 392 | for _, key := range paginationKeys { 393 | results = append(results, matchedTranslations[key]) 394 | } 395 | return results 396 | }) 397 | 398 | res.GetAdmin().RegisterFuncMap("i18n_primary_locale", getPrimaryLocale) 399 | 400 | res.GetAdmin().RegisterFuncMap("i18n_editing_locale", getEditingLocale) 401 | 402 | res.GetAdmin().RegisterFuncMap("i18n_viewable_locales", func(context admin.Context) []string { 403 | return getAvailableLocales(context.Request, context.CurrentUser) 404 | }) 405 | 406 | res.GetAdmin().RegisterFuncMap("i18n_editable_locales", func(context admin.Context) []string { 407 | return getEditableLocales(context.Request, context.CurrentUser) 408 | }) 409 | 410 | controller := i18nController{i18n} 411 | router := res.GetAdmin().GetRouter() 412 | router.Get(res.ToParam(), controller.Index, &admin.RouteConfig{Resource: res}) 413 | router.Post(res.ToParam(), controller.Update, &admin.RouteConfig{Resource: res}) 414 | router.Put(res.ToParam(), controller.Update, &admin.RouteConfig{Resource: res}) 415 | 416 | res.GetAdmin().RegisterViewPath("github.com/qor/i18n/views") 417 | } 418 | } 419 | 420 | func cacheKey(strs ...string) string { 421 | return strings.Join(strs, "/") 422 | } 423 | -------------------------------------------------------------------------------- /i18n_test.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | type backend struct{} 9 | 10 | func (b *backend) LoadTranslations() (translations []*Translation) { return translations } 11 | func (b *backend) SaveTranslation(t *Translation) error { return nil } 12 | func (b *backend) DeleteTranslation(t *Translation) error { return nil } 13 | 14 | const BIGNUM = 10000 15 | 16 | // run TestConcurrent* tests with -race flag would be better 17 | 18 | func TestConcurrentReadWrite(t *testing.T) { 19 | i18n := New(&backend{}) 20 | go func() { 21 | for i := 0; i < BIGNUM; i++ { 22 | i18n.AddTranslation(&Translation{Key: fmt.Sprintf("xx-%d", i), Locale: "xx", Value: fmt.Sprint(i)}) 23 | } 24 | }() 25 | for i := 0; i < BIGNUM; i++ { 26 | i18n.T("xx", fmt.Sprintf("xx-%d", i)) 27 | } 28 | } 29 | 30 | func TestConcurrentDeleteWrite(t *testing.T) { 31 | i18n := New(&backend{}) 32 | go func() { 33 | for i := 0; i < BIGNUM; i++ { 34 | i18n.AddTranslation(&Translation{Key: fmt.Sprintf("xx-%d", i), Locale: "xx", Value: fmt.Sprint(i)}) 35 | } 36 | }() 37 | for i := 0; i < BIGNUM; i++ { 38 | i18n.DeleteTranslation(&Translation{Key: fmt.Sprintf("xx-%d", i), Locale: "xx", Value: fmt.Sprint(i)}) 39 | } 40 | } 41 | 42 | func TestFallbackLocale(t *testing.T) { 43 | i18n := New(&backend{}) 44 | i18n.AddTranslation(&Translation{Key: "hello-world", Locale: "en-AU", Value: "Hello World"}) 45 | 46 | if i18n.Fallbacks("en-AU").T("en-UK", "hello-world") != "Hello World" { 47 | t.Errorf("Should fallback en-UK to en-US") 48 | } 49 | 50 | if i18n.T("en-DE", "hello-world") != "hello-world" { 51 | t.Errorf("Haven't setup any fallback") 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /inline_edit/inline_edit.go: -------------------------------------------------------------------------------- 1 | package inline_edit 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | 7 | "github.com/qor/admin" 8 | "github.com/qor/i18n" 9 | ) 10 | 11 | func init() { 12 | admin.RegisterViewPath("github.com/qor/i18n/inline_edit/views") 13 | } 14 | 15 | // FuncMap generate func map for inline edit 16 | func FuncMap(I18n *i18n.I18n, locale string, enableInlineEdit bool) template.FuncMap { 17 | return template.FuncMap{ 18 | "t": InlineEdit(I18n, locale, enableInlineEdit), 19 | } 20 | } 21 | 22 | // InlineEdit enable inline edit 23 | func InlineEdit(I18n *i18n.I18n, locale string, isInline bool) func(string, ...interface{}) template.HTML { 24 | return func(key string, args ...interface{}) template.HTML { 25 | // Get Translation Value 26 | var value template.HTML 27 | var defaultValue string 28 | if len(args) > 0 { 29 | if args[0] == nil { 30 | defaultValue = key 31 | } else { 32 | defaultValue = fmt.Sprint(args[0]) 33 | } 34 | value = I18n.Default(defaultValue).T(locale, key, args[1:]...) 35 | } else { 36 | value = I18n.T(locale, key) 37 | } 38 | 39 | // Append inline-edit script/tag 40 | if isInline { 41 | var editType string 42 | if len(value) > 25 { 43 | editType = "data-type=\"textarea\"" 44 | } 45 | prefix := I18n.Resource.GetAdmin().GetRouter().Prefix 46 | assetsTag := fmt.Sprintf("", prefix, prefix) 47 | return template.HTML(fmt.Sprintf("%s%s", assetsTag, editType, locale, key, string(value))) 48 | } 49 | return value 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /inline_edit/views/themes/i18n/assets/javascripts/i18n-checker.js: -------------------------------------------------------------------------------- 1 | if (!window.loadedI18nAsset) { 2 | window.loadjscssfile = function (filename, filetype) { 3 | var fileref; 4 | if (filetype == "js"){ 5 | fileref = document.createElement('script'); 6 | fileref.setAttribute("type", "text/javascript"); 7 | fileref.setAttribute("src", filename); 8 | } else if (filetype == "css"){ 9 | fileref = document.createElement("link"); 10 | fileref.setAttribute("rel", "stylesheet"); 11 | fileref.setAttribute("type", "text/css"); 12 | fileref.setAttribute("href", filename); 13 | } 14 | if (typeof fileref != "undefined") 15 | document.getElementsByTagName("head")[0].appendChild(fileref); 16 | }; 17 | 18 | window.loadedI18nAsset = true; 19 | var prefix = document.currentScript.getAttribute("data-prefix"); 20 | window.jQuery || loadjscssfile(prefix + "/assets/javascripts/vendors/jquery.min.js", "js"); 21 | loadjscssfile(prefix + "/assets/javascripts/i18n-inline.js?theme=i18n", "js"); 22 | loadjscssfile(prefix + "/assets/stylesheets/i18n-inline.css?theme=i18n", "css"); 23 | } 24 | -------------------------------------------------------------------------------- /inline_edit/views/themes/i18n/assets/javascripts/i18n-inline.js: -------------------------------------------------------------------------------- 1 | !function(t){function i(){t.each(e,function(){this.refresh(!0)})}var e=[],s=/^url\(["']?([^"'\)]*)["']?\);?$/i,n=/\.png$/i,o=!!window.createPopup&&"undefined"==document.documentElement.currentStyle.minWidth;t(window).resize(i),t.Poshytip=function(i,e){this.$elm=t(i),this.opts=t.extend({},t.fn.poshytip.defaults,e),this.$tip=t(['
','
','
',"
"].join("")).appendTo(document.body),this.$arrow=this.$tip.find("div.tip-arrow"),this.$inner=this.$tip.find("div.tip-inner"),this.disabled=!1,this.content=null,this.init()},t.Poshytip.prototype={init:function(){e.push(this);var i=this.$elm.attr("title");if(this.$elm.data("title.poshytip",void 0!==i?i:null).data("poshytip",this),"none"!=this.opts.showOn)switch(this.$elm.bind({"mouseenter.poshytip":t.proxy(this.mouseenter,this),"mouseleave.poshytip":t.proxy(this.mouseleave,this)}),this.opts.showOn){case"hover":"cursor"==this.opts.alignTo&&this.$elm.bind("mousemove.poshytip",t.proxy(this.mousemove,this)),this.opts.allowTipHover&&this.$tip.hover(t.proxy(this.clearTimeouts,this),t.proxy(this.mouseleave,this));break;case"focus":this.$elm.bind({"focus.poshytip":t.proxy(this.showDelayed,this),"blur.poshytip":t.proxy(this.hideDelayed,this)})}},mouseenter:function(t){return this.disabled?!0:(this.$elm.attr("title",""),"focus"==this.opts.showOn?!0:void this.showDelayed())},mouseleave:function(t){if(this.disabled||this.asyncAnimating&&(this.$tip[0]===t.relatedTarget||jQuery.contains(this.$tip[0],t.relatedTarget)))return!0;if(!this.$tip.data("active")){var i=this.$elm.data("title.poshytip");null!==i&&this.$elm.attr("title",i)}return"focus"==this.opts.showOn?!0:void this.hideDelayed()},mousemove:function(t){return this.disabled?!0:(this.eventX=t.pageX,this.eventY=t.pageY,void(this.opts.followCursor&&this.$tip.data("active")&&(this.calcPos(),this.$tip.css({left:this.pos.l,top:this.pos.t}),this.pos.arrow&&(this.$arrow[0].className="tip-arrow tip-arrow-"+this.pos.arrow))))},show:function(){this.disabled||this.$tip.data("active")||(this.reset(),this.update(),this.content&&(this.display(),this.opts.timeOnScreen&&this.hideDelayed(this.opts.timeOnScreen)))},showDelayed:function(i){this.clearTimeouts(),this.showTimeout=setTimeout(t.proxy(this.show,this),"number"==typeof i?i:this.opts.showTimeout)},hide:function(){!this.disabled&&this.$tip.data("active")&&this.display(!0)},hideDelayed:function(i){this.clearTimeouts(),this.hideTimeout=setTimeout(t.proxy(this.hide,this),"number"==typeof i?i:this.opts.hideTimeout)},reset:function(){this.$tip.queue([]).detach().css("visibility","hidden").data("active",!1),this.$inner.find("*").poshytip("hide"),this.opts.fade&&this.$tip.css("opacity",this.opacity),this.$arrow[0].className="tip-arrow tip-arrow-top tip-arrow-right tip-arrow-bottom tip-arrow-left",this.asyncAnimating=!1},update:function(t,i){if(!this.disabled){var e=void 0!==t;if(e){if(i||(this.opts.content=t),!this.$tip.data("active"))return}else t=this.opts.content;var s=this,n="function"==typeof t?t.call(this.$elm[0],function(t){s.update(t)}):"[title]"==t?this.$elm.data("title.poshytip"):t;this.content!==n&&(this.$inner.empty().append(n),this.content=n),this.refresh(e)}},refresh:function(i){if(!this.disabled){if(i){if(!this.$tip.data("active"))return;var e={left:this.$tip.css("left"),top:this.$tip.css("top")}}this.$tip.css({left:0,top:0}).appendTo(document.body),void 0===this.opacity&&(this.opacity=this.$tip.css("opacity"));var a=this.$tip.css("background-image").match(s),r=this.$arrow.css("background-image").match(s);if(a){var l=n.test(a[1]);o&&l?(this.$tip.css("background-image","none"),this.$inner.css({margin:0,border:0,padding:0}),a=l=!1):this.$tip.prepend('
').css({border:0,padding:0,"background-image":"none","background-color":"transparent"}).find(".tip-bg-image").css("background-image",'url("'+a[1]+'")').end().find("td").eq(3).append(this.$inner),l&&!t.support.opacity&&(this.opts.fade=!1)}r&&!t.support.opacity&&(o&&n.test(r[1])&&(r=!1,this.$arrow.css("background-image","none")),this.opts.fade=!1);var h=this.$tip.find("> table.tip-table");if(o){this.$tip[0].style.width="",h.width("auto").find("td").eq(3).width("auto");var p=this.$tip.width(),u=parseInt(this.$tip.css("min-width")),d=parseInt(this.$tip.css("max-width"));!isNaN(u)&&u>p?p=u:!isNaN(d)&&p>d&&(p=d),this.$tip.add(h).width(p).eq(0).find("td").eq(3).width("100%")}else h[0]&&h.width("auto").find("td").eq(3).width("auto").end().end().width(document.defaultView&&document.defaultView.getComputedStyle&&parseFloat(document.defaultView.getComputedStyle(this.$tip[0],null).width)||this.$tip.width()).find("td").eq(3).width("100%");if(this.tipOuterW=this.$tip.outerWidth(),this.tipOuterH=this.$tip.outerHeight(),this.calcPos(),r&&this.pos.arrow&&(this.$arrow[0].className="tip-arrow tip-arrow-"+this.pos.arrow,this.$arrow.css("visibility","inherit")),i&&this.opts.refreshAniDuration){this.asyncAnimating=!0;var c=this;this.$tip.css(e).animate({left:this.pos.l,top:this.pos.t},this.opts.refreshAniDuration,function(){c.asyncAnimating=!1})}else this.$tip.css({left:this.pos.l,top:this.pos.t})}},display:function(i){var e=this.$tip.data("active");if(!(e&&!i||!e&&i)){if(this.$tip.stop(),(this.opts.slide&&this.pos.arrow||this.opts.fade)&&(i&&this.opts.hideAniDuration||!i&&this.opts.showAniDuration)){var s={},n={};if(this.opts.slide&&this.pos.arrow){var o,a;"bottom"==this.pos.arrow||"top"==this.pos.arrow?(o="top",a="bottom"):(o="left",a="right");var r=parseInt(this.$tip.css(o));s[o]=r+(i?0:this.pos.arrow==a?-this.opts.slideOffset:this.opts.slideOffset),n[o]=r+(i?this.pos.arrow==a?this.opts.slideOffset:-this.opts.slideOffset:0)+"px"}this.opts.fade&&(s.opacity=i?this.$tip.css("opacity"):0,n.opacity=i?0:this.opacity),this.$tip.css(s).animate(n,this.opts[i?"hideAniDuration":"showAniDuration"])}if(i?this.$tip.queue(t.proxy(this.reset,this)):this.$tip.css("visibility","inherit"),e){var l=this.$elm.data("title.poshytip");null!==l&&this.$elm.attr("title",l)}this.$tip.data("active",!e)}},disable:function(){this.reset(),this.disabled=!0},enable:function(){this.disabled=!1},destroy:function(){this.reset(),this.$tip.remove(),delete this.$tip,this.content=null,this.$elm.unbind(".poshytip").removeData("title.poshytip").removeData("poshytip"),e.splice(t.inArray(this,e),1)},clearTimeouts:function(){this.showTimeout&&(clearTimeout(this.showTimeout),this.showTimeout=0),this.hideTimeout&&(clearTimeout(this.hideTimeout),this.hideTimeout=0)},calcPos:function(){var i,e,s,n,o,a,r={l:0,t:0,arrow:""},l=t(window),h={l:l.scrollLeft(),t:l.scrollTop(),w:l.width(),h:l.height()};if("cursor"==this.opts.alignTo)i=e=s=this.eventX,n=o=a=this.eventY;else{var p=this.$elm.offset(),u={l:p.left,t:p.top,w:this.$elm.outerWidth(),h:this.$elm.outerHeight()};i=u.l+("inner-right"!=this.opts.alignX?0:u.w),e=i+Math.floor(u.w/2),s=i+("inner-left"!=this.opts.alignX?u.w:0),n=u.t+("inner-bottom"!=this.opts.alignY?0:u.h),o=n+Math.floor(u.h/2),a=n+("inner-top"!=this.opts.alignY?u.h:0)}switch(this.opts.alignX){case"right":case"inner-left":r.l=s+this.opts.offsetX,this.opts.keepInViewport&&r.l+this.tipOuterW>h.l+h.w&&(r.l=h.l+h.w-this.tipOuterW),"right"!=this.opts.alignX&&"center"!=this.opts.alignY||(r.arrow="left");break;case"center":r.l=e-Math.floor(this.tipOuterW/2),this.opts.keepInViewport&&(r.l+this.tipOuterW>h.l+h.w?r.l=h.l+h.w-this.tipOuterW:r.lh.t+h.h&&(r.t=n-this.tipOuterH-this.opts.offsetY,"top"==r.arrow&&(r.arrow="bottom"));break;case"center":r.t=o-Math.floor(this.tipOuterH/2),this.opts.keepInViewport&&(r.t+this.tipOuterH>h.t+h.h?r.t=h.t+h.h-this.tipOuterH:r.t',"div.",n.className,"{visibility:hidden;position:absolute;top:0;left:0;}","div.",n.className," table.tip-table, div.",n.className," table.tip-table td{margin:0;font-family:inherit;font-size:inherit;font-weight:inherit;font-style:inherit;font-variant:inherit;vertical-align:middle;}","div.",n.className," td.tip-bg-image span{display:block;font:1px/1px sans-serif;height:",n.bgImageFrameSize,"px;width:",n.bgImageFrameSize,"px;overflow:hidden;}","div.",n.className," td.tip-right{background-position:100% 0;}","div.",n.className," td.tip-bottom{background-position:100% 100%;}","div.",n.className," td.tip-left{background-position:0 100%;}","div.",n.className," div.tip-inner{background-position:-",n.bgImageFrameSize,"px -",n.bgImageFrameSize,"px;}","div.",n.className," div.tip-arrow{visibility:hidden;position:absolute;overflow:hidden;font:1px/1px sans-serif;}",""].join("")).appendTo("head"),n.liveEvents&&"none"!=n.showOn){var o,a=t.extend({},n,{liveEvents:!1});switch(n.showOn){case"hover":o=function(){var i=t(this);i.data("poshytip")||i.poshytip(a).poshytip("mouseenter")},this.live?this.live("mouseenter.poshytip",o):t(document).delegate(this.selector,"mouseenter.poshytip",o);break;case"focus":o=function(){var i=t(this);i.data("poshytip")||i.poshytip(a).poshytip("showDelayed")},this.live?this.live("focus.poshytip",o):t(document).delegate(this.selector,"focus.poshytip",o)}return this}return this.each(function(){new t.Poshytip(this,n)})},t.fn.poshytip.defaults={content:"[title]",className:"tip-yellow",bgImageFrameSize:10,showTimeout:500,hideTimeout:100,timeOnScreen:0,showOn:"hover",liveEvents:!1,alignTo:"cursor",alignX:"right",alignY:"top",offsetX:-22,offsetY:18,keepInViewport:!0,allowTipHover:!0,followCursor:!1,fade:!0,slide:!0,slideOffset:8,showAniDuration:300,hideAniDuration:300,refreshAniDuration:200}}(jQuery),function(t){"use strict";var i=function(i,e){this.options=t.extend({},t.fn.editableform.defaults,e),this.$div=t(i),this.options.scope||(this.options.scope=this)};i.prototype={constructor:i,initInput:function(){this.input=this.options.input,this.value=this.input.str2value(this.options.value),this.input.prerender()},initTemplate:function(){this.$form=t(t.fn.editableform.template)},initButtons:function(){var i=this.$form.find(".editable-buttons");i.append(t.fn.editableform.buttons),"bottom"===this.options.showbuttons&&i.addClass("editable-buttons-bottom")},render:function(){this.$loading=t(t.fn.editableform.loading),this.$div.empty().append(this.$loading),this.initTemplate(),this.options.showbuttons?this.initButtons():this.$form.find(".editable-buttons").remove(),this.showLoading(),this.isSaving=!1,this.$div.triggerHandler("rendering"),this.initInput(),this.$form.find("div.editable-input").append(this.input.$tpl),this.$div.append(this.$form),t.when(this.input.render()).then(t.proxy(function(){if(this.options.showbuttons||this.input.autosubmit(),this.$form.find(".editable-cancel").click(t.proxy(this.cancel,this)),this.input.error)this.error(this.input.error),this.$form.find(".editable-submit").attr("disabled",!0),this.input.$input.attr("disabled",!0),this.$form.submit(function(t){t.preventDefault()});else{this.error(!1),this.input.$input.removeAttr("disabled"),this.$form.find(".editable-submit").removeAttr("disabled");var i=null===this.value||void 0===this.value||""===this.value?this.options.defaultValue:this.value;this.input.value2input(i),this.$form.submit(t.proxy(this.submit,this))}this.$div.triggerHandler("rendered"),this.showForm(),this.input.postrender&&this.input.postrender()},this))},cancel:function(){this.$div.triggerHandler("cancel")},showLoading:function(){var t,i;this.$form?(t=this.$form.outerWidth(),i=this.$form.outerHeight(),t&&this.$loading.width(t),i&&this.$loading.height(i),this.$form.hide()):(t=this.$loading.parent().width(),t&&this.$loading.width(t)),this.$loading.show()},showForm:function(t){this.$loading.hide(),this.$form.show(),t!==!1&&this.input.activate(),this.$div.triggerHandler("show")},error:function(i){var e,s=this.$form.find(".control-group"),n=this.$form.find(".editable-error-block");if(i===!1)s.removeClass(t.fn.editableform.errorGroupClass),n.removeClass(t.fn.editableform.errorBlockClass).empty().hide();else{if(i){e=i.split("\n");for(var o=0;o").text(e[o]).html();i=e.join("
")}s.addClass(t.fn.editableform.errorGroupClass),n.addClass(t.fn.editableform.errorBlockClass).html(i).show()}},submit:function(i){i.stopPropagation(),i.preventDefault();var e,s=this.input.input2value();if(e=this.validate(s))return this.error(e),void this.showForm();if(!this.options.savenochange&&this.input.value2str(s)==this.input.value2str(this.value))return void this.$div.triggerHandler("nochange");var n=this.input.value2submit(s);this.isSaving=!0,t.when(this.save(n)).done(t.proxy(function(t){this.isSaving=!1;var i="function"==typeof this.options.success?this.options.success.call(this.options.scope,t,s):null;return i===!1?(this.error(!1),void this.showForm(!1)):"string"==typeof i?(this.error(i),void this.showForm()):(i&&"object"==typeof i&&i.hasOwnProperty("newValue")&&(s=i.newValue),this.error(!1),this.value=s,void this.$div.triggerHandler("save",{newValue:s,submitValue:n,response:t}))},this)).fail(t.proxy(function(t){this.isSaving=!1;var i;i="function"==typeof this.options.error?this.options.error.call(this.options.scope,t,s):"string"==typeof t?t:t.responseText||t.statusText||"Unknown error!",this.error(i),this.showForm()},this))},save:function(i){this.options.pk=t.fn.editableutils.tryParseJson(this.options.pk,!0);var e,s="function"==typeof this.options.pk?this.options.pk.call(this.options.scope):this.options.pk,n=!!("function"==typeof this.options.url||this.options.url&&("always"===this.options.send||"auto"===this.options.send&&null!==s&&void 0!==s));return n?(this.showLoading(),e={name:this.options.name||"",value:i,pk:s},"function"==typeof this.options.params?e=this.options.params.call(this.options.scope,e):(this.options.params=t.fn.editableutils.tryParseJson(this.options.params,!0),t.extend(e,this.options.params)),"function"==typeof this.options.url?this.options.url.call(this.options.scope,e):t.ajax(t.extend({url:this.options.url,data:e,type:"POST"},this.options.ajaxOptions))):void 0},validate:function(t){return void 0===t&&(t=this.value),"function"==typeof this.options.validate?this.options.validate.call(this.options.scope,t):void 0},option:function(t,i){t in this.options&&(this.options[t]=i),"value"===t&&this.setValue(i)},setValue:function(t,i){i?this.value=this.input.str2value(t):this.value=t,this.$form&&this.$form.is(":visible")&&this.input.value2input(this.value)}},t.fn.editableform=function(e){var s=arguments;return this.each(function(){var n=t(this),o=n.data("editableform"),a="object"==typeof e&&e;o||n.data("editableform",o=new i(this,a)),"string"==typeof e&&o[e].apply(o,Array.prototype.slice.call(s,1))})},t.fn.editableform.Constructor=i,t.fn.editableform.defaults={type:"text",url:null,params:null,name:null,pk:null,value:null,defaultValue:null,send:"auto",validate:null,success:null,error:null,ajaxOptions:null,showbuttons:!0,scope:null,savenochange:!1},t.fn.editableform.template='
',t.fn.editableform.loading='
',t.fn.editableform.buttons='',t.fn.editableform.errorGroupClass=null,t.fn.editableform.errorBlockClass="editable-error",t.fn.editableform.engine="jquery"}(window.jQuery),function(t){"use strict";t.fn.editableutils={inherit:function(t,i){var e=function(){};e.prototype=i.prototype,t.prototype=new e,t.prototype.constructor=t,t.superclass=i.prototype},setCursorPosition:function(t,i){if(t.setSelectionRange)t.setSelectionRange(i,i);else if(t.createTextRange){var e=t.createTextRange();e.collapse(!0),e.moveEnd("character",i),e.moveStart("character",i),e.select()}},tryParseJson:function(t,i){if("string"==typeof t&&t.length&&t.match(/^[\{\[].*[\}\]]$/))if(i)try{t=new Function("return "+t)()}catch(e){}finally{return t}else t=new Function("return "+t)();return t},sliceObj:function(i,e,s){var n,o,a={};if(!t.isArray(e)||!e.length)return a;for(var r=0;r").text(i).html()},itemsByValue:function(i,e,s){if(!e||null===i)return[];if("function"!=typeof s){var n=s||"value";s=function(t){return t[n]}}var o=t.isArray(i),a=[],r=this;return t.each(e,function(e,n){if(n.children)a=a.concat(r.itemsByValue(i,n.children,s));else if(o)t.grep(i,function(t){return t==(n&&"object"==typeof n?s(n):n)}).length&&a.push(n);else{var l=n&&"object"==typeof n?s(n):n;i==l&&a.push(n)}}),a},createInput:function(i){var e,s,n,o=i.type;return"date"===o&&("inline"===i.mode?t.fn.editabletypes.datefield?o="datefield":t.fn.editabletypes.dateuifield&&(o="dateuifield"):t.fn.editabletypes.date?o="date":t.fn.editabletypes.dateui&&(o="dateui"),"date"!==o||t.fn.editabletypes.date||(o="combodate")),"datetime"===o&&"inline"===i.mode&&(o="datetimefield"),"wysihtml5"!==o||t.fn.editabletypes[o]||(o="textarea"),"function"==typeof t.fn.editabletypes[o]?(e=t.fn.editabletypes[o],s=this.sliceObj(i,this.objectKeys(e.defaults)),n=new e(s)):(t.error("Unknown type: "+o),!1)},supportsTransitions:function(){var t=document.body||document.documentElement,i=t.style,e="transition",s=["Moz","Webkit","Khtml","O","ms"];if("string"==typeof i[e])return!0;e=e.charAt(0).toUpperCase()+e.substr(1);for(var n=0;n"),this.tip().is(this.innerCss)?this.tip().append(this.$form):this.tip().find(this.innerCss).append(this.$form),this.renderForm()},hide:function(t){if(this.tip()&&this.tip().is(":visible")&&this.$element.hasClass("editable-open")){if(this.$form.data("editableform").isSaving)return void(this.delayedHide={reason:t});this.delayedHide=!1,this.$element.removeClass("editable-open"),this.innerHide(),this.$element.triggerHandler("hidden",t||"manual")}},innerShow:function(){},innerHide:function(){},toggle:function(t){this.container()&&this.tip()&&this.tip().is(":visible")?this.hide():this.show(t)},setPosition:function(){},save:function(t,i){this.$element.triggerHandler("save",i),this.hide("save")},option:function(t,i){this.options[t]=i,t in this.containerOptions?(this.containerOptions[t]=i,this.setContainerOption(t,i)):(this.formOptions[t]=i,this.$form&&this.$form.editableform("option",t,i))},setContainerOption:function(t,i){this.call("option",t,i)},destroy:function(){this.hide(),this.innerDestroy(),this.$element.off("destroyed"),this.$element.removeData("editableContainer")},innerDestroy:function(){},closeOthers:function(i){t(".editable-open").each(function(e,s){if(s!==i&&!t(s).find(i).length){var n=t(s),o=n.data("editableContainer");o&&("cancel"===o.options.onblur?n.data("editableContainer").hide("onblur"):"submit"===o.options.onblur&&n.data("editableContainer").tip().find("form").submit())}})},activate:function(){this.tip&&this.tip().is(":visible")&&this.$form&&this.$form.data("editableform").input.activate()}},t.fn.editableContainer=function(s){var n=arguments;return this.each(function(){var o=t(this),a="editableContainer",r=o.data(a),l="object"==typeof s&&s,h="inline"===l.mode?e:i;r||o.data(a,r=new h(this,l)),"string"==typeof s&&r[s].apply(r,Array.prototype.slice.call(n,1))})},t.fn.editableContainer.Popup=i,t.fn.editableContainer.Inline=e,t.fn.editableContainer.defaults={value:null,placement:"top",autohide:!0,onblur:"cancel",anim:!1,mode:"popup"},jQuery.event.special.destroyed={remove:function(t){t.handler&&t.handler()}}}(window.jQuery),function(t){"use strict";t.extend(t.fn.editableContainer.Inline.prototype,t.fn.editableContainer.Popup.prototype,{containerName:"editableform",innerCss:".editable-inline",containerClass:"editable-container editable-inline",initContainer:function(){this.$tip=t(""),this.options.anim||(this.options.anim=0)},splitOptions:function(){this.containerOptions={},this.formOptions=this.options},tip:function(){return this.$tip},innerShow:function(){this.$element.hide(),this.tip().insertAfter(this.$element).show()},innerHide:function(){this.$tip.hide(this.options.anim,t.proxy(function(){this.$element.show(),this.innerDestroy()},this))},innerDestroy:function(){this.tip()&&this.tip().empty().remove()}})}(window.jQuery),function(t){"use strict";var i=function(i,e){this.$element=t(i),this.options=t.extend({},t.fn.editable.defaults,e,t.fn.editableutils.getConfigData(this.$element)),this.options.selector?this.initLive():this.init(),this.options.highlight&&!t.fn.editableutils.supportsTransitions()&&(this.options.highlight=!1)};i.prototype={constructor:i,init:function(){var i,e=!1;if(this.options.name=this.options.name||this.$element.attr("id"),this.options.scope=this.$element[0],this.input=t.fn.editableutils.createInput(this.options),this.input){switch(void 0===this.options.value||null===this.options.value?(this.value=t.trim(this.$element.html()),e=!0):(this.options.value=t.fn.editableutils.tryParseJson(this.options.value,!0),"string"==typeof this.options.value?this.value=this.input.str2value(this.options.value):this.value=this.options.value),this.$element.addClass("editable"),"textarea"===this.input.type&&this.$element.addClass("editable-pre-wrapped"),"manual"!==this.options.toggle?(this.$element.addClass("editable-click"),this.$element.on(this.options.toggle+".editable",t.proxy(function(t){if(this.options.disabled||t.preventDefault(),"mouseenter"===this.options.toggle)this.show();else{var i="click"!==this.options.toggle;this.toggle(i)}},this))):this.$element.attr("tabindex",-1),"function"==typeof this.options.display&&(this.options.autotext="always"),this.options.autotext){case"always":i=!0;break;case"auto":i=!t.trim(this.$element.text()).length&&null!==this.value&&void 0!==this.value&&!e;break;default:i=!1}t.when(i?this.render():!0).then(t.proxy(function(){this.options.disabled?this.disable():this.enable(),this.$element.triggerHandler("init",this)},this))}},initLive:function(){var i=this.options.selector;this.options.selector=!1,this.options.autotext="never",this.$element.on(this.options.toggle+".editable",i,t.proxy(function(i){var e=t(i.target);e.data("editable")||(e.hasClass(this.options.emptyclass)&&e.empty(),e.editable(this.options).trigger(i))},this))},render:function(t){return this.options.display!==!1?this.input.value2htmlFinal?this.input.value2html(this.value,this.$element[0],this.options.display,t):"function"==typeof this.options.display?this.options.display.call(this.$element[0],this.value,t):this.input.value2html(this.value,this.$element[0]):void 0},enable:function(){this.options.disabled=!1,this.$element.removeClass("editable-disabled"),this.handleEmpty(this.isEmpty),"manual"!==this.options.toggle&&"-1"===this.$element.attr("tabindex")&&this.$element.removeAttr("tabindex")},disable:function(){this.options.disabled=!0,this.hide(),this.$element.addClass("editable-disabled"),this.handleEmpty(this.isEmpty),this.$element.attr("tabindex",-1)},toggleDisabled:function(){this.options.disabled?this.enable():this.disable()},option:function(i,e){return i&&"object"==typeof i?void t.each(i,t.proxy(function(i,e){this.option(t.trim(i),e)},this)):(this.options[i]=e,"disabled"===i?e?this.disable():this.enable():("value"===i&&this.setValue(e),this.container&&this.container.option(i,e),void(this.input.option&&this.input.option(i,e))))},handleEmpty:function(i){this.options.display!==!1&&(void 0!==i?this.isEmpty=i:"function"==typeof this.input.isEmpty?this.isEmpty=this.input.isEmpty(this.$element):this.isEmpty=""===t.trim(this.$element.html()),this.options.disabled?this.isEmpty&&(this.$element.empty(),this.options.emptyclass&&this.$element.removeClass(this.options.emptyclass)):this.isEmpty?(this.$element.html(this.options.emptytext),this.options.emptyclass&&this.$element.addClass(this.options.emptyclass)):this.options.emptyclass&&this.$element.removeClass(this.options.emptyclass))},show:function(i){if(!this.options.disabled){if(this.container){if(this.container.tip().is(":visible"))return}else{var e=t.extend({},this.options,{value:this.value,input:this.input});this.$element.editableContainer(e),this.$element.on("save.internal",t.proxy(this.save,this)),this.container=this.$element.data("editableContainer")}this.container.show(i)}},hide:function(){this.container&&this.container.hide()},toggle:function(t){this.container&&this.container.tip().is(":visible")?this.hide():this.show(t)},save:function(t,i){if(this.options.unsavedclass){var e=!1;e=e||"function"==typeof this.options.url,e=e||this.options.display===!1,e=e||void 0!==i.response,e=e||this.options.savenochange&&this.input.value2str(this.value)!==this.input.value2str(i.newValue),e?this.$element.removeClass(this.options.unsavedclass):this.$element.addClass(this.options.unsavedclass)}if(this.options.highlight){var s=this.$element,n=s.css("background-color");s.css("background-color",this.options.highlight),setTimeout(function(){"transparent"===n&&(n=""),s.css("background-color",n),s.addClass("editable-bg-transition"),setTimeout(function(){s.removeClass("editable-bg-transition")},1700)},10)}this.setValue(i.newValue,!1,i.response)},validate:function(){return"function"==typeof this.options.validate?this.options.validate.call(this,this.value):void 0},setValue:function(i,e,s){e?this.value=this.input.str2value(i):this.value=i,this.container&&this.container.option("value",this.value),t.when(this.render(s)).then(t.proxy(function(){this.handleEmpty()},this))},activate:function(){this.container&&this.container.activate()},destroy:function(){this.disable(),this.container&&this.container.destroy(),this.input.destroy(),"manual"!==this.options.toggle&&(this.$element.removeClass("editable-click"),this.$element.off(this.options.toggle+".editable")),this.$element.off("save.internal"),this.$element.removeClass("editable editable-open editable-disabled"),this.$element.removeData("editable")}},t.fn.editable=function(e){var s={},n=arguments,o="editable";switch(e){case"validate":return this.each(function(){var i,e=t(this),n=e.data(o);n&&(i=n.validate())&&(s[n.options.name]=i)}),s;case"getValue":return 2===arguments.length&&arguments[1]===!0?s=this.eq(0).data(o).value:this.each(function(){var i=t(this),e=i.data(o);e&&void 0!==e.value&&null!==e.value&&(s[e.options.name]=e.input.value2submit(e.value))}),s;case"submit":var a,r=arguments[1]||{},l=this,h=this.editable("validate");return t.isEmptyObject(h)?(a=this.editable("getValue"),r.data&&t.extend(a,r.data),t.ajax(t.extend({url:r.url,data:a,type:"POST"},r.ajaxOptions)).success(function(t){"function"==typeof r.success&&r.success.call(l,t,r)}).error(function(){"function"==typeof r.error&&r.error.apply(l,arguments)})):"function"==typeof r.error&&r.error.call(l,h),this}return this.each(function(){var s=t(this),a=s.data(o),r="object"==typeof e&&e;return r&&r.selector?void(a=new i(this,r)):(a||s.data(o,a=new i(this,r)),void("string"==typeof e&&a[e].apply(a,Array.prototype.slice.call(n,1))))})},t.fn.editable.defaults={type:"text",disabled:!1,toggle:"click",emptytext:"Empty",autotext:"auto",value:null,display:null,emptyclass:"editable-empty",unsavedclass:"editable-unsaved",selector:null,highlight:"#FFFF80"}}(window.jQuery),function(t){"use strict";t.fn.editabletypes={};var i=function(){};i.prototype={init:function(i,e,s){this.type=i,this.options=t.extend({},s,e)},prerender:function(){this.$tpl=t(this.options.tpl),this.$input=this.$tpl,this.$clear=null,this.error=null},render:function(){},value2html:function(i,e){t(e)[this.options.escape?"text":"html"](t.trim(i))},html2value:function(i){return t("
").html(i).text(); 2 | },value2str:function(t){return t},str2value:function(t){return t},value2submit:function(t){return t},value2input:function(t){this.$input.val(t)},input2value:function(){return this.$input.val()},activate:function(){this.$input.is(":visible")&&this.$input.focus()},clear:function(){this.$input.val(null)},escape:function(i){return t("
").text(i).html()},autosubmit:function(){},destroy:function(){},setClass:function(){this.options.inputclass&&this.$input.addClass(this.options.inputclass)},setAttr:function(t){void 0!==this.options[t]&&null!==this.options[t]&&this.$input.attr(t,this.options[t])},option:function(t,i){this.options[t]=i}},i.defaults={tpl:"",inputclass:null,escape:!0,scope:null,showbuttons:!0},t.extend(t.fn.editabletypes,{abstractinput:i})}(window.jQuery),function(t){"use strict";var i=function(t){};t.fn.editableutils.inherit(i,t.fn.editabletypes.abstractinput),t.extend(i.prototype,{render:function(){var i=t.Deferred();return this.error=null,this.onSourceReady(function(){this.renderList(),i.resolve()},function(){this.error=this.options.sourceError,i.resolve()}),i.promise()},html2value:function(t){return null},value2html:function(i,e,s,n){var o=t.Deferred(),a=function(){"function"==typeof s?s.call(e,i,this.sourceData,n):this.value2htmlFinal(i,e),o.resolve()};return null===i?a.call(this):this.onSourceReady(a,function(){o.resolve()}),o.promise()},onSourceReady:function(i,e){var s;if(t.isFunction(this.options.source)?(s=this.options.source.call(this.options.scope),this.sourceData=null):s=this.options.source,this.options.sourceCache&&t.isArray(this.sourceData))return void i.call(this);try{s=t.fn.editableutils.tryParseJson(s,!1)}catch(n){return void e.call(this)}if("string"==typeof s){if(this.options.sourceCache){var o,a=s;if(t(document).data(a)||t(document).data(a,{}),o=t(document).data(a),o.loading===!1&&o.sourceData)return this.sourceData=o.sourceData,this.doPrepend(),void i.call(this);if(o.loading===!0)return o.callbacks.push(t.proxy(function(){this.sourceData=o.sourceData,this.doPrepend(),i.call(this)},this)),void o.err_callbacks.push(t.proxy(e,this));o.loading=!0,o.callbacks=[],o.err_callbacks=[]}var r=t.extend({url:s,type:"get",cache:!1,dataType:"json",success:t.proxy(function(s){o&&(o.loading=!1),this.sourceData=this.makeArray(s),t.isArray(this.sourceData)?(o&&(o.sourceData=this.sourceData,t.each(o.callbacks,function(){this.call()})),this.doPrepend(),i.call(this)):(e.call(this),o&&t.each(o.err_callbacks,function(){this.call()}))},this),error:t.proxy(function(){e.call(this),o&&(o.loading=!1,t.each(o.err_callbacks,function(){this.call()}))},this)},this.options.sourceOptions);t.ajax(r)}else this.sourceData=this.makeArray(s),t.isArray(this.sourceData)?(this.doPrepend(),i.call(this)):e.call(this)},doPrepend:function(){null!==this.options.prepend&&void 0!==this.options.prepend&&(t.isArray(this.prependData)||(t.isFunction(this.options.prepend)&&(this.options.prepend=this.options.prepend.call(this.options.scope)),this.options.prepend=t.fn.editableutils.tryParseJson(this.options.prepend,!0),"string"==typeof this.options.prepend&&(this.options.prepend={"":this.options.prepend}),this.prependData=this.makeArray(this.options.prepend)),t.isArray(this.prependData)&&t.isArray(this.sourceData)&&(this.sourceData=this.prependData.concat(this.sourceData)))},renderList:function(){},value2htmlFinal:function(t,i){},makeArray:function(i){var e,s,n,o,a=[];if(!i||"string"==typeof i)return null;if(t.isArray(i)){o=function(t,i){return s={value:t,text:i},e++>=2?!1:void 0};for(var r=0;r1&&(n.children&&(n.children=this.makeArray(n.children)),a.push(n))):a.push({value:n,text:n})}else t.each(i,function(t,i){a.push({value:t,text:i})});return a},option:function(t,i){this.options[t]=i,"source"===t&&(this.sourceData=null),"prepend"===t&&(this.prependData=null)}}),i.defaults=t.extend({},t.fn.editabletypes.abstractinput.defaults,{source:null,prepend:!1,sourceError:"Error when loading list",sourceCache:!0,sourceOptions:null}),t.fn.editabletypes.list=i}(window.jQuery),function(t){"use strict";var i=function(t){this.init("text",t,i.defaults)};t.fn.editableutils.inherit(i,t.fn.editabletypes.abstractinput),t.extend(i.prototype,{render:function(){this.renderClear(),this.setClass(),this.setAttr("placeholder")},activate:function(){this.$input.is(":visible")&&(this.$input.focus(),t.fn.editableutils.setCursorPosition(this.$input.get(0),this.$input.val().length),this.toggleClear&&this.toggleClear())},renderClear:function(){this.options.clear&&(this.$clear=t(''),this.$input.after(this.$clear).css("padding-right",24).keyup(t.proxy(function(i){if(!~t.inArray(i.keyCode,[40,38,9,13,27])){clearTimeout(this.t);var e=this;this.t=setTimeout(function(){e.toggleClear(i)},100)}},this)).parent().css("position","relative"),this.$clear.click(t.proxy(this.clear,this)))},postrender:function(){},toggleClear:function(t){if(this.$clear){var i=this.$input.val().length,e=this.$clear.is(":visible");i&&!e&&this.$clear.show(),!i&&e&&this.$clear.hide()}},clear:function(){this.$clear.hide(),this.$input.val("").focus()}}),i.defaults=t.extend({},t.fn.editabletypes.abstractinput.defaults,{tpl:'',placeholder:null,clear:!0}),t.fn.editabletypes.text=i}(window.jQuery),function(t){"use strict";var i=function(t){this.init("textarea",t,i.defaults)};t.fn.editableutils.inherit(i,t.fn.editabletypes.abstractinput),t.extend(i.prototype,{render:function(){this.setClass(),this.setAttr("placeholder"),this.setAttr("rows"),this.$input.keydown(function(i){i.ctrlKey&&13===i.which&&t(this).closest("form").submit()})},activate:function(){t.fn.editabletypes.text.prototype.activate.call(this)}}),i.defaults=t.extend({},t.fn.editabletypes.abstractinput.defaults,{tpl:"",inputclass:"input-large",placeholder:null,rows:7}),t.fn.editabletypes.textarea=i}(window.jQuery),function(t){"use strict";var i=function(t){this.init("select",t,i.defaults)};t.fn.editableutils.inherit(i,t.fn.editabletypes.list),t.extend(i.prototype,{renderList:function(){this.$input.empty();var i=function(e,s){var n;if(t.isArray(s))for(var o=0;o",n),s[o].children))):(n.value=s[o].value,s[o].disabled&&(n.disabled=!0),e.append(t("