diff --git a/package.json b/package.json index 74c1c1c..b7b0055 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "azerothcore-armory", - "version": "0.2.0", + "version": "0.3.0", "description": "", "scripts": { "build": "tsc -p tsconfig.json", diff --git a/src/armory/Armory.ts b/src/armory/Armory.ts index 5aa5a83..9a5e723 100644 --- a/src/armory/Armory.ts +++ b/src/armory/Armory.ts @@ -65,6 +65,7 @@ export class Armory { const indexController = new IndexController(this); app.get("/", indexController.index.bind(indexController)); + app.get("/search", indexController.search.bind(indexController)); const charsController = new CharacterController(this); await charsController.load(); diff --git a/src/armory/DataTablesSsp.ts b/src/armory/DataTablesSsp.ts new file mode 100644 index 0000000..942385c --- /dev/null +++ b/src/armory/DataTablesSsp.ts @@ -0,0 +1,235 @@ +import { Query } from "express-serve-static-core"; +import { Connection, RowDataPacket } from "mysql2/promise"; + +export interface IResult { + recordsTotal: number; + recordsFiltered: number; + data: any[][]; +} + +export interface IColumnSettings { + name: string; + collation?: string; + formatter?: (data: string | number | null, row: any) => string; + table?: string; +} + +export interface IColumnJoin { + table1: string; + column1: string; + table2: string; + column2: string; + kind: "INNER" | "FULL OUTER" | "LEFT" | "RIGHT"; +} + +export class DataTablesSsp { + public draw: number; + public joins: IColumnJoin[] = []; + public extraDataColumns: string[] = []; + + private db: Connection; + private table: string; + private primaryKey: string; + private columnSettings: IColumnSettings[]; + + private start: number; + private length: number; + private _order: { + column: number, + dir: string, + }[]; + private columns: { + data: number, + name: string, + searchable: boolean, + orderable: boolean, + search: { + value: string, + regex: boolean, + } + }[]; + private search: { + value: string, + regex: boolean, + }; + + private wheres: string[] = []; + private filterBindings: (string | number)[] = []; + private customBindings: (string | number)[] = []; + private filterWhereSql: string = "1"; + private customWhereSql: string = "1"; + private limitSql: string = ""; + private orderSql: string = ""; + private joinSql: string = ""; + + public constructor(query: Query, db: Connection, table: string, primaryKey: string, columnSettings: IColumnSettings[]) { + this.start = parseInt(query.start as string, 10); + this.length = parseInt(query.length as string, 10); + this.draw = parseInt(query.draw as string, 10); + this._order = (query.order as { column: string, dir: string }[]) + .map(order => { return { column: parseInt(order.column, 10), dir: order.dir, }; }); + this.columns = (query.columns as { data: string, name: string, searchable: string, orderable: string, search: any }[]) + .map(column => { + return { + data: parseInt(column.data, 10), + name: column.name, + searchable: column.searchable === "true", + orderable: column.orderable === "true", + search: { value: column.search.value, regex: column.search.regex === "true" }, + }; + }); + this.search = { + value: (query.search as any).value as string, + regex: (query.search as any).regex === "true", + }; + + this.db = db; + this.table = table; + this.primaryKey = primaryKey; + this.columnSettings = columnSettings; + } + + private limit() { + if (this.start !== undefined && this.length !== -1) { + this.limitSql = `LIMIT ${this.length} OFFSET ${this.start}`; + } + return this; + } + + private order() { + if (this._order === undefined) { + return this; + } + + const orderBy = []; + for (const order of this._order) { + const requestColumn = this.columns[order.column]; + if (!requestColumn.orderable) { + continue; + } + + const colSettings = this.columnSettings[requestColumn.data]; + orderBy.push(`\`${colSettings.table || this.table}\`.\`${colSettings.name}\` ${order.dir}`); + } + orderBy.push(`\`${this.table}\`.\`${this.primaryKey}\``); + + if (orderBy.length > 0) { + this.orderSql = "ORDER BY " + orderBy.join(", "); + } + + return this; + } + + private join() { + for (const join of this.joins) { + this.joinSql += `${join.kind} JOIN \`${join.table2}\` ON \`${join.table2}\`.\`${join.column2}\` = \`${join.table1}\`.\`${join.column1}\`\n`; + } + + return this; + } + + private filter() { + if (this.search.value?.length > 0) { + const filterWheres = []; + this.filterBindings = []; + + for (const col of this.columns) { + if (!col.searchable) { + continue; + } + + const colSettings = this.columnSettings[col.data]; + const collate = colSettings.collation !== undefined ? `COLLATE ${colSettings.collation} ` : ""; + filterWheres.push(`\`${colSettings.table || this.table}\`.\`${colSettings.name}\` ${collate}LIKE ?`); + this.filterBindings.push(`%${this.search.value}%`); + } + if (filterWheres.length > 0) { + this.filterWhereSql = "(" + filterWheres.map(w => `(${w})`).join(" OR ") + ")"; + } + } + + this.customWhereSql = this.wheres.map(w => `(${w})`).join(" AND "); + return this; + } + + private buildSql(): string { + const columns = [ + ...this.columnSettings.map(c => `\`${c.table || this.table}\`.\`${c.name}\``), + ...this.extraDataColumns, + ]; + return ` + SELECT ${columns.join(", ")} + FROM ${this.table} + ${this.joinSql} + WHERE + ${this.filterWhereSql} AND + ${this.customWhereSql} + ${this.orderSql} + ${this.limitSql} + `; + } + + private buildTotalCountSql(): string { + return ` + SELECT COUNT(\`${this.table}\`.\`${this.primaryKey}\`) AS \`count\` + FROM ${this.table} + ${this.joinSql} + WHERE ${this.customWhereSql} + `; + } + + private buildFilteredCountSql(): string { + return ` + SELECT COUNT(\`${this.table}\`.\`${this.primaryKey}\`) AS \`count\` + FROM ${this.table} + ${this.joinSql} + WHERE + ${this.filterWhereSql} AND + ${this.customWhereSql} + `; + } + + public async run(): Promise { + this.limit() + .order() + .join() + .filter(); + + const bindings = [...this.filterBindings, ...this.customBindings]; + + let [rows, fields] = await this.db.query(this.buildTotalCountSql(), this.customBindings); + const recordsTotal = rows[0].count; + + [rows, fields] = await this.db.query(this.buildFilteredCountSql(), bindings); + const recordsFiltered = rows[0].count; + + [rows, fields] = await this.db.query({ + sql: this.buildSql(), + rowsAsArray: true, + values: bindings, + }); + rows = (rows as any[][]).map(row => { + for (let i = 0; i < this.columnSettings.length; ++i) { + const col = this.columnSettings[i]; + if (col.formatter !== undefined) { + row[i] = col.formatter(row[i], row); + } + } + return row; + }); + + return { + recordsTotal, + recordsFiltered, + data: rows, + }; + } + + public where(condition: string, binding?: string | number) { + this.wheres.push(condition); + if (binding !== undefined) { + this.customBindings.push(binding); + } + return this; + } +} diff --git a/src/armory/controllers/IndexController.ts b/src/armory/controllers/IndexController.ts index 8cdb731..4579c62 100644 --- a/src/armory/controllers/IndexController.ts +++ b/src/armory/controllers/IndexController.ts @@ -1,6 +1,32 @@ import * as express from "express"; import { Armory } from "../Armory"; +import { DataTablesSsp } from "../DataTablesSsp"; + +const raceFiles = { + 1: "human", + 2: "orc", + 3: "dwarf", + 4: "nightelf", + 5: "scourge", + 6: "tauren", + 7: "gnome", + 8: "troll", + 10: "bloodelf", + 11: "draenei", +}; +const classFiles = { + 1: "warrior", + 2: "paladin", + 3: "hunter", + 4: "rogue", + 5: "priest", + 6: "deathknight", + 7: "shaman", + 8: "mage", + 9: "warlock", + 11: "druid", +}; export class IndexController { private armory: Armory; @@ -12,6 +38,51 @@ export class IndexController { public async index(req: express.Request, res: express.Response): Promise { res.render("index.html", { title: "Armory", + realms: this.armory.config.realms.map(r => r.name), + }); + } + + public async search(req: express.Request, res: express.Response): Promise { + const realmName = req.query.realm as string; + const realm = realmName === undefined ? + this.armory.config.realms[0] : + this.armory.config.realms.find(r => r.name === realmName); + if (realm === undefined) { + res.status(400); + return; + } + + const db = this.armory.getCharactersDb(realm.name); + let [rows, fields] = await db.query(` + SELECT CCSA.character_set_name FROM information_schema.\`TABLES\` T, + information_schema.\`COLLATION_CHARACTER_SET_APPLICABILITY\` CCSA + WHERE CCSA.collation_name = T.table_collation + AND T.table_schema = "${db.config.database}" + AND T.table_name = "characters" + `); + const charSet = rows[0].character_set_name; + + const ssp = new DataTablesSsp(req.query, db, "characters", "guid", [ + { name: "name", collation: `${charSet}_general_ci` }, + { name: "name", table: "guild" }, + { name: "level" }, + { name: "race", formatter: (race, row) => `${raceFiles[race]}_${row[6] === 0 ? "male" : "female"}` }, + { name: "class", formatter: cls => classFiles[cls] }, + { name: "online", formatter: online => online === 1 }, + ]); + ssp.joins = [ + { table1: "characters", column1: "guid", table2: "guild_member", column2: "guid", kind: "LEFT" }, + { table1: "guild_member", column1: "guildid", table2: "guild", column2: "guildid", kind: "LEFT" }, + ]; + ssp.extraDataColumns = ["`characters`.`gender`"]; + + const result = await ssp + .where("`deleteInfos_Account` IS NULL") + .run(); + + res.json({ + draw: ssp.draw, + ...result, }); } } diff --git a/static/character-talents.html b/static/character-talents.html index 2190c6f..87f432a 100644 --- a/static/character-talents.html +++ b/static/character-talents.html @@ -68,6 +68,8 @@ const talentsData = JSON.parse(`{{{data}}}`); for (let spec = 0; spec < 2; ++spec) { + const $spec = $(`#talents-spec-${spec}`); + let nbLearned = 0; for (const tree of talentsData.trees) { const learned = {}; @@ -90,7 +92,7 @@ const $tree = $("#talent-tree-template") .clone(false) .removeAttr("id") - .prependTo($(`#talents-spec-${spec}`)); + .appendTo($spec); $tree.find(".header .icon").attr("src", `{{aowow}}/static/images/wow/icons/medium/${tree.icon}.jpg`); $tree.find(".header .name").text(tree.name); @@ -98,18 +100,19 @@ } if (nbLearned === 0) { - $(`#talents-spec-${spec}`).hide(); + $spec.hide(); $("#spec-links").hide(); continue; } - const $glyphs = $(`#talents-spec-${spec}`).find(".glyphs"); + const $glyphs = $spec.find(".glyphs"); for (const spell of talentsData.glyphs[spec]) { const $a = $(""); $a.attr("href", `{{aowow}}?spell=${spell}`); $glyphs.append($a); $glyphs.append("
"); } + $glyphs.parent().appendTo($spec); } function createTree(data, learnedTalents, container) { diff --git a/static/css/datatables.css b/static/css/datatables.css new file mode 100644 index 0000000..b7889b7 --- /dev/null +++ b/static/css/datatables.css @@ -0,0 +1,45 @@ +.dataTables_wrapper .dataTables_info, +.dataTables_wrapper .dataTables_filter, +.dataTables_wrapper .dataTables_length { + color: #e6e6e6; +} + +.dataTables_wrapper .dataTables_filter input, +.dataTables_wrapper .dataTables_length select { + color: #ffffff; +} +.dataTables_wrapper .dataTables_length select option { + color: #000000; +} + +table.dataTable tbody tr { + background-color: #272727; +} + +table.dataTable.stripe tbody tr.odd, +table.dataTable.display tbody tr.odd { + background-color: #313131; +} + +table.dataTable.hover tbody tr:hover, +table.dataTable.display tbody tr:hover { + background-color: #3f3f3f; +} + +.dataTables_wrapper .dataTables_paginate .paginate_button.disabled, +.dataTables_wrapper .dataTables_paginate .paginate_button.disabled:hover, +.dataTables_wrapper .dataTables_paginate .paginate_button.disabled:active { + color: #a3a3a3 !important; +} + +.dataTables_wrapper .dataTables_paginate .paginate_button, +.dataTables_wrapper .dataTables_paginate { + color: #e6e6e6 !important; +} + +table.dataTable.row-border tbody th, +table.dataTable.row-border tbody td, +table.dataTable.display tbody th, +table.dataTable.display tbody td { + border-top: 1px solid #222222; +} diff --git a/static/css/index.css b/static/css/index.css new file mode 100644 index 0000000..9a8daae --- /dev/null +++ b/static/css/index.css @@ -0,0 +1,11 @@ +#select-realm { + margin-bottom: 16px; +} + +#results { + width: 100% !important; +} + +#results td { + text-align: center; +} diff --git a/static/index.html b/static/index.html index a8d97b6..6db4b3f 100644 --- a/static/index.html +++ b/static/index.html @@ -1 +1,72 @@ -Armory + + + + + +

Armory

+ +{{#if (not (equalsLength realms 1))}} + +{{/if}} + + + + + + + + + + + + + +
Character nameGuildLevelRaceClassOnline
+ + diff --git a/static/partials/character-header.html b/static/partials/character-header.html index 4d0fbcf..0a7e409 100644 --- a/static/partials/character-header.html +++ b/static/partials/character-header.html @@ -18,5 +18,12 @@
{{realm}}
+
+ {{#if online}} + Online 🟢 + {{else}} + Offline 🔴 + {{/if}} +