From 4e9dc7fcb237319e2594cbefc618c25697ccaf06 Mon Sep 17 00:00:00 2001 From: Kvan7 <71402892+Kvan7@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:41:04 -0500 Subject: [PATCH] Chaos spam data (#863) * create base component * add as alpha * Add hotkey action * updating config stuff * Adds writing csv * ui responsive * Style updates * smor style updates * fix issue with recreating file * compute diff * general cleanup * rebuild into secondary widget * Remove builtin error * add a unit test * fix spelling? * add custom file path * Add folder option --- ipc/types.ts | 21 +- main/src/host-files/FileWriter.ts | 87 ++++++ main/src/main.ts | 3 + renderer/public/data/de/app_i18n.json | 2 - renderer/public/data/en/app_i18n.json | 12 +- renderer/public/data/es/app_i18n.json | 4 +- renderer/public/data/pt/app_i18n.json | 2 - .../specs/web/library/libraryChaos.test.ts | 45 ++++ renderer/src/parser/modifiers.ts | 17 ++ renderer/src/web/Config.ts | 29 +- .../src/web/library/SingleItemSession.vue | 194 ++++++++++++++ renderer/src/web/library/WidgetLibrary.vue | 251 ++++++++++++++++++ renderer/src/web/library/settings-library.vue | 49 ++++ renderer/src/web/library/widget.ts | 199 ++++++++++++++ renderer/src/web/overlay/widget-registry.ts | 2 + renderer/src/web/settings/SettingsWindow.vue | 20 ++ renderer/src/web/settings/general.vue | 32 ++- renderer/tailwind.config.js | 11 + 18 files changed, 960 insertions(+), 20 deletions(-) create mode 100644 main/src/host-files/FileWriter.ts create mode 100644 renderer/specs/web/library/libraryChaos.test.ts create mode 100644 renderer/src/web/library/SingleItemSession.vue create mode 100644 renderer/src/web/library/WidgetLibrary.vue create mode 100644 renderer/src/web/library/settings-library.vue create mode 100644 renderer/src/web/library/widget.ts diff --git a/ipc/types.ts b/ipc/types.ts index cf8649c0..2828ead6 100644 --- a/ipc/types.ts +++ b/ipc/types.ts @@ -9,6 +9,8 @@ export interface HostConfig { windowTitle: string; language: string; readClientLog: boolean; + libraryAlpha: boolean; + libraryOutputPath: string | null; } export interface ShortcutAction { @@ -88,11 +90,12 @@ export type IpcEvent = | IpcItemText | IpcOcrText | IpcConfigChanged - | IpcUserAction; + | IpcUserAction + | IpcWriteCsv; export type IpcEventPayload< Name extends IpcEvent["name"], - T extends IpcEvent = IpcEvent + T extends IpcEvent = IpcEvent, > = T extends { name: Name; payload: infer P } ? P : never; type IpcOverlayAttached = Event<"MAIN->OVERLAY::overlay-attached">; @@ -209,6 +212,20 @@ type IpcUserAction = Event< } >; +type IpcWriteCsv = Event< + "CLIENT->MAIN::write-data", + | { + action: "log-item"; + text: string; + } + | { + action: "session"; + start: boolean; + name?: string; + header?: string; + } +>; + interface Event { name: TName; payload: TPayload; diff --git a/main/src/host-files/FileWriter.ts b/main/src/host-files/FileWriter.ts new file mode 100644 index 00000000..af84fb04 --- /dev/null +++ b/main/src/host-files/FileWriter.ts @@ -0,0 +1,87 @@ +import path from "path"; +import { app } from "electron"; +import { ServerEvents } from "../server"; +import { promises as fs, existsSync } from "fs"; +import { Logger } from "../RemoteLogger"; + +export class FileWriter { + private defaultUploadsPath = path.join( + app.getPath("userData"), + "apt-data", + "csv-data", + ); + + private uploadsPath = this.defaultUploadsPath; + + private _state: { + file: fs.FileHandle; + } | null = null; + + private _enabled = false; + + constructor( + private server: ServerEvents, + private logger: Logger, + ) { + this.server.onEventAnyClient("CLIENT->MAIN::write-data", async (e) => { + if (!this._enabled) return; + if (e.action !== "log-item" && e.action !== "session") return; + if (e.action === "log-item") { + this.writeLine(e.text); + return; + } + // e.action === "session" + if (e.start) { + if (!e.name || !e.header) { + this.logger.write("error [FileWriter] Invalid session start event."); + return; + } + + await this.writeSessionStart(e.name, e.header); + } else { + this.writeSessionEnd(); + } + }); + } + + async restart(enabled: boolean, outputPath: string | null) { + this._enabled = enabled; + this.uploadsPath = outputPath ?? this.defaultUploadsPath; + if (!this.uploadsPath.length) { + this.uploadsPath = this.defaultUploadsPath; + } + } + + private async writeSessionStart(name: string, header: string) { + try { + if (!existsSync(this.uploadsPath)) { + await fs.mkdir(this.uploadsPath, { recursive: true }); + } + const filePath = path.join(this.uploadsPath, name + ".csv"); + if (!existsSync(filePath)) { + const file = await fs.open(filePath, "w"); + this._state = { file }; + + this.writeLine(header); + } else { + const file = await fs.open(filePath, "a"); + this._state = { file }; + } + } catch { + this.logger.write("error [FileWriter] Failed to create session file."); + } + } + + private writeSessionEnd() { + if (!this._state) return; + + this._state.file.close(); + this._state = null; + } + + private writeLine(line: string) { + if (!this._state) return; + + this._state.file.write(line + "\n"); + } +} diff --git a/main/src/main.ts b/main/src/main.ts index 091e28a2..ba977b0d 100644 --- a/main/src/main.ts +++ b/main/src/main.ts @@ -15,6 +15,7 @@ import { OverlayVisibility } from "./windowing/OverlayVisibility"; import { GameLogWatcher } from "./host-files/GameLogWatcher"; import { HttpProxy } from "./proxy"; import { installExtension, VUEJS_DEVTOOLS } from "electron-devtools-installer"; +import { FileWriter } from "./host-files/FileWriter"; if (!app.requestSingleInstanceLock()) { app.exit(); @@ -70,6 +71,7 @@ let tray: AppTray; const appUpdater = new AppUpdater(eventPipe); // eslint-disable-next-line @typescript-eslint/no-unused-vars const _httpProxy = new HttpProxy(server, logger); + const fileWriter = new FileWriter(eventPipe, logger); if (process.env.VITE_DEV_SERVER_URL) { try { @@ -114,6 +116,7 @@ let tray: AppTray; gameConfig.readConfig(cfg.gameConfig ?? ""); appUpdater.checkAtStartup(); tray.overlayKey = cfg.overlayKey; + fileWriter.restart(cfg.libraryAlpha, cfg.libraryOutputPath); }, ); uIOhook.start(); diff --git a/renderer/public/data/de/app_i18n.json b/renderer/public/data/de/app_i18n.json index ec1fe98d..2ec58af8 100644 --- a/renderer/public/data/de/app_i18n.json +++ b/renderer/public/data/de/app_i18n.json @@ -334,8 +334,6 @@ "show_overlay_ready": "Benachrichtigung anzeigen, wenn Overlay ein PoE-Fenster erkennt", "debug_hotkeys": "Alle Tastenanschläge aufzeichnen", "preferred_trade_site": "Bevorzugte Handelsseite", - "enable_alphas": "Alphas aktivieren", - "alphas_warning": "Alphas sind extrem experimentell und werden wahrscheinlich nicht wie erwartet funktionieren. Bitte melden Sie alle auftretenden Probleme.", "help": "Hilfe", "show_tips_on_startup": "Tipps bei Startbenachrichtigung anzeigen", "show_tips_frequency": "Wie oft sollen Tipps beim Preis-Check angezeigt werden?", diff --git a/renderer/public/data/en/app_i18n.json b/renderer/public/data/en/app_i18n.json index 92bc61cc..ecf8cbeb 100644 --- a/renderer/public/data/en/app_i18n.json +++ b/renderer/public/data/en/app_i18n.json @@ -373,8 +373,8 @@ "show_overlay_ready": "Show a notification when the Overlay detects a PoE window", "debug_hotkeys": "Record all key presses", "preferred_trade_site": "Preferred trade site", - "enable_alphas": "Enable alphas", - "alphas_warning": "Alphas are extremely experimental and will probably not work as expected. Please report any issues you encounter.", + "enable_alphas": "Enable experimental features", + "alpha_library": "Item Data Collection", "help": "Help", "show_tips_on_startup": "Show tips on startup notification", "show_tips_frequency": "How often to show tips when price checking", @@ -478,5 +478,13 @@ "exp_tracking_help": "XP tracking widget shows your current level, current experience multiplier, and how close you are to being under-leveled. XP penalty is applied immediately if you are over-leveled, and when you are under-leveled by \u230A level/16 + 3 \u230B. The 'Over' is how many levels the your current level is over the bottom of the 'safe zone', generally 0 or 1 means you are killing the highest level monsters you can without incurring the under-leveled penalty", "exp_inspire": "Based on ", "exp_astrict": "*Experience gain percentages, when over level 95, or monster level over 70, or potentially when not at 100% exp gain, may not be fully tested and accurate." + }, + "library": { + "name": "Library", + "log_item_key": "Log item to csv", + "output_file": "Output csv path", + "output_folder": "Output Folder", + "output_file_help": "Some info text", + "record_count": "Session Rolls:" } } diff --git a/renderer/public/data/es/app_i18n.json b/renderer/public/data/es/app_i18n.json index ed1e9230..e3358efa 100644 --- a/renderer/public/data/es/app_i18n.json +++ b/renderer/public/data/es/app_i18n.json @@ -332,9 +332,7 @@ "restore_clipboard": "Restaurar portapapeles", "show_overlay_ready": "Mostrar una notificación cuando la Superposición detecte una ventana de PoE", "debug_hotkeys": "Registrar todas las teclas presionadas", - "preferred_trade_site": "Sitio de comercio preferido", - "enable_alphas": "Habilitar alfas", - "alphas_warning": "Las alfas son extremadamente experimentales y probablemente no funcionarán como se espera. Por favor, reporta cualquier problema que encuentres." + "preferred_trade_site": "Sitio de comercio preferido" }, "price_check": { "name": "Comprobación de precios", diff --git a/renderer/public/data/pt/app_i18n.json b/renderer/public/data/pt/app_i18n.json index 55998e31..164b91e8 100644 --- a/renderer/public/data/pt/app_i18n.json +++ b/renderer/public/data/pt/app_i18n.json @@ -354,8 +354,6 @@ "show_overlay_ready": "Mostrar uma notificação quando a Sobreposição detectar uma janela do PoE", "debug_hotkeys": "Registrar todas as teclas pressionadas", "preferred_trade_site": "Site de comércio preferido", - "enable_alphas": "Habilitar versões alfas", - "alphas_warning": "As versões alfas são extremamente experimentais e provavelmente não funcionarão como esperado. Por favor, reporte qualquer problema que encontrar.", "help": "Ajuda", "show_tips_on_startup": "Mostrar dicas na notificação de inicialização", "show_tips_frequency": "Frequência das dicas ao checar preço", diff --git a/renderer/specs/web/library/libraryChaos.test.ts b/renderer/specs/web/library/libraryChaos.test.ts new file mode 100644 index 00000000..ee59f5c7 --- /dev/null +++ b/renderer/specs/web/library/libraryChaos.test.ts @@ -0,0 +1,45 @@ +import { ParsedModifier } from "@/parser/advanced-mod-desc"; +import { ModifierType } from "@/parser/modifiers"; +import { __testExports, ColumnOpts } from "@/web/library/widget"; +import { describe, expect, it } from "vitest"; + +describe("modFilter", () => { + it.each([ + [{ explicit: true, implicit: false, enchant: false, augment: false }, [0]], + [ + { explicit: true, implicit: false, enchant: true, augment: false }, + [0, 2], + ], + [ + { explicit: true, implicit: false, enchant: false, augment: true }, + [0, 3], + ], + [ + { explicit: true, implicit: false, enchant: true, augment: true }, + [0, 2, 3], + ], + [ + { explicit: true, implicit: true, enchant: false, augment: false }, + [0, 1], + ], + ])( + "Returns correct mods", + (filter: Record, expected: number[]) => { + const mods = [ + { info: { generation: "suffix" }, stats: [] }, + { info: { type: ModifierType.Implicit }, stats: [] }, + { info: { type: ModifierType.Enchant }, stats: [] }, + { info: { type: ModifierType.Augment }, stats: [] }, + ]; + const set = new Set(expected); + for (let i = 0; i < expected.length; i++) { + expect( + __testExports.modFilter( + mods[i] as unknown as ParsedModifier, + filter as unknown as ColumnOpts["keep"], + ), + ).toBe(set.has(i)); + } + }, + ); +}); diff --git a/renderer/src/parser/modifiers.ts b/renderer/src/parser/modifiers.ts index 9a76a3f8..923ae9e7 100644 --- a/renderer/src/parser/modifiers.ts +++ b/renderer/src/parser/modifiers.ts @@ -148,6 +148,23 @@ export function translateStatWithRoll( }; } +export function modsEqual(a: ParsedModifier, b: ParsedModifier) { + return ( + a.stats.length === b.stats.length && + a.info.name === b.info.name && + a.info.tier === b.info.tier && + a.info.generation === b.info.generation && + a.info.type === b.info.type && + a.stats.every( + (statA, i) => + statA.stat.ref === b.stats[i].stat.ref && + statA.roll?.min === b.stats[i].roll?.min && + statA.roll?.max === b.stats[i].roll?.max && + statA.roll?.value === b.stats[i].roll?.value, + ) + ); +} + export enum ModifierType { Pseudo = "pseudo", Explicit = "explicit", diff --git a/renderer/src/web/Config.ts b/renderer/src/web/Config.ts index bafc0386..d6c758fd 100644 --- a/renderer/src/web/Config.ts +++ b/renderer/src/web/Config.ts @@ -7,6 +7,7 @@ import type { StashSearchWidget } from "./stash-search/widget"; import type { ItemCheckWidget } from "./item-check/widget"; import type { ItemSearchWidget } from "./item-search/widget"; import { registry as widgetRegistry } from "./overlay/widget-registry.js"; +import { LibraryWidget } from "./library/widget"; const _config = shallowRef(null); let _lastSavedConfig: Config | null = null; @@ -147,7 +148,7 @@ export interface Config { showAttachNotification: boolean; overlayAlwaysClose: boolean; enableAlphas: boolean; - alphas: []; + alphas: Array<"library">; tipsFrequency: TipsFrequency; readClientLog: boolean; // default to false, opt-in only } @@ -620,6 +621,21 @@ function upgradeConfig(_config: Config): Config { config.configVersion = 29; } + + if (config.configVersion < 30) { + // NOTE: v0.13.11 || poe0.4.0d + const itemSearchId: number = config.widgets.find( + (w) => w.wmType === "item-search", + )!.wmId; + // splicing to insert after the item-search widget, for positioning on the main overlay + config.widgets.splice(itemSearchId, 0, { + ...defaultConfig().widgets.find((w) => w.wmType === "library")!, + wmId: Math.max(0, ...config.widgets.map((_) => _.wmId)) + 1, + }); + + config.configVersion = 30; + } + return config as unknown as Config; } @@ -682,6 +698,15 @@ function getConfigForHost(): HostConfig { action: { type: "copy-item", target: "item-check", focusOverlay: true }, }); } + const library = AppConfig("library") as LibraryWidget; + if (library.logItemKey) { + actions.push({ + shortcut: library.logItemKey, + keepModKeys: true, + action: { type: "copy-item", target: "log-item" }, + }); + } + const delveGrid = AppConfig("delve-grid") as widget.DelveGridWidget; if (delveGrid.toggleKey) { actions.push({ @@ -760,5 +785,7 @@ function getConfigForHost(): HostConfig { windowTitle: config.windowTitle, language: config.language, readClientLog: config.readClientLog, + libraryAlpha: config.enableAlphas && config.alphas.includes("library"), + libraryOutputPath: library.libraryOutputPath, }; } diff --git a/renderer/src/web/library/SingleItemSession.vue b/renderer/src/web/library/SingleItemSession.vue new file mode 100644 index 00000000..8ed6f2da --- /dev/null +++ b/renderer/src/web/library/SingleItemSession.vue @@ -0,0 +1,194 @@ + + + + + diff --git a/renderer/src/web/library/WidgetLibrary.vue b/renderer/src/web/library/WidgetLibrary.vue new file mode 100644 index 00000000..8ff449ab --- /dev/null +++ b/renderer/src/web/library/WidgetLibrary.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/renderer/src/web/library/settings-library.vue b/renderer/src/web/library/settings-library.vue new file mode 100644 index 00000000..3a778fb0 --- /dev/null +++ b/renderer/src/web/library/settings-library.vue @@ -0,0 +1,49 @@ + + + diff --git a/renderer/src/web/library/widget.ts b/renderer/src/web/library/widget.ts new file mode 100644 index 00000000..c3e90386 --- /dev/null +++ b/renderer/src/web/library/widget.ts @@ -0,0 +1,199 @@ +import { err, ok, Result } from "neverthrow"; +import { Anchor, Widget } from "@/web/overlay/widgets"; +import { ParsedItem } from "@/parser"; +import { ModifierType, modsEqual } from "@/parser/modifiers"; +import { ParsedModifier } from "@/parser/advanced-mod-desc"; + +export interface LibraryWidget extends Widget { + anchor: Anchor; + logItemKey: string | null; + libraryOutputPath: string | null; + profiles: Record; +} + +export interface ShortMod { + name?: string; + tier?: number; + roll: Array; + ref: string[]; + type: string; +} +export interface ColumnOpts { + refName: true; + itemLevel: true; + rarity: true; + sockets: true; + mods: true; + addedMods: boolean; + removedMods: boolean; + keep: { + explicit: boolean; + implicit: boolean; + enchant: boolean; + augment: boolean; + }; + modOpts: { + name: true; + tier: boolean; + roll: boolean; + ref: boolean; + type: boolean; + }; +} + +export interface CsvColumns { + [key: string]: ColumnOpts; +} + +function modFilter(mod: ParsedModifier, keep: ColumnOpts["keep"]) { + if ( + keep.explicit && + (mod.info.generation === "suffix" || + mod.info.generation === "prefix" || + mod.info.generation === "mutated" || + // Should be redundant with prefix/suffix + mod.info.type === ModifierType.Desecrated || + mod.info.type === ModifierType.Fractured) + ) { + return true; + } + if (keep.implicit && mod.info.type === ModifierType.Implicit) { + return true; + } + if (keep.enchant && mod.info.type === ModifierType.Enchant) { + return true; + } + if (keep.augment && mod.info.type === ModifierType.Augment) { + return true; + } + return false; +} + +export function buildCsvString( + item: ParsedItem, + sessionType: "chaos", + addedMods: ParsedModifier[], + removedMods: ParsedModifier[], + opts: { columnOpts: ColumnOpts }, +): Result { + const { columnOpts } = opts; + if (sessionType === "chaos") { + const filteredMods = item.newMods.filter((mod) => + modFilter(mod, columnOpts.keep), + ); + const out: Array = []; + if (columnOpts.refName) { + out.push(item.info.refName); + } + if (columnOpts.itemLevel) { + out.push(item.itemLevel); + } + if (columnOpts.rarity) { + out.push(item.rarity); + } + if (columnOpts.sockets) { + out.push(item.augmentSockets?.current ?? 0); + } + + if (columnOpts.mods) { + out.push( + arrayToCsvString(filteredMods.map((m) => modToShortMod(m, columnOpts))), + ); + } + + if (columnOpts.addedMods) { + out.push( + arrayToCsvString( + addedMods + .filter((mod) => modFilter(mod, columnOpts.keep)) + .map((m) => modToShortMod(m, columnOpts)), + ), + ); + } + + if (columnOpts.removedMods) { + out.push( + arrayToCsvString( + removedMods + .filter((mod) => modFilter(mod, columnOpts.keep)) + .map((m) => modToShortMod(m, columnOpts)), + ), + ); + } + + return ok(out.join(",")); + } + + return err("sessionType not supported"); +} + +export function getHeader(sessionType: "chaos") { + switch (sessionType) { + case "chaos": + return ok("base,ilvl,rarity,sockets,mods,addedMods,removedMods"); + + default: + return err("sessionType not supported"); + } +} + +export function diffItem(curr: ParsedItem, prev: ParsedItem | null) { + if (!prev) { + return { + added: curr.newMods.filter( + (mod) => + mod.info.generation === "suffix" || mod.info.generation === "prefix", + ), + removed: [], + }; + } + + const filteredModsA = curr.newMods.filter( + (mod) => + mod.info.generation === "suffix" || mod.info.generation === "prefix", + ); + const filteredModsB = prev.newMods.filter( + (mod) => + mod.info.generation === "suffix" || mod.info.generation === "prefix", + ); + + const added = filteredModsA.filter( + (mod) => !filteredModsB.some((modB) => modsEqual(mod, modB)), + ); + const removed = filteredModsB.filter( + (mod) => !filteredModsA.some((modA) => modsEqual(mod, modA)), + ); + + return { added, removed }; +} + +function modToShortMod(mod: ParsedModifier, opts: ColumnOpts): ShortMod { + let type: ShortMod["type"]; + if ( + mod.info.generation && + mod.info.generation !== "suffix" && + mod.info.generation !== "prefix" + ) { + type = mod.info.generation; + } else { + type = mod.info.type; + } + + return { + name: mod.info.name, + tier: mod.info.tier, + roll: mod.stats.map((s) => s.roll?.value ?? -999), + ref: mod.stats.map((s) => s.stat.ref), + type, + }; +} + +function arrayToCsvString(arr: ShortMod[]) { + const json = JSON.stringify(arr); + return `"${json.replaceAll("'", "\\'").replaceAll('"', "'")}"`; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const __testExports = { + modFilter, +}; diff --git a/renderer/src/web/overlay/widget-registry.ts b/renderer/src/web/overlay/widget-registry.ts index 177be20d..9c6c0d69 100644 --- a/renderer/src/web/overlay/widget-registry.ts +++ b/renderer/src/web/overlay/widget-registry.ts @@ -11,6 +11,7 @@ import WidgetItemSearch from "../item-search/WidgetItemSearch.vue"; import WidgetSettings from "../settings/SettingsWindow.vue"; import WidgetXpTracker from "../leveling/WidgetXpTracker.vue"; import WidgetNotepad from "../notepad/WidgetNotepad.vue"; +import WidgetLibrary from "../library/WidgetLibrary.vue"; type WidgetComponent = Component & { widget: WidgetSpec }; @@ -35,3 +36,4 @@ registry.widgets.push(WidgetItemCheck as unknown as WidgetComponent); registry.widgets.push(WidgetImageStrip as unknown as WidgetComponent); registry.widgets.push(WidgetDelveGrid as unknown as WidgetComponent); registry.widgets.push(WidgetNotepad as unknown as WidgetComponent); +registry.widgets.push(WidgetLibrary as unknown as WidgetComponent); diff --git a/renderer/src/web/settings/SettingsWindow.vue b/renderer/src/web/settings/SettingsWindow.vue index 62cc5904..30d29631 100644 --- a/renderer/src/web/settings/SettingsWindow.vue +++ b/renderer/src/web/settings/SettingsWindow.vue @@ -141,6 +141,7 @@ import SettingsStashSearch from "../stash-search/stash-search-editor.vue"; import SettingsStopwatch from "../stopwatch/settings-stopwatch.vue"; import SettingsItemSearch from "../item-search/settings-item-search.vue"; import SettingsLeveling from "../leveling/settings-leveling.vue"; +import SettingsLibrary from "../library/settings-library.vue"; import { disableWidget, enableWidget, findWidget } from "./utils"; function shuffle(array: T[]): T[] { @@ -260,6 +261,23 @@ export default defineComponent({ }, ); + watch( + () => + configClone.value?.enableAlphas && + configClone.value?.alphas.includes("library"), + (curr) => { + if (curr === undefined) return; + const library = findWidget("library", configClone.value!); + if (!library) return; + + if (curr) { + enableWidget(library); + } else { + disableWidget(library); + } + }, + ); + const menuItems = computed(() => flatJoin( menuByType(configWidget.value?.wmType).map((group) => @@ -329,6 +347,8 @@ function menuByType(type?: string) { return [[SettingsPricecheck]]; case "item-search": return [[SettingsItemSearch]]; + case "library": + return [[SettingsLibrary]]; default: return [ [SettingsHotkeys, SettingsChat], diff --git a/renderer/src/web/settings/general.vue b/renderer/src/web/settings/general.vue index 9488f326..1fd76d09 100644 --- a/renderer/src/web/settings/general.vue +++ b/renderer/src/web/settings/general.vue @@ -97,26 +97,25 @@ class="rounded bg-gray-900 px-1 block w-full mb-1 font-poe" /> - {{ + {{ t(":read_client_log") }} -
+
{{ t(":client_log_explain") }}
-
+
{{ t(":enable_alphas") }} -
No alphas available right now
-
- {{ t(":alphas_warning") }} -
+ {{ + t(":alpha_library") + }}