Merge pull request #1045 from WowUp/release/2.5.0

Release/2.5.0
This commit is contained in:
jliddev
2021-11-04 13:37:05 -05:00
committed by GitHub
262 changed files with 21811 additions and 18394 deletions

View File

@@ -24,7 +24,7 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: ["macos-latest"]
os: ["macos-11"]
node-version: [14.x]
steps:
@@ -42,7 +42,7 @@ jobs:
run: npm install -g @angular/cli
- name: Build Mac App
if: matrix.os == 'macos-latest'
if: matrix.os == 'macos-11'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
CSC_LINK: ${{ secrets.MACOS_CERT }}

View File

@@ -17,7 +17,7 @@
"tsConfig": "src/tsconfig.app.json",
"polyfills": "src/polyfills.ts",
"assets": ["src/assets"],
"styles": ["./node_modules/ngx-lightbox/lightbox.css", "src/styles.scss"],
"styles": ["src/styles.scss"],
"scripts": [],
"customWebpackConfig": {
"path": "./angular.webpack.js"

View File

@@ -1,10 +1,10 @@
import * as path from "path";
import * as fs from "fs-extra";
import * as _ from "lodash";
import * as log from "electron-log";
import * as pLimit from "p-limit";
import { CurseFolderScanResult } from "../src/common/curse/curse-folder-scan-result";
import { readDirRecursive } from "./file.utils";
import { exists, readDirRecursive } from "./file.utils";
import * as fsp from "fs/promises";
const nativeAddon = require("../build/Release/addon.node");
@@ -56,7 +56,7 @@ export class CurseFolderScanner {
}
private get tocFileRegex() {
return /^([^/]+)[\\/]\1(-mainline|-bcc|-classic)?\.toc$/i;
return /^([^/]+)[\\/]\1([-|_](mainline|bcc|tbc|classic|vanilla))?\.toc$/i;
}
private get bindingsXmlRegex() {
@@ -139,13 +139,14 @@ export class CurseFolderScanner {
return;
}
if (!fs.existsSync(nativePath) || matchingFileList.indexOf(nativePath) !== -1) {
const pathExists = await exists(nativePath);
if (!pathExists || matchingFileList.indexOf(nativePath) !== -1) {
return;
}
matchingFileList.push(nativePath);
let input = await fs.readFile(nativePath, { encoding: "utf-8" });
let input = await fsp.readFile(nativePath, { encoding: "utf-8" });
input = this.removeComments(nativePath, input);
const inclusions = this.getFileInclusionMatches(nativePath, input);
@@ -242,24 +243,10 @@ export class CurseFolderScanner {
}
}
private getFileHash(filePath: string): Promise<number> {
return new Promise((resolve, reject) => {
try {
fs.readFile(filePath, (err, buffer) => {
if (err) {
return reject(err);
}
const hash = nativeAddon.computeHash(buffer, buffer.length);
return resolve(hash);
});
} catch (err) {
log.error(err);
log.info(filePath);
return reject(err);
}
});
private async getFileHash(filePath: string): Promise<number> {
const buffer = await fsp.readFile(filePath);
const hash = nativeAddon.computeHash(buffer, buffer.length);
return hash;
}
private getRealPath(filePath: string) {

View File

@@ -1,39 +1,74 @@
import { exec } from "child_process";
import * as log from "electron-log";
import * as fs from "fs-extra";
import * as fsp from "fs/promises";
import { max } from "lodash";
import { max, sumBy } from "lodash";
import * as path from "path";
import * as crypto from "crypto";
import * as AdmZip from "adm-zip";
import { TreeNode } from "../src/common/models/ipc-events";
import { GetDirectoryTreeOptions } from "../src/common/models/ipc-request";
import { isWin } from "./platform";
export async function readDirRecursive(sourcePath: string): Promise<string[]> {
const dirFiles: string[] = [];
const files = await fs.readdir(sourcePath, { withFileTypes: true });
export function zipFile(srcPath: string, outPath: string): Promise<boolean> {
return new Promise((resolve, reject) => {
const zip = new AdmZip();
zip.addLocalFolder(srcPath);
for (const file of files) {
const filePath = path.join(sourcePath, file.name);
if (file.isDirectory()) {
const nestedFiles = await readDirRecursive(filePath);
dirFiles.push(...nestedFiles);
} else {
dirFiles.push(filePath);
}
}
return dirFiles;
zip.writeZip(outPath, (e) => {
return e ? reject(e) : resolve(true);
});
});
}
export async function getLastModifiedFileDate(sourcePath: string): Promise<number> {
const dirFiles = await readDirRecursive(sourcePath);
const dates: number[] = [];
for (const file of dirFiles) {
const stat = await fs.stat(file);
dates.push(stat.mtimeMs);
}
export function readFileInZip(zipPath: string, filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const zip = new AdmZip(zipPath);
zip.readAsTextAsync(filePath, (data, err) => {
return err ? reject(err) : resolve(data);
});
});
}
const latest = max(dates);
return latest;
export async function exists(path: string): Promise<boolean> {
try {
await fsp.access(path);
return true;
} catch (e) {
log.warn(`File does not exist: ${path}`);
log.warn(e.message);
return false;
}
}
export async function chmodDir(dirPath: string, mode: number | string): Promise<void> {
const entries = await fsp.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
await chmodDir(srcPath, mode);
} else {
await fsp.chmod(srcPath, mode);
}
}
}
export async function copyDir(src: string, dest: string): Promise<void> {
await fsp.mkdir(dest, { recursive: true });
const entries = await fsp.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDir(srcPath, destPath);
} else {
await fsp.copyFile(srcPath, destPath);
}
}
}
export async function remove(path: string): Promise<void> {
@@ -65,3 +100,89 @@ async function rmdir(path: string): Promise<void> {
await fsp.rm(path, { recursive: true, force: true });
}
}
export async function readDirRecursive(sourcePath: string): Promise<string[]> {
const dirFiles: string[] = [];
const files = await fsp.readdir(sourcePath, { withFileTypes: true });
for (const file of files) {
const filePath = path.join(sourcePath, file.name);
if (file.isDirectory()) {
const nestedFiles = await readDirRecursive(filePath);
dirFiles.push(...nestedFiles);
} else {
dirFiles.push(filePath);
}
}
return dirFiles;
}
export async function getDirTree(sourcePath: string, opts?: GetDirectoryTreeOptions): Promise<TreeNode> {
const files = await fsp.readdir(sourcePath, { withFileTypes: true });
const node: TreeNode = {
name: path.basename(sourcePath),
path: sourcePath,
children: [],
isDirectory: true,
size: 0,
};
for (const file of files) {
const filePath = path.join(sourcePath, file.name);
if (file.isDirectory()) {
const nestedNode = await getDirTree(filePath, opts);
node.children.push(nestedNode);
node.size = sumBy(node.children, (n) => n.size);
if (opts?.includeHash) {
node.hash = hashString(node.children.map((n) => n.hash).join(""), "sha256");
}
} else {
let hash = "";
if (opts?.includeHash) {
hash = await hashFile(filePath, "sha256");
}
const stats = await fsp.stat(filePath);
node.size += stats.size;
node.children.push({
name: file.name,
path: filePath,
children: [],
isDirectory: false,
size: stats.size,
hash,
});
}
}
if (opts?.includeHash) {
node.hash = hashString(node.children.map((n) => n.hash).join(""), "sha256");
}
return node;
}
export async function getLastModifiedFileDate(sourcePath: string): Promise<number> {
const dirFiles = await readDirRecursive(sourcePath);
const dates: number[] = [];
for (const file of dirFiles) {
const stat = await fsp.stat(file);
dates.push(stat.mtimeMs);
}
const latest = max(dates);
return latest;
}
export function hashString(str: string | crypto.BinaryLike, alg = "md5"): string {
const md5 = crypto.createHash(alg);
md5.update(str);
return md5.digest("hex");
}
export async function hashFile(filePath: string, alg = "md5"): Promise<string> {
const text = await fsp.readFile(filePath);
return hashString(text, alg);
}

View File

