diff --git a/lib/fileTypeUtils/dat/datExtractor.dart b/lib/fileTypeUtils/dat/datExtractor.dart index 6e1d77f6..74140d2c 100644 --- a/lib/fileTypeUtils/dat/datExtractor.dart +++ b/lib/fileTypeUtils/dat/datExtractor.dart @@ -131,3 +131,13 @@ Stream extractDatFilesAsStream(String datPath) async* { return; } } + +Future> peekDatFileNames(String datPath) async { + var bytes = await ByteDataWrapper.fromFile(datPath); + var header = _DatHeader(bytes); + bytes.position = header.fileNamesOffset; + var nameLength = bytes.readUint32(); + return List + .generate(header.fileNumber, (index) => + bytes.readString(nameLength).split("\u0000")[0]); +} diff --git a/lib/fileTypeUtils/smd/smdReader.dart b/lib/fileTypeUtils/smd/smdReader.dart index b0886eb6..b83abb02 100644 --- a/lib/fileTypeUtils/smd/smdReader.dart +++ b/lib/fileTypeUtils/smd/smdReader.dart @@ -1,4 +1,6 @@ +import 'dart:math'; + import '../utils/ByteDataWrapper.dart'; class SmdEntry { @@ -21,7 +23,8 @@ Future> readSmdFile(String path) async { for (int i = 0; i < count; i++) { String id = reader.readString(0x80, encoding: StringEncoding.utf16); int indexX10 = reader.readUint64(); - String text = reader.readString(0x800, encoding: StringEncoding.utf16); + var textSize = min(0x800, reader.length - reader.position); + String text = reader.readString(textSize, encoding: StringEncoding.utf16); var zerosRemover = RegExp("\x00+\$"); id = id.replaceAll(zerosRemover, ""); text = text.replaceAll(zerosRemover, ""); diff --git a/lib/fileTypeUtils/smd/smdWriter.dart b/lib/fileTypeUtils/smd/smdWriter.dart index 0d09d5e3..d108541e 100644 --- a/lib/fileTypeUtils/smd/smdWriter.dart +++ b/lib/fileTypeUtils/smd/smdWriter.dart @@ -9,6 +9,8 @@ Future saveSmd(List entries, String path) async { for (var entry in entries) { var id = entry.id.padRight(0x40, '\x00'); var text = entry.text.padRight(0x400, '\x00'); + if (text.contains("\n") && !text.contains("\r\n")) + text = text.replaceAll("\n", "\r\n"); bytes.writeString(id, StringEncoding.utf16); bytes.writeUint64(entry.indexX10); bytes.writeString(text, StringEncoding.utf16); diff --git a/lib/stateManagement/openFiles/openFileTypes.dart b/lib/stateManagement/openFiles/openFileTypes.dart index ff25bd60..e18af606 100644 --- a/lib/stateManagement/openFiles/openFileTypes.dart +++ b/lib/stateManagement/openFiles/openFileTypes.dart @@ -17,6 +17,7 @@ import '../undoable.dart'; import 'types/BnkFilePlaylistData.dart'; import 'types/BxmFileData.dart'; import 'types/EstFileData.dart'; +import 'types/FontSettingsDummy.dart'; import 'types/FtbFileData.dart'; import 'types/McdFileData.dart'; import 'types/RubyFileData.dart'; @@ -100,6 +101,8 @@ abstract class OpenFileData with HasUuid, Undoable, Disposable, HasUndoHistory { return EstFileData(name, path, secondaryName: secondaryName); else if (path == "preferences") return PreferencesData(); + else if (path == "fontSettings") + return FontSettingsDummy(); else return TextFileData(name, path, secondaryName: secondaryName); } diff --git a/lib/stateManagement/openFiles/types/FontSettingsDummy.dart b/lib/stateManagement/openFiles/types/FontSettingsDummy.dart new file mode 100644 index 00000000..fa85c985 --- /dev/null +++ b/lib/stateManagement/openFiles/types/FontSettingsDummy.dart @@ -0,0 +1,24 @@ + +import 'package:flutter/material.dart'; + +import '../../../widgets/filesView/FileType.dart'; +import '../../undoable.dart'; +import '../openFileTypes.dart'; +import 'McdFileData.dart'; + +class FontSettingsDummy extends OpenFileData { + FontSettingsDummy() : super("Font Settings", "", type: FileType.fontSettings, icon: Icons.text_fields) { + canBeReloaded = false; + if (McdData.availableFonts.isEmpty) + McdData.loadAvailableFonts(); + } + + @override + void restoreWith(Undoable snapshot) { + } + + @override + Undoable takeSnapshot() { + return FontSettingsDummy(); + } +} diff --git a/lib/stateManagement/openFiles/types/McdFileData.dart b/lib/stateManagement/openFiles/types/McdFileData.dart index bc3feee2..ce9e883f 100644 --- a/lib/stateManagement/openFiles/types/McdFileData.dart +++ b/lib/stateManagement/openFiles/types/McdFileData.dart @@ -84,7 +84,7 @@ class McdFileData extends OpenFileData { } abstract class _McdFilePart with HasUuid, Undoable implements Disposable { - OpenFileId file; + OpenFileId? file; _McdFilePart(this.file); @@ -436,13 +436,15 @@ class McdParagraph extends _McdFilePart { void addLine() { lines.add(McdLine(file, StringProp("", fileId: file))); - areasManager.onFileIdUndoEvent(file); + if (file != null) + areasManager.onFileIdUndoEvent(file!); } void removeLine(int index) { lines.removeAt(index) .dispose(); - areasManager.onFileIdUndoEvent(file); + if (file != null) + areasManager.onFileIdUndoEvent(file!); } @override @@ -526,13 +528,15 @@ class McdEvent extends _McdFilePart { NumberProp(fontId, true, fileId: file), ValueListNotifier([], fileId: file) )); - areasManager.onFileIdUndoEvent(file); + if (file != null) + areasManager.onFileIdUndoEvent(file!); } void removeParagraph(int index) { paragraphs.removeAt(index) .dispose(); - areasManager.onFileIdUndoEvent(file); + if (file != null) + areasManager.onFileIdUndoEvent(file!); } @override @@ -593,13 +597,15 @@ class McdData extends _McdFilePart { static NumberProp fontAtlasResolutionScale = NumberProp(1.0, false, fileId: null); static ChangeNotifier fontChanges = ChangeNotifier(); + final String mcdPath; final StringProp textureWtaPath; final StringProp textureWtpPath; final int firstMsgSeqNum; ValueListNotifier events; Map usedFonts; + Future Function(String) exportDatFunc = exportDat; - McdData(super.file, this.textureWtaPath, this.textureWtpPath, this.usedFonts, this.firstMsgSeqNum, this.events) { + McdData(super.file, this.mcdPath, this.textureWtaPath, this.textureWtpPath, this.usedFonts, this.firstMsgSeqNum, this.events) { events.addListener(onDataChanged); } @@ -628,7 +634,7 @@ class McdData extends _McdFilePart { return null; } - static Future fromMcdFile(OpenFileId file, String mcdPath) async { + static Future fromMcdFile(OpenFileId? file, String mcdPath) async { var datDir = dirname(mcdPath); var mcdName = basenameWithoutExtension(mcdPath); String? wtpPath = await searchForTexFile(datDir, mcdName, ".wtp"); @@ -665,6 +671,7 @@ class McdData extends _McdFilePart { return McdData( file, + mcdPath, StringProp(wtaPath, fileId: file), StringProp(wtpPath, fileId: file), {for (var f in usedFonts) f.fontId: f}, @@ -705,13 +712,15 @@ class McdData extends _McdFilePart { StringProp("NEW_EVENT_NAME${suffix.isNotEmpty ? "_$suffix" : ""}", fileId: file), ValueListNotifier([], fileId: file) )); - areasManager.onFileIdUndoEvent(file); + if (file != null) + areasManager.onFileIdUndoEvent(file!); } void removeEvent(int index) { events.removeAt(index) .dispose(); - areasManager.onFileIdUndoEvent(file); + if (file != null) + areasManager.onFileIdUndoEvent(file!); } static void addFontOverride() { @@ -907,11 +916,9 @@ class McdData extends _McdFilePart { exportEvents.sort((a, b) => a.id.compareTo(b.id)); var mcdFile = McdFile.fromParts(header, exportMessages, exportSymbols, exportGlyphs, exportFonts, exportEvents); - var openFile = areasManager.fromId(file)!; - await mcdFile.writeToFile(openFile.path); + await mcdFile.writeToFile(mcdPath); - print("Saved MCD file"); - messageLog.add("Saved MCD file ${basename(openFile.path)}"); + messageLog.add("Saved MCD file ${basename(mcdPath)}"); } Future updateFontsTexture() async { @@ -1162,7 +1169,7 @@ class McdData extends _McdFilePart { // export dtt var dttPath = dirname(textureWtpPath.value); - await exportDat(dttPath); + await exportDatFunc(dttPath); print("Generated font texture with ${symbols.length} symbols"); @@ -1223,6 +1230,7 @@ class McdData extends _McdFilePart { Undoable takeSnapshot() { var snapshot = McdData( file, + mcdPath, textureWtaPath.takeSnapshot() as StringProp, textureWtpPath.takeSnapshot() as StringProp, usedFonts.map((id, font) => MapEntry(id, font)), diff --git a/lib/utils/batchLocalization/BatchLocalizationData.dart b/lib/utils/batchLocalization/BatchLocalizationData.dart new file mode 100644 index 00000000..aa208229 --- /dev/null +++ b/lib/utils/batchLocalization/BatchLocalizationData.dart @@ -0,0 +1,182 @@ + +enum BatchLocalizationLanguage { + jp, de, es, fr, it, us, kor, cn; + + static BatchLocalizationLanguage fromString(String value) { + for (final language in values) { + if (language.name == value) { + return language; + } + } + throw Exception("Invalid language: $value"); + } +} + +class BatchLocalizationData { + final List files; + final BatchLocalizationLanguage language; + + BatchLocalizationData(this.files, this.language); + + factory BatchLocalizationData.read(StringReader reader) { + var langKey = reader.readUntil(_listSeparator); + assert(langKey == "Original Language"); + final langStr = reader.readUntil("\n$_fileSeparator"); + final language = BatchLocalizationLanguage.fromString(langStr); + final files = []; + while (!reader.isEOF) { + files.add(BatchLocalizationFileData.read(reader)); + } + return BatchLocalizationData(files, language); + } + + factory BatchLocalizationData.fromJson(Map json) { + final language = BatchLocalizationLanguage.fromString(json["originalLanguage"]); + final files = (json["files"] as List).map((e) => BatchLocalizationFileData.fromJson(e)).toList(); + return BatchLocalizationData(files, language); + } + + void writeString(StringSink sink) { + sink.write("Original Language"); + sink.write(_listSeparator); + sink.write(language.name); + sink.writeln(); + sink.write(_fileSeparator); + for (final file in files) { + file.writeString(sink); + } + } + + Map toJson() { + return { + "originalLanguage": language.name, + "files": files.map((e) => e.toJson()).toList() + }; + } +} + +class BatchLocalizationFileData { + final String datName; + final String fileName; + final List entries; + + BatchLocalizationFileData(this.datName, this.fileName, this.entries); + + factory BatchLocalizationFileData.read(StringReader reader) { + final datName = reader.readUntil(_listSeparator); + final fileName = reader.readUntil("\n"); + final entries = []; + var fileEndIndex = reader.indexOf(_fileSeparator); + while (true) { + var entryEndIndex = reader.indexOf(_entrySeparator); + if (entryEndIndex == -1 || entryEndIndex > fileEndIndex) + break; + entries.add(BatchLocalizationEntryData.read(reader)); + } + reader.readUntil(_fileSeparator); + return BatchLocalizationFileData(datName, fileName, entries); + } + + factory BatchLocalizationFileData.fromJson(Map json) { + final datName = json["datName"]; + final fileName = json["fileName"]; + final entries = (json["entries"] as List).map((e) => BatchLocalizationEntryData.fromJson(e)).toList(); + return BatchLocalizationFileData(datName, fileName, entries); + } + + void writeString(StringSink sink) { + sink.write(datName); + sink.write(_listSeparator); + sink.write(fileName); + sink.writeln(); + for (final entry in entries) { + entry.writeString(sink); + } + sink.write(_fileSeparator); + } + + Map toJson() { + return { + "datName": datName, + "fileName": fileName, + "entries": entries.map((e) => e.toJson()).toList() + }; + } + + Map asMap() { + return { + for (final entry in entries) + entry.key: entry.value + }; + } + + Map> asMapOfLists() { + Map> result = {}; + for (final entry in entries) { + if (!result.containsKey(entry.key)) { + result[entry.key] = []; + } + result[entry.key]!.add(entry.value); + } + return result; + } +} + +class BatchLocalizationEntryData { + final String key; + final String value; + + BatchLocalizationEntryData(this.key, this.value); + + factory BatchLocalizationEntryData.read(StringReader reader) { + final key = reader.readUntil(":\n"); + final value = reader.readUntil("\n$_entrySeparator"); + return BatchLocalizationEntryData(key, value); + } + + factory BatchLocalizationEntryData.fromJson(Map json) { + return BatchLocalizationEntryData(json["key"], json["value"]); + } + + void writeString(StringSink sink) { + sink.write(key); + sink.write(":\n"); + sink.write(value); + sink.writeln(); + sink.write(_entrySeparator); + } + + Map toJson() { + return { + "key": key, + "value": value + }; + } +} + +const _listSeparator = " -> "; +const _entrySeparator = "----\n"; +const _fileSeparator = "====\n"; + +class StringReader { + final String _string; + int _position = 0; + + StringReader(this._string); + + bool get isEOF => _position >= _string.length; + + int indexOf(String pattern) { + return _string.indexOf(pattern, _position); + } + + String readUntil(String pattern) { + final index = _string.indexOf(pattern, _position); + if (index == -1) { + throw Exception("Pattern not found"); + } + final result = _string.substring(_position, index); + _position = index + pattern.length; + return result; + } +} diff --git a/lib/utils/batchLocalization/BatchLocalizationExporter.dart b/lib/utils/batchLocalization/BatchLocalizationExporter.dart new file mode 100644 index 00000000..6ffdba38 --- /dev/null +++ b/lib/utils/batchLocalization/BatchLocalizationExporter.dart @@ -0,0 +1,325 @@ + +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:convert/convert.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart'; +import 'package:xml/xml.dart'; + +import '../../fileTypeUtils/dat/datRepacker.dart'; +import '../../fileTypeUtils/pak/pakRepacker.dart'; +import '../../fileTypeUtils/ruby/pythonRuby.dart'; +import '../../fileTypeUtils/smd/smdReader.dart'; +import '../../fileTypeUtils/smd/smdWriter.dart'; +import '../../fileTypeUtils/tmd/tmdReader.dart'; +import '../../fileTypeUtils/tmd/tmdWriter.dart'; +import '../../fileTypeUtils/xml/xmlExtension.dart'; +import '../../fileTypeUtils/yax/xmlToYax.dart'; +import '../../stateManagement/events/statusInfo.dart'; +import '../../stateManagement/openFiles/types/McdFileData.dart'; +import '../utils.dart'; +import 'BatchLocalizationData.dart'; +import 'batchLocalizationUtils.dart'; + + +Future exportBatchLocalization({ + required String workingDirectory, + required String localizationFile, + required String exportFolder, + required BatchLocExportProgress progress, +}) async { + BatchLocalizationData data; + if (localizationFile.endsWith(".json")) { + var json = await File(localizationFile).readAsString(); + data = BatchLocalizationData.fromJson(jsonDecode(json)); + } else { + var fileStr = await File(localizationFile).readAsString(); + fileStr = fileStr.replaceAll("\r\n", "\n"); + var reader = StringReader(fileStr); + data = BatchLocalizationData.read(reader); + } + + progress.step = 1; + progress.totalSteps = 2; + progress.stepName = "Check and save changes"; + progress.totalFiles = data.files.length; + progress.file = 0; + progress.notify(); + int errors = 0; + + var datDir = join(workingDirectory, "dat", datSubExtractDir); + List datFilesToExport = []; + for (var locFile in data.files) { + progress.file++; + progress.currentFile = locFile.fileName; + progress.notify(); + try { + if (locFile.fileName.endsWith(".mcd")) { + await _processMcd(locFile, datDir, datFilesToExport); + } + else if (locFile.fileName.endsWith(".tmd")) { + await _processTmd(locFile, datDir, datFilesToExport); + } + else if (locFile.fileName.endsWith(".smd")) { + await _processSmd(locFile, datDir, datFilesToExport); + } + else if (locFile.fileName.endsWith(".rb")) { + await _processRb(locFile, datDir, datFilesToExport, data.language); + } + else if (locFile.fileName.endsWith(".pak")) { + await _processHap(locFile, datDir, datFilesToExport, data.language); + } + else { + messageLog.add("Unsupported file type: ${locFile.fileName}"); + errors++; + } + } on Exception catch (e, st) { + messageLog.add("Error processing ${locFile.fileName}: $e\n$st"); + errors++; + } + } + datFilesToExport = deduplicate(datFilesToExport); + + progress.step = 2; + progress.stepName = "Repack DAT files"; + progress.totalFiles = datFilesToExport.length; + progress.file = 0; + progress.currentFile = null; + + for (var exportDatName in datFilesToExport) { + progress.file++; + progress.currentFile = exportDatName; + progress.notify(); + var datFolder = join(datDir, exportDatName); + var exportSubDir = getDatFolder(exportDatName); + var exportPath = join(exportFolder, exportSubDir, exportDatName); + await repackDat(datFolder, exportPath); + } + + if (errors == 0) + showToast("Export completed successfully"); + else + showToast("Export completed with ${pluralStr(errors, "error")}"); +} + +Future _processTmd(BatchLocalizationFileData locFile, String datDir, List datFilesToExport) async { + var tmdPath = join(datDir, locFile.datName, locFile.fileName); + var srcTmdEntries = await readTmdFile(tmdPath); + var locMap = locFile.asMapOfLists(); + Map visitedCounts = {}; + var hasChanges = false; + var newEntries = srcTmdEntries.map((e) { + var loc = _lookupLocWithDuplicates(locMap, e.id, visitedCounts); + if (loc == null) + return e; + hasChanges |= e.text != loc; + return TmdEntry.fromStrings(e.id, loc); + }).toList(); + + if (hasChanges) { + await saveTmd(newEntries, tmdPath); + datFilesToExport.add(locFile.datName); + } +} + +Future _processSmd(BatchLocalizationFileData locFile, String datDir, List datFilesToExport) async { + var smdPath = join(datDir, locFile.datName, locFile.fileName); + var srcSmdEntries = await readSmdFile(smdPath); + var locMap = locFile.asMap(); + var hasChanges = false; + var newEntries = srcSmdEntries.indexed.map((ie) { + var (i, e) = ie; + var loc = locMap[e.id]; + if (loc == null) + return e; + if (loc.contains("\n") && !loc.contains("\r\n")) + loc = loc.replaceAll("\n", "\r\n"); + hasChanges |= e.text != loc; + return SmdEntry(e.id, i * 10, loc); + }).toList(); + + if (hasChanges) { + await saveSmd(newEntries, smdPath); + datFilesToExport.add(locFile.datName); + } +} + +Future _processRb(BatchLocalizationFileData locFile, String datDir, List datFilesToExport, BatchLocalizationLanguage lang) async { + var rbPath = join(datDir, locFile.datName, locFile.fileName); + var rbStr = await File(rbPath).readAsString(); + var hasChanges = false; + var langIndex = batchLocRbOrder.indexOf(lang); + if (langIndex == -1) { + throw Exception("Language not found in order: $lang"); + } + var locMap = locFile.asMap(); + var regex = RegExp(r' (\w+) = \[\n(?: "[^\n]+",\n){' + langIndex.toString() + r'} "[^\n]+(?=")'); + var newRbStr = rbStr.replaceAllMapped(regex, (match) { + var key = match.group(1)!; + var fullMatch = match.group(0)!; + if (!locMap.containsKey(key)) + return fullMatch; + var replaceStart = fullMatch.lastIndexOf('\n\t\t"') + 4; + return fullMatch.replaceRange(replaceStart, fullMatch.length, locMap[key]!); + }); + hasChanges = newRbStr != rbStr; + + if (hasChanges) { + await File(rbPath).writeAsString(rbStr); + await rubyFileToBin(rbPath); + datFilesToExport.add(locFile.datName); + } +} + +Future _processHap(BatchLocalizationFileData locFile, String datDir, List datFilesToExport, BatchLocalizationLanguage langFilter) async { + var pakPath = join(datDir, locFile.datName, "pakExtracted", "core_hap.pak"); + var xml = join(pakPath, "25.xml"); + var charNameXmlStr = await File(xml).readAsString(); + var charNameXml = XmlDocument.parse(charNameXmlStr).rootElement; + var name = charNameXml.getElement("name")?.innerText; + if (name != "CharName") { + return; + } + var textParent = charNameXml + .getElement("text"); + var hexStr = textParent + ?.findElements("value") + .map((element) => element.innerText) + .join(""); + if (hexStr == null) { + return; + } + var hexData = hex.decode(hexStr); + var newLineCode = "\n".codeUnitAt(0); + var firstLineEnd = hexData.indexOf(newLineCode) + 1; + var firstLine = hexData.sublist(0, firstLineEnd); + hexData = hexData.sublist(firstLineEnd); + var charNamesStr = utf8.decode(hexData, allowMalformed: true); + var lines = charNamesStr.split("\n"); + var locMap = locFile.asMapOfLists(); + Map visitedCounts = {}; + var hasChanges = false; + var newCharNames = StringBuffer(); + String currentKey = ""; + for (var line in lines) { + if (line.startsWith(" ")) { + var spaceIndex = line.indexOf(" ", 2); + var key = line.substring(2, spaceIndex); + var lang = charNameKeysToBatchLocLang[key]; + var val = line.substring(spaceIndex + 1); + if (langFilter != lang) { + newCharNames.writeln(line); + continue; + } + var loc = _lookupLocWithDuplicates(locMap, currentKey, visitedCounts); + if (loc == null || loc == val) { + newCharNames.writeln(line); + } + else { + newCharNames.writeln(" $key $loc"); + hasChanges = true; + } + } + else { + if (line.isNotEmpty) { + currentKey = line.substring(1); + newCharNames.writeln(line); + } + } + } + + if (hasChanges) { + var newHexData = firstLine + utf8.encode(newCharNames.toString()); + var newHexStr = hex.encode(newHexData); + var sizeEl = textParent!.getElement("size"); + sizeEl!.innerText = "0x${newHexData.length.toRadixString(16)}"; + for (var oldValue in textParent.findElements("value").toList()) + oldValue.remove(); + for (int i = 0; i < newHexStr.length; i += 64) { + var valueEl = makeXmlElement(name: "value", text: newHexStr.substring(i, min(i + 64, newHexStr.length))); + textParent.children.add(valueEl); + } + var newXmlStr = charNameXml.toPrettyString(); + await File(xml).writeAsString(newXmlStr); + await xmlFileToYaxFile(xml); + await repackPak(pakPath); + datFilesToExport.add(locFile.datName); + } +} + +Future _processMcd(BatchLocalizationFileData locFile, String datDir, List datFilesToExport) async { + var mcdPath = join(datDir, locFile.datName, locFile.fileName); + McdData? mcd; + try { + mcd = await McdData.fromMcdFile(null, mcdPath); + mcd.exportDatFunc = (path) async => datFilesToExport.add(basename(path)); + var locMap = locFile.asMap(); + var hasChanges = false; + for (var event in mcd.events) { + var loc = locMap[event.name.value]; + if (loc == null) + continue; + var locLines = loc.split("\n"); + for (var paragraph in event.paragraphs) { + for (var (i, line) in paragraph.lines.indexed) { + if (i >= locLines.length) + break; + var locLine = locLines[i]; + if (line.text.value != locLine) { + line.text.value = locLine; + hasChanges = true; + } + } + } + } + + if (hasChanges) { + await mcd.save(); + datFilesToExport.add(locFile.datName); + datFilesToExport.add(locFile.datName.replaceFirst(".dat", ".dtt")); + } + } finally { + mcd?.dispose(); + } +} + +String? _lookupLocWithDuplicates(Map> locMap, String key, Map visitedCounts) { + var locs = locMap[key]; + if (locs == null || locs.isEmpty) + return null; + if (locs.length == 1) + return locs[0]; + var lastReadAtIndex = visitedCounts[key] ?? -1; + var readFromIndex = min(lastReadAtIndex + 1, locs.length - 1); + visitedCounts[key] = readFromIndex; + return locs[readFromIndex]; +} + +class BatchLocExportProgress extends ChangeNotifier { + bool isRunning = false; + int step = 0; + int totalSteps = 2; + String stepName = ""; + int file = 0; + int totalFiles = 0; + String? currentFile; + List messages = []; + + void reset() { + isRunning = false; + step = 0; + totalSteps = 2; + stepName = ""; + file = 0; + totalFiles = 0; + currentFile = null; + messages.clear(); + notifyListeners(); + } + + void notify() { + notifyListeners(); + } +} diff --git a/lib/utils/batchLocalization/LocalizationExtractor.dart b/lib/utils/batchLocalization/LocalizationExtractor.dart new file mode 100644 index 00000000..63992c77 --- /dev/null +++ b/lib/utils/batchLocalization/LocalizationExtractor.dart @@ -0,0 +1,349 @@ + +import 'dart:convert'; +import 'dart:io'; + +import 'package:convert/convert.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart'; +import 'package:xml/xml.dart'; + +import '../../fileTypeUtils/dat/datExtractor.dart'; +import '../../fileTypeUtils/mcd/mcdIO.dart'; +import '../../fileTypeUtils/pak/pakExtractor.dart'; +import '../../fileTypeUtils/ruby/pythonRuby.dart'; +import '../../fileTypeUtils/smd/smdReader.dart'; +import '../../fileTypeUtils/tmd/tmdReader.dart'; +import '../../stateManagement/events/statusInfo.dart'; +import '../utils.dart'; +import 'BatchLocalizationData.dart'; +import 'batchLocalizationUtils.dart'; + +Future extractLocalizationFiles({ + required String workDir, + required List searchPaths, + required String savePath, + required BatchLocalizationLanguage language, + required bool reextractDats, + required bool extractMcd, + required bool extractTmd, + required bool extractSmd, + required bool extractRb, + required bool extractHap, + required BatchLocExtractionProgress progress, +}) async { + var datDttFiles = (await Future.wait( + searchPaths.map((searchPath) => Directory(searchPath) + .list(recursive: true) + .where((file) => file.path.endsWith(".dat") || file.path.endsWith(".dtt")) + .toList()) + )) + .expand((files) => files) + .whereType() + .toList(); + var datFiles = datDttFiles.where((file) => file.path.endsWith(".dat")).toList(); + progress.totalFiles = datFiles.length; + + var datDir = join(workDir, "dat"); + await Directory(datDir).create(recursive: true); + List fileLoc = []; + for (var datFile in datFiles) { + progress.processedFiles++; + progress.currentFile = basename(datFile.path); + progress.update(); + try { + var parentDir = basename(dirname(datFile.path)); + var datLang = _getLangFromDat(datFile.path); + if (parentDir == "ui") { + if(!extractMcd) + continue; + if (datLang != language) + continue; + if (await datFile.length() == 0) + continue; + var mcdBaseName = _nameWithoutLang(datFile.path); + if (mcdBaseName.startsWith("ui_")) { + mcdBaseName = mcdBaseName.substring(3); + mcdBaseName = "mess$mcdBaseName"; + } + var mcdPath = join(await _getExtractedDir(datFile.path, datDir, reextractDats), "$mcdBaseName.mcd"); + var mcdData = await _getMcdData(mcdPath); + if (mcdData != null) { + fileLoc.add(mcdData); + var dttSrcPath = datFile.path.replaceFirst(".dat", ".dtt"); + var dttDestPath = join(datDir, basename(dttSrcPath)); + if (!await File(dttDestPath).exists() || reextractDats) { + await File(dttSrcPath).copy(dttDestPath); + await extractDatFiles(dttDestPath); + } + } + } + else if (parentDir == "txtmess") { + if(!extractTmd) + continue; + if (datLang != language) + continue; + var tmdPath = join(await _getExtractedDir(datFile.path, datDir, reextractDats), "${_nameWithoutLang(datFile.path)}.tmd"); + var tmdData = await _getTmdSmdData(tmdPath); + if (tmdData != null) { + fileLoc.add(tmdData); + } + } + else if (parentDir == "subtitle") { + if(!extractSmd) + continue; + if (datLang != language) + continue; + var smdPath = join(await _getExtractedDir(datFile.path, datDir, reextractDats), "${_nameWithoutLang(datFile.path)}.smd"); + var smdData = await _getTmdSmdData(smdPath); + if (smdData != null) { + fileLoc.add(smdData); + } + } + else if (datFile.path.endsWith("corehap.dat")) { + if (!extractHap) + continue; + var hapDatDir = await _getExtractedDir(datFile.path, datDir, reextractDats); + var hapData = await _getCoreHapData(hapDatDir, language, reextractDats); + if (hapData != null) { + fileLoc.add(hapData); + } + } + else if ( + parentDir == "core" || + parentDir == "quest" || + parentDir.startsWith("ph") || + parentDir.startsWith("st") || + parentDir.startsWith("wd") + ) { + if(!extractRb) + continue; + var datContents = await peekDatFileNames(datFile.path); + var hasRbFiles = datContents.any((file) => file.endsWith("_scp.bin")); + if (!hasRbFiles) + continue; + var rbDatDir = await _getExtractedDir(datFile.path, datDir, reextractDats); + var rbData = await _getRbData(rbDatDir, language, reextractDats); + fileLoc.addAll(rbData); + } + } catch (e, stack) { + messageLog.add("Error while processing $datFile: $e\n$stack"); + } + } + progress.currentFile = null; + + var locData = BatchLocalizationData(fileLoc, language); + var useJson = savePath.endsWith(".json"); + String fileString; + if (useJson) { + fileString = JsonEncoder.withIndent(" ").convert(locData.toJson()); + } + else { + var writer = StringBuffer(); + locData.writeString(writer); + fileString = writer.toString(); + } + await File(savePath).writeAsString(fileString); + + showToast("All text saved to $savePath"); +} + +Future _getExtractedDir(String datPath, String datWorkDir, bool reextractDats) async { + var datNewPath = join(datWorkDir, basename(datPath)); + if (!await File(datNewPath).exists() || reextractDats) + await File(datPath).copy(datNewPath); + var extractedDir = join(dirname(datNewPath), datSubExtractDir, basename(datNewPath)); + if (await Directory(extractedDir).exists()) { + if (reextractDats) { + await Directory(extractedDir).delete(recursive: true); + await extractDatFiles(datNewPath); + } + } else { + await extractDatFiles(datNewPath); + } + return extractedDir; +} + +String _nameWithoutLang(String datPath) { + var name = basenameWithoutExtension(datPath); + var endPos = name.lastIndexOf("_"); + if (endPos == -1) + return name; + return name.substring(0, endPos); +} + +Future _getTmdSmdData(String tmdSmdPath) async { + if (!await File(tmdSmdPath).exists()) + return null; + List entries; + if (tmdSmdPath.endsWith(".tmd")) { + entries = (await readTmdFile(tmdSmdPath)) + .map((entry) => BatchLocalizationEntryData(entry.id, entry.text)) + .toList(); + } else { + entries = (await readSmdFile(tmdSmdPath)) + .map((entry) => BatchLocalizationEntryData(entry.id, entry.text)) + .toList(); + } + if (entries.isEmpty) + return null; + var datPath = basename(dirname(tmdSmdPath)); + return BatchLocalizationFileData( + basename(datPath), + basename(tmdSmdPath), + entries, + ); +} + +Future> _getRbData(String datDir, BatchLocalizationLanguage langFilter, bool reextractDats) async { + var binFiles = await Directory(datDir) + .list() + .where((file) => file.path.endsWith("_scp.bin")) + .toList(); + List files = []; + await Future.wait(binFiles.map((binFile) async { + var rbPath = "${binFile.path}.rb"; + if (!await File(rbPath).exists() || reextractDats) + await binFileToRuby(binFile.path); + + List entries = []; + var rbText = await File(rbPath).readAsString(); + var arrayRegex = RegExp(r' (\w+) = \[\n(?: ".*",?\n){3,} \]'); + var matches = arrayRegex.allMatches(rbText); + for (var match in matches) { + var key = match.group(1); + var strRegex = RegExp(r' "(.*)",?\n'); + var strings = strRegex.allMatches(match.group(0)!); + for (var (i, string) in strings.indexed) { + if (i >= batchLocRbOrder.length) + break; + var lang = batchLocRbOrder[i]; + if (langFilter != lang) + continue; + entries.add(BatchLocalizationEntryData(key!, string.group(1)!)); + } + } + if (entries.isNotEmpty) { + var datPath = basename(dirname(binFile.path)); + files.add(BatchLocalizationFileData( + basename(datPath), + basename(rbPath), + entries, + )); + } + })); + return files; +} + +Future _getMcdData(String mcdPath) async { + if (!await File(mcdPath).exists()) + return null; + var mcd = await McdFile.fromFile(mcdPath); + List entries = []; + for (var event in mcd.events) { + var key = event.name; + var paragraph = event.message.paragraphs.first; + List lines = [] ; + for (var line in paragraph.lines) { + var text = line.toString(); + lines.add(text); + } + var text = lines.join("\n"); + entries.add(BatchLocalizationEntryData(key, text)); + } + if (entries.isEmpty) + return null; + var datPath = basename(dirname(mcdPath)); + return BatchLocalizationFileData( + basename(datPath), + basename(mcdPath), + entries, + ); +} + +Future _getCoreHapData(String datDir, BatchLocalizationLanguage langFilter, bool reextractDats) async { + var corehapPath = join(datDir, "core_hap.pak"); + var corehapExtractedPath = join(datDir, "pakExtracted", "core_hap.pak"); + if (!await File(corehapExtractedPath).exists() || reextractDats) + await extractPakFiles(corehapPath, yaxToXml: true); + var charNameXmlPath = join(corehapExtractedPath, "25.xml"); + if (!await File(charNameXmlPath).exists()) + return null; + var charNameXmlStr = await File(charNameXmlPath).readAsString(); + var charNameXml = XmlDocument.parse(charNameXmlStr).rootElement; + var name = charNameXml.getElement("name")?.innerText; + if (name != "CharName") + return null; + var hexStr = charNameXml + .getElement("text") + ?.findElements("value") + .map((element) => element.innerText) + .join(""); + if (hexStr == null) + return null; + var hexData = hex.decode(hexStr); + var charNamesStr = utf8.decode(hexData, allowMalformed: true); + var lines = charNamesStr.split("\n"); + lines = lines.sublist(1, lines.length - 1); + List entries = []; + String currentKey = ""; + for (var line in lines) { + if (line.startsWith(" ")) { + line = line.substring(2); + var spaceIndex = line.indexOf(" "); + var key = line.substring(0, spaceIndex); + var val = line.substring(spaceIndex + 1); + var lang = charNameKeysToBatchLocLang[key]; + if (langFilter != lang) + continue; + entries.add(BatchLocalizationEntryData(currentKey, val)); + } + else { + currentKey = line.substring(1); + } + } + if (entries.isEmpty) + return null; + var datPath = basename(dirname(corehapPath)); + return BatchLocalizationFileData( + basename(datPath), + basename(corehapPath), + entries, + ); +} + +const _extToLang = { + "us": BatchLocalizationLanguage.us, + "fr": BatchLocalizationLanguage.fr, + "de": BatchLocalizationLanguage.de, + "it": BatchLocalizationLanguage.it, + "jp": BatchLocalizationLanguage.jp, + "es": BatchLocalizationLanguage.es, +}; +BatchLocalizationLanguage? _getLangFromDat(String datPath) { + var name = basenameWithoutExtension(datPath); + var parts = name.split("_"); + if (parts.length == 1) + return BatchLocalizationLanguage.jp; + return _extToLang[parts.last]; +} + +class BatchLocExtractionProgress extends ChangeNotifier { + bool isRunning = false; + String? error; + int totalFiles = 0; + int processedFiles = 0; + String? currentFile; + + void reset() { + isRunning = false; + error = null; + totalFiles = 0; + processedFiles = 0; + currentFile = null; + notifyListeners(); + } + + void update() { + notifyListeners(); + } +} diff --git a/lib/utils/batchLocalization/batchLocalizationUtils.dart b/lib/utils/batchLocalization/batchLocalizationUtils.dart new file mode 100644 index 00000000..ca16f88b --- /dev/null +++ b/lib/utils/batchLocalization/batchLocalizationUtils.dart @@ -0,0 +1,24 @@ + +import 'BatchLocalizationData.dart'; + +const batchLocRbOrder = [ + BatchLocalizationLanguage.jp, + BatchLocalizationLanguage.us, + BatchLocalizationLanguage.fr, + BatchLocalizationLanguage.it, + BatchLocalizationLanguage.de, + BatchLocalizationLanguage.es, + BatchLocalizationLanguage.kor, + BatchLocalizationLanguage.cn, +]; + +const charNameKeysToBatchLocLang = { + "JPN": BatchLocalizationLanguage.jp, + "ENG": BatchLocalizationLanguage.us, + "FRA": BatchLocalizationLanguage.fr, + "ITA": BatchLocalizationLanguage.it, + "GER": BatchLocalizationLanguage.de, + "ESP": BatchLocalizationLanguage.es, + "CHT": BatchLocalizationLanguage.cn, + "KOR": BatchLocalizationLanguage.kor, +}; diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index c370ab0a..7e09fbba 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -688,6 +688,15 @@ num avrM(Iterable values, num Function(T) mapper) { return avr(values.map(mapper)); } +List deduplicate(List list) { + List deduped = []; + for (var item in list) { + if (!deduped.contains(item)) + deduped.add(item); + } + return deduped; +} + bool isSubtype() => [] is List; bool isAreaProp(XmlProp prop) { diff --git a/lib/widgets/filesView/FileType.dart b/lib/widgets/filesView/FileType.dart index e4cdacb8..337e8043 100644 --- a/lib/widgets/filesView/FileType.dart +++ b/lib/widgets/filesView/FileType.dart @@ -19,6 +19,7 @@ import 'types/SaveSlotDataEditor.dart'; import 'types/TextFileEditor.dart'; import 'types/bnkPlaylistEditor/BnkPlaylistEditor.dart'; import 'types/effect/EstFileEditor.dart'; +import 'types/fonts/FontsManager.dart'; import 'types/fonts/ftbEditor.dart'; import 'types/fonts/mcdEditor.dart'; import 'types/genericTable/TableFileEditor.dart'; @@ -41,6 +42,7 @@ enum FileType { saveSlotData, wta, est, + fontSettings, none, } @@ -70,6 +72,11 @@ Widget makeFileEditor(OpenFileData content) { return WtaWtpEditor(file: content as WtaWtpData); case FileType.est: return EstFileEditor(file: content as EstFileData); + case FileType.fontSettings: + return Padding( + padding: const EdgeInsets.only(top: 40), + child: FontsManager(), + ); case FileType.text: return TextFileEditor(key: Key(content.uuid), fileContent: content as TextFileData); default: diff --git a/lib/widgets/filesView/types/fonts/FontsManager.dart b/lib/widgets/filesView/types/fonts/FontsManager.dart index a8fc562b..3ba1efb8 100644 --- a/lib/widgets/filesView/types/fonts/FontsManager.dart +++ b/lib/widgets/filesView/types/fonts/FontsManager.dart @@ -8,12 +8,10 @@ import 'package:flutter/material.dart'; import '../../../../stateManagement/Property.dart'; import '../../../../stateManagement/openFiles/types/McdFileData.dart'; -import '../../../../utils/utils.dart'; import '../../../misc/ChangeNotifierWidget.dart'; -import '../../../misc/ColumnSeparated.dart'; import '../../../misc/SmoothScrollBuilder.dart'; import '../../../misc/smallButton.dart'; -import '../../../propEditors/primaryPropTextField.dart'; +import '../../../propEditors/UnderlinePropTextField.dart'; import '../../../propEditors/propEditorFactory.dart'; import '../../../propEditors/propTextField.dart'; import '../../../theme/customTheme.dart'; @@ -41,169 +39,30 @@ class __McdFontsManagerState extends ChangeNotifierState { iEnd = iStart + 1; } - const columnNames = [ - "", - "Font IDs", - "TTF/OTF Path", - "", - "fallback only", - "scale", - "xPadding", - "yPadding", - "xOffset", - "yOffset", - "thickness", - "shadow blur", - "", - "" - ]; return SmoothSingleChildScrollView( - child: ColumnSeparated( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Table( - columnWidths: const { - 0: FixedColumnWidth(16), - 1: FixedColumnWidth(100 + 8), - 2: FlexColumnWidth(1), - 3: FixedColumnWidth(30 + 8), - 4: FixedColumnWidth(120 + 8), - 5: FixedColumnWidth(70 + 8), - 6: FixedColumnWidth(70 + 8), - 7: FixedColumnWidth(70 + 8), - 8: FixedColumnWidth(70 + 8), - 9: FixedColumnWidth(70 + 8), - 10: FixedColumnWidth(70 + 8), - 11: FixedColumnWidth(100 + 8), - 12: FixedColumnWidth(30 + 8), - 13: FixedColumnWidth(16), - }, - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - children: [ - TableRow( - children: List.generate(columnNames.length, (i) => - Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: getTheme(context).tableBgColor, - border: between(i, 1, columnNames.length - 4) - ? Border( - right: BorderSide( - color: getTheme(context).dividerColor!, - width: 1, - ), - ) - : null, - ), - child: Center( - child: Text(columnNames[i], textScaleFactor: 1, overflow: TextOverflow.ellipsis), - ), - )) - ), - for (int i = iStart; i < iEnd; i++) - TableRow( - key: ValueKey(McdData.fontOverrides[i].uuid), - children: [ - const SizedBox(), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: ChangeNotifierBuilder( - notifier: McdData.fontOverrides[i].fontIds, - builder: (context) => Text( - McdData.fontOverrides[i].fontIds.map((id) => "$id").join(", "), - overflow: TextOverflow.ellipsis, - ) - ), - ), - IconButton( - onPressed: () => showDialog(context: context, builder: (context) => _FontOverrideIdsSelector(McdData.fontOverrides[i]),), - padding: EdgeInsets.zero, - constraints: BoxConstraints.tight(const Size(30, 30)), - splashRadius: 18, - iconSize: 18, - icon: const Icon(Icons.edit_outlined), - ), - ], - ), - PrimaryPropTextField( - prop: McdData.fontOverrides[i].fontPath, - options: const PropTFOptions(hintText: "Font Path", constraints: BoxConstraints.tightFor(height: 30)), - validatorOnChange: (str) => str.isEmpty || File(str).existsSync() ? null : "File not found", - onValid: (str) => McdData.fontOverrides[i].fontPath.value = str, - ), - SmallButton( - onPressed: () async { - var selectedFontFile = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ["ttf", "otf"], - allowMultiple: false, - ); - if (selectedFontFile == null) - return; - var fontPath = selectedFontFile.files.first.path!; - McdData.fontOverrides[i].fontPath.value = fontPath; - }, - constraints: BoxConstraints.tight(const Size(30, 30)), - child: const Icon(Icons.folder, size: 17), - ), - makePropEditor( - McdData.fontOverrides[i].isFallbackOnly, - const PropTFOptions(hintText: "Fallback only", constraints: BoxConstraints.tightFor(height: 30)), - ), - makePropEditor( - McdData.fontOverrides[i].heightScale, - const PropTFOptions(hintText: "scale", constraints: BoxConstraints.tightFor(height: 30)), - ), - makePropEditor( - McdData.fontOverrides[i].letXPadding, - const PropTFOptions(hintText: "X Padding", constraints: BoxConstraints.tightFor(height: 30)), - ), - makePropEditor( - McdData.fontOverrides[i].letYPadding, - const PropTFOptions(hintText: "Y Padding", constraints: BoxConstraints.tightFor(height: 30)), - ), - makePropEditor( - McdData.fontOverrides[i].xOffset, - const PropTFOptions(hintText: "X Offset", constraints: BoxConstraints.tightFor(height: 30)), - ), - makePropEditor( - McdData.fontOverrides[i].yOffset, - const PropTFOptions(hintText: "Y Offset", constraints: BoxConstraints.tightFor(height: 30)), - ), - makePropEditor( - McdData.fontOverrides[i].strokeWidth, - const PropTFOptions(hintText: "thickness", constraints: BoxConstraints.tightFor(height: 30)), - ), - makePropEditor( - McdData.fontOverrides[i].rgbBlurSize, - const PropTFOptions(hintText: "shadow", constraints: BoxConstraints.tightFor(height: 30)), - ), - SmallButton( - onPressed: () => McdData.fontOverrides.removeAt(i).dispose(), - constraints: BoxConstraints.tight(const Size(30, 30)), - child: const Icon(Icons.remove, size: 18), - ), - const SizedBox(), - ].map((e) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - child: e, - )).toList(), - ), - ], - ), + for (int i = iStart; i < iEnd; i++) + _FontOverrideEditor( + fontOverride: McdData.fontOverrides[i], + singleFontId: widget.singleFontId, + altColor: i % 2 == 1, + ), if (McdData.fontOverrides.isEmpty) const Padding( padding: EdgeInsets.only(left: 20), child: Text("No font overrides"), ), ...[ - if (widget.singleFontId == -1) + if (widget.singleFontId == -1) ...[ + const SizedBox(height: 16), SmallButton( onPressed: () => McdData.addFontOverride(), constraints: BoxConstraints.tight(const Size(30, 30)), child: const Icon(Icons.add), ), + ], Row( children: [ const Text("Letter Padding: "), @@ -255,12 +114,144 @@ class __McdFontsManagerState extends ChangeNotifierState { } } +class _FontOverrideEditor extends StatelessWidget { + static const _numberFieldConfig = PropTFOptions(constraints: BoxConstraints.tightFor(width: 50)); + final McdFontOverride fontOverride; + final int singleFontId; + final bool altColor; + + _FontOverrideEditor({ + required this.fontOverride, + required this.singleFontId, + required this.altColor, + }) : super(key: Key(fontOverride.uuid)); + + @override + Widget build(BuildContext context) { + return Material( + color: altColor ? getTheme(context).tableBgAltColor : getTheme(context).tableBgColor, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + children: [ + Row( + children: [ + Text("Font IDs: "), + ChangeNotifierBuilder( + notifier: fontOverride.fontIds, + builder: (context) => Text( + fontOverride.fontIds.map((id) => "$id").join(", "), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 12), + IconButton( + onPressed: () => showDialog(context: context, builder: (context) => _FontOverrideIdsSelector(fontOverride)), + padding: EdgeInsets.zero, + constraints: BoxConstraints.tight(const Size(30, 30)), + splashRadius: 18, + iconSize: 18, + icon: const Icon(Icons.edit_outlined), + ), + const SizedBox(width: 16), + Text(" Only as fallback: "), + makePropEditor( + fontOverride.isFallbackOnly, + ), + Spacer(), + SmallButton( + onPressed: () => McdData.fontOverrides.remove(fontOverride), + constraints: BoxConstraints.tight(const Size(30, 30)), + child: const Icon(Icons.remove, size: 18), + ), + ], + ), + Row( + children: [ + Text("TTF/OTF Path "), + UnderlinePropTextField( + prop: fontOverride.fontPath, + options: const PropTFOptions(constraints: BoxConstraints(minWidth: 300)), + validatorOnChange: (str) => str.isEmpty || File(str).existsSync() ? null : "File not found", + onValid: (str) => fontOverride.fontPath.value = str, + ), + const SizedBox(width: 12), + IconButton( + onPressed: () async { + var selectedFontFile = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ["ttf", "otf"], + allowMultiple: false, + ); + if (selectedFontFile == null) + return; + var fontPath = selectedFontFile.files.first.path!; + fontOverride.fontPath.value = fontPath; + }, + constraints: BoxConstraints.tight(const Size(30, 30)), + splashRadius: 18, + padding: EdgeInsets.zero, + icon: const Icon(Icons.folder, size: 17), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Text("Scale "), + makePropEditor(fontOverride.heightScale, _numberFieldConfig), + const SizedBox(width: 20), + Text("Thickness "), + makePropEditor(fontOverride.strokeWidth, _numberFieldConfig), + const SizedBox(width: 20), + Text("Shadow Blur "), + makePropEditor(fontOverride.rgbBlurSize, _numberFieldConfig), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + _make2x2Table("X Padding ", fontOverride.letXPadding, "Y Padding ", fontOverride.letYPadding), + const SizedBox(width: 20), + _make2x2Table("X Offset ", fontOverride.xOffset, "Y Offset ", fontOverride.yOffset), + ], + ), + ], + ), + ), + ); + } + + Widget _make2x2Table(String label1, Prop prop1, String label2, Prop prop2) { + return Table( + columnWidths: const { + 0: IntrinsicColumnWidth(), + 1: IntrinsicColumnWidth(), + }, + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + children: [ + TableRow( + children: [ + Text(label1), + makePropEditor(prop1, _numberFieldConfig), + ] + ), + TableRow( + children: [ + Text(label2), + makePropEditor(prop2, _numberFieldConfig), + ] + ), + ], + ); + } +} + class _FontOverrideIdsSelector extends StatefulWidget { final McdFontOverride fontOverride; const _FontOverrideIdsSelector(this.fontOverride); - @override State<_FontOverrideIdsSelector> createState() => _FontOverrideIdsSelectorState(); } diff --git a/lib/widgets/layout/searchPanel.dart b/lib/widgets/layout/searchPanel.dart index 336d7c81..40b56ee7 100644 --- a/lib/widgets/layout/searchPanel.dart +++ b/lib/widgets/layout/searchPanel.dart @@ -276,11 +276,11 @@ class _SearchPanelState extends State { }, style: ButtonStyle( backgroundColor: searchType == type - ? MaterialStateProperty.all(getTheme(context).textColor!.withOpacity(0.1)) - : MaterialStateProperty.all(Colors.transparent), + ? WidgetStateProperty.all(getTheme(context).textColor!.withOpacity(0.1)) + : WidgetStateProperty.all(Colors.transparent), foregroundColor: searchType == type - ? MaterialStateProperty.all(getTheme(context).textColor) - : MaterialStateProperty.all(getTheme(context).textColor!.withOpacity(0.5)), + ? WidgetStateProperty.all(getTheme(context).textColor) + : WidgetStateProperty.all(getTheme(context).textColor!.withOpacity(0.5)), ), child: Text( text, diff --git a/lib/widgets/tools/BatchLocalizationTool.dart b/lib/widgets/tools/BatchLocalizationTool.dart new file mode 100644 index 00000000..eb9b20db --- /dev/null +++ b/lib/widgets/tools/BatchLocalizationTool.dart @@ -0,0 +1,500 @@ + +import 'dart:io'; +import 'dart:math'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart'; + +import '../../main.dart'; +import '../../stateManagement/Property.dart'; +import '../../stateManagement/events/statusInfo.dart'; +import '../../stateManagement/listNotifier.dart'; +import '../../stateManagement/openFiles/openFilesManager.dart'; +import '../../stateManagement/preferencesData.dart'; +import '../../utils/batchLocalization/BatchLocalizationData.dart'; +import '../../utils/batchLocalization/BatchLocalizationExporter.dart'; +import '../../utils/batchLocalization/LocalizationExtractor.dart'; +import '../../utils/utils.dart'; +import '../misc/ChangeNotifierWidget.dart'; +import '../misc/confirmDialog.dart'; +import '../propEditors/UnderlinePropTextField.dart'; +import '../propEditors/boolPropCheckbox.dart'; +import '../propEditors/propEditorFactory.dart'; +import '../propEditors/propTextField.dart'; +import '../theme/customTheme.dart'; + +class BatchLocalizationTool extends StatefulWidget { + const BatchLocalizationTool({super.key}); + + @override + State createState() => _BatchLocalizationToolState(); +} + +class _BatchLocalizationToolState extends State { + final workingDirectory = StringProp("", fileId: null); + bool extractMode = true; + final useJson = BoolProp(false, fileId: null); + bool hasGuessedJsonMode = false; + // extract + bool datsAlreadyExtracted = false; + BatchLocalizationLanguage language = BatchLocalizationLanguage.fr; + final reextractDats = BoolProp(false, fileId: null); + final searchPaths = ValueListNotifier([], fileId: null); + final extractMcd = BoolProp(true, fileId: null); + final extractTmd = BoolProp(true, fileId: null); + final extractSmd = BoolProp(true, fileId: null); + final extractRb = BoolProp(true, fileId: null); + final extractHap = BoolProp(true, fileId: null); + BatchLocExtractionProgress? extractionProgress; + // repack + final repackPath = StringProp("", fileId: null); + BatchLocExportProgress? exportProgress; + + @override + void initState() { + super.initState(); + var prefs = PreferencesData(); + repackPath.value = prefs.dataExportPath?.value ?? ""; + searchPaths.add(StringProp("", fileId: null)); + workingDirectory.addListener(_onWorkingDirectoryChange); + } + + @override + void dispose() { + workingDirectory.dispose(); + reextractDats.dispose(); + searchPaths.dispose(); + extractMcd.dispose(); + extractTmd.dispose(); + extractSmd.dispose(); + extractRb.dispose(); + extractHap.dispose(); + repackPath.dispose(); + extractionProgress?.dispose(); + exportProgress?.dispose(); + super.dispose(); + } + + void _onWorkingDirectoryChange() { + _guessJsonMode(); + _checkIfAlreadyExtracted(); + } + + void _guessJsonMode() async { + if (!await Directory(workingDirectory.value).exists()) { + hasGuessedJsonMode = false; + return; + } + if (hasGuessedJsonMode) + return; + var txtPath = join(workingDirectory.value, "localization.txt"); + var jsonPath = join(workingDirectory.value, "localization.json"); + var txtExists = await File(txtPath).exists(); + var jsonExists = await File(jsonPath).exists(); + switch ((txtExists, jsonExists)) { + case (true, false): + useJson.value = false; + break; + case (false, true): + useJson.value = true; + break; + case (false, false): + break; + case (true, true): + var txtStat = await File(txtPath).stat(); + var jsonStat = await File(jsonPath).stat(); + useJson.value = jsonStat.modified.isAfter(txtStat.modified); + break; + } + if (txtExists || jsonExists) + hasGuessedJsonMode = true; + } + + void _checkIfAlreadyExtracted() async { + bool newDatsAlreadyExtracted; + var datsDir = join(workingDirectory.value, "dat", datSubExtractDir); + if (!await Directory(datsDir).exists()) { + newDatsAlreadyExtracted = false; + } + else { + var extractedFiles = await Directory(datsDir).list().take(2).length; + newDatsAlreadyExtracted = extractedFiles > 0; + } + if (newDatsAlreadyExtracted != datsAlreadyExtracted) { + datsAlreadyExtracted = newDatsAlreadyExtracted; + setState(() {}); + } + } + + void _extract() async { + var workDirValid = await Directory(workingDirectory.value).exists(); + var searchPathsValid = + (await Future.wait(searchPaths.map((e) => Directory(e.value).exists()))) + .every((e) => e); + if (!workDirValid || !searchPathsValid) { + showToast("Working directory or search path are invalid"); + return; + } + var saveName = useJson.value ? "localization.json" : "localization.txt"; + var savePath = join(workingDirectory.value, saveName); + if (await File(savePath).exists()) { + var answer = await confirmDialog( + getGlobalContext(), + title: "Overwrite $saveName?", + body: "All data in $saveName will be lost." + ); + if (answer != true) + return; + } + + extractionProgress?.reset(); + extractionProgress ??= BatchLocExtractionProgress(); + extractionProgress!.isRunning = true; + setState(() {}); + try { + await extractLocalizationFiles( + workDir: workingDirectory.value, + searchPaths: searchPaths.map((e) => e.value).toList(), + savePath: savePath, + language: language, + reextractDats: reextractDats.value, + extractMcd: extractMcd.value, + extractTmd: extractTmd.value, + extractSmd: extractSmd.value, + extractRb: extractRb.value, + extractHap: extractHap.value, + progress: extractionProgress!, + ); + } on Exception catch (e, st) { + messageLog.add("Error during extraction: \n$e\n$st"); + extractionProgress?.error ??= "An error occurred during extraction"; + } + extractionProgress!.isRunning = false; + setState(() {}); + } + + Future _repack() async { + var workDirValid = await Directory(workingDirectory.value).exists(); + var repackDirValid = await Directory(repackPath.value).exists(); + if (!workDirValid || !repackDirValid) { + showToast("Working directory or repack directory are invalid"); + return; + } + var saveName = useJson.value ? "localization.json" : "localization.txt"; + var savePath = join(workingDirectory.value, saveName); + if (!await File(savePath).exists()) { + showToast("$saveName not found in working directory"); + return; + } + + exportProgress?.reset(); + exportProgress ??= BatchLocExportProgress(); + exportProgress!.isRunning = true; + setState(() {}); + try { + await exportBatchLocalization( + workingDirectory: workingDirectory.value, + localizationFile: savePath, + exportFolder: repackPath.value, + progress: exportProgress!, + ); + } on Exception catch (e, st) { + messageLog.add("$e\n$st"); + messageLog.add("Error during export"); + } + exportProgress!.isRunning = false; + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _makeTabBar(context), + _makeCommonSettings(), + if (extractMode) + _makeExtractTab() + else + _makeRepackTab(), + const SizedBox(height: 8), + ], + ); + } + + Widget _makeTabBar(BuildContext context) { + ButtonStyle getStyle(bool isActive) { + return ButtonStyle( + backgroundColor: isActive + ? WidgetStateProperty.all(getTheme(context).textColor!.withOpacity(0.1)) + : WidgetStateProperty.all(Colors.transparent), + foregroundColor: isActive + ? WidgetStateProperty.all(getTheme(context).textColor) + : WidgetStateProperty.all(getTheme(context).textColor!.withOpacity(0.5)) + ); + } + return SizedBox( + height: 40, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: TextButton( + onPressed: () => setState(() => extractMode = true), + style: getStyle(extractMode), + child: const Text("Extract"), + ), + ), + Expanded( + child: TextButton( + onPressed: () => setState(() => extractMode = false), + style: getStyle(!extractMode), + child: const Text("Repack"), + ), + ), + ], + ), + ); + } + + Widget _makeCommonSettings() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _makeFolderSelector("Working directory", workingDirectory), + ], + ), + ); + } + + Widget _makeExtractTab() { + var progress = extractionProgress; + return Padding( + key: Key("extractTab"), + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _makeSearchPaths(), + _makeLanguageSelector(), + Text("Extracted file types:"), + _makeCheckBox("mcd", extractMcd), + _makeCheckBox("tmd", extractTmd), + _makeCheckBox("smd", extractSmd), + _makeCheckBox("rb", extractRb), + _makeCheckBox("hap", extractHap), + _makeCheckBox("Use JSON format", useJson), + if (datsAlreadyExtracted) + _makeCheckBox("Re-extract DATs", reextractDats), + const SizedBox(height: 8), + ElevatedButton( + onPressed: progress?.isRunning != true ? _extract : null, + child: Align( + alignment: Alignment.center, + child: const Text("Extract"), + ), + ), + if (progress != null) + ChangeNotifierBuilder( + notifier: progress, + builder: (context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Text( + "File ${progress.processedFiles} / ${progress.totalFiles}: ${progress.currentFile ?? ""}", + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + LinearProgressIndicator( + value: progress.processedFiles / (max(progress.totalFiles, 1)), + backgroundColor: Colors.transparent, + ), + if (progress.error != null) + Text(progress.error!), + ], + ) + ) + ], + ), + ); + } + + Widget _makeRepackTab() { + var progress = exportProgress; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Column( + key: Key("repackTab"), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _makeFolderSelector("Repack directory", repackPath), + _makeCheckBox("Use JSON format", useJson), + Align( + alignment: Alignment.centerRight, + child: OutlinedButton( + onPressed: () => areasManager.openFile("fontSettings"), + child: Text("Open MCD font settings") + ), + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: exportProgress?.isRunning != true ? _repack : null, + child: Align( + alignment: Alignment.center, + child: const Text("Repack") + ), + ), + if (progress != null) + ChangeNotifierBuilder( + notifier: progress, + builder: (context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Text("Step ${progress.step} / ${progress.totalSteps}: ${progress.stepName}", overflow: TextOverflow.ellipsis,), + const SizedBox(height: 4), + LinearProgressIndicator( + value: progress.step / (max(progress.totalSteps, 1)), + backgroundColor: Colors.transparent, + ), + const SizedBox(height: 4), + if (progress.step == 2 && progress.totalFiles == 0) + const Text("No changed files") + else + Text("File ${progress.file} / ${progress.totalFiles}: ${progress.currentFile ?? ""}", overflow: TextOverflow.ellipsis,), + const SizedBox(height: 4), + LinearProgressIndicator( + value: progress.totalFiles != 0 ? progress.file / progress.totalFiles : 1, + backgroundColor: Colors.transparent, + ), + ], + ), + ), + ], + ), + ); + } + + Widget _makeLanguageSelector() { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + children: [ + Flexible( + child: const Text("Language to edit: ", overflow: TextOverflow.ellipsis), + ), + PopupMenuButton( + initialValue: language, + onSelected: (lang) { + setState(() => language = lang); + }, + itemBuilder: (context) => _langNames.entries.map((e) => PopupMenuItem( + value: e.key, + height: 25, + child: Text(e.value), + )).toList(), + position: PopupMenuPosition.under, + // constraints: BoxConstraints.tightFor(width: 60), + popUpAnimationStyle: AnimationStyle(duration: Duration.zero), + tooltip: "", + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 8), + Text( + _langNames[language]!, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ], + ), + ); + } + + Widget _makeSearchPaths() { + return ChangeNotifierBuilder( + notifier: searchPaths, + builder: (context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < searchPaths.length; i++) + Row( + children: [ + Expanded( + child: _makeFolderSelector("Search path", searchPaths[i]), + ), + if (i > 0) + _makeIconButton( + onPressed: () => searchPaths.removeAt(i), + child: const Icon(Icons.close), + ), + ], + ), + _makeIconButton( + onPressed: () => searchPaths.add(StringProp("", fileId: null)), + child: const Icon(Icons.add), + ), + ], + ), + ); + } + + Widget _makeFolderSelector(String label, StringProp prop) { + return Row( + children: [ + Expanded( + child: makePropEditor(prop, PropTFOptions(hintText: label)) + ), + _makeIconButton( + onPressed: () async { + var result = await FilePicker.platform.getDirectoryPath( + dialogTitle: "Select $label", + ); + if (result == null) + return; + prop.value = result; + }, + child: const Icon(Icons.folder), + ), + ], + ); + } + + Widget _makeCheckBox(String label, BoolProp prop) { + return Row( + children: [ + BoolPropCheckbox(prop: prop), + Flexible( + child: GestureDetector( + onTap: () => prop.value = !prop.value, + child: Text(label, overflow: TextOverflow.ellipsis), + ), + ), + ], + ); + } + + Widget _makeIconButton({required VoidCallback onPressed, required Widget child}) { + return IconButton( + onPressed: onPressed, + splashRadius: 20, + icon: child, + ); + } +} + +const _langNames = { + BatchLocalizationLanguage.us: "English", + BatchLocalizationLanguage.fr: "French", + BatchLocalizationLanguage.de: "German", + BatchLocalizationLanguage.it: "Italian", + BatchLocalizationLanguage.jp: "Japanese", + BatchLocalizationLanguage.es: "Spanish", +}; diff --git a/lib/widgets/tools/toolsOverview.dart b/lib/widgets/tools/toolsOverview.dart index 16ce6746..91d4327f 100644 --- a/lib/widgets/tools/toolsOverview.dart +++ b/lib/widgets/tools/toolsOverview.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../misc/SmoothScrollBuilder.dart'; import '../theme/customTheme.dart'; import 'ExtractFileTool.dart'; +import 'batchLocalizationTool.dart'; import 'ddsTools.dart'; class ToolsOverview extends StatefulWidget { @@ -16,6 +17,7 @@ class ToolsOverview extends StatefulWidget { class _ToolsOverviewState extends State { final extractToolKey = const PageStorageKey("extractTool"); final textureToolKey = const PageStorageKey("textureToolKey"); + final batchLocKey = const PageStorageKey("batchLocKey"); @override Widget build(BuildContext context) { @@ -42,6 +44,16 @@ class _ToolsOverviewState extends State { DdsTool(), ], ), + ExpansionTile( + key: batchLocKey, + title: const Text("Batch Localization"), + initiallyExpanded: true, + textColor: getTheme(context).textColor, + maintainState: true, + children: const [ + BatchLocalizationTool(), + ], + ), ], ) );