diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-12 21:04:15 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-12 21:04:15 -0500 |
| commit | 3949dcfce95af4bd305f258ff5b170d7d50435f6 (patch) | |
| tree | 4c768be256c40f31794c3311f0a2c22933a6705a /libs | |
| parent | 892070f3dec1dc690983fd862880e37e711c2516 (diff) | |
VFS perf fixes, icon/stylesheet compat, download filename fix
VFS:
- Open backing fd at mo2_open time for read-only handles so mo2_read can
splice without re-opening the file on every read call.
- Bump RLIMIT_NOFILE at mount time to fit the resulting fd pressure when
the game keeps hundreds of BSAs open.
- Dedicated node_cache_mutex so resolveByInode / mo2_lookup stop racing
unordered_map writes while multiple tree_mutex readers are active.
- max_read=1MB + matching conn->max_read and raised max_readahead/max_write
so the kernel merges Wine's small sequential reads into bigger FUSE
requests.
- Per-op wall-clock counters in mo2_init() logs so we can distinguish VFS
latency from game-side work.
Proton launch:
- Write dxvk.conf to the prefix and set DXVK_CONFIG_FILE to force
dxvk.enableGraphicsPipelineLibrary=False, avoiding long GPL compile
stalls on first run.
UI / plugin compat:
- Clamp IconDelegate's per-icon width to [8, 16] so narrow content
columns don't trigger QIcon::pixmap(0,0) returning null and logging
"failed to load icon" every repaint.
- Pre-create lowercase symlinks in the stylesheet directory so QSS files
authored on Windows resolve url(foo.svg) when on-disk files are Foo.svg.
- Flip content icons empty-chessboard.png and facegen.png to 8-bit RGBA
so Qt's built-in PNG reader loads them uniformly.
- Add mobase.Version.canonicalString shim for legacy Python plugins that
still call the old VersionInfo method on appVersion()'s return.
- BSPluginList: compare normalized plugin name lists instead of byte
hashes so the post-run case-fix refresh stops spuriously firing the
"load order changed" dialog.
- BSPluginList disabled by default.
Download manager:
- Parse CDN URL with QUrl + QUrlQuery and read the filename from the
response-content-disposition query param (RFC 6266 quoted / unquoted /
ext-value forms), not QFileInfo on the raw URL.
- When the API or URL gives us a CDN object key (no archive extension),
prefer the actual Content-Disposition header from the live response in
metaDataChanged and downloadFinished.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'libs')
109 files changed, 17029 insertions, 2 deletions
diff --git a/libs/esptk/src/espfile.cpp b/libs/esptk/src/espfile.cpp index b97d222..a88398d 100644 --- a/libs/esptk/src/espfile.cpp +++ b/libs/esptk/src/espfile.cpp @@ -17,8 +17,30 @@ ESP::File::File(const std::wstring& fileName) #ifdef _WIN32 m_File.open(fileName, std::fstream::in | std::fstream::binary); #else - // Linux: convert wstring to string (UTF-8) - std::string narrowName(fileName.begin(), fileName.end()); + // Linux: properly encode wstring → UTF-8. The old naive + // `string(w.begin(), w.end())` copy truncated any codepoint > 0x7F + // (e.g. ö U+00F6, – U+2013) which broke paths like "Mörskom Estate" or + // "Official Master Files – Cleaned". + std::string narrowName; + narrowName.reserve(fileName.size()); + for (wchar_t wc : fileName) { + const auto cp = static_cast<uint32_t>(wc); + if (cp < 0x80) { + narrowName.push_back(static_cast<char>(cp)); + } else if (cp < 0x800) { + narrowName.push_back(static_cast<char>(0xC0 | (cp >> 6))); + narrowName.push_back(static_cast<char>(0x80 | (cp & 0x3F))); + } else if (cp < 0x10000) { + narrowName.push_back(static_cast<char>(0xE0 | (cp >> 12))); + narrowName.push_back(static_cast<char>(0x80 | ((cp >> 6) & 0x3F))); + narrowName.push_back(static_cast<char>(0x80 | (cp & 0x3F))); + } else { + narrowName.push_back(static_cast<char>(0xF0 | (cp >> 18))); + narrowName.push_back(static_cast<char>(0x80 | ((cp >> 12) & 0x3F))); + narrowName.push_back(static_cast<char>(0x80 | ((cp >> 6) & 0x3F))); + narrowName.push_back(static_cast<char>(0x80 | (cp & 0x3F))); + } + } m_File.open(narrowName, std::fstream::in | std::fstream::binary); #endif init(); diff --git a/libs/installer_bsplugins/CMakeLists.txt b/libs/installer_bsplugins/CMakeLists.txt new file mode 100644 index 0000000..b4a8f22 --- /dev/null +++ b/libs/installer_bsplugins/CMakeLists.txt @@ -0,0 +1,53 @@ +# modorganizer-bsplugins — vendored from github.com/Exit-9B/modorganizer-bsplugins +# Branch: update-mob +# Commit: d52d5d73a591c747962a3a235d4851deb421b009 (2026-04-07) +# +# Record-level conflict view for Bethesda plugins (ESP/ESM/ESL). +# LOOT integration gutted for Fluorine port (sort button + masterlist import +# + dirty tooltips). Keeps the big feature: form record conflict view. + +cmake_minimum_required(VERSION 3.22) + +# Compat shim: upstream calls mo2_install_target, fluorine defines mo2_install_plugin. +if (NOT COMMAND mo2_install_target AND COMMAND mo2_install_plugin) + function(mo2_install_target target) + mo2_install_plugin(${target}) + endfunction() +endif () + +# bsplugins uses unprefixed includes (`<iplugin.h>`, `<gameplugins.h>`, ...). +# We can't put uibase/include/uibase directly on -I because it contains +# `strings.h` which shadows libc's POSIX <strings.h> (libc's string.h does +# `#include <strings.h>`, and gcc picks our -I copy first → pulls in C++ <string> +# inside an extern "C" block → "template with C linkage" errors everywhere). +# +# Fix: build a passthrough include dir with symlinks of every uibase header +# EXCEPT strings.h. Use the passthrough dir on -I. +set(_UIBASE_INCLUDE_ROOT "${CMAKE_SOURCE_DIR}/libs/uibase/include/uibase") +set(BSP_UIBASE_FWD "${CMAKE_CURRENT_BINARY_DIR}/uibase_fwd") +file(MAKE_DIRECTORY ${BSP_UIBASE_FWD}) +file(MAKE_DIRECTORY ${BSP_UIBASE_FWD}/game_features) +file(MAKE_DIRECTORY ${BSP_UIBASE_FWD}/formatters) +file(GLOB _uibase_headers "${_UIBASE_INCLUDE_ROOT}/*.h") +foreach(h ${_uibase_headers}) + get_filename_component(_hn ${h} NAME) + if(NOT _hn STREQUAL "strings.h") + file(CREATE_LINK "${h}" "${BSP_UIBASE_FWD}/${_hn}" SYMBOLIC) + endif() +endforeach() +file(GLOB _gf_headers "${_UIBASE_INCLUDE_ROOT}/game_features/*.h") +foreach(h ${_gf_headers}) + get_filename_component(_hn ${h} NAME) + file(CREATE_LINK "${h}" "${BSP_UIBASE_FWD}/game_features/${_hn}" SYMBOLIC) + # Also flat at top level — bsplugins mixes `<game_features/foo.h>` and `<foo.h>`. + file(CREATE_LINK "${h}" "${BSP_UIBASE_FWD}/${_hn}" SYMBOLIC) +endforeach() +# formatters/ subdir (formatters.h uses `#include "./formatters/enums.h"`). +file(GLOB _fmt_headers "${_UIBASE_INCLUDE_ROOT}/formatters/*.h") +foreach(h ${_fmt_headers}) + get_filename_component(_hn ${h} NAME) + file(CREATE_LINK "${h}" "${BSP_UIBASE_FWD}/formatters/${_hn}" SYMBOLIC) +endforeach() +set(MO2_UIBASE_INCLUDE_DIRS ${BSP_UIBASE_FWD}) + +add_subdirectory(src) diff --git a/libs/installer_bsplugins/LICENSE b/libs/installer_bsplugins/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/libs/installer_bsplugins/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + <program> Copyright (C) <year> <name of author> + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +<https://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +<https://www.gnu.org/licenses/why-not-lgpl.html>. diff --git a/libs/installer_bsplugins/README.md b/libs/installer_bsplugins/README.md new file mode 100644 index 0000000..33286f5 --- /dev/null +++ b/libs/installer_bsplugins/README.md @@ -0,0 +1,2 @@ +# Bethesda Plugin Manager for Mod Organizer +Manages plugin load order for Bethesda Game Studios game engines diff --git a/libs/installer_bsplugins/plugindefinition.json b/libs/installer_bsplugins/plugindefinition.json new file mode 100644 index 0000000..f055e23 --- /dev/null +++ b/libs/installer_bsplugins/plugindefinition.json @@ -0,0 +1,27 @@ +{ + "Name": "Bethesda Plugin Manager", + "Author": "Parapets", + "Description": "Manages plugin load order for Bethesda Game Studios game engines", + "DocsUrl": "", + "GithubUrl": "https://github.com/Exit-9B/modorganizer-bsplugins", + "NexusUrl": "https://www.nexusmods.com/skyrimspecialedition/mods/111236", + "Versions": [ + { + "Version": "0.1.5.0b", + "Released": "2024-02-15", + "MinSupport": "2.5.0", + "MaxSupport": "", + "MinWorking": "2.5.0", + "MaxWorking": "", + "DownloadUrl": "https://github.com/Exit-9B/modorganizer-bsplugins/releases/download/v0.1.5/bsplugins-v0.1.5.7z", + "PluginPath": [ + "plugins/bsplugins.dll" + ], + "LocalePath": [ + "translations/bsplugins_en.qm" + ], + "DataPath": [], + "ReleaseNotes": [] + } + ] +}
\ No newline at end of file diff --git a/libs/installer_bsplugins/src/BSPluginInfo/AuxConflictModel.cpp b/libs/installer_bsplugins/src/BSPluginInfo/AuxConflictModel.cpp new file mode 100644 index 0000000..bd93000 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginInfo/AuxConflictModel.cpp @@ -0,0 +1,153 @@ +#include "AuxConflictModel.h" + +#include <utility> + +namespace BSPluginInfo +{ + +AuxConflictModel::AuxConflictModel(const TESData::PluginList* pluginList, + QObject* parent) + : QAbstractItemModel(parent), m_PluginList{pluginList} +{} + +void AuxConflictModel::appendItem(const QString& name, + QList<TESData::TESFileHandle>&& handles) +{ + m_Items.append({name, std::move(handles)}); +} + +QModelIndex AuxConflictModel::index(int row, int column, + const QModelIndex& parent) const +{ + if (parent.isValid()) { + return QModelIndex(); + } + + return createIndex(row, column, row); +} + +QModelIndex AuxConflictModel::parent([[maybe_unused]] const QModelIndex& index) const +{ + return QModelIndex(); +} + +int AuxConflictModel::rowCount(const QModelIndex& parent) const +{ + return !parent.isValid() ? m_Items.length() : 0; +} + +int AuxWinnerModel::columnCount([[maybe_unused]] const QModelIndex& parent) const +{ + return COL_COUNT; +} + +int AuxLoserModel::columnCount([[maybe_unused]] const QModelIndex& parent) const +{ + return COL_COUNT; +} + +int AuxNonConflictModel::columnCount([[maybe_unused]] const QModelIndex& parent) const +{ + return COL_COUNT; +} + +QVariant AuxWinnerModel::data(const QModelIndex& index, int role) const +{ + if (role == Qt::DisplayRole) { + switch (index.column()) { + case COL_NAME: + return m_Items[index.row()].name; + case COL_LOSERS: { + QStringList names; + const auto& handles = m_Items[index.row()].sortedHandles; + for (int i = 0, count = handles.length() - 1; i < count; ++i) { + const auto handle = handles[i]; + const auto entry = m_PluginList->findEntryByHandle(handle); + if (entry) { + names.append(QString::fromStdString(entry->name())); + } + } + return names.join(QStringLiteral(", ")); + } + } + } + return QVariant(); +} + +QVariant AuxLoserModel::data(const QModelIndex& index, int role) const +{ + if (role == Qt::DisplayRole) { + switch (index.column()) { + case COL_NAME: + return m_Items[index.row()].name; + case COL_WINNER: { + const auto& handles = m_Items[index.row()].sortedHandles; + const auto entry = m_PluginList->findEntryByHandle(handles.last()); + return entry ? QString::fromStdString(entry->name()) : QString(); + } + } + } + return QVariant(); +} + +QVariant AuxNonConflictModel::data(const QModelIndex& index, int role) const +{ + if (role == Qt::DisplayRole) { + switch (index.column()) { + case COL_NAME: + return m_Items[index.row()].name; + } + } + return QVariant(); +} + +QVariant AuxWinnerModel::headerData(int section, Qt::Orientation orientation, + int role) const +{ + if (orientation != Qt::Horizontal || role != Qt::DisplayRole) { + return QVariant(); + } + + switch (section) { + case COL_NAME: + return tr("File"); + case COL_LOSERS: + return tr("Overwritten Mods"); + default: + return QVariant(); + } +} + +QVariant AuxLoserModel::headerData(int section, Qt::Orientation orientation, + int role) const +{ + if (orientation != Qt::Horizontal || role != Qt::DisplayRole) { + return QVariant(); + } + + switch (section) { + case COL_NAME: + return tr("File"); + case COL_WINNER: + return tr("Providing Mod"); + default: + return QVariant(); + } +} + +QVariant AuxNonConflictModel::headerData(int section, Qt::Orientation orientation, + int role) const +{ + if (orientation != Qt::Horizontal || role != Qt::DisplayRole) { + return QVariant(); + } + + switch (section) { + case COL_NAME: + return tr("File"); + default: + return QVariant(); + } +} + +} // namespace BSPluginInfo diff --git a/libs/installer_bsplugins/src/BSPluginInfo/AuxConflictModel.h b/libs/installer_bsplugins/src/BSPluginInfo/AuxConflictModel.h new file mode 100644 index 0000000..328a39e --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginInfo/AuxConflictModel.h @@ -0,0 +1,101 @@ +#ifndef BSPLUGININFO_AUXCONFLICTMODEL_H +#define BSPLUGININFO_AUXCONFLICTMODEL_H + +#include "TESData/PluginList.h" + +#include <QAbstractItemModel> + +namespace BSPluginInfo +{ + +class AuxConflictModel : public QAbstractItemModel +{ + Q_OBJECT + +public: + struct Item + { + QString name; + QList<TESData::TESFileHandle> sortedHandles; + }; + + explicit AuxConflictModel(const TESData::PluginList* pluginList, + QObject* parent = nullptr); + + void appendItem(const QString& name, QList<TESData::TESFileHandle>&& handles); + + QModelIndex index(int row, int column, + const QModelIndex& parent = QModelIndex()) const override; + QModelIndex parent(const QModelIndex& index) const override; + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + +protected: + const TESData::PluginList* m_PluginList; + QList<Item> m_Items; +}; + +class AuxWinnerModel final : public AuxConflictModel +{ + Q_OBJECT + +public: + enum EColumn + { + COL_NAME, + COL_LOSERS, + + COL_COUNT + }; + + using AuxConflictModel::AuxConflictModel; + + int columnCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QVariant headerData(int section, Qt::Orientation orientation, + int role = Qt::DisplayRole) const override; +}; + +class AuxLoserModel final : public AuxConflictModel +{ + Q_OBJECT + +public: + enum EColumn + { + COL_NAME, + COL_WINNER, + + COL_COUNT + }; + + using AuxConflictModel::AuxConflictModel; + + int columnCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QVariant headerData(int section, Qt::Orientation orientation, + int role = Qt::DisplayRole) const override; +}; + +class AuxNonConflictModel final : public AuxConflictModel +{ + Q_OBJECT + +public: + enum EColumn + { + COL_NAME, + + COL_COUNT + }; + + using AuxConflictModel::AuxConflictModel; + + int columnCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QVariant headerData(int section, Qt::Orientation orientation, + int role = Qt::DisplayRole) const override; +}; + +} // namespace BSPluginInfo + +#endif // BSPLUGININFO_AUXCONFLICTMODEL_H diff --git a/libs/installer_bsplugins/src/BSPluginInfo/AuxTreeModel.cpp b/libs/installer_bsplugins/src/BSPluginInfo/AuxTreeModel.cpp new file mode 100644 index 0000000..b5df2ab --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginInfo/AuxTreeModel.cpp @@ -0,0 +1,83 @@ +#include "AuxTreeModel.h" + +namespace BSPluginInfo +{ + +AuxTreeModel::AuxTreeModel(const TESData::AssociatedEntry* entry, QObject* parent) + : QAbstractItemModel(parent), m_Entry{entry} +{} + +QModelIndex AuxTreeModel::index(int row, int column, const QModelIndex& parent) const +{ + if (row < 0 || column != 0) { + return QModelIndex(); + } + + const auto parentItem = parent.isValid() + ? static_cast<const Item*>(parent.internalPointer()) + : m_Entry->root().get(); + + const auto item = parentItem->getByIndex(row).get(); + return item ? createIndex(row, column, item) : QModelIndex(); +} + +QModelIndex AuxTreeModel::parent(const QModelIndex& index) const +{ + if (!index.isValid()) + return QModelIndex(); + + const auto item = static_cast<const Item*>(index.internalPointer()); + const auto parent = item ? item->parent() : nullptr; + if (!parent || !parent->parent()) { + return QModelIndex(); + } + + const int row = parent->parent()->indexOf(parent); + + return createIndex(row, 0, parent); +} + +int AuxTreeModel::rowCount(const QModelIndex& parent) const +{ + const auto parentItem = parent.isValid() + ? static_cast<const Item*>(parent.internalPointer()) + : m_Entry->root().get(); + + return parentItem ? parentItem->numChildren() : 0; +} + +int AuxTreeModel::columnCount([[maybe_unused]] const QModelIndex& parent) const +{ + return 1; +} + +QVariant AuxTreeModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) { + return QVariant(); + } + + const auto item = static_cast<const Item*>(index.internalPointer()); + switch (role) { + case Qt::DisplayRole: + return QString::fromStdString(item->name()); + } + + return QVariant(); +} + +QVariant AuxTreeModel::headerData(int section, Qt::Orientation orientation, + int role) const +{ + if (orientation != Qt::Horizontal || role != Qt::DisplayRole) { + return QVariant(); + } + + if (section == 0) { + return tr("File Name"); + } else { + return QVariant(); + } +} + +} // namespace BSPluginInfo diff --git a/libs/installer_bsplugins/src/BSPluginInfo/AuxTreeModel.h b/libs/installer_bsplugins/src/BSPluginInfo/AuxTreeModel.h new file mode 100644 index 0000000..6663aa8 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginInfo/AuxTreeModel.h @@ -0,0 +1,37 @@ +#ifndef BSPLUGININFO_AUXTREEMODEL_H +#define BSPLUGININFO_AUXTREEMODEL_H + +#include "TESData/AssociatedEntry.h" + +#include <QAbstractItemModel> + +namespace BSPluginInfo +{ + +class AuxTreeModel final : public QAbstractItemModel +{ + Q_OBJECT + +public: + explicit AuxTreeModel(const TESData::AssociatedEntry* entry, + QObject* parent = nullptr); + + QModelIndex index(int row, int column, + const QModelIndex& parent = QModelIndex()) const override; + QModelIndex parent(const QModelIndex& index) const override; + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + int columnCount(const QModelIndex& parent = QModelIndex()) const override; + + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QVariant headerData(int section, Qt::Orientation orientation, + int role = Qt::DisplayRole) const override; + +private: + using Item = TESData::AuxItem; + + const TESData::AssociatedEntry* m_Entry; +}; + +} // namespace BSPluginInfo + +#endif // BSPLUGININFO_AUXTREEMODEL_H diff --git a/libs/installer_bsplugins/src/BSPluginInfo/PluginInfoDialog.cpp b/libs/installer_bsplugins/src/BSPluginInfo/PluginInfoDialog.cpp new file mode 100644 index 0000000..e011629 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginInfo/PluginInfoDialog.cpp @@ -0,0 +1,235 @@ +#include "PluginInfoDialog.h" +#include "AuxConflictModel.h" +#include "GUI/IGeometrySettings.h" +#include "MOPlugin/Settings.h" +#include "PluginRecordView.h" +#include "ui_plugininfodialog.h" + +#include <QTreeView> +#include <QVBoxLayout> + +namespace BSPluginInfo +{ + +PluginInfoDialog::PluginInfoDialog(MOBase::IOrganizer* organizer, + TESData::PluginList* pluginList, + const QString& pluginName, QWidget* parent) + : QDialog(parent), ui{new Ui::PluginInfoDialog()}, m_PluginList{pluginList}, + m_PluginName{pluginName} +{ + ui->setupUi(this); + setWindowTitle(pluginName); + + ui->pluginRecordView->setup(organizer, pluginList, pluginName); + + const auto winnerModel = new AuxWinnerModel(pluginList, ui->winningTree); + const auto loserModel = new AuxLoserModel(pluginList, ui->losingTree); + const auto nonConflictModel = new AuxNonConflictModel(pluginList, ui->noConflictTree); + + const auto entry = pluginList->findEntryByName(pluginName.toStdString()); + const auto handle = entry ? entry->handle() : -1; + + if (const auto plugin = pluginList->getPluginByName(pluginName)) { + for (const auto& archive : plugin->archives()) { + const auto archiveEntry = m_PluginList->findArchive(archive); + if (archiveEntry) { + m_Archives.append(archive); + + int winningCount = 0; + int losingCount = 0; + int noConflictCount = 0; + + archiveEntry->forEachMember([&](auto&& member) { + QList<TESData::TESFileHandle> handles; + std::ranges::copy(member->alternatives, std::back_inserter(handles)); + std::ranges::sort(handles, std::less<int>(), [&](auto altHandle) { + const auto altEntry = pluginList->findEntryByHandle(altHandle); + const auto info = altEntry ? pluginList->getPluginByName( + QString::fromStdString(altEntry->name())) + : nullptr; + return info ? info->priority() : -1; + }); + + if (handles.length() <= 1) { + ++noConflictCount; + nonConflictModel->appendItem(QString::fromStdString(member->path), + std::move(handles)); + } else if (handles.last() == handle) { + ++winningCount; + winnerModel->appendItem(QString::fromStdString(member->path), + std::move(handles)); + } else { + ++losingCount; + loserModel->appendItem(QString::fromStdString(member->path), + std::move(handles)); + } + }); + + ui->winningCount->display(winningCount); + ui->losingCount->display(losingCount); + ui->noConflictCount->display(noConflictCount); + + const auto dataTree = new QTreeView(ui->archivesTreeStack); + + const auto model = new AuxTreeModel(archiveEntry, dataTree); + const auto proxy = new QSortFilterProxyModel(dataTree); + proxy->setRecursiveFilteringEnabled(true); + proxy->setSourceModel(model); + + dataTree->setModel(proxy); + + ui->archivesTreeStack->addWidget(dataTree); + } + } + } + + ui->winningTree->setModel(winnerModel); + ui->losingTree->setModel(loserModel); + ui->noConflictTree->setModel(nonConflictModel); + + m_WinningExpander.set(ui->winningExpander, ui->winningTree, true); + m_LosingExpander.set(ui->losingExpander, ui->losingTree, true); + m_NoConflictExpander.set(ui->noConflictExpander, ui->noConflictTree, true); + + m_FilterWinning.setEdit(ui->winningLineEdit); + m_FilterWinning.setList(ui->winningTree); + m_FilterWinning.setUseSourceSort(true); + + m_FilterLosing.setEdit(ui->losingLineEdit); + m_FilterLosing.setList(ui->losingTree); + m_FilterLosing.setUseSourceSort(true); + + m_FilterNoConflicts.setEdit(ui->noConflictLineEdit); + m_FilterNoConflicts.setList(ui->noConflictTree); + m_FilterNoConflicts.setUseSourceSort(true); + + const auto p = palette(); + if (!ui->pluginRecordView->hasData()) { + const auto index = ui->tabWidget->indexOf(ui->tabRecords); + ui->tabWidget->tabBar()->setTabTextColor( + index, p.color(QPalette::Disabled, QPalette::WindowText)); + } + if (m_Archives.isEmpty()) { + const auto index = ui->tabWidget->indexOf(ui->tabArchives); + ui->tabWidget->tabBar()->setTabTextColor( + index, p.color(QPalette::Disabled, QPalette::WindowText)); + } +} + +PluginInfoDialog::~PluginInfoDialog() noexcept +{ + delete ui; +} + +int PluginInfoDialog::exec() +{ + const auto settings = Settings::instance(); + GUI::GeometrySaver gs{*settings, this}; + + GUI::StateSaver ssRecordSplitter{*settings, ui->pluginRecordView->splitter()}; + GUI::StateSaver ssRecordPickHeader{*settings, + ui->pluginRecordView->pickRecordView()->header()}; + + GUI::StateSaver ssArchiveWinningExpander{*settings, &m_WinningExpander}; + GUI::StateSaver ssArchiveLosingExpander{*settings, &m_LosingExpander}; + GUI::StateSaver ssArchiveNoConflictExpander{*settings, &m_NoConflictExpander}; + GUI::StateSaver ssArchiveWinningHeader{*settings, ui->winningTree->header()}; + GUI::StateSaver ssArchiveLosingHeader{*settings, ui->losingTree->header()}; + GUI::StateSaver ssArchiveNoConflictHeader{*settings, ui->noConflictTree->header()}; + + const int r = QDialog::exec(); + + return r; +} + +void PluginInfoDialog::on_close_clicked() +{ + close(); +} + +void PluginInfoDialog::on_nextFile_clicked() +{ + const auto current = m_PluginList->getPluginByName(m_PluginName); + int nextPriority = current ? current->priority() + 1 : 0; + if (nextPriority >= m_PluginList->pluginCount()) { + nextPriority = 0; + } + + const auto next = m_PluginList->getPluginByPriority(nextPriority); + if (next) { + setCurrent(next->name()); + } +} + +void PluginInfoDialog::on_previousFile_clicked() +{ + const auto current = m_PluginList->getPluginByName(m_PluginName); + int previousPriority = current ? current->priority() - 1 : 0; + if (previousPriority < 0) { + previousPriority = m_PluginList->pluginCount() - 1; + } + + const auto previous = m_PluginList->getPluginByPriority(previousPriority); + if (previous) { + setCurrent(previous->name()); + } +} + +void PluginInfoDialog::on_previousArchiveButton_clicked() +{ + ui->archiveFilterEdit->clear(); + + const int currentIndex = ui->archivesTreeStack->currentIndex(); + const int count = ui->archivesTreeStack->count(); + + if (currentIndex == 0) { + ui->archivesTreeStack->setCurrentIndex(count - 1); + } else { + ui->archivesTreeStack->setCurrentIndex(currentIndex - 1); + } +} + +void PluginInfoDialog::on_nextArchiveButton_clicked() +{ + ui->archiveFilterEdit->clear(); + + const int currentIndex = ui->archivesTreeStack->currentIndex(); + const int count = ui->archivesTreeStack->count(); + + if (currentIndex == count - 1) { + ui->archivesTreeStack->setCurrentIndex(0); + } else { + ui->archivesTreeStack->setCurrentIndex(currentIndex + 1); + } +} + +void PluginInfoDialog::on_archivesTreeStack_currentChanged(int index) +{ + ui->currentArchiveLabel->setText(m_Archives[index]); +} + +void PluginInfoDialog::on_archiveFilterEdit_textChanged(const QString& text) +{ + const auto widget = ui->archivesTreeStack->currentWidget(); + const auto view = qobject_cast<QAbstractItemView*>(widget); + if (!view) { + return; + } + + const auto model = view->model(); + const auto filterProxy = qobject_cast<QSortFilterProxyModel*>(model); + if (!filterProxy) { + return; + } + + filterProxy->setFilterFixedString(text); +} + +void PluginInfoDialog::setCurrent(const QString& pluginName) +{ + m_PluginName = pluginName; + setWindowTitle(pluginName); + ui->pluginRecordView->setFile(pluginName); +} + +} // namespace BSPluginInfo diff --git a/libs/installer_bsplugins/src/BSPluginInfo/PluginInfoDialog.h b/libs/installer_bsplugins/src/BSPluginInfo/PluginInfoDialog.h new file mode 100644 index 0000000..db2fe4a --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginInfo/PluginInfoDialog.h @@ -0,0 +1,69 @@ +#ifndef BSPLUGININFO_PLUGININFODIALOG_H +#define BSPLUGININFO_PLUGININFODIALOG_H + +#include "AuxTreeModel.h" +#include "TESData/PluginList.h" + +#include <expanderwidget.h> +#include <filterwidget.h> +#include <imoinfo.h> + +#include <QDialog> +#include <QString> + +namespace Ui +{ +class PluginInfoDialog; +} + +namespace BSPluginInfo +{ + +class PluginInfoDialog final : public QDialog +{ + Q_OBJECT + +public: + PluginInfoDialog(MOBase::IOrganizer* organizer, TESData::PluginList* pluginList, + const QString& pluginName, QWidget* parent = nullptr); + + PluginInfoDialog(const PluginInfoDialog&) = delete; + PluginInfoDialog(PluginInfoDialog&&) = delete; + + ~PluginInfoDialog() noexcept; + + PluginInfoDialog& operator=(const PluginInfoDialog&) = delete; + PluginInfoDialog& operator=(PluginInfoDialog&&) = delete; + + int exec() override; + +private slots: + void on_close_clicked(); + void on_nextFile_clicked(); + void on_previousFile_clicked(); + + void on_previousArchiveButton_clicked(); + void on_nextArchiveButton_clicked(); + void on_archivesTreeStack_currentChanged(int index); + void on_archiveFilterEdit_textChanged(const QString& text); + +private: + void setCurrent(const QString& pluginName); + + Ui::PluginInfoDialog* ui; + + TESData::PluginList* m_PluginList; + QString m_PluginName; + + QStringList m_Archives; + MOBase::ExpanderWidget m_WinningExpander; + MOBase::ExpanderWidget m_LosingExpander; + MOBase::ExpanderWidget m_NoConflictExpander; + MOBase::FilterWidget m_FilterWinning; + MOBase::FilterWidget m_FilterLosing; + MOBase::FilterWidget m_FilterNoConflicts; +}; + +} // namespace BSPluginInfo + +#endif // BSPLUGININFO_PLUGININFODIALOG_H diff --git a/libs/installer_bsplugins/src/BSPluginInfo/PluginRecordModel.cpp b/libs/installer_bsplugins/src/BSPluginInfo/PluginRecordModel.cpp new file mode 100644 index 0000000..9bc554e --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginInfo/PluginRecordModel.cpp @@ -0,0 +1,352 @@ +#include "PluginRecordModel.h" +#include "TESData/BranchConflictParser.h" +#include "TESData/TypeStringNames.h" +#include "TESFile/Reader.h" + +#include <log.h> + +#include <algorithm> +#include <iterator> +#include <ranges> + +using namespace Qt::Literals::StringLiterals; + +namespace BSPluginInfo +{ + +PluginRecordModel::PluginRecordModel(MOBase::IOrganizer* organizer, + TESData::PluginList* pluginList, + const QString& pluginName) + : m_PluginName{pluginName}, m_Organizer{organizer}, m_PluginList{pluginList}, + m_FileEntry{pluginList->findEntryByName(pluginName.toStdString())} +{ + if (m_FileEntry) { + m_DataRoot = m_FileEntry->dataRoot(); + } +} + +TESData::RecordPath PluginRecordModel::getPath(const QModelIndex& index) const +{ + TESData::RecordPath path; + + std::vector<const Item*> parents; + const auto last = static_cast<const Item*>(index.internalPointer()); + for (auto item = last; item->parent; item = item->parent) { + parents.push_back(item); + } + + for (const auto& item : std::ranges::reverse_view(parents)) { + if (item->group.has_value()) { + path.push(item->group.value(), m_FileEntry->files(), m_PluginName.toStdString()); + } + } + + if (last->record) { + if (last->group.has_value()) { + path.pop(); + } + path.setIdentifier(last->record->identifier(), {&last->record->file(), 1}); + } + + return path; +} + +QModelIndex PluginRecordModel::index(int row, int column, + const QModelIndex& parent) const +{ + if (row < 0 || column < 0) + return QModelIndex(); + + const auto parentItem = parent.isValid() + ? static_cast<const Item*>(parent.internalPointer()) + : m_DataRoot; + + if (!parentItem || row >= parentItem->children.size()) + return QModelIndex(); + + const auto item = parentItem->children.nth(row)->second.get(); + return createIndex(row, column, item); +} + +QModelIndex PluginRecordModel::parent(const QModelIndex& index) const +{ + if (!index.isValid()) + return QModelIndex(); + + const auto item = static_cast<const Item*>(index.internalPointer()); + if (!item->parent || !item->parent->parent) { + return QModelIndex(); + } + + const auto& siblings = item->parent->parent->children; + const int row = static_cast<int>( + siblings.index_of(std::ranges::find(siblings, item->parent, [&](auto&& pair) { + return pair.second.get(); + }))); + + return createIndex(row, 0, item->parent); +} + +bool PluginRecordModel::hasChildren(const QModelIndex& parent) const +{ + if (!parent.isValid()) { + return m_DataRoot && !m_DataRoot->children.empty(); + } + + const auto parentItem = static_cast<const Item*>(parent.internalPointer()); + return parentItem ? parentItem->group.has_value() : false; +} + +int PluginRecordModel::rowCount(const QModelIndex& parent) const +{ + const auto parentItem = + parent.isValid() ? static_cast<Item*>(parent.internalPointer()) : m_DataRoot; + + return parentItem ? static_cast<int>(parentItem->children.size()) : 0; +} + +int PluginRecordModel::columnCount([[maybe_unused]] const QModelIndex& parent) const +{ + return COL_COUNT; +} + +bool PluginRecordModel::canFetchMore(const QModelIndex& parent) const +{ + if (!parent.isValid()) { + return false; + } + + const auto parentItem = static_cast<const Item*>(parent.internalPointer()); + return parentItem && parentItem->group.has_value() && parentItem->children.empty(); +} + +void PluginRecordModel::fetchMore(const QModelIndex& parent) +{ + const auto parentItem = + parent.isValid() ? static_cast<Item*>(parent.internalPointer()) : nullptr; + + if (!parentItem || !parentItem->children.empty() || !parentItem->group.has_value()) { + return; + } + + QList<QString> names; + if (parentItem->record) { + for (const auto handle : parentItem->record->alternatives()) { + const auto entry = m_PluginList->findEntryByHandle(handle); + names.append(QString::fromStdString(entry->name())); + } + } else { + names.append(m_PluginName); + } + + for (const auto& name : names) { + const auto vfsEntry = + m_Organizer->virtualFileTree()->find(name, MOBase::FileTreeEntry::FILE); + + const auto filePath = m_Organizer->resolvePath(name); + + try { + TESData::BranchConflictParser handler{m_PluginList, name.toStdString(), + getPath(parent)}; + TESFile::Reader<TESData::BranchConflictParser> reader{}; + reader.parse(std::filesystem::path(filePath.toStdString()), handler); + } catch (const std::exception& e) { + MOBase::log::error("Error parsing \"{}\": {}", filePath, e.what()); + } + } + + if (parentItem->children.empty()) { + beginRemoveRows(parent, 0, 0); + parentItem->group = std::nullopt; + endRemoveRows(); + } +} + +QVariant PluginRecordModel::data(const QModelIndex& index, int role) const +{ + const auto item = static_cast<const Item*>(index.internalPointer()); + + switch (role) { + case Qt::DisplayRole: + case Qt::EditRole: { + switch (index.column()) { + case COL_ID: { + if (item->record) { + if (item->record->hasFormId()) { + std::uint32_t formId = item->record->formId() & 0xFFFFFFU; + + const auto plugin = + m_PluginList ? m_PluginList->getPluginByName(m_PluginName) : nullptr; + + if (plugin) { + const auto file = item->record->file(); + const auto localIndex = std::distance( + std::begin(plugin->masters()), + TESFile::find(plugin->masters(), file, &QString::toStdString)); + formId |= ((localIndex & 0xFF) << 24U); + } + + QString str = u"%1"_s.arg(formId, 8, 16, QChar(u'0')).toUpper(); + + for (const Item* p = item->parent; p; p = p->parent) { + if (p->group && p->group->hasFormType()) { + if (item->formType != p->group->formType()) { + QString suffix = TESData::getFormName(item->formType).toString(); + if (suffix.isEmpty()) { + suffix = QString::fromLocal8Bit(item->formType.data(), + item->formType.size()); + } + str = u"%1 %2"_s.arg(str).arg(suffix); + } + break; + } + } + + return str; + + } else if (item->record->hasEditorId()) { + return QString::fromStdString(item->record->editorId()); + + } else if (item->record->hasTypeId()) { + const auto type = item->record->typeId(); + const auto typestr = QString::fromLocal8Bit(type.data(), type.size()); + const auto name = TESData::getDefaultObjectName(type); + if (!name.isEmpty()) { + return u"%1 - %2"_s.arg(typestr).arg(name); + } else { + return typestr; + } + } + } + + if (item->group.has_value()) { + return makeGroupName(item->group.value()); + } + + return QVariant(); + } + + case COL_OWNER: { + if (item->record && item->record->hasFormId()) { + const auto file = item->record->file(); + return QString::fromStdString(file); + } + return QVariant(); + } + + case COL_NAME: { + if (item->record && item->record->hasFormId()) { + return QString::fromStdString(item->name); + } + return QVariant(); + } + } + + return QVariant(); + } + + case Qt::BackgroundRole: { + if (!m_PluginList || !m_FileEntry || !item->record) { + return QVariant(); + } + + const auto info = m_PluginList->getPluginByName(m_PluginName); + if (!info) { + return QVariant(); + } + + bool isConflicted = false; + for (const auto alternative : item->record->alternatives()) { + const auto altEntry = m_PluginList->findEntryByHandle(alternative); + if (!altEntry || altEntry == m_FileEntry) + continue; + + const auto altInfo = + m_PluginList->getPluginByName(QString::fromStdString(altEntry->name())); + if (!altInfo || !altInfo->enabled()) + continue; + + isConflicted = true; + if (altInfo->priority() > info->priority()) { + return QColor(255, 0, 0, 64); + } + } + + if (isConflicted) { + return QColor(0, 255, 0, 64); + } else { + return QVariant(); + } + } + + case Qt::UserRole: { + return QVariant::fromValue(item); + } + } + + return QVariant(); +} + +QString PluginRecordModel::makeGroupName(TESFile::GroupData group) +{ + using GroupType = TESFile::GroupType; + + switch (group.type()) { + case GroupType::Top: { + const auto type = group.formType(); + const auto typestr = QString::fromLocal8Bit(type.data(), type.size()); + const auto name = TESData::getFormName(type); + if (!name.isEmpty()) { + return u"%1 - %2"_s.arg(typestr).arg(name); + } else { + return typestr; + } + } + case GroupType::WorldChildren: + return tr("Children"); + case GroupType::InteriorCellBlock: + return tr("Block %1").arg(group.block()); + case GroupType::InteriorCellSubBlock: + return tr("Sub-Block %1").arg(group.block()); + case GroupType::ExteriorCellBlock: { + const auto [x, y] = group.gridCell(); + return tr("Block %1, %2").arg(x).arg(y); + } + case GroupType::ExteriorCellSubBlock: { + const auto [x, y] = group.gridCell(); + return tr("Sub-Block %1, %2").arg(x).arg(y); + } + case GroupType::CellChildren: + return tr("Children"); + case GroupType::TopicChildren: + return tr("Children"); + case GroupType::CellPersistentChildren: + return tr("Persistent"); + case GroupType::CellTemporaryChildren: + return tr("Temporary"); + case GroupType::CellVisibleDistantChildren: + return tr("Visible Distant"); + default: + return tr("Children"); + } +} + +QVariant PluginRecordModel::headerData(int section, Qt::Orientation orientation, + int role) const +{ + if (orientation != Qt::Horizontal || role != Qt::DisplayRole) + return QVariant(); + + switch (section) { + case COL_ID: + return tr("Form"); + case COL_OWNER: + return tr("Origin"); + case COL_NAME: + return tr("Editor ID"); + } + + return QVariant(); +} + +} // namespace BSPluginInfo diff --git a/libs/installer_bsplugins/src/BSPluginInfo/PluginRecordModel.h b/libs/installer_bsplugins/src/BSPluginInfo/PluginRecordModel.h new file mode 100644 index 0000000..b625e2d --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginInfo/PluginRecordModel.h @@ -0,0 +1,60 @@ +#ifndef BSPLUGININFO_PLUGINRECORDMODEL_H +#define BSPLUGININFO_PLUGINRECORDMODEL_H + +#include "TESData/PluginList.h" + +#include <imoinfo.h> + +#include <QAbstractItemModel> + +namespace BSPluginInfo +{ + +class PluginRecordModel final : public QAbstractItemModel +{ + Q_OBJECT + +public: + enum EColumn + { + COL_ID, + COL_OWNER, + COL_NAME, + + COL_COUNT + }; + + PluginRecordModel(MOBase::IOrganizer* organizer, TESData::PluginList* pluginList, + const QString& pluginName); + + [[nodiscard]] TESData::RecordPath getPath(const QModelIndex& index) const; + + QModelIndex index(int row, int column, + const QModelIndex& parent = QModelIndex()) const override; + QModelIndex parent(const QModelIndex& index) const override; + bool hasChildren(const QModelIndex& parent = QModelIndex()) const override; + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + int columnCount(const QModelIndex& parent = QModelIndex()) const override; + + bool canFetchMore(const QModelIndex& parent) const override; + void fetchMore(const QModelIndex& parent) override; + + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QVariant headerData(int section, Qt::Orientation orientation, + int role = Qt::DisplayRole) const override; + +private: + using Item = TESData::FileEntry::TreeItem; + + static QString makeGroupName(TESFile::GroupData group); + + QString m_PluginName; + MOBase::IOrganizer* m_Organizer = nullptr; + TESData::PluginList* m_PluginList = nullptr; + TESData::FileEntry* m_FileEntry = nullptr; + Item* m_DataRoot = nullptr; +}; + +} // namespace BSPluginInfo + +#endif // BSPLUGININFO_PLUGINRECORDMODEL_H diff --git a/libs/installer_bsplugins/src/BSPluginInfo/PluginRecordView.cpp b/libs/installer_bsplugins/src/BSPluginInfo/PluginRecordView.cpp new file mode 100644 index 0000000..01f4464 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginInfo/PluginRecordView.cpp @@ -0,0 +1,283 @@ +#include "PluginRecordView.h" +#include "MOPlugin/Settings.h" +#include "ui_pluginrecordview.h" + +#include <widgetutility.h> + +#include <QMenu> + +#include <ranges> + +namespace BSPluginInfo +{ + +PluginRecordView::PluginRecordView(QWidget* parent) + : QWidget(parent), ui{new Ui::PluginRecordView()} +{ + ui->setupUi(this); + + MOBase::setCustomizableColumns(ui->pickRecordView); + + // fix issue where view scrolls after moving headers + ui->recordStructureView->header()->setAutoScroll(false); + + ui->conflictFilterRow->hide(); +} + +void PluginRecordView::setup(MOBase::IOrganizer* organizer, + TESData::PluginList* pluginList, const QString& pluginName) +{ + m_Organizer = organizer; + m_PluginList = pluginList; + m_FilterProxy = new RecordFilterProxyModel(pluginList, pluginName); + ui->conflictFilterRow->show(); + + setFile(pluginName); + + ui->pickRecordView->header()->resizeSection(PluginRecordModel::COL_ID, 222); + + connect(ui->recordStructureView->header(), &QHeaderView::sectionMoved, this, + &PluginRecordView::onFileHeaderMoved); + + const bool ignoreMasters = + Settings::instance()->get<bool>("ignore_master_conflicts", false); + + ui->ignoreMasterConflicts->setChecked(ignoreMasters); +} + +bool PluginRecordView::hasData() const +{ + return m_RecordModel->hasChildren(); +} + +QSplitter* PluginRecordView::splitter() const +{ + return ui->splitter; +} + +QTreeView* PluginRecordView::pickRecordView() const +{ + return ui->pickRecordView; +} + +QTreeView* PluginRecordView::recordStructureView() const +{ + return ui->recordStructureView; +} + +void PluginRecordView::setFile(const QString& pluginName) +{ + const auto recordModel = m_RecordModel; + const auto structureModel = m_StructureModel; + + m_RecordModel = new PluginRecordModel(m_Organizer, m_PluginList, pluginName); + m_FilterProxy->setFile(pluginName); + m_FilterProxy->setSourceModel(m_RecordModel); + ui->pickRecordView->setModel(m_FilterProxy); + + m_StructureModel = nullptr; + ui->recordStructureView->setModel(nullptr); + ui->recordStructureView->setVisible(false); + on_pickRecordView_expanded(QModelIndex()); + + connect(ui->pickRecordView->selectionModel(), &QItemSelectionModel::currentChanged, + this, &PluginRecordView::onRecordPicked); + + m_ConflictEntry = m_PluginList->findEntryByName(pluginName.toStdString()); + + delete recordModel; + delete structureModel; +} + +PluginRecordView::~PluginRecordView() noexcept +{ + delete ui; + delete m_RecordModel; + delete m_FilterProxy; + delete m_StructureModel; +} + +void PluginRecordView::onRecordPicked(const QModelIndex& current) +{ + if (!current.isValid()) { + return; + } + + const auto oldModel = m_StructureModel; + m_StructureModel = nullptr; + + const auto sourceIndex = m_FilterProxy->mapToSource(current); + if (m_ConflictEntry) { + const auto path = m_RecordModel->getPath(sourceIndex); + const auto record = m_ConflictEntry->findRecord(path).get(); + if (record) { + m_StructureModel = + new RecordStructureModel(m_PluginList, record, path, m_Organizer); + } + } + + ui->recordStructureView->setModel(m_StructureModel); + ui->recordStructureView->setVisible(m_StructureModel != nullptr); + expandStructureConflicts(); + + if (oldModel) { + delete oldModel; + } +} + +void PluginRecordView::onFileHeaderMoved(int logicalIndex, int oldVisualIndex, + int newVisualIndex) +{ + if (!m_PluginList || m_MovingSection) { + return; + } + + m_MovingSection = true; + + const auto& file = m_StructureModel->file(logicalIndex - 1); + const int index = m_PluginList->getIndex(file); + const auto info = m_PluginList->getPlugin(index); + if (!info) { + return; + } + + const auto& otherFile = m_StructureModel->file(newVisualIndex - 1); + const auto otherInfo = m_PluginList->getPluginByName(otherFile); + if (otherInfo) { + int destination = otherInfo->priority(); + if (newVisualIndex > oldVisualIndex) + ++destination; + + if (m_PluginList->canMoveToPriority({index}, destination)) { + m_PluginList->moveToPriority({index}, destination); + m_RecordModel->emit dataChanged(QModelIndex(), QModelIndex()); + m_StructureModel->refresh(); + } + } + + ui->recordStructureView->header()->moveSection(newVisualIndex, oldVisualIndex); + m_MovingSection = false; +} + +void PluginRecordView::on_pickRecordView_expanded(const QModelIndex& index) +{ + const auto model = ui->pickRecordView->model(); + for (int row = 0, count = model->rowCount(index); row < count; ++row) { + const auto child = model->index(row, 0, index); + + using Item = TESData::FileEntry::TreeItem; + const auto item = child.data(Qt::UserRole).value<const Item*>(); + if (!item || !item->record || !item->record->hasFormId()) { + ui->pickRecordView->setFirstColumnSpanned(row, index, true); + } + } +} + +void PluginRecordView::on_pickRecordView_customContextMenuRequested(const QPoint& pos) +{ + const auto selectionModel = ui->pickRecordView->selectionModel(); + const auto selectedRows = selectionModel->selectedRows(); + using Item = TESData::FileEntry::TreeItem; + const auto items = + std::ranges::transform_view(selectedRows, [](const QModelIndex& index) { + return index.data(Qt::UserRole).value<const Item*>(); + }); + + if (items.empty() || !std::ranges::all_of(items, [](const Item* item) { + return item && item->record; + })) { + return; + } + + QMenu menu; + + QAction* ignoreRecord; + ignoreRecord = menu.addAction(tr("Ignore Record"), [&] { + for (auto&& item : items) { + item->record->setIgnored(ignoreRecord->isChecked()); + + for (const auto handle : item->record->alternatives()) { + const auto entry = m_PluginList->findEntryByHandle(handle); + const auto info = + m_PluginList->getPluginByName(QString::fromStdString(entry->name())); + if (info) { + info->invalidateConflicts(); + } + } + } + }); + + ignoreRecord->setCheckable(true); + ignoreRecord->setChecked(std::ranges::all_of(items, [](const Item* item) { + return item->record->ignored(); + })); + + const QPoint p = ui->pickRecordView->viewport()->mapToGlobal(pos); + menu.exec(p); +} + +void PluginRecordView::on_recordStructureView_expanded(const QModelIndex& index) +{ + const auto model = ui->recordStructureView->model(); + const int rowCount = model->rowCount(index); + if (rowCount == 1) { + const auto child = model->index(0, 0, index); + if (model->hasChildren(child)) { + ui->recordStructureView->expand(child); + } + } else { + for (int i = 0; i < rowCount; ++i) { + const auto child = model->index(i, 0, index); + if (model->hasChildren(child) && child.data().toString().isEmpty()) { + ui->recordStructureView->expand(child); + } + } + } +} + +void PluginRecordView::on_filterCombo_currentIndexChanged(int index) +{ + switch (index) { + case Filter_AllConflicts: + m_FilterProxy->setFilterFlags(RecordFilterProxyModel::Filter_AllConflicts); + break; + case Filter_WinningConflicts: + m_FilterProxy->setFilterFlags(RecordFilterProxyModel::Filter_WinningConflicts); + break; + case Filter_LosingConflicts: + m_FilterProxy->setFilterFlags(RecordFilterProxyModel::Filter_LosingConflicts); + break; + } +} + +void PluginRecordView::on_ignoreMasterConflicts_stateChanged(int state) +{ + Settings::instance()->set("ignore_master_conflicts", state == Qt::Checked); + m_RecordModel->emit dataChanged(QModelIndex(), QModelIndex()); +} + +void PluginRecordView::expandStructureConflicts(const QModelIndex& parent) +{ + if (!m_StructureModel) { + return; + } + + const int count = m_StructureModel->rowCount(parent); + for (int i = 0; i < count; ++i) { + using Item = TESData::DataItem; + const auto idx = m_StructureModel->index(i, 0, parent); + const auto item = idx.data(Qt::UserRole).value<const Item*>(); + const int fileCount = m_StructureModel->columnCount(parent) - 1; + + if (item->numChildren() > 0 && item->isConflicted(fileCount)) { + static constexpr int EXPAND_MAX_ROWS = 80; + + if (item->numChildren() <= EXPAND_MAX_ROWS) { + ui->recordStructureView->expand(idx); + expandStructureConflicts(idx); + } + } + } +} + +} // namespace BSPluginInfo diff --git a/libs/installer_bsplugins/src/BSPluginInfo/PluginRecordView.h b/libs/installer_bsplugins/src/BSPluginInfo/PluginRecordView.h new file mode 100644 index 0000000..880cc1a --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginInfo/PluginRecordView.h @@ -0,0 +1,78 @@ +#ifndef BSPLUGININFO_PLUGINRECORDVIEW_H +#define BSPLUGININFO_PLUGINRECORDVIEW_H + +#include "PluginRecordModel.h" +#include "RecordFilterProxyModel.h" +#include "RecordStructureModel.h" + +#include <QWidget> + +class QSplitter; +class QTreeView; +namespace Ui +{ +class PluginRecordView; +} + +namespace BSPluginInfo +{ + +class PluginRecordView final : public QWidget +{ + Q_OBJECT + +public: + enum ConflictFilter + { + Filter_AllConflicts, + Filter_WinningConflicts, + Filter_LosingConflicts, + }; + + explicit PluginRecordView(QWidget* parent = nullptr); + + PluginRecordView(const PluginRecordView&) = delete; + PluginRecordView(PluginRecordView&&) = delete; + + ~PluginRecordView() noexcept; + + PluginRecordView& operator=(const PluginRecordView&) = delete; + PluginRecordView& operator=(PluginRecordView&&) = delete; + + void setup(MOBase::IOrganizer* organizer, TESData::PluginList* pluginList, + const QString& pluginName); + + [[nodiscard]] bool hasData() const; + + [[nodiscard]] QSplitter* splitter() const; + [[nodiscard]] QTreeView* pickRecordView() const; + [[nodiscard]] QTreeView* recordStructureView() const; + + void setFile(const QString& pluginName); + +private slots: + void onRecordPicked(const QModelIndex& current); + void onFileHeaderMoved(int logicalIndex, int oldVisualIndex, int newVisualIndex); + + void on_pickRecordView_expanded(const QModelIndex& index); + void on_pickRecordView_customContextMenuRequested(const QPoint& pos); + void on_recordStructureView_expanded(const QModelIndex& index); + void on_filterCombo_currentIndexChanged(int index); + void on_ignoreMasterConflicts_stateChanged(int state); + +private: + void expandStructureConflicts(const QModelIndex& parent = QModelIndex()); + + Ui::PluginRecordView* ui; + MOBase::IOrganizer* m_Organizer = nullptr; + const TESData::FileEntry* m_ConflictEntry = nullptr; + TESData::PluginList* m_PluginList = nullptr; + PluginRecordModel* m_RecordModel = nullptr; + RecordFilterProxyModel* m_FilterProxy = nullptr; + RecordStructureModel* m_StructureModel = nullptr; + bool m_MovingSection = false; +}; + +} // namespace BSPluginInfo + +#endif // BSPLUGININFO_PLUGINRECORDVIEW_H diff --git a/libs/installer_bsplugins/src/BSPluginInfo/RecordFilterProxyModel.cpp b/libs/installer_bsplugins/src/BSPluginInfo/RecordFilterProxyModel.cpp new file mode 100644 index 0000000..919e8c9 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginInfo/RecordFilterProxyModel.cpp @@ -0,0 +1,138 @@ +#include "RecordFilterProxyModel.h" +#include "MOPlugin/Settings.h" + +#include <algorithm> + +namespace BSPluginInfo +{ + +RecordFilterProxyModel::RecordFilterProxyModel(const TESData::PluginList* pluginList, + const QString& pluginName) + : m_PluginList{pluginList}, m_PluginName{pluginName}, + m_FilterFlags{Filter_AllConflicts} +{ + setRecursiveFilteringEnabled(true); +} + +void RecordFilterProxyModel::setFile(const QString& pluginName) +{ + m_PluginName = pluginName; +} + +void RecordFilterProxyModel::setFilterFlags(FilterFlags filterFlags) +{ + m_FilterFlags = filterFlags; + invalidateRowsFilter(); +} + +void RecordFilterProxyModel::setSourceModel(QAbstractItemModel* sourceModel) +{ + emit beginResetModel(); + + if (const auto oldSource = this->sourceModel()) { + disconnect(oldSource, nullptr, this, nullptr); + } + + QSortFilterProxyModel::setSourceModel(sourceModel); + + connect(sourceModel, &QAbstractItemModel::dataChanged, this, + &RecordFilterProxyModel::onSourceDataChanged); + connect(sourceModel, &QAbstractItemModel::rowsRemoved, this, + &RecordFilterProxyModel::onSourceRowsRemoved); + + emit endResetModel(); +} + +void RecordFilterProxyModel::onSourceDataChanged() +{ + if (dynamicSortFilter()) { + invalidateRowsFilter(); + } +} + +void RecordFilterProxyModel::onSourceRowsRemoved(const QModelIndex& parent, + [[maybe_unused]] int first, + [[maybe_unused]] int last) +{ + emit layoutAboutToBeChanged({parent}); + emit layoutChanged({parent}); +} + +bool RecordFilterProxyModel::filterAcceptsRow(int source_row, + const QModelIndex& source_parent) const +{ + if (!m_PluginList) { + return true; + } + + using Item = TESData::FileEntry::TreeItem; + const auto index = sourceModel()->index(source_row, 0, source_parent); + + const auto item = index.data(Qt::UserRole).value<const Item*>(); + const auto info = m_PluginList->getPluginByName(m_PluginName); + if (!item || !info) { + return true; + } + + if (!item->record) { + return false; + } + + if (m_FilterFlags == Filter_AllRecords) { + return true; + } + + const bool ignoreMasters = + Settings::instance()->get<bool>("ignore_master_conflicts", false); + + bool isConflicted = false; + bool isLosing = false; + for (const auto alternative : item->record->alternatives()) { + const auto altEntry = m_PluginList->findEntryByHandle(alternative); + if (!altEntry || altEntry->name() == m_PluginName.toStdString()) + continue; + + const auto altInfo = + m_PluginList->getPluginByName(QString::fromStdString(altEntry->name())); + if (!altInfo || !altInfo->enabled()) + continue; + + if (info->priority() > altInfo->priority()) { + if (!ignoreMasters || + !info->masters().contains(altInfo->name(), Qt::CaseInsensitive)) { + isConflicted = true; + } + } else { + if (!ignoreMasters || + !altInfo->masters().contains(info->name(), Qt::CaseInsensitive)) { + isConflicted = true; + isLosing = true; + break; + } + } + } + + if (isConflicted && !isLosing) { + return m_FilterFlags & Filter_WinningConflicts; + } + + if (!isConflicted) { + return m_FilterFlags & Filter_NoConflicts; + } else { + if (!isLosing) { + return m_FilterFlags & Filter_WinningConflicts; + } else { + if (m_FilterFlags == Filter_AllConflicts) { + return true; + } + + if (sourceModel()->canFetchMore(index)) { + sourceModel()->fetchMore(index); + } + + return m_FilterFlags & Filter_LosingConflicts; + } + } +} + +} // namespace BSPluginInfo diff --git a/libs/installer_bsplugins/src/BSPluginInfo/RecordFilterProxyModel.h b/libs/installer_bsplugins/src/BSPluginInfo/RecordFilterProxyModel.h new file mode 100644 index 0000000..b0313ba --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginInfo/RecordFilterProxyModel.h @@ -0,0 +1,53 @@ +#ifndef BSPLUGININFO_RECORDFILTERPROXYMODEL_H +#define BSPLUGININFO_RECORDFILTERPROXYMODEL_H + +#include "TESData/PluginList.h" + +#include <QSortFilterProxyModel> + +namespace BSPluginInfo +{ + +class RecordFilterProxyModel final : public QSortFilterProxyModel +{ + Q_OBJECT + +public: + enum FilterFlags + { + Filter_WinningConflicts = 0x1, + Filter_LosingConflicts = 0x2, + Filter_NoConflicts = 0x4, + + Filter_AllConflicts = Filter_WinningConflicts | Filter_LosingConflicts, + Filter_AllRecords = Filter_AllConflicts | Filter_NoConflicts, + }; + + RecordFilterProxyModel(const TESData::PluginList* pluginList, + const QString& pluginName); + + [[nodiscard]] const QString& file() const { return m_PluginName; } + void setFile(const QString& pluginName); + + [[nodiscard]] FilterFlags filterFlags() const { return m_FilterFlags; } + void setFilterFlags(FilterFlags filterFlags); + + void setSourceModel(QAbstractItemModel* sourceModel) override; + +protected: + bool filterAcceptsRow(int source_row, + const QModelIndex& source_parent) const override; + +private slots: + void onSourceDataChanged(); + void onSourceRowsRemoved(const QModelIndex& parent, int first, int last); + +private: + const TESData::PluginList* m_PluginList; + QString m_PluginName; + FilterFlags m_FilterFlags; +}; + +} // namespace BSPluginInfo + +#endif // BSPLUGININFO_RECORDFILTERPROXYMODEL_H diff --git a/libs/installer_bsplugins/src/BSPluginInfo/RecordStructureModel.cpp b/libs/installer_bsplugins/src/BSPluginInfo/RecordStructureModel.cpp new file mode 100644 index 0000000..a28d714 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginInfo/RecordStructureModel.cpp @@ -0,0 +1,167 @@ +#include "RecordStructureModel.h" +#include "TESData/SingleRecordParser.h" +#include "TESFile/Reader.h" + +#include <log.h> + +#include <boost/container/flat_map.hpp> + +namespace BSPluginInfo +{ + +RecordStructureModel::RecordStructureModel(TESData::PluginList* pluginList, + TESData::Record* record, + const TESData::RecordPath& path, + MOBase::IOrganizer* organizer) + : m_Organizer{organizer}, m_PluginList{pluginList}, m_Record{record}, m_Path{path}, + m_Root{std::make_shared<Item>()} +{ + refresh(); +} + +void RecordStructureModel::refresh() +{ + boost::container::flat_map<int, const TESData::FileEntry*> entries; + for (const auto handle : m_Record->alternatives()) { + const auto entry = m_PluginList->findEntryByHandle(handle); + const auto info = + m_PluginList->getPluginByName(QString::fromStdString(entry->name())); + if (info && info->enabled()) { + entries.emplace(info->priority(), entry); + } + } + + m_Files.resize(entries.size()); + + for (int index = static_cast<int>(entries.size()) - 1; index >= 0; --index) { + const auto entry = entries.nth(index)->second; + const auto name = QString::fromStdString(entry->name()); + const auto vfsEntry = + m_Organizer->virtualFileTree()->find(name, MOBase::FileTreeEntry::FILE); + + const auto filePath = m_Organizer->resolvePath(name); + m_Files[index] = QFileInfo(filePath).fileName(); + + readFile(m_Path, filePath, index); + } +} + +void RecordStructureModel::readFile(const TESData::RecordPath& path, + const QString& filePath, int index) +try { + const auto fileName = QFileInfo(filePath).fileName().toStdString(); + const auto gameName = m_Organizer->managedGame()->gameName(); + TESData::SingleRecordParser handler(gameName, path, fileName, m_Root.get(), index); + TESFile::Reader<TESData::SingleRecordParser> reader{}; + reader.parse(std::filesystem::path(filePath.toStdWString()), handler); +} catch (const std::exception& e) { + MOBase::log::error("Error parsing \"{}\": {}", filePath, e.what()); +} + +QModelIndex RecordStructureModel::index(int row, int column, + const QModelIndex& parent) const +{ + if (row < 0 || column < 0) { + return QModelIndex(); + } + + const auto parentItem = parent.isValid() + ? static_cast<const Item*>(parent.internalPointer()) + : m_Root.get(); + if (parentItem && row < parentItem->numChildren()) { + const auto childItem = parentItem->childAt(row); + return createIndex(row, column, childItem); + } + + return QModelIndex(); +} + +QModelIndex RecordStructureModel::parent(const QModelIndex& index) const +{ + const auto item = + index.isValid() ? static_cast<const Item*>(index.internalPointer()) : nullptr; + const auto parentItem = item ? item->parent() : nullptr; + if (!parentItem) { + return QModelIndex(); + } + + const int row = parentItem->index(); + return createIndex(row, 0, parentItem); +} + +int RecordStructureModel::rowCount(const QModelIndex& parent) const +{ + const auto item = parent.isValid() + ? static_cast<const Item*>(parent.internalPointer()) + : m_Root.get(); + return item ? item->numChildren() : 0; +} + +int RecordStructureModel::columnCount([[maybe_unused]] const QModelIndex& parent) const +{ + return 1 + static_cast<int>(m_Files.size()); +} + +QVariant RecordStructureModel::data(const QModelIndex& index, int role) const +{ + const auto item = + index.isValid() ? static_cast<const Item*>(index.internalPointer()) : nullptr; + if (!item) { + return QVariant(); + } + + switch (role) { + case Qt::DisplayRole: + case Qt::EditRole: + if (index.column() == 0) { + return item->rowHeader(); + } else { + return item->displayData(index.column() - 1); + } + + case Qt::BackgroundRole: { + if (index.column() == 0) { + return QVariant(); + } + + const int count = columnCount() - 1; + const int fileIndex = index.column() - 1; + if (item->isLosingConflict(fileIndex, count)) { + return QColor(255, 0, 0, 64); + } else if (item->isOverriding(fileIndex)) { + return QColor(0, 255, 0, 64); + } + + return QVariant(); + } + + case Qt::UserRole: + return QVariant::fromValue(item); + } + + return QVariant(); +} + +QVariant RecordStructureModel::headerData(int section, Qt::Orientation orientation, + int role) const +{ + if (orientation != Qt::Horizontal) + return QVariant(); + + switch (role) { + case Qt::DisplayRole: { + if (section == 0) { + // Name + return QVariant(); + } + + if (section - 1 < m_Files.length()) { + return m_Files[section - 1]; + } + } + } + + return QVariant(); +} + +} // namespace BSPluginInfo diff --git a/libs/installer_bsplugins/src/BSPluginInfo/RecordStructureModel.h b/libs/installer_bsplugins/src/BSPluginInfo/RecordStructureModel.h new file mode 100644 index 0000000..9592b24 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginInfo/RecordStructureModel.h @@ -0,0 +1,51 @@ +#ifndef BSPLUGININFO_RECORDSTRUCTUREMODEL_H +#define BSPLUGININFO_RECORDSTRUCTUREMODEL_H + +#include "TESData/DataItem.h" +#include "TESData/PluginList.h" +#include "TESData/RecordPath.h" + +#include <QAbstractItemModel> +#include <QList> + +namespace BSPluginInfo +{ + +class RecordStructureModel final : public QAbstractItemModel +{ + Q_OBJECT + +public: + RecordStructureModel(TESData::PluginList* pluginList, TESData::Record* record, + const TESData::RecordPath& path, MOBase::IOrganizer* organizer); + + void refresh(); + + [[nodiscard]] const QString& file(int index) const { return m_Files[index]; } + + QModelIndex index(int row, int column, + const QModelIndex& parent = QModelIndex()) const override; + QModelIndex parent(const QModelIndex& index) const override; + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + int columnCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QVariant headerData(int section, Qt::Orientation orientation, + int role = Qt::DisplayRole) const override; + +private: + using Item = TESData::DataItem; + + void readFile(const TESData::RecordPath& path, const QString& filePath, + int index = 0); + + QList<QString> m_Files; + MOBase::IOrganizer* m_Organizer = nullptr; + TESData::PluginList* m_PluginList = nullptr; + TESData::Record* m_Record = nullptr; + TESData::RecordPath m_Path; + std::shared_ptr<Item> m_Root; +}; + +} // namespace BSPluginInfo + +#endif // BSPLUGININFO_RECORDSTRUCTUREMODEL_H diff --git a/libs/installer_bsplugins/src/BSPluginInfo/plugininfodialog.ui b/libs/installer_bsplugins/src/BSPluginInfo/plugininfodialog.ui new file mode 100644 index 0000000..1b6fc59 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginInfo/plugininfodialog.ui @@ -0,0 +1,397 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>PluginInfoDialog</class> + <widget class="QDialog" name="PluginInfoDialog"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>1000</width> + <height>612</height> + </rect> + </property> + <property name="windowTitle"> + <string>BGS Data</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <widget class="QTabWidget" name="tabWidget"> + <property name="currentIndex"> + <number>0</number> + </property> + <widget class="QWidget" name="tabRecords"> + <attribute name="title"> + <string>Records</string> + </attribute> + <layout class="QVBoxLayout" name="verticalLayout_3"> + <item> + <widget class="BSPluginInfo::PluginRecordView" name="pluginRecordView" native="true"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Expanding"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + </widget> + </item> + </layout> + </widget> + <widget class="QWidget" name="tabArchives"> + <attribute name="title"> + <string>Archives</string> + </attribute> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <item> + <widget class="QTabWidget" name="tabArchivesTabs"> + <property name="currentIndex"> + <number>0</number> + </property> + <widget class="QWidget" name="tabConflictsGeneral"> + <attribute name="title"> + <string>Conflicts</string> + </attribute> + <layout class="QVBoxLayout" name="verticalLayout_5" stretch="0,1,0,1,0,1"> + <item> + <layout class="QVBoxLayout" name="winningRoot"> + <item> + <layout class="QHBoxLayout" name="winningHeader" stretch="1,0,0"> + <item> + <widget class="QToolButton" name="winningExpander"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="toolTip"> + <string>Files that exist in other mods but are overwritten by this mod</string> + </property> + <property name="styleSheet"> + <string notr="true">border: none; +text-align: left;</string> + </property> + <property name="text"> + <string>Winning file conflicts:</string> + </property> + </widget> + </item> + <item> + <widget class="MOBase::LineEditClear" name="winningLineEdit"> + <property name="placeholderText"> + <string>Filter</string> + </property> + </widget> + </item> + <item> + <widget class="QLCDNumber" name="winningCount"> + <property name="frameShadow"> + <enum>QFrame::Sunken</enum> + </property> + <property name="lineWidth"> + <number>1</number> + </property> + <property name="segmentStyle"> + <enum>QLCDNumber::Flat</enum> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + <item> + <widget class="QTreeView" name="winningTree"> + <property name="contextMenuPolicy"> + <enum>Qt::CustomContextMenu</enum> + </property> + <property name="toolTip"> + <string>Files that exist in other mods but are overwritten by this mod</string> + </property> + <property name="alternatingRowColors"> + <bool>true</bool> + </property> + <property name="selectionMode"> + <enum>QAbstractItemView::ExtendedSelection</enum> + </property> + <property name="textElideMode"> + <enum>Qt::ElideLeft</enum> + </property> + <property name="uniformRowHeights"> + <bool>true</bool> + </property> + <property name="sortingEnabled"> + <bool>true</bool> + </property> + </widget> + </item> + <item> + <layout class="QVBoxLayout" name="losingRoot"> + <item> + <layout class="QHBoxLayout" name="losingHeader" stretch="1,0,0"> + <item> + <widget class="QToolButton" name="losingExpander"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="toolTip"> + <string>Files that are unused because they are overwritten by other mods</string> + </property> + <property name="styleSheet"> + <string notr="true">border: none; +text-align: left;</string> + </property> + <property name="text"> + <string>Losing file conflicts:</string> + </property> + </widget> + </item> + <item> + <widget class="MOBase::LineEditClear" name="losingLineEdit"> + <property name="placeholderText"> + <string>Filter</string> + </property> + </widget> + </item> + <item> + <widget class="QLCDNumber" name="losingCount"> + <property name="frameShadow"> + <enum>QFrame::Sunken</enum> + </property> + <property name="segmentStyle"> + <enum>QLCDNumber::Flat</enum> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + <item> + <widget class="QTreeView" name="losingTree"> + <property name="contextMenuPolicy"> + <enum>Qt::CustomContextMenu</enum> + </property> + <property name="toolTip"> + <string>Files that are unused because they are overwritten by other mods</string> + </property> + <property name="alternatingRowColors"> + <bool>true</bool> + </property> + <property name="selectionMode"> + <enum>QAbstractItemView::ExtendedSelection</enum> + </property> + <property name="textElideMode"> + <enum>Qt::ElideLeft</enum> + </property> + <property name="uniformRowHeights"> + <bool>true</bool> + </property> + <property name="sortingEnabled"> + <bool>true</bool> + </property> + </widget> + </item> + <item> + <layout class="QVBoxLayout" name="noConflictRoot"> + <item> + <layout class="QHBoxLayout" name="noConflictHeader" stretch="1,0,0"> + <item> + <widget class="QToolButton" name="noConflictExpander"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="toolTip"> + <string>Files that have no conflicts</string> + </property> + <property name="styleSheet"> + <string notr="true">border: none; +text-align: left;</string> + </property> + <property name="text"> + <string>The following files have no conflicts:</string> + </property> + </widget> + </item> + <item> + <widget class="MOBase::LineEditClear" name="noConflictLineEdit"> + <property name="placeholderText"> + <string>Filter</string> + </property> + </widget> + </item> + <item> + <widget class="QLCDNumber" name="noConflictCount"> + <property name="frameShadow"> + <enum>QFrame::Sunken</enum> + </property> + <property name="segmentStyle"> + <enum>QLCDNumber::Flat</enum> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </item> + <item> + <widget class="QTreeView" name="noConflictTree"> + <property name="contextMenuPolicy"> + <enum>Qt::CustomContextMenu</enum> + </property> + <property name="toolTip"> + <string>Files that have no conflicts</string> + </property> + <property name="alternatingRowColors"> + <bool>true</bool> + </property> + <property name="selectionMode"> + <enum>QAbstractItemView::ExtendedSelection</enum> + </property> + <property name="textElideMode"> + <enum>Qt::ElideLeft</enum> + </property> + <property name="uniformRowHeights"> + <bool>true</bool> + </property> + <property name="sortingEnabled"> + <bool>true</bool> + </property> + </widget> + </item> + </layout> + </widget> + <widget class="QWidget" name="tabArchivesTree"> + <attribute name="title"> + <string>Tree</string> + </attribute> + <layout class="QVBoxLayout" name="verticalLayout_4"> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2" stretch="1,0,0"> + <item> + <widget class="QLabel" name="currentArchiveLabel"> + <property name="text"> + <string notr="true"/> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="previousArchiveButton"> + <property name="text"> + <string/> + </property> + <property name="icon"> + <iconset resource="../../../modorganizer/src/resources.qrc"> + <normaloff>:/MO/gui/previous</normaloff>:/MO/gui/previous</iconset> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="nextArchiveButton"> + <property name="text"> + <string/> + </property> + <property name="icon"> + <iconset resource="../../../modorganizer/src/resources.qrc"> + <normaloff>:/MO/gui/next</normaloff>:/MO/gui/next</iconset> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="QStackedWidget" name="archivesTreeStack"/> + </item> + <item> + <widget class="MOBase::LineEditClear" name="archiveFilterEdit"> + <property name="baseSize"> + <size> + <width>220</width> + <height>0</height> + </size> + </property> + <property name="placeholderText"> + <string>Filter</string> + </property> + </widget> + </item> + </layout> + </widget> + </widget> + </item> + </layout> + </widget> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <item> + <widget class="QPushButton" name="previousFile"> + <property name="text"> + <string>Previous</string> + </property> + <property name="autoDefault"> + <bool>false</bool> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="nextFile"> + <property name="text"> + <string>Next</string> + </property> + <property name="autoDefault"> + <bool>false</bool> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="close"> + <property name="text"> + <string>Close</string> + </property> + <property name="autoDefault"> + <bool>false</bool> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + <customwidgets> + <customwidget> + <class>MOBase::LineEditClear</class> + <extends>QLineEdit</extends> + <header>lineeditclear.h</header> + </customwidget> + <customwidget> + <class>BSPluginInfo::PluginRecordView</class> + <extends>QWidget</extends> + <header>BSPluginInfo/PluginRecordView.h</header> + <container>1</container> + </customwidget> + </customwidgets> + <resources> + <include location="../../../modorganizer/src/resources.qrc"/> + </resources> + <connections/> +</ui> diff --git a/libs/installer_bsplugins/src/BSPluginInfo/pluginrecordview.ui b/libs/installer_bsplugins/src/BSPluginInfo/pluginrecordview.ui new file mode 100644 index 0000000..02d2ce6 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginInfo/pluginrecordview.ui @@ -0,0 +1,149 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>PluginRecordView</class> + <widget class="QWidget" name="PluginRecordView"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>709</width> + <height>453</height> + </rect> + </property> + <property name="windowTitle"> + <string notr="true">BGS Data</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="leftMargin"> + <number>0</number> + </property> + <property name="topMargin"> + <number>0</number> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <property name="bottomMargin"> + <number>0</number> + </property> + <item> + <widget class="QSplitter" name="splitter"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <widget class="QTreeView" name="pickRecordView"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Expanding"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>150</width> + <height>0</height> + </size> + </property> + <property name="contextMenuPolicy"> + <enum>Qt::CustomContextMenu</enum> + </property> + <property name="alternatingRowColors"> + <bool>true</bool> + </property> + <property name="selectionMode"> + <enum>QAbstractItemView::ExtendedSelection</enum> + </property> + <property name="uniformRowHeights"> + <bool>true</bool> + </property> + </widget> + <widget class="QTreeView" name="recordStructureView"> + <property name="alternatingRowColors"> + <bool>true</bool> + </property> + <property name="selectionMode"> + <enum>QAbstractItemView::NoSelection</enum> + </property> + <property name="verticalScrollMode"> + <enum>QAbstractItemView::ScrollPerPixel</enum> + </property> + <property name="wordWrap"> + <bool>true</bool> + </property> + <attribute name="headerDefaultSectionSize"> + <number>150</number> + </attribute> + <attribute name="headerStretchLastSection"> + <bool>false</bool> + </attribute> + </widget> + </widget> + </item> + <item> + <widget class="QWidget" name="conflictFilterRow" native="true"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Fixed"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <property name="leftMargin"> + <number>0</number> + </property> + <property name="topMargin"> + <number>0</number> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <property name="bottomMargin"> + <number>0</number> + </property> + <item> + <widget class="QComboBox" name="filterCombo"> + <item> + <property name="text"> + <string>All conflicts</string> + </property> + </item> + <item> + <property name="text"> + <string>Winning conflicts</string> + </property> + </item> + <item> + <property name="text"> + <string>Losing conflicts</string> + </property> + </item> + </widget> + </item> + <item> + <widget class="QCheckBox" name="ignoreMasterConflicts"> + <property name="text"> + <string>Ignore conflicts with masters</string> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>780</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </widget> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/libs/installer_bsplugins/src/BSPluginList/ConflictIconDelegate.cpp b/libs/installer_bsplugins/src/BSPluginList/ConflictIconDelegate.cpp new file mode 100644 index 0000000..a32471a --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/ConflictIconDelegate.cpp @@ -0,0 +1,48 @@ +#include "ConflictIconDelegate.h" + +namespace BSPluginList +{ + +using enum TESData::FileInfo::EConflictFlag; + +ConflictIconDelegate::ConflictIconDelegate(PluginListView* view) + : GUI::IconDelegate(view), m_View{view} +{} + +QList<QString> ConflictIconDelegate::getIcons(const QModelIndex& index) const +{ + const auto flags = m_View->conflictFlags(index); + + QList<QString> icons; + + if ((flags & CONFLICT_MIXED) == CONFLICT_MIXED) { + icons.append(":/MO/gui/emblem_conflict_mixed"); + } else if (flags & CONFLICT_OVERRIDE) { + icons.append(":/MO/gui/emblem_conflict_overwrite"); + } else if (flags & CONFLICT_OVERRIDDEN) { + icons.append(":/MO/gui/emblem_conflict_overwritten"); + } + + if ((flags & CONFLICT_ARCHIVE_MIXED) == CONFLICT_ARCHIVE_MIXED) { + icons.append(":/MO/gui/archive_conflict_mixed"); + } else if (flags & CONFLICT_ARCHIVE_OVERWRITE) { + icons.append(":/MO/gui/archive_conflict_winner"); + } else if (flags & CONFLICT_ARCHIVE_OVERWRITTEN) { + icons.append(":/MO/gui/archive_conflict_loser"); + } + + return icons; +} + +[[nodiscard]] static int numIcons(uint flags) +{ + return ((flags & CONFLICT_MIXED) ? 1 : 0) + + ((flags & CONFLICT_ARCHIVE_MIXED) ? 1 : 0); +} + +int ConflictIconDelegate::getNumIcons(const QModelIndex& index) const +{ + return numIcons(m_View->conflictFlags(index)); +} + +} // namespace BSPluginList diff --git a/libs/installer_bsplugins/src/BSPluginList/ConflictIconDelegate.h b/libs/installer_bsplugins/src/BSPluginList/ConflictIconDelegate.h new file mode 100644 index 0000000..6343328 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/ConflictIconDelegate.h @@ -0,0 +1,28 @@ +#ifndef BSPLUGINLIST_CONFLICTICONDELEGATE_H +#define BSPLUGINLIST_CONFLICTICONDELEGATE_H + +#include "GUI/IconDelegate.h" +#include "PluginListView.h" +#include "TESData/FileInfo.h" + +namespace BSPluginList +{ + +class ConflictIconDelegate final : public GUI::IconDelegate +{ + Q_OBJECT + +public: + explicit ConflictIconDelegate(PluginListView* view); + +protected: + [[nodiscard]] QList<QString> getIcons(const QModelIndex& index) const override; + [[nodiscard]] int getNumIcons(const QModelIndex& index) const override; + +private: + const PluginListView* m_View; +}; + +} // namespace BSPluginList + +#endif // BSPLUGINLIST_CONFLICTICONDELEGATE_H diff --git a/libs/installer_bsplugins/src/BSPluginList/FlagIconDelegate.cpp b/libs/installer_bsplugins/src/BSPluginList/FlagIconDelegate.cpp new file mode 100644 index 0000000..72be162 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/FlagIconDelegate.cpp @@ -0,0 +1,67 @@ +#include "FlagIconDelegate.h" + +#include <bit> + +namespace BSPluginList +{ + +using enum TESData::FileInfo::EFlag; + +FlagIconDelegate::FlagIconDelegate(PluginListView* view) + : GUI::IconDelegate(view), m_View{view} +{} + +QList<QString> FlagIconDelegate::getIcons(const QModelIndex& index) const +{ + const auto flags = m_View->fileFlags(index); + + QList<QString> icons; + + if (flags & FLAG_PROBLEMATIC) { + icons.append(":/MO/gui/warning"); + } + + if (flags & FLAG_INFORMATION) { + icons.append(":/MO/gui/information"); + } + + if (flags & FLAG_INI) { + icons.append(":/MO/gui/attachment"); + } + + if (flags & FLAG_BSA) { + icons.append(":/MO/gui/archive_conflict_neutral"); + } + + if (flags & FLAG_MASTER) { + icons.append(":/bsplugins/star"); + } + + if (flags & FLAG_LIGHT) { + icons.append(":/bsplugins/feather"); + } + + if (flags & FLAG_MEDIUM) { + icons.append(":/MO/gui/run"); + if (flags & FLAG_LIGHT) { + icons.append(":/MO/gui/warning"); + } + } + + if (flags & FLAG_BLUEPRINT) { + icons.append(":/MO/gui/resources/go-down.png"); + } + + if (flags & FLAG_CLEAN) { + icons.append(":/MO/gui/edit_clear"); + } + + return icons; +} + +int FlagIconDelegate::getNumIcons(const QModelIndex& index) const +{ + return std::popcount(static_cast<uint>(m_View->fileFlags(index))); +} + +} // namespace BSPluginList diff --git a/libs/installer_bsplugins/src/BSPluginList/FlagIconDelegate.h b/libs/installer_bsplugins/src/BSPluginList/FlagIconDelegate.h new file mode 100644 index 0000000..7382233 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/FlagIconDelegate.h @@ -0,0 +1,28 @@ +#ifndef BSPLUGINLIST_FLAGICONDELEGATE_H +#define BSPLUGINLIST_FLAGICONDELEGATE_H + +#include "GUI/IconDelegate.h" +#include "PluginListView.h" +#include "TESData/FileInfo.h" + +namespace BSPluginList +{ + +class FlagIconDelegate final : public GUI::IconDelegate +{ + Q_OBJECT + +public: + explicit FlagIconDelegate(PluginListView* view); + +protected: + [[nodiscard]] QList<QString> getIcons(const QModelIndex& index) const override; + [[nodiscard]] int getNumIcons(const QModelIndex& index) const override; + +private: + const PluginListView* m_View; +}; + +} // namespace BSPluginList + +#endif // BSPLUGINLIST_FLAGICONDELEGATE_H diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginGroupProxyModel.cpp b/libs/installer_bsplugins/src/BSPluginList/PluginGroupProxyModel.cpp new file mode 100644 index 0000000..fb5adcc --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/PluginGroupProxyModel.cpp @@ -0,0 +1,594 @@ +#include "PluginGroupProxyModel.h" +#include "PluginListDropInfo.h" +#include "PluginListModel.h" + +#include <QSortFilterProxyModel> + +namespace BSPluginList +{ + +PluginGroupProxyModel::PluginGroupProxyModel(MOBase::IOrganizer* organizer, + QObject* parent) + : QAbstractProxyModel(parent), m_Organizer{organizer} +{} + +void PluginGroupProxyModel::setSourceModel(QAbstractItemModel* sourceModel) +{ + emit beginResetModel(); + + if (const auto oldSource = this->sourceModel()) { + disconnect(oldSource, nullptr, this, nullptr); + } + + QAbstractProxyModel::setSourceModel(sourceModel); + + if (sourceModel) { + connect(sourceModel, &QAbstractItemModel::layoutChanged, this, + &PluginGroupProxyModel::onSourceLayoutChanged, Qt::UniqueConnection); + connect(sourceModel, &QAbstractItemModel::rowsInserted, this, + &PluginGroupProxyModel::onSourceRowsInserted, Qt::UniqueConnection); + connect(sourceModel, &QAbstractItemModel::rowsRemoved, this, + &PluginGroupProxyModel::onSourceRowsRemoved, Qt::UniqueConnection); + connect(sourceModel, &QAbstractItemModel::modelReset, this, + &PluginGroupProxyModel::onSourceModelReset, Qt::UniqueConnection); + connect(sourceModel, &QAbstractItemModel::dataChanged, this, + &PluginGroupProxyModel::onSourceDataChanged, Qt::UniqueConnection); + + buildGroups(); + } + + emit endResetModel(); +} + +bool PluginGroupProxyModel::hasChildren(const QModelIndex& parent) const +{ + return rowCount(parent) > 0; +} + +int PluginGroupProxyModel::rowCount(const QModelIndex& parent) const +{ + if (!parent.isValid()) { + return static_cast<int>(m_TopLevel.size()); + } else { + const auto& item = m_ProxyItems.at(parent.internalId()); + const auto& group = item.groupInfo; + return group ? static_cast<int>(group->children.size()) : 0; + } +} + +int PluginGroupProxyModel::columnCount([[maybe_unused]] const QModelIndex& parent) const +{ + return sourceModel()->columnCount(); +} + +QModelIndex PluginGroupProxyModel::index(int row, int column, + const QModelIndex& parent) const +{ + if (row < 0 || column < 0) { + return QModelIndex(); + } + + if (parent.isValid()) { + const auto& item = m_ProxyItems.at(parent.internalId()); + if (const auto& group = item.groupInfo) { + if (row < group->children.size()) { + const auto id = group->children[row]; + return createIndex(row, column, id); + } + } + } else { + if (row < m_TopLevel.size()) { + const auto id = m_TopLevel[row]; + return createIndex(row, column, id); + } + } + return QModelIndex(); +} + +QModelIndex PluginGroupProxyModel::parent(const QModelIndex& index) const +{ + const auto& item = m_ProxyItems.at(index.internalId()); + const auto parentId = item.parentId; + if (parentId == NO_ID) { + return QModelIndex(); + } else { + const auto& parent = m_ProxyItems.at(parentId); + return createIndex(parent.row, 0, parentId); + } +} + +QModelIndex PluginGroupProxyModel::mapFromSource(const QModelIndex& sourceIndex) const +{ + if (!sourceIndex.isValid()) { + return QModelIndex(); + } + + const auto id = m_SourceMap[sourceIndex.row()]; + const auto& item = m_ProxyItems.at(id); + QModelIndex parentIndex; + if (item.parentId != NO_ID) { + const auto& parentItem = m_ProxyItems.at(item.parentId); + parentIndex = index(parentItem.row, 0); + } + return index(item.row, sourceIndex.column(), parentIndex); +} + +QModelIndex PluginGroupProxyModel::mapToSource(const QModelIndex& proxyIndex) const +{ + if (!proxyIndex.isValid()) { + return QModelIndex(); + } + + const auto& item = m_ProxyItems.at(proxyIndex.internalId()); + return sourceModel()->index(item.sourceRow, proxyIndex.column()); +} + +Qt::ItemFlags PluginGroupProxyModel::flags(const QModelIndex& index) const +{ + if (!index.isValid()) { + return QAbstractProxyModel::flags(index); + } + + const auto& item = m_ProxyItems.at(index.internalId()); + if (!item.isGroup()) { + if (item.isSourceItem()) { + return sourceModel()->flags(mapToSource(index)) | Qt::ItemNeverHasChildren; + } else { + return Qt::ItemNeverHasChildren; + } + } + + Qt::ItemFlags result = QAbstractProxyModel::flags(index); + if (index.column() == PluginListModel::COL_NAME) { + result |= Qt::ItemIsEditable; + } + result |= Qt::ItemIsSelectable; + result |= Qt::ItemIsDragEnabled; + result |= Qt::ItemIsDropEnabled; + result |= Qt::ItemIsEnabled; + return result; +} + +static QVariant groupData(const QString& name, int column, int role) +{ + switch (role) { + case Qt::DisplayRole: + case Qt::EditRole: + switch (column) { + case PluginListModel::COL_NAME: + return name; + default: + return QVariant(); + } + case Qt::FontRole: { + QFont result; + if (column == PluginListModel::COL_NAME) { + result.setItalic(true); + result.setBold(true); + } + return result; + } + case Qt::TextAlignmentRole: + return QVariant(Qt::AlignHCenter | Qt::AlignVCenter); + default: + return QVariant(); + } +} + +QVariant PluginGroupProxyModel::data(const QModelIndex& index, int role) const +{ + const auto& item = m_ProxyItems.at(index.internalId()); + if (item.isSourceItem()) { + return sourceModel()->data(mapToSource(index), role); + } + + if (const auto& group = item.groupInfo) { + return groupData(group->name, index.column(), role); + } + + return QVariant(); +} + +bool PluginGroupProxyModel::setData(const QModelIndex& index, const QVariant& value, + int role) +{ + const auto& item = m_ProxyItems.at(index.internalId()); + if (item.isSourceItem()) { + return sourceModel()->setData(mapToSource(index), value, role); + } + + if (const auto& group = item.groupInfo) { + if (role == Qt::EditRole) { + if (index.column() == 0) { + const QString valueString = value.toString(); + if (valueString.isEmpty()) + return false; + + emit groupRenameRequested(index, valueString); + } + } + } + + return false; +} + +QModelIndex PluginGroupProxyModel::buddy(const QModelIndex& index) const +{ + const auto& item = m_ProxyItems.at(index.internalId()); + if (item.isSourceItem()) { + return mapFromSource(sourceModel()->buddy(mapToSource(index))); + } + + return index; +} + +QVariant PluginGroupProxyModel::headerData(int section, Qt::Orientation orientation, + int role) const +{ + return sourceModel()->headerData(section, orientation, role); +} + +int PluginGroupProxyModel::mapLowerBoundToSourceRow(std::size_t id) const +{ + const auto& item = m_ProxyItems.at(id); + if (item.isSourceItem()) { + return item.sourceRow; + } else if (item.isGroup()) { + const auto child = item.groupInfo->children.front(); + const auto& childItem = m_ProxyItems.at(child); + return childItem.sourceRow; + } else { + if (item.row + 1 == m_TopLevel.size()) { + return -1; + } + const auto next = m_TopLevel.at(item.row + 1); + return mapLowerBoundToSourceRow(next); + } +} + +bool PluginGroupProxyModel::isAboveDivider(std::size_t id) const +{ + const auto& item = m_ProxyItems.at(id); + return item.isDivider(); +} + +bool PluginGroupProxyModel::isBelowDivider(std::size_t id) const +{ + const auto& item = m_ProxyItems.at(id); + if (item.parentId != NO_ID) { + return item.row == 0 && isBelowDivider(item.parentId); + } else { + return item.row > 0 && m_ProxyItems.at(m_TopLevel.at(item.row - 1)).isDivider(); + } +} + +QMimeData* PluginGroupProxyModel::mimeData(const QModelIndexList& indexes) const +{ + m_DraggingGroups.clear(); + + QModelIndexList sourceIndexes; + + for (const auto& idx : indexes) { + const auto sourceIndex = mapToSource(idx); + if (sourceIndex.isValid()) { + sourceIndexes.append(sourceIndex); + } else { + m_DraggingGroups.push_back(idx.internalId()); + for (int i = 0, count = rowCount(idx); i < count; ++i) { + sourceIndexes.append(mapToSource(index(i, 0, idx))); + } + } + } + + return sourceModel()->mimeData(sourceIndexes); +} + +bool PluginGroupProxyModel::canDropMimeData(const QMimeData* data, + Qt::DropAction action, int row, int column, + const QModelIndex& parent_) const +{ + // HACK: fix drop below expanded item + auto parent = parent_; + if (m_DroppingBelowExpandedItem) { + parent = index(row - 1, column, parent_); + row = 0; + } + + const bool draggedOntoGroup = parent.isValid() && row == -1; + const bool draggedToBottom = parent.isValid() && row == rowCount(parent); + const auto idx = draggedOntoGroup || draggedToBottom ? index(parent.row() + 1, 0) + : index(row, column, parent); + const int sourceRow = idx.isValid() ? mapLowerBoundToSourceRow(idx.internalId()) : -1; + + bool canDrop = true; + if (isBelowDivider(idx.internalId())) { + canDrop = canDrop && sourceModel()->canDropMimeData(data, action, sourceRow + 1, 0, + QModelIndex()); + } + if (isAboveDivider(idx.internalId())) { + canDrop = canDrop && sourceModel()->canDropMimeData(data, action, sourceRow - 1, 0, + QModelIndex()); + } + + return canDrop && + sourceModel()->canDropMimeData(data, action, sourceRow, 0, QModelIndex()); +} + +template <typename T> +static T* findBaseModel(QAbstractItemModel* sourceModel) +{ + for (auto nextModel = sourceModel; nextModel;) { + const auto proxyModel = qobject_cast<QAbstractProxyModel*>(nextModel); + if (proxyModel) { + nextModel = proxyModel->sourceModel(); + } else { + if (const auto baseModel = qobject_cast<T*>(nextModel)) { + return baseModel; + } + nextModel = nullptr; + } + } + return nullptr; +} + +bool PluginGroupProxyModel::dropMimeData(const QMimeData* data, Qt::DropAction action, + int row, int column, + const QModelIndex& parent_) +{ + // HACK: fix drop below expanded item + auto parent = parent_; + if (m_DroppingBelowExpandedItem) { + parent = index(row - 1, column, parent_); + row = 0; + } + + const bool draggedOntoGroup = parent.isValid() && row == -1; + const bool draggedToBottom = parent.isValid() && row == rowCount(parent); + const auto idx = draggedOntoGroup || draggedToBottom ? index(parent.row() + 1, 0) + : index(row, column, parent); + const int sourceRow = idx.isValid() ? mapLowerBoundToSourceRow(idx.internalId()) : -1; + + QString groupName; + const auto baseModel = findBaseModel<PluginListModel>(sourceModel()); + PluginListDropInfo dropInfo{data, row, parent, + baseModel ? baseModel->m_Plugins : nullptr}; + if (parent.isValid()) { + QModelIndex groupIdx = parent; + while (groupIdx.parent().isValid()) { + groupIdx = groupIdx.parent(); + } + + const auto& parentItem = m_ProxyItems.at(groupIdx.internalId()); + const auto& groupInfo = parentItem.groupInfo; + groupName = groupInfo ? groupInfo->name : QString(); + } + + if (baseModel) { + auto regroupRows = dropInfo.sourceRows(); + if (draggedToBottom || groupName.isEmpty()) { + for (const std::size_t id : m_DraggingGroups) { + const auto& groupItem = m_ProxyItems.at(id); + const auto group = groupItem.groupInfo; + + for (const std::size_t childId : group->children) { + const auto& childItem = m_ProxyItems.at(childId); + const auto childIndex = createIndex(childItem.row, 0, childId); + + std::erase(regroupRows, childIndex.data(PluginListModel::IndexRole).toInt()); + } + } + } + + baseModel->m_Plugins->setGroup(regroupRows, groupName); + } + m_DraggingGroups.clear(); + + if (!sourceModel()->dropMimeData(data, action, sourceRow, 0, QModelIndex())) { + return false; + } + + emit layoutAboutToBeChanged(); + buildGroups(); + emit layoutChanged(); + + return true; +} + +void PluginGroupProxyModel::onSourceDataChanged(const QModelIndex& topLeft, + const QModelIndex& bottomRight, + const QList<int>& roles) +{ + const auto topLeftProxy = mapFromSource(topLeft); + const auto bottomRightProxy = mapFromSource(bottomRight); + + emit dataChanged(topLeftProxy, bottomRightProxy, roles); + + if (!roles.isEmpty() && !roles.contains(PluginListModel::GroupingRole)) { + return; + } + + emit layoutAboutToBeChanged(); + buildGroups(); + emit layoutChanged(); +} + +void PluginGroupProxyModel::onSourceLayoutChanged( + [[maybe_unused]] const QList<QPersistentModelIndex>& parents, + QAbstractItemModel::LayoutChangeHint hint) +{ + emit layoutAboutToBeChanged({}, hint); + buildGroups(); + emit layoutChanged({}, hint); +} + +void PluginGroupProxyModel::onSourceModelReset() +{ + emit layoutAboutToBeChanged(); + buildGroups(); + emit layoutChanged(); +} + +void PluginGroupProxyModel::onSourceRowsInserted(const QModelIndex& parent) +{ + if (parent.isValid()) { + return; + } + + emit layoutAboutToBeChanged(); + buildGroups(); + emit layoutChanged(); +} + +void PluginGroupProxyModel::onSourceRowsRemoved(const QModelIndex& parent) +{ + if (parent.isValid()) { + return; + } + + emit layoutAboutToBeChanged(); + buildGroups(); + emit layoutChanged(); +} + +void PluginGroupProxyModel::buildGroups() +{ + m_TopLevel.clear(); + m_SourceMap.clear(); + + const auto sortProxy = qobject_cast<const QSortFilterProxyModel*>(sourceModel()); + const bool sorted = + sortProxy && sortProxy->sortColumn() == PluginListModel::COL_PRIORITY; + + int primaryDivider = -1; + int masterDivider = -1; + int blueprintDivider = -1; + for (int i = 0; i < sourceModel()->rowCount(); ++i) { + const auto idx = sourceModel()->index(i, 0); + const auto plugin = + idx.data(PluginListModel::InfoRole).value<const TESData::FileInfo*>(); + + if (sorted && plugin) { + if (sortProxy->sortOrder() == Qt::AscendingOrder) { + if (blueprintDivider == -1 && plugin->isBlueprintFile()) { + blueprintDivider = i; + } else if (plugin->forceLoaded()) { + primaryDivider = i; + } else if (plugin->isMasterFile()) { + masterDivider = i; + } + } else { + if (plugin->isBlueprintFile()) { + blueprintDivider = i - 1; + } else if (masterDivider == -1 && plugin->isMasterFile()) { + masterDivider = i - 1; + } else if (primaryDivider == -1 && plugin->forceLoaded()) { + primaryDivider = i - 1; + break; + } + } + } + } + + boost::container::flat_map<QString, int> groupRepeats; + + QString lastGroup; + std::size_t groupId = NO_ID; + for (int i = 0, count = sourceModel()->rowCount(); i < count; ++i) { + const auto idx = sourceModel()->index(i, 0); + const auto info = + idx.data(PluginListModel::InfoRole).value<const TESData::FileInfo*>(); + const QString name = info ? info->name() : QString(); + const QString group = idx.data(PluginListModel::GroupingRole).toString(); + + if (sorted && group != lastGroup) { + lastGroup = group; + + if (group.isNull()) { + groupId = NO_ID; + } else { + const int row = static_cast<int>(m_TopLevel.size()); + groupId = createItem(group, row, -1, NO_ID, std::make_shared<Group>(group), + groupRepeats[group]++); + m_TopLevel.push_back(groupId); + } + } + + { + auto& siblings = + groupId == NO_ID ? m_TopLevel : m_ProxyItems[groupId].groupInfo->children; + + const int row = static_cast<int>(siblings.size()); + const auto id = createItem(name, row, i, groupId, nullptr); + siblings.push_back(id); + m_SourceMap.push_back(id); + } + + if (sorted && + (i == primaryDivider || i == masterDivider || i == blueprintDivider) && + (i != 0 && i != count - 1)) { + lastGroup = QString(); + groupId = NO_ID; + + const QString key = QString(); + const int row = static_cast<int>(m_TopLevel.size()); + const auto id = createItem(key, row, -1, NO_ID, nullptr, groupRepeats[key]++); + m_TopLevel.push_back(id); + } + } + + for (std::size_t id = 0; id < m_ProxyItems.size(); ++id) { + auto& item = m_ProxyItems[id]; + if (item.row < 0) + continue; + + bool invalidate = false; + if (item.parentId == NO_ID) { + if (item.row >= m_TopLevel.size() || m_TopLevel[item.row] != id) { + invalidate = true; + } + } else { + const auto& parentItem = m_ProxyItems.at(item.parentId); + if (const auto group = parentItem.groupInfo) { + if (item.row >= group->children.size() || group->children[item.row] != id) { + invalidate = true; + } + } + } + + if (invalidate) { + // we want to try to keep items alive even if they are temporarily removed from + // the model, so assign them to an index that looks valid but won't be displayed + const int fakeRow = static_cast<int>(m_TopLevel.size()); + for (int column = 0, count = columnCount(); column < count; ++column) { + changePersistentIndex(createIndex(item.row, column, id), + createIndex(fakeRow, column, id)); + } + item = ProxyItem{fakeRow, -1, NO_ID, nullptr}; + } + } +} + +std::size_t PluginGroupProxyModel::createItem(const QString& name, int row, + int sourceRow, std::size_t parent, + std::shared_ptr<Group> group, int repeat) +{ + if (m_ItemMap.count(name) <= repeat) { + const auto id = m_ProxyItems.size(); + m_ItemMap.emplace(name, id); + m_ProxyItems.emplace_back(row, sourceRow, parent, std::move(group)); + return id; + } else { + const auto it = m_ItemMap.find(name) + repeat; + auto& item = m_ProxyItems.at(it->second); + if (row != item.row) { + for (int column = 0, count = columnCount(); column < count; ++column) { + changePersistentIndex(createIndex(item.row, column, it->second), + createIndex(row, column, it->second)); + } + } + item = ProxyItem{row, sourceRow, parent, std::move(group)}; + return it->second; + } +} + +} // namespace BSPluginList diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginGroupProxyModel.h b/libs/installer_bsplugins/src/BSPluginList/PluginGroupProxyModel.h new file mode 100644 index 0000000..c0f0f1c --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/PluginGroupProxyModel.h @@ -0,0 +1,118 @@ +#ifndef BSPLUGINLIST_PLUGINGROUPPROXYMODEL_H +#define BSPLUGINLIST_PLUGINGROUPPROXYMODEL_H + +#include "TESData/FileInfo.h" + +#include <imoinfo.h> + +#include <boost/container/flat_map.hpp> + +#include <QAbstractProxyModel> + +#include <limits> +#include <memory> +#include <vector> + +namespace BSPluginList +{ + +class PluginGroupProxyModel final : public QAbstractProxyModel +{ + Q_OBJECT + +public: + explicit PluginGroupProxyModel(MOBase::IOrganizer* organizer, + QObject* parent = nullptr); + + void setSourceModel(QAbstractItemModel* sourceModel) override; + + bool hasChildren(const QModelIndex& parent = QModelIndex()) const override; + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + int columnCount(const QModelIndex& parent = QModelIndex()) const override; + + QModelIndex index(int row, int column, + const QModelIndex& parent = QModelIndex()) const override; + QModelIndex parent(const QModelIndex& index) const override; + + QModelIndex mapFromSource(const QModelIndex& sourceIndex) const override; + QModelIndex mapToSource(const QModelIndex& proxyIndex) const override; + + Qt::ItemFlags flags(const QModelIndex& index) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + bool setData(const QModelIndex& index, const QVariant& value, + int role = Qt::EditRole) override; + QModelIndex buddy(const QModelIndex& index) const override; + QVariant headerData(int section, Qt::Orientation orientation, + int role = Qt::DisplayRole) const override; + + QMimeData* mimeData(const QModelIndexList& indexes) const override; + bool canDropMimeData(const QMimeData* data, Qt::DropAction action, int row, + int column, const QModelIndex& parent) const override; + bool dropMimeData(const QMimeData* data, Qt::DropAction action, int row, int column, + const QModelIndex& parent) override; + + void setDroppingBelowExpandedItem(bool value) { m_DroppingBelowExpandedItem = value; } + +signals: + void groupRenameRequested(const QModelIndex& index, const QString& name); + +private slots: + void onSourceDataChanged(const QModelIndex& topLeft, const QModelIndex& bottomRight, + const QList<int>& roles = QList<int>()); + void onSourceLayoutChanged( + const QList<QPersistentModelIndex>& parents = QList<QPersistentModelIndex>(), + QAbstractItemModel::LayoutChangeHint hint = + QAbstractItemModel::NoLayoutChangeHint); + void onSourceModelReset(); + void onSourceRowsInserted(const QModelIndex& parent); + void onSourceRowsRemoved(const QModelIndex& parent); + +private: + static constexpr std::size_t NO_ID = std::numeric_limits<std::size_t>::max(); + + struct Group + { + QString name; + std::vector<std::size_t> children; + + explicit Group(const QString& name) : name{name} {} + }; + + struct ProxyItem + { + int row; + int sourceRow; + std::size_t parentId; + std::shared_ptr<Group> groupInfo; + + [[nodiscard]] bool isSourceItem() const { return sourceRow != -1; } + [[nodiscard]] bool isGroup() const { return groupInfo != nullptr; } + [[nodiscard]] bool isDivider() const + { + return groupInfo == nullptr && sourceRow == -1; + } + }; + + [[nodiscard]] int mapLowerBoundToSourceRow(std::size_t id) const; + [[nodiscard]] bool isAboveDivider(std::size_t id) const; + [[nodiscard]] bool isBelowDivider(std::size_t id) const; + + void buildGroups(); + [[nodiscard]] std::size_t createItem(const QString& name, int row, int sourceRow, + std::size_t parent, std::shared_ptr<Group> group, + int repeat = 0); + + std::vector<ProxyItem> m_ProxyItems; + boost::container::flat_multimap<QString, std::size_t> m_ItemMap; + std::vector<std::size_t> m_TopLevel; + std::vector<std::size_t> m_SourceMap; + + bool m_DroppingBelowExpandedItem; + mutable std::vector<std::size_t> m_DraggingGroups; + + MOBase::IOrganizer* m_Organizer; +}; + +} // namespace BSPluginList + +#endif // BSPLUGINLIST_PLUGINGROUPPROXYMODEL_H diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListContextMenu.cpp b/libs/installer_bsplugins/src/BSPluginList/PluginListContextMenu.cpp new file mode 100644 index 0000000..9fc53ac --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/PluginListContextMenu.cpp @@ -0,0 +1,349 @@ +#include "PluginListContextMenu.h" +#include "GUI/ListDialog.h" +#include "MOPlugin/Settings.h" +#include "PluginListModel.h" +#include "PluginListView.h" +#include "TESData/FileInfo.h" + +#include <utility.h> + +#include <QInputDialog> +#include <QMessageBox> + +#include <algorithm> +#include <limits> +#include <utility> + +namespace BSPluginList +{ + +PluginListContextMenu::PluginListContextMenu(const QModelIndex& index, + PluginListModel* model, + PluginListView* view, + MOBase::IModList* modList, + MOBase::IPluginList* pluginList) + : QMenu(view), m_Index{index}, m_Model{model}, m_View{view} +{ + m_ViewSelected = view->selectionModel()->selectedRows(); + if (!m_ViewSelected.isEmpty()) { + m_ModelSelected = view->indexViewToModel(m_ViewSelected, model); + } else if (index.isValid()) { + m_ModelSelected = {index}; + } + + m_FilesSelected = + !m_ViewSelected.isEmpty() && std::ranges::all_of(m_ViewSelected, [](const QModelIndex& idx) { + return !idx.model()->hasChildren(idx); + }); + + m_GroupsSelected = + !m_ViewSelected.isEmpty() && std::ranges::all_of(m_ViewSelected, [](const QModelIndex& idx) { + return idx.model()->hasChildren(idx); + }); + + m_FilesTogglable = + m_FilesSelected && std::ranges::all_of(m_ViewSelected, [](const QModelIndex& idx) { + const auto plugin = + idx.data(PluginListModel::InfoRole).value<const TESData::FileInfo*>(); + return plugin && plugin->canBeToggled(); + }); + + m_FilesESM = + m_FilesSelected && std::ranges::all_of(m_ViewSelected, [](const QModelIndex& idx) { + const auto plugin = + idx.data(PluginListModel::InfoRole).value<const TESData::FileInfo*>(); + return plugin && !plugin->forceLoaded() && plugin->isMasterFile(); + }); + + m_FilesESP = + m_FilesSelected && std::ranges::all_of(m_ViewSelected, [](const QModelIndex& idx) { + const auto plugin = + idx.data(PluginListModel::InfoRole).value<const TESData::FileInfo*>(); + return plugin && !plugin->forceLoaded() && !plugin->isMasterFile(); + }); + + m_FilesMovable = m_FilesESM | m_FilesESP; + + addAllItemsMenu(); + addSelectedFilesActions(); + addSelectedGroupActions(); + addSelectionActions(); + addOriginActions(modList, pluginList); +} + +void PluginListContextMenu::addAllItemsMenu() +{ + QMenu* const allItemsMenu = addMenu(tr("All Items")); + + allItemsMenu->addAction(tr("Collapse all"), [this]() { + m_View->collapseAll(); + m_View->scrollToTop(); + }); + allItemsMenu->addAction(tr("Expand all"), [this]() { + m_View->expandAll(); + m_View->scrollToTop(); + }); + + allItemsMenu->addSeparator(); + + allItemsMenu->addAction(tr("Enable all"), [this]() { + if (QMessageBox::question(m_View->topLevelWidget(), tr("Confirm"), + tr("Really enable all plugins?"), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + m_Model->setEnabledAll(true); + } + }); + allItemsMenu->addAction(tr("Disable all"), [this]() { + if (QMessageBox::question(m_View->topLevelWidget(), tr("Confirm"), + tr("Really disable all plugins?"), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + m_Model->setEnabledAll(false); + } + }); +} + +void PluginListContextMenu::addSelectedFilesActions() +{ + if (!m_FilesTogglable) + return; + + addSeparator(); + + addAction(tr("Enable selected"), [this]() { + m_Model->setEnabled(m_ModelSelected, true); + }); + addAction(tr("Disable selected"), [this]() { + m_Model->setEnabled(m_ModelSelected, false); + }); +} + +void PluginListContextMenu::addSelectedGroupActions() +{ + if (!m_GroupsSelected || m_ViewSelected.length() != 1) + return; + + addSeparator(); + + const auto selectedIndex = m_ViewSelected.first(); + const bool expanded = m_View->isExpanded(selectedIndex); + addAction(tr("Collapse others"), [=, this]() { + m_View->collapseAll(); + m_View->setExpanded(selectedIndex, expanded); + m_View->scrollTo(selectedIndex); + }); +} + +void PluginListContextMenu::addSelectionActions() +{ + if (m_ModelSelected.isEmpty()) + return; + + addSeparator(); + + addSendToMenu(); + + if (m_FilesSelected) { + addAction(tr("Create Group..."), [this]() { + bool ok; + const QString group = + QInputDialog::getText(m_View->topLevelWidget(), tr("Create Group..."), + tr("Please enter a name:"), QLineEdit::Normal, "", &ok); + + if (!ok || group.isEmpty()) + return; + + QList<QPersistentModelIndex> persistent; + persistent.reserve(m_ViewSelected.length()); + std::ranges::transform(m_ViewSelected, std::back_inserter(persistent), + [](const QModelIndex& idx) { + return QPersistentModelIndex(idx); + }); + + const int priority = m_ModelSelected.first() + .siblingAtColumn(PluginListModel::COL_PRIORITY) + .data() + .toInt(); + m_Model->sendToPriority(m_ModelSelected, priority); + m_Model->setGroup(m_ModelSelected, group); + for (const auto& index : persistent) { + m_View->setExpanded(index.parent(), true); + } + }); + } else if (m_GroupsSelected) { + if (m_ViewSelected.length() == 1) { + addAction(tr("Rename Group..."), [this]() { + const auto& selected = m_ViewSelected.first(); + QModelIndexList indices; + for (int i = 0, count = selected.model()->rowCount(selected); i < count; ++i) { + const auto child = selected.model()->index(i, 0, selected); + auto&& index = m_View->indexViewToModel(child, m_Model); + indices.append(std::move(index)); + } + + bool ok; + const QString group = QInputDialog::getText( + m_View->topLevelWidget(), tr("Rename Group..."), tr("Please enter a name:"), + QLineEdit::Normal, "", &ok); + + if (!ok || group.isEmpty()) + return; + + const auto persistentIndex = + QPersistentModelIndex(selected.model()->index(0, 0, selected)); + const bool expanded = m_View->isExpanded(selected); + + m_Model->setGroup(indices, group); + + const auto groupIndex = persistentIndex.parent(); + const auto groupRight = + groupIndex.siblingAtColumn(selected.model()->columnCount() - 1); + m_View->setExpanded(groupIndex, expanded); + m_View->selectionModel()->select(QItemSelection(groupIndex, groupRight), + QItemSelectionModel::ClearAndSelect); + m_View->selectionModel()->setCurrentIndex(groupIndex, + QItemSelectionModel::Current); + }); + } + + addAction(tr("Remove Group..."), [this]() { + QModelIndexList indices; + for (const auto& selected : m_ViewSelected) { + for (int i = 0, count = selected.model()->rowCount(selected); i < count; ++i) { + const auto child = selected.model()->index(i, 0, selected); + auto&& index = m_View->indexViewToModel(child, m_Model); + indices.append(std::move(index)); + } + } + + if (QMessageBox::question(m_View->topLevelWidget(), tr("Confirm"), + tr("Are you sure you want to remove \"%1\"?") + .arg(m_ViewSelected.first().data().toString()), + QMessageBox::Yes | QMessageBox::No) == + QMessageBox::Yes) { + m_Model->setGroup(indices, QString()); + } + }); + } +} + +void PluginListContextMenu::addSendToMenu() +{ + if (!m_FilesMovable) { + return; + } + + QMenu* const sendToMenu = addMenu(tr("Send to... ")); + sendToMenu->addAction(tr("Top"), [this]() { + const auto selectedIndex = m_ViewSelected.first(); + const auto persistentIndex = QPersistentModelIndex(selectedIndex); + m_Model->sendToPriority(m_ModelSelected, 0, true); + m_View->scrollTo(persistentIndex); + }); + sendToMenu->addAction(tr("Bottom"), [this]() { + const auto selectedIndex = m_ViewSelected.first(); + const auto persistentIndex = QPersistentModelIndex(selectedIndex); + m_Model->sendToPriority(m_ModelSelected, std::numeric_limits<int>::max(), true); + m_View->scrollTo(persistentIndex); + }); + sendToMenu->addAction(tr("Priority..."), [this]() { + const auto selectedIndex = m_ViewSelected.first(); + + bool ok; + const int newPriority = + QInputDialog::getInt(m_View->topLevelWidget(), tr("Set Priority"), + tr("Set the priority of the selected plugins"), 0, 0, + std::numeric_limits<int>::max(), 1, &ok); + if (!ok) + return; + + const auto persistentIndex = QPersistentModelIndex(selectedIndex); + m_Model->sendToPriority(m_ModelSelected, newPriority); + m_View->scrollTo(persistentIndex); + }); + sendToMenu->addAction(tr("Group..."), [this]() { + sendSelectedToGroup(); + }); +} + +static MOBase::IModInterface* getModInfo(const QModelIndex& index, + MOBase::IModList* modList, + MOBase::IPluginList* pluginList) +{ + const auto info = + index.data(PluginListModel::InfoRole).value<const TESData::FileInfo*>(); + + if (!info) { + return nullptr; + } + + const QString fileName = info->name(); + return modList->getMod(pluginList->origin(fileName)); +} + +static void openOriginExplorer(const QModelIndexList& indices, + MOBase::IModList* modList, + MOBase::IPluginList* pluginList) +{ + for (const auto& idx : indices) { + if (const auto modInfo = getModInfo(idx, modList, pluginList)) { + MOBase::shell::Explore(modInfo->absolutePath()); + } + } +} + +void PluginListContextMenu::addOriginActions(MOBase::IModList* modList, + MOBase::IPluginList* pluginList) +{ + if (!m_Index.isValid()) + return; + + addSeparator(); + + const auto selectedIdx = + m_ModelSelected.length() == 1 ? m_ModelSelected.first() : m_Index; + const auto nameIdx = selectedIdx.siblingAtColumn(PluginListModel::COL_NAME); + + if (std::ranges::any_of(m_ModelSelected, [=](auto&& idx) { + return getModInfo(idx, modList, pluginList) != nullptr; + })) { + addAction(tr("Open Origin in Explorer"), [=, this]() { + openOriginExplorer(m_ModelSelected, modList, pluginList); + }); + + const auto modInfo = getModInfo(nameIdx, modList, pluginList); + if (modInfo && !modInfo->isForeign()) { + addAction(tr("Open Origin Info..."), [this, nameIdx] { + emit openModInformation(nameIdx); + }); + } + } + + const auto pluginInfoAction = addAction("Open Plugin Info...", [this, nameIdx] { + emit openPluginInformation(nameIdx); + }); + setDefaultAction(pluginInfoAction); +} + +void PluginListContextMenu::sendSelectedToGroup() +{ + GUI::ListDialog dialog{*Settings::instance(), m_View->topLevelWidget()}; + dialog.setWindowTitle(tr("Select a group...")); + if (m_FilesESM) { + dialog.setChoices(m_Model->masterGroups()); + } else if (m_FilesESP) { + dialog.setChoices(m_Model->regularGroups()); + } + + if (dialog.exec() != QDialog::Accepted) { + return; + } + + const QString result = dialog.getChoice(); + if (result.isEmpty()) { + return; + } + + m_Model->sendToGroup(m_ModelSelected, result, m_FilesESM); +} + +} // namespace BSPluginList diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListContextMenu.h b/libs/installer_bsplugins/src/BSPluginList/PluginListContextMenu.h new file mode 100644 index 0000000..9526166 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/PluginListContextMenu.h @@ -0,0 +1,54 @@ +#ifndef BSPLUGINLIST_PLUGINLISTCONTEXTMENU_H +#define BSPLUGINLIST_PLUGINLISTCONTEXTMENU_H + +#include <imodlist.h> +#include <ipluginlist.h> + +#include <QMenu> +#include <QModelIndex> + +namespace BSPluginList +{ + +class PluginListModel; +class PluginListView; + +class PluginListContextMenu final : public QMenu +{ + Q_OBJECT + +public: + PluginListContextMenu(const QModelIndex& index, PluginListModel* model, + PluginListView* view, MOBase::IModList* modList, + MOBase::IPluginList* pluginList); + +signals: + void openModInformation(const QModelIndex& index); + void openPluginInformation(const QModelIndex& index); + +private: + void addAllItemsMenu(); + void addSelectedFilesActions(); + void addSelectedGroupActions(); + void addSelectionActions(); + void addSendToMenu(); + void addOriginActions(MOBase::IModList* modList, MOBase::IPluginList* pluginList); + + void sendSelectedToGroup(); + + QModelIndex m_Index; + PluginListModel* m_Model; + PluginListView* m_View; + QModelIndexList m_ViewSelected; + QModelIndexList m_ModelSelected; + bool m_FilesSelected = false; + bool m_GroupsSelected = false; + bool m_FilesTogglable = false; + bool m_FilesESM = false; + bool m_FilesESP = false; + bool m_FilesMovable = false; +}; + +} // namespace BSPluginList + +#endif // BSPLUGINLIST_PLUGINLISTCONTEXTMENU_H diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListDropInfo.cpp b/libs/installer_bsplugins/src/BSPluginList/PluginListDropInfo.cpp new file mode 100644 index 0000000..5ffda76 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/PluginListDropInfo.cpp @@ -0,0 +1,42 @@ +#include "PluginListDropInfo.h" + +#include <QMap> +#include <QMimeData> +#include <QModelIndex> +#include <QVariant> + +namespace BSPluginList +{ + +PluginListDropInfo::PluginListDropInfo(const QMimeData* data, int insertId, + const QModelIndex& parent, + const TESData::PluginList* plugins) +{ + QByteArray encoded = data->data("application/x-qabstractitemmodeldatalist"); + QDataStream stream(&encoded, QIODevice::ReadOnly); + + while (!stream.atEnd()) { + int sourceRow; + int col; + QMap<int, QVariant> roleDataMap; + stream >> sourceRow >> col >> roleDataMap; + if (col == 0) { + m_SourceRows.push_back(sourceRow); + } + } + + if (insertId == -1) { + insertId = parent.row(); + } + + if (plugins) { + if (insertId < 0 || insertId >= plugins->pluginCount()) { + m_Destination = plugins->pluginCount(); + } else { + const auto plugin = plugins->getPlugin(insertId); + m_Destination = plugin ? plugin->priority() : plugins->pluginCount(); + } + } +} + +} // namespace BSPluginList diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListDropInfo.h b/libs/installer_bsplugins/src/BSPluginList/PluginListDropInfo.h new file mode 100644 index 0000000..d50b27a --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/PluginListDropInfo.h @@ -0,0 +1,29 @@ +#ifndef BSPLUGINLIST_PLUGINLISTDROPINFO_H +#define BSPLUGINLIST_PLUGINLISTDROPINFO_H + +#include "TESData/PluginList.h" + +#include <vector> + +class QMimeData; + +namespace BSPluginList +{ + +class PluginListDropInfo final +{ +public: + PluginListDropInfo(const QMimeData* data, int insertId, const QModelIndex& parent, + const TESData::PluginList* plugins); + + const std::vector<int>& sourceRows() const { return m_SourceRows; } + const int destination() const { return m_Destination; } + +private: + std::vector<int> m_SourceRows; + int m_Destination; +}; + +} // namespace BSPluginList + +#endif // BSPLUGINLIST_PLUGINLISTDROPINFO_H diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListModel.cpp b/libs/installer_bsplugins/src/BSPluginList/PluginListModel.cpp new file mode 100644 index 0000000..b330165 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/PluginListModel.cpp @@ -0,0 +1,871 @@ +#include "PluginListModel.h" +#include "MOPlugin/Settings.h" +#include "PluginListDropInfo.h" + +#include <QGuiApplication> +#include <QMimeData> + +#include <algorithm> +#include <iterator> +#include <utility> +#include <vector> + +namespace BSPluginList +{ + +PluginListModel::PluginListModel(TESData::PluginList* plugins) : m_Plugins{plugins} {} + +QModelIndex PluginListModel::index(int row, int column, + [[maybe_unused]] const QModelIndex& parent) const +{ + if ((row < 0) || (row >= rowCount()) || (column < 0) || (column >= columnCount())) { + return QModelIndex(); + } + return createIndex(row, column, row); +} + +QModelIndex PluginListModel::parent([[maybe_unused]] const QModelIndex& index) const +{ + return QModelIndex(); +} + +Qt::ItemFlags PluginListModel::flags(const QModelIndex& index) const +{ + const int id = index.row(); + + Qt::ItemFlags result = QAbstractItemModel::flags(index); + + if (index.isValid()) { + const auto plugin = m_Plugins->getPlugin(id); + if (plugin && (!plugin->forceLoaded() && !plugin->forceDisabled())) { + if (index.column() == COL_PRIORITY) + result |= Qt::ItemIsEditable; + result |= Qt::ItemIsUserCheckable; + } + result |= Qt::ItemIsDragEnabled; + result &= ~Qt::ItemIsDropEnabled; + } else { + result |= Qt::ItemIsDropEnabled; + } + + return result; +} + +static QVariantList +conflictListData(const TESData::PluginList* pluginList, const TESData::FileInfo* plugin, + const QSet<int>& (TESData::FileInfo::*getConflicts)() const) +{ + if (!plugin || !plugin->enabled()) { + return QVariantList(); + } + + QVariantList list; + for (const int otherId : (plugin->*getConflicts)()) { + const auto other = pluginList->getPlugin(otherId); + if (other && other->enabled()) { + list.append(otherId); + } + } + return list; +} + +QVariant PluginListModel::data(const QModelIndex& index, int role) const +{ + switch (role) { + case Qt::DisplayRole: + case Qt::EditRole: + return displayData(index); + case Qt::CheckStateRole: + if (index.column() == 0) { + return checkstateData(index); + } + break; + case Qt::ForegroundRole: + return foregroundData(index); + case Qt::BackgroundRole: + return backgroundData(index); + case Qt::FontRole: + return fontData(index); + case Qt::TextAlignmentRole: + return alignmentData(index); + case Qt::ToolTipRole: + return tooltipData(index); + case GroupingRole: { + const auto id = index.row(); + const auto plugin = m_Plugins->getPlugin(id); + return plugin ? plugin->group() : QVariant(); + } + case IndexRole: + return index.row(); + case InfoRole: { + const auto id = index.row(); + const auto plugin = m_Plugins->getPlugin(id); + return QVariant::fromValue(plugin); + } + case ConflictsIconRole: + return conflictData(index); + case FlagsIconRole: + return iconData(index); + case OriginRole: { + const int id = index.row(); + return m_Plugins->getOriginName(id); + } + case OverridingRole: { + const int id = index.row(); + const auto plugin = m_Plugins->getPlugin(id); + return conflictListData(m_Plugins, plugin, &TESData::FileInfo::getPluginOverriding); + } + case OverriddenRole: { + const int id = index.row(); + const auto plugin = m_Plugins->getPlugin(id); + return conflictListData(m_Plugins, plugin, &TESData::FileInfo::getPluginOverridden); + } + case OverwritingAuxRole: { + const int id = index.row(); + const auto plugin = m_Plugins->getPlugin(id); + return conflictListData(m_Plugins, plugin, + &TESData::FileInfo::getPluginOverwritingArchive); + } + case OverwrittenAuxRole: { + const int id = index.row(); + const auto plugin = m_Plugins->getPlugin(id); + return conflictListData(m_Plugins, plugin, + &TESData::FileInfo::getPluginOverwrittenArchive); + } + } + return QVariant(); +} + +QVariant PluginListModel::displayData(const QModelIndex& index) const +{ + const int id = index.row(); + const auto plugin = m_Plugins->getPlugin(id); + + if (!plugin) { + return QVariant(); + } + + switch (index.column()) { + case COL_NAME: + return plugin->name(); + case COL_PRIORITY: + return plugin->priority(); + case COL_MODINDEX: + return plugin->index(); + case COL_FORMVERSION: + return plugin->formVersion() != 0 ? QString::number(plugin->formVersion()) + : QString(); + case COL_HEADERVERSION: + return QString::number(plugin->headerVersion()); + case COL_AUTHOR: + return plugin->author(); + case COL_DESCRIPTION: + return plugin->description(); + default: + return QVariant(); + } +} + +QVariant PluginListModel::checkstateData(const QModelIndex& index) const +{ + const int id = index.row(); + const auto plugin = m_Plugins->getPlugin(id); + + if (!plugin) { + return QVariant(); + } + + if (plugin->isAlwaysEnabled()) { + // HACK: PluginListStyledItemDelegate draws the checkbox separately + return QVariant(); + } else if (plugin->forceDisabled()) { + return QVariant(); + } else { + return plugin->enabled() ? Qt::Checked : Qt::Unchecked; + } +} + +QVariant PluginListModel::foregroundData(const QModelIndex& index) const +{ + const int id = index.row(); + const auto plugin = m_Plugins->getPlugin(id); + + if (!plugin) { + return QVariant(); + } + + if (plugin->hasNoRecords()) { + if (index.column() == COL_NAME) { + return QBrush(Qt::gray); + } + } + + if (plugin->forceDisabled()) { + if (index.column() == COL_NAME) { + return QBrush(Qt::darkRed); + } + } + + return QVariant(); +} + +QVariant +PluginListModel::backgroundData([[maybe_unused]] const QModelIndex& index) const +{ + return QVariant(); +} + +QVariant PluginListModel::fontData(const QModelIndex& index) const +{ + const int id = index.row(); + const auto plugin = m_Plugins->getPlugin(id); + + QFont result; + + if (index.column() == COL_NAME) { + if (plugin && plugin->hasNoRecords()) { + result.setItalic(true); + } + } + + return result; +} + +QVariant PluginListModel::alignmentData(const QModelIndex& index) const +{ + if (index.column() == 0) { + return QVariant(Qt::AlignLeft | Qt::AlignVCenter); + } else { + return QVariant(Qt::AlignHCenter | Qt::AlignVCenter); + } +} + +static QString truncateString(const QString& text, int length = 1024) +{ + QString new_text = text; + + if (new_text.length() > length) { + new_text.truncate(length); + new_text += "..."; + } + + return new_text; +} + +static QString makeLootTooltip(const MOTools::Loot::Plugin&) +{ + // LOOT integration removed for Fluorine port — tooltip never built. + return {}; +} + +QVariant PluginListModel::tooltipData(const QModelIndex& index) const +{ + const int id = index.row(); + const auto plugin = m_Plugins->getPlugin(id); + const auto lootInfo = m_Plugins->getLootReport(plugin->name()); + + if (!plugin) { + return QVariant(); + } + + switch (index.column()) { + case COL_NAME: { + QString toolTip; + + toolTip += "<b>" + tr("Origin") + "</b>: " + m_Plugins->getOriginName(id); + + if (plugin->forceLoaded()) { + toolTip += "<br><b><i>" + + tr("This plugin can't be disabled or moved (enforced by the game).") + + "</i></b>"; + } else if (plugin->forceEnabled()) { + toolTip += "<br><b><i>" + + tr("This plugin can't be disabled (enforced by the game).") + + "</i></b>"; + } + + if (plugin->formVersion() != 0) { + // Oblivion-style plugin headers don't have a form version + toolTip += "<br><b>" + tr("Form Version") + + "</b>: " + QString::number(plugin->formVersion()); + } + + toolTip += "<br><b>" + tr("Header Version") + + "</b>: " + QString::number(plugin->headerVersion()); + + if (!plugin->author().isEmpty()) { + toolTip += "<br><b>" + tr("Author") + "</b>: " + truncateString(plugin->author()); + } + + if (plugin->description().size() > 0) { + toolTip += "<br><b>" + tr("Description") + + "</b>: " + truncateString(plugin->description()); + } + + if (plugin->enabled() && plugin->missingMasters().size() > 0) { + toolTip += "<br><b>" + tr("Missing Masters") + "</b>: " + "<b>" + + truncateString(QStringList(plugin->missingMasters().begin(), + plugin->missingMasters().end()) + .join(", ")) + + "</b>"; + } + + QStringList enabledMasters; + std::ranges::remove_copy_if(plugin->masters(), std::back_inserter(enabledMasters), + [&](auto&& master) { + return plugin->missingMasters().contains(master); + }); + + if (!enabledMasters.empty()) { + toolTip += "<br><b>" + tr("Enabled Masters") + + "</b>: " + truncateString(enabledMasters.join(", ")); + } + + if (!plugin->archives().empty()) { + QString archiveString = + plugin->archives().size() < 6 + ? truncateString( + QStringList(plugin->archives().begin(), plugin->archives().end()) + .join(", ")) + : ""; + toolTip += "<br><b>" + tr("Loads Archives") + "</b>: " + archiveString; + } + + if (plugin->hasIni()) { + toolTip += "<br><b>" + tr("Loads INI settings") + + "</b>: " + QFileInfo(plugin->name()).baseName() + ".ini"; + } + + if (plugin->hasNoRecords()) { + toolTip += + "<br><br>" + tr("This is a dummy plugin. It contains no records and is " + "typically used to load a paired archive file."); + } + + return toolTip; + } + case COL_CONFLICTS: { + const uint conflictFlags = data(index, ConflictsIconRole).toUInt(); + using enum TESData::FileInfo::EConflictFlag; + + QString toolTip; + if ((conflictFlags & CONFLICT_MIXED) == CONFLICT_MIXED) { + toolTip += tr("Overrides & has overridden records"); + } else if (conflictFlags & CONFLICT_OVERRIDE) { + toolTip += tr("Overrides records"); + } else if (conflictFlags & CONFLICT_OVERRIDDEN) { + toolTip += tr("Has overridden records"); + } + + if ((conflictFlags & CONFLICT_MIXED) && (conflictFlags & CONFLICT_ARCHIVE_MIXED)) { + toolTip += "<br>"; + } + + if ((conflictFlags & CONFLICT_ARCHIVE_MIXED) == CONFLICT_ARCHIVE_MIXED) { + toolTip += tr("Overwrites & has overwritten archive files"); + } else if (conflictFlags & CONFLICT_ARCHIVE_OVERWRITE) { + toolTip += tr("Overwrites another archive file"); + } else if (conflictFlags & CONFLICT_ARCHIVE_OVERWRITTEN) { + toolTip += tr("Overwritten by another archive file"); + } + return toolTip; + } + case COL_FLAGS: { + // HACK: insert some HTML to enable multiline tooltips + QString toolTip = "<nobr/>"; + const QString spacing = "<br><br>"; + + if (plugin->enabled() && plugin->missingMasters().size() > 0) { + toolTip += "<b>" + tr("Missing Masters") + "</b>: " + "<b>" + + truncateString(QStringList(plugin->missingMasters().begin(), + plugin->missingMasters().end()) + .join(", ")) + + "</b>" + spacing; + } + + if (plugin->hasIni()) { + toolTip += + tr("There is an ini file connected to this plugin. Its settings will " + "be added to your game settings, overwriting in case of conflicts.") + + "<br><br>"; + } + + if (!plugin->archives().empty()) { + toolTip += + tr("There are Archives connected to this plugin. Their assets will be " + "added to your game, overwriting in case of conflicts following the " + "plugin order. Loose files will always overwrite assets from " + "Archives.") + + spacing; + } + + if (plugin->isMasterFile()) { + toolTip += tr("This file is flagged as a master plugin (ESM). It will load " + "before any non-ESM " + "files in the load order.") + + spacing; + } + + if (plugin->isSmallFile()) { + toolTip += + tr("This file is flagged as a light plugin (ESL). It will adhere to its " + "position in " + "the load order but the records will be loaded in ESL space (FE/FF). You " + "can have up to 4096 light plugins in addition to other plugin types.") + + spacing; + } else if (plugin->isMediumFile()) { + toolTip += tr("This file is flagged as a medium plugin (ESH). It will adhere to " + "its position in the load order but the records will be loaded in " + "ESH space (FD). You can have 256 medium plugins in addition to " + "other plugin types.") + + spacing; + } + + if (plugin->isBlueprintFile()) { + toolTip += tr("This plugin has the blueprint flag. This forces it to load after " + "every other non-blueprint plugin. Blueprint plugins will adhere " + "to standard load order rules with other blueprint plugins.") + + spacing; + } + + if (plugin->isLightFlagged() && plugin->isMediumFlagged()) { + toolTip += tr("WARNING: This plugin is both light and medium flagged. This could " + "indicate that the file was saved improperly and may have " + "mismatched record references. Use it at your own risk.") + + spacing; + } + + if (plugin->forceDisabled()) { + toolTip += tr("This game does not currently permit custom plugin " + "loading. There may be manual workarounds."); + } + + if (toolTip.endsWith(spacing)) { + toolTip.chop(spacing.length()); + } + + if (lootInfo) { + const auto lootToolTip = makeLootTooltip(*lootInfo); + if (toolTip.length() > 7 && !lootToolTip.isEmpty()) { + toolTip += "<hr>"; + } + toolTip += lootToolTip; + } + + return toolTip; + } + default: + return QVariant(); + } +} + +QVariant PluginListModel::conflictData(const QModelIndex& index) const +{ + const int id = index.row(); + const auto plugin = m_Plugins->getPlugin(id); + return plugin->conflictState(); +} + +static bool isProblematic(const TESData::FileInfo* plugin, + const MOTools::Loot::Plugin* lootInfo) +{ + if (plugin && plugin->enabled() && plugin->hasMissingMasters()) { + return true; + } + + if (lootInfo && Settings::instance()->lootShowProblems()) { + if (!lootInfo->incompatibilities.empty()) { + return true; + } + + if (!lootInfo->missingMasters.empty()) { + return true; + } + } + + return false; +} + +QVariant PluginListModel::iconData(const QModelIndex& index) const +{ + const int id = index.row(); + const auto plugin = m_Plugins->getPlugin(id); + const auto lootInfo = m_Plugins->getLootReport(plugin->name()); + + if (!plugin) { + return QVariant(); + } + + using enum TESData::FileInfo::EFlag; + uint flag = 0; + + if (isProblematic(plugin, lootInfo)) { + flag |= FLAG_PROBLEMATIC; + } + + if (lootInfo && !lootInfo->messages.empty() && + Settings::instance()->lootShowMessages()) { + flag |= FLAG_INFORMATION; + } + + if (plugin->hasIni()) { + flag |= FLAG_INI; + } + + if (!plugin->archives().empty()) { + flag |= FLAG_BSA; + } + + if (plugin->isMasterFile()) { + flag |= FLAG_MASTER; + } + + if (plugin->isMediumFile()) { + flag |= FLAG_MEDIUM; + } + + if (plugin->isSmallFile()) { + flag |= FLAG_LIGHT; + } + + if (plugin->isBlueprintFile()) { + flag |= FLAG_BLUEPRINT; + } + + if (lootInfo && !lootInfo->dirty.empty() && Settings::instance()->lootShowDirty()) { + flag |= FLAG_CLEAN; + } + + return flag; +} + +QVariant PluginListModel::headerData(int section, Qt::Orientation orientation, + int role) const +{ + if (orientation == Qt::Horizontal) { + if (role == Qt::DisplayRole) { + switch (section) { + case COL_NAME: + return tr("Name"); + case COL_CONFLICTS: + return tr("Conflicts"); + case COL_FLAGS: + return tr("Flags"); + case COL_PRIORITY: + return tr("Priority"); + case COL_MODINDEX: + return tr("Mod Index"); + case COL_FORMVERSION: + return tr("Form Version"); + case COL_HEADERVERSION: + return tr("Header Version"); + case COL_AUTHOR: + return tr("Author"); + case COL_DESCRIPTION: + return tr("Description"); + default: + return tr("unknown"); + } + } + } + return QAbstractItemModel::headerData(section, orientation, role); +} + +int PluginListModel::rowCount([[maybe_unused]] const QModelIndex& parent) const +{ + return m_Plugins->pluginCount(); +} + +int PluginListModel::columnCount([[maybe_unused]] const QModelIndex& parent) const +{ + return COL_COUNT; +} + +bool PluginListModel::setData(const QModelIndex& index, const QVariant& value, int role) +{ + if (role == Qt::CheckStateRole) { + const int id = index.row(); + m_Plugins->setEnabled(id, value.toInt() == Qt::Checked); + emit dataChanged(this->index(0, 0), this->index(rowCount() - 1, COL_MODINDEX), + {Qt::EditRole, Qt::CheckStateRole}); + emit pluginStatesChanged({index}); + return true; + } else if (role == Qt::EditRole) { + if (index.column() == COL_PRIORITY) { + bool ok; + const int newPriority = value.toInt(&ok); + if (ok) { + int destination = newPriority; + if (newPriority > index.data(Qt::EditRole).toInt()) { + ++destination; + } + m_Plugins->moveToPriority({index.row()}, destination); + emit dataChanged(this->index(0, 0), + this->index(rowCount() - 1, columnCount() - 1), + {Qt::EditRole, GroupingRole}); + emit pluginOrderChanged(); + return true; + } + } + } + + return false; +} + +Qt::DropActions PluginListModel::supportedDropActions() const +{ + return Qt::MoveAction; +} + +bool PluginListModel::canDropMimeData(const QMimeData* data, Qt::DropAction action, + int row, [[maybe_unused]] int column, + const QModelIndex& parent) const +{ + if (action == Qt::IgnoreAction) { + return true; + } + + if (action != Qt::MoveAction) { + return false; + } + + PluginListDropInfo dropInfo{data, row, parent, m_Plugins}; + return m_Plugins->canMoveToPriority(dropInfo.sourceRows(), dropInfo.destination()); +} + +bool PluginListModel::dropMimeData(const QMimeData* data, Qt::DropAction action, + int row, [[maybe_unused]] int column, + const QModelIndex& parent) +{ + if (action == Qt::IgnoreAction) { + return true; + } + + if (action != Qt::MoveAction) { + return false; + } + + PluginListDropInfo dropInfo{data, row, parent, m_Plugins}; + m_Plugins->moveToPriority(dropInfo.sourceRows(), dropInfo.destination()); + emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1), + {Qt::DisplayRole, GroupingRole}); + emit pluginOrderChanged(); + + return true; +} + +QStringList +PluginListModel::groups(std::function<bool(const TESData::FileInfo*)> pred) const +{ + boost::container::flat_set<QString> groupSet; + QStringList groups; + QString lastGroup; + + for (int priority = 0, count = m_Plugins->pluginCount(); priority < count; + ++priority) { + const auto plugin = m_Plugins->getPluginByPriority(priority); + + if (pred && !pred(plugin)) { + continue; + } + + const auto& group = plugin ? plugin->group() : QString(); + if (group.isEmpty() || group == lastGroup) { + continue; + } + + auto [it, inserted] = groupSet.insert(group); + if (inserted) { + groups.append(group); + } + lastGroup = group; + } + + return groups; +} + +QStringList PluginListModel::masterGroups() const +{ + return groups([](auto&& plugin) { + return !plugin->forceLoaded() && plugin->isMasterFile(); + }); +} + +QStringList PluginListModel::regularGroups() const +{ + return groups([](auto&& plugin) { + return !plugin->forceLoaded() && !plugin->isMasterFile(); + }); +} + +void PluginListModel::refresh() +{ + emit beginResetModel(); + m_Plugins->refresh(); + emit endResetModel(); +} + +void PluginListModel::invalidate() +{ + emit beginResetModel(); + m_Plugins->refresh(true); + emit endResetModel(); +} + +void PluginListModel::invalidateConflicts() +{ + for (int i = 0, count = m_Plugins->pluginCount(); i < count; ++i) { + const auto plugin = m_Plugins->getPlugin(i); + plugin->invalidateConflicts(); + } + + emit dataChanged(index(0, COL_CONFLICTS), index(rowCount() - 1, COL_CONFLICTS), + {PluginListModel::ConflictsIconRole}); +} + +void PluginListModel::movePlugin(const QString& name, [[maybe_unused]] int oldPriority, + int newPriority) +{ + m_Plugins->setPriority(name, newPriority); + emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1), + {Qt::DisplayRole, GroupingRole}); + emit pluginOrderChanged(); +} + +void PluginListModel::changePluginStates( + const std::map<QString, MOBase::IPluginList::PluginStates>& infos) +{ + QModelIndexList indices; + for (auto& [name, state] : infos) { + m_Plugins->setState(name, state); + + const auto idx = m_Plugins->getIndex(name); + if (idx != -1) { + indices.append(index(idx, 0)); + } + } + + emit dataChanged(index(0, 0), index(rowCount() - 1, COL_MODINDEX), + {Qt::DisplayRole, Qt::CheckStateRole}); + emit pluginStatesChanged(indices); +} + +void PluginListModel::setEnabledAll(bool enabled) +{ + QModelIndexList indices; + indices.reserve(rowCount()); + std::generate_n(std::back_inserter(indices), rowCount(), [this, i = 0]() mutable { + return index(i++, 0); + }); + setEnabled(indices, enabled); +} + +void PluginListModel::setEnabled(const QModelIndexList& indices, bool enabled) +{ + if (indices.empty()) { + return; + } + + std::vector<int> ids; + ids.reserve(indices.size()); + std::ranges::transform(indices, std::back_inserter(ids), [](const QModelIndex& idx) { + return idx.row(); + }); + m_Plugins->setEnabled(std::move(ids), enabled); + emit pluginStatesChanged(indices); +} + +void PluginListModel::sendToPriority(const QModelIndexList& indices, int priority, + bool disjoint) +{ + if (indices.empty()) { + return; + } + + std::vector<int> ids; + ids.reserve(indices.size()); + std::ranges::transform(indices, std::back_inserter(ids), [](const QModelIndex& idx) { + return idx.row(); + }); + m_Plugins->moveToPriority(std::move(ids), priority, disjoint); + emit dataChanged(index(0, COL_PRIORITY), index(rowCount() - 1, COL_MODINDEX), + {Qt::DisplayRole}); + emit pluginOrderChanged(); +} + +void PluginListModel::shiftPluginsPriority(const QModelIndexList& indices, int offset) +{ + if (indices.empty()) { + return; + } + + std::vector<int> ids; + ids.reserve(indices.size()); + std::ranges::transform(indices, std::back_inserter(ids), [](const QModelIndex& idx) { + return idx.row(); + }); + m_Plugins->shiftPriority(std::move(ids), offset); + emit dataChanged(index(0, COL_PRIORITY), index(rowCount() - 1, COL_MODINDEX), + {Qt::DisplayRole}); + emit pluginOrderChanged(); +} + +void PluginListModel::toggleState(const QModelIndexList& indices) +{ + if (indices.empty()) { + return; + } + + std::vector<int> ids; + ids.reserve(indices.size()); + std::ranges::transform(indices, std::back_inserter(ids), [](const QModelIndex& idx) { + return idx.row(); + }); + m_Plugins->toggleState(std::move(ids)); + emit pluginStatesChanged(indices); +} + +void PluginListModel::setGroup(const QModelIndexList& indices, const QString& group) +{ + if (indices.empty()) { + return; + } + + std::vector<int> ids; + ids.reserve(indices.size()); + std::ranges::transform(indices, std::back_inserter(ids), [](const QModelIndex& idx) { + return idx.row(); + }); + m_Plugins->setGroup(std::move(ids), group); + emit dataChanged(this->index(0, 0), this->index(rowCount() - 1, COL_MODINDEX), + {GroupingRole}); +} + +void PluginListModel::sendToGroup(const QModelIndexList& indices, const QString& group, + bool isESM) +{ + int destination = -1; + for (int priority = 0, count = m_Plugins->pluginCount(); priority < count; + ++priority) { + const auto plugin = m_Plugins->getPluginByPriority(priority); + if (plugin && plugin->isMasterFile() == isESM && plugin->group() == group) { + destination = priority + 1; + } + } + + if (destination == -1) + return; + + std::vector<int> ids; + ids.reserve(indices.size()); + std::ranges::transform(indices, std::back_inserter(ids), [](const QModelIndex& idx) { + return idx.row(); + }); + m_Plugins->setGroup(ids, group); + m_Plugins->moveToPriority(std::move(ids), destination); + emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1), + {Qt::DisplayRole, GroupingRole}); + emit pluginOrderChanged(); +} + +} // namespace BSPluginList diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListModel.h b/libs/installer_bsplugins/src/BSPluginList/PluginListModel.h new file mode 100644 index 0000000..d0de9c6 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/PluginListModel.h @@ -0,0 +1,137 @@ +#ifndef BSPLUGINLIST_PLUGINLISTMODEL_H +#define BSPLUGINLIST_PLUGINLISTMODEL_H + +#include "TESData/PluginList.h" + +#include <QAbstractItemModel> + +#include <functional> + +namespace BSPluginList +{ + +class PluginListModel final : public QAbstractItemModel +{ + Q_OBJECT + +public: + friend class PluginGroupProxyModel; + + enum ItemDataRole + { + GroupingRole = Qt::UserRole, + IndexRole, + InfoRole, + ConflictsIconRole, + FlagsIconRole, + OriginRole, + OverridingRole, + OverriddenRole, + OverwritingAuxRole, + OverwrittenAuxRole, + ScrollMarkRole, + }; + + enum EColumn + { + COL_NAME, + COL_CONFLICTS, + COL_FLAGS, + COL_PRIORITY, + COL_MODINDEX, + COL_FORMVERSION, + COL_HEADERVERSION, + COL_AUTHOR, + COL_DESCRIPTION, + + COL_COUNT + }; + + explicit PluginListModel(TESData::PluginList* plugins); + + QModelIndex index(int row, int column, + const QModelIndex& parent = QModelIndex()) const override; + QModelIndex parent(const QModelIndex& index) const override; + + Qt::ItemFlags flags(const QModelIndex& index) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QVariant headerData(int section, Qt::Orientation orientation, + int role) const override; + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + int columnCount(const QModelIndex& parent = QModelIndex()) const override; + + bool setData(const QModelIndex& index, const QVariant& value, + int role = Qt::EditRole) override; + + Qt::DropActions supportedDropActions() const override; + bool canDropMimeData(const QMimeData* data, Qt::DropAction action, int row, + int column, const QModelIndex& parent) const override; + bool dropMimeData(const QMimeData* data, Qt::DropAction action, int row, int column, + const QModelIndex& parent) override; + + [[nodiscard]] QStringList + groups(std::function<bool(const TESData::FileInfo*)> pred = {}) const; + [[nodiscard]] QStringList masterGroups() const; + [[nodiscard]] QStringList regularGroups() const; + +public slots: + void refresh(); + + void invalidate(); + void invalidateConflicts(); + + void movePlugin(const QString& name, int oldPriority, int newPriority); + + void + changePluginStates(const std::map<QString, MOBase::IPluginList::PluginStates>& infos); + + // enable/disable all plugins + // + void setEnabledAll(bool enabled); + + // enable/disable plugins at the given indices. + // + void setEnabled(const QModelIndexList& indices, bool enabled); + + // send plugins to the given priority + // + void sendToPriority(const QModelIndexList& indices, int priority, + bool disjoint = false); + + // shift the priority of mods at the given indices by the given offset + // + void shiftPluginsPriority(const QModelIndexList& indices, int offset); + + // toggle the active state of mods at the given indices + // + void toggleState(const QModelIndexList& indices); + + // assign plugins to a group + // + void setGroup(const QModelIndexList& indices, const QString& group); + + // send plugins to the bottom of a group + // + void sendToGroup(const QModelIndexList& indices, const QString& group, bool isESM); + +signals: + void pluginStatesChanged(const QModelIndexList& indices) const; + void pluginOrderChanged() const; + +private: + [[nodiscard]] QVariant displayData(const QModelIndex& index) const; + [[nodiscard]] QVariant checkstateData(const QModelIndex& index) const; + [[nodiscard]] QVariant foregroundData(const QModelIndex& index) const; + [[nodiscard]] QVariant backgroundData(const QModelIndex& index) const; + [[nodiscard]] QVariant fontData(const QModelIndex& index) const; + [[nodiscard]] QVariant alignmentData(const QModelIndex& index) const; + [[nodiscard]] QVariant tooltipData(const QModelIndex& index) const; + [[nodiscard]] QVariant conflictData(const QModelIndex& index) const; + [[nodiscard]] QVariant iconData(const QModelIndex& index) const; + + TESData::PluginList* m_Plugins; +}; + +} // namespace BSPluginList + +#endif // BSPLUGINLIST_PLUGINLISTMODEL_H diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListStyledItemDelegate.cpp b/libs/installer_bsplugins/src/BSPluginList/PluginListStyledItemDelegate.cpp new file mode 100644 index 0000000..141ef17 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/PluginListStyledItemDelegate.cpp @@ -0,0 +1,119 @@ +#include "PluginListStyledItemDelegate.h" +#include "PluginListModel.h" +#include "PluginListView.h" + +#include <QApplication> + +namespace BSPluginList +{ + +PluginListStyledItemDelegate::PluginListStyledItemDelegate(PluginListView* view) + : QStyledItemDelegate(view), m_View{view} +{} + +void PluginListStyledItemDelegate::paint(QPainter* painter, + const QStyleOptionViewItem& option, + const QModelIndex& index) const +{ + QStyleOptionViewItem opt(option); + + if (index.column() == 0) { + if (!index.model()->hasChildren(index) || !index.data().isValid()) { + opt.rect.adjust(-m_View->indentation(), 0, 0, 0); + } + } + + const auto color = m_View->markerColor(index); + opt.backgroundBrush = color; + + const auto widget = opt.widget; + const auto style = widget ? widget->style() : QApplication::style(); + + if (!index.siblingAtColumn(0).data().isValid()) { + QStyleOptionFrame optFrame; + optFrame.initFrom(widget); + optFrame.rect = opt.rect; + optFrame.frameShape = QFrame::HLine; + optFrame.lineWidth = 0; + optFrame.midLineWidth = 1; + optFrame.state |= QStyle::State_Sunken; + + QApplication::style()->drawControl(QStyle::CE_ShapedFrame, &optFrame, painter, + widget); + return; + } + + const auto plugin = + index.data(PluginListModel::InfoRole).value<const TESData::FileInfo*>(); + + bool disabledText = false; + switch (index.column()) { + case PluginListModel::COL_NAME: + disabledText = plugin && plugin->isAlwaysEnabled(); + break; + case PluginListModel::COL_PRIORITY: + case PluginListModel::COL_MODINDEX: + disabledText = plugin && plugin->forceLoaded(); + break; + } + + if (disabledText) { + opt.palette.setBrush(QPalette::Text, + opt.palette.brush(QPalette::Disabled, QPalette::Text)); + } + + // HACK: we can't normally have a disabled checkbox on a selectable row, so create a + // margin for it and then draw one manually + bool drawCheck = false; + bool enabled = false; + if (index.column() == 0 && !index.data(Qt::CheckStateRole).isValid()) { + if (plugin) { + drawCheck = true; + if (plugin->forceLoaded() || plugin->forceEnabled()) { + enabled = true; + } + } + + // set decoration size to use as text margin + opt.decorationSize.setWidth( + style->pixelMetric(QStyle::PM_IndicatorWidth, &opt, widget)); + } + + QStyledItemDelegate::paint(painter, opt, index); + + // draw check on top + if (drawCheck) { + opt.features |= QStyleOptionViewItem::HasCheckIndicator; + + QRect checkRect = + style->subElementRect(QStyle::SE_ItemViewItemCheckIndicator, &opt, widget); + opt.rect = checkRect; + + opt.state &= ~QStyle::State_Enabled; + if (enabled) { + opt.state |= QStyle::State_On; + } + + style->drawPrimitive(QStyle::PE_IndicatorItemViewItemCheck, &opt, painter, widget); + } +} + +void PluginListStyledItemDelegate::initStyleOption(QStyleOptionViewItem* option, + const QModelIndex& index) const +{ + const auto backgroundColor = option->backgroundBrush.color(); + QStyledItemDelegate::initStyleOption(option, index); + + // HACK: create a text margin where the checkbox should be + if (index.column() == 0 && !index.data(Qt::CheckStateRole).isValid()) { + if (index.data(PluginListModel::InfoRole).isValid()) { + option->features |= QStyleOptionViewItem::HasDecoration; + } + } + + if (backgroundColor.isValid()) { + option->backgroundBrush = backgroundColor; + } +} + +} // namespace BSPluginList diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListStyledItemDelegate.h b/libs/installer_bsplugins/src/BSPluginList/PluginListStyledItemDelegate.h new file mode 100644 index 0000000..9ef2b81 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/PluginListStyledItemDelegate.h @@ -0,0 +1,31 @@ +#ifndef BSPLUGINLIST_PLUGINLISTSTYLEDITEMDELEGATE_H +#define BSPLUGINLIST_PLUGINLISTSTYLEDITEMDELEGATE_H + +#include <QStyledItemDelegate> + +namespace BSPluginList +{ + +class PluginListView; + +class PluginListStyledItemDelegate final : public QStyledItemDelegate +{ + Q_OBJECT + +public: + explicit PluginListStyledItemDelegate(PluginListView* view); + + void paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const override; + +protected: + void initStyleOption(QStyleOptionViewItem* option, + const QModelIndex& index) const override; + +private: + PluginListView* m_View; +}; + +} // namespace BSPluginList + +#endif // BSPLUGINLIST_PLUGINLISTSTYLEDITEMDELEGATE_H diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListView.cpp b/libs/installer_bsplugins/src/BSPluginList/PluginListView.cpp new file mode 100644 index 0000000..077adf9 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/PluginListView.cpp @@ -0,0 +1,395 @@ +#include "PluginListView.h" + +#include "ConflictIconDelegate.h" +#include "FlagIconDelegate.h" +#include "GUI/CopyEventFilter.h" +#include "MOPlugin/Settings.h" +#include "PluginGroupProxyModel.h" +#include "PluginListModel.h" +#include "PluginListStyledItemDelegate.h" +#include "PluginListViewMarkingScrollBar.h" +#include "PluginSortFilterProxyModel.h" +#include "TESData/PluginList.h" + +#include <widgetutility.h> + +#include <QHeaderView> +#include <QSortFilterProxyModel> + +#include <stdexcept> + +namespace BSPluginList +{ + +PluginListView::PluginListView(QWidget* parent) : QTreeView(parent) +{ + setVerticalScrollBar(new PluginListViewMarkingScrollBar(this)); + MOBase::setCustomizableColumns(this); + setItemDelegate(new PluginListStyledItemDelegate(this)); + installEventFilter(new GUI::CopyEventFilter(this)); +} + +void PluginListView::setup() +{ + setItemDelegateForColumn(PluginListModel::COL_CONFLICTS, + new ConflictIconDelegate(this)); + setItemDelegateForColumn(PluginListModel::COL_FLAGS, new FlagIconDelegate(this)); + + header()->resizeSection(PluginListModel::COL_NAME, 332); + header()->resizeSection(PluginListModel::COL_CONFLICTS, 71); + header()->resizeSection(PluginListModel::COL_FLAGS, 60); + header()->resizeSection(PluginListModel::COL_PRIORITY, 62); + header()->resizeSection(PluginListModel::COL_MODINDEX, 79); + header()->setSectionResizeMode(0, QHeaderView::Stretch); + + connect(this, &QTreeView::collapsed, this, &PluginListView::updateOverwriteMarkers); + connect(this, &QTreeView::expanded, this, &PluginListView::updateOverwriteMarkers); + connect(selectionModel(), &QItemSelectionModel::selectionChanged, this, + &PluginListView::updateOverwriteMarkers); +} + +void PluginListView::setModel(QAbstractItemModel* model) +{ + for (auto nextModel = model; nextModel;) { + const auto proxyModel = qobject_cast<QAbstractProxyModel*>(nextModel); + if (proxyModel) { + if (const auto groupProxy = qobject_cast<PluginGroupProxyModel*>(proxyModel)) { + connect(groupProxy, &PluginGroupProxyModel::groupRenameRequested, this, + &PluginListView::onGroupRenameRequested); + } else if (const auto sortProxy = + qobject_cast<PluginSortFilterProxyModel*>(proxyModel)) { + m_SortProxy = sortProxy; + } + nextModel = proxyModel->sourceModel(); + } else { + if (const auto pluginModel = qobject_cast<PluginListModel*>(nextModel)) { + m_PluginModel = pluginModel; + } else { + throw std::logic_error("PluginListView's model should be a PluginListModel"); + } + nextModel = nullptr; + } + } + + QTreeView::setModel(model); +} + +QRect PluginListView::visualRect(const QModelIndex& index) const +{ + QRect rect = QTreeView::visualRect(index); + if (index.column() == 0) { + if (index.isValid() && !index.model()->hasChildren(index) || + !index.data().isValid()) { + rect.adjust(-indentation(), 0, 0, 0); + } + } + return rect; +} + +QColor PluginListView::markerColor(const QModelIndex& index) const +{ + bool ok; + const uint pluginIndex = index.data(PluginListModel::IndexRole).toUInt(&ok); + if (ok) { + const bool highlight = m_Markers.highlight.contains(pluginIndex); + const bool overriding = m_Markers.overriding.contains(pluginIndex); + const bool overridden = m_Markers.overridden.contains(pluginIndex); + const bool overwritingAux = m_Markers.overwritingAux.contains(pluginIndex); + const bool overwrittenAux = m_Markers.overwrittenAux.contains(pluginIndex); + + // the color logic looks backwards but this is what the mod list does + if (highlight) { + return Settings::instance()->containedColor(); + } else if (overridden) { + return Settings::instance()->overwritingLooseFilesColor(); + } else if (overriding) { + return Settings::instance()->overwrittenLooseFilesColor(); + } else if (overwrittenAux) { + return Settings::instance()->overwritingArchiveFilesColor(); + } else if (overwritingAux) { + return Settings::instance()->overwrittenArchiveFilesColor(); + } + } + + const auto rowIndex = index.siblingAtColumn(0); + if (model()->hasChildren(rowIndex) && !isExpanded(rowIndex)) { + std::vector<QColor> colors; + for (int i = 0; i < model()->rowCount(rowIndex); ++i) { + const auto childIndex = model()->index(i, index.column(), rowIndex); + const auto childColor = markerColor(childIndex); + if (childColor.isValid()) { + colors.push_back(childColor); + } + } + + if (colors.empty()) { + return QColor(); + } + + int r = 0, g = 0, b = 0, a = 0; + for (const auto& color : colors) { + r += color.red(); + g += color.green(); + b += color.blue(); + a += color.alpha(); + } + + return QColor( + static_cast<int>(r / colors.size()), static_cast<int>(g / colors.size()), + static_cast<int>(b / colors.size()), static_cast<int>(a / colors.size())); + } + + return QColor(); +} + +uint PluginListView::fileFlags(const QModelIndex& index) const +{ + uint flags = index.data(PluginListModel::FlagsIconRole).toUInt(); + + if (model()->hasChildren(index) && !isExpanded(index.siblingAtColumn(0))) { + for (int i = 0, count = model()->rowCount(index); i < count; ++i) { + const auto child = model()->index(i, 0, index); + flags |= child.data(PluginListModel::FlagsIconRole).toUInt(); + } + } + + return flags; +} + +uint PluginListView::conflictFlags(const QModelIndex& index) const +{ + uint flags = index.data(PluginListModel::ConflictsIconRole).toUInt(); + + if (model()->hasChildren(index) && !isExpanded(index.siblingAtColumn(0))) { + for (int i = 0, count = model()->rowCount(index); i < count; ++i) { + const auto child = model()->index(i, 0, index); + flags |= child.data(PluginListModel::ConflictsIconRole).toUInt(); + } + } + + return flags; +} + +static void visitRows(const QAbstractItemModel* model, + std::function<void(const QModelIndex&)> visit, + const QModelIndex& root = QModelIndex()) +{ + if (root.isValid()) { + visit(root); + } + + for (int i = 0, count = model->rowCount(root); i < count; ++i) { + const auto idx = model->index(i, 0, root); + visitRows(model, visit, idx); + } +} + +void PluginListView::setHighlightedOrigins(const QStringList& origins) +{ + m_Markers.highlight.clear(); + visitRows(model(), [this, &origins](auto&& idx) { + const auto origin = idx.data(PluginListModel::OriginRole).toString(); + if (origins.contains(origin)) { + m_Markers.highlight.insert(idx.data(PluginListModel::IndexRole).toUInt()); + } + }); + + viewport()->update(); + verticalScrollBar()->repaint(); +} + +void PluginListView::clearOverwriteMarkers() +{ + m_Markers.overriding.clear(); + m_Markers.overridden.clear(); + m_Markers.overwritingAux.clear(); + m_Markers.overwrittenAux.clear(); +} + +void PluginListView::updateOverwriteMarkers() +{ + QModelIndexList indexes = selectionModel()->selectedRows(); + for (const auto& idx : selectionModel()->selectedRows()) { + if (model()->hasChildren(idx) && !isExpanded(idx)) { + for (int i = 0, count = model()->rowCount(idx); i < count; ++i) { + indexes.append(model()->index(i, idx.column(), idx)); + } + } + } + + const auto insert = [](auto& dest, const auto& from) { + for (const QVariant& elem : from) { + dest.insert(elem.toUInt()); + } + }; + + clearOverwriteMarkers(); + for (const auto& idx : indexes) { + insert(m_Markers.overriding, + model()->data(idx, PluginListModel::OverridingRole).toList()); + insert(m_Markers.overridden, + model()->data(idx, PluginListModel::OverriddenRole).toList()); + insert(m_Markers.overwritingAux, + model()->data(idx, PluginListModel::OverwritingAuxRole).toList()); + insert(m_Markers.overwrittenAux, + model()->data(idx, PluginListModel::OverwrittenAuxRole).toList()); + } + + viewport()->update(); + verticalScrollBar()->repaint(); +} + +bool PluginListView::event(QEvent* event) +{ + if (event->type() == QEvent::KeyPress) { + QKeyEvent* keyEvent = static_cast<QKeyEvent*>(event); + + if (keyEvent->modifiers() == Qt::ControlModifier && + (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter)) { + + if (selectionModel()->hasSelection() && + selectionModel()->selectedRows().count() == 1) { + + QModelIndex idx = selectionModel()->currentIndex(); + emit openOriginExplorer(idx); + return true; + } + } + + bool sorted = false; + if (const auto proxy = m_SortProxy) { + sorted = proxy->sortColumn() == PluginListModel::COL_PRIORITY; + } + + if (sorted && keyEvent->modifiers() == Qt::ControlModifier && + (keyEvent->key() == Qt::Key_Up || keyEvent->key() == Qt::Key_Down)) { + return moveSelection(keyEvent->key()); + } else if (keyEvent->key() == Qt::Key_Space) { + return toggleSelectionState(); + } + } + + return QTreeView::event(event); +} + +void PluginListView::dragMoveEvent(QDragMoveEvent* event) +{ + // HACK: dropping below an expanded item sends the same event as dropping below its + // children, so set an additional flag to signal this + if (const auto m = qobject_cast<PluginGroupProxyModel*>(model())) { + m->setDroppingBelowExpandedItem(dropIndicatorPosition() == + QAbstractItemView::BelowItem && + isExpanded(indexAt(event->position().toPoint()))); + } + + QTreeView::dragMoveEvent(event); +} + +void PluginListView::paintEvent(QPaintEvent* event) +{ + if (m_FirstPaint) { + header()->setSectionResizeMode(0, QHeaderView::Interactive); + header()->setStretchLastSection(true); + m_FirstPaint = false; + } + + QTreeView::paintEvent(event); +} + +bool PluginListView::moveSelection(int key) +{ + if (m_PluginModel == nullptr) { + return false; + } + + const auto sourceRows = + indexViewToModel(selectionModel()->selectedRows(), m_PluginModel, true); + + const auto sortOrder = m_SortProxy ? m_SortProxy->sortOrder() : Qt::DescendingOrder; + const int offset = key == Qt::Key_Up && sortOrder == Qt::AscendingOrder ? -1 : 1; + + m_PluginModel->shiftPluginsPriority(sourceRows, offset); + + return true; +} + +bool PluginListView::toggleSelectionState() +{ + if (m_PluginModel == nullptr) { + return false; + } + + const auto sourceRows = + indexViewToModel(selectionModel()->selectedRows(), m_PluginModel, false); + + m_PluginModel->toggleState(sourceRows); + + return true; +} + +QModelIndex PluginListView::indexViewToModel(const QModelIndex& index, + const QAbstractItemModel* model) const +{ + if (index.model() == model) { + return index; + } else if (const auto* const proxy = + qobject_cast<const QAbstractProxyModel*>(index.model())) { + return indexViewToModel(proxy->mapToSource(index), model); + } else { + return QModelIndex(); + } +} + +QModelIndexList PluginListView::indexViewToModel(const QModelIndexList& indices, + const QAbstractItemModel* model, + bool includeChildren) const +{ + QModelIndexList result; + + for (const auto& idx : indices) { + const auto modelIdx = indexViewToModel(idx, model); + if (modelIdx.isValid()) { + result.append(modelIdx); + } + + if (includeChildren) { + for (int row = 0, count = idx.model()->rowCount(idx); row < count; ++row) { + const auto childIdx = idx.model()->index(row, 0, idx); + const auto modelChildIdx = indexViewToModel(childIdx, model); + if (modelChildIdx.isValid()) { + result.append(modelChildIdx); + } + } + } + } + + return result; +} + +void PluginListView::onGroupRenameRequested(const QModelIndex& index, + const QString& name) +{ + if (!index.model()->hasChildren(index)) { + return; + } + + QModelIndexList sourceRows; + for (int row = 0, count = index.model()->rowCount(index); row < count; ++row) { + const auto childIndex = index.model()->index(row, 0, index); + sourceRows.append(indexViewToModel(childIndex, m_PluginModel)); + } + + const auto persistentIndex = QPersistentModelIndex(index.model()->index(0, 0, index)); + const bool expanded = isExpanded(index); + + m_PluginModel->setGroup(sourceRows, name); + + const auto newIndex = persistentIndex.parent(); + const auto newRight = newIndex.siblingAtColumn(index.model()->columnCount() - 1); + setExpanded(newIndex, expanded); + selectionModel()->select(QItemSelection(newIndex, newRight), + QItemSelectionModel::ClearAndSelect); + selectionModel()->setCurrentIndex(newIndex, QItemSelectionModel::Current); +} + +} // namespace BSPluginList diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListView.h b/libs/installer_bsplugins/src/BSPluginList/PluginListView.h new file mode 100644 index 0000000..5302b86 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/PluginListView.h @@ -0,0 +1,76 @@ +#ifndef BSPLUGINLIST_PLUGINLISTVIEW_H +#define BSPLUGINLIST_PLUGINLISTVIEW_H + +#include <QSet> +#include <QTreeView> + +namespace BSPluginList +{ + +struct MarkerInfos +{ + QSet<uint> overriding; + QSet<uint> overridden; + QSet<uint> overwritingAux; + QSet<uint> overwrittenAux; + QSet<uint> highlight; +}; + +class PluginListModel; +class PluginSortFilterProxyModel; + +class PluginListView final : public QTreeView +{ + Q_OBJECT + +public: + explicit PluginListView(QWidget* parent = nullptr); + + void setup(); + + void setModel(QAbstractItemModel* model) override; + QRect visualRect(const QModelIndex& index) const override; + + [[nodiscard]] QColor markerColor(const QModelIndex& index) const; + [[nodiscard]] uint fileFlags(const QModelIndex& index) const; + [[nodiscard]] uint conflictFlags(const QModelIndex& index) const; + +public slots: + void setHighlightedOrigins(const QStringList& origins); + void clearOverwriteMarkers(); + void updateOverwriteMarkers(); + +signals: + void openOriginExplorer(const QModelIndex& index); + +protected: + bool event(QEvent* event) override; + void dragMoveEvent(QDragMoveEvent* event) override; + void paintEvent(QPaintEvent* event) override; + + bool moveSelection(int key); + bool toggleSelectionState(); + +private: + friend class PluginListContextMenu; + + QModelIndex indexViewToModel(const QModelIndex& index, + const QAbstractItemModel* model) const; + + QModelIndexList indexViewToModel(const QModelIndexList& indices, + const QAbstractItemModel* model, + bool includeChildren = true) const; + +private slots: + void onGroupRenameRequested(const QModelIndex& index, const QString& name); + +private: + bool m_FirstPaint = true; + MarkerInfos m_Markers; + PluginListModel* m_PluginModel; + PluginSortFilterProxyModel* m_SortProxy; +}; + +} // namespace BSPluginList + +#endif // BSPLUGINLIST_PLUGINLISTVIEW_H diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListViewMarkingScrollBar.h b/libs/installer_bsplugins/src/BSPluginList/PluginListViewMarkingScrollBar.h new file mode 100644 index 0000000..f6359f0 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/PluginListViewMarkingScrollBar.h @@ -0,0 +1,29 @@ +#ifndef BSPLUGINLIST_PLUGINLISTVIEWMARKINGSCROLLBAR_H +#define BSPLUGINLIST_PLUGINLISTVIEWMARKINGSCROLLBAR_H + +#include "GUI/ViewMarkingScrollBar.h" +#include "PluginListView.h" + +namespace BSPluginList +{ + +class PluginListViewMarkingScrollBar : public GUI::ViewMarkingScrollBar +{ +public: + explicit PluginListViewMarkingScrollBar(PluginListView* view) + : GUI::ViewMarkingScrollBar(view, PluginListModel::ScrollMarkRole) + {} + + [[nodiscard]] QColor color(const QModelIndex& index) const override + { + auto color = static_cast<PluginListView*>(m_View)->markerColor(index); + if (!color.isValid()) { + color = ViewMarkingScrollBar::color(index); + } + return color; + } +}; + +} // namespace BSPluginList + +#endif // BSPLUGINLIST_PLUGINLISTVIEWMARKINGSCROLLBAR_H diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginSortFilterProxyModel.cpp b/libs/installer_bsplugins/src/BSPluginList/PluginSortFilterProxyModel.cpp new file mode 100644 index 0000000..29de392 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/PluginSortFilterProxyModel.cpp @@ -0,0 +1,122 @@ +#include "PluginSortFilterProxyModel.h" +#include "PluginListModel.h" + +#include <algorithm> + +namespace BSPluginList +{ + +void PluginSortFilterProxyModel::hideForceEnabledFiles(bool doHide) +{ + m_HideForceEnabledFiles = doHide; + invalidateRowsFilter(); +} + +bool PluginSortFilterProxyModel::filterMatchesPlugin(const QString& plugin) const +{ + if (m_CurrentFilter.isEmpty()) { + return true; + } + + QString filterCopy = QString(m_CurrentFilter); + filterCopy.replace("||", ";").replace("OR", ";").replace("|", ";"); + + const auto ORList = QStringTokenizer(filterCopy, u';', Qt::SkipEmptyParts); + + return std::ranges::any_of(ORList, [&plugin](auto&& ORSegment) { + const auto ANDkeywords = QStringTokenizer(ORSegment, u' ', Qt::SkipEmptyParts); + + return std::ranges::all_of(ANDkeywords, [&plugin](auto&& currentKeyword) { + return plugin.contains(currentKeyword, Qt::CaseInsensitive); + }); + }); +} + +bool PluginSortFilterProxyModel::canDropMimeData(const QMimeData* data, + Qt::DropAction action, int row, + int column, + const QModelIndex& parent) const +{ + if (sortColumn() != PluginListModel::COL_PRIORITY) { + return false; + } + + if (sortOrder() == Qt::DescendingOrder) { + --row; + } + + const QModelIndex proxyIndex = index(row, column, parent); + const QModelIndex sourceIndex = mapToSource(proxyIndex); + return sourceModel()->canDropMimeData(data, action, sourceIndex.row(), + sourceIndex.column(), sourceIndex.parent()); +} + +bool PluginSortFilterProxyModel::dropMimeData(const QMimeData* data, + Qt::DropAction action, int row, + int column, const QModelIndex& parent) +{ + if (sortColumn() != PluginListModel::COL_PRIORITY) { + return false; + } + + if (sortOrder() == Qt::DescendingOrder) { + --row; + } + + const QModelIndex proxyIndex = index(row, column, parent); + const QModelIndex sourceIndex = mapToSource(proxyIndex); + return sourceModel()->dropMimeData(data, action, sourceIndex.row(), + sourceIndex.column(), sourceIndex.parent()); +} + +bool PluginSortFilterProxyModel::filterAcceptsRow( + int source_row, const QModelIndex& source_parent) const +{ + const auto source_index = sourceModel()->index(source_row, 0, source_parent); + const auto plugin = + source_index.data(PluginListModel::InfoRole).value<const TESData::FileInfo*>(); + if (m_HideForceEnabledFiles && plugin && + (plugin->forceLoaded() || plugin->forceEnabled())) { + return false; + } + + return filterMatchesPlugin(sourceModel()->data(source_index).toString()); +} + +bool PluginSortFilterProxyModel::lessThan(const QModelIndex& source_left, + const QModelIndex& source_right) const +{ + switch (source_left.column()) { + case PluginListModel::COL_CONFLICTS: { + const uint lhs = source_left.data(PluginListModel::ConflictsIconRole).toUInt(); + const uint rhs = source_right.data(PluginListModel::ConflictsIconRole).toUInt(); + return lhs < rhs; + } + case PluginListModel::COL_FLAGS: { + const uint lhs = source_left.data(PluginListModel::FlagsIconRole).toUInt(); + const uint rhs = source_right.data(PluginListModel::FlagsIconRole).toUInt(); + if (std::popcount(lhs) != std::popcount(rhs)) { + return std::popcount(lhs) < std::popcount(rhs); + } else { + for (uint i = 0; i < sizeof(lhs) * 8; ++i) { + const uint lhsBit = (lhs & (1U << i)); + const uint rhsBit = (rhs & (1U << i)); + if (lhsBit != rhsBit) { + return lhsBit < rhsBit; + } + } + return false; + } + } + } + + return QSortFilterProxyModel::lessThan(source_left, source_right); +} + +void PluginSortFilterProxyModel::updateFilter(const QString& filter) +{ + m_CurrentFilter = filter; + invalidateRowsFilter(); +} + +} // namespace BSPluginList diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginSortFilterProxyModel.h b/libs/installer_bsplugins/src/BSPluginList/PluginSortFilterProxyModel.h new file mode 100644 index 0000000..e0cc49c --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/PluginSortFilterProxyModel.h @@ -0,0 +1,40 @@ +#ifndef BSPLUGINLIST_PLUGINSORTFILTERPROXYMODEL_H +#define BSPLUGINLIST_PLUGINSORTFILTERPROXYMODEL_H + +#include <QSortFilterProxyModel> + +namespace BSPluginList +{ + +class PluginSortFilterProxyModel : public QSortFilterProxyModel +{ + Q_OBJECT + +public: + void hideForceEnabledFiles(bool doHide); + + [[nodiscard]] bool filterMatchesPlugin(const QString& plugin) const; + + bool canDropMimeData(const QMimeData* data, Qt::DropAction action, int row, + int column, const QModelIndex& parent) const override; + bool dropMimeData(const QMimeData* data, Qt::DropAction action, int row, int column, + const QModelIndex& parent) override; + + bool filterAcceptsRow(int source_row, + const QModelIndex& source_parent) const override; + +protected: + bool lessThan(const QModelIndex& source_left, + const QModelIndex& source_right) const override; + +public slots: + void updateFilter(const QString& filter); + +private: + QString m_CurrentFilter; + bool m_HideForceEnabledFiles = false; +}; + +} // namespace BSPluginList + +#endif // BSPLUGINLIST_PLUGINSORTFILTERPROXYMODEL_H diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginsWidget.cpp b/libs/installer_bsplugins/src/BSPluginList/PluginsWidget.cpp new file mode 100644 index 0000000..a73725f --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/PluginsWidget.cpp @@ -0,0 +1,833 @@ +#include "PluginsWidget.h" + +#include "BSPluginInfo/PluginInfoDialog.h" +#include "GUI/MessageDialog.h" +#include "GUI/SelectionDialog.h" +#include "MOPlugin/Settings.h" +#include "MOTools/Loot.h" +#include "PluginListContextMenu.h" +#include "PluginSortFilterProxyModel.h" +#include "ui_pluginswidget.h" + +#include <game_features/gameplugins.h> +#include <game_features/igamefeatures.h> + +#include <boost/range/adaptor/reversed.hpp> + +#include <QApplication> +#include <QCryptographicHash> +#include <QMenu> +#include <QMessageBox> +#include <QStandardPaths> + +using namespace Qt::Literals::StringLiterals; + +namespace BSPluginList +{ + +PluginsWidget::PluginsWidget(MOBase::IOrganizer* organizer, + IPanelInterface* panelInterface, QWidget* parent) + : QWidget(parent), ui{new Ui_PluginsWidget()}, m_PanelInterface{panelInterface}, + m_Organizer{organizer} +{ + ui->setupUi(this); + + m_PluginList = new TESData::PluginList(organizer); + m_PluginListModel = new PluginListModel(m_PluginList); + m_SortProxy = new PluginSortFilterProxyModel(); + m_SortProxy->setSourceModel(m_PluginListModel); + m_GroupProxy = new PluginGroupProxyModel(organizer); + m_GroupProxy->setSourceModel(m_SortProxy); + ui->pluginList->setModel(m_GroupProxy); + ui->pluginList->setup(); + ui->pluginList->sortByColumn(PluginListModel::COL_PRIORITY, Qt::AscendingOrder); + ui->pluginList->expandAll(); + optionsMenu = listOptionsMenu(); + ui->listOptionsBtn->setMenu(optionsMenu); + + // LOOT integration stripped for Fluorine port — sort button is useless. + ui->sortButton->setVisible(false); + + // monitor main window for close event + topLevelWidget()->installEventFilter(this); + + restoreState(); + + connect(m_PluginList, &TESData::PluginList::pluginsListChanged, this, + &PluginsWidget::updatePluginCount); + + connect(m_PluginListModel, &PluginListModel::pluginStatesChanged, ui->pluginList, + &PluginListView::updateOverwriteMarkers); + connect(m_PluginListModel, &PluginListModel::pluginOrderChanged, ui->pluginList, + &PluginListView::updateOverwriteMarkers); + connect(m_PluginListModel, &QAbstractItemModel::modelReset, ui->pluginList, + &PluginListView::clearOverwriteMarkers); + + connect(m_PluginListModel, &PluginListModel::pluginStatesChanged, this, + &PluginsWidget::updatePluginCount); + + connect(m_GroupProxy, &QAbstractItemModel::modelReset, [this]() { + ui->pluginList->scrollToTop(); + }); + + connect(ui->pluginList, &QTreeView::collapsed, this, + &PluginsWidget::onGroupCollapsed); + connect(ui->pluginList, &QTreeView::expanded, this, &PluginsWidget::onGroupExpanded); + connect(ui->pluginList->selectionModel(), &QItemSelectionModel::selectionChanged, + this, &PluginsWidget::onSelectionChanged); + + panelInterface->onPanelActivated( + std::bind_front(&PluginsWidget::onPanelActivated, this)); + panelInterface->onSelectedOriginsChanged( + std::bind_front(&PluginsWidget::onSelectedOriginsChanged, this)); + + organizer->onAboutToRun(std::bind_front(&PluginsWidget::onAboutToRun, this)); + organizer->onFinishedRun(std::bind_front(&PluginsWidget::onFinishedRun, this)); + + organizer->modList()->onModStateChanged( + std::bind_front(&PluginsWidget::onModStateChanged, this)); + + Settings::instance()->onSettingChanged( + std::bind_front(&PluginsWidget::onSettingChanged, this)); + + synchronizePluginLists(organizer); + updatePluginCount(); +} + +PluginsWidget::~PluginsWidget() noexcept +{ + delete ui; + delete optionsMenu; + + delete m_PluginList; + delete m_PluginListModel; + delete m_SortProxy; + delete m_GroupProxy; +} + +void PluginsWidget::updatePluginCount() +{ + int activeMasterCount = 0; + int activeMediumMasterCount = 0; + int activeLightMasterCount = 0; + int activeRegularCount = 0; + int masterCount = 0; + int mediumMasterCount = 0; + int lightMasterCount = 0; + int regularCount = 0; + int activeVisibleCount = 0; + + const auto gameFeatures = m_Organizer->gameFeatures(); + const auto tesSupport = + gameFeatures ? gameFeatures->gameFeature<MOBase::GamePlugins>() : nullptr; + + const bool lightPluginsAreSupported = + tesSupport && tesSupport->lightPluginsAreSupported(); + const bool mediumPluginsAreSupported = + tesSupport && tesSupport->mediumPluginsAreSupported(); + + for (int i = 0, count = m_PluginListModel->rowCount(); i < count; ++i) { + const auto index = m_PluginListModel->index(i, 0); + const auto id = index.data(PluginListModel::IndexRole).toInt(); + const auto info = m_PluginList->getPlugin(id); + + if (!info) + continue; + + const bool active = info->enabled() || info->isAlwaysEnabled(); + const bool visible = m_SortProxy->filterAcceptsRow(index.row(), index.parent()); + if (info->isMediumFile()) { + ++mediumMasterCount; + activeMediumMasterCount += active ? 1 : 0; + activeVisibleCount += visible && active ? 1 : 0; + } else if (info->isSmallFile()) { + ++lightMasterCount; + activeLightMasterCount += active ? 1 : 0; + activeVisibleCount += visible && active ? 1 : 0; + } else if (info->isMasterFile()) { + ++masterCount; + activeMasterCount += active ? 1 : 0; + activeVisibleCount += visible && active ? 1 : 0; + } else { + ++regularCount; + activeRegularCount += active ? 1 : 0; + activeVisibleCount += visible && active ? 1 : 0; + } + } + + const int activeCount = activeMasterCount + activeMediumMasterCount + + activeLightMasterCount + activeRegularCount; + const int totalCount = + masterCount + mediumMasterCount + lightMasterCount + regularCount; + + ui->activePluginsCounter->display(activeVisibleCount); + + QString toolTip; + toolTip.reserve(575); + toolTip += uR"(<table cellspacing="6">)"_s + uR"(<tr><th>%1</th><th>%2</th><th>%3</th></tr>)"_s.arg(tr("Type")) + .arg(tr("Active"), -12) + .arg(tr("Total")); + + const QString row = uR"(<tr><td>%1:</td><td align=right>%2 </td>)"_s + uR"(<td align=right>%3</td></tr>)"_s; + + toolTip += row.arg(tr("All plugins")).arg(activeCount).arg(totalCount); + toolTip += row.arg(tr("ESMs")).arg(activeMasterCount).arg(masterCount); + toolTip += row.arg(tr("ESPs")).arg(activeRegularCount).arg(regularCount); + toolTip += row.arg(tr("ESMs+ESPs")) + .arg(activeMasterCount + activeRegularCount) + .arg(masterCount + regularCount); + if (mediumPluginsAreSupported) + toolTip += row.arg(tr("ESHs")).arg(activeMediumMasterCount).arg(mediumMasterCount); + if (lightPluginsAreSupported) + toolTip += row.arg(tr("ESLs")).arg(activeLightMasterCount).arg(lightMasterCount); + toolTip += uR"(</table>)"_s; + + ui->activePluginsCounter->setToolTip(toolTip); +} + +void PluginsWidget::on_espFilterEdit_textChanged(const QString& filter) +{ + m_SortProxy->updateFilter(filter); + + if (!filter.isEmpty()) { + setStyleSheet("QTreeView { border: 2px ridge #f00; }"); + ui->activePluginsCounter->setStyleSheet("QLCDNumber { border: 2px ridge #f00; }"); + } else { + setStyleSheet(""); + ui->activePluginsCounter->setStyleSheet(""); + } + updatePluginCount(); +} + +bool PluginsWidget::eventFilter(QObject* watched, QEvent* event) +{ + if (event->type() == QEvent::Close) { + saveState(); + m_PluginList->writePluginLists(); + } + + return QWidget::eventFilter(watched, event); +} + +void PluginsWidget::changeEvent(QEvent* event) +{ + QWidget::changeEvent(event); + switch (event->type()) { + case QEvent::LanguageChange: + ui->retranslateUi(this); + break; + default: + break; + } +} + +void PluginsWidget::onGroupCollapsed(const QModelIndex& index) +{ + if (ui->pluginList->selectionModel()->isSelected(index)) { + onSelectionChanged(); + } +} + +void PluginsWidget::onGroupExpanded(const QModelIndex& index) +{ + if (ui->pluginList->selectionModel()->isSelected(index)) { + onSelectionChanged(); + } +} + +void PluginsWidget::onSelectionChanged() +{ + QList<QString> selectedFiles; + std::function<void(const QModelIndex&)> addFiles; + addFiles = [&](const QModelIndex& index) { + if (index.model()->hasChildren(index)) { + if (ui->pluginList->isExpanded(index)) { + return; + } + + for (int i = 0, count = index.model()->rowCount(index); i < count; ++i) { + addFiles(index.model()->index(i, 0, index)); + } + } else { + if (const auto info = + index.data(PluginListModel::InfoRole).value<const TESData::FileInfo*>()) { + selectedFiles.append(info->name()); + } + } + }; + + for (const auto& index : ui->pluginList->selectionModel()->selectedRows()) { + addFiles(index); + } + + m_PanelInterface->setSelectedFiles(selectedFiles); +} + +void PluginsWidget::onPanelActivated() +{ + m_PluginListModel->refresh(); +} + +void PluginsWidget::onSelectedOriginsChanged(const QList<QString>& origins) +{ + ui->pluginList->setHighlightedOrigins(origins); +} + +void PluginsWidget::toggleHideForceEnabled() +{ + const bool doHide = toggleForceEnabled->isChecked(); + m_SortProxy->hideForceEnabledFiles(doHide); + updatePluginCount(); + + Settings::instance()->set("hide_force_enabled", doHide); +} + +void PluginsWidget::toggleIgnoreMasterConflicts() +{ + const bool doIgnore = toggleIgnoreMasters->isChecked(); + Settings::instance()->set("ignore_master_conflicts", doIgnore); + + m_PluginListModel->invalidateConflicts(); +} + +constexpr auto PATTERN_BACKUP_GLOB = R"/(.????_??_??_??_??_??)/"; +constexpr auto PATTERN_BACKUP_REGEX = R"/(\.(\d\d\d\d_\d\d_\d\d_\d\d_\d\d_\d\d))/"; +constexpr auto PATTERN_BACKUP_DATE = R"/(yyyy_MM_dd_hh_mm_ss)/"; + +static QString queryRestore(const QString& filePath, QWidget* parent = nullptr) +{ + QFileInfo pluginFileInfo(filePath); + QString pattern = pluginFileInfo.fileName() + ".*"; + QFileInfoList files = pluginFileInfo.absoluteDir().entryInfoList( + QStringList(pattern), QDir::Files, QDir::Name); + + GUI::SelectionDialog dialog(QObject::tr("Choose backup to restore"), parent); + QRegularExpression exp(QRegularExpression::anchoredPattern(pluginFileInfo.fileName() + + PATTERN_BACKUP_REGEX)); + QRegularExpression exp2( + QRegularExpression::anchoredPattern(pluginFileInfo.fileName() + "\\.(.*)")); + for (const QFileInfo& info : boost::adaptors::reverse(files)) { + auto match = exp.match(info.fileName()); + auto match2 = exp2.match(info.fileName()); + if (match.hasMatch()) { + QDateTime time = QDateTime::fromString(match.captured(1), PATTERN_BACKUP_DATE); + dialog.addChoice(time.toString(), "", match.captured(1)); + } else if (match2.hasMatch()) { + dialog.addChoice(match2.captured(1), "", match2.captured(1)); + } + } + + if (dialog.numChoices() == 0) { + QMessageBox::information(parent, QObject::tr("No Backups"), + QObject::tr("There are no backups to restore")); + return QString(); + } + + if (dialog.exec() == QDialog::Accepted) { + return dialog.getChoiceData().toString(); + } else { + return QString(); + } +} + +void PluginsWidget::displayPluginInformation(const QModelIndex& index) +{ + const int id = index.data(PluginListModel::IndexRole).toInt(); + const auto fileName = m_PluginList->getPlugin(id)->name(); + const auto parent = topLevelWidget(); + BSPluginInfo::PluginInfoDialog dialog{m_Organizer, m_PluginList, fileName, parent}; + dialog.exec(); + + const bool ignoreMasters = + Settings::instance()->get<bool>("ignore_master_conflicts", false); + toggleIgnoreMasters->setChecked(ignoreMasters); + m_PluginListModel->invalidateConflicts(); +} + +void PluginsWidget::on_pluginList_customContextMenuRequested(const QPoint& pos) +{ + PluginListContextMenu menu{ui->pluginList->indexAt(pos), m_PluginListModel, + ui->pluginList, m_Organizer->modList(), m_PluginList}; + + connect(&menu, &PluginListContextMenu::openModInformation, + [this](const QModelIndex& index) { + const int id = index.data(PluginListModel::IndexRole).toInt(); + const auto fileName = m_PluginList->getPlugin(id)->name(); + m_PanelInterface->displayOriginInformation(fileName); + }); + + connect(&menu, &PluginListContextMenu::openPluginInformation, this, + &PluginsWidget::displayPluginInformation); + + const QPoint p = ui->pluginList->viewport()->mapToGlobal(pos); + menu.exec(p); +} + +void PluginsWidget::on_pluginList_doubleClicked(const QModelIndex& index) +{ + bool ok; + const int id = index.data(PluginListModel::IndexRole).toInt(&ok); + if (ok) { + Qt::KeyboardModifiers modifiers = QApplication::queryKeyboardModifiers(); + if (modifiers.testFlag(Qt::ControlModifier)) { + const auto origin = m_PluginList->getOriginName(id); + const auto modInfo = m_Organizer->modList()->getMod(origin); + + if (modInfo) { + MOBase::shell::Explore(modInfo->absolutePath()); + } + } else { + displayPluginInformation(index); + } + } else if (ui->pluginList->model()->hasChildren(index)) { + ui->pluginList->setExpanded(index, !ui->pluginList->isExpanded(index)); + } +} + +void PluginsWidget::on_pluginList_openOriginExplorer(const QModelIndex& index) +{ + const int id = index.data(PluginListModel::IndexRole).toInt(); + const auto origin = m_PluginList->getOriginName(id); + const auto modInfo = m_Organizer->modList()->getMod(origin); + + if (modInfo == nullptr) { + return; + } + + MOBase::shell::Explore(modInfo->absolutePath()); +} + +void PluginsWidget::on_sortButton_clicked() +{ + // LOOT integration stripped for Fluorine port. + QMessageBox::information( + topLevelWidget(), tr("Sorting plugins"), + tr("LOOT integration is not available in this build.")); +} + +static bool tryRestore(const QString& filePath, const QString& identifier, + bool required, QWidget* parent = nullptr) +{ + const auto backupName = filePath + "." + identifier; + if (required || QFileInfo::exists(backupName)) { + return MOBase::shellCopy(backupName, filePath, true, parent); + } else { + return !QFileInfo::exists(filePath) || MOBase::shellDeleteQuiet(filePath, parent); + } +} + +void PluginsWidget::on_restoreButton_clicked() +{ + const auto app = this->topLevelWidget(); + const auto profilePath = QDir(m_Organizer->profilePath()); + const auto pluginsName = QDir::cleanPath(profilePath.absoluteFilePath("plugins.txt")); + + QString choice = queryRestore(pluginsName, app); + if (!choice.isEmpty()) { + const auto groupsName = + QDir::cleanPath(profilePath.absoluteFilePath("plugingroups.txt")); + const auto loadOrderName = + QDir::cleanPath(profilePath.absoluteFilePath("loadorder.txt")); + + if (!tryRestore(pluginsName, choice, true, app) || + !tryRestore(loadOrderName, choice, true, app) || + !tryRestore(groupsName, choice, false, app)) { + const auto e = ::GetLastError(); + + QMessageBox::critical( + this, tr("Restore failed"), + tr("Failed to restore the backup. Errorcode: %1") + .arg(QString::fromStdWString(MOBase::formatSystemMessage(e)))); + } + m_PluginListModel->invalidate(); + } +} + +static bool createBackup(const QString& filePath, const QString& identifier, + QWidget* parent = nullptr) +{ + QString outPath = filePath + "." + identifier; + if (MOBase::shellCopy(QStringList(filePath), QStringList(outPath), parent)) { + QFileInfo fileInfo(filePath); + MOBase::removeOldFiles(fileInfo.absolutePath(), + fileInfo.fileName() + PATTERN_BACKUP_GLOB, 10, QDir::Name); + return true; + } else { + return false; + } +} + +static bool createBackup(const QString& filePath, const QDateTime& time, + QWidget* parent = nullptr) +{ + return createBackup(filePath, time.toString(PATTERN_BACKUP_DATE), parent); +} + +void PluginsWidget::on_saveButton_clicked() +{ + m_PluginList->writePluginLists(); + + const auto app = this->topLevelWidget(); + const auto profilePath = QDir(m_Organizer->profilePath()); + const auto pluginsName = QDir::cleanPath(profilePath.absoluteFilePath("plugins.txt")); + const auto groupsName = + QDir::cleanPath(profilePath.absoluteFilePath("plugingroups.txt")); + const auto loadOrderName = + QDir::cleanPath(profilePath.absoluteFilePath("loadorder.txt")); + const auto lockedOrderName = + QDir::cleanPath(profilePath.absoluteFilePath("lockedorder.txt")); + + const QDateTime now = QDateTime::currentDateTime(); + + if (createBackup(pluginsName, now, app) && createBackup(loadOrderName, now, app) && + createBackup(groupsName, now, app) && createBackup(lockedOrderName, now, app)) { + GUI::MessageDialog::showMessage(tr("Backup of load order created"), app); + } +} + +QMenu* PluginsWidget::listOptionsMenu() +{ + QMenu* const menu = new QMenu(this); + toggleForceEnabled = menu->addAction(tr("Hide force-enabled files"), this, + &PluginsWidget::toggleHideForceEnabled); + toggleForceEnabled->setCheckable(true); + + toggleIgnoreMasters = menu->addAction(tr("Ignore conflicts with masters"), this, + &PluginsWidget::toggleIgnoreMasterConflicts); + toggleIgnoreMasters->setCheckable(true); + + menu->addSeparator(); + + menu->addAction(tr("Collapse all"), [this]() { + ui->pluginList->collapseAll(); + ui->pluginList->scrollToTop(); + }); + menu->addAction(tr("Expand all"), [this]() { + ui->pluginList->expandAll(); + ui->pluginList->scrollToTop(); + }); + + menu->addSeparator(); + + menu->addAction(tr("Enable all"), [this]() { + if (QMessageBox::question(topLevelWidget(), tr("Confirm"), + tr("Really enable all plugins?"), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + m_PluginListModel->setEnabledAll(true); + } + }); + menu->addAction(tr("Disable all"), [this]() { + if (QMessageBox::question(topLevelWidget(), tr("Confirm"), + tr("Really disable all plugins?"), + QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + m_PluginListModel->setEnabledAll(false); + } + }); + + return menu; +} + +void PluginsWidget::saveState() +{ + auto* const settings = Settings::instance(); + settings->saveState(ui->pluginList->header()); + // Fluorine port: saveTreeExpandState recurses the model, which on window + // close is being torn down by the time the close event fires → SIGSEGV + // inside the visitRows traversal. Column widths above still save fine. + // Tree expansion state is a minor UX loss; not worth the crash. +} + +void PluginsWidget::restoreState() +{ + const auto* const settings = Settings::instance(); + settings->restoreState(ui->pluginList->header()); + settings->restoreTreeExpandState(ui->pluginList); + + const bool doHide = settings->get<bool>("hide_force_enabled", false); + toggleForceEnabled->setChecked(doHide); + toggleHideForceEnabled(); + + const bool doIgnore = settings->get<bool>("ignore_master_conflicts", false); + toggleIgnoreMasters->setChecked(doIgnore); + toggleIgnoreMasterConflicts(); +} + +static bool containsPlugin(const MOBase::IModInterface* mod, const MOBase::IPluginGame* game) +{ + const auto fileTree = mod ? mod->fileTree() : nullptr; + if (!fileTree) + return false; + std::shared_ptr<const MOBase::IFileTree> searchDir; + if (!game->modDataDirectory().isEmpty()) { + searchDir = fileTree->findDirectory(game->modDataDirectory()); + } else { + searchDir = fileTree; + } + if (searchDir) { + return std::ranges::any_of(*searchDir, [&](auto&& entry) { + if (!entry) + return false; + const QString filename = entry->name(); + return filename.endsWith(u".esp"_s, Qt::CaseInsensitive) || + filename.endsWith(u".esm"_s, Qt::CaseInsensitive) || + filename.endsWith(u".esl"_s, Qt::CaseInsensitive); + }); + } + return false; +} + +void PluginsWidget::onModStateChanged( + const std::map<QString, MOBase::IModList::ModStates>& mods) +{ + // HACK: the virtual file tree won't update until the next refresh, so keep track of + // any mods that might be newly activated + const auto modList = m_Organizer->modList(); + if (!modList) + return; + + for (const auto& [modName, modState] : mods) { + const auto mod = modList->getMod(modName); + if (containsPlugin(mod, m_Organizer->managedGame())) { + m_PluginList->notifyPendingState(modName, modState); + } + } + m_PluginListModel->refresh(); +} + +bool PluginsWidget::onAboutToRun([[maybe_unused]] const QString& binary) +{ + m_PluginList->writePluginLists(); + + const auto profilePath = QDir(m_Organizer->profilePath()); + const auto pluginsName = QDir::cleanPath(profilePath.absoluteFilePath("plugins.txt")); + const auto loadOrderName = + QDir::cleanPath(profilePath.absoluteFilePath("loadorder.txt")); + const auto parent = this->topLevelWidget(); + + if (QFileInfo::exists(pluginsName + ".snapshot")) { + MOBase::shellDeleteQuiet(pluginsName + ".snapshot", parent); + } + + if (QFileInfo::exists(loadOrderName + ".snapshot")) { + MOBase::shellDeleteQuiet(loadOrderName + ".snapshot", parent); + } + + if (QFileInfo(binary).fileName().compare("lootcli.exe") != 0) { + createBackup(pluginsName, "snapshot", parent); + createBackup(loadOrderName, "snapshot", parent); + m_IsRunningApp = true; + } + + return true; +} + +void PluginsWidget::onFinishedRun(const QString& binary, + [[maybe_unused]] unsigned int exitCode) +{ + const auto binaryName = QFileInfo(binary).fileName(); + if (binaryName.compare("lootcli.exe", Qt::CaseInsensitive) == 0) { + return; + } + + // queue up behind the vanilla callbacks which might not have run yet, so we can react + // after loadorder.txt changes + m_Organizer->onNextRefresh([=, this]() { + m_PluginList->refresh(); + checkLoadOrderChanged(binaryName); + m_IsRunningApp = false; + m_ExternalStatesChanged = false; + }); +} + +void PluginsWidget::onSettingChanged(const QString& key, + [[maybe_unused]] const QVariant& oldValue, + const QVariant& newValue) +{ + if (key == u"enable_sort_button"_s) { + // Sort button permanently hidden — no LOOT integration. + ui->sortButton->setVisible(false); + } +} + +static QByteArray hashFile(const QString& filePath) +{ + QCryptographicHash hash{QCryptographicHash::Sha1}; + QFile file{filePath}; + if (file.open(QIODevice::ReadOnly)) { + hash.addData(file.readAll()); + } else { + return ""_ba; + } + + return hash.result(); +} + +// Return a normalized signature of a plugins.txt / loadorder.txt so the load- +// order-changed check can tell a cosmetic rewrite (case fix, line-ending flip, +// trailing newline, BOM, stray blank line) apart from an actual reorder or add/ +// remove. On a case-sensitive filesystem MO2 often rewrites loadorder.txt +// during post-run refresh to match the on-disk case of each plugin, which +// would otherwise fire the warning on every launch. +static QStringList normalizedPluginList(const QString& filePath) +{ + QFile file{filePath}; + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + + QStringList out; + const QByteArray raw = file.readAll(); + for (const QByteArray& rawLine : raw.split('\n')) { + QString line = QString::fromUtf8(rawLine).trimmed(); + if (line.isEmpty() || line.startsWith('#')) { + continue; + } + // plugins.txt may prefix active entries with '*' — strip it so a re-sort + // that flips active state order isn't treated as a load-order edit here. + if (line.startsWith('*')) { + line = line.mid(1); + } + out.append(line.toLower()); + } + return out; +} + +void PluginsWidget::checkLoadOrderChanged(const QString& binaryName) +{ + const auto profilePath = QDir(m_Organizer->profilePath()); + const auto pluginsName = QDir::cleanPath(profilePath.absoluteFilePath("plugins.txt")); + const auto loadOrderName = + QDir::cleanPath(profilePath.absoluteFilePath("loadorder.txt")); + const auto parent = this->topLevelWidget(); + + const auto pluginsSnapshot = pluginsName + ".snapshot"; + const auto loadOrderSnapshot = loadOrderName + ".snapshot"; + + const auto pluginsFile = QFileInfo(pluginsName); + const auto loadOrderFile = QFileInfo(loadOrderName); + + if (!QFileInfo(loadOrderSnapshot).exists()) + return; + + const bool enableWarning = Settings::instance()->externalChangeWarning(); + + // Compare normalized plugin name lists instead of raw byte hashes: MO2's + // post-run refresh rewrites loadorder.txt with filesystem-correct case, + // which flips the sha1 even when the actual order/contents didn't change. + const bool loadOrderChanged = + normalizedPluginList(loadOrderName) != normalizedPluginList(loadOrderSnapshot); + const bool pluginsChanged = + normalizedPluginList(pluginsName) != normalizedPluginList(pluginsSnapshot); + + // we just refreshed and rewrote loadorder.txt if plugins.txt changed + if (enableWarning && + (m_ExternalStatesChanged || loadOrderChanged || pluginsChanged)) { + + if (false) { + // LOOT integration removed for Fluorine port. + } else { + const auto response = QMessageBox::warning( + parent, tr("Load order changed"), + tr("Load order was changed while running %1. Keep changes?").arg(binaryName), + QMessageBox::Yes | QMessageBox::No); + + if (response == QMessageBox::No) { + if (!tryRestore(pluginsName, "snapshot", true, parent) || + !tryRestore(loadOrderName, "snapshot", true, parent)) { + const auto e = ::GetLastError(); + + QMessageBox::critical( + this, tr("Restore failed"), + tr("Failed to restore the backup. Errorcode: %1") + .arg(QString::fromStdWString(MOBase::formatSystemMessage(e)))); + + return; + } + } + } + } + + MOBase::shellDeleteQuiet(pluginsSnapshot, parent); + MOBase::shellDeleteQuiet(loadOrderSnapshot, parent); + m_PluginListModel->invalidate(); +} + +void PluginsWidget::importLootGroups() +{ + // LOOT integration removed for Fluorine port. +} + +void PluginsWidget::synchronizePluginLists(MOBase::IOrganizer* organizer) +{ + MOBase::IPluginList* const ipluginlist = organizer->pluginList(); + if (ipluginlist == nullptr || ipluginlist == m_PluginList) { + return; + } + + std::function<void()> startRefresh = [this] { + m_OrganizerRefreshing = true; + m_PluginList->flushPendingStates(); + // if we just finished running an application, we want the vanilla plugin list to + // finish reading and rewriting the load order files so that we don't end up + // ignoring the change + if (!m_IsRunningApp) { + // Fluorine port: non-invalidating refresh. Upstream uses invalidate() + // which re-parses every plugin through the TES reader on each organizer + // refresh — over FUSE VFS with 100+ plugins that freezes the GUI for + // seconds on every mod enable/disable. Incremental refresh only parses + // new plugins; cached ones stay. + m_PluginListModel->refresh(); + } + }; + + organizer->onNextRefresh(startRefresh, false); + + ipluginlist->onRefreshed([this, organizer, startRefresh]() { + if (m_OrganizerRefreshing) { + m_OrganizerRefreshing = false; + organizer->onNextRefresh(startRefresh, false); + } + }); + + ipluginlist->onPluginMoved( + [this](const QString& name, int oldPriority, int newPriority) { + if (m_OrganizerRefreshing) + return; + m_PluginListModel->movePlugin(name, oldPriority, newPriority); + }); + + ipluginlist->onPluginStateChanged( + [this](const std::map<QString, MOBase::IPluginList::PluginStates>& infos) { + if (m_OrganizerRefreshing) + return; + m_PluginListModel->changePluginStates(infos); + }); + + m_PluginList->onPluginMoved([=, this](const QString& name, + [[maybe_unused]] int oldPriority, + int newPriority) { + if (m_PluginList->isRefreshing()) + return; + + ipluginlist->setPriority(name, newPriority); + }); + + m_PluginList->onPluginStateChanged( + [=, this](const std::map<QString, MOBase::IPluginList::PluginStates>& infos) { + if (m_IsRunningApp) { + m_ExternalStatesChanged = true; + } + + if (m_PluginList->isRefreshing() || infos.empty()) + return; + + for (const auto& [name, state] : infos) { + m_PanelInterface->setPluginState(name, + state == MOBase::IPluginList::STATE_ACTIVE); + } + }); +} + +} // namespace BSPluginList diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginsWidget.h b/libs/installer_bsplugins/src/BSPluginList/PluginsWidget.h new file mode 100644 index 0000000..e44fc7e --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/PluginsWidget.h @@ -0,0 +1,103 @@ +#ifndef BSPLUGINLIST_PLUGINSWIDGET_H +#define BSPLUGINLIST_PLUGINSWIDGET_H + +#include "MOPlugin/IPanelInterface.h" +#include "PluginGroupProxyModel.h" +#include "PluginListModel.h" +#include "PluginSortFilterProxyModel.h" +#include "TESData/PluginList.h" + +#include <QSortFilterProxyModel> +#include <QWidget> + +class Ui_PluginsWidget; + +namespace BSPluginList +{ + +class PluginsWidget final : public QWidget +{ + Q_OBJECT + +public: + PluginsWidget(MOBase::IOrganizer* organizer, IPanelInterface* panelInterface, + QWidget* parent = nullptr); + + PluginsWidget(const PluginsWidget&) = delete; + PluginsWidget(PluginsWidget&&) = delete; + + ~PluginsWidget() noexcept; + + PluginsWidget& operator=(const PluginsWidget&) = delete; + PluginsWidget& operator=(PluginsWidget&&) = delete; + + [[nodiscard]] TESData::PluginList* getPluginList() { return m_PluginList; } + + bool eventFilter(QObject* watched, QEvent* event) override; + +public slots: + void updatePluginCount(); + +protected: + void changeEvent(QEvent* event) override; + +private slots: + void onGroupCollapsed(const QModelIndex& index); + void onGroupExpanded(const QModelIndex& index); + void onSelectionChanged(); + + void onPanelActivated(); + + void onSelectedOriginsChanged(const QList<QString>& origins); + + void toggleHideForceEnabled(); + void toggleIgnoreMasterConflicts(); + void displayPluginInformation(const QModelIndex& index); + + void on_pluginList_customContextMenuRequested(const QPoint& pos); + void on_pluginList_doubleClicked(const QModelIndex& index); + void on_pluginList_openOriginExplorer(const QModelIndex& index); + void on_espFilterEdit_textChanged(const QString& filter); + void on_sortButton_clicked(); + void on_restoreButton_clicked(); + void on_saveButton_clicked(); + +private: + [[nodiscard]] QMenu* listOptionsMenu(); + void saveState(); + void restoreState(); + + void onModStateChanged(const std::map<QString, MOBase::IModList::ModStates>& mods); + bool onAboutToRun(const QString& binary); + void onFinishedRun(const QString& binary, unsigned int exitCode); + void onSettingChanged(const QString& key, const QVariant& oldValue, + const QVariant& newValue); + void checkLoadOrderChanged(const QString& binaryName); + void importLootGroups(); + + // HACK: Attempt to keep our custom plugin list synchronized with the built-in panel + void synchronizePluginLists(MOBase::IOrganizer* organizer); + + Ui_PluginsWidget* ui; + + QMenu* optionsMenu = nullptr; + QAction* toggleForceEnabled = nullptr; + QAction* toggleIgnoreMasters = nullptr; + + TESData::PluginList* m_PluginList = nullptr; + PluginListModel* m_PluginListModel = nullptr; + PluginSortFilterProxyModel* m_SortProxy = nullptr; + PluginGroupProxyModel* m_GroupProxy = nullptr; + IPanelInterface* m_PanelInterface; + + MOBase::IOrganizer* m_Organizer; + + bool m_DidUpdateMasterList = false; + bool m_OrganizerRefreshing = false; + bool m_IsRunningApp = false; + bool m_ExternalStatesChanged = false; +}; + +} // namespace BSPluginList + +#endif // TESDATA_PLUGINSWIDGET_H diff --git a/libs/installer_bsplugins/src/BSPluginList/pluginswidget.ui b/libs/installer_bsplugins/src/BSPluginList/pluginswidget.ui new file mode 100644 index 0000000..ea87e57 --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginList/pluginswidget.ui @@ -0,0 +1,279 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>PluginsWidget</class> + <widget class="QWidget" name="PluginsWidget"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>313</width> + <height>332</height> + </rect> + </property> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Expanding"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="windowTitle"> + <string>Plugins</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="spacing"> + <number>2</number> + </property> + <property name="topMargin"> + <number>6</number> + </property> + <property name="bottomMargin"> + <number>0</number> + </property> + <item> + <layout class="QHBoxLayout" name="horizontalLayout"> + <property name="spacing"> + <number>6</number> + </property> + <item> + <widget class="QPushButton" name="sortButton"> + <property name="toolTip"> + <string>Sort the plugins using LOOT.</string> + </property> + <property name="whatsThis"> + <string>Sort the plugins using LOOT.</string> + </property> + <property name="text"> + <string>Sort</string> + </property> + <property name="icon"> + <iconset resource="../../../modorganizer/src/resources.qrc"> + <normaloff>:/MO/gui/sort</normaloff>:/MO/gui/sort</iconset> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="QPushButton" name="listOptionsBtn"> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>16777215</height> + </size> + </property> + <property name="toolTip"> + <string>Open list options...</string> + </property> + <property name="whatsThis"> + <string>Refresh list. This is usually not necessary unless you modified data outside the program.</string> + </property> + <property name="text"> + <string/> + </property> + <property name="icon"> + <iconset resource="../../../modorganizer/src/resources.qrc"> + <normaloff>:/MO/gui/settings</normaloff>:/MO/gui/settings</iconset> + </property> + <property name="iconSize"> + <size> + <width>16</width> + <height>16</height> + </size> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="restoreButton"> + <property name="toolTip"> + <string>Restore a backup.</string> + </property> + <property name="whatsThis"> + <string>Restore a backup.</string> + </property> + <property name="text"> + <string notr="true"/> + </property> + <property name="icon"> + <iconset resource="../../../modorganizer/src/resources.qrc"> + <normaloff>:/MO/gui/restore</normaloff>:/MO/gui/restore</iconset> + </property> + <property name="iconSize"> + <size> + <width>16</width> + <height>16</height> + </size> + </property> + </widget> + </item> + <item> + <widget class="QPushButton" name="saveButton"> + <property name="toolTip"> + <string>Create a backup.</string> + </property> + <property name="whatsThis"> + <string>Create a backup.</string> + </property> + <property name="text"> + <string notr="true"/> + </property> + <property name="icon"> + <iconset resource="../../../modorganizer/src/resources.qrc"> + <normaloff>:/MO/gui/backup</normaloff>:/MO/gui/backup</iconset> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="activePluginsLabel"> + <property name="text"> + <string>Active:</string> + </property> + </widget> + </item> + <item> + <widget class="QLCDNumber" name="activePluginsCounter"> + <property name="minimumSize"> + <size> + <width>0</width> + <height>26</height> + </size> + </property> + <property name="whatsThis"> + <string><html><head/><body><p>This provides statistics about the plugin list. The total number of active plugins is normally displayed. Other statistics may be accessed with the tooltip of this counter.</p></body></html></string> + </property> + <property name="frameShadow"> + <enum>QFrame::Sunken</enum> + </property> + <property name="digitCount"> + <number>4</number> + </property> + <property name="segmentStyle"> + <enum>QLCDNumber::Flat</enum> + </property> + </widget> + </item> + </layout> + </item> + <item> + <widget class="BSPluginList::PluginListView" name="pluginList"> + <property name="minimumSize"> + <size> + <width>250</width> + <height>250</height> + </size> + </property> + <property name="contextMenuPolicy"> + <enum>Qt::CustomContextMenu</enum> + </property> + <property name="toolTip"> + <string>List of available esp/esm files.</string> + </property> + <property name="whatsThis"> + <string>List of available esp/esm files.</string> + </property> + <property name="editTriggers"> + <set>QAbstractItemView::EditKeyPressed|QAbstractItemView::SelectedClicked</set> + </property> + <property name="dragEnabled"> + <bool>true</bool> + </property> + <property name="dragDropOverwriteMode"> + <bool>false</bool> + </property> + <property name="dragDropMode"> + <enum>QAbstractItemView::InternalMove</enum> + </property> + <property name="defaultDropAction"> + <enum>Qt::MoveAction</enum> + </property> + <property name="alternatingRowColors"> + <bool>true</bool> + </property> + <property name="selectionMode"> + <enum>QAbstractItemView::ExtendedSelection</enum> + </property> + <property name="selectionBehavior"> + <enum>QAbstractItemView::SelectRows</enum> + </property> + <property name="indentation"> + <number>20</number> + </property> + <property name="uniformRowHeights"> + <bool>true</bool> + </property> + <property name="itemsExpandable"> + <bool>true</bool> + </property> + <property name="sortingEnabled"> + <bool>true</bool> + </property> + <property name="expandsOnDoubleClick"> + <bool>false</bool> + </property> + <attribute name="headerStretchLastSection"> + <bool>false</bool> + </attribute> + </widget> + </item> + <item> + <layout class="QHBoxLayout" name="horizontalLayout_2"> + <item> + <spacer name="horizontalSpacer_2"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + <item> + <widget class="MOBase::LineEditClear" name="espFilterEdit"> + <property name="toolTip"> + <string>Filter the list of plugins.</string> + </property> + <property name="whatsThis"> + <string>Filter the list of plugins.</string> + </property> + <property name="text"> + <string/> + </property> + <property name="placeholderText"> + <string>Filter</string> + </property> + </widget> + </item> + </layout> + </item> + </layout> + </widget> + <customwidgets> + <customwidget> + <class>MOBase::LineEditClear</class> + <extends>QLineEdit</extends> + <header>lineeditclear.h</header> + </customwidget> + <customwidget> + <class>BSPluginList::PluginListView</class> + <extends>QTreeView</extends> + <header>BSPluginList/PluginListView.h</header> + </customwidget> + </customwidgets> + <resources> + <include location="../../../modorganizer/src/resources.qrc"/> + </resources> + <connections/> +</ui> diff --git a/libs/installer_bsplugins/src/CMakeLists.txt b/libs/installer_bsplugins/src/CMakeLists.txt new file mode 100644 index 0000000..c3c67a1 --- /dev/null +++ b/libs/installer_bsplugins/src/CMakeLists.txt @@ -0,0 +1,111 @@ +cmake_minimum_required(VERSION 3.22) + +# Fluorine port: simplified from upstream vcpkg-based build. +# Removed deps: WebEngineWidgets (LOOT dialog gutted), ryml (LootGroups gutted), +# lootcli (stubbed), lz4 (transitive via bsatk), interprocess/uuid/accumulators +# boost libs (unused), libbsarch/dds-header (unused). See README / commit for rationale. + +find_package(Qt6 REQUIRED COMPONENTS Widgets) +find_package(ZLIB REQUIRED) +find_package(Boost CONFIG REQUIRED COMPONENTS thread) +find_package(Python 3 COMPONENTS Interpreter REQUIRED) + +# ── TES form parser codegen (from ParseTES.cmake, simplified: no esp.json fetch, +# we vendor or skip the generated header on the fly) ── +include(FetchContent) +FetchContent_Declare( + esp_json + GIT_REPOSITORY https://github.com/matortheeternal/esp.json.git + GIT_TAG master +) +FetchContent_MakeAvailable(esp_json) + +set(CODEGEN_SCRIPT ${CMAKE_CURRENT_SOURCE_DIR}/MakeFormParser.py) +set(TES_INCLUDE_FILES "") +foreach(GAME SSE) + set(INPUT_FILE ${esp_json_SOURCE_DIR}/data/${GAME}.json) + set(OUTPUT_FILE ${CMAKE_CURRENT_BINARY_DIR}/include/FormParser.${GAME}.inl) + add_custom_command( + OUTPUT ${OUTPUT_FILE} + COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/include + COMMAND ${Python_EXECUTABLE} ${CODEGEN_SCRIPT} ${INPUT_FILE} ${OUTPUT_FILE} + DEPENDS ${CODEGEN_SCRIPT} ${INPUT_FILE} + VERBATIM + ) + list(APPEND TES_INCLUDE_FILES ${OUTPUT_FILE}) +endforeach() + +# ── Source collection ── +file(GLOB_RECURSE BSPLUGINS_SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/BSPluginInfo/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/BSPluginList/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/GUI/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/MOPlugin/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/MOTools/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/TESData/*.cpp +) +file(GLOB_RECURSE BSPLUGINS_HEADERS + ${CMAKE_CURRENT_SOURCE_DIR}/BSPluginInfo/*.h + ${CMAKE_CURRENT_SOURCE_DIR}/BSPluginList/*.h + ${CMAKE_CURRENT_SOURCE_DIR}/GUI/*.h + ${CMAKE_CURRENT_SOURCE_DIR}/MOPlugin/*.h + ${CMAKE_CURRENT_SOURCE_DIR}/MOTools/*.h + ${CMAKE_CURRENT_SOURCE_DIR}/TESData/*.h + ${CMAKE_CURRENT_SOURCE_DIR}/TESFile/*.h +) +file(GLOB BSPLUGINS_UI + ${CMAKE_CURRENT_SOURCE_DIR}/BSPluginInfo/*.ui + ${CMAKE_CURRENT_SOURCE_DIR}/BSPluginList/*.ui + ${CMAKE_CURRENT_SOURCE_DIR}/GUI/*.ui +) +file(GLOB BSPLUGINS_QRC + ${CMAKE_CURRENT_SOURCE_DIR}/*.qrc +) + +add_library(bsplugins SHARED + ${BSPLUGINS_SOURCES} + ${BSPLUGINS_HEADERS} + ${BSPLUGINS_UI} + ${BSPLUGINS_QRC} + ${TES_INCLUDE_FILES} +) + +set_target_properties(bsplugins PROPERTIES + AUTOMOC ON + AUTOUIC ON + AUTORCC ON + CXX_STANDARD 20 + CXX_STANDARD_REQUIRED ON +) + +target_include_directories(bsplugins + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} + PRIVATE + ${CMAKE_CURRENT_BINARY_DIR}/include + # Bare uibase includes (<iplugin.h>, <log.h>, <gameplugins.h>, etc.) + ${MO2_UIBASE_INCLUDE_DIRS} + # moc's preprocessor needs these on -I so quote-includes like + # `#include "IPluginPanel.h"` inside MOPlugin/BSPlugins.h resolve. + ${CMAKE_CURRENT_SOURCE_DIR}/MOPlugin + ${CMAKE_CURRENT_SOURCE_DIR}/BSPluginInfo + ${CMAKE_CURRENT_SOURCE_DIR}/BSPluginList + ${CMAKE_CURRENT_SOURCE_DIR}/GUI + ${CMAKE_CURRENT_SOURCE_DIR}/MOTools + ${CMAKE_CURRENT_SOURCE_DIR}/TESData + ${CMAKE_CURRENT_SOURCE_DIR}/TESFile +) + +target_link_libraries(bsplugins PRIVATE + mo2::uibase + mo2::bsatk + Qt6::Widgets + Boost::thread + ZLIB::ZLIB +) + +# Install into fluorine plugins dir (uses fomod_plus-style shim). +if(COMMAND mo2_install_plugin) + mo2_install_plugin(bsplugins) +elseif(COMMAND mo2_install_target) + mo2_install_target(bsplugins) +endif() diff --git a/libs/installer_bsplugins/src/GUI/CopyEventFilter.cpp b/libs/installer_bsplugins/src/GUI/CopyEventFilter.cpp new file mode 100644 index 0000000..abd23fd --- /dev/null +++ b/libs/installer_bsplugins/src/GUI/CopyEventFilter.cpp @@ -0,0 +1,53 @@ +#include "CopyEventFilter.h" + +#include <QClipboard> +#include <QGuiApplication> +#include <QKeyEvent> + +namespace GUI +{ + +CopyEventFilter::CopyEventFilter(QAbstractItemView* view, int column, int role) + : CopyEventFilter(view, [=](const auto& index) { + return index.sibling(index.row(), column).data(role).toString(); + }) +{} + +CopyEventFilter::CopyEventFilter(QAbstractItemView* view, + std::function<QString(const QModelIndex&)> format) + : QObject(view), m_View{view}, m_Format{format} +{} + +void CopyEventFilter::copySelection() const +{ + if (!m_View->selectionModel()->hasSelection()) { + return; + } + + // sort to reflect the visual order + QModelIndexList selectedRows = m_View->selectionModel()->selectedRows(); + std::ranges::sort(selectedRows, [this](const auto& lidx, const auto& ridx) { + return m_View->visualRect(lidx).top() < m_View->visualRect(ridx).top(); + }); + + QStringList rows; + for (const auto& idx : selectedRows) { + rows.append(m_Format(idx)); + } + + QGuiApplication::clipboard()->setText(rows.join("\n")); +} + +bool CopyEventFilter::eventFilter(QObject* sender, QEvent* event) +{ + if (sender == m_View && event->type() == QEvent::KeyPress) { + QKeyEvent* const keyEvent = static_cast<QKeyEvent*>(event); + if (keyEvent->modifiers() == Qt::ControlModifier && keyEvent->key() == Qt::Key_C) { + copySelection(); + return true; + } + } + return QObject::eventFilter(sender, event); +} + +} // namespace GUI diff --git a/libs/installer_bsplugins/src/GUI/CopyEventFilter.h b/libs/installer_bsplugins/src/GUI/CopyEventFilter.h new file mode 100644 index 0000000..fca7b61 --- /dev/null +++ b/libs/installer_bsplugins/src/GUI/CopyEventFilter.h @@ -0,0 +1,47 @@ +#ifndef GUI_COPY_EVENT_FILTER_H +#define GUI_COPY_EVENT_FILTER_H + +#include <QAbstractItemView> +#include <QModelIndex> +#include <QObject> + +#include <functional> + +namespace GUI +{ + +// this small class provides copy on Ctrl+C and also +// exposes a method to actual copy the selection +// +// the way the selection is copied can be customized by +// passing a functor to format each index, by default +// it only extracts the display role +// +// only works for view that selects whole row since it only +// considers the first cell in each row +// +class CopyEventFilter : public QObject +{ + Q_OBJECT + +public: + explicit CopyEventFilter(QAbstractItemView* view, int column = 0, + int role = Qt::DisplayRole); + CopyEventFilter(QAbstractItemView* view, + std::function<QString(const QModelIndex&)> format); + + // copy the selection of the view associated with this + // event filter into the clipboard + // + void copySelection() const; + + bool eventFilter(QObject* sender, QEvent* event) override; + +private: + QAbstractItemView* m_View; + std::function<QString(const QModelIndex&)> m_Format; +}; + +} // namespace GUI + +#endif // GUI_COPY_EVENT_FILTER_H diff --git a/libs/installer_bsplugins/src/GUI/GenericIconDelegate.cpp b/libs/installer_bsplugins/src/GUI/GenericIconDelegate.cpp new file mode 100644 index 0000000..9e5f31c --- /dev/null +++ b/libs/installer_bsplugins/src/GUI/GenericIconDelegate.cpp @@ -0,0 +1,29 @@ +#include "GenericIconDelegate.h" + +namespace GUI +{ + +GenericIconDelegate::GenericIconDelegate(QTreeView* parent, int role, int logicalIndex, + int compactSize) + : IconDelegate(parent, logicalIndex, compactSize), m_Role{role} +{} + +QList<QString> GenericIconDelegate::getIcons(const QModelIndex& index) const +{ + QList<QString> result; + if (index.isValid()) { + for (const QVariant& var : index.data(m_Role).toList()) { + if (!compact() || !var.toString().isEmpty()) { + result.append(var.toString()); + } + } + } + return result; +} + +int GenericIconDelegate::getNumIcons(const QModelIndex& index) const +{ + return index.data(m_Role).toList().count(); +} + +} // namespace GUI diff --git a/libs/installer_bsplugins/src/GUI/GenericIconDelegate.h b/libs/installer_bsplugins/src/GUI/GenericIconDelegate.h new file mode 100644 index 0000000..c0d517e --- /dev/null +++ b/libs/installer_bsplugins/src/GUI/GenericIconDelegate.h @@ -0,0 +1,45 @@ +#ifndef GUI_GENERICICONDELEGATE_H +#define GUI_GENERICICONDELEGATE_H + +#include "IconDelegate.h" + +namespace GUI +{ + +/** + * @brief an icon delegate that takes the list of icons from a user-defines data role + */ +class GenericIconDelegate : public IconDelegate +{ + Q_OBJECT +public: + /** + * @brief constructor + * @param parent parent object + * @param role role of the itemmodel from which the icon list can be queried (as a + * QVariantList) + * @param logicalIndex logical index within the model. This is part of a "hack". + * Normally "empty" icons will be allocated the same space as a regular icon. This way + * the model can use empty icons as spacers and thus align same icons horizontally. + * Now, if you set the logical Index to a valid column and connect + * the columnResized slot to the sectionResized signal of the view, the delegate will + * turn off this behaviour if the column is smaller than "compactSize" + * @param compactSize see explanation of logicalIndex + */ + GenericIconDelegate(QTreeView* parent, int role, int logicalIndex = -1, + int compactSize = 150); + +private: + [[nodiscard]] QList<QString> getIcons(const QModelIndex& index) const override; + [[nodiscard]] int getNumIcons(const QModelIndex& index) const override; + +private: + int m_Role; + int m_LogicalIndex; + int m_CompactSize; + bool m_Compact; +}; + +} // namespace GUI + +#endif // GUI_GENERICICONDELEGATE_H diff --git a/libs/installer_bsplugins/src/GUI/IGeometrySettings.h b/libs/installer_bsplugins/src/GUI/IGeometrySettings.h new file mode 100644 index 0000000..681fc11 --- /dev/null +++ b/libs/installer_bsplugins/src/GUI/IGeometrySettings.h @@ -0,0 +1,44 @@ +#ifndef GUI_IGEOMETRYSETTINGS_H +#define GUI_IGEOMETRYSETTINGS_H + +#include <concepts> + +namespace GUI +{ + +template <class W> +class IGeometrySettings +{ +public: + virtual void saveGeometry(const W* widget) = 0; + virtual void restoreGeometry(W* widget) const = 0; +}; + +template <class W> +class [[nodiscard]] GeometrySaver final +{ +public: + GeometrySaver(IGeometrySettings<W>& s, W* w) : m_Settings{s}, m_Widget{w} + { + m_Settings.restoreGeometry(m_Widget); + } + + GeometrySaver(const GeometrySaver<W>&) = delete; + GeometrySaver(GeometrySaver<W>&&) = delete; + + ~GeometrySaver() { m_Settings.saveGeometry(m_Widget); } + + GeometrySaver<W>& operator=(const GeometrySaver<W>&) = delete; + GeometrySaver<W>& operator=(GeometrySaver<W>&&) = delete; + +private: + IGeometrySettings<W>& m_Settings; + W* m_Widget; +}; + +template <class W, std::derived_from<W> Derived> +GeometrySaver(IGeometrySettings<W>&, Derived*) -> GeometrySaver<W>; + +} // namespace GUI + +#endif // GUI_IGEOMETRYSETTINGS_H diff --git a/libs/installer_bsplugins/src/GUI/IStateSettings.h b/libs/installer_bsplugins/src/GUI/IStateSettings.h new file mode 100644 index 0000000..15425a0 --- /dev/null +++ b/libs/installer_bsplugins/src/GUI/IStateSettings.h @@ -0,0 +1,44 @@ +#ifndef GUI_ISTATESETTINGS_H +#define GUI_ISTATESETTINGS_H + +#include <concepts> + +namespace GUI +{ + +template <class W> +class IStateSettings +{ +public: + virtual void saveState(const W* widget) = 0; + virtual void restoreState(W* widget) const = 0; +}; + +template <class W> +class [[nodiscard]] StateSaver final +{ +public: + StateSaver(IStateSettings<W>& s, W* w) : m_Settings{s}, m_Widget{w} + { + m_Settings.restoreState(m_Widget); + } + + StateSaver(const StateSaver<W>&) = delete; + StateSaver(StateSaver<W>&&) = delete; + + ~StateSaver() { m_Settings.saveState(m_Widget); } + + StateSaver<W>& operator=(const StateSaver<W>&) = delete; + StateSaver<W>& operator=(StateSaver<W>&&) = delete; + +private: + IStateSettings<W>& m_Settings; + W* m_Widget; +}; + +template <class W, class I = IStateSettings<W>> +StateSaver(I&, W*) -> StateSaver<W>; + +} // namespace GUI + +#endif // GUI_ISTATESETTINGS_H diff --git a/libs/installer_bsplugins/src/GUI/IconDelegate.cpp b/libs/installer_bsplugins/src/GUI/IconDelegate.cpp new file mode 100644 index 0000000..ff99405 --- /dev/null +++ b/libs/installer_bsplugins/src/GUI/IconDelegate.cpp @@ -0,0 +1,89 @@ +#include "IconDelegate.h" + +#include <QDebug> +#include <QHBoxLayout> +#include <QHeaderView> +#include <QLabel> +#include <QPainter> +#include <QPixmapCache> +#include <algorithm> +#include <log.h> + +namespace GUI +{ + +using namespace MOBase; + +constexpr int MAX_WIDTH = 16; +constexpr int MARGIN_X = 4; + +IconDelegate::IconDelegate(QTreeView* view, int column, int compactSize) + : QStyledItemDelegate(view), m_Column{column}, m_CompactSize{compactSize}, + m_Compact{false} +{ + if (view) { + connect(view->header(), &QHeaderView::sectionResized, + [=](int logicalIndex, [[maybe_unused]] int oldSize, int newSize) { + if (logicalIndex == m_Column) { + m_Compact = newSize < m_CompactSize; + } + }); + } +} + +void IconDelegate::paintIcons(QPainter* painter, const QStyleOptionViewItem& option, + [[maybe_unused]] const QModelIndex& index, + const QList<QString>& icons) +{ + int x = MARGIN_X; + painter->save(); + + int iconWidth = + icons.size() > 0 ? ((option.rect.width() / icons.size()) - MARGIN_X) : MAX_WIDTH; + // Clamp to at least 8px so narrow columns don't make QIcon::pixmap() + // return null and spuriously log "failed to load icon". + iconWidth = std::clamp(iconWidth, 8, MAX_WIDTH); + + const int margin = (option.rect.height() - iconWidth) / 2; + + painter->translate(option.rect.topLeft()); + for (const QString& iconId : icons) { + if (iconId.isEmpty()) { + x += iconWidth + MARGIN_X; + continue; + } + QPixmap icon; + const QString fullIconId = QString("%1_%2").arg(iconId).arg(iconWidth); + if (!QPixmapCache::find(fullIconId, &icon)) { + icon = QIcon(iconId).pixmap(iconWidth, iconWidth); + if (icon.isNull()) { + log::warn("failed to load icon {}", iconId); + } + QPixmapCache::insert(fullIconId, icon); + } + painter->drawPixmap(x, margin, iconWidth, iconWidth, icon); + x += iconWidth + MARGIN_X; + } + + painter->restore(); +} + +void IconDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const +{ + if (auto* const w = qobject_cast<QAbstractItemView*>(parent())) { + w->itemDelegate()->paint(painter, option, index); + } else { + QStyledItemDelegate::paint(painter, option, index); + } + + paintIcons(painter, option, index, getIcons(index)); +} + +QSize IconDelegate::sizeHint(const QStyleOptionViewItem& option, + const QModelIndex& index) const +{ + return QSize(getNumIcons(index) * (MAX_WIDTH + MARGIN_X), option.rect.height()); +} + +} // namespace GUI diff --git a/libs/installer_bsplugins/src/GUI/IconDelegate.h b/libs/installer_bsplugins/src/GUI/IconDelegate.h new file mode 100644 index 0000000..057cd4e --- /dev/null +++ b/libs/installer_bsplugins/src/GUI/IconDelegate.h @@ -0,0 +1,44 @@ +#ifndef GUI_ICONDELEGATE_H +#define GUI_ICONDELEGATE_H + +#include <QAbstractItemView> +#include <QAbstractProxyModel> +#include <QStyledItemDelegate> +#include <QTreeView> + +namespace GUI +{ + +class IconDelegate : public QStyledItemDelegate +{ + Q_OBJECT + +public: + explicit IconDelegate(QTreeView* view, int column = -1, int compactSize = 100); + + void paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const override; + + QSize sizeHint(const QStyleOptionViewItem& option, + const QModelIndex& index) const override; + +protected: + // check if icons should be compacted or not + // + [[nodiscard]] bool compact() const { return m_Compact; } + + static void paintIcons(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index, const QList<QString>& icons); + + [[nodiscard]] virtual QList<QString> getIcons(const QModelIndex& index) const = 0; + [[nodiscard]] virtual int getNumIcons(const QModelIndex& index) const = 0; + +private: + int m_Column; + int m_CompactSize; + bool m_Compact; +}; + +} // namespace GUI + +#endif // GUI_ICONDELEGATE_H diff --git a/libs/installer_bsplugins/src/GUI/ListDialog.cpp b/libs/installer_bsplugins/src/GUI/ListDialog.cpp new file mode 100644 index 0000000..784280a --- /dev/null +++ b/libs/installer_bsplugins/src/GUI/ListDialog.cpp @@ -0,0 +1,66 @@ +#include "ListDialog.h" +#include "ui_listdialog.h" + +namespace GUI +{ + +ListDialog::ListDialog(IGeometrySettings<QDialog>& settings, QWidget* parent) + : QDialog(parent), ui{new Ui::ListDialog}, m_Settings{settings} +{ + ui->setupUi(this); + ui->filterEdit->setFocus(); + connect(ui->choiceList, &QListWidget::itemDoubleClicked, this, &QDialog::accept); +} + +ListDialog::~ListDialog() noexcept +{ + delete ui; +} + +int ListDialog::exec() +{ + GeometrySaver gs{m_Settings, this}; + return QDialog::exec(); +} + +void ListDialog::setChoices(QStringList choices) +{ + m_Choices = choices; + ui->choiceList->clear(); + ui->choiceList->addItems(m_Choices); +} + +QString ListDialog::getChoice() const +{ + if (ui->choiceList->selectedItems().length()) { + return ui->choiceList->currentItem()->text(); + } else { + return ""; + } +} + +void ListDialog::on_filterEdit_textChanged(QString filter) +{ + QStringList newChoices; + for (auto choice : m_Choices) { + if (choice.contains(filter, Qt::CaseInsensitive)) { + newChoices << choice; + } + } + ui->choiceList->clear(); + ui->choiceList->addItems(newChoices); + + if (newChoices.length() == 1) { + QListWidgetItem* item = ui->choiceList->item(0); + item->setSelected(true); + ui->choiceList->setCurrentItem(item); + } + + if (!filter.isEmpty()) { + ui->choiceList->setStyleSheet("QListWidget { border: 2px ridge #f00; }"); + } else { + ui->choiceList->setStyleSheet(""); + } +} + +} // namespace GUI diff --git a/libs/installer_bsplugins/src/GUI/ListDialog.h b/libs/installer_bsplugins/src/GUI/ListDialog.h new file mode 100644 index 0000000..8cd2373 --- /dev/null +++ b/libs/installer_bsplugins/src/GUI/ListDialog.h @@ -0,0 +1,42 @@ +#ifndef GUI_LISTDIALOG_H +#define GUI_LISTDIALOG_H + +#include "IGeometrySettings.h" + +#include <QDialog> + +namespace Ui +{ +class ListDialog; +} + +namespace GUI +{ + +class ListDialog : public QDialog +{ + Q_OBJECT + +public: + ListDialog(IGeometrySettings<QDialog>& settings, QWidget* parent = nullptr); + ~ListDialog() noexcept; + + // also saves and restores geometry + // + int exec() override; + + void setChoices(QStringList choices); + [[nodiscard]] QString getChoice() const; + +public slots: + void on_filterEdit_textChanged(QString filter); + +private: + Ui::ListDialog* ui; + IGeometrySettings<QDialog>& m_Settings; + QStringList m_Choices; +}; + +} // namespace GUI + +#endif // GUI_LISTDIALOG_H diff --git a/libs/installer_bsplugins/src/GUI/MessageDialog.cpp b/libs/installer_bsplugins/src/GUI/MessageDialog.cpp new file mode 100644 index 0000000..92197c5 --- /dev/null +++ b/libs/installer_bsplugins/src/GUI/MessageDialog.cpp @@ -0,0 +1,98 @@ +#include "MessageDialog.h" +#include "ui_messagedialog.h" + +#include <log.h> + +#include <QMainWindow> +#include <QResizeEvent> +#include <QTimer> + +using namespace MOBase; + +namespace GUI +{ + +MessageDialog::MessageDialog(const QString& text, QWidget* reference) + : QDialog(reference), ui{new Ui::MessageDialog} +{ + ui->setupUi(this); + + // very crude way to ensure no single word in the test is wider than the message + // window. ellide in the center if necessary + QFontMetrics metrics(ui->message->font()); + QString restrictedText; + QStringList lines = text.split("\n"); + foreach (const QString& line, lines) { + QString newLine; + QStringList words = line.split(" "); + foreach (const QString& word, words) { + if (word.length() > 10) { + newLine += + "<span style=\"nobreak\">" + + metrics.elidedText(word, Qt::ElideMiddle, ui->message->maximumWidth()) + + "</span>"; + } else { + newLine += word; + } + newLine += " "; + } + restrictedText += newLine + "\n"; + } + + ui->message->setText(restrictedText); + this->setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint); + this->setFocusPolicy(Qt::NoFocus); + this->setAttribute(Qt::WA_ShowWithoutActivating); + QTimer::singleShot(1000 + (text.length() * 40), this, SLOT(hide())); + if (reference != nullptr) { + QPoint position = + reference->mapToGlobal(QPoint(reference->width() / 2, reference->height())); + position.rx() -= this->width() / 2; + position.ry() -= this->height() + 5; + move(position); + } +} + +MessageDialog::~MessageDialog() +{ + delete ui; +} + +void MessageDialog::resizeEvent(QResizeEvent* event) +{ + QWidget* par = parentWidget(); + if (par != nullptr) { + QPoint position = par->mapToGlobal(QPoint(par->width() / 2, par->height())); + position.rx() -= event->size().width() / 2; + position.ry() -= event->size().height() + 5; + move(position); + } +} + +void MessageDialog::showMessage(const QString& text, QWidget* reference, + bool bringToFront) +{ + log::debug("{}", text); + + if (!reference) { + for (QWidget* w : qApp->topLevelWidgets()) { + if (dynamic_cast<QMainWindow*>(w)) { + reference = w; + break; + } + } + } + + if (!reference) { + return; + } + + MessageDialog* dialog = new MessageDialog(text, reference); + dialog->show(); + + if (bringToFront) { + reference->activateWindow(); + } +} + +} // namespace GUI diff --git a/libs/installer_bsplugins/src/GUI/MessageDialog.h b/libs/installer_bsplugins/src/GUI/MessageDialog.h new file mode 100644 index 0000000..4ea63cb --- /dev/null +++ b/libs/installer_bsplugins/src/GUI/MessageDialog.h @@ -0,0 +1,59 @@ +#ifndef GUI_MESSAGEDIALOG_H +#define GUI_MESSAGEDIALOG_H + +#include <QDialog> + +namespace Ui +{ +class MessageDialog; +} + +namespace GUI +{ + +/** + * borderless dialog used to display short messages that will automatically + * vanish after a moment + **/ +class MessageDialog : public QDialog +{ + Q_OBJECT + +public: + /** + * @brief constructor + * + * @param text the message to display + * @param reference parent widget. This will also be used to position the message at + * the bottom center of the dialog + **/ + + explicit MessageDialog(const QString& text, QWidget* reference); + + ~MessageDialog(); + + /** + * factory function for message dialogs. This can be used as a fire-and-forget. The + * message will automatically positioned to the reference dialog and get a reasonable + * view time + * + * @param text the text to display. The length of this text is used to determine how + * long the dialog is to be shown + * @param reference the reference widget on top of which the message should be + * displayed + * @param true if the message should bring MO to front to ensure this message is + * visible + **/ + static void showMessage(const QString& text, QWidget* reference, + bool bringToFront = true); + +protected: + virtual void resizeEvent(QResizeEvent* event); + +private: + Ui::MessageDialog* ui; +}; + +} // namespace GUI + +#endif // GUI_MESSAGEDIALOG_H diff --git a/libs/installer_bsplugins/src/GUI/SelectionDialog.cpp b/libs/installer_bsplugins/src/GUI/SelectionDialog.cpp new file mode 100644 index 0000000..3572abe --- /dev/null +++ b/libs/installer_bsplugins/src/GUI/SelectionDialog.cpp @@ -0,0 +1,102 @@ +#include "SelectionDialog.h" +#include "ui_selectiondialog.h" + +#include <QCommandLinkButton> + +namespace GUI +{ + +SelectionDialog::SelectionDialog(const QString& description, QWidget* parent, + const QSize& iconSize) + : QDialog(parent), ui{new Ui::SelectionDialog}, m_Choice{nullptr}, + m_ValidateByData{false}, m_IconSize{iconSize} +{ + ui->setupUi(this); + + ui->descriptionLabel->setText(description); +} + +SelectionDialog::~SelectionDialog() noexcept +{ + delete ui; +} + +void SelectionDialog::addChoice(const QString& buttonText, const QString& description, + const QVariant& data) +{ + QAbstractButton* button = + new QCommandLinkButton(buttonText, description, ui->buttonBox); + if (m_IconSize.isValid()) { + button->setIconSize(m_IconSize); + } + button->setProperty("data", data); + ui->buttonBox->addButton(button, QDialogButtonBox::AcceptRole); + if (data.isValid()) + m_ValidateByData = true; +} + +void SelectionDialog::addChoice(const QIcon& icon, const QString& buttonText, + const QString& description, const QVariant& data) +{ + QAbstractButton* button = + new QCommandLinkButton(buttonText, description, ui->buttonBox); + if (m_IconSize.isValid()) { + button->setIconSize(m_IconSize); + } + button->setIcon(icon); + button->setProperty("data", data); + ui->buttonBox->addButton(button, QDialogButtonBox::AcceptRole); + if (data.isValid()) + m_ValidateByData = true; +} + +int SelectionDialog::numChoices() const +{ + return ui->buttonBox->findChildren<QCommandLinkButton*>(QString()).count(); +} + +QVariant SelectionDialog::getChoiceData() +{ + return m_Choice->property("data"); +} + +QString SelectionDialog::getChoiceString() +{ + if ((m_Choice == nullptr) || + (m_ValidateByData && !m_Choice->property("data").isValid())) { + return QString(); + } else { + return m_Choice->text(); + } +} + +QString SelectionDialog::getChoiceDescription() +{ + if (m_Choice == nullptr) + return QString(); + else + return m_Choice->accessibleDescription(); +} + +void SelectionDialog::disableCancel() +{ + ui->cancelButton->setEnabled(false); + ui->cancelButton->setHidden(true); +} + +void SelectionDialog::on_buttonBox_clicked(QAbstractButton* button) +{ + m_Choice = button; + if (!m_ValidateByData || m_Choice->property("data").isValid()) { + this->accept(); + } else { + this->reject(); + } +} + +void SelectionDialog::on_cancelButton_clicked() +{ + this->reject(); +} + +} // namespace GUI diff --git a/libs/installer_bsplugins/src/GUI/SelectionDialog.h b/libs/installer_bsplugins/src/GUI/SelectionDialog.h new file mode 100644 index 0000000..52fdcf9 --- /dev/null +++ b/libs/installer_bsplugins/src/GUI/SelectionDialog.h @@ -0,0 +1,69 @@ +#ifndef GUI_SELECTIONDIALOG_H +#define GUI_SELECTIONDIALOG_H + +#include <QAbstractButton> +#include <QDialog> + +namespace Ui +{ +class SelectionDialog; +} + +namespace GUI +{ + +class SelectionDialog : public QDialog +{ + Q_OBJECT + +public: + explicit SelectionDialog(const QString& description, QWidget* parent = 0, + const QSize& iconSize = QSize()); + + SelectionDialog(const SelectionDialog&) = delete; + SelectionDialog(SelectionDialog&&) = delete; + + ~SelectionDialog() noexcept; + + SelectionDialog& operator=(const SelectionDialog&) = delete; + SelectionDialog& operator=(SelectionDialog&&) = delete; + + /** + * @brief add a choice to the dialog + * @param buttonText the text to be displayed on the button + * @param description the description that shows up under in small letters inside the + * button + * @param data data to be stored with the button. Please note that as soon as one + * choice has data associated with it (non-invalid QVariant) all buttons that contain + * no data will be treated as "cancel" buttons + */ + void addChoice(const QString& buttonText, const QString& description, + const QVariant& data); + + void addChoice(const QIcon& icon, const QString& buttonText, + const QString& description, const QVariant& data); + + int numChoices() const; + + QVariant getChoiceData(); + QString getChoiceString(); + QString getChoiceDescription(); + + void disableCancel(); + +private slots: + + void on_buttonBox_clicked(QAbstractButton* button); + + void on_cancelButton_clicked(); + +private: + Ui::SelectionDialog* ui; + QAbstractButton* m_Choice; + bool m_ValidateByData; + QSize m_IconSize; +}; + +} // namespace GUI + +#endif // GUI_SELECTIONDIALOG_H diff --git a/libs/installer_bsplugins/src/GUI/ViewMarkingScrollBar.cpp b/libs/installer_bsplugins/src/GUI/ViewMarkingScrollBar.cpp new file mode 100644 index 0000000..94da308 --- /dev/null +++ b/libs/installer_bsplugins/src/GUI/ViewMarkingScrollBar.cpp @@ -0,0 +1,80 @@ +#include "ViewMarkingScrollBar.h" + +#include <QPainter> +#include <QStyle> +#include <QStyleOptionSlider> + +namespace GUI +{ + +static QModelIndexList visibleIndexImpl(QTreeView* view, int column, + const QModelIndex& parent) +{ + if (parent.isValid() && !view->isExpanded(parent)) { + return {}; + } + + const auto* const model = view->model(); + QModelIndexList index; + for (int i = 0; i < model->rowCount(parent); ++i) { + index.append(model->index(i, column, parent)); + index.append(visibleIndexImpl(view, column, index.back())); + } + return index; +} + +static QModelIndexList visibleIndex(QTreeView* view, int column) +{ + return visibleIndexImpl(view, column, QModelIndex()); +} + +ViewMarkingScrollBar::ViewMarkingScrollBar(QTreeView* view, int role) + : QScrollBar(view), m_View{view}, m_Role{role} +{ + // not implemented for horizontal sliders + Q_ASSERT(this->orientation() == Qt::Vertical); +} + +QColor ViewMarkingScrollBar::color(const QModelIndex& index) const +{ + const auto data = index.data(m_Role); + if (data.canConvert<QColor>()) { + return data.value<QColor>(); + } + return QColor(); +} + +void ViewMarkingScrollBar::paintEvent(QPaintEvent* event) +{ + if (m_View->model() == nullptr) { + return; + } + QScrollBar::paintEvent(event); + + QStyleOptionSlider styleOption; + initStyleOption(&styleOption); + + QPainter painter(this); + + QRect handleRect = style()->subControlRect(QStyle::CC_ScrollBar, &styleOption, + QStyle::SC_ScrollBarSlider, this); + QRect innerRect = style()->subControlRect(QStyle::CC_ScrollBar, &styleOption, + QStyle::SC_ScrollBarGroove, this); + + auto indices = visibleIndex(m_View, 0); + + painter.translate(innerRect.topLeft() + QPoint(0, 3)); + const qreal scale = + static_cast<qreal>(innerRect.height() - 3) / static_cast<qreal>(indices.size()); + + for (int i = 0; i < indices.size(); ++i) { + const QColor color = this->color(indices[i]); + if (color.isValid()) { + painter.setPen(color); + painter.setBrush(color); + painter.drawRect(QRect(2, i * scale - 2, handleRect.width() - 5, 3)); + } + } +} + +} // namespace GUI diff --git a/libs/installer_bsplugins/src/GUI/ViewMarkingScrollBar.h b/libs/installer_bsplugins/src/GUI/ViewMarkingScrollBar.h new file mode 100644 index 0000000..750c743 --- /dev/null +++ b/libs/installer_bsplugins/src/GUI/ViewMarkingScrollBar.h @@ -0,0 +1,34 @@ +#ifndef GUI_VIEWMARKINGSCROLLBAR_H +#define GUI_VIEWMARKINGSCROLLBAR_H + +#include <QColor> +#include <QModelIndex> +#include <QPaintEvent> +#include <QScrollBar> +#include <QTreeView> + +namespace GUI +{ + +class ViewMarkingScrollBar : public QScrollBar +{ +public: + ViewMarkingScrollBar(QTreeView* view, int role); + +protected: + void paintEvent(QPaintEvent* event) override; + + // retrieve the color of the marker for the given index + // + [[nodiscard]] virtual QColor color(const QModelIndex& index) const; + +protected: + QTreeView* m_View; + +private: + int m_Role; +}; + +} // namespace GUI + +#endif // GUI_VIEWMARKINGSCROLLBAR_H diff --git a/libs/installer_bsplugins/src/GUI/listdialog.ui b/libs/installer_bsplugins/src/GUI/listdialog.ui new file mode 100644 index 0000000..7aa3eba --- /dev/null +++ b/libs/installer_bsplugins/src/GUI/listdialog.ui @@ -0,0 +1,103 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>ListDialog</class> + <widget class="QDialog" name="ListDialog"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>260</width> + <height>380</height> + </rect> + </property> + <property name="windowTitle"> + <string>Select an item...</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <item> + <widget class="QListWidget" name="choiceList"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="minimumSize"> + <size> + <width>240</width> + <height>280</height> + </size> + </property> + <property name="verticalScrollMode"> + <enum>QAbstractItemView::ScrollPerPixel</enum> + </property> + </widget> + </item> + <item> + <widget class="MOBase::LineEditClear" name="filterEdit"> + <property name="text"> + <string/> + </property> + <property name="placeholderText"> + <string>Filter</string> + </property> + </widget> + </item> + <item> + <widget class="QDialogButtonBox" name="buttonBox"> + <property name="layoutDirection"> + <enum>Qt::LeftToRight</enum> + </property> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="standardButtons"> + <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set> + </property> + </widget> + </item> + </layout> + </widget> + <customwidgets> + <customwidget> + <class>MOBase::LineEditClear</class> + <extends>QLineEdit</extends> + <header>lineeditclear.h</header> + </customwidget> + </customwidgets> + <resources/> + <connections> + <connection> + <sender>buttonBox</sender> + <signal>accepted()</signal> + <receiver>ListDialog</receiver> + <slot>accept()</slot> + <hints> + <hint type="sourcelabel"> + <x>248</x> + <y>254</y> + </hint> + <hint type="destinationlabel"> + <x>157</x> + <y>274</y> + </hint> + </hints> + </connection> + <connection> + <sender>buttonBox</sender> + <signal>rejected()</signal> + <receiver>ListDialog</receiver> + <slot>reject()</slot> + <hints> + <hint type="sourcelabel"> + <x>316</x> + <y>260</y> + </hint> + <hint type="destinationlabel"> + <x>286</x> + <y>274</y> + </hint> + </hints> + </connection> + </connections> +</ui> diff --git a/libs/installer_bsplugins/src/GUI/messagedialog.ui b/libs/installer_bsplugins/src/GUI/messagedialog.ui new file mode 100644 index 0000000..ed785ff --- /dev/null +++ b/libs/installer_bsplugins/src/GUI/messagedialog.ui @@ -0,0 +1,94 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>MessageDialog</class> + <widget class="QDialog" name="MessageDialog"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>106</width> + <height>35</height> + </rect> + </property> + <property name="sizePolicy"> + <sizepolicy hsizetype="Preferred" vsizetype="Preferred"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>16777215</width> + <height>16777215</height> + </size> + </property> + <property name="sizeIncrement"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="windowTitle"> + <string>Placeholder</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="leftMargin"> + <number>0</number> + </property> + <property name="topMargin"> + <number>0</number> + </property> + <property name="rightMargin"> + <number>0</number> + </property> + <property name="bottomMargin"> + <number>0</number> + </property> + <item> + <widget class="QLabel" name="message"> + <property name="sizePolicy"> + <sizepolicy hsizetype="MinimumExpanding" vsizetype="MinimumExpanding"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="maximumSize"> + <size> + <width>300</width> + <height>16777215</height> + </size> + </property> + <property name="sizeIncrement"> + <size> + <width>0</width> + <height>0</height> + </size> + </property> + <property name="frameShape"> + <enum>QFrame::Box</enum> + </property> + <property name="text"> + <string>Placeholder</string> + </property> + <property name="textFormat"> + <enum>Qt::RichText</enum> + </property> + <property name="alignment"> + <set>Qt::AlignCenter</set> + </property> + <property name="wordWrap"> + <bool>true</bool> + </property> + <property name="margin"> + <number>2</number> + </property> + <property name="textInteractionFlags"> + <set>Qt::NoTextInteraction</set> + </property> + </widget> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/libs/installer_bsplugins/src/GUI/selectiondialog.ui b/libs/installer_bsplugins/src/GUI/selectiondialog.ui new file mode 100644 index 0000000..9b7f7e3 --- /dev/null +++ b/libs/installer_bsplugins/src/GUI/selectiondialog.ui @@ -0,0 +1,85 @@ +<?xml version="1.0" encoding="UTF-8"?> +<ui version="4.0"> + <class>SelectionDialog</class> + <widget class="QDialog" name="SelectionDialog"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>527</width> + <height>327</height> + </rect> + </property> + <property name="windowTitle"> + <string>Select</string> + </property> + <layout class="QVBoxLayout" name="verticalLayout"> + <property name="spacing"> + <number>20</number> + </property> + <item> + <widget class="QLabel" name="descriptionLabel"> + <property name="text"> + <string>Placeholder</string> + </property> + <property name="wordWrap"> + <bool>true</bool> + </property> + </widget> + </item> + <item> + <widget class="QScrollArea" name="scrollArea"> + <property name="horizontalScrollBarPolicy"> + <enum>Qt::ScrollBarAlwaysOff</enum> + </property> + <property name="widgetResizable"> + <bool>true</bool> + </property> + <widget class="QWidget" name="scrollAreaWidgetContents"> + <property name="geometry"> + <rect> + <x>0</x> + <y>0</y> + <width>503</width> + <height>219</height> + </rect> + </property> + <layout class="QVBoxLayout" name="verticalLayout_2"> + <property name="margin"> + <number>0</number> + </property> + <item> + <widget class="QDialogButtonBox" name="buttonBox"> + <property name="sizePolicy"> + <sizepolicy hsizetype="Expanding" vsizetype="Expanding"> + <horstretch>0</horstretch> + <verstretch>0</verstretch> + </sizepolicy> + </property> + <property name="orientation"> + <enum>Qt::Vertical</enum> + </property> + <property name="standardButtons"> + <set>QDialogButtonBox::NoButton</set> + </property> + <property name="centerButtons"> + <bool>false</bool> + </property> + </widget> + </item> + </layout> + </widget> + </widget> + </item> + <item> + <widget class="QPushButton" name="cancelButton"> + <property name="text"> + <string>Cancel</string> + </property> + </widget> + </item> + </layout> + </widget> + <resources/> + <connections/> +</ui> diff --git a/libs/installer_bsplugins/src/MOPlugin/BSPlugins.cpp b/libs/installer_bsplugins/src/MOPlugin/BSPlugins.cpp new file mode 100644 index 0000000..442fbcf --- /dev/null +++ b/libs/installer_bsplugins/src/MOPlugin/BSPlugins.cpp @@ -0,0 +1,79 @@ +#include "BSPlugins.h" + +#include "BSPluginList/PluginsWidget.h" +#include "Settings.h" + +using namespace Qt::Literals::StringLiterals; + +bool BSPlugins::initPlugin(MOBase::IOrganizer* organizer) +{ + m_Organizer = organizer; + Settings::init(organizer); + return true; +} + +QString BSPlugins::name() const +{ + return NAME; +} + +std::vector<std::shared_ptr<const MOBase::IPluginRequirement>> +BSPlugins::requirements() const +{ + return {Requirements::gameDependency( + {u"Oblivion"_s, u"Fallout 3"_s, u"New Vegas"_s, u"Skyrim"_s, u"Enderal"_s, + u"Fallout 4"_s, u"Skyrim Special Edition"_s, u"Enderal Special Edition"_s, + u"Skyrim VR"_s, u"Fallout 4 VR"_s, u"Starfield"_s, u"Oblivion Remastered"_s})}; +} + +QString BSPlugins::author() const +{ + return u"Parapets"_s; +} + +QString BSPlugins::description() const +{ + return tr("Manages plugin load order for BGS game engines"); +} + +MOBase::VersionInfo BSPlugins::version() const +{ + return MOBase::VersionInfo(0, 1, 5, 0, MOBase::VersionInfo::RELEASE_BETA); +} + +QList<MOBase::PluginSetting> BSPlugins::settings() const +{ + return { + {u"enable_sort_button"_s, u"Enable the Sort button in the Plugins panel"_s, false}, + {u"external_change_warning"_s, + u"Warn if load order changes while running an executable"_s, true}, + {u"loot_show_dirty"_s, + u"LOOT: Show information about plugins that can be cleaned"_s, true}, + {u"loot_show_messages"_s, + u"LOOT: Show general information and warning messages"_s, true}, + {u"loot_show_problems"_s, + u"LOOT: Show information about incompatibilities and missing masters"_s, true}, + }; +} + +bool BSPlugins::enabledByDefault() const +{ + return false; +} + +QWidget* BSPlugins::createWidget(IPanelInterface* panelInterface, QWidget* parent) +{ + const auto widget = + new BSPluginList::PluginsWidget(m_Organizer, panelInterface, parent); + return widget; +} + +QString BSPlugins::label() const +{ + return tr("Plugins"); +} + +IPluginPanel::Position BSPlugins::position() const +{ + return Position::inPlaceOf(u"espTab"_s); +} diff --git a/libs/installer_bsplugins/src/MOPlugin/BSPlugins.h b/libs/installer_bsplugins/src/MOPlugin/BSPlugins.h new file mode 100644 index 0000000..81248d7 --- /dev/null +++ b/libs/installer_bsplugins/src/MOPlugin/BSPlugins.h @@ -0,0 +1,39 @@ +#ifndef BSPLUGINS_H +#define BSPLUGINS_H + +#include "IPluginPanel.h" + +class BSPlugins final : public IPluginPanel +{ + Q_OBJECT + Q_INTERFACES(MOBase::IPlugin IPluginPanel) + Q_PLUGIN_METADATA(IID "org.tannin.BSPlugins" FILE "bsplugins.json") + +public: + inline static const QString NAME = QStringLiteral("Bethesda Plugin Manager"); + + BSPlugins() = default; + + // IPlugin + + QString name() const override; + std::vector<std::shared_ptr<const MOBase::IPluginRequirement>> + requirements() const override; + QString author() const override; + QString description() const override; + MOBase::VersionInfo version() const override; + QList<MOBase::PluginSetting> settings() const override; + bool enabledByDefault() const override; + + // IPluginPanel + + bool initPlugin(MOBase::IOrganizer* organizer); + QWidget* createWidget(IPanelInterface* panelInterface, QWidget* parent) override; + QString label() const override; + Position position() const override; + +private: + MOBase::IOrganizer* m_Organizer; +}; + +#endif // BSPLUGINS_H diff --git a/libs/installer_bsplugins/src/MOPlugin/IPanelInterface.h b/libs/installer_bsplugins/src/MOPlugin/IPanelInterface.h new file mode 100644 index 0000000..2904e8e --- /dev/null +++ b/libs/installer_bsplugins/src/MOPlugin/IPanelInterface.h @@ -0,0 +1,30 @@ +#ifndef IPANELINTERFACE_H +#define IPANELINTERFACE_H + +#include <QList> +#include <QString> +#include <functional> + +class IPanelInterface +{ +public: + // user selected files in the panel whose origins should be highlighted + // + virtual void setSelectedFiles(const QList<QString>& selectedFiles) = 0; + + // request mod info dialog for origin of file + // + virtual void displayOriginInformation(const QString& file) = 0; + + virtual bool onPanelActivated(const std::function<void()>& func) = 0; + + virtual bool + onSelectedOriginsChanged(const std::function<void(const QList<QString>&)>& func) = 0; + + // HACK: this shouldn't be here, but the MOBase::IPluginList::setState function does + // not cause the list to notify its onPluginStateChanged listeners, so this will work + // around that + virtual void setPluginState(const QString& name, bool enable) = 0; +}; + +#endif // IPANELINTERFACE_H diff --git a/libs/installer_bsplugins/src/MOPlugin/IPluginPanel.cpp b/libs/installer_bsplugins/src/MOPlugin/IPluginPanel.cpp new file mode 100644 index 0000000..904f049 --- /dev/null +++ b/libs/installer_bsplugins/src/MOPlugin/IPluginPanel.cpp @@ -0,0 +1,99 @@ +#include "IPluginPanel.h" +#include "MOPanelInterface.h" + +#include <QTabWidget> + +using namespace Qt::Literals::StringLiterals; + +IPluginPanel::Position IPluginPanel::Position::before(const QString& panelName) +{ + return {Order::Before, panelName}; +} + +IPluginPanel::Position IPluginPanel::Position::after(const QString& panelName) +{ + return {Order::After, panelName}; +} + +IPluginPanel::Position IPluginPanel::Position::inPlaceOf(const QString& panelName) +{ + return {Order::InPlaceOf, panelName}; +} + +IPluginPanel::Position IPluginPanel::Position::atStart() +{ + return {Order::AtStart, {}}; +} + +IPluginPanel::Position IPluginPanel::Position::atEnd() +{ + return {Order::AtEnd, {}}; +} + +bool IPluginPanel::init(MOBase::IOrganizer* organizer) +{ + if (organizer == nullptr) { + return false; + } + + organizer->onUserInterfaceInitialized([this, organizer](QMainWindow* mainWindow) { + const auto tabWidget = mainWindow->findChild<QTabWidget*>(u"tabWidget"_s); + + if (tabWidget == nullptr) { + return; + } + + const auto intfc = new MOPanelInterface(organizer, mainWindow); + const auto widget = this->createWidget(intfc, tabWidget); + const auto label = this->label(); + const auto position = this->position(); + + switch (position.order_) { + case Order::Before: { + const auto refTab = !position.reference_.isNull() + ? tabWidget->findChild<QWidget*>(position.reference_) + : nullptr; + if (refTab != nullptr) { + if (const int index = tabWidget->indexOf(refTab); index != -1) { + const int currentIndex = tabWidget->currentIndex(); + tabWidget->insertTab(index, widget, label); + if (index <= currentIndex) { + tabWidget->setCurrentIndex(currentIndex); + } + } + break; + } + } + [[fallthrough]]; + case Order::AtStart: { + const int currentIndex = tabWidget->currentIndex(); + tabWidget->insertTab(0, widget, label); + tabWidget->setCurrentIndex(currentIndex); + } break; + case Order::InPlaceOf: + case Order::After: { + const auto refTab = !position.reference_.isNull() + ? tabWidget->findChild<QWidget*>(position.reference_) + : nullptr; + + if (refTab != nullptr) { + if (const int index = tabWidget->indexOf(refTab); index != -1) { + tabWidget->insertTab(index + 1, widget, label); + if (position.order_ == Order::InPlaceOf) { + tabWidget->removeTab(index); + } + } + break; + } + } + [[fallthrough]]; + case Order::AtEnd: { + tabWidget->addTab(widget, label); + } break; + } + + intfc->assignWidget(tabWidget, widget); + }); + + return this->initPlugin(organizer); +} diff --git a/libs/installer_bsplugins/src/MOPlugin/IPluginPanel.h b/libs/installer_bsplugins/src/MOPlugin/IPluginPanel.h new file mode 100644 index 0000000..0c73a9c --- /dev/null +++ b/libs/installer_bsplugins/src/MOPlugin/IPluginPanel.h @@ -0,0 +1,47 @@ +#ifndef IPLUGINPANEL_H +#define IPLUGINPANEL_H + +#include "IPanelInterface.h" + +#include <iplugin.h> + +class IPluginPanel : public QObject, public MOBase::IPlugin +{ + Q_INTERFACES(MOBase::IPlugin) + +public: + enum class Order + { + Before, + AtStart, + InPlaceOf, + After, + AtEnd, + }; + + struct Position + { + Order order_; + QString reference_; + + static Position before(const QString& panelName); + static Position after(const QString& panelName); + static Position inPlaceOf(const QString& panelName); + static Position atStart(); + static Position atEnd(); + }; + + bool init(MOBase::IOrganizer* organizer) final; + + virtual bool initPlugin(MOBase::IOrganizer* organizer) = 0; + + virtual QWidget* createWidget(IPanelInterface* callbacks, QWidget* parent) = 0; + + virtual QString label() const = 0; + + virtual Position position() const = 0; +}; + +Q_DECLARE_INTERFACE(IPluginPanel, "org.tannin.IPluginPanel/1.0") + +#endif // IPLUGINPANEL_H diff --git a/libs/installer_bsplugins/src/MOPlugin/MOPanelInterface.cpp b/libs/installer_bsplugins/src/MOPlugin/MOPanelInterface.cpp new file mode 100644 index 0000000..c8bb242 --- /dev/null +++ b/libs/installer_bsplugins/src/MOPlugin/MOPanelInterface.cpp @@ -0,0 +1,144 @@ +#include "MOPanelInterface.h" + +#include <log.h> + +#include <algorithm> +#include <functional> +#include <iterator> + +using namespace Qt::Literals::StringLiterals; + +MOPanelInterface::MOPanelInterface(MOBase::IOrganizer* organizer, + QMainWindow* mainWindow) + : m_ModList{organizer->modList()}, m_PluginList{organizer->pluginList()}, + m_ModListView{mainWindow->findChild<QTreeView*>(u"modList"_s)}, + m_PluginListView{mainWindow->findChild<QTreeView*>(u"espList"_s)} +{ + if (m_ModListView) { + QObject::connect(m_ModListView, &QTreeView::collapsed, this, + &MOPanelInterface::onModSeparatorCollapsed); + QObject::connect(m_ModListView, &QTreeView::expanded, this, + &MOPanelInterface::onModSeparatorExpanded); + QObject::connect(m_ModListView->selectionModel(), + &QItemSelectionModel::selectionChanged, this, + &MOPanelInterface::onModSelectionChanged); + } +} + +MOPanelInterface::~MOPanelInterface() noexcept +{ + m_SelectedOriginsChanged.disconnect_all_slots(); +} + +void MOPanelInterface::assignWidget(QTabWidget* tabWidget, QWidget* panel) +{ + QObject::connect(tabWidget, &QTabWidget::currentChanged, + [this, tabWidget, panel](int index) { + QWidget* const currentWidget = tabWidget->widget(index); + if (currentWidget == panel) { + m_PanelActivated(); + } + }); +} + +void MOPanelInterface::setSelectedFiles(const QList<QString>& selectedFiles) +{ + if (!m_PluginListView) { + return; + } + + QItemSelection selection; + const auto model = m_PluginListView->model(); + for (int row = 0, count = model->rowCount(); row < count; ++row) { + const auto index = model->index(row, 0); + if (selectedFiles.contains(index.data(Qt::DisplayRole))) { + const auto end = model->index(row, model->columnCount() - 1); + selection.select(index, end); + } + } + + m_PluginListView->selectionModel()->select(selection, + QItemSelectionModel::ClearAndSelect); +} + +// FIXME: only works for files in the plugins panel +void MOPanelInterface::displayOriginInformation(const QString& file) +{ + const auto model = m_PluginListView->model(); + for (int row = 0, count = model->rowCount(); row < count; ++row) { + const auto index = model->index(row, 0); + const auto other = index.data(Qt::DisplayRole).toString(); + if (file.compare(other, Qt::CaseInsensitive) == 0) { + m_PluginListView->selectionModel()->select( + QItemSelection(index, model->index(row, model->columnCount() - 1)), + QItemSelectionModel::ClearAndSelect); + m_PluginListView->selectionModel()->setCurrentIndex(index, + QItemSelectionModel::Current); + m_PluginListView->doubleClicked(index); + return; + } + } + + MOBase::log::warn("failed to open origin info for \"{}\"", file); +} + +bool MOPanelInterface::onPanelActivated(const std::function<void()>& func) +{ + auto connection = m_PanelActivated.connect(func); + return connection.connected(); +} + +bool MOPanelInterface::onSelectedOriginsChanged( + const std::function<void(const QList<QString>&)>& func) +{ + auto connection = m_SelectedOriginsChanged.connect(func); + return connection.connected(); +} + +void MOPanelInterface::setPluginState(const QString& name, bool enable) +{ + const auto model = m_PluginListView->model(); + for (int i = 0, count = model->rowCount(); i < count; ++i) { + const auto index = model->index(i, 0); + if (index.data().toString().compare(name, Qt::CaseInsensitive) == 0) { + model->setData(index, enable ? Qt::Checked : Qt::Unchecked, Qt::CheckStateRole); + } + } +} + +void MOPanelInterface::onModSeparatorCollapsed(const QModelIndex& index) +{ + if (m_ModListView->selectionModel()->isSelected(index)) { + onModSelectionChanged(); + } +} + +void MOPanelInterface::onModSeparatorExpanded(const QModelIndex& index) +{ + if (m_ModListView->selectionModel()->isSelected(index)) { + onModSelectionChanged(); + } +} + +void MOPanelInterface::onModSelectionChanged() +{ + QList<QString> origins; + std::function<void(const QModelIndex&)> addOrigins; + addOrigins = [&](const QModelIndex& index) { + if (index.model()->hasChildren(index)) { + if (m_ModListView->isExpanded(index)) { + return; + } + + for (int i = 0, count = index.model()->rowCount(index); i < count; ++i) { + addOrigins(index.model()->index(i, 0, index)); + } + } else { + origins.append(index.data(Qt::DisplayRole).toString()); + } + }; + + const auto indexes = m_ModListView->selectionModel()->selectedRows(); + std::ranges::for_each(indexes, addOrigins); + m_SelectedOriginsChanged(origins); +} diff --git a/libs/installer_bsplugins/src/MOPlugin/MOPanelInterface.h b/libs/installer_bsplugins/src/MOPlugin/MOPanelInterface.h new file mode 100644 index 0000000..184d087 --- /dev/null +++ b/libs/installer_bsplugins/src/MOPlugin/MOPanelInterface.h @@ -0,0 +1,60 @@ +#ifndef MOPANELINTERFACE_H +#define MOPANELINTERFACE_H + +#include "IPanelInterface.h" + +#include <imoinfo.h> + +#include <boost/signals2.hpp> + +#include <QMainWindow> +#include <QTreeView> + +class MOPanelInterface final : public QObject, public IPanelInterface +{ + Q_OBJECT + +public: + using SignalPanelActivated = boost::signals2::signal<void()>; + using SignalSelectedOriginsChanged = + boost::signals2::signal<void(const QList<QString>&)>; + + MOPanelInterface(MOBase::IOrganizer* organizer, QMainWindow* mainWindow); + + MOPanelInterface(const MOPanelInterface&) = delete; + MOPanelInterface(MOPanelInterface&&) = delete; + + ~MOPanelInterface() noexcept; + + MOPanelInterface& operator=(const MOPanelInterface&) = delete; + MOPanelInterface& operator=(MOPanelInterface&&) = delete; + + void assignWidget(QTabWidget* tabWidget, QWidget* panel); + + void setSelectedFiles(const QList<QString>& selectedFiles) override; + + void displayOriginInformation(const QString& file) override; + + bool onPanelActivated(const std::function<void()>& func) override; + + bool onSelectedOriginsChanged( + const std::function<void(const QList<QString>&)>& func) override; + + void setPluginState(const QString& name, bool enable) override; + +private slots: + void onModSeparatorCollapsed(const QModelIndex& index); + void onModSeparatorExpanded(const QModelIndex& index); + void onModSelectionChanged(); + +private: + MOBase::IModList* m_ModList; + MOBase::IPluginList* m_PluginList; + QTreeView* m_ModListView; + QTreeView* m_PluginListView; + + SignalPanelActivated m_PanelActivated; + SignalSelectedOriginsChanged m_SelectedOriginsChanged; +}; + +#endif // MOPANELINTERFACE_H diff --git a/libs/installer_bsplugins/src/MOPlugin/Settings.cpp b/libs/installer_bsplugins/src/MOPlugin/Settings.cpp new file mode 100644 index 0000000..8ca77ec --- /dev/null +++ b/libs/installer_bsplugins/src/MOPlugin/Settings.cpp @@ -0,0 +1,254 @@ +#include "Settings.h" +#include "BSPlugins.h" + +#include <QDir> +#include <QStandardPaths> +#include <QTreeView> + +Settings* Instance = nullptr; + +static QString findIniPath(MOBase::IOrganizer* organizer) +{ + // FIXME: we can't find non-portable instance paths + return QDir(organizer->basePath()).filePath("ModOrganizer.ini"); +} + +Settings::Settings(MOBase::IOrganizer* organizer) + : Organizer{organizer}, MOSettings{findIniPath(organizer), QSettings::IniFormat} +{ + organizer->onPluginSettingChanged( + std::bind_front(&Settings::onPluginSettingChanged, this)); +} + +void Settings::init(MOBase::IOrganizer* organizer) +{ + Instance = new Settings(organizer); +} + +Settings* Settings::instance() +{ + return Instance; +} + +void Settings::set(const QString& setting, const QVariant& value) +{ + Organizer->setPersistent(BSPlugins::NAME, setting, value); +} + +[[nodiscard]] QVariant Settings::get(const QString& setting, const QVariant& def) const +{ + return Organizer->persistent(BSPlugins::NAME, setting, def); +} + +bool Settings::onSettingChanged( + const std::function<void(const QString&, const QVariant&, const QVariant&)>& func) +{ + auto connection = SettingChanged.connect(func); + return connection.connected(); +} + +QColor Settings::overwrittenLooseFilesColor() const +{ + return MOSettings.value("Settings/overwrittenLooseFilesColor", QColor(0, 255, 0, 64)) + .value<QColor>(); +} + +QColor Settings::overwritingLooseFilesColor() const +{ + return MOSettings.value("Settings/overwritingLooseFilesColor", QColor(255, 0, 0, 64)) + .value<QColor>(); +} + +QColor Settings::overwrittenArchiveFilesColor() const +{ + return MOSettings + .value("Settings/overwrittenArchiveFilesColor", QColor(0, 255, 255, 64)) + .value<QColor>(); +} + +QColor Settings::overwritingArchiveFilesColor() const +{ + return MOSettings + .value("Settings/overwritingArchiveFilesColor", QColor(255, 0, 255, 64)) + .value<QColor>(); +} + +QColor Settings::containedColor() const +{ + return MOSettings.value("Settings/containedColor", QColor(0, 0, 255, 64)) + .value<QColor>(); +} + +bool Settings::offlineMode() const +{ + return MOSettings.value("Settings/offline_mode", false).value<bool>(); +} + +lootcli::LogLevels Settings::lootLogLevel() const +{ + // LOOT integration removed for Fluorine port — return default. + return lootcli::LogLevels::Info; +} + +bool Settings::externalChangeWarning() const +{ + return Organizer->pluginSetting(BSPlugins::NAME, "external_change_warning") + .value<bool>(); +} + +bool Settings::enableSortButton() const +{ + return Organizer->pluginSetting(BSPlugins::NAME, "enable_sort_button").value<bool>(); +} + +bool Settings::lootShowDirty() const +{ + return Organizer->pluginSetting(BSPlugins::NAME, "loot_show_dirty").value<bool>(); +} + +bool Settings::lootShowMessages() const +{ + return Organizer->pluginSetting(BSPlugins::NAME, "loot_show_messages").value<bool>(); +} + +bool Settings::lootShowProblems() const +{ + return Organizer->pluginSetting(BSPlugins::NAME, "loot_show_problems").value<bool>(); +} + +static QString stateSettingName(const QHeaderView* header) +{ + return header->parent()->objectName() + "_header"; +} + +static void visitRows(const QAbstractItemModel* model, + std::function<void(const QModelIndex&)> visit, + const QModelIndex& root = QModelIndex()) +{ + if (root.isValid()) { + visit(root); + } + + for (int i = 0, count = model->rowCount(root); i < count; ++i) { + const auto idx = model->index(i, 0, root); + visitRows(model, visit, idx); + } +} + +void Settings::saveTreeExpandState(const QTreeView* view) +{ + QVariantList expanded; + visitRows(view->model(), [&expanded, view](const QModelIndex& index) { + if (view->isExpanded(index)) { + expanded.append(index.data()); + } + }); + + Organizer->setPersistent(BSPlugins::NAME, view->objectName() + "_expanded", expanded); +} + +void Settings::restoreTreeExpandState(QTreeView* view) const +{ + // empty is serialized as invalid + const QVariant state = Organizer->persistent( + BSPlugins::NAME, view->objectName() + "_expanded", QVariantList()); + + if (!state.isValid()) { + view->collapseAll(); + } else { + const auto expanded = state.toList(); + if (!expanded.isEmpty()) { + view->collapseAll(); + visitRows(view->model(), [&expanded, view](const QModelIndex& index) { + if (expanded.contains(index.data())) { + view->expand(index); + } + }); + } + } +} + +void Settings::saveState(const QHeaderView* header) +{ + Organizer->setPersistent(BSPlugins::NAME, stateSettingName(header), + header->saveState()); +} + +void Settings::restoreState(QHeaderView* header) const +{ + const auto state = + Organizer->persistent(BSPlugins::NAME, stateSettingName(header)).toByteArray(); + if (!state.isEmpty()) { + header->restoreState(state); + } +} + +static QString stateSettingName(const QSplitter* splitter) +{ + return splitter->parentWidget()->objectName() + "_splitter"; +} + +void Settings::saveState(const QSplitter* splitter) +{ + Organizer->setPersistent(BSPlugins::NAME, stateSettingName(splitter), + splitter->saveState()); +} + +void Settings::restoreState(QSplitter* splitter) const +{ + const auto state = + Organizer->persistent(BSPlugins::NAME, stateSettingName(splitter)).toByteArray(); + if (!state.isEmpty()) { + splitter->restoreState(state); + } +} + +static QString stateSettingName(const MOBase::ExpanderWidget* expander) +{ + return expander->button()->objectName() + "_state"; +} + +void Settings::saveState(const MOBase::ExpanderWidget* expander) +{ + Organizer->setPersistent(BSPlugins::NAME, stateSettingName(expander), + expander->saveState()); +} + +void Settings::restoreState(MOBase::ExpanderWidget* expander) const +{ + const auto state = + Organizer->persistent(BSPlugins::NAME, stateSettingName(expander)).toByteArray(); + if (!state.isEmpty()) { + expander->restoreState(state); + } +} + +static QString geometrySettingName(const QDialog* dialog) +{ + return dialog->objectName() + "_geometry"; +} + +void Settings::saveGeometry(const QDialog* dialog) +{ + Organizer->setPersistent(BSPlugins::NAME, geometrySettingName(dialog), + dialog->saveGeometry()); +} + +void Settings::restoreGeometry(QDialog* dialog) const +{ + const auto geometry = + Organizer->persistent(BSPlugins::NAME, geometrySettingName(dialog)).toByteArray(); + if (!geometry.isEmpty()) { + dialog->restoreGeometry(geometry); + } +} + +void Settings::onPluginSettingChanged(const QString& pluginName, const QString& key, + const QVariant& oldValue, + const QVariant& newValue) +{ + if (pluginName != BSPlugins::NAME) + return; + + SettingChanged(key, oldValue, newValue); +} diff --git a/libs/installer_bsplugins/src/MOPlugin/Settings.h b/libs/installer_bsplugins/src/MOPlugin/Settings.h new file mode 100644 index 0000000..cf93a29 --- /dev/null +++ b/libs/installer_bsplugins/src/MOPlugin/Settings.h @@ -0,0 +1,88 @@ +#ifndef SETTINGS_H +#define SETTINGS_H + +#include "GUI/IGeometrySettings.h" +#include "GUI/IStateSettings.h" + +#include <expanderwidget.h> +#include <imoinfo.h> +#include <lootcli/lootcli.h> + +#include <boost/signals2.hpp> + +#include <QColor> +#include <QDialog> +#include <QHeaderView> +#include <QSettings> +#include <QSplitter> +#include <QTreeView> + +class Settings final : public GUI::IGeometrySettings<QDialog>, + public GUI::IStateSettings<QHeaderView>, + public GUI::IStateSettings<QSplitter>, + public GUI::IStateSettings<MOBase::ExpanderWidget> +{ +public: + using SignalSettingChanged = + boost::signals2::signal<void(const QString&, const QVariant&, const QVariant&)>; + + static void init(MOBase::IOrganizer* organizer); + + [[nodiscard]] static Settings* instance(); + + void set(const QString& setting, const QVariant& value); + + [[nodiscard]] QVariant get(const QString& setting, const QVariant& def) const; + + template <typename T> + [[nodiscard]] T get(const QString& setting, const QVariant& def) const + { + return get(setting, def).value<T>(); + } + + bool onSettingChanged(const std::function<void(const QString&, const QVariant&, + const QVariant&)>& func); + + [[nodiscard]] QColor overwrittenLooseFilesColor() const; + [[nodiscard]] QColor overwritingLooseFilesColor() const; + [[nodiscard]] QColor overwrittenArchiveFilesColor() const; + [[nodiscard]] QColor overwritingArchiveFilesColor() const; + [[nodiscard]] QColor containedColor() const; + [[nodiscard]] bool offlineMode() const; + [[nodiscard]] lootcli::LogLevels lootLogLevel() const; + + [[nodiscard]] bool externalChangeWarning() const; + [[nodiscard]] bool enableSortButton() const; + [[nodiscard]] bool lootShowDirty() const; + [[nodiscard]] bool lootShowMessages() const; + [[nodiscard]] bool lootShowProblems() const; + + void saveTreeExpandState(const QTreeView* view); + void restoreTreeExpandState(QTreeView* view) const; + + void saveState(const QHeaderView* header) override; + void restoreState(QHeaderView* header) const override; + + void saveState(const QSplitter* splitter) override; + void restoreState(QSplitter* splitter) const override; + + void saveState(const MOBase::ExpanderWidget* expander) override; + void restoreState(MOBase::ExpanderWidget* expander) const override; + + // IGeometrySettings<QDialog> + + void saveGeometry(const QDialog* dialog) override; + void restoreGeometry(QDialog* dialog) const override; + +private: + void onPluginSettingChanged(const QString& pluginName, const QString& key, + const QVariant& oldValue, const QVariant& newValue); + + explicit Settings(MOBase::IOrganizer* organizer); + + MOBase::IOrganizer* Organizer; + QSettings MOSettings; + SignalSettingChanged SettingChanged; +}; + +#endif // SETTINGS_H diff --git a/libs/installer_bsplugins/src/MOTools/ILootCache.h b/libs/installer_bsplugins/src/MOTools/ILootCache.h new file mode 100644 index 0000000..9476979 --- /dev/null +++ b/libs/installer_bsplugins/src/MOTools/ILootCache.h @@ -0,0 +1,29 @@ +#ifndef MOTOOLS_ILOOTCACHE_H +#define MOTOOLS_ILOOTCACHE_H + +#include "Loot.h" + +#include <QString> + +namespace MOTools +{ + +class ILootCache +{ +public: + /** + * @brief clear all additional information we stored on plugins + */ + virtual void clearAdditionalInformation() = 0; + + /** + * @brief adds information from a loot report + * @param name name of the plugin to add information about + * @param plugin loot report for the plugin + */ + virtual void addLootReport(const QString& name, Loot::Plugin plugin) = 0; +}; + +} // namespace MOTools + +#endif // MOTOOLS_ILOOTCACHE_H diff --git a/libs/installer_bsplugins/src/MOTools/Loot.h b/libs/installer_bsplugins/src/MOTools/Loot.h new file mode 100644 index 0000000..414cdab --- /dev/null +++ b/libs/installer_bsplugins/src/MOTools/Loot.h @@ -0,0 +1,63 @@ +#ifndef MOTOOLS_LOOT_H +#define MOTOOLS_LOOT_H + +// LOOT integration stubbed out for Fluorine port. +// LootDialog/Loot impl/LootGroups removed (depended on Qt WebEngine + lootcli). +// Only the data types needed by ILootCache / PluginList::m_LootInfo remain. + +#include <QString> +#include <vector> + +namespace MOTools +{ + +class ILootCache; + +class Loot +{ +public: + struct Message + { + int type = 0; + QString text; + }; + + struct File + { + QString name; + QString displayName; + }; + + struct Dirty + { + qint64 crc = 0; + qint64 itm = 0; + qint64 deletedReferences = 0; + qint64 deletedNavmesh = 0; + QString cleaningUtility; + QString info; + + QString toString(bool isClean) const + { + Q_UNUSED(isClean); + return {}; + } + QString cleaningString() const { return {}; } + }; + + struct Plugin + { + QString name; + std::vector<File> incompatibilities; + std::vector<Message> messages; + std::vector<Dirty> dirty, clean; + std::vector<QString> missingMasters; + bool loadsArchive = false; + bool isMaster = false; + bool isLightMaster = false; + }; +}; + +} // namespace MOTools + +#endif // MOTOOLS_LOOT_H diff --git a/libs/installer_bsplugins/src/MakeFormParser.py b/libs/installer_bsplugins/src/MakeFormParser.py new file mode 100644 index 0000000..d09f15a --- /dev/null +++ b/libs/installer_bsplugins/src/MakeFormParser.py @@ -0,0 +1,738 @@ +from typing import * +import json +import sys + +def push(code: TextIO, name: str, signature: Optional[str] = None, + conflictType: str = 'Override', alignable: bool = True) -> None: + if signature: + code.write( + 'pushItem(item, indexStack, "{}"_ts, u"{}"_s, ConflictType::{}, {});\n' + .format(signature, name, conflictType, 'true' if alignable else 'false')) + else: + code.write( + 'pushItem(item, indexStack, u"{}"_s, ConflictType::{}, {});\n' + .format(name, conflictType, 'true' if alignable else 'false')) + +def pop(code: TextIO, name: str, signature: Optional[str] = None) -> None: + comment: str + if signature: + comment = signature + ' - ' + name + else: + comment = name + code.write('popItem(item, indexStack); // {}\n\n'.format(comment)) + +class FormatValue: + def divide(code: TextIO, format: dict[str, Any]) -> None: + value: int = int(format['value']) + code.write('item->setDisplayData(fileIndex, val.toInt() / {}.0f);\n' + .format(value)) + + def enum(code: TextIO, format: dict[str, Any]) -> None: + options: dict[str, str] = format['options'] + code.write('switch (val.toUInt()) {\n') + + val: str + label: str + for val, label in options.items(): + if val.isdigit(): + code.write('case {}U:\n'.format(val)) + else: + code.write('case "{}"_ts.value:\n'.format(val)) + code.write('item->setDisplayData(fileIndex, u"{}"_s);\n'.format(label)) + code.write('break;\n') + code.write('}\n') + + def flags(code: TextIO, format: dict[str, Any]) -> None: + code.write('item->setDisplayData(fileIndex, u""_s);\n') + mask: int = 0 + + i: int + bit: str + name: str + for i, (bit, name) in enumerate(format['flags'].items()): + mask |= (1 << int(bit)) + name = name.replace('"', '\\"') + code.write('item = item->getOrInsertChild({}, u"{}"_s);\n'.format(i, name)) + code.write('if (val.toUInt() & (1U << {})) {{\n'.format(bit)) + code.write('item->setData(fileIndex, u"{}"_s);\n'.format(name)) + code.write('}\n') + code.write('item = item->parent();\n') + if not format['flags'].get('showUnknown', False): + code.write('item->setData(' + 'fileIndex, val.toUInt() & {}U);\n'.format(mask)) + + def ScriptObjectAliasFormat(code: TextIO, format: dict[str, Any]) -> None: + # TODO + pass + + def CtdaTypeFormat(code: TextIO, format: dict[str, Any]) -> None: + # TODO + pass + + def CTDAFunctionFormat(code: TextIO, format: dict[str, Any]) -> None: + # TODO + pass + + def CTDAParam1StringFormat(code: TextIO, format: dict[str, Any]) -> None: + # TODO + pass + + def ConditionAliasFormat(code: TextIO, format: dict[str, Any]) -> None: + # TODO + pass + + def EventFunctionAndMemberFormat(code: TextIO, format: dict[str, Any]) -> None: + # TODO + pass + + def CTDAParam2StringFormat(code: TextIO, format: dict[str, Any]) -> None: + # TODO + pass + + def CTDAParam2QuestStageFormat(code: TextIO, format: dict[str, Any]) -> None: + # TODO + pass + + # TES4 + def NextObjectIDFormat(code: TextIO, format: dict[str, Any]) -> None: + # TODO + pass + + # CLMT + def ClmtMoonsPhaseLengthFormat(code: TextIO, format: dict[str, Any]) -> None: + code.write('const bool masser = (val.toUInt() & 0x40);\n') + code.write('const bool secunda = (val.toUInt() & 0x80);\n') + code.write('const QString moon = ' + 'masser && secunda ? u"Masser, Secunda"_s ' + ': masser ? u"Masser"_s ' + ': secunda ? u"Secunda"_s ' + ': u"No Moon"_s;\n') + code.write('const int phaseLength = (val.toUInt() & 0x3F);\n') + code.write('item->setDisplayData(fileIndex, u"%1 / %2"_s' + '.arg(moon).arg(phaseLength));\n') + + # CLMT + def ClmtTimeFormat(code: TextIO, format: dict[str, Any]) -> None: + code.write('const int hours = val.toUInt() / 6;\n') + code.write('const int minutes = (val.toUInt() % 6) * 10;\n') + code.write('item->setDisplayData(fileIndex, u"%1:%2"_s' + '.arg(hours, 2, 10, QChar(u\'0\'))' + '.arg(minutes, 2, 10, QChar(u\'0\')));\n') + + # LAND + def AtxtPositionFormat(code: TextIO, format: dict[str, Any]) -> None: + pass + + # NAVM + def Vertex0Format(code: TextIO, format: dict[str, Any]) -> None: + pass + + def Vertex1Format(code: TextIO, format: dict[str, Any]) -> None: + pass + + def Vertex2Format(code: TextIO, format: dict[str, Any]) -> None: + pass + + def Edge0Format(code: TextIO, format: dict[str, Any]) -> None: + pass + + def Edge1Format(code: TextIO, format: dict[str, Any]) -> None: + pass + + def Edge2Format(code: TextIO, format: dict[str, Any]) -> None: + pass + + # NPC_ + def TintLayerFormat(code: TextIO, format: dict[str, Any]) -> None: + pass + + # PACK / PLDT / PLVD + def PackageLocationAliasFormat(code: TextIO, format: dict[str, Any]) -> None: + pass + + # PERK + def PerkDATAQuestStageFormat(code: TextIO, format: dict[str, Any]) -> None: + pass + + # PERK + def EPFDActorValueFormat(code: TextIO, format: dict[str, Any]) -> None: + # TODO: lookup actor value + pass + + # QUST + def QuestAliasFormat(code: TextIO, format: dict[str, Any]) -> None: + pass + + # QUST + def QuestExternalAliasFormat(code: TextIO, format: dict[str, Any]) -> None: + pass + + # REFR + def REFRNavmeshTriangleFormat(code: TextIO, format: dict[str, Any]) -> None: + pass + + # REGN + def HideFFFF_Format(code: TextIO, format: dict[str, Any]) -> None: + code.write('if (val.toUInt() == 0xFFFF) {\n' + 'item->setDisplayData(fileIndex, u""_s);\n' + '}\n') + + # WTHR + def CloudSpeedFormat(code: TextIO, format: dict[str, Any]) -> None: + code.write('item->setDisplayData(fileIndex, (val.toInt() - 127) / 1270.0f);\n') + +def format_val(code: TextIO, format: dict[str, Any], defs: dict[str, Any]) -> None: + if 'id' in format: + format = defs[format['id']] + + type: str = format['type'] + getattr(FormatValue, type)(code, format) + +class UnionDecider: + def CTDACompValueDecider(code: TextIO) -> None: + code.write('decider = (item->parent()->childData(' + 'u"Type"_s, fileIndex).toInt() & 0x04) != 0;\n') + + def CTDAParam1Decider(code: TextIO) -> None: + # TODO: Analyze functions from exe + code.write('decider = 0;\n') + + def CTDAParam2Decider(code: TextIO) -> None: + # TODO: Analyze functions from exe + code.write('decider = 0;\n') + + def CTDAParam2VATSValueParamDecider(code: TextIO) -> None: + # TODO: Analyze functions from exe + code.write('decider = 4;\n') + + def CTDAReferenceDecider(code: TextIO) -> None: + # TODO: Analyze functions from exe + code.write('decider = 0;\n') + + def ScriptPropertyDecider(code: TextIO) -> None: + code.write('decider = ' + 'item->parent()->childData(u"Type"_s, fileIndex).toInt();\n') + + def ScriptObjFormatDecider(code: TextIO) -> None: + code.write('decider = ObjectFormat == 1;\n') + + # BOOK + def BOOKTeachesDecider(code: TextIO) -> None: + code.write('const int flags = ' + 'item->parent()->childData(u"Flags"_s, fileIndex).toInt();\n') + code.write('decider = (flags & 0x04) ? 1 : 0;\n') + + # FACT / PACK + def TypeDecider(code: TextIO) -> None: + code.write('decider = ' + 'item->parent()->childData(u"Type"_s, fileIndex).toInt();\n') + + # GMST + def GMSTUnionDecider(code: TextIO) -> None: + code.write('switch (root->childData("EDID"_ts, fileIndex)' + '.toString()[0].unicode()) {\n' + "case u'b': decider = 3; break;\n" + "case u'f': decider = 2; break;\n" + "case u'i': decider = 1; break;\n" + "case u'u': decider = 1; break;\n" + "case u's': decider = 0; break;\n" + '}\n') + + # LVLN + def COEDOwnerDecider(code: TextIO) -> None: + # TODO: Resolve "Owner" form type (NPC_ -> 0, FACT -> 1) + code.write('decider = 0;\n') + + # MGEF + def MGEFAssocItemDecider(code: TextIO) -> None: + # TODO: Move streampos +0x38 to read "Archtype" early and map to assoc type + code.write('decider = 0;\n') + + # NAVI + def NAVIIslandDataDecider(code: TextIO) -> None: + code.write('decider = ' + 'item->parent()->childData(u"Is Island"_s, fileIndex).toBool();\n') + + # NAVI + def NAVIParentDecider(code: TextIO) -> None: + code.write('const auto worldspace = item->parent()->childData(' + 'u"Parent Worldspace"_s, fileIndex).toUInt();\n') + code.write('decider = worldspace == 0x0000003CU ? 0 : 1;\n') + + # NAVM + def NVNMParentDecider(code: TextIO) -> None: + code.write('const auto worldspace = item->parent()->childData(' + 'u"Parent Worldspace"_s, fileIndex).toUInt();\n') + code.write('decider = worldspace ? 0 : 1;\n') + + # NPC_ + def NPCLevelDecider(code: TextIO) -> None: + code.write('const int flags = ' + 'item->parent()->childData(u"Flags"_s, fileIndex).toInt();\n') + code.write('decider = (flags & 0x80) ? 1 : 0;\n') + + # PACK + def PubPackCNAMDecider(code: TextIO) -> None: + code.write('const QString activityType = ' + 'item->parent()->childData("ANAM"_ts, fileIndex).toString();\n' + 'if (activityType == u"Bool"_s) {\n' + 'decider = 1;\n' + '} else if (activityType == u"Int"_s) {\n' + 'decider = 2;\n' + '} else if (activityType == u"Float"_s) {\n' + 'decider = 3;\n' + '} else {\n' + 'decider = 0;\n' + '}\n') + + # PERK + def PerkDATADecider(code: TextIO) -> None: + code.write('decider = item->parent()->findChild("PRKE"_ts)' + '->childData(u"Type"_s, fileIndex).toInt();\n') + + # PERK + def EPFDDecider(code: TextIO) -> None: + code.write('decider = item->parent()->childData(' + '"EPFT"_ts, fileIndex).toInt();\n') + +class DefineType: + def __integral(type: str, code: TextIO, element: dict[str, Any]) -> None: + code.write('QVariant val = TESFile::readType<{}>(*stream);\n'.format(type)) + if element.get('name') == 'Object Format': + code.write('ObjectFormat = val.toInt();\n') + code.write('item->setData(fileIndex, val);\n') + if 'format' in element: + format: dict[str, Any] = element['format'] + format_val(code, format, defs) + + def int0(code: TextIO, element: dict[str, Any]) -> None: + code.write('QVariant val = 0;\n') + code.write('item->setData(fileIndex, val);\n') + if 'format' in element: + format: dict[str, Any] = element['format'] + format_val(code, format, defs) + + def int8(code: TextIO, element: dict[str, Any]) -> None: + DefineType.__integral('std::int8_t', code, element) + + def int16(code: TextIO, element: dict[str, Any]) -> None: + DefineType.__integral('std::int16_t', code, element) + + def int32(code: TextIO, element: dict[str, Any]) -> None: + DefineType.__integral('std::int32_t', code, element) + + def uint8(code: TextIO, element: dict[str, Any]) -> None: + DefineType.__integral('std::uint8_t', code, element) + + def uint16(code: TextIO, element: dict[str, Any]) -> None: + DefineType.__integral('std::uint16_t', code, element) + + def uint32(code: TextIO, element: dict[str, Any]) -> None: + DefineType.__integral('std::uint32_t', code, element) + + def float(code: TextIO, element: dict[str, Any]) -> None: + code.write('QVariant val = TESFile::readType<float>(*stream);\n') + code.write('item->setData(fileIndex, val);\n') + + def string(code: TextIO, element: dict[str, Any]) -> None: + localized: bool = element.get('localized', False) + if localized: + code.write('item->setData(' + 'fileIndex, readLstring(localized, *stream), true);\n') + elif 'prefix' in element: + prefix: int = element['prefix'] + lengthType: str + if prefix == 1: + lengthType = 'std::uint8_t' + elif prefix == 2: + lengthType = 'std::uint16_t' + elif prefix == 4: + lengthType = 'std::uint32_t' + code.write( + 'item->setData(fileIndex, readWstring<{}>(*stream));\n' + .format(lengthType)) + else: + code.write( + 'item->setData(fileIndex, readZstring(*stream));\n') + + def formId(code: TextIO, element: dict[str, Any]) -> None: + code.write('item->setData(' + 'fileIndex, readFormId(masters, plugin, *stream));\n') + + def bytes(code: TextIO, element: dict[str, Any]) -> None: + size: int = element.get('size', 256) + code.write('item->setData(fileIndex, readBytes(*stream, {}));\n'.format( + size)) + + def array(code: TextIO, element: dict[str, Any]) -> None: + name: str = element['name'] + alignable: bool = True + if 'defFlags' in element: + defFlags: list[str] = element['defFlags'] + if 'notAlignable' in defFlags: + alignable = False + + code.write('if (stream->peek() != std::char_traits<char>::eof()) {\n') + if 'count' in element: + count: int = element['count'] + code.write(('for ([[maybe_unused]] int i_{} : ' + 'std::ranges::iota_view(0, {})) {{\n' + ).format(name.replace(' ', ''), count)) + elif 'counter' in element: + nameId: str = name.replace(' ', '') + counter: dict[str, Any] = element['counter'] + counterType: str = counter['type'] + if counterType == 'elementCounter': + path: str = counter['path'] + code.write(('const int count_{} = ' + 'root->childData(u"{}"_s, fileIndex).toInt();\n' + ).format(nameId, path)) + elif counterType == 'ScriptFragmentsInfoCounter': + code.write(('const int count_{} = std::popcount(' + 'item->parent()->childData(' + 'u"Flags"_s, fileIndex).toUInt() & 0x3U);\n' + ).format(nameId)) + elif counterType == 'ScriptFragmentsPackCounter': + code.write(('const int count_{} = std::popcount(' + 'item->parent()->childData(' + 'u"Flags"_s, fileIndex).toUInt() & 0x7U);\n' + ).format(nameId)) + elif counterType == 'ScriptFragmentsQuestCounter': + code.write(('const int count_{} = ' + 'item->parent()->childData(' + 'u"FragmentCount"_s, fileIndex)' + '.toInt();\n').format(nameId)) + elif counterType == 'ScriptFragmentsSceneCounter': + code.write(('const int count_{} = std::popcount(' + 'item->parent()->childData(' + 'u"Flags"_s, fileIndex).toUInt() & 0x3U);\n' + ).format(nameId)) + else: + code.write('#pragma message("warning: unknown counter type {}")'.format( + counterType)) + code.write(('for ([[maybe_unused]] int i_{0} : ' + 'std::ranges::iota_view(0, count_{0})) {{\n' + ).format(nameId)) + elif 'prefix' in element: + nameId: str = name.replace(' ', '').replace('?', '') + prefix: int = element['prefix'] + if prefix == 1: + code.write(('const int count_{} = ' + 'TESFile::readType<std::uint8_t>(*stream);\n' + ).format(nameId)) + elif prefix == 2: + code.write(('const int count_{} = ' + 'TESFile::readType<std::uint16_t>(*stream);\n' + ).format(nameId)) + elif prefix == 4: + code.write(('const int count_{} = ' + 'TESFile::readType<std::uint32_t>(*stream);\n' + ).format(nameId)) + code.write(('for ([[maybe_unused]] int i_{0} : ' + 'std::ranges::iota_view(0, count_{0})) {{\n' + ).format(nameId)) + else: + code.write('while (!stream->eof()) {\n') + arrayElement: dict[str, Any] = element['element'] + if 'id' in arrayElement: + arrayElement = defs[arrayElement['id']] + elementName: str = arrayElement['name'] + push(code, elementName, alignable=alignable) + define_type(code, arrayElement, defs) + pop(code, elementName) + code.write('}\n') + code.write('}\n') + + def struct(code: TextIO, element: dict[str, Any]) -> None: + name: str = element['name'] + + code.write('if (stream->peek() != std::char_traits<char>::eof()) {\n') + structElements: list[Any] = element['elements'] + structElement: dict[str, Any] + for structElement in structElements: + if 'id' in structElement: + structElement = defs[structElement['id']] | structElement + del structElement['id'] + conflictType = structElement.get('conflictType', 'Override') + elementName: str = structElement['name'] + push(code, elementName, conflictType=conflictType) + code.write('{\n') + define_type(code, structElement, defs) + code.write('}\n') + pop(code, elementName) + code.write('}\n') + + def union(code: TextIO, element: dict[str, Any]) -> None: + decider: str = element['decider'] + code.write('[[maybe_unused]] int decider = 0;\n') + getattr(UnionDecider, decider)(code) + + unionElements: list[Any] = element['elements'] + code.write('switch (decider) {\n') + + num: int + unionElement: dict[str, Any] + for num, unionElement in enumerate(unionElements): + if 'id' in unionElement: + unionElement = defs[unionElement['id']] + + code.write('case {}: {{\n'.format(num)) + define_type(code, unionElement, defs) + code.write('} break;\n') + code.write('}\n') + + def empty(code: TextIO, element: dict[str, Any]) -> None: + code.write('// TODO: empty\n') + + def memberStruct(code: TextIO, element: dict[str, Any]) -> None: + members: list[Any] = element['members'] + structMember: dict[str, Any] + for structMember in members: + if 'id' in structMember: + structMember = defs[structMember['id']] | structMember + del structMember['id'] + structType: str = structMember['type'] + if structType.startswith('member'): + define_member(code, structMember, defs) + else: + structSig: str = structMember['signature'].encode('unicode_escape').decode() + structName = structMember.get('name', '') + push(code, structName, signature=structSig) + code.write('if (signature == "{}"_ts) {{\n'.format(structSig)) + define_type(code, structMember, defs) + code.write('co_await std::suspend_always();\n}\n') + pop(code, structName, signature=structSig) + + def memberUnion(code: TextIO, element: dict[str, Any]) -> None: + define_member(code, element, defs) + +def define_type(code: TextIO, element: dict[str, Any], defs: dict[str, Any]) -> None: + if 'id' in element: + element = defs[element['id']] + + type: str = element['type'] + getattr(DefineType, type)(code, element) + +class MemberCondition: + def memberArray(member: dict[str, Any], defs: dict[str, Any]) -> str: + return member_condition(member['member'], defs) + + def memberStruct(member: dict[str, Any], defs: dict[str, Any]) -> str: + return member_condition(member['members'][0], defs) + + def memberUnion(member: dict[str, Any], defs: dict[str, Any]) -> str: + return ' || '.join( + (member_condition(unionMember, defs) + for unionMember in member['members'])) + + def default(member: dict[str, Any], defs: dict[str, Any]) -> str: + signature: str = member['signature'].encode('unicode_escape').decode() + return 'signature == "{}"_ts'.format(signature) + +def member_condition(member: dict[str, Any], defs: dict[str, Any]) -> str: + if 'id' in member: + member = defs[member['id']] + memberType = member['type'] + return getattr(MemberCondition, memberType, MemberCondition.default)(member, defs) + +class DefineMember: + def memberArray(code: TextIO, member: dict[str, Any], defs: dict[str, Any]) -> None: + name: str = member.get('name', 'Unknown') + conflictType: str = member.get('conflictType', 'Override') + alignable: bool = True + if 'defFlags' in member: + defFlags: list[str] = member['defFlags'] + if 'notAlignable' in defFlags: + alignable = False + + arrayMember: dict[str, Any] = member['member'] + if 'id' in arrayMember: + arrayMember = defs[arrayMember['id']] + arrayName: str = '' + if 'name' in arrayMember: + arrayName = arrayMember['name'] + push(code, name, conflictType=conflictType) + code.write('while ({}) {{\n'.format(member_condition(arrayMember, defs))) + + arrayType: str = arrayMember['type'] + if arrayType.startswith('member'): + define_member(code, arrayMember, defs) + else: + arraySig: str = arrayMember.get('signature') + if arraySig: + arraySig = arraySig.encode('unicode_escape').decode() + push(code, arrayName, signature=arraySig, conflictType=conflictType, + alignable=alignable) + + define_type(code, arrayMember, defs) + pop(code, arrayName, signature=arraySig) + + if 'signature' in arrayMember: + code.write('co_await std::suspend_always();\n') + code.write('}\n') + pop(code, name) + + def memberStruct(code: TextIO, member: dict[str, Any], defs: dict[str, Any] + ) -> None: + name: str = member.get('name', 'Unknown') + conflictType: str = member.get('conflictType', 'Override') + + push(code, name, conflictType=conflictType) + members: list[Any] = member['members'] + + structMember: dict[str, Any] + for structMember in members: + if 'id' in structMember: + structMember = defs[structMember['id']] | structMember + del structMember['id'] + structType: str = structMember['type'] + if structType.startswith('member'): + define_member(code, structMember, defs) + else: + structSig: str = structMember['signature'].encode('unicode_escape').decode() + structName: str = '' + if 'name' in structMember: + structName: str = structMember['name'] + push(code, structName, signature=structSig, conflictType=conflictType) + code.write('if (signature == "{}"_ts) {{\n'.format(structSig)) + define_type(code, structMember, defs) + code.write('co_await std::suspend_always();\n}\n') + pop(code, structName, signature=structSig) + pop(code, name) + + def memberUnion(code: TextIO, member: dict[str, Any], defs: dict[str, Any]) -> None: + name: str = member.get('name', 'Unknown') + conflictType: str = member.get('conflictType', 'Override') + + push(code, name, conflictType=conflictType) + members: list[Any] = member['members'] + + unionMember: dict[str, Any] + for unionMember in members: + if 'id' in unionMember: + unionMember = defs[unionMember['id']] + unionType: str = unionMember['type'] + code.write('if ({}) {{\n'.format(member_condition(unionMember, defs))) + if unionType.startswith('member'): + define_member(code, unionMember, defs) + else: + unionSig: str = unionMember['signature'].encode('unicode_escape').decode() + unionName: str = '' + if 'name' in unionMember: + unionName: str = unionMember['name'] + code.write('if (signature == "{}"_ts) {{\n'.format(unionSig)) + push(code, unionName, signature=unionSig) + define_type(code, unionMember, defs) + pop(code, unionName, signature=unionSig) + code.write('co_await std::suspend_always();\n}\n') + code.write('}\n') + pop(code, name) + + def default(code: TextIO, member: dict[str, Any], defs: dict[str, Any]) -> None: + name: str = member.get('name', 'Unknown') + conflictType: str = member.get('conflictType', 'Override') + + signature: str = member['signature'].encode('unicode_escape').decode() + if signature == 'VMAD': + code.write('int ObjectFormat;\n') + push(code, name, signature=signature, conflictType=conflictType) + code.write('if (signature == "{}"_ts) {{\n'.format(signature)) + define_type(code, member, defs) + code.write('co_await std::suspend_always();\n}\n') + pop(code, name, signature=signature) + +def define_member(code: TextIO, member: dict[str, Any], defs: dict[str, Any]) -> None: + type: str = member['type'] + getattr(DefineMember, type, DefineMember.default)(code, member, defs) + +def define_record(code: TextIO, definition: dict[str, Any], defs: dict[str, Any]) -> str: + signature: str = definition['signature'].encode('unicode_escape').decode() + if 'id' in definition: + definition = defs[definition['id']] | definition + + code.write( + 'template <>\n' + 'void FormParser<"{}">::parseFlags(DataItem* root, [[maybe_unused]] int fileIndex, \n' + ' [[maybe_unused]] std::uint32_t flags) const\n' + '{{\n' + '[[maybe_unused]] DataItem* item = root->getOrInsertChild(0, u"Record Flags"_s);\n' + '\n'.format(signature)) + + if 'flags' in definition: + flags: dict[str, Any] = definition['flags'] + + flagsDict: dict[str, str] = {} + flagsType: str = flags['type'] + if flagsType == 'flags': + flagsDict = flags['flags'] + elif flagsType == 'formatUnion': + formats: list[Any] = flags['formats'] + + format: dict[str, Any] + for format in formats: + flagsDict |= format['flags'] + else: + code.write('#pragma message(warning: unknown flags type {}'.format(flagsType)) + + i: int + bit: str + name: str + for i, (bit, name) in enumerate(flagsDict.items()): + code.write('item = item->getOrInsertChild({}, u"{}"_s);\n'.format(i, name)) + code.write('if (flags & (1U << {})) {{\n'.format(bit)) + code.write('item->setData(fileIndex, u"{}"_s);\n'.format(name)) + code.write('}\n') + code.write('item = item->parent();\n') + + code.write('}\n\n') + + code.write( + 'template <>\n' + 'ParseTask FormParser<"{}">::parseForm(' + ' DataItem* root, int fileIndex, [[maybe_unused]] bool localized,\n' + ' [[maybe_unused]] std::span<const std::string> masters,\n' + ' [[maybe_unused]] const std::string& plugin, const TESFile::Type& signature,\n' + ' std::istream* const& stream) const\n' + '{{\n' + 'using ConflictType = DataItem::ConflictType;' + 'DataItem* item = root;\n' + 'std::vector<int> indexStack{{1}};\n' + '\n'.format(signature)) + + members: list[Any] = definition['members'] + + member: dict[str, Any] + for member in members: + if 'id' in member: + member = defs[member['id']] | member + del member['id'] + define_member(code, member, defs) + + code.write('for (;;) {\n' + 'parseUnknown(' + 'root, indexStack.front(), fileIndex, signature, *stream);\n' + 'co_await std::suspend_always();\n' + '}\n') + code.write('}\n\n') + +if __name__ == '__main__': + dataPath: str = sys.argv[1] + outPath: str = sys.argv[2] + + dataFile: TextIO + with open(dataPath, 'r') as dataFile: + data: dict[str, Any] = json.load(dataFile) + defs: dict[str, Any] = data['defs'] + + outFile: TextIO + with open(outPath, 'w') as outFile: + outFile.write('namespace TESData\n{\n\n') + + id: str + definition: dict[str, Any] + for id, definition in defs.items(): + type: str = definition['type'] + if type == 'record' and 'signature' in definition: + define_record(outFile, definition, defs) + + outFile.write('\n}\n') diff --git a/libs/installer_bsplugins/src/ParseTES.cmake b/libs/installer_bsplugins/src/ParseTES.cmake new file mode 100644 index 0000000..9a6f4c5 --- /dev/null +++ b/libs/installer_bsplugins/src/ParseTES.cmake @@ -0,0 +1,53 @@ +cmake_minimum_required(VERSION 3.27) + +include(FetchContent) + +FetchContent_Declare( + esp_json + GIT_REPOSITORY https://github.com/matortheeternal/esp.json.git + GIT_TAG master +) + +FetchContent_MakeAvailable(esp_json) + +set(Python_FIND_VIRTUALENV STANDARD) + +# find Python before include mo2-cmake, otherwise this will trigger a bunch of CMP0111 +# due to the imported configuration mapping variables defined in mo2.cmake +find_package(Python ${MO2_PYTHON_VERSION} COMPONENTS Interpreter Development REQUIRED) +get_filename_component(Python_HOME ${Python_EXECUTABLE} PATH) +set(Python_DLL_DIR "${Python_HOME}/DLLs") +set(Python_LIB_DIR "${Python_HOME}/Lib") + +find_program(CLANG_FORMAT clang-format) + +set(CODEGEN_SCRIPT ${CMAKE_CURRENT_SOURCE_DIR}/MakeFormParser.py) + +foreach(GAME SSE) + set(INPUT_FILE ${esp_json_SOURCE_DIR}/data/${GAME}.json) + set(OUTPUT_FILE ${CMAKE_CURRENT_BINARY_DIR}/include/FormParser.${GAME}.inl) + add_custom_command( + OUTPUT ${OUTPUT_FILE} + COMMAND + ${Python_EXECUTABLE} + ${CODEGEN_SCRIPT} + ${INPUT_FILE} + ${OUTPUT_FILE} + DEPENDS + ${CODEGEN_SCRIPT} + ${INPUT_FILE} + ) + + if(CLANG_FORMAT AND EXISTS ${PROJECT_SOURCE_DIR}/.clang-format) + add_custom_command( + OUTPUT ${OUTPUT_FILE} + COMMAND + ${CLANG_FORMAT} -i + --style=file:${PROJECT_SOURCE_DIR}/.clang-format + ${OUTPUT_FILE} + APPEND + ) + endif() +endforeach() + +list(APPEND TES_INCLUDE_FILES ${OUTPUT_FILE}) diff --git a/libs/installer_bsplugins/src/TESData/AssociatedEntry.cpp b/libs/installer_bsplugins/src/TESData/AssociatedEntry.cpp new file mode 100644 index 0000000..c11697c --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/AssociatedEntry.cpp @@ -0,0 +1,100 @@ +#include "AssociatedEntry.h" + +#include <mutex> +#include <shared_mutex> +#include <vector> + +namespace TESData +{ + +AuxItem::AuxItem(const std::string& name, const AuxItem* parent) + : m_Name{name}, m_Parent{parent} +{} + +std::shared_ptr<AuxItem> AuxItem::getByIndex(int index) const +{ + std::shared_lock lk{m_Mutex}; + + if (index < 0 || index >= m_Children.size()) { + return nullptr; + } + + return m_Children.nth(index)->second; +} + +std::shared_ptr<AuxItem> AuxItem::getByName(const std::string& name) const +{ + std::shared_lock lk{m_Mutex}; + + const auto it = m_Children.find(name); + if (it == m_Children.end()) { + return nullptr; + } + + return it->second; +} + +int AuxItem::indexOf(const AuxItem* item) const +{ + std::shared_lock lk{m_Mutex}; + + const auto it = std::ranges::find(m_Children, item, [&](auto&& pair) { + return pair.second.get(); + }); + + return static_cast<int>(m_Children.index_of(it)); +} + +std::shared_ptr<AuxItem> AuxItem::insert(const std::string& name) +{ + std::unique_lock lk{m_Mutex}; + + const auto [it, inserted] = + m_Children.try_emplace(name, std::make_shared<AuxItem>(name, this)); + return it->second; +} + +std::shared_ptr<AuxMember> AuxItem::createMember(const std::string& path) +{ + std::unique_lock lk{m_Mutex}; + + auto& item = m_Member; + if (item != nullptr) { + return item; + } + + item = std::make_shared<AuxMember>(); + item->path = path; + return item; +} + +void AuxItem::setMember(std::shared_ptr<AuxMember> item) +{ + m_Member = item; +} + +AssociatedEntry::AssociatedEntry(const std::string& rootName) + : m_Root{std::make_shared<AuxItem>(rootName)} +{} + +void AssociatedEntry::forEachMember( + std::function<void(const std::shared_ptr<const AuxMember>&)> func) const +{ + std::vector<std::shared_ptr<AuxItem>> stack{m_Root}; + + while (!stack.empty()) { + const auto item = std::move(stack.back()); + stack.pop_back(); + + if (const auto member = item->member()) { + func(member); + } + + for (int i = 0; i < item->numChildren(); ++i) { + const auto child = item->getByIndex(i); + stack.push_back(child); + } + } +} + +} // namespace TESData diff --git a/libs/installer_bsplugins/src/TESData/AssociatedEntry.h b/libs/installer_bsplugins/src/TESData/AssociatedEntry.h new file mode 100644 index 0000000..82c8323 --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/AssociatedEntry.h @@ -0,0 +1,70 @@ +#ifndef TESDATA_ASSOCIATEDENTRY_H +#define TESDATA_ASSOCIATEDENTRY_H + +#include <boost/container/flat_map.hpp> + +#include <QString> + +#include <functional> +#include <memory> +#include <set> +#include <shared_mutex> +#include <string> + +namespace cont = boost::container; + +namespace TESData +{ + +using TESFileHandle = int; + +struct AuxMember +{ + std::string path; + std::set<TESFileHandle> alternatives; +}; + +class AuxItem final +{ +public: + explicit AuxItem(const std::string& name, const AuxItem* parent = nullptr); + + [[nodiscard]] const std::string& name() const { return m_Name; } + [[nodiscard]] const AuxItem* parent() const { return m_Parent; } + + [[nodiscard]] int numChildren() const { return static_cast<int>(m_Children.size()); } + [[nodiscard]] std::shared_ptr<AuxItem> getByIndex(int index) const; + [[nodiscard]] std::shared_ptr<AuxItem> getByName(const std::string& name) const; + [[nodiscard]] int indexOf(const AuxItem* item) const; + std::shared_ptr<AuxItem> insert(const std::string& name); + + [[nodiscard]] const auto& member() const { return m_Member; } + std::shared_ptr<AuxMember> createMember(const std::string& path); + void setMember(std::shared_ptr<AuxMember> item); + +private: + std::string m_Name; + const AuxItem* m_Parent; + cont::flat_map<std::string, std::shared_ptr<AuxItem>> m_Children; + std::shared_ptr<AuxMember> m_Member; + mutable std::shared_mutex m_Mutex; +}; + +class AssociatedEntry final +{ +public: + explicit AssociatedEntry(const std::string& rootName = {}); + + [[nodiscard]] std::shared_ptr<AuxItem> root() { return m_Root; } + [[nodiscard]] std::shared_ptr<const AuxItem> root() const { return m_Root; } + + void forEachMember( + std::function<void(const std::shared_ptr<const AuxMember>&)> func) const; + +private: + std::shared_ptr<AuxItem> m_Root; +}; + +} // namespace TESData + +#endif // TESDATA_ASSOCIATEDENTRY_H diff --git a/libs/installer_bsplugins/src/TESData/BranchConflictParser.cpp b/libs/installer_bsplugins/src/TESData/BranchConflictParser.cpp new file mode 100644 index 0000000..7ea33de --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/BranchConflictParser.cpp @@ -0,0 +1,133 @@ +#include "BranchConflictParser.h" + +#include <algorithm> +#include <iterator> + +namespace TESData +{ + +BranchConflictParser::BranchConflictParser(PluginList* pluginList, + const std::string& pluginName, + const RecordPath& path) + : m_PluginList{pluginList}, m_PluginName{pluginName}, m_Path{path} +{} + +bool BranchConflictParser::Group(TESFile::GroupData group) +{ + if (m_CurrentPath.groups().size() < m_Path.groups().size()) { + const auto& lastGroup = m_Path.groups()[m_CurrentPath.groups().size()]; + if (group.type() != lastGroup.type()) { + return false; + } + + if (group.hasParent()) { + const std::uint8_t localIndex = group.parent() >> 24U; + const std::string& owner = + localIndex < m_Masters.size() ? m_Masters[localIndex] : m_PluginName; + + if (!TESFile::iequals(owner, m_Path.files()[lastGroup.parent() >> 24])) { + return false; + } + } else if (group != lastGroup) { + return false; + } + } else { + if (group.hasParent() && m_Path.hasFormId()) { + if ((group.parent() & 0xFFFFFF) != (m_Path.formId() & 0xFFFFFF)) { + return false; + } + + const std::uint8_t localIndex = group.parent() >> 24U; + const std::string& owner = + localIndex < m_Masters.size() ? m_Masters[localIndex] : m_PluginName; + + if (!TESFile::iequals(owner.data(), m_Path.files()[m_Path.formId() >> 24])) { + return false; + } + } + } + + m_CurrentPath.push(group, m_Masters, m_PluginName); + return true; +} + +void BranchConflictParser::EndGroup() +{ + m_CurrentPath.pop(); +} + +bool BranchConflictParser::Form(TESFile::FormData form) +{ + m_CurrentType = form.type(); + + if (m_CurrentPath.groups().empty()) { + return form.type() == "TES4"_ts; + } + + if (m_CurrentPath.groups().size() < m_Path.groups().size()) { + return false; + } else if (m_CurrentPath.groups().size() == m_Path.groups().size()) { + if (m_Path.hasFormId()) { + if ((form.formId() & 0xFFFFFF) != (m_Path.formId() & 0xFFFFFF)) { + return false; + } + + const std::uint8_t localIndex = form.formId() >> 24U; + const std::string& owner = + localIndex < m_Masters.size() ? m_Masters[localIndex] : m_PluginName; + + if (!TESFile::iequals(owner, m_Path.files()[m_Path.formId() >> 24])) { + return false; + } + } + } + + m_CurrentPath.setFormId(form.formId(), m_Masters, m_PluginName); + + const std::uint8_t localModIndex = form.localModIndex(); + const bool isMasterRecord = localModIndex < m_Masters.size(); + return isMasterRecord; +} + +void BranchConflictParser::EndForm() +{ + if (m_CurrentType != "TES4"_ts && m_CurrentType != "TES3"_ts) { + + m_PluginList->addRecordConflict(m_PluginName, m_CurrentPath, m_CurrentType, + m_CurrentName); + } + + m_CurrentPath.unsetFormId(); + m_CurrentType = {}; + m_CurrentChunk = {}; + m_CurrentName.clear(); +} + +bool BranchConflictParser::Chunk(TESFile::Type type) +{ + m_CurrentChunk = type; + if (m_CurrentPath.groups().empty()) { + return type == "MAST"_ts; + } else { + return type == "EDID"_ts; + } +} + +void BranchConflictParser::Data(std::istream& stream) +{ + switch (m_CurrentChunk) { + case "MAST"_ts: { + const std::string master = TESFile::readZstring(stream); + if (!master.empty()) { + m_Masters.push_back(master); + } + } break; + + case "EDID"_ts: { + const std::string editorId = TESFile::readZstring(stream); + m_CurrentName = std::move(editorId); + } break; + } +} + +} // namespace TESData diff --git a/libs/installer_bsplugins/src/TESData/BranchConflictParser.h b/libs/installer_bsplugins/src/TESData/BranchConflictParser.h new file mode 100644 index 0000000..c2083bd --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/BranchConflictParser.h @@ -0,0 +1,40 @@ +#ifndef TESDATA_BRANCHCONFLICTPARSER_H +#define TESDATA_BRANCHCONFLICTPARSER_H + +#include "PluginList.h" +#include "RecordPath.h" + +#include <istream> +#include <vector> + +namespace TESData +{ + +class BranchConflictParser final +{ +public: + BranchConflictParser(PluginList* pluginList, const std::string& pluginName, + const RecordPath& path); + + bool Group(TESFile::GroupData group); + void EndGroup(); + bool Form(TESFile::FormData form); + void EndForm(); + bool Chunk(TESFile::Type type); + void Data(std::istream& stream); + +private: + PluginList* m_PluginList; + std::string m_PluginName; + TESData::RecordPath m_Path; + + std::vector<std::string> m_Masters; + RecordPath m_CurrentPath; + TESFile::Type m_CurrentType; + TESFile::Type m_CurrentChunk; + std::string m_CurrentName; +}; + +} // namespace TESData + +#endif // TESDATA_BRANCHCONFLICTPARSER_H diff --git a/libs/installer_bsplugins/src/TESData/DataItem.cpp b/libs/installer_bsplugins/src/TESData/DataItem.cpp new file mode 100644 index 0000000..c083aae --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/DataItem.cpp @@ -0,0 +1,231 @@ +#include "DataItem.h" + +#include <algorithm> +#include <iterator> + +using namespace Qt::Literals::StringLiterals; + +namespace TESData +{ + +QString DataItem::makeName(TESFile::Type signature, const QString& name) +{ + const QByteArray sig = + QByteArray(signature.data(), signature.size()).toPercentEncoding("@"_ba); + + if (!name.isEmpty()) { + return QStringLiteral("%1 - %2").arg(QString::fromLatin1(sig), name); + } else { + return QString::fromLatin1(sig); + } +} + +QVariant DataItem::data(int fileIndex) const +{ + if (fileIndex < m_Data.size()) { + return m_Data[fileIndex]; + } + + return QVariant(); +} + +QVariant DataItem::displayData(int fileIndex) const +{ + if (fileIndex < m_DisplayData.size()) { + const auto& displayData = m_DisplayData[fileIndex]; + if (displayData.isValid()) { + return displayData; + } + } + + return data(fileIndex); +} + +bool DataItem::isLosingConflict(int fileIndex, int fileCount) const +{ + if (m_ConflictType == ConflictType::Ignore || + m_ConflictType == ConflictType::Benign || + m_ConflictType == ConflictType::BenignIfAdded) { + return false; + } + + if (!m_Data.isEmpty()) { + if (fileIndex >= m_Data.length()) { + return false; + } else if (fileCount > m_Data.length() && + m_ConflictType != ConflictType::NormalIgnoreEmpty) { + return true; + } + + for (int i = fileIndex + 1; i < m_Data.length(); ++i) { + if (hasConflict(m_Data[i], m_Data[fileIndex])) { + return true; + } + } + } + + for (const auto& child : m_Children) { + if (child->isLosingConflict(fileIndex, fileCount)) { + return true; + } + } + + return false; +} + +bool DataItem::isOverriding(int fileIndex) const +{ + if (m_ConflictType == ConflictType::Ignore) { + return false; + } + + if (!m_Data.isEmpty()) { + if (fileIndex >= m_Data.length() && + m_ConflictType != ConflictType::NormalIgnoreEmpty) { + return m_ConflictType != ConflictType::Benign; + } + + for (int i = 0; i < std::min(fileIndex, static_cast<int>(m_Data.length())); ++i) { + if (m_ConflictType == ConflictType::Benign && !m_Data[i].isValid()) { + continue; + } + + if (hasConflict(m_Data[i], m_Data[fileIndex])) { + return true; + } + } + } + + for (const auto& child : m_Children) { + if (child->isOverriding(fileIndex)) { + return true; + } + } + + return false; +} + +bool DataItem::isConflicted(int fileCount) const +{ + if (m_ConflictType == ConflictType::Ignore) { + return false; + } + + if (!m_Data.isEmpty()) { + if (fileCount > m_Data.length() && + m_ConflictType != ConflictType::NormalIgnoreEmpty) { + return true; + } + + for (int i = 1; i < m_Data.length(); ++i) { + if (hasConflict(m_Data[i], m_Data[0])) { + return true; + } + } + } + + for (const auto& child : m_Children) { + if (child->isConflicted(fileCount)) { + return true; + } + } + + return false; +} + +DataItem* DataItem::findChild(TESFile::Type signature) const +{ + const auto it = std::ranges::find_if(m_Children, [&](auto&& child) { + return child->signature() == signature; + }); + + return it != std::end(m_Children) ? it->get() : nullptr; +} + +QVariant DataItem::childData(TESFile::Type signature, int fileIndex) const +{ + const auto it = std::ranges::find_if(m_Children, [&](auto&& child) { + return child->signature() == signature; + }); + + if (it == std::end(m_Children)) { + return QVariant(); + } + + return (*it)->data(fileIndex); +} + +QVariant DataItem::childData(const QString& name, int fileIndex) const +{ + const auto it = std::ranges::find_if(m_Children, [&](auto&& child) { + return child->name() == name; + }); + + if (it == std::end(m_Children)) { + return QVariant(); + } + + return (*it)->data(fileIndex); +} + +int DataItem::indexOf(const DataItem* child) const +{ + const auto it = std::ranges::find(m_Children, child, &std::shared_ptr<DataItem>::get); + if (it == std::end(m_Children)) { + return -1; + } + return std::distance(std::begin(m_Children), it); +} + +DataItem* DataItem::getOrInsertChild(int index, const QString& name, + ConflictType conflictType) +{ + if (index < m_Children.size()) { + const auto& child = m_Children[index]; + if (child->name() == name) { + return child.get(); + } + } + return insertChild(index, name, conflictType); +} + +DataItem* DataItem::getOrInsertChild(int index, TESFile::Type signature, + const QString& name, ConflictType conflictType) +{ + if (index < m_Children.size()) { + const auto& child = m_Children[index]; + if (child->signature() == signature) { + return child.get(); + } + } + return insertChild(index, signature, name, conflictType); +} + +void DataItem::setData(int fileIndex, const QVariant& data, bool caseSensitive) +{ + if (m_Data.size() <= fileIndex) { + m_Data.resize(fileIndex + 1); + } + m_Data[fileIndex] = data; + m_CaseSensitive = caseSensitive; +} + +void DataItem::setDisplayData(int fileIndex, const QVariant& data) +{ + if (m_DisplayData.size() <= fileIndex) { + m_DisplayData.resize(fileIndex + 1); + } + m_DisplayData[fileIndex] = data; +} + +bool DataItem::hasConflict(const QVariant& var1, const QVariant& var2) const +{ + if (!m_CaseSensitive && var1.userType() == QMetaType::QString && + var2.userType() == QMetaType::QString) { + return var1.toString().compare(var2.toString(), Qt::CaseInsensitive) != 0; + } else { + return var1 != var2; + } +} + +} // namespace TESData diff --git a/libs/installer_bsplugins/src/TESData/DataItem.h b/libs/installer_bsplugins/src/TESData/DataItem.h new file mode 100644 index 0000000..824a096 --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/DataItem.h @@ -0,0 +1,100 @@ +#ifndef TESDATA_DATAITEM_H +#define TESDATA_DATAITEM_H + +#include "TESFile/Type.h" + +#include <QList> +#include <QString> +#include <QVariant> + +#include <memory> +#include <utility> +#include <vector> + +namespace TESData +{ + +class DataItem final +{ +public: + enum class ConflictType + { + Ignore, + BenignIfAdded, + Benign, + Override, + Translate, + NormalIgnoreEmpty, + Critical, + FormID, + }; + + DataItem() : m_Parent{nullptr} {} + + DataItem(DataItem* parent, const QString& name, ConflictType conflictType) + : m_ConflictType{conflictType}, m_Name{name}, m_Parent{parent} + {} + + DataItem(DataItem* parent, TESFile::Type signature, const QString& name, + ConflictType conflictType) + : m_ConflictType{conflictType}, m_Signature{signature}, + m_Name{makeName(signature, name)}, m_Parent{parent} + {} + + [[nodiscard]] static QString makeName(TESFile::Type signature, const QString& name); + + [[nodiscard]] ConflictType conflictType() const { return m_ConflictType; } + [[nodiscard]] TESFile::Type signature() const { return m_Signature; } + [[nodiscard]] QString name() const { return m_Name; } + [[nodiscard]] DataItem* parent() const { return m_Parent; } + [[nodiscard]] int numChildren() const { return static_cast<int>(m_Children.size()); } + [[nodiscard]] DataItem* childAt(int index) const { return m_Children[index].get(); } + [[nodiscard]] int index() const { return m_Parent ? m_Parent->indexOf(this) : 0; } + + [[nodiscard]] QVariant rowHeader() const { return name(); } + + [[nodiscard]] QVariant data(int fileIndex) const; + [[nodiscard]] QVariant displayData(int fileIndex) const; + + [[nodiscard]] bool isLosingConflict(int fileIndex, int fileCount) const; + [[nodiscard]] bool isOverriding(int fileIndex) const; + [[nodiscard]] bool isConflicted(int fileCount) const; + + [[nodiscard]] DataItem* findChild(TESFile::Type signature) const; + [[nodiscard]] QVariant childData(TESFile::Type signature, int fileIndex) const; + [[nodiscard]] QVariant childData(const QString& name, int fileIndex) const; + [[nodiscard]] int indexOf(const DataItem* child) const; + + template <typename... Args> + DataItem* insertChild(int index, Args&&... args) + { + const auto it = m_Children.insert( + m_Children.begin() + index, + std::make_shared<DataItem>(this, std::forward<Args>(args)...)); + return it->get(); + } + + DataItem* getOrInsertChild(int index, const QString& name, + ConflictType conflictType = ConflictType::Override); + DataItem* getOrInsertChild(int index, TESFile::Type signature, const QString& name, + ConflictType conflictType = ConflictType::Override); + + void setData(int fileIndex, const QVariant& data, bool caseSensitive = false); + void setDisplayData(int fileIndex, const QVariant& data); + +private: + [[nodiscard]] bool hasConflict(const QVariant& var1, const QVariant& var2) const; + + ConflictType m_ConflictType{ConflictType::Override}; + TESFile::Type m_Signature{}; + bool m_CaseSensitive = false; + QString m_Name; + QList<QVariant> m_Data; + QList<QVariant> m_DisplayData; + DataItem* m_Parent; + std::vector<std::shared_ptr<DataItem>> m_Children; +}; + +} // namespace TESData + +#endif // BSPLUGININFO_DATAITEM_H diff --git a/libs/installer_bsplugins/src/TESData/FileConflictParser.cpp b/libs/installer_bsplugins/src/TESData/FileConflictParser.cpp new file mode 100644 index 0000000..e4958e4 --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/FileConflictParser.cpp @@ -0,0 +1,228 @@ +#include "FileConflictParser.h" +#include "PluginList.h" + +#include <log.h> + +#include <stdexcept> +#include <string_view> + +namespace TESData +{ + +FileConflictParser::FileConflictParser(PluginList* pluginList, FileInfo* plugin, + bool lightSupported, bool mediumSupported, + bool blueprintSupported) + : m_PluginList{pluginList}, m_Plugin{plugin}, m_LightSupported{lightSupported}, + m_MediumSupported{mediumSupported}, m_BlueprintSupported{blueprintSupported} +{ + m_PluginName = m_Plugin->name().toStdString(); +} + +bool FileConflictParser::Group(TESFile::GroupData group) +{ + if (group.hasDirectParent()) { + m_CurrentPath.push(group, m_Masters, m_PluginName); + m_PluginList->addGroupPlaceholder(m_PluginName, m_CurrentPath); + m_CurrentPath.pop(); + return false; + } + + if (m_Masters.empty() && group.hasFormType() && group.formType() != "GMST"_ts && + group.formType() != "DOBJ"_ts) { + return false; + } + + if (group.hasFormType() && group.formType() == "NAVI"_ts) { + return false; + } + + m_CurrentPath.push(group, m_Masters, m_PluginName); + return true; +} + +void FileConflictParser::EndGroup() +{ + m_CurrentPath.pop(); +} + +bool FileConflictParser::Form(TESFile::FormData form) +{ + m_CurrentType = form.type(); + + if (m_CurrentPath.groups().empty()) { + if (form.type() == "TES4"_ts) { + m_Plugin->setMasterFlagged(form.flags() & TESFile::RecordFlags::Master); + m_Plugin->setMediumFlagged(m_MediumSupported && + (form.flags() & TESFile::RecordFlags::Medium)); + m_Plugin->setBlueprintFlagged(m_BlueprintSupported && + (form.flags() & TESFile::RecordFlags::Blueprint)); + m_Plugin->setLightFlagged( + m_MediumSupported ? (form.flags() & TESFile::RecordFlags::SmallNew) + : m_LightSupported ? (form.flags() & TESFile::RecordFlags::SmallOld) + : false); + m_Plugin->setFormVersion(form.formVersion()); + return true; + } else { + throw std::runtime_error("Unsupported header record"); + } + } + + switch (m_CurrentPath.groups().front().formType()) { + case "DOBJ"_ts: + case "GMST"_ts: + return true; + default: + m_CurrentPath.setFormId(form.formId(), m_Masters, m_PluginName); + + const int localModIndex = form.localModIndex(); + const bool isMasterRecord = localModIndex < m_Masters.size(); + return isMasterRecord; + } +} + +void FileConflictParser::EndForm() +{ + if (m_CurrentType != "TES4"_ts && m_CurrentType != "TES3"_ts && + m_CurrentType != "GMST"_ts && m_CurrentType != "DOBJ"_ts) { + + m_PluginList->addRecordConflict(m_PluginName, m_CurrentPath, m_CurrentType, + m_CurrentName); + } + + m_CurrentPath.unsetFormId(); + m_CurrentType = {}; + m_CurrentChunk = {}; + m_CurrentName.clear(); +} + +bool FileConflictParser::Chunk(TESFile::Type type) +{ + m_CurrentChunk = type; + if (m_CurrentPath.groups().empty()) { + switch (type) { + case "HEDR"_ts: + case "MAST"_ts: + case "CNAM"_ts: + case "SNAM"_ts: + return true; + } + return false; + } else if (m_CurrentPath.groups().front().formType() == "DOBJ"_ts) { + switch (type) { + case "DNAM"_ts: + return true; + } + return false; + } else { + switch (type) { + case "EDID"_ts: + return true; + } + return false; + } +} + +void FileConflictParser::Data(std::istream& stream) +{ + if (m_CurrentPath.groups().empty()) { + return MainRecordData(stream); + } + + switch (m_CurrentPath.groups().front().formType()) { + case "DOBJ"_ts: + return DefaultObjectData(stream); + case "GMST"_ts: + return GameSettingData(stream); + default: + return StandardData(stream); + } +} + +void FileConflictParser::MainRecordData(std::istream& stream) +{ + switch (m_CurrentChunk) { + case "HEDR"_ts: { + struct Header + { + float version; + int32_t numRecords; + uint32_t nextObjectId; + }; + + const auto header = TESFile::readType<Header>(stream); + if (stream.fail()) { + MOBase::log::error("failed to read HEDR data"); + return; + } + + m_Plugin->setHasNoRecords(header.numRecords == 0); + m_Plugin->setHeaderVersion(header.version); + } break; + + case "MAST"_ts: { + const std::string master = TESFile::readZstring(stream); + if (!master.empty()) { + m_Plugin->addMaster(QString::fromStdString(master.c_str())); + m_Masters.push_back(master); + } + } break; + + case "CNAM"_ts: { + const std::string author = TESFile::readZstring(stream); + if (!author.empty()) { + m_Plugin->setAuthor(QString::fromLatin1(author.data())); + } + } break; + + case "SNAM"_ts: { + const std::string desc = TESFile::readZstring(stream); + if (!desc.empty()) { + m_Plugin->setDescription(QString::fromLatin1(desc.data())); + } + } break; + } +} + +void FileConflictParser::DefaultObjectData(std::istream& stream) +{ + switch (m_CurrentChunk) { + case "DNAM"_ts: + while (stream.peek() != std::char_traits<char>::eof()) { + const TESFile::Type name = TESFile::readType<TESFile::Type>(stream); + [[maybe_unused]] const std::uint32_t formId = + TESFile::readType<std::uint32_t>(stream); + if (name == TESFile::Type()) { + continue; + } + if (name == "BBBB"_ts) { + break; + } + m_CurrentPath.setTypeId(name); + m_PluginList->addRecordConflict(m_PluginName, m_CurrentPath, "DOBJ"_ts, ""); + } + break; + } +} + +void FileConflictParser::GameSettingData(std::istream& stream) +{ + switch (m_CurrentChunk) { + case "EDID"_ts: { + const std::string editorId = TESFile::readZstring(stream); + m_CurrentPath.setEditorId(editorId); + m_PluginList->addRecordConflict(m_PluginName, m_CurrentPath, "GMST"_ts, ""); + } break; + } +} + +void FileConflictParser::StandardData(std::istream& stream) +{ + switch (m_CurrentChunk) { + case "EDID"_ts: { + std::string editorId = TESFile::readZstring(stream); + m_CurrentName = std::move(editorId); + } break; + } +} + +} // namespace TESData diff --git a/libs/installer_bsplugins/src/TESData/FileConflictParser.h b/libs/installer_bsplugins/src/TESData/FileConflictParser.h new file mode 100644 index 0000000..fcea497 --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/FileConflictParser.h @@ -0,0 +1,51 @@ +#ifndef TESDATA_FILECONFLICTPARSER_H +#define TESDATA_FILECONFLICTPARSER_H + +#include "RecordPath.h" +#include "TESFile/Stream.h" + +#include <string> +#include <vector> + +namespace TESData +{ + +class PluginList; +class FileInfo; + +class FileConflictParser final +{ +public: + FileConflictParser(PluginList* pluginList, FileInfo* plugin, bool lightSupported, + bool mediumSupported, bool blueprintSupported); + + bool Group(TESFile::GroupData group); + void EndGroup(); + bool Form(TESFile::FormData form); + void EndForm(); + bool Chunk(TESFile::Type type); + void Data(std::istream& stream); + +private: + void MainRecordData(std::istream& stream); + void DefaultObjectData(std::istream& stream); + void GameSettingData(std::istream& stream); + void StandardData(std::istream& stream); + + PluginList* m_PluginList; + FileInfo* m_Plugin; + bool m_LightSupported; + bool m_MediumSupported; + bool m_BlueprintSupported; + + std::string m_PluginName; + std::vector<std::string> m_Masters; + RecordPath m_CurrentPath; + TESFile::Type m_CurrentType; + TESFile::Type m_CurrentChunk; + std::string m_CurrentName; +}; + +} // namespace TESData + +#endif // TESDATA_FILECONFLICTPARSER_H diff --git a/libs/installer_bsplugins/src/TESData/FileEntry.cpp b/libs/installer_bsplugins/src/TESData/FileEntry.cpp new file mode 100644 index 0000000..4eb1e02 --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/FileEntry.cpp @@ -0,0 +1,227 @@ +#include "FileEntry.h" + +#include <algorithm> +#include <iterator> +#include <mutex> +#include <shared_mutex> +#include <utility> + +namespace TESData +{ + +FileEntry::FileEntry(TESFileHandle handle, const std::string& name) + : m_Handle{handle}, m_Name{name}, m_Root{std::make_shared<TreeItem>()} +{} + +void FileEntry::forEachRecord( + std::function<void(const std::shared_ptr<const Record>&)> func) const +{ + std::vector<std::shared_ptr<TreeItem>> stack; + stack.push_back(m_Root); + while (!stack.empty()) { + const auto item = std::move(stack.back()); + stack.pop_back(); + + if (item->record) { + func(item->record); + } + + for (const auto& [identifier, child] : item->children) { + stack.push_back(child); + } + } +} + +std::shared_ptr<Record> FileEntry::createRecord(const RecordPath& path, + const std::string& name, + TESFile::Type formType) +{ + const auto item = createHierarchy(path); + + std::unique_lock lk{m_Mutex}; + + if (!item->record) { + item->record = std::make_shared<Record>(); + lk.unlock(); + + item->record->setIdentifier(path.identifier(), path.files()); + item->record->addAlternative(m_Handle); + item->name = name; + item->formType = formType; + } + + return item->record; +} + +void FileEntry::addRecord(const RecordPath& path, const std::string& name, + TESFile::Type formType, std::shared_ptr<Record> record) +{ + record->addAlternative(m_Handle); + const auto item = createHierarchy(path); + item->record = record; + item->name = name; + item->formType = formType; +} + +void FileEntry::addChildGroup(const RecordPath& path) +{ + const auto item = findItem(path); + if (!item || !item->record) { + // no record to add children to + return; + } + + TESFile::GroupData group = path.groups().back(); + if (group.hasParent()) { + const auto& file = path.files()[group.parent() >> 24]; + const std::uint8_t newIndex = static_cast<std::uint8_t>( + std::distance(std::begin(m_Files), TESFile::find(m_Files, file))); + + if (newIndex == m_Files.size()) { + m_Files.push_back(file); + } + + group.setLocalIndex(newIndex); + } + + item->group = group; +} + +std::shared_ptr<Record> FileEntry::findRecord(const RecordPath& path) const +{ + const auto item = findItem(path); + return item ? item->record : nullptr; +} + +std::shared_ptr<FileEntry::TreeItem> FileEntry::findItem(const RecordPath& path) const +{ + std::shared_lock lk{m_Mutex}; + + const auto groups = path.groups(); + auto item = m_Root; + for (TESFile::GroupData group : groups) { + if (group.hasParent()) { + const auto& file = path.files()[group.parent() >> 24]; + const std::uint8_t newIndex = static_cast<std::uint8_t>( + std::distance(std::begin(m_Files), TESFile::find(m_Files, file))); + + if (newIndex == m_Files.size()) { + return nullptr; + } + + group.setLocalIndex(newIndex); + } + + if (group.hasDirectParent() && + (!item->record || item->record->formId() != group.parent())) { + if (const auto it = item->children.find(group.parent()); + it != item->children.end()) { + item = it->second; + } else { + return nullptr; + } + } else { + if (const auto it = item->children.find(group); it != item->children.end()) { + item = it->second; + } else { + return nullptr; + } + } + } + + TreeItem::Key key; + if (path.hasFormId()) { + const auto& file = path.files()[path.formId() >> 24]; + const std::uint8_t newIndex = static_cast<std::uint8_t>( + std::distance(std::begin(m_Files), TESFile::find(m_Files, file))); + + if (newIndex == m_Files.size()) { + return nullptr; + } + + const std::uint32_t formId = (path.formId() & 0xFFFFFFU) | (newIndex << 24U); + key = formId; + } else if (path.hasEditorId()) { + key = path.editorId(); + } else if (path.hasTypeId()) { + key = path.typeId(); + } else { + return item; + } + + const auto it = item->children.find(key); + return it != item->children.end() ? it->second : nullptr; +} + +std::shared_ptr<FileEntry::TreeItem> FileEntry::createHierarchy(const RecordPath& path) +{ + std::unique_lock lk{m_Mutex}; + + const auto groups = path.groups(); + auto item = m_Root; + for (TESFile::GroupData group : groups) { + if (group.hasParent()) { + const auto& file = path.files()[group.parent() >> 24]; + const std::uint8_t newIndex = static_cast<std::uint8_t>( + std::distance(std::begin(m_Files), TESFile::find(m_Files, file))); + + if (newIndex == m_Files.size()) { + m_Files.push_back(file); + } + + group.setLocalIndex(newIndex); + } + + if (group.hasDirectParent() && + (!item->record || item->record->formId() != group.parent())) { + auto& nextItem = item->children[group.parent()]; + if (!nextItem) { + nextItem = std::make_shared<TreeItem>(); + nextItem->parent = item.get(); + nextItem->record = std::make_shared<Record>(); + nextItem->record->setIdentifier(group.parent(), m_Files); + } + nextItem->group = group; + + item = nextItem; + } else { + auto& nextItem = item->children[group]; + if (!nextItem) { + nextItem = std::make_shared<TreeItem>(); + nextItem->parent = item.get(); + nextItem->group = group; + } + item = nextItem; + } + } + + TreeItem::Key key; + if (path.hasFormId()) { + const auto& file = path.files()[path.formId() >> 24]; + const std::uint8_t newIndex = static_cast<std::uint8_t>( + std::distance(std::begin(m_Files), TESFile::find(m_Files, file))); + + if (newIndex == m_Files.size()) { + m_Files.push_back(file); + } + + const std::uint32_t formId = (path.formId() & 0xFFFFFFU) | (newIndex << 24U); + key = formId; + } else if (path.hasEditorId()) { + key = path.editorId(); + } else if (path.hasTypeId()) { + key = path.typeId(); + } else { + return item; + } + + auto& recordItem = item->children[key]; + if (!recordItem) { + recordItem = std::make_shared<TreeItem>(); + recordItem->parent = item.get(); + } + + return recordItem; +} + +} // namespace TESData diff --git a/libs/installer_bsplugins/src/TESData/FileEntry.h b/libs/installer_bsplugins/src/TESData/FileEntry.h new file mode 100644 index 0000000..0ee6922 --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/FileEntry.h @@ -0,0 +1,73 @@ +#ifndef TESDATA_FILEENTRY_H +#define TESDATA_FILEENTRY_H + +#include "Record.h" +#include "RecordPath.h" +#include "TESFile/Type.h" + +#include <boost/container/flat_map.hpp> + +#include <cstdint> +#include <functional> +#include <memory> +#include <optional> +#include <shared_mutex> +#include <string> +#include <variant> +#include <vector> + +namespace cont = boost::container; + +namespace TESData +{ + +using TESFileHandle = int; + +class FileEntry final +{ +public: + struct TreeItem + { + using Key = + std::variant<TESFile::GroupData, std::uint32_t, std::string, TESFile::Type>; + + const TreeItem* parent{nullptr}; + std::string name; + TESFile::Type formType{}; + std::optional<TESFile::GroupData> group; + std::shared_ptr<Record> record; + cont::flat_map<Key, std::shared_ptr<TreeItem>> children; + }; + + FileEntry(TESFileHandle handle, const std::string& name); + + [[nodiscard]] TESFileHandle handle() const { return m_Handle; } + [[nodiscard]] const std::string& name() const { return m_Name; } + [[nodiscard]] TreeItem* dataRoot() const { return m_Root.get(); } + [[nodiscard]] const std::vector<std::string>& files() const { return m_Files; } + + void + forEachRecord(std::function<void(const std::shared_ptr<const Record>&)> func) const; + + std::shared_ptr<Record> createRecord(const RecordPath& path, const std::string& name, + TESFile::Type formType); + void addRecord(const RecordPath& path, const std::string& name, + TESFile::Type formType, std::shared_ptr<Record> record); + void addChildGroup(const RecordPath&); + + [[nodiscard]] std::shared_ptr<Record> findRecord(const RecordPath& path) const; + [[nodiscard]] std::shared_ptr<TreeItem> findItem(const RecordPath& path) const; + +private: + std::shared_ptr<TreeItem> createHierarchy(const RecordPath& path); + + TESFileHandle m_Handle; + std::string m_Name; + std::shared_ptr<TreeItem> m_Root; + std::vector<std::string> m_Files; + mutable std::shared_mutex m_Mutex; +}; + +} // namespace TESData + +#endif // TESDATA_FILEENTRY_H diff --git a/libs/installer_bsplugins/src/TESData/FileInfo.cpp b/libs/installer_bsplugins/src/TESData/FileInfo.cpp new file mode 100644 index 0000000..8429a11 --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/FileInfo.cpp @@ -0,0 +1,177 @@ +#include "FileInfo.h" +#include "FileEntry.h" +#include "MOPlugin/Settings.h" +#include "PluginList.h" + +#include <algorithm> + +using namespace Qt::Literals::StringLiterals; + +namespace TESData +{ + +FileInfo::FileInfo(PluginList* pluginList, const QString& name, bool forceLoaded, + bool forceEnabled, bool forceDisabled, bool lightSupported) + : m_PluginList{pluginList}, + m_FileSystemData{ + .name = name, + .hasMasterExtension = name.endsWith(u".esm"_s, Qt::CaseInsensitive), + .hasLightExtension = + lightSupported && name.endsWith(u".esl"_s, Qt::CaseInsensitive), + .forceLoaded = forceLoaded, + .forceEnabled = forceEnabled, + .forceDisabled = forceDisabled, + }, + m_Conflicts{[this]() { + return doConflictCheck(); + }} +{} + +bool FileInfo::isMasterFile() const +{ + return m_Metadata.isMasterFlagged || m_FileSystemData.hasMasterExtension || + m_FileSystemData.hasLightExtension; +} + +bool FileInfo::isMediumFile() const +{ + return m_Metadata.isMediumFlagged; +} + +bool FileInfo::isSmallFile() const +{ + return m_Metadata.isLightFlagged || m_FileSystemData.hasLightExtension; +} + +bool FileInfo::isBlueprintFile() const +{ + return isMasterFile() && m_Metadata.isBlueprintFlagged; +} + +bool FileInfo::isAlwaysEnabled() const +{ + return m_FileSystemData.forceLoaded || m_FileSystemData.forceEnabled; +} + +bool FileInfo::canBeToggled() const +{ + return !m_FileSystemData.forceLoaded && !m_FileSystemData.forceEnabled && + !m_FileSystemData.forceDisabled; +} + +bool FileInfo::mustLoadAfter(const FileInfo& other) const +{ + if (this->isBlueprintFile() && !other.isBlueprintFile()) { + return true; + } else if (other.isBlueprintFile() && !this->isBlueprintFile()) { + return false; + } + + const bool hasMaster = this->masters().contains(other.name(), Qt::CaseInsensitive); + const bool isMaster = other.masters().contains(this->name(), Qt::CaseInsensitive); + + if (hasMaster && !isMaster) { + return true; + } else if (isMaster) { + return false; + } + + if (other.forceLoaded() && !this->forceLoaded()) { + return true; + } + + if (other.isMasterFile() && !this->isMasterFile()) { + return true; + } + + return false; +} + +static void checkConflict(QSet<int>& winning, QSet<int>& losing, const FileInfo& file, + const TESData::PluginList* pluginList, + TESFileHandle alternative, bool ignoreMasters) +{ + const auto entry = pluginList->findEntryByName(file.name().toStdString()); + const auto otherEntry = pluginList->findEntryByHandle(alternative); + if (otherEntry == nullptr || otherEntry == entry) { + return; + } + + const auto otherFile = + pluginList->getPluginByName(QString::fromStdString(otherEntry->name())); + if (otherFile == nullptr) { + return; + } + + const QString otherName = QString::fromStdString(otherEntry->name()); + const int otherIndex = pluginList->getIndex(otherName); + + if (file.priority() > otherFile->priority()) { + if (!ignoreMasters || + !file.masters().contains(otherFile->name(), Qt::CaseInsensitive)) { + winning.insert(otherIndex); + } + } else { + if (!ignoreMasters || + !otherFile->masters().contains(file.name(), Qt::CaseInsensitive)) { + losing.insert(otherIndex); + } + } +} + +FileInfo::Conflicts FileInfo::doConflictCheck() const +{ + Conflicts conflicts; + + const auto entry = m_PluginList->findEntryByName(name().toStdString()); + if (entry == nullptr) { + return conflicts; + } + + const bool ignoreMasters = + Settings::instance()->get<bool>("ignore_master_conflicts", false); + + entry->forEachRecord([&](auto&& record) { + if (record->ignored()) + return; + + for (const auto alternative : record->alternatives()) { + checkConflict(conflicts.m_OverridingList, conflicts.m_OverriddenList, *this, + m_PluginList, alternative, ignoreMasters); + } + }); + + for (const auto& archive : m_FileSystemData.archives) { + const auto archiveEntry = m_PluginList->findArchive(archive); + if (!archiveEntry) { + continue; + } + + archiveEntry->forEachMember([&](auto&& item) { + for (const auto alternative : item->alternatives) { + checkConflict(conflicts.m_OverwritingArchiveList, + conflicts.m_OverwrittenArchiveList, *this, m_PluginList, + alternative, ignoreMasters); + } + }); + } + + uint conflictState = CONFLICT_NONE; + if (!conflicts.m_OverridingList.empty()) { + conflictState |= CONFLICT_OVERRIDE; + } + if (!conflicts.m_OverriddenList.empty()) { + conflictState |= CONFLICT_OVERRIDDEN; + } + if (!conflicts.m_OverwritingArchiveList.empty()) { + conflictState |= CONFLICT_ARCHIVE_OVERWRITE; + } + if (!conflicts.m_OverwrittenArchiveList.empty()) { + conflictState |= CONFLICT_ARCHIVE_OVERWRITTEN; + } + conflicts.m_CurrentConflictState = static_cast<EConflictFlag>(conflictState); + + return conflicts; +} + +} // namespace TESData diff --git a/libs/installer_bsplugins/src/TESData/FileInfo.h b/libs/installer_bsplugins/src/TESData/FileInfo.h new file mode 100644 index 0000000..57c5d60 --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/FileInfo.h @@ -0,0 +1,222 @@ +#ifndef TESDATA_FILEINFO_H +#define TESDATA_FILEINFO_H + +#include <ifiletree.h> +#include <memoizedlock.h> + +#include <boost/container/flat_set.hpp> + +#include <QDateTime> +#include <QSet> +#include <QString> + +namespace TESData +{ + +class PluginList; + +class FileInfo +{ +public: + enum EConflictFlag : uint + { + CONFLICT_NONE = 0x0, + CONFLICT_OVERRIDE = 0x1, + CONFLICT_OVERRIDDEN = 0x2, + CONFLICT_ARCHIVE_OVERWRITE = 0x4, + CONFLICT_ARCHIVE_OVERWRITTEN = 0x8, + + CONFLICT_MIXED = CONFLICT_OVERRIDE | CONFLICT_OVERRIDDEN, + CONFLICT_ARCHIVE_MIXED = CONFLICT_ARCHIVE_OVERWRITE | CONFLICT_ARCHIVE_OVERWRITTEN, + }; + + enum EFlag : uint + { + FLAG_NONE = 0x000, + FLAG_PROBLEMATIC = 0x001, + FLAG_INFORMATION = 0x002, + FLAG_INI = 0x004, + FLAG_BSA = 0x008, + FLAG_MASTER = 0x010, + FLAG_MEDIUM = 0x020, + FLAG_LIGHT = 0x040, + FLAG_BLUEPRINT = 0x080, + FLAG_CLEAN = 0x100, + }; + + struct FileSystemData + { + QString name; + + bool hasMasterExtension; + bool hasLightExtension; + + bool forceLoaded; + bool forceEnabled; + bool forceDisabled; + + bool hasIni; + boost::container::flat_set<QString, MOBase::FileNameComparator> archives; + }; + + struct Metadata + { + QString author; + QString description; + + bool isMasterFlagged; + bool isMediumFlagged; + bool isLightFlagged; + bool isBlueprintFlagged; + bool hasNoRecords; + + int formVersion; + float headerVersion; + + QStringList masters; + mutable boost::container::flat_set<QString, MOBase::FileNameComparator> masterUnset; + }; + + struct State + { + bool enabled; + int priority = -1; + QString index; + int loadOrder; + QString group; + + bool operator<(const State& other) const { return (loadOrder < other.loadOrder); } + }; + + struct Conflicts + { + EConflictFlag m_CurrentConflictState = CONFLICT_NONE; + QSet<int> m_OverridingList; + QSet<int> m_OverriddenList; + QSet<int> m_OverwritingArchiveList; + QSet<int> m_OverwrittenArchiveList; + }; + + FileInfo(PluginList* pluginList, const QString& name, bool forceLoaded, + bool forceEnabled, bool forceDisabled, bool lightSupported); + + [[nodiscard]] const QString& name() const { return m_FileSystemData.name; } + + [[nodiscard]] bool hasMasterExtension() const + { + return m_FileSystemData.hasMasterExtension; + } + + [[nodiscard]] bool hasLightExtension() const + { + return m_FileSystemData.hasLightExtension; + } + + [[nodiscard]] bool forceLoaded() const { return m_FileSystemData.forceLoaded; } + [[nodiscard]] bool forceEnabled() const { return m_FileSystemData.forceEnabled; } + [[nodiscard]] bool forceDisabled() const { return m_FileSystemData.forceDisabled; } + + [[nodiscard]] bool hasIni() const { return m_FileSystemData.hasIni; } + void setHasIni(bool hasIni) { m_FileSystemData.hasIni = hasIni; } + [[nodiscard]] const auto& archives() const { return m_FileSystemData.archives; } + void addArchive(const QString& archive) { m_FileSystemData.archives.insert(archive); } + + [[nodiscard]] const QString& author() const { return m_Metadata.author; } + void setAuthor(const QString& author) { m_Metadata.author = author; } + [[nodiscard]] const QString& description() const { return m_Metadata.description; } + void setDescription(const QString& text) { m_Metadata.description = text; } + [[nodiscard]] bool isMasterFlagged() const { return m_Metadata.isMasterFlagged; } + void setMasterFlagged(bool value) { m_Metadata.isMasterFlagged = value; } + [[nodiscard]] bool isMediumFlagged() const { return m_Metadata.isMediumFlagged; } + void setMediumFlagged(bool value) { m_Metadata.isMediumFlagged = value; } + [[nodiscard]] bool isLightFlagged() const { return m_Metadata.isLightFlagged; } + void setLightFlagged(bool value) { m_Metadata.isLightFlagged = value; } + [[nodiscard]] bool isBlueprintFlagged() const { return m_Metadata.isBlueprintFlagged; } + void setBlueprintFlagged(bool value) { m_Metadata.isBlueprintFlagged = value; } + [[nodiscard]] bool hasNoRecords() const { return m_Metadata.hasNoRecords; } + void setHasNoRecords(bool value) { m_Metadata.hasNoRecords = value; } + [[nodiscard]] int formVersion() const { return m_Metadata.formVersion; } + void setFormVersion(int value) { m_Metadata.formVersion = value; } + [[nodiscard]] float headerVersion() const { return m_Metadata.headerVersion; } + void setHeaderVersion(float value) { m_Metadata.headerVersion = value; } + + [[nodiscard]] const auto& masters() const { return m_Metadata.masters; } + void addMaster(const QString& master) { m_Metadata.masters.push_back(master); } + + [[nodiscard]] bool hasMissingMasters() const + { + return !m_Metadata.masterUnset.empty(); + } + + [[nodiscard]] const auto& missingMasters() const { return m_Metadata.masterUnset; } + + template <std::ranges::input_range R> + void setMissingMasters(R&& range) const + { + m_Metadata.masterUnset.clear(); + m_Metadata.masterUnset.insert(std::begin(range), std::end(range)); + } + + [[nodiscard]] bool enabled() const { return m_State.enabled; } + void setEnabled(bool enabled) { m_State.enabled = enabled; } + [[nodiscard]] int priority() const { return m_State.priority; } + void setPriority(int priority) + { + m_State.priority = priority; + m_Conflicts.invalidate(); + } + [[nodiscard]] const QString& index() const { return m_State.index; } + void setIndex(const QString& index) { m_State.index = index; } + [[nodiscard]] int loadOrder() const { return m_State.loadOrder; } + void setLoadOrder(int loadOrder) { m_State.loadOrder = loadOrder; } + [[nodiscard]] const QString& group() const { return m_State.group; } + void setGroup(const QString& group) { m_State.group = group; } + + [[nodiscard]] EConflictFlag conflictState() const + { + return m_Conflicts.value().m_CurrentConflictState; + } + + [[nodiscard]] const auto& getPluginOverriding() const + { + return m_Conflicts.value().m_OverridingList; + } + + [[nodiscard]] const auto& getPluginOverridden() const + { + return m_Conflicts.value().m_OverriddenList; + } + + [[nodiscard]] const auto& getPluginOverwritingArchive() const + { + return m_Conflicts.value().m_OverwritingArchiveList; + } + + [[nodiscard]] const auto& getPluginOverwrittenArchive() const + { + return m_Conflicts.value().m_OverwrittenArchiveList; + } + + [[nodiscard]] bool isMasterFile() const; + [[nodiscard]] bool isMediumFile() const; + [[nodiscard]] bool isSmallFile() const; + [[nodiscard]] bool isBlueprintFile() const; + [[nodiscard]] bool isAlwaysEnabled() const; + [[nodiscard]] bool canBeToggled() const; + [[nodiscard]] bool mustLoadAfter(const FileInfo& other) const; + + void invalidateConflicts() const { m_Conflicts.invalidate(); } + +private: + [[nodiscard]] Conflicts doConflictCheck() const; + + PluginList* m_PluginList; + FileSystemData m_FileSystemData; + Metadata m_Metadata; + State m_State; + mutable MOBase::MemoizedLocked<Conflicts> m_Conflicts; +}; + +} // namespace TESData + +#endif // TESDATA_FILEINFO_H diff --git a/libs/installer_bsplugins/src/TESData/FormParser.cpp b/libs/installer_bsplugins/src/TESData/FormParser.cpp new file mode 100644 index 0000000..08fc06b --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/FormParser.cpp @@ -0,0 +1,180 @@ +#include "FormParser.h" + +#include <bit> +#include <ranges> +#include <utility> + +namespace TESData +{ + +auto FormParserManager::registrationMap() -> RegistrationMap& +{ + static RegistrationMap registrationMap; + return registrationMap; +} + +std::shared_ptr<const IFormParser> FormParserManager::getParser(Game game, + TESFile::Type type) +{ + if (game == Game::SSE) { + if (const auto it = registrationMap().find(type); it != registrationMap().end()) { + return it->second; + } + } + + return registrationMap()[TESFile::Type()]; +} + +QString readBytes(std::istream& stream, int size) +{ + QString data; + for (int i = 0; i < size; ++i) { + char ch; + stream.get(ch); + if (stream.eof()) { + break; + } + data += u"%1 "_s.arg(static_cast<std::uint8_t>(ch), 2, 16, QChar(u'0')).toUpper(); + } + return data; +} + +QString readLstring(bool localized, std::istream& stream) +{ + if (localized) { + const std::uint32_t index = TESFile::readType<std::uint32_t>(stream); + if (index == 0) { + return u""_s; + } + return u"<lstring:%1>"_s.arg(index); + } else { + std::string str; + std::getline(stream, str, '\0'); + return QString::fromStdString(str); + } +} + +QString readFormId(std::span<const std::string> masters, + const std::string& plugin, std::istream& stream) +{ + const std::uint32_t formId = TESFile::readType<std::uint32_t>(stream); + if (!formId) { + return u"NONE"_s; + } + + const std::uint8_t localIndex = formId >> 24U; + const std::string& file = localIndex < masters.size() ? masters[localIndex] : plugin; + return u"%2 | %1"_s.arg(formId & 0xFFFFFFU, 6, 16, QChar(u'0')) + .toUpper() + .arg(QString::fromStdString(file)); +} + +static void parseUnknown(DataItem* parent, int& index, int fileIndex, + TESFile::Type signature, std::istream& stream) +{ + DataItem* item = nullptr; + for (int i = index; i < parent->numChildren(); ++i) { + const auto child = parent->childAt(i); + if (child->signature() == signature) { + item = child; + index = i + 1; + break; + } + } + + if (item == nullptr) { + item = parent->insertChild(index++, signature, u"Unknown"_s, + DataItem::ConflictType::Override); + } + + item->setData(fileIndex, readBytes(stream, 256)); +} + +static void +pushItem(DataItem*& item, std::vector<int>& indexStack, const QString& name, + DataItem::ConflictType conflictType = DataItem::ConflictType::Override, + bool alignable = true) +{ + if (alignable) { + item = item->getOrInsertChild(indexStack.back()++, name, conflictType); + } else { + item = item->insertChild(indexStack.back()++, name, conflictType); + } + indexStack.push_back(0); +} + +static void +pushItem(DataItem*& item, std::vector<int>& indexStack, TESFile::Type signature, + const QString& name, + DataItem::ConflictType conflictType = DataItem::ConflictType::Override, + bool alignable = true) +{ + if (alignable) { + item = item->getOrInsertChild(indexStack.back()++, signature, name, conflictType); + } else { + item = item->insertChild(indexStack.back()++, signature, name, conflictType); + } + indexStack.push_back(0); +} + +static void popItem(DataItem*& item, std::vector<int>& indexStack) +{ + item = item->parent(); + indexStack.pop_back(); +} + +[[nodiscard]] static QString readZstring(std::istream& stream) +{ + return QString::fromStdString(TESFile::readZstring(stream)); +} + +template <std::integral T> +[[nodiscard]] static QString readWstring(std::istream& stream) +{ + const T length = TESFile::readType<T>(stream); + std::string str; + str.resize(length); + stream.read(str.data(), length); + return QString::fromStdString(str); +} + +template <> +void FormParser<>::parseFlags(DataItem* root, int fileIndex, std::uint32_t flags) const +{ + DataItem* item = root->getOrInsertChild(0, u"Record Flags"_s); + + item = item->getOrInsertChild(0, u"Compressed"_s); + if (flags & (TESFile::RecordFlags::Compressed)) { + item->setData(fileIndex, u"Compressed"_s); + } +} + +template <> +ParseTask FormParser<>::parseForm(DataItem* root, int fileIndex, + [[maybe_unused]] bool localized, + [[maybe_unused]] std::span<const std::string> masters, + [[maybe_unused]] const std::string& plugin, + const TESFile::Type& signature, + std::istream* const& stream) const +{ + int index = 1; + for (;;) { + if (signature == "EDID"_ts) { + std::string editorId; + std::getline(*stream, editorId, '\0'); + root->getOrInsertChild(index++, "EDID"_ts, u"Editor ID"_s) + ->setData(fileIndex, QString::fromStdString(editorId)); + } else { + parseUnknown(root, index, fileIndex, signature, *stream); + } + + co_await std::suspend_always(); + } +} + +} // namespace TESData + +#pragma warning(push) +#pragma warning(disable : 4456) +#include "FormParser.SSE.inl" +#pragma warning(pop) diff --git a/libs/installer_bsplugins/src/TESData/FormParser.h b/libs/installer_bsplugins/src/TESData/FormParser.h new file mode 100644 index 0000000..b0220a0 --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/FormParser.h @@ -0,0 +1,115 @@ +#ifndef TESDATA_FORMPARSER_H +#define TESDATA_FORMPARSER_H + +#include "DataItem.h" +#include "TESFile/Stream.h" +#include "TESFile/Type.h" + +#include <coroutine> +#include <istream> +#include <map> +#include <memory> +#include <span> +#include <string> + +using namespace Qt::Literals::StringLiterals; + +namespace TESData +{ + +namespace Parse +{ + struct Promise; + + struct Task : std::coroutine_handle<Promise> + { + using promise_type = Promise; + + Task(std::coroutine_handle<Promise>&& promise) + : std::coroutine_handle<Promise>::coroutine_handle(promise) + {} + }; + + struct Promise + { + Task get_return_object() { return {Task::from_promise(*this)}; } + std::suspend_always initial_suspend() noexcept { return {}; } + std::suspend_always final_suspend() noexcept { return {}; } + void return_void() {} + void unhandled_exception() {} + }; +} // namespace Parse + +using ParseTask = Parse::Task; + +class IFormParser +{ +public: + virtual void parseFlags(DataItem* root, int fileIndex, std::uint32_t flags) const = 0; + + virtual ParseTask parseForm(DataItem* root, int fileIndex, bool localized, + std::span<const std::string> masters, + const std::string& plugin, const TESFile::Type& signature, + std::istream* const& stream) const = 0; +}; + +class FormParserManager final +{ + template <typename T, TESFile::Type Type> + friend struct RegisterFormParser; + +public: + enum class Game + { + TES4, + FO3, + FNV, + TES5, + FO4, + SSE, + + Unknown + }; + + FormParserManager() = delete; + + [[nodiscard]] static std::shared_ptr<const IFormParser> getParser(Game game, + TESFile::Type type); + +private: + using RegistrationMap = std::map<TESFile::Type, std::shared_ptr<IFormParser>>; + + static auto registrationMap() -> RegistrationMap&; +}; + +template <typename T, TESFile::Type Type> +struct [[maybe_unused]] RegisterFormParser +{ + explicit RegisterFormParser() + { + FormParserManager::registrationMap().emplace(Type, std::make_shared<T>()); + } +}; + +template <TESFile::Type Type = {}> +class FormParser : public IFormParser +{ + inline static RegisterFormParser<FormParser<Type>, Type> reg{}; + +public: + void parseFlags(DataItem* root, int fileIndex, std::uint32_t flags) const override; + + ParseTask parseForm(DataItem* root, int fileIndex, bool localized, + std::span<const std::string> masters, const std::string& plugin, + const TESFile::Type& signature, + std::istream* const& stream) const override; +}; + +QString readBytes(std::istream& stream, int size); +QString readLstring(bool localized, std::istream& stream); +QString readFormId(std::span<const std::string> masters, const std::string& plugin, + std::istream& stream); + +} // namespace TESData + +#endif // TESDATA_FORMPARSER_H diff --git a/libs/installer_bsplugins/src/TESData/PluginList.cpp b/libs/installer_bsplugins/src/TESData/PluginList.cpp new file mode 100644 index 0000000..805261c --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/PluginList.cpp @@ -0,0 +1,1429 @@ +#include "PluginList.h" +#include "FileConflictParser.h" +#include "TESFile/Reader.h" + +#include <bsatk/bsatk.h> +#include <game_features/gameplugins.h> +#include <game_features/igamefeatures.h> +#include <log.h> +#include <safewritefile.h> +#include <utility.h> + +#include <boost/container/flat_map.hpp> +#include <boost/container/flat_set.hpp> +#include <boost/thread/future.hpp> + +#include <QDir> +#include <QFile> +#include <QStringTokenizer> +#include <QTextStream> + +#include <algorithm> +#include <future> +#include <iterator> +#include <limits> +#include <ranges> +#include <semaphore> +#include <thread> +#include <tuple> +#include <utility> + +using namespace Qt::Literals::StringLiterals; + +namespace TESData +{ + +#pragma region Constructor / Destructor + +PluginList::PluginList(const MOBase::IOrganizer* moInfo) : m_Organizer{moInfo} +{ + refresh(true); +} + +PluginList::~PluginList() noexcept +{ + m_Refreshed.disconnect_all_slots(); + m_PluginMoved.disconnect_all_slots(); + m_PluginStateChanged.disconnect_all_slots(); +} + +#pragma endregion Constructor / Destructor +#pragma region Plugin Access + +int PluginList::pluginCount() const +{ + return static_cast<int>(m_Plugins.size()); +} + +int PluginList::getIndex(const QString& pluginName) const +{ + const auto it = m_PluginsByName.find(pluginName); + return it != m_PluginsByName.end() ? it->second : -1; +} + +int PluginList::getIndexAtPriority(int priority) const +{ + return m_PluginsByPriority.at(priority); +} + +QString PluginList::getOriginName(int index) const +{ + if (index < 0 || index >= m_Plugins.size()) { + return QString(); + } + + const auto& name = m_Plugins[index]->name(); + const auto origins = m_Organizer->getFileOrigins(name); + return !origins.isEmpty() ? origins.first() : QString(); +} + +const FileInfo* PluginList::getPlugin(int index) const +{ + if (index < 0 || index >= m_Plugins.size()) { + return nullptr; + } + + return m_Plugins[index].get(); +} + +const FileInfo* PluginList::getPluginByName(const QString& name) const +{ + const auto it = m_PluginsByName.find(name); + if (it == m_PluginsByName.end()) { + return nullptr; + } else { + return m_Plugins.at(it->second).get(); + } +} + +const FileInfo* PluginList::getPluginByPriority(int priority) const +{ + if (priority < 0 || priority >= m_PluginsByPriority.size()) { + return nullptr; + } + + return m_Plugins.at(m_PluginsByPriority[priority]).get(); +} + +FileInfo* PluginList::findPlugin(const QString& name) +{ + const auto it = m_PluginsByName.find(name); + if (it == m_PluginsByName.end()) { + return nullptr; + } else { + return m_Plugins.at(it->second).get(); + } +} + +const FileInfo* PluginList::findPlugin(const QString& name) const +{ + const auto it = m_PluginsByName.find(name); + if (it == m_PluginsByName.end()) { + return nullptr; + } else { + return m_Plugins.at(it->second).get(); + } +} + +#pragma endregion Plugin Access +#pragma region Record Access + +FileEntry* PluginList::findEntryByName(const std::string& pluginName) const +{ + std::shared_lock lk{m_FileEntryMutex}; + const auto it = m_EntriesByName.find(pluginName); + return it != m_EntriesByName.end() ? it->second.get() : nullptr; +} + +FileEntry* PluginList::findEntryByHandle(TESFileHandle handle) const +{ + std::shared_lock lk{m_FileEntryMutex}; + const auto it = m_EntriesByHandle.find(handle); + return it != m_EntriesByHandle.end() ? it->second.get() : nullptr; +} + +AssociatedEntry* PluginList::findArchive(const QString& name) const +{ + std::shared_lock lk{m_ArchiveEntryMutex}; + const auto it = m_Archives.find(name); + return it != m_Archives.end() ? it->second.get() : nullptr; +} + +FileEntry* PluginList::createEntry(const std::string& name) +{ + std::unique_lock lk{m_FileEntryMutex}; + + const auto it = m_EntriesByName.find(name); + if (it != m_EntriesByName.end()) { + return it->second.get(); + } + + const auto entry = std::make_shared<FileEntry>(m_NextHandle++, name); + m_EntriesByHandle.emplace_hint(m_EntriesByHandle.cend(), entry->handle(), entry); + m_EntriesByName[name] = entry; + return entry.get(); +} + +void PluginList::addRecordConflict(const std::string& pluginName, + const RecordPath& path, TESFile::Type type, + const std::string& name) +{ + const auto& master = path.hasFormId() ? path.files()[path.formId() >> 24] : ""; + const auto owner = createEntry(master); + const auto record = owner->createRecord(path, name, type); + if (pluginName != master) { + const auto entry = createEntry(pluginName); + entry->addRecord(path, name, type, record); + } +} + +void PluginList::addGroupPlaceholder(const std::string& pluginName, + const RecordPath& path) +{ + const auto group = path.groups().back(); + const auto& master = group.hasParent() ? path.files()[group.parent() >> 24] : ""; + if (const auto owner = findEntryByName(master)) { + owner->addChildGroup(path); + } + if (pluginName != master) { + if (const auto entry = findEntryByName(pluginName)) { + entry->addChildGroup(path); + } + } +} + +#pragma endregion Record Access +#pragma region List Management + +QString PluginList::groupsPath() const +{ + const auto profilePath = QDir(m_Organizer->profilePath()); + if (profilePath.isEmpty()) { + return QString(); + } + + return QDir::cleanPath(profilePath.absoluteFilePath(u"plugingroups.txt"_s)); +} + +QString PluginList::lockedOrderPath() const +{ + const auto profilePath = QDir(m_Organizer->profilePath()); + if (profilePath.isEmpty()) { + return QString(); + } + + return QDir::cleanPath(profilePath.absoluteFilePath(u"lockedorder.txt"_s)); +} + +void PluginList::refresh(bool invalidate) +{ + MOBase::TimeThis tt{"TESData::PluginList::refresh()"}; + + m_Refreshing = true; + scanDataFiles(invalidate); + readPluginLists(); + + if (const auto groupsFile = groupsPath(); !groupsFile.isEmpty()) { + readGroups(groupsFile); + } + + computeCompileIndices(); + refreshLoadOrder(); + dispatchPluginStateChanges(); + testMasters(); + + if (const auto lockedOrderFile = lockedOrderPath(); !lockedOrderFile.isEmpty()) { + writeEmptyTextFile(lockedOrderFile); + } + + m_Refreshing = false; + m_Refreshed(); +} + +void PluginList::setEnabled(int id, bool enable) +{ + const auto plugin = m_Plugins.at(id); + + const bool enabled = plugin->enabled(); + const bool shouldEnable = + (enable && !plugin->forceDisabled()) || plugin->isAlwaysEnabled(); + + if (shouldEnable != enabled) { + plugin->setEnabled(shouldEnable); + computeCompileIndices(); + refreshLoadOrder(); + pluginStatesChanged({plugin->name()}, shouldEnable ? STATE_ACTIVE : STATE_INACTIVE); + testMasters(); + } +} + +void PluginList::setEnabled(const std::vector<int>& ids, bool enable) +{ + QStringList changed; + for (const int id : ids) { + const auto plugin = m_Plugins.at(id); + + const bool enabled = plugin->enabled(); + const bool shouldEnable = + (enable && !plugin->forceDisabled()) || plugin->isAlwaysEnabled(); + + if (shouldEnable != enabled) { + plugin->setEnabled(shouldEnable); + changed.append(plugin->name()); + } + } + + if (!changed.isEmpty()) { + computeCompileIndices(); + refreshLoadOrder(); + pluginStatesChanged(changed, enable ? STATE_ACTIVE : STATE_INACTIVE); + testMasters(); + } +} + +void PluginList::toggleState(const std::vector<int>& ids) +{ + QStringList active; + QStringList inactive; + for (const int id : ids) { + const auto plugin = m_Plugins.at(id); + + const bool enabled = plugin->enabled(); + const bool shouldEnable = + (!enabled && !plugin->forceDisabled()) || plugin->isAlwaysEnabled(); + + if (shouldEnable != enabled) { + plugin->setEnabled(shouldEnable); + if (shouldEnable) { + active.append(plugin->name()); + } else { + inactive.append(plugin->name()); + } + } + } + + if (!active.isEmpty() || !inactive.isEmpty()) { + computeCompileIndices(); + refreshLoadOrder(); + if (!active.isEmpty()) { + pluginStatesChanged(active, STATE_ACTIVE); + } + if (!inactive.isEmpty()) { + pluginStatesChanged(inactive, STATE_INACTIVE); + } + testMasters(); + } +} + +bool PluginList::canMoveToPriority(const std::vector<int>& ids, int newPriority) const +{ + boost::container::flat_set<QString, MOBase::FileNameComparator> names; + names.reserve(ids.size()); + for (const int id : ids) { + const auto plugin = m_Plugins[id]; + names.insert(plugin->name()); + } + + for (const int id : ids) { + const auto pluginToMove = m_Plugins[id]; + const int priority = pluginToMove->priority(); + + if (pluginToMove->forceLoaded()) { + const int min = std::min(priority, newPriority); + const int max = std::max(priority, newPriority); + for (int i = min; i < max; ++i) { + if (std::ranges::find(ids, m_PluginsByPriority.at(i)) == std::end(ids)) { + return false; + } + } + } + + for (int i = newPriority; i < priority; ++i) { + const auto plugin = m_Plugins.at(m_PluginsByPriority.at(i)); + if (!names.contains(plugin->name()) && pluginToMove->mustLoadAfter(*plugin)) { + return false; + } + } + + for (int i = priority + 1; i < newPriority; ++i) { + const auto plugin = m_Plugins.at(m_PluginsByPriority.at(i)); + if (!names.contains(plugin->name()) && plugin->mustLoadAfter(*pluginToMove)) { + return false; + } + } + } + + return true; +} + +QString PluginList::destinationGroup( + int oldPriority, int newPriority, const QString& originalGroup, bool isESM, + const boost::container::flat_set<QString, MOBase::FileNameComparator>& exclusions) +{ + const auto findPrevious = [&, this](int priority) -> FileInfo* { + for (int i = priority - 1; i >= 0; --i) { + const auto& plugin = m_Plugins.at(m_PluginsByPriority[i]); + if (!exclusions.contains(plugin->name())) { + return plugin.get(); + } + } + return nullptr; + }; + + const auto findNext = [&, this](int priority) -> FileInfo* { + for (int i = priority + 1; i < m_PluginsByPriority.size(); ++i) { + const auto& plugin = m_Plugins.at(m_PluginsByPriority[i]); + if (!exclusions.contains(plugin->name())) { + return plugin.get(); + } + } + return nullptr; + }; + + bool removedFromGroup = false; + if (!originalGroup.isEmpty()) { + if (const auto previous = findPrevious(oldPriority)) { + if (previous->group() == originalGroup && previous->isMasterFile() == isESM) { + removedFromGroup = true; + } + } + + if (const auto next = findNext(oldPriority)) { + if (next->group() == originalGroup && next->isMasterFile() == isESM) { + removedFromGroup = true; + } + } + } + + QString displacedGroup; + if (const auto displaced = m_Plugins.at(m_PluginsByPriority[newPriority])) { + displacedGroup = displaced->group(); + } + + QString neighborGroup; + if (newPriority < oldPriority) { + if (const auto neighbor = findPrevious(newPriority)) { + neighborGroup = neighbor->group(); + } + } else if (newPriority > oldPriority) { + if (const auto neighbor = findNext(newPriority)) { + neighborGroup = neighbor->group(); + } + } + + if (!displacedGroup.isEmpty() && neighborGroup == displacedGroup) { + return displacedGroup; + } + + if (!removedFromGroup || displacedGroup == originalGroup) { + return originalGroup; + } + + return QString(); +} + +void PluginList::moveToPriority(std::vector<int> ids, int destination, bool disjoint) +{ + if (ids.empty()) { + return; + } + + destination = std::max(destination, 0); + destination = std::min(destination, pluginCount()); + + boost::container::flat_set<QString, MOBase::FileNameComparator> names; + names.reserve(ids.size()); + boost::container::flat_map<QString, int> priorities; + priorities.reserve(ids.size()); + for (const int id : ids) { + const auto plugin = m_Plugins.at(id); + names.insert(plugin->name()); + priorities.emplace(plugin->name(), plugin->priority()); + } + + std::ranges::sort(ids, [this](int lhs, int rhs) { + return m_Plugins[lhs]->priority() > m_Plugins[rhs]->priority(); + }); + + int nextDestination = destination; + for (const int id : ids) { + const auto& pluginToMove = m_Plugins[id]; + const int priority = pluginToMove->priority(); + + if (nextDestination < priority) { + for (int i = priority - 1; i >= nextDestination; --i) { + const auto& plugin = m_Plugins.at(m_PluginsByPriority.at(i)); + if (!names.contains(plugin->name()) && pluginToMove->mustLoadAfter(*plugin)) { + nextDestination = i + 1; + break; + } + } + + const int newPriority = nextDestination; + pluginToMove->setGroup(destinationGroup(priority, newPriority, + pluginToMove->group(), + pluginToMove->isMasterFile(), names)); + for (int i = priority; i > nextDestination; --i) { + m_PluginsByPriority[i] = m_PluginsByPriority[i - 1]; + m_Plugins[m_PluginsByPriority[i]]->setPriority(i); + } + + m_PluginsByPriority[newPriority] = id; + pluginToMove->setPriority(newPriority); + } else if (nextDestination > priority) { + for (int i = priority + 1; i < nextDestination; ++i) { + const auto& plugin = m_Plugins.at(m_PluginsByPriority.at(i)); + if (!names.contains(plugin->name()) && plugin->mustLoadAfter(*pluginToMove)) { + nextDestination = i; + break; + } + } + + const int newPriority = --nextDestination; + pluginToMove->setGroup(destinationGroup(priority, newPriority, + pluginToMove->group(), + pluginToMove->isMasterFile(), names)); + for (int i = priority; i < newPriority; ++i) { + m_PluginsByPriority[i] = m_PluginsByPriority[i + 1]; + m_Plugins[m_PluginsByPriority[i]]->setPriority(i); + } + + m_PluginsByPriority[newPriority] = id; + pluginToMove->setPriority(newPriority); + } + + if (disjoint) { + nextDestination = destination; + } + } + + computeCompileIndices(); + refreshLoadOrder(); + + boost::container::flat_map<int, std::tuple<QString, int>> movedUp; + boost::container::flat_map<int, std::tuple<QString, int>, std::greater<int>> + movedDown; + + for (const int id : ids) { + const auto& plugin = m_Plugins[id]; + const auto& name = plugin->name(); + const int oldPriority = priorities[name]; + const int newPriority = plugin->priority(); + if (newPriority < oldPriority) { + movedUp[oldPriority] = std::make_tuple(name, newPriority); + } else if (newPriority > oldPriority) { + movedDown[oldPriority] = std::make_tuple(name, newPriority); + } + } + + for (auto& [oldPriority, moveInfo] : movedDown) { + auto& [name, newPriority] = moveInfo; + m_PluginMoved(name, oldPriority, newPriority); + } + + for (auto& [oldPriority, moveInfo] : movedUp) { + auto& [name, newPriority] = moveInfo; + m_PluginMoved(name, oldPriority, newPriority); + } +} + +void PluginList::shiftPriority(const std::vector<int>& ids, int offset) +{ + if (offset < 0) { + const int minPriority = + std::ranges::min(std::ranges::transform_view(ids, [this](auto&& id) { + return m_Plugins.at(id)->priority(); + })); + moveToPriority(ids, minPriority + offset); + } else if (offset > 0) { + const int maxPriority = + std::ranges::max(std::ranges::transform_view(ids, [this](auto&& id) { + return m_Plugins.at(id)->priority(); + })); + moveToPriority(ids, maxPriority + 1 + offset); + } +} + +void PluginList::setGroup(const std::vector<int>& ids, const QString& group) +{ + for (const int id : ids) { + m_Plugins.at(id)->setGroup(group); + } + + if (const auto groupsFile = groupsPath(); !groupsFile.isEmpty()) { + writeGroups(groupsFile); + } +} + +QStringList PluginList::loadOrder() const +{ + QStringList result; + + for (const int id : m_PluginsByPriority) { + result.append(m_Plugins.at(id)->name()); + } + + return result; +} + +void PluginList::notifyPendingState(const QString& mod, + MOBase::IModList::ModStates state) +{ + if (state & MOBase::IModList::STATE_ACTIVE) { + m_PendingActive.insert(mod); + } else { + m_PendingActive.erase(mod); + } +} + +void PluginList::flushPendingStates() +{ + m_PendingActive.clear(); +} + +#pragma endregion List Management +#pragma region IPluginList + +QStringList PluginList::pluginNames() const +{ + QStringList result; + + for (const auto& info : m_Plugins) { + result.append(info->name()); + } + + return result; +} + +MOBase::IPluginList::PluginStates PluginList::state(const QString& name) const +{ + const auto plugin = findPlugin(name); + if (!plugin) { + return IPluginList::STATE_MISSING; + } else { + return plugin->enabled() ? IPluginList::STATE_ACTIVE : IPluginList::STATE_INACTIVE; + } +} + +void PluginList::setState(const QString& name, PluginStates state) +{ + const auto it = m_PluginsByName.find(name); + if (it == m_PluginsByName.end()) { + return; + } + + const auto plugin = m_Plugins.at(it->second).get(); + + if (state == STATE_MISSING) { + m_Plugins.erase(m_Plugins.begin() + it->second); + } else { + const bool enabled = plugin->enabled(); + const bool shouldEnable = (state == STATE_ACTIVE && !plugin->forceDisabled()) || + plugin->isAlwaysEnabled(); + if (shouldEnable == enabled) { + return; + } + plugin->setEnabled(shouldEnable); + } + + queuePluginStateChange(plugin->name(), state); +} + +int PluginList::priority(const QString& name) const +{ + const auto plugin = findPlugin(name); + return plugin ? plugin->priority() : -1; +} + +bool PluginList::setPriority(const QString& name, int newPriority) +{ + if (newPriority < 0 || newPriority >= static_cast<int>(m_PluginsByPriority.size())) { + MOBase::log::warn("Requested priority for \"{}\" out of range: {}", name, + newPriority); + return false; + } + + const int oldPriority = priority(name); + if (oldPriority == -1) { + return false; + } + + const int rowIndex = m_PluginsByPriority.at(oldPriority); + + int destination = newPriority; + if (newPriority > oldPriority) { + ++destination; + } + moveToPriority({rowIndex}, destination); + + return true; +} + +int PluginList::loadOrder(const QString& name) const +{ + const auto plugin = findPlugin(name); + return plugin ? plugin->loadOrder() : -1; +} + +void PluginList::setLoadOrder(const QStringList& pluginList) +{ + for (const auto& info : m_Plugins) { + info->setPriority(-1); + } + + int maxPriority = 0; + for (const QString& pluginName : pluginList) { + const auto plugin = const_cast<TESData::FileInfo*>(findPlugin(pluginName)); + if (plugin != nullptr) { + plugin->setPriority(maxPriority++); + } + } + + for (const auto& info : m_Plugins) { + if (info->priority() == -1) { + info->setPriority(maxPriority++); + } + } + + updateCache(); +} + +bool PluginList::isMaster(const QString& name) const +{ + return isMasterFlagged(name); +} + +QStringList PluginList::masters(const QString& name) const +{ + const auto plugin = findPlugin(name); + QStringList result; + if (plugin != nullptr) { + for (const QString& master : plugin->masters()) { + result.append(master); + } + } + return result; +} + +QString PluginList::origin(const QString& name) const +{ + if (m_PluginsByName.find(name) == m_PluginsByName.end()) { + return QString(); + } + + const auto origins = m_Organizer->getFileOrigins(name); + return !origins.isEmpty() ? origins.first() : QString(); +} + +bool PluginList::onRefreshed(const std::function<void()>& callback) +{ + auto connection = m_Refreshed.connect(callback); + return connection.connected(); +} + +bool PluginList::onPluginMoved( + const std::function<void(const QString&, int, int)>& func) +{ + auto connection = m_PluginMoved.connect(func); + return connection.connected(); +} + +bool PluginList::onPluginStateChanged( + const std::function<void(const std::map<QString, PluginStates>&)>& func) +{ + auto connection = m_PluginStateChanged.connect(func); + return connection.connected(); +} + +bool PluginList::hasMasterExtension(const QString& name) const +{ + const auto plugin = findPlugin(name); + return plugin ? plugin->hasMasterExtension() : false; +} + +bool PluginList::hasLightExtension(const QString& name) const +{ + const auto plugin = findPlugin(name); + return plugin ? plugin->hasLightExtension() : false; +} + +bool PluginList::isMasterFlagged(const QString& name) const +{ + const auto plugin = findPlugin(name); + return plugin ? plugin->isMasterFlagged() : false; +} + +bool PluginList::isMediumFlagged(const QString& name) const +{ + const auto plugin = findPlugin(name); + return plugin ? plugin->isMediumFlagged() : false; +} + +bool PluginList::isLightFlagged(const QString& name) const +{ + const auto plugin = findPlugin(name); + return plugin ? plugin->isLightFlagged() : false; +} + +bool PluginList::isBlueprintFlagged(const QString& name) const +{ + const auto plugin = findPlugin(name); + return plugin ? plugin->isBlueprintFlagged() : false; +} + +bool PluginList::hasNoRecords(const QString& name) const +{ + const auto plugin = findPlugin(name); + return plugin ? plugin->hasNoRecords() : false; +} + +int PluginList::formVersion(const QString& name) const +{ + const auto plugin = findPlugin(name); + return plugin ? plugin->formVersion() : 0; +} + +float PluginList::headerVersion(const QString& name) const +{ + const auto plugin = findPlugin(name); + return plugin ? plugin->headerVersion() : 0.0; +} + +QString PluginList::author(const QString& name) const +{ + const auto plugin = findPlugin(name); + return plugin ? plugin->author() : QString(); +} + +QString PluginList::description(const QString& name) const +{ + const auto plugin = findPlugin(name); + return plugin ? plugin->description() : QString(); +} + +#pragma endregion IPluginList +#pragma region ILootCache + +void PluginList::clearAdditionalInformation() +{ + m_LootInfo.clear(); +} + +void PluginList::addLootReport(const QString& name, MOTools::Loot::Plugin plugin) +{ + const auto it = m_PluginsByName.find(name); + if (it != m_PluginsByName.end()) { + m_LootInfo[name] = std::move(plugin); + } else { + MOBase::log::warn("failed to associate loot report for \"{}\"", name); + } +} + +const MOTools::Loot::Plugin* PluginList::getLootReport(const QString& name) const +{ + const auto it = m_LootInfo.find(name); + return it != m_LootInfo.end() ? &it->second : nullptr; +} + +#pragma endregion ILootCache +#pragma region Slots + +void PluginList::writePluginLists() const +{ + const auto gameFeatures = m_Organizer->gameFeatures(); + const auto tesSupport = + gameFeatures ? gameFeatures->gameFeature<MOBase::GamePlugins>() : nullptr; + if (tesSupport) { + tesSupport->writePluginLists(this); + } + + if (const auto groupsFile = groupsPath(); !groupsFile.isEmpty()) { + writeGroups(groupsFile); + } +} + +#pragma endregion Slots +#pragma region Helpers + +static bool isPluginFile(const QString& filename) +{ + return filename.endsWith(u".esp"_s, Qt::CaseInsensitive) || + filename.endsWith(u".esm"_s, Qt::CaseInsensitive) || + filename.endsWith(u".esl"_s, Qt::CaseInsensitive); +} + +enum class Game +{ + Skyrim, + Fallout4, + Starfield, + Other +}; + +static bool isAssociatedArchive(TESData::FileInfo& info, const QString& candidate, + Game game) +{ + const QString baseName = QFileInfo(info.name()).completeBaseName(); + if (!candidate.startsWith(baseName)) { + return false; + } + + switch (game) { + case Game::Skyrim: + if (candidate.endsWith(u".bsa"_s)) { + if (candidate.compare(baseName + u".bsa"_s, Qt::CaseInsensitive) == 0 || + candidate.compare(baseName + u" - Textures.bsa"_s, Qt::CaseInsensitive) == + 0) { + return true; + } + } + return false; + + case Game::Fallout4: + case Game::Starfield: + if (candidate.endsWith(u".ba2"_s)) { + if (candidate.compare(baseName + u" - Main.ba2"_s, Qt::CaseInsensitive) == 0 || + candidate.compare(baseName + u" - Textures.ba2"_s, Qt::CaseInsensitive) == + 0 || + candidate.startsWith(baseName + u" - Voices_"_s, Qt::CaseInsensitive)) { + return true; + } + } + return false; + + default: + return candidate.endsWith(u".bsa"_s); + } +} + +void PluginList::checkBsa(TESData::FileInfo& info, + const std::shared_ptr<const MOBase::IFileTree>& fileTree) +{ + const auto managedGame = m_Organizer->managedGame(); + const auto gameName = managedGame ? managedGame->gameName() : QString(); + const Game game = gameName.startsWith(u"Skyrim"_s) ? Game::Skyrim + : gameName.startsWith(u"Enderal"_s) ? Game::Skyrim + : gameName.startsWith(u"Fallout 4"_s) ? Game::Fallout4 + : gameName == u"Starfield"_s ? Game::Starfield + : Game::Other; + + const QString baseName = QFileInfo(info.name()).completeBaseName(); + for (const auto entry : *fileTree) { + if (!entry) { + continue; + } + + const auto candidate = entry->name(); + if (isAssociatedArchive(info, candidate, game)) { + info.addArchive(candidate); + associateArchive(info, candidate); + } + } +} + +static void checkIni(TESData::FileInfo& info, + const std::shared_ptr<const MOBase::IFileTree>& fileTree) +{ + QString baseName = QFileInfo(info.name()).completeBaseName(); + QString iniPath = baseName + u".ini"_s; + if (fileTree->find(iniPath, MOBase::FileTreeEntry::FILE) != nullptr) { + info.setHasIni(true); + } +} + +static void assignConsecutivePriorities(std::vector<std::shared_ptr<FileInfo>>& plugins) +{ + boost::container::flat_multimap<int, int> priorityToId; + for (int i = 0; i < plugins.size(); ++i) { + int priority = plugins[i]->priority(); + if (priority == -1) { + priority = std::numeric_limits<int>::max(); + } + priorityToId.emplace(priority, i); + } + + int newPriority = 0; + for (auto& [priority, id] : priorityToId) { + plugins[id]->setPriority(newPriority++); + } +} + +void PluginList::scanDataFiles(bool invalidate) +{ + if (invalidate) { + m_Plugins.clear(); + m_PluginsByName.clear(); + m_PluginsByPriority.clear(); + + m_EntriesByName.clear(); + m_EntriesByHandle.clear(); + m_NextHandle = 0; + + m_MasterArchiveEntry = std::make_shared<AssociatedEntry>(); + m_Archives.clear(); + } + + const auto managedGame = m_Organizer->managedGame(); + const auto gameFeatures = m_Organizer->gameFeatures(); + + const QStringList primaryPlugins = + managedGame ? managedGame->primaryPlugins() : QStringList(); + const QStringList enabledPlugins = + managedGame ? managedGame->enabledPlugins() : QStringList(); + const auto loadOrderMechanism = managedGame + ? managedGame->loadOrderMechanism() + : MOBase::IPluginGame::LoadOrderMechanism::None; + + const auto tesSupport = + gameFeatures ? gameFeatures->gameFeature<MOBase::GamePlugins>() : nullptr; + + const bool lightPluginsAreSupported = + tesSupport && tesSupport->lightPluginsAreSupported(); + const bool mediumPluginsAreSupported = + tesSupport && tesSupport->mediumPluginsAreSupported(); + const bool blueprintPluginsAreSupported = + tesSupport && tesSupport->blueprintPluginsAreSupported(); + + QStringList availablePlugins; + + const auto tree = m_Organizer->virtualFileTree(); + for (const std::shared_ptr<const MOBase::FileTreeEntry> entry : *tree) { + if (entry == nullptr) { + continue; + } + + const QString filename = entry->name(); + + if (!isPluginFile(filename)) { + continue; + } + + if (m_Organizer->resolvePath(filename).isEmpty()) { + continue; + } + + availablePlugins.append(filename); + } + + for (const auto& modName : m_PendingActive) { + const auto modInterface = m_Organizer->modList()->getMod(modName); + const auto fileTree = modInterface ? modInterface->fileTree() : nullptr; + + if (!fileTree) + continue; + + std::shared_ptr<const MOBase::IFileTree> searchDir; + if (!m_Organizer->managedGame()->modDataDirectory().isEmpty()) { + searchDir = + fileTree->findDirectory(m_Organizer->managedGame()->modDataDirectory()); + } else { + searchDir = fileTree; + } + if (searchDir) { + for (auto&& entry : *searchDir) { + if (entry && isPluginFile(entry->name()) && + !availablePlugins.contains(entry->name(), Qt::CaseInsensitive)) { + availablePlugins.append(entry->name()); + } + } + } + } + + // Fluorine port: use ALL cores + force true async (upstream used hw/2 + + // default launch policy which libstdc++ may run as deferred → sequential). + const uint concurrency = std::max(1U, std::thread::hardware_concurrency()); + std::counting_semaphore smph{concurrency}; + + std::vector<std::shared_future<void>> futures; + for (const auto& filename : availablePlugins) { + if (!invalidate && m_PluginsByName.contains(filename)) { + continue; + } + + const bool forceLoaded = primaryPlugins.contains(filename, Qt::CaseInsensitive); + const bool forceEnabled = enabledPlugins.contains(filename, Qt::CaseInsensitive); + const bool forceDisabled = + !forceLoaded && !forceEnabled && + (loadOrderMechanism == MOBase::IPluginGame::LoadOrderMechanism::None); + + const QString fullPath = m_Organizer->resolvePath(filename); + + const auto& info = m_Plugins.emplace_back( + std::make_shared<FileInfo>(this, filename, forceLoaded, forceEnabled, + forceDisabled, lightPluginsAreSupported)); + + auto assocTask = std::async(std::launch::async, [=, &smph] { + smph.acquire(); + checkBsa(*info, tree); + checkIni(*info, tree); + smph.release(); + }); + + auto fileTask = std::async( + std::launch::async, [=, this, &smph, path = fullPath.toStdString()] { + smph.acquire(); + + try { + FileConflictParser handler{this, info.get(), lightPluginsAreSupported, + mediumPluginsAreSupported, + blueprintPluginsAreSupported}; + TESFile::Reader<FileConflictParser> reader{}; + // Fluorine port: use UTF-8 bytes (upstream used toStdWString which + // silently mangles paths through the C locale on Linux). Also fall + // back to case-insensitive directory lookup — IOrganizer::resolvePath + // returns paths with the user's original casing, while the file on + // disk may differ (Windows mods unpacked on case-sensitive Linux). + std::filesystem::path fspath{path}; + if (!std::filesystem::exists(fspath)) { + const auto parent = fspath.parent_path(); + const auto wantName = fspath.filename().string(); + if (std::filesystem::exists(parent)) { + for (const auto& entry : std::filesystem::directory_iterator{parent}) { + const auto name = entry.path().filename().string(); + if (name.size() == wantName.size() && + std::equal(name.begin(), name.end(), wantName.begin(), + [](unsigned char a, unsigned char b) { + return std::tolower(a) == std::tolower(b); + })) { + fspath = entry.path(); + break; + } + } + } + } + reader.parse(fspath, handler); + } catch (const std::exception& e) { + MOBase::log::error("Error parsing \"{}\": {}", path, e.what()); + } + + smph.release(); + }); + + futures.push_back(assocTask.share()); + futures.push_back(fileTask.share()); + } + + boost::wait_for_all(futures.begin(), futures.end()); + + if (!invalidate) { + std::erase_if(m_Plugins, [&](auto&& plugin) { + return !plugin || !availablePlugins.contains(plugin->name(), Qt::CaseInsensitive); + }); + } + + assignConsecutivePriorities(m_Plugins); + updateCache(); +} + +void PluginList::readPluginLists() +{ + const auto gameFeatures = m_Organizer->gameFeatures(); + const auto tesSupport = + gameFeatures ? gameFeatures->gameFeature<MOBase::GamePlugins>() : nullptr; + + if (tesSupport) { + tesSupport->readPluginLists(this); + } + + enforcePluginRelationships(); +} + +static void populateArchiveFiles(const std::shared_ptr<AuxItem>& master, + const std::shared_ptr<AuxItem>& entry, + const BSA::Folder::Ptr& archiveFolder, + TESFileHandle handle) +{ + for (unsigned int i = 0, num = archiveFolder->getNumFiles(); i < num; ++i) { + const auto file = archiveFolder->getFile(i); + + const auto conflictItem = + master->insert(file->getName())->createMember(file->getFilePath()); + conflictItem->alternatives.insert(handle); + entry->insert(file->getName())->setMember(conflictItem); + } + + for (unsigned int i = 0, num = archiveFolder->getNumSubFolders(); i < num; ++i) { + const auto folder = archiveFolder->getSubFolder(i); + populateArchiveFiles(master->insert(folder->getName()), + entry->insert(folder->getName()), folder, handle); + } +} + +void PluginList::associateArchive(const TESData::FileInfo& info, + const QString& archiveName) +{ + const auto archiveEntry = [this, &archiveName] { + std::unique_lock lk{m_ArchiveEntryMutex}; + auto& entry = m_Archives[archiveName]; + if (entry == nullptr) { + entry = std::make_shared<AssociatedEntry>(); + } + return entry; + }(); + + const QString archivePath = m_Organizer->resolvePath(archiveName); + if (archivePath.isEmpty()) { + return; + } + + BSA::Archive archive; + BSA::EErrorCode result = BSA::ERROR_NONE; + + try { + result = archive.read(qPrintable(archivePath), false); + } catch (const std::exception& e) { + MOBase::log::error("invalid bsa '{}', error {}", archivePath, e.what()); + return; + } + + if (result != BSA::ERROR_NONE && result != BSA::ERROR_INVALIDHASHES) { + MOBase::log::error("invalid bsa '{}', error {}", archivePath, result); + return; + } + + const auto handle = createEntry(info.name().toStdString())->handle(); + populateArchiveFiles(m_MasterArchiveEntry->root(), archiveEntry->root(), + archive.getRoot(), handle); +} + +void PluginList::clearGroups() +{ + for (const auto& plugin : m_Plugins) { + plugin->setGroup(QString()); + } +} + +void PluginList::readGroups(const QString& fileName) +{ + clearGroups(); + + QFile file{fileName}; + if (!file.exists()) { + return; + } + + int lineNumber = 0; + if (!file.open(QFile::ReadOnly)) { + return; + } + + QTextStream stream{&file}; + + QString line; + while (stream.readLineInto(&line)) { + ++lineNumber; + + if (line.length() <= 0 || line.at(0) == u'#') { + continue; + } + + const auto fields = line.split(u'|'); + if (fields.count() != 2) { + MOBase::log::error("plugin groups file: invalid line #{}: {}", lineNumber, line); + continue; + } + + if (const auto it = m_PluginsByName.find(fields[0]); it != m_PluginsByName.end()) { + const auto& plugin = m_Plugins.at(it->second); + plugin->setGroup(fields[1]); + } + } + + file.close(); +} + +void PluginList::writeEmptyTextFile(const QString& fileName) const +{ + MOBase::SafeWriteFile file{fileName}; + + file->resize(0); + file->write("# This file was automatically generated by Mod Organizer.\r\n"_ba); + file->commit(); +} + +void PluginList::writeGroups(const QString& fileName) const +{ + MOBase::SafeWriteFile file{fileName}; + + file->resize(0); + file->write("# This file was automatically generated by Mod Organizer.\r\n"_ba); + for (const auto& [name, i] : m_PluginsByName) { + const auto& plugin = m_Plugins.at(i); + const auto& group = plugin->group(); + if (!group.isEmpty()) { + file->write(u"%1|%2\r\n"_s.arg(name).arg(group).toUtf8()); + } + } + + file->commit(); +} + +void PluginList::queuePluginStateChange(const QString& pluginName, PluginStates state) +{ + if (m_Refreshing) { + m_QueuedStateChanges[pluginName] = state; + } else { + computeCompileIndices(); + refreshLoadOrder(); + pluginStatesChanged({pluginName}, state); + testMasters(); + } +} + +void PluginList::dispatchPluginStateChanges() +{ + if (!m_QueuedStateChanges.empty()) { + m_PluginStateChanged(m_QueuedStateChanges); + m_QueuedStateChanges.clear(); + } +} + +void PluginList::pluginStatesChanged(const QStringList& pluginNames, + PluginStates state) const +{ + if (pluginNames.isEmpty()) { + return; + } + + std::map<QString, IPluginList::PluginStates> infos; + for (const auto& name : pluginNames) { + infos[name] = state; + } + + m_PluginStateChanged(infos); +} + +void PluginList::enforcePluginRelationships() +{ + MOBase::TimeThis tt{"TESData::PluginList::enforcePluginRelationships"}; + + for (int i = 0; i < m_PluginsByPriority.size(); ++i) { + const int& firstIndex = m_PluginsByPriority[i]; + + for (int j = i + 1; j < m_PluginsByPriority.size(); ++j) { + const int secondIndex = m_PluginsByPriority[j]; + const auto& firstPlugin = m_Plugins.at(firstIndex); + const auto& secondPlugin = m_Plugins.at(secondIndex); + + if (firstPlugin->mustLoadAfter(*secondPlugin)) { + for (int k = j; k > i + 1; --k) { + m_PluginsByPriority[k] = m_PluginsByPriority[k - 1]; + m_Plugins[m_PluginsByPriority[k]]->setPriority(k); + } + m_PluginsByPriority[i + 1] = firstIndex; + firstPlugin->setPriority(i + 1); + m_PluginsByPriority[i] = secondIndex; + secondPlugin->setPriority(i); + j = i; + } + } + } + + computeCompileIndices(); + refreshLoadOrder(); +} + +void PluginList::testMasters() +{ + boost::container::flat_set<QString, MOBase::FileNameComparator> enabledMasters; + for (const auto& plugin : m_Plugins) { + if (plugin->enabled()) { + enabledMasters.insert(plugin->name()); + } + } + + for (auto& plugin : m_Plugins) { + std::vector<QString> missingMasters; + std::ranges::remove_copy_if(plugin->masters(), std::back_inserter(missingMasters), + [&](auto&& file) { + return enabledMasters.contains(file); + }); + plugin->setMissingMasters(missingMasters); + } +} + +void PluginList::updateCache() +{ + m_PluginsByName.clear(); + m_PluginsByPriority.clear(); + m_PluginsByPriority.resize(m_Plugins.size()); + for (int i = 0; i < m_Plugins.size(); ++i) { + if (m_Plugins[i]->priority() < 0) { + continue; + } + if (m_Plugins[i]->priority() >= static_cast<int>(m_Plugins.size())) { + MOBase::log::error("invalid plugin priority: {}", m_Plugins[i]->priority()); + continue; + } + m_PluginsByName[m_Plugins[i]->name()] = i; + m_PluginsByPriority[m_Plugins[i]->priority()] = i; + } + + computeCompileIndices(); + refreshLoadOrder(); +} + +void PluginList::computeCompileIndices() +{ + int numNormal = 0; + int numESLs = 0; + int numESHs = 0; + int numSkipped = 0; + + const auto gameFeatures = m_Organizer->gameFeatures(); + const auto tesSupport = + gameFeatures ? gameFeatures->gameFeature<MOBase::GamePlugins>() : nullptr; + + const bool lightPluginsAreSupported = + tesSupport && tesSupport->lightPluginsAreSupported(); + const bool mediumPluginsAreSupported = + tesSupport && tesSupport->mediumPluginsAreSupported(); + + for (int priority = 0; priority < m_PluginsByPriority.size(); ++priority) { + const int index = m_PluginsByPriority[priority]; + const auto& plugin = m_Plugins.at(index); + + if (!plugin->enabled()) { + plugin->setIndex(QString()); + ++numSkipped; + continue; + } + + if (mediumPluginsAreSupported && plugin->isMediumFile()) { + const int ESHpos = 0xFD + (numESHs >> 8); + plugin->setIndex((u"%1:%2"_s) + .arg(ESHpos, 2, 16, QChar(u'0')) + .arg(numESHs & 0xFF, 2, 16, QChar(u'0')) + .toUpper()); + } else if (lightPluginsAreSupported && plugin->isSmallFile()) { + const int ESLpos = 0xFE + (numESLs >> 12); + plugin->setIndex((u"%1:%2"_s) + .arg(ESLpos, 2, 16, QChar(u'0')) + .arg(numESLs & 0xFFF, 3, 16, QChar(u'0')) + .toUpper()); + ++numESLs; + } else { + plugin->setIndex((u"%1"_s).arg(numNormal++, 2, 16, QChar(u'0')).toUpper()); + } + } +} + +void PluginList::refreshLoadOrder() +{ + int loadOrder = 0; + for (int i = 0; i < m_PluginsByPriority.size(); ++i) { + int index = m_PluginsByPriority[i]; + const auto& plugin = m_Plugins.at(index); + + if (plugin->enabled()) { + plugin->setLoadOrder(loadOrder++); + } else { + plugin->setLoadOrder(-1); + } + } + emit pluginsListChanged(); +} + +#pragma endregion Helpers + +} // namespace TESData diff --git a/libs/installer_bsplugins/src/TESData/PluginList.h b/libs/installer_bsplugins/src/TESData/PluginList.h new file mode 100644 index 0000000..b263ffb --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/PluginList.h @@ -0,0 +1,200 @@ +#ifndef TESDATA_PLUGINLIST_H +#define TESDATA_PLUGINLIST_H + +#include "AssociatedEntry.h" +#include "FileEntry.h" +#include "FileInfo.h" +#include "MOTools/ILootCache.h" +#include "TESFile/Type.h" + +#include <gameplugins.h> +#include <ifiletree.h> +#include <imoinfo.h> +#include <iplugingame.h> +#include <ipluginlist.h> +#include <memoizedlock.h> + +#include <boost/signals2.hpp> + +#include <QObject> + +#include <atomic> +#include <map> +#include <memory> +#include <set> +#include <shared_mutex> +#include <string> +#include <vector> + +namespace TESData +{ + +class PluginList final : public QObject, + public MOBase::IPluginList, + public MOTools::ILootCache +{ + Q_OBJECT + +public: + using SignalRefreshed = boost::signals2::signal<void()>; + using SignalPluginMoved = boost::signals2::signal<void(const QString&, int, int)>; + using SignalPluginStateChanged = + boost::signals2::signal<void(const std::map<QString, PluginStates>&)>; + + explicit PluginList(const MOBase::IOrganizer* moInfo); + + PluginList(const PluginList&) = delete; + PluginList(PluginList&&) = delete; + + ~PluginList() noexcept; + + PluginList& operator=(const PluginList&) = delete; + PluginList& operator=(PluginList&&) = delete; + + [[nodiscard]] int pluginCount() const; + + [[nodiscard]] const FileInfo* getPlugin(int index) const; + [[nodiscard]] const FileInfo* getPluginByName(const QString& name) const; + [[nodiscard]] const FileInfo* getPluginByPriority(int priority) const; + [[nodiscard]] int getIndex(const QString& pluginName) const; + [[nodiscard]] int getIndexAtPriority(int priority) const; + [[nodiscard]] QString getOriginName(int index) const; + + [[nodiscard]] FileEntry* findEntryByName(const std::string& pluginName) const; + [[nodiscard]] FileEntry* findEntryByHandle(TESFileHandle handle) const; + [[nodiscard]] AssociatedEntry* findArchive(const QString& name) const; + + FileEntry* createEntry(const std::string& name); + void addRecordConflict(const std::string& pluginName, const RecordPath& path, + TESFile::Type type, const std::string& name); + void addGroupPlaceholder(const std::string& pluginName, const RecordPath& path); + + void refresh(bool invalidate = false); + + void setEnabled(int id, bool enable); + void setEnabled(const std::vector<int>& ids, bool enable); + void toggleState(const std::vector<int>& ids); + + [[nodiscard]] bool canMoveToPriority(const std::vector<int>& ids, + int newPriority) const; + void moveToPriority(std::vector<int> ids, int destination, bool disjoint = false); + void shiftPriority(const std::vector<int>& ids, int offset); + + void setGroup(const std::vector<int>& ids, const QString& group); + + [[nodiscard]] QStringList loadOrder() const; + + [[nodiscard]] bool isRefreshing() const { return m_Refreshing; } + + void notifyPendingState(const QString& mod, MOBase::IModList::ModStates state); + void flushPendingStates(); + + // IPluginList + + [[nodiscard]] QStringList pluginNames() const override; + [[nodiscard]] PluginStates state(const QString& name) const override; + void setState(const QString& name, PluginStates state) override; + [[nodiscard]] int priority(const QString& name) const override; + bool setPriority(const QString& name, int newPriority) override; + [[nodiscard]] int loadOrder(const QString& name) const override; + void setLoadOrder(const QStringList& pluginList) override; + [[deprecated]] bool isMaster(const QString& name) const override; + [[nodiscard]] QStringList masters(const QString& name) const override; + [[nodiscard]] QString origin(const QString& name) const override; + + bool onRefreshed(const std::function<void()>& callback) override; + bool + onPluginMoved(const std::function<void(const QString&, int, int)>& func) override; + bool onPluginStateChanged( + const std::function<void(const std::map<QString, PluginStates>&)>& func) override; + + [[nodiscard]] bool hasMasterExtension(const QString& name) const override; + [[nodiscard]] bool hasLightExtension(const QString& name) const override; + [[nodiscard]] bool isMasterFlagged(const QString& name) const override; + [[nodiscard]] bool isMediumFlagged(const QString& name) const override; + [[nodiscard]] bool isLightFlagged(const QString& name) const override; + [[nodiscard]] bool isBlueprintFlagged(const QString& name) const override; + [[nodiscard]] bool hasNoRecords(const QString& name) const override; + [[nodiscard]] int formVersion(const QString& name) const override; + [[nodiscard]] float headerVersion(const QString& name) const override; + + [[nodiscard]] QString author(const QString& name) const override; + [[nodiscard]] QString description(const QString& name) const override; + + // ILootCache + + void clearAdditionalInformation() override; + void addLootReport(const QString& name, MOTools::Loot::Plugin plugin) override; + [[nodiscard]] const MOTools::Loot::Plugin* getLootReport(const QString& name) const; + +public slots: + void writePluginLists() const; + +signals: + void pluginsListChanged(); + +private: + [[nodiscard]] FileInfo* findPlugin(const QString& name); + [[nodiscard]] const FileInfo* findPlugin(const QString& name) const; + + void scanDataFiles(bool invalidate); + void readPluginLists(); + void checkBsa(TESData::FileInfo& info, + const std::shared_ptr<const MOBase::IFileTree>& fileTree); + void associateArchive(const TESData::FileInfo& info, const QString& archiveName); + + [[nodiscard]] QString groupsPath() const; + [[nodiscard]] QString lockedOrderPath() const; + void clearGroups(); + void readGroups(const QString& fileName); + void writeEmptyTextFile(const QString& fileName) const; + void writeGroups(const QString& fileName) const; + [[nodiscard]] QString destinationGroup( + int oldPriority, int newPriority, const QString& originalGroup, bool isESM, + const boost::container::flat_set<QString, MOBase::FileNameComparator>& + exclusions); + + void queuePluginStateChange(const QString& pluginName, PluginStates state); + void dispatchPluginStateChanges(); + void pluginStatesChanged(const QStringList& pluginNames, PluginStates state) const; + void enforcePluginRelationships(); + void testMasters(); + void updateCache(); + void computeCompileIndices(); + void refreshLoadOrder(); + + const MOBase::IOrganizer* m_Organizer; + + std::vector<std::shared_ptr<FileInfo>> m_Plugins; + + std::map<QString, int, MOBase::FileNameComparator> m_PluginsByName; + std::vector<int> m_PluginsByPriority; + + std::map<QString, MOTools::Loot::Plugin, MOBase::FileNameComparator> m_LootInfo; + + std::atomic<TESFileHandle> m_NextHandle = 0; + std::map<std::string, std::shared_ptr<FileEntry>, TESFile::less> m_EntriesByName; + boost::container::flat_map<TESFileHandle, std::shared_ptr<FileEntry>> + m_EntriesByHandle; + std::map<std::string, std::shared_ptr<Record>> m_Settings; + std::map<TESFile::Type, std::shared_ptr<Record>> m_DefaultObjects; + + std::shared_ptr<AssociatedEntry> m_MasterArchiveEntry; + std::map<QString, std::shared_ptr<AssociatedEntry>, MOBase::FileNameComparator> + m_Archives; + + mutable std::shared_mutex m_FileEntryMutex; + mutable std::shared_mutex m_ArchiveEntryMutex; + + bool m_Refreshing = true; + std::map<QString, PluginStates> m_QueuedStateChanges; + std::set<QString> m_PendingActive; + + SignalRefreshed m_Refreshed; + SignalPluginMoved m_PluginMoved; + SignalPluginStateChanged m_PluginStateChanged; +}; + +} // namespace TESData + +#endif // TESDATA_PLUGINLIST_H diff --git a/libs/installer_bsplugins/src/TESData/Record.h b/libs/installer_bsplugins/src/TESData/Record.h new file mode 100644 index 0000000..0edda85 --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/Record.h @@ -0,0 +1,90 @@ +#ifndef TESDATA_RECORD_H +#define TESDATA_RECORD_H + +#include "TESFile/Type.h" + +#include <QString> + +#include <set> +#include <span> + +namespace TESData +{ + +using TESFileHandle = int; + +class Record final +{ +public: + using Identifier = + std::variant<std::monostate, std::uint32_t, std::string, TESFile::Type>; + + [[nodiscard]] TESFile::Type formType() const { return m_FormType; } + + [[nodiscard]] const std::string& file() const { return m_File; } + + [[nodiscard]] const Identifier& identifier() const { return m_Identifier; } + + [[nodiscard]] bool hasFormId() const + { + return std::holds_alternative<std::uint32_t>(m_Identifier); + } + + [[nodiscard]] bool hasEditorId() const + { + return std::holds_alternative<std::string>(m_Identifier); + } + + [[nodiscard]] bool hasTypeId() const + { + return std::holds_alternative<TESFile::Type>(m_Identifier); + } + + [[nodiscard]] std::uint32_t formId() const + { + return std::get<std::uint32_t>(m_Identifier); + } + + [[nodiscard]] const std::string& editorId() const + { + return std::get<std::string>(m_Identifier); + } + + [[nodiscard]] TESFile::Type typeId() const + { + return std::get<TESFile::Type>(m_Identifier); + } + + [[nodiscard]] const std::set<TESFileHandle>& alternatives() const + { + return m_Alternatives; + } + + [[nodiscard]] bool ignored() const { return m_Ignored; } + void setIgnored(bool value) { m_Ignored = value; } + + void setIdentifier(const Identifier& identifier, std::span<const std::string> files) + { + m_Identifier = identifier; + + if (std::holds_alternative<std::uint32_t>(m_Identifier)) { + std::uint32_t& formId = std::get<std::uint32_t>(m_Identifier); + const std::uint8_t localIndex = formId >> 24U; + formId &= ~0xFF000000U; + m_File = files[localIndex]; + } + } + + void addAlternative(TESFileHandle origin) { m_Alternatives.insert(origin); } + +private: + TESFile::Type m_FormType; + std::string m_File; + Identifier m_Identifier; + std::set<TESFileHandle> m_Alternatives; + bool m_Ignored = false; +}; + +} // namespace TESData + +#endif // TESDATA_RECORD_H diff --git a/libs/installer_bsplugins/src/TESData/RecordPath.cpp b/libs/installer_bsplugins/src/TESData/RecordPath.cpp new file mode 100644 index 0000000..ac6a575 --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/RecordPath.cpp @@ -0,0 +1,158 @@ +#include "RecordPath.h" + +#include <algorithm> +#include <iomanip> +#include <iterator> +#include <sstream> + +namespace TESData +{ + +std::string RecordPath::string() const +{ + std::ostringstream ss; + ss << ":/"; + + for (int i = 0; i < m_Groups.size(); ++i) { + const TESFile::GroupData group = m_Groups[i]; + + if (group.hasFormType()) { + ss << group.formType().view() << "/"; + } else if (group.hasParent()) { + if (i == 0 || !m_Groups[i - 1].hasParent() || + m_Groups[i - 1].parent() != group.parent()) { + const auto parentId = group.parent(); + const std::uint8_t localIndex = parentId >> 24U; + const std::string& owner = m_Files.at(localIndex); + ss << owner << "|"; + ss << std::hex << std::setfill('0') << std::setw(6) << (parentId & 0xFFFFFF); + ss << "/"; + } else { + switch (group.type()) { + case TESFile::GroupType::CellPersistentChildren: + ss << "Persistent/"; + break; + case TESFile::GroupType::CellTemporaryChildren: + ss << "Temporary/"; + break; + case TESFile::GroupType::CellVisibleDistantChildren: + ss << "Visible Distant/"; + break; + } + } + } else if (group.hasBlock()) { + ss << group.block() << "/"; + } else if (group.hasGridCell()) { + auto [x, y] = group.gridCell(); + ss << x << ", " << y << "/"; + } + } + + if (hasFormId()) { + const auto id = formId(); + const std::uint8_t localIndex = id >> 24U; + const std::string& owner = m_Files.at(localIndex); + ss << owner << "|"; + ss << std::hex << std::setfill('0') << std::setw(6) << (id & 0xFFFFFF); + } else if (hasEditorId()) { + ss << editorId(); + } else if (hasTypeId()) { + ss << typeId(); + } + + return ss.str(); +} + +void RecordPath::setFormId(std::uint32_t formId, std::span<const std::string> masters, + const std::string& file) +{ + const std::uint8_t localIndex = formId >> 24U; + const std::string& owner = localIndex < masters.size() ? masters[localIndex] : file; + const std::uint8_t newIndex = static_cast<std::uint8_t>( + std::distance(std::begin(m_Files), TESFile::find(m_Files, owner))); + + if (newIndex == m_Files.size()) { + m_Files.push_back(owner); + } + + m_Identifier = (formId & 0xFFFFFF) | (newIndex << 24U); +} + +void RecordPath::unsetFormId() +{ + if (!hasFormId()) + return; + + const std::uint8_t localIndex = formId() >> 24U; + m_Identifier = {}; + + if (localIndex == m_Files.size() - 1) { + cleanLastFile(); + } +} + +void RecordPath::setEditorId(const std::string& editorId) +{ + unsetFormId(); + m_Identifier = editorId; +} + +void RecordPath::setTypeId(TESFile::Type type) +{ + unsetFormId(); + m_Identifier = type; +} + +void RecordPath::setIdentifier(const Identifier& identifier, + std::span<const std::string> masters) +{ + if (std::holds_alternative<std::uint32_t>(identifier)) { + setFormId(std::get<std::uint32_t>(identifier), masters, ""); + } else { + unsetFormId(); + m_Identifier = identifier; + } +} + +void RecordPath::push(TESFile::GroupData group, std::span<const std::string> masters, + const std::string& file) +{ + if (group.hasParent()) { + const std::uint8_t localIndex = group.parent() >> 24U; + const std::string& owner = localIndex < masters.size() ? masters[localIndex] : file; + const std::uint8_t newIndex = static_cast<std::uint8_t>( + std::distance(std::begin(m_Files), TESFile::find(m_Files, owner))); + + if (newIndex == m_Files.size()) { + m_Files.push_back(owner); + } + + group.setLocalIndex(newIndex); + } + + m_Groups.push_back(group); +} + +void RecordPath::pop() +{ + unsetFormId(); + + const auto& top = m_Groups.back(); + const std::uint8_t localIndex = top.hasParent() ? top.parent() >> 24U : 0xFF; + m_Groups.pop_back(); + + if (localIndex == m_Files.size() - 1) { + cleanLastFile(); + } +} + +void RecordPath::cleanLastFile() +{ + if (!std::ranges::any_of(m_Groups, [&](auto&& group) { + return group.hasParent() && group.parent() >> 24U == m_Files.size() - 1; + })) { + m_Files.pop_back(); + } +} + +} // namespace TESData diff --git a/libs/installer_bsplugins/src/TESData/RecordPath.h b/libs/installer_bsplugins/src/TESData/RecordPath.h new file mode 100644 index 0000000..712edfa --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/RecordPath.h @@ -0,0 +1,86 @@ +#ifndef TESDATA_RECORDPATH_H +#define TESDATA_RECORDPATH_H + +#include "TESFile/Stream.h" + +#include <boost/container/small_vector.hpp> + +#include <cstdint> +#include <span> +#include <string> +#include <variant> + +namespace TESData +{ + +class RecordPath final +{ +public: + using Identifier = + std::variant<std::monostate, std::uint32_t, std::string, TESFile::Type>; + + [[nodiscard]] bool hasFormId() const + { + return std::holds_alternative<std::uint32_t>(m_Identifier); + } + + [[nodiscard]] bool hasEditorId() const + { + return std::holds_alternative<std::string>(m_Identifier); + } + + [[nodiscard]] bool hasTypeId() const + { + return std::holds_alternative<TESFile::Type>(m_Identifier); + } + + [[nodiscard]] std::uint32_t formId() const + { + return std::get<std::uint32_t>(m_Identifier); + } + + [[nodiscard]] const std::string& editorId() const + { + return std::get<std::string>(m_Identifier); + } + + [[nodiscard]] TESFile::Type typeId() const + { + return std::get<TESFile::Type>(m_Identifier); + } + + [[nodiscard]] std::span<const std::string> files() const { return m_Files; } + + [[nodiscard]] std::span<const TESFile::GroupData> groups() const { return m_Groups; } + + [[nodiscard]] const auto& identifier() const { return m_Identifier; } + + [[nodiscard]] std::string string() const; + + void setFormId(std::uint32_t formId, std::span<const std::string> masters, + const std::string& file); + + void unsetFormId(); + + void setEditorId(const std::string& editorId); + + void setTypeId(TESFile::Type type); + + void setIdentifier(const Identifier& identifier, std::span<const std::string> files); + + void push(TESFile::GroupData group, const std::span<const std::string> masters, + const std::string& file); + + void pop(); + +private: + void cleanLastFile(); + + boost::container::small_vector<std::string, 2> m_Files; + boost::container::small_vector<TESFile::GroupData, 4> m_Groups; + Identifier m_Identifier; +}; + +} // namespace TESData + +#endif // TESDATA_RECORDPATH_H diff --git a/libs/installer_bsplugins/src/TESData/SingleRecordParser.cpp b/libs/installer_bsplugins/src/TESData/SingleRecordParser.cpp new file mode 100644 index 0000000..5cc553d --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/SingleRecordParser.cpp @@ -0,0 +1,179 @@ +#include "SingleRecordParser.h" +#include "FormParser.h" + +#include <algorithm> +#include <array> +#include <iterator> +#include <utility> + +using namespace Qt::Literals::StringLiterals; + +namespace TESData +{ + +using Game = FormParserManager::Game; + +static constexpr auto GameMap = std::to_array<std::pair<QStringView, Game>>({ + {u"Skyrim Special Edition", Game::SSE}, + {u"Skyrim VR", Game::SSE}, + {u"Enderal Special Edition", Game::SSE}, + {u"Fallout 4", Game::FO4}, + {u"Fallout 4 VR", Game::FO4}, + {u"Skyrim", Game::TES5}, + {u"Enderal", Game::TES5}, + {u"New Vegas", Game::FNV}, + {u"TTW", Game::FNV}, + {u"Fallout 3", Game::FO3}, + {u"Oblivion", Game::TES4}, + {u"Oblivion Remastered", Game::TES4}, +}); + +static Game gameIdentifier(QStringView gameName) +{ + const auto it = std::ranges::find_if(GameMap, [=](auto&& pair) { + return pair.first == gameName; + }); + + if (it != std::end(GameMap)) { + return it->second; + } else { + return Game::Unknown; + } +} + +SingleRecordParser::SingleRecordParser(const QString& gameName, const RecordPath& path, + const std::string& file, DataItem* root, + int index) + : m_GameName{gameName}, m_Path{path}, m_File{file}, m_DataRoot{root}, + m_FileIndex{index} +{} + +bool SingleRecordParser::Group(TESFile::GroupData group) +{ + if (m_Depth == m_Path.groups().size()) { + return false; + } + + if (group.hasParent()) { + const std::uint8_t localIndex = group.parent() >> 24U; + const std::string& owner = + localIndex < m_Masters.size() ? m_Masters[localIndex] : m_File; + const auto files = m_Path.files(); + const std::uint8_t newIndex = static_cast<std::uint8_t>( + std::distance(std::begin(files), TESFile::find(files, owner))); + + group.setLocalIndex(newIndex); + } + + if (group == m_Path.groups()[m_Depth]) { + ++m_Depth; + return true; + } + + return false; +} + +bool SingleRecordParser::Form(TESFile::FormData form) +{ + m_CurrentType = form.type(); + m_CurrentFlags = form.flags(); + + if (m_Depth == 0) { + if (form.type() == "TES4"_ts) { + m_Localized = (form.flags() & TESFile::RecordFlags::Localized); + } + + return true; + } + + if (m_Path.hasFormId()) { + if ((form.formId() & 0xFFFFFF) != (m_Path.formId() & 0xFFFFFF)) { + return false; + } + + const std::uint8_t localIndex = form.formId() >> 24U; + const std::string& owner = + localIndex < m_Masters.size() ? m_Masters[localIndex] : m_File; + if (!TESFile::iequals(m_Path.files()[m_Path.formId() >> 24U], owner)) { + return false; + } + + m_RecordFound = true; + const auto game = gameIdentifier(m_GameName); + FormParserManager::getParser(game, m_CurrentType) + ->parseFlags(m_DataRoot, m_FileIndex, m_CurrentFlags); + return true; + } else { + return !m_RecordFound; + } + + return false; +} + +bool SingleRecordParser::Chunk(TESFile::Type type) +{ + m_CurrentChunk = type; + return true; +} + +void SingleRecordParser::Data(std::istream& stream) +{ + m_ChunkStream = &stream; + + if (m_Depth == 0) { + if (m_CurrentChunk == "MAST"_ts) { + const std::string master = TESFile::readZstring(stream); + if (!master.empty()) { + m_Masters.push_back(master); + } + } + return; + } + + if (m_Path.hasEditorId() && m_CurrentChunk == "EDID"_ts) { + const std::string editorId = TESFile::readZstring(stream); + if (editorId != m_Path.editorId()) { + return; + } + + m_RecordFound = true; + const auto game = gameIdentifier(m_GameName); + FormParserManager::getParser(game, m_CurrentType) + ->parseFlags(m_DataRoot, m_FileIndex, m_CurrentFlags); + stream.seekg(std::ios::beg); + } + + if (m_Path.hasTypeId() && m_CurrentChunk == "DNAM"_ts) { + while (stream.peek() != std::char_traits<char>::eof()) { + const TESFile::Type name = TESFile::readType<TESFile::Type>(stream); + const QString formIdString = readFormId(m_Masters, m_File, stream); + if (name == m_Path.typeId()) { + m_DataRoot->getOrInsertChild(0, name, u""_s) + ->setData(m_FileIndex, formIdString); + m_RecordFound = true; + break; + } + if (name == "BBBB"_ts) { + break; + } + } + return; + } + + if (!m_RecordFound) { + return; + } + + if (!m_ParseTask) { + const auto game = gameIdentifier(m_GameName); + m_ParseTask = FormParserManager::getParser(game, m_CurrentType) + ->parseForm(m_DataRoot, m_FileIndex, m_Localized, m_Masters, + m_File, m_CurrentChunk, m_ChunkStream); + } + + if (!m_ParseTask.done()) { + m_ParseTask.resume(); + } +} + +} // namespace TESData diff --git a/libs/installer_bsplugins/src/TESData/SingleRecordParser.h b/libs/installer_bsplugins/src/TESData/SingleRecordParser.h new file mode 100644 index 0000000..4304166 --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/SingleRecordParser.h @@ -0,0 +1,52 @@ +#ifndef TESDATA_SINGLERECORDPARSER_H +#define TESDATA_SINGLERECORDPARSER_H + +#include "DataItem.h" +#include "RecordPath.h" +#include "TESFile/Stream.h" + +#include <iplugingame.h> + +#include <coroutine> +#include <functional> +#include <memory> +#include <vector> + +namespace TESData +{ + +class IFormParser; + +class SingleRecordParser final +{ +public: + SingleRecordParser(const QString& gameName, const RecordPath& path, + const std::string& file, DataItem* root, int index); + + bool Group(TESFile::GroupData group); + bool Form(TESFile::FormData form); + bool Chunk(TESFile::Type type); + void Data(std::istream& stream); + +private: + QString m_GameName; + RecordPath m_Path; + std::string m_File; + DataItem* m_DataRoot; + int m_FileIndex; + + TESFile::Type m_CurrentType; + std::uint32_t m_CurrentFlags = 0; + std::coroutine_handle<> m_ParseTask; + std::istream* m_ChunkStream; + + std::vector<std::string> m_Masters; + int m_Depth = 0; + bool m_Localized = false; + bool m_RecordFound = false; + TESFile::Type m_CurrentChunk; +}; + +} // namespace TESData + +#endif // TESDATA_SINGLERECORDPARSER_H diff --git a/libs/installer_bsplugins/src/TESData/TypeStringNames.h b/libs/installer_bsplugins/src/TESData/TypeStringNames.h new file mode 100644 index 0000000..61f6916 --- /dev/null +++ b/libs/installer_bsplugins/src/TESData/TypeStringNames.h @@ -0,0 +1,560 @@ +#ifndef TESDATA_FORMNAMES_H +#define TESDATA_FORMNAMES_H + +#include "TESFile/Type.h" + +#include <QStringView> + +#include <algorithm> +#include <array> +#include <utility> + +namespace TESData +{ + +constexpr auto FormNames = std::to_array<std::pair<TESFile::Type, QStringView>>({ + {"AACT"_ts, u"Actor Action"}, + {"ACHR"_ts, u"Placed NPC"}, + {"ACTI"_ts, u"Activator"}, + {"ADDN"_ts, u"AddOnNode"}, + {"ALCH"_ts, u"Potion"}, + {"AMMO"_ts, u"Ammo"}, + {"ANIO"_ts, u"AnimObject"}, + {"APPA"_ts, u"Alchemical Apparatus"}, + {"ARMA"_ts, u"ArmorAddon"}, + {"ARMO"_ts, u"Armor"}, + {"ARTO"_ts, u"Art Object"}, + {"ASPC"_ts, u"Acoustic Space"}, + {"ASTP"_ts, u"Association Type"}, + {"AVIF"_ts, u"ActorValueInfo"}, + {"BOOK"_ts, u"Book"}, + {"BPTD"_ts, u"BodyPartData"}, + {"CAMS"_ts, u"CameraShot"}, + {"CELL"_ts, u"Cell"}, + {"CLAS"_ts, u"Class"}, + {"CLDC"_ts, u""}, + {"CLFM"_ts, u"ColorForm"}, + {"CLMT"_ts, u"Climate"}, + {"COBJ"_ts, u"Constructible Object"}, + {"COLL"_ts, u"Collision Layer"}, + {"CONT"_ts, u"Container"}, + {"CPTH"_ts, u"Camera Path"}, + {"CSTY"_ts, u"CombatStyle"}, + {"DEBR"_ts, u"Debris"}, + {"DIAL"_ts, u"Dialogue Topic"}, + {"DLBR"_ts, u"Dialogue Branch"}, + {"DLVW"_ts, u"Dialogue View"}, + {"DOBJ"_ts, u"Default Object"}, + {"DOOR"_ts, u"Door"}, + {"DUAL"_ts, u"Dual Cast Data"}, + {"ECZN"_ts, u"Encounter Zone"}, + {"EFSH"_ts, u"EffectShader"}, + {"ENCH"_ts, u"Enchantment"}, + {"EQUP"_ts, u"Equip Slot"}, + {"EXPL"_ts, u"Explosion"}, + {"EYES"_ts, u"Eyes"}, + {"FACT"_ts, u"Faction"}, + {"FLOR"_ts, u"Flora"}, + {"FLST"_ts, u"FormList"}, + {"FSTP"_ts, u"Footstep"}, + {"FSTS"_ts, u"Footstep Set"}, + {"FURN"_ts, u"Furniture"}, + {"GLOB"_ts, u"Global"}, + {"GMST"_ts, u"GameSetting"}, + {"GRAS"_ts, u"Grass"}, + {"HAIR"_ts, u""}, + {"HAZD"_ts, u"Hazard"}, + {"HDPT"_ts, u"HeadPart"}, + {"IDLE"_ts, u"Animation"}, + {"IDLM"_ts, u"IdleMarker"}, + {"IMAD"_ts, u"Imagespace Modifier"}, + {"IMGS"_ts, u"Imagespace"}, + {"INFO"_ts, u"Topic Info"}, + {"INGR"_ts, u"Ingredient"}, + {"IPCT"_ts, u"ImpactData"}, + {"IPDS"_ts, u"ImpactDataSet"}, + {"KEYM"_ts, u"Key"}, + {"KYWD"_ts, u"Keyword"}, + {"LAND"_ts, u"Landscape"}, + {"LCRT"_ts, u"Location Ref Type"}, + {"LCTN"_ts, u"Location"}, + {"LENS"_ts, u"LensFlare"}, + {"LGTM"_ts, u"Lighting Template"}, + {"LIGH"_ts, u"Light"}, + {"LSCR"_ts, u"LoadScreen"}, + {"LTEX"_ts, u"LandTexture"}, + {"LVLI"_ts, u"LeveledItem"}, + {"LVLN"_ts, u"LeveledCharacter"}, + {"LVSP"_ts, u"LeveledSpell"}, + {"MATO"_ts, u"Material Object"}, + {"MATT"_ts, u"Material Type"}, + {"MESG"_ts, u"Message"}, + {"MGEF"_ts, u"Magic Effect"}, + {"MISC"_ts, u"MiscItem"}, + {"MOVT"_ts, u"Movement Type"}, + {"MSTT"_ts, u"MovableStatic"}, + {"MUSC"_ts, u"Music Type"}, + {"MUST"_ts, u"Music Track"}, + {"NAVI"_ts, u"Navigation Mesh Info Map"}, + {"NAVM"_ts, u"Navigation Mesh"}, + {"NPC_"_ts, u"Actor"}, + {"OTFT"_ts, u"Outfit"}, + {"PACK"_ts, u"Package"}, + {"PARW"_ts, u"Placed Arrow"}, + {"PBAR"_ts, u"Placed Barrier"}, + {"PBEA"_ts, u"Placed Beam"}, + {"PCON"_ts, u"Placed Cone/Voice"}, + {"PERK"_ts, u"Perk"}, + {"PFLA"_ts, u"Placed Flame"}, + {"PGRE"_ts, u"Placed Projectile"}, + {"PHZD"_ts, u"Placed Hazard"}, + {"PLYR"_ts, u"Player Reference"}, + {"PMIS"_ts, u"Placed Missile"}, + {"PROJ"_ts, u"Projectile"}, + {"PWAT"_ts, u""}, + {"QUST"_ts, u"Quest"}, + {"RACE"_ts, u"Race"}, + {"REFR"_ts, u"Placed Object"}, + {"REGN"_ts, u"Region"}, + {"RELA"_ts, u"Relationship"}, + {"REVB"_ts, u"Reverb Parameters"}, + {"RFCT"_ts, u"Visual Effect"}, + {"RGDL"_ts, u""}, + {"SCEN"_ts, u"Scene"}, + {"SCOL"_ts, u"Static Collection"}, + {"SCPT"_ts, u""}, + {"SCRL"_ts, u"Scroll"}, + {"SHOU"_ts, u"Shout"}, + {"SLGM"_ts, u"Soul Gem"}, + {"SMBN"_ts, u"Story Manager Branch Node"}, + {"SMEN"_ts, u"Story Manager Event Node"}, + {"SMQN"_ts, u"Story Manager Quest Node"}, + {"SNCT"_ts, u"Sound Category"}, + {"SNDR"_ts, u"Sound Descriptor"}, + {"SOPM"_ts, u"Sound Output Model"}, + {"SOUN"_ts, u"Sound Marker"}, + {"SPEL"_ts, u"Spell"}, + {"SPGD"_ts, u"Shader Particle Geometry"}, + {"STAT"_ts, u"Static"}, + {"TACT"_ts, u"TalkingActivator"}, + {"TES4"_ts, u"File Header"}, + {"TREE"_ts, u"Tree"}, + {"TXST"_ts, u"TextureSet"}, + {"VOLI"_ts, u"VolumetricLighting"}, + {"VTYP"_ts, u"VoiceType"}, + {"WATR"_ts, u"WaterType"}, + {"WEAP"_ts, u"Weapon"}, + {"WOOP"_ts, u"Word of Power"}, + {"WRLD"_ts, u"World Space"}, + {"WTHR"_ts, u"Weather"}, +}); +static_assert(std::ranges::is_sorted(FormNames)); + +constexpr auto DefaultObjectNames = + std::to_array<std::pair<TESFile::Type, QStringView>>({ + {"AAAC"_ts, u"Action Activate"}, + {"AAB1"_ts, u"Action Bleedout Start"}, + {"AAB2"_ts, u"Action Bleedout Stop"}, + {"AABA"_ts, u"Action Block Anticipate"}, + {"AABH"_ts, u"Action Block Hit"}, + {"AABI"_ts, u"Action Bumped Into"}, + {"AADA"_ts, u"Action DualAttack"}, + {"AADE"_ts, u"Action Death"}, + {"AADL"_ts, u"Action DualRelease"}, + {"AADR"_ts, u"Action Draw"}, + {"AADW"_ts, u"Action Death Wait"}, + {"AAF1"_ts, u"Action Fly Start"}, + {"AAF2"_ts, u"Action Fly Stop"}, + {"AAFA"_ts, u"Action Fall"}, + {"AAFQ"_ts, u"Action Force Equip"}, + {"AAGU"_ts, u"Action Get Up"}, + {"AAH1"_ts, u"Action Hover Start"}, + {"AAH2"_ts, u"Action Hover Stop"}, + {"AAID"_ts, u"Action Idle"}, + {"AAIS"_ts, u"Action Idle Stop"}, + {"AAJP"_ts, u"Action Jump"}, + {"AALA"_ts, u"Action LeftAttack"}, + {"AALD"_ts, u"Action LeftReady"}, + {"AALI"_ts, u"Action LeftInterrupt"}, + {"AALK"_ts, u"Action Look"}, + {"AALM"_ts, u"Action Large Movement Delta"}, + {"AALN"_ts, u"Action Land"}, + {"AALR"_ts, u"Action LeftRelease"}, + {"AAPA"_ts, u"Action Right Power Attack"}, + {"AAPE"_ts, u"Action Path End"}, + {"AAPS"_ts, u"Action Path Start"}, + {"AAR2"_ts, u"Action Large Recoil"}, + {"AARA"_ts, u"Action RightAttack"}, + {"AARC"_ts, u"Action Recoil"}, + {"AARD"_ts, u"Action RightReady"}, + {"AARI"_ts, u"Action RightInterrupt"}, + {"AARR"_ts, u"Action RightRelease"}, + {"AAS1"_ts, u"Action Stagger Start"}, + {"AASC"_ts, u"Action Shield Change"}, + {"AASH"_ts, u"Action Sheath"}, + {"AASN"_ts, u"Action Sneak"}, + {"AASP"_ts, u"Action Sprint Stop"}, + {"AASS"_ts, u"Action Summoned Start"}, + {"AAST"_ts, u"Action Sprint Start"}, + {"AASW"_ts, u"Action Swim State Change"}, + {"AAVC"_ts, u"Action Voice"}, + {"AAVD"_ts, u"Action VoiceReady"}, + {"AAVI"_ts, u"Action VoiceInterrupt"}, + {"AAVR"_ts, u"Action VoiceRelease"}, + {"AAWH"_ts, u"Action Ward Hit"}, + {"ABSE"_ts, u"Art Object - Absorb Effect"}, + {"ADPA"_ts, u"Action Dual Power Attack"}, + {"AFNP"_ts, u"Keyword - Activator Furniture No Player"}, + {"AHBM"_ts, u"Keyword - Armor Material Heavy Bonemold"}, + {"AHCH"_ts, u"Keyword - Armor Material Heavy Chitin"}, + {"AHNC"_ts, u"Keyword - Armor Material Heavy Nordic"}, + {"AHSM"_ts, u"Keyword - Armor Material Heavy Stalhrim"}, + {"AIDW"_ts, u"Action Idle Warn"}, + {"AIVC"_ts, u"Verlet Cape"}, + {"AKDN"_ts, u"Action Knockdown"}, + {"ALBM"_ts, u"Keyword - Armor Material Light Bonemold"}, + {"ALCH"_ts, u"Keyword - Armor Material Light Chitin"}, + {"ALDM"_ts, u"Ash LOD Material"}, + {"ALHD"_ts, u"Ash LOD Material (HD)"}, + {"ALNC"_ts, u"Keyword - Armor Material Light Nordic"}, + {"ALPA"_ts, u"Action Left Power Attack"}, + {"ALSM"_ts, u"Keyword - Armor Material Light Stalhrim"}, + {"ALTI"_ts, u"Action Listen Idle"}, + {"AMBK"_ts, u"Action Move Backward"}, + {"AMFD"_ts, u"Action Move Forward"}, + {"AMLT"_ts, u"Action Move Left"}, + {"AMRT"_ts, u"Action Move Right"}, + {"AMSP"_ts, u"Action Move Stop"}, + {"AMST"_ts, u"Action Move Start"}, + {"ANML"_ts, u"Keyword - Animal"}, + {"AODA"_ts, u"Keyword - Armor Material Daedric"}, + {"AODB"_ts, u"Keyword - Armor Material Dragonbone"}, + {"AODP"_ts, u"Keyword - Armor Material Dragonplate"}, + {"AODS"_ts, u"Keyword - Armor Material Dragonscale"}, + {"AODW"_ts, u"Keyword - Armor Material Dwarven"}, + {"AOEB"_ts, u"Keyword - Armor Material Ebony"}, + {"AOEL"_ts, u"Keyword - Armor Material Elven"}, + {"AOES"_ts, u"Keyword - Armor Material ElvenSplinted"}, + {"AOFE"_ts, u"Keyword - Armor Material Iron"}, + {"AOFL"_ts, u"Keyword - Armor Material FullLeather"}, + {"AOGL"_ts, u"Keyword - Armor Material Glass"}, + {"AOHI"_ts, u"Keyword - Armor Material Hide"}, + {"AOIB"_ts, u"Keyword - Armor Material IronBanded"}, + {"AOIH"_ts, u"Keyword - Armor Material ImperialHeavy"}, + {"AOIM"_ts, u"Keyword - Armor Material Imperial"}, + {"AOIR"_ts, u"Keyword - Armor Material ImperialReinforced"}, + {"AOOR"_ts, u"Keyword - Armor Material Orcish"}, + {"AOSC"_ts, u"Keyword - Armor Material Scaled"}, + {"AOSD"_ts, u"Keyword - Armor Material Studded"}, + {"AOSK"_ts, u"Keyword - Armor Material Stormcloak"}, + {"AOSP"_ts, u"Keyword - Armor Material SteelPlate"}, + {"AOST"_ts, u"Keyword - Armor Material Steel"}, + {"APSH"_ts, u"Allow Player Shout"}, + {"ARAG"_ts, u"Action Reset Animation Graph"}, + {"AREL"_ts, u"Action Reload"}, + {"ARGI"_ts, u"Action Ragdoll Instant"}, + {"ARTL"_ts, u"Armor Material List"}, + {"ASID"_ts, u"Action Idle Stop Instant"}, + {"ATKI"_ts, u"Action Talking Idle"}, + {"ATLE"_ts, u"Action Turn Left"}, + {"ATRI"_ts, u"Action Turn Right"}, + {"ATSP"_ts, u"Action Turn Stop"}, + {"AVVP"_ts, u"Vampire Available Perks"}, + {"AVWP"_ts, u"Werewolf Available Perks"}, + {"AWWS"_ts, u"Action Waterwalk Start"}, + {"AWWW"_ts, u"Bunny Faction"}, + {"BAPO"_ts, u"Base Potion"}, + {"BAPS"_ts, u"Base Poison"}, + {"BEEP"_ts, u"Keyword - Robot"}, + {"BENA"_ts, u"Base Armor Enchantment"}, + {"BENW"_ts, u"Base Weapon Enchantment"}, + {"BTMS"_ts, u"Battle Music"}, + {"CACA"_ts, u"Commanded Actor Ability"}, + {"CMPX"_ts, u"Complex Scene Object"}, + {"COEX"_ts, u"Keyword - Conditional Explosion"}, + {"COOK"_ts, u"Keyword: Cooking Pot"}, + {"CSTY"_ts, u"Combat Style"}, + {"CWNE"_ts, u"Keyword - Civil War Neutral"}, + {"CWOK"_ts, u"Keyword - Civil War Owner"}, + {"DAED"_ts, u"Keyword - Daedra"}, + {"DBHF"_ts, u"Dark Brotherhood Faction"}, + {"DCMS"_ts, u"Dungeon Cleared Music"}, + {"DCZM"_ts, u"Dragon Crash Zone Marker"}, + {"DDSC"_ts, u"Dialogue Voice Category"}, + {"DEIS"_ts, u"Drug Wears Off Image Space"}, + {"DFMS"_ts, u"Default Music"}, + {"DFTS"_ts, u"Footstep Set"}, + {"DGFL"_ts, u"DialogueFollower Quest"}, + {"DIEN"_ts, u"Keyword - Disallow Enchanting"}, + {"DLMT"_ts, u"Landscape Material"}, + {"DLZM"_ts, u"Dragon Land Zone Marker"}, + {"DMFL"_ts, u"Default MovementType: Fly"}, + {"DMRN"_ts, u"Default MovementType: Run"}, + {"DMSN"_ts, u"Default MovementType: Sneak"}, + {"DMSP"_ts, u"Default MovementType: Sprint"}, + {"DMSW"_ts, u"Default MovementType: Swim"}, + {"DMWL"_ts, u"Default MovementType: Walk"}, + {"DMXL"_ts, u"Dragon Mount No Land List"}, + {"DOP2"_ts, u"Dialogue Output Model (3D)"}, + {"DOP3"_ts, u"Dialogue Output Model (2D)"}, + {"DRAK"_ts, u"Keyword - Dragon"}, + {"DTMS"_ts, u"Death Music"}, + {"EACA"_ts, u"Every Actor Ability"}, + {"EHEQ"_ts, u"EitherHand Equip"}, + {"EPDF"_ts, u"Eat Package Default Food"}, + {"FFFP"_ts, u"Keyword - Furniture Forces 1st Person"}, + {"FFTP"_ts, u"Keyword - Furniture Forces 3rd Person"}, + {"FGPD"_ts, u"Favor Gifts Per Day"}, + {"FMFF"_ts, u"Flying Mount - Fly Fast Worldspaces"}, + {"FMNS"_ts, u"Flying Mount - Disallowed Spells"}, + {"FMYS"_ts, u"Flying Mount - Allowed Spells"}, + {"FORG"_ts, u"Keyword: Forge"}, + {"FPCL"_ts, u"Favor Cost Large"}, + {"FPCM"_ts, u"Favor Cost Medium"}, + {"FPCS"_ts, u"Favor Cost Small"}, + {"FTEL"_ts, u"Male Face Texture Set: Eyes"}, + {"FTGF"_ts, u"Fighters' Guild Faction"}, + {"FTHD"_ts, u"Male Face Texture Set: Head"}, + {"FTHF"_ts, u"Female Face Texture Set: Head"}, + {"FTMF"_ts, u"Female Face Texture Set: Mouth"}, + {"FTML"_ts, u"Favor travel marker location"}, + {"FTMO"_ts, u"Male Face Texture Set: Mouth"}, + {"FTNP"_ts, u"Furniture Test NPC"}, + {"FTRF"_ts, u"Female Face Texture Set: Eyes"}, + {"GCK1"_ts, u"Keyword - Generic Craftable Keyword 01"}, + {"GCK2"_ts, u"Keyword - Generic Craftable Keyword 02"}, + {"GCK3"_ts, u"Keyword - Generic Craftable Keyword 03"}, + {"GCK4"_ts, u"Keyword - Generic Craftable Keyword 04"}, + {"GCK5"_ts, u"Keyword - Generic Craftable Keyword 05"}, + {"GCK6"_ts, u"Keyword - Generic Craftable Keyword 06"}, + {"GCK7"_ts, u"Keyword - Generic Craftable Keyword 07"}, + {"GCK8"_ts, u"Keyword - Generic Craftable Keyword 08"}, + {"GCK9"_ts, u"Keyword - Generic Craftable Keyword 09"}, + {"GCKX"_ts, u"Keyword - Generic Craftable Keyword 10"}, + {"GFAC"_ts, u"Guard Faction"}, + {"GOLD"_ts, u"Gold"}, + {"HBAL"_ts, u"Help - Basic Alchemy"}, + {"HBAT"_ts, u"Help - Attack Target"}, + {"HBBR"_ts, u"Help - Barter"}, + {"HBCO"_ts, u"Help - Basic Cooking"}, + {"HBEC"_ts, u"Help - Basic Enchanting"}, + {"HBFG"_ts, u"Help - Basic Forging"}, + {"HBFM"_ts, u"Help - Flying Mount"}, + {"HBFS"_ts, u"Help - Favorites"}, + {"HBFT"_ts, u"Help - Teamate Favor"}, + {"HBHJ"_ts, u"Help - Jail"}, + {"HBJL"_ts, u"Help - Journal"}, + {"HBLH"_ts, u"Help - Low Health"}, + {"HBLK"_ts, u"Help - Basic Lockpicking (PC)"}, + {"HBLM"_ts, u"Help - Low Magicka"}, + {"HBLS"_ts, u"Help - Low Stamina"}, + {"HBLU"_ts, u"Help - Leveling up"}, + {"HBLX"_ts, u"Help - Basic Lockpicking (Console)"}, + {"HBML"_ts, u"Help - Basic Smelting"}, + {"HBMM"_ts, u"Help - Map Menu"}, + {"HBOC"_ts, u"Help - Basic Object Creation"}, + {"HBSA"_ts, u"Help - Basic Smithing Armor"}, + {"HBSK"_ts, u"Help - Skills Menu"}, + {"HBSM"_ts, u"Help - Basic Smithing Weapon"}, + {"HBTA"_ts, u"Help - Basic Tanning"}, + {"HBTL"_ts, u"Help - Target Lock"}, + {"HBWC"_ts, u"Help - Weapon Charge"}, + {"HCLL"_ts, u"FormList - Hair Color List"}, + {"HFSD"_ts, u"Heartbeat Sound Fast"}, + {"HMPC"_ts, u"Help Manual PC"}, + {"HMXB"_ts, u"Help Manual XBox"}, + {"HRSK"_ts, u"Keyword - Horse"}, + {"HSSD"_ts, u"Heartbeat Sound Slow"}, + {"HVFS"_ts, u"Harvest Failed Sound"}, + {"HVSS"_ts, u"Harvest Sound"}, + {"IMID"_ts, u"ImageSpaceModifier for inventory menu."}, + {"IMLH"_ts, u"Imagespace: Low Health"}, + {"INVP"_ts, u"Inventory Player"}, + {"IOPM"_ts, u"Interface Output Model"}, + {"JRLF"_ts, u"Jarl Faction"}, + {"JWLR"_ts, u"Keyword - Jewelry"}, + {"KHFL"_ts, u"Kinect Help FormList"}, + {"KWBR"_ts, u"Keyword - BeastRace"}, + {"KWCU"_ts, u"Keyword - Cuirass"}, + {"KWDM"_ts, u"Keyword - DummyObject"}, + {"KWDO"_ts, u"Keyword - ClearableLocation"}, + {"KWGE"_ts, u"Keyword - UseGeometryEmitter"}, + {"KWMS"_ts, u"Keyword - MustStop"}, + {"KWOT"_ts, u"Keyword - Skip Outfit Items"}, + {"KWSP"_ts, u"Skyrim - Worldspace"}, + {"KWUA"_ts, u"Keyword - UpdateDuringArchery"}, + {"LHEQ"_ts, u"LeftHand Equip"}, + {"LKHO"_ts, u"Keyword - Hold Location"}, + {"LKPK"_ts, u"Lockpick"}, + {"LMHP"_ts, u"Local Map Hide Plane"}, + {"LRRD"_ts, u"LocRefType - Resource Destructible"}, + {"LRSO"_ts, u"LocRefType - Civil War Soldier"}, + {"LRTB"_ts, u"LocRefType Boss"}, + {"LSIS"_ts, u"Imagespace: Load screen"}, + {"LUMS"_ts, u"Level Up Music"}, + {"MDSC"_ts, u"Music Sound Category"}, + {"MFSN"_ts, u"Magic Fail Sound"}, + {"MGGF"_ts, u"Mages' Guild Faction"}, + {"MHFL"_ts, u"Help - Mods"}, + {"MMCL"_ts, u"Main Menu Cell"}, + {"MMSD"_ts, u"Map Menu Looping Sound"}, + {"MNT2"_ts, u"Keyword - Mount"}, + {"MNTK"_ts, u"Keyword - Mount"}, + {"MORP"_ts, u""}, + {"MTSC"_ts, u"Master Sound Category"}, + {"MVBL"_ts, u"Keyword - Movable"}, + {"MYSF"_ts, u""}, + {"MYSN"_ts, u""}, + {"NASD"_ts, u"No-Activation Sound"}, + {"NDSC"_ts, u"Non-Dialogue Voice Category"}, + {"NMRD"_ts, u"Road Marker"}, + {"NPCK"_ts, u"Keyword - NPC"}, + {"NRNT"_ts, u"Keyword - Nirnroot"}, + {"P3OM"_ts, u"Player's Output Model (3rd Person)"}, + {"PCMD"_ts, u"Player Can Mount Dragon Here List"}, + {"PDLC"_ts, u"Pause During Loading Menu Category"}, + {"PDMC"_ts, u"Pause During Menu Category (Fade)"}, + {"PDSA"_ts, u"Putdown Sound Armor"}, + {"PDSB"_ts, u"Putdown Sound Book"}, + {"PDSG"_ts, u"Putdown Sound Generic"}, + {"PDSI"_ts, u"Putdown Sound Ingredient"}, + {"PDSW"_ts, u"Putdown Sound Weapon"}, + {"PFAC"_ts, u"Player Faction"}, + {"PIMC"_ts, u"Pause During Menu Category (Immediate)"}, + {"PIVV"_ts, u"Player Is Vampire Variable"}, + {"PIWV"_ts, u"Player Is Werewolf Variable"}, + {"PLOC"_ts, u"PersistAll Location"}, + {"PLST"_ts, u"Default Pack List"}, + {"POEQ"_ts, u"Potion Equip"}, + {"POPM"_ts, u"Player's Output Model (1st Person)"}, + {"PPAR"_ts, u""}, + {"PTEM"_ts, u"Package template"}, + {"PTFR"_ts, u"PotentialFollower Faction"}, + {"PTNP"_ts, u"Pathing Test NPC"}, + {"PUSA"_ts, u"Pickup Sound Armor"}, + {"PUSB"_ts, u"Pickup Sound Book"}, + {"PUSG"_ts, u"Pickup Sound Generic"}, + {"PUSI"_ts, u"Pickup Sound Ingredient"}, + {"PUSW"_ts, u"Pickup Sound Weapon"}, + {"PVFA"_ts, u"Player Voice (Female)"}, + {"PVFC"_ts, u"Player Voice (Female Child)"}, + {"PVMA"_ts, u"Player Voice (Male)"}, + {"PVMC"_ts, u"Player Voice (Male Child)"}, + {"PWFD"_ts, u"Wait-For-Dialogue Package"}, + {"RADA"_ts, u""}, + {"RHEQ"_ts, u"RightHand Equip"}, + {"RIVR"_ts, u"Vampire Race"}, + {"RIVS"_ts, u"Vampire Spells"}, + {"RIWR"_ts, u"Werewolf Race"}, + {"RUSG"_ts, u"Keyword - Reusable SoulGem"}, + {"RVBT"_ts, u"Reverb Type"}, + {"SALT"_ts, u"Sitting Angle Limit"}, + {"SAT1"_ts, u"Keyword: Scale Actor To 1.0"}, + {"SCMS"_ts, u"Success Music"}, + {"SCSD"_ts, u"Soul Captured Sound"}, + {"SFDC"_ts, u"SFX To Fade In Dialogue Category"}, + {"SFSN"_ts, u"Shout Fail Sound"}, + {"SKAB"_ts, u"Survival - Keyword Armor Body"}, + {"SKAF"_ts, u"Survival - Keyword Armor Feet"}, + {"SKAH"_ts, u"Survival - Keyword Armor Hands"}, + {"SKAO"_ts, u"Survival - Keyword Armor Head"}, + {"SKCB"_ts, u"Survival - Keyword Clothing Body"}, + {"SKCD"_ts, u"Survival - Keyword Cold"}, + {"SKCF"_ts, u"Survival - Keyword Clothing Feet"}, + {"SKCH"_ts, u"Survival - Keyword Clothing Hands"}, + {"SKCO"_ts, u"Survival - Keyword Clothing Head"}, + {"SKLK"_ts, u"SkeletonKey"}, + {"SKWM"_ts, u"Survival - Keyword Warm"}, + {"SLDM"_ts, u"Snow LOD Material"}, + {"SLHD"_ts, u"Snow LOD Material (HD)"}, + {"SMLT"_ts, u"Keyword: Smelter"}, + {"SMSC"_ts, u"Stats Mute Category"}, + {"SPFK"_ts, u"Keyword - Special Furniture"}, + {"SRCP"_ts, u"Survival - Cold Penalty"}, + {"SRHP"_ts, u"Survival - Hunger Penalty"}, + {"SRSP"_ts, u"Survival - Sleep Penalty"}, + {"SRTP"_ts, u"Survival - Temperature"}, + {"SRVE"_ts, u"Survival Mode Enabled"}, + {"SRVS"_ts, u"Survival Mode - Show Option"}, + {"SRVT"_ts, u"Survival Mode - Toggle"}, + {"SSSC"_ts, u"Stats Music"}, + {"TANN"_ts, u"Keyword: Tanning Rack"}, + {"TKAM"_ts, u"Keyword - Type Ammo"}, + {"TKAR"_ts, u"Keyword - Type Armor"}, + {"TKBK"_ts, u"Keyword - Type Book"}, + {"TKGS"_ts, u"Telekinesis Grab Sound"}, + {"TKIG"_ts, u"Keyword - Type Ingredient"}, + {"TKKY"_ts, u"Keyword - Type Key"}, + {"TKMS"_ts, u"Keyword - Type Misc"}, + {"TKPT"_ts, u"Keyword - Type Potion"}, + {"TKSG"_ts, u"Keyword - Type SoulGem"}, + {"TKTS"_ts, u"Telekinesis Throw Sound"}, + {"TKWP"_ts, u"Keyword - Type Weapon"}, + {"TSSC"_ts, u"Time Sensitive Sound Category"}, + {"TVGF"_ts, u"Thieves' Guild Faction"}, + {"UNDK"_ts, u"Keyword - Undead"}, + {"URVT"_ts, u"Underwater Reverb Type"}, + {"UWLS"_ts, u"Underwater Loop Sound"}, + {"VAMP"_ts, u"Keyword: Vampire"}, + {"VFNC"_ts, u"Vampire Feed No Crime Faction"}, + {"VLOC"_ts, u"Virtual Location"}, + {"VOEQ"_ts, u"Voice Equip"}, + {"WASN"_ts, u"Ward Absorb Sound"}, + {"WBSN"_ts, u"Ward Break Sound"}, + {"WDSN"_ts, u"Ward Deflect Sound"}, + {"WEML"_ts, u"Weapon Material List"}, + {"WMDA"_ts, u"Keyword - Weapon Material Daedric"}, + {"WMDH"_ts, u"Keyword - Weapon Material DraugrHoned"}, + {"WMDR"_ts, u"Keyword - Weapon Material Draugr"}, + {"WMDW"_ts, u"Keyword - Weapon Material Dwarven"}, + {"WMEB"_ts, u"Keyword - Weapon Material Ebony"}, + {"WMEL"_ts, u"Keyword - Weapon Material Elven"}, + {"WMFA"_ts, u"Keyword - Weapon Material Falmer"}, + {"WMFH"_ts, u"Keyword - Weapon Material FalmerHoned"}, + {"WMGL"_ts, u"Keyword - Weapon Material Glass"}, + {"WMIM"_ts, u"Keyword - Weapon Material Imperial"}, + {"WMIR"_ts, u"Keyword - Weapon Material Iron"}, + {"WMOR"_ts, u"Keyword - Weapon Material Orcish"}, + {"WMST"_ts, u"Keyword - Weapon Material Steel"}, + {"WMWE"_ts, u"World Map Weather"}, + {"WMWO"_ts, u"Keyword - Weapon Material Wood"}, + {"WPNC"_ts, u"Keyword - Weapon Material Nordic"}, + {"WPSM"_ts, u"Keyword - Weapon Material Stalhrim"}, + {"WTBA"_ts, u"Keyword - WeaponTypeBoundArrow"}, + {"WWSP"_ts, u"Werewolf Spell"}, + }); +static_assert(std::ranges::is_sorted(DefaultObjectNames)); + +template <typename K, typename V, std::size_t N> +inline constexpr V find(const std::array<std::pair<K, V>, N>& table, const K& key) +{ + struct GetKey + { + constexpr K operator()(const std::pair<K, V>& element) noexcept + { + return element.first; + } + }; + + const auto it = std::ranges::lower_bound(table, key, std::ranges::less{}, GetKey{}); + + if (it != std::ranges::end(table) && it->first == key) { + return it->second; + } else { + return V{}; + } +} + +inline constexpr QStringView getFormName(TESFile::Type type) +{ + return find(FormNames, type); +} + +inline constexpr QStringView getDefaultObjectName(TESFile::Type type) +{ + return find(DefaultObjectNames, type); +} + +} // namespace TESData + +#endif // TESDATA_FORMNAMES_H diff --git a/libs/installer_bsplugins/src/TESFile/Reader.h b/libs/installer_bsplugins/src/TESFile/Reader.h new file mode 100644 index 0000000..0479842 --- /dev/null +++ b/libs/installer_bsplugins/src/TESFile/Reader.h @@ -0,0 +1,67 @@ +#ifndef TESFILE_READER_H +#define TESFILE_READER_H + +#include "Stream.h" + +#include <concepts> +#include <cstdint> +#include <filesystem> +#include <istream> +#include <utility> + +namespace TESFile +{ + +template <typename Handler> +concept ReaderHandler = requires(Handler& handler) { + { + handler.Group(std::declval<GroupData>()) + } -> std::convertible_to<bool>; + { + handler.Form(std::declval<FormData>()) + } -> std::convertible_to<bool>; + { + handler.Chunk(std::declval<Type>()) + } -> std::convertible_to<bool>; + { + handler.Data(std::declval<std::istream&>()) + }; +}; + +template <ReaderHandler Handler> +class Reader +{ +public: + void parse(const std::filesystem::path& path, Handler& handler); + + void parse(std::istream& stream, Handler& handler); + +private: + enum HeaderSize + { + HeaderSize_Standard = sizeof(RecordHeader), + HeaderSize_Oblivion = 20, + HeaderSize_Morrowind = 16, + }; + + std::uint32_t parsePluginInfo(std::istream& stream, Handler& handler); + + std::uint32_t parseRecord(std::istream& stream, Handler& handler); + + std::uint32_t handleForm(std::istream& stream, const RecordHeader& header, + Handler& handler); + + std::uint32_t handleGroup(std::istream& stream, const RecordHeader& header, + Handler& handler); + + std::uint32_t parseChunk(std::istream& stream, Handler& handler); + + TESFormat chunkFormat_; + int headerSize_; +}; + +} // namespace TESFile + +#include "Reader.inl" + +#endif // TESFILE_READER_H diff --git a/libs/installer_bsplugins/src/TESFile/Reader.inl b/libs/installer_bsplugins/src/TESFile/Reader.inl new file mode 100644 index 0000000..3324a15 --- /dev/null +++ b/libs/installer_bsplugins/src/TESFile/Reader.inl @@ -0,0 +1,238 @@ +#include "Reader.h" + +#include <zlib.h> + +#include <cstring> +#include <format> +#include <fstream> +#include <stdexcept> + +namespace TESFile +{ + +template <ReaderHandler Handler> +inline void Reader<Handler>::parse(const std::filesystem::path& path, Handler& handler) +{ + std::ifstream stream; + stream.open(path, std::ios_base::binary | std::ios_base::in); + if (!stream.good()) { + throw std::runtime_error(std::strerror(errno)); + } + parse(stream, handler); +} + +template <ReaderHandler Handler> +inline void Reader<Handler>::parse(std::istream& stream, Handler& handler) +{ + parsePluginInfo(stream, handler); + while (stream.good() && stream.peek() != std::char_traits<char>::eof()) { + parseRecord(stream, handler); + } +} + +template <ReaderHandler Handler> +inline std::uint32_t Reader<Handler>::parsePluginInfo(std::istream& stream, + Handler& handler) +{ + const RecordHeader header = readType<RecordHeader>(stream); + if (stream.fail()) { + throw std::runtime_error("record incomplete"); + } + + if (header.type == "TES4"_ts) { + if (header.old.firstChunk == "HEDR"_ts) { + chunkFormat_ = TESFormat::Oblivion; + headerSize_ = HeaderSize_Oblivion; + stream.seekg(HeaderSize_Oblivion - sizeof(RecordHeader), std::istream::cur); + } else { + chunkFormat_ = TESFormat::Standard; + headerSize_ = sizeof(RecordHeader); + } + } else if (header.type == "TES3"_ts) { + chunkFormat_ = TESFormat::Morrowind; + headerSize_ = HeaderSize_Morrowind; + stream.seekg(HeaderSize_Morrowind - sizeof(RecordHeader), std::istream::cur); + } else { + throw std::runtime_error( + std::format("Unrecognized plugin info type: '{}'", header.type.value)); + } + + return handleForm(stream, header, handler); +} + +template <ReaderHandler Handler> +inline std::uint32_t Reader<Handler>::parseRecord(std::istream& stream, + Handler& handler) +{ + RecordHeader header; + stream.read(reinterpret_cast<char*>(&header), headerSize_); + if (stream.fail()) { + throw std::runtime_error("record incomplete"); + } + + if (header.type == "GRUP"_ts) { + return handleGroup(stream, header, handler); + } else { + return handleForm(stream, header, handler); + } +} + +template <ReaderHandler Handler> +inline std::uint32_t Reader<Handler>::handleForm(std::istream& stream, + const RecordHeader& header, + Handler& handler) +{ + std::uint32_t dataSize = header.dataSize; + const bool compressed = header.formData.flags & RecordFlags::Compressed; + // Check for Oblivion plugin data + uint16_t version = 0; + if (header.old.firstChunk.string() != "HEDR") { + version = header.version; + } + if (handler.Form( + FormData(header.type, header.formData.flags, header.formData.formId, version))) { + + std::string data; + data.resize(dataSize); + stream.read(data.data(), dataSize); + if (stream.fail()) { + throw std::runtime_error("chunk incomplete"); + } + + std::istringstream chunkstream; + if (!compressed) { + chunkstream.str(std::move(data)); + } else { + if (data.size() < 4) { + throw std::runtime_error("chunk incomplete"); + } + + std::memcpy(&dataSize, data.data(), sizeof(dataSize)); + + std::string inflated; + inflated.resize(dataSize); + + int zret; + ::z_stream zstreambuf{ + .next_in = reinterpret_cast<z_const ::Bytef*>(data.data() + sizeof(dataSize)), + .avail_in = static_cast<::uInt>(data.size() - sizeof(dataSize)), + .next_out = reinterpret_cast<::Bytef*>(inflated.data()), + .avail_out = static_cast<::uInt>(inflated.size()), + .zalloc = Z_NULL, + .zfree = Z_NULL, + .opaque = Z_NULL, + }; + zret = ::inflateInit(&zstreambuf); + if (zret != Z_OK) { + throw std::runtime_error("zlib failed to init"); + } + + zret = ::inflate(&zstreambuf, Z_FINISH); + if (zret != Z_STREAM_END) { + throw std::runtime_error("zlib failed to read data"); + } + zret = ::inflateEnd(&zstreambuf); + + chunkstream.str(std::move(inflated)); + } + + while (dataSize != 0) { + const std::uint32_t fieldSize = parseChunk(chunkstream, handler); + + if (fieldSize > dataSize) { + throw std::runtime_error( + std::format("Subrecord exceeded record size ({}-{})", dataSize, fieldSize)); + } + + dataSize -= fieldSize; + } + + if constexpr (requires { handler.EndForm(); }) { + handler.EndForm(); + } + } else { + stream.seekg(dataSize, std::istream::cur); + if (stream.fail()) { + throw std::runtime_error("record incomplete"); + } + } + + return headerSize_ + header.dataSize; +} + +template <ReaderHandler Handler> +inline std::uint32_t Reader<Handler>::handleGroup(std::istream& stream, + const RecordHeader& header, + Handler& handler) +{ + std::uint32_t dataSize = header.dataSize - headerSize_; + + if (handler.Group(GroupData(header.groupData.label, header.groupData.groupType))) { + + while (dataSize != 0) { + const std::uint32_t recordSize = parseRecord(stream, handler); + + if (recordSize > dataSize) { + throw std::runtime_error( + std::format("Record exceeded group size ({}-{})", dataSize, recordSize)); + } + + dataSize -= recordSize; + } + + if constexpr (requires { handler.EndGroup(); }) { + handler.EndGroup(); + } + } else { + stream.seekg(dataSize, std::istream::cur); + if (stream.fail()) { + throw std::runtime_error("group incomplete"); + } + } + + return header.dataSize; +} + +template <ReaderHandler Handler> +inline std::uint32_t Reader<Handler>::parseChunk(std::istream& stream, Handler& handler) +{ + ChunkHeader header = readType<ChunkHeader>(stream); + if (stream.fail()) { + throw std::runtime_error("chunk header incomplete"); + } + std::uint32_t readSize = sizeof(ChunkHeader) + header.dataSize; + + std::uint32_t dataSize = header.dataSize; + if (header.type == "XXXX"_ts) { + if (dataSize != 4) { + throw std::runtime_error("XXXX field has invalid size"); + } + dataSize = readType<std::uint32_t>(stream); + header = readType<ChunkHeader>(stream); + if (stream.fail()) { + throw std::runtime_error("chunk size incomplete"); + } + readSize += sizeof(ChunkHeader) + dataSize; + } + + if (handler.Chunk(header.type)) { + std::string field; + field.resize(dataSize); + stream.read(field.data(), dataSize); + if (stream.fail()) { + throw std::runtime_error("chunk data incomplete"); + } + std::istringstream data(std::move(field)); + data.exceptions(std::ios_base::failbit); + handler.Data(data); + } else { + stream.seekg(dataSize, std::istream::cur); + if (stream.fail()) { + throw std::runtime_error("chunk incomplete"); + } + } + + return readSize; +} + +} // namespace TESFile diff --git a/libs/installer_bsplugins/src/TESFile/Stream.h b/libs/installer_bsplugins/src/TESFile/Stream.h new file mode 100644 index 0000000..db22580 --- /dev/null +++ b/libs/installer_bsplugins/src/TESFile/Stream.h @@ -0,0 +1,291 @@ +#ifndef TESFILE_STREAM_H +#define TESFILE_STREAM_H + +#include "Type.h" + +#include <algorithm> +#include <cctype> +#include <cstdint> +#include <cstring> +#include <istream> +#include <ranges> +#include <utility> + +namespace TESFile +{ + +template <typename T> +inline T readType(std::istream& stream) +{ + union + { + char buffer[sizeof(T)]; + T value; + }; + std::memset(buffer, 0x42, sizeof(T)); + stream.read(buffer, sizeof(T)); + return value; +} + +inline std::string readZstring(std::istream& stream) +{ + std::string value; + std::getline(stream, value, '\0'); + return value; +} + +inline int icompare(const std::string& a, const std::string& b) +{ + const std::size_t n = std::min(a.size(), b.size()); + for (std::size_t i = 0; i < n; ++i) { + const unsigned char ca = + static_cast<unsigned char>(std::tolower(static_cast<unsigned char>(a[i]))); + const unsigned char cb = + static_cast<unsigned char>(std::tolower(static_cast<unsigned char>(b[i]))); + if (ca != cb) + return ca < cb ? -1 : 1; + } + if (a.size() == b.size()) + return 0; + return a.size() < b.size() ? -1 : 1; +} + +inline bool iequals(const std::string& a, const std::string& b) +{ + return icompare(a, b) == 0; +} + +template <std::ranges::input_range R, typename Proj = std::identity> +inline auto find(R&& r, const std::string& value, Proj proj = {}) +{ + return std::ranges::find_if( + r, + [&](auto&& val) { + return iequals(val, value); + }, + proj); +} + +struct less +{ + bool operator()(const std::string& a, const std::string& b) const + { + return icompare(a, b) < 0; + } +}; + +enum class TESFormat +{ + Standard, + Oblivion, + Morrowind, +}; + +enum class GroupType : std::int32_t +{ + Top, + WorldChildren, + InteriorCellBlock, + InteriorCellSubBlock, + ExteriorCellBlock, + ExteriorCellSubBlock, + CellChildren, + TopicChildren, + CellPersistentChildren, + CellTemporaryChildren, + QuestChildren, + CellVisibleDistantChildren = 10, +}; + +struct RecordFlags +{ + enum TES4Flag : std::uint32_t + { + Master = 0x1, + Optimized = 0x10, + Localized = 0x80, + SmallNew = 0x100, + SmallOld = 0x200, + Update = 0x200, + Medium = 0x400, + Blueprint = 0x800, + }; + + enum Flag : std::uint32_t + { + Compressed = 0x40000, + }; +}; + +struct RecordHeader +{ + Type type; + std::uint32_t dataSize; + union + { + struct + { + std::uint32_t flags; + std::uint32_t formId; + } formData; + struct + { + std::uint32_t label; + GroupType groupType; + } groupData; + }; + union + { + struct + { + std::uint32_t revision; + Type firstChunk; + } old; + struct + { + std::uint16_t timestamp; + std::uint16_t revision; + std::uint16_t version; + std::uint16_t unknown1; + }; + }; +}; +static_assert(sizeof(RecordHeader) == 24); + +struct ChunkHeader +{ + Type type; + std::uint16_t dataSize; +}; +static_assert(sizeof(ChunkHeader) == 6); + +class GroupData final +{ +public: + constexpr GroupData() : formId_{0}, groupType_{GroupType::Top} {} + + constexpr GroupData(std::uint32_t label, GroupType type) + : formId_{label}, groupType_{type} + {} + + constexpr auto operator<=>(const GroupData& other) const + { + if (groupType_ != other.groupType_) { + return groupType_ <=> other.groupType_; + } else { + if (hasFormType()) { + return formType() <=> other.formType(); + } else if (hasBlock()) { + return block() <=> other.block(); + } else if (hasGridCell()) { + return gridCell() <=> other.gridCell(); + } else { + return formId_ <=> other.formId_; + } + } + } + + constexpr bool operator==(const GroupData& other) const + { + return groupType_ == other.groupType_ && formId_ == other.formId_; + } + + [[nodiscard]] constexpr GroupType type() const { return groupType_; } + + [[nodiscard]] constexpr bool hasFormType() const + { + return groupType_ == GroupType::Top; + } + + [[nodiscard]] constexpr bool hasParent() const + { + return groupType_ == GroupType::WorldChildren || + groupType_ == GroupType::CellChildren || + groupType_ == GroupType::TopicChildren || + groupType_ == GroupType::CellPersistentChildren || + groupType_ == GroupType::CellTemporaryChildren || + groupType_ == GroupType::QuestChildren; + } + + [[nodiscard]] constexpr bool hasDirectParent() const + { + return groupType_ == GroupType::WorldChildren || + groupType_ == GroupType::CellChildren || + groupType_ == GroupType::TopicChildren || + groupType_ == GroupType::QuestChildren; + } + + [[nodiscard]] constexpr bool hasBlock() const + { + return groupType_ == GroupType::InteriorCellBlock || + groupType_ == GroupType::InteriorCellSubBlock; + } + + [[nodiscard]] constexpr bool hasGridCell() const + { + return groupType_ == GroupType::ExteriorCellBlock || + groupType_ == GroupType::ExteriorCellSubBlock; + } + + [[nodiscard]] constexpr Type formType() const { return formType_; } + + [[nodiscard]] constexpr std::uint32_t parent() const { return formId_; } + + [[nodiscard]] constexpr std::int32_t block() const { return number_; } + + [[nodiscard]] constexpr std::pair<std::int16_t, std::int16_t> gridCell() const + { + return {cell_.x, cell_.y}; + } + + constexpr void setLocalIndex(std::uint8_t index) + { + if (hasParent()) { + formId_ = (formId_ & 0xFFFFFFU) | (index << 24U); + } + } + +private: + GroupType groupType_; + union + { + Type formType_; + std::uint32_t formId_; + std::int32_t number_; + struct + { + std::int16_t y; + std::int16_t x; + } cell_; + }; +}; + +class FormData final +{ +public: + constexpr FormData(Type type, std::uint32_t flags, std::uint32_t formId, std::uint16_t formVersion) + : type_{type}, flags_{flags}, formId_{formId}, formVersion_{formVersion} + {} + + [[nodiscard]] constexpr Type type() const { return type_; } + + [[nodiscard]] constexpr std::uint32_t flags() const { return flags_; } + + [[nodiscard]] constexpr std::uint32_t formId() const { return formId_; } + + [[nodiscard]] constexpr std::uint16_t formVersion() const { return formVersion_; } + + [[nodiscard]] constexpr std::uint8_t localModIndex() const { return formId_ >> 24; } + +private: + Type type_; + std::uint32_t flags_; + std::uint32_t formId_; + std::uint16_t formVersion_; +}; + +} // namespace TESFile + +using namespace TESFile::literals; + +#endif // TESFILE_STREAM_H diff --git a/libs/installer_bsplugins/src/TESFile/Type.h b/libs/installer_bsplugins/src/TESFile/Type.h new file mode 100644 index 0000000..3b93b20 --- /dev/null +++ b/libs/installer_bsplugins/src/TESFile/Type.h @@ -0,0 +1,78 @@ +#ifndef TESFILE_TYPE_H +#define TESFILE_TYPE_H + +#include <bit> +#include <concepts> +#include <cstddef> +#include <cstdint> +#include <string> +#include <string_view> +#include <type_traits> +#include <utility> + +namespace TESFile +{ + +template <std::integral T> +constexpr std::make_unsigned_t<T> bswap(T i) +{ + return []<std::size_t... N>(std::make_unsigned_t<T> i, std::index_sequence<N...>) { + return ((((i >> (N * 8)) & 0xFF) << ((sizeof(T) - 1 - N) * 8)) | ...); + }(i, std::make_index_sequence<sizeof(T)>()); +} + +#pragma pack(push, 1) +struct Type +{ + std::uint32_t value; + + constexpr Type() = default; + + template <std::size_t N> + constexpr Type(const char (&str)[N]) + : value{[&]<std::size_t... Is>(std::index_sequence<Is...>) { + return ((static_cast<std::uint32_t>(str[Is]) << Is * 8U) | ...); + }(std::make_index_sequence<N - 1>())} + {} + + constexpr operator std::uint32_t() const { return value; } + + constexpr auto operator<=>(const Type& other) const + { + return bswap(value) <=> bswap(other.value); + } + + [[nodiscard]] std::string string() const { return std::string(view()); } + + [[nodiscard]] std::string_view view() const + { + return std::string_view(reinterpret_cast<const char*>(&value), sizeof(value)); + } + + [[nodiscard]] const char* data() const + { + return reinterpret_cast<const char*>(&value); + } + + [[nodiscard]] static constexpr std::size_t size() { return sizeof(value); } +}; +static_assert(sizeof(Type) == 4); +static_assert(alignof(Type) == 1); +static_assert(Type().value == 0); +#pragma pack(pop) + +namespace literals +{ + template <Type T> + consteval Type operator""_ts() + { + return T; + } + static_assert("TES4"_ts.value == '4SET'); + static_assert("AMMO"_ts < "BOOK"_ts); +} // namespace literals +} // namespace TESFile + +using namespace TESFile::literals; + +#endif // TESFILE_TYPE_H diff --git a/libs/installer_bsplugins/src/bsplugins.json b/libs/installer_bsplugins/src/bsplugins.json new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/libs/installer_bsplugins/src/bsplugins.json diff --git a/libs/installer_bsplugins/src/bsplugins_en.ts b/libs/installer_bsplugins/src/bsplugins_en.ts new file mode 100644 index 0000000..d0a5969 --- /dev/null +++ b/libs/installer_bsplugins/src/bsplugins_en.ts @@ -0,0 +1,978 @@ +<?xml version="1.0" encoding="utf-8"?> +<!DOCTYPE TS> +<TS version="2.1" language="en_US"> +<context> + <name>BSPluginInfo::AuxLoserModel</name> + <message> + <location filename="BSPluginInfo/AuxConflictModel.cpp" line="130"/> + <source>File</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/AuxConflictModel.cpp" line="132"/> + <source>Providing Mod</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>BSPluginInfo::AuxNonConflictModel</name> + <message> + <location filename="BSPluginInfo/AuxConflictModel.cpp" line="147"/> + <source>File</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>BSPluginInfo::AuxTreeModel</name> + <message> + <location filename="BSPluginInfo/AuxTreeModel.cpp" line="77"/> + <source>File Name</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>BSPluginInfo::AuxWinnerModel</name> + <message> + <location filename="BSPluginInfo/AuxConflictModel.cpp" line="113"/> + <source>File</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/AuxConflictModel.cpp" line="115"/> + <source>Overwritten Mods</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>BSPluginInfo::PluginRecordModel</name> + <message> + <location filename="BSPluginInfo/PluginRecordModel.cpp" line="304"/> + <location filename="BSPluginInfo/PluginRecordModel.cpp" line="318"/> + <location filename="BSPluginInfo/PluginRecordModel.cpp" line="320"/> + <location filename="BSPluginInfo/PluginRecordModel.cpp" line="328"/> + <source>Children</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/PluginRecordModel.cpp" line="306"/> + <source>Block %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/PluginRecordModel.cpp" line="308"/> + <source>Sub-Block %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/PluginRecordModel.cpp" line="311"/> + <source>Block %1, %2</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/PluginRecordModel.cpp" line="315"/> + <source>Sub-Block %1, %2</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/PluginRecordModel.cpp" line="322"/> + <source>Persistent</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/PluginRecordModel.cpp" line="324"/> + <source>Temporary</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/PluginRecordModel.cpp" line="326"/> + <source>Visible Distant</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/PluginRecordModel.cpp" line="340"/> + <source>Form</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/PluginRecordModel.cpp" line="342"/> + <source>Origin</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/PluginRecordModel.cpp" line="344"/> + <source>Editor ID</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>BSPluginInfo::PluginRecordView</name> + <message> + <location filename="BSPluginInfo/PluginRecordView.cpp" line="195"/> + <source>Ignore Record</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>BSPluginList::PluginListContextMenu</name> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="75"/> + <source>All Items</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="77"/> + <source>Collapse all</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="81"/> + <source>Expand all</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="88"/> + <source>Enable all</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="89"/> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="96"/> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="217"/> + <source>Confirm</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="90"/> + <source>Really enable all plugins?</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="95"/> + <source>Disable all</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="97"/> + <source>Really disable all plugins?</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="111"/> + <source>Enable selected</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="114"/> + <source>Disable selected</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="128"/> + <source>Collapse others</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="145"/> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="148"/> + <source>Create Group...</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="149"/> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="184"/> + <source>Please enter a name:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="173"/> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="184"/> + <source>Rename Group...</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="207"/> + <source>Remove Group...</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="218"/> + <source>Are you sure you want to remove "%1"?</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="234"/> + <source>Send to... </source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="235"/> + <source>Top</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="241"/> + <source>Bottom</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="247"/> + <source>Priority...</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="252"/> + <source>Set Priority</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="253"/> + <source>Set the priority of the selected plugins</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="262"/> + <source>Group...</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="308"/> + <source>Open Origin in Explorer</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="314"/> + <source>Open Origin Info...</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListContextMenu.cpp" line="329"/> + <source>Select a group...</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>BSPluginList::PluginListModel</name> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="322"/> + <source>Origin</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="326"/> + <source>This plugin can't be disabled or moved (enforced by the game).</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="330"/> + <source>This plugin can't be disabled (enforced by the game).</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="336"/> + <location filename="BSPluginList/PluginListModel.cpp" line="606"/> + <source>Form Version</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="340"/> + <location filename="BSPluginList/PluginListModel.cpp" line="608"/> + <source>Header Version</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="344"/> + <location filename="BSPluginList/PluginListModel.cpp" line="610"/> + <source>Author</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="348"/> + <location filename="BSPluginList/PluginListModel.cpp" line="612"/> + <source>Description</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="353"/> + <location filename="BSPluginList/PluginListModel.cpp" line="426"/> + <source>Missing Masters</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="367"/> + <source>Enabled Masters</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="378"/> + <source>Loads Archives</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="382"/> + <source>Loads INI settings</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="388"/> + <source>This is a dummy plugin. It contains no records and is typically used to load a paired archive file.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="400"/> + <source>Overrides & has overridden records</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="402"/> + <source>Overrides records</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="404"/> + <source>Has overridden records</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="412"/> + <source>Overwrites & has overwritten archive files</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="414"/> + <source>Overwrites another archive file</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="416"/> + <source>Overwritten by another archive file</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="435"/> + <source>There is an ini file connected to this plugin. Its settings will be added to your game settings, overwriting in case of conflicts.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="442"/> + <source>There are Archives connected to this plugin. Their assets will be added to your game, overwriting in case of conflicts following the plugin order. Loose files will always overwrite assets from Archives.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="450"/> + <source>This file is flagged as a master plugin (ESM). It will load before any non-ESM files in the load order.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="458"/> + <source>This file is flagged as a light plugin (ESL). It will adhere to its position in the load order but the records will be loaded in ESL space (FE/FF). You can have up to 4096 light plugins in addition to other plugin types.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="464"/> + <source>This file is flagged as a medium plugin (ESH). It will adhere to its position in the load order but the records will be loaded in ESH space (FD). You can have 256 medium plugins in addition to other plugin types.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="472"/> + <source>This plugin has the blueprint flag. This forces it to load after every other non-blueprint plugin. Blueprint plugins will adhere to standard load order rules with other blueprint plugins.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="479"/> + <source>WARNING: This plugin is both light and medium flagged. This could indicate that the file was saved improperly and may have mismatched record references. Use it at your own risk.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="486"/> + <source>This game does not currently permit custom plugin loading. There may be manual workarounds.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="596"/> + <source>Name</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="598"/> + <source>Conflicts</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="600"/> + <source>Flags</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="602"/> + <source>Priority</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="604"/> + <source>Mod Index</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="614"/> + <source>unknown</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>BSPluginList::PluginsWidget</name> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="168"/> + <source>Type</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="169"/> + <source>Active</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="170"/> + <source>Total</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="175"/> + <source>All plugins</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="176"/> + <source>ESMs</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="177"/> + <source>ESPs</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="178"/> + <source>ESMs+ESPs</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="182"/> + <source>ESHs</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="184"/> + <source>ESLs</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="410"/> + <location filename="BSPluginList/PluginsWidget.cpp" line="417"/> + <source>Sorting plugins</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="411"/> + <location filename="BSPluginList/PluginsWidget.cpp" line="418"/> + <source>Are you sure you want to sort your plugins list?</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="413"/> + <source>Note: You are currently in offline mode and LOOT will not update the master list.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="471"/> + <location filename="BSPluginList/PluginsWidget.cpp" line="731"/> + <source>Restore failed</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="472"/> + <location filename="BSPluginList/PluginsWidget.cpp" line="732"/> + <source>Failed to restore the backup. Errorcode: %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="517"/> + <source>Backup of load order created</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="524"/> + <source>Hide force-enabled files</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="528"/> + <source>Ignore conflicts with masters</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="534"/> + <source>Collapse all</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="538"/> + <source>Expand all</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="545"/> + <source>Enable all</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="546"/> + <location filename="BSPluginList/PluginsWidget.cpp" line="553"/> + <source>Confirm</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="547"/> + <source>Really enable all plugins?</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="552"/> + <source>Disable all</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="554"/> + <source>Really disable all plugins?</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="721"/> + <source>Load order changed</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="722"/> + <source>Load order was changed while running %1. Keep changes?</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>BSPlugins</name> + <message> + <location filename="MOPlugin/BSPlugins.cpp" line="36"/> + <source>Manages plugin load order for BGS game engines</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOPlugin/BSPlugins.cpp" line="73"/> + <source>Plugins</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>ListDialog</name> + <message> + <location filename="GUI/listdialog.ui" line="14"/> + <source>Select an item...</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="GUI/listdialog.ui" line="42"/> + <source>Filter</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>LootDialog</name> + <message> + <location filename="MOTools/lootdialog.ui" line="14"/> + <source>LOOT</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/lootdialog.ui" line="50"/> + <source>Progress</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/lootdialog.ui" line="89"/> + <source>about:blank</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/lootdialog.ui" line="118"/> + <source>Open JSON report</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>MOTools::Loot</name> + <message> + <location filename="MOTools/Loot.cpp" line="238"/> + <source>Loot failed to run</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/Loot.cpp" line="243"/> + <source>No errors were reported. The log below might have more information. +</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/Loot.cpp" line="245"/> + <source>Get more information by using the LOOT application to sort your load order. +</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/Loot.cpp" line="301"/> + <source>Errors</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/Loot.cpp" line="313"/> + <source>Warnings</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/Loot.cpp" line="506"/> + <source>failed to start loot</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/Loot.cpp" line="620"/> + <source>Loot failed. Exit code was: 0x%1</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>MOTools::LootDialog</name> + <message> + <location filename="MOTools/LootDialog.cpp" line="150"/> + <source>Stopping LOOT...</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/LootDialog.cpp" line="225"/> + <source>Running LOOT...</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>MessageDialog</name> + <message> + <location filename="GUI/messagedialog.ui" line="32"/> + <location filename="GUI/messagedialog.ui" line="71"/> + <source>Placeholder</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>PluginInfoDialog</name> + <message> + <location filename="BSPluginInfo/plugininfodialog.ui" line="14"/> + <source>BGS Data</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/plugininfodialog.ui" line="24"/> + <source>Records</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/plugininfodialog.ui" line="41"/> + <source>Archives</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/plugininfodialog.ui" line="51"/> + <source>Conflicts</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/plugininfodialog.ui" line="67"/> + <location filename="BSPluginInfo/plugininfodialog.ui" line="108"/> + <source>Files that exist in other mods but are overwritten by this mod</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/plugininfodialog.ui" line="74"/> + <source>Winning file conflicts:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/plugininfodialog.ui" line="81"/> + <location filename="BSPluginInfo/plugininfodialog.ui" line="154"/> + <location filename="BSPluginInfo/plugininfodialog.ui" line="224"/> + <location filename="BSPluginInfo/plugininfodialog.ui" line="319"/> + <source>Filter</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/plugininfodialog.ui" line="140"/> + <location filename="BSPluginInfo/plugininfodialog.ui" line="178"/> + <source>Files that are unused because they are overwritten by other mods</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/plugininfodialog.ui" line="147"/> + <source>Losing file conflicts:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/plugininfodialog.ui" line="210"/> + <location filename="BSPluginInfo/plugininfodialog.ui" line="248"/> + <source>Files that have no conflicts</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/plugininfodialog.ui" line="217"/> + <source>The following files have no conflicts:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/plugininfodialog.ui" line="271"/> + <source>Tree</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/plugininfodialog.ui" line="336"/> + <source>Previous</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/plugininfodialog.ui" line="346"/> + <source>Next</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/plugininfodialog.ui" line="369"/> + <source>Close</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>PluginRecordView</name> + <message> + <location filename="BSPluginInfo/pluginrecordview.ui" line="107"/> + <source>All conflicts</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/pluginrecordview.ui" line="112"/> + <source>Winning conflicts</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/pluginrecordview.ui" line="117"/> + <source>Losing conflicts</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginInfo/pluginrecordview.ui" line="125"/> + <source>Ignore conflicts with masters</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>PluginsWidget</name> + <message> + <location filename="BSPluginList/pluginswidget.ui" line="20"/> + <source>Plugins</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/pluginswidget.ui" line="40"/> + <location filename="BSPluginList/pluginswidget.ui" line="43"/> + <source>Sort the plugins using LOOT.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/pluginswidget.ui" line="46"/> + <source>Sort</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/pluginswidget.ui" line="76"/> + <source>Open list options...</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/pluginswidget.ui" line="79"/> + <source>Refresh list. This is usually not necessary unless you modified data outside the program.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/pluginswidget.ui" line="99"/> + <location filename="BSPluginList/pluginswidget.ui" line="102"/> + <source>Restore a backup.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/pluginswidget.ui" line="122"/> + <location filename="BSPluginList/pluginswidget.ui" line="125"/> + <source>Create a backup.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/pluginswidget.ui" line="139"/> + <source>Active:</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/pluginswidget.ui" line="152"/> + <source><html><head/><body><p>This provides statistics about the plugin list. The total number of active plugins is normally displayed. Other statistics may be accessed with the tooltip of this counter.</p></body></html></source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/pluginswidget.ui" line="179"/> + <location filename="BSPluginList/pluginswidget.ui" line="182"/> + <source>List of available esp/esm files.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/pluginswidget.ui" line="246"/> + <location filename="BSPluginList/pluginswidget.ui" line="249"/> + <source>Filter the list of plugins.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/pluginswidget.ui" line="255"/> + <source>Filter</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>QObject</name> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="261"/> + <source>Incompatible with %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="273"/> + <location filename="MOTools/Loot.cpp" line="422"/> + <source>Warning</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginListModel.cpp" line="277"/> + <location filename="MOTools/Loot.cpp" line="417"/> + <source>Error</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="306"/> + <source>Choose backup to restore</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="323"/> + <source>No Backups</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="BSPluginList/PluginsWidget.cpp" line="324"/> + <source>There are no backups to restore</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/Loot.cpp" line="265"/> + <source>General messages</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/Loot.cpp" line="277"/> + <source>Plugins</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/Loot.cpp" line="288"/> + <source>No messages.</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/Loot.cpp" line="336"/> + <source>Incompatibilities</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/Loot.cpp" line="351"/> + <source>Missing masters</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/Loot.cpp" line="383"/> + <source>Verified clean by %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/Loot.cpp" line="403"/> + <source>%1 found %2 ITM record(s), %3 deleted reference(s) and %4 deleted navmesh(es).</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/Loot.cpp" line="926"/> + <source>failed to run loot: %1</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/LootDialog.cpp" line="21"/> + <source>Checking masterlist existence</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/LootDialog.cpp" line="22"/> + <source>Updating masterlist</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/LootDialog.cpp" line="23"/> + <source>Loading lists</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/LootDialog.cpp" line="24"/> + <source>Reading plugins</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/LootDialog.cpp" line="25"/> + <source>Sorting plugins</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/LootDialog.cpp" line="26"/> + <source>Writing loadorder.txt</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/LootDialog.cpp" line="27"/> + <source>Parsing loot messages</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="MOTools/LootDialog.cpp" line="28"/> + <source>Done</source> + <translation type="unfinished"></translation> + </message> +</context> +<context> + <name>SelectionDialog</name> + <message> + <location filename="GUI/selectiondialog.ui" line="14"/> + <source>Select</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="GUI/selectiondialog.ui" line="23"/> + <source>Placeholder</source> + <translation type="unfinished"></translation> + </message> + <message> + <location filename="GUI/selectiondialog.ui" line="77"/> + <source>Cancel</source> + <translation type="unfinished"></translation> + </message> +</context> +</TS> diff --git a/libs/installer_bsplugins/src/lootcli/lootcli.h b/libs/installer_bsplugins/src/lootcli/lootcli.h new file mode 100644 index 0000000..af09fc0 --- /dev/null +++ b/libs/installer_bsplugins/src/lootcli/lootcli.h @@ -0,0 +1,21 @@ +#ifndef LOOTCLI_STUB_H +#define LOOTCLI_STUB_H + +// Stub lootcli header for Fluorine port (real lootcli lib not vendored). +// Only the types referenced by non-LOOT code (Settings::lootLogLevel) remain. + +namespace lootcli +{ + +enum class LogLevels +{ + Trace, + Debug, + Info, + Warning, + Error, +}; + +} // namespace lootcli + +#endif // LOOTCLI_STUB_H diff --git a/libs/installer_bsplugins/src/resources.qrc b/libs/installer_bsplugins/src/resources.qrc new file mode 100644 index 0000000..4e6407a --- /dev/null +++ b/libs/installer_bsplugins/src/resources.qrc @@ -0,0 +1,8 @@ +<RCC> + <qresource prefix="/bsplugins"> + <file alias="feather">resources/feather.png</file> + </qresource> + <qresource prefix="/bsplugins"> + <file alias="star">resources/emblem-star.png</file> + </qresource> +</RCC> diff --git a/libs/installer_bsplugins/src/resources/emblem-star.png b/libs/installer_bsplugins/src/resources/emblem-star.png Binary files differnew file mode 100644 index 0000000..dd3c2ee --- /dev/null +++ b/libs/installer_bsplugins/src/resources/emblem-star.png diff --git a/libs/installer_bsplugins/src/resources/feather.png b/libs/installer_bsplugins/src/resources/feather.png Binary files differnew file mode 100644 index 0000000..c240680 --- /dev/null +++ b/libs/installer_bsplugins/src/resources/feather.png diff --git a/libs/plugin_python/src/mobase/wrappers/basic_classes.cpp b/libs/plugin_python/src/mobase/wrappers/basic_classes.cpp index f782099..7f06b77 100644 --- a/libs/plugin_python/src/mobase/wrappers/basic_classes.cpp +++ b/libs/plugin_python/src/mobase/wrappers/basic_classes.cpp @@ -114,6 +114,14 @@ namespace mo2::python { [](Version const& version) { return version.string(Version::FormatCondensed); }) + // Compatibility shim: older plugins call .canonicalString() on + // whatever IOrganizer.appVersion() returned, which used to be a + // VersionInfo. It now returns Version, so we mirror the old name + // here. LazyModlistExport and friends use this. + .def("canonicalString", + [](Version const& version) { + return version.string(Version::FormatCondensed); + }) .def(py::self < py::self) .def(py::self > py::self) .def(py::self <= py::self) |
