├── .travis.yml ├── lib ├── color.js ├── toc.js ├── unzip.js ├── wowaads.js └── config.js ├── LICENSE ├── .gitignore ├── package.json ├── source ├── git.js ├── mmoui.js ├── github.js ├── curse.js ├── tukui.js └── index.js ├── index.js ├── test.js ├── README.md └── core.js /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | os: 3 | - linux 4 | - osx 5 | - windows 6 | node_js: 7 | - '12' 8 | - '10' 9 | after_success: 10 | - './node_modules/.bin/nyc report --reporter=text-lcov | ./node_modules/.bin/coveralls' 11 | -------------------------------------------------------------------------------- /lib/color.js: -------------------------------------------------------------------------------- 1 | const ck = require('chalk') 2 | const sp = require('sprintf-js').sprintf 3 | const _ = require('underscore') 4 | 5 | let cl = { 6 | i: ck.yellow, 7 | h: ck.red, 8 | x: ck.dim, 9 | i2: ck.blue, 10 | ls(ents) { 11 | let max = _.max(ents.map(x => x.length)) + 1 12 | 13 | let n = ents.length 14 | 15 | let cols = Math.floor(process.stdout.columns / max) 16 | let rows = Math.ceil(n / cols) 17 | cols = Math.ceil(n / rows) 18 | 19 | let s = '' 20 | for (let i = 0; i < rows; i++) { 21 | for (let j = 0; j < cols; j++) { 22 | let ent = ents[i + j * rows] 23 | if (!ent) continue 24 | s += sp(`%-${max}s`, ent) 25 | } 26 | s += '\n' 27 | } 28 | 29 | return s 30 | } 31 | } 32 | 33 | module.exports = cl 34 | -------------------------------------------------------------------------------- /lib/toc.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const log = console.log 3 | 4 | module.exports = { 5 | parse(path) { 6 | let src = fs.readFileSync(path, 'utf-8') 7 | let toc = { lua: [], xml: [] } 8 | 9 | src 10 | .split('\r') 11 | .map(x => x.trim()) 12 | .forEach(line => { 13 | line = line.trim() 14 | 15 | if (line.match(/^##/)) { 16 | let pair = line 17 | .substring(2) 18 | .split(':') 19 | .map(x => x.trim()) 20 | 21 | if (!pair[1]) return 22 | 23 | toc[pair[0]] = 24 | pair[0] === 'Dependencies' || pair[0] === 'SavedVariables' 25 | ? pair[1].split(',').map(x => x.trim()) 26 | : pair[1] 27 | } else if (line.match(/^#/)) return 28 | else if (line.match(/\.lua/)) { 29 | toc.lua.push(line) 30 | } else if (line.match(/\.xml/)) { 31 | toc.xml.push(line) 32 | } 33 | }) 34 | 35 | return toc 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 antiwinter 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. -------------------------------------------------------------------------------- /lib/unzip.js: -------------------------------------------------------------------------------- 1 | const yaz = require('yauzl') 2 | const path = require('path') 3 | const mk = require('mkdirp') 4 | const fs = require('fs') 5 | 6 | const log = console.log 7 | 8 | module.exports = (src, dst, done) => { 9 | yaz.open(src, { lazyEntries: true }, (err, f) => { 10 | if (err) return done(err) 11 | f.readEntry() 12 | f.on('entry', ent => { 13 | if (/\/$/.test(ent.fileName)) { 14 | // log('...', path.join(dst, ent.fileName)) 15 | f.readEntry() 16 | } else { 17 | // log(' |', path.join(dst, ent.fileName)) 18 | mk(path.dirname(path.join(dst, ent.fileName)), err => { 19 | f.openReadStream(ent, (err, rs) => { 20 | if (err) return done(err) 21 | 22 | rs.pipe(fs.createWriteStream(path.join(dst, ent.fileName))).on( 23 | 'close', 24 | () => { 25 | f.readEntry() 26 | } 27 | ) 28 | }) 29 | }) 30 | } 31 | }).on('end', () => { 32 | done() 33 | }) 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | package-lock.json 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # parcel-bundler cache (https://parceljs.org/) 64 | .cache 65 | 66 | # next.js build output 67 | .next 68 | 69 | # nuxt.js build output 70 | .nuxt 71 | 72 | # Nuxt generate 73 | dist 74 | 75 | # vuepress build output 76 | .vuepress/dist 77 | 78 | # Serverless directories 79 | .serverless 80 | 81 | # IDE 82 | .idea 83 | data 84 | config 85 | trash 86 | .DS_Store 87 | 88 | vds-cfg.js 89 | tmp 90 | *.html 91 | exp/ 92 | skull 93 | 94 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@octokit/rest": "^16.43.1", 4 | "agentkeepalive": "^4.1.0", 5 | "async": "^3.0.1", 6 | "chalk": "^2.4.2", 7 | "commander": "^6.2.0", 8 | "easy-table": "^1.1.1", 9 | "got": "^9.6.0", 10 | "listr": "^0.14.3", 11 | "mkdirp": "^0.5.1", 12 | "moment": "^2.24.0", 13 | "ncp": "^2.0.0", 14 | "numeral": "^2.0.6", 15 | "package-info": "^3.0.2", 16 | "rimraf": "^2.6.3", 17 | "simple-git": "^1.126.0", 18 | "sprintf-js": "^1.1.2", 19 | "ua-string": "^3.0.0", 20 | "underscore": "^1.9.1", 21 | "which": "^2.0.1", 22 | "yauzl": "^2.10.0" 23 | }, 24 | "name": "wowa", 25 | "version": "1.3.16", 26 | "description": "Manage World of Warcraft addons, upload WCL, etc.", 27 | "main": "index.js", 28 | "devDependencies": { 29 | "ava": "^2.1.0", 30 | "coveralls": "^3.0.4", 31 | "cross-env": "^6.0.3", 32 | "file-type": "^12.0.0", 33 | "nyc": "^14.1.1", 34 | "release-it": "^14.10.0", 35 | "valid-url": "^1.0.9" 36 | }, 37 | "scripts": { 38 | "test": "cross-env TEST_WOWA=1 nyc ava", 39 | "release": "release-it" 40 | }, 41 | "author": "antiwinter", 42 | "license": "MIT", 43 | "keywords": [ 44 | "world of warcraft", 45 | "wow", 46 | "addon", 47 | "lua" 48 | ], 49 | "bin": { 50 | "wowa": "./index.js" 51 | }, 52 | "repository": { 53 | "type": "git", 54 | "url": "git+https://github.com/antiwinter/wowa.git" 55 | }, 56 | "release-it": { 57 | "git": { 58 | "tagName": "v${version}", 59 | "commitMessage": "release v${version}" 60 | }, 61 | "github": { 62 | "release": true 63 | } 64 | }, 65 | "engines": { 66 | "node": ">=10.16" 67 | }, 68 | "resolutions": { 69 | "minimist": "^1.2.5", 70 | "lodash": "^4.17.19", 71 | "deep-extend": "^0.5.1" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lib/wowaads.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const _ = require('underscore') 3 | const rm = require('rimraf') 4 | const async = require('async') 5 | const path = require('path') 6 | 7 | const cl = require('./color') 8 | const cfg = require('./config') 9 | const log = console.log 10 | 11 | let w = { 12 | data: {}, 13 | 14 | load() { 15 | let p = cfg.getPath('wowaads') 16 | 17 | w.data = fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf-8')) : {} 18 | return w 19 | }, 20 | 21 | save() { 22 | fs.writeFileSync( 23 | cfg.getPath('wowaads'), 24 | JSON.stringify(w.data, null, 2), 25 | 'utf-8' 26 | ) 27 | }, 28 | 29 | checkDuplicate() { 30 | let keys = _.keys(w.data) 31 | let k 32 | 33 | while ((k = keys.pop())) { 34 | for (let i = 0; i < keys.length; i++) { 35 | let k2 = keys[i] 36 | let inc = _.intersection(w.data[k].sub, w.data[k2].sub) 37 | if (inc.length) { 38 | log( 39 | `\n${cl.i('Note:')} ${cl.h(k)} and ${cl.h( 40 | k2 41 | )} use the same subset of directory, make sure you have only one of them installed\n` 42 | ) 43 | // log(inc) 44 | return true 45 | } 46 | } 47 | } 48 | 49 | return false 50 | }, 51 | 52 | clearUp(key, done) { 53 | let p = cfg.getPath('addon') 54 | if (key in w.data) { 55 | async.forEach( 56 | w.data[key].sub, 57 | (sub, cb) => { 58 | rm(path.join(p, sub), err => { 59 | if (err) { 60 | log('clear up addon failed', sub) 61 | done(err) 62 | cb(false) 63 | return 64 | } 65 | 66 | cb() 67 | }) 68 | }, 69 | () => { 70 | delete w.data[key] 71 | done() 72 | } 73 | ) 74 | } else if (fs.existsSync(path.join(p, key))) rm(path.join(p, key), done) 75 | else done('na') 76 | }, 77 | 78 | dirStatus(dir) { 79 | for (let k in w.data) if (w.data[k].sub.indexOf(dir) >= 0) return k 80 | }, 81 | 82 | unknownDirs() { 83 | try { 84 | return _.filter( 85 | fs.readdirSync(cfg.getPath('addon')), 86 | d => !/^\./.test(d) && !w.dirStatus(d) 87 | ) 88 | } catch(err) { 89 | return [] 90 | } 91 | } 92 | } 93 | 94 | module.exports = w 95 | -------------------------------------------------------------------------------- /source/git.js: -------------------------------------------------------------------------------- 1 | const sg = require('simple-git')() 2 | const which = require('which') 3 | const log = console.log 4 | 5 | let git = { 6 | $scl: 'git', 7 | $lcl: /notpossiblyfind/, 8 | 9 | info(ad, done) { 10 | which('git', err => { 11 | if (err) { 12 | if (ad.source === 'git') 13 | log( 14 | 'In order to install from arbitrary git, wowa requires git to be installed and that it can be called using the command git.' 15 | ) 16 | return done() 17 | } 18 | sg.listRemote([ad.uri], (err, data) => { 19 | if (err) return done() 20 | 21 | let d = { 'refs/tags/': 1, 'refs/heads/': 1 } 22 | let info = { 23 | name: ad.uri, 24 | page: ad.uri, 25 | update: new Date() / 1000, 26 | version: [] 27 | } 28 | 29 | for (k in d) 30 | data.split('\n').forEach(line => { 31 | // log('>>', line) 32 | if (line.match(/{}$/)) return 33 | if (line.match(k)) 34 | info.version.unshift({ 35 | name: line.slice(line.search(k) + k.length), 36 | hash: line.split('\t')[0] 37 | }) 38 | }) 39 | 40 | // log(info) 41 | done(info) 42 | }) 43 | }) 44 | }, 45 | 46 | clone(uri, ref, to, hook) { 47 | sg.outputHandler((cmd, o1, o2) => { 48 | let unit = { KiB: 1024, MiB: 1 << 20, GiB: 1 << 30 } 49 | o2.on('data', line => { 50 | line 51 | .toString() 52 | .split('\r') 53 | .forEach(l => { 54 | if (!l.match(/Receiving objects/)) return 55 | let tr = /, [0-9\.]+ [KMG]iB \|/.exec(l) 56 | let evt = { 57 | percent: 58 | /\([0-9]+\//.exec(l)[0].slice(1, -1) / 59 | /\/[0-9]+\)/.exec(l)[0].slice(1, -1), 60 | transferred: !tr 61 | ? 0 62 | : tr[0].slice(2, -6) * unit[tr[0].slice(-5, -2)] 63 | } 64 | hook(evt) 65 | 66 | // log(evt, l) 67 | }) 68 | }) 69 | }) 70 | .silent(true) 71 | .clone( 72 | uri, 73 | to, 74 | ['-b', ref, '--single-branch', '--depth', 1, '--progress', '--verbose'], 75 | (err, data) => { 76 | if (err) return hook(err.toString()) 77 | else hook('done') 78 | } 79 | ) 80 | } 81 | } 82 | 83 | module.exports = git 84 | -------------------------------------------------------------------------------- /source/mmoui.js: -------------------------------------------------------------------------------- 1 | const _ = require('underscore') 2 | const g = require('got') 3 | const cfg = require('../lib/config') 4 | const log = console.log 5 | 6 | let api = { 7 | $url: 'https://api.mmoui.com/v3/game/WOW', 8 | $web: 'https://wowinterface.com', 9 | 10 | $lcl: /\/info(.*)\.html/, 11 | $scl: 'wowinterface.com##mmoui.com', 12 | 13 | info(ad, done) { 14 | let id = ad.key.split('-')[0] 15 | 16 | // log('getting', `${api.$url}/filedetails/${id}.json`) 17 | g(`${api.$url}/filedetails/${id}.json`) 18 | .then(res => { 19 | let x = JSON.parse(res.body)[0] 20 | 21 | ad.key = id + '-' + x.UIName.replace(/[^a-zA-Z0-9]/g, '') 22 | 23 | done({ 24 | name: x.UIName, 25 | author: x.UIAuthorName, 26 | update: x.UIDate / 1000, 27 | download: x.UIHitCount, 28 | version: [{ link: x.UIDownload, name: x.UIVersion }] 29 | }) 30 | }) 31 | .catch(x => done()) 32 | }, 33 | 34 | summary(done) { 35 | g(`${api.$url}/filelist.json`) 36 | .then(res => { 37 | let r = JSON.parse(res.body) 38 | 39 | // log(r[0]) 40 | done( 41 | r.map(x => { 42 | return { 43 | id: x.UID, 44 | name: x.UIName, 45 | key: x.UID + '-' + x.UIName.replace(/[^a-zA-Z0-9]/g, ''), 46 | mode: x.UICATID === '160' ? '_classic_' : '_retail_', 47 | cat: x.UICATID, 48 | version: x.UIVersion, 49 | update: x.UIDate / 1000, 50 | author: x.UIAuthorName, 51 | download: x.UIDownloadTotal, 52 | game: x.UICompatibility 53 | ? _.uniq(x.UICompatibility.map(c => c.version)) 54 | : null, 55 | dir: x.UIDir, 56 | source: 'mmoui' 57 | } 58 | }) 59 | ) 60 | }) 61 | .catch(err => { 62 | log('mmoui summary failed', err) 63 | done([]) 64 | }) 65 | }, 66 | 67 | search(ad, done) { 68 | let mo = cfg.getMode() 69 | 70 | let top = require('./index') 71 | top.getDB('mmoui', db => { 72 | if (!db) return done() 73 | 74 | if (!ad.anyway) db = _.filter(db, d => mo === d.mode) 75 | 76 | // log(mo) 77 | 78 | let res = _.filter( 79 | db, 80 | d => 81 | d.name.toLowerCase().search(ad.key.toLowerCase()) >= 0 || 82 | d.dir[0].toLowerCase().search(ad.key.toLowerCase()) >= 0 83 | ) 84 | 85 | res.sort((a, b) => b.download - a.download) 86 | res = res.slice(0, 15) 87 | 88 | // log(res) 89 | done( 90 | res.map(x => { 91 | return { 92 | name: x.name, 93 | key: x.key, 94 | download: parseInt(x.download), 95 | update: x.update, 96 | page: `${api.$web}/downloads/info${x.key}.html` 97 | } 98 | }) 99 | ) 100 | }) 101 | } 102 | } 103 | 104 | module.exports = api 105 | -------------------------------------------------------------------------------- /source/github.js: -------------------------------------------------------------------------------- 1 | const { Octokit } = require('@octokit/rest') 2 | const async = require('async') 3 | const ok = new Octokit({ 4 | auth: process.env.GITHUB_TOKEN 5 | }) 6 | const log = console.log 7 | 8 | let api = { 9 | $url: 'https://github.com/', 10 | $lcl: /github\.com\/(.*)$/, 11 | $fcl: '/', 12 | $scl: 'github.com', 13 | 14 | info(ad, done) { 15 | // install branch tip if branch provided 16 | 17 | let seg = ad.key.split('/') 18 | 19 | if (seg.length > 2) { 20 | ad.branch = seg.pop() 21 | for (; seg.length > 2; seg.pop()); 22 | ad.key = seg.join('/') 23 | } 24 | 25 | let owner = seg.shift() 26 | let repo = seg.shift() 27 | 28 | // log('getting', { owner, repo, branch: ad.branch }) 29 | 30 | let fetch = () => { 31 | let h = ad.branch 32 | ? ok.repos.getBranch({ owner, repo, branch: ad.branch }) 33 | : ok.repos.listTags({ owner, repo }) 34 | h.then(err => { 35 | // log('got dat', JSON.stringify(err, null, 2)) 36 | let data = err.data 37 | let d = { 38 | name: repo, 39 | owner, 40 | author: owner, 41 | page: api.$url + ad.key 42 | } 43 | 44 | d.version = ad.branch 45 | ? [ 46 | { 47 | name: data.commit.sha.slice(0, 7), 48 | link: `${api.$url}${ad.key}/archive/${!ad.branch ? 'master' : ad.branch 49 | }.zip`, 50 | commit: data.commit 51 | } 52 | ] 53 | : data.slice(0, 5).map(x => { 54 | return { 55 | name: x.name, 56 | link: `${api.$url}${ad.key}/archive/${x.name}.zip`, 57 | commit: x.commit 58 | } 59 | }) 60 | 61 | if (!d.version || !d.version.length) { 62 | ad.branch = 'master' 63 | fetch() 64 | return 65 | } 66 | 67 | async.forEachLimit(d.version, 1, (x, cb) => { 68 | ok.git 69 | .getCommit({ 70 | owner, 71 | repo, 72 | commit_sha: x.commit.sha 73 | }) 74 | .then(({ data }) => { 75 | x.update = new Date(data.committer.date).valueOf() / 1000 76 | cb() 77 | }) 78 | }, () => { 79 | d.version.sort((a, b) => b.update - a.update) 80 | d.update = d.version[0].update 81 | done(d) 82 | }) 83 | 84 | }).catch(err => { 85 | if (err) { 86 | let msg = err.toString() 87 | if (typeof msg === 'string' && msg.match(/rate limit/)) { 88 | log( 89 | '\nThe [github] API has reached its rate limits. You can either create a GITHUB_TOKEN env, or try another time after an hour.' 90 | ) 91 | log( 92 | 'To aquire a GITHUB_TOKEN, goto https://github.com/settings/tokens, and click "Generate new token"\n' 93 | ) 94 | } 95 | } 96 | done() 97 | }) 98 | } 99 | 100 | fetch() 101 | } 102 | } 103 | 104 | module.exports = api 105 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | const mk = require('mkdirp') 4 | 5 | const win = process.platform === 'win32' 6 | const env = process.env 7 | const log = console.log 8 | 9 | let cfg = { 10 | $anyway: 0, 11 | $exp: null, 12 | getPath(cat) { 13 | let pac = path.join(win ? env.APPDATA : env.HOME, win ? 'wowa' : '.wowa') 14 | let pag 15 | 16 | if (env.TEST_WOWA) pac = path.join(path.dirname(pac), '.test_wowa') 17 | 18 | let pathFile = path.join(pac, 'wow_path.txt') 19 | if (fs.existsSync(pathFile)) pag = fs.readFileSync(pathFile, 'utf-8').trim() 20 | else { 21 | if (env.TEST_WOWA) pag = path.join(pac, '.test/_retail_') 22 | else 23 | pag = path.join( 24 | win ? 'C:\\Program Files' : '/Applications', 25 | 'World of Warcraft', 26 | '_retail_' 27 | ) 28 | 29 | mk(pac, err => { 30 | fs.writeFileSync(pathFile, pag, 'utf-8') 31 | }) 32 | } 33 | 34 | let mode = path.basename(pag) 35 | 36 | if (mode === '_tbc_') { 37 | cfg.$exp = '[TBC]' 38 | pag = pag.replace(/_tbc_/g, '_classic_') 39 | mode = path.basename(pag) 40 | } else 41 | cfg.$exp = null 42 | 43 | let paths = { 44 | addon: path.join(pag, 'Interface', 'AddOns'), 45 | wtf: path.join(pag, 'WTF'), 46 | wowaads: path.join(pag, 'WTF', 'wowaads.json'), 47 | pathfile: pathFile, 48 | tmp: pac, 49 | mode: mode, 50 | db: path.join(pac, '.db'), 51 | update: path.join(pac, '.update'), 52 | game: pag 53 | } 54 | 55 | return cat in paths ? paths[cat] : pag 56 | }, 57 | 58 | checkPath() { 59 | let wow = cfg.getPath() 60 | let e = fs.existsSync(wow) 61 | 62 | // log('checking', wow) 63 | if (!e) { 64 | log( 65 | '\nWoW folder not found, you can specify it by editing the file below:' 66 | ) 67 | log('\n ' + cfg.getPath('pathfile') + '\n') 68 | } 69 | 70 | return e 71 | }, 72 | 73 | getMode(ver) { 74 | let tail = path.basename(cfg.getPath()) 75 | 76 | return ver 77 | ? tail.match(/ptr/) 78 | ? '[PTR]' 79 | : tail.match(/beta/) 80 | ? '[BETA]' 81 | : cfg.$exp ? cfg.$exp : '' 82 | : tail.match(/classic/) 83 | ? '_classic_' 84 | : '_retail_' 85 | }, 86 | 87 | testMode(mode) { 88 | let m = cfg.getMode() 89 | 90 | return typeof mode === 'number' 91 | ? mode & (m === '_retail_' ? 1 : 2) 92 | : m === mode 93 | }, 94 | 95 | setModePath(mp) { 96 | let p1 = path.join(path.dirname(cfg.getPath('game')), mp === '_tbc_' ? '_classic_' : mp) 97 | let p = path.join(path.dirname(cfg.getPath('game')), mp) 98 | 99 | if (fs.existsSync(p1)) fs.writeFileSync(cfg.getPath('pathfile'), p, 'utf-8') 100 | else 101 | log( 102 | '\nMode folder does not exist, you must run the game in that mode for at least one time before using wowa' 103 | ) 104 | }, 105 | 106 | getClassicExp() { 107 | return cfg.$exp 108 | }, 109 | 110 | // these two is useless at this moment, we don't bother with the version at this moment 111 | // getGameVersion() { 112 | // let mo = cfg.getMode() 113 | // return mo === '_classic_' ? '1.13.2' : '8.2.0' 114 | // }, 115 | 116 | // isValidVersion(v) { 117 | // let mo = cfg.getMode() 118 | 119 | // return ( 120 | // (mo === '_classic_' && v.search('1.') === 0) || 121 | // (mo === '_retail_' && v.search('1.') !== 0) 122 | // ) 123 | // }, 124 | 125 | anyway(en) { 126 | if (!en) return cfg.$anyway 127 | cfg.$anyway = en 128 | } 129 | } 130 | 131 | module.exports = cfg 132 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const cli = require('commander') 4 | const pkg = require('./package.json') 5 | const cfg = require('./lib/config') 6 | const core = require('./core') 7 | 8 | cli.version(pkg.version).usage(' [option] ') 9 | 10 | cli 11 | .command('add ') 12 | .description('install one or more addons locally') 13 | .alias('install') 14 | .alias('get') 15 | .option('--anyway', 'install latest addon release for _classic_ mode anyway') 16 | .action((aa, cmd) => { 17 | cfg.anyway(cmd.anyway) 18 | core.add(aa) 19 | }) 20 | 21 | cli 22 | .command('rm ') 23 | .description('remove addons from local installation') 24 | .alias('delete') 25 | .alias('uninstall') 26 | .alias('remove') 27 | .alias('del') 28 | .action(key => core.rm(key)) 29 | 30 | cli 31 | .command('search ') 32 | .description('search addons whose name contain ') 33 | .option( 34 | '--anyway', 35 | 'search for latest addon release for _classic_ mode anyway' 36 | ) 37 | .action((text, cmd) => { 38 | cfg.anyway(cmd.anyway) 39 | core.search(text) 40 | }) 41 | 42 | cli 43 | .command('ls') 44 | .description('list all installed addons') 45 | .option('-l, --long', 'show detailed addon information') 46 | .option('-t, --time', 'sort by updated time') 47 | .alias('list') 48 | .action(core.ls) 49 | 50 | cli 51 | .command('info ') 52 | .description( 53 | 'show info of an addon, the addon does not have to be an installed locally' 54 | ) 55 | .option( 56 | '--anyway', 57 | 'show info of latest addon release for _classic_ mode anyway' 58 | ) 59 | .action((ad, cmd) => { 60 | cfg.anyway(cmd.anyway) 61 | core.info(ad) 62 | }) 63 | 64 | cli 65 | .command('update') 66 | .description('update all installed addons') 67 | .option('--anyway', 'update latest addon release for _classic_ mode anyway') 68 | .option( 69 | '--db', 70 | 'for update addon database, no addon will be updated if this option is specified' 71 | ) 72 | .action(cmd => { 73 | cfg.anyway(cmd.anyway) 74 | core.update(cli.args.length > 1 ? cli.args.slice(0, -1) : null, cmd) 75 | }) 76 | 77 | cli 78 | .command('import') 79 | .description('import local addons') 80 | .action(() => core.pickup()) 81 | 82 | cli 83 | .command('pin ') 84 | .description("pin an addon to it's current version, prevent it from updating") 85 | .action(key => core.pin(key, 1)) 86 | 87 | cli 88 | .command('unpin ') 89 | .description("unpin an addon, allow it to be updated") 90 | .action(key => core.pin(key, 0)) 91 | 92 | cli 93 | .command('switch') 94 | .alias('sw') 95 | .description('switch mode between retail and classic') 96 | .option('--ptr', 'switch mode to: retail PTR') 97 | .option('--beta', 'switch mode to: retail BETA') 98 | .option('--retail', 'switch mode to: retail formal') 99 | .option('--retail-ptr', 'switch mode to: retail PTR') 100 | .option('--retail-beta', 'switch mode to: retail BETA') 101 | .option('--classic', 'switch mode to: classic formal') 102 | .option('--classic-tbc', 'switch mode to: classic TBC') 103 | .option('--classic-ptr', 'switch mode to: classic PTR') 104 | .option('--classic-beta', 'switch mode to: classic BETA') 105 | .action(core.switch) 106 | 107 | cli 108 | .command('restore [repo]') 109 | .description( 110 | 'restore addons from github repo, only is required, not the full URL. (e.g. antiwinter/wowui)' 111 | ) 112 | .option( 113 | '-f, --full', 114 | 'not only restore addons, but also restore addons settings' 115 | ) 116 | .action(repo => core.restore(repo)) 117 | 118 | cli.on('command:*', () => { 119 | cli.help() 120 | }) 121 | 122 | if (process.argv.length < 3) return cli.help() 123 | 124 | // do the job 125 | 126 | if (!cfg.checkPath()) return 127 | 128 | core.checkUpdate(() => { 129 | cli.parse(process.argv) 130 | }) 131 | -------------------------------------------------------------------------------- /source/curse.js: -------------------------------------------------------------------------------- 1 | const g = require('got') 2 | const _ = require('underscore') 3 | const cfg = require('../lib/config') 4 | const { HttpsAgent } = require('agentkeepalive') 5 | const log = console.log 6 | 7 | let api = { 8 | $url: 'https://www.curseforge.com/wow/addons/', 9 | $srl: 'https://addons-ecs.forgesvc.net/api/v2/addon', 10 | 11 | $lcl: /(addons|projects)\/(.*)$/, 12 | $scl: 'curseforge.com', 13 | 14 | info(ad, done) { 15 | let flavor = cfg.getMode() === '_classic_' ? 16 | (cfg.getClassicExp() === '[TBC]' ? 'wow_burning_crusade' : 'wow_classic') 17 | : 'wow_retail' 18 | let top = require('./') 19 | 20 | top.getDB('curse', db => { 21 | // log('curse info') 22 | if (!db) return done() 23 | 24 | // log('got db', db) 25 | 26 | let x = _.find(db, d => ad.key === d.key) 27 | 28 | // log('got x', x) 29 | 30 | if (!x) return done() 31 | 32 | let id = x.id 33 | 34 | let qs = `${api.$srl}/${id}` 35 | 36 | // log('getting', qs) 37 | g.get(qs) 38 | .then(res => { 39 | res = JSON.parse(res.body) 40 | // log('got', res) 41 | 42 | let data = { 43 | name: res.name, 44 | owner: res.authors ? res.authors[0].name : 'unknown', 45 | create: new Date(res.dateCreated).valueOf() / 1000, 46 | update: new Date(res.dateReleased).valueOf() / 1000, 47 | download: res.downloadCount, 48 | version: res.latestFiles.map(x => { 49 | return { 50 | name: x.displayName, 51 | size: x.fileLength, 52 | link: x.downloadUrl, 53 | flavor: x.gameVersionFlavor, 54 | date: new Date(x.fileDate), 55 | stage: x.releaseType // stage: 1 formal, 2 beta, 3 alpha (0, 4 not found) 56 | } 57 | }) 58 | } 59 | 60 | if (!ad.anyway) 61 | data.version = _.filter(data.version, x => x.flavor === flavor) 62 | 63 | if (!ad.nolib) 64 | data.version = _.filter(data.version, x => !x.name.match(/-nolib$/)) 65 | 66 | let beta = _.filter(data.version, x => x.stage < 3) 67 | if (beta) data.version = beta 68 | 69 | data.version.sort((a, b) => b.date - a.date) 70 | 71 | // log('final data', data) 72 | done(!data.version.length ? null : data) 73 | }) 74 | .catch(err => { 75 | done() 76 | }) 77 | }) 78 | }, 79 | 80 | summary(done) { 81 | g.get('https://github.com/antiwinter/scrap/raw/master/wowa/db-curse.json', { 82 | agent: new HttpsAgent({ keepAlive: true }) 83 | }) 84 | .then(res => { 85 | done(JSON.parse(res.body)) 86 | }) 87 | .catch(err => { 88 | log('require curse db failed', err.toString()) 89 | done([]) 90 | }) 91 | }, 92 | 93 | search(ad, done) { 94 | let flavor = cfg.getMode() === '_classic_' ? 'wow_classic' : 'wow_retail' 95 | 96 | let qs = `${api.$srl}/search?gameId=1&index=0&pageSize=30&searchFilter=${ad.key}` 97 | // log('searching', qs) 98 | g.get(qs) 99 | .then(res => { 100 | // log(res.body) 101 | let data = JSON.parse(res.body) 102 | 103 | if (!ad.anyway) 104 | data = _.filter(data, d => 105 | _.find( 106 | d.gameVersionLatestFiles, 107 | _d => _d.gameVersionFlavor === flavor 108 | ) 109 | ) 110 | 111 | done( 112 | data.map(x => { 113 | return { 114 | name: x.name, 115 | key: x.websiteUrl.split('/').pop(), 116 | download: x.downloadCount, 117 | update: new Date(x.dateModified).valueOf() / 1000, 118 | page: x.websiteUrl 119 | } 120 | }) 121 | ) 122 | }) 123 | .catch(err => { 124 | done() 125 | }) 126 | } 127 | } 128 | 129 | module.exports = api 130 | -------------------------------------------------------------------------------- /source/tukui.js: -------------------------------------------------------------------------------- 1 | const _ = require('underscore') 2 | const cfg = require('../lib/config') 3 | const g = require('got') 4 | const log = console.log 5 | 6 | let api = { 7 | $url: 'https://www.tukui.org', 8 | $base: 'https://www.tukui.org/download.php', 9 | 10 | $lcl: /\?id=(.*)$/, 11 | $fcl: 'tukui', 12 | $scl: 'tukui.com', 13 | 14 | cfv: cfg.getClassicExp() === '[TBC]' ? 'classic-tbc-addons' : 'classic-addons', 15 | info(ad, done) { 16 | let id = ad.key.split('-')[0] 17 | let mo = cfg.getMode() 18 | let top = require('./') 19 | 20 | if (mo === '_retail_' && ad.key.match(/0-.tukui|0-.elvui/i)) { 21 | g(`${api.$base}?ui=${ad.key.match(/0-.tukui/i) ? 'tukui' : 'elvui'}`) 22 | .then(res => { 23 | let i = { 24 | name: ad.key, 25 | author: 'tukui.org', 26 | download: 1000000, 27 | version: [{}] 28 | } 29 | 30 | res.body 31 | .replace(/\r/g, '') 32 | .split('\n') 33 | .forEach(line => { 34 | if (line.match(/current version/i)) { 35 | let d = line.replace(/').split('>') 36 | i.update = new Date(d[6]) / 1000 37 | i.version[0].name = d[2] 38 | } 39 | 40 | if (line.match(/btn-mod/i)) { 41 | i.version[0].link = api.$url + line.split('"')[1] 42 | } 43 | }) 44 | 45 | done(i) 46 | }) 47 | .catch(x => done()) 48 | return 49 | } 50 | 51 | top.getDB('tukui', db => { 52 | if (!db) return done() 53 | 54 | let x = _.find(db, d => ad.key === d.key) 55 | if (!x) return done() 56 | 57 | done({ 58 | name: x.name, 59 | author: x.author, 60 | update: x.update, 61 | download: x.download, 62 | version: [{ 63 | name: x.version, 64 | game: x.game, 65 | link: `${api.$url}/${mo === '_retail_' 66 | ? 'addons' : api.cfv}.php?download=${x.id}` 67 | }] 68 | }) 69 | }) 70 | }, 71 | 72 | summary(done) { 73 | // inject the base UI 74 | let r = [ 75 | { 76 | id: 0, 77 | name: 'TukUI', 78 | downloads: 1000000, 79 | lastupdate: new Date().toDateString(), 80 | small_desc: 'TukUI', 81 | }, 82 | { 83 | id: 0, 84 | name: 'ElvUI', 85 | downloads: 1000000, 86 | lastupdate: new Date().toDateString(), 87 | small_desc: 'ElvUI', 88 | } 89 | ] 90 | 91 | // get retail addon list 92 | g(`${api.$url}/api.php?addons`).then(res => { 93 | r = r.concat(JSON.parse(res.body)) 94 | r.forEach(x => x.mode = 1) 95 | 96 | // get classic addon list 97 | g(`${api.$url}/api.php?${api.cfv}`).then(res => { 98 | r = r.concat(JSON.parse(res.body).map(x => { 99 | x.mode = 2 100 | return x 101 | })) 102 | 103 | done(r.map(x => { 104 | return { 105 | id: x.id, 106 | name: x.name, 107 | key: x.id + '-' + x.mode + x.name.replace(/[^a-zA-Z0-9]/g, ''), 108 | mode: x.mode, 109 | version: x.version, 110 | update: new Date(x.lastupdate).valueOf() / 1000, 111 | author: x.author, 112 | download: x.downloads, 113 | small_desc: x.small_desc, 114 | source: 'tukui' 115 | } 116 | })) 117 | }) 118 | }).catch(err => { 119 | log('tukui summary failed', err) 120 | done([]) 121 | }) 122 | }, 123 | 124 | search(ad, done) { 125 | let mo = cfg.getMode() === '_retail_' ? 1 : 2 126 | let top = require('./') 127 | 128 | top.getDB('tukui', db => { 129 | let res = _.filter(db, x => x.mode === mo && ( 130 | x.name.toLowerCase().search(ad.key.toLowerCase()) >= 0 || 131 | x.small_desc.toLowerCase().search(ad.key.toLowerCase()) >= 0 132 | )) 133 | 134 | res.sort((a, b) => b.downloads - a.downloads) 135 | res = res.slice(0, 15) 136 | 137 | done( 138 | res.map(x => { 139 | x.page = `${api.$url}/${mo === 1 140 | ? 'addons' : api.cfv}.php?id=${x.id}` 141 | return x 142 | }) 143 | ) 144 | }) 145 | } 146 | } 147 | 148 | module.exports = api 149 | -------------------------------------------------------------------------------- /source/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const async = require('async') 4 | const _ = require('underscore') 5 | const mk = require('mkdirp') 6 | const cfg = require('../lib/config') 7 | const cl = require('../lib/color') 8 | const log = console.log 9 | 10 | let src = { 11 | $api: { 12 | curse: require('./curse'), 13 | mmoui: require('./mmoui'), 14 | tukui: require('./tukui'), 15 | github: require('./github'), 16 | git: require('./git') 17 | }, 18 | 19 | $valid(ad) { 20 | if (ad.source && !src.$api[ad.source]) { 21 | log(`\nInvalid source: ${ad.source}, use one of below instead:`) 22 | log( 23 | _.keys(src.$api) 24 | .map(x => ` ${x}`) 25 | .join('\n') 26 | ) 27 | return false 28 | } 29 | 30 | return true 31 | }, 32 | 33 | parseName(name) { 34 | let t = name 35 | let d = {} 36 | 37 | if (name.match(/@/)) { 38 | t = name.split('@') 39 | d.version = t[1] 40 | t = t[0] 41 | } 42 | 43 | for (let k in src.$api) { 44 | if (t.match(/:\/\//)) { 45 | // looks like an long uri 46 | let r = src.$api[k].$lcl.exec(t) 47 | // log('long clue exec:', r) 48 | 49 | d.uri = t 50 | d.key = t 51 | 52 | if (r) { 53 | d.source = k 54 | d.key = r[r.length - 1] 55 | break 56 | } 57 | } else { 58 | // treat as short uri 59 | let s = null 60 | let z = t.split(':') 61 | if (z.length > 1) s = z.shift() 62 | d.key = z[0] 63 | 64 | let f = src.$api[k].$fcl 65 | if (!s && f && d.key.search(f) >= 0) { 66 | d.source = k 67 | break 68 | } else if (s && src.$api[k].$scl.search(s) >= 0) { 69 | d.source = k 70 | break 71 | } 72 | } 73 | } 74 | 75 | d.anyway = cfg.anyway() 76 | return d 77 | }, 78 | 79 | info(ad, done) { 80 | // log('\n\ngetting info', ad, '\n\n') 81 | if (!src.$valid(ad)) return done() 82 | 83 | async.eachOfLimit( 84 | src.$api, 85 | 1, 86 | (api, source, cb) => { 87 | if (ad.source && source !== ad.source) return cb() 88 | if (!ad.source && source === 'github') return cb() 89 | if (!ad.uri && source === 'git') return cb() 90 | 91 | let res = null 92 | // log('iter', source) 93 | api.info(ad, info => { 94 | if (info && info.version.length) { 95 | res = info 96 | res.source = source 97 | // log('g info', info) 98 | 99 | if (source === 'git') { 100 | if (!ad.version) ad.version = 'master' 101 | ad.branch = ad.version 102 | 103 | let target = _.find(info.version, x => x.name === ad.branch) 104 | if (!target) { 105 | log( 106 | `\n${cl.i2(ad.branch)} is not a valid entry for ${cl.h( 107 | ad.uri 108 | )}` 109 | ) 110 | log( 111 | 'Valid entries:', 112 | res.version 113 | .map(x => cl.i2(x.name)) 114 | .slice(0, 10) 115 | .join(', '), 116 | '\n' 117 | ) 118 | return done() 119 | } else { 120 | res.hash = target.hash 121 | } 122 | } 123 | done(res) 124 | cb(false) 125 | } else cb() 126 | }) 127 | }, 128 | () => { 129 | done() 130 | } 131 | ) 132 | }, 133 | 134 | search(ad, done) { 135 | if (!src.$valid(ad)) return done() 136 | 137 | async.eachOfLimit( 138 | src.$api, 139 | 1, 140 | (api, source, cb) => { 141 | if (!api.search) return cb() 142 | if (ad.source && source !== ad.source) return cb() 143 | 144 | // log('searching', source) 145 | let res = null 146 | // log('searching', source) 147 | api.search(ad, data => { 148 | if (data && data.length) { 149 | res = { source, data } 150 | done(res) 151 | cb(false) 152 | } else cb() 153 | }) 154 | }, 155 | () => { 156 | done() 157 | } 158 | ) 159 | }, 160 | 161 | summary(done) { 162 | let db = [] 163 | src.$api.curse.summary(d => { 164 | db = db.concat(d) 165 | src.$api.mmoui.summary(d => { 166 | db = db.concat(d) 167 | src.$api.tukui.summary(d => { 168 | db = db.concat(d) 169 | done(db) 170 | }) 171 | }) 172 | }) 173 | }, 174 | 175 | getDB(filter, done) { 176 | let p = cfg.getPath('db') 177 | 178 | if (!done) { 179 | done = filter 180 | filter = null 181 | } 182 | 183 | let _done = db => { 184 | done(filter ? _.filter(db, d => d && d.source === filter) : db) 185 | } 186 | 187 | if ( 188 | !fs.existsSync(p) || 189 | new Date() - fs.statSync(p).mtime > 24 * 3600 * 1000 || 190 | !done // force update 191 | ) { 192 | mk(path.dirname(p), err => { 193 | process.stdout.write(cl.i('\nUpdating database...')) 194 | src.summary(s => { 195 | fs.writeFileSync(p, JSON.stringify(s), 'utf-8') 196 | log(cl.i('done')) 197 | if (done) _done(s) 198 | }) 199 | }) 200 | 201 | return 202 | } 203 | 204 | _done(fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf-8')) : null) 205 | return 206 | } 207 | } 208 | 209 | module.exports = src 210 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import ava from 'ava' 2 | import fs from 'fs' 3 | import vau from 'valid-url' 4 | import mk from 'mkdirp' 5 | import rm from 'rimraf' 6 | import path from 'path' 7 | import _ from 'underscore' 8 | 9 | import cfg from './lib/config' 10 | import core from './core' 11 | import ads from './lib/wowaads' 12 | import api from './source' 13 | 14 | const log = console.log 15 | 16 | let ccc = 1 17 | function nme(x) { 18 | return ccc++ + '-' + x 19 | } 20 | 21 | let find = _.find 22 | let keys = _.keys 23 | let filter = _.filter 24 | 25 | ava.serial.before.cb('path', t => { 26 | let tmpdir = cfg.getPath('tmp') 27 | 28 | // log('tmpdir is', tmpdir) 29 | rm(tmpdir, err => { 30 | t.assert(!err) 31 | mk(path.join(tmpdir, '.test', '_retail_', 'Interface', 'Addons'), err => { 32 | t.assert(!err) 33 | mk(path.join(tmpdir, '.test', '_retail_', 'WTF'), err => { 34 | t.assert(!err) 35 | mk(path.join(tmpdir, '.test', '_classic_', 'WTF'), err => { 36 | t.assert(!err) 37 | mk( 38 | path.join(tmpdir, '.test', '_classic_', 'Interface', 'Addons'), 39 | err => { 40 | t.assert(!err) 41 | ads.load() 42 | t.end() 43 | } 44 | ) 45 | }) 46 | }) 47 | }) 48 | }) 49 | }) 50 | 51 | ava.serial.cb(nme('appetizer'), t => { 52 | t.end() 53 | }) 54 | 55 | function commonTests(aa) { 56 | ava.serial.cb(nme('install'), t => { 57 | core.add(aa.map(a => a[0]), res => { 58 | let p = cfg.getPath('addon') 59 | t.assert(res.count === aa.length) 60 | t.assert(res.update === 0) 61 | 62 | aa.forEach(a => { 63 | t.assert(find(fs.readdirSync(p), d => d.match(a[1]))) 64 | }) 65 | 66 | ads.load() 67 | 68 | t.assert(keys(ads.data).length === aa.length) 69 | t.assert(!find(ads.data, d => !d.sub.length)) 70 | t.end() 71 | }) 72 | }) 73 | 74 | ava.serial.cb(nme('update-none'), t => { 75 | core.update(null, {}, res => { 76 | let p = cfg.getPath('addon') 77 | t.assert(res.count === 0) 78 | t.assert(res.update === 1) 79 | t.assert(res.ud === 0) 80 | 81 | aa.forEach(a => { 82 | t.assert(find(fs.readdirSync(p), d => d.match(a[1]))) 83 | }) 84 | 85 | ads.load() 86 | 87 | t.assert(keys(ads.data).length === aa.length) 88 | t.assert(!find(ads.data, d => !d.sub.length)) 89 | t.end() 90 | }) 91 | }) 92 | 93 | ava.serial.cb(nme('update-1'), t => { 94 | ads.data['classicon'].update = 0 95 | 96 | core.update(null, {}, res => { 97 | let p = cfg.getPath('addon') 98 | t.assert(res.count === 1) 99 | t.assert(res.update === 1) 100 | t.assert(res.ud === 1) 101 | 102 | aa.forEach(a => { 103 | t.assert(find(fs.readdirSync(p), d => d.match(a[1]))) 104 | }) 105 | 106 | ads.load() 107 | 108 | t.assert(ads.data['classicon'].update > 0) 109 | t.assert(keys(ads.data).length === aa.length) 110 | t.assert(!find(ads.data, d => !d.sub.length)) 111 | t.end() 112 | }) 113 | }) 114 | 115 | ava.serial.cb(nme('rm-1'), t => { 116 | core.rm(['classicon'], res => { 117 | let p = cfg.getPath('addon') 118 | 119 | t.assert(!find(fs.readdirSync(p), d => d.match(/^Class/))) 120 | 121 | ads.load() 122 | 123 | t.assert(!ads.data['classicon']) 124 | t.assert(keys(ads.data).length === aa.length - 1) 125 | 126 | core.add(['classicon'], () => { 127 | t.end() 128 | }) 129 | }) 130 | }) 131 | 132 | ava.serial.cb(nme('search-none'), t => { 133 | core.search('abcdef', info => { 134 | t.assert(!info) 135 | t.end() 136 | }) 137 | }) 138 | 139 | ava.serial.cb(nme('search-curse'), t => { 140 | core.search('dbm', info => { 141 | t.assert(info.data.length > 0) 142 | let v = info.data[0] 143 | 144 | // log('gg', info) 145 | t.assert(v.name.match(/Deadly Boss Mods/)) 146 | t.assert(v.key.match(/deadly-boss-mods/)) 147 | 148 | t.assert(vau.isUri(v.page)) 149 | t.assert(v.download > 200000000) 150 | t.assert(v.update > 1561424000) 151 | 152 | t.end() 153 | }) 154 | }) 155 | 156 | ava.serial.cb(nme('search-mmoui'), t => { 157 | core.search('mmoui:dbm', info => { 158 | t.assert(info.data.length > 0) 159 | let v = info.data[0] 160 | 161 | // log('gg', info) 162 | t.assert(v.name.match(/Deadly Boss Mods/)) 163 | 164 | if (cfg.getMode() === '_classic_') t.assert(v.key.match(/24921-/)) 165 | else t.assert(v.key.match(/8814-DeadlyBossMods/)) 166 | 167 | t.assert(vau.isUri(v.page)) 168 | t.assert(v.download > 100) 169 | t.assert(v.update > 1561424000) 170 | 171 | t.end() 172 | }) 173 | }) 174 | 175 | ava.serial.cb(nme('search-tukui'), t => { 176 | core.search('tukui:elv', info => { 177 | t.assert(info.data.length > 0) 178 | let v = info.data[0] 179 | 180 | // log('gg', info) 181 | // t.assert(v.name.match(/ElvUI/)) 182 | 183 | // if (cfg.getMode() === '_classic_') t.assert(v.key.match(/2-/)) 184 | // else t.assert(v.key.match(/0-/)) 185 | 186 | t.assert(vau.isUri(v.page)) 187 | t.assert(v.download > 100) 188 | t.assert(v.update > 1561424000) 189 | 190 | t.end() 191 | }) 192 | }) 193 | 194 | ava.serial.cb(nme('ls'), t => { 195 | let ls = core.ls({ long: 1 }) 196 | 197 | // t.assert(ls.search(cfg.getMode()) > 0) 198 | t.assert(ls.search('sellableitemdrops') > 0) 199 | t.assert(ls.search('classicon') > 0) 200 | 201 | ls = core.ls({}) 202 | 203 | // t.assert(ls.search(cfg.getMode()) > 0) 204 | t.assert(ls.search('sellableitemdrops') > 0) 205 | t.assert(ls.search('classicon') > 0) 206 | 207 | t.end() 208 | }) 209 | 210 | ava.serial.cb(nme('info-none'), t => { 211 | core.info('abcdef', res => { 212 | t.assert(!res) 213 | t.end() 214 | }) 215 | }) 216 | 217 | ava.serial.cb(nme('info-curse'), t => { 218 | core.info('deadly-boss-mods', res => { 219 | t.assert(res.match(/Deadly Boss Mods/)) 220 | t.assert(res.match(/MysticalOS/)) 221 | t.assert(res.match(/curse/)) 222 | // t.assert(res.search(cfg.getGameVersion()) > 0) 223 | t.end() 224 | }) 225 | }) 226 | 227 | ava.serial.cb(nme('info-mmoui'), t => { 228 | core.info('8814-xx', res => { 229 | t.assert(res.match(/Deadly Boss Mods/)) 230 | t.assert(res.match(/mmoui/)) 231 | t.end() 232 | }) 233 | }) 234 | 235 | ava.serial.cb(nme('import'), t => { 236 | ads.data = {} 237 | ads.save() 238 | 239 | core.pickup(res => { 240 | ads.load() 241 | 242 | log(ads.data) 243 | 244 | t.assert(keys(ads.data).length === filter(aa, a => a[2]).length) 245 | // aa.forEach(a => { 246 | // if (a[2]) t.assert(find(keys(ads.data), k => k.search(a[2]) >= 0)) 247 | // }) 248 | 249 | t.end() 250 | }) 251 | }) 252 | } 253 | 254 | commonTests([ 255 | ['deadlybossmods/deadlybossmods', /^DBM/, 1], 256 | ['classicon', /^Class/, 1], 257 | ['mmoui:11190-Bartender4', /^Bart/, 1], 258 | ['tukui:66-1ElvUIExtraDataTexts', /^ElvUI/, 1], 259 | ['sellableitemdrops', /^Sella/, 1], 260 | ['https://git.tukui.org/Azilroka/AddOnSkins.git@main', 'AddOnSkins', 1] 261 | ]) 262 | 263 | ava.serial.cb(nme('info-tukui-retail'), t => { 264 | core.info('0-1ElvUI', res => { 265 | t.assert(res.match(/[0-9]+\/[0-9]+\/[0-9]+/)) 266 | t.assert(res.match(/[0-9]+\.[0-9]+/g).length === 3) 267 | t.assert(res.match(/\.zip/)) 268 | t.end() 269 | }) 270 | }) 271 | 272 | ava.serial.cb(nme('switch-to-classic'), t => { 273 | core.switch({}) 274 | t.end() 275 | }) 276 | 277 | commonTests([ 278 | ['deadlybossmods/deadlybossmods', /^DBM/, 1], 279 | ['bigwigsmods/bigwigs/classic', /^BigWigs/, 1], 280 | ['classicon', /^Class/, 1], 281 | ['mmoui:11190-Bartender4', /^Bart/, 1], 282 | ['tukui:6-2RedtuzkUIClassic', /^ElvUI/, 0], 283 | ['sellableitemdrops', /^Sella/, 1] 284 | ]) 285 | 286 | ava.serial.cb(nme('wowa update'), t => { 287 | core.checkUpdate(res => { 288 | t.assert(res.name === 'wowa') 289 | 290 | core.checkUpdate(res => { 291 | t.assert(res.name === 'wowa') 292 | 293 | t.end() 294 | }) 295 | }) 296 | }) 297 | 298 | ava.serial.cb(nme('name parser'), t => { 299 | let names = { 300 | 'curse:molinari': { key: 'molinari', source: 'curse' }, 301 | 'https://www.curseforge.com/wow/addons/molinari': { 302 | key: 'molinari', 303 | source: 'curse' 304 | }, 305 | 'https://wow.curseforge.com/projects/molinari': { 306 | key: 'molinari', 307 | source: 'curse' 308 | }, 309 | 'wowi:13188': { key: '13188', source: 'mmoui' }, 310 | 'https://www.wowinterface.com/downloads/info13188-Molinari.html': { 311 | key: '13188-Molinari', 312 | source: 'mmoui' 313 | }, 314 | 'deadly-boss-mods': { key: 'deadly-boss-mods', source: null }, 315 | 'curse:deadly-boss-mods': { key: 'deadly-boss-mods', source: 'curse' }, 316 | 'mmoui:8814-DeadlyBossMods': { 317 | key: '8814-DeadlyBossMods', 318 | source: 'mmoui' 319 | }, 320 | '8814-DeadlyBossMods': { key: '8814-DeadlyBossMods', source: null }, 321 | 'deadlybossmods/deadlybossmods': { 322 | key: 'deadlybossmods/deadlybossmods', 323 | source: 'github' 324 | }, 325 | 'bigwigsmods/bigwigs/classic': { 326 | key: 'bigwigsmods/bigwigs/classic', 327 | source: 'github' 328 | }, 329 | 'antiwinter/dlt': { key: 'antiwinter/dlt', source: 'github' }, 330 | 'https://github.com/BigWigsMods/BigWigs/tree/master': { 331 | source: 'github', 332 | key: 'BigWigsMods/BigWigs/tree/master' 333 | }, 334 | 'https://github.com/BigWigsMods/BigWigs/tree': { 335 | source: 'github', 336 | key: 'BigWigsMods/BigWigs/tree' 337 | }, 338 | 'https://github.com/BigWigsMods/BigWigs': { 339 | source: 'github', 340 | key: 'BigWigsMods/BigWigs' 341 | }, 342 | 'https://www.tukui.org/classic-addons.php?id=6': { 343 | source: 'tukui', 344 | key: '6' 345 | } 346 | } 347 | 348 | for (let k in names) { 349 | let d = api.parseName(k) 350 | 351 | let r = names[k] 352 | 353 | for (let k2 in r) { 354 | if (r[k2]) t.assert(r[k2] === d[k2]) 355 | t.assert(!r[k2] === !d[k2]) 356 | } 357 | } 358 | 359 | t.end() 360 | }) 361 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WoWA 2 | 3 | [![cover](https://coveralls.io/repos/github/antiwinter/wowa/badge.svg?branch=master)](https://coveralls.io/github/antiwinter/wowa?branch=master) 4 | [![status](https://travis-ci.com/antiwinter/wowa.svg?branch=master)](https://travis-ci.com/antiwinter/wowa) 5 | [![npm](https://img.shields.io/npm/v/wowa.svg)](https://www.npmjs.com/package/wowa) 6 | [![npm](https://img.shields.io/npm/l/wowa.svg)](https://github.com/antiwinter/wowa/blob/master/LICENSE) 7 | [![install size](https://packagephobia.now.sh/badge?p=wowa)](https://packagephobia.now.sh/result?p=wowa) 8 | ![platform](https://img.shields.io/badge/platform-windows%20%7C%20macos%20%7C%20linux-lightgrey) 9 | 10 | **Recent Notice** 11 | 12 | **WoWA** dev is discontinued for several reasons. The most important one of them is that curseforge/overwolf has already developed very good [clients](https://download.curseforge.com/#download-options) for there addons for each platform (win/mac/even linux). Not like the moment that I decided to develop wowa, back when they only had a twitch client for only Windows. 13 | 14 | Thank you for using **WoWA**. Thank you for those who have liked **WoWA** 15 | 16 | --- 17 | 18 | **WoWA** stands for World of Warcraft Assistant, it is designed to help managing WoW addons, uploading WCL logs, etc. 19 | 20 | There used to be some command line manager for WoW addons in the past, but are mostly out of maintaince at this time. A list of these projects can be found in the [related prjects](#related-projects) section. 21 | 22 | As comparing to these projects, **WoWA** offers serveral advantages: 23 | 24 | - Better CLI interface: colorful and meaningful 25 | - Concurrency: when installing or updating, WoWA can take advantage of multi-processing 26 | - **wowaads.json** file: this is the file where WoWA stores addon information. Unlike other projects, WoWA stores this file in the **WTF** folder. This design benifits people when they want to backup their WoW setting. Backing up one **WTF** folder is enough 27 | 28 | ## Install 29 | 30 | ``` 31 | npm install -g wowa 32 | ``` 33 | 34 | ## Setup WoW path 35 | 36 | The WoW path configuration file is located at `%APPDATA%/wowa/wow_path.txt` on Windows, and `~/.wowa/wow_path.txt` on macOS or Linux. 37 | 38 | Normally **wowa** would remind you to edit this file if it cannot find the game at its default location. 39 | 40 | ## Usage 41 | 42 | ### Quick reference 43 | 44 | **To install an addon** 45 | 46 | ``` 47 | wowa add deadly-boss-mods # install dbm from curse 48 | wowa add curse:deadly-boss-mods # install dbm from curse 49 | wowa add mmoui:8814-DeadlyBossMods # install dbm from wowinterface 50 | wowa add 8814-DeadlyBossMods # install dbm from wowinterface 51 | wowa add deadlybossmods/deadlybossmods # install dbm from github 52 | wowa add bigwigsmods/bigwigs/classic # install bigwigs (classic branch) from github 53 | wowa add antiwinter/dlt # install dlt from github 54 | ``` 55 | 56 | **To search an addon** 57 | 58 | ``` 59 | wowa search dbm # search for dbm automatically 60 | wowa search mmoui:dbm # search for dbm only from wowinterface 61 | ``` 62 | 63 | **If an addon does not provide a classic version, but the author declares that the addon supports classic. You can:** 64 | 65 | ``` 66 | wowa add some-addon --anyway 67 | ``` 68 | 69 | *Note: you will need to check the __Load out of date addons__ option in game* 70 | 71 | ### Installing an addon 72 | 73 | ![](https://raw.githubusercontent.com/antiwinter/scrap/master/wowa/ins1-min.gif) 74 | 75 | ### Installing an addon from arbitrary repo 76 | 77 | ``` 78 | wowa add https://git.tukui.org/Azilroka/AddOnSkins.git # install master branch 79 | wowa add https://git.tukui.org/Azilroka/AddOnSkins.git@master # install master branch 80 | wowa add https://git.tukui.org/Azilroka/AddOnSkins.git@v1.88 # install tag v1.88 81 | ``` 82 | 83 | ### Search for an addon 84 | 85 | ![](https://raw.githubusercontent.com/antiwinter/scrap/master/wowa/search-min.gif) 86 | 87 | **Note:** that WoWA manages addons by keys (keys are provided by [curse](https://www.curseforge.com)) not by addon names, sometimes they are different. If you are not sure a key for an addon, you can search that addon by some fuzzy name, and the search result provides the correct key to use. 88 | 89 | ### Installing two or more addons 90 | 91 | ![](https://raw.githubusercontent.com/antiwinter/scrap/master/wowa/ins2-min.gif) 92 | 93 | ### Removing an addon 94 | 95 | ![](https://raw.githubusercontent.com/antiwinter/scrap/master/wowa/rm-min.gif) 96 | 97 | ### Update all installed addons 98 | 99 | ![](https://raw.githubusercontent.com/antiwinter/scrap/master/wowa/update-min.gif) 100 | 101 | ### Pin an addon, prevent it from updating 102 | 103 | ``` 104 | wowa pin deadly-boss-mods # addon is pinned to it's current version 105 | wowa unpin deadly-boss-mods # addon is unpinned 106 | ``` 107 | 108 | `wowa ls -l` displays an exclaimation mark before version, incicating that addon is pinned. 109 | 110 | ### List all installed addons 111 | 112 | ![](https://raw.githubusercontent.com/antiwinter/scrap/master/wowa/ls-min.gif) 113 | 114 | ### Import local addons 115 | 116 | If use **wowa** for the first time, you need to import your local addon. Then **wowa** can manage them for you. 117 | 118 | ``` 119 | wowa import 120 | ``` 121 | 122 | ### Switch modes 123 | 124 | ``` 125 | wowa sw switch between _retail_ and _classic_ [TBC] 126 | wowa sw --ptr switch mode to: retail PTR 127 | wowa sw --beta switch mode to: retail BETA 128 | wowa sw --retail switch mode to: retail formal 129 | wowa sw --retail-ptr switch mode to: retail PTR 130 | wowa sw --retail-beta switch mode to: retail BETA 131 | wowa sw --classic switch mode to: classic formal [Vanilla] 132 | wowa sw --classic-ptr switch mode to: classic PTR 133 | wowa sw --classic-beta switch mode to: classic BETA 134 | wowa sw --classic-tbc switch mode to: classic [TBC] 135 | ``` 136 | 137 | ## Related projects 138 | 139 | ### Actively maintained 140 | 141 | - [layday/instawow](https://github.com/layday/instawow) - ![update](https://img.shields.io/github/last-commit/layday/instawow) ![interface](https://img.shields.io/badge/interface-CLI-brightgreen) ![lang](https://img.shields.io/github/languages/top/layday/instawow) ![stars](https://img.shields.io/github/stars/layday/instawow) 142 | - [erikabp123/ClassicAddonManager](https://github.com/erikabp123/ClassicAddonManager) - ![update](https://img.shields.io/github/last-commit/erikabp123/ClassicAddonManager) ![interface](https://img.shields.io/badge/interface-GUI-brightgreen) ![lang](https://img.shields.io/github/languages/top/erikabp123/ClassicAddonManager) ![stars](https://img.shields.io/github/stars/erikabp123/ClassicAddonManager) 143 | - [AcidWeb/CurseBreaker](https://github.com/AcidWeb/CurseBreaker) - ![update](https://img.shields.io/github/last-commit/AcidWeb/CurseBreaker) ![interface](https://img.shields.io/badge/interface-CLI-brightgreen) ![lang](https://img.shields.io/github/languages/top/AcidWeb/CurseBreaker) ![stars](https://img.shields.io/github/stars/AcidWeb/CurseBreaker) 144 | - [Saionaro/wow-addons-updater](https://github.com/Saionaro/wow-addons-updater) - ![update](https://img.shields.io/github/last-commit/Saionaro/wow-addons-updater) ![interface](https://img.shields.io/badge/interface-GUI-brightgreen) ![lang](https://img.shields.io/github/languages/top/Saionaro/wow-addons-updater) ![stars](https://img.shields.io/github/stars/Saionaro/wow-addons-updater) 145 | - [ogri-la/wowman](https://github.com/ogri-la/wowman) - ![update](https://img.shields.io/github/last-commit/ogri-la/wowman) ![interface](https://img.shields.io/badge/interface-GUI-brightgreen) ![lang](https://img.shields.io/github/languages/top/ogri-la/wowman) ![stars](https://img.shields.io/github/stars/ogri-la/wowman) 146 | - [vargen2/Addon](https://github.com/vargen2/Addon) - ![update](https://img.shields.io/github/last-commit/vargen2/Addon) ![interface](https://img.shields.io/badge/interface-GUI-brightgreen) ![lang](https://img.shields.io/github/languages/top/vargen2/Addon) ![stars](https://img.shields.io/github/stars/vargen2/Addon) 147 | - [ephraim/lcurse](https://github.com/ephraim/lcurse) - ![update](https://img.shields.io/github/last-commit/ephraim/lcurse) ![interface](https://img.shields.io/badge/interface-GUI-brightgreen) ![lang](https://img.shields.io/github/languages/top/ephraim/lcurse) ![stars](https://img.shields.io/github/stars/ephraim/lcurse) 148 | 149 | ### Not Actively maintained 150 | 151 | - [nazarov-tech/wowa](https://github.com/nazarov-tech/wowa) - ![update](https://img.shields.io/github/last-commit/nazarov-tech/wowa) ![interface](https://img.shields.io/badge/interface-CLI-brightgreen) ![lang](https://img.shields.io/github/languages/top/nazarov-tech/wowa) ![stars](https://img.shields.io/github/stars/nazarov-tech/wowa) 152 | - [Lund259/WoW-Addon-Manager](https://github.com/Lund259/WoW-Addon-Manager) - ![update](https://img.shields.io/github/last-commit/Lund259/WoW-Addon-Manager) ![interface](https://img.shields.io/badge/interface-GUI-brightgreen) ![lang](https://img.shields.io/github/languages/top/Lund259/WoW-Addon-Manager) ![stars](https://img.shields.io/github/stars/Lund259/WoW-Addon-Manager) 153 | - [OpenAddOnManager/OpenAddOnManager](https://github.com/OpenAddOnManager/OpenAddOnManager) - ![update](https://img.shields.io/github/last-commit/OpenAddOnManager/OpenAddOnManager) ![interface](https://img.shields.io/badge/interface-GUI-brightgreen) ![lang](https://img.shields.io/github/languages/top/OpenAddOnManager/OpenAddOnManager) ![stars](https://img.shields.io/github/stars/OpenAddOnManager/OpenAddOnManager) 154 | - [vargen2/addonmanager](https://github.com/vargen2/addonmanager) - ![update](https://img.shields.io/github/last-commit/vargen2/addonmanager) ![interface](https://img.shields.io/badge/interface-GUI-brightgreen) ![lang](https://img.shields.io/github/languages/top/vargen2/addonmanager) ![stars](https://img.shields.io/github/stars/vargen2/addonmanager) 155 | - [qwezarty/wow-addon-manager](https://github.com/qwezarty/wow-addon-manager) - ![update](https://img.shields.io/github/last-commit/qwezarty/wow-addon-manager) ![interface](https://img.shields.io/badge/interface-CLI-brightgreen) ![lang](https://img.shields.io/github/languages/top/qwezarty/wow-addon-manager) ![stars](https://img.shields.io/github/stars/qwezarty/wow-addon-manager) 156 | - [WorldofAddons/worldofaddons](https://github.com/WorldofAddons/worldofaddons) - ![update](https://img.shields.io/github/last-commit/WorldofAddons/worldofaddons) ![interface](https://img.shields.io/badge/interface-GUI-brightgreen) ![lang](https://img.shields.io/github/languages/top/WorldofAddons/worldofaddons) ![stars](https://img.shields.io/github/stars/WorldofAddons/worldofaddons) 157 | - [sysworx/wowam](https://github.com/sysworx/wowam) - ![update](https://img.shields.io/github/last-commit/sysworx/wowam) ![interface](https://img.shields.io/badge/interface-GUI-brightgreen) ![lang](https://img.shields.io/github/languages/top/sysworx/wowam) ![stars](https://img.shields.io/github/stars/sysworx/wowam) 158 | - [kuhnertdm/wow-addon-updater](https://github.com/kuhnertdm/wow-addon-updater) - ![update](https://img.shields.io/github/last-commit/kuhnertdm/wow-addon-updater) ![interface](https://img.shields.io/badge/interface-CLI-brightgreen) ![lang](https://img.shields.io/github/languages/top/kuhnertdm/wow-addon-updater) ![stars](https://img.shields.io/github/stars/kuhnertdm/wow-addon-updater) 159 | - [JonasKnarbakk/GWAM](https://github.com/JonasKnarbakk/GWAM) - ![update](https://img.shields.io/github/last-commit/JonasKnarbakk/GWAM) ![interface](https://img.shields.io/badge/interface-GUI-brightgreen) ![lang](https://img.shields.io/github/languages/top/JonasKnarbakk/GWAM) ![stars](https://img.shields.io/github/stars/JonasKnarbakk/GWAM) 160 | - [Sumolari/WAM](https://github.com/Sumolari/WAM) - ![update](https://img.shields.io/github/last-commit/Sumolari/WAM) ![interface](https://img.shields.io/badge/interface-CLI-brightgreen) ![lang](https://img.shields.io/github/languages/top/Sumolari/WAM) ![stars](https://img.shields.io/github/stars/Sumolari/WAM) 161 | - [wttw/wowaddon](https://github.com/wttw/wowaddon) - ![update](https://img.shields.io/github/last-commit/wttw/wowaddon) ![interface](https://img.shields.io/badge/interface-CLI-brightgreen) ![lang](https://img.shields.io/github/languages/top/wttw/wowaddon) ![stars](https://img.shields.io/github/stars/wttw/wowaddon) 162 | - [DayBr3ak/wow-better-cli](https://github.com/DayBr3ak/wow-better-cli) - ![update](https://img.shields.io/github/last-commit/DayBr3ak/wow-better-cli) ![interface](https://img.shields.io/badge/interface-CLI-brightgreen) ![lang](https://img.shields.io/github/languages/top/DayBr3ak/wow-better-cli) ![stars](https://img.shields.io/github/stars/DayBr3ak/wow-better-cli) 163 | - [acdtrx/wowam](https://github.com/acdtrx/wowam) - ![update](https://img.shields.io/github/last-commit/acdtrx/wowam) ![interface](https://img.shields.io/badge/interface-CLI-brightgreen) ![lang](https://img.shields.io/github/languages/top/acdtrx/wowam) ![stars](https://img.shields.io/github/stars/acdtrx/wowam) 164 | - [zekesonxx/wow-cli](https://github.com/zekesonxx/wow-cli) - ![update](https://img.shields.io/github/last-commit/zekesonxx/wow-cli) ![interface](https://img.shields.io/badge/interface-CLI-brightgreen) ![lang](https://img.shields.io/github/languages/top/zekesonxx/wow-cli) ![stars](https://img.shields.io/github/stars/zekesonxx/wow-cli) 165 | - [SeriousBug/WoWutils](https://github.com/SeriousBug/WoWutils) - ![update](https://img.shields.io/github/last-commit/SeriousBug/WoWutils) ![interface](https://img.shields.io/badge/interface-CLI-brightgreen) ![lang](https://img.shields.io/github/languages/top/SeriousBug/WoWutils) ![stars](https://img.shields.io/github/stars/SeriousBug/WoWutils) 166 | 167 | ## Roadmap 168 | 169 | - [x] Support projects on wowinterface.com 170 | - [x] Support projects on github.com 171 | - [ ] Game version detection 172 | - [x] Add test cases 173 | - [x] Support projects on tukui.org 174 | - [x] **Support WoW Classic !** 175 | - [x] Import existing addons 176 | - [x] Check **wowa** updates 177 | - [ ] Optimize color scheme 178 | - [ ] Shrink size of package 179 | - [ ] Support releasing UI (addons list, together with settings) to github.com 180 | - [ ] Support backing up to github.com 181 | - [ ] Support restoring from github.com 182 | - [ ] Support uploading to warcraftlogs.com 183 | 184 | ## GFW treatment 185 | 186 | 如果你身在国内并经常更新 database 缓慢或出现以下错误,可以按下文的方法解决。 187 | 188 | ``` 189 | Updating database...require curse db failed RequestError: read ECONNRESET 190 | ``` 191 | 192 | **解决方法** 193 | 194 | 1. 按快捷键 **Win**+**x**,**a**,打开管理员权限的 **powershell** 195 | 2. 输入以下命令编辑 *hosts* 文件,`notepad c:\windows\system32\drivers\etc\hosts` 196 | 3. 在 *hosts* 文件中增加以下两行,保存退出 197 | ``` 198 | 199.232.68.133 raw.githubusercontent.com 199 | 140.82.112.5 api.github.com 200 | ``` 201 | 4. 在 **powershell** 中输入 `ipconfig /flushdns` 202 | 203 | ### A more convenient method 204 | 205 | 1. Use [proxychains-windows](https://github.com/shunf4/proxychains-windows) 206 | 2. Add bellow code to *C:\Windows\System32\WindowsPowerShell\v1.0\profile.ps1* 207 | ``` 208 | function wowa{ 209 | echo 'v.p.' 210 | proxychains.exe -q wowa $args 211 | } 212 | ``` 213 | -------------------------------------------------------------------------------- /core.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const rm = require('rimraf') 4 | const mk = require('mkdirp') 5 | const numeral = require('numeral') 6 | const moment = require('moment') 7 | const async = require('async') 8 | const ncp = require('ncp').ncp 9 | const tb = require('easy-table') 10 | const Listr = require('listr') 11 | const _ = require('underscore') 12 | const g = require('got') 13 | const pi = require('package-info') 14 | 15 | const api = require('./source') 16 | const cfg = require('./lib/config') 17 | const unzip = require('./lib/unzip') 18 | const cl = require('./lib/color') 19 | const ads = require('./lib/wowaads').load() 20 | const pkg = require('./package.json') 21 | const log = console.log 22 | 23 | function getAd(ad, info, tmp, hook) { 24 | let src = path.join(tmp, '1.zip') 25 | let dst = path.join(tmp, 'dec') 26 | 27 | // download git 28 | if (info.source === 'git') 29 | return api.$api.git.clone(ad.uri, ad.branch, dst, hook) 30 | 31 | let v = info.version[0] 32 | if (!v) { 33 | log('fatal: version not found') 34 | return hook() 35 | } 36 | 37 | if (ad.version) v = _.find(info.version, d => d.name === ad.version) 38 | 39 | // log('streaming', v.link) 40 | 41 | // fix version 42 | ad.version = v.name 43 | g.stream(v.link, { 44 | headers: { 45 | 'user-agent': require('ua-string') 46 | } 47 | }) 48 | .on('downloadProgress', hook) 49 | .on('error', err => { 50 | // log('stream error', typeof err, err) 51 | hook(err ? err.toString() : 'download error') 52 | }) 53 | .pipe(fs.createWriteStream(src)) 54 | .on('close', () => { 55 | unzip(src, dst, err => { 56 | if (err) return hook('unzip failed') 57 | hook('done') 58 | }) 59 | }) 60 | } 61 | 62 | function _install(from, to, sub, done) { 63 | let ls = fs.readdirSync(from) 64 | let c_ver = cfg.getMode() === '_classic_' ? 65 | (cfg.getClassicExp() === '[TBC]' ? /bcc|tbc/i : /classic/i) 66 | : null 67 | 68 | let _toc = _.filter(ls, x => x.match(/\.toc$/)) 69 | let toc // toc to use 70 | let itoc // no -bcc/-classic 71 | 72 | if (_toc.length >= 1) { 73 | if (_toc.length > 1) 74 | if (c_ver) toc = _.find(_toc, x => x.match(c_ver)) 75 | itoc = _.filter(_toc, x => !x.match(/bcc|tbc|classic/i))[0] 76 | if (!itoc) itoc = _toc[0] 77 | if (!toc) toc = itoc 78 | } 79 | 80 | // log('\n\n searching', from, toc, to, 'itoc', itoc) 81 | if (itoc) { 82 | let dir = itoc.replace(/\.toc$/, '') 83 | let target = path.join(to, dir) 84 | 85 | // log('\n\ntoc found, copy', from, '>>', target, '\n\n') 86 | rm(target, err => { 87 | // log('\n\n', 'rm err', err) 88 | mk(target, err => { 89 | ncp(from, target, err => { 90 | if (itoc != toc) { 91 | fs.renameSync(path.join(target, itoc), path.join(target, itoc + '.bak')) 92 | fs.renameSync(path.join(target, toc), path.join(target, itoc)) 93 | } 94 | done(err) 95 | }) 96 | sub.push(dir) 97 | }) 98 | }) 99 | } else { 100 | async.eachLimit( 101 | _.filter(ls.map(x => path.join(from, x)), x => 102 | fs.statSync(x).isDirectory() 103 | ), 104 | 1, 105 | (d, cb) => { 106 | _install(d, to, sub, err => { 107 | if (err) { 108 | log('\n\nerr??', err, '\n\n') 109 | done(err) 110 | cb(false) 111 | return 112 | } 113 | // log('\n\ninstalling from', d, 'to', to, sub, '\n\n') 114 | cb() 115 | }) 116 | }, 117 | () => { 118 | done() 119 | } 120 | ) 121 | } 122 | } 123 | 124 | function install(ad, update, hook) { 125 | // log('installing', ad) 126 | let tmp = path.join(cfg.getPath('tmp'), ad.key.replace(/\/|:/g, '.')) 127 | let notify = (status, msg) => { 128 | hook({ 129 | status, 130 | msg 131 | }) 132 | } 133 | 134 | if (update && ad.pin) return notify('skip', 'is pinned') 135 | 136 | notify('ongoing', update ? 'checking for updates...' : 'waiting...') 137 | 138 | api.info(ad, info => { 139 | if (!info) return notify('failed', 'not available') 140 | 141 | // fix source 142 | ad.source = info.source 143 | 144 | let _d = ads.data[ad.key] 145 | if ( 146 | update && 147 | _d && 148 | (_d.update >= info.update || (_d.hash && _d.hash === info.hash)) 149 | ) 150 | return notify('skip', 'is already up to date') 151 | 152 | notify('ongoing', 'preparing download...') 153 | rm(tmp, err => { 154 | if (err) return notify('failed', 'failed to rmdir ' + JSON.stringify(err)) 155 | 156 | let dec = path.join(tmp, 'dec') 157 | mk(dec, err => { 158 | if (err) 159 | return notify('failed', 'failed to mkdir ' + JSON.stringify(err)) 160 | 161 | let size = 0 162 | notify('ongoing', 'downloading...') 163 | getAd(ad, info, tmp, evt => { 164 | if (!evt || (typeof evt === 'string' && evt !== 'done')) { 165 | notify('failed', !evt ? 'failed to download' : evt) 166 | } else if (evt === 'done') { 167 | notify('ongoing', 'clearing previous install...') 168 | 169 | ads.clearUp(ad.key, () => { 170 | let d = (ads.data[ad.key] = { 171 | name: info.name, 172 | version: ad.version, 173 | size, 174 | source: info.source, 175 | update: info.update, 176 | sub: [] 177 | }) 178 | 179 | if (ad.anyway) d.anyway = ad.anyway 180 | if (ad.branch) d.branch = ad.branch 181 | if (ad.source === 'git') { 182 | d.uri = ad.uri 183 | d.hash = info.hash 184 | } 185 | 186 | _install(dec, cfg.getPath('addon'), d.sub, err => { 187 | if (err) return notify('failed', 'failed to copy file') 188 | 189 | ads.save() 190 | notify('done', update ? 'updated' : 'installed') 191 | }) 192 | }) 193 | } else { 194 | notify( 195 | 'ongoing', 196 | `downloading... ${(evt.percent * 100).toFixed(0)}%` 197 | ) 198 | size = evt.transferred 199 | // log(evt) 200 | } 201 | }) 202 | }) 203 | }) 204 | }) 205 | } 206 | 207 | function batchInstall(aa, update, done) { 208 | let t0 = moment().unix() 209 | 210 | let list = new Listr([], { 211 | concurrent: 10, 212 | renderer: process.env.TEST_WOWA ? 'silent' : 'default' 213 | }) 214 | let ud = 0 215 | let id = 0 216 | 217 | aa.forEach(ad => { 218 | list.add({ 219 | title: `${cl.h(ad.key)} waiting...`, 220 | task(ctx, task) { 221 | let promise = new Promise((res, rej) => { 222 | install(ad, update, evt => { 223 | if (!task.$st) { 224 | task.title = '' 225 | task.title += cl.h(ad.key) 226 | if (ad.version) task.title += cl.i2(' @' + cl.i2(ad.version)) 227 | if (ad.source) task.title += cl.i(` [${ad.source}]`) 228 | 229 | task.title += ' ' + cl.x(evt.msg) 230 | } 231 | 232 | if ( 233 | evt.status === 'done' || 234 | evt.status === 'skip' || 235 | evt.status === 'failed' 236 | ) { 237 | task.$st = evt.status 238 | if (evt.status !== 'done') task.skip() 239 | else { 240 | if (update) ud++ 241 | id++ 242 | } 243 | 244 | res('ok') 245 | } 246 | }) 247 | }) 248 | 249 | return promise 250 | } 251 | }) 252 | }) 253 | 254 | list.run().then(res => { 255 | ads.save() 256 | log(`\n${id} addons` + (update ? `, ${ud} updated` : ' installed')) 257 | log(`✨ done in ${moment().unix() - t0}s.\n`) 258 | if (done) done({ count: id, update, ud }) 259 | }) 260 | } 261 | 262 | let core = { 263 | add(aa, done) { 264 | api.getDB(db => { 265 | log('\nInstalling addon' + (aa.length > 1 ? 's...' : '...') + '\n') 266 | batchInstall(aa.map(x => api.parseName(x)), 0, done) 267 | }) 268 | }, 269 | 270 | rm(keys, done) { 271 | let n = 0 272 | async.eachLimit( 273 | keys, 274 | 1, 275 | (key, cb) => { 276 | ads.clearUp(key, err => { 277 | if (!err) n++ 278 | ads.save() 279 | cb() 280 | }) 281 | }, 282 | () => { 283 | log(`✨ ${n} addon${n > 1 ? 's' : ''} removed.`) 284 | if (done) done() 285 | } 286 | ) 287 | }, 288 | 289 | pin(keys, pup) { 290 | let d = ads.data 291 | 292 | let n = 0 293 | keys.forEach(k => { 294 | if (d[k]) { 295 | d[k].pin = pup 296 | n++ 297 | } 298 | }) 299 | ads.save() 300 | 301 | log(`✨ ${n} addon${n > 1 ? 's' : ''} ${pup ? '' : 'un'}pinned.`) 302 | }, 303 | 304 | search(text, done) { 305 | // log(text) 306 | 307 | api.search(api.parseName(text), info => { 308 | if (!info) { 309 | log('\nNothing is found\n') 310 | if (done) done(info) 311 | return 312 | } 313 | 314 | let kv = (k, v) => { 315 | let c = cl.i 316 | let h = cl.x 317 | 318 | return `${h(k + ':') + c(' ' + v + '')}` 319 | } 320 | 321 | let data = info.data.slice(0, 15) 322 | 323 | log(`\n${cl.i(data.length)} results from ${cl.i(info.source)}`) 324 | 325 | data.forEach((v, i) => { 326 | log() 327 | log(cl.h(v.name) + ' ' + cl.x('(' + v.page + ')')) 328 | log( 329 | ` ${kv('key', v.key)} ${kv( 330 | 'download', 331 | numeral(v.download).format('0.0a') 332 | )} ${kv('version', moment(v.update * 1000).format('MM/DD/YYYY'))}` 333 | ) 334 | // log('\n ' + v.desc) 335 | }) 336 | 337 | log() 338 | if (done) done(info) 339 | }) 340 | }, 341 | 342 | ls(opt) { 343 | let t = new tb() 344 | let _d = ads.data 345 | 346 | let ks = _.keys(_d) 347 | 348 | ks.sort((a, b) => { 349 | return opt.time 350 | ? _d[b].update - _d[a].update 351 | : 1 - (a.replace(/[^a-zA-Z]/g, '') < b.replace(/[^a-zA-Z]/g, '')) * 2 352 | }) 353 | 354 | ks.forEach(k => { 355 | let v = _d[k] 356 | 357 | t.cell(cl.x('Addon keys'), cl.h(k) + (v.anyway ? cl.i2(' [anyway]') : '')) 358 | t.cell(cl.x('Version'), (v.pin ? cl.i('! ') : '') + cl.i2(v.version)) 359 | t.cell(cl.x('Source'), cl.i(v.source)) 360 | t.cell(cl.x('Update'), cl.i(moment(v.update * 1000).format('YYYY-MM-DD'))) 361 | t.newRow() 362 | }) 363 | 364 | log() 365 | 366 | if (!ks.length) log('no addons\n') 367 | else log(opt.long ? t.toString() : cl.h(cl.ls(ks))) 368 | 369 | ads.checkDuplicate() 370 | 371 | log( 372 | `${cl.x('You are in: ')} ${cl.i(cfg.getMode())} ${cl.i2( 373 | cfg.getMode('ver') 374 | )}\n` 375 | ) 376 | 377 | let ukn = ads.unknownDirs() 378 | 379 | if (ukn.length) { 380 | log( 381 | cl.x( 382 | `❗ ${ukn.length} folder${ukn.length > 1 ? 's' : '' 383 | } not managing by wowa` 384 | ) 385 | ) 386 | log(cl.x('---------------------------------')) 387 | log(cl.x(cl.ls(ukn))) 388 | } 389 | 390 | return t.toString() 391 | }, 392 | 393 | info(ad, done) { 394 | let t = new tb() 395 | 396 | ad = api.parseName(ad) 397 | api.info(ad, info => { 398 | log('\n' + cl.h(ad.key) + '\n') 399 | if (!info) { 400 | log('Not available\n') 401 | if (done) done() 402 | return 403 | } 404 | 405 | let kv = (k, v) => { 406 | // log('adding', k, v) 407 | t.cell(cl.x('Item'), cl.x(k)) 408 | t.cell(cl.x('Info'), cl.i(v)) 409 | t.newRow() 410 | } 411 | 412 | for (let k in info) { 413 | if (k === 'version' || info[k] === undefined) continue 414 | 415 | kv( 416 | k, 417 | k === 'create' || k === 'update' 418 | ? moment(info[k] * 1000).format('MM/DD/YYYY') 419 | : k === 'download' 420 | ? numeral(info[k]).format('0.0a') 421 | : info[k] 422 | ) 423 | } 424 | 425 | let v = info.version[0] 426 | if (v && info.source !== 'git') { 427 | kv('version', v.name) 428 | if (v.size) kv('size', v.size) 429 | if (v.game) 430 | kv('game version', _.uniq(info.version.map(x => x.game)).join(', ')) 431 | if (v.link) kv('link', v.link) 432 | } 433 | 434 | log(t.toString()) 435 | if (done) done(t.toString()) 436 | }) 437 | }, 438 | 439 | update(keys, opt, done) { 440 | api.getDB(opt.db ? null : db => { 441 | let aa = [] 442 | if (!keys) keys = _.keys(ads.data) 443 | 444 | keys.forEach(k => { 445 | if (k in ads.data) 446 | aa.push({ 447 | key: k, 448 | source: ads.data[k].source, 449 | anyway: ads.data[k].anyway && cfg.anyway(), 450 | branch: ads.data[k].branch, 451 | uri: ads.data[k].uri, 452 | hash: ads.data[k].hash, 453 | pin: ads.data[k].pin 454 | }) 455 | }) 456 | 457 | if (!aa.length) { 458 | log('\nnothing to update\n') 459 | return 460 | } 461 | 462 | if (ads.checkDuplicate()) return 463 | log('\nUpdating addons:\n') 464 | batchInstall(aa, 1, done) 465 | }) 466 | }, 467 | 468 | restore(repo, done) { 469 | if (repo) { 470 | log('\nrestore from remote is not implemented yet\n') 471 | return 472 | } 473 | 474 | api.getDB(db => { 475 | let aa = [] 476 | for (let k in ads.data) { 477 | aa.push({ 478 | key: k, 479 | source: ads.data[k].source, 480 | anyway: ads.data[k].anyway && cfg.anyway(), 481 | branch: ads.data[k].branch, 482 | uri: ads.data[k].uri, 483 | hash: ads.data[k].hash 484 | }) 485 | } 486 | 487 | if (!aa.length) { 488 | log('\nnothing to restore\n') 489 | return 490 | } 491 | 492 | log('\nRestoring addons:') 493 | batchInstall(aa, 0, done) 494 | }) 495 | }, 496 | 497 | pickup(done) { 498 | api.getDB(db => { 499 | let p = cfg.getPath('addon') 500 | let imported = 0 501 | let importedDirs = 0 502 | 503 | if (!db) { 504 | if (done) done() 505 | return 506 | } 507 | 508 | ads.unknownDirs().forEach(dir => { 509 | if (ads.dirStatus(dir)) return 510 | 511 | // log('picking up', dir) 512 | let l = _.filter(db, a => a.dir && a.dir.indexOf(dir) >= 0 && cfg.testMode(a.mode)) 513 | 514 | if (!l.length) return 515 | l.sort((a, b) => a.id - b.id) 516 | // log(l) 517 | l = l[0] 518 | 519 | // log('found', l) 520 | importedDirs++ 521 | let update = Math.floor(fs.statSync(path.join(p, dir)).mtimeMs / 1000) 522 | let k = 523 | l.source === 'curse' 524 | ? l.key 525 | : l.id + 526 | '-' + 527 | _.filter(l.name.split(''), s => s.match(/^[a-z0-9]+$/i)).join('') 528 | 529 | if (ads.data[k]) ads.data[k].sub.push(dir) 530 | else { 531 | ads.data[k] = { 532 | name: l.name, 533 | version: 'unknown', 534 | source: l.source, 535 | update, 536 | sub: [dir] 537 | } 538 | imported++ 539 | } 540 | }) 541 | 542 | log(`\n✨ imported ${imported} addons (${importedDirs} folders)\n`) 543 | 544 | let ukn = ads.unknownDirs() 545 | if (ukn.length) { 546 | log( 547 | cl.h( 548 | `❗ ${ukn.length} folder${ukn.length > 1 ? 's are' : ' is' 549 | } not recgonized\n` 550 | ) 551 | ) 552 | log(cl.x(cl.ls(ukn))) 553 | } 554 | 555 | ads.save() 556 | if (done) done(ukn) 557 | }) 558 | }, 559 | 560 | switch(opt) { 561 | let mo = 562 | opt.ptr || opt.retailPtr 563 | ? '_ptr_' 564 | : opt.beta || opt.retailBeta 565 | ? '_beta_' 566 | : opt.classicPtr 567 | ? '_classic_ptr_' 568 | : opt.classicTbc 569 | ? '_tbc_' 570 | : opt.classicBeta 571 | ? '_classic_beta_' 572 | : opt.retail 573 | ? '_retail_' 574 | : opt.classic 575 | ? '_classic_' 576 | : cfg.testMode('_retail_') 577 | ? '_tbc_' 578 | : '_retail_' 579 | 580 | cfg.setModePath(mo) 581 | 582 | log( 583 | `\n${cl.x('Mode switched to: ')} ${cl.i(cfg.getMode())} ${cl.i2( 584 | cfg.getMode('ver') 585 | )}\n` 586 | ) 587 | ads.load() 588 | }, 589 | 590 | checkUpdate(done) { 591 | let v2n = v => { 592 | let _v = 0 593 | v.split('.').forEach((n, i) => { 594 | _v *= 100 595 | _v += parseInt(n) 596 | }) 597 | 598 | return _v 599 | } 600 | 601 | let p = cfg.getPath('update') 602 | let e = fs.existsSync(p) 603 | let i 604 | 605 | if (!e || new Date() - fs.statSync(p).mtime > 24 * 3600 * 1000) { 606 | // fetch new data 607 | pi('/wowa').then(res => { 608 | fs.writeFileSync(p, JSON.stringify(res), 'utf-8') 609 | done(res) 610 | }) 611 | return 612 | } else if (e) i = JSON.parse(fs.readFileSync(p, 'utf-8')) 613 | 614 | if (i) { 615 | // log(v2n(i.version), v2n(pkg.version)) 616 | if (v2n(i.version) > v2n(pkg.version)) { 617 | log( 618 | cl.i('\nNew wowa version'), 619 | cl.i2(i.version), 620 | cl.i('is available, use the command below to update\n'), 621 | ' npm install -g wowa\n' 622 | ) 623 | } 624 | } 625 | 626 | done(i) 627 | } 628 | } 629 | 630 | module.exports = core 631 | --------------------------------------------------------------------------------