Merge remote-tracking branch 'upstream/electron' into fix/myaddon-default-sort

This commit is contained in:
Dean Campbell
2020-10-17 22:26:59 -07:00
112 changed files with 4158 additions and 1606 deletions

View File

@@ -3,6 +3,7 @@
"directories": {
"output": "release/"
},
"generateUpdatesFilesForAllChannels": true,
"publish": ["github"],
"nodeGypRebuild": true,
"files": [

View File

@@ -1,8 +1,12 @@
import { ipcMain, shell } from "electron";
import * as fs from "fs";
import * as async from "async";
import * as path from "path";
import * as admZip from "adm-zip";
import { ncp } from "ncp";
import * as rimraf from "rimraf";
import { readDirRecursive } from "./file.utils";
import { readDirRecursive, readFile } from "./file.utils";
import {
CURSE_HASH_FILE_CHANNEL,
LIST_DIRECTORIES_CHANNEL,
@@ -10,8 +14,16 @@ import {
SHOW_DIRECTORY,
PATH_EXISTS_CHANNEL,
CURSE_GET_SCAN_RESULTS,
WOWUP_GET_SCAN_RESULTS,
UNZIP_FILE_CHANNEL,
COPY_FILE_CHANNEL,
COPY_DIRECTORY_CHANNEL,
DELETE_DIRECTORY_CHANNEL,
RENAME_DIRECTORY_CHANNEL,
READ_FILE_CHANNEL,
GET_ASSET_FILE_PATH,
} from "./src/common/constants";
import { CurseGetScanResultsRequest } from "./src/common/models/curse-get-scan-results-request";
import { CurseGetScanResultsRequest } from "./src/common/curse/curse-get-scan-results-request";
import { CurseGetScanResultsResponse } from "./src/common/curse/curse-get-scan-results-response";
import { CurseHashFileRequest } from "./src/common/models/curse-hash-file-request";
import { CurseHashFileResponse } from "./src/common/models/curse-hash-file-response";
@@ -23,6 +35,19 @@ import { ValueResponse } from "./src/common/models/value-response";
import { CurseScanResult } from "./src/common/curse/curse-scan-result";
import { CurseFolderScanner } from "./src/common/curse/curse-folder-scanner";
import { WowUpGetScanResultsRequest } from "./src/common/wowup/wowup-get-scan-results-request";
import { WowUpGetScanResultsResponse } from "./src/common/wowup/wowup-get-scan-results-response";
import { WowUpFolderScanner } from "./src/common/wowup/wowup-folder-scanner";
import { WowUpScanResult } from "./src/common/wowup/wowup-scan-result";
import { UnzipRequest } from "./src/common/models/unzip-request";
import { UnzipStatus } from "./src/common/models/unzip-status";
import { UnzipStatusType } from "./src/common/models/unzip-status-type";
import { CopyFileRequest } from "./src/common/models/copy-file-request";
import { CopyDirectoryRequest } from "./src/common/models/copy-directory-request";
import { DeleteDirectoryRequest } from "./src/common/models/delete-directory-request";
import { ReadFileRequest } from "./src/common/models/read-file-request";
import { ReadFileResponse } from "./src/common/models/read-file-response";
const nativeAddon = require("./build/Release/addon.node");
ipcMain.on(SHOW_DIRECTORY, async (evt, arg: ShowDirectoryRequest) => {
@@ -30,6 +55,13 @@ ipcMain.on(SHOW_DIRECTORY, async (evt, arg: ShowDirectoryRequest) => {
evt.reply(arg.responseKey, true);
});
ipcMain.on(GET_ASSET_FILE_PATH, async (evt, arg: ValueRequest<string>) => {
const response: ValueResponse<string> = {
value: path.join(__dirname, "assets", arg.value),
};
evt.reply(arg.responseKey, response);
});
ipcMain.on(CURSE_HASH_FILE_CHANNEL, async (evt, arg: CurseHashFileRequest) => {
// console.log(CURSE_HASH_FILE_CHANNEL, arg);
@@ -78,7 +110,7 @@ ipcMain.on(LIST_FILES_CHANNEL, async (evt, arg: ListFilesRequest) => {
response.error = err;
}
evt.reply(arg.sourcePath, response);
evt.reply(arg.responseKey, response);
});
ipcMain.on(LIST_DIRECTORIES_CHANNEL, (evt, arg: ValueRequest<string>) => {
@@ -142,3 +174,89 @@ ipcMain.on(
evt.reply(arg.responseKey, response);
}
);
ipcMain.on(
WOWUP_GET_SCAN_RESULTS,
async (evt, arg: WowUpGetScanResultsRequest) => {
const response: WowUpGetScanResultsResponse = {
scanResults: [],
};
try {
const scanResults = await async.mapLimit<string, WowUpScanResult>(
arg.filePaths,
2,
async (folder, callback) => {
const scanResult = await new WowUpFolderScanner(folder).scanFolder();
callback(undefined, scanResult);
}
);
response.scanResults = scanResults;
} catch (err) {
response.error = err;
}
evt.reply(arg.responseKey, response);
}
);
ipcMain.on(UNZIP_FILE_CHANNEL, (evt, arg: UnzipRequest) => {
const zipFilePath = arg.zipFilePath;
const outputFolder = arg.outputFolder;
const zip = new admZip(zipFilePath);
zip.extractAllToAsync(outputFolder, true, (err) => {
const status: UnzipStatus = {
type: UnzipStatusType.Complete,
outputFolder,
};
if (err) {
status.type = UnzipStatusType.Error;
status.error = err;
}
evt.reply(arg.responseKey, status);
});
});
ipcMain.on(COPY_FILE_CHANNEL, (evt, arg: CopyFileRequest) => {
console.log("Copy File", arg);
fs.copyFile(arg.sourceFilePath, arg.destinationFilePath, (err) => {
evt.reply(arg.responseKey, { error: err });
});
});
ipcMain.on(COPY_DIRECTORY_CHANNEL, (evt, arg: CopyDirectoryRequest) => {
console.log("Copy Dir", arg);
ncp(arg.sourcePath, arg.destinationPath, (err) => {
evt.reply(arg.responseKey, err);
});
});
ipcMain.on(DELETE_DIRECTORY_CHANNEL, (evt, arg: DeleteDirectoryRequest) => {
console.log("Delete Dir", arg);
rimraf(arg.sourcePath, (err) => {
evt.reply(arg.responseKey, err);
});
});
ipcMain.on(RENAME_DIRECTORY_CHANNEL, (evt, arg: CopyDirectoryRequest) => {
console.log("Rename Dir", arg);
fs.rename(arg.sourcePath, arg.destinationPath, (err) => {
evt.reply(arg.responseKey, err);
});
});
ipcMain.on(READ_FILE_CHANNEL, async (evt, arg: ReadFileRequest) => {
// console.log('Read File', arg);
const response: ReadFileResponse = { data: "" };
try {
response.data = await readFile(arg.sourcePath);
} catch (err) {
response.error = err;
}
evt.reply(arg.responseKey, response);
});

View File

@@ -12,37 +12,19 @@ import {
} from "electron";
import * as path from "path";
import * as url from "url";
import * as fs from "fs";
import { release, arch } from "os";
import * as electronDl from "electron-dl";
import * as admZip from "adm-zip";
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 { UnzipStatus } from "./src/common/models/unzip-status";
import {
DOWNLOAD_FILE_CHANNEL,
UNZIP_FILE_CHANNEL,
COPY_FILE_CHANNEL,
COPY_DIRECTORY_CHANNEL,
DELETE_DIRECTORY_CHANNEL,
RENAME_DIRECTORY_CHANNEL,
READ_FILE_CHANNEL,
} from "./src/common/constants";
import { UnzipStatusType } from "./src/common/models/unzip-status-type";
import { UnzipRequest } from "./src/common/models/unzip-request";
import { CopyFileRequest } from "./src/common/models/copy-file-request";
import { CopyDirectoryRequest } from "./src/common/models/copy-directory-request";
import { DeleteDirectoryRequest } from "./src/common/models/delete-directory-request";
import { ReadFileRequest } from "./src/common/models/read-file-request";
import { ReadFileResponse } from "./src/common/models/read-file-response";
import { DOWNLOAD_FILE_CHANNEL } from "./src/common/constants";
import "./ipc-events";
import { ncp } from "ncp";
import * as rimraf from "rimraf";
import * as log from "electron-log";
import { autoUpdater } from "electron-updater";
import * as Store from "electron-store";
import { readFile } from "./file.utils";
import { WindowState } from "./src/common/models/window-state";
import { Subject } from "rxjs";
import { debounceTime } from "rxjs/operators";
const isMac = process.platform === "darwin";
const isWin = process.platform === "win32";
@@ -51,6 +33,8 @@ const preferenceStore = new Store({ name: "preferences" });
let appIsQuitting = false;
autoUpdater.logger = log;
autoUpdater.allowPrerelease = true;
autoUpdater.channel = "alpha";
autoUpdater.on("update-available", () => {
log.info("AVAILABLE");
win.webContents.send("update_available");
@@ -98,6 +82,8 @@ const appMenuTemplate: Array<MenuItemConstructorOptions | MenuItem> = isMac
const appMenu = Menu.buildFromTemplate(appMenuTemplate);
Menu.setApplicationMenu(appMenu);
app.disableHardwareAcceleration(); // Try to improve font blur?
const LOG_PATH = path.join(app.getPath("userData"), "logs");
app.setAppLogsPath(LOG_PATH);
log.transports.file.resolvePath = (
@@ -153,15 +139,91 @@ function createTray() {
tray.setContextMenu(contextMenu);
}
function createWindow(): BrowserWindow {
const electronScreen = screen;
const size = electronScreen.getPrimaryDisplay().workAreaSize;
function windowStateManager(
windowName: string,
{ width, height }: { width: number; height: number }
) {
let window: BrowserWindow;
let windowState: WindowState;
const saveState$ = new Subject<void>();
const windowOptions: BrowserWindowConstructorOptions = {
function setState() {
let setDefaults = false;
windowState = preferenceStore.get(
`${windowName}-window-state`
) as WindowState;
if (!windowState) {
setDefaults = true;
} else {
log.info("found window state:", windowState);
const valid = screen.getAllDisplays().some((display) => {
return (
windowState.x >= display.bounds.x &&
windowState.y >= display.bounds.y &&
windowState.x + windowState.width <=
display.bounds.x + display.bounds.width &&
windowState.y + windowState.height <=
display.bounds.y + display.bounds.height
);
});
if (!valid) {
log.info("reset window state, bounds are outside displays");
setDefaults = true;
}
}
if (setDefaults) {
log.info("setting window defaults");
windowState = <WindowState>{ width, height };
}
}
function saveState() {
log.info("saving window state");
if (!window.isMaximized() && !window.isFullScreen()) {
windowState = { ...windowState, ...window.getBounds() };
}
windowState.isMaximized = window.isMaximized();
windowState.isFullScreen = window.isFullScreen();
preferenceStore.set(`${windowName}-window-state`, windowState);
}
function monitorState(win: BrowserWindow) {
window = win;
win.on("close", saveState);
win.on("resize", () => saveState$.next());
win.on("move", () => saveState$.next());
win.on("closed", () => saveState$.unsubscribe());
}
saveState$.pipe(debounceTime(500)).subscribe(() => saveState());
setState();
return {
...windowState,
monitorState,
};
}
function createWindow(): BrowserWindow {
// Main object for managing window state
// Initialize with a window name and default size
const mainWindowManager = windowStateManager("main", {
width: 900,
height: 600,
});
const windowOptions: BrowserWindowConstructorOptions = {
width: mainWindowManager.width,
height: mainWindowManager.height,
x: mainWindowManager.x,
y: mainWindowManager.y,
backgroundColor: "#444444",
// frame: false,
title: "WowUp",
titleBarStyle: "hidden",
webPreferences: {
@@ -173,6 +235,7 @@ function createWindow(): BrowserWindow {
},
minWidth: 900,
minHeight: 550,
show: false,
};
if (isWin) {
@@ -182,14 +245,26 @@ function createWindow(): BrowserWindow {
// Create the browser window.
win = new BrowserWindow(windowOptions);
// Keep track of window state
mainWindowManager.monitorState(win);
win.webContents.userAgent = USER_AGENT;
win.once("ready-to-show", () => {
win.show();
autoUpdater.checkForUpdatesAndNotify().then((result) => {
console.log("UPDATE", result);
});
});
win.once("show", () => {
if (mainWindowManager.isFullScreen) {
win.setFullScreen(true);
} else if (mainWindowManager.isMaximized) {
win.maximize();
}
});
if (isMac) {
win.on("close", (e) => {
if (appIsQuitting) {
@@ -293,84 +368,26 @@ ipcMain.on(DOWNLOAD_FILE_CHANNEL, async (evt, arg: DownloadRequest) => {
const download = await electronDl.download(win, arg.url, {
directory: arg.outputFolder,
onProgress: (progress) => {
win.webContents.send(arg.url, {
const progressStatus: DownloadStatus = {
type: DownloadStatusType.Progress,
progress: parseFloat((progress.percent * 100.0).toFixed(2)),
} as DownloadStatus);
};
win.webContents.send(arg.responseKey, progressStatus);
},
});
win.webContents.send(arg.url, {
const status: DownloadStatus = {
type: DownloadStatusType.Complete,
savePath: download.getSavePath(),
} as DownloadStatus);
};
win.webContents.send(arg.responseKey, status);
} catch (err) {
console.error(err);
win.webContents.send(arg.url, {
const status: DownloadStatus = {
type: DownloadStatusType.Error,
error: err,
} as DownloadStatus);
}
});
ipcMain.on(UNZIP_FILE_CHANNEL, async (evt, arg: UnzipRequest) => {
const zipFilePath = arg.zipFilePath;
const outputFolder = arg.outputFolder;
const zip = new admZip(zipFilePath);
zip.extractAllToAsync(outputFolder, true, (err) => {
const status: UnzipStatus = {
type: UnzipStatusType.Complete,
outputFolder,
};
if (err) {
status.type = UnzipStatusType.Error;
status.error = err;
}
win.webContents.send(zipFilePath, status);
});
});
ipcMain.on(COPY_FILE_CHANNEL, async (evt, arg: CopyFileRequest) => {
console.log("Copy File", arg);
fs.copyFile(arg.sourceFilePath, arg.destinationFilePath, (err) => {
win.webContents.send(arg.destinationFilePath, { error: err });
});
});
ipcMain.on(COPY_DIRECTORY_CHANNEL, async (evt, arg: CopyDirectoryRequest) => {
console.log("Copy Dir", arg);
ncp(arg.sourcePath, arg.destinationPath, (err) => {
win.webContents.send(arg.destinationPath, err);
});
});
ipcMain.on(
DELETE_DIRECTORY_CHANNEL,
async (evt, arg: DeleteDirectoryRequest) => {
console.log("Delete Dir", arg);
rimraf(arg.sourcePath, (err) => {
win.webContents.send(arg.sourcePath, err);
});
win.webContents.send(arg.responseKey, status);
}
);
ipcMain.on(RENAME_DIRECTORY_CHANNEL, async (evt, arg: CopyDirectoryRequest) => {
console.log("Rename Dir", arg);
fs.rename(arg.sourcePath, arg.destinationPath, (err) => {
win.webContents.send(arg.destinationPath, err);
});
});
ipcMain.on(READ_FILE_CHANNEL, async (evt, arg: ReadFileRequest) => {
// console.log('Read File', arg);
const response: ReadFileResponse = { data: "" };
try {
response.data = await readFile(arg.sourcePath);
} catch (err) {
response.error = err;
}
win.webContents.send(arg.sourcePath, response);
});

View File

@@ -1,7 +1,7 @@
{
"name": "wowup",
"productName": "WowUp",
"version": "2.0.0-alpha.7",
"version": "2.0.0-alpha.11",
"description": "Word of Warcraft addon updater",
"homepage": "https://github.com/maximegris/angular-electron",
"author": {
@@ -67,7 +67,9 @@
"@types/mocha": "8.0.3",
"@types/ncp": "2.0.4",
"@types/node": "12.12.62",
"@types/opossum": "4.1.1",
"@types/rimraf": "3.0.0",
"@types/uuid": "8.3.0",
"@typescript-eslint/eslint-plugin": "4.3.0",
"@typescript-eslint/eslint-plugin-tslint": "4.3.0",
"@typescript-eslint/parser": "4.3.0",
@@ -92,7 +94,6 @@
"node-gyp": "7.1.0",
"npm-run-all": "4.1.5",
"protractor": "~7.0.0",
"rxjs": "6.6.3",
"spectron": "12.0.0",
"ts-node": "9.0.0",
"tslib": "2.0.1",
@@ -123,9 +124,11 @@
"ncp": "2.0.0",
"node-cache": "5.1.2",
"node-disk-info": "1.1.0",
"opossum": "5.0.1",
"protobufjs": "6.10.1",
"rimraf": "3.0.2",
"rollbar": "2.19.3",
"rxjs": "6.6.3",
"uuid": "8.3.1"
}
}

View File

@@ -1,32 +1,60 @@
import { WowClientType } from "../models/warcraft/wow-client-type";
import { Addon } from "../entities/addon";
import { PotentialAddon } from "../models/wowup/potential-addon";
import { AddonSearchResult } from "../models/wowup/addon-search-result";
import { Observable } from "rxjs";
import { AddonFolder } from "app/models/wowup/addon-folder";
import { AddonChannelType } from "app/models/wowup/addon-channel-type";
export interface AddonProvider {
name: AddonProviderType;
getAll(clientType: WowClientType, addonIds: string[]): Promise<AddonSearchResult[]>;
getAll(
clientType: WowClientType,
addonIds: string[]
): Promise<AddonSearchResult[]>;
getFeaturedAddons(clientType: WowClientType): Observable<PotentialAddon[]>;
getFeaturedAddons(
clientType: WowClientType,
channelType?: AddonChannelType
): Promise<AddonSearchResult[]>;
searchByQuery(query: string, clientType: WowClientType): Promise<PotentialAddon[]>;
searchByQuery(
query: string,
clientType: WowClientType,
channelType?: AddonChannelType
): Promise<AddonSearchResult[]>;
searchByUrl(addonUri: URL, clientType: WowClientType): Promise<PotentialAddon>;
searchByUrl(
addonUri: URL,
clientType: WowClientType
): Promise<AddonSearchResult>;
searchByName(addonName: string, folderName: string, clientType: WowClientType, nameOverride?: string): Promise<AddonSearchResult[]>;
searchByName(
addonName: string,
folderName: string,
clientType: WowClientType,
nameOverride?: string
): Promise<AddonSearchResult[]>;
getById(addonId: string, clientType: WowClientType): Observable<AddonSearchResult>;
getById(
addonId: string,
clientType: WowClientType
): Observable<AddonSearchResult>;
isValidAddonUri(addonUri: URL): boolean;
onPostInstall(addon: Addon): void;
scan(clientType: WowClientType, addonChannelType: AddonChannelType, addonFolders: AddonFolder[]): Promise<void>;
scan(
clientType: WowClientType,
addonChannelType: AddonChannelType,
addonFolders: AddonFolder[]
): Promise<void>;
}
export type AddonProviderType = 'Curse' | 'GitHub' | 'TukUI' | 'WowInterface';
export type AddonProviderType =
| "Curse"
| "GitHub"
| "TukUI"
| "WowInterface"
| "WowUp";

View File

@@ -4,12 +4,10 @@ import { Addon } from "../entities/addon";
import { HttpClient } from "@angular/common/http";
import { map } from "rxjs/operators";
import * as _ from "lodash";
import * as fp from "lodash/fp";
import { AddonSearchResult } from "../models/wowup/addon-search-result";
import { Observable, of } from "rxjs";
import { from, Observable, of } from "rxjs";
import { AddonSearchResultFile } from "../models/wowup/addon-search-result-file";
import { AddonChannelType } from "../models/wowup/addon-channel-type";
import { PotentialAddon } from "../models/wowup/potential-addon";
import { CachingService } from "app/services/caching/caching-service";
import { AddonFolder } from "app/models/wowup/addon-folder";
import { ElectronService } from "app/services";
@@ -20,23 +18,51 @@ import { CurseGetScanResultsRequest } from "common/curse/curse-get-scan-results-
import { CurseGetScanResultsResponse } from "common/curse/curse-get-scan-results-response";
import { CurseMatch } from "common/curse/curse-match";
import { CurseFingerprintsResponse } from "../models/curse/curse-fingerprint-response";
import { CurseSearchResult } from "../models/curse/curse-search-result";
import { CurseSearchResult } from "../../common/curse/curse-search-result";
import { CurseFile } from "common/curse/curse-file";
import { CurseReleaseType } from "common/curse/curse-release-type";
import { CurseGetFeaturedResponse } from "app/models/curse/curse-get-featured-response";
import * as CircuitBreaker from "opossum";
const API_URL = "https://addons-ecs.forgesvc.net/api/v2";
const HUB_API_URL = "https://hub.dev.wowup.io";
export class CurseAddonProvider implements AddonProvider {
private readonly _circuitBreaker: CircuitBreaker<
[clientType: () => Promise<any>],
any
>;
private getCircuitBreaker<T>() {
return this._circuitBreaker as CircuitBreaker<
[clientType: () => Promise<T>],
T
>;
}
public readonly name = "Curse";
constructor(
private _httpClient: HttpClient,
private _cachingService: CachingService,
private _electronService: ElectronService
) {}
) {
this._circuitBreaker = new CircuitBreaker(
(action) => this.sendRequest(action),
{
resetTimeout: 60000,
}
);
async scan(
this._circuitBreaker.on("open", () => {
console.log(`${this.name} circuit breaker open`);
});
this._circuitBreaker.on("close", () => {
console.log(`${this.name} circuit breaker close`);
});
}
public async scan(
clientType: WowClientType,
addonChannelType: AddonChannelType,
addonFolders: AddonFolder[]
@@ -59,7 +85,7 @@ export class CurseAddonProvider implements AddonProvider {
);
const addonIds = _.uniq(matchedScanResultIds);
var addonResults = await this.getAllIds(addonIds).toPromise();
var addonResults = await this.getAllIds(addonIds);
for (let addonFolder of addonFolders) {
var scanResult = scanResults.find(
@@ -79,93 +105,17 @@ export class CurseAddonProvider implements AddonProvider {
}
try {
const newAddon = this.getAddon(
clientType,
addonChannelType,
scanResult
);
const newAddon = this.getAddon(clientType, scanResult);
addonFolder.matchingAddon = newAddon;
} catch (err) {
console.error(scanResult);
console.error(err);
// TODO
// _analyticsService.Track(ex, $"Failed to create addon for result {scanResult.FolderScanner.Fingerprint}");
}
}
}
private async mapAddonFolders(
scanResults: AppCurseScanResult[],
clientType: WowClientType
) {
if (clientType === WowClientType.None) {
return;
}
const fingerprintResponse = await this.getAddonsByFingerprints(
scanResults.map((result) => result.fingerprint)
).toPromise();
console.log(fingerprintResponse);
for (let scanResult of scanResults) {
// Curse can deliver the wrong result sometimes, ensure the result matches the client type
scanResult.exactMatch = fingerprintResponse.exactMatches.find(
(exactMatch) =>
this.isGameVersionFlavor(
exactMatch.file.gameVersionFlavor,
clientType
) && this.hasMatchingFingerprint(scanResult, exactMatch)
);
// If the addon does not have an exact match, check the partial matches.
if (!scanResult.exactMatch) {
scanResult.exactMatch = fingerprintResponse.partialMatches.find(
(partialMatch) =>
partialMatch.file?.modules?.some(
(module) => module.fingerprint === scanResult.fingerprint
)
);
}
}
}
private hasMatchingFingerprint(
scanResult: AppCurseScanResult,
exactMatch: CurseMatch
) {
return exactMatch.file.modules.some(
(m) => m.fingerprint === scanResult.fingerprint
);
}
private isGameVersionFlavor(
gameVersionFlavor: string,
clientType: WowClientType
) {
return gameVersionFlavor === this.getGameVersionFlavor(clientType);
}
private getAddonsByFingerprints(
fingerprints: number[]
): Observable<CurseFingerprintsResponse> {
const url = `${API_URL}/fingerprint`;
return this._httpClient.post<CurseFingerprintsResponse>(url, fingerprints);
}
private getAllIds(addonIds: number[]): Observable<CurseSearchResult[]> {
if (!addonIds?.length) {
return of([]);
}
const url = `${API_URL}/addon`;
return this._httpClient.post<CurseSearchResult[]>(url, addonIds);
}
private getScanResults = async (
public getScanResults = async (
addonFolders: AddonFolder[]
): Promise<AppCurseScanResult[]> => {
const t1 = Date.now();
@@ -200,6 +150,115 @@ export class CurseAddonProvider implements AddonProvider {
});
};
private async mapAddonFolders(
scanResults: AppCurseScanResult[],
clientType: WowClientType
) {
if (clientType === WowClientType.None) {
return;
}
scanResults.forEach((result) => {
console.debug(result.folderName, result.fingerprint);
});
const fingerprintResponse = await this.getAddonsByFingerprintsW(
scanResults.map((result) => result.fingerprint)
);
console.log("fingerprintResponse", fingerprintResponse);
for (let scanResult of scanResults) {
// Curse can deliver the wrong result sometimes, ensure the result matches the client type
scanResult.exactMatch = fingerprintResponse.exactMatches.find(
(exactMatch) =>
this.isGameVersionFlavor(
exactMatch.file.gameVersionFlavor,
clientType
) && this.hasMatchingFingerprint(scanResult, exactMatch)
);
// If the addon does not have an exact match, check the partial matches.
if (!scanResult.exactMatch && fingerprintResponse.partialMatches) {
scanResult.exactMatch = fingerprintResponse.partialMatches.find(
(partialMatch) =>
partialMatch.file?.modules?.some(
(module) => module.fingerprint === scanResult.fingerprint
)
);
}
}
}
private hasMatchingFingerprint(
scanResult: AppCurseScanResult,
exactMatch: CurseMatch
) {
return exactMatch.file.modules.some(
(m) => m.fingerprint === scanResult.fingerprint
);
}
private isGameVersionFlavor(
gameVersionFlavor: string,
clientType: WowClientType
) {
return gameVersionFlavor === this.getGameVersionFlavor(clientType);
}
private async getAddonsByFingerprintsW(fingerprints: number[]) {
const url = `${HUB_API_URL}/curseforge/addons/fingerprint`;
console.log(`Wowup Fetching fingerprints`, JSON.stringify(fingerprints));
return await this._httpClient
.post<CurseFingerprintsResponse>(url, {
fingerprints,
})
.toPromise();
return await this.getCircuitBreaker<CurseFingerprintsResponse>().fire(
async () =>
await this._httpClient
.post<CurseFingerprintsResponse>(url, fingerprints)
.toPromise()
);
}
private async getAddonsByFingerprints(
fingerprints: number[]
): Promise<CurseFingerprintsResponse> {
const url = `${API_URL}/fingerprint`;
console.log(`Curse Fetching fingerprints`, JSON.stringify(fingerprints));
return await this.getCircuitBreaker<CurseFingerprintsResponse>().fire(
async () =>
await this._httpClient
.post<CurseFingerprintsResponse>(url, fingerprints)
.toPromise()
);
}
private async getAllIds(addonIds: number[]): Promise<CurseSearchResult[]> {
if (!addonIds?.length) {
return [];
}
const url = `${API_URL}/addon`;
return await this.getCircuitBreaker<CurseSearchResult[]>().fire(
async () =>
await this._httpClient
.post<CurseSearchResult[]>(url, addonIds)
.toPromise()
);
}
private sendRequest<T>(action: () => Promise<T>): Promise<T> {
return action.call(this);
}
async getAll(
clientType: WowClientType,
addonIds: string[]
@@ -211,7 +270,7 @@ export class CurseAddonProvider implements AddonProvider {
const addonResults: AddonSearchResult[] = [];
const searchResults = await this.getAllIds(
addonIds.map((id) => parseInt(id, 10))
).toPromise();
);
for (let result of searchResults) {
const latestFiles = this.getLatestFiles(result, clientType);
@@ -228,15 +287,15 @@ export class CurseAddonProvider implements AddonProvider {
return addonResults;
}
getFeaturedAddons(clientType: WowClientType): Observable<PotentialAddon[]> {
return this.getFeaturedAddonList().pipe(
map((addons) => {
return this.filterFeaturedAddons(addons, clientType);
}),
map((filteredAddons) => {
return filteredAddons.map((addon) => this.getPotentialAddon(addon));
})
);
public async getFeaturedAddons(
clientType: WowClientType
): Promise<AddonSearchResult[]> {
const addons = await this.getFeaturedAddonList();
const filteredAddons = this.filterFeaturedAddons(addons, clientType);
return filteredAddons.map((addon) => {
const latestFiles = this.getLatestFiles(addon, clientType);
return this.getAddonSearchResult(addon, latestFiles);
});
}
private filterFeaturedAddons(
@@ -260,18 +319,20 @@ export class CurseAddonProvider implements AddonProvider {
async searchByQuery(
query: string,
clientType: WowClientType
): Promise<PotentialAddon[]> {
var searchResults: PotentialAddon[] = [];
clientType: WowClientType,
channelType?: AddonChannelType
): Promise<AddonSearchResult[]> {
channelType = channelType || AddonChannelType.Stable;
var searchResults: AddonSearchResult[] = [];
var response = await this.getSearchResults(query).toPromise();
var response = await this.getSearchResults(query);
for (let result of response) {
var latestFiles = this.getLatestFiles(result, clientType);
if (!latestFiles.length) {
continue;
}
searchResults.push(this.getPotentialAddon(result));
searchResults.push(this.getAddonSearchResult(result));
}
return searchResults;
@@ -280,7 +341,7 @@ export class CurseAddonProvider implements AddonProvider {
searchByUrl(
addonUri: URL,
clientType: WowClientType
): Promise<PotentialAddon> {
): Promise<AddonSearchResult> {
throw new Error("Method not implemented.");
}
@@ -293,12 +354,17 @@ export class CurseAddonProvider implements AddonProvider {
throw new Error("Method not implemented.");
}
private getSearchResults(query: string): Observable<CurseSearchResult[]> {
private async getSearchResults(query: string): Promise<CurseSearchResult[]> {
const url = new URL(`${API_URL}/addon/search`);
url.searchParams.set("gameId", "1");
url.searchParams.set("searchFilter", query);
return this._httpClient.get<CurseSearchResult[]>(url.toString());
return await this.getCircuitBreaker<CurseSearchResult[]>().fire(
async () =>
await this._httpClient
.get<CurseSearchResult[]>(url.toString())
.toPromise()
);
}
getById(
@@ -307,7 +373,12 @@ export class CurseAddonProvider implements AddonProvider {
): Observable<AddonSearchResult> {
const url = `${API_URL}/addon/${addonId}`;
return this._httpClient.get<CurseSearchResult>(url).pipe(
return from(
this.getCircuitBreaker<CurseSearchResult>().fire(
async () =>
await this._httpClient.get<CurseSearchResult>(url).toPromise()
)
).pipe(
map((result) => {
if (!result) {
return null;
@@ -335,23 +406,9 @@ export class CurseAddonProvider implements AddonProvider {
throw new Error("Method not implemented.");
}
private getPotentialAddon(result: CurseSearchResult): PotentialAddon {
return {
author: this.getAuthor(result),
downloadCount: result.downloadCount,
externalId: result.id.toString(),
externalUrl: result.websiteUrl,
name: result.name,
providerName: this.name,
thumbnailUrl: this.getThumbnailUrl(result),
summary: result.summary,
screenshotUrls: this.getScreenshotUrls(result),
};
}
private getAddonSearchResult(
result: CurseSearchResult,
latestFiles: CurseFile[]
latestFiles: CurseFile[] = []
): AddonSearchResult {
try {
const thumbnailUrl = this.getThumbnailUrl(result);
@@ -379,7 +436,8 @@ export class CurseAddonProvider implements AddonProvider {
thumbnailUrl,
externalUrl: result.websiteUrl,
providerName: this.name,
files: searchResultFiles,
files: _.orderBy(searchResultFiles, f => f.channelType).reverse(),
downloadCount: result.downloadCount,
};
return searchResult;
@@ -389,13 +447,13 @@ export class CurseAddonProvider implements AddonProvider {
}
}
private getFeaturedAddonList(): Observable<CurseSearchResult[]> {
private async getFeaturedAddonList(): Promise<CurseSearchResult[]> {
const url = `${API_URL}/addon/featured`;
const cachedResponse = this._cachingService.get<CurseGetFeaturedResponse>(
url
);
if (cachedResponse) {
return of(cachedResponse.Popular);
return cachedResponse.Popular;
}
const body = {
@@ -405,17 +463,22 @@ export class CurseAddonProvider implements AddonProvider {
updatedCount: 0,
};
return this._httpClient.post<CurseGetFeaturedResponse>(url, body).pipe(
map((result) => {
if (!result) {
return [];
}
this._cachingService.set(url, result);
return result.Popular;
})
const result = await this.getCircuitBreaker<
CurseGetFeaturedResponse
>().fire(
async () =>
await this._httpClient
.post<CurseGetFeaturedResponse>(url, body)
.toPromise()
);
if (!result) {
return [];
}
this._cachingService.set(url, result);
return result.Popular;
}
private getChannelType(releaseType: CurseReleaseType): AddonChannelType {
@@ -492,30 +555,35 @@ export class CurseAddonProvider implements AddonProvider {
private getAddon(
clientType: WowClientType,
addonChannelType: AddonChannelType,
scanResult: AppCurseScanResult
): Addon {
const currentVersion = scanResult.exactMatch.file;
const authors = scanResult.searchResult.authors
.map((author) => author.name)
.join(", ");
const folderList = scanResult.exactMatch.file.modules
.map((module) => module.foldername)
.join(",");
const latestFiles = this.getLatestFiles(
scanResult.searchResult,
clientType
);
let channelType = addonChannelType;
let channelType = this.getChannelType(
scanResult.exactMatch.file.releaseType
);
let latestVersion = latestFiles.find(
(lf) => this.getChannelType(lf.releaseType) <= addonChannelType
(lf) => this.getChannelType(lf.releaseType) <= channelType
);
// If there were no releases that met the channel type restrictions
if (!latestVersion) {
latestVersion = _.first(latestFiles);
channelType = this.getWowUpChannel(latestVersion.releaseType);
console.warn("falling back to default channel");
}
return {
@@ -537,6 +605,9 @@ export class CurseAddonProvider implements AddonProvider {
latestVersion: latestVersion.displayName,
providerName: this.name,
thumbnailUrl: this.getThumbnailUrl(scanResult.searchResult),
screenshotUrls: this.getScreenshotUrls(scanResult.searchResult),
downloadCount: scanResult.searchResult.downloadCount,
summary: scanResult.searchResult.summary,
};
}
}

View File

@@ -5,211 +5,231 @@ import { WowClientType } from "app/models/warcraft/wow-client-type";
import { AddonChannelType } from "app/models/wowup/addon-channel-type";
import { AddonFolder } from "app/models/wowup/addon-folder";
import { AddonSearchResult } from "app/models/wowup/addon-search-result";
import { PotentialAddon } from "app/models/wowup/potential-addon";
import { forkJoin, Observable, of } from "rxjs";
import { map } from "rxjs/operators";
import { AddonProvider } from "./addon-provider";
import * as _ from 'lodash';
import { extname } from 'path';
import * as _ from "lodash";
import { extname } from "path";
import { GitHubAsset } from "app/models/github/github-asset";
import { GitHubRepository } from "app/models/github/github-repository";
import { AddonSearchResultFile } from "app/models/wowup/addon-search-result-file";
const API_URL = "https://api.github.com/repos";
const RELEASE_CONTENT_TYPES = [
"application/x-zip-compressed",
"application/zip"
"application/x-zip-compressed",
"application/zip",
];
export class GitHubAddonProvider implements AddonProvider {
public readonly name = "GitHub";
public readonly name = "GitHub";
constructor(
private _httpClient: HttpClient,
) { }
constructor(private _httpClient: HttpClient) {}
async getAll(clientType: WowClientType, addonIds: string[]): Promise<AddonSearchResult[]> {
var searchResults: AddonSearchResult[] = []
async getAll(
clientType: WowClientType,
addonIds: string[]
): Promise<AddonSearchResult[]> {
var searchResults: AddonSearchResult[] = [];
for (let addonId in addonIds) {
var result = await this.getById(addonId, clientType).toPromise();
if (result == null) {
continue;
}
for (let addonId of addonIds) {
var result = await this.getById(addonId, clientType).toPromise();
if (result == null) {
continue;
}
searchResults.push(result);
searchResults.push(result);
}
return searchResults;
}
async getFeaturedAddons(
clientType: WowClientType
): Promise<AddonSearchResult[]> {
return [];
}
async searchByQuery(
query: string,
clientType: WowClientType
): Promise<AddonSearchResult[]> {
return [];
}
async searchByUrl(
addonUri: URL,
clientType: WowClientType
): Promise<AddonSearchResult> {
const repoPath = addonUri.pathname;
const repoExtension = extname(repoPath); // if the repo has the git extension it wont work?
if (!repoPath || repoExtension) {
throw new Error(`Invlaid URL: ${addonUri}`);
}
const results = await this.getReleases(repoPath).toPromise();
const latestRelease = this.getLatestRelease(results);
const asset = this.getValidAsset(latestRelease, clientType);
if (asset == null) {
throw new Error(`No release found: ${addonUri}`);
}
var repository = await this.getRepository(repoPath).toPromise();
var author = repository.owner.login;
var authorImageUrl = repository.owner.avatar_url;
var potentialAddon: AddonSearchResult = {
author: author,
downloadCount: asset.download_count,
externalId: repoPath,
externalUrl: repository.html_url,
name: repository.name,
providerName: this.name,
thumbnailUrl: authorImageUrl,
};
return potentialAddon;
}
async searchByName(
addonName: string,
folderName: string,
clientType: WowClientType,
nameOverride?: string
): Promise<AddonSearchResult[]> {
return [];
}
getById(
addonId: string,
clientType: WowClientType
): Observable<AddonSearchResult> {
return forkJoin([
this.getReleases(addonId),
this.getRepository(addonId),
]).pipe(
map(([releases, repository]) => {
if (!releases?.length) {
return undefined;
}
return searchResults;
}
getFeaturedAddons(clientType: WowClientType): Observable<PotentialAddon[]> {
return of([]);
}
async searchByQuery(query: string, clientType: WowClientType): Promise<PotentialAddon[]> {
return [];
}
async searchByUrl(addonUri: URL, clientType: WowClientType): Promise<PotentialAddon> {
const repoPath = addonUri.pathname;
const repoExtension = extname(repoPath); // if the repo has the git extension it wont work?
if (!repoPath || repoExtension) {
throw new Error(`Invlaid URL: ${addonUri}`);
const latestRelease = this.getLatestRelease(releases);
if (!latestRelease) {
return undefined;
}
const results = await this.getReleases(repoPath).toPromise();
const latestRelease = this.getLatestRelease(results);
const asset = this.getValidAsset(latestRelease, clientType);
if (asset == null) {
throw new Error(`No release found: ${addonUri}`);
if (!asset) {
return undefined;
}
var repository = await this.getRepository(repoPath).toPromise();
var author = repository.owner.login;
var authorImageUrl = repository.owner.avatar_url;
const author = repository.owner.login;
const authorImageUrl = repository.owner.avatar_url;
const addonName = this.getAddonName(addonId);
var potentialAddon: PotentialAddon = {
author: author,
downloadCount: asset.download_count,
externalId: repoPath,
externalUrl: latestRelease.url,
name: repository.name,
providerName: this.name,
thumbnailUrl: authorImageUrl
var searchResultFile: AddonSearchResultFile = {
channelType: AddonChannelType.Stable,
downloadUrl: asset.browser_download_url,
folders: [addonName],
gameVersion: "",
version: asset.name,
releaseDate: new Date(asset.created_at),
};
return potentialAddon;
var searchResult: AddonSearchResult = {
author: author,
externalId: addonId,
externalUrl: repository.html_url,
files: [searchResultFile],
name: addonName,
providerName: this.name,
thumbnailUrl: authorImageUrl,
};
return searchResult;
})
);
}
isValidAddonUri(addonUri: URL): boolean {
return addonUri.host && addonUri.host.endsWith("github.com");
}
onPostInstall(addon: Addon): void {}
async scan(
clientType: WowClientType,
addonChannelType: AddonChannelType,
addonFolders: AddonFolder[]
): Promise<void> {}
private getLatestRelease(releases: GitHubRelease[]): GitHubRelease {
let sortedReleases = _.filter(releases, (r) => !r.draft);
sortedReleases = _.sortBy(
sortedReleases,
(release) => new Date(release.published_at)
).reverse();
return _.first(sortedReleases);
}
private getValidAsset(
release: GitHubRelease,
clientType: WowClientType
): GitHubAsset {
const sortedAssets = _.filter(
release.assets,
(asset) =>
this.isNotNoLib(asset) &&
this.isValidContentType(asset) &&
this.isValidClientType(clientType, asset)
);
return _.first(sortedAssets);
}
private isNotNoLib(asset: GitHubAsset): boolean {
return asset.name.toLowerCase().indexOf("-nolib") === -1;
}
private isValidContentType(asset: GitHubAsset): boolean {
return RELEASE_CONTENT_TYPES.some((ct) => ct == asset.content_type);
}
private isValidClientType(
clientType: WowClientType,
asset: GitHubAsset
): boolean {
const isClassic = this.isClassicAsset(asset);
switch (clientType) {
case WowClientType.Retail:
case WowClientType.RetailPtr:
case WowClientType.Beta:
return !isClassic;
case WowClientType.Classic:
case WowClientType.ClassicPtr:
return isClassic;
default:
return false;
}
}
async searchByName(addonName: string, folderName: string, clientType: WowClientType, nameOverride?: string): Promise<AddonSearchResult[]> {
return [];
}
private isClassicAsset(asset: GitHubAsset): boolean {
return asset.name.toLowerCase().endsWith("-classic.zip");
}
getById(addonId: string, clientType: WowClientType): Observable<AddonSearchResult> {
return forkJoin([
this.getReleases(addonId),
this.getRepository(addonId)
])
.pipe(
map(([releases, repository]) => {
if (!releases?.length) {
return undefined;
}
private getAddonName(addonId: string): string {
return addonId.split("/").filter((str) => !!str)[1];
}
const latestRelease = this.getLatestRelease(releases);
if (!latestRelease) {
return undefined;
}
private getReleases(repositoryPath: string): Observable<GitHubRelease[]> {
const url = `${API_URL}${repositoryPath}/releases`;
return this._httpClient.get<GitHubRelease[]>(url.toString());
}
const asset = this.getValidAsset(latestRelease, clientType);
if (!asset) {
return undefined;
}
const author = repository.owner.login;
const authorImageUrl = repository.owner.avatar_url;
const addonName = this.getAddonName(addonId);
var searchResultFile: AddonSearchResultFile = {
channelType: AddonChannelType.Stable,
downloadUrl: asset.browser_download_url,
folders: [addonName],
gameVersion: '',
version: asset.name,
releaseDate: new Date(asset.created_at)
};
var searchResult: AddonSearchResult = {
author: author,
externalId: addonId,
externalUrl: asset.url,
files: [searchResultFile],
name: addonName,
providerName: this.name,
thumbnailUrl: authorImageUrl
};
return searchResult;
})
);
}
isValidAddonUri(addonUri: URL): boolean {
return addonUri.host &&
addonUri.host.endsWith("github.com");
}
onPostInstall(addon: Addon): void {
}
async scan(
clientType: WowClientType,
addonChannelType: AddonChannelType,
addonFolders: AddonFolder[]
): Promise<void> {
}
private getLatestRelease(releases: GitHubRelease[]): GitHubRelease {
let sortedReleases = _.filter(releases, r => !r.draft);
sortedReleases = _.sortBy(sortedReleases, release => new Date(release.published_at))
.reverse();
return _.first(sortedReleases);
}
private getValidAsset(release: GitHubRelease, clientType: WowClientType): GitHubAsset {
const sortedAssets = _.filter(
release.assets,
asset => this.isNotNoLib(asset) &&
this.isValidContentType(asset) &&
this.isValidClientType(clientType, asset));
return _.first(sortedAssets);
}
private isNotNoLib(asset: GitHubAsset): boolean {
return asset.name.toLowerCase().indexOf("-nolib") === -1;
}
private isValidContentType(asset: GitHubAsset): boolean {
return RELEASE_CONTENT_TYPES.some(ct => ct == asset.content_type);
}
private isValidClientType(clientType: WowClientType, asset: GitHubAsset): boolean {
const isClassic = this.isClassicAsset(asset);
switch (clientType) {
case WowClientType.Retail:
case WowClientType.RetailPtr:
case WowClientType.Beta:
return !isClassic;
case WowClientType.Classic:
case WowClientType.ClassicPtr:
return isClassic;
default:
return false;
}
}
private isClassicAsset(asset: GitHubAsset): boolean {
return asset.name.toLowerCase().endsWith("-classic.zip");
}
private getAddonName(addonId: string): string {
return addonId.split("/").filter(str => !!str)[1];
}
private getReleases(repositoryPath: string): Observable<GitHubRelease[]> {
const url = `${API_URL}${repositoryPath}/releases`;
return this._httpClient.get<GitHubRelease[]>(url.toString());
}
private getRepository(repositoryPath: string): Observable<GitHubRepository> {
const url = `${API_URL}${repositoryPath}`;
return this._httpClient.get<GitHubRepository>(url.toString());
}
}
private getRepository(repositoryPath: string): Observable<GitHubRepository> {
const url = `${API_URL}${repositoryPath}`;
return this._httpClient.get<GitHubRepository>(url.toString());
}
}

View File

@@ -5,22 +5,26 @@ import { WowClientType } from "app/models/warcraft/wow-client-type";
import { AddonChannelType } from "app/models/wowup/addon-channel-type";
import { AddonFolder } from "app/models/wowup/addon-folder";
import { AddonSearchResult } from "app/models/wowup/addon-search-result";
import { PotentialAddon } from "app/models/wowup/potential-addon";
import { CachingService } from "app/services/caching/caching-service";
import { ElectronService } from "app/services/electron/electron.service";
import { FileService } from "app/services/files/file.service";
import { from, Observable, of } from "rxjs";
import { AddonProvider } from "./addon-provider";
import * as _ from 'lodash';
import * as _ from "lodash";
import { AddonSearchResultFile } from "app/models/wowup/addon-search-result-file";
import { map } from "rxjs/operators";
import { v4 as uuidv4 } from 'uuid';
import { v4 as uuidv4 } from "uuid";
import * as CircuitBreaker from "opossum";
const API_URL = "https://www.tukui.org/api.php";
const CLIENT_API_URL = "https://www.tukui.org/client-api.php";
const CACHE_TIME = 10 * 60 * 1000;
export class TukUiAddonProvider implements AddonProvider {
private readonly _circuitBreaker: CircuitBreaker<
[clientType: WowClientType],
TukUiAddon[]
>;
public readonly name = "TukUI";
@@ -29,15 +33,30 @@ export class TukUiAddonProvider implements AddonProvider {
private _cachingService: CachingService,
private _electronService: ElectronService,
private _fileService: FileService
) { }
) {
this._circuitBreaker = new CircuitBreaker(this.fetchApiResults, {
resetTimeout: 60000,
});
async getAll(clientType: WowClientType, addonIds: string[]): Promise<AddonSearchResult[]> {
this._circuitBreaker.on("open", () => {
console.log(`${this.name} circuit breaker open`);
});
this._circuitBreaker.on("close", () => {
console.log(`${this.name} circuit breaker close`);
});
}
async getAll(
clientType: WowClientType,
addonIds: string[]
): Promise<AddonSearchResult[]> {
let results: AddonSearchResult[] = [];
try {
const addons = await this.getAllAddons(clientType);
results = addons.filter(addon => _.some(addonIds, aid => aid === addon.id))
.map(addon => this.toSearchResult(addon, ''));
results = addons
.filter((addon) => _.some(addonIds, (aid) => aid === addon.id))
.map((addon) => this.toSearchResult(addon, ""));
} catch (err) {
// _analyticsService.Track(ex, "Failed to search TukUi");
}
@@ -45,29 +64,41 @@ export class TukUiAddonProvider implements AddonProvider {
return results;
}
getFeaturedAddons(clientType: WowClientType): Observable<PotentialAddon[]> {
return from(this.getAllAddons(clientType))
.pipe(
map(tukUiAddons => {
return tukUiAddons.map(addon => this.toPotentialAddon(addon));
})
);
public async getFeaturedAddons(
clientType: WowClientType
): Promise<AddonSearchResult[]> {
const tukUiAddons = await this.getAllAddons(clientType);
return tukUiAddons.map((addon) => this.toSearchResult(addon));
}
async searchByQuery(query: string, clientType: WowClientType): Promise<PotentialAddon[]> {
async searchByQuery(
query: string,
clientType: WowClientType
): Promise<AddonSearchResult[]> {
const addons = await this.getAllAddons(clientType);
const canonQuery = query.toLowerCase();
let similarAddons = _.filter(addons, addon => addon.name.toLowerCase().indexOf(canonQuery) !== -1);
similarAddons = _.orderBy(similarAddons, ['downloads']);
let similarAddons = _.filter(
addons,
(addon) => addon.name.toLowerCase().indexOf(canonQuery) !== -1
);
similarAddons = _.orderBy(similarAddons, ["downloads"]);
return _.map(similarAddons, addon => this.toPotentialAddon(addon));
return _.map(similarAddons, (addon) => this.toSearchResult(addon));
}
searchByUrl(addonUri: URL, clientType: WowClientType): Promise<PotentialAddon> {
searchByUrl(
addonUri: URL,
clientType: WowClientType
): Promise<AddonSearchResult> {
throw new Error("Method not implemented.");
}
async searchByName(addonName: string, folderName: string, clientType: WowClientType, nameOverride?: string): Promise<AddonSearchResult[]> {
async searchByName(
addonName: string,
folderName: string,
clientType: WowClientType,
nameOverride?: string
): Promise<AddonSearchResult[]> {
const results: AddonSearchResult[] = [];
try {
const addons = await this.searchAddons(addonName, clientType);
@@ -82,31 +113,42 @@ export class TukUiAddonProvider implements AddonProvider {
return results;
}
getById(addonId: string, clientType: WowClientType): Observable<AddonSearchResult | undefined> {
return from(this.getAllAddons(clientType))
.pipe(
map(addons => {
const match = _.find(addons, addon => addon.id === addonId);
return this.toSearchResult(match, '');
})
)
getById(
addonId: string,
clientType: WowClientType
): Observable<AddonSearchResult | undefined> {
return from(this.getAllAddons(clientType)).pipe(
map((addons) => {
const match = _.find(addons, (addon) => addon.id === addonId);
return this.toSearchResult(match, "");
})
);
}
isValidAddonUri(addonUri: URL): boolean {
return false;
}
onPostInstall(addon: Addon): void {
}
onPostInstall(addon: Addon): void {}
async scan(clientType: WowClientType, addonChannelType: AddonChannelType, addonFolders: AddonFolder[]): Promise<void> {
async scan(
clientType: WowClientType,
addonChannelType: AddonChannelType,
addonFolders: AddonFolder[]
): Promise<void> {
const allAddons = await this.getAllAddons(clientType);
for (let addonFolder of addonFolders) {
let tukUiAddon: TukUiAddon;
if (addonFolder.toc?.tukUiProjectId) {
tukUiAddon = _.find(allAddons, addon => addon.id.toString() === addonFolder.toc.tukUiProjectId);
tukUiAddon = _.find(
allAddons,
(addon) => addon.id.toString() === addonFolder.toc.tukUiProjectId
);
} else {
const results = await this.searchAddons(addonFolder.toc.title, clientType);
const results = await this.searchAddons(
addonFolder.toc.title,
clientType
);
tukUiAddon = _.first(results);
}
@@ -130,43 +172,37 @@ export class TukUiAddonProvider implements AddonProvider {
latestVersion: tukUiAddon.version,
providerName: this.name,
thumbnailUrl: tukUiAddon.screenshot_url,
updatedAt: new Date()
}
updatedAt: new Date(),
summary: tukUiAddon.small_desc,
downloadCount: Number.parseFloat(tukUiAddon.downloads),
screenshotUrls: [tukUiAddon.screenshot_url],
};
}
}
}
private async searchAddons(addonName: string, clientType: WowClientType) {
var addons = await this.getAllAddons(clientType);
return addons
.filter(addon => addon.name.toLowerCase() === addonName.toLowerCase());
return addons.filter(
(addon) => addon.name.toLowerCase() === addonName.toLowerCase()
);
}
private toPotentialAddon(addon: TukUiAddon): PotentialAddon {
return {
author: addon.author,
downloadCount: parseInt(addon.downloads, 10),
externalId: addon.id,
externalUrl: addon.web_url,
name: addon.name,
providerName: this.name,
thumbnailUrl: addon.screenshot_url,
summary: addon.small_desc
};
}
private toSearchResult(addon: TukUiAddon, folderName: string): AddonSearchResult | undefined {
private toSearchResult(
addon: TukUiAddon,
folderName?: string
): AddonSearchResult | undefined {
if (!addon) {
return undefined;
}
var latestFile: AddonSearchResultFile = {
channelType: AddonChannelType.Stable,
folders: [folderName],
folders: folderName ? [folderName] : [],
downloadUrl: addon.url,
gameVersion: addon.patch,
version: addon.version,
releaseDate: new Date(addon.lastUpdate)
releaseDate: new Date(addon.lastUpdate),
};
return {
@@ -176,12 +212,15 @@ export class TukUiAddonProvider implements AddonProvider {
thumbnailUrl: addon.screenshot_url,
externalUrl: addon.web_url,
providerName: this.name,
files: [latestFile]
downloadCount: parseInt(addon.downloads, 10),
files: [latestFile],
};
}
private async getAllAddons(clientType: WowClientType): Promise<TukUiAddon[]> {
if(clientType === WowClientType.None){
private getAllAddons = async (
clientType: WowClientType
): Promise<TukUiAddon[]> => {
if (clientType === WowClientType.None) {
return [];
}
@@ -192,36 +231,44 @@ export class TukUiAddonProvider implements AddonProvider {
}
try {
const query = this.getAddonsSuffix(clientType);
const url = new URL(API_URL);
url.searchParams.append(query, 'all');
const addons = await this._circuitBreaker.fire(clientType);
const addons = await this._httpClient.get<TukUiAddon[]>(url.toString()).toPromise();
if (this.isRetail(clientType)) {
addons.push(await this.getTukUiRetailAddon().toPromise());
addons.push(await this.getElvUiRetailAddon().toPromise());
}
console.log('CACHED')
console.log("CACHED");
this._cachingService.set(cacheKey, addons, CACHE_TIME);
return addons;
} catch (err) {
console.error(err);
return [];
}
}
};
private fetchApiResults = async (clientType: WowClientType) => {
const query = this.getAddonsSuffix(clientType);
const url = new URL(API_URL);
url.searchParams.append(query, "all");
const addons = await this._httpClient
.get<TukUiAddon[]>(url.toString())
.toPromise();
if (this.isRetail(clientType)) {
addons.push(await this.getTukUiRetailAddon().toPromise());
addons.push(await this.getElvUiRetailAddon().toPromise());
}
return addons;
};
private getTukUiRetailAddon() {
return this.getClientApiAddon('tukui');
return this.getClientApiAddon("tukui");
}
private getElvUiRetailAddon() {
return this.getClientApiAddon('elvui');
return this.getClientApiAddon("elvui");
}
private getClientApiAddon(addonName: string): Observable<TukUiAddon> {
const url = new URL(CLIENT_API_URL);
url.searchParams.append('ui', addonName);
url.searchParams.append("ui", addonName);
return this._httpClient.get<TukUiAddon>(url.toString());
}
@@ -247,7 +294,7 @@ export class TukUiAddonProvider implements AddonProvider {
case WowClientType.Beta:
return "addons";
default:
return '';
return "";
}
}
@@ -261,7 +308,7 @@ export class TukUiAddonProvider implements AddonProvider {
case WowClientType.Beta:
return "tukui_addons";
default:
return '';
return "";
}
}
}

View File

@@ -5,21 +5,26 @@ import { AddonDetailsResponse } from "app/models/wow-interface/addon-details-res
import { AddonChannelType } from "app/models/wowup/addon-channel-type";
import { AddonFolder } from "app/models/wowup/addon-folder";
import { AddonSearchResult } from "app/models/wowup/addon-search-result";
import { PotentialAddon } from "app/models/wowup/potential-addon";
import { ElectronService } from "app/services";
import { CachingService } from "app/services/caching/caching-service";
import { FileService } from "app/services/files/file.service";
import { from, Observable, of } from "rxjs";
import { AddonProvider } from "./addon-provider";
import * as _ from 'lodash';
import * as _ from "lodash";
import { AddonSearchResultFile } from "app/models/wowup/addon-search-result-file";
import { map } from "rxjs/operators";
import { v4 as uuidv4 } from 'uuid';
import { v4 as uuidv4 } from "uuid";
import * as CircuitBreaker from "opossum";
const API_URL = "https://api.mmoui.com/v4/game/WOW";
const ADDON_URL = "https://www.wowinterface.com/downloads/info";
export class WowInterfaceAddonProvider implements AddonProvider {
private readonly _circuitBreaker: CircuitBreaker<
[addonId: string],
AddonDetailsResponse
>;
public readonly name = "WowInterface";
constructor(
@@ -27,9 +32,23 @@ export class WowInterfaceAddonProvider implements AddonProvider {
private _cachingService: CachingService,
private _electronService: ElectronService,
private _fileService: FileService
) { }
) {
this._circuitBreaker = new CircuitBreaker(this.getAddonDetails, {
resetTimeout: 60000,
});
async getAll(clientType: WowClientType, addonIds: string[]): Promise<AddonSearchResult[]> {
this._circuitBreaker.on("open", () => {
console.log(`${this.name} circuit breaker open`);
});
this._circuitBreaker.on("close", () => {
console.log(`${this.name} circuit breaker close`);
});
}
async getAll(
clientType: WowClientType,
addonIds: string[]
): Promise<AddonSearchResult[]> {
var searchResults: AddonSearchResult[] = [];
for (let addonId of addonIds) {
@@ -44,55 +63,84 @@ export class WowInterfaceAddonProvider implements AddonProvider {
return searchResults;
}
getFeaturedAddons(clientType: WowClientType): Observable<PotentialAddon[]> {
return of([]);
}
async searchByQuery(query: string, clientType: WowClientType): Promise<PotentialAddon[]> {
public async getFeaturedAddons(
clientType: WowClientType
): Promise<AddonSearchResult[]> {
return [];
}
async searchByUrl(addonUri: URL, clientType: WowClientType): Promise<PotentialAddon> {
async searchByQuery(
query: string,
clientType: WowClientType
): Promise<AddonSearchResult[]> {
return [];
}
async searchByUrl(
addonUri: URL,
clientType: WowClientType
): Promise<AddonSearchResult> {
const addonId = this.getAddonId(addonUri);
if (!addonId) {
throw new Error(`Addon ID not found ${addonUri}`);
}
var addon = await this.getAddonDetails(addonId).toPromise();
var addon = await this._circuitBreaker.fire(addonId);
if (addon == null) {
throw new Error(`Bad addon api response ${addonUri}`);
}
return this.toPotentialAddon(addon);
return this.toAddonSearchResult(addon);
}
searchByName(addonName: string, folderName: string, clientType: WowClientType, nameOverride?: string): Promise<AddonSearchResult[]> {
searchByName(
addonName: string,
folderName: string,
clientType: WowClientType,
nameOverride?: string
): Promise<AddonSearchResult[]> {
throw new Error("Method not implemented.");
}
getById(addonId: string, clientType: WowClientType): Observable<AddonSearchResult> {
return from(this.getAddonDetails(addonId))
.pipe(
map(result => result ? this.toAddonSearchResult(result, '') : undefined)
);
getById(
addonId: string,
clientType: WowClientType
): Observable<AddonSearchResult> {
return from(this._circuitBreaker.fire(addonId)).pipe(
map((result) =>
result ? this.toAddonSearchResult(result, "") : undefined
)
);
}
isValidAddonUri(addonUri: URL): boolean {
return addonUri.host && addonUri.host.endsWith('wowinterface.com');
return addonUri.host && addonUri.host.endsWith("wowinterface.com");
}
onPostInstall(addon: Addon): void {
throw new Error("Method not implemented.");
}
async scan(clientType: WowClientType, addonChannelType: AddonChannelType, addonFolders: AddonFolder[]): Promise<void> {
async scan(
clientType: WowClientType,
addonChannelType: AddonChannelType,
addonFolders: AddonFolder[]
): Promise<void> {
for (let addonFolder of addonFolders) {
if (!addonFolder?.toc?.wowInterfaceId) {
continue;
}
const details = await this.getAddonDetails(addonFolder.toc.wowInterfaceId).toPromise();
addonFolder.matchingAddon = this.toAddon(details, clientType, addonChannelType, addonFolder);
const details = await this._circuitBreaker.fire(
addonFolder.toc.wowInterfaceId
);
addonFolder.matchingAddon = this.toAddon(
details,
clientType,
addonChannelType,
addonFolder
);
}
}
@@ -103,14 +151,16 @@ export class WowInterfaceAddonProvider implements AddonProvider {
return match[1];
}
private getAddonDetails(addonId: string): Observable<AddonDetailsResponse> {
private getAddonDetails = (
addonId: string
): Promise<AddonDetailsResponse> => {
console.debug("getAddonDetails");
const url = new URL(`${API_URL}/filedetails/${addonId}.json`);
return this._httpClient
.get<AddonDetailsResponse[]>(url.toString())
.pipe(
map(responses => _.first(responses))
);
.pipe(map((responses) => _.first(responses)))
.toPromise();
};
private getThumbnailUrl(response: AddonDetailsResponse) {
@@ -137,7 +187,7 @@ export class WowInterfaceAddonProvider implements AddonProvider {
externalId: response.id.toString(),
externalUrl: this.getAddonUrl(response),
folderName: addonFolder.name,
gameVersion: '',
gameVersion: "",
installedAt: new Date(),
installedFolders: addonFolder.name,
installedVersion: addonFolder.toc?.version,
@@ -145,31 +195,25 @@ export class WowInterfaceAddonProvider implements AddonProvider {
latestVersion: response.version,
name: response.title,
providerName: this.name,
thumbnailUrl: this.getThumbnailUrl(response)
};
}
private toPotentialAddon(response: AddonDetailsResponse): PotentialAddon {
return {
providerName: this.name,
name: response.title,
downloadCount: response.downloads,
thumbnailUrl: this.getThumbnailUrl(response),
externalId: response.id.toString(),
externalUrl: this.getAddonUrl(response),
author: response.author,
summary: response.description,
screenshotUrls: response.images?.map((img) => img.imageUrl),
downloadCount: response.downloads,
};
}
private toAddonSearchResult(response: AddonDetailsResponse, folderName: string): AddonSearchResult {
private toAddonSearchResult(
response: AddonDetailsResponse,
folderName?: string
): AddonSearchResult {
try {
var searchResultFile: AddonSearchResultFile = {
channelType: AddonChannelType.Stable,
version: response.version,
downloadUrl: response.downloadUri,
folders: [folderName],
gameVersion: '',
releaseDate: new Date()
folders: folderName ? [folderName] : [],
gameVersion: "",
releaseDate: new Date(),
};
return {
@@ -179,10 +223,11 @@ export class WowInterfaceAddonProvider implements AddonProvider {
thumbnailUrl: this.getThumbnailUrl(response),
externalUrl: this.getAddonUrl(response),
providerName: this.name,
files: [searchResultFile]
downloadCount: response.downloads,
files: [searchResultFile],
};
} catch (err) {
console.error('Failed to create addon search result', err);
console.error("Failed to create addon search result", err);
return undefined;
}
}

View File

@@ -0,0 +1,269 @@
import { HttpClient } from "@angular/common/http";
import { AppConfig } from "environments/environment";
import { Observable, of } from "rxjs";
import { v4 as uuidv4 } from "uuid";
import { Addon } from "../entities/addon";
import { WowClientType } from "../models/warcraft/wow-client-type";
import { AddonSearchResult } from "../models/wowup/addon-search-result";
import { AddonProvider, AddonProviderType } from "./addon-provider";
import { WowUpAddonRepresentation } from "../models/wowup-api/wowup-addon.representation";
import { AddonFolder } from "app/models/wowup/addon-folder";
import { WowUpGetScanResultsRequest } from "common/wowup/wowup-get-scan-results-request";
import { WowUpGetScanResultsResponse } from "common/wowup/wowup-get-scan-results-response";
import { ElectronService } from "app/services";
import { WOWUP_GET_SCAN_RESULTS } from "common/constants";
import { WowUpScanResult } from "common/wowup/wowup-scan-result";
import { GetAddonsByFingerprintResponse } from "app/models/wowup-api/get-addons-by-fingerprint.response";
import { WowUpAddonReleaseRepresentation } from "app/models/wowup-api/wowup-addon-release.representation";
import { WowGameType } from "app/models/wowup-api/wow-game-type";
import { AddonChannelType } from "app/models/wowup/addon-channel-type";
import { AppWowUpScanResult } from "app/models/wowup/app-wowup-scan-result";
const API_URL = AppConfig.wowUpHubUrl;
export class WowUpAddonProvider implements AddonProvider {
public readonly name = "WowUp";
constructor(
private _httpClient: HttpClient,
private _electronService: ElectronService
) {}
async getAll(
clientType: WowClientType,
addonIds: string[]
): Promise<AddonSearchResult[]> {
const url = `${API_URL}/addons`;
const addons = await this._httpClient
.get<WowUpAddonRepresentation[]>(url.toString())
.toPromise();
// TODO
return [];
}
public async getFeaturedAddons(
clientType: WowClientType
): Promise<AddonSearchResult[]> {
// TODO
return [];
}
async searchByQuery(
query: string,
clientType: WowClientType
): Promise<AddonSearchResult[]> {
// TODO
return [];
}
async searchByUrl(
addonUri: URL,
clientType: WowClientType
): Promise<AddonSearchResult> {
// TODO
return undefined;
}
async searchByName(
addonName: string,
folderName: string,
clientType: WowClientType,
nameOverride?: string
): Promise<AddonSearchResult[]> {
// TODO
return [];
}
getById(
addonId: string,
clientType: WowClientType
): Observable<AddonSearchResult> {
// TODO
return of(undefined);
}
isValidAddonUri(addonUri: URL): boolean {
// TODO
return false;
}
onPostInstall(addon: Addon): void {
throw new Error("Method not implemented.");
}
async scan(
clientType: WowClientType,
addonChannelType: any,
addonFolders: AddonFolder[]
): Promise<void> {
// const url = `${API_URL}/addons`;
// const addons = await this._httpClient
// .get<WuAddon[]>(url.toString())
// .toPromise();
const scanResults = await this.getScanResults(addonFolders);
console.log("ScanResults", scanResults.length);
const fingerprintResponse = await this.getAddonsByFingerprints(
scanResults.map((result) => result.fingerprint)
).toPromise();
console.log("fingerprintResponse", fingerprintResponse);
for (let scanResult of scanResults) {
// Curse can deliver the wrong result sometimes, ensure the result matches the client type
scanResult.exactMatch = fingerprintResponse.exactMatches.find(
(exactMatch) =>
this.isGameType(exactMatch.matched_release, clientType) &&
this.hasMatchingFingerprint(scanResult, exactMatch.matched_release)
);
// If the addon does not have an exact match, check the partial matches.
// if (!scanResult.exactMatch) {
// scanResult.exactMatch = fingerprintResponse.partialMatches.find(
// (partialMatch) =>
// partialMatch.file?.modules?.some(
// (module) => module.fingerprint === scanResult.fingerprint
// )
// );
// }
}
const matchedScanResults = scanResults.filter((sr) => !!sr.exactMatch);
const matchedScanResultIds = matchedScanResults.map(
(sr) => sr.exactMatch.id
);
for (let addonFolder of addonFolders) {
var scanResult = scanResults.find((sr) => sr.path === addonFolder.path);
if (!scanResult.exactMatch) {
console.log("No search result match", scanResult.path);
continue;
}
try {
const newAddon = this.getAddon(
clientType,
addonChannelType,
scanResult
);
addonFolder.matchingAddon = newAddon;
} catch (err) {
console.error(scanResult);
console.error(err);
// TODO
// _analyticsService.Track(ex, $"Failed to create addon for result {scanResult.FolderScanner.Fingerprint}");
}
}
}
private hasMatchingFingerprint(
scanResult: WowUpScanResult,
release: WowUpAddonReleaseRepresentation
) {
return release.addonFolders.some(
(addonFolder) => addonFolder.fingerprint == scanResult.fingerprint
);
}
private isGameType(
release: WowUpAddonReleaseRepresentation,
clientType: WowClientType
) {
return release.game_type === this.getWowGameType(clientType);
}
private getWowGameType(clientType: WowClientType): string {
switch (clientType) {
case WowClientType.Classic:
case WowClientType.ClassicPtr:
return WowGameType.Classic;
case WowClientType.Retail:
case WowClientType.RetailPtr:
case WowClientType.Beta:
default:
return WowGameType.Retail;
}
}
private getAddonsByFingerprints(
fingerprints: string[]
): Observable<GetAddonsByFingerprintResponse> {
const url = `${API_URL}/addons/fingerprint`;
return this._httpClient.post<any>(url, {
fingerprints,
});
}
private getScanResults = async (
addonFolders: AddonFolder[]
): Promise<AppWowUpScanResult[]> => {
const t1 = Date.now();
return new Promise((resolve, reject) => {
const eventHandler = (_evt: any, arg: WowUpGetScanResultsResponse) => {
if (arg.error) {
return reject(arg.error);
}
console.log("scan delta", Date.now() - t1);
console.log("WowUpGetScanResultsResponse", arg);
resolve(arg.scanResults);
};
const request: WowUpGetScanResultsRequest = {
filePaths: addonFolders.map((addonFolder) => addonFolder.path),
responseKey: uuidv4(),
};
this._electronService.ipcRenderer.once(request.responseKey, eventHandler);
this._electronService.ipcRenderer.send(WOWUP_GET_SCAN_RESULTS, request);
});
};
private getAddon(
clientType: WowClientType,
addonChannelType: AddonChannelType,
scanResult: AppWowUpScanResult
): Addon {
const primaryAddonFolder = scanResult.exactMatch.matched_release.addonFolders.find(
(af) => af.load_on_demand === false
);
const authors = scanResult.exactMatch.owner_name;
const folderList = scanResult.exactMatch.matched_release.addonFolders
.map((af) => af.folder_name)
.join(", ");
let channelType = addonChannelType;
let latestVersion = primaryAddonFolder.version;
return {
id: uuidv4(),
author: authors,
name: scanResult.exactMatch.repository_name,
channelType,
autoUpdateEnabled: false,
clientType,
downloadUrl: scanResult.exactMatch.matched_release.download_url,
externalUrl: scanResult.exactMatch.repository,
externalId: scanResult.exactMatch.external_id,
folderName: primaryAddonFolder.folder_name,
gameVersion: scanResult.exactMatch.matched_release.game_version,
installedAt: new Date(),
installedFolders: folderList,
installedVersion: scanResult.exactMatch.matched_release.tagName,
isIgnored: false,
latestVersion: scanResult.exactMatch.matched_release.tagName,
providerName: this.name,
providerSource: scanResult.exactMatch.source,
thumbnailUrl: scanResult.exactMatch.image_url,
patreonFundingLink: scanResult.exactMatch.patreon_funding_link,
customFundingLink: scanResult.exactMatch.custom_funding_link,
githubFundingLink: scanResult.exactMatch.github_funding_link,
};
}
}

View File

@@ -6,6 +6,7 @@ import { TelemetryDialogComponent } from "./components/telemetry-dialog/telemetr
import { ElectronService } from "./services";
import { AddonService } from "./services/addons/addon.service";
import { AnalyticsService } from "./services/analytics/analytics.service";
import { FileService } from "./services/files/file.service";
import { WarcraftService } from "./services/warcraft/warcraft.service";
import { WowUpService } from "./services/wowup/wowup.service";
@@ -21,7 +22,8 @@ export class AppComponent implements AfterViewInit {
constructor(
private _analyticsService: AnalyticsService,
private electronService: ElectronService,
private _electronService: ElectronService,
private _fileService: FileService,
private translate: TranslateService,
private warcraft: WarcraftService,
private _wowUpService: WowUpService,
@@ -30,14 +32,14 @@ export class AppComponent implements AfterViewInit {
) {
this.translate.setDefaultLang("en");
this.translate.use(this.electronService.locale);
this.translate.use(this._electronService.locale);
}
ngAfterViewInit(): void {
if (this._analyticsService.shouldPromptTelemetry) {
this.openDialog();
} else {
// TODO track startup
this._analyticsService.trackStartup();
}
this.onAutoUpdateInterval();
@@ -53,12 +55,28 @@ export class AppComponent implements AfterViewInit {
});
dialogRef.afterClosed().subscribe((result) => {
this._wowUpService.telemetryEnabled = result;
this._analyticsService.telemetryEnabled = result;
if (result) {
this._analyticsService.trackStartup();
}
});
}
private onAutoUpdateInterval = async () => {
console.log("Auto update");
const updateCount = await this._addonService.processAutoUpdates();
if (updateCount === 0) {
return;
}
const iconPath = await this._fileService.getAssetFilePath(
"wowup_logo_512np.png"
);
this._electronService.showNotification("Auto Updates", {
body: `Automatically updated ${updateCount} addons.`,
icon: iconPath,
});
};
}

View File

@@ -10,7 +10,7 @@ import {
HTTP_INTERCEPTORS,
} from "@angular/common/http";
import { SharedModule } from "./shared/shared.module";
import { ErrorHandlerIntercepter } from './interceptors/error-handler-intercepter';
import { AppRoutingModule } from "./app-routing.module";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
@@ -28,6 +28,8 @@ import { AnalyticsService } from "./services/analytics/analytics.service";
import { DirectiveModule } from "./directive.module";
import { MatModule } from "./mat-module";
import { MatProgressButtonsModule } from "mat-progress-buttons";
import { ElectronService } from "./services";
import { PreferenceStorageService } from "./services/storage/preference-storage.service";
// AoT requires an exported function for factories
export function httpLoaderFactory(http: HttpClient): TranslateHttpLoader {
@@ -61,8 +63,8 @@ export function httpLoaderFactory(http: HttpClient): TranslateHttpLoader {
useClass: DefaultHeadersInterceptor,
multi: true,
},
{ provide: ErrorHandler, useClass: AnalyticsService },
{ provide: ErrorHandler, useClass: ErrorHandlerIntercepter, deps: [AnalyticsService] },
],
bootstrap: [AppComponent],
})
export class AppModule {}
export class AppModule { }

View File

@@ -0,0 +1,36 @@
import { AddonChannelType } from "app/models/wowup/addon-channel-type";
import { AddonSearchResult } from "app/models/wowup/addon-search-result";
import { AddonSearchResultFile } from "app/models/wowup/addon-search-result-file";
import * as _ from "lodash";
export class GetAddonListItem {
public readonly searchResult: AddonSearchResult;
get downloadCount() {
return this.searchResult.downloadCount || 0;
}
get name() {
return this.searchResult.name;
}
get thumbnailUrl() {
return this.searchResult.thumbnailUrl;
}
get author() {
return this.searchResult.author;
}
get providerName() {
return this.searchResult.providerName;
}
constructor(searchResult: AddonSearchResult) {
this.searchResult = searchResult;
}
public getLatestFile(channel: AddonChannelType): AddonSearchResultFile {
return _.find(this.searchResult.files, (f) => f.channelType <= channel);
}
}

View File

@@ -0,0 +1,93 @@
import { Addon } from "app/entities/addon";
import { AddonChannelType } from "app/models/wowup/addon-channel-type";
import { AddonDisplayState } from "../models/wowup/addon-display-state";
export class AddonViewModel {
public readonly addon: Addon;
isInstalling: boolean = false;
installProgress: number = 0;
statusText: string = "";
selected: boolean = false;
get needsInstall() {
return (
!this.isInstalling && this.displayState === AddonDisplayState.Install
);
}
get needsUpdate() {
return !this.isInstalling && this.displayState === AddonDisplayState.Update;
}
get isAutoUpdate() {
return this.addon.autoUpdateEnabled;
}
get isUpToDate() {
return (
!this.isInstalling && this.displayState === AddonDisplayState.UpToDate
);
}
get isIgnored() {
return this.displayState === AddonDisplayState.Ignored;
}
get isStableChannel() {
return this.addon.channelType === AddonChannelType.Stable;
}
get isBetaChannel() {
return this.addon.channelType === AddonChannelType.Beta;
}
get isAlphaChannel() {
return this.addon.channelType === AddonChannelType.Alpha;
}
get displayState(): AddonDisplayState {
if (this.addon.isIgnored) {
return AddonDisplayState.Ignored;
}
if (!this.addon.installedVersion) {
return AddonDisplayState.Install;
}
if (this.addon.installedVersion !== this.addon.latestVersion) {
return AddonDisplayState.Update;
}
if (this.addon.installedVersion === this.addon.latestVersion) {
return AddonDisplayState.UpToDate;
}
return AddonDisplayState.Unknown;
}
constructor(addon?: Addon) {
this.addon = addon;
this.statusText = this.getStateText();
}
public onClicked() {
this.selected = !this.selected;
}
public getStateText() {
switch (this.displayState) {
case AddonDisplayState.UpToDate:
return "Up to Date";
case AddonDisplayState.Ignored:
return "Ignored";
case AddonDisplayState.Update:
case AddonDisplayState.Install:
return "Install";
case AddonDisplayState.Unknown:
default:
console.log("Unhandled display state", this.displayState);
return "";
}
}
}

View File

@@ -1,85 +0,0 @@
import { Addon } from "app/entities/addon";
import { WowClientType } from "app/models/warcraft/wow-client-type";
import { AddonChannelType } from "app/models/wowup/addon-channel-type";
import { AddonDisplayState } from "../models/wowup/addon-display-state";
export class MyAddonsListItem {
addon: Addon;
isInstalling: boolean = false;
installProgress: number = 0;
statusText: string = '';
selected: boolean = false;
get needsInstall() {
return !this.isInstalling && this.displayState === AddonDisplayState.Install;
}
get needsUpdate() {
return !this.isInstalling && this.displayState === AddonDisplayState.Update;
}
get isUpToDate() {
return !this.isInstalling && this.displayState === AddonDisplayState.UpToDate;
}
get isIgnored() {
return this.displayState === AddonDisplayState.Ignored;
}
get isStableChannel() {
return this.addon.channelType === AddonChannelType.Stable;
}
get isBetaChannel() {
return this.addon.channelType === AddonChannelType.Beta;
}
get isAlphaChannel() {
return this.addon.channelType === AddonChannelType.Alpha;
}
get displayState(): AddonDisplayState {
if (this.addon.isIgnored) {
return AddonDisplayState.Ignored;
}
if (!this.addon.installedVersion) {
return AddonDisplayState.Install;
}
if (this.addon.installedVersion !== this.addon.latestVersion) {
return AddonDisplayState.Update;
}
if (this.addon.installedVersion === this.addon.latestVersion) {
return AddonDisplayState.UpToDate;
}
return AddonDisplayState.Unknown;
}
constructor(addon?: Addon) {
this.addon = addon;
this.statusText = this.getStateText();
}
public onClicked() {
this.selected = !this.selected;
}
public getStateText() {
switch (this.displayState) {
case AddonDisplayState.UpToDate:
return "Up to Date";
case AddonDisplayState.Ignored:
return "Ignored";
case AddonDisplayState.Update:
case AddonDisplayState.Install:
case AddonDisplayState.Unknown:
default:
console.log('Unhandled display state', this.displayState)
return '';
}
}
}

View File

@@ -0,0 +1,183 @@
export interface ScanResult {
path: string;
fileFingerprints: { [path: string]: string };
fingerprint: string;
gameVersion: string;
addonTitle: string;
addonAuthors: string;
loadOnDemand: boolean;
version: string;
}
export class WowUpFolderScanner {
private _folderPath = "";
constructor(folderPath: string) {
this._folderPath = folderPath;
}
private get tocFileCommentsRegex() {
return /\s*#.*$/gm;
}
private get tocFileIncludesRegex() {
return /^\s*((?:(?<!\.\.).)+\.(?:xml|lua))\s*$/gim;
}
private get tocFileRegex() {
return /^([^\/]+)[\\\/]\1\.toc$/i;
}
private get bindingsXmlRegex() {
return /^[^\/\\]+[\/\\]Bindings\.xml$/i;
}
private get bindingsXmlIncludesRegex() {
return /<(?:Include|Script)\s+file=[\""\""']((?:(?<!\.\.).)+)[\""\""']\s*\/>/gi;
}
private get bindingsXmlCommentsRegex() {
return /<!--.*?-->/gs;
}
private async _scanFolder(folderPath: string): Promise<ScanResult> {
const files = await readDirRecursive(folderPath);
console.log("listAllFiles", folderPath, files.length);
let matchingFiles = await this.getMatchingFiles(folderPath, files);
matchingFiles = _.sortBy(matchingFiles, (f) => f.toLowerCase());
const tocFile = this.getTocFile(folderPath);
const toc = await this.parseToc(tocFile);
let fileFingerprints: { [path: string]: string } = {};
for (let file of matchingFiles) {
const fileHash = await hashFile(file);
fileFingerprints[file] = fileHash;
}
const hashConcat = _.orderBy(Object.values(fileFingerprints)).join("");
const fingerprint = hashString(hashConcat);
const result: ScanResult = {
fileFingerprints,
fingerprint,
path: folderPath,
addonAuthors: toc?.author ?? '',
addonTitle: toc?.title ?? '',
gameVersion: toc?.interface ?? '',
loadOnDemand: toc?.loadOnDemand === '1',
version: toc?.version ?? ''
};
return result;
}
private getTocFile(directory: string) {
const baseFiles = fs.readdirSync(directory);
const tocFile = baseFiles.find(file => path.extname(file) === '.toc');
if (!tocFile) {
console.warn('No toc file: ' + directory);
return '';
}
return path.join(directory, tocFile);
}
private async parseToc(filePath: string) {
if (!filePath) {
return undefined;
}
const tocText = await readFile(filePath);
return parseToc(tocText);
}
private async getMatchingFiles(
folderPath: string,
filePaths: string[]
): Promise<string[]> {
const parentDir = path.dirname(folderPath) + path.sep;
const matchingFileList: string[] = [];
const fileInfoList: string[] = [];
for (let filePath of filePaths) {
const input = filePath.toLowerCase().replace(parentDir.toLowerCase(), "");
if (this.tocFileRegex.test(input)) {
fileInfoList.push(filePath);
} else if (this.bindingsXmlRegex.test(input)) {
matchingFileList.push(filePath);
}
}
// console.log('fileInfoList', fileInfoList.length)
for (let fileInfo of fileInfoList) {
await this.processIncludeFile(matchingFileList, fileInfo);
}
return matchingFileList;
}
private async processIncludeFile(
matchingFileList: string[],
fileInfo: string
) {
if (!fs.existsSync(fileInfo) || matchingFileList.indexOf(fileInfo) !== -1) {
return;
}
matchingFileList.push(fileInfo);
let input = await readFile(fileInfo);
input = this.removeComments(fileInfo, input);
const inclusions = this.getFileInclusionMatches(fileInfo, input);
if (!inclusions || !inclusions.length) {
return;
}
const dirname = path.dirname(fileInfo);
for (let include of inclusions) {
const fileName = path.join(dirname, include.replace(/\\/g, path.sep));
await this.processIncludeFile(matchingFileList, fileName);
}
}
private removeComments(fileInfo: string, fileContent: string): string {
const ext = path.extname(fileInfo);
switch (ext) {
case ".xml":
return fileContent.replace(this.bindingsXmlCommentsRegex, "");
case ".toc":
return fileContent.replace(this.tocFileCommentsRegex, "");
default:
return fileContent;
}
}
private getFileInclusionMatches(
fileInfo: string,
fileContent: string
): string[] | null {
const ext = path.extname(fileInfo);
switch (ext) {
case ".xml":
return this.matchAll(fileContent, this.bindingsXmlIncludesRegex);
case ".toc":
return this.matchAll(fileContent, this.tocFileIncludesRegex);
default:
return null;
}
}
private matchAll(str: string, regex: RegExp): string[] {
const matches: string[] = [];
let currentMatch: RegExpExecArray;
do {
currentMatch = regex.exec(str);
if (currentMatch) {
matches.push(currentMatch[1]);
}
} while (currentMatch);
return matches;
}
}

View File

@@ -1,30 +1,40 @@
<div class="addon-detail-view">
<div class="addon-detail-title">
<img mat-card-avatar class="addon-header-thumbnail" [src]="addonDetail.thumbnailUrl" />
<h2 mat-dialog-title>{{ addonDetail.name }}</h2>
<img
mat-card-avatar
class="addon-header-thumbnail"
[src]="addon.thumbnailUrl"
/>
<h2 mat-dialog-title>{{ addon.name }}</h2>
<button mat-icon-button [mat-dialog-close]="true">
<mat-icon class="close-icon" color="accent">close</mat-icon>
</button>
</div>
<div class="addon-detail-subtitle">
<div class="mat-subheading-1">By {{ addonDetail.author }}</div>
<app-addon-provider-badge [providerName]="addonDetail.providerName"></app-addon-provider-badge>
<div class="mat-subheading-1">By {{ addon.author }}</div>
<app-addon-provider-badge
[providerName]="addon.providerName"
></app-addon-provider-badge>
</div>
<mat-dialog-content>
<div class="mat-caption addon-detail-summary">
{{ addonDetail.summary }}
{{ addon.summary }}
</div>
<div class="screenshot-container">
<img [src]="defaultImageUrl" alt="Addon Picture" />
</div>
<p>
{{ addonDetail.description }}
</p>
<img mat-card-image [src]="defaultImageUrl" alt="Addon Picture" />
</mat-dialog-content>
<mat-dialog-actions class="dialog-buttons">
<a
appExternalLink
mat-stroked-button
cdkFocusInitial
color="primary"
[href]="addon.externalUrl"
>{{ "DIALOGS.ADDON_DETAILS.VIEW_IN_BROWSER_BUTTON" | translate }}</a
>
<div class="btn-wrapper">
<a appExternalLink mat-stroked-button color="primary" [href]="addonDetail.externalUrl">
{{'DIALOGS.ADDON_DETAILS.VIEW_IN_BROWSER_BUTTON' | translate}}
</a>
<app-addon-install-button [addon]="addonDetail"> </app-addon-install-button>
<app-addon-install-button [addon]="addon"> </app-addon-install-button>
</div>
</mat-dialog-actions>
</div>
</div>

View File

@@ -1,12 +1,18 @@
.addon-detail-view {
width: 450px;
width: 35vw;
max-width: 500px;
margin-top: 8px;
.addon-detail-title,
.addon-detail-subtitle {
display: flex;
justify-content: space-between;
h2 {
padding: 0 0.5rem;
}
}
.mat-icon-button .mat-button-wrapper,
.close-icon {
cursor: pointer !important;
@@ -16,15 +22,20 @@
}
.addon-detail-summary {
margin-bottom: 8px;
font-weight: bold;
font-weight: 400;
font-size: 14px;
letter-spacing: 0.6px;
}
.dialog-buttons {
display:flex;
display: flex;
.btn-wrapper {
margin-left: auto;
}
}
.screenshot-container {
img {
max-height: 200px;
}
}
}

View File

@@ -1,6 +1,16 @@
import { Component, Inject, OnInit } from "@angular/core";
import { MAT_DIALOG_DATA } from "@angular/material/dialog";
import { AddonDetailModel } from "app/models/wowup/addon-detail.model";
import { Addon } from "app/entities/addon";
export interface AddonDetailModel {
author?: string;
externalUrl?: string;
name: string;
providerName?: string;
screenshotUrls?: string[];
summary?: string;
thumbnailUrl?: string;
}
@Component({
selector: "app-addon-detail",
@@ -8,23 +18,19 @@ import { AddonDetailModel } from "app/models/wowup/addon-detail.model";
styleUrls: ["./addon-detail.component.scss"],
})
export class AddonDetailComponent implements OnInit {
addonDetail: AddonDetailModel;
public addon: AddonDetailModel;
constructor(@Inject(MAT_DIALOG_DATA) public data: AddonDetailModel) {
this.addonDetail = this.data;
this.addon = this.data;
}
get defaultImageUrl(): string {
return this.addonDetail?.screenshotUrls && this.addonDetail?.screenshotUrls[0]
? this.addonDetail?.screenshotUrls[0]
: this.addonDetail?.thumbnailUrl
? this.addonDetail?.thumbnailUrl
return this.addon?.screenshotUrls && this.addon?.screenshotUrls[0]
? this.addon?.screenshotUrls[0]
: this.addon?.thumbnailUrl
? this.addon?.thumbnailUrl
: "";
}
ngOnInit(): void {}
installAddon() {}
openInBrowser() {}
}

View File

@@ -1 +1,5 @@
<mat-bar-button (btnClick)="onButtonClick()" [options]="buttonOptions" cdkFocusInitial></mat-bar-button>
<mat-bar-button (btnClick)="onUninstallClick()" [options]="btnUninstallOptions" id="btn-addon-uninstall"
*ngIf="canUninstall">
</mat-bar-button>
<mat-bar-button (btnClick)="onInstallUpdateClick()" [options]="btnInstallOptions" id="btn-addon-installorupdate">
</mat-bar-button>

View File

@@ -1,13 +1,18 @@
import { Component, Input, OnDestroy, OnInit } from "@angular/core";
import { AddonDetailModel } from "app/models/wowup/addon-detail.model";
import { AddonInstallState } from "app/models/wowup/addon-install-state";
import { MatDialog } from "@angular/material/dialog";
import { TranslateService } from "@ngx-translate/core";
import { AddonViewModel } from "app/business-objects/my-addon-list-item";
import { Addon } from "app/entities/addon";
import { AddonDisplayState } from "app/models/wowup/addon-display-state";
import { AddonSearchResult } from "app/models/wowup/addon-search-result";
import { AddonUpdateEvent } from "app/models/wowup/addon-update-event";
import { PotentialAddon } from "app/models/wowup/potential-addon";
import { AddonService } from "app/services/addons/addon.service";
import { SessionService } from "app/services/session/session.service";
import { getEnumName } from "app/utils/enum.utils";
import { MatProgressButtonOptions } from "mat-progress-buttons";
import { Subscription } from "rxjs";
import { filter, map } from "rxjs/operators";
import { ConfirmDialogComponent } from "../confirm-dialog/confirm-dialog.component";
@Component({
selector: "app-addon-install-button",
@@ -15,37 +20,46 @@ import { filter, map } from "rxjs/operators";
styleUrls: ["./addon-install-button.component.scss"],
})
export class AddonInstallButtonComponent implements OnInit, OnDestroy {
@Input("addon") addon: PotentialAddon | AddonDetailModel;
@Input() addon: Addon;
@Input() hideUninstall = false;
isInstalled: boolean;
canUninstall: boolean;
hasUpdate: boolean;
buttonOptions: MatProgressButtonOptions;
addonModel: AddonViewModel;
isInstalled = false;
btnUninstallOptions: MatProgressButtonOptions;
btnInstallOptions: MatProgressButtonOptions;
private _subscriptions: Subscription[];
get canUninstall(): boolean {
return this.isInstalled && !this.hideUninstall;
}
get shouldDisableInstallButton(): boolean {
return (
(!this.addonModel.needsInstall && !this.addonModel.needsUpdate) ||
this.addonModel.isInstalling
);
}
constructor(
private _addonService: AddonService,
private _sessionService: SessionService
private _sessionService: SessionService,
private _dialog: MatDialog,
private _translate: TranslateService
) {}
ngOnInit(): void {
this.addonModel = new AddonViewModel(this.addon);
this.isInstalled = this._addonService.isInstalled(
this.addon.externalId,
this._sessionService.selectedClientType
);
this.setButtonOptions();
const addonUpdateSubscription = this._addonService.addonInstalled$
.pipe(
filter((x) => x.addon.externalId === this.addon.externalId),
map((event: AddonUpdateEvent) => {
if (event.installState === AddonInstallState.Complete) {
this.isInstalled = true;
this.setButtonOptions();
}
})
)
.subscribe();
.pipe(filter((x) => x.addon.externalId === this.addon.externalId))
.subscribe((event) => this.onAddonUpdate(event));
this._subscriptions = [addonUpdateSubscription];
}
@@ -53,70 +67,130 @@ export class AddonInstallButtonComponent implements OnInit, OnDestroy {
this._subscriptions.forEach((x) => x.unsubscribe());
}
setButtonOptions(): void {
if (!this.isInstalled) {
this.buttonOptions = this.getBaseBtnOptions();
} else if (this.isInstalled && !this.canUninstall) {
this.buttonOptions = this.getInstalledBtnOptions();
} else if (this.isInstalled && this.canUninstall) {
this.buttonOptions = this.getUninstallBtnOptions();
} else if (this.isInstalled && this.hasUpdate) {
this.buttonOptions = this.getUpdateBtnOptions();
onAddonUpdate(event: AddonUpdateEvent): void {
const addonModel = new AddonViewModel(event.addon);
addonModel.installProgress = event.progress;
// addonModel.updateInstallState(event.installState);
// addonModel.setStatusText(event.installState);
this.addonModel = addonModel;
if (event.installState === 4) {
this.setButtonOptions();
} else {
this.updateButtonOptions();
}
}
onButtonClick(): void {
this.buttonOptions.active = true;
this.buttonOptions.text = "Installing...";
setButtonOptions(): void {
this.btnInstallOptions = this.getBaseBtnOptions();
this.btnUninstallOptions = this.getUninstallBtnOptions();
if (this.shouldDisableInstallButton) {
this.btnInstallOptions.disabled = true;
}
if (!this.canUninstall) {
this.btnUninstallOptions.disabled = true;
this.btnUninstallOptions.active = false;
}
}
updateButtonOptions(): void {
this.btnInstallOptions.active = this.addonModel.isInstalling;
this.btnInstallOptions.value = this.addonModel.installProgress;
this.btnInstallOptions.text = this.addonModel.isInstalling
? this.getTranslatedStatusText()
: this.getTranslatedStateText();
}
onInstallUpdateClick(): void {
this.btnInstallOptions.active = true;
if (this.addonModel.needsUpdate) {
this.updateAddon();
} else if (this.addonModel.needsInstall) {
this.installAddon();
}
}
onUninstallClick(): void {
this.confirmRemoveAddon();
}
private installAddon() {
this._addonService.installPotentialAddon(
this.addon as PotentialAddon,
this._sessionService.selectedClientType,
(state, progress) => {
if (state === AddonInstallState.Complete) {
this.isInstalled = true;
this.setButtonOptions();
}
this.buttonOptions.value = progress;
this.addonModel.addon as AddonSearchResult,
this._sessionService.selectedClientType
);
}
private updateAddon() {
this._addonService.installAddon(this.addonModel.addon.id);
}
private confirmRemoveAddon() {
const dialogRef = this._dialog.open(ConfirmDialogComponent, {
data: {
title: this._translate.instant("DIALOGS.REMOVE_ADDON.TITLE"),
message: this._translate.instant("DIALOGS.REMOVE_ADDON.MESSAGE", {
addon: this.addon.name,
}),
},
});
dialogRef.afterClosed().subscribe((result) => {
if (!result) {
return;
}
this.btnUninstallOptions.active = true;
this.btnUninstallOptions.text = this._translate.instant(
"COMMON.ADDON_STATUS.UNINSTALLING"
);
this._addonService.removeAddon(this.addonModel.addon);
// Parent component should listen to addon removed event and make changes.
});
}
private getTranslatedStatusText(): string {
const status = this.addonModel.statusText;
return this._translate.instant(
`COMMON.ADDON_STATUS.${status.toUpperCase()}`
);
}
private getTranslatedStateText(): string {
const state = this.addonModel.displayState;
return this._translate.instant(
`COMMON.ADDON_STATE.${getEnumName(
AddonDisplayState,
state
).toUpperCase()}`
);
}
private getBaseBtnOptions(): MatProgressButtonOptions {
return {
active: false,
text: "Install",
active: this.addonModel.isInstalling,
disabled: this.shouldDisableInstallButton,
value: this.addonModel.installProgress,
text: this.addonModel.isInstalling
? this.getTranslatedStatusText()
: this.getTranslatedStateText(),
mode: "determinate",
buttonColor: "primary",
barColor: "accent",
customClass: "install-button",
raised: true,
stroked: false,
mode: "determinate",
value: 0,
disabled: false,
fullWidth: false,
};
}
private getInstalledBtnOptions(): MatProgressButtonOptions {
return {
...this.getBaseBtnOptions(),
disabled: true,
active: false,
text: "Installed",
};
}
private getUninstallBtnOptions(): MatProgressButtonOptions {
return {
...this.getBaseBtnOptions(),
text: "Uninstall",
};
}
private getUpdateBtnOptions(): MatProgressButtonOptions {
return {
...this.getBaseBtnOptions(),
text: "Update",
text: this._translate.instant("COMMON.ADDON_STATE.UNINSTALL"),
mode: "indeterminate",
buttonColor: "warn",
barColor: "primary",
};
}
}

View File

@@ -1,10 +1,15 @@
<footer class="bg-dark-4 text-light-2">
<a appExternalLink class="patreon-link" href="https://www.patreon.com/jliddev">
<img class="patron-img" src="assets/Digital-Patreon-Wordmark_FieryCoral.png" />
</a>
<p class="flex-grow-1">{{sessionService.statusText$ | async}}</p>
<div class="row">
<p class="mr-3">{{sessionService.pageContextText$ | async}}</p>
<p>v{{wowUpService.applicationVersion}}</p>
</div>
</footer>
<a appExternalLink class="patreon-link" href="https://www.patreon.com/jliddev" matTooltip="Support WowUp on Patreon"
appUserActionTracker category="Footer" action="Patreon">
<img class="patron-img" src="assets/Digital-Patreon-Wordmark_FieryCoral.png" />
</a>
<a appExternalLink class="discord-link" href="https://discord.gg/rk4F5aD" matTooltip="Chat with us on Discord"
appUserActionTracker category="Footer" action="Discord">
<img class="discord-img" src="assets/images/discord_logo_small.png" />
</a>
<p class="flex-grow-1">{{sessionService.statusText$ | async}}</p>
<div class="row">
<p class="mr-3">{{sessionService.pageContextText$ | async}}</p>
<p>v{{wowUpService.applicationVersion}}</p>
</div>
</footer>

View File

@@ -1,8 +1,6 @@
@import "../../../variables.scss";
.patron-img {
height: 25px;
}
footer {
height: 25px;
padding: 0.25em 0.5em;
@@ -28,8 +26,25 @@ footer {
}
.patreon-link{
margin-right: 1em;
&:hover {
background-color: $dark-3;
}
.patron-img {
height: 25px;
}
}
.discord-link {
padding: 0 0.25em;
&:hover {
background-color: $dark-3;
}
.discord-img {
height: 25px;
}
}
}

View File

@@ -1,27 +1,27 @@
import { Component, NgZone, OnInit } from '@angular/core';
import { SessionService } from 'app/services/session/session.service';
import { WowUpService } from 'app/services/wowup/wowup.service';
import { Component, NgZone, OnInit } from "@angular/core";
import { SessionService } from "app/services/session/session.service";
import { WowUpService } from "app/services/wowup/wowup.service";
@Component({
selector: 'app-footer',
templateUrl: './footer.component.html',
styleUrls: ['./footer.component.scss']
selector: "app-footer",
templateUrl: "./footer.component.html",
styleUrls: ["./footer.component.scss"],
})
export class FooterComponent implements OnInit {
constructor(
private _zone: NgZone,
public wowUpService: WowUpService,
public sessionService: SessionService
) { }
) {}
ngOnInit(): void {
// Force the angular zone to pump for every progress update since its outside the zone
this.sessionService.statusText$
.subscribe(text => {
this._zone.run(() => { });
})
}
this.sessionService.statusText$.subscribe((text) => {
this._zone.run(() => {});
});
this.sessionService.pageContextText$.subscribe((text) => {
this._zone.run(() => {});
});
}
}

View File

@@ -0,0 +1,2 @@
<mat-bar-button (btnClick)="onInstallUpdateClick()" [options]="buttonOptions$ | async" class="install-button">
</mat-bar-button>

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GetAddonStatusColumnComponent } from './get-addon-status-column.component';
describe('GetAddonStatusColumnComponent', () => {
let component: GetAddonStatusColumnComponent;
let fixture: ComponentFixture<GetAddonStatusColumnComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ GetAddonStatusColumnComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(GetAddonStatusColumnComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,110 @@
import { Component, Input, OnDestroy, OnInit } from "@angular/core";
import { TranslateService } from "@ngx-translate/core";
import { GetAddonListItem } from "app/business-objects/get-addon-list-item";
import { AddonInstallState } from "app/models/wowup/addon-install-state";
import { AddonService } from "app/services/addons/addon.service";
import { SessionService } from "app/services/session/session.service";
import { MatProgressButtonOptions } from "mat-progress-buttons";
import { BehaviorSubject, Observable, Subject } from "rxjs";
@Component({
selector: "app-get-addon-status-column",
templateUrl: "./get-addon-status-column.component.html",
styleUrls: ["./get-addon-status-column.component.scss"],
})
export class GetAddonStatusColumnComponent implements OnInit, OnDestroy {
@Input() listItem: GetAddonListItem;
private readonly _buttonOptionsSrc: BehaviorSubject<MatProgressButtonOptions>;
private installState: AddonInstallState = AddonInstallState.Unknown;
private installProgress: number = 0;
public readonly buttonOptions$: Observable<MatProgressButtonOptions>;
public get buttonText() {
if (this.installState !== AddonInstallState.Unknown) {
return this.getInstallStateText(this.installState);
}
return this._translate.instant("COMMON.ADDON_STATE.INSTALL");
}
public getInstallStateText(installState: AddonInstallState) {
switch (installState) {
case AddonInstallState.BackingUp:
return this._translate.instant("COMMON.ADDON_STATUS.BACKINGUP");
case AddonInstallState.Complete:
return this._translate.instant("COMMON.ADDON_STATUS.COMPLETE");
case AddonInstallState.Downloading:
return this._translate.instant("COMMON.ADDON_STATUS.DOWNLOADING");
case AddonInstallState.Installing:
return this._translate.instant("COMMON.ADDON_STATUS.INSTALLING");
case AddonInstallState.Pending:
return this._translate.instant("COMMON.ADDON_STATUS.PENDING");
default:
return "";
}
}
public get isButtonActive() {
return (
this.installState !== AddonInstallState.Unknown &&
this.installState !== AddonInstallState.Complete
);
}
public get isButtonDisabled() {
return this.installState === AddonInstallState.Complete;
}
public getButtonOptions(): MatProgressButtonOptions {
return {
active: this.isButtonActive,
disabled: this.isButtonDisabled,
value: this.installProgress,
text: this.buttonText,
mode: "determinate",
buttonColor: "primary",
barColor: "accent",
customClass: "install-button",
raised: false,
flat: true,
stroked: false,
fullWidth: false,
};
}
constructor(
private _addonService: AddonService,
private _sessionService: SessionService,
private _translate: TranslateService
) {
this._buttonOptionsSrc = new BehaviorSubject<MatProgressButtonOptions>(
this.getButtonOptions()
);
this.buttonOptions$ = this._buttonOptionsSrc.asObservable();
}
ngOnInit(): void {}
ngOnDestroy(): void {
this._buttonOptionsSrc.complete();
}
onInstallUpdateClick() {
this._addonService.installPotentialAddon(
this.listItem.searchResult,
this._sessionService.selectedClientType,
this.onInstallUpdate
);
}
private onInstallUpdate = (
installState: AddonInstallState,
progress: number
) => {
this.installState = installState;
this.installProgress = progress;
this._buttonOptionsSrc.next(this.getButtonOptions());
};
}

View File

@@ -40,7 +40,8 @@
<button mat-button (click)="onClose()">
{{'DIALOGS.INSTALL_FROM_URL.CLOSE_BUTTON' | translate}}
</button>
<button mat-flat-button color="primary" cdkFocusInitial (click)="onImportUrl()">
<button mat-flat-button color="primary" cdkFocusInitial (click)="onImportUrl()" appUserActionTracker
category="InstallFromUrl" action="ImportUrl" [label]="query">
{{'DIALOGS.INSTALL_FROM_URL.IMPORT_BUTTON' | translate}}
</button>
</div>

View File

@@ -1,25 +1,24 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { PotentialAddon } from 'app/models/wowup/potential-addon';
import { AddonService } from 'app/services/addons/addon.service';
import { SessionService } from 'app/services/session/session.service';
import { from, Subscription } from 'rxjs';
import { AlertDialogComponent } from '../alert-dialog/alert-dialog.component';
import { HttpErrorResponse } from "@angular/common/http";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { MatDialog, MatDialogRef } from "@angular/material/dialog";
import { AddonSearchResult } from "app/models/wowup/addon-search-result";
import { AddonService } from "app/services/addons/addon.service";
import { SessionService } from "app/services/session/session.service";
import { from, Subscription } from "rxjs";
import { AlertDialogComponent } from "../alert-dialog/alert-dialog.component";
@Component({
selector: 'app-install-from-url-dialog',
templateUrl: './install-from-url-dialog.component.html',
styleUrls: ['./install-from-url-dialog.component.scss']
selector: "app-install-from-url-dialog",
templateUrl: "./install-from-url-dialog.component.html",
styleUrls: ["./install-from-url-dialog.component.scss"],
})
export class InstallFromUrlDialogComponent implements OnInit, OnDestroy {
public isBusy = false;
public showInstallSpinner = false;
public showInstallButton = false;
public showInstallSuccess = false;
public query = '';
public addon?: PotentialAddon;
public query = "";
public addon?: AddonSearchResult;
private _installSubscription?: Subscription;
@@ -27,11 +26,10 @@ export class InstallFromUrlDialogComponent implements OnInit, OnDestroy {
private _addonService: AddonService,
private _dialog: MatDialog,
private _sessionService: SessionService,
public dialogRef: MatDialogRef<InstallFromUrlDialogComponent>,
) { }
public dialogRef: MatDialogRef<InstallFromUrlDialogComponent>
) {}
ngOnInit(): void {
}
ngOnInit(): void {}
ngOnDestroy() {
this._installSubscription?.unsubscribe();
@@ -42,7 +40,7 @@ export class InstallFromUrlDialogComponent implements OnInit, OnDestroy {
}
onClearSearch() {
this.query = '';
this.query = "";
this.onImportUrl();
}
@@ -50,20 +48,23 @@ export class InstallFromUrlDialogComponent implements OnInit, OnDestroy {
this.showInstallButton = false;
this.showInstallSpinner = true;
this._installSubscription = from(this._addonService
.installPotentialAddon(this.addon, this._sessionService.selectedClientType))
.subscribe({
next: () => {
this.showInstallSpinner = false;
this.showInstallSuccess = true;
},
error: (err) => {
console.error(err);
this.showInstallSpinner = false;
this.showInstallButton = true;
this.showErrorMessage('Failed to install addon.');
}
});
this._installSubscription = from(
this._addonService.installPotentialAddon(
this.addon,
this._sessionService.selectedClientType
)
).subscribe({
next: () => {
this.showInstallSpinner = false;
this.showInstallSuccess = true;
},
error: (err) => {
console.error(err);
this.showInstallSpinner = false;
this.showInstallButton = true;
this.showErrorMessage("Failed to install addon.");
},
});
}
async onImportUrl() {
@@ -81,12 +82,14 @@ export class InstallFromUrlDialogComponent implements OnInit, OnDestroy {
}
try {
const importedAddon = await this._addonService
.getAddonByUrl(url, this._sessionService.selectedClientType);
const importedAddon = await this._addonService.getAddonByUrl(
url,
this._sessionService.selectedClientType
);
console.log(importedAddon);
if (!importedAddon) {
throw new Error('Addon not found');
throw new Error("Addon not found");
}
this.addon = importedAddon;
@@ -98,13 +101,14 @@ export class InstallFromUrlDialogComponent implements OnInit, OnDestroy {
}
this.showInstallButton = true;
}
catch (err) {
} catch (err) {
console.error(err);
let message = err.message;
if (err instanceof HttpErrorResponse) {
message = `No addon was found.`;
} else if (err.code && err.code === "EOPENBREAKER") { // Provider circuit breaker is open
message = `Cannot connect to API, please wait a bit and try again.`;
}
this.showErrorMessage(message);
@@ -112,16 +116,18 @@ export class InstallFromUrlDialogComponent implements OnInit, OnDestroy {
}
private addonExists(externalId: string) {
return this._addonService.isInstalled(externalId, this._sessionService.selectedClientType);
return this._addonService.isInstalled(
externalId,
this._sessionService.selectedClientType
);
}
private getUrlFromQuery(): URL | undefined {
try {
return new URL(this.query);
}
catch (err) {
} catch (err) {
console.error(`Invalid url: ${this.query}`);
this.showErrorMessage('Invalid URL.');
this.showErrorMessage("Invalid URL.");
return undefined;
}
}
@@ -131,10 +137,9 @@ export class InstallFromUrlDialogComponent implements OnInit, OnDestroy {
minWidth: 250,
data: {
title: `Error`,
message: errorMessage
}
message: errorMessage,
},
});
dialogRef.afterClosed().subscribe();
}
}

View File

@@ -0,0 +1,13 @@
<mat-bar-button *ngIf="showStatusText === false" (btnClick)="onInstallUpdateClick()" [options]="buttonOptions$ | async"
class="install-button">
</mat-bar-button>
<div class="row">
<div *ngIf="showStatusText === true" class="status-text" [ngClass]="{ 'ignored': listItem.isIgnored }">
{{getStatusText()}}
</div>
<mat-icon *ngIf="this.listItem.isAutoUpdate === true" class="auto-update-icon"
[matTooltip]="'PAGES.MY_ADDONS.TABLE.AUTO_UPDATE_ICON_TOOLTIP' | translate">
update
</mat-icon>
</div>

View File

@@ -0,0 +1,8 @@
@import "../../../variables.scss";
.auto-update-icon {
margin-left: 0.5em;
}
.ignored {
color: $white-4;
}

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MyAddonStatusColumnComponent } from './my-addon-status-column.component';
describe('MyAddonStatusColumnComponent', () => {
let component: MyAddonStatusColumnComponent;
let fixture: ComponentFixture<MyAddonStatusColumnComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ MyAddonStatusColumnComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(MyAddonStatusColumnComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,134 @@
import { Component, Input, OnDestroy, OnInit } from "@angular/core";
import { MatProgressButtonOptions } from "mat-progress-buttons";
import { BehaviorSubject, Observable } from "rxjs";
import { AddonInstallState } from "app/models/wowup/addon-install-state";
import { AddonViewModel } from "app/business-objects/my-addon-list-item";
import { AddonService } from "app/services/addons/addon.service";
import { TranslateService } from "@ngx-translate/core";
@Component({
selector: "app-my-addon-status-column",
templateUrl: "./my-addon-status-column.component.html",
styleUrls: ["./my-addon-status-column.component.scss"],
})
export class MyAddonStatusColumnComponent implements OnInit, OnDestroy {
@Input() listItem: AddonViewModel;
private readonly _buttonOptionsSrc: BehaviorSubject<MatProgressButtonOptions>;
private installState: AddonInstallState = AddonInstallState.Unknown;
private installProgress: number = 0;
public readonly buttonOptions$: Observable<MatProgressButtonOptions>;
public get showStatusText() {
return this.listItem?.isUpToDate || this.listItem?.isIgnored;
}
public get buttonText() {
if (this.installState !== AddonInstallState.Unknown) {
return this.getInstallStateText(this.installState);
}
return this.getStatusText();
}
public get isButtonActive() {
return (
this.installState !== AddonInstallState.Unknown &&
this.installState !== AddonInstallState.Complete
);
}
public get isButtonDisabled() {
return (
this.listItem?.isUpToDate ||
this.installState === AddonInstallState.Complete
);
}
constructor(
private _addonService: AddonService,
private _translate: TranslateService
) {
this._buttonOptionsSrc = new BehaviorSubject<MatProgressButtonOptions>(
this.getButtonOptions()
);
this.buttonOptions$ = this._buttonOptionsSrc.asObservable();
}
ngOnInit(): void {
this._buttonOptionsSrc.next(this.getButtonOptions());
}
ngOnDestroy(): void {
this._buttonOptionsSrc.complete();
}
public getStatusText() {
if (this.listItem?.needsInstall) {
return this._translate.instant(
"PAGES.MY_ADDONS.TABLE.ADDON_INSTALL_BUTTON"
);
}
if (this.listItem?.needsUpdate) {
return this._translate.instant(
"PAGES.MY_ADDONS.TABLE.ADDON_UPDATE_BUTTON"
);
}
return this.listItem?.statusText;
}
public onInstallUpdateClick() {
this._addonService.installAddon(
this.listItem.addon.id,
this.onInstallUpdate
);
}
private onInstallUpdate = (
installState: AddonInstallState,
progress: number
) => {
this.installState = installState;
this.installProgress = progress;
console.log(this.getButtonOptions());
this._buttonOptionsSrc.next(this.getButtonOptions());
};
private getInstallStateText(installState: AddonInstallState) {
switch (installState) {
case AddonInstallState.BackingUp:
return this._translate.instant("COMMON.ADDON_STATUS.BACKINGUP");
case AddonInstallState.Complete:
return this._translate.instant("COMMON.ADDON_STATE.UPTODATE");
case AddonInstallState.Downloading:
return this._translate.instant("COMMON.ADDON_STATUS.DOWNLOADING");
case AddonInstallState.Installing:
return this._translate.instant("COMMON.ADDON_STATUS.INSTALLING");
case AddonInstallState.Pending:
return this._translate.instant("COMMON.ADDON_STATUS.PENDING");
default:
return "";
}
}
private getButtonOptions(): MatProgressButtonOptions {
return {
active: this.isButtonActive,
disabled: this.isButtonDisabled,
value: this.installProgress,
text: this.buttonText,
mode: "determinate",
buttonColor: "primary",
barColor: "accent",
customClass: "install-button",
raised: false,
flat: true,
stroked: false,
fullWidth: false,
};
}
}

View File

@@ -3,10 +3,26 @@
<div class="addon-logo-container" [style.backgroundImage]="'url(' + listItem.addon.thumbnailUrl + ')'">
</div>
<div *ngIf="listItem.isBetaChannel || listItem.isAlphaChannel" class="channel"
[ngClass]="{'beta': listItem.isBetaChannel, 'alpha': listItem.isAlphaChannel }">{{listItem.isAlphaChannel ? 'Alpha': 'Beta'}}</div>
[ngClass]="{'beta': listItem.isBetaChannel, 'alpha': listItem.isAlphaChannel }">
{{listItem.isAlphaChannel ? 'Alpha': 'Beta'}}</div>
</div>
<div>
<a appExternalLink class="addon-title mat-subheading-2" [href]="listItem.addon.externalUrl">{{listItem.addon.name}}</a>
<div class="addon-version">{{listItem.addon.installedVersion}}</div>
<a class="addon-title mat-subheading-2" (click)="viewDetails()"
[ngClass]="{ 'ignored': listItem.isIgnored }">{{listItem.addon.name}}</a>
<div class="addon-funding">
<a *ngIf="listItem.addon.patreonFundingLink" appExternalLink [href]="listItem.addon.patreonFundingLink"
matTooltip="Support the author on Patreon">
<img class="funding-icon" src="assets/images/patreon_logo_small.png" />
</a>
<a *ngIf="listItem.addon.githubFundingLink" appExternalLink [href]="listItem.addon.githubFundingLink"
matTooltip="Support the author on GitHub">
<img class="funding-icon" src="assets/images/github_logo_small.png" />
</a>
<a *ngIf="listItem.addon.customFundingLink" appExternalLink [href]="listItem.addon.customFundingLink"
matTooltip="Support this author">
<img class="funding-icon" src="assets/images/custom_funding_logo_small.png" />
</a>
</div>
<div class="addon-version" [ngClass]="{ 'ignored': listItem.isIgnored }">{{listItem.addon.installedVersion}}</div>
</div>
</div>

View File

@@ -27,7 +27,7 @@
.channel {
background: $dark-4;
text-align: center;
font-weight: bold;
font-weight: 400;
font-size: 0.8em;
&.beta {
@@ -51,9 +51,28 @@
text-decoration: underline;
color: $white-2;
}
&.ignored {
color: $white-4;
}
}
.addon-version {
color: $white-2;
}
.addon-funding {
a {
margin-right: 1em;
color: $white-1;
}
.funding-icon {
width: 15px;
height: 15px;
&:hover {
cursor: pointer;
}
}
}
}

View File

@@ -1,19 +1,22 @@
import { Component, Input, OnInit } from '@angular/core';
import { Addon } from 'app/entities/addon';
import { MyAddonsListItem } from 'app/business-objects/my-addons-list-item';
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { Addon } from "app/entities/addon";
import { AddonViewModel } from "app/business-objects/my-addon-list-item";
@Component({
selector: 'app-my-addons-addon-cell',
templateUrl: './my-addons-addon-cell.component.html',
styleUrls: ['./my-addons-addon-cell.component.scss']
selector: "app-my-addons-addon-cell",
templateUrl: "./my-addons-addon-cell.component.html",
styleUrls: ["./my-addons-addon-cell.component.scss"],
})
export class MyAddonsAddonCellComponent implements OnInit {
@Input("addon") listItem: AddonViewModel;
@Input('addon') listItem: MyAddonsListItem;
@Output() onViewDetails: EventEmitter<AddonViewModel> = new EventEmitter();
constructor() { }
constructor() {}
ngOnInit(): void {
ngOnInit(): void {}
viewDetails() {
this.onViewDetails.emit(this.listItem);
}
}

View File

@@ -3,6 +3,6 @@
</div>
<div>
<a class="addon-title mat-subheading-2" (click)="viewDetails()">{{addon.name}}</a>
<div>{{addon.downloadCount | downloadCount}} downloads</div>
<div class="addon-version">{{addonVersion}}</div>
</div>
</div>

View File

@@ -33,4 +33,8 @@
color: $white-2;
}
}
.addon-version {
color: $white-2;
}
}

View File

@@ -1,21 +1,50 @@
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { PotentialAddon } from "app/models/wowup/potential-addon";
import {
Component,
EventEmitter,
Input,
OnChanges,
OnInit,
Output,
SimpleChanges,
} from "@angular/core";
import { GetAddonListItem } from "app/business-objects/get-addon-list-item";
import { AddonChannelType } from "app/models/wowup/addon-channel-type";
import { AddonSearchResult } from "app/models/wowup/addon-search-result";
import { SessionService } from "app/services/session/session.service";
import { WowUpService } from "app/services/wowup/wowup.service";
@Component({
selector: "app-potential-addon-table-column",
templateUrl: "./potential-addon-table-column.component.html",
styleUrls: ["./potential-addon-table-column.component.scss"],
})
export class PotentialAddonTableColumnComponent implements OnInit {
@Input("addon") addon: PotentialAddon;
export class PotentialAddonTableColumnComponent implements OnInit, OnChanges {
@Input("addon") addon: GetAddonListItem;
@Input() channel: AddonChannelType;
@Output() onViewDetails: EventEmitter<PotentialAddon> = new EventEmitter();
@Output() onViewDetails: EventEmitter<AddonSearchResult> = new EventEmitter();
constructor() {}
public addonVersion: string = "";
ngOnInit(): void { }
constructor(
private _sessionService: SessionService,
private _wowupService: WowUpService
) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes.channel) {
const latestFile = this.addon.getLatestFile(this.channel);
this.addonVersion = latestFile?.version;
}
}
ngOnInit(): void {
// this._defaultChannel = this._wowupService.getDefaultAddonChannel(
// this._sessionService.selectedClientType
// );
}
viewDetails() {
this.onViewDetails.emit(this.addon);
this.onViewDetails.emit(this.addon.searchResult);
}
}

View File

@@ -1,13 +1,13 @@
<div class="titlebar bg-purple-1 text-light" [ngClass]="{'titlebar-mac': isMac, 'titlebar-windows': isWindows }">
<div class="titlebar-drag-region"></div>
<div class="titlebar-drag-region" (dblclick)="onDblClick()"></div>
<div class="window-logo-container" *ngIf="electronService.isWin">
<img src="assets/wowup_logo_512np.png" />
</div>
<div class="title-container">
<div>WowUp.io</div>
</div>
<div *ngIf="isMac" class="window-control-container">
<mat-icon class="debug-button" (click)="onClickDebug()">bug_report</mat-icon>
<div *ngIf="isMac" class="window-control-container pointer">
<mat-icon class="debug-button pointer" (click)="onClickDebug()">bug_report</mat-icon>
</div>
<div *ngIf="isWindows" class="window-control-container">
<mat-icon (click)="onClickDebug()">bug_report</mat-icon>

View File

@@ -27,6 +27,7 @@ export class TitlebarComponent implements OnInit, OnDestroy {
) {
const windowMaximizedSubscription = this.electronService.windowMaximized$.subscribe(
(maximized) => {
console.log('subscription maximized:', maximized);
this._ngZone.run(() => (this.isMaximized = maximized));
}
);
@@ -51,4 +52,21 @@ export class TitlebarComponent implements OnInit, OnDestroy {
onClickDebug() {
this.electronService.remote.getCurrentWebContents().openDevTools();
}
onDblClick() {
const win = this.electronService.remote.getCurrentWindow();
if (this.isMac) {
const action = this.electronService.remote.systemPreferences.getUserDefault('AppleActionOnDoubleClick', 'string');
if (action === 'Maximize') {
if (win.isMaximized()) {
win.unmaximize();
} else {
win.maximize();
}
} else if (action === 'Minimize') {
win.minimize();
}
}
}
}

View File

@@ -4,9 +4,11 @@
<mat-form-field class="grow folder-input">
<mat-label>{{clientTypeName}} Path</mat-label>
<input matInput disabled [value]="clientLocation">
<mat-hint>The folder that contains the {{clientTypeName | lowercase}} client folder "{{clientFolderName}}"</mat-hint>
<mat-hint>The folder that contains the {{clientTypeName | lowercase}} client folder "{{clientFolderName}}"
</mat-hint>
</mat-form-field>
<button mat-flat-button color="primary" class="select-button" (click)="onSelectClientPath()">Select</button>
<button mat-flat-button color="primary" class="select-button" (click)="onSelectClientPath()" appUserActionTracker
category="Options" [action]="clientTypeName + 'SelectPath'">Select</button>
</div>
<div class="row">
<div class="grow">
@@ -14,7 +16,9 @@
</div>
<mat-form-field class="light-select">
<mat-label>{{'PAGES.OPTIONS.WOW.DEFAULT_ADDON_CHANNEL_SELECT_LABEL' | translate}}</mat-label>
<mat-select [(value)]="selectedAddonChannelType" (selectionChange)="onDefaultAddonChannelChange($event)">
<mat-select [(value)]="selectedAddonChannelType" (selectionChange)="onDefaultAddonChannelChange($event)"
appUserActionTracker category="Options" [action]="clientTypeName + 'DefaultChannel'"
[label]="selectedAddonChannelType">
<mat-option *ngFor="let channel of addonChannelInfos" [value]="channel.type">{{channel.name}}</mat-option>
</mat-select>
</mat-form-field>
@@ -24,6 +28,7 @@
<p>{{'PAGES.OPTIONS.WOW.AUTO_UPDATE_LABEL' | translate}}</p>
<small class="hint">{{'PAGES.OPTIONS.WOW.AUTO_UPDATE_DESCRIPTION' | translate}}</small>
</div>
<mat-slide-toggle [(checked)]="clientAutoUpdate" (change)="onDefaultAutoUpdateChange($event)">
<mat-slide-toggle [(checked)]="clientAutoUpdate" (change)="onDefaultAutoUpdateChange($event)" appUserActionTracker
category="Options" [action]="clientTypeName + 'DefaultAutoUpdate'" [label]="clientAutoUpdate">
</mat-slide-toggle>
</div>
</div>

View File

@@ -1,8 +1,15 @@
import { NgModule } from "@angular/core";
import { ExternalLinkDirective } from "./directives/external-link.directive";
import { UserActionTrackerDirective } from "./directives/user-action-tracker.directive";
@NgModule({
declarations: [ExternalLinkDirective],
exports: [ExternalLinkDirective],
declarations: [
ExternalLinkDirective,
UserActionTrackerDirective
],
exports: [
ExternalLinkDirective,
UserActionTrackerDirective
],
})
export class DirectiveModule {}
export class DirectiveModule { }

View File

@@ -0,0 +1,8 @@
import { UserActionTrackerDirective } from './user-action-tracker.directive';
describe('UserActionTrackerDirective', () => {
it('should create an instance', () => {
const directive = new UserActionTrackerDirective();
expect(directive).toBeTruthy();
});
});

View File

@@ -0,0 +1,20 @@
import { Directive, HostListener, Input } from '@angular/core';
import { AnalyticsService } from '../services/analytics/analytics.service';
@Directive({
selector: '[appUserActionTracker]'
})
export class UserActionTrackerDirective {
@Input() appUserActionTracker: string;
@Input() category: string;
@Input() action: string;
@Input() label: string;
@HostListener('click', ['$event']) onClick($event) {
this._analyticsService.trackUserAction(this.category, this.action, this.label);
}
constructor(private _analyticsService: AnalyticsService) { }
}

View File

@@ -11,6 +11,7 @@ export interface Addon {
installedAt?: Date;
externalId?: string;
providerName?: string;
providerSource?: string;
externalUrl?: string;
thumbnailUrl?: string;
gameVersion?: string;
@@ -21,4 +22,10 @@ export interface Addon {
clientType: WowClientType;
channelType: AddonChannelType;
updatedAt?: Date;
patreonFundingLink?: string;
githubFundingLink?: string;
customFundingLink?: string;
downloadCount?: number;
summary?: string;
screenshotUrls?: string[];
}

View File

@@ -0,0 +1,36 @@
import { ErrorHandler } from "@angular/core";
import { AnalyticsService } from "app/services/analytics/analytics.service";
import { AppConfig } from "environments/environment";
import * as Rollbar from "rollbar";
export class ErrorHandlerIntercepter implements ErrorHandler {
private readonly rollbarConfig = {
accessToken: AppConfig.rollbarAccessKey,
captureUncaught: true,
captureUnhandledRejections: true,
};
private _rollbar: Rollbar;
private get rollbar() {
if (!this._analytics.telemetryEnabled) {
return undefined;
}
if (!this._rollbar) {
this._rollbar = new Rollbar(this.rollbarConfig);
}
return this._rollbar;
}
constructor(private _analytics: AnalyticsService) {
}
// ErrorHandler
handleError(error: any): void {
console.error("Caught error", error);
this.rollbar?.error(error.originalError || error);
}
}

View File

@@ -1,4 +1,4 @@
import { CurseSearchResult } from "./curse-search-result";
import { CurseSearchResult } from "../../../common/curse/curse-search-result";
export interface CurseGetFeaturedResponse {
Featured: CurseSearchResult[];

View File

@@ -0,0 +1,5 @@
import { WowUpAddonRepresentation } from "./wowup-addon.representation";
export interface GetAddonsByFingerprintResponse {
exactMatches: WowUpAddonRepresentation[];
}

View File

@@ -0,0 +1,4 @@
export enum WowGameType {
Retail = "retail",
Classic = "classic",
}

View File

@@ -0,0 +1,10 @@
export interface WowUpAddonReleaseFolderRepresentation {
id: number;
folder_name: string;
fingerprint: string;
game_version: string;
addon_title: string;
addon_authors: string;
load_on_demand: boolean;
version: string;
}

View File

@@ -0,0 +1,17 @@
import { WowUpAddonReleaseFolderRepresentation } from "./wowup-addon-release-folder.representation";
import { WowGameType } from "./wow-game-type";
export interface WowUpAddonReleaseRepresentation {
id: number;
url: string;
name: string;
tagName: string;
external_id: string;
prerelease: boolean;
body: string;
game_version: string;
download_url: string;
published_at: Date;
addonFolders?: WowUpAddonReleaseFolderRepresentation[];
game_type: WowGameType;
}

View File

@@ -0,0 +1,20 @@
import { WowUpAddonReleaseRepresentation } from "./wowup-addon-release.representation";
export interface WowUpAddonRepresentation {
id: number;
repository: string;
repository_name: string;
external_id: string;
source: string;
patreon_funding_link?: string;
github_funding_link?: string;
custom_funding_link?: string;
owner_name?: string;
owner_image_url?: string;
image_url?: string;
description?: string;
homepage?: string;
current_release?: WowUpAddonReleaseRepresentation;
matched_release?: WowUpAddonReleaseRepresentation;
releases?: WowUpAddonReleaseRepresentation[];
}

View File

@@ -1,7 +1,8 @@
export enum AddonInstallState {
Pending,
Downloading,
BackingUp,
Installing,
Complete
}
Pending,
Downloading,
BackingUp,
Installing,
Complete,
Unknown,
}

View File

@@ -1,11 +1,15 @@
import { AddonSearchResultFile } from "./addon-search-result-file";
export interface AddonSearchResult {
name: string;
author: string;
thumbnailUrl: string;
downloadCount?: number;
externalId: string;
externalUrl: string;
files?: AddonSearchResultFile[];
name: string;
providerName: string;
files: AddonSearchResultFile[];
}
screenshotUrl?: string;
screenshotUrls?: string[];
summary?: string;
thumbnailUrl: string;
}

View File

@@ -0,0 +1,6 @@
import { WowUpAddonRepresentation } from "../wowup-api/wowup-addon.representation";
import { WowUpScanResult } from "../../../common/wowup/wowup-scan-result";
export interface AppWowUpScanResult extends WowUpScanResult{
exactMatch?: WowUpAddonRepresentation;
}

View File

@@ -1,12 +0,0 @@
export interface PotentialAddon {
name: string;
providerName: string;
thumbnailUrl: string;
screenshotUrl?: string;
externalId: string;
externalUrl: string;
author: string;
downloadCount: number;
summary?: string;
screenshotUrls?: string[];
}

View File

@@ -6,7 +6,7 @@
<h2>{{'PAGES.ABOUT.TITLE' | translate}}</h2>
<div class="version">v{{version}}</div>
<div class="link-container">
<a appExternalLink href="https://wowup.io">
<a appExternalLink href="https://wowup.io" appUserActionTracker category="About" action="ViewWebsite">
{{'PAGES.ABOUT.WEBSITE_LINK_LABEL' | translate}}
</a>
</div>
@@ -17,10 +17,10 @@
</h2>
<ul class="change-log-list">
<li class="changelog" *ngFor="let cl of changeLogs">
<div class="version">{{cl.Version}}</div>
<div class="version mat-subheading-2">{{cl.Version}}</div>
<pre class="description">{{cl.Description}}</pre>
</li>
</ul>
</div>
</div>
</div>
</div>

View File

@@ -52,8 +52,8 @@
background-color: $dark-4;
.version {
font-size: 1em;
font-weight: bold;
// font-size: 1em;
// font-weight: bold;
}
.description {

View File

@@ -23,10 +23,12 @@
</mat-form-field>
</div>
<div class="button-container">
<button mat-flat-button color="primary" (click)="onRefresh()">
<button mat-flat-button color="primary" (click)="onRefresh()" appUserActionTracker category="GetAddons"
action="Refresh">
{{'PAGES.GET_ADDONS.REFRESH_BUTTON' | translate}}
</button>
<button mat-flat-button color="primary" (click)="onInstallFromUrl()">
<button mat-flat-button color="primary" (click)="onInstallFromUrl()" appUserActionTracker category="GetAddons"
action="InstallFromUrl">
{{'PAGES.GET_ADDONS.INSTALL_FROM_URL_BUTTON' | translate}}
</button>
</div>
@@ -36,18 +38,28 @@
<app-progress-spinner *ngIf="isBusy === true"></app-progress-spinner>
<div class="table-container flex-grow-1" [hidden]="isBusy === true">
<table mat-table matSort [dataSource]="dataSource" class="mat-elevation-z8">
<table mat-table matSort [dataSource]="dataSource" matSortActive="downloadCount" matSortDirection="desc"
class="mat-elevation-z8">
<ng-container matColumnDef="name">
<th mat-header-cell mat-sort-header *matHeaderCellDef>
{{'PAGES.GET_ADDONS.TABLE.ADDON_COLUMN_HEADER' | translate}}
</th>
<td mat-cell *matCellDef="let element">
<app-potential-addon-table-column [addon]="element" (onViewDetails)="openDetailDialog($event)">
<app-potential-addon-table-column [addon]="element" [channel]="defaultAddonChannel" (onViewDetails)="openDetailDialog($event)">
</app-potential-addon-table-column>
</td>
</ng-container>
<ng-container matColumnDef="provider">
<ng-container matColumnDef="downloadCount">
<th mat-header-cell mat-sort-header *matHeaderCellDef>
{{'PAGES.GET_ADDONS.TABLE.DOWNLOAD_COUNT_COLUMN_HEADER' | translate}}
</th>
<td mat-cell *matCellDef="let element">
{{element.downloadCount | downloadCount}}
</td>
</ng-container>
<ng-container matColumnDef="providerName">
<th mat-header-cell mat-sort-header *matHeaderCellDef class="provider-column">
{{'PAGES.GET_ADDONS.TABLE.PROVIDER_COLUMN_HEADER' | translate}}
</th>
@@ -60,7 +72,7 @@
<th mat-header-cell mat-sort-header *matHeaderCellDef class="author-column">
{{'PAGES.GET_ADDONS.TABLE.AUTHOR_COLUMN_HEADER' | translate}}
</th>
<td mat-cell *matCellDef="let element">
<td mat-cell *matCellDef="let element" class="cell-padding">
{{element.author}}
</td>
</ng-container>
@@ -70,7 +82,7 @@
{{'PAGES.GET_ADDONS.TABLE.STATUS_COLUMN_HEADER' | translate}}
</th>
<td mat-cell *matCellDef="let element">
<app-addon-install-button [addon]="element"> </app-addon-install-button>
<app-get-addon-status-column [listItem]="element" ></app-get-addon-status-column>
</td>
</ng-container>
@@ -78,4 +90,4 @@
<tr mat-row *matRowDef="let row; columns: displayedColumns;let i = index" (dblclick)="openDetailDialog(row)"></tr>
</table>
</div>
</div>
</div>

View File

@@ -64,15 +64,20 @@
}
.addon-title {
font-weight: bold;
font-weight: 400;
font-size: 1.1em;
word-break: break-all;
white-space: pre-wrap;
}
.cell-padding {
padding-right: 1em;
}
}
.author-column {
width: 100px;
padding-right: 1em;
}
.provider-column {
min-width: 60px;

View File

@@ -4,18 +4,20 @@ import { AddonDetailComponent } from "app/components/addon-detail/addon-detail.c
import { InstallFromUrlDialogComponent } from "app/components/install-from-url-dialog/install-from-url-dialog.component";
import { WowClientType } from "app/models/warcraft/wow-client-type";
import { AddonDetailModel } from "app/models/wowup/addon-detail.model";
import { AddonUpdateEvent } from "app/models/wowup/addon-update-event";
import { ColumnState } from "app/models/wowup/column-state";
import { PotentialAddon } from "app/models/wowup/potential-addon";
import { ElectronService } from "app/services";
import { AddonService } from "app/services/addons/addon.service";
import { SessionService } from "app/services/session/session.service";
import { WarcraftService } from "app/services/warcraft/warcraft.service";
import { BehaviorSubject, Subject, Subscription } from "rxjs";
import { map } from "rxjs/operators";
import { MatTableDataSource } from '@angular/material/table';
import { MatSort } from '@angular/material/sort';
import * as _ from 'lodash';
import { filter, map } from "rxjs/operators";
import { MatTableDataSource } from "@angular/material/table";
import { MatSort } from "@angular/material/sort";
import * as _ from "lodash";
import { GetAddonListItem } from "app/business-objects/get-addon-list-item";
import { AddonSearchResult } from "app/models/wowup/addon-search-result";
import { AddonChannelType } from "app/models/wowup/addon-channel-type";
import { WowUpService } from "app/services/wowup/wowup.service";
@Component({
selector: "app-get-addons",
@@ -23,21 +25,25 @@ import * as _ from 'lodash';
styleUrls: ["./get-addons.component.scss"],
})
export class GetAddonsComponent implements OnInit, OnDestroy {
@Input('tabIndex') tabIndex: number;
@Input("tabIndex") tabIndex: number;
@ViewChild(MatSort) sort: MatSort;
private readonly _displayAddonsSrc = new BehaviorSubject<PotentialAddon[]>([]);
private readonly _displayAddonsSrc = new BehaviorSubject<GetAddonListItem[]>(
[]
);
private readonly _destroyed$ = new Subject<void>();
private subscriptions: Subscription[] = [];
private isSelectedTab: boolean = false;
private channelTypeKey: string = "";
public dataSource = new MatTableDataSource<PotentialAddon>([]);
public dataSource = new MatTableDataSource<GetAddonListItem>([]);
columns: ColumnState[] = [
{ name: "name", display: "Addon", visible: true },
{ name: "downloadCount", display: "Downloads", visible: true },
{ name: "author", display: "Author", visible: true },
{ name: "provider", display: "Provider", visible: true },
{ name: "providerName", display: "Provider", visible: true },
{ name: "status", display: "Status", visible: true },
];
@@ -45,6 +51,18 @@ export class GetAddonsComponent implements OnInit, OnDestroy {
return this.columns.filter((col) => col.visible).map((col) => col.name);
}
public get defaultAddonChannelKey() {
return this._wowUpService.getClientDefaultAddonChannelKey(
this._sessionService.selectedClientType
);
}
public get defaultAddonChannel() {
return this._wowUpService.getDefaultAddonChannel(
this._sessionService.selectedClientType
);
}
public query = "";
public isBusy = false;
public selectedClient = WowClientType.None;
@@ -53,35 +71,55 @@ export class GetAddonsComponent implements OnInit, OnDestroy {
private _addonService: AddonService,
private _sessionService: SessionService,
private _dialog: MatDialog,
private _wowUpService: WowUpService,
public electronService: ElectronService,
public warcraftService: WarcraftService
) { }
) {
_sessionService.selectedHomeTab$.subscribe((tabIndex) => {
this.isSelectedTab = tabIndex === this.tabIndex;
if (this.isSelectedTab) {
this.setPageContextText();
}
});
}
ngOnInit(): void {
const selectedClientSubscription = this._sessionService.selectedClientType$.pipe(
map((clientType) => {
this.selectedClient = clientType;
this.loadPopularAddons(this.selectedClient);
})
).subscribe();
const selectedClientSubscription = this._sessionService.selectedClientType$
.pipe(
map((clientType) => {
this.selectedClient = clientType;
this.loadPopularAddons(this.selectedClient);
})
)
.subscribe();
const addonRemovedSubscription = this._addonService.addonRemoved$.pipe(
map((event: string) => {
this.onRefresh();
})
).subscribe();
const addonRemovedSubscription = this._addonService.addonRemoved$
.pipe(
map((event: string) => {
this.onRefresh();
})
)
.subscribe();
const displayAddonSubscription = this._displayAddonsSrc
.subscribe((items: PotentialAddon[]) => {
const displayAddonSubscription = this._displayAddonsSrc.subscribe(
(items: GetAddonListItem[]) => {
this.dataSource.data = items;
this.dataSource.sortingDataAccessor = _.get;
this.dataSource.sort = this.sort;
}
);
const channelTypeSubscription = this._wowUpService.preferenceChange$
.pipe(filter((change) => change.key === this.defaultAddonChannelKey))
.subscribe((change) => {
this.onSearch();
});
this.subscriptions = [
selectedClientSubscription,
addonRemovedSubscription,
displayAddonSubscription
displayAddonSubscription,
channelTypeSubscription,
];
}
@@ -112,6 +150,7 @@ export class GetAddonsComponent implements OnInit, OnDestroy {
async onSearch() {
if (!this.query) {
this.loadPopularAddons(this.selectedClient);
this.setPageContextText();
return;
}
@@ -122,15 +161,16 @@ export class GetAddonsComponent implements OnInit, OnDestroy {
this.selectedClient
);
searchResults = this.filterInstalledAddons(searchResults);
this.formatAddons(searchResults);
this._displayAddonsSrc.next(searchResults);
this._displayAddonsSrc.next(
this.formatAddons(this.filterInstalledAddons(searchResults))
);
this.isBusy = false;
this.setPageContextText();
}
openDetailDialog(addon: PotentialAddon) {
openDetailDialog(addon: AddonSearchResult) {
const dialogRef = this._dialog.open(AddonDetailComponent, {
data: new AddonDetailModel(addon),
data: addon,
});
dialogRef.afterClosed().subscribe();
@@ -145,9 +185,8 @@ export class GetAddonsComponent implements OnInit, OnDestroy {
this._addonService.getFeaturedAddons(clientType).subscribe({
next: (addons) => {
addons = this.filterInstalledAddons(addons);
this.formatAddons(addons);
this._displayAddonsSrc.next(addons);
const listItems = this.formatAddons(this.filterInstalledAddons(addons));
this._displayAddonsSrc.next(listItems);
this.isBusy = false;
},
error: (err) => {
@@ -156,7 +195,7 @@ export class GetAddonsComponent implements OnInit, OnDestroy {
});
}
private filterInstalledAddons(addons: PotentialAddon[]) {
private filterInstalledAddons(addons: AddonSearchResult[]) {
return addons.filter(
(addon) =>
!this._addonService.isInstalled(
@@ -166,11 +205,21 @@ export class GetAddonsComponent implements OnInit, OnDestroy {
);
}
private formatAddons(addons: PotentialAddon[]) {
private formatAddons(addons: AddonSearchResult[]): GetAddonListItem[] {
addons.forEach((addon) => {
if (!addon.thumbnailUrl) {
addon.thumbnailUrl = "assets/wowup_logo_512np.png";
}
});
return addons.map((addon) => new GetAddonListItem(addon));
}
private setPageContextText() {
const contextStr = this._displayAddonsSrc.value?.length
? `${this._displayAddonsSrc.value.length} results`
: "";
this._sessionService.setContextText(this.tabIndex, contextStr);
}
}

View File

@@ -1,6 +1,6 @@
<div class="page-container">
<div class="tabs">
<mat-tab-group mat-align-tabs="center" [backgroundColor]="'primary'" [disablePagination]="true"
<mat-tab-group mat-align-tabs="center" animationDuration="0ms" [backgroundColor]="'primary'" [disablePagination]="true"
[(selectedIndex)]="selectedIndex" (selectedIndexChange)="onSelectedIndexChange($event)">
<mat-tab [disabled]="hasWowClient !== true" [label]="'PAGES.HOME.MY_ADDONS_TAB_TITLE' | translate">
<app-my-addons [tabIndex]="0"></app-my-addons>

View File

@@ -24,6 +24,8 @@ import { AddonDetailComponent } from "app/components/addon-detail/addon-detail.c
import { AddonProviderBadgeComponent } from "app/components/addon-provider-badge/addon-provider-badge.component";
import { MatProgressButtonsModule } from "mat-progress-buttons";
import { AddonInstallButtonComponent } from "app/components/addon-install-button/addon-install-button.component";
import { GetAddonStatusColumnComponent } from "app/components/get-addon-status-column/get-addon-status-column.component";
import { MyAddonStatusColumnComponent } from "app/components/my-addon-status-column/my-addon-status-column.component";
@NgModule({
declarations: [
@@ -44,6 +46,8 @@ import { AddonInstallButtonComponent } from "app/components/addon-install-button
AddonDetailComponent,
AddonProviderBadgeComponent,
AddonInstallButtonComponent,
GetAddonStatusColumnComponent,
MyAddonStatusColumnComponent,
],
imports: [
CommonModule,

View File

@@ -4,7 +4,7 @@
<div class="select-container ">
<mat-form-field>
<mat-label>{{'PAGES.MY_ADDONS.CLIENT_TYPE_SELECT_LABEL' | translate}}</mat-label>
<mat-select class="select" [(value)]="selectedClient" (selectionChange)="onClientChange()"
<mat-select class="select pointer" [(value)]="selectedClient" (selectionChange)="onClientChange()"
[disabled]="enableControls === false">
<mat-option [value]="clientType" *ngFor="let clientType of warcraftService.installedClientTypes$ | async">
{{warcraftService.getClientDisplayName(clientType)}}
@@ -25,15 +25,20 @@
</div>
<div class="button-container">
<button mat-flat-button color="primary" [matTooltip]="'PAGES.MY_ADDONS.UPDATE_ALL_BUTTON_TOOLTIP' | translate"
[disabled]="enableControls === false" (click)="onUpdateAll()" (contextmenu)="onUpdateAllContext($event)">
[disabled]="enableControls === false" (click)="onUpdateAll()" (contextmenu)="onUpdateAllContext($event)"
appUserActionTracker category="MyAddons" action="UpdateAll">
{{'PAGES.MY_ADDONS.UPDATE_ALL_BUTTON' | translate}}
</button>
<button mat-flat-button color="primary" [matTooltip]="'PAGES.MY_ADDONS.CHECK_UPDATES_BUTTON_TOOLTIP' | translate"
[disabled]="enableControls === false" (click)="onRefresh()">
<button mat-flat-button color="primary"
[matTooltip]="'PAGES.MY_ADDONS.CHECK_UPDATES_BUTTON_TOOLTIP' | translate"
[disabled]="enableControls === false" (click)="onRefresh()" appUserActionTracker category="MyAddons"
action="CheckUpdates">
{{'PAGES.MY_ADDONS.CHECK_UPDATES_BUTTON' | translate}}
</button>
<button mat-flat-button color="primary" [matTooltip]="'PAGES.MY_ADDONS.RESCAN_FOLDERS_BUTTON_TOOLTIP' | translate"
[disabled]="enableControls === false" (click)="onReScan()">
<button mat-flat-button color="primary"
[matTooltip]="'PAGES.MY_ADDONS.RESCAN_FOLDERS_BUTTON_TOOLTIP' | translate"
[disabled]="enableControls === false" (click)="onReScan()" appUserActionTracker category="MyAddons"
action="ReScanFolders">
{{'PAGES.MY_ADDONS.RESCAN_FOLDERS_BUTTON' | translate}}
</button>
</div>
@@ -46,38 +51,22 @@
</div>
<div class="table-container flex-grow-1" [hidden]="isBusy === true">
<table mat-table matSort [matSortActive]="activeSortColumn" matSortDirection="asc" [dataSource]="dataSource"
<table mat-table matSort matSortActive="displayState" matSortDirection="asc" [dataSource]="dataSource"
class="mat-elevation-z8">
<ng-container matColumnDef="addon.name">
<th mat-header-cell mat-sort-header *matHeaderCellDef>{{'PAGES.MY_ADDONS.TABLE.ADDON_COLUMN_HEADER' | translate}}</th>
<td mat-cell *matCellDef="let element">
<app-my-addons-addon-cell [addon]="element"></app-my-addons-addon-cell>
<th mat-header-cell mat-sort-header *matHeaderCellDef>
{{'PAGES.MY_ADDONS.TABLE.ADDON_COLUMN_HEADER' | translate}}</th>
<td mat-cell *matCellDef="let element" class="cell-padding">
<app-my-addons-addon-cell [addon]="element" (onViewDetails)="openDetailDialog($event)">
</app-my-addons-addon-cell>
</td>
</ng-container>
<ng-container matColumnDef="displayState">
<th mat-header-cell mat-sort-header *matHeaderCellDef>{{'PAGES.MY_ADDONS.TABLE.STATUS_COLUMN_HEADER' | translate}}</th>
<td mat-cell *matCellDef="let element">
<div class="status-column">
<button *ngIf="element.needsInstall === true" mat-flat-button color="primary" (click)="onInstall()">
{{'PAGES.MY_ADDONS.TABLE.ADDON_INSTALL_BUTTON' | translate}}
</button>
<button *ngIf="element.needsUpdate === true" mat-flat-button color="primary"
(click)="onUpdateAddon(element)">
{{'PAGES.MY_ADDONS.TABLE.ADDON_UPDATE_BUTTON' | translate}}
</button>
<div *ngIf="element.isUpToDate === true || element.isIgnored === true" class="status-text">
{{element.statusText}}</div>
<mat-icon *ngIf="element.addon.autoUpdateEnabled" class="auto-update-icon"
[matTooltip]="'PAGES.MY_ADDONS.TABLE.AUTO_UPDATE_ICON_TOOLTIP' | translate">
update
</mat-icon>
<div *ngIf="element.isInstalling === true" class="progress-container">
<p class="progress-text">{{element.statusText}}</p>
<mat-progress-bar class="addon-progress" mode="determinate" [value]="element.installProgress">
</mat-progress-bar>
</div>
</div>
<th mat-header-cell mat-sort-header *matHeaderCellDef>
{{'PAGES.MY_ADDONS.TABLE.STATUS_COLUMN_HEADER' | translate}}</th>
<td mat-cell *matCellDef="let element" class="status-column cell-padding">
<app-my-addon-status-column [listItem]="element"></app-my-addon-status-column>
</td>
</ng-container>
@@ -85,7 +74,7 @@
<th mat-header-cell mat-sort-header *matHeaderCellDef>
{{'PAGES.MY_ADDONS.TABLE.LATEST_VERSION_COLUMN_HEADER' | translate}}
</th>
<td mat-cell *matCellDef="let element">
<td mat-cell *matCellDef="let element" class="cell-padding">
{{element.addon.latestVersion}}
</td>
</ng-container>
@@ -103,8 +92,15 @@
<th mat-header-cell mat-sort-header *matHeaderCellDef class="provider-column">
{{'PAGES.MY_ADDONS.TABLE.PROVIDER_COLUMN_HEADER' | translate}}
</th>
<td mat-cell *matCellDef="let element">
{{element.addon.providerName}}
<td mat-cell *matCellDef="let element" class="cell-padding">
<div *ngIf="element.addon.providerName !== 'WowUp'">
{{element.addon.providerName}}
</div>
<div *ngIf="element.addon.providerName === 'WowUp'" class="addon-provider">
<div class="addon-provider-name">{{element.addon.providerSource}}</div>
<img class="provider-logo" [matTooltip]="'Sourced from ' + element.addon.providerName"
src="assets/icons/favicon.256x256.png">
</div>
</td>
</ng-container>
@@ -112,7 +108,7 @@
<th mat-header-cell mat-sort-header *matHeaderCellDef class="author-column">
{{'PAGES.MY_ADDONS.TABLE.AUTHOR_COLUMN_HEADER' | translate}}
</th>
<td mat-cell *matCellDef="let element">
<td mat-cell *matCellDef="let element" class="author-column">
{{element.addon.author}}
</td>
</ng-container>
@@ -120,9 +116,10 @@
<tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"
(contextmenu)="onHeaderContext($event); $event.preventDefault();"></tr>
<tr mat-row *matRowDef="let row; let i = index; columns: displayedColumns;"
<tr mat-row *matRowDef="let row; let i = index; columns: displayedColumns;" tabindex="0"
[ngClass]="{'selected-row': row.selected}" (click)="onRowClicked($event, row, i)"
(contextmenu)="onCellContext($event, row)"></tr>
(dblclick)="openDetailDialog(row)" (contextmenu)="onCellContext($event, row)"
(keydown.control.a)="selectAllRows($event)" (keydown.meta.a)="selectAllRows($event)"></tr>
</table>
</div>
@@ -142,26 +139,80 @@
</div>
<mat-divider></mat-divider>
<mat-checkbox class="mat-menu-item" [checked]="listItem.addon.isIgnored"
(change)="onClickIgnoreAddon($event, listItem)">
(change)="onClickIgnoreAddon($event, listItem)" appUserActionTracker category="MyAddons" action="IgnoreAddon"
[label]="listItem.addon.name">
{{'PAGES.MY_ADDONS.ADDON_CONTEXT_MENU.IGNORE_ADDON_BUTTON' | translate}}
</mat-checkbox>
<mat-checkbox *ngIf="listItem.addon.isIgnored === false" class="mat-menu-item"
[checked]="listItem.addon.autoUpdateEnabled" (change)="onClickAutoUpdateAddon($event, listItem.addon)">
[checked]="listItem.addon.autoUpdateEnabled" (change)="onClickAutoUpdateAddon($event, listItem)"
appUserActionTracker category="MyAddons" action="AutoUpdateAddon" [label]="listItem.addon.name">
{{'PAGES.MY_ADDONS.ADDON_CONTEXT_MENU.AUTO_UPDATE_ADDON_BUTTON' | translate}}
</mat-checkbox>
<button mat-menu-item [matMenuTriggerFor]="addonChannels">
{{'PAGES.MY_ADDONS.ADDON_CONTEXT_MENU.CHANNEL_SUBMENT_TITLE' | translate}}
</button>
<button mat-menu-item (click)="onReInstallAddon(listItem.addon)">
<button mat-menu-item (click)="onShowfolder(listItem.addon)" appUserActionTracker category="MyAddons"
action="ShowAddonFolder" [label]="listItem.addon.name">
{{'PAGES.MY_ADDONS.ADDON_CONTEXT_MENU.SHOW_FOLDER' | translate}}
</button>
<button mat-menu-item (click)="onReInstallAddon(listItem)" appUserActionTracker category="MyAddons"
action="ReInstallAddon" [label]="listItem.addon.name">
{{'PAGES.MY_ADDONS.ADDON_CONTEXT_MENU.REINSTALL_ADDON_BUTTON' | translate}}
</button>
<mat-divider></mat-divider>
<button mat-menu-item (click)="onRemoveAddon(listItem.addon)">
<button mat-menu-item (click)="onRemoveAddon(listItem.addon)" appUserActionTracker category="MyAddons"
action="RemoveAddon" [label]="listItem.addon.name">
{{'PAGES.MY_ADDONS.ADDON_CONTEXT_MENU.REMOVE_ADDON_BUTTON' | translate}}
</button>
<mat-menu #addonChannels="matMenu" class="addon-context-menu">
<mat-radio-group class="vertical-radio-group" [ngModel]="listItem.addon.channelType"
(change)="onSelectedAddonChannelChange($event, listItem.addon)">
(change)="onSelectedAddonChannelChange($event, listItem)">
<mat-radio-button class="mat-menu-item" [value]="0" appUserActionTracker category="MyAddons"
action="SetStableAddonChannel" [label]="listItem.addon.name">
{{'PAGES.MY_ADDONS.ADDON_CONTEXT_MENU.STABLE_ADDON_CHANNEL' | translate}}
</mat-radio-button>
<mat-radio-button class="mat-menu-item" [value]="1" appUserActionTracker category="MyAddons"
action="SetBetaAddonChannel" [label]="listItem.addon.name">
{{'PAGES.MY_ADDONS.ADDON_CONTEXT_MENU.BETA_ADDON_CHANNEL' | translate}}
</mat-radio-button>
<mat-radio-button class="mat-menu-item" [value]="2" appUserActionTracker category="MyAddons"
action="SetAlphaAddonChannel" [label]="listItem.addon.name">
{{'PAGES.MY_ADDONS.ADDON_CONTEXT_MENU.ALPHA_ADDON_CHANNEL' | translate}}
</mat-radio-button>
</mat-radio-group>
</mat-menu>
</ng-template>
</mat-menu>
<div style="visibility: hidden; position: fixed" #addonMultiContextMenuTrigger="matMenuTrigger"
[style.left]="contextMenuPosition.x" [style.top]="contextMenuPosition.y" [matMenuTriggerFor]="multiContextMenu">
</div>
<mat-menu #multiContextMenu="matMenu" class="addon-context-menu">
<ng-template matMenuContent let-listItems="listItems">
<div class="addon-context-menu-header">
{{ listItems.length + ' addons selected' }}
</div>
<mat-divider></mat-divider>
<mat-checkbox class="mat-menu-item" [checked]="isSelectedItemsProp(listItems, 'addon.isIgnored')"
(change)="onClickIgnoreAddons($event, listItems)">
{{'PAGES.MY_ADDONS.ADDON_CONTEXT_MENU.IGNORE_ADDON_BUTTON' | translate}}
</mat-checkbox>
<mat-checkbox class="mat-menu-item" [checked]="isSelectedItemsProp(listItems, 'addon.autoUpdateEnabled')"
(change)="onClickAutoUpdateAddons($event, listItems)">
{{'PAGES.MY_ADDONS.ADDON_CONTEXT_MENU.AUTO_UPDATE_ADDON_BUTTON' | translate}}
</mat-checkbox>
<button mat-menu-item [matMenuTriggerFor]="addonChannels">
{{'PAGES.MY_ADDONS.ADDON_CONTEXT_MENU.CHANNEL_SUBMENT_TITLE' | translate}}
</button>
<button mat-menu-item (click)="onReInstallAddons(listItems)">
{{'PAGES.MY_ADDONS.ADDON_CONTEXT_MENU.REINSTALL_ADDON_BUTTON' | translate}}
</button>
<mat-divider></mat-divider>
<button mat-menu-item (click)="onRemoveAddons(listItems)">
{{'PAGES.MY_ADDONS.ADDON_CONTEXT_MENU.REMOVE_ADDON_BUTTON' | translate}}
</button>
<mat-menu #addonChannels="matMenu" class="addon-context-menu">
<mat-radio-group class="vertical-radio-group" (change)="onSelectedAddonsChannelChange($event, listItems)">
<mat-radio-button class="mat-menu-item" [value]="0">
{{'PAGES.MY_ADDONS.ADDON_CONTEXT_MENU.STABLE_ADDON_CHANNEL' | translate}}
</mat-radio-button>
@@ -199,10 +250,12 @@
</div>
<mat-menu #updateAllContextMenu="matMenu" class="addon-context-menu">
<ng-template matMenuContent let-columns="columns">
<button mat-menu-item (click)="onUpdateAllRetailClassic()">
<button mat-menu-item (click)="onUpdateAllRetailClassic()" appUserActionTracker category="MyAddons"
action="UpdateAllClassicRetail">
{{'PAGES.MY_ADDONS.UPDATE_ALL_CONTEXT_MENU.UPDATE_RETAIL_CLASSIC_BUTTON' | translate}}
</button>
<button mat-menu-item (click)="onUpdateAllClients()">
<button mat-menu-item (click)="onUpdateAllClients()" appUserActionTracker category="MyAddons"
action="UpdateAllClients">
{{'PAGES.MY_ADDONS.UPDATE_ALL_CONTEXT_MENU.UPDATE_ALL_CLIENTS_BUTTON' | translate}}
</button>
</ng-template>

View File

@@ -69,7 +69,7 @@
}
.addon-title {
font-weight: bold;
font-weight: 400;
font-size: 1.1em;
word-break: break-all;
white-space: pre-wrap;
@@ -80,9 +80,9 @@
}
.status-column {
display: flex;
// display: flex;
width: 130px;
align-items: center;
// align-items: center;
.auto-update-icon {
margin-left: 0.5em;
@@ -100,6 +100,24 @@
.selected-row {
background: $dark-4;
}
.addon-provider {
display: flex;
align-items: center;
.addon-provider-name {
margin-right: .25em;
}
.provider-logo {
width: 15px;
height: 15px;
}
}
.cell-padding {
padding-right: 1em;
}
}
.author-column {

View File

@@ -8,7 +8,7 @@ import {
ViewContainerRef,
} from "@angular/core";
import { WowClientType } from "../../models/warcraft/wow-client-type";
import { filter, map } from "rxjs/operators";
import { map } from "rxjs/operators";
import { from, BehaviorSubject, Subscription, Subject } from "rxjs";
import { Addon } from "app/entities/addon";
import { WarcraftService } from "app/services/warcraft/warcraft.service";
@@ -17,7 +17,7 @@ import { SessionService } from "app/services/session/session.service";
import { Overlay, OverlayRef } from "@angular/cdk/overlay";
import { ColumnState } from "app/models/wowup/column-state";
import { MatCheckboxChange } from "@angular/material/checkbox";
import { MyAddonsListItem } from "app/business-objects/my-addons-list-item";
import { AddonViewModel } from "app/business-objects/my-addon-list-item";
import * as _ from "lodash";
import { ElectronService } from "app/services";
import { AddonDisplayState } from "app/models/wowup/addon-display-state";
@@ -30,6 +30,8 @@ import { getEnumName } from "app/utils/enum.utils";
import { MatTableDataSource } from "@angular/material/table";
import { MatSort } from "@angular/material/sort";
import { stringIncludes } from "app/utils/string.utils";
import { GetAddonListItem } from "app/business-objects/get-addon-list-item";
import { AddonDetailComponent } from "app/components/addon-detail/addon-detail.component";
@Component({
selector: "app-my-addons",
@@ -40,25 +42,27 @@ export class MyAddonsComponent implements OnInit, OnDestroy {
@Input("tabIndex") tabIndex: number;
@ViewChild("addonContextMenuTrigger") contextMenu: MatMenuTrigger;
@ViewChild("addonMultiContextMenuTrigger") multiContextMenu: MatMenuTrigger;
@ViewChild("columnContextMenuTrigger") columnContextMenu: MatMenuTrigger;
@ViewChild("updateAllContextMenuTrigger")
updateAllContextMenu: MatMenuTrigger;
@ViewChild(MatSort) sort: MatSort;
private readonly _displayAddonsSrc = new BehaviorSubject<MyAddonsListItem[]>(
private readonly _displayAddonsSrc = new BehaviorSubject<AddonViewModel[]>(
[]
);
private readonly _destroyed$ = new Subject<void>();
private subscriptions: Subscription[] = [];
private isSelectedTab: boolean = false;
private sortedListItems: AddonViewModel[] = [];
public spinnerMessage = "Loading...";
contextMenuPosition = { x: "0px", y: "0px" };
public dataSource = new MatTableDataSource<MyAddonsListItem>([]);
public dataSource = new MatTableDataSource<AddonViewModel>([]);
public filter = "";
public activeSortColumn = 'displayState';
columns: ColumnState[] = [
{ name: "addon.name", display: "Addon", visible: true },
@@ -102,16 +106,23 @@ export class MyAddonsComponent implements OnInit, OnDestroy {
constructor(
private addonService: AddonService,
private _sessionService: SessionService,
private _ngZone: NgZone,
private _dialog: MatDialog,
public electronService: ElectronService,
public overlay: Overlay,
public viewContainerRef: ViewContainerRef,
public warcraftService: WarcraftService,
private _ngZone: NgZone,
private _dialog: MatDialog
public warcraftService: WarcraftService
) {
_sessionService.selectedHomeTab$.subscribe((tabIndex) => {
this.isSelectedTab = tabIndex === this.tabIndex;
if (this.isSelectedTab) {
this.setPageContextText();
}
});
const addonInstalledSubscription = this.addonService.addonInstalled$.subscribe(
(evt) => {
let listItems: MyAddonsListItem[] = [].concat(
let listItems: AddonViewModel[] = [].concat(
this._displayAddonsSrc.value
);
@@ -129,7 +140,8 @@ export class MyAddonsComponent implements OnInit, OnDestroy {
if (listItemIdx === -1) {
listItems.push(listItem);
} else {
listItems[listItemIdx] = listItem;
return;
// listItems[listItemIdx] = listItem;
}
listItems = this.sortListItems(listItems);
@@ -142,7 +154,7 @@ export class MyAddonsComponent implements OnInit, OnDestroy {
const addonRemovedSubscription = this.addonService.addonRemoved$.subscribe(
(addonId) => {
const addons: MyAddonsListItem[] = [].concat(
const addons: AddonViewModel[] = [].concat(
this._displayAddonsSrc.value
);
const listItemIdx = addons.findIndex((li) => li.addon.id === addonId);
@@ -155,35 +167,25 @@ export class MyAddonsComponent implements OnInit, OnDestroy {
);
const displayAddonSubscription = this._displayAddonsSrc.subscribe(
(items: MyAddonsListItem[]) => {
(items: AddonViewModel[]) => {
this.dataSource.data = items;
this.dataSource.sortingDataAccessor = _.get;
this.dataSource.filterPredicate = (
item: MyAddonsListItem,
filter: string
) => {
if (
stringIncludes(item.addon.name, filter) ||
stringIncludes(item.addon.latestVersion, filter) ||
stringIncludes(item.addon.author, filter)
) {
return true;
}
return false;
};
this.dataSource.filterPredicate = this.filterListItem;
this.dataSource.sort = this.sort;
}
);
const selectedTabSubscription = this._sessionService.selectedHomeTab$
.pipe(filter((tabIndex) => this.tabIndex === this.tabIndex))
.subscribe(() => this.setPageContextText());
const dataSourceSortSubscription = this.dataSource
.connect()
.subscribe((sortedListItems) => {
this.sortedListItems = sortedListItems;
});
this.subscriptions.push(
addonInstalledSubscription,
addonRemovedSubscription,
displayAddonSubscription,
selectedTabSubscription
dataSourceSortSubscription
);
}
@@ -210,41 +212,62 @@ export class MyAddonsComponent implements OnInit, OnDestroy {
this.loadAddons(this.selectedClient);
}
onRowClicked(event: MouseEvent, row: MyAddonsListItem, index: number) {
onRowClicked(event: MouseEvent, row: AddonViewModel, index: number) {
console.log(row.displayState);
console.log("index clicked: " + index);
if (event.ctrlKey) {
if (
(event.ctrlKey && !this.electronService.isMac) ||
(event.metaKey && this.electronService.isMac)
) {
row.selected = !row.selected;
return;
}
let listItems: MyAddonsListItem[] = [].concat(this._displayAddonsSrc.value);
if (event.shiftKey) {
const startIdx = listItems.findIndex((item) => item.selected);
listItems.forEach((item, i) => {
const startIdx = this.sortedListItems.findIndex((item) => item.selected);
this.sortedListItems.forEach((item, i) => {
if (i >= startIdx && i <= index) {
item.selected = true;
} else {
item.selected = false;
}
});
} else {
listItems.forEach((item, i) => {
if (i === index) {
item.selected = !item.selected;
} else {
item.selected = false;
}
});
return;
}
this._ngZone.run(() => {
this._displayAddonsSrc.next(listItems);
this.sortedListItems.forEach((item, i) => {
if (item.addon.id === row.addon.id) {
item.selected = !item.selected;
} else {
item.selected = false;
}
});
}
selectAllRows(event: KeyboardEvent) {
event.preventDefault();
if (
(event.ctrlKey && this.electronService.isMac) ||
(event.metaKey && !this.electronService.isMac)
) {
return;
}
this.sortedListItems.forEach((item) => {
item.selected = true;
});
}
openDetailDialog(listItem: AddonViewModel) {
const dialogRef = this._dialog.open(AddonDetailComponent, {
data: listItem.addon,
});
dialogRef.afterClosed().subscribe();
}
filterAddons(): void {
this.dataSource.filter = this.filter.trim().toLowerCase();
}
@@ -302,12 +325,22 @@ export class MyAddonsComponent implements OnInit, OnDestroy {
this.columnContextMenu.openMenu();
}
onCellContext(event: MouseEvent, listItem: MyAddonsListItem) {
onCellContext(event: MouseEvent, listItem: AddonViewModel) {
event.preventDefault();
this.updateContextMenuPosition(event);
this.contextMenu.menuData = { listItem: listItem };
this.contextMenu.menu.focusFirstItem("mouse");
this.contextMenu.openMenu();
const selectedItems = this._displayAddonsSrc.value.filter(
(item) => item.selected
);
if (selectedItems.length > 1) {
this.multiContextMenu.menuData = { listItems: selectedItems };
this.multiContextMenu.menu.focusFirstItem("mouse");
this.multiContextMenu.openMenu();
} else {
this.contextMenu.menuData = { listItem: listItem };
this.contextMenu.menu.focusFirstItem("mouse");
this.contextMenu.openMenu();
}
}
onUpdateAllContext(event: MouseEvent) {
@@ -316,15 +349,30 @@ export class MyAddonsComponent implements OnInit, OnDestroy {
this.updateAllContextMenu.openMenu();
}
async onReInstallAddon(addon: Addon) {
async onReInstallAddon(listItems: AddonViewModel) {
await this.onReInstallAddons([listItems]);
}
async onReInstallAddons(listItems: AddonViewModel[]) {
for (let listItem of listItems) {
try {
await this.addonService.installAddon(listItem.addon.id);
} catch (err) {
console.error(err);
}
}
}
onShowfolder(addon: Addon) {
try {
this.addonService.installAddon(addon.id);
const addonPath = this.addonService.getFullInstallPath(addon);
this.electronService.shell.openExternal(addonPath);
} catch (err) {
console.error(err);
}
}
onUpdateAddon(listItem: MyAddonsListItem) {
onUpdateAddon(listItem: AddonViewModel) {
listItem.isInstalling = true;
this.addonService.installAddon(listItem.addon.id);
@@ -375,25 +423,106 @@ export class MyAddonsComponent implements OnInit, OnDestroy {
});
}
onInstall() {}
onRemoveAddons(listItems: AddonViewModel[]) {
let message = "";
if (listItems.length > 3) {
message = `Are you sure you want to remove the selected ${listItems.length} addons?`;
} else {
message = "Are you sure you want to remove the following addons?";
listItems.forEach(
(listItem) => (message = `${message}\n\t• ${listItem.addon.name}`)
);
}
message +=
"\nThis will remove all related folders from your World of Warcraft folder.";
onClickIgnoreAddon(evt: MatCheckboxChange, listItem: MyAddonsListItem) {
listItem.addon.isIgnored = evt.checked;
listItem.statusText = listItem.getStateText();
this.addonService.saveAddon(listItem.addon);
const dialogRef = this._dialog.open(ConfirmDialogComponent, {
data: {
title: `Uninstall Addons?`,
message: message,
},
});
dialogRef.afterClosed().subscribe(async (result) => {
console.log("The dialog was closed", result);
if (!result) {
return;
}
for (let listItem of listItems) {
await this.addonService.removeAddon(listItem.addon);
}
});
}
onClickAutoUpdateAddon(evt: MatCheckboxChange, addon: Addon) {
addon.autoUpdateEnabled = evt.checked;
this.addonService.saveAddon(addon);
onInstall() { }
onClickIgnoreAddon(evt: MatCheckboxChange, listItem: AddonViewModel) {
this.onClickIgnoreAddons(evt, [listItem]);
}
onSelectedAddonChannelChange(evt: MatRadioChange, addon: Addon) {
addon.channelType = evt.value;
this.addonService.saveAddon(addon);
onClickIgnoreAddons(evt: MatCheckboxChange, listItems: AddonViewModel[]) {
listItems.forEach((listItem) => {
listItem.addon.isIgnored = evt.checked;
if (evt.checked) {
listItem.addon.autoUpdateEnabled = false;
}
listItem.statusText = listItem.getStateText();
this.addonService.saveAddon(listItem.addon);
});
if (!this.sort.active) {
this.sortTable(this.dataSource);
}
}
onClickAutoUpdateAddon(evt: MatCheckboxChange, listItem: AddonViewModel) {
this.onClickAutoUpdateAddons(evt, [listItem]);
}
onClickAutoUpdateAddons(
evt: MatCheckboxChange,
listItems: AddonViewModel[]
) {
listItems.forEach((listItem) => {
listItem.addon.autoUpdateEnabled = evt.checked;
if (evt.checked) {
listItem.addon.isIgnored = false;
}
this.addonService.saveAddon(listItem.addon);
});
if (!this.sort.active) {
this.sortTable(this.dataSource);
}
}
onSelectedAddonChannelChange(
evt: MatRadioChange,
listItem: AddonViewModel
) {
this.onSelectedAddonsChannelChange(evt, [listItem]);
}
onSelectedAddonsChannelChange(
evt: MatRadioChange,
listItems: AddonViewModel[]
) {
listItems.forEach((listItem) => {
listItem.addon.channelType = evt.value;
this.addonService.saveAddon(listItem.addon);
});
this.loadAddons(this.selectedClient);
}
isSelectedItemsProp(listItems: AddonViewModel[], prop: string) {
return _.some(listItems, prop);
}
private sortTable(dataSource: MatTableDataSource<AddonViewModel>) {
this.dataSource.data = this.sortListItems(dataSource.data, dataSource.sort);
}
private async updateAllWithSpinner(...clientTypes: WowClientType[]) {
this.isBusy = true;
this.spinnerMessage = "Gathering addons...";
@@ -407,7 +536,7 @@ export class MyAddonsComponent implements OnInit, OnDestroy {
// Only care about the ones that need to be updated/installed
addons = addons
.map((addon) => new MyAddonsListItem(addon))
.map((addon) => new AddonViewModel(addon))
.filter((listItem) => listItem.needsUpdate || listItem.needsInstall)
.map((listItem) => listItem.addon);
@@ -415,8 +544,7 @@ export class MyAddonsComponent implements OnInit, OnDestroy {
for (let addon of addons) {
updatedCt += 1;
this.spinnerMessage = `Updating ${updatedCt}/${
addons.length
this.spinnerMessage = `Updating ${updatedCt}/${addons.length
}\n${getEnumName(WowClientType, addon.clientType)}: ${addon.name}`;
await this.addonService.installAddon(addon.id);
@@ -445,10 +573,8 @@ export class MyAddonsComponent implements OnInit, OnDestroy {
this.isBusy = false;
this.enableControls = true;
this._ngZone.run(() => {
this._sessionService.contextText = `${addons.length} addons`;
const formattedAddons = this.formatAddons(addons);
this.activeSortColumn = formattedAddons.filter(i => i.needsUpdate).length === 0 ? 'addon.name' : 'displayState';
this._displayAddonsSrc.next(formattedAddons);
this._displayAddonsSrc.next(this.formatAddons(addons));
this.setPageContextText();
});
},
error: (err) => {
@@ -459,18 +585,36 @@ export class MyAddonsComponent implements OnInit, OnDestroy {
});
}
private formatAddons(addons: Addon[]): MyAddonsListItem[] {
private formatAddons(addons: Addon[]): AddonViewModel[] {
const listItems = addons.map((addon) => this.createAddonListItem(addon));
return this.sortListItems(listItems);
}
private sortListItems(listItems: MyAddonsListItem[]) {
return _.orderBy(listItems, ["displayState", "addon.name"]);
private sortListItems(listItems: AddonViewModel[], sort?: MatSort) {
if (!sort || !sort.active || sort.direction === "") {
return _.orderBy(listItems, ["displayState", "addon.name"]);
}
return _.orderBy(
listItems,
[(listItem) => _.get(listItem, sort.active, "")],
[sort.direction === "asc" ? "asc" : "desc"]
);
}
private filterListItem(item: AddonViewModel, filter: string) {
if (
stringIncludes(item.addon.name, filter) ||
stringIncludes(item.addon.latestVersion, filter) ||
stringIncludes(item.addon.author, filter)
) {
return true;
}
return false;
}
private createAddonListItem(addon: Addon) {
const listItem = new MyAddonsListItem(addon);
const listItem = new AddonViewModel(addon);
if (!listItem.addon.thumbnailUrl) {
listItem.addon.thumbnailUrl = "assets/wowup_logo_512np.png";
@@ -487,7 +631,10 @@ export class MyAddonsComponent implements OnInit, OnDestroy {
return;
}
this._sessionService.contextText = `${this._displayAddonsSrc.value.length} addons`;
this._sessionService.setContextText(
this.tabIndex,
`${this._displayAddonsSrc.value.length} addons`
);
}
private getInstallStateText(installState: AddonInstallState) {

View File

@@ -7,7 +7,8 @@
<mat-card-content>
<div class="row">
<div class="grow">{{'PAGES.OPTIONS.WOW.RESCAN_CLIENTS_LABEL' | translate}}</div>
<button mat-flat-button color="primary" (click)="onReScan()">
<button mat-flat-button color="primary" (click)="onReScan()" appUserActionTracker category="Options"
action="ScanInstalls">
{{'PAGES.OPTIONS.WOW.RESCAN_CLIENTS_BUTTON' | translate}}
</button>
</div>
@@ -45,7 +46,8 @@
<p>{{'PAGES.OPTIONS.APPLICATION.MINIMIZE_ON_CLOSE_LABEL' | translate}}</p>
<small class="hint">{{'PAGES.OPTIONS.APPLICATION.MINIMIZE_ON_CLOSE_DESCRIPTION' | translate}}</small>
</div>
<mat-slide-toggle [(checked)]="collapseToTray" (change)="onCollapseChange($event)">
<mat-slide-toggle [(checked)]="collapseToTray" (change)="onCollapseChange($event)" appUserActionTracker
category="Options" action="CollapseToTray" [label]="collapseToTray">
</mat-slide-toggle>
</div>
</mat-card-content>
@@ -60,7 +62,8 @@
<p>{{'PAGES.OPTIONS.DEBUG.LOG_FILES_LABEL' | translate}}</p>
<small class="hint">{{'PAGES.OPTIONS.DEBUG.LOG_FILES_DESCRIPTION' | translate}}</small>
</div>
<button mat-flat-button color="primary" (click)="onShowLogs()">
<button mat-flat-button color="primary" (click)="onShowLogs()" appUserActionTracker category="Options"
action="ShowLogFiles">
{{'PAGES.OPTIONS.DEBUG.LOG_FILES_BUTTON' | translate}}
</button>
</div>
@@ -69,11 +72,12 @@
<p>{{'PAGES.OPTIONS.DEBUG.DEBUG_DATA_LABEL' | translate}}</p>
<small class="hint">{{'PAGES.OPTIONS.DEBUG.DEBUG_DATA_DESCRIPTION' | translate}}</small>
</div>
<button mat-flat-button color="primary" (click)="onShowLogs()">
<button mat-flat-button color="primary" (click)="onLogDebugData()" appUserActionTracker category="Options"
action="LogDebugData">
{{'PAGES.OPTIONS.DEBUG.DEBUG_DATA_BUTTON' | translate}}
</button>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
</div>

View File

@@ -1,41 +1,60 @@
import { Component, OnInit, NgZone, OnChanges, SimpleChanges, Input } from '@angular/core';
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { WowClientType } from 'app/models/warcraft/wow-client-type';
import { ElectronService } from 'app/services';
import { WarcraftService } from 'app/services/warcraft/warcraft.service';
import { WowUpService } from 'app/services/wowup/wowup.service';
import { telemetryEnabledKey } from '../../../constants';
import { filter, map } from 'rxjs/operators';
import * as _ from 'lodash';
import * as path from 'path';
import { MatDialog } from '@angular/material/dialog';
import { AlertDialogComponent } from 'app/components/alert-dialog/alert-dialog.component';
import { getEnumList, getEnumName } from 'app/utils/enum.utils';
import { WowUpReleaseChannelType } from 'app/models/wowup/wowup-release-channel-type';
import { MatSelectChange } from '@angular/material/select';
import {
Component,
OnInit,
NgZone,
OnChanges,
SimpleChanges,
Input,
} from "@angular/core";
import { MatSlideToggleChange } from "@angular/material/slide-toggle";
import { WowClientType } from "app/models/warcraft/wow-client-type";
import { ElectronService } from "app/services";
import { WarcraftService } from "app/services/warcraft/warcraft.service";
import { WowUpService } from "app/services/wowup/wowup.service";
import { filter, map } from "rxjs/operators";
import * as _ from "lodash";
import * as path from "path";
import { MatDialog } from "@angular/material/dialog";
import { AlertDialogComponent } from "app/components/alert-dialog/alert-dialog.component";
import { getEnumList, getEnumName } from "app/utils/enum.utils";
import { WowUpReleaseChannelType } from "app/models/wowup/wowup-release-channel-type";
import { MatSelectChange } from "@angular/material/select";
import { AnalyticsService } from "app/services/analytics/analytics.service";
import { AddonService } from "app/services/addons/addon.service";
import { GET_ASSET_FILE_PATH } from "common/constants";
@Component({
selector: 'app-options',
templateUrl: './options.component.html',
styleUrls: ['./options.component.scss']
selector: "app-options",
templateUrl: "./options.component.html",
styleUrls: ["./options.component.scss"],
})
export class OptionsComponent implements OnInit, OnChanges {
@Input("tabIndex") tabIndex: number;
@Input('tabIndex') tabIndex: number;
public retailLocation = '';
public classicLocation = '';
public retailPtrLocation = '';
public classicPtrLocation = '';
public betaLocation = '';
public retailLocation = "";
public classicLocation = "";
public retailPtrLocation = "";
public classicPtrLocation = "";
public betaLocation = "";
public collapseToTray = false;
public telemetryEnabled = false;
public wowClientTypes: WowClientType[] = getEnumList(WowClientType).filter(clientType => clientType !== WowClientType.None) as WowClientType[];
public wowClientTypes: WowClientType[] = getEnumList(WowClientType).filter(
(clientType) => clientType !== WowClientType.None
) as WowClientType[];
public wowUpReleaseChannel: WowUpReleaseChannelType;
public wowUpReleaseChannels: { type: WowUpReleaseChannelType, name: string }[] = getEnumList(WowUpReleaseChannelType)
.map((type: WowUpReleaseChannelType) => ({ type, name: getEnumName(WowUpReleaseChannelType, type) }));
public wowUpReleaseChannels: {
type: WowUpReleaseChannelType;
name: string;
}[] = getEnumList(WowUpReleaseChannelType).map(
(type: WowUpReleaseChannelType) => ({
type,
name: getEnumName(WowUpReleaseChannelType, type),
})
);
constructor(
private _addonService: AddonService,
private _analyticsService: AnalyticsService,
private warcraft: WarcraftService,
private _electronService: ElectronService,
private _warcraftService: WarcraftService,
@@ -44,15 +63,13 @@ export class OptionsComponent implements OnInit, OnChanges {
private zone: NgZone,
public electronService: ElectronService
) {
_wowUpService.preferenceChange$
.pipe(filter(change => change.key === telemetryEnabledKey))
.subscribe(change => {
this.telemetryEnabled = change.value === true.toString()
})
_analyticsService.telemetryEnabled$.subscribe((enabled) => {
this.telemetryEnabled = enabled;
});
}
ngOnChanges(changes: SimpleChanges): void {
console.log(changes)
console.log(changes);
}
ngOnInit(): void {
@@ -63,25 +80,29 @@ export class OptionsComponent implements OnInit, OnChanges {
onShowLogs = () => {
this._wowUpService.showLogsFolder();
}
};
onReScan = () => {
this.warcraft.scanProducts();
this.loadData();
}
};
onTelemetryChange = (evt: MatSlideToggleChange) => {
this._wowUpService.telemetryEnabled = evt.checked;
}
this._analyticsService.telemetryEnabled = evt.checked;
};
onCollapseChange = (evt: MatSlideToggleChange) => {
this._wowUpService.collapseToTray = evt.checked;
}
};
onWowUpChannelChange(evt: MatSelectChange) {
this._wowUpService.wowUpReleaseChannel = evt.value;
}
async onLogDebugData() {
await this._addonService.logDebugData();
}
async onSelectRetailClientPath() {
const selectedPath = await this.selectWowClientPath(WowClientType.Retail);
if (selectedPath) {
@@ -90,7 +111,9 @@ export class OptionsComponent implements OnInit, OnChanges {
}
async onSelectRetailPtrClientPath() {
const selectedPath = await this.selectWowClientPath(WowClientType.RetailPtr);
const selectedPath = await this.selectWowClientPath(
WowClientType.RetailPtr
);
if (selectedPath) {
this.retailPtrLocation = selectedPath;
}
@@ -104,7 +127,9 @@ export class OptionsComponent implements OnInit, OnChanges {
}
async onSelectClassicPtrClientPath() {
const selectedPath = await this.selectWowClientPath(WowClientType.ClassicPtr);
const selectedPath = await this.selectWowClientPath(
WowClientType.ClassicPtr
);
if (selectedPath) {
this.classicPtrLocation = selectedPath;
}
@@ -117,52 +142,74 @@ export class OptionsComponent implements OnInit, OnChanges {
}
}
private async selectWowClientPath(clientType: WowClientType): Promise<string> {
const dialogResult = await this._electronService.remote.dialog.showOpenDialog({
properties: ['openDirectory']
});
private async selectWowClientPath(
clientType: WowClientType
): Promise<string> {
const dialogResult = await this._electronService.remote.dialog.showOpenDialog(
{
properties: ["openDirectory"],
}
);
if (dialogResult.canceled) {
return '';
return "";
}
const selectedPath = _.first(dialogResult.filePaths);
if (!selectedPath) {
console.warn('No path selected')
return '';
console.warn("No path selected");
return "";
}
console.log('dialogResult', selectedPath);
console.log("dialogResult", selectedPath);
if (this._warcraftService.setWowFolderPath(clientType, selectedPath)) {
return selectedPath;
}
const clientFolderName = this._warcraftService.getClientFolderName(clientType);
const clientExecutableName = this._warcraftService.getExecutableName(clientType);
const clientExecutablePath = path.join(selectedPath, clientFolderName, clientExecutableName);
const clientFolderName = this._warcraftService.getClientFolderName(
clientType
);
const clientExecutableName = this._warcraftService.getExecutableName(
clientType
);
const clientExecutablePath = path.join(
selectedPath,
clientFolderName,
clientExecutableName
);
const dialogRef = this._dialog.open(AlertDialogComponent, {
data: {
title: `Alert`,
message: `Unable to set "${selectedPath}" as your ${getEnumName(WowClientType, clientType)} folder.\nPath not found: "${clientExecutablePath}".`
}
message: `Unable to set "${selectedPath}" as your ${getEnumName(
WowClientType,
clientType
)} folder.\nPath not found: "${clientExecutablePath}".`,
},
});
await dialogRef.afterClosed().toPromise();
return '';
return "";
}
private loadData() {
this.zone.run(() => {
this.telemetryEnabled = this._wowUpService.telemetryEnabled;
this.telemetryEnabled = this._analyticsService.telemetryEnabled;
this.collapseToTray = this._wowUpService.collapseToTray;
this.retailLocation = this.warcraft.getClientLocation(WowClientType.Retail);
this.classicLocation = this.warcraft.getClientLocation(WowClientType.Classic);
this.retailPtrLocation = this.warcraft.getClientLocation(WowClientType.RetailPtr);
this.classicPtrLocation = this.warcraft.getClientLocation(WowClientType.ClassicPtr);
this.retailLocation = this.warcraft.getClientLocation(
WowClientType.Retail
);
this.classicLocation = this.warcraft.getClientLocation(
WowClientType.Classic
);
this.retailPtrLocation = this.warcraft.getClientLocation(
WowClientType.RetailPtr
);
this.classicPtrLocation = this.warcraft.getClientLocation(
WowClientType.ClassicPtr
);
this.betaLocation = this.warcraft.getClientLocation(WowClientType.Beta);
})
});
}
}

View File

@@ -6,6 +6,7 @@ import { TukUiAddonProvider } from "app/addon-providers/tukui-addon-provider";
import { WowInterfaceAddonProvider } from "app/addon-providers/wow-interface-addon-provider";
import { CurseAddonProvider } from "../../addon-providers/curse-addon-provider";
import { WowUpAddonProvider } from "../../addon-providers/wowup-addon-provider";
import { CachingService } from "../caching/caching-service";
import { ElectronService } from "../electron/electron.service";
import { FileService } from "../files/file.service";
@@ -63,4 +64,8 @@ export class AddonProviderFactory {
public createGitHubAddonProvider(): GitHubAddonProvider {
return new GitHubAddonProvider(this._httpClient);
}
public createWowUpAddonProvider(): WowUpAddonProvider {
return new WowUpAddonProvider(this._httpClient, this._electronService);
}
}

View File

@@ -1,35 +1,28 @@
import { Injectable, Injector } from "@angular/core";
import { Injectable } from "@angular/core";
import { AddonStorageService } from "../storage/addon-storage.service";
import { Addon } from "../../entities/addon";
import { WarcraftService } from "../warcraft/warcraft.service";
import { AddonProvider } from "../../addon-providers/addon-provider";
import { CurseAddonProvider } from "../../addon-providers/curse-addon-provider";
import { HttpClient } from "@angular/common/http";
import * as _ from "lodash";
import { v4 as uuidv4 } from "uuid";
import * as path from "path";
import * as fs from "fs";
import { WowUpApiService } from "../wowup-api/wowup-api.service";
import { WowClientType } from "app/models/warcraft/wow-client-type";
import { PotentialAddon } from "app/models/wowup/potential-addon";
import { AddonFolder } from "app/models/wowup/addon-folder";
import { AddonChannelType } from "app/models/wowup/addon-channel-type";
import { AddonSearchResult } from "app/models/wowup/addon-search-result";
import { AddonSearchResultFile } from "app/models/wowup/addon-search-result-file";
import { forkJoin, Observable, Subject } from "rxjs";
import { map } from "rxjs/operators";
import { CachingService } from "../caching/caching-service";
import { AddonInstallState } from "app/models/wowup/addon-install-state";
import { DownloadSevice } from "../download/download.service";
import { WowUpService } from "../wowup/wowup.service";
import { FileService } from "../files/file.service";
import { TocService } from "../toc/toc.service";
import { ElectronService } from "../electron/electron.service";
import { TukUiAddonProvider } from "app/addon-providers/tukui-addon-provider";
import { AddonUpdateEvent } from "app/models/wowup/addon-update-event";
import { WowInterfaceAddonProvider } from "app/addon-providers/wow-interface-addon-provider";
import { GitHubAddonProvider } from "app/addon-providers/github-addon-provider";
import { AddonProviderFactory } from "./addon.provider.factory";
import { AnalyticsService } from "../analytics/analytics.service";
@Injectable({
providedIn: "root",
@@ -44,6 +37,7 @@ export class AddonService {
constructor(
private _addonStorage: AddonStorageService,
private _analyticsService: AnalyticsService,
private _warcraftService: WarcraftService,
private _wowUpService: WowUpService,
private _downloadService: DownloadSevice,
@@ -66,20 +60,25 @@ export class AddonService {
public async search(
query: string,
clientType: WowClientType
): Promise<PotentialAddon[]> {
): Promise<AddonSearchResult[]> {
var searchTasks = this._addonProviders.map((p) =>
p.searchByQuery(query, clientType)
);
var searchResults = await Promise.all(searchTasks);
// await _analyticsService.TrackUserAction("Addons", "Search", $"{clientType}|{query}");
await this._analyticsService.trackUserAction(
"Addons",
"Search",
`${clientType}|${query}`
);
const flatResults = searchResults.flat(1);
return _.orderBy(flatResults, "downloadCount").reverse();
}
public async installPotentialAddon(
potentialAddon: PotentialAddon,
potentialAddon: AddonSearchResult,
clientType: WowClientType,
onUpdate: (
installState: AddonInstallState,
@@ -100,6 +99,7 @@ export class AddonService {
clientType
).toPromise();
this._addonStorage.set(addon.id, addon);
await this.installAddon(addon.id, onUpdate);
}
@@ -113,7 +113,6 @@ export class AddonService {
for (let clientTypeStr in clientTypeGroups) {
const clientType: WowClientType = parseInt(clientTypeStr, 10);
// console.log('clientType', clientType, clientTypeGroups[clientType]);
const synced = await this.syncAddons(
clientType,
@@ -132,7 +131,7 @@ export class AddonService {
await this.installAddon(addon.id);
updateCt += 1;
} catch (err) {
// _analyticsService.Track(ex, "Failed to install addon");
console.error(err);
}
}
}
@@ -173,7 +172,6 @@ export class AddonService {
let downloadedFilePath = "";
let unzippedDirectory = "";
let downloadedThumbnail = "";
try {
downloadedFilePath = await this._downloadService.downloadZipFile(
addon.downloadUrl,
@@ -181,6 +179,7 @@ export class AddonService {
);
onUpdate?.call(this, AddonInstallState.Installing, 75);
this._addonInstalledSrc.next({
addon,
installState: AddonInstallState.Installing,
@@ -191,6 +190,7 @@ export class AddonService {
this._wowUpService.applicationDownloadsFolderPath,
uuidv4()
);
unzippedDirectory = await this._downloadService.unzipFile(
downloadedFilePath,
unzipPath
@@ -214,7 +214,11 @@ export class AddonService {
this._addonStorage.set(addon.id, addon);
// await _analyticsService.TrackUserAction("Addons", "InstallById", $"{addon.ClientType}|{addon.Name}");
await this._analyticsService.trackUserAction(
"Addons",
"InstallById",
`${addon.clientType}|${addon.name}`
);
} catch (err) {
console.error(err);
@@ -237,6 +241,28 @@ export class AddonService {
});
}
public async logDebugData() {
const curseProvider = this._addonProviders.find(
(p) => p.name === "Curse"
) as CurseAddonProvider;
const clientTypes = await this._warcraftService.getWowClientTypes();
for (let clientType of clientTypes) {
const addonFolders = await this._warcraftService.listAddons(clientType);
const scanResults = await curseProvider.getScanResults(addonFolders);
const map = {};
scanResults.forEach((sr) => (map[sr.folderName] = sr.fingerprint));
console.log(
`clientType ${this._warcraftService.getClientDisplayName(
clientType
)} addon fingerprints`
);
console.log(map);
}
}
private async getLatestGameVersion(
baseDir: string,
installedFolders: string[]
@@ -361,8 +387,15 @@ export class AddonService {
);
}
public getFullInstallPath(addon: Addon) {
const addonFolderPath = this._warcraftService.getAddonFolderPath(
addon.clientType
);
return path.join(addonFolderPath, addon.folderName);
}
public async removeAddon(addon: Addon) {
const installedDirectories = addon.installedFolders.split(",");
const installedDirectories = addon.installedFolders?.split(",") ?? [];
const addonFolderPath = this._warcraftService.getAddonFolderPath(
addon.clientType
@@ -381,9 +414,13 @@ export class AddonService {
rescan = false
): Promise<Addon[]> {
let addons = this._addonStorage.getAllForClientType(clientType);
if (rescan || !addons.length) {
if (rescan) {
const newAddons = await this.scanAddons(clientType);
this.updateAddons(addons, newAddons);
console.log(newAddons);
this._addonStorage.removeAllForClientType(clientType);
addons = this.updateAddons(addons, newAddons);
this._addonStorage.saveAll(addons);
}
await this.syncAddons(clientType, addons);
@@ -391,48 +428,24 @@ export class AddonService {
return addons;
}
private updateAddons(existingAddons: Addon[], newAddons: Addon[]): Addon[] {
const removedAddons = existingAddons.filter(
(existingAddon) =>
!newAddons.some((newAddon) => this.addonsMatch(existingAddon, newAddon))
);
const addedAddons = newAddons.filter(
(newAddon) =>
!existingAddons.some((existingAddon) =>
this.addonsMatch(existingAddon, newAddon)
)
);
_.remove(existingAddons, (addon) =>
removedAddons.some((removedAddon) => removedAddon.id === addon.id)
);
existingAddons.push(...addedAddons);
for (let existingAddon of existingAddons) {
var matchingAddon = newAddons.find((newAddon) =>
this.addonsMatch(newAddon, existingAddon)
private updateAddons(existingAddons: Addon[], newAddons: Addon[]) {
_.forEach(newAddons, (newAddon) => {
const existingAddon = _.find(
existingAddons,
(ea) =>
ea.externalId == newAddon.externalId &&
ea.providerName == newAddon.providerName
);
if (!matchingAddon) {
continue;
if (!existingAddon) {
return;
}
existingAddon.name = matchingAddon.name;
existingAddon.folderName = matchingAddon.folderName;
existingAddon.downloadUrl = matchingAddon.downloadUrl;
existingAddon.installedVersion = matchingAddon.installedVersion;
existingAddon.externalUrl = matchingAddon.externalUrl;
existingAddon.latestVersion = matchingAddon.latestVersion;
existingAddon.thumbnailUrl = matchingAddon.thumbnailUrl;
existingAddon.gameVersion = matchingAddon.gameVersion;
existingAddon.author = matchingAddon.author;
}
newAddon.autoUpdateEnabled = existingAddon.autoUpdateEnabled;
newAddon.isIgnored = existingAddon.isIgnored;
});
this._addonStorage.removeAll(...removedAddons);
this._addonStorage.setAll(existingAddons);
return existingAddons;
return newAddons;
}
private addonsMatch(addon1: Addon, addon2: Addon): boolean {
@@ -549,7 +562,7 @@ export class AddonService {
public getFeaturedAddons(
clientType: WowClientType
): Observable<PotentialAddon[]> {
): Observable<AddonSearchResult[]> {
return forkJoin(
this._addonProviders.map((p) => p.getFeaturedAddons(clientType))
).pipe(

View File

@@ -1,47 +1,124 @@
import { ErrorHandler, Injectable } from "@angular/core";
import * as Rollbar from 'rollbar';
import { v4 as uuidv4 } from "uuid";
import { PreferenceStorageService } from "../storage/preference-storage.service";
import { telemetryEnabledKey } from "../../../constants";
import { AppConfig } from "environments/environment";
import { HttpClient, HttpParams } from "@angular/common/http";
import { ElectronService } from "../electron/electron.service";
import { BehaviorSubject } from "rxjs";
@Injectable({
providedIn: 'root'
providedIn: "root",
})
export class AnalyticsService implements ErrorHandler {
private rollbarConfig = {
accessToken: 'd01c11314a064572b11acee18d880650',
captureUncaught: true,
captureUnhandledRejections: true,
};
export class AnalyticsService {
private readonly analyticsUrl = "https://www.google-analytics.com";
private readonly installIdPreferenceKey = "install_id";
private readonly _installId: string;
private readonly _appVersion: string;
private readonly _telemetryEnabledSrc = new BehaviorSubject(false);
private _rollbar: Rollbar;
private get rollbar() {
if (!this.telemetryEnabled) {
return undefined;
}
public readonly telemetryPromptUsedKey = "telemetry_prompt_sent";
public readonly telemetryEnabledKey = "telemetry_enabled";
public readonly telemetryEnabled$ = this._telemetryEnabledSrc.asObservable();
if (!this._rollbar) {
this._rollbar = new Rollbar(this.rollbarConfig);
}
return this._rollbar;
private get installId() {
return this._installId;
}
public get shouldPromptTelemetry() {
return (
this._preferenceStorageService.get(this.telemetryEnabledKey) === undefined
);
}
public get telemetryEnabled() {
const preference = this._preferenceStorageService.findByKey(
this.telemetryEnabledKey
);
return preference === true.toString();
}
public set telemetryEnabled(value: boolean) {
this._preferenceStorageService.set(this.telemetryEnabledKey, value);
this._telemetryEnabledSrc.next(value);
}
constructor(
private _electronService: ElectronService,
private _httpClient: HttpClient,
private _preferenceStorageService: PreferenceStorageService
) {
this._appVersion = _electronService.remote.app.getVersion();
this._installId = this.loadInstallId();
this._telemetryEnabledSrc.next(this.telemetryEnabled);
console.log("installId", this._installId);
}
public async trackStartup() {
await this.track((params) => {
params.set("t", "pageview");
params.set("dp", "app/startup");
});
}
public async trackUserAction(
category: string,
action: string,
label: string = null
) {
await this.track((params) => {
params.set("t", "event");
params.set("ec", category);
params.set("ea", action);
params.set("el", label);
});
}
private async track(action: (params: HttpParams) => void = undefined) {
if (!this.telemetryEnabled) {
return;
}
private get telemetryEnabled() {
return this._preferenceStorageService.get(telemetryEnabledKey) === true.toString();
var url = `${this.analyticsUrl}/collect`;
try {
let params = new URLSearchParams();
params.set("v", "1");
params.set("tid", AppConfig.googleAnalyticsId);
params.set("cid", this._installId);
params.set("ua", window.navigator.userAgent);
params.set("an", "WowUp Client");
params.set("av", this._appVersion);
action?.call(this, params);
const fullUrl = `${url}?${params}`;
const response = await this._httpClient
.post(
fullUrl,
{},
{
responseType: "text",
}
)
.toPromise();
} catch (e) {
// eat
console.error(e);
}
}
private loadInstallId() {
let installId = this._preferenceStorageService.findByKey(
this.installIdPreferenceKey
);
if (installId) {
return installId;
}
installId = uuidv4();
this._preferenceStorageService.set(this.installIdPreferenceKey, installId);
public get shouldPromptTelemetry() {
return this._preferenceStorageService.get(telemetryEnabledKey) === undefined;
}
constructor(
private _preferenceStorageService: PreferenceStorageService
) { }
// ErrorHandler
handleError(error: any): void {
console.error('Caught error', error);
this.rollbar?.error(error.originalError || error);
}
}
return installId;
}
}

View File

@@ -1,5 +1,10 @@
import { Injectable } from "@angular/core";
import { COPY_FILE_CHANNEL, DOWNLOAD_FILE_CHANNEL, UNZIP_FILE_CHANNEL } from "common/constants";
import {
COPY_FILE_CHANNEL,
DOWNLOAD_FILE_CHANNEL,
UNZIP_FILE_CHANNEL,
} from "common/constants";
import { v4 as uuidv4 } from "uuid";
import { DownloadRequest } from "common/models/download-request";
import { DownloadStatus } from "common/models/download-status";
import { DownloadStatusType } from "common/models/download-status-type";
@@ -10,78 +15,93 @@ import { ElectronService } from "../electron/electron.service";
import { CopyFileRequest } from "common/models/copy-file-request";
@Injectable({
providedIn: 'root'
providedIn: "root",
})
export class DownloadSevice {
constructor(private _electronService: ElectronService) {}
constructor(
private _electronService: ElectronService
) { }
public downloadZipFile(
url: string,
outputFolder: string,
onProgress?: (progress: number) => void
): Promise<string> {
return new Promise((resolve, reject) => {
const request: DownloadRequest = {
url,
outputFolder,
responseKey: uuidv4(),
};
public downloadZipFile(url: string, outputFolder: string, onProgress?: (progress: number) => void): Promise<string> {
return new Promise((resolve, reject) => {
const eventHandler = (_evt: any, arg: DownloadStatus) => {
switch (arg.type) {
case DownloadStatusType.Complete:
resolve(arg.savePath);
this._electronService.ipcRenderer.off(url, eventHandler);
break;
case DownloadStatusType.Error:
reject(arg.error);
this._electronService.ipcRenderer.off(url, eventHandler);
break;
case DownloadStatusType.Progress:
onProgress?.call(null, arg.progress);
break;
default:
break;
}
};
const eventHandler = (_evt: any, arg: DownloadStatus) => {
switch (arg.type) {
case DownloadStatusType.Complete:
this._electronService.ipcRenderer.off(
request.responseKey,
eventHandler
);
resolve(arg.savePath);
break;
case DownloadStatusType.Error:
this._electronService.ipcRenderer.off(
request.responseKey,
eventHandler
);
reject(arg.error);
break;
case DownloadStatusType.Progress:
onProgress?.call(null, arg.progress);
break;
default:
break;
}
};
this._electronService.ipcRenderer.on(url, eventHandler);
this._electronService.ipcRenderer.send(DOWNLOAD_FILE_CHANNEL, { url, outputFolder } as DownloadRequest);
})
}
this._electronService.ipcRenderer.on(request.responseKey, eventHandler);
this._electronService.ipcRenderer.send(DOWNLOAD_FILE_CHANNEL, request);
});
}
public unzipFile(zipFilePath: string, outputFolder: string): Promise<string> {
return new Promise((resolve, reject) => {
const eventHandler = (_evt: any, arg: UnzipStatus) => {
this._electronService.ipcRenderer.off(zipFilePath, eventHandler);
public unzipFile(zipFilePath: string, outputFolder: string): Promise<string> {
return new Promise((resolve, reject) => {
const eventHandler = (_evt: any, arg: UnzipStatus) => {
if (arg.type === UnzipStatusType.Error) {
return reject(arg.error);
}
resolve(arg.outputFolder);
};
if (arg.type === UnzipStatusType.Error) {
return reject(arg.error);
}
resolve(arg.outputFolder);
}
const request: UnzipRequest = {
outputFolder,
zipFilePath,
responseKey: uuidv4(),
};
const request: UnzipRequest = {
outputFolder,
zipFilePath
};
this._electronService.ipcRenderer.once(request.responseKey, eventHandler);
this._electronService.ipcRenderer.send(UNZIP_FILE_CHANNEL, request);
});
}
this._electronService.ipcRenderer.on(zipFilePath, eventHandler);
this._electronService.ipcRenderer.send(UNZIP_FILE_CHANNEL, request);
});
}
public copyFile(
sourceFilePath: string,
destinationFilePath: string
): Promise<string> {
return new Promise((resolve, reject) => {
const eventHandler = (_evt: any, arg: { error?: Error }) => {
if (arg.error) {
return reject(arg.error);
}
public copyFile(sourceFilePath: string, destinationFilePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const eventHandler = (_evt: any, arg: { error?: Error }) => {
this._electronService.ipcRenderer.off(destinationFilePath, eventHandler);
if (arg.error) {
return reject(arg.error);
}
resolve(destinationFilePath);
};
resolve(destinationFilePath);
};
const request: CopyFileRequest = {
destinationFilePath,
sourceFilePath,
responseKey: uuidv4(),
};
const request: CopyFileRequest = {
destinationFilePath,
sourceFilePath
};
this._electronService.ipcRenderer.on(destinationFilePath, eventHandler);
this._electronService.ipcRenderer.send(COPY_FILE_CHANNEL, request);
})
}
}
this._electronService.ipcRenderer.once(request.responseKey, eventHandler);
this._electronService.ipcRenderer.send(COPY_FILE_CHANNEL, request);
});
}
}

View File

@@ -1,14 +1,17 @@
import { Injectable } from '@angular/core';
import { Injectable } from "@angular/core";
import { v4 as uuidv4 } from "uuid";
// If you import a module but never use any of the imported values other than as TypeScript types,
// the resulting javascript file will look as if you never imported the module at all.
import { ipcRenderer, webFrame, remote, shell } from 'electron';
import * as childProcess from 'child_process';
import * as fs from 'fs';
import { BehaviorSubject } from 'rxjs';
import { ipcRenderer, webFrame, remote, shell } from "electron";
import * as childProcess from "child_process";
import * as fs from "fs";
import { BehaviorSubject } from "rxjs";
import { ValueResponse } from "common/models/value-response";
import { ValueRequest } from "common/models/value-request";
@Injectable({
providedIn: 'root'
providedIn: "root",
})
export class ElectronService {
private readonly _windowMaximizedSrc = new BehaviorSubject(false);
@@ -31,7 +34,7 @@ export class ElectronService {
}
get locale(): string {
return this.remote.app.getLocale().split('-')[0];
return this.remote.app.getLocale().split("-")[0];
}
constructor() {
@@ -39,30 +42,31 @@ export class ElectronService {
if (!this.isElectron) {
return;
}
this.ipcRenderer = window.require('electron').ipcRenderer;
this.webFrame = window.require('electron').webFrame;
this.remote = window.require('electron').remote;
this.shell = window.require('electron').shell;
this.ipcRenderer = window.require("electron").ipcRenderer;
this.webFrame = window.require("electron").webFrame;
this.remote = window.require("electron").remote;
this.shell = window.require("electron").shell;
this.childProcess = window.require('child_process');
this.fs = window.require('fs');
this.childProcess = window.require("child_process");
this.fs = window.require("fs");
this.remote.getCurrentWindow().on('minimize', () => {
this.remote.getCurrentWindow().on("minimize", () => {
this._windowMinimizedSrc.next(true);
});
this.remote.getCurrentWindow().on('restore', () => {
this.remote.getCurrentWindow().on("restore", () => {
this._windowMinimizedSrc.next(false);
});
this.remote.getCurrentWindow().on('maximize', () => {
this.remote.getCurrentWindow().on("maximize", () => {
this._windowMaximizedSrc.next(true);
});
this.remote.getCurrentWindow().on('unmaximize', () => {
this.remote.getCurrentWindow().on("unmaximize", () => {
this._windowMaximizedSrc.next(false);
});
this._windowMaximizedSrc.next(this.remote.getCurrentWindow().isMaximized());
}
minimizeWindow() {
@@ -85,4 +89,31 @@ export class ElectronService {
this.remote.getCurrentWindow().close();
this.remote.app.quit();
}
public showNotification(title: string, options?: NotificationOptions) {
const myNotification = new Notification(title, options);
}
public sendIpcValueMessage<TIN, TOUT>(
channel: string,
value: TIN
): Promise<TOUT> {
return new Promise((resolve, reject) => {
const eventHandler = (_evt: any, arg: ValueResponse<TOUT>) => {
if (arg.error) {
return reject(arg.error);
}
resolve(arg.value);
};
const request: ValueRequest<TIN> = {
value,
responseKey: uuidv4(),
};
this.ipcRenderer.once(request.responseKey, eventHandler);
this.ipcRenderer.send(channel, request);
});
}
}

View File

@@ -1,27 +1,41 @@
import { Injectable } from "@angular/core";
import { COPY_DIRECTORY_CHANNEL, DELETE_DIRECTORY_CHANNEL, LIST_DIRECTORIES_CHANNEL, LIST_FILES_CHANNEL, PATH_EXISTS_CHANNEL, READ_FILE_CHANNEL, RENAME_DIRECTORY_CHANNEL, SHOW_DIRECTORY } from "common/constants";
import {
COPY_DIRECTORY_CHANNEL,
DELETE_DIRECTORY_CHANNEL,
GET_ASSET_FILE_PATH,
LIST_DIRECTORIES_CHANNEL,
LIST_FILES_CHANNEL,
PATH_EXISTS_CHANNEL,
READ_FILE_CHANNEL,
RENAME_DIRECTORY_CHANNEL,
SHOW_DIRECTORY,
} from "common/constants";
import { CopyDirectoryRequest } from "common/models/copy-directory-request";
import { DeleteDirectoryRequest } from "common/models/delete-directory-request";
import { ElectronService } from "../electron/electron.service";
import * as fs from 'fs';
import * as globrex from 'globrex';
import * as fs from "fs";
import * as globrex from "globrex";
import { ReadFileResponse } from "common/models/read-file-response";
import { ReadFileRequest } from "common/models/read-file-request";
import { ListFilesResponse } from "common/models/list-files-response";
import { ListFilesRequest } from "common/models/list-files-request";
import { ShowDirectoryRequest } from "common/models/show-directory-request";
import { v4 as uuidv4 } from 'uuid';
import { v4 as uuidv4 } from "uuid";
import { ValueRequest } from "common/models/value-request";
import { ValueResponse } from "common/models/value-response";
@Injectable({
providedIn: 'root'
providedIn: "root",
})
export class FileService {
constructor(private _electronService: ElectronService) {}
constructor(
private _electronService: ElectronService
) { }
public async getAssetFilePath(fileName: string) {
return await this._electronService.sendIpcValueMessage<string, string>(
GET_ASSET_FILE_PATH,
fileName
);
}
public showDirectory(sourceDir: string) {
return new Promise((resolve, reject) => {
@@ -29,11 +43,14 @@ export class FileService {
resolve(arg);
};
const request: ShowDirectoryRequest = { sourceDir, responseKey: uuidv4() };
const request: ShowDirectoryRequest = {
sourceDir,
responseKey: uuidv4(),
};
this._electronService.ipcRenderer.once(request.responseKey, eventHandler);
this._electronService.ipcRenderer.send(SHOW_DIRECTORY, request);
})
});
}
public pathExists(sourcePath: string) {
@@ -45,11 +62,14 @@ export class FileService {
resolve(arg.value);
};
const request: ValueRequest<string> = { value: sourcePath, responseKey: uuidv4() };
const request: ValueRequest<string> = {
value: sourcePath,
responseKey: uuidv4(),
};
this._electronService.ipcRenderer.once(request.responseKey, eventHandler);
this._electronService.ipcRenderer.send(PATH_EXISTS_CHANNEL, request);
})
});
}
public deleteDirectory(sourcePath: string) {
@@ -61,11 +81,14 @@ export class FileService {
resolve(sourcePath);
};
const request: DeleteDirectoryRequest = { sourcePath };
const request: DeleteDirectoryRequest = {
sourcePath,
responseKey: uuidv4(),
};
this._electronService.ipcRenderer.once(sourcePath, eventHandler);
this._electronService.ipcRenderer.once(request.responseKey, eventHandler);
this._electronService.ipcRenderer.send(DELETE_DIRECTORY_CHANNEL, request);
})
});
}
public copyDirectory(sourcePath: string, destinationPath: string) {
@@ -78,11 +101,15 @@ export class FileService {
resolve(destinationPath);
};
const request: CopyDirectoryRequest = { sourcePath, destinationPath };
const request: CopyDirectoryRequest = {
sourcePath,
destinationPath,
responseKey: uuidv4(),
};
this._electronService.ipcRenderer.once(destinationPath, eventHandler);
this._electronService.ipcRenderer.once(request.responseKey, eventHandler);
this._electronService.ipcRenderer.send(COPY_DIRECTORY_CHANNEL, request);
})
});
}
public renameDirectory(sourcePath: string, destinationPath: string) {
@@ -95,9 +122,13 @@ export class FileService {
resolve(destinationPath);
};
const request: CopyDirectoryRequest = { sourcePath, destinationPath };
const request: CopyDirectoryRequest = {
sourcePath,
destinationPath,
responseKey: uuidv4(),
};
this._electronService.ipcRenderer.once(destinationPath, eventHandler);
this._electronService.ipcRenderer.once(request.responseKey, eventHandler);
this._electronService.ipcRenderer.send(RENAME_DIRECTORY_CHANNEL, request);
});
}
@@ -112,11 +143,14 @@ export class FileService {
resolve(arg.data);
};
const request: ReadFileRequest = { sourcePath };
const request: ReadFileRequest = {
sourcePath,
responseKey: uuidv4(),
};
this._electronService.ipcRenderer.once(sourcePath, eventHandler);
this._electronService.ipcRenderer.once(request.responseKey, eventHandler);
this._electronService.ipcRenderer.send(READ_FILE_CHANNEL, request);
})
});
}
public listDirectories(sourcePath: string): Promise<string[]> {
@@ -129,7 +163,10 @@ export class FileService {
resolve(arg.value);
};
const request: ValueRequest<string> = { value: sourcePath, responseKey: uuidv4() };
const request: ValueRequest<string> = {
value: sourcePath,
responseKey: uuidv4(),
};
this._electronService.ipcRenderer.once(request.responseKey, eventHandler);
this._electronService.ipcRenderer.send(LIST_DIRECTORIES_CHANNEL, request);
@@ -139,12 +176,16 @@ export class FileService {
public listFiles(sourcePath: string, filter: string) {
const globFilter = globrex(filter);
return fs.readdirSync(sourcePath, { withFileTypes: true })
.filter(entry => !!globFilter.regex.test(entry.name))
.map(entry => entry.name);
return fs
.readdirSync(sourcePath, { withFileTypes: true })
.filter((entry) => !!globFilter.regex.test(entry.name))
.map((entry) => entry.name);
}
public listAllFiles(sourcePath: string, recursive: boolean = true): Promise<string[]> {
public listAllFiles(
sourcePath: string,
recursive: boolean = true
): Promise<string[]> {
return new Promise((resolve, reject) => {
const eventHandler = (_evt: any, arg: ListFilesResponse) => {
if (arg.error) {
@@ -154,10 +195,14 @@ export class FileService {
resolve(arg.files);
};
const request: ListFilesRequest = { sourcePath, recursive, responseKey: uuidv4() };
const request: ListFilesRequest = {
sourcePath,
recursive,
responseKey: uuidv4(),
};
this._electronService.ipcRenderer.once(sourcePath, eventHandler);
this._electronService.ipcRenderer.once(request.responseKey, eventHandler);
this._electronService.ipcRenderer.send(LIST_FILES_CHANNEL, request);
});
}
}
}

View File

@@ -28,7 +28,11 @@ export class SessionService {
this.loadInitialClientType().pipe(first()).subscribe();
}
public set contextText(text: string) {
public setContextText(tabIndex: number, text: string) {
if (tabIndex !== this._selectedHomeTabSrc.value) {
return;
}
this._pageContextTextSrc.next(text);
}
@@ -37,8 +41,8 @@ export class SessionService {
}
public set selectedHomeTab(tabIndex: number) {
this._pageContextTextSrc.next("");
this._selectedHomeTabSrc.next(tabIndex);
this.contextText = "";
}
public set selectedClientType(clientType: WowClientType) {

View File

@@ -33,7 +33,7 @@ export class AddonStorageService {
return addons;
}
public setAll(addons: Addon[]) {
public saveAll(addons: Addon[]) {
addons.forEach(addon => this.set(addon.id, addon));
}
@@ -53,7 +53,7 @@ export class AddonStorageService {
this._store.delete(addon.id);
}
public removeForClientType(clientType: WowClientType) {
public removeAllForClientType(clientType: WowClientType) {
const addons = this.getAllForClientType(clientType);
addons.forEach(addon => this._store.delete(addon.id));
}

View File

@@ -1,9 +1,9 @@
import { Injectable } from "@angular/core";
import { CachingService } from "../caching/caching-service";
import { remote } from "electron";
import { join } from 'path';
import { existsSync } from 'fs';
import { v4 as uuidv4 } from 'uuid';
import { join } from "path";
import { existsSync } from "fs";
import { v4 as uuidv4 } from "uuid";
import { PreferenceStorageService } from "../storage/preference-storage.service";
import { WowUpReleaseChannelType } from "app/models/wowup/wowup-release-channel-type";
import { getEnumList, getEnumName } from "app/utils/enum.utils";
@@ -15,7 +15,7 @@ import { from, Observable, of, Subject } from "rxjs";
import { LatestVersionResponse } from "app/models/wowup-api/latest-version-response";
import { map, switchMap } from "rxjs/operators";
import { LatestVersion } from "app/models/wowup-api/latest-version";
import * as compareVersions from 'compare-versions';
import * as compareVersions from "compare-versions";
import { DownloadSevice } from "../download/download.service";
import { PreferenceChange } from "app/models/wowup/preference-change";
import { FileService } from "../files/file.service";
@@ -24,24 +24,32 @@ import {
defaultAutoUpdateKeySuffix,
defaultChannelKeySuffix,
lastSelectedWowClientTypeKey,
telemetryEnabledKey,
wowupReleaseChannelKey
wowupReleaseChannelKey,
} from "../../../constants";
const LATEST_VERSION_CACHE_KEY = 'latest-version-response';
const LATEST_VERSION_CACHE_KEY = "latest-version-response";
@Injectable({
providedIn: 'root'
providedIn: "root",
})
export class WowUpService {
private readonly _preferenceChangeSrc = new Subject<PreferenceChange>();
public readonly updaterName = 'WowUpUpdater.exe';
public readonly applicationFolderPath: string = remote.app.getPath('userData');
public readonly applicationLogsFolderPath: string = remote.app.getPath('logs');
public readonly applicationDownloadsFolderPath: string = join(this.applicationFolderPath, 'downloads');
public readonly applicationUpdaterPath: string = join(this.applicationFolderPath, this.updaterName);
public readonly updaterName = "WowUpUpdater.exe";
public readonly applicationFolderPath: string = remote.app.getPath(
"userData"
);
public readonly applicationLogsFolderPath: string = remote.app.getPath(
"logs"
);
public readonly applicationDownloadsFolderPath: string = join(
this.applicationFolderPath,
"downloads"
);
public readonly applicationUpdaterPath: string = join(
this.applicationFolderPath,
this.updaterName
);
public readonly applicationVersion: string;
public readonly isBetaBuild: boolean;
public readonly preferenceChange$ = this._preferenceChangeSrc.asObservable();
@@ -57,7 +65,8 @@ export class WowUpService {
this.setDefaultPreferences();
this.applicationVersion = _electronService.remote.app.getVersion();
this.isBetaBuild = this.applicationVersion.toLowerCase().indexOf('beta') != -1;
this.isBetaBuild =
this.applicationVersion.toLowerCase().indexOf("beta") != -1;
}
public get updaterExists() {
@@ -65,29 +74,22 @@ export class WowUpService {
}
public get collapseToTray() {
const preference = this._preferenceStorageService.findByKey(collapseToTrayKey);
return preference === 'true';
const preference = this._preferenceStorageService.findByKey(
collapseToTrayKey
);
return preference === "true";
}
public set collapseToTray(value: boolean) {
const key = collapseToTrayKey;
this._preferenceStorageService.set(key, value);
this._preferenceChangeSrc.next({ key, value: value.toString() })
}
public get telemetryEnabled() {
const preference = this._preferenceStorageService.findByKey(telemetryEnabledKey);
return preference === 'true';
}
public set telemetryEnabled(value: boolean) {
const key = telemetryEnabledKey;
this._preferenceStorageService.set(key, value);
this._preferenceChangeSrc.next({ key, value: value.toString() })
this._preferenceChangeSrc.next({ key, value: value.toString() });
}
public get wowUpReleaseChannel() {
const preference = this._preferenceStorageService.findByKey(wowupReleaseChannelKey);
const preference = this._preferenceStorageService.findByKey(
wowupReleaseChannelKey
);
return parseInt(preference, 10) as WowUpReleaseChannelType;
}
@@ -96,15 +98,23 @@ export class WowUpService {
}
public get lastSelectedClientType(): WowClientType {
const preference = this._preferenceStorageService.findByKey(lastSelectedWowClientTypeKey);
const preference = this._preferenceStorageService.findByKey(
lastSelectedWowClientTypeKey
);
const value = parseInt(preference, 10);
return isNaN(value)
? WowClientType.None
: value as WowClientType;
return isNaN(value) ? WowClientType.None : (value as WowClientType);
}
public set lastSelectedClientType(clientType: WowClientType) {
this._preferenceStorageService.set(lastSelectedWowClientTypeKey, clientType);
this._preferenceStorageService.set(
lastSelectedWowClientTypeKey,
clientType
);
}
public getClientDefaultAddonChannelKey(clientType: WowClientType) {
const typeName = getEnumName(WowClientType, clientType);
return `${typeName}${defaultChannelKeySuffix}`.toLowerCase();
}
public getDefaultAddonChannel(clientType: WowClientType): AddonChannelType {
@@ -113,9 +123,13 @@ export class WowUpService {
return parseInt(preference, 10) as AddonChannelType;
}
public setDefaultAddonChannel(clientType: WowClientType, channelType: AddonChannelType) {
public setDefaultAddonChannel(
clientType: WowClientType,
channelType: AddonChannelType
) {
const key = this.getClientDefaultAddonChannelKey(clientType);
this._preferenceStorageService.set(key, channelType);
this._preferenceChangeSrc.next({ key, value: channelType.toString() });
}
public getDefaultAutoUpdate(clientType: WowClientType): boolean {
@@ -136,51 +150,68 @@ export class WowUpService {
public isUpdateAvailable(): Observable<boolean> {
const releaseChannel = this.wowUpReleaseChannel;
return this.getLatestWowUpVersion(releaseChannel)
.pipe(
map(response => {
if (!response?.version) {
console.error("Got empty WowUp version");
return false;
}
return this.getLatestWowUpVersion(releaseChannel).pipe(
map((response) => {
if (!response?.version) {
console.error("Got empty WowUp version");
return false;
}
if (this.isBetaBuild && releaseChannel != WowUpReleaseChannelType.Beta) {
return true;
}
if (
this.isBetaBuild &&
releaseChannel != WowUpReleaseChannelType.Beta
) {
return true;
}
return compareVersions(response.version, this._electronService.remote.app.getVersion()) > 0;
})
);
return (
compareVersions(
response.version,
this._electronService.remote.app.getVersion()
) > 0
);
})
);
}
public getLatestWowUpVersion(channel: WowUpReleaseChannelType): Observable<LatestVersion> {
const cachedResponse = this._cacheService.get<LatestVersionResponse>(LATEST_VERSION_CACHE_KEY);
public getLatestWowUpVersion(
channel: WowUpReleaseChannelType
): Observable<LatestVersion> {
const cachedResponse = this._cacheService.get<LatestVersionResponse>(
LATEST_VERSION_CACHE_KEY
);
if (cachedResponse) {
return of(channel === WowUpReleaseChannelType.Beta ? cachedResponse.beta : cachedResponse.stable);
}
return this._wowUpApiService.getLatestVersion()
.pipe(
map(response => {
this._cacheService.set(LATEST_VERSION_CACHE_KEY, response);
return channel === WowUpReleaseChannelType.Beta ? response.beta : response.stable;
})
return of(
channel === WowUpReleaseChannelType.Beta
? cachedResponse.beta
: cachedResponse.stable
);
}
return this._wowUpApiService.getLatestVersion().pipe(
map((response) => {
this._cacheService.set(LATEST_VERSION_CACHE_KEY, response);
return channel === WowUpReleaseChannelType.Beta
? response.beta
: response.stable;
})
);
}
public getLatestUpdaterVersion() {
return this._wowUpApiService.getLatestVersion()
.pipe(
map(response => {
return response.updater;
})
);
return this._wowUpApiService.getLatestVersion().pipe(
map((response) => {
return response.updater;
})
);
}
public installUpdate() {
// TODO
}
public checkUpdaterApp(onProgress?: (progress: number) => void): Observable<void> {
public checkUpdaterApp(
onProgress?: (progress: number) => void
): Observable<void> {
if (this.updaterExists) {
return of(undefined);
} else {
@@ -188,23 +219,37 @@ export class WowUpService {
}
}
private installUpdater(onProgress?: (progress: number) => void): Observable<void> {
return this.getLatestUpdaterVersion()
.pipe(
switchMap(response => from(this._downloadService.downloadZipFile(response.url, this.applicationDownloadsFolderPath, onProgress))),
switchMap(downloadedPath => {
const unzipPath = join(this.applicationDownloadsFolderPath, uuidv4());
return from(this._downloadService.unzipFile(downloadedPath, unzipPath));
}),
switchMap(unzippedDir => {
console.log(unzippedDir);
const newUpdaterPath = join(unzippedDir, this.updaterName);
return from(this._downloadService.copyFile(newUpdaterPath, this.applicationUpdaterPath));
}),
map(() => {
console.log('DOWNLOAD COMPLETE')
})
)
private installUpdater(
onProgress?: (progress: number) => void
): Observable<void> {
return this.getLatestUpdaterVersion().pipe(
switchMap((response) =>
from(
this._downloadService.downloadZipFile(
response.url,
this.applicationDownloadsFolderPath,
onProgress
)
)
),
switchMap((downloadedPath) => {
const unzipPath = join(this.applicationDownloadsFolderPath, uuidv4());
return from(this._downloadService.unzipFile(downloadedPath, unzipPath));
}),
switchMap((unzippedDir) => {
console.log(unzippedDir);
const newUpdaterPath = join(unzippedDir, this.updaterName);
return from(
this._downloadService.copyFile(
newUpdaterPath,
this.applicationUpdaterPath
)
);
}),
map(() => {
console.log("DOWNLOAD COMPLETE");
})
);
}
private setDefaultPreference(key: string, defaultValue: any) {
@@ -214,11 +259,6 @@ export class WowUpService {
}
}
private getClientDefaultAddonChannelKey(clientType: WowClientType) {
const typeName = getEnumName(WowClientType, clientType);
return `${typeName}${defaultChannelKeySuffix}`.toLowerCase();
}
private getClientDefaultAutoUpdateKey(clientType: WowClientType): string {
const typeName = getEnumName(WowClientType, clientType);
return `${typeName}${defaultAutoUpdateKeySuffix}`.toLowerCase();
@@ -226,13 +266,18 @@ export class WowUpService {
private setDefaultPreferences() {
this.setDefaultPreference(collapseToTrayKey, true);
this.setDefaultPreference(wowupReleaseChannelKey, this.getDefaultReleaseChannel());
this.setDefaultPreference(
wowupReleaseChannelKey,
this.getDefaultReleaseChannel()
);
this.setDefaultClientPreferences();
}
private setDefaultClientPreferences() {
const keys = getEnumList<WowClientType>(WowClientType).filter(key => key !== WowClientType.None);
keys.forEach(key => {
const keys = getEnumList<WowClientType>(WowClientType).filter(
(key) => key !== WowClientType.None
);
keys.forEach((key) => {
const preferenceKey = this.getClientDefaultAddonChannelKey(key);
this.setDefaultPreference(preferenceKey, AddonChannelType.Stable);
@@ -242,6 +287,8 @@ export class WowUpService {
}
private getDefaultReleaseChannel() {
return this.isBetaBuild ? WowUpReleaseChannelType.Beta : WowUpReleaseChannelType.Stable;
return this.isBetaBuild
? WowUpReleaseChannelType.Beta
: WowUpReleaseChannelType.Stable;
}
}
}

View File

@@ -1,5 +1,25 @@
{
"ChangeLogs": [
{
"Version": "2.0.0-alpha.11",
"Description": "Add version label to the 'Get Addons' page.\nUpdates to the addon detail page (by Flippey).\nRussian local updates (by Medok).\nAdd the system notification for auto updates.\nFix some column spacing.\nTelemetry for user actions added.\nFix a bug not allowing users to uninstall an addon with no 'installed folders'.\nHopefully less issues with installing multiple addons at once.\nFix some errors related to addons imported from GitHub."
},
{
"Version": "2.0.0-alpha.10",
"Description": "Temporary CF fingerprint endpoint patch."
},
{
"Version": "2.0.0-alpha.9",
"Description": "Update Russian locale.\nTry to fix some font blurriness.\nRemove debug error in WowInterface provider."
},
{
"Version": "2.0.0-alpha.8",
"Description": "Localize most static text (by Pansa and Medok).\nImplemented addon detail view in my-addons (by Flippeey).\nWindow position/size/maximized state should now be restored when starting the app (by Chops).\nDouble clicking the titlebar on mac should perform the action set in your system settings (by Chops).\nAddon scanning is now much faster.\nWhen scanning addons, the matching channel type should be applied not the default.\nRe-scan is now less complicated and should prevent duplicates.\nAdd the 'Show Folder' button to the addon context menu.\nAdd http circuit breakers to the providers.\nFix issue with My Addons Provider column not sorting.\nPage context data on the footer.\nRemove the windows menu bar buttons for mac.\nAdd WowUp addon provider prototype"
},
{
"Version": "2.0.0-alpha.7",
"Description": "Stuff?"
},
{
"Version": "1.16.1",
"Description": "Default addon channel can now be set separately per World of Warcraft client.\nUI updates.\nBug Fixes."

View File

@@ -14,7 +14,8 @@
"ADDON_COLUMN_HEADER": "Addon",
"AUTHOR_COLUMN_HEADER": "Autor",
"PROVIDER_COLUMN_HEADER": "Anbieter",
"STATUS_COLUMN_HEADER": "Status"
"STATUS_COLUMN_HEADER": "Status",
"DOWNLOAD_COUNT_COLUMN_HEADER": "TEXT_ELEMENT"
}
},
"HOME": {
@@ -48,6 +49,7 @@
"IGNORE_ADDON_BUTTON": "Ignorieren",
"AUTO_UPDATE_ADDON_BUTTON": "Automatisches Aktualisieren",
"CHANNEL_SUBMENT_TITLE": "Kanal",
"SHOW_FOLDER": "SHOW_FOLDER",
"REINSTALL_ADDON_BUTTON": "Neu installieren",
"REMOVE_ADDON_BUTTON": "Entfernen",
"STABLE_ADDON_CHANNEL": "Stall",
@@ -118,5 +120,21 @@
"POSITIVE_BUTTON": "Sicher!",
"TITLE": "WowUp Telemetrie"
}
},
"COMMON": {
"ADDON_STATUS": {
"COMPLETE": "COMPLETE",
"DOWNLOADING": "DOWNLOADING",
"INSTALLING": "INSTALLING",
"UNINSTALLING": "UNINSTALLING",
"UPDATING": "UPDATING"
},
"ADDON_STATE": {
"UNINSTALL": "UNINSTALL",
"IGNORED": "IGNORED",
"UPDATE": "UPDATE",
"INSTALL": "INSTALL",
"UPTODATE": "UPTODATE"
}
}
}
}

View File

@@ -14,7 +14,8 @@
"ADDON_COLUMN_HEADER": "Addon",
"AUTHOR_COLUMN_HEADER": "Author",
"PROVIDER_COLUMN_HEADER": "Provider",
"STATUS_COLUMN_HEADER": "Status"
"STATUS_COLUMN_HEADER": "Status",
"DOWNLOAD_COUNT_COLUMN_HEADER": "Downloads"
}
},
"HOME": {
@@ -49,6 +50,7 @@
"IGNORE_ADDON_BUTTON": "Ignore",
"AUTO_UPDATE_ADDON_BUTTON": "Auto Update",
"CHANNEL_SUBMENT_TITLE": "Channel",
"SHOW_FOLDER": "Show Folder",
"REINSTALL_ADDON_BUTTON": "Re-Install",
"REMOVE_ADDON_BUTTON": "Remove",
"STABLE_ADDON_CHANNEL": "Stable",
@@ -119,5 +121,23 @@
"POSITIVE_BUTTON": "Sure!",
"TITLE": "WowUp Telemetry"
}
},
"COMMON": {
"ADDON_STATUS": {
"BACKINGUP": "Backing Up",
"COMPLETE": "Installed",
"DOWNLOADING": "Downloading",
"INSTALLING": "Installing",
"PENDING": "Pending",
"UNINSTALLING": "Uninstalling",
"UPDATING": "Updating..."
},
"ADDON_STATE": {
"UNINSTALL": "Uninstall",
"IGNORED": "Ignored",
"UPDATE": "Update",
"INSTALL": "Install",
"UPTODATE": "Up to date"
}
}
}

View File

@@ -14,7 +14,8 @@
"ADDON_COLUMN_HEADER": "Addon",
"AUTHOR_COLUMN_HEADER": "Autor",
"PROVIDER_COLUMN_HEADER": "Proveedor",
"STATUS_COLUMN_HEADER": "Estado"
"STATUS_COLUMN_HEADER": "Estado",
"DOWNLOAD_COUNT_COLUMN_HEADER": "TEXT_ELEMENT"
}
},
"HOME": {
@@ -48,6 +49,7 @@
"IGNORE_ADDON_BUTTON": "Ignorar",
"AUTO_UPDATE_ADDON_BUTTON": "Actualización automática",
"CHANNEL_SUBMENT_TITLE": "Canal",
"SHOW_FOLDER": "SHOW_FOLDER",
"REINSTALL_ADDON_BUTTON": "Reinstalar",
"REMOVE_ADDON_BUTTON": "Eliminar",
"STABLE_ADDON_CHANNEL": "Estable",
@@ -118,5 +120,21 @@
"POSITIVE_BUTTON": "¡Seguro!",
"TITLE": "Telemetría WowUp"
}
},
"COMMON": {
"ADDON_STATUS": {
"COMPLETE": "COMPLETE",
"DOWNLOADING": "DOWNLOADING",
"INSTALLING": "INSTALLING",
"UNINSTALLING": "UNINSTALLING",
"UPDATING": "UPDATING"
},
"ADDON_STATE": {
"UNINSTALL": "UNINSTALL",
"IGNORED": "IGNORED",
"UPDATE": "UPDATE",
"INSTALL": "INSTALL",
"UPTODATE": "UPTODATE"
}
}
}
}

View File

@@ -14,7 +14,8 @@
"ADDON_COLUMN_HEADER": "Addon",
"AUTHOR_COLUMN_HEADER": "Auteur",
"PROVIDER_COLUMN_HEADER": "Fournisseur",
"STATUS_COLUMN_HEADER": "Statut"
"STATUS_COLUMN_HEADER": "Statut",
"DOWNLOAD_COUNT_COLUMN_HEADER": "TEXT_ELEMENT"
}
},
"HOME": {
@@ -48,6 +49,7 @@
"IGNORE_ADDON_BUTTON": "Ignorer",
"AUTO_UPDATE_ADDON_BUTTON": "Mise à jour automatique",
"CHANNEL_SUBMENT_TITLE": "Chaîne",
"SHOW_FOLDER": "SHOW_FOLDER",
"REINSTALL_ADDON_BUTTON": "Réinstaller",
"REMOVE_ADDON_BUTTON": "Retirer",
"STABLE_ADDON_CHANNEL": "Écurie",
@@ -118,5 +120,21 @@
"POSITIVE_BUTTON": "Bien sûr!",
"TITLE": "Télémétrie WowUp"
}
},
"COMMON": {
"ADDON_STATUS": {
"COMPLETE": "COMPLETE",
"DOWNLOADING": "DOWNLOADING",
"INSTALLING": "INSTALLING",
"UNINSTALLING": "UNINSTALLING",
"UPDATING": "UPDATING"
},
"ADDON_STATE": {
"UNINSTALL": "UNINSTALL",
"IGNORED": "IGNORED",
"UPDATE": "UPDATE",
"INSTALL": "INSTALL",
"UPTODATE": "UPTODATE"
}
}
}
}

View File

@@ -14,7 +14,8 @@
"ADDON_COLUMN_HEADER": "Addon",
"AUTHOR_COLUMN_HEADER": "Autore",
"PROVIDER_COLUMN_HEADER": "Provveditore",
"STATUS_COLUMN_HEADER": "Stato"
"STATUS_COLUMN_HEADER": "Stato",
"DOWNLOAD_COUNT_COLUMN_HEADER": "TEXT_ELEMENT"
}
},
"HOME": {
@@ -48,6 +49,7 @@
"IGNORE_ADDON_BUTTON": "Ignora",
"AUTO_UPDATE_ADDON_BUTTON": "Aggiornamento Automatico",
"CHANNEL_SUBMENT_TITLE": "Canale",
"SHOW_FOLDER": "SHOW_FOLDER",
"REINSTALL_ADDON_BUTTON": "Reinstalla",
"REMOVE_ADDON_BUTTON": "Rimuovi",
"STABLE_ADDON_CHANNEL": "Stabile",
@@ -118,5 +120,21 @@
"POSITIVE_BUTTON": "Certo!",
"TITLE": "Telemetria WowUp"
}
},
"COMMON": {
"ADDON_STATUS": {
"COMPLETE": "COMPLETE",
"DOWNLOADING": "DOWNLOADING",
"INSTALLING": "INSTALLING",
"UNINSTALLING": "UNINSTALLING",
"UPDATING": "UPDATING"
},
"ADDON_STATE": {
"UNINSTALL": "UNINSTALL",
"IGNORED": "IGNORED",
"UPDATE": "UPDATE",
"INSTALL": "INSTALL",
"UPTODATE": "UPTODATE"
}
}
}

View File

@@ -1,123 +1,141 @@
{
"PAGES": {
"ABOUT": {
"CHANGE_LOG_SECTION_LABEL": "Registro de Alterações",
"TITLE": "WowUp.io",
"WEBSITE_LINK_LABEL": "Conheça o nosso site!"
},
"GET_ADDONS": {
"CLIENT_TYPE_SELECT_LABEL": "World of Warcraft",
"REFRESH_BUTTON": "Atualizar",
"INSTALL_FROM_URL_BUTTON": "Instalar pela URL",
"SEARCH_LABEL": "Procurar",
"TABLE": {
"ADDON_COLUMN_HEADER": "Addon",
"AUTHOR_COLUMN_HEADER": "Autor",
"PROVIDER_COLUMN_HEADER": "Provedor",
"STATUS_COLUMN_HEADER": "Estado"
}
},
"HOME": {
"TITLE": "App funciona!",
"GO_TO_DETAIL": "Ir para Detalhes",
"MY_ADDONS_TAB_TITLE": "Meus Addons",
"GET_ADDONS_TAB_TITLE": "Obtenha Addons",
"ABOUT_TAB_TITLE": "Sobre",
"OPTIONS_TAB_TITLE": "Opções"
},
"MY_ADDONS": {
"CHECK_UPDATES_BUTTON": "Verificar Atualizações",
"CHECK_UPDATES_BUTTON_TOOLTIP": "Verificar atualizações recentes",
"CLIENT_TYPE_SELECT_LABEL": "World of Warcraft",
"RESCAN_FOLDERS_BUTTON": "Re-escanear pastas",
"RESCAN_FOLDERS_BUTTON_TOOLTIP": "Procura por Addons instalados",
"UPDATE_ALL_BUTTON": "Actualizar todos",
"UPDATE_ALL_BUTTON_TOOLTIP": "Atualizar todos os Addons",
"TABLE": {
"ADDON_COLUMN_HEADER": "Addon",
"ADDON_INSTALL_BUTTON": "Instalar",
"ADDON_UPDATE_BUTTON": "Atualizar",
"AUTHOR_COLUMN_HEADER": "Autor",
"AUTO_UPDATE_ICON_TOOLTIP": "Atualização automática habilitada",
"GAME_VERSION_COLUMN_HEADER": "Versão do Jogo",
"LATEST_VERSION_COLUMN_HEADER": "Ultima versão",
"PROVIDER_COLUMN_HEADER": "Provedor",
"STATUS_COLUMN_HEADER": "Estado"
},
"ADDON_CONTEXT_MENU": {
"IGNORE_ADDON_BUTTON": "Ignorar",
"AUTO_UPDATE_ADDON_BUTTON": "Atualização Automática",
"CHANNEL_SUBMENT_TITLE": "Canal",
"REINSTALL_ADDON_BUTTON": "Reinstalar",
"REMOVE_ADDON_BUTTON": "Remover",
"STABLE_ADDON_CHANNEL": "Estável",
"BETA_ADDON_CHANNEL": "Beta",
"ALPHA_ADDON_CHANNEL": "Alfa"
},
"COLUMNS_CONTEXT_MENU": {
"TITLE": "Exibir Colunas"
},
"UPDATE_ALL_CONTEXT_MENU": {
"UPDATE_RETAIL_CLASSIC_BUTTON": "Atualizar Retail/Clássico",
"UPDATE_ALL_CLIENTS_BUTTON": "Atualizar todos os clientes"
}
},
"OPTIONS": {
"APPLICATION": {
"MINIMIZE_ON_CLOSE_LABEL": "Minimizar ao Fechar",
"MINIMIZE_ON_CLOSE_DESCRIPTION": "Ao fechar a janela do WowUp, minimize para a bandeja do sistema.",
"TELEMETRY_DESCRIPTION": "Ajude a melhorar o WowUp enviando dados e/ou erros de instalação anônimamente.",
"TELEMETRY_LABEL": "Telemetria",
"TITLE": "Aplicativo"
},
"DEBUG": {
"DEBUG_DATA_BUTTON": "Esvaziar log de depuração de dados",
"DEBUG_DATA_DESCRIPTION": "Registra os dados de depuração e ajuda a diagnosticar problemas potenciais. Apenas por o curiosidade, isso pode ser encontrado em seu último arquivo de registro.",
"DEBUG_DATA_LABEL": "Depurar Dados",
"LOG_FILES_BUTTON": "Mostrar Arquivos de Registro",
"LOG_FILES_DESCRIPTION": "Abre a pasta que contém seus últimos arquivos de registro.",
"LOG_FILES_LABEL": "Arquivos de Registro",
"TITLE": "Depurar"
},
"WOW": {
"AUTO_UPDATE_DESCRIPTION": "Addons recém-instalados serão definidos para atualizar automáticamente por padrão",
"AUTO_UPDATE_LABEL": "Atualização Automática",
"TITLE": "World of Warcraft",
"DEFAULT_ADDON_CHANNEL_LABEL": "Canal de Addon Padrão",
"DEFAULT_ADDON_CHANNEL_SELECT_LABEL": "Canal de Addon",
"RESCAN_CLIENTS_BUTTON": "Re-escanear",
"RESCAN_CLIENTS_LABEL": "Reescanear World of Warcraft instalados"
}
"PAGES": {
"ABOUT": {
"CHANGE_LOG_SECTION_LABEL": "Registro de Alterações",
"TITLE": "WowUp.io",
"WEBSITE_LINK_LABEL": "Conheça o nosso site!"
},
"GET_ADDONS": {
"CLIENT_TYPE_SELECT_LABEL": "World of Warcraft",
"REFRESH_BUTTON": "Atualizar",
"INSTALL_FROM_URL_BUTTON": "Instalar pela URL",
"SEARCH_LABEL": "Procurar",
"TABLE": {
"ADDON_COLUMN_HEADER": "Addon",
"AUTHOR_COLUMN_HEADER": "Autor",
"PROVIDER_COLUMN_HEADER": "Provedor",
"STATUS_COLUMN_HEADER": "Estado",
"DOWNLOAD_COUNT_COLUMN_HEADER": "TEXT_ELEMENT"
}
},
"DIALOGS": {
"ADDON_DETAILS": {
"VIEW_IN_BROWSER_BUTTON": "Visualizar no navegador"
"HOME": {
"TITLE": "App funciona!",
"GO_TO_DETAIL": "Ir para Detalhes",
"MY_ADDONS_TAB_TITLE": "Meus Addons",
"GET_ADDONS_TAB_TITLE": "Obtenha Addons",
"ABOUT_TAB_TITLE": "Sobre",
"OPTIONS_TAB_TITLE": "Opções"
},
"MY_ADDONS": {
"CHECK_UPDATES_BUTTON": "Verificar Atualizações",
"CHECK_UPDATES_BUTTON_TOOLTIP": "Verificar atualizações recentes",
"CLIENT_TYPE_SELECT_LABEL": "World of Warcraft",
"RESCAN_FOLDERS_BUTTON": "Re-escanear pastas",
"RESCAN_FOLDERS_BUTTON_TOOLTIP": "Procura por Addons instalados",
"UPDATE_ALL_BUTTON": "Actualizar todos",
"UPDATE_ALL_BUTTON_TOOLTIP": "Atualizar todos os Addons",
"TABLE": {
"ADDON_COLUMN_HEADER": "Addon",
"ADDON_INSTALL_BUTTON": "Instalar",
"ADDON_UPDATE_BUTTON": "Atualizar",
"AUTHOR_COLUMN_HEADER": "Autor",
"AUTO_UPDATE_ICON_TOOLTIP": "Atualização automática habilitada",
"GAME_VERSION_COLUMN_HEADER": "Versão do Jogo",
"LATEST_VERSION_COLUMN_HEADER": "Ultima versão",
"PROVIDER_COLUMN_HEADER": "Provedor",
"STATUS_COLUMN_HEADER": "Estado"
},
"ALERT": {
"POSITIVE_BUTTON": "Ok"
"ADDON_CONTEXT_MENU": {
"IGNORE_ADDON_BUTTON": "Ignorar",
"AUTO_UPDATE_ADDON_BUTTON": "Atualização Automática",
"CHANNEL_SUBMENT_TITLE": "Canal",
"SHOW_FOLDER": "SHOW_FOLDER",
"REINSTALL_ADDON_BUTTON": "Reinstalar",
"REMOVE_ADDON_BUTTON": "Remover",
"STABLE_ADDON_CHANNEL": "Estável",
"BETA_ADDON_CHANNEL": "Beta",
"ALPHA_ADDON_CHANNEL": "Alfa"
},
"CONFIRM": {
"NEGATIVE_BUTTON": "Não",
"POSITIVE_BUTTON": "Sim"
"COLUMNS_CONTEXT_MENU": {
"TITLE": "Exibir Colunas"
},
"INSTALL_FROM_URL": {
"ADDON_URL_INPUT_LABEL": "Addon URL",
"ADDON_URL_INPUT_PLACEHOLDER": "Ex. GitHub ou WowInterface URL",
"CLOSE_BUTTON": "Fechar",
"IMPORT_BUTTON": "Importar",
"INSTALL_BUTTON": "Instalar",
"INSTALL_SUCCESS_LABEL": "Instalado!",
"TITLE": "Instalar Addon pela URL",
"DESCRIPTION": "Se você deseja instalar um addon diretamente de uma URL, cole-a abaixo para iniciar.",
"SUPPORTED_SOURCES": "Suporta WowInterface e GitHub*"
"UPDATE_ALL_CONTEXT_MENU": {
"UPDATE_RETAIL_CLASSIC_BUTTON": "Atualizar Retail/Clássico",
"UPDATE_ALL_CLIENTS_BUTTON": "Atualizar todos os clientes"
}
},
"OPTIONS": {
"APPLICATION": {
"MINIMIZE_ON_CLOSE_LABEL": "Minimizar ao Fechar",
"MINIMIZE_ON_CLOSE_DESCRIPTION": "Ao fechar a janela do WowUp, minimize para a bandeja do sistema.",
"TELEMETRY_DESCRIPTION": "Ajude a melhorar o WowUp enviando dados e/ou erros de instalação anônimamente.",
"TELEMETRY_LABEL": "Telemetria",
"TITLE": "Aplicativo"
},
"TELEMETRY": {
"DESCRIPTION": "Ajude-nos a melhorar o WowUp enviando dados e/ou erros de instalação do aplicativo anônimamente?",
"NEGATIVE_BUTTON": "Não obrigado",
"POSITIVE_BUTTON": "Claro!",
"TITLE": "Telemetria do WowUp"
"DEBUG": {
"DEBUG_DATA_BUTTON": "Esvaziar log de depuração de dados",
"DEBUG_DATA_DESCRIPTION": "Registra os dados de depuração e ajuda a diagnosticar problemas potenciais. Apenas por o curiosidade, isso pode ser encontrado em seu último arquivo de registro.",
"DEBUG_DATA_LABEL": "Depurar Dados",
"LOG_FILES_BUTTON": "Mostrar Arquivos de Registro",
"LOG_FILES_DESCRIPTION": "Abre a pasta que contém seus últimos arquivos de registro.",
"LOG_FILES_LABEL": "Arquivos de Registro",
"TITLE": "Depurar"
},
"WOW": {
"AUTO_UPDATE_DESCRIPTION": "Addons recém-instalados serão definidos para atualizar automáticamente por padrão",
"AUTO_UPDATE_LABEL": "Atualização Automática",
"TITLE": "World of Warcraft",
"DEFAULT_ADDON_CHANNEL_LABEL": "Canal de Addon Padrão",
"DEFAULT_ADDON_CHANNEL_SELECT_LABEL": "Canal de Addon",
"RESCAN_CLIENTS_BUTTON": "Re-escanear",
"RESCAN_CLIENTS_LABEL": "Reescanear World of Warcraft instalados"
}
},
"COMMON": {
"ADDON_STATUS": {
"COMPLETE": "COMPLETE",
"DOWNLOADING": "DOWNLOADING",
"INSTALLING": "INSTALLING",
"UNINSTALLING": "UNINSTALLING",
"UPDATING": "UPDATING"
},
"ADDON_STATE": {
"UNINSTALL": "UNINSTALL",
"IGNORED": "IGNORED",
"UPDATE": "UPDATE",
"INSTALL": "INSTALL",
"UPTODATE": "UPTODATE"
}
}
},
"DIALOGS": {
"ADDON_DETAILS": {
"VIEW_IN_BROWSER_BUTTON": "Visualizar no navegador"
},
"ALERT": {
"POSITIVE_BUTTON": "Ok"
},
"CONFIRM": {
"NEGATIVE_BUTTON": "Não",
"POSITIVE_BUTTON": "Sim"
},
"INSTALL_FROM_URL": {
"ADDON_URL_INPUT_LABEL": "Addon URL",
"ADDON_URL_INPUT_PLACEHOLDER": "Ex. GitHub ou WowInterface URL",
"CLOSE_BUTTON": "Fechar",
"IMPORT_BUTTON": "Importar",
"INSTALL_BUTTON": "Instalar",
"INSTALL_SUCCESS_LABEL": "Instalado!",
"TITLE": "Instalar Addon pela URL",
"DESCRIPTION": "Se você deseja instalar um addon diretamente de uma URL, cole-a abaixo para iniciar.",
"SUPPORTED_SOURCES": "Suporta WowInterface e GitHub*"
},
"TELEMETRY": {
"DESCRIPTION": "Ajude-nos a melhorar o WowUp enviando dados e/ou erros de instalação do aplicativo anônimamente?",
"NEGATIVE_BUTTON": "Não obrigado",
"POSITIVE_BUTTON": "Claro!",
"TITLE": "Telemetria do WowUp"
}
}
}

View File

@@ -3,54 +3,57 @@
"ABOUT": {
"CHANGE_LOG_SECTION_LABEL": "Журнал изменений",
"TITLE": "WowUp.io",
"WEBSITE_LINK_LABEL": "Посмотрите на сайт!"
"WEBSITE_LINK_LABEL": "Посетите наш сайт!"
},
"GET_ADDONS": {
"CLIENT_TYPE_SELECT_LABEL": "World of Warcraft",
"REFRESH_BUTTON": "Обновить",
"INSTALL_FROM_URL_BUTTON": "Установить из URL",
"INSTALL_FROM_URL_BUTTON": "Установить по ссылке",
"SEARCH_LABEL": "Искать",
"TABLE": {
"ADDON_COLUMN_HEADER": "Addon",
"ADDON_COLUMN_HEADER": "Модификация",
"AUTHOR_COLUMN_HEADER": "Автор",
"PROVIDER_COLUMN_HEADER": "Поставщик",
"STATUS_COLUMN_HEADER": "Статус"
"PROVIDER_COLUMN_HEADER": "Источник",
"STATUS_COLUMN_HEADER": "Статус",
"DOWNLOAD_COUNT_COLUMN_HEADER": "TEXT_ELEMENT"
}
},
"HOME": {
"TITLE": "Приложение работает !",
"GO_TO_DETAIL": "Детали",
"MY_ADDONS_TAB_TITLE": "Мои аддоны",
"GET_ADDONS_TAB_TITLE": "Получить аддоны",
"MY_ADDONS_TAB_TITLE": "Мои модификации",
"GET_ADDONS_TAB_TITLE": "Получить модификации",
"ABOUT_TAB_TITLE": "О программе",
"OPTIONS_TAB_TITLE": "Варианты"
"OPTIONS_TAB_TITLE": "Настройки"
},
"MY_ADDONS": {
"CHECK_UPDATES_BUTTON": "Проверить обновления",
"CHECK_UPDATES_BUTTON_TOOLTIP": "Проверить наличие последних обновлений аддона",
"CHECK_UPDATES_BUTTON_TOOLTIP": "Проверить наличие последних обновлений модификации",
"CLIENT_TYPE_SELECT_LABEL": "World of Warcraft",
"RESCAN_FOLDERS_BUTTON": "Пересканировать папки",
"RESCAN_FOLDERS_BUTTON_TOOLTIP": "Сканирование клиентской папки для установленных аддонов",
"UPDATE_ALL_BUTTON": "Обновить все",
"UPDATE_ALL_BUTTON_TOOLTIP": "Обновить все аддоны для этого клиента",
"FILTER_LABEL": "Фильтр",
"RESCAN_FOLDERS_BUTTON": "Сканировать папки",
"RESCAN_FOLDERS_BUTTON_TOOLTIP": "Сканирование папки клиента на наличие установленных модификаций",
"UPDATE_ALL_BUTTON": "Обновить всё",
"UPDATE_ALL_BUTTON_TOOLTIP": "Обновить все модификации для этого клиента",
"TABLE": {
"ADDON_COLUMN_HEADER": "Addon",
"ADDON_COLUMN_HEADER": "Модификация",
"ADDON_INSTALL_BUTTON": "Установить",
"ADDON_UPDATE_BUTTON": "Обновить",
"AUTHOR_COLUMN_HEADER": "Автор",
"AUTO_UPDATE_ICON_TOOLTIP": "Автообновление включено",
"GAME_VERSION_COLUMN_HEADER": "Версия игры",
"LATEST_VERSION_COLUMN_HEADER": "Последняя версия",
"PROVIDER_COLUMN_HEADER": "Поставщик",
"PROVIDER_COLUMN_HEADER": "Источник",
"STATUS_COLUMN_HEADER": "Статус"
},
"ADDON_CONTEXT_MENU": {
"IGNORE_ADDON_BUTTON": "Пропустить",
"IGNORE_ADDON_BUTTON": "Пропускать",
"AUTO_UPDATE_ADDON_BUTTON": "Автообновление",
"CHANNEL_SUBMENT_TITLE": "Канал",
"CHANNEL_SUBMENT_TITLE": "Тип выпуска",
"SHOW_FOLDER": "Показать папку",
"REINSTALL_ADDON_BUTTON": "Переустановить",
"REMOVE_ADDON_BUTTON": "Удалить",
"STABLE_ADDON_CHANNEL": "Конюшня",
"STABLE_ADDON_CHANNEL": "Стабильная",
"BETA_ADDON_CHANNEL": "Бета",
"ALPHA_ADDON_CHANNEL": "Альфа"
},
@@ -58,41 +61,41 @@
"TITLE": "Показать колонки"
},
"UPDATE_ALL_CONTEXT_MENU": {
"UPDATE_RETAIL_CLASSIC_BUTTON": "Обновить Retail/Classic",
"UPDATE_ALL_CLIENTS_BUTTON": "Обновить всех клиентов"
"UPDATE_RETAIL_CLASSIC_BUTTON": "Обновить Текущую/Classic",
"UPDATE_ALL_CLIENTS_BUTTON": "Обновить все клиенты"
}
},
"OPTIONS": {
"APPLICATION": {
"MINIMIZE_ON_CLOSE_LABEL": "Свернуть при закрытии",
"MINIMIZE_ON_CLOSE_DESCRIPTION": "При закрытии окна WowUp сворачиваем в системный трей.",
"MINIMIZE_ON_CLOSE_LABEL": "Свернуть в трей при закрытии",
"MINIMIZE_ON_CLOSE_DESCRIPTION": "При закрытии окна WowUp сворачивается в системный трей.",
"TELEMETRY_DESCRIPTION": "Помогите улучшить WowUp, отправив анонимные данные об установке и/или ошибках.",
"TELEMETRY_LABEL": "Телеметрия",
"TITLE": "Приложение"
},
"DEBUG": {
"DEBUG_DATA_BUTTON": "Дамп отладочных данных",
"DEBUG_DATA_DESCRIPTION": "Записывать отладочные данные, чтобы помочь в диагностике потенциальных проблем. Это можно найти в последнем файле журнала для любопытства.",
"DEBUG_DATA_DESCRIPTION": "Записывать отладочные данные, чтобы помочь в диагностике потенциальных проблем. Его можно найти в последнем лог-файле, если необходимо.",
"DEBUG_DATA_LABEL": "Отладка данных",
"LOG_FILES_BUTTON": "Показать лог-файлы",
"LOG_FILES_DESCRIPTION": "Откройте папку, содержащую последние несколько лог файлов.",
"LOG_FILES_DESCRIPTION": "Открыть папку, содержащую последние несколько лог-файлов.",
"LOG_FILES_LABEL": "Файлы логов",
"TITLE": "Debug"
"TITLE": "Отладка"
},
"WOW": {
"AUTO_UPDATE_DESCRIPTION": "Новые установленные дополнения будут автоматически обновляться по умолчанию",
"AUTO_UPDATE_DESCRIPTION": "Новые установленные модификации будут автоматически обновляться по умолчанию",
"AUTO_UPDATE_LABEL": "Автообновление",
"TITLE": "World of Warcraft",
"DEFAULT_ADDON_CHANNEL_LABEL": "Канал аддона по умолчанию",
"DEFAULT_ADDON_CHANNEL_SELECT_LABEL": "Канал дополнения",
"RESCAN_CLIENTS_BUTTON": ересканировать",
"RESCAN_CLIENTS_LABEL": ересканируйте установленные продукты World of Warcraft"
"DEFAULT_ADDON_CHANNEL_LABEL": "Тип выпуска модификации по умолчанию",
"DEFAULT_ADDON_CHANNEL_SELECT_LABEL": "Тип выпуска модификации",
"RESCAN_CLIENTS_BUTTON": овторное сканирование",
"RESCAN_CLIENTS_LABEL": овторно найти установленные продукты World of Warcraft"
}
}
},
"DIALOGS": {
"ADDON_DETAILS": {
"VIEW_IN_BROWSER_BUTTON": росмотр в браузере"
"VIEW_IN_BROWSER_BUTTON": "Посмотреть в браузере"
},
"ALERT": {
"POSITIVE_BUTTON": "Окей"
@@ -102,21 +105,37 @@
"POSITIVE_BUTTON": "Да"
},
"INSTALL_FROM_URL": {
"ADDON_URL_INPUT_LABEL": "Addon URL",
"ADDON_URL_INPUT_PLACEHOLDER": "Пример URL-адреса GitHub или WowInterface",
"ADDON_URL_INPUT_LABEL": "Ссылка на модификацию",
"ADDON_URL_INPUT_PLACEHOLDER": "Например ссылки GitHub или WowInterface",
"CLOSE_BUTTON": "Закрыть",
"IMPORT_BUTTON": "Импорт",
"INSTALL_BUTTON": "Установить",
"INSTALL_SUCCESS_LABEL": "Установлено!",
"TITLE": "Install Addon URL",
"DESCRIPTION": "Если вы хотите установить аддон непосредственно с URL, вставьте его ниже, чтобы начать.",
"SUPPORTED_SOURCES": "Поддерживает WowInterface и GitHub*"
"TITLE": "Ссылка на установку модификации",
"DESCRIPTION": "Если вы хотите установить модификацию непосредственно по ссылке, вставьте её ниже, чтобы начать.",
"SUPPORTED_SOURCES": "Поддерживаются WowInterface и GitHub*"
},
"TELEMETRY": {
"DESCRIPTION": "Помогите мне улучшить WowUp, отправив анонимные данные и/или ошибки в приложении?",
"DESCRIPTION": "Хотите помочь мне улучшить WowUp, анонимно отправляя данные об установке и ошибках?",
"NEGATIVE_BUTTON": "Нет, спасибо",
"POSITIVE_BUTTON": "Конечно!",
"TITLE": "WowUp Телеметрия"
"TITLE": "Телеметрия WowUp"
}
},
"COMMON": {
"ADDON_STATUS": {
"COMPLETE": "COMPLETE",
"DOWNLOADING": "DOWNLOADING",
"INSTALLING": "INSTALLING",
"UNINSTALLING": "UNINSTALLING",
"UPDATING": "UPDATING"
},
"ADDON_STATE": {
"UNINSTALL": "UNINSTALL",
"IGNORED": "IGNORED",
"UPDATE": "UPDATE",
"INSTALL": "INSTALL",
"UPTODATE": "UPTODATE"
}
}
}
}

View File

@@ -14,7 +14,8 @@
"ADDON_COLUMN_HEADER": "Addon",
"AUTHOR_COLUMN_HEADER": "作者",
"PROVIDER_COLUMN_HEADER": "提供商",
"STATUS_COLUMN_HEADER": "状态"
"STATUS_COLUMN_HEADER": "状态",
"DOWNLOAD_COUNT_COLUMN_HEADER": "TEXT_ELEMENT"
}
},
"HOME": {
@@ -48,6 +49,7 @@
"IGNORE_ADDON_BUTTON": "忽略",
"AUTO_UPDATE_ADDON_BUTTON": "自动更新",
"CHANNEL_SUBMENT_TITLE": "频道",
"SHOW_FOLDER": "SHOW_FOLDER",
"REINSTALL_ADDON_BUTTON": "重新安装",
"REMOVE_ADDON_BUTTON": "删除",
"STABLE_ADDON_CHANNEL": "稳定",
@@ -118,5 +120,21 @@
"POSITIVE_BUTTON": "当然!",
"TITLE": "WowUp遥测"
}
},
"COMMON": {
"ADDON_STATUS": {
"COMPLETE": "COMPLETE",
"DOWNLOADING": "DOWNLOADING",
"INSTALLING": "INSTALLING",
"UNINSTALLING": "UNINSTALLING",
"UPDATING": "UPDATING"
},
"ADDON_STATE": {
"UNINSTALL": "UNINSTALL",
"IGNORED": "IGNORED",
"UPDATE": "UPDATE",
"INSTALL": "INSTALL",
"UPTODATE": "UPTODATE"
}
}
}
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View File

@@ -12,3 +12,5 @@ export const COPY_FILE_CHANNEL = "copy-file";
export const CURSE_HASH_FILE_CHANNEL = "curse-hash-file";
export const SHOW_DIRECTORY = "show-directory";
export const CURSE_GET_SCAN_RESULTS = "curse-get-scan-results";
export const WOWUP_GET_SCAN_RESULTS = "wowup-get-scan-results";
export const GET_ASSET_FILE_PATH = "get-asset-file-path";

View File

@@ -1,9 +1,9 @@
import { CurseAuthor } from "../../../common/curse/curse-author";
import { CurseAttachment } from "../../../common/curse/curse-attachment";
import { CurseFile } from "../../../common/curse/curse-file";
import { CurseCategory } from "../../../common/curse/curse-category";
import { CurseCategorySection } from "../../../common/curse/curse-category-section";
import { CurseGameVersionLatestFile } from "../../../common/curse/curse-game-version-latest-file";
import { CurseAuthor } from "./curse-author";
import { CurseAttachment } from "./curse-attachment";
import { CurseFile } from "./curse-file";
import { CurseCategory } from "./curse-category";
import { CurseCategorySection } from "./curse-category-section";
import { CurseGameVersionLatestFile } from "./curse-game-version-latest-file";
export interface CurseSearchResult {
id: number;

View File

@@ -1,4 +1,6 @@
export interface CopyDirectoryRequest {
sourcePath: string;
destinationPath: string;
}
import { IpcRequest } from "./ipc-request";
export interface CopyDirectoryRequest extends IpcRequest {
sourcePath: string;
destinationPath: string;
}

View File

@@ -1,4 +1,6 @@
export interface CopyFileRequest {
sourceFilePath: string;
destinationFilePath: string;
}
import { IpcRequest } from "./ipc-request";
export interface CopyFileRequest extends IpcRequest {
sourceFilePath: string;
destinationFilePath: string;
}

View File

@@ -1,3 +1,5 @@
export interface DeleteDirectoryRequest {
sourcePath: string;
}
import { IpcRequest } from "./ipc-request";
export interface DeleteDirectoryRequest extends IpcRequest {
sourcePath: string;
}

View File

@@ -1,4 +1,6 @@
export interface DownloadRequest {
url: string;
outputFolder: string;
}
import { IpcRequest } from "./ipc-request";
export interface DownloadRequest extends IpcRequest {
url: string;
outputFolder: string;
}

View File

@@ -1,3 +1,5 @@
export interface ReadFileRequest {
sourcePath: string;
}
import { IpcRequest } from "./ipc-request";
export interface ReadFileRequest extends IpcRequest {
sourcePath: string;
}

View File

@@ -1,4 +1,6 @@
export interface UnzipRequest {
zipFilePath: string;
outputFolder: string;
}
import { IpcRequest } from "./ipc-request";
export interface UnzipRequest extends IpcRequest {
zipFilePath: string;
outputFolder: string;
}

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