@@ -1,17 +1,17 @@
import axios from "axios";
import {
app,
BrowserWindow,
clipboard,
dialog,
ipcMain,
IpcMainInvokeEvent,
net,
OpenDialogOptions,
Settings,
shell,
systemPreferences,
} from "electron";
import * as log from "electron-log";
import * as fs from "fs-extra";
import * as globrex from "globrex";
import * as _ from "lodash";
import { nanoid } from "nanoid";
@@ -20,6 +20,7 @@ import * as pLimit from "p-limit";
import * as path from "path";
import { Transform } from "stream";
import * as yauzl from "yauzl";
import * as fs from "fs";
import {
IPC_ADDONS_SAVE_ALL,
@@ -34,6 +35,7 @@ import {
IPC_FOCUS_WINDOW,
IPC_GET_APP_VERSION,
IPC_GET_ASSET_FILE_PATH,
IPC_GET_DIRECTORY_TREE,
IPC_GET_LATEST_DIR_UPDATE_TIME,
IPC_GET_LAUNCH_ARGS,
IPC_GET_LOCALE,
@@ -41,6 +43,7 @@ import {
IPC_GET_PENDING_OPEN_URLS,
IPC_GET_ZOOM_FACTOR,
IPC_IS_DEFAULT_PROTOCOL_CLIENT,
IPC_LIST_DIR_RECURSIVE,
IPC_LIST_DIRECTORIES_CHANNEL,
IPC_LIST_DISKS_WIN32,
IPC_LIST_ENTRIES,
@@ -67,6 +70,11 @@ import {
IPC_WINDOW_LEAVE_FULLSCREEN,
IPC_WOWUP_GET_SCAN_RESULTS,
IPC_WRITE_FILE_CHANNEL,
DEFAULT_FILE_MODE,
IPC_PUSH_INIT,
IPC_PUSH_REGISTER,
IPC_PUSH_UNREGISTER,
IPC_PUSH_SUBSCRIBE,
} from "../src/common/constants";
import { CurseFolderScanResult } from "../src/common/curse/curse-folder-scan-result";
import { Addon } from "../src/common/entities/addon";
@@ -74,18 +82,31 @@ import { CopyFileRequest } from "../src/common/models/copy-file-request";
import { DownloadRequest } from "../src/common/models/download-request";
import { DownloadStatus } from "../src/common/models/download-status";
import { DownloadStatusType } from "../src/common/models/download-status-type";
import { FsDirent, FsStats } from "../src/common/models/ipc-events";
import { FsDirent, FsStats, TreeNode } from "../src/common/models/ipc-events";
import { UnzipRequest } from "../src/common/models/unzip-request";
import { RendererChannels } from "../src/common/wowup";
import { MenuConfig, SystemTrayConfig, WowUpScanResult } from "../src/common/wowup/models";
import { createAppMenu } from "./app-menu";
import { CurseFolderScanner } from "./curse-folder-scanner";
import { getLastModifiedFileDate, remove } from "./file.utils";
import * as fsp from "fs/promises";
import {
chmodDir,
copyDir,
exists,
getDirTree,
getLastModifiedFileDate,
readDirRecursive,
readFileInZip,
remove,
zipFile,
} from "./file.utils";
import { addonStore } from "./stores";
import { createTray, restoreWindow } from "./system-tray";
import { WowUpFolderScanner } from "./wowup-folder-scanner";
import * as push from "./push";
import { GetDirectoryTreeRequest } from "../src/common/models/ipc-request";
let USER_AGENT = "";
let PENDING_OPEN_URLS: string[] = [];
interface SymlinkDir {
@@ -95,6 +116,8 @@ interface SymlinkDir {
isDir: boolean;
}
const _dlMap = new Map<string, (evt: Electron.Event, item: Electron.DownloadItem, ec: Electron.WebContents) => void>();
async function getSymlinkDirs(basePath: string, files: fs.Dirent[]): Promise<SymlinkDir[]> {
// Find and resolve symlinks found and return the folder names as
const symlinks = _.filter(files, (file) => file.isSymbolicLink());
@@ -108,8 +131,8 @@ async function getSymlinkDirs(basePath: string, files: fs.Dirent[]): Promise<Sym
});
for (const symlinkDir of symlinkDirs) {
const realPath = await fs.realpath(symlinkDir.originalPath);
const lstat = await fs.lstat(realPath);
const realPath = await fsp.realpath(symlinkDir.originalPath);
const lstat = await fsp.lstat(realPath);
symlinkDir.realPath = realPath;
symlinkDir.isDir = lstat.isDirectory();
@@ -131,7 +154,7 @@ export function setPendingOpenUrl(...openUrls: string[]): void {
}
export function initializeIpcHandlers(window: BrowserWindow, userAgent: string): void {
USER_AGENT = userAgent;
log.info("process.versions", process.versions);
// Remove the pending URLs once read so they are only able to be gotten once
handle(IPC_GET_PENDING_OPEN_URLS, (): string[] => {
@@ -151,6 +174,10 @@ export function initializeIpcHandlers(window: BrowserWindow, userAgent: string):
}
);
handle("clipboard-read-text", (evt) => {
return clipboard.readText();
});
handle(IPC_SHOW_DIRECTORY, async (evt, filePath: string): Promise<string> => {
return await shell.openPath(filePath);
});
@@ -161,7 +188,7 @@ export function initializeIpcHandlers(window: BrowserWindow, userAgent: string):
handle(IPC_CREATE_DIRECTORY_CHANNEL, async (evt, directoryPath: string): Promise<boolean> => {
log.info(`[CreateDirectory] '${directoryPath}'`);
await fs.ensureDir(directoryPath);
await fsp.mkdir(directoryPath, { recursive: true });
return true;
});
@@ -177,6 +204,10 @@ export function initializeIpcHandlers(window: BrowserWindow, userAgent: string):
return window.webContents?.setVisualZoomLevelLimits(minimumLevel, maximumLevel);
});
handle("show-item-in-folder", (evt, path: string) => {
shell.showItemInFolder(path);
});
handle(IPC_SET_ZOOM_FACTOR, (evt, zoomFactor: number) => {
if (window?.webContents) {
window.webContents.zoomFactor = zoomFactor;
@@ -208,7 +239,7 @@ export function initializeIpcHandlers(window: BrowserWindow, userAgent: string):
});
handle(IPC_READDIR, async (evt, dirPath: string): Promise<string[]> => {
return await fs.readdir(dirPath);
return await fsp.readdir(dirPath);
});
handle(IPC_IS_DEFAULT_PROTOCOL_CLIENT, (evt, protocol: string) => {
@@ -224,7 +255,7 @@ export function initializeIpcHandlers(window: BrowserWindow, userAgent: string):
});
handle(IPC_LIST_DIRECTORIES_CHANNEL, async (evt, filePath: string, scanSymlinks: boolean) => {
const files = await fs.readdir(filePath, { withFileTypes: true });
const files = await fsp.readdir(filePath, { withFileTypes: true });
let symlinkNames: string[] = [];
if (scanSymlinks === true) {
log.info("Scanning symlinks");
@@ -241,7 +272,7 @@ export function initializeIpcHandlers(window: BrowserWindow, userAgent: string):
const limit = pLimit(3);
const tasks = _.map(filePaths, (path) =>
limit(async () => {
const stats = await fs.stat(path);
const stats = await fsp.stat(path);
const fsStats: FsStats = {
atime: stats.atime,
atimeMs: stats.atimeMs,
@@ -281,7 +312,7 @@ export function initializeIpcHandlers(window: BrowserWindow, userAgent: string):
handle(IPC_LIST_ENTRIES, async (evt, sourcePath: string, filter: string) => {
const globFilter = globrex(filter);
const results = await fs.readdir(sourcePath, { withFileTypes: true });
const results = await fsp.readdir(sourcePath, { withFileTypes: true });
const matches = _.filter(results, (entry) => globFilter.regex.test(entry.name));
return _.map(matches, (match) => {
const dirEnt: FsDirent = {
@@ -299,8 +330,13 @@ export function initializeIpcHandlers(window: BrowserWindow, userAgent: string):
});
handle(IPC_LIST_FILES_CHANNEL, async (evt, sourcePath: string, filter: string) => {
const pathExists = await exists(sourcePath);
if (!pathExists) {
return [];
}
const globFilter = globrex(filter);
const results = await fs.readdir(sourcePath, { withFileTypes: true });
const results = await fsp.readdir(sourcePath, { withFileTypes: true });
const matches = _.filter(results, (entry) => globFilter.regex.test(entry.name));
return _.map(matches, (match) => match.name);
});
@@ -311,7 +347,7 @@ export function initializeIpcHandlers(window: BrowserWindow, userAgent: string):
}
try {
await fs.access(filePath);
await fsp.access(filePath);
} catch (e) {
if (e.code !== "ENOENT") {
log.error(e);
@@ -347,13 +383,36 @@ export function initializeIpcHandlers(window: BrowserWindow, userAgent: string):
});
});
await chmodDir(arg.outputFolder, DEFAULT_FILE_MODE);
return arg.outputFolder;
});
handle("zip-file", async (evt, srcPath: string, destPath: string) => {
log.info(`[ZipFile]: '${srcPath} -> ${destPath}`);
return await zipFile(srcPath, destPath);
});
handle("zip-read-file", async (evt, zipPath: string, filePath: string) => {
log.info(`[ZipReadFile]: '${zipPath} : ${filePath}`);
return await readFileInZip(zipPath, filePath);
});
handle("rename-file", async (evt, srcPath: string, destPath: string) => {
log.info(`[RenameFile]: '${srcPath} -> ${destPath}`);
return await fsp.rename(srcPath, destPath);
});
handle(IPC_COPY_FILE_CHANNEL, async (evt, arg: CopyFileRequest): Promise<boolean> => {
log.info(`[FileCopy] '${arg.sourceFilePath}' -> '${arg.destinationFilePath}'`);
await fs.copy(arg.sourceFilePath, arg.destinationFilePath);
await fs.chmod(arg.destinationFilePath, arg.destinationFileChmod);
const stat = await fsp.lstat(arg.sourceFilePath);
if (stat.isDirectory()) {
await copyDir(arg.sourceFilePath, arg.destinationFilePath);
await chmodDir(arg.destinationFilePath, DEFAULT_FILE_MODE);
} else {
await fsp.copyFile(arg.sourceFilePath, arg.destinationFilePath);
await fsp.chmod(arg.destinationFilePath, DEFAULT_FILE_MODE);
}
return true;
});
@@ -363,15 +422,15 @@ export function initializeIpcHandlers(window: BrowserWindow, userAgent: string):
});
handle(IPC_READ_FILE_CHANNEL, async (evt, filePath: string) => {
return await fs.readFile(filePath, { encoding: "utf-8" });
return await fsp.readFile(filePath, { encoding: "utf-8" });
});
handle(IPC_READ_FILE_BUFFER_CHANNEL, async (evt, filePath: string) => {
return await fs.readFile(filePath);
return await fsp.readFile(filePath);
});
handle(IPC_WRITE_FILE_CHANNEL, async (evt, filePath: string, contents: string) => {
return await fs.writeFile(filePath, contents, { encoding: "utf-8" });
return await fsp.writeFile(filePath, contents, { encoding: "utf-8", mode: DEFAULT_FILE_MODE });
});
handle(IPC_CREATE_TRAY_MENU_CHANNEL, (evt, config: SystemTrayConfig) => {
@@ -386,6 +445,15 @@ export function initializeIpcHandlers(window: BrowserWindow, userAgent: string):
return getLastModifiedFileDate(dirPath);
});
handle(IPC_LIST_DIR_RECURSIVE, (evt, dirPath: string): Promise<string[]> => {
return readDirRecursive(dirPath);
});
handle(IPC_GET_DIRECTORY_TREE, (evt, args: GetDirectoryTreeRequest): Promise<TreeNode> => {
log.debug(IPC_GET_DIRECTORY_TREE, args);
return getDirTree(args.dirPath, args.opts);
});
handle(IPC_MINIMIZE_WINDOW, () => {
if (window?.minimizable) {
window.minimize();
@@ -441,55 +509,111 @@ export function initializeIpcHandlers(window: BrowserWindow, userAgent: string):
return await dialog.showOpenDialog(options);
});
handle(IPC_PUSH_INIT, () => {
return push.startPushService();
});
handle(IPC_PUSH_REGISTER, async (evt, appId: string) => {
return await push.registerForPush(appId);
});
handle(IPC_PUSH_UNREGISTER, async () => {
return await push.unregisterPush();
});
handle(IPC_PUSH_SUBSCRIBE, async (evt, channel) => {
return await push.subscribeToChannel(channel);
});
ipcMain.on(IPC_DOWNLOAD_FILE_CHANNEL, (evt, arg: DownloadRequest) => {
handleDownloadFile(arg).catch((e) => console.error(e));
handleDownloadFile(arg).catch((e) => console.error(e.toString()));
});
// In order to allow concurrent downloads, we have to get creative with this session handler
window.webContents.session.on("will-download", (evt, item, wc) => {
for (const key of _dlMap.keys()) {
log.info(`will-download: ${key}`);
if (!item.getURLChain().includes(key)) {
continue;
}
try {
const action = _dlMap.get(key);
action.call(null, evt, item, wc);
} catch (e) {
log.error(e);
} finally {
_dlMap.delete(key);
}
}
});
async function handleDownloadFile(arg: DownloadRequest) {
const status: DownloadStatus = {
type: DownloadStatusType.Pending,
savePath: "",
};
try {
await fs.ensureDir(arg.outputFolder, 0o666);
await fsp.mkdir(arg.outputFolder, { recursive: true });
const savePath = path.join(arg.outputFolder, `${nanoid()}-${arg.fileName}`);
log.info(`[DownloadFile] '${arg.url}' -> '${savePath}'`);
const { data } = await axios({
url: arg.url,
method: "GET",
responseType: "stream",
headers: {
"User-Agent": USER_AGENT,
},
});
// const totalLength = headers["content-length"];
// Progress is not shown anywhere
// data.on("data", (chunk) => {
// log.info("DLPROG", arg.responseKey);
// });
const url = new URL(arg.url).toString();
const writer = fs.createWriteStream(savePath);
data.pipe(writer);
await new Promise((resolve, reject) => {
writer.on("finish", resolve);
writer.on("error", reject);
});
try {
await new Promise((resolve, reject) => {
let size = 0;
let percentMod = -1;
const req = net.request(url);
req.on("response", (response) => {
const fileLength = parseInt((response.headers["content-length"] as string) ?? "0", 10);
if (response.statusCode < 200 || response.statusCode >= 300) {
return reject(new Error(`Invalid response (${response.statusCode}): ${url}`));
}
response.on("data", (data) => {
writer.write(data, () => {
size += data.length;
const percent = fileLength <= 0 ? 0 : Math.floor((size / fileLength) * 100);
if (percent % 5 === 0 && percentMod !== percent) {
percentMod = percent;
log.debug(`Write: [${percent}] ${size}`);
}
});
});
response.on("end", () => {
console.log("No more data in response.");
return resolve(undefined);
});
response.on("error", (err) => {
return reject(err);
});
});
req.end();
});
} finally {
// always close stream
writer.end();
}
status.type = DownloadStatusType.Complete;
status.savePath = savePath;
const status: DownloadStatus = {
type: DownloadStatusType.Complete,
savePath,
};
window.webContents.send(arg.responseKey, status);
} catch (err) {
log.error(err);
const status: DownloadStatus = {
type: DownloadStatusType.Error,
error: err,
};
status.type = DownloadStatusType.Error;
status.error = err;
window.webContents.send(arg.responseKey, status);
}
}
}
// Adapted from https://github.com/thejoshwolfe/yauzl/blob/96f0eb552c560632a754ae0e1701a7edacbda389/examples/unzip.js#L124
function handleZipFile(err: Error, zipfile: yauzl.ZipFile, targetDir: string): Promise<boolean> {
return new Promise((resolve, reject) => {
@@ -510,7 +634,7 @@ function handleZipFile(err: Error, zipfile: yauzl.ZipFile, targetDir: string): P
if (/\/$/.test(entry.fileName)) {
// directory file names end with '/'
const dirPath = path.join(targetDir, entry.fileName);
fs.mkdirp(dirPath, function () {
fs.mkdir(dirPath, { recursive: true }, function () {
if (err) throw err;
zipfile.readEntry();
});
@@ -518,7 +642,7 @@ function handleZipFile(err: Error, zipfile: yauzl.ZipFile, targetDir: string): P
// ensure parent directory exists
const filePath = path.join(targetDir, entry.fileName);
const parentPath = path.join(targetDir, path.dirname(entry.fileName));
fs.mkdirp(parentPath, function () {
fs.mkdir(parentPath, { recursive: true }, function () {
zipfile.openReadStream(entry, (err, readStream) => {
if (err) {
throw err;

View File

@@ -12,6 +12,7 @@ import { AppUpdater } from "./app-updater";
import { initializeIpcHandlers, setPendingOpenUrl } from "./ipc-events";
import * as platform from "./platform";
import {
APP_PROTOCOL_NAME,
APP_USER_MODEL_ID,
COLLAPSE_TO_TRAY_PREFERENCE_KEY,
CURRENT_THEME_KEY,
@@ -22,6 +23,7 @@ import {
IPC_POWER_MONITOR_RESUME,
IPC_POWER_MONITOR_SUSPEND,
IPC_POWER_MONITOR_UNLOCK,
IPC_PUSH_NOTIFICATION,
IPC_WINDOW_ENTER_FULLSCREEN,
IPC_WINDOW_LEAVE_FULLSCREEN,
IPC_WINDOW_MAXIMIZED,
@@ -39,6 +41,7 @@ import { MainChannels } from "../src/common/wowup";
import { AppOptions } from "../src/common/wowup/models";
import { initializeStoreIpcHandlers, preferenceStore } from "./stores";
import { windowStateManager } from "./window-state";
import { pushEvents, PUSH_NOTIFICATION_EVENT } from "./push";
// LOGGING SETUP
// Override the default log path so they aren't a pain to find on Mac
@@ -63,6 +66,11 @@ process.on("unhandledRejection", (error) => {
log.error("unhandledRejection", error);
});
// WINDOWS CERTS
if (platform.isWin) {
require("win-ca");
}
// VARIABLES
const startedAt = Date.now();
const argv = minimist(process.argv.slice(1), {
@@ -81,6 +89,9 @@ let loadFailCount = 0;
// APP MENU SETUP
createAppMenu(win);
// WowUp Protocol Handler
app.setAsDefaultProtocolClient(APP_PROTOCOL_NAME);
// Set the app ID so that our notifications work correctly on Windows
app.setAppUserModelId(APP_USER_MODEL_ID);
@@ -92,8 +103,6 @@ if (preferenceStore.get(USE_HARDWARE_ACCELERATION_PREFERENCE_KEY) === "false") {
log.info("Hardware acceleration enabled");
}
app.allowRendererProcessReuse = false;
// Some servers don't supply good CORS headers for us, so we ignore them.
app.commandLine.appendSwitch("disable-features", "OutOfBlinkCors");
@@ -152,6 +161,10 @@ if (app.isReady()) {
// setTimeout(() => {
createWindow();
// }, 400);
// Preload native lib
const nativeAddon = require("../build/Release/addon.node");
nativeAddon.hello();
});
}
@@ -250,8 +263,11 @@ function createWindow(): BrowserWindow {
allowRunningInsecureContent: argv.serve,
webSecurity: false,
nativeWindowOpen: true,
enableRemoteModule: false, // This is only required for electron store https://github.com/sindresorhus/electron-store/issues/152,
additionalArguments: [`--log-path=${LOG_PATH}`, `--user-data-path=${app.getPath("userData")}`],
additionalArguments: [
`--log-path=${LOG_PATH}`,
`--user-data-path=${app.getPath("userData")}`,
`--base-bg-color=${getBackgroundColor()}`,
],
},
show: false,
};
@@ -273,6 +289,10 @@ function createWindow(): BrowserWindow {
initializeIpcHandlers(win, USER_AGENT);
initializeStoreIpcHandlers();
pushEvents.on(PUSH_NOTIFICATION_EVENT, (data) => {
win.webContents.send(IPC_PUSH_NOTIFICATION, data);
});
// Keep track of window state
mainWindowManager.monitorState(win);
@@ -342,6 +362,7 @@ function createWindow(): BrowserWindow {
win.on("close", (e) => {
if (appIsQuitting || preferenceStore.get(COLLAPSE_TO_TRAY_PREFERENCE_KEY) !== "true") {
pushEvents.removeAllListeners(PUSH_NOTIFICATION_EVENT);
return;
}
e.preventDefault();

View File

@@ -36,6 +36,7 @@ function getArg(argKey: string): string {
const LOG_PATH = getArg("log-path");
const USER_DATA_PATH = getArg("user-data-path");
const BASE_BG_COLOR = getArg("base-bg-color");
log.transports.file.resolvePath = (variables: log.PathVariables) => {
return join(LOG_PATH, variables.fileName);
@@ -73,25 +74,30 @@ function openPath(path: string): Promise<string> {
return shell.openPath(path);
}
if (window.opener === null) {
window.log = log;
window.libs = {
handlebars: require("handlebars"),
autoLaunch: require("auto-launch"),
};
window.userDataPath = USER_DATA_PATH;
window.logPath = LOG_PATH;
window.platform = process.platform;
window.wowup = {
onRendererEvent,
onceRendererEvent,
rendererSend,
rendererInvoke,
rendererOff,
rendererOn,
openExternal,
openPath,
};
} else {
console.log("HAS OPENER");
try {
if (window.opener === null) {
window.log = log;
window.baseBgColor = BASE_BG_COLOR;
window.libs = {
handlebars: require("handlebars"),
autoLaunch: require("auto-launch"),
};
window.userDataPath = USER_DATA_PATH;
window.logPath = LOG_PATH;
window.platform = process.platform;
window.wowup = {
onRendererEvent,
onceRendererEvent,
rendererSend,
rendererInvoke,
rendererOff,
rendererOn,
openExternal,
openPath,
};
} else {
console.log("HAS OPENER");
}
} catch (e) {
log.error(e);
}

View File

@@ -0,0 +1,64 @@
import * as log from "electron-log";
import { EventEmitter } from "events";
export const PUSH_NOTIFICATION_EVENT = "push-notification";
export const pushEvents = new EventEmitter();
const channelSubscriptions = new Map<string, boolean>();
let Pushy: any;
export function startPushService(): boolean {
if (!Pushy) {
Pushy = require("pushy-electron");
}
// Listen for push notifications
Pushy.setNotificationListener((data) => {
pushEvents.emit(PUSH_NOTIFICATION_EVENT, data);
});
Pushy.listen();
return true;
}
export async function registerForPush(appId: string): Promise<string> {
if (!Pushy) {
throw new Error("Push not started");
}
if (!appId) {
throw new Error("Invalid push app id");
}
return await Pushy.register({ appId });
}
export async function unregisterPush(): Promise<void> {
for (const [key] of channelSubscriptions) {
try {
await Pushy.unsubscribe(key);
} catch (e) {
console.error(e);
}
}
channelSubscriptions.clear();
Pushy.disconnect();
}
export async function subscribeToChannel(channel: string): Promise<void> {
// Make sure the user is registered
if (!Pushy.isRegistered()) {
throw new Error("Push services not registered");
}
if (channelSubscriptions.has(channel)) {
log.warn(`Already listening: ${channel}`);
return;
}
// Subscribe the user to a topic
await Pushy.subscribe(channel);
channelSubscriptions.set(channel, true);
log.debug(`Subscribed: ${channel}`);
}

View File

@@ -1,11 +1,10 @@
import * as _ from "lodash";
import * as crypto from "crypto";
import * as fs from "fs-extra";
import * as path from "path";
import * as pLimit from "p-limit";
import * as log from "electron-log";
import { WowUpScanResult } from "../src/common/wowup/models";
import { readDirRecursive } from "./file.utils";
import { exists, readDirRecursive, hashFile, hashString } from "./file.utils";
import * as fsp from "fs/promises";
const INVALID_PATH_CHARS = [
"|",
@@ -61,7 +60,7 @@ export class WowUpFolderScanner {
}
private get tocFileRegex() {
return /^([^/]+)[\\/]\1(-mainline|-bcc|-classic)?\.toc$/i;
return /^([^/]+)[\\/]\1([-|_](mainline|bcc|tbc|classic|vanilla))?\.toc$/i;
}
private get bindingsXmlRegex() {
@@ -86,14 +85,14 @@ export class WowUpFolderScanner {
const limit = pLimit(4);
const tasks = _.map(matchingFiles, (file) =>
limit(async () => {
return { hash: await this.hashFile(file), file };
return { hash: await hashFile(file), file };
})
);
const fileFingerprints = await Promise.all(tasks);
const fingerprintList = _.map(fileFingerprints, (ff) => ff.hash);
const hashConcat = _.orderBy(fingerprintList).join("");
const fingerprint = this.hashString(hashConcat);
const fingerprint = hashString(hashConcat);
const result: WowUpScanResult = {
fileFingerprints: fingerprintList,
@@ -136,13 +135,14 @@ export class WowUpFolderScanner {
return;
}
if (!fs.existsSync(nativePath) || matchingFileList.indexOf(nativePath) !== -1) {
const pathExists = await exists(nativePath);
if (!pathExists || matchingFileList.indexOf(nativePath) !== -1) {
return;
}
matchingFileList.push(nativePath);
let input = await fs.readFile(nativePath, { encoding: "utf-8" });
let input = await fsp.readFile(nativePath, { encoding: "utf-8" });
input = this.removeComments(nativePath, input);
const inclusions = this.getFileInclusionMatches(nativePath, input);
@@ -203,17 +203,6 @@ export class WowUpFolderScanner {
return matches;
}
private hashString(str: string | crypto.BinaryLike) {
const md5 = crypto.createHash("md5");
md5.update(str);
return md5.digest("hex");
}
private async hashFile(filePath: string): Promise<string> {
const text = await fs.readFile(filePath);
return this.hashString(text);
}
private getRealPath(filePath: string) {
const lowerPath = filePath.toLowerCase();
const matchedPath = this._fileMap[lowerPath];

View File

@@ -55,15 +55,22 @@
"mac": {
"icon": "electron-build/icon.icns",
"category": "public.app-category.games",
"target": ["default"],
"target": [
{
"target": "default",
"arch": ["x64", "arm64"]
}
],
"hardenedRuntime": true,
"entitlements": "./electron-build/entitlements.mac.plist",
"extendInfo": {
"CFBundleURLTypes": [{
"CFBundleTypeRole": "Shell",
"CFBundleURLName": "CurseForge",
"CFBundleURLSchemes": "curseforge"
}]
"CFBundleURLTypes": [
{
"CFBundleTypeRole": "Shell",
"CFBundleURLName": "CurseForge",
"CFBundleURLSchemes": "curseforge"
}
]
}
},
"linux": {

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "wowup",
"productName": "WowUp",
"version": "2.4.7",
"version": "2.5.0",
"description": "World of Warcraft addon updater",
"homepage": "https://wowup.io",
"author": {
@@ -49,115 +49,121 @@
"pretty": "npx prettier --write . && ng lint --fix"
},
"devDependencies": {
"@angular-builders/custom-webpack": "12.1.0",
"@angular-devkit/build-angular": "12.1.0",
"@angular-eslint/builder": "12.1.0",
"@angular-eslint/eslint-plugin": "12.1.0",
"@angular-eslint/eslint-plugin-template": "12.1.0",
"@angular-eslint/schematics": "12.2.0",
"@angular-eslint/template-parser": "12.1.0",
"@angular/cli": "12.1.0",
"@angular-builders/custom-webpack": "12.1.3",
"@angular-devkit/build-angular": "12.2.12",
"@angular-eslint/builder": "12.6.1",
"@angular-eslint/eslint-plugin": "12.6.1",
"@angular-eslint/eslint-plugin-template": "file:latest@angular-eslint/template-parser@latest",
"@angular-eslint/schematics": "12.6.1",
"@angular-eslint/template-parser": "12.6.1",
"@angular/cli": "12.2.12",
"@ngx-translate/core": "13.0.0",
"@ngx-translate/http-loader": "6.0.0",
"@types/globrex": "0.1.0",
"@types/jasmine": "3.7.6",
"@types/jasminewd2": "2.0.9",
"@types/lodash": "4.14.170",
"@types/markdown-it": "12.0.1",
"@types/mocha": "8.2.2",
"@types/node": "15.9.0",
"@types/opossum": "4.1.1",
"@types/slug": "5.0.0",
"@types/adm-zip": "0.4.34",
"@types/archiver": "5.1.1",
"@types/flat": "5.0.2",
"@types/globrex": "0.1.1",
"@types/jasmine": "3.10.0",
"@types/jasminewd2": "2.0.10",
"@types/lodash": "4.14.176",
"@types/markdown-it": "12.2.3",
"@types/mocha": "9.0.0",
"@types/node": "14.17.15",
"@types/object-hash": "2.2.1",
"@types/opossum": "6.2.0",
"@types/slug": "5.0.2",
"@types/string-similarity": "4.0.0",
"@types/uuid": "8.3.0",
"@typescript-eslint/eslint-plugin": "4.26.0",
"@typescript-eslint/eslint-plugin-tslint": "4.26.0",
"@typescript-eslint/parser": "4.26.0",
"@types/uuid": "8.3.1",
"@typescript-eslint/eslint-plugin": "4.33.0",
"@typescript-eslint/eslint-plugin-tslint": "4.33.0",
"@typescript-eslint/parser": "4.33.0",
"chai": "4.3.4",
"conventional-changelog-cli": "2.1.1",
"core-js": "3.17.2",
"cross-env": "7.0.3",
"dotenv": "10.0.0",
"electron": "13.5.1",
"electron-builder": "22.11.7",
"electron-notarize": "1.0.0",
"electron-reload": "1.5.0",
"eslint": "7.27.0",
"electron": "15.3.0",
"electron-builder": "22.13.1",
"electron-notarize": "1.1.1",
"electron-reload": "2.0.0-alpha.1",
"eslint": "7.32.0",
"eslint-config-prettier": "8.3.0",
"eslint-plugin-import": "2.23.4",
"eslint-plugin-jsdoc": "35.1.3",
"eslint-plugin-import": "2.24.2",
"eslint-plugin-jsdoc": "36.1.0",
"eslint-plugin-prefer-arrow": "1.2.3",
"flat": "5.0.2",
"i18next-json-sync": "2.3.1",
"jasmine-core": "3.7.1",
"jasmine-core": "3.9.0",
"jasmine-spec-reporter": "7.0.0",
"karma": "6.3.3",
"karma": "6.3.4",
"karma-coverage-istanbul-reporter": "3.0.3",
"karma-electron": "7.0.0",
"karma-jasmine": "4.0.1",
"karma-jasmine-html-reporter": "1.6.0",
"mocha": "8.4.0",
"nan": "2.14.2",
"node-addon-api": "4.0.0",
"node-gyp": "8.1.0",
"karma-jasmine-html-reporter": "1.7.0",
"mocha": "9.1.1",
"node-addon-api": "4.2.0",
"node-gyp": "8.3.0",
"npm-run-all": "4.1.5",
"prettier": "2.3.0",
"prettier": "2.4.1",
"spectron": "15.0.0",
"ts-node": "9.1.1",
"typescript": "4.2.4",
"wait-on": "5.3.0",
"ts-node": "10.3.0",
"typescript": "4.3.5",
"wait-on": "6.0.0",
"webdriver-manager": "12.1.8"
},
"engines": {
"node": ">=14.0.0"
},
"dependencies": {
"@angular/animations": "~12.1.0",
"@angular/cdk": "12.1.0",
"@angular/common": "12.1.0",
"@angular/compiler": "12.1.0",
"@angular/compiler-cli": "12.1.0",
"@angular/core": "12.1.0",
"@angular/forms": "12.1.0",
"@angular/material": "12.1.0",
"@angular/platform-browser": "12.1.0",
"@angular/platform-browser-dynamic": "12.1.0",
"@angular/router": "12.1.0",
"@angular/animations": "12.2.12",
"@angular/cdk": "12.2.12",
"@angular/common": "12.2.12",
"@angular/compiler": "12.2.12",
"@angular/compiler-cli": "12.2.12",
"@angular/core": "12.2.12",
"@angular/forms": "12.2.12",
"@angular/material": "12.2.12",
"@angular/platform-browser": "12.2.12",
"@angular/platform-browser-dynamic": "12.2.12",
"@angular/router": "12.2.12",
"@bbob/core": "2.7.0",
"@bbob/html": "2.7.0",
"@bbob/preset-html5": "2.7.0",
"@fortawesome/fontawesome-svg-core": "1.2.35",
"@fortawesome/free-brands-svg-icons": "5.15.3",
"@fortawesome/free-regular-svg-icons": "5.15.3",
"@fortawesome/free-solid-svg-icons": "5.15.3",
"@microsoft/applicationinsights-web": "2.6.2",
"ag-grid-angular": "25.3.0",
"ag-grid-community": "25.3.0",
"@circlon/angular-tree-component": "11.0.4",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@microsoft/applicationinsights-web": "2.7.0",
"adm-zip": "0.5.9",
"ag-grid-angular": "26.1.0",
"ag-grid-community": "26.1.0",
"archiver": "5.3.0",
"auto-launch": "5.0.5",
"axios": "0.21.1",
"compare-versions": "3.6.0",
"core-js": "3.13.1",
"electron-log": "4.3.5",
"electron-store": "8.0.0",
"electron-log": "4.4.1",
"electron-store": "8.0.1",
"electron-updater": "4.3.9",
"fs-extra": "10.0.0",
"globrex": "0.1.2",
"handlebars": "4.7.7",
"lodash": "4.17.21",
"markdown-it": "12.0.6",
"markdown-it": "12.2.0",
"messageformat": "2.3.0",
"minimist": "1.2.5",
"nanoid": "3.1.23",
"ngx-lightbox": "2.4.1",
"nanoid": "3.1.30",
"ng-gallery": "5.0.0",
"ngx-translate-messageformat-compiler": "4.10.0",
"node-cache": "5.1.2",
"node-disk-info": "1.3.0",
"opossum": "6.1.0",
"object-hash": "2.2.0",
"opossum": "6.2.1",
"p-limit": "3.1.0",
"protobufjs": "6.11.2",
"pushy-electron": "1.0.8",
"rxjs": "6.6.7",
"slug": "5.1.0",
"string-similarity": "4.0.4",
"ts-custom-error": "3.2.0",
"tslib": "2.2.0",
"tslib": "2.3.1",
"uuid": "8.3.2",
"win-ca": "3.4.5",
"yauzl": "2.10.0",

View File

@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { WowInstallation } from "../models/wowup/wow-installation";
import { WowInstallation } from "../../common/warcraft/wow-installation";
import { Observable, of } from "rxjs";
import { Addon } from "../../common/entities/addon";

View File

@@ -34,7 +34,7 @@ import { AddonSearchResult } from "../models/wowup/addon-search-result";
import { AddonSearchResultDependency } from "../models/wowup/addon-search-result-dependency";
import { AddonSearchResultFile } from "../models/wowup/addon-search-result-file";
import { ProtocolSearchResult } from "../models/wowup/protocol-search-result";
import { WowInstallation } from "../models/wowup/wow-installation";
import { WowInstallation } from "../../common/warcraft/wow-installation";
import { ElectronService } from "../services";
import { CachingService } from "../services/caching/caching-service";
import { CircuitBreakerWrapper, NetworkService } from "../services/network/network.service";
@@ -65,14 +65,17 @@ const FEATURED_ADDONS_CACHE_TTL_SEC = AppConfig.featuredAddonsCacheTimeSec;
const GAME_TYPE_LISTS = [
{
flavor: "wow_classic",
typeId: 67408,
matches: [WowClientType.ClassicEra, WowClientType.ClassicEraPtr],
},
{
flavor: "wow_burning_crusade",
typeId: 73246,
matches: [WowClientType.Classic, WowClientType.ClassicPtr, WowClientType.ClassicBeta],
},
{
flavor: "wow_retail",
typeId: 517,
matches: [WowClientType.Retail, WowClientType.RetailPtr, WowClientType.Beta],
},
];
@@ -144,6 +147,7 @@ export class CurseAddonProvider extends AddonProvider {
return undefined;
}
}
console.debug("addonResult", addonResult);
const addonFileResponse = await this.getAddonFileById(protocolData.addonId, protocolData.fileId).toPromise();
@@ -312,8 +316,10 @@ export class CurseAddonProvider extends AddonProvider {
for (const scanResult of scanResults) {
// Curse can deliver the wrong result sometimes, ensure the result matches the client type
scanResult.exactMatch = fingerprintResponse.exactMatches.find((exactMatch) =>
this.hasMatchingFingerprint(scanResult, exactMatch)
scanResult.exactMatch = fingerprintResponse.exactMatches.find(
(exactMatch) =>
this.hasMatchingFingerprint(scanResult, exactMatch) &&
this.isCompatible(installation.clientType, exactMatch.file)
);
// If the addon does not have an exact match, check the partial matches.
@@ -339,6 +345,7 @@ export class CurseAddonProvider extends AddonProvider {
{
fingerprints,
},
undefined,
AppConfig.wowUpHubHttpTimeoutMs
);
@@ -686,7 +693,7 @@ export class CurseAddonProvider extends AddonProvider {
try {
const blockList = await this._wowupApiService.getBlockList().toPromise();
const blockedAuthorIds = _.map(blockList.curse.authors, (author) => author.authorId);
const blockedAuthorIds = blockList.curse.authors.map((author) => author.authorId);
return blockedAuthorIds.includes(author.id.toString()) || blockedAuthorIds.includes(author.userId.toString());
} catch (e) {
return false;
@@ -813,61 +820,38 @@ export class CurseAddonProvider extends AddonProvider {
}
private isClientType(file: CurseFile, clientType: WowClientType) {
const clientTypeStr = this.getGameVersionFlavor(clientType);
if (file.isAlternate) {
return false;
}
// If the version flavor is an exact match, use that
if (file.gameVersionFlavor === clientTypeStr) {
return true;
}
// This check was a workaround for CF not supporting multi toc, it causes odd behavior with legacy (12 year old) addons showing up as valid.
// Otherwise check if the game version array is close enough
// const gameVersionRegex = this.getGameVersionRegex(clientType);
// return file.gameVersion.some((gameVersion) => gameVersionRegex.test(gameVersion));
return false;
return this.isCompatible(clientType, file);
}
private getGameVersionRegex(clientType: WowClientType): RegExp {
switch (clientType) {
case WowClientType.ClassicEra:
case WowClientType.ClassicEraPtr:
return /^1.\d+.\d+$/;
case WowClientType.Classic:
case WowClientType.ClassicPtr:
case WowClientType.ClassicBeta:
return /^2.\d+.\d+$/;
case WowClientType.Retail:
case WowClientType.RetailPtr:
case WowClientType.Beta:
default:
return /^[3-9].\d+.\d+$/;
private getGameVersionTypeId(clientType: WowClientType): number {
const gameType = GAME_TYPE_LISTS.find((gtl) => gtl.matches.includes(clientType));
if (!gameType) {
throw new Error(`Game type not found: ${clientType}`);
}
return gameType.typeId;
}
private getGameVersionFlavor(clientType: WowClientType): CurseGameVersionFlavor {
switch (clientType) {
case WowClientType.ClassicEra:
case WowClientType.ClassicEraPtr:
return "wow_classic";
case WowClientType.Classic:
case WowClientType.ClassicPtr:
case WowClientType.ClassicBeta:
return "wow_burning_crusade";
case WowClientType.Retail:
case WowClientType.RetailPtr:
case WowClientType.Beta:
default:
return "wow_retail";
const gameType = GAME_TYPE_LISTS.find((gtl) => gtl.matches.includes(clientType));
if (!gameType) {
throw new Error(`Game type not found: ${clientType}`);
}
return gameType.flavor as CurseGameVersionFlavor;
}
private getValidClientTypes(file: CurseAddonFileResponse): WowClientType[] {
const gameVersions: WowClientType[] = [];
const flavorMatches = GAME_TYPE_LISTS.find((list) => list.flavor === file.gameVersionFlavor)?.matches ?? [];
const flavorMatches =
GAME_TYPE_LISTS.find(
(list) => file.sortableGameVersion.find((sgv) => sgv.gameVersionTypeId === list.typeId) !== undefined
)?.matches ?? [];
gameVersions.push(...flavorMatches);
if (!Array.isArray(file.gameVersion) || file.gameVersion.length === 0) {
@@ -897,6 +881,24 @@ export class CurseAddonProvider extends AddonProvider {
}
}
private isCompatible(clientType: WowClientType, file: CurseFile): boolean {
if (Array.isArray(file.sortableGameVersion) && file.sortableGameVersion.length > 0) {
const gameVersionTypeId = this.getGameVersionTypeId(clientType);
return this.hasSortableGameVersion(file, gameVersionTypeId);
}
const gameVersionFlavor = this.getGameVersionFlavor(clientType);
console.debug(`Checking via game version flavor fallback`, gameVersionFlavor, file.displayName);
return file.gameVersionFlavor === gameVersionFlavor;
}
private hasSortableGameVersion(file: CurseFile, typeId: number): boolean {
if (!file.sortableGameVersion) {
console.debug(file);
}
return file.sortableGameVersion.find((sgv) => sgv.gameVersionTypeId === typeId) !== undefined;
}
private getAddon(installation: WowInstallation, scanResult: AppCurseScanResult): Addon {
if (!scanResult.exactMatch || !scanResult.searchResult) {
throw new Error("No scan result exact match");
@@ -911,7 +913,12 @@ export class CurseAddonProvider extends AddonProvider {
const latestFiles = this.getLatestFiles(scanResult.searchResult, installation.clientType);
const targetToc = this._tocService.getTocForGameType2(scanResult.addonFolder.tocs, installation.clientType);
const targetToc = this._tocService.getTocForGameType2(scanResult.addonFolder, installation.clientType);
if (!targetToc) {
console.error(scanResult.addonFolder.tocs);
throw new Error("Target toc not found");
}
const gameVersion = AddonUtils.getGameVersion(targetToc.interface);
let channelType = this.getChannelType(scanResult.exactMatch.file.releaseType);
@@ -934,6 +941,7 @@ export class CurseAddonProvider extends AddonProvider {
name: scanResult.searchResult?.name ?? "unknown",
channelType,
autoUpdateEnabled: false,
autoUpdateNotificationsEnabled: false,
clientType: installation.clientType,
downloadUrl: latestVersion?.downloadUrl ?? scanResult.exactMatch?.file.downloadUrl ?? "",
externalUrl: scanResult.searchResult?.websiteUrl ?? "",

View File

@@ -22,7 +22,7 @@ import { AddonChannelType } from "../../common/wowup/models";
import { AddonSearchResult } from "../models/wowup/addon-search-result";
import { AddonSearchResultFile } from "../models/wowup/addon-search-result-file";
import { AddonProvider, GetAllResult } from "./addon-provider";
import { WowInstallation } from "../models/wowup/wow-installation";
import { WowInstallation } from "../../common/warcraft/wow-installation";
import { convertMarkdown } from "../utils/markdown.utlils";
import { strictFilterBy } from "../utils/array.utils";
@@ -359,11 +359,11 @@ export class GitHubAddonProvider extends AddonProvider {
}
private isClassicAsset(asset: GitHubAsset): boolean {
return asset.name.toLowerCase().endsWith("-classic.zip");
return /[-](classic|vanilla)\.zip$/i.test(asset.name);
}
private isBurningCrusadeAsset(asset: GitHubAsset): boolean {
return asset.name.toLowerCase().endsWith("-bc.zip") || asset.name.toLowerCase().endsWith("-bcc.zip");
return /[-](bc|bcc|tbc)\.zip$/i.test(asset.name);
}
private getAddonName(addonId: string): string {

View File

@@ -1,4 +1,4 @@
import { WowInstallation } from "../models/wowup/wow-installation";
import { WowInstallation } from "../../common/warcraft/wow-installation";
import * as _ from "lodash";
import { v4 as uuidv4 } from "uuid";
@@ -37,7 +37,7 @@ export class RaiderIoAddonProvider extends AddonProvider {
return Promise.resolve(undefined);
}
const targetToc = this._tocService.getTocForGameType2(raiderIo.tocs, installation.clientType);
const targetToc = this._tocService.getTocForGameType2(raiderIo, installation.clientType);
const dependencies = _.filter(addonFolders, (addonFolder) => this.isRaiderIoDependant(addonFolder));
console.debug("RAIDER IO CLIENT FOUND", dependencies);
@@ -46,10 +46,11 @@ export class RaiderIoAddonProvider extends AddonProvider {
const installedFolders = installedFolderList.join(",");
for (const rioAddonFolder of rioAddonFolders) {
const subTargetToc = this._tocService.getTocForGameType2(rioAddonFolder.tocs, installation.clientType);
const subTargetToc = this._tocService.getTocForGameType2(rioAddonFolder, installation.clientType);
rioAddonFolder.matchingAddon = {
autoUpdateEnabled: false,
autoUpdateNotificationsEnabled: false,
channelType: AddonChannelType.Stable,
clientType: installation.clientType,
id: uuidv4(),

View File

@@ -11,7 +11,7 @@ import { TukUiAddon } from "../models/tukui/tukui-addon";
import { AddonFolder } from "../models/wowup/addon-folder";
import { AddonSearchResult } from "../models/wowup/addon-search-result";
import { AddonSearchResultFile } from "../models/wowup/addon-search-result-file";
import { WowInstallation } from "../models/wowup/wow-installation";
import { WowInstallation } from "../../common/warcraft/wow-installation";
import { CachingService } from "../services/caching/caching-service";
import { CircuitBreakerWrapper, NetworkService } from "../services/network/network.service";
import { getGameVersion } from "../utils/addon.utils";
@@ -61,45 +61,6 @@ export class TukUiAddonProvider extends AddonProvider {
return searchResults;
}
private mapAddonCategory(category: AddonCategory): string[] {
switch (category) {
case AddonCategory.Achievements:
return ["Achievements"];
case AddonCategory.ActionBars:
return ["Action Bars"];
case AddonCategory.BagsInventory:
return ["Bags & Inventory"];
case AddonCategory.BuffsDebuffs:
return ["Buffs & Debuffs"];
case AddonCategory.Bundles:
return ["Edited UIs & Compilations", "Full UI Replacements"];
case AddonCategory.ChatCommunication:
return ["Chat & Communication"];
case AddonCategory.Class:
return ["Class"];
case AddonCategory.Combat:
return ["Combat"];
case AddonCategory.Guild:
return ["Guild"];
case AddonCategory.MapMinimap:
return ["Map & Minimap"];
case AddonCategory.Miscellaneous:
return ["Miscellaneous"];
case AddonCategory.Plugins:
return ["Plugins: ElvUI", "Plugins: Tukui", "Plugins: Other", "Skins"];
case AddonCategory.Professions:
return ["Professions"];
case AddonCategory.Roleplay:
return ["Roleplay"];
case AddonCategory.Tooltips:
return ["Tooltips"];
case AddonCategory.UnitFrames:
return ["Unit Frames"];
default:
throw new Error("Unhandled addon category");
}
}
public async getDescription(installation: WowInstallation, externalId: string): Promise<string> {
const addons = await this.getAllAddons(installation.clientType);
const addonMatch = _.find(addons, (addon) => addon.id.toString() === externalId.toString());
@@ -175,12 +136,12 @@ export class TukUiAddonProvider extends AddonProvider {
const matches: TukUiAddon[] = [];
// Sort folders to prioritize ones with a toc id
const tukProjectAddonFolders = addonFolders.filter((folder) =>
const tukProjectAddonFolders = _.sortBy(addonFolders, (folder) =>
folder.tocs.some((toc) => !!toc.tukUiProjectId && toc.loadOnDemand !== "1")
);
).reverse();
for (const addonFolder of tukProjectAddonFolders) {
const targetToc = this._tocService.getTocForGameType2(addonFolder.tocs, installation.clientType);
const targetToc = this._tocService.getTocForGameType2(addonFolder, installation.clientType);
let tukUiAddon: TukUiAddon;
if (targetToc?.tukUiProjectId) {
@@ -220,6 +181,7 @@ export class TukUiAddonProvider extends AddonProvider {
addonFolder.matchingAddon = {
autoUpdateEnabled: false,
autoUpdateNotificationsEnabled: false,
channelType: addonChannelType,
clientType: installation.clientType,
id: uuidv4(),
@@ -299,16 +261,14 @@ export class TukUiAddonProvider extends AddonProvider {
const canonAddonName = addonName.toLowerCase();
const addons = await this.getAllAddons(clientType);
let matches = _.orderBy(
addons
.map((addon) => {
const similarity = stringSimilarity.compareTwoStrings(canonAddonName, addon.name.toLowerCase());
return { addon, similarity };
})
.filter((result) => result.similarity > 0.7),
(match) => match.similarity,
"desc"
).map((result) => result.addon);
const similarity = addons
.map((addon) => {
const similarity = stringSimilarity.compareTwoStrings(canonAddonName, addon.name.toLowerCase());
return { addon, similarity };
})
.filter((result) => result.similarity > 0.7);
let matches = _.orderBy(similarity, (match) => match.similarity, "desc").map((result) => result.addon);
// If we didnt get any similarity matches
if (allowContain && matches.length === 0) {
@@ -449,4 +409,43 @@ export class TukUiAddonProvider extends AddonProvider {
return "";
}
}
private mapAddonCategory(category: AddonCategory): string[] {
switch (category) {
case AddonCategory.Achievements:
return ["Achievements"];
case AddonCategory.ActionBars:
return ["Action Bars"];
case AddonCategory.BagsInventory:
return ["Bags & Inventory"];
case AddonCategory.BuffsDebuffs:
return ["Buffs & Debuffs"];
case AddonCategory.Bundles:
return ["Edited UIs & Compilations", "Full UI Replacements"];
case AddonCategory.ChatCommunication:
return ["Chat & Communication"];
case AddonCategory.Class:
return ["Class"];
case AddonCategory.Combat:
return ["Combat"];
case AddonCategory.Guild:
return ["Guild"];
case AddonCategory.MapMinimap:
return ["Map & Minimap"];
case AddonCategory.Miscellaneous:
return ["Miscellaneous"];
case AddonCategory.Plugins:
return ["Plugins: ElvUI", "Plugins: Tukui", "Plugins: Other", "Skins"];
case AddonCategory.Professions:
return ["Professions"];
case AddonCategory.Roleplay:
return ["Roleplay"];
case AddonCategory.Tooltips:
return ["Tooltips"];
case AddonCategory.UnitFrames:
return ["Unit Frames"];
default:
throw new Error("Unhandled addon category");
}
}
}

View File

@@ -13,7 +13,7 @@ import { AddonDetailsResponse } from "../models/wow-interface/addon-details-resp
import { AddonFolder } from "../models/wowup/addon-folder";
import { AddonSearchResult } from "../models/wowup/addon-search-result";
import { AddonSearchResultFile } from "../models/wowup/addon-search-result-file";
import { WowInstallation } from "../models/wowup/wow-installation";
import { WowInstallation } from "../../common/warcraft/wow-installation";
import { CachingService } from "../services/caching/caching-service";
import { CircuitBreakerWrapper, NetworkService } from "../services/network/network.service";
import { getGameVersion } from "../utils/addon.utils";
@@ -149,7 +149,7 @@ export class WowInterfaceAddonProvider extends AddonProvider {
const addonDetails = await this.getAllAddonDetails(addonIds);
for (const addonFolder of wowiFolders) {
const targetToc = this._tocService.getTocForGameType2(addonFolder.tocs, installation.clientType);
const targetToc = this._tocService.getTocForGameType2(addonFolder, installation.clientType);
if (!targetToc?.wowInterfaceId) {
continue;
}
@@ -223,12 +223,13 @@ export class WowInterfaceAddonProvider extends AddonProvider {
addonChannelType: AddonChannelType,
addonFolder: AddonFolder
): Addon {
const targetToc = this._tocService.getTocForGameType2(addonFolder.tocs, installation.clientType);
const targetToc = this._tocService.getTocForGameType2(addonFolder, installation.clientType);
return {
id: uuidv4(),
author: response.author,
autoUpdateEnabled: false,
autoUpdateNotificationsEnabled: false,
channelType: addonChannelType,
clientType: installation.clientType,
downloadUrl: response.downloadUri,

View File

@@ -6,7 +6,7 @@ import { v4 as uuidv4 } from "uuid";
import { ADDON_PROVIDER_HUB, IPC_WOWUP_GET_SCAN_RESULTS } from "../../common/constants";
import { Addon } from "../../common/entities/addon";
import { WowClientType } from "../../common/warcraft/wow-client-type";
import { AddonChannelType, WowUpScanResult } from "../../common/wowup/models";
import { AddonCategory, AddonChannelType, WowUpScanResult } from "../../common/wowup/models";
import { AppConfig } from "../../environments/environment";
import { SourceRemovedAddonError } from "../errors";
import { WowUpAddonReleaseRepresentation, WowUpAddonRepresentation } from "../models/wowup-api/addon-representations";
@@ -14,6 +14,7 @@ import {
GetFeaturedAddonsResponse,
WowUpGetAddonReleaseResponse,
WowUpGetAddonResponse,
WowUpGetAddonsResponse,
WowUpSearchAddonsResponse,
} from "../models/wowup-api/api-responses";
import { GetAddonsByFingerprintResponse } from "../models/wowup-api/get-addons-by-fingerprint.response";
@@ -22,7 +23,7 @@ import { AddonFolder } from "../models/wowup/addon-folder";
import { AddonSearchResult } from "../models/wowup/addon-search-result";
import { AddonSearchResultFile } from "../models/wowup/addon-search-result-file";
import { AppWowUpScanResult } from "../models/wowup/app-wowup-scan-result";
import { WowInstallation } from "../models/wowup/wow-installation";
import { WowInstallation } from "../../common/warcraft/wow-installation";
import { ElectronService } from "../services";
import { CachingService } from "../services/caching/caching-service";
import { CircuitBreakerWrapper, NetworkService } from "../services/network/network.service";
@@ -232,6 +233,17 @@ export class WowUpAddonProvider extends AddonProvider {
);
}
public async getCategory(category: AddonCategory, installation: WowInstallation): Promise<AddonSearchResult[]> {
const gameType = this.getWowGameType(installation.clientType);
const response = await this.getAddonsByCategory(gameType, category);
const searchResults = _.map(response?.addons, (addon) => this.getSearchResult(addon, gameType)).filter(
(sr) => sr !== undefined
);
return searchResults;
}
public async scan(
installation: WowInstallation,
addonChannelType: AddonChannelType,
@@ -303,6 +315,15 @@ export class WowUpAddonProvider extends AddonProvider {
return scanResults;
};
private async getAddonsByCategory(gameType: WowGameType, category: AddonCategory) {
const url = new URL(`${API_URL}/addons/category/${category}/${gameType}`);
return await this._cachingService.transaction(
url.toString(),
() => this._circuitBreaker.getJson<WowUpGetAddonsResponse>(url),
CHANGELOG_CACHE_TTL_SEC
);
}
private async getAddonById(addonId: number | string) {
const url = new URL(`${API_URL}/addons/${addonId}`);
return await this._cachingService.transaction(
@@ -400,20 +421,30 @@ export class WowUpAddonProvider extends AddonProvider {
return {
author: authors,
externalId: representation.id.toString(),
externalUrl: representation.repository,
externalUrl: `${AppConfig.wowUpWebsiteUrl}/addons/${representation.id}`,
name,
providerName: this.name,
thumbnailUrl: representation.image_url || representation.owner_image_url || "",
downloadCount: representation.total_download_count,
files: searchResultFiles,
releasedAt: new Date(),
screenshotUrl: "",
screenshotUrls: [],
summary: representation.description,
fundingLinks: [...(representation?.funding_links ?? [])],
screenshotUrls: this.getScreenshotUrls(clientReleases),
};
}
// Currently we only support images, so we filter for those
private getScreenshotUrls(releases: WowUpAddonReleaseRepresentation[]): string[] {
const urls = _.flatten(
releases.map((release) =>
release.previews?.filter((preview) => preview.preview_type === "image").map((preview) => preview.url)
)
).filter((url) => !!url);
return _.uniq(urls);
}
private getAddon(
installation: WowInstallation,
addonChannelType: AddonChannelType,
@@ -448,15 +479,21 @@ export class WowUpAddonProvider extends AddonProvider {
throw new Error("Invalid matching version data");
}
const screenshotUrls = this.getScreenshotUrls([matchedRelease]);
const externalUrl = scanResult.exactMatch
? `${AppConfig.wowUpWebsiteUrl}/addons/${scanResult.exactMatch.id}`
: "unknown";
return {
id: uuidv4(),
author: authors,
name,
channelType,
autoUpdateEnabled: false,
autoUpdateNotificationsEnabled: false,
clientType: installation.clientType,
downloadUrl: scanResult.exactMatch?.matched_release?.download_url ?? "",
externalUrl: scanResult.exactMatch?.repository ?? "unknown",
externalUrl,
externalId: scanResult.exactMatch?.id.toString() ?? "unknown",
gameVersion: getGameVersion(interfaceVer),
installedAt: new Date(),
@@ -476,6 +513,7 @@ export class WowUpAddonProvider extends AddonProvider {
latestChangelog: scanResult.exactMatch?.matched_release?.body,
externalLatestReleaseId: scanResult?.exactMatch?.matched_release?.id?.toString(),
installationId: installation.id,
screenshotUrls,
};
}

View File

@@ -4,7 +4,7 @@ import { v4 as uuidv4 } from "uuid";
import { ADDON_PROVIDER_WOWUP_COMPANION, WOWUP_DATA_ADDON_FOLDER_NAME } from "../../common/constants";
import { AddonChannelType } from "../../common/wowup/models";
import { AddonFolder } from "../models/wowup/addon-folder";
import { WowInstallation } from "../models/wowup/wow-installation";
import { WowInstallation } from "../../common/warcraft/wow-installation";
import { FileService } from "../services/files/file.service";
import { TocService } from "../services/toc/toc.service";
import { getGameVersion } from "../utils/addon.utils";
@@ -36,11 +36,12 @@ export class WowUpCompanionAddonProvider extends AddonProvider {
return;
}
const targetToc = this._tocService.getTocForGameType2(companion.tocs, installation.clientType);
const targetToc = this._tocService.getTocForGameType2(companion, installation.clientType);
const lastUpdatedAt = await this._fileService.getLatestDirUpdateTime(companion.path);
companion.matchingAddon = {
autoUpdateEnabled: false,
autoUpdateNotificationsEnabled: false,
channelType: AddonChannelType.Stable,
clientType: installation.clientType,
id: uuidv4(),

View File

@@ -15,7 +15,7 @@ import { FileService } from "../services/files/file.service";
import { TocService } from "../services/toc/toc.service";
import { WarcraftService } from "../services/warcraft/warcraft.service";
import { AddonProvider } from "./addon-provider";
import { WowInstallation } from "../models/wowup/wow-installation";
import { WowInstallation } from "../../common/warcraft/wow-installation";
const VALID_ZIP_CONTENT_TYPES = ["application/zip", "application/x-zip-compressed", "application/octet-stream"];

View File

@@ -5,12 +5,14 @@ import { OverlayContainer, OverlayModule } from "@angular/cdk/overlay";
import { HttpClient, HttpClientModule } from "@angular/common/http";
import { TestBed } from "@angular/core/testing";
import { MatDialog } from "@angular/material/dialog";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { RouterTestingModule } from "@angular/router/testing";
import { TranslateCompiler, TranslateLoader, TranslateModule } from "@ngx-translate/core";
import { AppComponent } from "./app.component";
import { httpLoaderFactory } from "./app.module";
import { MatModule } from "./mat-module";
import { AnimatedLogoComponent } from "./components/common/animated-logo/animated-logo.component";
import { MatModule } from "./modules/mat-module";
import { PreferenceChange } from "./models/wowup/preference-change";
import { ElectronService } from "./services";
import { AddonService } from "./services/addons/addon.service";
@@ -18,11 +20,9 @@ import { AnalyticsService } from "./services/analytics/analytics.service";
import { FileService } from "./services/files/file.service";
import { SessionService } from "./services/session/session.service";
import { PreferenceStorageService } from "./services/storage/preference-storage.service";
import { WowUpService } from "./services/wowup/wowup.service";
import { WowUpAddonService } from "./services/wowup/wowup-addon.service";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { AnimatedLogoComponent } from "./components/animated-logo/animated-logo.component";
import { WarcraftInstallationService } from "./services/warcraft/warcraft-installation.service";
import { WowUpAddonService } from "./services/wowup/wowup-addon.service";
import { WowUpService } from "./services/wowup/wowup.service";
import { ZoomService } from "./services/zoom/zoom.service";
describe("AppComponent", () => {

View File

@@ -33,7 +33,7 @@ import {
WOWUP_LOGO_FILENAME,
} from "../common/constants";
import { AppUpdateState, MenuConfig, SystemTrayConfig } from "../common/wowup/models";
import { TelemetryDialogComponent } from "./components/telemetry-dialog/telemetry-dialog.component";
import { TelemetryDialogComponent } from "./components/common/telemetry-dialog/telemetry-dialog.component";
import { ElectronService } from "./services";
import { AddonService } from "./services/addons/addon.service";
import { AnalyticsService } from "./services/analytics/analytics.service";
@@ -44,13 +44,13 @@ import { ZoomDirection } from "./utils/zoom.utils";
import { Addon } from "../common/entities/addon";
import { AppConfig } from "../environments/environment";
import { PreferenceStorageService } from "./services/storage/preference-storage.service";
import { InstallFromUrlDialogComponent } from "./components/install-from-url-dialog/install-from-url-dialog.component";
import { InstallFromUrlDialogComponent } from "./components/addons/install-from-url-dialog/install-from-url-dialog.component";
import { WowUpAddonService } from "./services/wowup/wowup-addon.service";
import { AddonSyncError, GitHubFetchReleasesError, GitHubFetchRepositoryError, GitHubLimitError } from "./errors";
import { SnackbarService } from "./services/snackbar/snackbar.service";
import { WarcraftInstallationService } from "./services/warcraft/warcraft-installation.service";
import { ZoomService } from "./services/zoom/zoom.service";
import { AlertDialogComponent } from "./components/alert-dialog/alert-dialog.component";
import { AlertDialogComponent } from "./components/common/alert-dialog/alert-dialog.component";
import { AddonInstallState } from "./models/wowup/addon-install-state";
@Component({
@@ -217,11 +217,11 @@ export class AppComponent implements OnInit, OnDestroy, AfterViewInit {
.pipe(
first(),
switchMap(() => from(this.createSystemTray())),
map(() => {
switchMap(() => {
if (this._analyticsService.shouldPromptTelemetry) {
this.openDialog();
return of(this.openDialog());
} else {
this._analyticsService.trackStartup();
return from(this._analyticsService.trackStartup());
}
}),
catchError((e) => {
@@ -259,12 +259,18 @@ export class AppComponent implements OnInit, OnDestroy, AfterViewInit {
disableClose: true,
});
dialogRef.afterClosed().subscribe((result) => {
this._analyticsService.telemetryEnabled = result;
if (result) {
this._analyticsService.trackStartup();
}
});
dialogRef
.afterClosed()
.pipe(
switchMap((result) => {
this._analyticsService.telemetryEnabled = result;
if (result) {
return from(this._analyticsService.trackStartup());
}
return of(undefined);
})
)
.subscribe();
}
private openInstallFromUrlDialog(path?: string) {
@@ -305,11 +311,15 @@ export class AppComponent implements OnInit, OnDestroy, AfterViewInit {
}
if (this.wowUpService.enableSystemNotifications) {
const addonsWithNotificationsEnabled = updatedAddons.filter(
(addon) => addon.autoUpdateNotificationsEnabled === true
);
// Windows notification only shows so many chars
if (this.getAddonNamesLength(updatedAddons) > 60) {
await this.showManyAddonsAutoUpdated(updatedAddons);
if (this.getAddonNamesLength(addonsWithNotificationsEnabled) > 60) {
await this.showManyAddonsAutoUpdated(addonsWithNotificationsEnabled);
} else {
await this.showFewAddonsAutoUpdated(updatedAddons);
await this.showFewAddonsAutoUpdated(addonsWithNotificationsEnabled);
}
} else {
await this.checkQuitEnabled();

View File

@@ -10,25 +10,25 @@ import { BrowserModule } from "@angular/platform-browser";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { TranslateCompiler, TranslateLoader, TranslateModule } from "@ngx-translate/core";
import { TranslateHttpLoader } from "@ngx-translate/http-loader";
import { GalleryModule } from "ng-gallery";
import { AppRoutingModule } from "./app-routing.module";
import { AppComponent } from "./app.component";
import { FooterComponent } from "./components/footer/footer.component";
import { TitlebarComponent } from "./components/titlebar/titlebar.component";
import { DirectiveModule } from "./directive.module";
import { TitlebarComponent } from "./components/common/titlebar/titlebar.component";
import { DirectiveModule } from "./modules/directive.module";
import { DefaultHeadersInterceptor } from "./interceptors/default-headers.interceptor";
import { ErrorHandlerInterceptor } from "./interceptors/error-handler-interceptor";
import { MatModule } from "./mat-module";
import { MatModule } from "./modules/mat-module";
import { HomeModule } from "./pages/home/home.module";
import { AnalyticsService } from "./services/analytics/analytics.service";
import { WowUpApiService } from "./services/wowup-api/wowup-api.service";
import { WowUpService } from "./services/wowup/wowup.service";
import { WarcraftInstallationService } from "./services/warcraft/warcraft-installation.service";
import { SharedModule } from "./shared.module";
import { AddonService } from "./services/addons/addon.service";
import { IconService } from "./services/icons/icon.service";
import { VerticalTabsComponent } from "./components/vertical-tabs/vertical-tabs.component";
import { HorizontalTabsComponent } from "./components/horizontal-tabs/horizontal-tabs.component";
import { HorizontalTabsComponent } from "./components/common/horizontal-tabs/horizontal-tabs.component";
import { CommonUiModule } from "./modules/common-ui.module";
import { FooterComponent } from "./components/common/footer/footer.component";
// AoT requires an exported function for factories
export function httpLoaderFactory(http: HttpClient): TranslateHttpLoader {
@@ -42,12 +42,11 @@ export function initializeApp(wowupService: WowUpService) {
}
@NgModule({
declarations: [AppComponent, TitlebarComponent, FooterComponent, VerticalTabsComponent, HorizontalTabsComponent],
declarations: [AppComponent, TitlebarComponent, FooterComponent, HorizontalTabsComponent],
imports: [
BrowserModule,
FormsModule,
HttpClientModule,
SharedModule,
HomeModule,
AppRoutingModule,
DirectiveModule,
@@ -64,6 +63,8 @@ export function initializeApp(wowupService: WowUpService) {
},
}),
BrowserAnimationsModule,
GalleryModule,
CommonUiModule,
],
providers: [
{

View File

@@ -5,6 +5,7 @@ import { AddonInstallState } from "../models/wowup/addon-install-state";
import { AddonStatusSortOrder } from "../models/wowup/addon-status-sort-order";
import * as AddonUtils from "../utils/addon.utils";
import { ADDON_PROVIDER_UNKNOWN } from "../../common/constants";
import * as objectHash from "object-hash";
export class AddonViewModel {
public addon: Addon | undefined;
@@ -19,9 +20,12 @@ export class AddonViewModel {
public isLoadOnDemand = false;
public hasThumbnail = false;
public thumbnailLetter = "";
public showUpdate = false;
public canonicalName = "";
public get isIgnored(): boolean {
return this.addon?.isIgnored ?? false;
}
public get name(): string {
return this.addon?.name ?? "";
}
@@ -46,6 +50,10 @@ export class AddonViewModel {
return this.addon?.author ?? "";
}
public get hash(): string {
return objectHash(this.addon);
}
public constructor(addon: Addon | undefined) {
this.addon = addon;
this.installedAt = addon?.installedAt ? new Date(addon?.installedAt).getTime() : 0;

View File

@@ -63,11 +63,11 @@
<div #changelogContainer class="markdown-body addon-changelog text-1 mt-3" [innerHTML]="changelog$ | async"></div>
</mat-tab>
<!-- SCREENSHOTS -->
<mat-tab *ngIf="imageUrls.length > 0" [label]="'DIALOGS.ADDON_DETAILS.IMAGES_TAB' | translate">
<mat-tab *ngIf="previewItems.length > 0" [label]="'DIALOGS.ADDON_DETAILS.IMAGES_TAB' | translate">
<mat-grid-list class="image-grid pt-3" cols="4" rowHeight="1:1" gutterSize="3">
<mat-grid-tile *ngFor="let image of imageUrls">
<mat-grid-tile *ngFor="let image of previewItems; index as i">
<div class="image-thumb-container ">
<img class="image-thumb mat-elevation-z8" [src]="image.src" (click)="onClickImage(image.src)">
<img class="image-thumb mat-elevation-z8" [src]="image.data.thumb" [lightbox]="i">
</div>
</mat-grid-tile>
</mat-grid-list>
@@ -76,7 +76,7 @@
</mat-dialog-content>
<mat-dialog-actions *ngIf="isUnknownProvider === false">
<div class="row w-100">
<button *ngIf="showUpdateButton" mat-button color="warn" (click)="onClickRemoveAddon()"> {{
<button *ngIf="showRemoveButton" mat-button color="warn" (click)="onClickRemoveAddon()"> {{
"PAGES.MY_ADDONS.ADDON_CONTEXT_MENU.REMOVE_ADDON_BUTTON" | translate }}</button>
<div class="flex-grow-1"></div>
<a #providerLink mat-button class="mr-3 text-1" appExternalLink [href]="externalUrl"

View File

@@ -1,5 +1,3 @@
@import "../../../variables.scss";
.image-grid {
width: 70vw;
.image-thumb-container {

View File

@@ -8,19 +8,21 @@ import { MatDialogRef, MAT_DIALOG_DATA } from "@angular/material/dialog";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { TranslateCompiler, TranslateLoader, TranslateModule } from "@ngx-translate/core";
import { httpLoaderFactory } from "../../app.module";
import { AddonViewModel } from "../../business-objects/addon-view-model";
import { Addon } from "../../../common/entities/addon";
import { MatModule } from "../../mat-module";
import { AddonUpdateEvent } from "../../models/wowup/addon-update-event";
import { AddonService } from "../../services/addons/addon.service";
import { SessionService } from "../../services/session/session.service";
import { overrideIconModule } from "../../tests/mock-mat-icon";
import { httpLoaderFactory } from "../../../app.module";
import { AddonViewModel } from "../../../business-objects/addon-view-model";
import { Addon } from "../../../../common/entities/addon";
import { AddonUpdateEvent } from "../../../models/wowup/addon-update-event";
import { AddonService } from "../../../services/addons/addon.service";
import { SessionService } from "../../../services/session/session.service";
import { overrideIconModule } from "../../../tests/mock-mat-icon";
import { AddonDetailComponent, AddonDetailModel } from "./addon-detail.component";
import { mockPreload } from "../../tests/test-helpers";
import { WowUpService } from "../../services/wowup/wowup.service";
import { LightboxModule } from "ngx-lightbox";
import { LinkService } from "../../services/links/link.service";
import { mockPreload } from "../../../tests/test-helpers";
import { WowUpService } from "../../../services/wowup/wowup.service";
import { LinkService } from "../../../services/links/link.service";
import { GalleryModule } from "ng-gallery";
import { LightboxModule } from "ng-gallery/lightbox";
import { MatModule } from "../../../modules/mat-module";
import { AddonUiService } from "../../../services/addons/addon-ui.service";
describe("AddonDetailComponent", () => {
let dialogModel: AddonDetailModel;
@@ -28,6 +30,7 @@ describe("AddonDetailComponent", () => {
let sessionServiceSpy: SessionService;
let wowUpService: WowUpService;
let linkService: any;
let addonUiService: any;
beforeEach(async () => {
mockPreload();
@@ -42,6 +45,7 @@ describe("AddonDetailComponent", () => {
}
);
addonUiService = jasmine.createSpyObj("AddonUiService", [""], {});
wowUpService = jasmine.createSpyObj("WowUpService", [""], {});
linkService = jasmine.createSpyObj("LinkService", [""], {});
@@ -73,6 +77,7 @@ describe("AddonDetailComponent", () => {
useClass: TranslateMessageFormatCompiler,
},
}),
GalleryModule,
LightboxModule,
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
@@ -90,6 +95,7 @@ describe("AddonDetailComponent", () => {
provide: WowUpService,
useValue: wowUpService,
},
{ provide: AddonUiService, useValue: addonUiService },
],
},
});

View File

@@ -1,7 +1,7 @@
import { last } from "lodash";
import { BehaviorSubject, from, of, Subscription } from "rxjs";
import { filter, first, map, switchMap, tap } from "rxjs/operators";
import { IAlbum, Lightbox } from "ngx-lightbox";
import { Gallery, GalleryItem, ImageItem } from "ng-gallery";
import { BehaviorSubject, from, Subscription } from "rxjs";
import { filter, first, map, tap } from "rxjs/operators";
import {
AfterViewChecked,
@@ -15,24 +15,24 @@ import {
OnInit,
ViewChild,
} from "@angular/core";
import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from "@angular/material/dialog";
import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog";
import { MatTabChangeEvent, MatTabGroup } from "@angular/material/tabs";
import { TranslateService } from "@ngx-translate/core";
import { ADDON_PROVIDER_GITHUB, ADDON_PROVIDER_UNKNOWN } from "../../../common/constants";
import { AddonFundingLink } from "../../../common/entities/addon";
import { AddonChannelType, AddonDependency, AddonDependencyType } from "../../../common/wowup/models";
import { AddonViewModel } from "../../business-objects/addon-view-model";
import { AddonSearchResult } from "../../models/wowup/addon-search-result";
import { AddonSearchResultDependency } from "../../models/wowup/addon-search-result-dependency";
import { AddonUpdateEvent } from "../../models/wowup/addon-update-event";
import { AddonService } from "../../services/addons/addon.service";
import { SessionService } from "../../services/session/session.service";
import { SnackbarService } from "../../services/snackbar/snackbar.service";
import * as SearchResult from "../../utils/search-result.utils";
import { ConfirmDialogComponent } from "../confirm-dialog/confirm-dialog.component";
import { formatDynamicLinks } from "../../utils/dom.utils";
import { LinkService } from "../../services/links/link.service";
import { ADDON_PROVIDER_GITHUB, ADDON_PROVIDER_UNKNOWN, TAB_INDEX_MY_ADDONS } from "../../../../common/constants";
import { Addon, AddonFundingLink } from "../../../../common/entities/addon";
import { AddonChannelType, AddonDependency, AddonDependencyType } from "../../../../common/wowup/models";
import { AddonViewModel } from "../../../business-objects/addon-view-model";
import { AddonSearchResult } from "../../../models/wowup/addon-search-result";
import { AddonSearchResultDependency } from "../../../models/wowup/addon-search-result-dependency";
import { AddonUpdateEvent } from "../../../models/wowup/addon-update-event";
import { AddonService } from "../../../services/addons/addon.service";
import { LinkService } from "../../../services/links/link.service";
import { SessionService } from "../../../services/session/session.service";
import { SnackbarService } from "../../../services/snackbar/snackbar.service";
import { formatDynamicLinks } from "../../../utils/dom.utils";
import * as SearchResult from "../../../utils/search-result.utils";
import { AddonUiService } from "../../../services/addons/addon-ui.service";
export interface AddonDetailModel {
listItem?: AddonViewModel;
@@ -69,6 +69,7 @@ export class AddonDetailComponent implements OnInit, OnDestroy, AfterViewChecked
public hasChangeLog = false;
public showInstallButton = false;
public showUpdateButton = false;
public showRemoveButton = false;
public hasRequiredDependencies = false;
public title = "";
public subtitle = "";
@@ -85,19 +86,19 @@ export class AddonDetailComponent implements OnInit, OnDestroy, AfterViewChecked
public isUnknownProvider = false;
public isMissingUnknownDependencies = false;
public missingDependencies: string[] = [];
public imageUrls: IAlbum[] = [];
public previewItems: GalleryItem[] = [];
public constructor(
@Inject(MAT_DIALOG_DATA) public model: AddonDetailModel,
private _dialogRef: MatDialogRef<AddonDetailComponent>,
private _dialog: MatDialog,
private _addonService: AddonService,
private _cdRef: ChangeDetectorRef,
private _snackbarService: SnackbarService,
private _translateService: TranslateService,
private _sessionService: SessionService,
private _lightbox: Lightbox,
private _linkService: LinkService
private _linkService: LinkService,
private _addonUiService: AddonUiService,
public gallery: Gallery
) {
this._dependencies = this.getDependencies();
@@ -130,6 +131,8 @@ export class AddonDetailComponent implements OnInit, OnDestroy, AfterViewChecked
this.showUpdateButton = !!this.model.listItem;
this.showRemoveButton = this.isAddonInstalled();
this.title = this.model.listItem?.addon?.name || this.model.searchResult?.name || "UNKNOWN";
this.subtitle = this.model.listItem?.addon?.author || this.model.searchResult?.author || "UNKNOWN";
@@ -169,12 +172,12 @@ export class AddonDetailComponent implements OnInit, OnDestroy, AfterViewChecked
this.isMissingUnknownDependencies = !!this.missingDependencies.length;
const imageUrlList = this.model.listItem?.addon?.screenshotUrls ?? this.model.searchResult?.screenshotUrls ?? [];
this.imageUrls = imageUrlList.map((url) => {
return {
src: url,
thumb: url,
};
this.previewItems = imageUrlList.map((url) => {
return new ImageItem({ src: url, thumb: url });
});
this.gallery.ref().load(this.previewItems);
}
public ngAfterViewInit(): void {}
@@ -190,15 +193,6 @@ export class AddonDetailComponent implements OnInit, OnDestroy, AfterViewChecked
this._subscriptions.forEach((sub) => sub.unsubscribe());
}
public onClickImage(url: string): void {
const idx = this.imageUrls.findIndex((album) => album.src === url);
if (idx >= 0) {
this._lightbox.open(this.imageUrls, idx, {
centerVertically: true,
});
}
}
public onInstallUpdated(): void {
this._cdRef.detectChanges();
}
@@ -214,39 +208,44 @@ export class AddonDetailComponent implements OnInit, OnDestroy, AfterViewChecked
}
public onClickRemoveAddon(): void {
if (!this.model.listItem?.addon?.name) {
console.warn("Invalid model list item addon");
let addon: Addon = null;
// Addon is expected to be available through the model when browsing My Addons tab
if (this._sessionService.getSelectedHomeTab() === TAB_INDEX_MY_ADDONS) {
if (!this.model.listItem?.addon.name) {
console.warn("Invalid model list item addon");
return;
}
addon = this.model.listItem?.addon;
} else {
const selectedInstallation = this._sessionService.getSelectedWowInstallation();
const externalId = this.model.searchResult?.externalId ?? "";
const providerName = this.model.searchResult?.providerName ?? "";
if (!externalId || !providerName || !selectedInstallation) {
console.warn("Invalid search result when identifying which addon to remove", {
selectedInstallation,
externalId,
providerName,
});
return;
}
addon = this._addonService.getByExternalId(externalId, providerName, selectedInstallation.id);
}
if (!addon) {
console.warn("Invalid addon when attempting removal");
return;
}
this.getRemoveAddonPrompt(this.model.listItem.addon.name)
.afterClosed()
this._addonUiService
.handleRemoveAddon(addon)
.pipe(
first(),
switchMap((result) => {
if (!result) {
return of(false);
}
const addon = this.model.listItem?.addon;
if (!addon) {
console.warn(`Invalid addon`);
return of(false);
}
if (this._addonService.getRequiredDependencies(addon).length === 0) {
return from(this._addonService.removeAddon(addon)).pipe(map(() => true));
} else {
return this.getRemoveDependenciesPrompt(addon.name, (addon.dependencies ?? []).length)
.afterClosed()
.pipe(
switchMap((result) => from(this._addonService.removeAddon(addon, result))),
map(() => true)
);
}
}),
map((shouldClose) => {
if (shouldClose) {
map((result) => {
if (result.removed) {
this._dialogRef.close();
}
})
@@ -254,44 +253,6 @@ export class AddonDetailComponent implements OnInit, OnDestroy, AfterViewChecked
.subscribe();
}
private getRemoveAddonPrompt(addonName: string): MatDialogRef<ConfirmDialogComponent, any> {
const title = this._translateService.instant("PAGES.MY_ADDONS.UNINSTALL_POPUP.TITLE", { count: 1 });
const message1: string = this._translateService.instant("PAGES.MY_ADDONS.UNINSTALL_POPUP.CONFIRMATION_ONE", {
addonName,
});
const message2: string = this._translateService.instant(
"PAGES.MY_ADDONS.UNINSTALL_POPUP.CONFIRMATION_ACTION_EXPLANATION"
);
return this._dialog.open(ConfirmDialogComponent, {
data: {
title,
message: `${message1}\n\n${message2}`,
},
});
}
private getRemoveDependenciesPrompt(
addonName: string,
dependencyCount: number
): MatDialogRef<ConfirmDialogComponent, any> {
const title = this._translateService.instant("PAGES.MY_ADDONS.UNINSTALL_POPUP.DEPENDENCY_TITLE");
const message1: string = this._translateService.instant("PAGES.MY_ADDONS.UNINSTALL_POPUP.DEPENDENCY_MESSAGE", {
addonName,
dependencyCount,
});
const message2: string = this._translateService.instant(
"PAGES.MY_ADDONS.UNINSTALL_POPUP.CONFIRMATION_ACTION_EXPLANATION"
);
return this._dialog.open(ConfirmDialogComponent, {
data: {
title,
message: `${message1}\n\n${message2}`,
},
});
}
private getSelectedTabTypeFromIndex(index: number): DetailsTabType {
return index === 0 ? "description" : "changelog";
}
@@ -432,4 +393,21 @@ export class AddonDetailComponent implements OnInit, OnDestroy, AfterViewChecked
private getLatestSearchResultFile() {
return SearchResult.getLatestFile(this.model.searchResult, this.model.channelType ?? AddonChannelType.Stable);
}
private isAddonInstalled(): boolean {
const selectedInstallation = this._sessionService.getSelectedWowInstallation();
if (!selectedInstallation) {
console.warn("No selected installation");
return;
}
const externalId = this.model.searchResult?.externalId ?? this.model.listItem?.addon?.externalId ?? "";
const providerName = this.model.searchResult?.providerName ?? this.model.listItem?.addon?.providerName ?? "";
if (externalId && providerName) {
return this._addonService.isInstalled(externalId, providerName, selectedInstallation);
}
console.warn("Invalid list item addon when verifying if installed");
}
}

View File

@@ -1,15 +1,17 @@
import { TranslateMessageFormatCompiler } from "ngx-translate-messageformat-compiler";
import { Subject } from "rxjs";
import { HttpClient, HttpClientModule } from "@angular/common/http";
import { TestBed } from "@angular/core/testing";
import { TranslateCompiler, TranslateLoader, TranslateModule } from "@ngx-translate/core";
import { HttpClient, HttpClientModule } from "@angular/common/http";
import { TranslateMessageFormatCompiler } from "ngx-translate-messageformat-compiler";
import { WowClientType } from "../../../../common/warcraft/wow-client-type";
import { httpLoaderFactory } from "../../../app.module";
import { AddonUpdateEvent } from "../../../models/wowup/addon-update-event";
import { AddonService } from "../../../services/addons/addon.service";
import { SessionService } from "../../../services/session/session.service";
import { ProgressButtonComponent } from "../../common/progress-button/progress-button.component";
import { AddonInstallButtonComponent } from "./addon-install-button.component";
import { AddonService } from "../../services/addons/addon.service";
import { SessionService } from "../../services/session/session.service";
import { httpLoaderFactory } from "../../app.module";
import { WowClientType } from "../../../common/warcraft/wow-client-type";
import { Subject } from "rxjs";
import { AddonUpdateEvent } from "../../models/wowup/addon-update-event";
import { ProgressButtonComponent } from "../progress-button/progress-button.component";
describe("AddonInstallButtonComponent", () => {
let addonServiceSpy: AddonService;

View File

@@ -4,11 +4,11 @@ import { filter } from "rxjs/operators";
import { ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { TranslateService } from "@ngx-translate/core";
import { AddonInstallState } from "../../models/wowup/addon-install-state";
import { AddonSearchResult } from "../../models/wowup/addon-search-result";
import { AddonUpdateEvent } from "../../models/wowup/addon-update-event";
import { AddonService } from "../../services/addons/addon.service";
import { SessionService } from "../../services/session/session.service";
import { AddonInstallState } from "../../../models/wowup/addon-install-state";
import { AddonSearchResult } from "../../../models/wowup/addon-search-result";
import { AddonUpdateEvent } from "../../../models/wowup/addon-update-event";
import { AddonService } from "../../../services/addons/addon.service";
import { SessionService } from "../../../services/session/session.service";
@Component({
selector: "app-addon-install-button",

View File

@@ -0,0 +1,162 @@
<div mat-dialog-title class="addon-manage-dialog">
<div class="row align-items-center">
<h2 class="flex-grow-1 m-0">
{{ "ADDON_IMPORT.DIALOG_TITLE" | translate: { clientType: selectedInstallation.label } }}
</h2>
<button mat-icon-button [mat-dialog-close]="true" color="accent" [disabled]="(installing$ | async) === true">
<mat-icon svgIcon="fas:times"> </mat-icon>
</button>
</div>
</div>
<mat-dialog-content>
<mat-tab-group (selectedIndexChange)="selectedTab$.next($event)">
<!-- EXPORT -->
<mat-tab [label]="'ADDON_IMPORT.EXPORT_TAB_LABEL' | translate" [disabled]="(installing$ | async) === true">
<div class="row align-items-center">
<p *ngIf="exportSummary !== undefined" class="mt-3 flex-grow-1">
<span>{{ "ADDON_IMPORT.ACTIVE_ADDON_COUNT" | translate: { count: exportSummary.activeCount } }}</span>
<span *ngIf="exportSummary.ignoreCount > 0" class="text-warning">
{{ "ADDON_IMPORT.IGNORED_ADDON_COUNT" | translate: { count: exportSummary.ignoreCount } }}
</span>
</p>
</div>
<div *ngIf="exportPayload !== undefined">
<mat-form-field appearance="fill" class="w-100">
<mat-label>{{ "ADDON_IMPORT.EXPORT_TEXT_LABEL" | translate }}</mat-label>
<textarea matInput class="export-content" spellcheck="false" readonly="readonly">{{
exportPayload
}}</textarea>
</mat-form-field>
</div>
</mat-tab>
<!-- IMPORT -->
<mat-tab [label]="'ADDON_IMPORT.IMPORT_TAB_LABEL' | translate" [disabled]="(installing$ | async) === true">
<!-- IMPORT FORM -->
<div *ngIf="(hasImportSummary$ | async) === false">
<div class="row align-items-center mb-1">
<p class="mt-3 flex-grow-1">
<span>{{ "ADDON_IMPORT.IMPORT_TEXT_INSTRUCTIONS" | translate }}</span>
</p>
</div>
<mat-form-field appearance="fill" class="w-100">
<mat-label>{{ "ADDON_IMPORT.IMPORT_TEXT_LABEL" | translate }}</mat-label>
<textarea matInput class="import-content" spellcheck="false" [(ngModel)]="importData"></textarea>
</mat-form-field>
</div>
<!-- IMPORT RESULT -->
<div *ngIf="hasImportSummary$ | async">
<p class="m-0 mt-3">
{{ "ADDON_IMPORT.IMPORT_TOTAL_COUNT" | translate: { count: importSummaryComparisonCt$ | async } }}
</p>
<p class="text-2">
<span *ngIf="(importSummaryConflictCt$ | async) > 0"
>{{ "ADDON_IMPORT.IMPORT_CONFLICT_COUNT" | translate: { count: importSummaryConflictCt$ | async } }}
</span>
<span *ngIf="(importSummaryAddedCt$ | async) > 0"
>{{ "ADDON_IMPORT.IMPORT_ADDED_COUNT" | translate: { count: importSummaryAddedCt$ | async } }}
</span>
<span *ngIf="(importSummaryNoChangeCt$ | async) > 0">
{{ "ADDON_IMPORT.IMPORT_NO_CHANGE_COUNT" | translate: { count: importSummaryNoChangeCt$ | async } }}
</span>
</p>
<div class="bg-secondary-2 rounded p-3">
<div *ngFor="let comp of importSummaryComparisons$ | async">
<div class="comparison-row" [ngClass]="[comp.state, comp.state === 'no-change' ? 'text-3' : '']">
<div *ngIf="comp.isInstalling === false">
<div
*ngIf="comp.state === 'no-change'"
class="rounded comp-badge no-change-badge"
[matTooltip]="'ADDON_IMPORT.NO_CHANGE_BADGE_TOOLTIP' | translate"
>
{{ "ADDON_IMPORT.IMPORT_BADGE_NO_CHANGE" | translate }}
</div>
<div
*ngIf="comp.state === 'added'"
class="rounded comp-badge added-badge"
[matTooltip]="'ADDON_IMPORT.ADDED_BADGE_TOOLTIP' | translate"
>
{{ "ADDON_IMPORT.IMPORT_BADGE_ADDED" | translate }}
</div>
<div
*ngIf="comp.state === 'conflict'"
class="rounded comp-badge conflict-badge"
[matTooltip]="'ADDON_IMPORT.CONFLICT_BADGE_TOOLTIP' | translate"
>
{{ "ADDON_IMPORT.IMPORT_BADGE_CONFLICT" | translate }}
</div>
</div>
<div *ngIf="comp.isInstalling === true">
<div *ngIf="comp.isCompleted === false" class="rounded comp-badge">
<mat-spinner [diameter]="20"></mat-spinner>
</div>
<div *ngIf="comp.isCompleted === true" class="rounded comp-badge">
<mat-icon class="badge-icon success-icon" svgIcon="far:check-circle"> </mat-icon>
</div>
</div>
<div>
<div class="comp-text">{{ comp.imported.name }}</div>
<small *ngIf="comp.state === 'conflict'" class="text-2">{{
"ADDON_IMPORT." + comp.conflictReason | translate
}}</small>
</div>
</div>
</div>
</div>
</div>
</mat-tab>
</mat-tab-group>
</mat-dialog-content>
<!-- EXPORT ACTIONS -->
<mat-dialog-actions *ngIf="(selectedTab$ | async) === TAB_IDX_EXPORT">
<div class="flex-grow-1"></div>
<button
*ngIf="exportPayload !== undefined"
mat-flat-button
color="primary"
[cdkCopyToClipboard]="exportPayload"
[disabled]="(installing$ | async) === true"
(click)="onClickCopy()"
>
{{ "ADDON_IMPORT.COPY_BUTTON" | translate }}
</button>
</mat-dialog-actions>
<!-- IMPORT ACTIONS -->
<mat-dialog-actions *ngIf="(selectedTab$ | async) === TAB_IDX_IMPORT">
<button
*ngIf="hasImportSummary$ | async"
mat-button
color="warn"
[disabled]="(installing$ | async) === true"
(click)="importSummary$.next(undefined)"
>
{{ "ADDON_IMPORT.RESET_BUTTON" | translate }}
</button>
<div class="flex-grow-1"></div>
<button
*ngIf="hasImportSummary$ | async"
mat-flat-button
color="primary"
[disabled]="(canInstall$ | async) === false || (installing$ | async) === true"
(click)="onClickInstall()"
>
{{ "ADDON_IMPORT.INSTALL_BUTTON" | translate }}
</button>
<button
*ngIf="(hasImportSummary$ | async) === false"
mat-button
color="accent"
[disabled]="(installing$ | async) === true"
(click)="onClickPaste()"
>
{{ "ADDON_IMPORT.PASTE_BUTTON" | translate }}
</button>
<button
*ngIf="(hasImportSummary$ | async) === false"
mat-flat-button
color="primary"
[disabled]="(installing$ | async) === true"
(click)="onClickImport()"
>
{{ "ADDON_IMPORT.IMPORT_BUTTON" | translate }}
</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,61 @@
.addon-manage-dialog {
min-width: 600px;
}
.export-content {
resize: none;
width: 100%;
min-height: 200px;
}
.import-content {
resize: none;
width: 100%;
min-height: 200px;
}
.comparison-row {
margin: 0.5em 0;
display: flex;
flex-direction: row;
border-bottom: 1px solid var(--background-secondary-1);
padding-bottom: 0.25em;
// align-items: center;
}
.comp-badge {
display: flex;
padding: 0.25em 0.5em;
margin-right: 0.5em;
font-size: 0.8em;
background-color: var(--background-secondary-1);
img {
width: 20px;
}
mat-icon {
height: 18px;
}
}
.comp-text {
padding: 0.25em 0;
}
.added-badge {
background-color: var(--success-color);
}
.conflict-badge {
background-color: var(--warning-color);
color: black;
}
.no-change-badge {
background-color: var(--background-secondary-1);
}
.success-icon {
text-align: center;
filter: invert(23%) sepia(99%) saturate(1821%) hue-rotate(104deg) brightness(96%) contrast(106%);
}

View File

@@ -0,0 +1,172 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { BehaviorSubject, Subscription } from "rxjs";
import { map } from "rxjs/operators";
import { AddonInstallState } from "../../../models/wowup/addon-install-state";
import { WowInstallation } from "../../../../common/warcraft/wow-installation";
import {
AddonBrokerService,
ExportPayload,
ExportSummary,
ImportComparison,
ImportSummary,
} from "../../../services/addons/addon-broker.service";
import { SessionService } from "../../../services/session/session.service";
import { SnackbarService } from "../../../services/snackbar/snackbar.service";
import { ElectronService } from "../../../services";
interface ImportComparisonViewModel extends ImportComparison {
isInstalling?: boolean;
isCompleted?: boolean;
didError?: boolean;
}
interface ImportSummaryViewModel extends ImportSummary {
comparisons: ImportComparisonViewModel[];
}
@Component({
selector: "app-addon-manage-dialog",
templateUrl: "./addon-manage-dialog.component.html",
styleUrls: ["./addon-manage-dialog.component.scss"],
})
export class AddonManageDialogComponent implements OnInit, OnDestroy {
private readonly _subscriptions: Subscription[] = [];
public readonly selectedTab$ = new BehaviorSubject<number>(0);
public readonly TAB_IDX_EXPORT = 0;
public readonly TAB_IDX_IMPORT = 1;
public readonly selectedInstallation: WowInstallation;
public exportSummary: ExportSummary | undefined;
public exportPayload!: string;
public importData = "";
public installing$ = new BehaviorSubject<boolean>(false);
public importSummary$ = new BehaviorSubject<ImportSummaryViewModel | undefined>(undefined);
public hasImportSummary$ = this.importSummary$.pipe(map((summary) => summary !== undefined));
public importSummaryAddedCt$ = this.importSummary$.pipe(map((summary) => summary?.addedCt ?? 0));
public importSummaryConflictCt$ = this.importSummary$.pipe(map((summary) => summary?.conflictCt ?? 0));
public importSummaryNoChangeCt$ = this.importSummary$.pipe(map((summary) => summary?.noChangeCt ?? 0));
public importSummaryComparisons$ = this.importSummary$.pipe(map((summary) => summary?.comparisons ?? []));
public importSummaryComparisonCt$ = this.importSummary$.pipe(map((summary) => summary?.comparisons?.length ?? 0));
public canInstall$ = this.importSummary$.pipe(
map((summary) => {
if (!summary) {
return false;
}
// if there are any new addons, we can install
return summary.comparisons.some((comp) => comp.state === "added");
})
);
public constructor(
private _electronService: ElectronService,
private _addonBrokerService: AddonBrokerService,
private _sessionService: SessionService,
private _snackbarService: SnackbarService
) {
this.selectedInstallation = this._sessionService.getSelectedWowInstallation();
}
public ngOnInit(): void {
this.exportSummary = this._addonBrokerService.getExportSummary(this.selectedInstallation);
const payload = this._addonBrokerService.getExportPayload(this.selectedInstallation);
this.exportPayload = btoa(JSON.stringify(payload));
const installSub = this._addonBrokerService.addonInstall$.subscribe((evt) => {
console.log("Install", evt);
const viewModel = { ...this.importSummary$.value };
const compVm = viewModel.comparisons.find((comp) => comp.id === evt.comparisonId);
compVm.isInstalling = true;
compVm.isCompleted = evt.installState === AddonInstallState.Complete;
compVm.didError = evt.installState === AddonInstallState.Error;
this.importSummary$.next(viewModel);
});
this._subscriptions.push(installSub);
}
public ngOnDestroy(): void {
this._subscriptions.forEach((sub) => sub.unsubscribe());
}
public onClickCopy(): void {
this._snackbarService.showSuccessSnackbar("ADDON_IMPORT.EXPORT_STRING_COPIED", {
timeout: 2000,
});
}
public async onClickPaste(): Promise<void> {
try {
const txt = await this._electronService.readClipboardText();
this.importData = txt;
this._snackbarService.showSuccessSnackbar("ADDON_IMPORT.EXPORT_STRING_PASTED", {
timeout: 2000,
});
} catch (e) {
console.error(e);
}
}
public async onClickInstall(): Promise<void> {
try {
this.installing$.next(true);
await this._addonBrokerService.installImportSummary(this.importSummary$.value, this.selectedInstallation);
} catch (e) {
console.error(e);
} finally {
this.installing$.next(false);
}
}
public onClickImport(): void {
let importJson: ExportPayload;
try {
importJson = this._addonBrokerService.parseImportString(this.importData);
console.debug(importJson);
} catch (e) {
console.error(e);
this._snackbarService.showErrorSnackbar("ADDON_IMPORT.IMPORT_STRING_INVALID", {
timeout: 2000,
});
return;
}
try {
const importSummary = this._addonBrokerService.getImportSummary(importJson, this.selectedInstallation);
console.debug(importSummary);
if (importSummary.errorCode !== undefined) {
this._snackbarService.showErrorSnackbar(`ADDON_IMPORT.${importSummary.errorCode}`, {
timeout: 2000,
});
return;
}
const viewModel = this.getImportSummaryViewModel(importSummary);
this.importSummary$.next(viewModel);
} catch (e) {
console.error(e);
this._snackbarService.showErrorSnackbar("ADDON_IMPORT.GENERIC_IMPORT_ERROR", {
timeout: 2000,
});
}
}
private getImportSummaryViewModel(importSummary: ImportSummary): ImportSummaryViewModel {
const viewModel: ImportSummaryViewModel = { ...importSummary };
viewModel.comparisons.forEach((comp) => {
comp.isInstalling = false;
comp.didError = false;
comp.isCompleted = false;
});
return viewModel;
}
}

View File

@@ -1,8 +1,8 @@
<div *ngIf="hasUrl() === true" class="addon-logo-container bg-secondary-3" [style.width]="size + 'px'"
<div *ngIf="hasUrl() === true" class="addon-logo-container bg-secondary-3 rounded" [style.width]="size + 'px'"
[style.height]="size + 'px'">
<img [src]="url" loading="lazy" />
</div>
<div *ngIf="hasUrl() === false" class="addon-logo-container">
<div *ngIf="hasUrl() === false" class="addon-logo-container bg-secondary-3 rounded">
<div class="addon-logo-letter text-3">
{{ getLetter() }}
</div>

View File

@@ -1,18 +1,21 @@
import { TestBed } from "@angular/core/testing";
import { TranslateCompiler, TranslateLoader, TranslateModule } from "@ngx-translate/core";
import { HttpClient, HttpClientModule } from "@angular/common/http";
import { TranslateMessageFormatCompiler } from "ngx-translate-messageformat-compiler";
import { MatDialog } from "@angular/material/dialog";
import { AnalyticsService } from "../../services/analytics/analytics.service";
import { AddonService } from "../../services/addons/addon.service";
import { ElectronService } from "../../services";
import { httpLoaderFactory } from "../../app.module";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { AddonUpdateButtonComponent } from "./addon-update-button.component";
import { Subject } from "rxjs";
import { AddonUpdateEvent } from "../../models/wowup/addon-update-event";
import { MatModule } from "../../mat-module";
import { ProgressButtonComponent } from "../progress-button/progress-button.component";
import { HttpClient, HttpClientModule } from "@angular/common/http";
import { TestBed } from "@angular/core/testing";
import { MatDialog } from "@angular/material/dialog";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { TranslateCompiler, TranslateLoader, TranslateModule } from "@ngx-translate/core";
import { httpLoaderFactory } from "../../../app.module";
import { MatModule } from "../../../modules/mat-module";
import { AddonUpdateEvent } from "../../../models/wowup/addon-update-event";
import { ElectronService } from "../../../services";
import { AddonService } from "../../../services/addons/addon.service";
import { AnalyticsService } from "../../../services/analytics/analytics.service";
import { ProgressButtonComponent } from "../../common/progress-button/progress-button.component";
import { AddonUpdateButtonComponent } from "./addon-update-button.component";
describe("AddonUpdateButtonComponent", () => {
let addonServiceSpy: AddonService;
let analyticsServiceSpy: AnalyticsService;

View File

@@ -4,13 +4,13 @@ import { filter } from "rxjs/operators";
import { ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { TranslateService } from "@ngx-translate/core";
import { WowClientType } from "../../../common/warcraft/wow-client-type";
import { AddonViewModel } from "../../business-objects/addon-view-model";
import { AddonInstallState } from "../../models/wowup/addon-install-state";
import { AddonUpdateEvent } from "../../models/wowup/addon-update-event";
import { AddonService } from "../../services/addons/addon.service";
import { getEnumName } from "../../utils/enum.utils";
import { ADDON_PROVIDER_UNKNOWN } from "../../../common/constants";
import { WowClientType } from "../../../../common/warcraft/wow-client-type";
import { AddonViewModel } from "../../../business-objects/addon-view-model";
import { AddonInstallState } from "../../../models/wowup/addon-install-state";
import { AddonUpdateEvent } from "../../../models/wowup/addon-update-event";
import { AddonService } from "../../../services/addons/addon.service";
import { getEnumName } from "../../../utils/enum.utils";
import { ADDON_PROVIDER_UNKNOWN } from "../../../../common/constants";
@Component({
selector: "app-addon-update-button",

View File

@@ -1,8 +1,9 @@
import { DatePipe } from "@angular/common";
import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NgxDatePipe } from "../../pipes/ngx-date.pipe";
import { RelativeDurationPipe } from "../../pipes/relative-duration-pipe";
import { getStandardTestImports } from "../../utils/test.utils";
import { NgxDatePipe } from "../../../pipes/ngx-date.pipe";
import { RelativeDurationPipe } from "../../../pipes/relative-duration-pipe";
import { getStandardTestImports } from "../../../utils/test.utils";
import { DateTooltipCellComponent } from "./date-tooltip-cell.component";
@@ -14,7 +15,7 @@ describe("DateTooltipCellComponent", () => {
await TestBed.configureTestingModule({
declarations: [DateTooltipCellComponent, RelativeDurationPipe, NgxDatePipe],
imports: [...getStandardTestImports()],
providers: [RelativeDurationPipe, NgxDatePipe],
providers: [RelativeDurationPipe, NgxDatePipe, DatePipe],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents();
});

View File

@@ -6,6 +6,7 @@
</a>
<a *ngIf="size === 'small'" class="funding-button small text-1 hover-primary-2 text-1-hover" appExternalLink
[matTooltip]="tooltipKey | translate:{platform:fundingName}" [href]="funding.url">
<mat-icon *ngIf="isFontIcon === true" [svgIcon]="iconSrc"></mat-icon>
<mat-icon *ngIf="isFontIcon === true" [class]="getClassName()" [svgIcon]="iconSrc">
</mat-icon>
<img *ngIf="isFontIcon === false" class="funding-icon" [src]="iconSrc" />
</a>

View File

@@ -1,5 +1,3 @@
@import "../../../variables.scss";
:host {
margin-right: 0.5em;
@@ -41,3 +39,9 @@
}
}
}
.patreon-icon {
color: var(--patreon-color);
}
.custom-icon {
color: var(--wow-gold-color);
}

View File

@@ -1,13 +1,15 @@
import { TranslateMessageFormatCompiler } from "ngx-translate-messageformat-compiler";
import { HttpClient, HttpClientModule } from "@angular/common/http";
import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { TranslateCompiler, TranslateLoader, TranslateModule } from "@ngx-translate/core";
import { TranslateMessageFormatCompiler } from "ngx-translate-messageformat-compiler";
import { httpLoaderFactory } from "../../app.module";
import { MatModule } from "../../mat-module";
import { MatIcon } from "@angular/material/icon";
import { MatIconTestingModule } from "@angular/material/icon/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { TranslateCompiler, TranslateLoader, TranslateModule } from "@ngx-translate/core";
import { httpLoaderFactory } from "../../../app.module";
import { MatModule } from "../../../modules/mat-module";
import { FundingButtonComponent } from "./funding-button.component";
describe("FundingButtonComponent", () => {

View File

@@ -1,5 +1,5 @@
import { Component, Input, OnInit } from "@angular/core";
import { AddonFundingLink } from "../../../common/entities/addon";
import { AddonFundingLink } from "../../../../common/entities/addon";
@Component({
selector: "app-funding-button",
@@ -26,6 +26,16 @@ export class FundingButtonComponent implements OnInit {
return `PAGES.MY_ADDONS.FUNDING_TOOLTIP.${this.funding.platform.toUpperCase()}`;
}
public getClassName(): string {
switch (this.funding.platform) {
case "PATREON":
return "patreon-icon";
case "GITHUB":
return "github-icon";
default:
return "custom-icon";
}
}
private getIsFontIcon(): boolean {
switch (this.funding.platform) {
case "PATREON":

View File

@@ -1,7 +1,9 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { AgRendererComponent } from "ag-grid-angular";
import { ICellRendererParams } from "ag-grid-community";
import { AddonSearchResult } from "../../models/wowup/addon-search-result";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { AddonSearchResult } from "../../../models/wowup/addon-search-result";
@Component({
selector: "app-get-addon-status-column",

View File

@@ -5,13 +5,13 @@ import { MatDialogRef, MAT_DIALOG_DATA } from "@angular/material/dialog";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { TranslateCompiler, TranslateLoader, TranslateModule } from "@ngx-translate/core";
import { TranslateMessageFormatCompiler } from "ngx-translate-messageformat-compiler";
import { httpLoaderFactory } from "../../app.module";
import { MatModule } from "../../mat-module";
import { AddonService } from "../../services/addons/addon.service";
import { IconService } from "../../services/icons/icon.service";
import { SessionService } from "../../services/session/session.service";
import { WarcraftInstallationService } from "../../services/warcraft/warcraft-installation.service";
import { overrideIconModule } from "../../tests/mock-mat-icon";
import { httpLoaderFactory } from "../../../app.module";
import { MatModule } from "../../../modules/mat-module";
import { AddonService } from "../../../services/addons/addon.service";
import { IconService } from "../../../services/icons/icon.service";
import { SessionService } from "../../../services/session/session.service";
import { WarcraftInstallationService } from "../../../services/warcraft/warcraft-installation.service";
import { overrideIconModule } from "../../../tests/mock-mat-icon";
import {
InstallFromProtocolDialogComponent,

View File

@@ -6,11 +6,11 @@ import { AfterViewInit, Component, Inject, OnInit } from "@angular/core";
import { FormControl } from "@angular/forms";
import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog";
import { ProtocolSearchResult } from "../../models/wowup/protocol-search-result";
import { WowInstallation } from "../../models/wowup/wow-installation";
import { AddonService } from "../../services/addons/addon.service";
import { SessionService } from "../../services/session/session.service";
import { WarcraftInstallationService } from "../../services/warcraft/warcraft-installation.service";
import { ProtocolSearchResult } from "../../../models/wowup/protocol-search-result";
import { AddonService } from "../../../services/addons/addon.service";
import { SessionService } from "../../../services/session/session.service";
import { WarcraftInstallationService } from "../../../services/warcraft/warcraft-installation.service";
import { WowInstallation } from "../../../../common/warcraft/wow-installation";
export interface InstallFromProtocolDialogComponentData {
protocol: string;

View File

@@ -6,10 +6,23 @@
<p>{{ "DIALOGS.INSTALL_FROM_URL.SUPPORTED_SOURCES" | translate }}</p>
<mat-form-field class="url-input-container">
<mat-label>{{ "DIALOGS.INSTALL_FROM_URL.ADDON_URL_INPUT_LABEL" | translate }}</mat-label>
<input matInput [placeholder]="'DIALOGS.INSTALL_FROM_URL.ADDON_URL_INPUT_PLACEHOLDER' | translate"
[(ngModel)]="query" (keyup.enter)="onImportUrl()" [disabled]="isBusy === true" />
<button mat-button color="accent" *ngIf="query" matSuffix mat-icon-button aria-label="Clear"
(click)="onClearSearch()" [disabled]="isBusy === true">
<input
matInput
[placeholder]="'DIALOGS.INSTALL_FROM_URL.ADDON_URL_INPUT_PLACEHOLDER' | translate"
[(ngModel)]="query"
(keyup.enter)="onImportUrl()"
[disabled]="isBusy === true"
/>
<button
mat-button
color="accent"
*ngIf="query"
matSuffix
mat-icon-button
aria-label="Clear"
(click)="onClearSearch()"
[disabled]="isBusy === true"
>
<mat-icon svgIcon="fas:times"></mat-icon>
</button>
</mat-form-field>
@@ -17,8 +30,11 @@
<mat-spinner diameter="50"></mat-spinner>
</div>
<div *ngIf="isBusy === false && addon !== undefined" class="addon-container">
<div *ngIf="hasThumbnail === true" class="addon-thumb" [style.backgroundImage]="'url(' + addon.thumbnailUrl + ')'">
</div>
<div
*ngIf="hasThumbnail === true"
class="addon-thumb"
[style.backgroundImage]="'url(' + addon.thumbnailUrl + ')'"
></div>
<div *ngIf="hasThumbnail === false" class="addon-thumb">
<div class="addon-logo-letter text-3">
{{ thumbnailLetter }}
@@ -27,7 +43,7 @@
<div class="addon-info">
<h4>{{ addon.name }}</h4>
<p>{{ addon.author }}</p>
<p>{{ addon.files?.length ? addon?.files[0]?.version ?? '' : '' }}</p>
<p>{{ addon.files?.length ? addon?.files[0]?.version ?? "" : "" }}</p>
<p class="addon-download-count">
{{ "DIALOGS.INSTALL_FROM_URL.DOWNLOAD_COUNT" | translate: getDownloadCountParams() }}
</p>
@@ -55,4 +71,4 @@
<button mat-flat-button color="primary" cdkFocusInitial (click)="onImportUrl()">
{{ "DIALOGS.INSTALL_FROM_URL.IMPORT_BUTTON" | translate }}
</button>
</div>
</div>

View File

@@ -6,14 +6,14 @@ import { MatDialogRef } from "@angular/material/dialog";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { TranslateCompiler, TranslateLoader, TranslateModule } from "@ngx-translate/core";
import { httpLoaderFactory } from "../../app.module";
import { MatModule } from "../../mat-module";
import { DownloadCountPipe } from "../../pipes/download-count.pipe";
import { AddonService } from "../../services/addons/addon.service";
import { SessionService } from "../../services/session/session.service";
import { httpLoaderFactory } from "../../../app.module";
import { MatModule } from "../../../modules/mat-module";
import { DownloadCountPipe } from "../../../pipes/download-count.pipe";
import { AddonService } from "../../../services/addons/addon.service";
import { SessionService } from "../../../services/session/session.service";
import { InstallFromUrlDialogComponent } from "./install-from-url-dialog.component";
import { IconService } from "../../services/icons/icon.service";
import { overrideIconModule } from "../../tests/mock-mat-icon";
import { IconService } from "../../../services/icons/icon.service";
import { overrideIconModule } from "../../../tests/mock-mat-icon";
describe("InstallFromUrlDialogComponent", () => {
console.log("InstallFromUrlDialogComponent");

View File

@@ -2,21 +2,21 @@ import { HttpErrorResponse } from "@angular/common/http";
import { Component, OnDestroy } from "@angular/core";
import { MatDialog, MatDialogRef } from "@angular/material/dialog";
import { from, Subscription } from "rxjs";
import { AddonSearchResult } from "../../models/wowup/addon-search-result";
import { AddonService } from "../../services/addons/addon.service";
import { SessionService } from "../../services/session/session.service";
import { AlertDialogComponent } from "../alert-dialog/alert-dialog.component";
import { AddonSearchResult } from "../../../models/wowup/addon-search-result";
import { AddonService } from "../../../services/addons/addon.service";
import { SessionService } from "../../../services/session/session.service";
import { AlertDialogComponent } from "../../common/alert-dialog/alert-dialog.component";
import { TranslateService } from "@ngx-translate/core";
import { roundDownloadCount, shortenDownloadCount } from "../../utils/number.utils";
import { DownloadCountPipe } from "../../pipes/download-count.pipe";
import { NO_SEARCH_RESULTS_ERROR } from "../../../common/constants";
import { roundDownloadCount, shortenDownloadCount } from "../../../utils/number.utils";
import { DownloadCountPipe } from "../../../pipes/download-count.pipe";
import { NO_SEARCH_RESULTS_ERROR } from "../../../../common/constants";
import {
AssetMissingError,
BurningCrusadeAssetMissingError,
ClassicAssetMissingError,
GitHubLimitError,
NoReleaseFoundError,
} from "../../errors";
} from "../../../errors";
interface DownloadCounts {
count: number;

View File

@@ -6,10 +6,10 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
import { MatDialog } from "@angular/material/dialog";
import { TranslateCompiler, TranslateLoader, TranslateModule } from "@ngx-translate/core";
import { httpLoaderFactory } from "../../app.module";
import { MatModule } from "../../mat-module";
import { AddonUpdateEvent } from "../../models/wowup/addon-update-event";
import { AddonService } from "../../services/addons/addon.service";
import { httpLoaderFactory } from "../../../app.module";
import { MatModule } from "../../../modules/mat-module";
import { AddonUpdateEvent } from "../../../models/wowup/addon-update-event";
import { AddonService } from "../../../services/addons/addon.service";
import { MyAddonStatusColumnComponent } from "./my-addon-status-column.component";
describe("MyAddonStatusColumnComponent", () => {

View File

@@ -7,13 +7,13 @@ import { Component, NgZone, OnDestroy } from "@angular/core";
import { MatDialog } from "@angular/material/dialog";
import { TranslateService } from "@ngx-translate/core";
import { Addon } from "../../../common/entities/addon";
import { AddonWarningType } from "../../../common/wowup/models";
import { AddonViewModel } from "../../business-objects/addon-view-model";
import { AddonInstallState } from "../../models/wowup/addon-install-state";
import { AddonService } from "../../services/addons/addon.service";
import * as AddonUtils from "../../utils/addon.utils";
import { AlertDialogComponent } from "../alert-dialog/alert-dialog.component";
import { Addon } from "../../../../common/entities/addon";
import { AddonWarningType } from "../../../../common/wowup/models";
import { AddonViewModel } from "../../../business-objects/addon-view-model";
import { AddonInstallState } from "../../../models/wowup/addon-install-state";
import { AddonService } from "../../../services/addons/addon.service";
import * as AddonUtils from "../../../utils/addon.utils";
import { AlertDialogComponent } from "../../common/alert-dialog/alert-dialog.component";
@Component({
selector: "app-my-addon-status-column",

View File

@@ -0,0 +1,66 @@
<div>
<div class="addon-column row align-items-center">
<div class="thumbnail-container">
<app-addon-thumbnail [url]="thumbnailUrl$ | async" [name]="name$ | async"></app-addon-thumbnail>
</div>
<div class="version-container">
<div class="title-container">
<a class="addon-title hover-text-2" (click)="viewDetails()"
[ngClass]="{ 'text-3': isIgnored$ | async, 'text-warning': hasWarning$ | async }">{{
name$ | async
}}</a>
</div>
<div class="addon-version text-2 row align-items-center" [ngClass]="{ 'ignored': isIgnored$ | async }">
<div *ngIf="hasFundingLinks$ | async" class="addon-funding mr-2">
<app-funding-button *ngFor="let link of fundingLinks$ | async" [funding]="link" size="small">
</app-funding-button>
</div>
<div *ngIf="showChannel$ | async" class="channel bg-secondary-3 mr-2" [ngClass]="channelClass$ | async">
{{ channelTranslationKey$ | async | translate }}
</div>
<div *ngIf="hasMultipleProviders$ | async" class="mr-2">
<mat-icon class="auto-update-icon" svgIcon="fas:code-branch"
[matTooltip]="'PAGES.MY_ADDONS.MULTIPLE_PROVIDERS_TOOLTIP' | translate">
</mat-icon>
</div>
<div *ngIf="autoUpdateEnabled$ | async" class="mr-2">
<mat-icon class="auto-update-icon text-2"
[matTooltip]="'PAGES.MY_ADDONS.TABLE.AUTO_UPDATE_ICON_TOOLTIP' | translate" svgIcon="far:clock">
</mat-icon>
</div>
<div *ngIf="hasRequiredDependencies$ | async" class="mr-2"
[matTooltip]="'COMMON.DEPENDENCY.TOOLTIP' | translate: (dependencyTooltip$ | async)">
<mat-icon class="auto-update-icon" svgIcon="fas:link"></mat-icon>
</div>
<div *ngIf="isLoadOnDemand$ | async" class="mr-2">
<mat-icon class="auto-update-icon text-warning"
[matTooltip]="'PAGES.MY_ADDONS.REQUIRED_DEPENDENCY_MISSING_TOOLTIP' | translate"
svgIcon="fas:exclamation-triangle">
</mat-icon>
</div>
<div *ngIf="hasWarning$ | async" class="mr-2">
<mat-icon class="auto-update-icon text-warning" [matTooltip]="getWarningText(listItem)"
svgIcon="fas:exclamation-triangle">
</mat-icon>
</div>
<div *ngIf="hasIgnoreReason$ | async" class="mr-2">
<mat-icon class="auto-update-icon" [matTooltip]="ignoreTooltipKey$ | async | translate"
[style.color]="'#ff9800'" [svgIcon]="ignoreIcon$ | async">
</mat-icon>
</div>
<!-- If no warning and not ignored for some specific reason, default to this -->
<div *ngIf="isUnknownAddon$ | async" class="mr-2">
<mat-icon class="auto-update-icon" [matTooltip]="'PAGES.MY_ADDONS.UNKNOWN_ADDON_INFO_TOOLTIP' | translate"
[matTooltipClass]="['text-center']" [style.color]="'#ff9800'" svgIcon="fas:exclamation-triangle">
</mat-icon>
</div>
{{ installedVersion$ | async }}
<div *ngIf="showUpdateVersion$ | async" class="text-1 row">
<mat-icon class="upgrade-icon" svgIcon="fas:play"></mat-icon>
<div class="bg-secondary-4 text-2 rounded px-1">{{ latestVersion$ | async }}</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,8 +1,10 @@
@import "../../../variables.scss";
.addon-column {
padding-top: 0.6em;
padding-bottom: 0.6em;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
padding-top: 0.5em;
padding-bottom: 0.5em;
.thumbnail-container {
margin-right: 11px;
@@ -57,7 +59,7 @@
}
.title-container {
padding-bottom: 0.5em;
// padding-bottom: 0.5em;
}
.addon-title {

View File

@@ -1,19 +1,25 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { TranslateCompiler, TranslateLoader, TranslateModule } from "@ngx-translate/core";
import { HttpClient, HttpClientModule } from "@angular/common/http";
import { httpLoaderFactory } from "../../app.module";
import { TranslateMessageFormatCompiler } from "ngx-translate-messageformat-compiler";
import { MyAddonsAddonCellComponent } from "./my-addons-addon-cell.component";
import { AddonViewModel } from "../../business-objects/addon-view-model";
import { Addon } from "../../../common/entities/addon";
import { MatModule } from "../../mat-module";
import { HttpClient, HttpClientModule } from "@angular/common/http";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { TranslateCompiler, TranslateLoader, TranslateModule } from "@ngx-translate/core";
import { Addon } from "../../../../common/entities/addon";
import { httpLoaderFactory } from "../../../app.module";
import { AddonViewModel } from "../../../business-objects/addon-view-model";
import { MatModule } from "../../../modules/mat-module";
import { SessionService } from "../../../services/session/session.service";
import { MyAddonsAddonCellComponent } from "./my-addons-addon-cell.component";
describe("MyAddonsAddonCellComponent", () => {
let component: MyAddonsAddonCellComponent;
let fixture: ComponentFixture<MyAddonsAddonCellComponent>;
let sessionService: SessionService;
beforeEach(async () => {
sessionService = jasmine.createSpyObj("SessionService", [""], {});
await TestBed.configureTestingModule({
declarations: [MyAddonsAddonCellComponent],
imports: [
@@ -32,7 +38,13 @@ describe("MyAddonsAddonCellComponent", () => {
},
}),
],
}).compileComponents();
})
.overrideComponent(MyAddonsAddonCellComponent, {
set: {
providers: [{ provide: SessionService, useValue: sessionService }],
},
})
.compileComponents();
fixture = TestBed.createComponent(MyAddonsAddonCellComponent);
component = fixture.componentInstance;

View File

@@ -0,0 +1,194 @@
import { AgRendererComponent } from "ag-grid-angular";
import { ICellRendererParams } from "ag-grid-community";
import { BehaviorSubject, combineLatest } from "rxjs";
import { filter, map } from "rxjs/operators";
import { Component } from "@angular/core";
import { TranslateService } from "@ngx-translate/core";
import { ADDON_PROVIDER_UNKNOWN } from "../../../../common/constants";
import { AddonChannelType, AddonDependencyType, AddonWarningType } from "../../../../common/wowup/models";
import { AddonViewModel } from "../../../business-objects/addon-view-model";
import { DialogFactory } from "../../../services/dialog/dialog.factory";
import { SessionService } from "../../../services/session/session.service";
import * as AddonUtils from "../../../utils/addon.utils";
@Component({
selector: "app-my-addons-addon-cell",
templateUrl: "./my-addons-addon-cell.component.html",
styleUrls: ["./my-addons-addon-cell.component.scss"],
})
export class MyAddonsAddonCellComponent implements AgRendererComponent {
private readonly _listItemSrc = new BehaviorSubject<AddonViewModel | undefined>(undefined);
public readonly listItem$ = this._listItemSrc.asObservable().pipe(filter((item) => item !== undefined));
public readonly name$ = this.listItem$.pipe(map((item) => item.name));
public readonly isIgnored$ = this.listItem$.pipe(map((item) => item.isIgnored));
public readonly hasWarning$ = this.listItem$.pipe(map((item) => this.hasWarning(item)));
public readonly hasFundingLinks$ = this.listItem$.pipe(
map((item) => Array.isArray(item.addon?.fundingLinks) && item.addon.fundingLinks.length > 0)
);
public readonly fundingLinks$ = this.listItem$.pipe(map((item) => item.addon?.fundingLinks ?? []));
public readonly showChannel$ = this.listItem$.pipe(map((item) => item.isBetaChannel() || item.isAlphaChannel()));
public readonly channelClass$ = this.listItem$.pipe(
map((item) => {
if (item.isBetaChannel()) {
return "beta";
}
if (item.isAlphaChannel()) {
return "alpha";
}
return "";
})
);
public readonly channelTranslationKey$ = this.listItem$.pipe(
map((item) => {
const channelType = item.addon?.channelType ?? AddonChannelType.Stable;
return channelType === AddonChannelType.Alpha
? "COMMON.ENUM.ADDON_CHANNEL_TYPE.ALPHA"
: "COMMON.ENUM.ADDON_CHANNEL_TYPE.BETA";
})
);
public readonly hasMultipleProviders$ = this.listItem$.pipe(
map((item) => (item.addon === undefined ? false : AddonUtils.hasMultipleProviders(item.addon)))
);
public readonly autoUpdateEnabled$ = this.listItem$.pipe(map((item) => item.addon?.autoUpdateEnabled ?? false));
public readonly hasIgnoreReason$ = this.listItem$.pipe(map((item) => this.hasIgnoreReason(item)));
public readonly hasRequiredDependencies$ = this.listItem$.pipe(
map((item) => this.getRequireDependencyCount(item) > 0)
);
public readonly dependencyTooltip$ = this.listItem$.pipe(
map((item) => {
return {
dependencyCount: this.getRequireDependencyCount(item),
};
})
);
public readonly isLoadOnDemand$ = this.listItem$.pipe(map((item) => item.isLoadOnDemand));
public readonly ignoreTooltipKey$ = this.listItem$.pipe(map((item) => this.getIgnoreTooltipKey(item)));
public readonly ignoreIcon$ = this.listItem$.pipe(map((item) => this.getIgnoreIcon(item)));
public readonly warningText$ = this.listItem$.pipe(
filter((item) => this.hasWarning(item)),
map((item) => this.getWarningText(item))
);
public readonly isUnknownAddon$ = this.listItem$.pipe(
map((item) => {
return (
!item.isLoadOnDemand &&
!this.hasIgnoreReason(item) &&
!this.hasWarning(item) &&
item.addon.providerName === ADDON_PROVIDER_UNKNOWN
);
})
);
public readonly installedVersion$ = this.listItem$.pipe(map((item) => item.addon.installedVersion));
public readonly latestVersion$ = this.listItem$.pipe(map((item) => item.addon.latestVersion));
public readonly thumbnailUrl$ = this.listItem$.pipe(map((item) => item.addon.thumbnailUrl));
public readonly showUpdateVersion$ = combineLatest([
this.listItem$,
this.sessionService.myAddonsCompactVersion$,
]).pipe(
map(([item, compactVersion]) => {
return compactVersion && item.needsUpdate();
})
);
public set listItem(item: AddonViewModel) {
this._listItemSrc.next(item);
}
public constructor(
private _translateService: TranslateService,
private _dialogFactory: DialogFactory,
public sessionService: SessionService
) {}
public agInit(params: ICellRendererParams): void {
this._listItemSrc.next(params.data);
}
public refresh(): boolean {
return false;
}
public afterGuiAttached?(): void {}
public viewDetails(): void {
this._dialogFactory.getAddonDetailsDialog(this._listItemSrc.value);
}
public getRequireDependencyCount(item: AddonViewModel): number {
return item.getDependencies(AddonDependencyType.Required).length;
}
public hasIgnoreReason(item: AddonViewModel): boolean {
return !!item?.addon?.ignoreReason;
}
public getIgnoreTooltipKey(item: AddonViewModel): string {
switch (item.addon?.ignoreReason) {
case "git_repo":
return "PAGES.MY_ADDONS.ADDON_IS_CODE_REPOSITORY";
case "missing_dependency":
case "unknown":
default:
return "";
}
}
public getIgnoreIcon(item: AddonViewModel): string {
switch (item.addon?.ignoreReason) {
case "git_repo":
return "fas:code";
case "missing_dependency":
case "unknown":
default:
return "";
}
}
public hasWarning(item: AddonViewModel): boolean {
return item?.addon?.warningType !== undefined;
}
public getWarningText(item: AddonViewModel): string {
if (!this.hasWarning(item)) {
return "";
}
const toolTipParams = {
providerName: item.providerName,
};
switch (item.addon.warningType) {
case AddonWarningType.MissingOnProvider:
return this._translateService.instant("COMMON.ADDON_WARNING.MISSING_ON_PROVIDER_TOOLTIP", toolTipParams);
case AddonWarningType.NoProviderFiles:
return this._translateService.instant("COMMON.ADDON_WARNING.NO_PROVIDER_FILES_TOOLTIP", toolTipParams);
default:
return this._translateService.instant("COMMON.ADDON_WARNING.GENERIC_TOOLTIP", toolTipParams);
}
}
}

View File

@@ -1,5 +1,3 @@
@import "../../../variables.scss";
.addon-column {
display: flex;
flex-direction: row;
@@ -87,6 +85,8 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
flex-direction: row;
}
.dependency-icon {

View File

@@ -1,6 +1,6 @@
import { TestBed } from "@angular/core/testing";
import { GetAddonListItemFilePropPipe } from "../../pipes/get-addon-list-item-file-prop.pipe";
import { DialogFactory } from "../../services/dialog/dialog.factory";
import { GetAddonListItemFilePropPipe } from "../../../pipes/get-addon-list-item-file-prop.pipe";
import { DialogFactory } from "../../../services/dialog/dialog.factory";
import { PotentialAddonTableColumnComponent } from "./potential-addon-table-column.component";
describe("PotentialAddonTableColumnComponent", () => {

View File

@@ -3,14 +3,14 @@ import { ICellRendererParams } from "ag-grid-community";
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from "@angular/core";
import { WowClientType } from "../../../common/warcraft/wow-client-type";
import { AddonChannelType, AddonDependencyType } from "../../../common/wowup/models";
import { GetAddonListItem } from "../../business-objects/get-addon-list-item";
import { AddonSearchResult } from "../../models/wowup/addon-search-result";
import { AddonSearchResultDependency } from "../../models/wowup/addon-search-result-dependency";
import { GetAddonListItemFilePropPipe } from "../../pipes/get-addon-list-item-file-prop.pipe";
import { DialogFactory } from "../../services/dialog/dialog.factory";
import * as SearchResults from "../../utils/search-result.utils";
import { WowClientType } from "../../../../common/warcraft/wow-client-type";
import { AddonChannelType, AddonDependencyType } from "../../../../common/wowup/models";
import { GetAddonListItem } from "../../../business-objects/get-addon-list-item";
import { AddonSearchResult } from "../../../models/wowup/addon-search-result";
import { AddonSearchResultDependency } from "../../../models/wowup/addon-search-result-dependency";
import { GetAddonListItemFilePropPipe } from "../../../pipes/get-addon-list-item-file-prop.pipe";
import { DialogFactory } from "../../../services/dialog/dialog.factory";
import * as SearchResults from "../../../utils/search-result.utils";
export interface PotentialAddonViewDetailsEvent {
searchResult: AddonSearchResult;

View File

@@ -1,6 +1,6 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { SessionService } from "../../services/session/session.service";
import { SessionService } from "../../../services/session/session.service";
import { TableContextHeaderCellComponent } from "./table-context-header-cell.component";
describe("TableContextHeaderCellComponent", () => {

View File

@@ -1,9 +1,11 @@
import { Component, NgZone } from "@angular/core";
import { IHeaderAngularComp } from "ag-grid-angular";
import { IHeaderParams } from "ag-grid-community";
import { SessionService } from "../../services/session/session.service";
import { BehaviorSubject } from "rxjs";
import { Component, NgZone } from "@angular/core";
import { SessionService } from "../../../services/session/session.service";
interface HeaderParams extends IHeaderParams {
menuIcon: string;
onHeaderContext: (event: MouseEvent) => void;

View File

@@ -0,0 +1,66 @@
<div mat-dialog-title class="">
<div class="row align-items-center">
<h2 class="flex-grow-1 m-0">
{{ "WTF_BACKUP.DIALOG_TITLE" | translate: { clientType: selectedInstallation.label } }}
</h2>
<button mat-icon-button [mat-dialog-close]="true" color="accent" [disabled]="busy$ | async">
<mat-icon svgIcon="fas:times"> </mat-icon>
</button>
</div>
</div>
<mat-dialog-content *ngIf="busy$ | async" class="wtf-backup-dialog">
<div class="row justify-content-center">
<div class="col align-items-center">
<mat-spinner diameter="55"></mat-spinner>
<div>{{ this.busyText$ | async | translate: (busyTextParams$ | async) }}</div>
</div>
</div>
</mat-dialog-content>
<mat-dialog-content *ngIf="(busy$ | async) === false" class="wtf-backup-dialog">
<div *ngIf="(hasBackups$ | async) === false">
<h4 class="mb-0">No backups were found at:</h4>
<p class="text-2">{{ backupPath }}</p>
</div>
<div *ngIf="(hasBackups$ | async) === true">
<p class="text-2">
{{ "WTF_BACKUP.BACKUP_COUNT_TEXT" | translate: { count: backupCt$ | async } }}
</p>
<ul class="backup-list rounded">
<li *ngFor="let backup of backups$ | async" class="backup-list-item">
<div class="row align-items-center">
<div class="flex-grow-1">
<div class="row">
<div class="title ml-1 flex-grow-1" [ngClass]="{ 'text-warning': backup.error }">{{ backup.title }}</div>
</div>
<div class="row text-2">
<div class="mr-3">{{ backup.date | relativeDuration }}</div>
<div class="mr-3">{{ backup.size }}</div>
</div>
</div>
<!-- ACTIONS -->
<div *ngIf="backup.error" class="flex-shrink-0">{{ "WTF_BACKUP.ERROR." + backup.error | translate }}</div>
<div *ngIf="backup.error === undefined" class="flex-shrink-0">
<button mat-icon-button [disabled]="busy$ | async"
[matTooltip]="'WTF_BACKUP.TOOL_TIP.APPLY_BUTTON' | translate" (click)="onClickApplyBackup(backup)">
<mat-icon svgIcon="fas:history"> </mat-icon>
</button>
<button mat-icon-button color="warn" [disabled]="busy$ | async"
[matTooltip]="'WTF_BACKUP.TOOL_TIP.DELETE_BUTTON' | translate" (click)="onClickDeleteBackup(backup)">
<mat-icon svgIcon="fas:trash"> </mat-icon>
</button>
</div>
</div>
</li>
</ul>
</div>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button color="primary" [disabled]="busy$ | async" (click)="onShowFolder()">
{{ "WTF_BACKUP.SHOW_FOLDER_BUTTON" | translate }}
</button>
<div class="flex-grow-1"></div>
<button mat-flat-button color="primary" [disabled]="busy$ | async" (click)="onCreateBackup()">
{{ "WTF_BACKUP.CREATE_BACKUP_BUTTON" | translate }}
</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,36 @@
.wtf-backup-dialog {
width: 600px;
max-width: 100%;
}
.backup-list {
background-color: var(--background-secondary-2);
margin: 0;
padding: 0;
overflow: auto;
box-sizing: border-box;
.backup-list-item:not(:last-child) {
border-bottom: 1px solid var(--background-secondary-1);
}
.backup-list-item {
padding: 1em;
list-style: none;
border-left: 3px solid transparent;
.status-badge {
width: 20px;
height: 20px;
}
.title {
font-size: 1.2em;
}
&:hover {
border-left-color: var(--background-primary);
background-color: var(--background-secondary-1);
}
}
}

View File

@@ -0,0 +1,180 @@
import { Component, OnInit } from "@angular/core";
import { TranslateService } from "@ngx-translate/core";
import { BehaviorSubject, from, of } from "rxjs";
import { catchError, first, map, switchMap } from "rxjs/operators";
import { WowInstallation } from "../../../../common/warcraft/wow-installation";
import { ElectronService } from "../../../services";
import { DialogFactory } from "../../../services/dialog/dialog.factory";
import { SessionService } from "../../../services/session/session.service";
import { SnackbarService } from "../../../services/snackbar/snackbar.service";
import { WtfBackup, WtfService } from "../../../services/wtf/wtf.service";
import { formatSize } from "../../../utils/number.utils";
interface WtfBackupViewModel {
title: string;
size: string;
date: number;
error?: string;
}
@Component({
selector: "app-wtf-backup",
templateUrl: "./wtf-backup.component.html",
styleUrls: ["./wtf-backup.component.scss"],
})
export class WtfBackupComponent implements OnInit {
public readonly busy$ = new BehaviorSubject<boolean>(false);
public readonly backups$ = new BehaviorSubject<WtfBackupViewModel[]>([]);
public readonly busyText$ = new BehaviorSubject<string>("");
public readonly busyTextParams$ = new BehaviorSubject<any>({ count: "" });
public readonly selectedInstallation: WowInstallation;
public readonly hasBackups$ = this.backups$.pipe(map((backups) => backups.length > 0));
public readonly backupCt$ = this.backups$.pipe(map((backups) => backups.length));
public readonly backupPath: string;
public constructor(
private _electronService: ElectronService,
private _sessionService: SessionService,
private _wtfService: WtfService,
private _snackbarService: SnackbarService,
private _dialogFactory: DialogFactory,
private _translateService: TranslateService
) {
this.selectedInstallation = this._sessionService.getSelectedWowInstallation();
this.backupPath = this._wtfService.getBackupPath(this.selectedInstallation);
}
public ngOnInit(): void {
this.loadBackups().catch((e) => console.error(e));
}
public async onShowFolder(): Promise<void> {
const backupPath = this._wtfService.getBackupPath(this.selectedInstallation);
await this._electronService.showItemInFolder(backupPath);
}
public onClickApplyBackup(backup: WtfBackupViewModel): void {
const title = this._translateService.instant("WTF_BACKUP.APPLY_CONFIRMATION.TITLE");
const message = this._translateService.instant("WTF_BACKUP.APPLY_CONFIRMATION.MESSAGE", { name: backup.title });
const dialogRef = this._dialogFactory.getConfirmDialog(title, message);
dialogRef
.afterClosed()
.pipe(
first(),
switchMap((result) => {
if (!result) {
return of(undefined);
}
this.busy$.next(true);
this.busyText$.next("WTF_BACKUP.BUSY_TEXT.APPLYING_BACKUP");
return from(this._wtfService.applyBackup(backup.title, this.selectedInstallation)).pipe(map(() => true));
}),
catchError((e) => {
console.error(e);
this._snackbarService.showErrorSnackbar("WTF_BACKUP.ERROR.BACKUP_APPLY_FAILED", {
timeout: 2000,
localeArgs: {
name: backup.title,
},
});
return of(false);
})
)
.subscribe((result) => {
this.busy$.next(false);
if (result === true) {
this._snackbarService.showSuccessSnackbar("WTF_BACKUP.BACKUP_APPLY_SUCCESS", {
timeout: 2000,
localeArgs: {
name: backup.title,
},
});
}
});
}
public onClickDeleteBackup(backup: WtfBackupViewModel): void {
const title = this._translateService.instant("WTF_BACKUP.DELETE_CONFIRMATION.TITLE");
const message = this._translateService.instant("WTF_BACKUP.DELETE_CONFIRMATION.MESSAGE", { name: backup.title });
const dialogRef = this._dialogFactory.getConfirmDialog(title, message);
dialogRef
.afterClosed()
.pipe(
first(),
switchMap((result) => {
if (!result) {
return of(undefined);
}
this.busyText$.next("WTF_BACKUP.BUSY_TEXT.REMOVING_BACKUP");
this.busy$.next(true);
return from(this._wtfService.deleteBackup(backup.title, this.selectedInstallation)).pipe(
switchMap(() => from(this.loadBackups()))
);
}),
catchError((e) => {
console.error("Failed to delete backup", e);
this._snackbarService.showErrorSnackbar("WTF_BACKUP.ERROR.FAILED_TO_DELETE", {
timeout: 2000,
localeArgs: {
name: backup.title,
},
});
return of(undefined);
})
)
.subscribe(() => {
this.busy$.next(false);
});
}
public async onCreateBackup(): Promise<void> {
this.busyText$.next("WTF_BACKUP.BUSY_TEXT.CREATING_BACKUP");
this.busy$.next(true);
try {
await this._wtfService.createBackup(this.selectedInstallation, (count) => {
this.busyTextParams$.next({ count });
});
await this.loadBackups();
} catch (e) {
console.error(e);
} finally {
this.busy$.next(false);
this.busyTextParams$.next({ count: "" });
}
}
private async loadBackups() {
this.busyText$.next("WTF_BACKUP.BUSY_TEXT.LOADING_BACKUPS");
this.busy$.next(true);
try {
const backups = await this._wtfService.getBackupList(this.selectedInstallation);
const viewModels = backups.map((b) => this.toViewModel(b));
this.backups$.next(viewModels);
} catch (e) {
console.error(e);
} finally {
this.busy$.next(false);
}
}
private toViewModel(backup: WtfBackup): WtfBackupViewModel {
return {
title: backup.fileName,
size: formatSize(backup.size),
date: backup.metadata?.createdAt ?? backup.birthtimeMs,
error: backup.error,
};
}
}

View File

@@ -1,12 +1,14 @@
import { TestBed } from "@angular/core/testing";
import { AlertDialogComponent } from "./alert-dialog.component";
import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog";
import { HttpClient, HttpClientModule } from "@angular/common/http";
import { TranslateCompiler, TranslateLoader, TranslateModule } from "@ngx-translate/core";
import { httpLoaderFactory } from "../../app.module";
import { TranslateMessageFormatCompiler } from "ngx-translate-messageformat-compiler";
import { MatModule } from "../../mat-module";
import { HttpClient, HttpClientModule } from "@angular/common/http";
import { TestBed } from "@angular/core/testing";
import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { TranslateCompiler, TranslateLoader, TranslateModule } from "@ngx-translate/core";
import { httpLoaderFactory } from "../../../app.module";
import { MatModule } from "../../../modules/mat-module";
import { AlertDialogComponent } from "./alert-dialog.component";
describe("AlertDialogComponent", () => {
beforeEach(async () => {

View File

@@ -1,5 +1,5 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ElectronService } from "../../services";
import { ElectronService } from "../../../services";
import { AnimatedLogoComponent } from "./animated-logo.component";

View File

@@ -1,5 +1,5 @@
import { Component } from "@angular/core";
import { ElectronService } from "../../services";
import { ElectronService } from "../../../services";
@Component({
selector: "app-animated-logo",

View File

@@ -1,8 +1,8 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { MAT_SNACK_BAR_DATA } from "@angular/material/snack-bar";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { MatModule } from "../../mat-module";
import { MatModule } from "../../../modules/mat-module";
import { CenteredSnackbarComponent, CenteredSnackbarComponentData } from "./centered-snackbar.component";
describe("CenteredSnackbarComponent", () => {

Some files were not shown because too many files have changed in this diff Show More