From 984a660eed17bf71c223db0df1350cab91641e5c Mon Sep 17 00:00:00 2001 From: Limo Date: Mon, 12 Aug 2024 19:12:41 +0200 Subject: [PATCH] initial release --- .gitignore | 79 + CMakeLists.txt | 246 ++ LICENSE | 674 ++++ README.md | 98 + resources/filter_accept.svg | 72 + resources/filter_reject.svg | 61 + resources/icons.qrc | 7 + resources/logo.png | Bin 0 -> 34122 bytes resources/logo_small.png | Bin 0 -> 7298 bytes resources/showcase.png | Bin 0 -> 97283 bytes src/core/addmodinfo.h | 42 + src/core/appinfo.h | 72 + src/core/autotag.cpp | 110 + src/core/autotag.h | 191 ++ src/core/backupmanager.cpp | 397 +++ src/core/backupmanager.h | 196 ++ src/core/backuptarget.cpp | 21 + src/core/backuptarget.h | 46 + src/core/casematchingdeployer.cpp | 145 + src/core/casematchingdeployer.h | 61 + src/core/compressionerror.h | 25 + src/core/conflictinfo.h | 31 + src/core/cryptography.cpp | 121 + src/core/cryptography.h | 58 + src/core/deployer.cpp | 696 ++++ src/core/deployer.h | 349 ++ src/core/deployerfactory.cpp | 21 + src/core/deployerfactory.h | 61 + src/core/deployerinfo.h | 32 + src/core/editapplicationinfo.h | 38 + src/core/editautotagaction.cpp | 49 + src/core/editautotagaction.h | 92 + src/core/editdeployerinfo.h | 27 + src/core/editmanualtagaction.cpp | 22 + src/core/editmanualtagaction.h | 60 + src/core/editprofileinfo.h | 24 + src/core/fomod/dependency.cpp | 139 + src/core/fomod/dependency.h | 83 + src/core/fomod/file.h | 49 + src/core/fomod/fomodinstaller.cpp | 411 +++ src/core/fomod/fomodinstaller.h | 169 + src/core/fomod/installstep.h | 29 + src/core/fomod/plugin.h | 67 + src/core/fomod/plugindependency.h | 26 + src/core/fomod/plugingroup.h | 44 + src/core/fomod/plugintype.h | 38 + src/core/importmodinfo.h | 57 + src/core/installer.cpp | 431 +++ src/core/installer.h | 176 + src/core/log.cpp | 65 + src/core/log.h | 61 + src/core/lootdeployer.cpp | 650 ++++ src/core/lootdeployer.h | 276 ++ src/core/manualtag.cpp | 61 + src/core/manualtag.h | 65 + src/core/mod.cpp | 53 + src/core/mod.h | 78 + src/core/moddedapplication.cpp | 1993 +++++++++++ src/core/moddedapplication.h | 729 ++++ src/core/modinfo.h | 84 + src/core/nexus/api.cpp | 334 ++ src/core/nexus/api.h | 152 + src/core/nexus/file.cpp | 44 + src/core/nexus/file.h | 86 + src/core/nexus/mod.cpp | 52 + src/core/nexus/mod.h | 100 + src/core/parseerror.h | 27 + src/core/pathutils.cpp | 201 ++ src/core/pathutils.h | 110 + src/core/progressnode.cpp | 107 + src/core/progressnode.h | 131 + src/core/tag.cpp | 29 + src/core/tag.h | 57 + src/core/tagcondition.h | 34 + src/core/tagconditionnode.cpp | 442 +++ src/core/tagconditionnode.h | 156 + src/cspell.json | 27 + src/lmm_Doxyfile | 2737 +++++++++++++++ src/main.cpp | 127 + src/ui/addapikeydialog.cpp | 52 + src/ui/addapikeydialog.h | 59 + src/ui/addapikeydialog.ui | 75 + src/ui/addappdialog.cpp | 217 ++ src/ui/addappdialog.h | 139 + src/ui/addappdialog.ui | 231 ++ src/ui/addautotagdialog.cpp | 46 + src/ui/addautotagdialog.h | 53 + src/ui/addautotagdialog.ui | 94 + src/ui/addbackupdialog.cpp | 44 + src/ui/addbackupdialog.h | 79 + src/ui/addbackupdialog.ui | 110 + src/ui/addbackuptargetdialog.cpp | 113 + src/ui/addbackuptargetdialog.h | 103 + src/ui/addbackuptargetdialog.ui | 135 + src/ui/adddeployerdialog.cpp | 213 ++ src/ui/adddeployerdialog.h | 123 + src/ui/adddeployerdialog.ui | 192 ++ src/ui/addmoddialog.cpp | 424 +++ src/ui/addmoddialog.h | 207 ++ src/ui/addmoddialog.ui | 270 ++ src/ui/addprofiledialog.cpp | 87 + src/ui/addprofiledialog.h | 83 + src/ui/addprofiledialog.ui | 135 + src/ui/addtodeployerdialog.cpp | 52 + src/ui/addtodeployerdialog.h | 66 + src/ui/addtodeployerdialog.ui | 87 + src/ui/addtogroupdialog.cpp | 52 + src/ui/addtogroupdialog.h | 75 + src/ui/addtogroupdialog.ui | 98 + src/ui/addtooldialog.cpp | 50 + src/ui/addtooldialog.h | 62 + src/ui/addtooldialog.ui | 121 + src/ui/applicationmanager.cpp | 891 +++++ src/ui/applicationmanager.h | 897 +++++ src/ui/backuplistmodel.cpp | 120 + src/ui/backuplistmodel.h | 109 + src/ui/backuplistview.cpp | 57 + src/ui/backuplistview.h | 47 + src/ui/backupnamedelegate.cpp | 36 + src/ui/backupnamedelegate.h | 68 + src/ui/changeapipwdialog.cpp | 95 + src/ui/changeapipwdialog.h | 78 + src/ui/changeapipwdialog.ui | 90 + src/ui/colors.h | 27 + src/ui/conflictsmodel.cpp | 67 + src/ui/conflictsmodel.h | 74 + src/ui/deployerlistmodel.cpp | 128 + src/ui/deployerlistmodel.h | 87 + src/ui/deployerlistproxymodel.cpp | 181 + src/ui/deployerlistproxymodel.h | 147 + src/ui/deployerlistview.cpp | 108 + src/ui/deployerlistview.h | 69 + src/ui/editautotagsdialog.cpp | 443 +++ src/ui/editautotagsdialog.h | 182 + src/ui/editautotagsdialog.ui | 173 + src/ui/editmanualtagsdialog.cpp | 177 + src/ui/editmanualtagsdialog.h | 103 + src/ui/editmanualtagsdialog.ui | 89 + src/ui/editmodsourcesdialog.cpp | 94 + src/ui/editmodsourcesdialog.h | 95 + src/ui/editmodsourcesdialog.ui | 125 + src/ui/enterapipwdialog.cpp | 59 + src/ui/enterapipwdialog.h | 74 + src/ui/enterapipwdialog.ui | 74 + src/ui/fomodcheckbox.cpp | 25 + src/ui/fomodcheckbox.h | 53 + src/ui/fomoddialog.cpp | 291 ++ src/ui/fomoddialog.h | 148 + src/ui/fomoddialog.ui | 149 + src/ui/fomodradiobutton.cpp | 25 + src/ui/fomodradiobutton.h | 53 + src/ui/importfromsteamdialog.cpp | 209 ++ src/ui/importfromsteamdialog.h | 92 + src/ui/importfromsteamdialog.ui | 147 + src/ui/ipcclient.cpp | 25 + src/ui/ipcclient.h | 38 + src/ui/ipcserver.cpp | 44 + src/ui/ipcserver.h | 50 + src/ui/mainwindow.cpp | 3055 +++++++++++++++++ src/ui/mainwindow.h | 1426 ++++++++ src/ui/mainwindow.ui | 1603 +++++++++ src/ui/managemodtagsdialog.cpp | 87 + src/ui/managemodtagsdialog.h | 94 + src/ui/managemodtagsdialog.ui | 88 + src/ui/modlistmodel.cpp | 270 ++ src/ui/modlistmodel.h | 174 + src/ui/modlistproxymodel.cpp | 160 + src/ui/modlistproxymodel.h | 143 + src/ui/modlistview.cpp | 236 ++ src/ui/modlistview.h | 195 ++ src/ui/modnamedelegate.cpp | 42 + src/ui/modnamedelegate.h | 71 + src/ui/movemoddialog.cpp | 30 + src/ui/movemoddialog.h | 58 + src/ui/movemoddialog.ui | 94 + src/ui/nexusmoddialog.cpp | 239 ++ src/ui/nexusmoddialog.h | 80 + src/ui/nexusmoddialog.ui | 134 + src/ui/overwritebackupdialog.cpp | 69 + src/ui/overwritebackupdialog.h | 71 + src/ui/overwritebackupdialog.ui | 98 + src/ui/passwordfield.cpp | 96 + src/ui/passwordfield.h | 99 + src/ui/settingsdialog.cpp | 433 +++ src/ui/settingsdialog.h | 151 + src/ui/settingsdialog.ui | 528 +++ src/ui/tablecelldelegate.cpp | 92 + src/ui/tablecelldelegate.h | 45 + src/ui/tablepushbutton.cpp | 11 + src/ui/tablepushbutton.h | 38 + src/ui/tabletoolbutton.cpp | 13 + src/ui/tabletoolbutton.h | 48 + src/ui/tagcheckbox.cpp | 16 + src/ui/tagcheckbox.h | 64 + src/ui/validatinglineedit.cpp | 69 + src/ui/validatinglineedit.h | 81 + src/ui/versionboxdelegate.cpp | 176 + src/ui/versionboxdelegate.h | 115 + tests/CMakeLists.txt | 109 + tests/data/app/.a.lmmbakman.json | 13 + tests/data/app/0.txt | 1 + tests/data/app/a-Fil _3 | 1 + tests/data/app/a.1.lmmbakman/2.txt | 1 + tests/data/app/a.1.lmmbakman/a-Fil _3 | 1 + tests/data/app/a.1.lmmbakman/file.cfg | 1 + tests/data/app/a/2.txt | 1 + tests/data/app/a/a-Fil _3 | 1 + tests/data/app/a/file.cfg | 1 + tests/data/app/b/3aBc | 1 + tests/data/app/c/0 | 1 + tests/data/app/c/wasd | 1 + tests/data/source/0/0.txt | 1 + tests/data/source/0/1.txt | 1 + tests/data/source/0/a-Fil _3 | 1 + tests/data/source/0/a/0.txt | 1 + tests/data/source/0/a/2.txt | 1 + tests/data/source/0/a/b/1.txt | 1 + tests/data/source/0/a/b/2.txt | 1 + tests/data/source/0/b/3 | 1 + tests/data/source/0/b/3aBc | 1 + tests/data/source/1/6 | 1 + tests/data/source/1/7 | 1 + tests/data/source/1/f/b c.t | 1 + tests/data/source/1/f/g/0 | 1 + tests/data/source/2/0 | 1 + tests/data/source/2/0.txt | 1 + tests/data/source/2/a/1.txt | 1 + tests/data/source/2/a/b/2.txt | 1 + tests/data/source/2/b/3 | 1 + tests/data/source/app/0.txt | 1 + tests/data/source/app/a-Fil _3 | 1 + tests/data/source/app/a/2.txt | 1 + tests/data/source/app/a/file.cfg | 1 + tests/data/source/app/b/3aBc | 1 + tests/data/source/app/c/0 | 1 + tests/data/source/app/c/wasd | 1 + tests/data/source/auto_tags/0/asd | 0 .../source/auto_tags/0/dir/12argdar3wahtdh | 0 tests/data/source/auto_tags/0/dir/abc/a | 0 tests/data/source/auto_tags/0/dir/abc/abc_123 | 0 tests/data/source/auto_tags/0/dir/s.txt | 1 + .../source/auto_tags/0/dir/some_12_file_abc | 0 tests/data/source/auto_tags/0/rw3 | 0 tests/data/source/auto_tags/1/asd | 0 .../source/auto_tags/1/dir/not_an_image.png | 0 tests/data/source/auto_tags/1/dir/stxt | 1 + tests/data/source/auto_tags/1/rw3 | 0 tests/data/source/auto_tags/2/j/A_file | 0 tests/data/source/auto_tags/2/qwert | 0 tests/data/source/auto_tags/2/unique_file | 0 tests/data/source/bak_man/a-Fil _3 | 1 + tests/data/source/bak_man/file.cfg | 1 + tests/data/source/case_matching/0/0.txt | 1 + tests/data/source/case_matching/0/0file | 0 tests/data/source/case_matching/0/1.txt | 1 + tests/data/source/case_matching/0/a-Fil _3 | 1 + tests/data/source/case_matching/0/b/3aBc | 1 + .../case_matching/0/new_dir/aB/someFile.txt | 1 + .../source/case_matching/0/new_dir/new_file | 1 + tests/data/source/case_matching/1/0.txt | 1 + tests/data/source/case_matching/1/1.txt | 1 + tests/data/source/case_matching/1/123/456/789 | 0 tests/data/source/case_matching/1/a-Fil _3 | 1 + tests/data/source/case_matching/1/b/3aBc | 1 + .../case_matching/1/new_dir/aB/someFile.txt | 1 + .../source/case_matching/1/new_dir/new_file | 1 + tests/data/source/case_matching/orig_0/0.TxT | 1 + tests/data/source/case_matching/orig_0/0file | 0 tests/data/source/case_matching/orig_0/1.txt | 1 + tests/data/source/case_matching/orig_0/B/3abc | 1 + .../data/source/case_matching/orig_0/a-fil _3 | 1 + .../orig_0/new_dir/aB/someFile.txt | 1 + .../case_matching/orig_0/new_dir/new_file | 1 + tests/data/source/case_matching/orig_1/0.TxT | 1 + tests/data/source/case_matching/orig_1/1.txt | 1 + .../source/case_matching/orig_1/123/456/789 | 0 tests/data/source/case_matching/orig_1/B/3abc | 1 + .../data/source/case_matching/orig_1/a-fil _3 | 1 + .../case_matching/orig_1/neW_dir/NeW_fiLe | 1 + .../orig_1/neW_dir/ab/SOMefile.txt | 1 + tests/data/source/conflicts/0/0 | 0 tests/data/source/conflicts/1/2 | 0 tests/data/source/conflicts/2/0 | 0 tests/data/source/conflicts/2/2 | 0 tests/data/source/conflicts/3/3 | 0 tests/data/source/conflicts/4/4 | 0 tests/data/source/conflicts/5/2 | 0 tests/data/source/conflicts/5/3 | 0 tests/data/source/conflicts/5/5 | 0 tests/data/source/conflicts/6/4 | 0 tests/data/source/conflicts/6/6 | 0 tests/data/source/conflicts/7/7 | 0 .../data/source/fomod/another_example.plugin | 0 tests/data/source/fomod/example.plugin | 1 + tests/data/source/fomod/fomod/matrix.xml | 123 + tests/data/source/fomod/fomod/simple.xml | 24 + tests/data/source/fomod/fomod/steps.xml | 126 + tests/data/source/fomod/texture_red_b | 0 tests/data/source/loot/loadorder.txt | 5 + tests/data/source/loot/plugins.txt | 3 + tests/data/source/mod0.tar.gz | Bin 0 -> 348 bytes tests/data/source/mod1.zip | Bin 0 -> 538 bytes tests/data/source/mod2.tar.gz | Bin 0 -> 259 bytes tests/data/source/split/mod/123 | 0 tests/data/source/split/mod/D/d.txt | 1 + tests/data/source/split/mod/a/B/123/123.txt | 1 + tests/data/source/split/mod/a/B/wer | 0 tests/data/source/split/mod/a/C/ghj | 0 tests/data/source/split/mod/a/abc | 0 .../change_bak/.a-Fil _3.lmmbakman.json | 0 .../bak_man/change_bak/.a.lmmbakman.json | 0 tests/data/target/bak_man/change_bak/0.txt | 1 + tests/data/target/bak_man/change_bak/a-Fil _3 | 1 + .../bak_man/change_bak/a-Fil _3.0.lmmbakman | 1 + .../bak_man/change_bak/a.0.lmmbakman/file.cfg | 1 + tests/data/target/bak_man/change_bak/a/2.txt | 1 + .../data/target/bak_man/change_bak/a/file.cfg | 1 + tests/data/target/bak_man/change_bak/b/3aBc | 1 + tests/data/target/bak_man/change_bak/c/0 | 1 + tests/data/target/bak_man/change_bak/c/wasd | 1 + .../bak_man/create_bak/.0.txt.lmmbakman.json | 1 + .../bak_man/create_bak/.a.lmmbakman.json | 0 tests/data/target/bak_man/create_bak/0.txt | 1 + .../bak_man/create_bak/0.txt.1.lmmbakman | 1 + tests/data/target/bak_man/create_bak/a-Fil _3 | 1 + .../bak_man/create_bak/a.1.lmmbakman/2.txt | 1 + .../bak_man/create_bak/a.1.lmmbakman/file.cfg | 1 + tests/data/target/bak_man/create_bak/a/2.txt | 1 + .../data/target/bak_man/create_bak/a/file.cfg | 1 + tests/data/target/bak_man/create_bak/b/3aBc | 1 + tests/data/target/bak_man/create_bak/c/0 | 1 + tests/data/target/bak_man/create_bak/c/wasd | 1 + .../invalid_state/.a-Fil _3.lmmbakman.json | 0 .../bak_man/invalid_state/.a.lmmbakman.json | 0 tests/data/target/bak_man/invalid_state/0.txt | 1 + .../target/bak_man/invalid_state/a-Fil _3 | 1 + .../bak_man/invalid_state/a.0.lmmbakman/2.txt | 1 + .../invalid_state/a.0.lmmbakman/file.cfg | 1 + .../bak_man/invalid_state/a.1.lmmbakman/2.txt | 1 + .../invalid_state/a.15.lmmbakmanOLD/2.txt | 1 + .../invalid_state/a.15.lmmbakmanOLD/file.cfg | 1 + .../bak_man/invalid_state/a.3.lmmbakman/2.txt | 1 + .../invalid_state/a.3.lmmbakman/file.cfg | 1 + .../invalid_state/a.3.lmmbakman/newfile | 1 + .../invalid_state/a.8.lmmbakmanOLD/2.txt | 1 + .../invalid_state/a.8.lmmbakmanOLD/file.cfg | 1 + .../data/target/bak_man/invalid_state/a/2.txt | 1 + .../target/bak_man/invalid_state/a/file.cfg | 1 + .../data/target/bak_man/invalid_state/b/3aBc | 1 + tests/data/target/bak_man/invalid_state/c/0 | 1 + .../data/target/bak_man/invalid_state/c/wasd | 1 + tests/data/target/bak_man/overwrite0/2.txt | 1 + tests/data/target/bak_man/overwrite1/2.txt | 1 + tests/data/target/bak_man/overwrite1/a-Fil _3 | 1 + tests/data/target/bak_man/overwrite1/file.cfg | 1 + .../profiles_0/.a-Fil _3.lmmbakman.json | 0 .../bak_man/profiles_0/.a.lmmbakman.json | 0 tests/data/target/bak_man/profiles_0/0.txt | 1 + tests/data/target/bak_man/profiles_0/a-Fil _3 | 1 + .../bak_man/profiles_0/a-Fil _3.0.lmmbakman | 1 + .../bak_man/profiles_0/a.0.lmmbakman/2.txt | 1 + .../bak_man/profiles_0/a.0.lmmbakman/file.cfg | 1 + .../bak_man/profiles_0/a.1.lmmbakman/2.txt | 1 + .../bak_man/profiles_0/a.3.lmmbakman/file.cfg | 1 + .../bak_man/profiles_0/a.4.lmmbakman/2.txt | 1 + .../bak_man/profiles_0/a.4.lmmbakman/file.cfg | 1 + .../bak_man/profiles_0/a.4.lmmbakman/newfile | 1 + tests/data/target/bak_man/profiles_0/a/2.txt | 1 + .../data/target/bak_man/profiles_0/a/file.cfg | 1 + tests/data/target/bak_man/profiles_0/b/3aBc | 1 + tests/data/target/bak_man/profiles_0/c/0 | 1 + tests/data/target/bak_man/profiles_0/c/wasd | 1 + .../profiles_1/.a-Fil _3.lmmbakman.json | 0 .../bak_man/profiles_1/.a.lmmbakman.json | 0 tests/data/target/bak_man/profiles_1/0.txt | 1 + tests/data/target/bak_man/profiles_1/a-Fil _3 | 1 + .../bak_man/profiles_1/a-Fil _3.1.lmmbakman | 1 + .../bak_man/profiles_1/a.0.lmmbakman/2.txt | 1 + .../bak_man/profiles_1/a.0.lmmbakman/file.cfg | 1 + .../bak_man/profiles_1/a.2.lmmbakman/2.txt | 1 + .../bak_man/profiles_1/a.2.lmmbakman/file.cfg | 1 + .../bak_man/profiles_1/a.3.lmmbakman/file.cfg | 1 + .../bak_man/profiles_1/a.4.lmmbakman/2.txt | 1 + .../bak_man/profiles_1/a.4.lmmbakman/file.cfg | 1 + .../bak_man/profiles_1/a.4.lmmbakman/newfile | 1 + tests/data/target/bak_man/profiles_1/a/2.txt | 1 + tests/data/target/bak_man/profiles_1/b/3aBc | 1 + tests/data/target/bak_man/profiles_1/c/0 | 1 + tests/data/target/bak_man/profiles_1/c/wasd | 1 + .../profiles_2/.a-Fil _3.lmmbakman.json | 0 .../bak_man/profiles_2/.a.lmmbakman.json | 0 tests/data/target/bak_man/profiles_2/0.txt | 1 + tests/data/target/bak_man/profiles_2/a-Fil _3 | 1 + .../bak_man/profiles_2/a-Fil _3.1.lmmbakman | 1 + .../bak_man/profiles_2/a.1.lmmbakman/2.txt | 1 + .../bak_man/profiles_2/a.2.lmmbakman/2.txt | 1 + .../bak_man/profiles_2/a.2.lmmbakman/file.cfg | 1 + .../bak_man/profiles_2/a.3.lmmbakman/file.cfg | 1 + .../bak_man/profiles_2/a.4.lmmbakman/2.txt | 1 + .../bak_man/profiles_2/a.4.lmmbakman/file.cfg | 1 + .../bak_man/profiles_2/a.4.lmmbakman/newfile | 1 + tests/data/target/bak_man/profiles_2/a/2.txt | 1 + .../data/target/bak_man/profiles_2/a/file.cfg | 1 + tests/data/target/bak_man/profiles_2/b/3aBc | 1 + tests/data/target/bak_man/profiles_2/c/0 | 1 + tests/data/target/bak_man/profiles_2/c/wasd | 1 + .../bak_man/remove_bak_0/.a.lmmbakman.json | 0 tests/data/target/bak_man/remove_bak_0/0.txt | 1 + .../data/target/bak_man/remove_bak_0/a-Fil _3 | 1 + .../bak_man/remove_bak_0/a.1.lmmbakman/2.txt | 1 + .../remove_bak_0/a.2.lmmbakman/file.cfg | 1 + .../bak_man/remove_bak_0/a.3.lmmbakman/2.txt | 1 + .../remove_bak_0/a.3.lmmbakman/file.cfg | 1 + .../remove_bak_0/a.3.lmmbakman/newfile | 1 + .../data/target/bak_man/remove_bak_0/a/2.txt | 1 + .../target/bak_man/remove_bak_0/a/file.cfg | 1 + tests/data/target/bak_man/remove_bak_0/b/3aBc | 1 + tests/data/target/bak_man/remove_bak_0/c/0 | 1 + tests/data/target/bak_man/remove_bak_0/c/wasd | 1 + .../bak_man/remove_bak_1/.a.lmmbakman.json | 0 tests/data/target/bak_man/remove_bak_1/0.txt | 1 + .../data/target/bak_man/remove_bak_1/a-Fil _3 | 1 + .../remove_bak_1/a.1.lmmbakman/file.cfg | 1 + .../bak_man/remove_bak_1/a.2.lmmbakman/2.txt | 1 + .../remove_bak_1/a.2.lmmbakman/file.cfg | 1 + .../remove_bak_1/a.2.lmmbakman/newfile | 1 + .../data/target/bak_man/remove_bak_1/a/2.txt | 1 + tests/data/target/bak_man/remove_bak_1/b/3aBc | 1 + tests/data/target/bak_man/remove_bak_1/c/0 | 1 + tests/data/target/bak_man/remove_bak_1/c/wasd | 1 + .../bak_man/remove_bak_2/.a.lmmbakman.json | 0 tests/data/target/bak_man/remove_bak_2/0.txt | 1 + .../data/target/bak_man/remove_bak_2/a-Fil _3 | 1 + .../bak_man/remove_bak_2/a.1.lmmbakman/2.txt | 1 + .../remove_bak_2/a.1.lmmbakman/file.cfg | 1 + .../remove_bak_2/a.1.lmmbakman/newfile | 1 + .../data/target/bak_man/remove_bak_2/a/2.txt | 1 + tests/data/target/bak_man/remove_bak_2/b/3aBc | 1 + tests/data/target/bak_man/remove_bak_2/c/0 | 1 + tests/data/target/bak_man/remove_bak_2/c/wasd | 1 + .../remove_target/.a-Fil _3.lmmbakman.json | 0 tests/data/target/bak_man/remove_target/0.txt | 1 + .../target/bak_man/remove_target/a-Fil _3 | 1 + .../remove_target/a-Fil _3.1.lmmbakman | 1 + .../data/target/bak_man/remove_target/a/2.txt | 1 + .../target/bak_man/remove_target/a/file.cfg | 1 + .../data/target/bak_man/remove_target/b/3aBc | 1 + tests/data/target/bak_man/remove_target/c/0 | 1 + .../data/target/bak_man/remove_target/c/wasd | 1 + tests/data/target/case_matching/0/0.txt | 1 + tests/data/target/case_matching/0/0file | 0 tests/data/target/case_matching/0/1.txt | 1 + tests/data/target/case_matching/0/a-Fil _3 | 1 + tests/data/target/case_matching/0/b/3aBc | 1 + .../case_matching/0/new_dir/aB/someFile.txt | 1 + .../target/case_matching/0/new_dir/new_file | 1 + tests/data/target/case_matching/1/0.txt | 1 + tests/data/target/case_matching/1/1.txt | 1 + tests/data/target/case_matching/1/123/456/789 | 0 tests/data/target/case_matching/1/a-Fil _3 | 1 + tests/data/target/case_matching/1/b/3aBc | 1 + .../case_matching/1/new_dir/aB/someFile.txt | 1 + .../target/case_matching/1/new_dir/new_file | 1 + tests/data/target/case_matching/123/456/789 | 0 tests/data/target/loot/profiles/.lmmconfig | 6 + .../loot/profiles/.loadorder.txt.lmmprof0 | 5 + .../loot/profiles/.loadorder.txt.lmmprof1 | 5 + .../loot/profiles/.plugins.txt.lmmprof0 | 3 + .../loot/profiles/.plugins.txt.lmmprof1 | 3 + tests/data/target/loot/profiles/loadorder.txt | 5 + tests/data/target/loot/profiles/plugins.txt | 3 + tests/data/target/loot/source/Morrowind.esm | 0 tests/data/target/loot/source/a.esp | 0 tests/data/target/loot/source/c.esp | 0 tests/data/target/loot/source/d.esp | 0 tests/data/target/loot/target/.lmmconfig | 6 + .../loot/target/.loadorder.txt.lmmprof0 | 5 + .../loot/target/.loadorder.txt.lmmprof1 | 5 + .../target/loot/target/.plugins.txt.lmmprof0 | 3 + .../target/loot/target/.plugins.txt.lmmprof1 | 3 + tests/data/target/loot/target/loadorder.txt | 5 + tests/data/target/loot/target/plugins.txt | 3 + tests/data/target/lower/0.txt | 1 + tests/data/target/lower/1.txt | 1 + tests/data/target/lower/a-fil _3 | 1 + tests/data/target/lower/a/0.txt | 1 + tests/data/target/lower/a/2.txt | 1 + tests/data/target/lower/a/b/1.txt | 1 + tests/data/target/lower/a/b/2.txt | 1 + tests/data/target/lower/b/3 | 1 + tests/data/target/lower/b/3abc | 1 + tests/data/target/mod012/0 | 1 + tests/data/target/mod012/0.txt | 1 + tests/data/target/mod012/0.txt.lmmbak | 1 + tests/data/target/mod012/1.txt | 1 + tests/data/target/mod012/6 | 1 + tests/data/target/mod012/7 | 1 + tests/data/target/mod012/a-Fil _3 | 1 + tests/data/target/mod012/a-Fil _3.lmmbak | 1 + tests/data/target/mod012/a/0.txt | 1 + tests/data/target/mod012/a/1.txt | 1 + tests/data/target/mod012/a/2.txt | 1 + tests/data/target/mod012/a/2.txt.lmmbak | 1 + tests/data/target/mod012/a/b/1.txt | 1 + tests/data/target/mod012/a/b/2.txt | 1 + tests/data/target/mod012/a/file.cfg | 1 + tests/data/target/mod012/b/3 | 1 + tests/data/target/mod012/b/3aBc | 1 + tests/data/target/mod012/b/3aBc.lmmbak | 1 + tests/data/target/mod012/c/0 | 1 + tests/data/target/mod012/c/wasd | 1 + tests/data/target/mod012/f/b c.t | 1 + tests/data/target/mod012/f/g/0 | 1 + tests/data/target/mod1/0.txt | 1 + tests/data/target/mod1/6 | 1 + tests/data/target/mod1/7 | 1 + tests/data/target/mod1/a-Fil _3 | 1 + tests/data/target/mod1/a/2.txt | 1 + tests/data/target/mod1/a/file.cfg | 1 + tests/data/target/mod1/b/3aBc | 1 + tests/data/target/mod1/c/0 | 1 + tests/data/target/mod1/c/wasd | 1 + tests/data/target/mod1/f/b c.t | 1 + tests/data/target/mod1/f/g/0 | 1 + .../target/remove/simple/.lmm_mods.json.bak | 0 tests/data/target/remove/simple/1/6 | 1 + tests/data/target/remove/simple/1/7 | 1 + tests/data/target/remove/simple/1/f/b c.t | 1 + tests/data/target/remove/simple/1/f/g/0 | 1 + tests/data/target/remove/simple/lmm_mods.json | 0 .../target/remove/version/.lmm_mods.json.bak | 0 tests/data/target/remove/version/1/6 | 1 + tests/data/target/remove/version/1/7 | 1 + tests/data/target/remove/version/1/f/b c.t | 1 + tests/data/target/remove/version/1/f/g/0 | 1 + tests/data/target/remove/version/2/0.txt | 1 + tests/data/target/remove/version/2/1.txt | 1 + tests/data/target/remove/version/2/a-Fil _3 | 1 + tests/data/target/remove/version/2/a/0.txt | 1 + tests/data/target/remove/version/2/a/2.txt | 1 + tests/data/target/remove/version/2/a/b/1.txt | 1 + tests/data/target/remove/version/2/a/b/2.txt | 1 + tests/data/target/remove/version/2/b/3 | 1 + tests/data/target/remove/version/2/b/3aBc | 1 + .../data/target/remove/version/lmm_mods.json | 0 tests/data/target/root_level/0/0.txt | 1 + tests/data/target/root_level/0/1.txt | 1 + tests/data/target/root_level/0/a-Fil _3 | 1 + tests/data/target/root_level/0/a/0.txt | 1 + tests/data/target/root_level/0/a/2.txt | 1 + tests/data/target/root_level/0/a/b/1.txt | 1 + tests/data/target/root_level/0/a/b/2.txt | 1 + tests/data/target/root_level/0/b/3 | 1 + tests/data/target/root_level/0/b/3aBc | 1 + tests/data/target/root_level/1/0.txt | 1 + tests/data/target/root_level/1/2.txt | 1 + tests/data/target/root_level/1/3 | 1 + tests/data/target/root_level/1/3aBc | 1 + tests/data/target/root_level/1/b/1.txt | 1 + tests/data/target/root_level/1/b/2.txt | 1 + tests/data/target/root_level/2/1.txt | 1 + tests/data/target/root_level/2/2.txt | 1 + tests/data/target/single_dir/0.txt | 1 + tests/data/target/single_dir/1.txt | 1 + tests/data/target/single_dir/2.txt | 1 + tests/data/target/single_dir/3 | 1 + tests/data/target/single_dir/3aBc | 1 + tests/data/target/single_dir/a-Fil _3 | 1 + tests/data/target/split/0/123 | 0 tests/data/target/split/0/D/d.txt | 1 + tests/data/target/split/1/abc | 0 tests/data/target/split/2/wer | 0 tests/data/target/split/3/123.txt | 1 + tests/data/target/split/4/ghj | 0 tests/data/target/upper/0.TXT | 1 + tests/data/target/upper/1.TXT | 1 + tests/data/target/upper/A-FIL _3 | 1 + tests/data/target/upper/A/0.TXT | 1 + tests/data/target/upper/A/2.TXT | 1 + tests/data/target/upper/A/B/1.TXT | 1 + tests/data/target/upper/A/B/2.TXT | 1 + tests/data/target/upper/B/3 | 1 + tests/data/target/upper/B/3ABC | 1 + tests/data/target/upper_single/0.TXT | 1 + tests/data/target/upper_single/1.TXT | 1 + tests/data/target/upper_single/2.TXT | 1 + tests/data/target/upper_single/3 | 1 + tests/data/target/upper_single/3ABC | 1 + tests/data/target/upper_single/A-FIL _3 | 1 + tests/data/target/vanilla/0.txt | 1 + tests/data/target/vanilla/a-Fil _3 | 1 + tests/data/target/vanilla/a/2.txt | 1 + tests/data/target/vanilla/a/file.cfg | 1 + tests/data/target/vanilla/b/3aBc | 1 + tests/data/target/vanilla/c/0 | 1 + tests/data/target/vanilla/c/wasd | 1 + tests/test_backupmanager.cpp | 209 ++ tests/test_cryptography.cpp | 49 + tests/test_deployer.cpp | 201 ++ tests/test_fomodinstaller.cpp | 59 + tests/test_installer.cpp | 104 + tests/test_lootdeployer.cpp | 75 + tests/test_moddedapplication.cpp | 250 ++ tests/test_tagconditionnode.cpp | 164 + tests/test_utils.cpp | 67 + tests/test_utils.h.in | 17 + tests/tests.cpp | 5 + 607 files changed, 37936 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 LICENSE create mode 100644 README.md create mode 100644 resources/filter_accept.svg create mode 100644 resources/filter_reject.svg create mode 100644 resources/icons.qrc create mode 100644 resources/logo.png create mode 100644 resources/logo_small.png create mode 100644 resources/showcase.png create mode 100644 src/core/addmodinfo.h create mode 100644 src/core/appinfo.h create mode 100644 src/core/autotag.cpp create mode 100644 src/core/autotag.h create mode 100644 src/core/backupmanager.cpp create mode 100644 src/core/backupmanager.h create mode 100644 src/core/backuptarget.cpp create mode 100644 src/core/backuptarget.h create mode 100644 src/core/casematchingdeployer.cpp create mode 100644 src/core/casematchingdeployer.h create mode 100644 src/core/compressionerror.h create mode 100644 src/core/conflictinfo.h create mode 100644 src/core/cryptography.cpp create mode 100644 src/core/cryptography.h create mode 100644 src/core/deployer.cpp create mode 100644 src/core/deployer.h create mode 100644 src/core/deployerfactory.cpp create mode 100644 src/core/deployerfactory.h create mode 100644 src/core/deployerinfo.h create mode 100644 src/core/editapplicationinfo.h create mode 100644 src/core/editautotagaction.cpp create mode 100644 src/core/editautotagaction.h create mode 100644 src/core/editdeployerinfo.h create mode 100644 src/core/editmanualtagaction.cpp create mode 100644 src/core/editmanualtagaction.h create mode 100644 src/core/editprofileinfo.h create mode 100644 src/core/fomod/dependency.cpp create mode 100644 src/core/fomod/dependency.h create mode 100644 src/core/fomod/file.h create mode 100644 src/core/fomod/fomodinstaller.cpp create mode 100644 src/core/fomod/fomodinstaller.h create mode 100644 src/core/fomod/installstep.h create mode 100644 src/core/fomod/plugin.h create mode 100644 src/core/fomod/plugindependency.h create mode 100644 src/core/fomod/plugingroup.h create mode 100644 src/core/fomod/plugintype.h create mode 100644 src/core/importmodinfo.h create mode 100644 src/core/installer.cpp create mode 100644 src/core/installer.h create mode 100644 src/core/log.cpp create mode 100644 src/core/log.h create mode 100644 src/core/lootdeployer.cpp create mode 100644 src/core/lootdeployer.h create mode 100644 src/core/manualtag.cpp create mode 100644 src/core/manualtag.h create mode 100644 src/core/mod.cpp create mode 100644 src/core/mod.h create mode 100644 src/core/moddedapplication.cpp create mode 100644 src/core/moddedapplication.h create mode 100644 src/core/modinfo.h create mode 100644 src/core/nexus/api.cpp create mode 100644 src/core/nexus/api.h create mode 100644 src/core/nexus/file.cpp create mode 100644 src/core/nexus/file.h create mode 100644 src/core/nexus/mod.cpp create mode 100644 src/core/nexus/mod.h create mode 100644 src/core/parseerror.h create mode 100644 src/core/pathutils.cpp create mode 100644 src/core/pathutils.h create mode 100644 src/core/progressnode.cpp create mode 100644 src/core/progressnode.h create mode 100644 src/core/tag.cpp create mode 100644 src/core/tag.h create mode 100644 src/core/tagcondition.h create mode 100644 src/core/tagconditionnode.cpp create mode 100644 src/core/tagconditionnode.h create mode 100644 src/cspell.json create mode 100644 src/lmm_Doxyfile create mode 100644 src/main.cpp create mode 100644 src/ui/addapikeydialog.cpp create mode 100644 src/ui/addapikeydialog.h create mode 100644 src/ui/addapikeydialog.ui create mode 100644 src/ui/addappdialog.cpp create mode 100644 src/ui/addappdialog.h create mode 100644 src/ui/addappdialog.ui create mode 100644 src/ui/addautotagdialog.cpp create mode 100644 src/ui/addautotagdialog.h create mode 100644 src/ui/addautotagdialog.ui create mode 100644 src/ui/addbackupdialog.cpp create mode 100644 src/ui/addbackupdialog.h create mode 100644 src/ui/addbackupdialog.ui create mode 100644 src/ui/addbackuptargetdialog.cpp create mode 100644 src/ui/addbackuptargetdialog.h create mode 100644 src/ui/addbackuptargetdialog.ui create mode 100644 src/ui/adddeployerdialog.cpp create mode 100644 src/ui/adddeployerdialog.h create mode 100644 src/ui/adddeployerdialog.ui create mode 100644 src/ui/addmoddialog.cpp create mode 100644 src/ui/addmoddialog.h create mode 100644 src/ui/addmoddialog.ui create mode 100644 src/ui/addprofiledialog.cpp create mode 100644 src/ui/addprofiledialog.h create mode 100644 src/ui/addprofiledialog.ui create mode 100644 src/ui/addtodeployerdialog.cpp create mode 100644 src/ui/addtodeployerdialog.h create mode 100644 src/ui/addtodeployerdialog.ui create mode 100644 src/ui/addtogroupdialog.cpp create mode 100644 src/ui/addtogroupdialog.h create mode 100644 src/ui/addtogroupdialog.ui create mode 100644 src/ui/addtooldialog.cpp create mode 100644 src/ui/addtooldialog.h create mode 100644 src/ui/addtooldialog.ui create mode 100644 src/ui/applicationmanager.cpp create mode 100644 src/ui/applicationmanager.h create mode 100644 src/ui/backuplistmodel.cpp create mode 100644 src/ui/backuplistmodel.h create mode 100644 src/ui/backuplistview.cpp create mode 100644 src/ui/backuplistview.h create mode 100644 src/ui/backupnamedelegate.cpp create mode 100644 src/ui/backupnamedelegate.h create mode 100644 src/ui/changeapipwdialog.cpp create mode 100644 src/ui/changeapipwdialog.h create mode 100644 src/ui/changeapipwdialog.ui create mode 100644 src/ui/colors.h create mode 100644 src/ui/conflictsmodel.cpp create mode 100644 src/ui/conflictsmodel.h create mode 100644 src/ui/deployerlistmodel.cpp create mode 100644 src/ui/deployerlistmodel.h create mode 100644 src/ui/deployerlistproxymodel.cpp create mode 100644 src/ui/deployerlistproxymodel.h create mode 100644 src/ui/deployerlistview.cpp create mode 100644 src/ui/deployerlistview.h create mode 100644 src/ui/editautotagsdialog.cpp create mode 100644 src/ui/editautotagsdialog.h create mode 100644 src/ui/editautotagsdialog.ui create mode 100644 src/ui/editmanualtagsdialog.cpp create mode 100644 src/ui/editmanualtagsdialog.h create mode 100644 src/ui/editmanualtagsdialog.ui create mode 100644 src/ui/editmodsourcesdialog.cpp create mode 100644 src/ui/editmodsourcesdialog.h create mode 100644 src/ui/editmodsourcesdialog.ui create mode 100644 src/ui/enterapipwdialog.cpp create mode 100644 src/ui/enterapipwdialog.h create mode 100644 src/ui/enterapipwdialog.ui create mode 100644 src/ui/fomodcheckbox.cpp create mode 100644 src/ui/fomodcheckbox.h create mode 100644 src/ui/fomoddialog.cpp create mode 100644 src/ui/fomoddialog.h create mode 100644 src/ui/fomoddialog.ui create mode 100644 src/ui/fomodradiobutton.cpp create mode 100644 src/ui/fomodradiobutton.h create mode 100644 src/ui/importfromsteamdialog.cpp create mode 100644 src/ui/importfromsteamdialog.h create mode 100644 src/ui/importfromsteamdialog.ui create mode 100644 src/ui/ipcclient.cpp create mode 100644 src/ui/ipcclient.h create mode 100644 src/ui/ipcserver.cpp create mode 100644 src/ui/ipcserver.h create mode 100644 src/ui/mainwindow.cpp create mode 100644 src/ui/mainwindow.h create mode 100644 src/ui/mainwindow.ui create mode 100644 src/ui/managemodtagsdialog.cpp create mode 100644 src/ui/managemodtagsdialog.h create mode 100644 src/ui/managemodtagsdialog.ui create mode 100644 src/ui/modlistmodel.cpp create mode 100644 src/ui/modlistmodel.h create mode 100644 src/ui/modlistproxymodel.cpp create mode 100644 src/ui/modlistproxymodel.h create mode 100644 src/ui/modlistview.cpp create mode 100644 src/ui/modlistview.h create mode 100644 src/ui/modnamedelegate.cpp create mode 100644 src/ui/modnamedelegate.h create mode 100644 src/ui/movemoddialog.cpp create mode 100644 src/ui/movemoddialog.h create mode 100644 src/ui/movemoddialog.ui create mode 100644 src/ui/nexusmoddialog.cpp create mode 100644 src/ui/nexusmoddialog.h create mode 100644 src/ui/nexusmoddialog.ui create mode 100644 src/ui/overwritebackupdialog.cpp create mode 100644 src/ui/overwritebackupdialog.h create mode 100644 src/ui/overwritebackupdialog.ui create mode 100644 src/ui/passwordfield.cpp create mode 100644 src/ui/passwordfield.h create mode 100644 src/ui/settingsdialog.cpp create mode 100644 src/ui/settingsdialog.h create mode 100644 src/ui/settingsdialog.ui create mode 100644 src/ui/tablecelldelegate.cpp create mode 100644 src/ui/tablecelldelegate.h create mode 100644 src/ui/tablepushbutton.cpp create mode 100644 src/ui/tablepushbutton.h create mode 100644 src/ui/tabletoolbutton.cpp create mode 100644 src/ui/tabletoolbutton.h create mode 100644 src/ui/tagcheckbox.cpp create mode 100644 src/ui/tagcheckbox.h create mode 100644 src/ui/validatinglineedit.cpp create mode 100644 src/ui/validatinglineedit.h create mode 100644 src/ui/versionboxdelegate.cpp create mode 100644 src/ui/versionboxdelegate.h create mode 100644 tests/CMakeLists.txt create mode 100644 tests/data/app/.a.lmmbakman.json create mode 100644 tests/data/app/0.txt create mode 100644 tests/data/app/a-Fil _3 create mode 100644 tests/data/app/a.1.lmmbakman/2.txt create mode 100644 tests/data/app/a.1.lmmbakman/a-Fil _3 create mode 100644 tests/data/app/a.1.lmmbakman/file.cfg create mode 100644 tests/data/app/a/2.txt create mode 100644 tests/data/app/a/a-Fil _3 create mode 100644 tests/data/app/a/file.cfg create mode 100644 tests/data/app/b/3aBc create mode 100644 tests/data/app/c/0 create mode 100644 tests/data/app/c/wasd create mode 100644 tests/data/source/0/0.txt create mode 100644 tests/data/source/0/1.txt create mode 100644 tests/data/source/0/a-Fil _3 create mode 100644 tests/data/source/0/a/0.txt create mode 100644 tests/data/source/0/a/2.txt create mode 100644 tests/data/source/0/a/b/1.txt create mode 100644 tests/data/source/0/a/b/2.txt create mode 100644 tests/data/source/0/b/3 create mode 100644 tests/data/source/0/b/3aBc create mode 100644 tests/data/source/1/6 create mode 100644 tests/data/source/1/7 create mode 100644 tests/data/source/1/f/b c.t create mode 100644 tests/data/source/1/f/g/0 create mode 100644 tests/data/source/2/0 create mode 100644 tests/data/source/2/0.txt create mode 100644 tests/data/source/2/a/1.txt create mode 100644 tests/data/source/2/a/b/2.txt create mode 100644 tests/data/source/2/b/3 create mode 100644 tests/data/source/app/0.txt create mode 100644 tests/data/source/app/a-Fil _3 create mode 100644 tests/data/source/app/a/2.txt create mode 100644 tests/data/source/app/a/file.cfg create mode 100644 tests/data/source/app/b/3aBc create mode 100644 tests/data/source/app/c/0 create mode 100644 tests/data/source/app/c/wasd create mode 100644 tests/data/source/auto_tags/0/asd create mode 100644 tests/data/source/auto_tags/0/dir/12argdar3wahtdh create mode 100644 tests/data/source/auto_tags/0/dir/abc/a create mode 100644 tests/data/source/auto_tags/0/dir/abc/abc_123 create mode 100644 tests/data/source/auto_tags/0/dir/s.txt create mode 100644 tests/data/source/auto_tags/0/dir/some_12_file_abc create mode 100644 tests/data/source/auto_tags/0/rw3 create mode 100644 tests/data/source/auto_tags/1/asd create mode 100644 tests/data/source/auto_tags/1/dir/not_an_image.png create mode 100644 tests/data/source/auto_tags/1/dir/stxt create mode 100644 tests/data/source/auto_tags/1/rw3 create mode 100644 tests/data/source/auto_tags/2/j/A_file create mode 100644 tests/data/source/auto_tags/2/qwert create mode 100644 tests/data/source/auto_tags/2/unique_file create mode 100644 tests/data/source/bak_man/a-Fil _3 create mode 100644 tests/data/source/bak_man/file.cfg create mode 100644 tests/data/source/case_matching/0/0.txt create mode 100644 tests/data/source/case_matching/0/0file create mode 100644 tests/data/source/case_matching/0/1.txt create mode 100644 tests/data/source/case_matching/0/a-Fil _3 create mode 100644 tests/data/source/case_matching/0/b/3aBc create mode 100644 tests/data/source/case_matching/0/new_dir/aB/someFile.txt create mode 100644 tests/data/source/case_matching/0/new_dir/new_file create mode 100644 tests/data/source/case_matching/1/0.txt create mode 100644 tests/data/source/case_matching/1/1.txt create mode 100644 tests/data/source/case_matching/1/123/456/789 create mode 100644 tests/data/source/case_matching/1/a-Fil _3 create mode 100644 tests/data/source/case_matching/1/b/3aBc create mode 100644 tests/data/source/case_matching/1/new_dir/aB/someFile.txt create mode 100644 tests/data/source/case_matching/1/new_dir/new_file create mode 100644 tests/data/source/case_matching/orig_0/0.TxT create mode 100644 tests/data/source/case_matching/orig_0/0file create mode 100644 tests/data/source/case_matching/orig_0/1.txt create mode 100644 tests/data/source/case_matching/orig_0/B/3abc create mode 100644 tests/data/source/case_matching/orig_0/a-fil _3 create mode 100644 tests/data/source/case_matching/orig_0/new_dir/aB/someFile.txt create mode 100644 tests/data/source/case_matching/orig_0/new_dir/new_file create mode 100644 tests/data/source/case_matching/orig_1/0.TxT create mode 100644 tests/data/source/case_matching/orig_1/1.txt create mode 100644 tests/data/source/case_matching/orig_1/123/456/789 create mode 100644 tests/data/source/case_matching/orig_1/B/3abc create mode 100644 tests/data/source/case_matching/orig_1/a-fil _3 create mode 100644 tests/data/source/case_matching/orig_1/neW_dir/NeW_fiLe create mode 100644 tests/data/source/case_matching/orig_1/neW_dir/ab/SOMefile.txt create mode 100644 tests/data/source/conflicts/0/0 create mode 100644 tests/data/source/conflicts/1/2 create mode 100644 tests/data/source/conflicts/2/0 create mode 100644 tests/data/source/conflicts/2/2 create mode 100644 tests/data/source/conflicts/3/3 create mode 100644 tests/data/source/conflicts/4/4 create mode 100644 tests/data/source/conflicts/5/2 create mode 100644 tests/data/source/conflicts/5/3 create mode 100644 tests/data/source/conflicts/5/5 create mode 100644 tests/data/source/conflicts/6/4 create mode 100644 tests/data/source/conflicts/6/6 create mode 100644 tests/data/source/conflicts/7/7 create mode 100644 tests/data/source/fomod/another_example.plugin create mode 100644 tests/data/source/fomod/example.plugin create mode 100644 tests/data/source/fomod/fomod/matrix.xml create mode 100644 tests/data/source/fomod/fomod/simple.xml create mode 100644 tests/data/source/fomod/fomod/steps.xml create mode 100644 tests/data/source/fomod/texture_red_b create mode 100644 tests/data/source/loot/loadorder.txt create mode 100644 tests/data/source/loot/plugins.txt create mode 100644 tests/data/source/mod0.tar.gz create mode 100644 tests/data/source/mod1.zip create mode 100644 tests/data/source/mod2.tar.gz create mode 100644 tests/data/source/split/mod/123 create mode 100644 tests/data/source/split/mod/D/d.txt create mode 100644 tests/data/source/split/mod/a/B/123/123.txt create mode 100644 tests/data/source/split/mod/a/B/wer create mode 100644 tests/data/source/split/mod/a/C/ghj create mode 100644 tests/data/source/split/mod/a/abc create mode 100644 tests/data/target/bak_man/change_bak/.a-Fil _3.lmmbakman.json create mode 100644 tests/data/target/bak_man/change_bak/.a.lmmbakman.json create mode 100644 tests/data/target/bak_man/change_bak/0.txt create mode 100644 tests/data/target/bak_man/change_bak/a-Fil _3 create mode 100644 tests/data/target/bak_man/change_bak/a-Fil _3.0.lmmbakman create mode 100644 tests/data/target/bak_man/change_bak/a.0.lmmbakman/file.cfg create mode 100644 tests/data/target/bak_man/change_bak/a/2.txt create mode 100644 tests/data/target/bak_man/change_bak/a/file.cfg create mode 100644 tests/data/target/bak_man/change_bak/b/3aBc create mode 100644 tests/data/target/bak_man/change_bak/c/0 create mode 100644 tests/data/target/bak_man/change_bak/c/wasd create mode 100644 tests/data/target/bak_man/create_bak/.0.txt.lmmbakman.json create mode 100644 tests/data/target/bak_man/create_bak/.a.lmmbakman.json create mode 100644 tests/data/target/bak_man/create_bak/0.txt create mode 100644 tests/data/target/bak_man/create_bak/0.txt.1.lmmbakman create mode 100644 tests/data/target/bak_man/create_bak/a-Fil _3 create mode 100644 tests/data/target/bak_man/create_bak/a.1.lmmbakman/2.txt create mode 100644 tests/data/target/bak_man/create_bak/a.1.lmmbakman/file.cfg create mode 100644 tests/data/target/bak_man/create_bak/a/2.txt create mode 100644 tests/data/target/bak_man/create_bak/a/file.cfg create mode 100644 tests/data/target/bak_man/create_bak/b/3aBc create mode 100644 tests/data/target/bak_man/create_bak/c/0 create mode 100644 tests/data/target/bak_man/create_bak/c/wasd create mode 100644 tests/data/target/bak_man/invalid_state/.a-Fil _3.lmmbakman.json create mode 100644 tests/data/target/bak_man/invalid_state/.a.lmmbakman.json create mode 100644 tests/data/target/bak_man/invalid_state/0.txt create mode 100644 tests/data/target/bak_man/invalid_state/a-Fil _3 create mode 100644 tests/data/target/bak_man/invalid_state/a.0.lmmbakman/2.txt create mode 100644 tests/data/target/bak_man/invalid_state/a.0.lmmbakman/file.cfg create mode 100644 tests/data/target/bak_man/invalid_state/a.1.lmmbakman/2.txt create mode 100644 tests/data/target/bak_man/invalid_state/a.15.lmmbakmanOLD/2.txt create mode 100644 tests/data/target/bak_man/invalid_state/a.15.lmmbakmanOLD/file.cfg create mode 100644 tests/data/target/bak_man/invalid_state/a.3.lmmbakman/2.txt create mode 100644 tests/data/target/bak_man/invalid_state/a.3.lmmbakman/file.cfg create mode 100644 tests/data/target/bak_man/invalid_state/a.3.lmmbakman/newfile create mode 100644 tests/data/target/bak_man/invalid_state/a.8.lmmbakmanOLD/2.txt create mode 100644 tests/data/target/bak_man/invalid_state/a.8.lmmbakmanOLD/file.cfg create mode 100644 tests/data/target/bak_man/invalid_state/a/2.txt create mode 100644 tests/data/target/bak_man/invalid_state/a/file.cfg create mode 100644 tests/data/target/bak_man/invalid_state/b/3aBc create mode 100644 tests/data/target/bak_man/invalid_state/c/0 create mode 100644 tests/data/target/bak_man/invalid_state/c/wasd create mode 100644 tests/data/target/bak_man/overwrite0/2.txt create mode 100644 tests/data/target/bak_man/overwrite1/2.txt create mode 100644 tests/data/target/bak_man/overwrite1/a-Fil _3 create mode 100644 tests/data/target/bak_man/overwrite1/file.cfg create mode 100644 tests/data/target/bak_man/profiles_0/.a-Fil _3.lmmbakman.json create mode 100644 tests/data/target/bak_man/profiles_0/.a.lmmbakman.json create mode 100644 tests/data/target/bak_man/profiles_0/0.txt create mode 100644 tests/data/target/bak_man/profiles_0/a-Fil _3 create mode 100644 tests/data/target/bak_man/profiles_0/a-Fil _3.0.lmmbakman create mode 100644 tests/data/target/bak_man/profiles_0/a.0.lmmbakman/2.txt create mode 100644 tests/data/target/bak_man/profiles_0/a.0.lmmbakman/file.cfg create mode 100644 tests/data/target/bak_man/profiles_0/a.1.lmmbakman/2.txt create mode 100644 tests/data/target/bak_man/profiles_0/a.3.lmmbakman/file.cfg create mode 100644 tests/data/target/bak_man/profiles_0/a.4.lmmbakman/2.txt create mode 100644 tests/data/target/bak_man/profiles_0/a.4.lmmbakman/file.cfg create mode 100644 tests/data/target/bak_man/profiles_0/a.4.lmmbakman/newfile create mode 100644 tests/data/target/bak_man/profiles_0/a/2.txt create mode 100644 tests/data/target/bak_man/profiles_0/a/file.cfg create mode 100644 tests/data/target/bak_man/profiles_0/b/3aBc create mode 100644 tests/data/target/bak_man/profiles_0/c/0 create mode 100644 tests/data/target/bak_man/profiles_0/c/wasd create mode 100644 tests/data/target/bak_man/profiles_1/.a-Fil _3.lmmbakman.json create mode 100644 tests/data/target/bak_man/profiles_1/.a.lmmbakman.json create mode 100644 tests/data/target/bak_man/profiles_1/0.txt create mode 100644 tests/data/target/bak_man/profiles_1/a-Fil _3 create mode 100644 tests/data/target/bak_man/profiles_1/a-Fil _3.1.lmmbakman create mode 100644 tests/data/target/bak_man/profiles_1/a.0.lmmbakman/2.txt create mode 100644 tests/data/target/bak_man/profiles_1/a.0.lmmbakman/file.cfg create mode 100644 tests/data/target/bak_man/profiles_1/a.2.lmmbakman/2.txt create mode 100644 tests/data/target/bak_man/profiles_1/a.2.lmmbakman/file.cfg create mode 100644 tests/data/target/bak_man/profiles_1/a.3.lmmbakman/file.cfg create mode 100644 tests/data/target/bak_man/profiles_1/a.4.lmmbakman/2.txt create mode 100644 tests/data/target/bak_man/profiles_1/a.4.lmmbakman/file.cfg create mode 100644 tests/data/target/bak_man/profiles_1/a.4.lmmbakman/newfile create mode 100644 tests/data/target/bak_man/profiles_1/a/2.txt create mode 100644 tests/data/target/bak_man/profiles_1/b/3aBc create mode 100644 tests/data/target/bak_man/profiles_1/c/0 create mode 100644 tests/data/target/bak_man/profiles_1/c/wasd create mode 100644 tests/data/target/bak_man/profiles_2/.a-Fil _3.lmmbakman.json create mode 100644 tests/data/target/bak_man/profiles_2/.a.lmmbakman.json create mode 100644 tests/data/target/bak_man/profiles_2/0.txt create mode 100644 tests/data/target/bak_man/profiles_2/a-Fil _3 create mode 100644 tests/data/target/bak_man/profiles_2/a-Fil _3.1.lmmbakman create mode 100644 tests/data/target/bak_man/profiles_2/a.1.lmmbakman/2.txt create mode 100644 tests/data/target/bak_man/profiles_2/a.2.lmmbakman/2.txt create mode 100644 tests/data/target/bak_man/profiles_2/a.2.lmmbakman/file.cfg create mode 100644 tests/data/target/bak_man/profiles_2/a.3.lmmbakman/file.cfg create mode 100644 tests/data/target/bak_man/profiles_2/a.4.lmmbakman/2.txt create mode 100644 tests/data/target/bak_man/profiles_2/a.4.lmmbakman/file.cfg create mode 100644 tests/data/target/bak_man/profiles_2/a.4.lmmbakman/newfile create mode 100644 tests/data/target/bak_man/profiles_2/a/2.txt create mode 100644 tests/data/target/bak_man/profiles_2/a/file.cfg create mode 100644 tests/data/target/bak_man/profiles_2/b/3aBc create mode 100644 tests/data/target/bak_man/profiles_2/c/0 create mode 100644 tests/data/target/bak_man/profiles_2/c/wasd create mode 100644 tests/data/target/bak_man/remove_bak_0/.a.lmmbakman.json create mode 100644 tests/data/target/bak_man/remove_bak_0/0.txt create mode 100644 tests/data/target/bak_man/remove_bak_0/a-Fil _3 create mode 100644 tests/data/target/bak_man/remove_bak_0/a.1.lmmbakman/2.txt create mode 100644 tests/data/target/bak_man/remove_bak_0/a.2.lmmbakman/file.cfg create mode 100644 tests/data/target/bak_man/remove_bak_0/a.3.lmmbakman/2.txt create mode 100644 tests/data/target/bak_man/remove_bak_0/a.3.lmmbakman/file.cfg create mode 100644 tests/data/target/bak_man/remove_bak_0/a.3.lmmbakman/newfile create mode 100644 tests/data/target/bak_man/remove_bak_0/a/2.txt create mode 100644 tests/data/target/bak_man/remove_bak_0/a/file.cfg create mode 100644 tests/data/target/bak_man/remove_bak_0/b/3aBc create mode 100644 tests/data/target/bak_man/remove_bak_0/c/0 create mode 100644 tests/data/target/bak_man/remove_bak_0/c/wasd create mode 100644 tests/data/target/bak_man/remove_bak_1/.a.lmmbakman.json create mode 100644 tests/data/target/bak_man/remove_bak_1/0.txt create mode 100644 tests/data/target/bak_man/remove_bak_1/a-Fil _3 create mode 100644 tests/data/target/bak_man/remove_bak_1/a.1.lmmbakman/file.cfg create mode 100644 tests/data/target/bak_man/remove_bak_1/a.2.lmmbakman/2.txt create mode 100644 tests/data/target/bak_man/remove_bak_1/a.2.lmmbakman/file.cfg create mode 100644 tests/data/target/bak_man/remove_bak_1/a.2.lmmbakman/newfile create mode 100644 tests/data/target/bak_man/remove_bak_1/a/2.txt create mode 100644 tests/data/target/bak_man/remove_bak_1/b/3aBc create mode 100644 tests/data/target/bak_man/remove_bak_1/c/0 create mode 100644 tests/data/target/bak_man/remove_bak_1/c/wasd create mode 100644 tests/data/target/bak_man/remove_bak_2/.a.lmmbakman.json create mode 100644 tests/data/target/bak_man/remove_bak_2/0.txt create mode 100644 tests/data/target/bak_man/remove_bak_2/a-Fil _3 create mode 100644 tests/data/target/bak_man/remove_bak_2/a.1.lmmbakman/2.txt create mode 100644 tests/data/target/bak_man/remove_bak_2/a.1.lmmbakman/file.cfg create mode 100644 tests/data/target/bak_man/remove_bak_2/a.1.lmmbakman/newfile create mode 100644 tests/data/target/bak_man/remove_bak_2/a/2.txt create mode 100644 tests/data/target/bak_man/remove_bak_2/b/3aBc create mode 100644 tests/data/target/bak_man/remove_bak_2/c/0 create mode 100644 tests/data/target/bak_man/remove_bak_2/c/wasd create mode 100644 tests/data/target/bak_man/remove_target/.a-Fil _3.lmmbakman.json create mode 100644 tests/data/target/bak_man/remove_target/0.txt create mode 100644 tests/data/target/bak_man/remove_target/a-Fil _3 create mode 100644 tests/data/target/bak_man/remove_target/a-Fil _3.1.lmmbakman create mode 100644 tests/data/target/bak_man/remove_target/a/2.txt create mode 100644 tests/data/target/bak_man/remove_target/a/file.cfg create mode 100644 tests/data/target/bak_man/remove_target/b/3aBc create mode 100644 tests/data/target/bak_man/remove_target/c/0 create mode 100644 tests/data/target/bak_man/remove_target/c/wasd create mode 100644 tests/data/target/case_matching/0/0.txt create mode 100644 tests/data/target/case_matching/0/0file create mode 100644 tests/data/target/case_matching/0/1.txt create mode 100644 tests/data/target/case_matching/0/a-Fil _3 create mode 100644 tests/data/target/case_matching/0/b/3aBc create mode 100644 tests/data/target/case_matching/0/new_dir/aB/someFile.txt create mode 100644 tests/data/target/case_matching/0/new_dir/new_file create mode 100644 tests/data/target/case_matching/1/0.txt create mode 100644 tests/data/target/case_matching/1/1.txt create mode 100644 tests/data/target/case_matching/1/123/456/789 create mode 100644 tests/data/target/case_matching/1/a-Fil _3 create mode 100644 tests/data/target/case_matching/1/b/3aBc create mode 100644 tests/data/target/case_matching/1/new_dir/aB/someFile.txt create mode 100644 tests/data/target/case_matching/1/new_dir/new_file create mode 100644 tests/data/target/case_matching/123/456/789 create mode 100644 tests/data/target/loot/profiles/.lmmconfig create mode 100644 tests/data/target/loot/profiles/.loadorder.txt.lmmprof0 create mode 100644 tests/data/target/loot/profiles/.loadorder.txt.lmmprof1 create mode 100644 tests/data/target/loot/profiles/.plugins.txt.lmmprof0 create mode 100644 tests/data/target/loot/profiles/.plugins.txt.lmmprof1 create mode 100644 tests/data/target/loot/profiles/loadorder.txt create mode 100644 tests/data/target/loot/profiles/plugins.txt create mode 100644 tests/data/target/loot/source/Morrowind.esm create mode 100644 tests/data/target/loot/source/a.esp create mode 100644 tests/data/target/loot/source/c.esp create mode 100644 tests/data/target/loot/source/d.esp create mode 100644 tests/data/target/loot/target/.lmmconfig create mode 100644 tests/data/target/loot/target/.loadorder.txt.lmmprof0 create mode 100644 tests/data/target/loot/target/.loadorder.txt.lmmprof1 create mode 100644 tests/data/target/loot/target/.plugins.txt.lmmprof0 create mode 100644 tests/data/target/loot/target/.plugins.txt.lmmprof1 create mode 100644 tests/data/target/loot/target/loadorder.txt create mode 100644 tests/data/target/loot/target/plugins.txt create mode 100644 tests/data/target/lower/0.txt create mode 100644 tests/data/target/lower/1.txt create mode 100644 tests/data/target/lower/a-fil _3 create mode 100644 tests/data/target/lower/a/0.txt create mode 100644 tests/data/target/lower/a/2.txt create mode 100644 tests/data/target/lower/a/b/1.txt create mode 100644 tests/data/target/lower/a/b/2.txt create mode 100644 tests/data/target/lower/b/3 create mode 100644 tests/data/target/lower/b/3abc create mode 100644 tests/data/target/mod012/0 create mode 100644 tests/data/target/mod012/0.txt create mode 100644 tests/data/target/mod012/0.txt.lmmbak create mode 100644 tests/data/target/mod012/1.txt create mode 100644 tests/data/target/mod012/6 create mode 100644 tests/data/target/mod012/7 create mode 100644 tests/data/target/mod012/a-Fil _3 create mode 100644 tests/data/target/mod012/a-Fil _3.lmmbak create mode 100644 tests/data/target/mod012/a/0.txt create mode 100644 tests/data/target/mod012/a/1.txt create mode 100644 tests/data/target/mod012/a/2.txt create mode 100644 tests/data/target/mod012/a/2.txt.lmmbak create mode 100644 tests/data/target/mod012/a/b/1.txt create mode 100644 tests/data/target/mod012/a/b/2.txt create mode 100644 tests/data/target/mod012/a/file.cfg create mode 100644 tests/data/target/mod012/b/3 create mode 100644 tests/data/target/mod012/b/3aBc create mode 100644 tests/data/target/mod012/b/3aBc.lmmbak create mode 100644 tests/data/target/mod012/c/0 create mode 100644 tests/data/target/mod012/c/wasd create mode 100644 tests/data/target/mod012/f/b c.t create mode 100644 tests/data/target/mod012/f/g/0 create mode 100644 tests/data/target/mod1/0.txt create mode 100644 tests/data/target/mod1/6 create mode 100644 tests/data/target/mod1/7 create mode 100644 tests/data/target/mod1/a-Fil _3 create mode 100644 tests/data/target/mod1/a/2.txt create mode 100644 tests/data/target/mod1/a/file.cfg create mode 100644 tests/data/target/mod1/b/3aBc create mode 100644 tests/data/target/mod1/c/0 create mode 100644 tests/data/target/mod1/c/wasd create mode 100644 tests/data/target/mod1/f/b c.t create mode 100644 tests/data/target/mod1/f/g/0 create mode 100644 tests/data/target/remove/simple/.lmm_mods.json.bak create mode 100644 tests/data/target/remove/simple/1/6 create mode 100644 tests/data/target/remove/simple/1/7 create mode 100644 tests/data/target/remove/simple/1/f/b c.t create mode 100644 tests/data/target/remove/simple/1/f/g/0 create mode 100644 tests/data/target/remove/simple/lmm_mods.json create mode 100644 tests/data/target/remove/version/.lmm_mods.json.bak create mode 100644 tests/data/target/remove/version/1/6 create mode 100644 tests/data/target/remove/version/1/7 create mode 100644 tests/data/target/remove/version/1/f/b c.t create mode 100644 tests/data/target/remove/version/1/f/g/0 create mode 100644 tests/data/target/remove/version/2/0.txt create mode 100644 tests/data/target/remove/version/2/1.txt create mode 100644 tests/data/target/remove/version/2/a-Fil _3 create mode 100644 tests/data/target/remove/version/2/a/0.txt create mode 100644 tests/data/target/remove/version/2/a/2.txt create mode 100644 tests/data/target/remove/version/2/a/b/1.txt create mode 100644 tests/data/target/remove/version/2/a/b/2.txt create mode 100644 tests/data/target/remove/version/2/b/3 create mode 100644 tests/data/target/remove/version/2/b/3aBc create mode 100644 tests/data/target/remove/version/lmm_mods.json create mode 100644 tests/data/target/root_level/0/0.txt create mode 100644 tests/data/target/root_level/0/1.txt create mode 100644 tests/data/target/root_level/0/a-Fil _3 create mode 100644 tests/data/target/root_level/0/a/0.txt create mode 100644 tests/data/target/root_level/0/a/2.txt create mode 100644 tests/data/target/root_level/0/a/b/1.txt create mode 100644 tests/data/target/root_level/0/a/b/2.txt create mode 100644 tests/data/target/root_level/0/b/3 create mode 100644 tests/data/target/root_level/0/b/3aBc create mode 100644 tests/data/target/root_level/1/0.txt create mode 100644 tests/data/target/root_level/1/2.txt create mode 100644 tests/data/target/root_level/1/3 create mode 100644 tests/data/target/root_level/1/3aBc create mode 100644 tests/data/target/root_level/1/b/1.txt create mode 100644 tests/data/target/root_level/1/b/2.txt create mode 100644 tests/data/target/root_level/2/1.txt create mode 100644 tests/data/target/root_level/2/2.txt create mode 100644 tests/data/target/single_dir/0.txt create mode 100644 tests/data/target/single_dir/1.txt create mode 100644 tests/data/target/single_dir/2.txt create mode 100644 tests/data/target/single_dir/3 create mode 100644 tests/data/target/single_dir/3aBc create mode 100644 tests/data/target/single_dir/a-Fil _3 create mode 100644 tests/data/target/split/0/123 create mode 100644 tests/data/target/split/0/D/d.txt create mode 100644 tests/data/target/split/1/abc create mode 100644 tests/data/target/split/2/wer create mode 100644 tests/data/target/split/3/123.txt create mode 100644 tests/data/target/split/4/ghj create mode 100644 tests/data/target/upper/0.TXT create mode 100644 tests/data/target/upper/1.TXT create mode 100644 tests/data/target/upper/A-FIL _3 create mode 100644 tests/data/target/upper/A/0.TXT create mode 100644 tests/data/target/upper/A/2.TXT create mode 100644 tests/data/target/upper/A/B/1.TXT create mode 100644 tests/data/target/upper/A/B/2.TXT create mode 100644 tests/data/target/upper/B/3 create mode 100644 tests/data/target/upper/B/3ABC create mode 100644 tests/data/target/upper_single/0.TXT create mode 100644 tests/data/target/upper_single/1.TXT create mode 100644 tests/data/target/upper_single/2.TXT create mode 100644 tests/data/target/upper_single/3 create mode 100644 tests/data/target/upper_single/3ABC create mode 100644 tests/data/target/upper_single/A-FIL _3 create mode 100644 tests/data/target/vanilla/0.txt create mode 100644 tests/data/target/vanilla/a-Fil _3 create mode 100644 tests/data/target/vanilla/a/2.txt create mode 100644 tests/data/target/vanilla/a/file.cfg create mode 100644 tests/data/target/vanilla/b/3aBc create mode 100644 tests/data/target/vanilla/c/0 create mode 100644 tests/data/target/vanilla/c/wasd create mode 100644 tests/test_backupmanager.cpp create mode 100644 tests/test_cryptography.cpp create mode 100644 tests/test_deployer.cpp create mode 100644 tests/test_fomodinstaller.cpp create mode 100644 tests/test_installer.cpp create mode 100644 tests/test_lootdeployer.cpp create mode 100644 tests/test_moddedapplication.cpp create mode 100644 tests/test_tagconditionnode.cpp create mode 100644 tests/test_utils.cpp create mode 100644 tests/test_utils.h.in create mode 100644 tests/tests.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2b4809 --- /dev/null +++ b/.gitignore @@ -0,0 +1,79 @@ +# This file is used to ignore files which are generated +# ---------------------------------------------------------------------------- + +*~ +*.autosave +*.a +*.core +*.moc +*.o +*.obj +*.orig +*.rej +*.so +*.so.* +*_pch.h.cpp +*_resource.rc +*.qm +.#* +*.*# +core +!core/ +tags +.DS_Store +.directory +*.debug +Makefile* +*.prl +*.app +moc_*.cpp +ui_*.h +qrc_*.cpp +Thumbs.db +*.res +*.rc +/.qmake.cache +/.qmake.stash + +# qtcreator generated files +*.pro.user* + +# xemacs temporary files +*.flc + +# Vim temporary files +.*.swp + +# Visual Studio generated files +*.ib_pdb_index +*.idb +*.ilk +*.pdb +*.sln +*.suo +*.vcproj +*vcproj.*.*.user +*.ncb +*.sdf +*.opensdf +*.vcxproj +*vcxproj.* + +# MinGW generated files +*.Debug +*.Release + +# Python byte code +*.pyc + +# Binaries +# -------- +*.dll +*.exe + +# Builds +build +tests/build + +# docs +doc diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..5844377 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,246 @@ +cmake_minimum_required(VERSION 3.25) + +project(Limo VERSION 1.0 LANGUAGES CXX) + +set(CMAKE_INCLUDE_CURRENT_DIR ON) + +set(CMAKE_AUTOUIC ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# jsoncpp +find_package(PkgConfig REQUIRED) +pkg_check_modules(JSONCPP jsoncpp) + +# libarchive +find_package(LibArchive REQUIRED) + +# pugixml +pkg_check_modules(PUGIXML pugixml) +find_package(pugixml REQUIRED) + +# cpr +find_package(cpr REQUIRED) + +# OpenSSL +find_package(OpenSSL REQUIRED) + +# Qt +find_package(QT NAMES Qt5 REQUIRED COMPONENTS Widgets) +find_package(Qt5 REQUIRED COMPONENTS Widgets Svg Network) + +set(PROJECT_SOURCES + resources/icons.qrc + src/core/addmodinfo.h + src/core/appinfo.h + src/core/autotag.cpp + src/core/autotag.h + src/core/backupmanager.cpp + src/core/backupmanager.h + src/core/backuptarget.cpp + src/core/backuptarget.h + src/core/casematchingdeployer.cpp + src/core/casematchingdeployer.h + src/core/compressionerror.h + src/core/conflictinfo.h + src/core/cryptography.cpp + src/core/cryptography.h + src/core/deployer.cpp + src/core/deployer.h + src/core/deployerfactory.cpp + src/core/deployerfactory.h + src/core/deployerinfo.h + src/core/editapplicationinfo.h + src/core/editautotagaction.cpp + src/core/editautotagaction.h + src/core/editdeployerinfo.h + src/core/editmanualtagaction.cpp + src/core/editmanualtagaction.h + src/core/editprofileinfo.h + src/core/fomod/dependency.cpp + src/core/fomod/dependency.h + src/core/fomod/file.h + src/core/fomod/fomodinstaller.cpp + src/core/fomod/fomodinstaller.h + src/core/fomod/plugin.h + src/core/fomod/plugindependency.h + src/core/fomod/plugingroup.h + src/core/fomod/plugintype.h + src/core/importmodinfo.h + src/core/installer.cpp + src/core/installer.h + src/core/log.cpp + src/core/log.h + src/core/lootdeployer.cpp + src/core/lootdeployer.h + src/core/manualtag.cpp + src/core/manualtag.h + src/core/mod.cpp + src/core/mod.h + src/core/moddedapplication.cpp + src/core/moddedapplication.h + src/core/modinfo.h + src/core/nexus/api.cpp + src/core/nexus/api.h + src/core/nexus/file.cpp + src/core/nexus/file.h + src/core/nexus/mod.cpp + src/core/nexus/mod.h + src/core/parseerror.h + src/core/pathutils.cpp + src/core/pathutils.h + src/core/progressnode.cpp + src/core/progressnode.h + src/core/tag.cpp + src/core/tag.h + src/core/tagcondition.h + src/core/tagconditionnode.cpp + src/core/tagconditionnode.h + src/main.cpp + src/ui/addapikeydialog.cpp + src/ui/addapikeydialog.h + src/ui/addapikeydialog.ui + src/ui/addappdialog.cpp + src/ui/addappdialog.h + src/ui/addappdialog.ui + src/ui/addautotagdialog.cpp + src/ui/addautotagdialog.h + src/ui/addautotagdialog.ui + src/ui/addbackupdialog.cpp + src/ui/addbackupdialog.h + src/ui/addbackupdialog.ui + src/ui/addbackuptargetdialog.cpp + src/ui/addbackuptargetdialog.h + src/ui/addbackuptargetdialog.ui + src/ui/adddeployerdialog.cpp + src/ui/adddeployerdialog.h + src/ui/adddeployerdialog.ui + src/ui/addmoddialog.cpp + src/ui/addmoddialog.h + src/ui/addmoddialog.ui + src/ui/addprofiledialog.cpp + src/ui/addprofiledialog.h + src/ui/addprofiledialog.ui + src/ui/addtodeployerdialog.cpp + src/ui/addtodeployerdialog.h + src/ui/addtodeployerdialog.ui + src/ui/addtogroupdialog.cpp + src/ui/addtogroupdialog.h + src/ui/addtogroupdialog.ui + src/ui/addtooldialog.cpp + src/ui/addtooldialog.h + src/ui/addtooldialog.ui + src/ui/applicationmanager.cpp + src/ui/applicationmanager.h + src/ui/backuplistmodel.cpp + src/ui/backuplistmodel.h + src/ui/backuplistview.cpp + src/ui/backuplistview.h + src/ui/backupnamedelegate.cpp + src/ui/backupnamedelegate.h + src/ui/changeapipwdialog.cpp + src/ui/changeapipwdialog.h + src/ui/changeapipwdialog.ui + src/ui/colors.h + src/ui/conflictsmodel.cpp + src/ui/conflictsmodel.h + src/ui/deployerlistmodel.cpp + src/ui/deployerlistmodel.h + src/ui/deployerlistproxymodel.cpp + src/ui/deployerlistproxymodel.h + src/ui/deployerlistview.cpp + src/ui/deployerlistview.h + src/ui/editautotagsdialog.cpp + src/ui/editautotagsdialog.h + src/ui/editautotagsdialog.ui + src/ui/editmanualtagsdialog.cpp + src/ui/editmanualtagsdialog.h + src/ui/editmanualtagsdialog.ui + src/ui/editmodsourcesdialog.cpp + src/ui/editmodsourcesdialog.h + src/ui/editmodsourcesdialog.ui + src/ui/enterapipwdialog.cpp + src/ui/enterapipwdialog.h + src/ui/enterapipwdialog.ui + src/ui/fomodcheckbox.cpp + src/ui/fomodcheckbox.h + src/ui/fomoddialog.cpp + src/ui/fomoddialog.h + src/ui/fomoddialog.ui + src/ui/fomodradiobutton.cpp + src/ui/fomodradiobutton.h + src/ui/importfromsteamdialog.cpp + src/ui/importfromsteamdialog.h + src/ui/importfromsteamdialog.ui + src/ui/ipcclient.cpp + src/ui/ipcclient.h + src/ui/ipcserver.cpp + src/ui/ipcserver.h + src/ui/mainwindow.cpp + src/ui/mainwindow.h + src/ui/mainwindow.ui + src/ui/managemodtagsdialog.cpp + src/ui/managemodtagsdialog.h + src/ui/managemodtagsdialog.ui + src/ui/modlistmodel.cpp + src/ui/modlistmodel.h + src/ui/modlistproxymodel.cpp + src/ui/modlistproxymodel.h + src/ui/modlistview.cpp + src/ui/modlistview.h + src/ui/modnamedelegate.cpp + src/ui/modnamedelegate.h + src/ui/movemoddialog.cpp + src/ui/movemoddialog.h + src/ui/movemoddialog.ui + src/ui/nexusmoddialog.cpp + src/ui/nexusmoddialog.h + src/ui/nexusmoddialog.ui + src/ui/overwritebackupdialog.cpp + src/ui/overwritebackupdialog.h + src/ui/overwritebackupdialog.ui + src/ui/passwordfield.cpp + src/ui/passwordfield.h + src/ui/settingsdialog.cpp + src/ui/settingsdialog.h + src/ui/settingsdialog.ui + src/ui/tablecelldelegate.cpp + src/ui/tablecelldelegate.h + src/ui/tablepushbutton.cpp + src/ui/tablepushbutton.h + src/ui/tabletoolbutton.cpp + src/ui/tabletoolbutton.h + src/ui/tagcheckbox.cpp + src/ui/tagcheckbox.h + src/ui/validatinglineedit.cpp + src/ui/validatinglineedit.h + src/ui/versionboxdelegate.cpp + src/ui/versionboxdelegate.h +) + +add_executable(Limo + ${PROJECT_SOURCES}) + +target_include_directories(Limo + PRIVATE "${PROJECT_SOURCE_DIR}/src" + PRIVATE ${LibArchive_INCLUDE_DIRS} + PRIVATE /usr/include/loot + PRIVATE ${JSONCPP_INCLUDE_DIRS}) + +target_link_libraries(Limo + PRIVATE Qt${QT_VERSION_MAJOR}::Widgets + PRIVATE ${JSONCPP_LIBRARIES} + PRIVATE ${LibArchive_LIBRARIES} + PRIVATE ${PUGIXML_LIBRARIES} + PRIVATE libloot.so + PRIVATE Qt${QT_VERSION_MAJOR}::Svg + PRIVATE cpr::cpr + PRIVATE OpenSSL::SSL + PRIVATE Qt${QT_VERSION_MAJOR}::Network) + +install(TARGETS Limo + BUNDLE DESTINATION . + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 +. + + 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 +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..059bac7 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# Limo logo +--- + +General purpose mod manager primarily developed for Linux with support for the [NexusMods](https://www.nexusmods.com/) API and [LOOT](https://loot.github.io/). + +

+logo +

+## Features +--- + +- Multiple target directories per application +- Automatic adaptation of mod file names to prevent issues with case mismatches +- Auto-Tagging system for filtering +- Sort load order according to conflicts +- Import installed games from Steam +- Simple backup system +- LOOT integration: + - Manage installed plugins + - Automatically sort the load order + - Check for issues with installed plugins +- NexusMods API support: + - Check for mod updates + - View description, changelogs and available files + - Download mods through Limo + +***For a guide on how to use Limo, refer to the wiki.*** + +## Installation +--- + +### Build from source + +#### Install the dependencies + + - [Qt5](https://doc.qt.io/qt-5/index.html) + - [JsonCpp](https://github.com/open-source-parsers/jsoncpp) + - [libarchive](https://github.com/libarchive/libarchive) + - [pugixml](https://github.com/zeux/pugixml) + - [OpenSSL](https://github.com/openssl/openssl) + - [cpr](https://github.com/libcpr/cpr) + - [libloot](https://github.com/loot/libloot) + - (Optional, for tests) [Catch2](https://github.com/catchorg/Catch2) + - (Optional, for docs) [doxygen](https://github.com/doxygen/doxygen) + +On Debian based systems most dependencies, with the exception of cpr and libloot, can be installed with the following command: + +``` +sudo apt install \ + build-essential \ + cmake \ + git \ + libpugixml-dev \ + libjsoncpp-dev \ + libarchive-dev \ + pkg-config \ + libssl-dev \ + qtbase5-dev \ + qtchooser \ + qt5-qmake \ + qtbase5-dev-tools \ + libqt5svg5-dev \ + libbost-all-dev \ + libtbb-dev \ + cargo \ + cbindgen \ + catch2 \ + doxygen +``` + +#### Clone this repository: + +``` +git clone https://github.com/limo-app/limo.git +``` + +#### Build Limo: + +``` +mkdir build +cmake -DCMAKE_BUILD_TYPE=Release -S . -B build +cmake --build build +``` + +#### (Optional) Run the tests: + +``` +mkdir tests/build +cmake -DCMAKE_BUILD_TYPE=Release -S tests -B tests/build +cmake --build tests/build +tests/build/tests +``` + +#### (Optional) Build the documentation: + +``` +doxygen src/lmm_Doxyfile +``` diff --git a/resources/filter_accept.svg b/resources/filter_accept.svg new file mode 100644 index 0000000..e6303aa --- /dev/null +++ b/resources/filter_accept.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + diff --git a/resources/filter_reject.svg b/resources/filter_reject.svg new file mode 100644 index 0000000..d1ec05b --- /dev/null +++ b/resources/filter_reject.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + diff --git a/resources/icons.qrc b/resources/icons.qrc new file mode 100644 index 0000000..58401c6 --- /dev/null +++ b/resources/icons.qrc @@ -0,0 +1,7 @@ + + + filter_accept.svg + filter_reject.svg + logo.png + + diff --git a/resources/logo.png b/resources/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..2058f3d0df82c1b6340f6e8310205a9f5ed90fd7 GIT binary patch literal 34122 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�gTjg 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 IyD|L#26?eom;e9( literal 0 HcmV?d00001 diff --git a/resources/logo_small.png b/resources/logo_small.png new file mode 100644 index 0000000000000000000000000000000000000000..1df0d8c6f3b080334d5665167828847f4e52e813 GIT binary patch literal 7298 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 Kn6K}4@_zs;QpQOD literal 0 HcmV?d00001 diff --git a/resources/showcase.png b/resources/showcase.png new file mode 100644 index 0000000000000000000000000000000000000000..759cb9e0675a60e758c8a315f558aa94d62cd541 GIT binary patch literal 97283 zcmce;1yqz@yFNS?s0b)ZNeL1n-3*C8~?X*64-Yi--SRR*iw?B3J}Om6a;d8 z`8EbPqty89C-`y4T2d7Xf#9@U{ks;)fI|X-Jb_4wzEpD3+M026lSNXr?~T<;`O*5t zdMA%inf~4?KqkFU%sS4=%GzL6i-;P{8T20grB;aan;eTu0MO!V)u^|dp@{MIcP)!)OR&D{L{gFi=Tk9=dY z-`|5wVVCaj$Mk$3UHJcfW1RBeFCh89`4!S2Y;emk{%PBlmf_)tA3hN3ONoYco6=mI z*)wBkQB!mNR>6Ppz*#RwhPI}*#u|C}#4L)jtw^vX_t!Je>nwQI^VgNLM^ySE9$lnS zCb}Kd(ThA{KS0TMxlDTX)a0{=rIPZ*I>*yF2+i28P|NR3lM1=S6cn)PtBNYT2qxuw zrgdQg@dop=-?D$aPbJZpKq}%8wifeIUzNP6b1?Lk_ucE(!cQ9B%0A1@)5UYG-FtEu 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 h^{aokvOe!4D&rHW$k1 +#include +#include + + +/*! + * \brief Stores data needed to install a new mod. + */ +struct AddModInfo +{ + /*! \brief Name of the new mod. */ + std::string name; + /*! \brief Version of the new mod. */ + std::string version; + /*! \brief Installer type to be used. */ + std::string installer; + /*! \brief Path to the mods files. */ + std::string source_path; + /*! \brief Ids of deployers to which the new mod will be added. */ + std::vector deployers; + /*! \brief Id of the mod the group of which the new mod will be added to, or -1 for no group. */ + int group; + /*! \brief Flags for the installer. */ + int installer_flags; + /*! \brief If > 0: Remove path components with depth < root_level. */ + int root_level; + /*! \brief Contains pairs of source and destination paths for installation files. */ + std::vector> files; + /*! \brief If true: The newly installed mod will replace the mod specified in group. */ + bool replace_mod = false; + /*! \brief Path to the local archive or directory used to install this mod. */ + std::filesystem::path local_source = ""; + /*! \brief URL from where the mod was downloaded. */ + std::string remote_source = ""; +}; diff --git a/src/core/appinfo.h b/src/core/appinfo.h new file mode 100644 index 0000000..56879e8 --- /dev/null +++ b/src/core/appinfo.h @@ -0,0 +1,72 @@ +/*! + * \file appinfo.h + * \brief Contains the AppInfo struct. + */ + +#pragma once + +#include "tagcondition.h" +#include +#include +#include + + +/*! + * \brief Stores information about a ModdedApplication. + */ +struct AppInfo +{ + /*! \brief The \ref ModdedApplication "application's" name. */ + std::string name = ""; + /*! \brief The \ref ModdedApplication "application's" staging directory. */ + std::string staging_dir = ""; + /*! \brief Command used to run the \ref ModdedApplication "application". */ + std::string command = ""; + /*! \brief Number of installed mods of the \ref ModdedApplication "application". */ + int num_mods = 0; + /*! + * \brief Names of \ref Deployer "deployers" belonging to the + * \ref ModdedApplication "application". + */ + std::vector deployers{}; + /*! + * \brief Types of \ref Deployer "deployers" belonging to the + * \ref ModdedApplication "application". + */ + std::vector deployer_types{}; + /*! + * \brief Staging directory of \ref Deployer "deployers" belonging to the + * \ref ModdedApplication "application". + */ + std::vector target_dirs{}; + /*! + * \brief Number of mods for each \ref Deployer "deployer" belonging to the + * \ref ModdedApplication "application". + */ + std::vector deployer_mods{}; + /*! + * \brief One bool per deployer indicating whether file are copied for deployment. + */ + std::vector uses_copy_deployment{}; + /*! + * \brief Name and command for each tool belonging to the + * \ref ModdedApplication "application". + */ + std::vector> tools{}; + /*! + * \brief Maps the names of all manual tags to the number of mods with that tag in the + * \ref ModdedApplication "application". + */ + std::map num_mods_per_manual_tag; + /*! + * \brief Maps the names of all auto tags to the number of mods with that tag in the + * \ref ModdedApplication "application". + */ + std::map num_mods_per_auto_tag; + /*! + * \brief Maps all auto tag names to a pair of the expression used and a vector of Tagconditions. + */ + std::map>> auto_tags; + /*! \brief Version of the target application. */ + std::string app_version = ""; +}; diff --git a/src/core/autotag.cpp b/src/core/autotag.cpp new file mode 100644 index 0000000..3cf307b --- /dev/null +++ b/src/core/autotag.cpp @@ -0,0 +1,110 @@ +#include "autotag.h" +#include "parseerror.h" +#include +#include + +namespace sfs = std::filesystem; +namespace str = std::ranges; + + +AutoTag::AutoTag(const std::string& name, + const std::string& expression, + const std::vector& conditions) : + expression_(expression), conditions_(conditions), evaluator_(expression, conditions) +{ + name_ = name; +} + +AutoTag::AutoTag(const Json::Value& json) +{ + if(!json.isMember("name")) + throw ParseError("Tag name is missing."); + name_ = json["name"].asString(); + + if(json.isMember("mod_ids")) + { + for(const auto& mod : json["mod_ids"]) + mods_.push_back(mod.asInt()); + } + + if(!json.isMember("expression")) + throw ParseError("Auto-Tag expression is missing."); + expression_ = json["expression"].asString(); + + if(!json.isMember("conditions")) + throw ParseError("Auto-Tag conditions are missing."); + for(const auto& json_condition : json["conditions"]) + { + TagCondition condition; + if(!json_condition.isMember("invert")) + throw ParseError("Auto-Tag condition invert flag is missing."); + condition.invert = json_condition["invert"].asBool(); + + if(!json_condition.isMember("use_regex")) + throw ParseError("Auto-Tag condition use_regex flag is missing."); + condition.use_regex = json_condition["use_regex"].asBool(); + + if(!json_condition.isMember("search_string")) + throw ParseError("Auto-Tag search_string is missing."); + condition.search_string = json_condition["search_string"].asString(); + + if(!json_condition.isMember("condition_type")) + throw ParseError("Auto-Tag condition_type is missing."); + condition.condition_type = json_condition["condition_type"].asString() == "file_name" + ? TagCondition::Type::file_name + : TagCondition::Type::path; + conditions_.push_back(condition); + } + if(!TagConditionNode::expressionIsValid(expression_, conditions_.size())) + throw ParseError(std::format("Invalid auto tag expression \"{}\".", expression_)); + evaluator_ = TagConditionNode(expression_, conditions_); +} + +void AutoTag::setEvaluator(const std::string& expression, + const std::vector& conditions) +{ + expression_ = expression; + conditions_ = conditions; + evaluator_ = TagConditionNode(expression, conditions_); +} + +Json::Value AutoTag::toJson() const +{ + Json::Value json; + for(int i = 0; i < mods_.size(); i++) + json["mod_ids"][i] = mods_[i]; + + json["expression"] = expression_; + json["name"] = name_; + + for(const auto& [index, condition] : str::enumerate_view(conditions_)) + { + const int i = index; + json["conditions"][i]["invert"] = condition.invert; + json["conditions"][i]["use_regex"] = condition.use_regex; + json["conditions"][i]["search_string"] = condition.search_string; + json["conditions"][i]["condition_type"] = + condition.condition_type == TagCondition::Type::file_name ? "file_name" : "path"; + } + return json; +} + +bool AutoTag::operator==(const std::string& name) const +{ + return name_ == name; +} + +std::string AutoTag::getExpression() const +{ + return expression_; +} + +std::vector AutoTag::getConditions() const +{ + return conditions_; +} + +int AutoTag::getNumConditions() const +{ + return conditions_.size(); +} diff --git a/src/core/autotag.h b/src/core/autotag.h new file mode 100644 index 0000000..8c43de5 --- /dev/null +++ b/src/core/autotag.h @@ -0,0 +1,191 @@ +/*! + * \file autotag.h + * \brief Header for the AutoTag class. + */ + +#pragma once + +#include "pathutils.h" +#include "progressnode.h" +#include "tag.h" +#include "tagconditionnode.h" +#include +#include +#include +#include +#include + + +/*! + * \brief Tag which is automatically added to a mod when its files fulfill the tags conditions. + * Conditions are managed by a TagConditionNode object. + */ +class AutoTag : public Tag +{ +public: + /*! + * \brief Constructor. + * \param name Name of the new tag. + * \param expression Boolean expression used to combine the given conditions. The tag is applied + * to a mod when this evaluates to true. + * \param conditions Vector of conditions used to decide if this tag is to be applied. These + * act as variables in the tags expression. + */ + AutoTag(const std::string& name, + const std::string& expression, + const std::vector& conditions); + /*! + * \brief Deserializes an AutoTag from the given json object. + * \param json Source json object. + * \throws ParseError when the json object is invalid. + */ + AutoTag(const Json::Value& json); + + /*! + * \brief Removes this tag from all mods, then applies it to all given mods which + * fulfill its conditions. + * \param files Maps mod ids to a vector of pairs of paths and file names for that mod. + * \param mods Iterable container containing int ids of all mods to be checked. + * \param progress_node Used to inform about progress. + */ + template + void reapplyMods(const std::map>>& files, + const View& mods, + std::optional progress_node = {}) + { + mods_.clear(); + for(int mod : mods) + { + if(evaluator_.evaluate(files.at(mod))) + mods_.push_back(mod); + if(progress_node) + (*progress_node)->advance(); + } + } + /*! + * \brief Removes this tag from all mods, then applies it to all given mods which + * fulfill its conditions. + * \param staging_dir Directory containing the mods. + * \param mods Iterable container containing int ids of all mods to be checked. + * \param progress_node Used to inform about progress. + */ + template + void reapplyMods(const std::filesystem::path& staging_dir, + const View& mods, + std::optional progress_node = {}) + { + reapplyMods(readModFiles(staging_dir, mods), mods, progress_node); + } + /*! + * \brief Reevaluates if the given mods should have this tag. Adds/ removes the tag + * from all given mods when needed. + * \param files Maps mod ids to a vector of pairs of paths and file names for that mod. + * \param mods Iterable container containing int ids of all mods to be checked. + * \param progress_node Used to inform about progress. + */ + template + void updateMods(const std::map>>& files, + const View& mods, + std::optional progress_node = {}) + { + for(int mod : mods) + { + auto iter = std::ranges::find(mods_, mod); + if(iter != mods_.end()) + mods_.erase(iter); + if(evaluator_.evaluate(files.at(mod))) + mods_.push_back(mod); + if(progress_node) + (*progress_node)->advance(); + } + } + /*! + * \brief Reevaluates if the given mods should have this tag. Adds/ removes the tag + * from all given mods when needed. + * \param staging_dir Directory containing the mods. + * \param mods Iterable container containing int ids of all mods to be checked. + * \param progress_node Used to inform about progress. + */ + template + void updateMods(const std::filesystem::path& staging_dir, + const View& mods, + std::optional progress_node = {}) + { + updateMods(readModFiles(staging_dir, mods), mods, progress_node); + } + /*! + * \brief Changes the conditions and expression used by this tag. + * \param expression The new expression. + * \param conditions The new conditions. + */ + void setEvaluator(const std::string& expression, const std::vector& conditions); + /*! + * \brief Serializes this tag to a json object. + * \return The json object. + */ + Json::Value toJson() const; + /*! + * \brief Compares this tag by name to the given name. + * \param name Name to compare to. + * \return True if the names are identical. + */ + bool operator==(const std::string& name) const; + /*! + * \brief Getter for this tags expression. + * \return The expression. + */ + std::string getExpression() const; + /*! + * \brief Getter for this tags conditions. + * \return The conditions. + */ + std::vector getConditions() const; + /*! + * \brief Returns the number of conditions for this tag. + * \return The number of conditions. + */ + int getNumConditions() const; + /*! + * \brief Recursively iterates over all files for all mods with given ids and creates a + * a map of mod ids to a vector containing pairs of path and file name. + * This vector is used as input for the reapplyMods and updateMods functions. + * \param staging_dir Staging directory for the given mods. + * \param mods Iterable container containing int ids of all mods to be checked. + * \param progress_node Used to inform about progress. + * \return The map. + */ + template + static std::map>> readModFiles( + const std::filesystem::path& staging_dir, + View mods, + std::optional progress_node = {}) + { + std::map>> files; + for(int mod : mods) + { + files[mod] = {}; + const std::filesystem::path mod_path = staging_dir / std::to_string(mod); + for(const auto& dir_entry : std::filesystem::recursive_directory_iterator(mod_path)) + { + std::string path = path_utils::getRelativePath(dir_entry.path(), mod_path); + if(path.front() == '/') + path.erase(0, 1); + files[mod].emplace_back(path, dir_entry.path().filename().string()); + } + if(progress_node) + (*progress_node)->advance(); + } + return files; + } + +private: + /*! \brief Expression used by the TagConditionNode. */ + std::string expression_; + /*! \brief Conditions used by the TagConditionNode. */ + std::vector conditions_; + /*! + * \brief This tag is applied to a mod if this nodes evaluate function returns true for + * the mods installation directory + */ + TagConditionNode evaluator_; +}; diff --git a/src/core/backupmanager.cpp b/src/core/backupmanager.cpp new file mode 100644 index 0000000..cef847c --- /dev/null +++ b/src/core/backupmanager.cpp @@ -0,0 +1,397 @@ +#include "backupmanager.h" +#include "parseerror.h" +#include "pathutils.h" +#include +#include + +namespace sfs = std::filesystem; +namespace pu = path_utils; + + +void BackupManager::addTarget(const sfs::path& path, + const std::string& name, + const std::vector& backup_names) +{ + if(!sfs::exists(path)) + throw std::runtime_error(std::format("Path \"{}\" does not exist", path.string())); + sfs::path trimmed_path = path; + if(path.string().ends_with(sfs::path::preferred_separator)) + trimmed_path = path.string().erase(path.string().size() - 1, 1); + for(const auto& target : targets_) + { + if(target.path == trimmed_path) + throw std::runtime_error(std::format( + "\"{}\" is already managed as \"{}\" by BackupManager", path.string(), target.target_name)); + } + if(sfs::exists(getConfigPath(trimmed_path))) + addTarget(trimmed_path); + else + { + if(backup_names.empty()) + throw std::runtime_error("At least one backup name must be provided"); + targets_.emplace_back(trimmed_path, + name, + std::vector{ backup_names[0] }, + std::vector(num_profiles_, 0)); + for(int i = 1; i < backup_names.size(); i++) + addBackup(targets_.size() - 1, backup_names[i]); + } + updateSettings(); +} + +void BackupManager::addTarget(const sfs::path& path) +{ + if(!sfs::exists(path)) + throw std::runtime_error(std::format("Path \"{}\" does not exist", path.string())); + if(!sfs::exists(getConfigPath(path))) + throw std::runtime_error( + std::format("Could not find settings file at \"{}\"", getConfigPath(path).string())); + for(const auto& target : targets_) + { + if(target.path == path) + throw std::runtime_error(std::format( + "\"{}\" is already managed as \"{}\" by BackupManager", path.string(), target.target_name)); + } + targets_.push_back({ path, "", {}, {} }); + updateState(); +} + +void BackupManager::addBackup(int target_id, const std::string& name, int source) +{ + if(target_id < 0 || target_id >= targets_.size()) + throw std::runtime_error(std::format("Invalid target id: {}", target_id)); + updateDirectories(target_id); + auto& target = targets_[target_id]; + sfs::path source_path; + if(source >= 0 && source < targets_[target_id].backup_names.size()) + source_path = getBackupPath(target_id, source); + else + source_path = getBackupPath(target_id, target.active_members[cur_profile_]); + sfs::copy(source_path, + getBackupPath(target.path, target.backup_names.size()), + sfs::copy_options::recursive | sfs::copy_options::copy_symlinks); + target.backup_names.push_back(name); + updateSettings(); +} + +void BackupManager::removeTarget(int target_id) +{ + for(int backup = 0; backup < targets_[target_id].backup_names.size(); backup++) + { + if(backup == targets_[target_id].active_members[cur_profile_]) + continue; + const auto path = getBackupPath(target_id, backup); + if(sfs::exists(path)) + sfs::remove_all(path); + } + const auto config_file = getConfigPath(targets_[target_id].path); + if(sfs::exists(config_file)) + sfs::remove(config_file); + targets_.erase(targets_.begin() + target_id); +} + +void BackupManager::removeBackup(int target_id, int backup_id, bool update_dirs) +{ + if(target_id < 0 || target_id >= targets_.size()) + throw std::runtime_error(std::format("Invalid target id: {}", target_id)); + if(update_dirs) + updateDirectories(target_id); + if(targets_[target_id].backup_names.size() == 1) + throw std::runtime_error( + std::format("No backups to remove for \"{}\"", targets_[target_id].target_name)); + auto& target = targets_[target_id]; + if(backup_id < 0 || backup_id >= target.backup_names.size()) + throw std::runtime_error( + std::format("Invalid backup id: {} for target: {}", backup_id, target_id)); + + if(target.active_members[cur_profile_] == backup_id) + setActiveBackup(target_id, backup_id == 0 ? 1 : 0); + for(int prof = 0; prof < num_profiles_; prof++) + target.active_members[prof] = 0; + sfs::path backup_path = getBackupPath(target.path, backup_id); + if(sfs::exists(backup_path)) + sfs::remove_all(backup_path); + for(int i = backup_id + 1; i < target.backup_names.size(); i++) + { + sfs::path cur_path = getBackupPath(target.path, i); + if(sfs::exists(cur_path)) + sfs::rename(cur_path, getBackupPath(target.path, i - 1)); + } + target.backup_names.erase(target.backup_names.begin() + backup_id); + if(update_dirs) + updateSettings(); +} + +void BackupManager::setActiveBackup(int target_id, int backup_id) +{ + if(target_id < 0 || target_id >= targets_.size()) + throw std::runtime_error(std::format("Invalid target id: {}", target_id)); + updateDirectories(target_id); + auto& target = targets_[target_id]; + if(backup_id < 0 || backup_id >= target.backup_names.size()) + throw std::runtime_error( + std::format("Invalid backup id: {} for target: \"{}\"", target.target_name, backup_id)); + int active_id = target.active_members[cur_profile_]; + if(backup_id == active_id) + return; + sfs::rename(target.path, getBackupPath(target.path, active_id)); + sfs::rename(getBackupPath(target.path, backup_id), target.path); + target.active_members[cur_profile_] = backup_id; + target.cur_active_member = backup_id; + updateSettings(); +} + +void BackupManager::setProfile(int profile) +{ + if(profile == cur_profile_) + return; + for(int target_id = 0; target_id < targets_.size(); target_id++) + { + auto& target = targets_[target_id]; + int old_id = target.active_members[cur_profile_]; + int new_id = target.active_members[profile]; + if(old_id == new_id) + continue; + setActiveBackup(target_id, new_id); + target.active_members[cur_profile_] = old_id; + } + cur_profile_ = profile; +} + +void BackupManager::addProfile(int source) +{ + num_profiles_++; + if(cur_profile_ < 0 || cur_profile_ >= num_profiles_) + cur_profile_ = 0; + for(auto& target : targets_) + { + int active_id = source >= 0 && source < num_profiles_ ? target.active_members[source] : 0; + target.active_members.push_back(active_id); + } + updateSettings(); +} + +void BackupManager::removeProfile(int profile) +{ + num_profiles_--; + for(auto& target : targets_) + target.active_members.erase(target.active_members.begin() + profile); + if(profile == cur_profile_) + setProfile(0); + else if(cur_profile_ > profile) + cur_profile_--; + updateSettings(); +} + +std::vector BackupManager::getTargets() const +{ + auto ret_targets = targets_; + for(auto& target : ret_targets) + target.cur_active_member = target.active_members[cur_profile_]; + return ret_targets; +} + +void BackupManager::reset() +{ + targets_.clear(); + num_profiles_ = 0; +} + +int BackupManager::getNumTargets() +{ + return targets_.size(); +} + +int BackupManager::getNumBackups(int target_id) +{ + return targets_[target_id].backup_names.size(); +} + +void BackupManager::setBackupName(int target_id, int backup_id, const std::string& name) +{ + targets_[target_id].backup_names[backup_id] = name; + updateSettings(); +} + +void BackupManager::setBackupTargetName(int target_id, const std::string& name) +{ + targets_[target_id].target_name = name; + updateSettings(); +} + +void BackupManager::overwriteBackup(int target_id, int source_backup, int dest_backup) +{ + if(source_backup == dest_backup) + return; + const auto source_path = getBackupPath(target_id, source_backup); + const auto dest_path = getBackupPath(target_id, dest_backup); + sfs::remove_all(dest_path); + sfs::copy( + source_path, dest_path, sfs::copy_options::recursive | sfs::copy_options::overwrite_existing); +} + +void BackupManager::setLog(const std::function& new_log) +{ + log_ = new_log; +} + +void BackupManager::updateDirectories(int target_id) +{ + std::vector missing_dirs; + for(int backup_id = 0; backup_id < targets_[target_id].backup_names.size(); backup_id++) + { + if(!sfs::exists(getBackupPath(targets_[target_id].path, backup_id)) && + backup_id != targets_[target_id].active_members[cur_profile_]) + missing_dirs.push_back(backup_id); + } + + for(int j = missing_dirs.size() - 1; j >= 0; j--) + { + log_(Log::LOG_WARNING, + std::format("Could not find backup \"{}\" for target \"{}\".", + targets_[target_id].backup_names[missing_dirs[j]], + targets_[target_id].target_name)); + removeBackup(target_id, missing_dirs[j], false); + } + + std::vector extra_dirs; + for(const auto& dir_entry : sfs::directory_iterator(targets_[target_id].path.parent_path())) + { + const auto file_name = dir_entry.path().filename(); + if(!file_name.has_extension() || file_name.extension().string() != BAK_EXTENSION) + continue; + if(!file_name.stem().has_extension()) + continue; + std::string extension = file_name.stem().extension(); + if(sfs::path(file_name).stem().stem() != targets_[target_id].path.filename()) + continue; + if(extension.starts_with(".")) + extension.replace(0, 1, ""); + if(extension.find_first_not_of("0123456789") != extension.npos) + continue; + int id = std::stoi(extension); + if(id >= targets_[target_id].backup_names.size() || + id == targets_[target_id].active_members[cur_profile_]) + extra_dirs.push_back(dir_entry.path()); + } + + for(const auto& path : extra_dirs) + { + sfs::path new_path = path.string() + "OLD"; + int i = 0; + while(sfs::exists(new_path)) + new_path = path.string() + "OLD" + std::to_string(i++); + log_(Log::LOG_WARNING, + std::format( + "Unknown backup found at \"{}\". Moving to \"{}\".", path.string(), new_path.string())); + sfs::rename(path, new_path); + } + updateSettings(); +} + +void BackupManager::updateDirectories() +{ + for(int target_id = 0; target_id < targets_.size(); target_id++) + updateDirectories(target_id); + updateSettings(); +} + +void BackupManager::updateState() +{ + for(auto& target : targets_) + { + const auto settings = readSettings(getConfigPath(target.path)); + auto keys = { "path", "target_name", "backup_names", "active_members" }; + for(const auto& key : keys) + { + if(!settings.isMember(key)) + throw ParseError(std::format("\"{}\" is missing in \"{}\"", key, target.path.string())); + } + if(settings["path"].asString() != target.path.string()) + throw ParseError(std::format( + "Invalid path \"{}\" in \"{}\"", settings["path"].asString(), target.path.string())); + std::vector new_names; + auto names = settings["backup_names"]; + if(names.empty()) + throw ParseError(std::format("No backups found for \"{}\"", target.path.string())); + for(int i = 0; i < names.size(); i++) + new_names.push_back(names[i].asString()); + std::vector new_active_members; + auto active_members = settings["active_members"]; + for(int i = 0; i < active_members.size(); i++) + { + int member = active_members[i].asInt(); + if(member < 0 || member >= new_names.size()) + throw ParseError( + std::format("Invalid active member\"{}\" in \"{}\"", member, target.path.string())); + new_active_members.push_back(member); + } + if(active_members.size() != num_profiles_) + throw ParseError( + std::format("Failed to parse active_members in \"{}\"", target.path.string())); + target.target_name = settings["target_name"].asString(); + target.backup_names = new_names; + target.active_members = new_active_members; + } + updateDirectories(); +} + +void BackupManager::updateSettings() +{ + for(const auto& target : targets_) + { + Json::Value settings; + settings["path"] = target.path.string(); + settings["target_name"] = target.target_name; + for(int i = 0; i < target.backup_names.size(); i++) + settings["backup_names"][i] = target.backup_names[i]; + for(int i = 0; i < target.active_members.size(); i++) + settings["active_members"][i] = target.active_members[i]; + writeSettings(getConfigPath(target.path), settings); + } +} + +void BackupManager::writeSettings(const sfs::path& path, const Json::Value& settings) const +{ + std::ofstream file(path, std::fstream::binary); + if(!file.is_open()) + throw std::runtime_error("Error: Could not write to \"" + path.string() + "\"."); + file << settings; + file.close(); +} + +Json::Value BackupManager::readSettings(const sfs::path& path) const +{ + Json::Value settings; + std::ifstream file(path, std::fstream::binary); + if(!file.is_open()) + throw std::runtime_error("Error: Could not read from \"" + path.string() + "\"."); + file >> settings; + file.close(); + return settings; +} + +sfs::path BackupManager::getConfigPath(const sfs::path& path) const +{ + if(!path.has_parent_path()) + throw std::runtime_error("Creating backups of the filesystem root is not supported"); + + sfs::path dest = path; + if(path.string().ends_with("/")) + dest = dest.parent_path(); + return dest.parent_path() / + ("." + pu::getRelativePath(dest, dest.parent_path()) + JSON_EXTENSION); +} + +sfs::path BackupManager::getBackupPath(const sfs::path& path, int backup) const +{ + return path.string() + "." + std::to_string(backup) + BAK_EXTENSION; +} + +sfs::path BackupManager::getBackupPath(int target, int backup) const +{ + sfs::path file_path = targets_[target].path; + if(targets_[target].active_members[cur_profile_] == backup) + return file_path; + return getBackupPath(file_path, backup); +} diff --git a/src/core/backupmanager.h b/src/core/backupmanager.h new file mode 100644 index 0000000..38daa2d --- /dev/null +++ b/src/core/backupmanager.h @@ -0,0 +1,196 @@ +/*! + * \file backupmanager.h + * \brief Header for the BackupManager class. + */ + +#pragma once + +#include "backuptarget.h" +#include "log.h" +#include +#include +#include +#include + + +/*! + * \brief Handles creation of, deletion of and switching between, bachups. + */ +class BackupManager +{ +public: + /*! \brief Empty default constructor. */ + BackupManager() = default; + + /*! + * \brief Adds a new target file or directory to be managed. + * \param path Path to the target file or directory. + * \param name Display name for this target. + * \param backup_names Display names for initial backups. Must contain at least one. + */ + void addTarget(const std::filesystem::path& path, + const std::string& name, + const std::vector& backup_names); + /*! + * \brief Adds a backup target which was previously managed by a BackupManager. + * \param path Path to the target file or directory. + */ + void addTarget(const std::filesystem::path& path); + /*! + * \brief Removes the given target by deleting all relevant backups and config files. + * \param target_id Target to remove. + */ + void removeTarget(int target_id); + /*! + * \brief Adds a new backup for the given target by copying the currently active backup. + * \param target_id Target for which to create a new backup. + * \param name Display name for the new backup. + * \param source Backup from which to copy files to create the new backup. If -1: + * copy currently active backup. + */ + void addBackup(int target_id, const std::string& name, int source = -1); + /*! + * \brief Deletes the given backup for given target. + * \param target_id Target from which to delete a backup. + * \param backup_id Backup to remove. + * \param update_dirs If true: Repair the target if it is in an invalid state, e.g. if + * a backup has been manually deleted. + */ + void removeBackup(int target_id, int backup_id, bool update_dirs = true); + /*! + * \brief Changes the currently active backup for the given target. + * \param target_id Target for which to change the active backup. + * \param backup_id New active backup. + */ + void setActiveBackup(int target_id, int backup_id); + /*! + * \brief Sets the active profile to the new profile and changes all active backups if + * needed. + * \param profile New active profile. + */ + void setProfile(int profile); + /*! + * \brief Adds a new profile. + * \param source If this refers to an existing backup: Copy the active backups from that + * profile. + */ + void addProfile(int source = -1); + /*! + * \brief Removes the given profile. + * \param profile Profile to be removed. + */ + void removeProfile(int profile); + /*! + * \brief Returns a vector containing information about all managed backup targets. + * \return The vector. + */ + std::vector getTargets() const; + /*! \brief Deletes all entries in targets_ as well as all profiles. */ + void reset(); + /*! \brief Returns the number of backup targets. */ + int getNumTargets(); + /*! + * \brief Returns the number of backups for the given target. + * \param target_id Backup target. + * \return The number of backups. + */ + int getNumBackups(int target_id); + /*! + * \brief Setter for the name of a backup belonging to the given target. + * \param target_id Backup target. + * \param backup_id Backup to be edited. + * \param name The new name. + */ + void setBackupName(int target_id, int backup_id, const std::string& name); + /*! + * \brief Setter for the name of a backup target. + * \param target_id Backup target. + * \param name The new name. + */ + void setBackupTargetName(int target_id, const std::string& name); + /*! + * \brief Deletes all files in the dest backup and replaces them with the files + * from the source backup. + * \param target_id Backup target. + * \param source_backup Backup from which to copy files. + * \param dest_backup Target for data deletion. + */ + void overwriteBackup(int target_id, int source_backup, int dest_backup); + /*! + * \brief Setter for log callback. + * \param new_log New log callback + */ + void setLog(const std::function& new_log); + +private: + /*! \brief File extension used for backups. */ + static inline const std::string BAK_EXTENSION = ".lmmbakman"; + /*! \brief File extension used for the files used to store a targets state. */ + static inline const std::string JSON_EXTENSION = BAK_EXTENSION + ".json"; + /*! \brief Contains all managed targets. */ + std::vector targets_{}; + /*! \brief Number of profiles. */ + int num_profiles_ = 0; + /*! \brief Currently active profile. */ + int cur_profile_ = -1; + /*! \brief Callback for logging. */ + std::function log_ = [](Log::LogLevel a, + const std::string& b) {}; + + /*! + * \brief Ensures consistency with the data on disk. + * + * This is accomplished by deleting backups for which + * no file exists and files on disk which should by filename and extension be a + * backup but have an invalid id. This is done for all files matching the filename + * and path of any target. + */ + void updateDirectories(); + /*! + * \brief Ensures consistency with the data on disk. + * + * This is accomplished by deleting backups for which + * no file exists and renaming files on disk which should by filename and extension be a + * backup but have an invalid id. This is done for all files matching the filename + * and path of the given target. + * \param target_id Target to check. + */ + void updateDirectories(int target_id); + /*! \brief Updates internal state by parsing every targets state file. */ + void updateState(); + /*! \brief Updates every targets state file with the internal state. */ + void updateSettings(); + /*! + * \brief Writes the given json object to disk. + * \param path Path to write to. + * \param settings The json object. + */ + void writeSettings(const std::filesystem::path& path, const Json::Value& settings) const; + /*! + * \brief Reads the given file and creates a json object from the files data. + * \param path File to read. + * \return The json object created from the file. + */ + Json::Value readSettings(const std::filesystem::path& path) const; + /*! + * \brief Returns the path to the file which contains state data for the given file + * or directory. + * \param path File or directory for which to generate the path. + * \return The path. + */ + std::filesystem::path getConfigPath(const std::filesystem::path& path) const; + /*! + * \brief Returns the path to the given backup for the given file or directory. + * \param path Path to a backup target. + * \param backup Backup id for the given target. + * \return The path. + */ + std::filesystem::path getBackupPath(const std::filesystem::path& path, int backup) const; + /*! + * \brief Returns the path to the given existing backup for the given target. + * \param path target Target for which to find the path. + * \param backup Backup id for the given target. + * \return The path. + */ + std::filesystem::path getBackupPath(int target, int backup) const; +}; diff --git a/src/core/backuptarget.cpp b/src/core/backuptarget.cpp new file mode 100644 index 0000000..b504eba --- /dev/null +++ b/src/core/backuptarget.cpp @@ -0,0 +1,21 @@ +#include "backuptarget.h" + +BackupTarget::BackupTarget(const std::filesystem::path& path, + const std::string& target_name, + const std::vector& backup_names, + const std::vector& active_members) : + path(path), target_name(target_name), backup_names(backup_names), active_members(active_members) +{} + +bool BackupTarget::operator==(const BackupTarget& other) const +{ + for(int i = 0; i < backup_names.size(); i++) + if(backup_names[i] != other.backup_names[i]) + return false; + for(int i = 0; i < active_members.size(); i++) + if(active_members[i] != other.active_members[i]) + return false; + return path == other.path && target_name == other.target_name && + backup_names.size() == other.backup_names.size() && + active_members.size() == other.active_members.size(); +} diff --git a/src/core/backuptarget.h b/src/core/backuptarget.h new file mode 100644 index 0000000..7ee909e --- /dev/null +++ b/src/core/backuptarget.h @@ -0,0 +1,46 @@ +/*! + * \file backuptarget.h + * \brief Header for the BackupTarget struct. + */ + +#pragma once + +#include +#include + + +/*! + * \brief Stores information about a backup target. + */ +struct BackupTarget +{ + /*! \brief Path to the target file or directory. */ + std::filesystem::path path; + /*! \brief Display name for this backup target. */ + std::string target_name; + /*! \brief Contains display names for all backups for this target. */ + std::vector backup_names; + /*! \brief Contains the currently active backup for every profile. */ + std::vector active_members; + /*! \brief Active member for current profile. */ + int cur_active_member = 0; + + /*! + * \brief Constructor. + * \param path Path to the target file or directory. + * \param target_name Display name for this backup target. + * \param backup_names Contains display names for all backups for this target. + * \param active_members Contains the currently active backup for every profile. + */ + BackupTarget(const std::filesystem::path& path, + const std::string& target_name, + const std::vector& backup_names, + const std::vector& active_members); + + /*! + * \brief Tests every member of this and other for equality. + * \param other BackupTarget to compare this to. + * \return True only if every member of this is equal to the respective member in other. + */ + bool operator==(const BackupTarget& other) const; +}; diff --git a/src/core/casematchingdeployer.cpp b/src/core/casematchingdeployer.cpp new file mode 100644 index 0000000..9f461bb --- /dev/null +++ b/src/core/casematchingdeployer.cpp @@ -0,0 +1,145 @@ +#include "casematchingdeployer.h" +#include "pathutils.h" +#include +#include + +namespace sfs = std::filesystem; +namespace pu = path_utils; + + +CaseMatchingDeployer::CaseMatchingDeployer(const sfs::path& source_path, + const sfs::path& dest_path, + const std::string& name, + bool use_copy_deployment) : + Deployer(source_path, dest_path, name, use_copy_deployment) +{ + type_ = "Case Matching Deployer"; +} + +std::map CaseMatchingDeployer::deploy( + const std::vector& loadorder, + std::optional progress_node) +{ + if(progress_node) + (*progress_node)->addChildren({ 2, 1, 3 }); + adaptLoadorderFiles(loadorder, + progress_node ? &(*progress_node)->child(0) : std::optional{}); + updateConflictGroups(progress_node ? &(*progress_node)->child(1) : std::optional{}); + return Deployer::deploy( + loadorder, progress_node ? &(*progress_node)->child(2) : std::optional{}); +} + +void CaseMatchingDeployer::adaptDirectoryFiles(const sfs::path& path, + int mod_id, + const sfs::path& target_path) const +{ + std::vector directories; + for(auto const& dir_entry : sfs::directory_iterator(source_path_ / std::to_string(mod_id) / path)) + { + const std::string relative_path = + pu::getRelativePath(dir_entry.path(), source_path_ / std::to_string(mod_id)); + if(sfs::exists(target_path / relative_path)) + { + if(sfs::is_directory(target_path / relative_path)) + directories.push_back(relative_path); + continue; + } + std::string file_name = std::prev(dir_entry.path().end())->string(); + int num_matches = 0; + std::string match_file_name = file_name; + if(!sfs::exists(target_path / path)) + continue; + for(const auto& dest_entry : sfs::directory_iterator(target_path / path)) + { + std::string dest_file_name = std::prev(dest_entry.path().end())->string(); + if(!std::equal(file_name.begin(), + file_name.end(), + dest_file_name.begin(), + dest_file_name.end(), + [](char a, char b) { return std::tolower(a) == std::tolower(b); })) + continue; + num_matches++; + match_file_name = dest_file_name; + if(num_matches > 1) + break; + } + if(num_matches == 1) + { + const auto source = source_path_ / std::to_string(mod_id) / path / file_name; + const auto target = source_path_ / std::to_string(mod_id) / path / match_file_name; + if(!sfs::exists(target)) + sfs::rename(source, target); + else if(sfs::is_directory(target)) + pu::moveFilesToDirectory(source, target); + else + throw std::runtime_error(std::format("Could not rename file '{}' to '{}' " + "because the target already exists", + source.string(), + target.string())); + } + if(sfs::is_directory(source_path_ / std::to_string(mod_id) / path / match_file_name)) + directories.push_back(path / match_file_name); + } + for(const auto& dir : directories) + adaptDirectoryFiles(dir, mod_id, target_path); +} + +void CaseMatchingDeployer::adaptLoadorderFiles(const std::vector& loadorder, + std::optional progress_node) const +{ + log_(Log::LOG_INFO, std::format("Deployer '{}': Matching file names...", name_)); + if(progress_node) + { + (*progress_node)->addChildren({ 2, 1 }); + (*progress_node)->child(0).setTotalSteps(loadorder.size()); + (*progress_node)->child(1).setTotalSteps(loadorder.size()); + } + for(int mod_id : loadorder) + { + if(checkModPathExistsAndMaybeLogError(mod_id)) + adaptDirectoryFiles("", mod_id, dest_path_); + if(progress_node) + (*progress_node)->child(0).advance(); + } + + std::map file_name_map; + for(int mod_id : loadorder) + { + const sfs::path mod_path = source_path_ / std::to_string(mod_id); + std::vector mod_paths; + for(const auto& dir_entry : sfs::recursive_directory_iterator(mod_path)) + mod_paths.push_back(dir_entry.path()); + std::sort(mod_paths.begin(), + mod_paths.end(), + [](const std::string& a, const std::string& b) { return a.size() > b.size(); }); + for(const auto& path : mod_paths) + { + const std::string relative_path = pu::getRelativePath(path, mod_path); + const std::string file_name = std::prev(sfs::path(relative_path).end())->string(); + std::string lower_case_path = pu::toLowerCase(relative_path); + if(file_name_map.contains(lower_case_path)) + { + const sfs::path target_file_name = + std::prev(sfs::path(file_name_map[lower_case_path]).end())->string(); + if(file_name == target_file_name) + continue; + const sfs::path source = mod_path / relative_path; + const sfs::path target = + mod_path / sfs::path(relative_path).parent_path() / target_file_name; + if(!sfs::exists(target)) + sfs::rename(source, target); + else if(sfs::is_directory(target)) + pu::moveFilesToDirectory(source, target); + else + throw std::runtime_error(std::format("Could not rename file '{}' to '{}' " + "because the target already exists", + source.string(), + target.string())); + } + else + file_name_map[lower_case_path] = relative_path; + } + if(progress_node) + (*progress_node)->child(1).advance(); + } +} diff --git a/src/core/casematchingdeployer.h b/src/core/casematchingdeployer.h new file mode 100644 index 0000000..c91c50d --- /dev/null +++ b/src/core/casematchingdeployer.h @@ -0,0 +1,61 @@ +/*! + * \file casematchingdeployer.h + * \brief Header for the CaseMatchingDeployer class + */ + +#pragma once + +#include "deployer.h" + +/*! + * \brief Automatically renames mod files to match the case of target files. + */ +class CaseMatchingDeployer : public Deployer +{ +public: + /*! + * \brief Passes arguments to base class constructor. + * \param source_path Path to directory containing mods installed using the Installer class. + * \param dest_path Path to target directory for mod deployment. + * \param name A custom name for this instance. + * \param use_copy_deployment If True: copy files during deployment, else use hard links. + */ + CaseMatchingDeployer(const std::filesystem::path& source_path, + const std::filesystem::path& dest_path, + const std::string& name, + bool use_copy_deployment = false); + /*! + * \brief Iterates over every file and directory contained in the mods in the given load order. + * If any name case insensitively matches the name of a file in the target directory, the source + * is renamed to be identical to the target. Then calls + * \ref Deployer.deploy() "Deployer::deploy(loadorder)". + * \param loadorder A vector of mod ids representing the load order. + * \param progress_node Used to inform about the current progress of deployment. + * \return A map from deployed mod ids to their respective mods total size on disk. + */ + virtual std::map deploy( + const std::vector& loadorder, + std::optional progress_node = {}) override; + /*! \brief Use base class implementation of overloaded function. */ + using Deployer::deploy; + +private: + /*! + * \brief Recursively renames every file in source_path_/mod_id/path to the name of a file + * in dest_path_, if both match case insensitively. + * \param path Path relative to the mods root directory. + * \param mod_id Id of the mod containing the source files. + * \param target_path Path used for file comparisons. + */ + void adaptDirectoryFiles(const std::filesystem::path& path, + int mod_id, + const std::filesystem::path& target_path) const; + /*! + * \brief Renames every file in every mod in the given load order + * such that all paths are case invariant and match the case of files in \ref dest_path_. + * \param loadorder Contains ids of mods the files of which will be adapted. + * \param progress_node Used to inform about the current progress of deployment. + */ + void adaptLoadorderFiles(const std::vector& loadorder, + std::optional progress_node = {}) const; +}; diff --git a/src/core/compressionerror.h b/src/core/compressionerror.h new file mode 100644 index 0000000..45c7666 --- /dev/null +++ b/src/core/compressionerror.h @@ -0,0 +1,25 @@ +/*! + * \file compressionerror.h + * \brief Contains the CompressionError class + */ +#pragma once + + +/*! + * \brief Exception used for errors during archive extraction. + */ +#include +class CompressionError : public std::runtime_error +{ +public: + /*! + * \brief Constructor accepts an error message. + * \param message Exception message. + */ + CompressionError(const char* message) : std::runtime_error(message){}; + /*! + * \brief Returns the message of this exception. + * \return The message. + */ + const char* what() const throw() { return std::runtime_error::what(); }; +}; diff --git a/src/core/conflictinfo.h b/src/core/conflictinfo.h new file mode 100644 index 0000000..feae8f9 --- /dev/null +++ b/src/core/conflictinfo.h @@ -0,0 +1,31 @@ +/*! + * \file conflictinfo.h + * \brief Contains the ConflictInfo struct. + */ + +#pragma once + +#include + + +/*! + * \brief Stores information about a file conflict. + */ +struct ConflictInfo +{ + /*! \brief Name of the conflicting file. */ + std::string file; + /*! \brief Id of the conflicts winning mod. */ + int mod_id; + /*! \brief Name of the conflicts winning mod. */ + std::string mod_name; + /*! + * \brief Constructor. Simply initializes members. + * \param file Name of the conflicting file. + * \param mod_id Id of the conflicts winning mod. + * \param mod_name Name of the conflicts winning mod. + */ + ConflictInfo(std::string file, int mod_id, std::string mod_name) : + file(std::move(file)), mod_id(mod_id), mod_name(std::move(mod_name)) + {} +}; diff --git a/src/core/cryptography.cpp b/src/core/cryptography.cpp new file mode 100644 index 0000000..e842006 --- /dev/null +++ b/src/core/cryptography.cpp @@ -0,0 +1,121 @@ +#include "cryptography.h" +#include +#include +#include +#include +#include + + +void throwError(const std::string& step) +{ + std::string error = "Error during " + step + ".\n"; + auto code = ERR_get_error(); + char buffer[256]; + while(code) + { + ERR_error_string(code, buffer); + error.append(std::string(buffer)); + error.append("\n"); + code = ERR_get_error(); + } + ERR_free_strings(); + throw CryptographyError(error); +} + +namespace cryptography +{ +std::tuple encrypt(const std::string& plain_text, + const std::string& key) +{ + auto ctx = EVP_CIPHER_CTX_new(); + if(!ctx) + throwError("encryption"); + + if(EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), NULL, NULL, NULL) != 1) + throwError("encryption"); + + constexpr int nonce_size = 12; + unsigned char nonce[nonce_size]; + if(RAND_bytes(nonce, nonce_size) != 1) + throwError("encryption"); + + std::string actual_key = key.empty() ? default_key : key; + constexpr int key_size = 32; + unsigned char key_padded[key_size]; + for(int i = 0; i < key_size; i++) + key_padded[i] = actual_key[i % actual_key.size()]; + if(EVP_EncryptInit_ex(ctx, NULL, NULL, key_padded, nonce) != 1) + throwError("encryption"); + + const int buffer_size = exp2((int)(log(plain_text.size() + 16) / log(2)) + 1); + unsigned char cipher_text[buffer_size]; + int cur_length = 0; + unsigned char plain_array[plain_text.size()]; + for(int i = 0; i < plain_text.size(); i++) + plain_array[i] = plain_text[i]; + if(EVP_EncryptUpdate(ctx, cipher_text, &cur_length, plain_array, plain_text.size()) != 1) + throwError("encryption"); + + int cipher_length = cur_length; + if(EVP_EncryptFinal_ex(ctx, cipher_text + cur_length, &cur_length) != 1) + throwError("encryption"); + cipher_length += cur_length; + + unsigned char tag[16]; + if(EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, 16, tag) != 1) + throwError("encryption"); + + EVP_CIPHER_CTX_free(ctx); + + const std::string cipher_str(reinterpret_cast(cipher_text), cipher_length); + const std::string nonce_str(reinterpret_cast(nonce), nonce_size); + const std::string tag_str(reinterpret_cast(tag), 16); + + return { cipher_str, nonce_str, tag_str }; +} + +std::string decrypt(const std::string& cipher_text, + const std::string& key, + const std::string& nonce, + const std::string& tag) +{ + auto ctx = EVP_CIPHER_CTX_new(); + if(!ctx) + throwError("decryption"); + + if(EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), NULL, NULL, NULL) != 1) + throwError("decryption"); + + std::string actual_key = key.empty() ? default_key : key; + constexpr int key_size = 32; + unsigned char key_arr[key_size]; + for(int i = 0; i < key_size; i++) + key_arr[i] = actual_key[i % actual_key.size()]; + unsigned char nonce_arr[nonce.size()]; + for(int i = 0; i < nonce.size(); i++) + nonce_arr[i] = nonce[i]; + if(EVP_DecryptInit_ex(ctx, NULL, NULL, key_arr, nonce_arr) != 1) + throwError("decryption"); + + unsigned char cipher_arr[cipher_text.size()]; + for(int i = 0; i < cipher_text.size(); i++) + cipher_arr[i] = cipher_text[i]; + unsigned char plain_text[(int)exp2((int)(log(cipher_text.size()) / log(2)) + 1)]; + int cur_length = 0; + if(EVP_DecryptUpdate(ctx, plain_text, &cur_length, cipher_arr, cipher_text.size()) != 1) + throwError("decryption"); + int plain_text_length = cur_length; + + unsigned char tag_arr[tag.size()]; + for(int i = 0; i < tag.size(); i++) + tag_arr[i] = tag[i]; + if(EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, 16, tag_arr) != 1) + throwError("decryption"); + + if(EVP_DecryptFinal_ex(ctx, plain_text + cur_length, &cur_length) <= 0) + throwError("decryption"); + plain_text_length += cur_length; + + return std::string(reinterpret_cast(plain_text), plain_text_length); +} +} diff --git a/src/core/cryptography.h b/src/core/cryptography.h new file mode 100644 index 0000000..ec31229 --- /dev/null +++ b/src/core/cryptography.h @@ -0,0 +1,58 @@ +/*! + * \file cryptography.h + * \brief Header for the cryptography namespace. + */ + +#pragma once + +#include +#include + + +/*! + * \brief Exception indicating an error during a cryptographic operation. + */ +class CryptographyError : public std::runtime_error +{ +public: + /*! + * \brief Constructor. + * \param message Message for the exception. + */ + CryptographyError(const char* message) : std::runtime_error(message) {} + /*! + * \brief Constructor. + * \param message Message for the exception. + */ + CryptographyError(const std::string& message) : std::runtime_error(message) {} +}; + + +namespace cryptography +{ +/*! + * \brief Encrypts the given string using AES-GCM with the given key. + * \param plain_text Text to be encrapted. + * \param key Key to use for encryption. + * \return The cipher text, the random nonce(IV) used, the authentication tag. + * \throws CryptographyError When an OpenSSL internal error occurs. + */ +std::tuple encrypt(const std::string& plain_text, + const std::string& key); +/*! + * \brief Decrypts the given cipher text using AES-GCM. + * \param cipher_text Text to be decrypted. + * \param key Key used for decryption. + * \param nonce Nonce (IV) used during enryption. + * \param tag Authentication tag. + * \return The plain text. + * \throws CryptographyError When an OpenSSL internal error occurs. + */ +std::string decrypt(const std::string& cipher_text, + const std::string& key, + const std::string& nonce, + const std::string& tag); + +/*! \brief A default encryption key used in case no key was specified. */ +constexpr char default_key[] = "rWnYJVdtxz8Iu62GSJy0OPlOat7imMb8"; +}; diff --git a/src/core/deployer.cpp b/src/core/deployer.cpp new file mode 100644 index 0000000..42d338b --- /dev/null +++ b/src/core/deployer.cpp @@ -0,0 +1,696 @@ +#include "deployer.h" +#include "pathutils.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace str = std::ranges; +namespace sfs = std::filesystem; +namespace pu = path_utils; + + +Deployer::Deployer(const sfs::path& source_path, + const sfs::path& dest_path, + const std::string& name, + bool use_copy_deployment) : + source_path_(source_path), dest_path_(dest_path), name_(name), + use_copy_deployment_(use_copy_deployment) +{} + +std::string Deployer::getDestPath() const +{ + return dest_path_; +} + +std::string Deployer::getName() const +{ + return name_; +} + +void Deployer::setName(const std::string& name) +{ + name_ = name; +} + +std::map Deployer::deploy(const std::vector& loadorder, + std::optional progress_node) +{ + auto [source_files, mod_sizes] = getDeploymentSourceFilesAndModSizes(loadorder); + log_(Log::LOG_INFO, + std::format("Deployer '{}': Deploying {} files for {} mods...", + name_, + source_files.size(), + loadorder.size())); + if(progress_node) + (*progress_node)->addChildren({ 2, 5, 1 }); + std::map dest_files = + loadDeployedFiles(progress_node ? &(*progress_node)->child(0) : std::optional{}); + backupOrRestoreFiles(source_files, dest_files); + deployFiles(source_files, + progress_node ? &(*progress_node)->child(1) : std::optional{}); + saveDeployedFiles(source_files, + progress_node ? &(*progress_node)->child(2) : std::optional{}); + return mod_sizes; +} + +std::map Deployer::deploy(std::optional progress_node) +{ + std::vector loadorder; + for(auto const& [id, enabled] : loadorders_[current_profile_]) + { + if(enabled) + loadorder.push_back(id); + } + return deploy(loadorder, progress_node); +} + +void Deployer::setLoadorder(const std::vector>& loadorder) +{ + loadorders_[current_profile_] = loadorder; +} + +std::vector> Deployer::getLoadorder() const +{ + if(loadorders_.empty() || current_profile_ < 0 || current_profile_ >= loadorders_.size() || + loadorders_[current_profile_].empty()) + return std::vector>{}; + return loadorders_[current_profile_]; +} + +std::string Deployer::getType() const +{ + return type_; +} + +void Deployer::changeLoadorder(int from_index, int to_index) +{ + if(to_index == from_index) + return; + if(to_index < 0 || to_index >= loadorders_[current_profile_].size()) + return; + if(to_index < from_index) + { + std::rotate(loadorders_[current_profile_].begin() + to_index, + loadorders_[current_profile_].begin() + from_index, + loadorders_[current_profile_].begin() + from_index + 1); + } + else + { + std::rotate(loadorders_[current_profile_].begin() + from_index, + loadorders_[current_profile_].begin() + from_index + 1, + loadorders_[current_profile_].begin() + to_index + 1); + } +} + +bool Deployer::addMod(int mod_id, bool enabled, bool update_conflicts) +{ + if(hasMod(mod_id)) + return false; + loadorders_[current_profile_].emplace_back(mod_id, enabled); + if(update_conflicts && auto_update_conflict_groups_) + updateConflictGroups(); + return true; +} + +bool Deployer::removeMod(int mod_id) +{ + auto iter = std::find_if(loadorders_[current_profile_].begin(), + loadorders_[current_profile_].end(), + [mod_id](auto elem) { return std::get<0>(elem) == mod_id; }); + if(iter == loadorders_[current_profile_].end()) + return false; + loadorders_[current_profile_].erase(iter); + if(auto_update_conflict_groups_) + updateConflictGroups(); + return true; +} + +void Deployer::setModStatus(int mod_id, bool status) +{ + auto iter = std::find_if(loadorders_[current_profile_].begin(), + loadorders_[current_profile_].end(), + [mod_id, status](const auto& t) { return std::get<0>(t) == mod_id; }); + std::get<1>(*iter) = status; + return; +} + +bool Deployer::hasMod(int mod_id) const +{ + return std::find_if(loadorders_[current_profile_].begin(), + loadorders_[current_profile_].end(), + [mod_id](const auto& tuple) { return std::get<0>(tuple) == mod_id; }) != + loadorders_[current_profile_].end(); +} + +std::vector Deployer::getFileConflicts( + int mod_id, + bool show_disabled, + std::optional progress_node) const +{ + std::vector conflicts; + std::unordered_set unique_files; + std::unordered_set mod_files = getModFiles(mod_id, false); + if(!checkModPathExistsAndMaybeLogError(mod_id)) + return conflicts; + sfs::path mod_base_path = source_path_ / std::to_string(mod_id); + std::vector loadorder; + for(auto const& [id, enabled] : loadorders_[current_profile_]) + { + if(enabled || show_disabled) + loadorder.push_back(id); + } + + if(progress_node) + (*progress_node)->setTotalSteps(loadorder.size()); + bool mod_found = false; + for(int cur_id : loadorder) + { + if(cur_id == mod_id) + { + mod_found = true; + continue; + } + if(!checkModPathExistsAndMaybeLogError(cur_id)) + continue; + mod_base_path = source_path_ / std::to_string(cur_id); + for(const auto& dir_entry : sfs::recursive_directory_iterator(mod_base_path)) + { + const auto relative_path = pu::getRelativePath(dir_entry.path(), mod_base_path); + if(mod_files.contains(relative_path) && !unique_files.contains(relative_path)) + { + unique_files.insert(relative_path); + if(mod_found) + conflicts.emplace_back(relative_path, cur_id, ""); + else + conflicts.emplace_back(relative_path, mod_id, ""); + } + } + if(progress_node) + (*progress_node)->advance(); + } + return conflicts; +} + +int Deployer::getNumMods() const +{ + return loadorders_[current_profile_].size(); +} + +const std::filesystem::path& Deployer::destPath() const +{ + return dest_path_; +} + +void Deployer::setDestPath(const sfs::path& path) +{ + dest_path_ = path; +} + +std::unordered_set Deployer::getModConflicts(int mod_id, + std::optional progress_node) +{ + std::unordered_set conflicts{ mod_id }; + std::unordered_set mod_files = getModFiles(mod_id, false); + if(!checkModPathExistsAndMaybeLogError(mod_id)) + return conflicts; + sfs::path mod_base_path = source_path_ / std::to_string(mod_id); + if(progress_node) + (*progress_node)->setTotalSteps(loadorders_[current_profile_].size()); + for(const auto [cur_id, _] : loadorders_[current_profile_]) + { + if(!checkModPathExistsAndMaybeLogError(cur_id)) + continue; + mod_base_path = source_path_ / std::to_string(cur_id); + for(const auto& dir_entry : sfs::recursive_directory_iterator(mod_base_path)) + { + const auto relative_path = pu::getRelativePath(dir_entry.path(), mod_base_path); + if(mod_files.contains(relative_path)) + { + conflicts.insert(cur_id); + break; + } + } + if(progress_node) + (*progress_node)->advance(); + } + return conflicts; +} + +void Deployer::addProfile(int source) +{ + if(source < 0 || source >= loadorders_.size()) + { + loadorders_.push_back(std::vector>{}); + conflict_groups_.push_back(std::vector>{}); + } + else + { + loadorders_.push_back(loadorders_[source]); + conflict_groups_.push_back(conflict_groups_[source]); + } +} + +void Deployer::removeProfile(int profile) +{ + loadorders_.erase(loadorders_.begin() + profile); + conflict_groups_.erase(conflict_groups_.begin() + profile); + if(profile == current_profile_) + setProfile(0); +} + +void Deployer::setProfile(int profile) +{ + current_profile_ = profile; +} + +int Deployer::getProfile() const +{ + return current_profile_; +} + +int Deployer::verifyDirectories() +{ + std::string file_name = "_lmm_write_test_file_"; + try + { + std::ofstream file(source_path_ / file_name); + if(file.is_open()) + file << "test"; + } + catch(const std::ios_base::failure& f) + { + return 1; + } + try + { + if(sfs::exists(dest_path_ / file_name)) + sfs::remove(dest_path_ / file_name); + if(use_copy_deployment_) + sfs::copy_file(source_path_ / file_name, dest_path_ / file_name); + else + sfs::create_hard_link(source_path_ / file_name, dest_path_ / file_name); + } + catch(sfs::filesystem_error& e) + { + sfs::remove(source_path_ / file_name); + if(use_copy_deployment_) + return 3; + else + return 2; + } + sfs::remove(source_path_ / file_name); + sfs::remove(dest_path_ / file_name); + return 0; +} + +bool Deployer::swapMod(int old_id, int new_id) +{ + auto iter = std::find_if(loadorders_[current_profile_].begin(), + loadorders_[current_profile_].end(), + [old_id](auto elem) { return std::get<0>(elem) == old_id; }); + if(iter == loadorders_[current_profile_].end() || std::get<0>(*iter) == new_id) + return false; + *iter = { new_id, std::get<1>(*iter) }; + if(auto_update_conflict_groups_) + updateConflictGroups(); + return true; +} + +void Deployer::sortModsByConflicts(std::optional progress_node) +{ + updateConflictGroups(progress_node); + std::vector> new_loadorder; + new_loadorder.reserve(loadorders_[current_profile_].size()); + int i = 0; + for(const auto& group : conflict_groups_[current_profile_]) + { + for(int mod_id : group) + { + auto entry = str::find_if(loadorders_[current_profile_], + [mod_id](auto t) { return std::get<0>(t) == mod_id; }); + new_loadorder.emplace_back(mod_id, std::get<1>(*entry)); + } + i++; + } + loadorders_[current_profile_] = new_loadorder; +} + +std::vector> Deployer::getConflictGroups() const +{ + return conflict_groups_[current_profile_]; +} + +void Deployer::setConflictGroups(const std::vector>& newConflict_groups) +{ + conflict_groups_[current_profile_] = newConflict_groups; +} + +bool Deployer::usesCopyDeployment() const +{ + return use_copy_deployment_; +} + +void Deployer::setUseCopyDeployment(bool new_use_copy_deployment) +{ + use_copy_deployment_ = new_use_copy_deployment; +} + +bool Deployer::isAutonomous() +{ + return is_autonomous_; +} + +std::vector Deployer::getModNames() const +{ + return {}; +} + +std::filesystem::path Deployer::sourcePath() const +{ + return source_path_; +} + +void Deployer::setSourcePath(const sfs::path& newSourcePath) +{ + source_path_ = newSourcePath; +} + +std::pair, std::map> +Deployer::getDeploymentSourceFilesAndModSizes(const std::vector& loadorder) const +{ + std::map source_files{}; + std::map mod_sizes{}; + for(int i = loadorder.size() - 1; i >= 0; i--) + { + if(!checkModPathExistsAndMaybeLogError(loadorder[i])) + continue; + sfs::path mod_base_path = source_path_ / std::to_string(loadorder[i]); + unsigned long mod_size = 0; + for(auto const& dir_entry : sfs::recursive_directory_iterator(mod_base_path)) + { + const bool is_regular_file = dir_entry.is_regular_file(); + if(is_regular_file) + mod_size += dir_entry.file_size(); + if(is_regular_file || dir_entry.is_directory()) + source_files.insert({ pu::getRelativePath(dir_entry.path(), mod_base_path), loadorder[i] }); + } + mod_sizes[loadorder[i]] = mod_size; + } + return { source_files, mod_sizes }; +} + +void Deployer::backupOrRestoreFiles(const std::map& source_files, + const std::map& dest_files) const +{ + std::map restore_targets; + std::map backup_targets; + std::set_difference(dest_files.begin(), + dest_files.end(), + source_files.begin(), + source_files.end(), + std::inserter(restore_targets, restore_targets.begin()), + dest_files.value_comp()); + std::set_difference(source_files.begin(), + source_files.end(), + dest_files.begin(), + dest_files.end(), + std::inserter(backup_targets, backup_targets.begin()), + source_files.value_comp()); + + std::map restore_directories; + for(const auto& [path, id] : restore_targets) + { + sfs::path absolute_path = dest_path_ / path; + if(!sfs::exists(absolute_path)) + continue; + if(sfs::is_directory(absolute_path)) + { + restore_directories[path] = id; + continue; + } + sfs::path backup_name = absolute_path.string() + backup_extension_; + sfs::remove(absolute_path); + if(sfs::exists(backup_name)) + sfs::rename(backup_name, absolute_path); + } + for(const auto& [path, id] : restore_directories) + { + sfs::path absolute_path = dest_path_ / path; + if(pu::directoryIsEmpty(absolute_path)) + sfs::remove_all(absolute_path); + } + + for(const auto& [path, id] : backup_targets) + { + sfs::path absolute_path = dest_path_ / path; + sfs::path backup_name = absolute_path.string() + backup_extension_; + if(sfs::exists(absolute_path) && !sfs::is_directory(absolute_path)) + sfs::rename(absolute_path, backup_name); + } +} + +void Deployer::deployFiles(const std::map& source_files, + std::optional progress_node) const +{ + if(progress_node) + (*progress_node)->setTotalSteps(source_files.size()); + for(const auto& [path, id] : source_files) + { + sfs::path dest_path = dest_path_ / path; + if(!checkModPathExistsAndMaybeLogError(id)) + continue; + sfs::path source_path = source_path_ / std::to_string(id) / path; + if(sfs::is_directory(source_path) || + sfs::exists(dest_path) && sfs::equivalent(source_path, dest_path)) + { + if(progress_node) + (*progress_node)->advance(); + continue; + } + sfs::create_directories(dest_path.parent_path()); + sfs::remove(dest_path); + if(use_copy_deployment_) + sfs::copy_file(source_path, dest_path); + else + sfs::create_hard_link(source_path, dest_path); + if(progress_node) + (*progress_node)->advance(); + } +} + +std::map Deployer::loadDeployedFiles( + std::optional progress_node) const +{ + if(progress_node) + { + (*progress_node)->addChildren({ 1, 2 }); + (*progress_node)->child(0).setTotalSteps(1); + } + std::map deployed_files; + sfs::path deployed_files_path = dest_path_ / deployed_files_name_; + if(!sfs::exists(deployed_files_path)) + return deployed_files; + std::ifstream file(deployed_files_path, std::fstream::binary); + if(!file.is_open()) + throw std::runtime_error("Could not read \"" + deployed_files_path.string() + "\""); + Json::Value json_object; + file >> json_object; + if(progress_node) + { + (*progress_node)->child(0).advance(); + (*progress_node)->child(1).setTotalSteps(json_object["files"].size()); + } + for(int i = 0; i < json_object["files"].size(); i++) + { + deployed_files[json_object["files"][i]["path"].asString()] = + json_object["files"][i]["mod_id"].asInt(); + if(progress_node) + (*progress_node)->child(1).advance(); + } + return deployed_files; +} + +void Deployer::saveDeployedFiles(const std::map& deployed_files, + std::optional progress_node) const +{ + if(progress_node) + { + (*progress_node)->addChildren({ 1, 1 }); + (*progress_node)->child(0).setTotalSteps(deployed_files.size()); + (*progress_node)->child(1).setTotalSteps(1); + } + sfs::path deployed_files_path = dest_path_ / deployed_files_name_; + std::ofstream file(deployed_files_path, std::fstream::binary); + if(!file.is_open()) + throw std::runtime_error("Could not write \"" + deployed_files_path.string() + "\""); + Json::Value json_object; + int i = 0; + for(auto const& [path, id] : deployed_files) + { + json_object["files"][i]["path"] = path.c_str(); + json_object["files"][i]["mod_id"] = id; + i++; + if(progress_node) + (*progress_node)->child(0).advance(); + } + file << json_object; + file.close(); + if(progress_node) + (*progress_node)->child(1).advance(); +} + +std::unordered_set Deployer::getModFiles(int mod_id, bool include_directories) const +{ + std::unordered_set mod_files; + if(!checkModPathExistsAndMaybeLogError(mod_id)) + return mod_files; + sfs::path mod_base_path = source_path_ / std::to_string(mod_id); + for(const auto& dir_entry : sfs::recursive_directory_iterator(mod_base_path)) + { + if(!dir_entry.is_directory() || include_directories) + mod_files.insert(pu::getRelativePath(dir_entry.path(), mod_base_path)); + } + return mod_files; +} + +bool Deployer::checkModPathExistsAndMaybeLogError(int mod_id) const +{ + if(sfs::exists(source_path_ / std::to_string(mod_id))) + return true; + + log_(Log::LOG_ERROR, std::format("No installation directory exists for mod with id {}", mod_id)); + return false; +} + +void Deployer::updateConflictGroups(std::optional progress_node) +{ + log_(Log::LOG_INFO, std::format("Deployer '{}': Updating conflict groups...", name_)); + std::map file_map; + std::vector> groups; + std::vector non_conflicting; + // create groups + if(progress_node) + (*progress_node)->setTotalSteps(loadorders_[current_profile_].size()); + for(const auto& [mod_id, _] : loadorders_[current_profile_]) + { + if(!checkModPathExistsAndMaybeLogError(mod_id)) + continue; + std::string base_path = (source_path_ / std::to_string(mod_id)).string(); + for(const auto& dir_entry : sfs::recursive_directory_iterator(base_path)) + { + if(dir_entry.is_directory()) + continue; + const auto relative_path = pu::getRelativePath(dir_entry.path(), base_path); + if(!file_map.contains(relative_path)) + file_map[relative_path] = mod_id; + else + { + int other_id = file_map[relative_path]; + auto contains_id = [other_id](const auto& s) { return str::find(s, other_id) != s.end(); }; + auto group_iter = str::find_if(groups, contains_id); + if(group_iter != groups.end()) + group_iter->insert(mod_id); + else + groups.push_back({ other_id, mod_id }); + } + } + if(progress_node) + (*progress_node)->advance(); + } + std::vector> merged_groups; + // merge groups + for(int i = 0; i < groups.size(); i++) + { + if(groups[i].empty()) + continue; + std::set new_group = groups[i]; + bool found_intersection = true; + while(found_intersection) + { + found_intersection = false; + for(int j = i + 1; j < groups.size(); j++) + { + if(groups[j].empty()) + continue; + std::vector intersection; + std::set_intersection(new_group.begin(), + new_group.end(), + groups[j].begin(), + groups[j].end(), + std::back_inserter(intersection)); + if(!intersection.empty()) + { + found_intersection = true; + new_group.merge(groups[j]); + groups[j].clear(); + } + } + } + merged_groups.push_back(std::move(new_group)); + } + std::vector> sorted_groups(merged_groups.size() + 1, std::vector()); + // sort mods + for(const auto& [mod_id, _] : loadorders_[current_profile_]) + { + bool is_in_group = false; + for(int i = 0; i < merged_groups.size(); i++) + { + if(merged_groups[i].contains(mod_id)) + { + sorted_groups[i].push_back(mod_id); + is_in_group = true; + break; + } + } + if(!is_in_group) + sorted_groups[sorted_groups.size() - 1].push_back(mod_id); + } + conflict_groups_[current_profile_] = sorted_groups; + log_(Log::LOG_INFO, std::format("Deployer '{}': Conflict groups updated", name_)); +} + +void Deployer::setLog(const std::function& newLog) +{ + log_ = newLog; +} + +void Deployer::cleanup() +{ + deploy(std::vector{}); + if(sfs::exists(dest_path_ / deployed_files_name_)) + sfs::remove(dest_path_ / deployed_files_name_); +} + +bool Deployer::autoUpdateConflictGroups() const +{ + return auto_update_conflict_groups_; +} + +void Deployer::setAutoUpdateConflictGroups(bool status) +{ + auto_update_conflict_groups_ = status; +} + +std::optional Deployer::getModStatus(int mod_id) +{ + auto iter = str::find_if(loadorders_[current_profile_], + [mod_id](auto t) { return std::get<0>(t) == mod_id; }); + if(iter == loadorders_[current_profile_].end()) + return {}; + return { std::get<1>(*iter) }; +} + +std::vector> Deployer::getAutoTags() +{ + return {}; +} + +std::map Deployer::getAutoTagMap() +{ + return {}; +} diff --git a/src/core/deployer.h b/src/core/deployer.h new file mode 100644 index 0000000..173bee7 --- /dev/null +++ b/src/core/deployer.h @@ -0,0 +1,349 @@ +/*! + * \file deployer.h + * \brief Header for the Deployer class. + */ + +#pragma once + +#include "conflictinfo.h" +#include "log.h" +#include "progressnode.h" +#include +#include +#include +#include +#include + + +/*! + * \brief Handles deployment of mods to target directory. + */ +class Deployer +{ +public: + /*! + * \brief Constructor. + * \param source_path Path to directory containing mods installed using the Installer class. + * \param dest_path Path to target directory for mod deployment. + * \param name A custom name for this instance. + * \param use_copy_deployment If True: copy files during deployment, else use hard links. + */ + Deployer(const std::filesystem::path& source_path, + const std::filesystem::path& dest_path, + const std::string& name, + bool use_copy_deployment = false); + + /*! + * \brief Getter for path to deployment target directory. + * \return The path. + */ + std::string getDestPath() const; + /*! + * \brief Getter for deployer name. + * \return The name. + */ + std::string getName() const; + /*! + * \brief Setter for deployer name. + * \param name The new name. + */ + void setName(const std::string& name); + /*! + * \brief Deploys all mods to the target directory using hard links. + * If any file already exists in the target directory, a backup for that file is created. + * Previously backed up files are automatically restored if no mod in the current load order + * overwrites them. Conflicts are handled by overwriting mods earlier in the load order + * with later mods. + * \param loadorder A vector of mod ids representing the load order. + * \param progress_node Used to inform about the current progress of deployment. + * \return A map from deployed mod ids to their respective mods total size on disk. + */ + virtual std::map deploy(const std::vector& loadorder, + std::optional progress_node = {}); + /*! + * \brief Deploys all mods to the target directory using hard links. + * If any file already exists in the target directory, a backup for that file is created. + * Previously backed up files are automatically restored if no mod in the current load order + * overwrites them. Conflicts are handled by overwriting mods earlier in the load order + * with later mods. This function uses the internal load order. + * \param progress_node Used to inform about the current progress of deployment. + * \return A map from deployed mod ids to their respective mods total size on disk. + */ + virtual std::map deploy(std::optional progress_node = {}); + /*! + * \brief Setter for the load order used for deployment. + * \param loadorder The new load order. + */ + void setLoadorder(const std::vector>& loadorder); + /*! + * \brief Getter for the current mod load order. + * \return The load order. + */ + virtual std::vector> getLoadorder() const; + /*! + * \brief Returns the type of this deployer, i.e. SIMPLEDEPLOYER + * \return The type. + */ + std::string getType() const; + /*! + * \brief Moves a mod from one position in the load order to another. + * \param from_index Index of mod to be moved. + * \param to_index Destination index. + */ + virtual void changeLoadorder(int from_index, int to_index); + /*! + * \brief Appends a new mod to the load order. + * \param mod_id Id of the mod to be added. + * \param enabled Controls if the new mod will be enabled. + * \param update_conflicts If true: Update mod conflict groups. + * \return True iff the mod has been added. + */ + virtual bool addMod(int mod_id, bool enabled = true, bool update_conflicts = true); + /*! + * \brief Removes a mod from the load order. + * \param mod_id Id of the mod to be removed. + * \return True iff the mod has been removed. + */ + virtual bool removeMod(int mod_id); + /*! + * \brief Enables or disables the given mod in the load order. + * \param mod_id Mod to be edited. + * \param status The new status. + */ + virtual void setModStatus(int mod_id, bool status); + /*! + * \brief Checks if given mod id is part of the load order. + * \param mod_id Mod to be checked. + * \return True is mod is in load order, else false. + */ + virtual bool hasMod(int mod_id) const; + /*! + * \brief Checks for file conflicts of given mod with all other mods in the load order. + * \param mod_id Mod to be checked. + * \param show_disabled If true: Also check for conflicts with disabled mods. + * \param progress_node Used to inform about the current progress. + * \return A vector with information about conflicts with every other mod. + */ + virtual std::vector getFileConflicts( + int mod_id, + bool show_disabled = false, + std::optional progress_node = {}) const; + /*! + * \brief Returns the number of mods in the load order. + * \return The number of mods. + */ + virtual int getNumMods() const; + /*! + * \brief Getter for path to deployment target directory. + * \return The path. + */ + const std::filesystem::path& destPath() const; + /*! + * \brief Setter for path to deployment target directory. + * \param newDest_path the new path. + */ + void setDestPath(const std::filesystem::path& path); + /*! + * \brief Checks for conflicts with other mods. + * Two mods are conflicting if they share at least one file. + * \param mod_id The mod to be checked. + * \param progress_node Used to inform about the current progress. + * \return A set of mod ids which conflict with the given mod. + */ + virtual std::unordered_set getModConflicts(int mod_id, + std::optional progress_node = {}); + /*! + * \brief Adds a new profile and optionally copies it's load order from an existing profile. + * \param source The profile to be copied. A value of -1 indicates no copy. + */ + virtual void addProfile(int source = -1); + /*! + * \brief Removes a profile. + * \param profile The profile to be removed. + */ + virtual void removeProfile(int profile); + /*! + * \brief Setter for the active profile. + * \param profile The new profile. + */ + virtual void setProfile(int profile); + /*! + * \brief Getter for the active profile. + * \return The profile. + */ + int getProfile() const; + /*! + * \brief Checks if writing to the deployment directory is possible. + * \return A code indicating success(0), an IO error(1) or an error during link creation(2). + */ + int verifyDirectories(); + /*! + * \brief Replaces the given id in the load order with a new id. + * \param old_id The mod to be replaced. + * \param new_id The new mod. + * \return True iff the mod has been swapped. + */ + virtual bool swapMod(int old_id, int new_id); + /*! + * \brief Sorts the load order by grouping mods which contain conflicting files. + * \param progress_node Used to inform about the current progress. + */ + virtual void sortModsByConflicts(std::optional progress_node = {}); + /*! + * \brief Getter for the conflict groups of the current profile. + * \return The conflict groups. + */ + virtual std::vector> getConflictGroups() const; + /*! + * \brief Setter for the conflict groups of the current profile. + * \param newConflict_groups The new conflict groups. + */ + virtual void setConflictGroups(const std::vector>& newConflict_groups); + /*! + * \brief Getter for use_copy_deployment_. + * \return True if this copies files during deployment, else use hard links. + */ + bool usesCopyDeployment() const; + /*! + * \brief Sets the copy deployment. + * \param newUse_copy_deployment If true: copy files during deployment, else use hard links. + */ + void setUseCopyDeployment(bool newUse_copy_deployment); + /*! \brief Getter for is_autonomous_. */ + bool isAutonomous(); + /*! + * \brief Autonomous deployers override this tho provide names for their mods. + * Non Autonomous deployers return an empty vector. + * \return The mod name vector. + */ + virtual std::vector getModNames() const; + /*! \brief Getter for mod source path. */ + std::filesystem::path sourcePath() const; + /*! + * \brief Setter for mod source path. + * \param New source path. + */ + void setSourcePath(const std::filesystem::path& newSourcePath); + /*! + * \brief Setter for log callback. + * \param newLog New log callback + */ + void setLog(const std::function& newLog); + /*! + * \brief Removes all deployed mods from the target directory and deletes the file + * which stores the state of this deployer. + */ + virtual void cleanup(); + /*! + * \brief Updates conflict_groups_ for the current profile. + * \param progress_node Used to inform about the current progress. + */ + void updateConflictGroups(std::optional progress_node = {}); + /*! \brief Getter for \ref auto_update_conflict_groups_. */ + bool autoUpdateConflictGroups() const; + /*! \brief Setter for \ref auto_update_conflict_groups_. */ + void setAutoUpdateConflictGroups(bool status); + /*! + * \brief Searches the load order for the given mod id and returns the corresponding mods + * activation status, if found. + * \param mod_id Mod to be found. + * \return The activation status, if found. + */ + std::optional getModStatus(int mod_id); + /*! + * \brief Getter for auto tags. + * Only implemented in autonomous deployers. + * \return For every mod: A vector of auto tags added to that mod. + */ + virtual std::vector> getAutoTags(); + /*! + * \brief Returns all available auto tag names mapped to the number of mods for that tag. + * Only implemented in autonomous deployers. + * \return The tag map. + */ + virtual std::map getAutoTagMap(); + +protected: + /*! \brief Type of this deployer, e.g. Simple Deployer. */ + std::string type_ = "Simple Deployer"; + /*! \brief Path to the directory containing all mods which are to be deployed. */ + std::filesystem::path source_path_; + /*! \brief Path to the directory where all mods are deployed to. */ + std::filesystem::path dest_path_; + /*! \brief The file extension appended to backed up files. */ + const std::string backup_extension_ = ".lmmbak"; + /*! \brief The file name for a file in the target directory containing names of deployed files*/ + const std::string deployed_files_name_ = ".lmmfiles"; + /*! \brief The name of this deployer. */ + std::string name_; + /*! \brief The currently active profile. */ + int current_profile_ = 0; + /*! \brief One load order per profile consisting of tuples of mod ids and their enabled status. */ + std::vector>> loadorders_; + /*! + * \brief For every profile: Groups of mods which conflict with each other. The last + * group contains mods with no conflicts. + */ + std::vector>> conflict_groups_; + /*! \brief If false: Use hard links to deploy mods, else: copy files. */ + bool use_copy_deployment_ = false; + /*! \brief Autonomous deployers manage their own mods and do not rely on ModdedApplication. */ + bool is_autonomous_ = false; + /*! \brief If true: Automatically update conflict groups when necessary. */ + bool auto_update_conflict_groups_ = false; + + /*! + * \brief Creates a pair of maps. One maps relative file paths to the mod id from which that + * file is to be deployed. The other maps mod ids to their total file size on disk. + * \param loadorder The load order used for file checks. + * \return The generated maps. + */ + std::pair, std::map> + getDeploymentSourceFilesAndModSizes(const std::vector& loadorder) const; + /*! + * \brief Backs up all files which would be overwritten during deployment and restores all + * files backed up during previous deployments files which are no longer overwritten. + * \param source_files A map of files to be deployed to their source mods. + * \param dest_files A map of files currently deployed to their source mods. + */ + void backupOrRestoreFiles(const std::map& source_files, + const std::map& dest_files) const; + /*! + * \brief Hard links all given files to target directory. + * \param source_files A map of files to be deployed to their source mods. + * \param progress_node Used to inform about the current progress of deployment. + */ + void deployFiles(const std::map& source_files, + std::optional progress_node = {}) const; + /*! + * \brief Creates a map of currently deployed files to their source mods. + * \param progress_node Used to inform about the current progress. + * \return The map. + */ + std::map loadDeployedFiles( + std::optional progress_node = {}) const; + /*! + * \brief Creates a file containing information about currently deployed files. + * \param deployed_files The currently deployed files. + * \param progress_node Used to inform about the current progress. + */ + void saveDeployedFiles(const std::map& deployed_files, + std::optional progress_node = {}) const; + /*! + * \brief Creates a set containing every file contained in one mod. Files are + * represented as paths relative to the mods root directory. + * \param mod_id Target mod. + * \param include_directories If true: Also include all directories in the mod. + * \return The set of files. + */ + std::unordered_set getModFiles(int mod_id, bool include_directories = false) const; + /*! \brief Callback for logging. */ + std::function log_ = [](Log::LogLevel a, + const std::string& b) {}; + /*! + * \brief Checks if the directory containing the given mod exists, if not logs an error. + * \param mod_id If of the mod to check. + * \return True if the directory exists, else false. + */ + bool checkModPathExistsAndMaybeLogError(int mod_id) const; +}; diff --git a/src/core/deployerfactory.cpp b/src/core/deployerfactory.cpp new file mode 100644 index 0000000..ec388d9 --- /dev/null +++ b/src/core/deployerfactory.cpp @@ -0,0 +1,21 @@ +#include "deployerfactory.h" +#include "casematchingdeployer.h" +#include "lootdeployer.h" + + +std::unique_ptr DeployerFactory::makeDeployer(const std::string& type, + const std::filesystem::path& source_path, + const std::filesystem::path& dest_path, + const std::string& name, + bool use_copy_deployment) +{ + if(type == SIMPLEDEPLOYER) + return std::make_unique(source_path, dest_path, name, use_copy_deployment); + else if(type == CASEMATCHINGDEPLOYER) + return std::make_unique( + source_path, dest_path, name, use_copy_deployment); + else if(type == LOOTDEPLOYER) + return std::make_unique(source_path, dest_path, name); + else + throw std::runtime_error("Unknown deployer type \"" + type + "\"!"); +} diff --git a/src/core/deployerfactory.h b/src/core/deployerfactory.h new file mode 100644 index 0000000..2fbc750 --- /dev/null +++ b/src/core/deployerfactory.h @@ -0,0 +1,61 @@ +#pragma once + +#include "deployer.h" + + +class DeployerFactory +{ +public: + DeployerFactory() = delete; + + /*! \brief Performs no additional actions. */ + inline static const std::string SIMPLEDEPLOYER{ "Simple Deployer" }; + /*! + * \brief Uses case insensitive string matching when comparing + * mod file names with target file names. + */ + inline static const std::string CASEMATCHINGDEPLOYER{ "Case Matching Deployer" }; + inline static const std::string LOOTDEPLOYER{ "Loot Deployer" }; + /*! + * \brief Returns a vector of available deployer types. + * \return The vector of deployer types. + */ + /*! \brief Contains all available deployer types. */ + inline static const std::vector DEPLOYER_TYPES{ CASEMATCHINGDEPLOYER, + SIMPLEDEPLOYER, + LOOTDEPLOYER }; + /*! \brief Maps deployer types to a description of what they do. */ + inline static const std::map DEPLOYER_DESCRIPTIONS{ + { SIMPLEDEPLOYER, + "Links/ copies all files from enabled mods in its loadorder into " + "target directory. Backs up and restores existing files when needed." }, + { CASEMATCHINGDEPLOYER, + "When the target directory contains a file with the same name " + "but different case as a mods file name, renames the mods name to " + "match the target file. Then deploys as normal." }, + { LOOTDEPLOYER, + "Uses LOOT to manage plugins for games like Skyrim. Source path " + "should point to the directory which plugins are installed into." + "Target path should point to the directory containing plugins.txt " + "and loadorder.txt" } + }; + /*! \brief Maps deployer types to a bool indicating + * if the type refers to an autonomous deployer. */ + inline static const std::map AUTONOMOUS_DEPLOYERS{ { SIMPLEDEPLOYER, false }, + { CASEMATCHINGDEPLOYER, + false }, + { LOOTDEPLOYER, true } }; + /*! + * \brief Constructs a unique pointer to a new deployer of given type. + * \param type Deployer type to be constructed. + * \param source_path Path to directory containing mods installed using the Installer class. + * \param dest_path Path to target directory for mod deployment. + * \param name A custom name for this instance. + * \return The constructed unique pointer. + */ + static std::unique_ptr makeDeployer(const std::string& type, + const std::filesystem::path& source_path, + const std::filesystem::path& dest_path, + const std::string& name, + bool use_copy_deployment = false); +}; diff --git a/src/core/deployerinfo.h b/src/core/deployerinfo.h new file mode 100644 index 0000000..6ed913f --- /dev/null +++ b/src/core/deployerinfo.h @@ -0,0 +1,32 @@ +/*! + * \file deployerinfo.h + * \brief Contains the DeployerInfo struct. + */ + +#pragma once + +#include +#include +#include + + +/*! + * \brief Stores a \ref Deployer "deployer's" installed mods and load order. + */ +struct DeployerInfo +{ + /*! \brief Names of the mods managed by this deployer, in their load order. */ + std::vector mod_names; + /*! \brief The \ref Deployer "deployer's" load order. */ + std::vector> loadorder; + /*! \brief Contains groups of mods which conflict with each other. */ + std::vector> conflict_groups; + /*! \brief If true: Deployer manages its own mods and does not rely on ModdedApplication. */ + bool is_autonomous = false; + /*! \brief For every mod: A vector of manual tags added to that mod. */ + std::vector> manual_tags; + /*! \brief For every mod: A vector of auto tags added to that mod. */ + std::vector> auto_tags; + /*! \brief Maps tag names to the number of mods for that tag. */ + std::map mods_per_tag; +}; diff --git a/src/core/editapplicationinfo.h b/src/core/editapplicationinfo.h new file mode 100644 index 0000000..3eb2ed3 --- /dev/null +++ b/src/core/editapplicationinfo.h @@ -0,0 +1,38 @@ +/*! + * \file editapplicationinfo.h + * \brief Contains the EditApplicationInfo struct. + */ + +#pragma once + +#include +#include + + +/*! + * \brief Stores data needed to either create a new or edit an existing + * \ref ModdedApplication "application". + */ +struct EditApplicationInfo +{ + /*! \brief New name of the application. */ + std::string name; + /*! \brief Path to the staging directory. */ + std::string staging_dir; + /*! \brief Command used to run the application. */ + std::string command; + /*! + * \brief When creating a new application, this contains names and target paths + * for initial deployers. + */ + std::vector> deployers; + /*! + * \brief When editing an application, this indicates whether to move the existing + * staging directory to the new path specified in staging_dir. + */ + bool move_staging_dir = false; + /*! \brief Path to the applications icon. */ + std::string icon_path; + /*! \brief Version of the app. This is used for FOMOD conditions. */ + std::string app_version; +}; diff --git a/src/core/editautotagaction.cpp b/src/core/editautotagaction.cpp new file mode 100644 index 0000000..13e534c --- /dev/null +++ b/src/core/editautotagaction.cpp @@ -0,0 +1,49 @@ +#include "editautotagaction.h" + +EditAutoTagAction::EditAutoTagAction(const std::string& name, ActionType type) +{ + name_ = name; + type_ = type; +} + +EditAutoTagAction::EditAutoTagAction(const std::string& name, const std::string& new_name) +{ + name_ = name; + new_name_ = new_name; + type_ = ActionType::rename; +} + +EditAutoTagAction::EditAutoTagAction(const std::string& name, + const std::string& expression, + const std::vector& conditions) +{ + name_ = name; + expression_ = expression; + conditions_ = conditions; + type_ = ActionType::change_evaluator; +} + +std::string EditAutoTagAction::getName() const +{ + return name_; +} + +std::string EditAutoTagAction::getNewName() const +{ + return new_name_; +} + +EditAutoTagAction::ActionType EditAutoTagAction::getType() const +{ + return type_; +} + +std::string EditAutoTagAction::getExpression() const +{ + return expression_; +} + +std::vector EditAutoTagAction::getConditions() const +{ + return conditions_; +} diff --git a/src/core/editautotagaction.h b/src/core/editautotagaction.h new file mode 100644 index 0000000..fcdda99 --- /dev/null +++ b/src/core/editautotagaction.h @@ -0,0 +1,92 @@ +/*! + * \file editautotagaction.h + * \brief Header for the EditAutoTagAction class. + */ + +#pragma once + +#include "tagcondition.h" +#include +#include + + +/*! + * \brief Contains data relevent for the action of editing an auto tag. + */ +class EditAutoTagAction +{ +public: + /*! \brief Represents the type of action performed. */ + enum class ActionType + { + /*! \brief Add a new tag. */ + add, + /*! \brief Remove an existing tag. */ + remove, + /*! \brief Rename a tag. */ + rename, + /*! \brief Create a new evaluator. */ + change_evaluator + }; + + /*! + * \brief Constructor for an add or remove action. + * \param name Name of the tag to be added/ removed. + * \param type Action type. + */ + EditAutoTagAction(const std::string& name, ActionType type); + /*! + * \brief Constructor for a rename action. + * \param name Name of the tag to be renamed. + * \param new_name New name for the tag. + */ + EditAutoTagAction(const std::string& name, const std::string& new_name); + /*! + * \brief Constructor for a change_evaluator action. + * \param name Name of the tag the evaluator of which is to be updated. + * \param expression New evaluator expression. + * \param conditions New evaluator conditions. + */ + EditAutoTagAction(const std::string& name, + const std::string& expression, + const std::vector& conditions); + + + /*! + * \brief Getter for the target tags name. + * \return The name. + */ + std::string getName() const; + /*! + * \brief Getter for the new name. + * \return The new name. + */ + std::string getNewName() const; + /*! + * \brief Getter for the ActionType to be performed. + * \return The ActionType. + */ + ActionType getType() const; + /*! + * \brief Getter for the expression of the updated evaluator. + * \return The expression. + */ + std::string getExpression() const; + /*! + * \brief Getter for the conditions of the updated evaluator. + * \return The conditions. + */ + std::vector getConditions() const; + +private: + /*! \brief The target tags name. */ + std::string name_; + /*! \brief The target tags new name, if ActionType == rename. */ + std::string new_name_; + /*! \brief The type of action to be performed. */ + ActionType type_; + /*! \brief Expression used to generate a new evaluator. */ + std::string expression_; + /*! \brief Conditions used to generate a new evaluator. */ + std::vector conditions_; +}; diff --git a/src/core/editdeployerinfo.h b/src/core/editdeployerinfo.h new file mode 100644 index 0000000..e1fff9d --- /dev/null +++ b/src/core/editdeployerinfo.h @@ -0,0 +1,27 @@ +/*! + * \file editdeployerinfo.h + * \brief Contains the EditDeployerInfo struct. + */ + +#pragma once + +#include + + +/*! + * \brief Stores data needed to either create a new or edit an existing + * \ref Deployer "deployer". + */ +struct EditDeployerInfo +{ + /*! \brief Type of the deployer. */ + std::string type; + /*! \brief Name of the deployer */ + std::string name; + /*! \brief This is where the deployer will deploy to. */ + std::string target_dir; + /*! \brief If true: Copy mods to target directory, else: use hard links. */ + bool use_copy_deployment; + /*! \brief The deployers mod source directory. Only used by autonomous deployers. */ + std::string source_dir = ""; +}; diff --git a/src/core/editmanualtagaction.cpp b/src/core/editmanualtagaction.cpp new file mode 100644 index 0000000..de9d9c4 --- /dev/null +++ b/src/core/editmanualtagaction.cpp @@ -0,0 +1,22 @@ +#include "editmanualtagaction.h" + +EditManualTagAction::EditManualTagAction(const std::string& name, + ActionType type, + const std::string& new_name) : + name_(name), type_(type), new_name_(new_name) +{} + +std::string EditManualTagAction::getName() const +{ + return name_; +} + +std::string EditManualTagAction::getNewName() const +{ + return new_name_; +} + +EditManualTagAction::ActionType EditManualTagAction::getType() const +{ + return type_; +} diff --git a/src/core/editmanualtagaction.h b/src/core/editmanualtagaction.h new file mode 100644 index 0000000..ae025a6 --- /dev/null +++ b/src/core/editmanualtagaction.h @@ -0,0 +1,60 @@ +/*! + * \file editmanualtagaction.h + * \brief Header for the EditManualTagAction class. + */ + +#pragma once + +#include + + +/*! + * \brief Contains data relevent for the action of editing a manual tag. + */ +class EditManualTagAction +{ +public: + /*! \brief Represents the type of action performed. */ + enum class ActionType + { + /*! \brief Add a new tag. */ + add, + /*! \brief Remove an existing tag. */ + remove, + /*! \brief Rename a tag. */ + rename + }; + + /*! + * \brief Constructor. + * \param name Name of the tag to be edited. + * \param type Type of editing action to be performed. + * \param new_name Contains the tags new name, if action is of type Rename. + */ + EditManualTagAction(const std::string& name, ActionType type, const std::string& new_name = ""); + + + /*! + * \brief Getter for the target tags name. + * \return The name. + */ + std::string getName() const; + /*! + * \brief Getter for the new name. + * \return The new name. + */ + std::string getNewName() const; + /*! + * \brief Getter for the ActionType to be performed. + * \return The ActionType. + */ + ActionType getType() const; + +private: + /*! \brief The target tags name. */ + std::string name_; + /*! \brief The target tags new name, if ActionType == rename. */ + std::string new_name_; + /*! \brief The type of action to be performed. */ + ActionType type_; +}; diff --git a/src/core/editprofileinfo.h b/src/core/editprofileinfo.h new file mode 100644 index 0000000..5665391 --- /dev/null +++ b/src/core/editprofileinfo.h @@ -0,0 +1,24 @@ +/*! + * \file editprofileinfo.h + * \brief Contains the EditProfileInfo struct. + */ + +#pragma once + +#include + + +/*! + * \brief Stores data needed to either create a new or edit an existing + * profile of a \ref ModdedApplication "application". + */ +struct EditProfileInfo +{ + /*! \brief The new name of the profile. */ + std::string name; + /*! \brief The new app version of the profile. Used for FOMOD conditions. */ + std::string app_version; + /*! \brief If a new profile is created and this is != -1: Copy all settings from source profile. + */ + int source = -1; +}; diff --git a/src/core/fomod/dependency.cpp b/src/core/fomod/dependency.cpp new file mode 100644 index 0000000..4187d3c --- /dev/null +++ b/src/core/fomod/dependency.cpp @@ -0,0 +1,139 @@ +#include "dependency.h" +#include "../pathutils.h" + +using namespace fomod; +namespace sfs = std::filesystem; +namespace pu = path_utils; + + +Dependency::Dependency(pugi::xml_node source) +{ + if(!source) + { + type_ = dummy_node; + return; + } + const std::string name(source.name()); + if(name == "dependencies" || name == "moduleDependencies") + { + type_ = source.attribute("operator").value() == std::string("Or") ? or_node : and_node; + std::map> file_dependencies; + for(auto child : source.children()) + { + if(type_ == or_node && child && std::string(child.name()) == "fileDependency") + { + const std::string target = child.attribute("file").value(); + if(!file_dependencies.contains(target)) + file_dependencies[target] = {child.attribute("state").value(), child}; + else + { + const std::string child_state = child.attribute("state").value(); + if(child_state == "Active" && file_dependencies[target].first != "Active") + file_dependencies[target] = {"Active", child}; + } + } + else + children_.emplace_back(child); + } + for(const auto& [target, pair]: file_dependencies) + children_.emplace_back(pair.second); + } + else if(name == "fileDependency") + { + type_ = file_leaf; + target_ = source.attribute("file").value(); + state_ = source.attribute("state").value(); + } + else if(name == "flagDependency") + { + type_ = flag_leaf; + target_ = source.attribute("flag").value(); + state_ = source.attribute("value").value(); + } + else if(name == "gameDependency") + { + type_ = game_version_leaf; + target_ = source.attribute("version").value(); + } + else if(name == "fommDependency") + { + type_ = fomm_version_leaf; + target_ = source.attribute("version").value(); + } +} + +Dependency::Dependency() +{ + type_ = dummy_node; +} + +bool Dependency::evaluate(const sfs::path& target_path, + const std::map& flags, + std::function eval_game_version, + std::function eval_fomm_version) const +{ + if(type_ == and_node) + { + if(children_.empty()) + return true; + for(const auto& child : children_) + { + if(!child.evaluate(target_path, flags, eval_game_version, eval_fomm_version)) + return false; + } + return true; + } + else if(type_ == or_node) + { + if(children_.empty()) + return true; + for(const auto& child : children_) + { + if(child.evaluate(target_path, flags, eval_game_version, eval_fomm_version)) + return true; + } + return false; + } + else if(type_ == file_leaf) + { + const bool exists = pu::pathExists(target_, target_path) ? true : false; + if(state_ == "Active") + return exists; + return !exists; + } + else if(type_ == flag_leaf) + { + if(!flags.contains(target_)) + return false; + return flags.at(target_) == state_; + } + else if(type_ == game_version_leaf) + return eval_game_version(target_); + else if(type_ == fomm_version_leaf) + return eval_fomm_version(target_); + return true; +} + +std::string Dependency::toString() const +{ + if(type_ == file_leaf) + return "(File '" + target_ + "' is '" + state_ + "')"; + else if(type_ == flag_leaf) + return "(Flag '" + target_ + "' is '" + state_ + "')"; + else if(type_ == game_version_leaf) + return "(Game version == '" + target_ + "')"; + else if(type_ == fomm_version_leaf) + return "(Fomm version == '" + target_ + "')"; + else + { + std::string op = type_ == or_node ? "OR" : "AND"; + std::string chain = "( "; + for(int i = 0; i < children_.size(); i++) + { + chain += children_.at(i).toString(); + if(i < children_.size() - 1) + chain += " " + op + " "; + } + return chain + " )"; + } +} diff --git a/src/core/fomod/dependency.h b/src/core/fomod/dependency.h new file mode 100644 index 0000000..bd1011e --- /dev/null +++ b/src/core/fomod/dependency.h @@ -0,0 +1,83 @@ +/*! + * \file fomoddependency.h + * \brief Header for the FomodDependency class and FomodFile struct. + */ + +#pragma once + +#include "pugixml.hpp" +#include +#include +#include +#include +#include +#include + + +/*! + * \brief The fomod namespace contains classes used for parsing a FOMOD xml file and for + * creating an installer. + */ +namespace fomod +{ +/*! + * \brief Represents a fomod dependency tree node. + */ +class Dependency +{ + /*! \brief Represents different dependency types. */ + enum Type + { + /*! \brief Always evaluates to true. */ + dummy_node, + /*! \brief True if all children evaluate to true. */ + and_node, + /*! \brief True if at least one child evaluates to true. */ + or_node, + /*! \brief File must exist. */ + file_leaf, + /*! \brief Flag must be set. */ + flag_leaf, + /*! \brief Game version must be == some version. */ + game_version_leaf, + /*! \brief Fomm version must be == some version. */ + fomm_version_leaf + }; + + +public: + /*! + * \brief Recursively builds a dependency tree from given fomod node. + * \param source Source fomod node. + */ + Dependency(pugi::xml_node source); + /*! \brief Constructs a dummy node. */ + Dependency(); + + /*! + * \brief Checks given flags, files, game version and fomm version fulfill the condition + * represented by this tree. + * \param target_path Path to target files. + * \param flags Flags to be checked. + * \param eval_game_version Used to check if this nodes game version is valid. + * \param eval_fomm_version Used to check if this nodes fomm version is valid. + * \return True if conditions are met, else false. + */ + bool evaluate( + const std::filesystem::path& target_path, + const std::map& flags, + std::function eval_game_version, + std::function eval_fomm_version = [](auto s) { return true; }) const; + std::string toString() const; + +private: + /*! \brief Type of this dependency. */ + Type type_; + /*! \brief Value for comparison, e.g. file path for a file dependency. */ + std::string target_; + /*! \brief State of file or flag. */ + std::string state_; + /*! \brief Children of this node. */ + std::vector children_; +}; +} diff --git a/src/core/fomod/file.h b/src/core/fomod/file.h new file mode 100644 index 0000000..cd7bd48 --- /dev/null +++ b/src/core/fomod/file.h @@ -0,0 +1,49 @@ +/*! + * \file file.h + * \brief Header for the File struct. + */ + +#pragma once + +#include + + +/*! + * \brief The fomod namespace contains classes used for parsing a FOMOD xml file and for + * creating an installer. + */ +namespace fomod +{ +/*! + * \brief Holds data regarding the installation of a single file in a fomod configuration. + */ +struct File +{ + /*! \brief Source path, relative to mods root directory. */ + std::filesystem::path source; + /*! \brief Destination path, relative to target root.*/ + std::filesystem::path destination = ""; + /*! \brief If True: Always install, regardless of selection. */ + bool always_install = false; + /*! \brief If True: Always install if dependencies are fulfilled. */ + bool install_if_usable = false; + /*! \brief If two files share a destination, the higher priority file gets installed. */ + int priority = -std::numeric_limits::max(); + + /*! + * \brief Compares two File objects by their destination. + * \param other Other File. + * \return True if destinations are equal. + */ + bool operator==(const File& other) const + { + return destination.string() == other.destination.string(); + } + /*! + * \brief Compares two File objects by their priority. + * \param other Other File. + * \return True if this has lower priority. + */ + bool operator<(const File& other) const { return priority < other.priority; } +}; +} diff --git a/src/core/fomod/fomodinstaller.cpp b/src/core/fomod/fomodinstaller.cpp new file mode 100644 index 0000000..2f4e52a --- /dev/null +++ b/src/core/fomod/fomodinstaller.cpp @@ -0,0 +1,411 @@ +#include "fomodinstaller.h" +#include "../log.h" +#include "../pathutils.h" +#include +#include +#include +#include + +using namespace fomod; +namespace sfs = std::filesystem; +namespace pu = path_utils; + + +void FomodInstaller::init(const sfs::path& config_file, + const sfs::path& target_path, + const std::string& app_version) +{ + if(!app_version.empty()) + version_eval_fun_ = [app_version](std::string version) { return app_version == version; }; + cur_step_ = -1; + config_file_.reset(); + files_.clear(); + steps_.clear(); + int cur_step_ = -1; + flags_.clear(); + prev_selections_.clear(); + target_path_ = target_path; + if(sfs::is_directory(config_file)) + { + mod_base_path_ = config_file; + auto [fomod_dir_name, config_file_name] = getFomodPath(config_file); + config_file_.load_file((config_file / fomod_dir_name / config_file_name).c_str()); + } + else + { + mod_base_path_ = config_file.parent_path().parent_path(); + config_file_.load_file(config_file.c_str()); + } + config_ = config_file_.child("config"); + auto file_list = config_.child("requiredInstallFiles"); + if(file_list) + parseFileList(file_list, files_); + auto steps = config_.child("installSteps"); + if(steps) + parseInstallSteps(steps); +} + +std::optional FomodInstaller::step(const std::vector>& selection) +{ + updateState(selection); + for(int i = cur_step_ + 1; i < steps_.size(); i++) + { + if(steps_[i].dependencies.evaluate(target_path_, flags_, version_eval_fun_, fomm_eval_fun_)) + { + for(auto& group : steps_[i].groups) + { + for(auto& plugin : group.plugins) + plugin.updateType(target_path_, flags_, version_eval_fun_, fomm_eval_fun_); + } + if(cur_step_ > -1) + prev_selections_.push_back(selection); + cur_step_ = i; + return steps_[i]; + } + } + return {}; +} + +std::optional>, InstallStep>> FomodInstaller::stepBack() +{ + if(cur_step_ < 1) + return {}; + files_.clear(); + flags_.clear(); + for(auto& step : steps_) + { + for(auto& group : step.groups) + { + for(auto& plugin : group.plugins) + plugin.updateType(target_path_, flags_, version_eval_fun_, fomm_eval_fun_); + } + } + if(prev_selections_.size() == 1) + { + cur_step_ = -1; + auto old_selection = prev_selections_[0]; + prev_selections_.clear(); + return { { old_selection, *step() } }; + } + cur_step_ = -1; + auto old_selections = prev_selections_; + prev_selections_.clear(); + step(); + for(int i = 0; i < old_selections.size() - 2; i++) + step(old_selections[i]); + int idx = old_selections.size() - 2; + return { { old_selections[idx + 1], *step(old_selections[idx]) } }; +} + +bool FomodInstaller::hasNextStep(const std::vector>& selection) const +{ + if(cur_step_ == steps_.size() - 1) + return false; + std::map cur_flags = flags_; + int group_idx = 0; + if(!selection.empty()) + { + for(auto& group : steps_[cur_step_].groups) + { + int plugin_idx = 0; + for(auto& plugin : group.plugins) + { + if(!selection[group_idx][plugin_idx]) + { + plugin_idx++; + continue; + } + for(const auto& [key, value] : plugin.flags) + cur_flags[key] = value; + plugin_idx++; + } + group_idx++; + } + } + for(int i = cur_step_ + 1; i < steps_.size(); i++) + { + if(steps_[i].dependencies.evaluate(target_path_, cur_flags, version_eval_fun_, fomm_eval_fun_)) + return true; + } + return false; +} + +bool FomodInstaller::hasNoSteps() const +{ + return steps_.empty(); +} + +std::pair FomodInstaller::getMetaData(const sfs::path& path) +{ + pugi::xml_document doc; + auto [dir_name, file_name] = getFomodPath(path, "info.xml"); + doc.load_file((path / dir_name / file_name).c_str()); + return { doc.child("fomod").child_value("Name"), doc.child("fomod").child_value("Version") }; +} + +std::vector> FomodInstaller::getInstallationFiles( + const std::vector>& selection) +{ + updateState(selection); + parseInstallList(); + std::vector> files; + for(const auto& file : files_) + files.emplace_back(file.source, file.destination); + return files; +} + +bool FomodInstaller::hasPreviousStep() const +{ + return cur_step_ > 0; +} + +void FomodInstaller::parseFileList(const pugi::xml_node& file_list, + std::vector& target_vector, + bool warn_missing) +{ + for(auto file : file_list.children()) + { + File new_file; + const auto source_path = pu::normalizePath(file.attribute("source").value()); + auto source_path_optional = pu::pathExists(source_path, mod_base_path_); + if(!source_path_optional) + { + if(warn_missing) + Log::warning(std::format("Fomod requires installation of non existent file '{}'", + (mod_base_path_ / source_path).string())); + continue; + } + new_file.source = *source_path_optional; + auto dest = file.attribute("destination"); + if(dest) + new_file.destination = pu::normalizePath(dest.value()); + else + new_file.destination = new_file.source; + auto always_install = file.attribute("alwaysInstall"); + if(always_install) + new_file.always_install = always_install.as_bool(); + auto install_if_usable = file.attribute("installIfUsable"); + if(install_if_usable) + new_file.install_if_usable = install_if_usable.as_bool(); + auto priority = file.attribute("priority"); + if(priority) + new_file.priority = priority.as_int(); + target_vector.push_back(new_file); + } +} + +void FomodInstaller::parseInstallSteps(const pugi::xml_node& steps) +{ + for(const auto& step : steps.children()) + { + InstallStep cur_step; + cur_step.name = step.attribute("name").value(); + if(step.child("visible")) + cur_step.dependencies = *(step.child("visible").children().begin()); + for(const auto& group : step.child("optionalFileGroups").children()) + { + PluginGroup cur_group; + cur_group.name = group.attribute("name").value(); + cur_group.type = parseGroupType(group.attribute("type").value()); + for(const auto& plugin : group.child("plugins").children()) + { + Plugin cur_plugin; + initPlugin(plugin, cur_plugin); + cur_group.plugins.push_back(cur_plugin); + } + sortVector(cur_group.plugins, group.child("plugins").attribute("order").value()); + cur_step.groups.push_back(cur_group); + } + sortVector(cur_step.groups, step.child("optionalFileGroups").attribute("order").value()); + steps_.push_back(cur_step); + } + sortVector(steps_, steps.attribute("order").value()); +} + +PluginGroup::Type FomodInstaller::parseGroupType(const std::string& type) +{ + if(type == "SelectAtLeastOne") + return PluginGroup::at_least_one; + else if(type == "SelectAtMostOne") + return PluginGroup::at_most_one; + else if(type == "SelectExactlyOne") + return PluginGroup::exactly_one; + else if(type == "SelectAll") + return PluginGroup::all; + return PluginGroup::any; +} + +void FomodInstaller::parseInstallList() +{ + auto root = config_.child("conditionalFileInstalls"); + if(!root) + return; + for(const auto& pattern : root.child("patterns").children()) + { + if(!Dependency(pattern.child("dependencies")) + .evaluate(target_path_, flags_, version_eval_fun_, fomm_eval_fun_)) + continue; + std::vector cur_files; + parseFileList(pattern.child("files"), cur_files); + for(const auto& file : cur_files) + { + auto duplicate_iter = + std::ranges::find_if(files_, + [file = file](const File& other) + { + return file.source.string() == other.source.string() && + file.destination.string() == other.destination.string(); + }); + if(duplicate_iter != files_.end()) + continue; + auto iter = std::ranges::find(files_, file); + if(iter == files_.end() || file.destination.empty() || + file.destination.string().ends_with("/") || + sfs::is_directory(mod_base_path_ / file.source) && + sfs::is_directory(mod_base_path_ / iter->source)) + files_.push_back(file); + else if(*(iter) < file) + *iter = file; + } + } +} + +void FomodInstaller::initPlugin(const pugi::xml_node& xml_node, Plugin& plugin) +{ + plugin.name = xml_node.attribute("name").value(); + plugin.description = xml_node.child_value("description"); + std::string image_path = xml_node.child("image").attribute("path").value(); + if(image_path.empty()) + plugin.image_path = ""; + else + plugin.image_path = mod_base_path_ / pu::normalizePath(image_path); + if(xml_node.child("files")) + parseFileList(xml_node.child("files"), plugin.files, false); + for(const auto& flag : xml_node.child("conditionFlags").children()) + plugin.flags[flag.attribute("name").value()] = flag.text().as_string(); + if(xml_node.child("typeDescriptor").child("type")) + { + auto type = + parsePluginType(xml_node.child("typeDescriptor").child("type").attribute("name").value()); + plugin.type = type; + plugin.default_type = type; + } + else + { + auto type = parsePluginType(xml_node.child("typeDescriptor") + .child("dependencyType") + .child("defaultType") + .attribute("name") + .value()); + plugin.type = type; + plugin.default_type = type; + for(const auto& pattern : + xml_node.child("typeDescriptor").child("dependencyType").child("patterns").children()) + { + PluginDependency dependency; + dependency.type = parsePluginType(pattern.child("type").attribute("name").value()); + dependency.dependencies = pattern.child("dependencies"); + plugin.potential_types.push_back(dependency); + } + } +} + +PluginType FomodInstaller::parsePluginType(const std::string& type) +{ + if(type == "Required") + return PluginType::required; + else if(type == "Optional") + return PluginType::optional; + else if(type == "Recommended") + return PluginType::recommended; + else if(type == "NotUsable") + return PluginType::not_usable; + return PluginType::could_be_usable; +} + +void FomodInstaller::updateState(const std::vector>& selection) +{ + if(cur_step_ < 0 || selection.empty()) + return; + for(int group_idx = 0; auto& group : steps_[cur_step_].groups) + { + for(int plugin_idx = 0; auto& plugin : group.plugins) + { + if(!selection[group_idx][plugin_idx]) + { + plugin_idx++; + continue; + } + for(const auto& [key, value] : plugin.flags) + flags_[key] = value; + for(const auto& file : plugin.files) + { + auto duplicate_iter = + std::ranges::find_if(files_, + [file = file](const File& other) + { + return file.source.string() == other.source.string() && + file.destination.string() == other.destination.string(); + }); + if(duplicate_iter != files_.end()) + continue; + auto iter = std::ranges::find(files_, file); + if(iter == files_.end() || file.destination.empty() || + file.destination.string().ends_with("/") || + sfs::is_directory(mod_base_path_ / file.source) && + sfs::is_directory(mod_base_path_ / iter->source)) + files_.push_back(file); + else if(*(iter) < file) + *iter = file; + else + Log::warning( + std::format("Ignoring file '{}' because '{}' points to the same destination '{}'", + file.source.string(), + iter->source.string(), + file.destination.string())); + } + plugin_idx++; + } + group_idx++; + } +} + +std::pair FomodInstaller::getFomodPath(const sfs::path& source, + const std::string& file_name) +{ + std::string fomod_dir_name = "fomod"; + auto str_equals = [](const std::string& a, const std::string& b) + { + return std::equal(a.begin(), + a.end(), + b.begin(), + b.end(), + [](char c1, char c2) { return tolower(c1) == tolower(c2); }); + }; + for(const auto& dir_entry : sfs::directory_iterator(source)) + { + if(!dir_entry.is_directory()) + continue; + const std::string cur_dir = std::prev(dir_entry.path().end())->string(); + if(str_equals(cur_dir, fomod_dir_name)) + { + fomod_dir_name = cur_dir; + break; + } + } + if(!sfs::exists(source / fomod_dir_name)) + return { fomod_dir_name, file_name }; + std::string actual_name = file_name; + for(const auto& dir_entry : sfs::directory_iterator(source / fomod_dir_name)) + { + if(dir_entry.is_directory()) + continue; + const std::string cur_file = dir_entry.path().filename(); + if(str_equals(cur_file, file_name)) + { + actual_name = cur_file; + break; + } + } + return { fomod_dir_name, actual_name }; +} diff --git a/src/core/fomod/fomodinstaller.h b/src/core/fomod/fomodinstaller.h new file mode 100644 index 0000000..8aeaf6b --- /dev/null +++ b/src/core/fomod/fomodinstaller.h @@ -0,0 +1,169 @@ +/*! + * \file fomodinstaller.h + * \brief Header for the FomodInstaller class. + */ + +#pragma once + +#include "installstep.h" +#include +#include +#include +#include +#include + + +/*! + * \brief The fomod namespace contains classes used for parsing a FOMOD xml file and for + * creating an installer. + */ +namespace fomod +{ +/*! + * \brief Holds data and functions needed to pass a fomod file. + */ +class FomodInstaller +{ +public: + /*! \brief Default constructor. */ + FomodInstaller() = default; + + /*! + * \brief Initializes the installer. + * \param config_file Fomod file to be parsed. + * \param target_path Installation target, this is only used to check file dependencies. + */ + void init(const std::filesystem::path& config_file, + const std::filesystem::path& target_path = "", + const std::string& app_version = ""); + /*! + * \brief Advances installation process by one step. + * \param selection For every group: for every plugin: True if selected. + * \return The next installation step, if one exists. + */ + std::optional step(const std::vector>& selection = {}); + /*! + * \brief Returns a pair of the previous installation step and the + * selections made at that step. + * \return The step, if one exists. + */ + std::optional>, InstallStep>> stepBack(); + /*! + * \brief Checks if there is at least one more valid installation step. + * \param selection Current plugin selection. + * \return True if more steps exist. + */ + bool hasNextStep(const std::vector>& selection) const; + /*! + * \brief Returns all files to be installed with current selection. + * \param selection For every group: for every plugin: True if selected. + * \return Pair or source, destination paths for every file. + */ + std::vector> getInstallationFiles( + const std::vector>& selection = {}); + /*! + * \brief Checks if there is a previous installation step. + * \return True if there is one. + */ + bool hasPreviousStep() const; + /*! + * \brief Checks if installation has not steps. + * \return True if no steps where found. + */ + bool hasNoSteps() const; + /*! + * \brief Extracts mod name and version from a fomod info file in path/fomod/info.xml + * \param path Mod root directory. + * \return Mod name and version. + */ + static std::pair getMetaData(const std::filesystem::path& path); + +private: + /*! \brief Source fomod config file. */ + pugi::xml_document config_file_; + /*! \brief Root node of the config file. */ + pugi::xml_node config_; + /*! \brief Path used to check for file dependencies. */ + std::filesystem::path target_path_; + /*! \brief Contains all files extracted from the config file. */ + std::vector files_; + /*! \brief Steps performed during installation. */ + std::vector steps_; + /*! \brief Current installation step. */ + int cur_step_ = -1; + /*! \brief Maps flags to their value. */ + std::map flags_; + /*! \brief Base path of the mod to be installed. */ + std::filesystem::path mod_base_path_; + /*! \brief Previous selections made during installation process. */ + std::vector>> prev_selections_; + /*! \brief Used to evaluate game version conditions. */ + std::function version_eval_fun_ = [](auto s) { return true; }; + /*! \brief Used to evaluate fomm version conditions. */ + std::function fomm_eval_fun_ = [](auto s) { return true; }; + + /*! + * \brief Extracts all files from given file list node and appends them to given vector. + * \param file_list Source file list. + * \param target_list Extracted files will be appended to this vector. + * \param warn_missing If true: Warn if a file is missing. + */ + void parseFileList(const pugi::xml_node& file_list, + std::vector& target_vector, + bool warn_missing = true); + /*! + * \brief Extracts all install steps from given node and stores them in steps_. + * \param steps Source node. + */ + void parseInstallSteps(const pugi::xml_node& steps); + /*! + * \brief Determines group type from given string. + * \param type Source string. + * \return The type. + */ + PluginGroup::Type parseGroupType(const std::string& type); + /*! \brief Updates files_ according to the fomod files conditionalFileInstalls node. */ + void parseInstallList(); + /*! + * \brief Initializes given plugin plugin from fomod node. + * \param xml_node Source node. + * \param plugin Target plugin. + */ + void initPlugin(const pugi::xml_node& xml_node, Plugin& plugin); + /*! + * \brief Determines plugin type from given string. + * \param type Source string. + * \return The type. + */ + PluginType parsePluginType(const std::string& type); + /*! + * \brief Updates flags_ and files_ with selection. + * \param selection For every group: for every plugin: True if selected. + */ + void updateState(const std::vector>& selection); + /*! + * \brief Tries to find fomod/file_name in the given path. + * \param source Path to check. + * \param file_name File name to search for. + * \return Name of fomod directory and file, adapted to the actual capitalization. + */ + static std::pair getFomodPath( + const std::filesystem::path& source, + const std::string& file_name = "ModuleConfig.xml"); + /*! + * \brief Sorts given vector according to given ordering type. + * \param source Vector to be sorted. + * \param order Ordering type. + */ + template + void sortVector(std::vector& source, std::string order) + { + if(order == "Explicit") + return; + else if(order == "Descending") + std::ranges::sort(source, [](auto a, auto b) { return a.name > b.name; }); + else + std::ranges::sort(source, [](auto a, auto b) { return a.name < b.name; }); + } +}; +} diff --git a/src/core/fomod/installstep.h b/src/core/fomod/installstep.h new file mode 100644 index 0000000..89ded61 --- /dev/null +++ b/src/core/fomod/installstep.h @@ -0,0 +1,29 @@ +/*! + * \file installstep.h + * \brief Header for the InstallStep struct. + */ + +#pragma once + +#include "dependency.h" +#include "plugingroup.h" +#include + + +/*! + * \brief The fomod namespace contains classes used for parsing a FOMOD xml file and for + * creating an installer. + */ +namespace fomod +{ +/*! \brief A step during installation. */ +struct InstallStep +{ + /*! \brief Step name. */ + std::string name; + /*! \brief Step description. */ + Dependency dependencies; + /*! \brief Sets of choices displayed during this step. */ + std::vector groups; +}; +} diff --git a/src/core/fomod/plugin.h b/src/core/fomod/plugin.h new file mode 100644 index 0000000..525d0b3 --- /dev/null +++ b/src/core/fomod/plugin.h @@ -0,0 +1,67 @@ +/*! + * \file plugin.h + * \brief Header for the Plugin struct. + */ + +#pragma once + +#include "file.h" +#include "plugindependency.h" +#include "plugintype.h" +#include +#include +#include + + +/*! + * \brief The fomod namespace contains classes used for parsing a FOMOD xml file and for + * creating an installer. + */ +namespace fomod +{ +/*! \brief Represents one selectable option during installation. */ +struct Plugin +{ + /*! \brief Plugin name. */ + std::string name; + /*! \brief Plugin description. */ + std::string description; + /*! \brief Path to an image representing this plugin. */ + std::filesystem::path image_path; + /*! \brief Affects how this plugin is displayed. */ + PluginType type; + /*! \brief Fallback type if this has potential types but none are valid. */ + PluginType default_type; + /*! \brief Plugin takes the first type for which the condition is fulfilled. */ + std::vector potential_types; + /*! \brief Flags to be set when this is selected. */ + std::map flags; + /*! \brief Files to be installed when this is selected. */ + std::vector files; + + /*! + * \brief Updates type according to potential_types + * \param target_path Path file conditions. + * \param current_flags Flags to check. + * \param version_eval_fun Used to evaluate game version conditions. + * \param fomm_eval_fun Used to evaluate game fromm conditions. + */ + void updateType( + const std::filesystem::path& target_path, + const std::map& current_flags, + std::function version_eval_fun, + std::function fomm_eval_fun = [](auto s) { return true; }) + { + for(const auto& cur_type : potential_types) + { + if(cur_type.dependencies.evaluate( + target_path, current_flags, version_eval_fun, fomm_eval_fun)) + { + type = cur_type.type; + return; + } + } + type = default_type; + } +}; +} diff --git a/src/core/fomod/plugindependency.h b/src/core/fomod/plugindependency.h new file mode 100644 index 0000000..abbcf6a --- /dev/null +++ b/src/core/fomod/plugindependency.h @@ -0,0 +1,26 @@ +/*! + * \file plugindependency.h + * \brief Header for the PluginDependency struct. + */ + +#pragma once + +#include "dependency.h" +#include "plugintype.h" + + +/*! + * \brief The fomod namespace contains classes used for parsing a FOMOD xml file and for + * creating an installer. + */ +namespace fomod +{ +/*! \brief Represents a possible plugin type. */ +struct PluginDependency +{ + /*! \brief Possible type. */ + PluginType type; + /*! \brief Conditions which must be fulfilled for a plugin to take this type. */ + Dependency dependencies; +}; +} diff --git a/src/core/fomod/plugingroup.h b/src/core/fomod/plugingroup.h new file mode 100644 index 0000000..d55b4bb --- /dev/null +++ b/src/core/fomod/plugingroup.h @@ -0,0 +1,44 @@ +/*! + * \file plugingroup.h + * \brief Header for the PluginGroup struct. + */ + +#pragma once + +#include "plugin.h" +#include +#include + + +/*! + * \brief The fomod namespace contains classes used for parsing a FOMOD xml file and for + * creating an installer. + */ +namespace fomod +{ +/*! \brief Represents a set of options which can be selected during installation. */ +struct PluginGroup +{ + /*! \brief Describes restriction on how plugins in a group can be selected. */ + enum Type + { + /*! \brief At least one plugin must be selected. */ + at_least_one, + /*! \brief At most one plugin must be selected. */ + at_most_one, + /*! \brief Exactly one plugin must be selected. */ + exactly_one, + /*! \brief All plugins must be selected. */ + all, + /*! \brief No restrictions on selection. */ + any + }; + + /*! \brief Group name. */ + std::string name; + /*! \brief Selection restrictions. */ + Type type; + /*! \brief Selectable plugins in this group. */ + std::vector plugins; +}; +} diff --git a/src/core/fomod/plugintype.h b/src/core/fomod/plugintype.h new file mode 100644 index 0000000..37f8c92 --- /dev/null +++ b/src/core/fomod/plugintype.h @@ -0,0 +1,38 @@ +/*! + * \file plugintype.h + * \brief Header for the PluginType enum. + */ + +#pragma once + +#include +#include + + +/*! + * \brief The fomod namespace contains classes used for parsing a FOMOD xml file and for + * creating an installer. + */ +namespace fomod +{ +/*! \brief Describes how a plugin is presented. */ +enum PluginType +{ + /*! \brief Always installed. */ + required, + /*! \brief Can be installed. */ + optional, + /*! \brief Should be installed. */ + recommended, + /*! \brief Cannot be installed. */ + not_usable, + /*! \brief Usage unclear, will be treated like optional. */ + could_be_usable +}; + +const std::vector PLUGIN_TYPE_NAMES{ "Required", + "Optional", + "Recommended", + "Not Available", + "Could be usable" }; +} diff --git a/src/core/importmodinfo.h b/src/core/importmodinfo.h new file mode 100644 index 0000000..540cafd --- /dev/null +++ b/src/core/importmodinfo.h @@ -0,0 +1,57 @@ +/*! + * \file importmodinfo.h + * \brief Contains the ImportModInfo struct. + */ + +#pragma once + +#include +#include +#include + + +/*! + * \brief Stores data needed to download or extract a mod. + */ +struct ImportModInfo +{ + /*! \brief Describes what import action should be taken. */ + enum Type + { + download = 0, + extract = 1 + }; + /*! \brief Target ModdedApplication */ + int app_id; + /*! \brief Type of action to be performed. */ + Type type; + /*! \brief Path to the local file used for extraction or empty if type == download. */ + std::filesystem::path local_source; + /*! + * \brief URL used to download the mod. Can be either a URL pointing to the mod itself or + * a NexusMods nxm URL. + */ + std::string remote_source = ""; + /*! \brief This is where the mod should be stored after extraction/ download. */ + std::filesystem::path target_path; + /*! \brief If remote_source is a NexusMods mod page: The id of the file to be downloaded, else: + * Not set. */ + int nexus_file_id = -1; + /*! \brief If !=-1: The mod should be added to this mods group after installation. */ + int mod_id = -1; + /*! \brief Time at which this object was added to the queue. Used for sorting. */ + std::chrono::time_point queue_time = + std::chrono::high_resolution_clock::now(); + + /*! + * \brief Compares with another ImportModInfo object by their type. + * \param other Object to compare to. + * \return True if only this object has type extract, else false. + */ + bool operator<(const ImportModInfo& other) const + { + if(type == other.type) + return queue_time > other.queue_time; + return type < other.type; + } +}; diff --git a/src/core/installer.cpp b/src/core/installer.cpp new file mode 100644 index 0000000..f79778e --- /dev/null +++ b/src/core/installer.cpp @@ -0,0 +1,431 @@ +#include "installer.h" +#include "compressionerror.h" +#include "pathutils.h" +#include +#include +#include +#include +#include +#include + +namespace sfs = std::filesystem; +namespace pu = path_utils; + + +void Installer::extract(const sfs::path& source_path, + const sfs::path& dest_path, + std::optional progress_node) +{ + if(sfs::is_directory(source_path)) + { + sfs::create_directories(dest_path); + if(source_path.parent_path() == dest_path.parent_path()) + sfs::rename(source_path, dest_path); + else + sfs::copy(source_path, dest_path, sfs::copy_options::recursive); + return; + } + + try + { + extractWithProgress(source_path, dest_path, progress_node); + } + catch(CompressionError& error) + { + if(source_path.extension().string() == ".rar") + { + if(sfs::exists(dest_path)) + sfs::remove_all(dest_path); + extractBrokenRarArchive(source_path, dest_path); + } + else + throw error; + } + for(const auto& dir_entry : sfs::recursive_directory_iterator(dest_path)) + { + auto permissions = sfs::perms::owner_read | sfs::perms::owner_write | sfs::perms::group_read | + sfs::perms::group_write | sfs::perms::others_read; + if(dir_entry.is_directory()) + permissions |= sfs::perms::owner_exec | sfs::perms::group_exec | sfs::perms::others_exec; + sfs::permissions(dir_entry.path(), permissions); + } +} + +unsigned long Installer::install(const sfs::path& source, + const sfs::path& destination, + int options, + const std::string& type, + int root_level, + const std::vector> fomod_files) +{ + if(type != SIMPLEINSTALLER && type != FOMODINSTALLER) + throw std::runtime_error("Error: Unknown Installer type \"" + type + "\"!"); + unsigned tmp_id = 0; + sfs::path tmp_dir; + do + tmp_dir = destination.parent_path() / (EXTRACT_TMP_DIR + std::to_string(tmp_id)); + while(sfs::exists(tmp_dir) && tmp_id++ < std::numeric_limits::max()); + if(tmp_id == std::numeric_limits::max()) + throw std::runtime_error("Could not create directory!"); + try + { + extract(source, tmp_dir, {}); + } + catch(CompressionError& error) + { + sfs::remove_all(tmp_dir); + throw error; + } + + if(type == FOMODINSTALLER) + { + if(fomod_files.empty()) + { + sfs::remove_all(tmp_dir); + throw std::runtime_error("No files to install."); + } + if(root_level > 0) + { + auto tmp_move_dir = tmp_dir.string() + "." + MOVE_EXTENSION; + pu::moveFilesWithDepth(tmp_dir, tmp_move_dir, root_level); + sfs::rename(tmp_move_dir, tmp_dir); + } + // for(const auto& [source_file, dest_file] : fomod_files) + // { + // std::cout << std::format("'{}'\n -> '{}'", source_file.string(), dest_file.string()) + // << std::endl; + // } + for(auto iter = fomod_files.begin(); iter != fomod_files.end(); iter++) + { + const auto& [source_file, dest_file] = *iter; + sfs::create_directories(destination / dest_file.parent_path()); + if(!sfs::exists(tmp_dir / source_file)) + { + sfs::remove_all(destination); + sfs::remove_all(tmp_dir); + throw std::runtime_error("Could not find '" + source_file.string() + "'"); + } + const bool contains_no_duplicates = std::find_if(std::next(iter), + fomod_files.end(), + [source_file](auto pair) { + return pair.first == source_file; + }) == fomod_files.end(); + if(sfs::is_directory(tmp_dir / source_file)) + { + if(sfs::exists(destination / dest_file)) + pu::moveFilesToDirectory( + tmp_dir / source_file, destination / dest_file, contains_no_duplicates); + else + pu::copyOrMoveFiles( + tmp_dir / source_file, destination / dest_file, contains_no_duplicates); + } + else + { + if(sfs::exists(destination / dest_file) && !sfs::is_directory(destination / dest_file)) + sfs::remove(destination / dest_file); + if(dest_file.empty()) + pu::copyOrMoveFiles( + tmp_dir / source_file, destination / source_file.filename(), contains_no_duplicates); + else + pu::copyOrMoveFiles( + tmp_dir / source_file, destination / dest_file, contains_no_duplicates); + } + } + sfs::remove_all(tmp_dir); + } + else + { + if(options & lower_case) + pu::renameFiles(tmp_dir, tmp_dir, [](unsigned char c) { return std::tolower(c); }); + else if(options & upper_case) + pu::renameFiles(tmp_dir, tmp_dir, [](unsigned char c) { return std::toupper(c); }); + if(options & single_directory) + { + std::vector directories; + for(const auto& dir_entry : sfs::recursive_directory_iterator(tmp_dir)) + { + if(!dir_entry.is_directory()) + sfs::rename(dir_entry.path(), tmp_dir / dir_entry.path().filename()); + else + directories.push_back(dir_entry.path()); + } + for(const auto& dir : directories) + { + if(sfs::exists(dir)) + sfs::remove_all(dir); + } + } + + sfs::create_directories(destination); + try + { + if(root_level == 0) + sfs::rename(tmp_dir, destination); + else + pu::moveFilesWithDepth(tmp_dir, destination, root_level); + } + catch(sfs::filesystem_error& error) + { + sfs::remove_all(tmp_dir); + sfs::remove_all(destination); + throw error; + } + catch(std::runtime_error& error) + { + sfs::remove_all(tmp_dir); + sfs::remove_all(destination); + throw error; + } + } + unsigned long size = 0; + for(const auto& dir_entry : sfs::recursive_directory_iterator(destination)) + if(dir_entry.is_regular_file()) + size += dir_entry.file_size(); + return size; +} + +void Installer::uninstall(const sfs::path& mod_path, const std::string& type) +{ + sfs::remove_all(mod_path); +} + +std::vector Installer::getArchiveFileNames(const sfs::path& path) +{ + std::vector file_names; + if(sfs::is_directory(path)) + { + for(const auto& dir_entry : sfs::recursive_directory_iterator(path)) + file_names.push_back(pu::getRelativePath(dir_entry.path(), path)); + return file_names; + } + struct archive* source; + struct archive_entry* entry; + source = archive_read_new(); + archive_read_support_filter_all(source); + archive_read_support_format_all(source); + if(archive_read_open_filename(source, path.string().c_str(), 10240) != ARCHIVE_OK) + throw CompressionError("Could not open archive file."); + while(archive_read_next_header(source, &entry) == ARCHIVE_OK) + file_names.push_back(archive_entry_pathname(entry)); + if(archive_read_free(source) != ARCHIVE_OK) + throw CompressionError("Parsing of archive failed."); + return file_names; +} + +std::tuple Installer::detectInstallerSignature( + const sfs::path& source) +{ + const auto path = (sfs::path("fomod") / "ModuleConfig.xml"); + auto str_equals = [](const std::string& a, const std::string& b) + { + return std::equal(a.begin(), + a.end(), + b.begin(), + b.end(), + [](char c1, char c2) { return tolower(c1) == tolower(c2); }); + }; + const auto files = getArchiveFileNames(source); + int max_length = 0; + for(const auto& file : files) + max_length = std::max(max_length, pu::getPathLength(file)); + for(int root_level = 0; root_level < max_length; root_level++) + { + for(const auto& file : files) + { + const auto [head, tail] = pu::removePathComponents(file, root_level); + if(str_equals(path, tail)) + return { root_level, head.string(), FOMODINSTALLER }; + } + } + return { 0, {}, SIMPLEINSTALLER }; +} + +void Installer::cleanupFailedInstallation(const sfs::path& staging_dir, int mod_id) +{ + if(mod_id >= 0) + { + if(sfs::exists(staging_dir / std::to_string(mod_id))) + sfs::remove_all(staging_dir / std::to_string(mod_id)); + } + for(const auto& dir_entry : sfs::directory_iterator(staging_dir)) + { + if(!dir_entry.is_directory()) + continue; + if(dir_entry.path().extension() == MOVE_EXTENSION) + sfs::remove_all(dir_entry.path()); + std::regex tmp_dir_regex(EXTRACT_TMP_DIR + R"(\d+)"); + if(std::regex_search(dir_entry.path().filename().string(), tmp_dir_regex)) + sfs::remove_all(dir_entry.path()); + } +} + +void Installer::setIsAFlatpak(bool is_a_flatpak) +{ + is_a_flatpak_ = is_a_flatpak; +} + +void Installer::throwCompressionError(struct archive* source) +{ + throw CompressionError("Error during archive extraction."); + + // The following code sometimes crashes during execution of archive_error_string: + + // throw CompressionError( + // ("Error during archive extraction: " + std::string(archive_error_string(source))).c_str()); +} + +void Installer::copyArchive(struct archive* source, struct archive* dest) +{ + int return_code; + const void* buffer; + size_t size; + la_int64_t offset; + + while(true) + { + return_code = archive_read_data_block(source, &buffer, &size, &offset); + if(return_code == ARCHIVE_EOF) + return; + if(return_code < ARCHIVE_OK) + throwCompressionError(source); + if(archive_write_data_block(dest, buffer, size, offset) < ARCHIVE_OK) + throwCompressionError(dest); + } +} + +void Installer::extractWithProgress(const sfs::path& source_path, + const sfs::path& dest_path, + std::optional progress_node) +{ + constexpr int buffer_size = 10240; + struct archive* source; + struct archive* dest; + struct archive_entry* entry; + int return_code; + const char* file_name = source_path.c_str(); + int flags = ARCHIVE_EXTRACT_TIME; + sfs::path working_dir = "/tmp"; + try + { + working_dir = sfs::current_path(); + } + catch(std::filesystem::filesystem_error& error) + {} + if(!sfs::exists(dest_path)) + sfs::create_directories(dest_path); + sfs::current_path(dest_path); + source = archive_read_new(); + archive_read_support_format_all(source); + archive_read_support_filter_all(source); + dest = archive_write_disk_new(); + archive_write_disk_set_options(dest, flags); + archive_write_disk_set_standard_lookup(dest); + if(archive_read_open_filename(source, file_name, buffer_size)) + { + sfs::current_path(working_dir); + throw CompressionError("Could not open archive file."); + } + uint64_t total_size = 0; + while(true) + { + return_code = archive_read_next_header(source, &entry); + if(return_code == ARCHIVE_EOF) + break; + if(return_code < ARCHIVE_OK) + { + sfs::current_path(working_dir); + throwCompressionError(source); + } + total_size += archive_entry_size(entry); + } + if(progress_node) + (*progress_node)->setTotalSteps(total_size); + archive_read_close(source); + archive_read_free(source); + source = archive_read_new(); + archive_read_support_format_all(source); + archive_read_support_filter_all(source); + if(archive_read_open_filename(source, file_name, buffer_size)) + { + sfs::current_path(working_dir); + throw CompressionError("Could not open archive file."); + } + + while(true) + { + return_code = archive_read_next_header(source, &entry); + if(return_code == ARCHIVE_EOF) + break; + if(return_code < ARCHIVE_OK) + { + sfs::current_path(working_dir); + throwCompressionError(source); + } + archive_entry_set_pathname(entry, archive_entry_pathname(entry)); + if(archive_write_header(dest, entry) < ARCHIVE_OK) + { + sfs::current_path(working_dir); + throwCompressionError(dest); + } + const void* buff; + size_t size; + int64_t offset; + + while(true) + { + return_code = archive_read_data_block(source, &buff, &size, &offset); + if(return_code == ARCHIVE_EOF) + break; + if(return_code < ARCHIVE_OK) + { + sfs::current_path(working_dir); + throwCompressionError(source); + } + if(archive_write_data_block(dest, buff, size, offset) != ARCHIVE_OK) + { + sfs::current_path(working_dir); + throwCompressionError(dest); + } + if(progress_node) + (*progress_node)->advance(size); + } + if(archive_write_finish_entry(dest) < ARCHIVE_OK) + { + sfs::current_path(working_dir); + throwCompressionError(dest); + } + } + archive_read_close(source); + archive_read_free(source); + archive_write_close(dest); + archive_write_free(dest); + sfs::current_path(working_dir); +} + +void Installer::extractBrokenRarArchive(const sfs::path& source_path, const sfs::path& dest_path) +{ + sfs::path working_dir = sfs::current_path(); + if(!sfs::exists(dest_path)) + sfs::create_directories(dest_path); + sfs::current_path(dest_path); + std::string output; + std::array buffer; + std::string command = "\"" + UNRAR_PATH.string() + "\" x \"" + source_path.string() + "\""; + if(is_a_flatpak_) + command = "flatpak-spawn --host " + command; + auto pipe = + popen(command.c_str(), "r"); + while(!feof(pipe)) + { + if(fgets(buffer.data(), buffer.size(), pipe) != nullptr) + output += buffer.data(); + } + int ret_code = pclose(pipe); + sfs::current_path(working_dir); + if(ret_code == 127) + throw std::runtime_error( + "Invalid path to unrar. Try setting a different path in the settings."); + if(ret_code != 0) + throw std::runtime_error("Failed to extract archive using unrar. " + "Try setting a different path in the settings."); +} diff --git a/src/core/installer.h b/src/core/installer.h new file mode 100644 index 0000000..ac7f4a9 --- /dev/null +++ b/src/core/installer.h @@ -0,0 +1,176 @@ +/*! + * \file installer.h + * \brief Header for the Installer class + */ + +#pragma once + +#include "progressnode.h" +#include +#include +#include +#include +#include + + +/*! + * \brief Holds static functions to install and uninstall mods. + */ +class Installer +{ +public: + /*! \brief Flags used for installation options. */ + enum Flag + { + preserve_case = 0, + lower_case = 1 << 0, + upper_case = 1 << 1, + preserve_directories = 1 << 2, + single_directory = 1 << 3 + }; + /*! \brief Every vector represents an exclusive group of flags. */ + inline static const std::vector> OPTION_GROUPS{ + { preserve_case, lower_case, upper_case }, + { preserve_directories, single_directory } + }; + /*! \brief Maps installer flags to descriptive names. */ + inline static const std::map OPTION_NAMES{ + { preserve_case, "Preserve file names" }, + { lower_case, "Convert to lower case" }, + { upper_case, "Convert to upper case" }, + { preserve_directories, "Preserve directories" }, + { single_directory, "Root directory only" } + }; + /*! \brief Maps installer flags to brief descriptions of what they do. */ + inline static const std::map OPTION_DESCRIPTIONS{ + { preserve_case, "Do not alter file names" }, + { lower_case, "Convert file and directory names to lower case (FiLe -> file)" }, + { upper_case, "Convert file and directory names to upper case (FiLe -> FILE)" }, + { preserve_directories, "Do not alter directory structure" }, + { single_directory, "Move files from all sub directories to the mods root directory" } + }; + /*! \brief Simply extracts files */ + inline static const std::string SIMPLEINSTALLER{ "Simple Installer" }; + /*! + * \brief Takes a vector of files created by fomod::FomodInstaller and + * moves them to their target. + */ + inline static const std::string FOMODINSTALLER{ "Fomod Installer" }; + /*! + * \brief Contains all available installer types. + */ + inline static const std::vector INSTALLER_TYPES{ SIMPLEINSTALLER, FOMODINSTALLER }; + /*! + * \brief Path to the unrar binary. When set, this is used to extract certain rar + * archives. + */ + inline static std::filesystem::path UNRAR_PATH = "/bin/unrar"; + + /*! + * \brief Extracts the given archive to the given directory. + * \param source Path to the archive. + * \param destination Destination directory for extraction. + * \param progress_node Used to inform about extraction progress. + * \return Int indicating success(0), a filesystem error(-2) or an error + * during extraction(-1). + */ + static void extract(const std::filesystem::path& source, + const std::filesystem::path& destination, + std::optional progress_node = {}); + /*! + * \brief Extracts the archive, performs any actions specified by the installer type, + * then copies all files to given destination. + * \param path Path to the archive. + * \param destination Destination directory for the installation. + * \param options Sum of installation flags + * \param installer Installer type to use. + * \param root_level If > 0: Ignore all mod files and path components with depth < + * root_level. + * \return The total file size of the installed mod on disk. + */ + static unsigned long install( + const std::filesystem::path& source, + const std::filesystem::path& destination, + int options, + const std::string& type = SIMPLEINSTALLER, + int root_level = 0, + const std::vector> fomod_files = {}); + /*! + * \brief Uninstalls the mod at given directory using the given installer type. + * \param path Path to the mod. + * \param installer Installer type to use. + */ + static void uninstall(const std::filesystem::path& mod_path, + const std::string& type = SIMPLEINSTALLER); + /*! + * \brief Recursively reads all file and directory names from given archive. + * \param path Path to given archive. + * \return Vector of paths within the archive. + */ + static std::vector getArchiveFileNames(const std::filesystem::path& path); + /*! + * \brief Identifies the appropriate installer type from given source archive or + * directory. + * \param source Path to mod source. + * \return Required root level and type of the installer. + */ + static std::tuple detectInstallerSignature( + const std::filesystem::path& source); + /*! + * \brief Deletes all temporary files created during a previous installation attempt. + * \param staging_dir Directory containing temporary files. + * \param mod_id Id of the mod whose installation failed. + */ + static void cleanupFailedInstallation(const std::filesystem::path& staging_dir, int mod_id); + /*! + * \brief Sets whether this application is running as a flatpak. + * \param is_a_flatpak If true: The application is running as a flatpak. + */ + static void setIsAFlatpak(bool is_a_flatpak); + +private: + /*! \brief Directory name used to temporary storage of files during installation. */ + static inline std::string EXTRACT_TMP_DIR = "lmm_tmp_extract"; + /*! \brief Extension used for temporary storage during file movement. */ + static inline std::string MOVE_EXTENSION = "tmpmove"; + /*! \brief If true: The application is running as a flatpak. */ + static inline bool is_a_flatpak_ = false; + + /*! + * \brief Throws a CompressionError containing the error message of given archive. + * \param source Archive containing the error message. + */ + static void throwCompressionError(struct archive* source); + /*! + * \brief Copies data from given source archive to given destination archive. + * Throws CompressionError when an reading or writing fails. + * \param source Source archive. + * \param dest Destination archive. + */ + static void copyArchive(struct archive* source, struct archive* dest); + + /*! + * \brief Extracts the given archive to the given directory. Informs about + * extraction progress using the provided node. + * \param source Path to the archive. + * \param destination Destination directory for extraction. + * \param progress_node Used to inform about extraction progress. + * \return Int indicating success(0), a filesystem error(-2) or an error + * during extraction(-1). + */ + static void extractWithProgress(const std::filesystem::path& source_path, + const std::filesystem::path& dest_path, + std::optional progress_node = {}); + /*! + * \brief Libarchive sometime fails to extract certain rar archives when + * using the method implemented in \ref extractWithProgress. This function + * uses the unrar binary instead of libarchive to extract a given rar archive. + * \param source Path to the archive. + * \param destination Destination directory for extraction. + * \param progress_node Used to inform about extraction progress. + * \return Int indicating success(0), a filesystem error(-2) or an error + * during extraction(-1). + */ + static void extractBrokenRarArchive(const std::filesystem::path& source_path, + const std::filesystem::path& dest_path); +}; diff --git a/src/core/log.cpp b/src/core/log.cpp new file mode 100644 index 0000000..4ef11f8 --- /dev/null +++ b/src/core/log.cpp @@ -0,0 +1,65 @@ +#include "log.h" +#include +#include + + +std::string getTimestamp(Log::LogLevel log_level) +{ + const auto now = std::chrono::system_clock::now(); + auto cur_time = std::chrono::system_clock::to_time_t(now); + std::stringstream ss; + ss << std::put_time(std::localtime(&cur_time), "%F %T"); + if(log_level == Log::LOG_DEBUG) + ss << "." + << std::chrono::time_point_cast(now).time_since_epoch().count() % + 1000; + return ss.str(); +} + +namespace Log +{ +void error(const std::string& message) +{ + if(log_level >= LOG_ERROR) + log_printer(getTimestamp(Log::LOG_ERROR) + " [Error]: " + message, LOG_ERROR); +} + +void warning(const std::string& message) +{ + if(log_level >= LOG_WARNING) + log_printer(getTimestamp(Log::LOG_WARNING) + " [Warning]: " + message, LOG_WARNING); +} + +void info(const std::string& message) +{ + if(log_level >= LOG_INFO) + log_printer(getTimestamp(Log::LOG_INFO) + " [Info]: " + message, LOG_INFO); +} + +void debug(const std::string& message) +{ + if(log_level >= LOG_DEBUG) + log_printer(getTimestamp(Log::LOG_DEBUG) + " [Debug]: " + message, LOG_DEBUG); +} + +void log(LogLevel level, const std::string& message) +{ + switch(level) + { + case LOG_DEBUG: + debug(message); + break; + case LOG_INFO: + info(message); + break; + case LOG_WARNING: + warning(message); + break; + case LOG_ERROR: + error(message); + break; + default: + break; + } +} +} diff --git a/src/core/log.h b/src/core/log.h new file mode 100644 index 0000000..0e37f73 --- /dev/null +++ b/src/core/log.h @@ -0,0 +1,61 @@ +/*! + * \file log.h + * \brief Header for the Log namespace + */ +#pragma once + +#include +#include +#include + + +/*! + * \brief Contains functions for logging. + */ +namespace Log +{ +/*! \brief Represents the importance of a log message. */ +enum LogLevel +{ + LOG_ERROR = 0, + LOG_WARNING = 1, + LOG_INFO = 2, + LOG_DEBUG = 3 +}; + +/*! + * \brief Current log level. Messages with a log level less important than + * this will be ignored. + */ +inline LogLevel log_level = LOG_INFO; +/*! \brief Callback function used to output log messages. */ +inline std::function log_printer = [](std::string, LogLevel) {}; + + +/*! + * \brief Prints the current time and date followed by a debug message. + * \param message Message to be printed. + */ +void debug(const std::string& message); +/*! + * \brief Prints the current time and date followed by an info message. + * \param message Message to be printed. + */ +void info(const std::string& message); +/*! + * \brief Prints the current time and date followed by a warning message. + * \param message Message to be printed. + */ +void warning(const std::string& message); +/*! + * \brief Prints the current time and date followed by an error message. + * \param message Message to be printed. + */ +void error(const std::string& message); +/*! + * \brief Calls the appropriate logging function for the given log level with the given message. + * \param level Log level for the message. + * \param message Message to be printed. + */ +void log(LogLevel level, const std::string& message); +} diff --git a/src/core/lootdeployer.cpp b/src/core/lootdeployer.cpp new file mode 100644 index 0000000..e3c507f --- /dev/null +++ b/src/core/lootdeployer.cpp @@ -0,0 +1,650 @@ +#include "lootdeployer.h" +#include "pathutils.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sfs = std::filesystem; +namespace str = std::ranges; +namespace pu = path_utils; + + +LootDeployer::LootDeployer(const sfs::path& source_path, + const sfs::path& dest_path, + const std::string& name, + bool init_tags) : Deployer(source_path, dest_path, name) +{ + LIST_URLS = DEFAULT_LIST_URLS; + type_ = "Loot Deployer"; + is_autonomous_ = true; + updateAppType(); + setupPluginFiles(); + loadPlugins(); + updatePlugins(); + if(sfs::exists(dest_path_ / CONFIG_FILE_NAME)) + loadSettings(); + if(init_tags) + readPluginTags(); +} + +std::map LootDeployer::deploy(std::optional progress_node) +{ + log_(Log::LOG_INFO, std::format("Deployer '{}': Updating plugins...", name_)); + updatePlugins(); + updatePluginTags(); + return {}; +} + +std::map LootDeployer::deploy(const std::vector& loadorder, + std::optional progress_node) +{ + log_(Log::LOG_INFO, std::format("Deployer '{}': Updating plugins...", name_)); + updatePlugins(); + updatePluginTags(); + return {}; +} + +void LootDeployer::changeLoadorder(int from_index, int to_index) +{ + if(to_index == from_index) + return; + if(to_index < 0 || to_index >= plugins_.size()) + return; + if(to_index < from_index) + std::rotate(plugins_.begin() + to_index, + plugins_.begin() + from_index, + plugins_.begin() + from_index + 1); + else + std::rotate(plugins_.begin() + from_index, + plugins_.begin() + from_index + 1, + plugins_.begin() + to_index + 1); + writePlugins(); +} + +void LootDeployer::setModStatus(int mod_id, bool status) +{ + if(mod_id >= plugins_.size() || mod_id < 0) + return; + plugins_[mod_id].second = status; + writePlugins(); +} + +std::vector> LootDeployer::getConflictGroups() const +{ + std::vector group(plugins_.size()); + std::iota(group.begin(), group.end(), 0); + return { group }; +} + +std::vector LootDeployer::getModNames() const +{ + std::vector names{}; + names.reserve(plugins_.size()); + for(int i = 0; i < plugins_.size(); i++) + names.push_back(plugins_[i].first); + return names; +} + +void LootDeployer::addProfile(int source) +{ + if(num_profiles_ == 0) + { + num_profiles_++; + saveSettings(); + return; + } + if(source >= 0 && source <= num_profiles_ && num_profiles_ > 1) + { + sfs::copy(dest_path_ / ("." + PLUGIN_FILE_NAME + EXTENSION + std::to_string(source)), + dest_path_ / ("." + PLUGIN_FILE_NAME + EXTENSION + std::to_string(num_profiles_))); + sfs::copy(dest_path_ / ("." + LOADORDER_FILE_NAME + EXTENSION + std::to_string(source)), + dest_path_ / ("." + LOADORDER_FILE_NAME + EXTENSION + std::to_string(num_profiles_))); + } + else + { + sfs::copy(dest_path_ / PLUGIN_FILE_NAME, + dest_path_ / ("." + PLUGIN_FILE_NAME + EXTENSION + std::to_string(num_profiles_))); + sfs::copy(dest_path_ / LOADORDER_FILE_NAME, + dest_path_ / ("." + LOADORDER_FILE_NAME + EXTENSION + std::to_string(num_profiles_))); + } + num_profiles_++; + saveSettings(); +} + +void LootDeployer::removeProfile(int profile) +{ + if(profile >= num_profiles_ || profile < 0) + return; + std::string plugin_file = "." + PLUGIN_FILE_NAME + EXTENSION + std::to_string(profile); + std::string loadorder_file = "." + LOADORDER_FILE_NAME + EXTENSION + std::to_string(profile); + if(profile == current_profile_) + setProfile(profile == 0 ? num_profiles_ - 2 : 0); + if(sfs::exists(dest_path_ / plugin_file)) + sfs::remove(dest_path_ / plugin_file); + if(sfs::exists(dest_path_ / loadorder_file)) + sfs::remove(dest_path_ / loadorder_file); + num_profiles_--; + saveSettings(); +} + +void LootDeployer::setProfile(int profile) +{ + if(profile >= num_profiles_ || profile < 0 || profile == current_profile_) + return; + if(!sfs::exists(dest_path_ / PLUGIN_FILE_NAME) || + !sfs::exists(dest_path_ / LOADORDER_FILE_NAME) || + !sfs::exists(dest_path_ / ("." + PLUGIN_FILE_NAME + EXTENSION + std::to_string(profile))) || + !sfs::exists(dest_path_ / ("." + LOADORDER_FILE_NAME + EXTENSION + std::to_string(profile)))) + { + resetSettings(); + return; + } + sfs::rename(dest_path_ / PLUGIN_FILE_NAME, + dest_path_ / ("." + PLUGIN_FILE_NAME + EXTENSION + std::to_string(current_profile_))); + sfs::rename(dest_path_ / LOADORDER_FILE_NAME, + dest_path_ / + ("." + LOADORDER_FILE_NAME + EXTENSION + std::to_string(current_profile_))); + sfs::rename(dest_path_ / ("." + PLUGIN_FILE_NAME + EXTENSION + std::to_string(profile)), + dest_path_ / PLUGIN_FILE_NAME); + sfs::rename(dest_path_ / ("." + LOADORDER_FILE_NAME + EXTENSION + std::to_string(profile)), + dest_path_ / LOADORDER_FILE_NAME); + current_profile_ = profile; + saveSettings(); + loadPlugins(); + updatePlugins(); +} + +void LootDeployer::setConflictGroups(const std::vector>& newConflict_groups) +{ + log_(Log::LOG_DEBUG, + "WARNING: You are trying to set a load order for an autonomous" + " deployer. This will have no effect"); +} + +int LootDeployer::getNumMods() const +{ + return plugins_.size(); +} + +std::vector> LootDeployer::getLoadorder() const +{ + std::vector> loadorder; + loadorder.reserve(plugins_.size()); + for(int i = 0; i < plugins_.size(); i++) + loadorder.emplace_back(i, plugins_[i].second); + return loadorder; +} + +bool LootDeployer::addMod(int mod_id, bool enabled, bool update_conflicts) +{ + log_(Log::LOG_DEBUG, + "WARNING: You are trying to add a mod to an autonomous" + " deployer. This will have no effect"); + return false; +} + +bool LootDeployer::removeMod(int mod_id) +{ + log_(Log::LOG_DEBUG, + "WARNING: You are trying to remove a mod from an autonomous" + " deployer. This will have no effect"); + return false; +} + +bool LootDeployer::hasMod(int mod_id) const +{ + return false; +} + +bool LootDeployer::swapMod(int old_id, int new_id) +{ + log_(Log::LOG_DEBUG, + "WARNING: You are trying to swap a mod in an autonomous" + " deployer. This will have no effect"); + return false; +} + +std::vector LootDeployer::getFileConflicts( + int mod_id, + bool show_disabled, + std::optional progress_node) const +{ + if(progress_node) + { + (*progress_node)->setTotalSteps(1); + (*progress_node)->advance(); + } + return {}; +} + +std::unordered_set LootDeployer::getModConflicts(int mod_id, + std::optional progress_node) +{ + std::unordered_set conflicts{ mod_id }; + auto loot_handle = loot::CreateGameHandle(app_type_, source_path_, dest_path_); + std::vector plugin_paths; + plugin_paths.reserve(plugins_.size()); + for(const auto& [path, s] : plugins_) + plugin_paths.emplace_back(source_path_ / path); + loot_handle->LoadPlugins(plugin_paths, false); + auto plugin = loot_handle->GetPlugin(plugins_[mod_id].first); + for(int i = 0; i < plugins_.size(); i++) + { + if(i == mod_id) + continue; + if(loot_handle->GetPlugin(plugins_[i].first)->DoRecordsOverlap(*plugin)) + conflicts.insert(i); + } + return conflicts; +} + +void LootDeployer::sortModsByConflicts(std::optional progress_node) +{ + if(progress_node) + { + (*progress_node)->addChildren({ 1, 2, 5, 0.2f }); + (*progress_node)->child(0).setTotalSteps(1); + (*progress_node)->child(1).setTotalSteps(1); + (*progress_node)->child(2).setTotalSteps(1); + (*progress_node)->child(3).setTotalSteps(1); + } + updateMasterList(); + if(progress_node) + (*progress_node)->child(0).advance(); + sfs::path master_list_path = dest_path_ / "masterlist.yaml"; + if(!sfs::exists(master_list_path)) + throw std::runtime_error("Could not find masterlist.yaml at '" + master_list_path.string() + + "'\n.Try to update the URL in the " + + "settings. Alternatively, you can manually download the " + + "file and place it in '" + dest_path_.string() + "'.\nYou can " + + "disable auto updates in '" + + (dest_path_ / CONFIG_FILE_NAME).string() + "'."); + auto loot_handle = loot::CreateGameHandle(app_type_, source_path_, dest_path_); + sfs::path user_list_path(""); + if(sfs::exists(dest_path_ / "userlist.yaml")) + user_list_path = dest_path_ / "userlist.yaml"; + loot_handle->GetDatabase().LoadLists(master_list_path, user_list_path); + if(progress_node) + (*progress_node)->child(1).advance(); + std::vector plugin_paths; + plugin_paths.reserve(plugins_.size() + prefix_plugins_.size()); + for(const auto& plugin : prefix_plugins_) + plugin_paths.emplace_back(source_path_ / plugin); + for(const auto& [path, s] : plugins_) + plugin_paths.emplace_back(source_path_ / path); + auto sorted_plugins = loot_handle->SortPlugins(plugin_paths); + if(progress_node) + (*progress_node)->child(2).advance(); + std::vector> new_plugins; + std::set conflicting; + int num_light_plugins = 0; + int num_master_plugins = 0; + int num_standard_plugins = 0; + for(const auto& plugin : sorted_plugins) + { + if(str::find(prefix_plugins_, plugin) != prefix_plugins_.end()) + continue; + auto iter = str::find_if(plugins_, [plugin](const auto& p) { return p.first == plugin; }); + bool enabled = true; + if(iter != plugins_.end()) + enabled = iter->second; + const auto cur_plugin = loot_handle->GetPlugin(plugin); + if(cur_plugin->IsLightPlugin()) + num_light_plugins++; + else if(cur_plugin->IsMaster()) + num_master_plugins++; + else + num_standard_plugins++; + new_plugins.emplace_back(plugin, enabled); + auto masters = cur_plugin->GetMasters(); + for(const auto& master : masters) + { + if(!pu::pathExists(master, source_path_) && enabled) + log_(Log::LOG_WARNING, + "LOOT: Plugin '" + master + "' is missing but required" + " for '" + plugin + "'"); + } + auto meta_data = loot_handle->GetDatabase().GetPluginMetadata(plugin); + if(!meta_data) + continue; + auto requirements = meta_data->GetRequirements(); + for(const auto& req : requirements) + { + std::string file = static_cast(req.GetName()); + if(!pu::pathExists(file, source_path_)) + log_(Log::LOG_WARNING, "LOOT: Requirement '" + file + "' not met for '" + plugin + "'"); + } + } + log_(Log::LOG_INFO, + std::format("LOOT: Total Plugins: {}, Master: {}, Standard: {}, Light: {}", + new_plugins.size(), + num_master_plugins, + num_standard_plugins, + num_light_plugins)); + plugins_ = new_plugins; + writePlugins(); + if(progress_node) + (*progress_node)->child(3).advance(); +} + +void LootDeployer::cleanup() +{ + for(int i = 0; i < num_profiles_; i++) + { + sfs::path plugin_path = dest_path_ / ("." + PLUGIN_FILE_NAME + EXTENSION + std::to_string(i)); + sfs::path load_order_path = + dest_path_ / ("." + LOADORDER_FILE_NAME + EXTENSION + std::to_string(i)); + if(sfs::exists(plugin_path)) + sfs::remove(plugin_path); + if(sfs::exists(load_order_path)) + sfs::remove(load_order_path); + } + current_profile_ = 0; + num_profiles_ = 1; + if(sfs::exists(dest_path_ / CONFIG_FILE_NAME)) + sfs::remove(dest_path_ / CONFIG_FILE_NAME); +} + +std::vector> LootDeployer::getAutoTags() +{ + return tags_; +} + +std::map LootDeployer::getAutoTagMap() +{ + return { { LIGHT_PLUGIN, num_light_plugins_ }, + { MASTER_PLUGIN, num_master_plugins_ }, + { STANDARD_PLUGIN, num_standard_plugins_ } }; +} + +void LootDeployer::updatePlugins() +{ + std::vector plugin_files; + std::vector> new_plugins; + for(const auto& dir_entry : sfs::directory_iterator(source_path_)) + { + if(dir_entry.is_directory()) + continue; + const std::string file_name = dir_entry.path().filename().string(); + if(str::find(prefix_plugins_, file_name) != prefix_plugins_.end()) + continue; + if(std::regex_match(file_name, std::regex(R"(.*\.[eE][sS][pPlLmM]$)"))) + plugin_files.push_back(file_name); + } + for(auto it = plugins_.begin(); it != plugins_.end(); it++) + { + if(str::find_if(plugin_files, [&it](const auto& s) { return it->first == s; }) != + plugin_files.end()) + new_plugins.emplace_back(*it); + } + for(auto it = plugin_files.begin(); it != plugin_files.end(); it++) + { + if(str::find_if(new_plugins, [&it](auto& p) { return p.first == *it; }) == new_plugins.end()) + new_plugins.emplace_back(*it, true); + } + plugins_ = new_plugins; + writePlugins(); +} + +void LootDeployer::loadPlugins() +{ + plugins_.clear(); + prefix_plugins_.clear(); + std::string line; + std::ifstream plugin_file; + plugin_file.open(dest_path_ / PLUGIN_FILE_NAME); + if(!plugin_file.is_open()) + throw std::runtime_error("Could not open " + PLUGIN_FILE_NAME + + "!\nMake sure you have launched the game at least once."); + while(getline(plugin_file, line)) + { + std::smatch match; + if(std::regex_match(line, match, std::regex(R"(^\s*(\*?)([^#]*\.es[plm])(\r?))"))) + plugins_.emplace_back(match[2], match[1] == "*"); + } + plugin_file.close(); + std::ifstream loadorder_file; + loadorder_file.open(dest_path_ / LOADORDER_FILE_NAME); + if(!loadorder_file.is_open()) + throw std::runtime_error("Could not open " + LOADORDER_FILE_NAME + + "!\nMake sure you have launched the game at least once."); + while(getline(loadorder_file, line)) + { + std::smatch match; + if(std::regex_match(line, match, std::regex(R"(^\s*([^#]*\.es[plm])(\r)?)"))) + { + if((plugins_.empty() || plugins_[0].first != match[1])) + prefix_plugins_.push_back(match[1]); + else if(plugins_[0].first == match[1]) + break; + } + } + loadorder_file.close(); +} + +void LootDeployer::writePlugins() const +{ + std::ofstream plugin_file; + plugin_file.open(dest_path_ / PLUGIN_FILE_NAME); + if(!plugin_file.is_open()) + throw std::runtime_error("Could not open " + PLUGIN_FILE_NAME + "!"); + for(const auto& [name, status] : plugins_) + plugin_file << (status ? "*" : "") << name << "\n"; + plugin_file.close(); + std::ofstream loadorder_file; + loadorder_file.open(dest_path_ / LOADORDER_FILE_NAME); + if(!loadorder_file.is_open()) + throw std::runtime_error("Could not open " + LOADORDER_FILE_NAME + "!"); + for(const auto& name : prefix_plugins_) + loadorder_file << name << "\n"; + for(const auto& [name, status] : plugins_) + loadorder_file << name << "\n"; + loadorder_file.close(); +} + +void LootDeployer::saveSettings() const +{ + Json::Value settings; + settings["num_profiles"] = num_profiles_; + settings["current_profile"] = current_profile_; + settings["list_download_time"] = list_download_time_; + settings["auto_update_master_list"] = auto_update_lists_; + sfs::path settings_file_path = dest_path_ / CONFIG_FILE_NAME; + std::ofstream file(settings_file_path, std::fstream::binary); + if(!file.is_open()) + throw std::runtime_error("Error: Could not write to \"" + settings_file_path.string() + "\"."); + file << settings; + file.close(); +} + +void LootDeployer::loadSettings() +{ + Json::Value settings; + sfs::path settings_file_path = dest_path_ / CONFIG_FILE_NAME; + if(!sfs::exists(settings_file_path)) + { + resetSettings(); + return; + } + std::ifstream file(settings_file_path, std::fstream::binary); + if(!file.is_open()) + { + resetSettings(); + return; + } + file >> settings; + file.close(); + if(!settings.isMember("num_profiles") || !settings.isMember("current_profile") || + !settings.isMember("list_download_time") || !settings.isMember("auto_update_master_list")) + { + resetSettings(); + return; + } + num_profiles_ = settings["num_profiles"].asInt(); + current_profile_ = settings["current_profile"].asInt(); + list_download_time_ = settings["list_download_time"].asInt64(); + auto_update_lists_ = settings["auto_update_master_list"].asBool(); +} + +void LootDeployer::updateAppType() +{ + for(const auto& [type, file] : TYPE_IDENTIFIERS) + { + if(pu::pathExists(source_path_ / file, "")) + { + app_type_ = type; + return; + } + } + throw std::runtime_error("Could not identify game type in '" + source_path_.string() + "'"); +} + +void LootDeployer::updateMasterList() +{ + if(!auto_update_lists_) + return; + const auto cur_time = std::chrono::system_clock::now(); + const std::chrono::time_point update_time( + (std::chrono::seconds(list_download_time_))); + const auto one_day_ago = cur_time - std::chrono::days(1); + if(update_time >= one_day_ago && sfs::exists(dest_path_ / "masterlist.yaml")) + return; + + std::ofstream fstream(dest_path_ / "masterlist.yaml.tmp", std::ios::binary); + if(!fstream.is_open()) + throw std::runtime_error("Failed to update masterlist.yaml: Could not write to: \"" + + dest_path_.string() + "\"."); + + std::string url = LIST_URLS.at(app_type_); + auto pos = url.find(" "); + while(pos != std::string::npos) + { + url.replace(pos, 1, "%20"); + pos = url.find(" "); + } + cpr::Response response = cpr::Download(fstream, cpr::Url{ url }); + if(response.status_code != 200) + { + if(sfs::exists(dest_path_ / "masterlist.yaml.tmp")) + sfs::remove(dest_path_ / "masterlist.yaml.tmp"); + throw std::runtime_error("Could not download masterlist.yaml from '" + LIST_URLS.at(app_type_) + + "'.\nTry to update the URL in the " + + "settings. Alternatively, you can manually download the " + + "file and place it in '" + dest_path_.string() + + "'. You can disable auto updates in '" + + (dest_path_ / CONFIG_FILE_NAME).string() + "'."); + } + if(sfs::exists(dest_path_ / "masterlist.yaml")) + sfs::remove(dest_path_ / "masterlist.yaml"); + sfs::rename(dest_path_ / "masterlist.yaml.tmp", dest_path_ / "masterlist.yaml"); + list_download_time_ = + std::chrono::duration_cast(cur_time.time_since_epoch()).count(); + saveSettings(); +} + +void LootDeployer::resetSettings() +{ + num_profiles_ = 1; + current_profile_ = 0; + auto_update_lists_ = true; + list_download_time_ = 0; +} + +void LootDeployer::setupPluginFiles() +{ + if(sfs::exists(dest_path_ / PLUGIN_FILE_NAME) && sfs::exists(dest_path_ / LOADORDER_FILE_NAME)) + return; + updatePlugins(); +} + +void LootDeployer::updatePluginTags() +{ + tags_.clear(); + auto loot_handle = loot::CreateGameHandle(app_type_, source_path_, dest_path_); + std::vector plugin_paths; + plugin_paths.reserve(plugins_.size()); + for(const auto& [path, s] : plugins_) + plugin_paths.emplace_back(source_path_ / path); + loot_handle->LoadPlugins(plugin_paths, false); + num_light_plugins_ = 0; + num_master_plugins_ = 0; + num_standard_plugins_ = 0; + for(int i = 0; i < plugins_.size(); i++) + { + auto plugin = loot_handle->GetPlugin(plugins_[i].first); + if(plugin->IsLightPlugin()) + { + num_light_plugins_++; + tags_.push_back({ LIGHT_PLUGIN }); + } + else if(plugin->IsMaster()) + { + num_master_plugins_++; + tags_.push_back({ MASTER_PLUGIN }); + } + else + { + num_standard_plugins_++; + tags_.push_back({ STANDARD_PLUGIN }); + } + } + writePluginTags(); +} + +void LootDeployer::writePluginTags() const +{ + Json::Value json; + for(int i = 0; i < tags_.size(); i++) + { + for(int j = 0; j < tags_[i].size(); j++) + json[i][j] = tags_.at(i).at(j); + } + + const sfs::path tag_file_path = dest_path_ / TAGS_FILE_NAME; + std::ofstream file(tag_file_path, std::fstream::binary); + if(!file.is_open()) + throw std::runtime_error("Error: Could not write to \"" + tag_file_path.string() + "\"."); + file << json; + file.close(); +} + +void LootDeployer::readPluginTags() +{ + const sfs::path tag_file_path = dest_path_ / TAGS_FILE_NAME; + if(!sfs::exists(tag_file_path)) + { + updatePluginTags(); + return; + } + tags_.clear(); + num_light_plugins_ = 0; + num_master_plugins_ = 0; + num_standard_plugins_ = 0; + std::ifstream file(tag_file_path, std::fstream::binary); + if(!file.is_open()) + throw std::runtime_error("Error: Could not read from \"" + tag_file_path.string() + "\"."); + Json::Value json; + file >> json; + file.close(); + for(int i = 0; i < json.size(); i++) + { + tags_.push_back({}); + for(int j = 0; j < json[i].size(); j++) + { + const std::string tag = json[i][j].asString(); + tags_[i].push_back(tag); + if(tag == LIGHT_PLUGIN) + num_light_plugins_++; + else if(tag == MASTER_PLUGIN) + num_master_plugins_++; + else if(tag == STANDARD_PLUGIN) + num_standard_plugins_++; + } + } + if(tags_.size() != plugins_.size()) + updatePluginTags(); +} diff --git a/src/core/lootdeployer.h b/src/core/lootdeployer.h new file mode 100644 index 0000000..6e7eb5b --- /dev/null +++ b/src/core/lootdeployer.h @@ -0,0 +1,276 @@ +/*! + * \file lootdeployer.h + * \brief Header for the LootDeployer class + */ +#pragma once + +#include "deployer.h" +#include "loot/api.h" +#include + + +/*! + * \brief Autonomous Deployer which handles plugins for Fallout 3, Fallout 4, + * Fallout New Vegas, Fallout 4 VR, Starfield, Morrowind, Oblivion, Skyrim, + * Skyrim SE and Skyrim VR. + */ +class LootDeployer : public Deployer +{ +public: + /*! + * \brief Loads plugins and identifies the app type to be managed. + * \param source_path Path to the directory containing installed plugins. + * \param dest_path Path to the directory containing plugins.txt and loadorder.txt. + * \param name A custom name for this instance. + * \param init_tags If true: Initializes plugin tags. Disable this for testing purposes + * with invalid plugin files + */ + LootDeployer(const std::filesystem::path& source_path, + const std::filesystem::path& dest_path, + const std::string& name, + bool init_tags = true); + + /*! \brief Maps game type to a URL pointing to the masterlist.yaml for that type. */ + static inline const std::map DEFAULT_LIST_URLS = { + { loot::GameType::fo3, + "https://raw.githubusercontent.com/loot/fallout3/master/masterlist.yaml" }, + { loot::GameType::fo4, + "https://raw.githubusercontent.com/loot/fallout4/master/masterlist.yaml" }, + { loot::GameType::fo4vr, + "https://raw.githubusercontent.com/loot/fallout4vr/master/masterlist.yaml" }, + { loot::GameType::fonv, + "https://raw.githubusercontent.com/loot/falloutnv/master/masterlist.yaml" }, + { loot::GameType::starfield, + "https://raw.githubusercontent.com/loot/starfield/master/masterlist.yaml" }, + { loot::GameType::tes3, + "https://raw.githubusercontent.com/loot/morrowind/master/masterlist.yaml" }, + { loot::GameType::tes4, + "https://raw.githubusercontent.com/loot/oblivion/master/masterlist.yaml" }, + { loot::GameType::tes5, + "https://raw.githubusercontent.com/loot/skyrim/master/masterlist.yaml" }, + { loot::GameType::tes5se, + "https://raw.githubusercontent.com/loot/skyrimse/master/masterlist.yaml" }, + { loot::GameType::tes5vr, + "https://raw.githubusercontent.com/loot/skyrimvr/master/masterlist.yaml" } + }; + + static inline std::map LIST_URLS; + + /*! + * \brief Reloads all deployed plugins. Does NOT save current load order to disk. + * \param progress_node Used to inform about the current progress of deployment. + * \return Since this is an autonomous deployer, the returned map is always empty. + */ + std::map deploy(std::optional progress_node = {}) override; + /*! + * \brief Reloads all deployed plugins. Does NOT save current load order to disk. + * \param loadorder Ignored. + * \param progress_node Used to inform about the current progress of deployment. + * \return Since this is an autonomous deployer, the returned map is always empty. + */ + std::map deploy(const std::vector& loadorder, + std::optional progress_node = {}) override; + /*! + * \brief Moves a mod from one position in the load order to another. Saves changes to disk. + * \param from_index Index of mod to be moved. + * \param to_index Destination index. + */ + void changeLoadorder(int from_index, int to_index) override; + /*! + * \brief Enables or disables the given mod in the load order. Saves changes to disk. + * \param mod_id Mod to be edited. + * \param status The new status. + */ + void setModStatus(int mod_id, bool status) override; + /*! + * \brief Conflict groups are not supported by this type. + * \return All plugins in the non conflicting group. + */ + std::vector> getConflictGroups() const override; + /*! + * \brief Generates a vector of names for every plugin. + * \return The name vector. + */ + std::vector getModNames() const override; + /*! + * \brief Adds a new profile and optionally copies it's load order from an existing profile. + * Profiles are stored in the target directory. + * \param source The profile to be copied. A value of -1 indicates no copy. + */ + void addProfile(int source = -1) override; + /*! + * \brief Removes a profile. + * \param profile The profile to be removed. + */ + void removeProfile(int profile) override; + /*! + * \brief Setter for the active profile. Changes the currently active loadorder.txt + * and plugin.txt to the ones saved in the new profile. + * \param profile The new profile. + */ + void setProfile(int profile) override; + /*! + * \brief Does nothing. + * \param newConflict_groups Ignored. + */ + void setConflictGroups(const std::vector>& newConflict_groups) override; + /*! + * \brief Returns the number of plugins on the load order. + * \return The number of plugins. + */ + int getNumMods() const override; + /*! + * \brief Getter for the current plugin load order. + * \return The load order. + */ + std::vector> getLoadorder() const override; + /*! + * \brief Does nothing since this deployer manages its own mods. + * \param mod_id Ignored. + * \param enabled Ignored. + * \param update_conflicts Ignored. + * \return False. + */ + bool addMod(int mod_id, bool enabled = true, bool update_conflicts = true) override; + /*! + * \brief Does nothing. + * \param mod_id Ignored. + * \return False. + */ + bool removeMod(int mod_id) override; + /*! + * \brief Since this deployer uses its own internal mod ids, this function always + * returns false. + * \param mod_id Ignores + * \return False. + */ + bool hasMod(int mod_id) const override; + /*! + * \brief swapMod Does nothing since this deployer manages its own mods. + * \param old_id Ignored. + * \param new_id Ignored + * \return False. + */ + bool swapMod(int old_id, int new_id) override; + /*! + * \brief Not supported. + * \param mod_id Ignored. + * \param show_disabled Ignored. + * \param progress_node Set to 100%. + * \return An empty vector. + */ + std::vector getFileConflicts( + int mod_id, + bool show_disabled = false, + std::optional progress_node = {}) const override; + /*! + * \brief Checks for conflicts with other mods. + * Two mods are conflicting if they share at least one record. + * \param mod_id The mod to be checked. + * \param progress_node Used to inform about the current progress. + * \return A set of mod ids which conflict with the given mod. + */ + std::unordered_set getModConflicts(int mod_id, + std::optional progress_node = {}) override; + /*! + * \brief Sorts the current load order using LOOT. Uses a masterlist.yaml appropriate + * for the game managed by this deployer and optionally a userlist.yaml in the target + * directory. Saves the new load order to disk after sorting. + * \param progress_node Used to inform about the current progress. + */ + void sortModsByConflicts(std::optional progress_node = {}) override; + /*! \brief Deletes the config file and all profile files. */ + void cleanup() override; + /*! + * \brief Getter for mod tags. + * \return For every mod: A vector of auto tags added to that mod. + */ + virtual std::vector> getAutoTags() override; + /*! + * \brief Returns all available auto tag names. + * \return The tag names. + */ + virtual std::map getAutoTagMap() override; + +private: + /*! \brief Name of the file containing plugin activation status. */ + static constexpr std::string PLUGIN_FILE_NAME = "plugins.txt"; + /*! \brief Name of the file containing plugin load order. */ + static constexpr std::string LOADORDER_FILE_NAME = "loadorder.txt"; + /*! \brief Appended to profile file names. */ + static constexpr std::string EXTENSION = ".lmmprof"; + /*! \brief Name of the file containing settings. */ + static constexpr std::string CONFIG_FILE_NAME = ".lmmconfig"; + /*! \brief Name of the file containing loot tags. */ + static constexpr std::string TAGS_FILE_NAME = ".loot_tags"; + /*! \brief Maps supported game type to a path to a file unique to that type. */ + static inline const std::map TYPE_IDENTIFIERS = { + { loot::GameType::fo3, "Fallout3.esm" }, + { loot::GameType::fo4, "Fallout4.esm" }, + { loot::GameType::fo4vr, "Fallout4_VR.esm" }, + { loot::GameType::fonv, "FalloutNV.esm" }, + { loot::GameType::starfield, "Starfield.esm" }, + { loot::GameType::tes3, "Morrowind.esm" }, + { loot::GameType::tes4, "Oblivion.esm" }, + { loot::GameType::tes5, std::filesystem::path("..") / "TESV.exe" }, + { loot::GameType::tes5se, std::filesystem::path("..") / "SkyrimSE.exe" }, + { loot::GameType::tes5vr, "SkyrimVR.esm" } + }; + /*! \brief Name of a light plugin tag. */ + static constexpr std::string LIGHT_PLUGIN = "Light"; + /*! \brief Name of a master plugin tag. */ + static constexpr std::string MASTER_PLUGIN = "Master"; + /*! \brief Name of a standard plugin tag. */ + static constexpr std::string STANDARD_PLUGIN = "Standard"; + /*! \brief Contains names of all plugins and their activation status. */ + std::vector> plugins_; + /*! \brief Contains names of plugins which are in loadorder.txt but not in plugins.txt. */ + std::vector prefix_plugins_; + /*! \brief Current number of profiles. */ + int num_profiles_ = 0; + /*! \brief Type of game to be managed. */ + loot::GameType app_type_; + /*! \brief Timestamp representing the last time the masterlist.yaml was updated. */ + long list_download_time_ = 0; + /*! \brief If true: Automatically download new master lists. */ + bool auto_update_lists_ = true; + /*! \brief Current number of light plugins. */ + int num_light_plugins_ = 0; + /*! \brief Current number of master plugins. */ + int num_master_plugins_ = 0; + /*! \brief Current number of standard plugins. */ + int num_standard_plugins_ = 0; + /*! \brief For every plugin: Every loot tag associated with that plugin. */ + std::vector> tags_; + + /*! \brief Updates current plugins to reflect plugins actually in the source directory. */ + void updatePlugins(); + /*! \brief Load plugins from plugins.txt and loadorder.txt. */ + void loadPlugins(); + /*! \brief Writes current load order to plugins.txt and loadorder.txt. */ + void writePlugins() const; + /*! + * \brief Saves number of profiles, active profile, list_download_time_ and + * auto_update_lists_ to the config file. + */ + void saveSettings() const; + /*! + * \brief Loads number of profiles, active profile, list_download_time_ and + * auto_update_lists_ from the config file. + */ + void loadSettings(); + /*! \brief Identifies the type of game in the source directory using signature files. */ + void updateAppType(); + /*! \brief Downloads a new masterlist.yaml, if the current one is older than a day. */ + void updateMasterList(); + /*! \brief Resets all settings to default values. */ + void resetSettings(); + /*! \brief Creates plugin.txt and loadorder.txt files if they do not exist. */ + void setupPluginFiles(); + /*! \brief Updates the loot plugin tags for every currently loaded plugin. */ + void updatePluginTags(); + /*! \brief Writes the current tags_ to disk. */ + void writePluginTags() const; + /*! \brief Reads tags_ from disk. */ + void readPluginTags(); +}; diff --git a/src/core/manualtag.cpp b/src/core/manualtag.cpp new file mode 100644 index 0000000..7e8fa04 --- /dev/null +++ b/src/core/manualtag.cpp @@ -0,0 +1,61 @@ +#include "manualtag.h" +#include "parseerror.h" + +namespace str = std::ranges; + + +ManualTag::ManualTag(std::string name) +{ + name_ = name; +} + +ManualTag::ManualTag(const Json::Value& json) +{ + if(!json.isMember("name")) + throw ParseError("Tag name is missing."); + name_ = json["name"].asString(); + + if(json.isMember("mod_ids")) + { + for(const auto& mod : json["mod_ids"]) + mods_.push_back(mod.asInt()); + } +} + +void ManualTag::addMod(int mod_id) +{ + auto iter = str::find(mods_, mod_id); + if(iter == mods_.end()) + mods_.push_back(mod_id); +} + +void ManualTag::removeMod(int mod_id) +{ + auto iter = str::find(mods_, mod_id); + if(iter != mods_.end()) + mods_.erase(iter); +} + +void ManualTag::setMods(const std::vector mods) +{ + mods_ = mods; +} + +Json::Value ManualTag::toJson() const +{ + Json::Value json; + json["name"] = name_; + for(int i = 0; i < mods_.size(); i++) + json["mod_ids"][i] = mods_[i]; + return json; +} + +bool ManualTag::operator==(const std::string& name) const +{ + return this->name_ == name; +} + +bool ManualTag::operator==(const ManualTag& other) const +{ + return this->name_ == other.name_; +} diff --git a/src/core/manualtag.h b/src/core/manualtag.h new file mode 100644 index 0000000..3dc091c --- /dev/null +++ b/src/core/manualtag.h @@ -0,0 +1,65 @@ +/*! + * \file manualtag.h + * \brief Header for the ManualTag class. + */ + +#pragma once + +#include "tag.h" +#include +#include +#include + + +/*! + * \brief Tag which has to be manually added to mods. + */ +class ManualTag : public Tag +{ +public: + /*! + * \brief Constructs a new tag with the given name. + * \param name The tags name. + */ + ManualTag(std::string name); + /*! + * \brief Deserializes a ManualTag from the given json object. + * \param json Source json object. + * \param json_path Path to the json object. Used is exception messaged. + * \throws ParseError when the json object is invalid. + */ + ManualTag(const Json::Value& json); + + /*! + * \brief Adds this tag to the given mod. + * \param mod_id Id if the mod to which this tag is to be added. + */ + void addMod(int mod_id); + /*! + * \brief Removes this tag from the given mod. + * \param mod_id Id if the mod from which this tag is to be removed. + */ + void removeMod(int mod_id); + /*! + * \brief Removes this tag from all mods and adds it only to the given mods. + * \param mods Mods to which this tag is to be added. + */ + void setMods(const std::vector mods); + /*! + * \brief Serializes this tag to a json object. + * \return The json object. + */ + Json::Value toJson() const; + /*! + * \brief Compares this tag by name to the given name. + * \param name Name to compare to. + * \return True if the names are identical. + */ + bool operator==(const std::string& name) const; + /*! + * \brief Compares this tag by name to the given tag. + * \param other Tag to compare to. + * \return True if the names are identical. + */ + bool operator==(const ManualTag& other) const; +}; diff --git a/src/core/mod.cpp b/src/core/mod.cpp new file mode 100644 index 0000000..80a0e29 --- /dev/null +++ b/src/core/mod.cpp @@ -0,0 +1,53 @@ +#include "mod.h" + +Mod::Mod(int id, + const std::string& name, + const std::string& version, + const std::time_t& time, + const std::filesystem::path& source_l, + const std::string& source_r, + const std::time_t& time_r, + unsigned long size, + const std::time_t& suppress_time) : + id(id), name(std::move(name)), version(std::move(version)), install_time(time), + local_source(source_l), remote_source(source_r), remote_update_time(time_r), size_on_disk(size), + suppress_update_time(suppress_time) +{} + +Mod::Mod(const Json::Value& json) +{ + Mod(json["id"].asInt(), + json["name"].asString(), + json["version"].asString(), + json["install_time"].asInt64(), + json["local_source"].asString(), + json["remote_source"].asString(), + json["remote_update_time"].asInt64(), + json["size_on_disk"].asInt64(), + json["suppress_update_time"].asInt64()); +} + +Json::Value Mod::toJson() const +{ + Json::Value json; + json["id"] = id; + json["name"] = name; + json["version"] = version; + json["install_time"] = install_time; + json["local_source"] = local_source.string(); + json["remote_source"] = remote_source; + json["remote_update_time"] = remote_update_time; + json["size_on_disk"] = size_on_disk; + json["suppress_update_time"] = suppress_update_time; + return json; +} + +bool Mod::operator==(const Mod& other) const +{ + return id == other.id; +} + +bool Mod::operator<(const Mod& other) const +{ + return id < other.id; +} diff --git a/src/core/mod.h b/src/core/mod.h new file mode 100644 index 0000000..50dfe77 --- /dev/null +++ b/src/core/mod.h @@ -0,0 +1,78 @@ +/*! + * \file mod.h + * \brief Contains the Mod struct. + */ + +#pragma once + +#include +#include +#include + + +/*! + * \brief Stores information about an installed mod. + */ +struct Mod +{ + /*! \brief The mod's id. */ + int id; + /*! \brief The mod's name. */ + std::string name; + /*! \brief The mod's version. */ + std::string version; + /*! \brief The mod's installation time. */ + std::time_t install_time; + /*! \brief Path to the local archive or directory used to install this mod. */ + std::filesystem::path local_source; + /*! \brief URL from where the mod was downloaded. */ + std::string remote_source; + /*! \brief Timestamp for when the mod was updated at the remote source. */ + std::time_t remote_update_time; + /*! \brief Total size of the installed mod on disk. */ + unsigned long size_on_disk; + /*! \brief Timestamp for when the user requested to suppress current update notifications. */ + std::time_t suppress_update_time; + + /*! + * \brief Constructor. Simply initializes members. + * \param id The mod's id. + * \param name The mod's name. + * \param version The mod's version. + * \param time The mod's installation time. + * \param source_l Path to the local archive or directory used to install this mod. + * \param source_r URL from where the mod was downloaded. + * \param time_r Timestamp for when the mod was updated at the remote source. + * \param size Total size of the installed mod on disk. + * \param suppress_time Timestamp for when the user requested to suppress current update + * notifications. + */ + Mod(int id, + const std::string& name, + const std::string& version, + const std::time_t& time, + const std::filesystem::path& source_l, + const std::string& source_r, + const std::time_t& time_r, + unsigned long size, + const std::time_t& suppress_time); + /*! + * \brief Initializes all members from a JSON object. + * \param json The source for member values. + */ + Mod(const Json::Value& json); + + Json::Value toJson() const; + /*! + * \brief Compares to another mod by id. + * \param other Mod to compare to. + * \return True if both share the same id, else false. + */ + bool operator==(const Mod& other) const; + /*! + * \brief Compares mods by their id. + * \param Other mod for comparison. + * \return True only if this.id < other.id + */ + bool operator<(const Mod& other) const; +}; diff --git a/src/core/moddedapplication.cpp b/src/core/moddedapplication.cpp new file mode 100644 index 0000000..26af439 --- /dev/null +++ b/src/core/moddedapplication.cpp @@ -0,0 +1,1993 @@ +#include "moddedapplication.h" +#include "deployerfactory.h" +#include "installer.h" +#include "parseerror.h" +#include "pathutils.h" +#include +#include +#include +#include +#include + +namespace sfs = std::filesystem; +namespace str = std::ranges; +namespace pu = path_utils; + + +ModdedApplication::ModdedApplication(sfs::path staging_dir, + std::string name, + std::string command, + std::filesystem::path icon_path, + std::string app_version) : + staging_dir_(staging_dir), name_(name), command_(command), icon_path_(icon_path) +{ + if(sfs::exists(staging_dir / CONFIG_FILE_NAME)) + updateState(true); + else + { + addProfile({ "Default", app_version, -1 }); + updateSettings(true); + } + sfs::copy(staging_dir_ / CONFIG_FILE_NAME, + staging_dir_ / ("." + CONFIG_FILE_NAME + ".bak"), + sfs::copy_options::overwrite_existing); +} + +void ModdedApplication::deployMods() +{ + std::vector deployers; + for(int i = 0; i < deployers_.size(); i++) + deployers.push_back(i); + deployModsFor(deployers); +} + +void ModdedApplication::deployModsFor(const std::vector& deployers) +{ + std::vector weights; + for(int i : deployers) + { + const int num_mods = deployers_[i]->getNumMods(); + if(deployers_[i]->isAutonomous() || num_mods == 0) + weights.push_back(1); + else + weights.push_back(num_mods); + } + + // always deploy normal deployers first, since some autonomous deployers + // may depend on their output + ProgressNode node(progress_callback_, weights); + for(int i : deployers) + { + if(!deployers_[i]->isAutonomous()) + { + const auto mod_sizes = deployers_[i]->deploy(&(node.child(i))); + for(const auto [mod_id, mod_size] : mod_sizes) + { + auto mod_iter = + str::find_if(installed_mods_, [id = mod_id](const Mod& m) { return m.id == id; }); + if(mod_iter != installed_mods_.end()) + mod_iter->size_on_disk = mod_size; + } + } + } + + for(int i : deployers) + { + if(deployers_[i]->isAutonomous()) + deployers_[i]->deploy(&(node.child(i))); + } + + updateSettings(true); +} + +void ModdedApplication::installMod(const AddModInfo& info) +{ + if(info.replace_mod && info.group != -1) + { + replaceMod(info); + return; + } + ProgressNode progress_node(progress_callback_); + if(info.group >= 0 && !info.deployers.empty()) + progress_node.addChildren({ 1.0f, 10.0f, info.deployers.size() > 1 ? 10.0f : 1.0f }); + else if(info.group >= 0 || !info.deployers.empty()) + progress_node.addChildren({ 1, 10 }); + else + progress_node.addChildren({ 1 }); + progress_node.child(0).setTotalSteps(1); + int mod_id = 0; + if(!installed_mods_.empty()) + mod_id = std::max_element(installed_mods_.begin(), installed_mods_.end())->id + 1; + while(sfs::exists(staging_dir_ / std::to_string(mod_id)) && + mod_id < std::numeric_limits().max()) + mod_id++; + if(mod_id == std::numeric_limits().max()) + throw std::runtime_error("Error: Could not generate new mod id."); + last_mod_id_ = mod_id; + const auto mod_size = Installer::install(info.source_path, + staging_dir_ / std::to_string(mod_id), + info.installer_flags, + info.installer, + info.root_level, + info.files); + const auto time_now = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); + installed_mods_.emplace_back(mod_id, + info.name, + info.version, + time_now, + info.local_source, + info.remote_source, + time_now, + mod_size, + time_now); + installer_map_[mod_id] = info.installer; + progress_node.child(0).advance(); + if(info.group >= 0) + { + if(modHasGroup(info.group)) + addModToGroup(mod_id, group_map_[info.group], &progress_node.child(1)); + else + createGroup(mod_id, info.group, &progress_node.child(1)); + } + + for(int deployer : info.deployers) + addModToDeployer(deployer, mod_id, true, &progress_node.child(info.group >= 0 ? 2 : 1)); + + for(auto& tag : auto_tags_) + tag.updateMods(staging_dir_, std::vector{ mod_id }); + updateAutoTagMap(); + + updateSettings(true); +} + +void ModdedApplication::uninstallMods(const std::vector& mod_ids, + const std::string& installer_type) +{ + std::vector weights; + std::vector> update_targets; + for(int depl = 0; depl < deployers_.size(); depl++) + update_targets.push_back({}); + for(int mod_id : mod_ids) + { + if(group_map_.contains(mod_id)) + removeModFromGroup(mod_id, false); + auto mod_iter = std::find_if( + installed_mods_.begin(), installed_mods_.end(), [mod_id](Mod m) { return m.id == mod_id; }); + if(mod_iter == installed_mods_.end()) + continue; + for(int depl = 0; depl < deployers_.size(); depl++) + { + if(deployers_[depl]->isAutonomous()) + continue; + for(int prof = 0; prof < profile_names_.size(); prof++) + { + deployers_[depl]->setProfile(prof); + if(deployers_[depl]->removeMod(mod_id) && + str::find(update_targets[depl], prof) == update_targets[depl].end()) + { + update_targets[depl].push_back(prof); + weights.push_back(deployers_[depl]->getNumMods()); + } + } + deployers_[depl]->setProfile(current_profile_); + } + + installed_mods_.erase(mod_iter); + std::string installer = Installer::SIMPLEINSTALLER; + if(installer_type == "" && installer_map_.contains(mod_id)) + installer = installer_map_[mod_id]; + Installer::uninstall(staging_dir_ / std::to_string(mod_id), installer); + + for(auto& tag : manual_tags_) + tag.removeMod(mod_id); + } + + ProgressNode node(progress_callback_, weights); + int i = 0; + for(int depl = 0; depl < update_targets.size(); depl++) + { + for(int prof : update_targets[depl]) + { + deployers_[depl]->setProfile(prof); + deployers_[depl]->updateConflictGroups(&node.child(i)); + i++; + } + deployers_[depl]->setProfile(current_profile_); + } + + updateSettings(true); +} + +void ModdedApplication::changeLoadorder(int deployer, int from_index, int to_index) +{ + deployers_[deployer]->changeLoadorder(from_index, to_index); + updateSettings(true); +} + +void ModdedApplication::addModToDeployer(int deployer, + int mod_id, + bool update_conflicts, + std::optional progress_node) +{ + if(!deployers_[deployer]->isAutonomous()) + { + const bool was_added = deployers_[deployer]->addMod(mod_id); + ProgressNode node(progress_callback_); + if(update_conflicts && was_added) + deployers_[deployer]->updateConflictGroups(progress_node ? progress_node : &node); + else if(progress_node) + { + (*progress_node)->setTotalSteps(1); + (*progress_node)->advance(); + } + splitMod(mod_id, deployer); + updateSettings(true); + } +} + +void ModdedApplication::removeModFromDeployer(int deployer, + int mod_id, + bool update_conflicts, + std::optional progress_node) +{ + if(!deployers_[deployer]->isAutonomous()) + { + const bool was_removed = deployers_[deployer]->removeMod(mod_id); + ProgressNode node(progress_callback_); + if(update_conflicts && was_removed) + deployers_[deployer]->updateConflictGroups(progress_node ? progress_node : &node); + else if(progress_node) + { + (*progress_node)->setTotalSteps(1); + (*progress_node)->advance(); + } + updateSettings(true); + } +} + +void ModdedApplication::setModStatus(int deployer, int mod_id, bool status) +{ + deployers_[deployer]->setModStatus(mod_id, status); + updateSettings(true); +} + +void ModdedApplication::addDeployer(const EditDeployerInfo& info) +{ + std::string source_dir = staging_dir_; + if(DeployerFactory::AUTONOMOUS_DEPLOYERS.at(info.type)) + source_dir = info.source_dir; + deployers_.push_back(DeployerFactory::makeDeployer( + info.type, source_dir, info.target_dir, info.name, info.use_copy_deployment)); + for(int i = 0; i < profile_names_.size(); i++) + deployers_[deployers_.size() - 1]->addProfile(); + deployers_[deployers_.size() - 1]->setProfile(current_profile_); + for(int i = 0; i < installed_mods_.size(); i++) + { + for(int depl = 0; depl < deployers_.size(); depl++) + { + if(deployers_[depl]->hasMod(installed_mods_[i].id)) + splitMod(installed_mods_[i].id, depl); + } + } + updateSettings(true); +} + +void ModdedApplication::removeDeployer(int deployer, bool cleanup) +{ + if(cleanup) + deployers_[deployer]->cleanup(); + deployers_.erase(deployers_.begin() + deployer); + updateSettings(true); +} + +std::vector ModdedApplication::getDeployerNames() const +{ + std::vector names; + for(const auto& deployer : deployers_) + names.push_back(deployer->getName()); + return names; +} + +std::vector ModdedApplication::getModInfo() const +{ + std::vector mod_info{}; + for(const auto& mod : installed_mods_) + { + std::vector deployer_names; + std::vector deployer_ids; + std::vector statuses; + for(int i = 0; i < deployers_.size(); i++) + { + if(deployers_[i]->isAutonomous()) + continue; + auto status = deployers_[i]->getModStatus(mod.id); + if(status) + { + deployer_names.push_back(deployers_[i]->getName()); + deployer_ids.push_back(i); + statuses.push_back(*status); + } + } + + int group = -1; + bool is_active = false; + if(group_map_.contains(mod.id)) + { + group = group_map_.at(mod.id); + is_active = active_group_members_[group] == mod.id; + } + + mod_info.emplace_back( + mod.id, + mod.name, + mod.version, + mod.install_time, + mod.local_source, + mod.remote_source, + mod.remote_update_time, + mod.size_on_disk, + mod.suppress_update_time, + deployer_names, + deployer_ids, + statuses, + group, + is_active, + manual_tag_map_.contains(mod.id) ? manual_tag_map_.at(mod.id) : std::vector{}, + auto_tag_map_.contains(mod.id) ? auto_tag_map_.at(mod.id) : std::vector{}); + } + return mod_info; +} + +std::vector> ModdedApplication::getLoadorder(int deployer) const +{ + return deployers_[deployer]->getLoadorder(); +} + +const sfs::path& ModdedApplication::getStagingDir() const +{ + return staging_dir_; +} + +void ModdedApplication::setStagingDir(std::string staging_dir, bool move_existing) +{ + if(staging_dir == staging_dir_) + return; + if(move_existing) + { + for(const auto& mod : installed_mods_) + { + std::string mod_dir = std::to_string(mod.id); + sfs::rename(staging_dir_ / mod_dir, sfs::path(staging_dir) / mod_dir); + } + sfs::rename(staging_dir_ / CONFIG_FILE_NAME, sfs::path(staging_dir) / CONFIG_FILE_NAME); + } + staging_dir_ = staging_dir; + updateState(true); +} + +const std::string& ModdedApplication::name() const +{ + return name_; +} + +void ModdedApplication::setName(const std::string& newName) +{ + name_ = newName; + updateSettings(true); +} + +int ModdedApplication::getNumDeployers() const +{ + return deployers_.size(); +} + +const std::string& ModdedApplication::getConfigFileName() const +{ + return CONFIG_FILE_NAME; +} + +void ModdedApplication::changeModName(int mod_id, const std::string& new_name) +{ + auto iter = std::find_if( + installed_mods_.begin(), installed_mods_.end(), [mod_id](Mod m) { return m.id == mod_id; }); + if(iter == installed_mods_.end()) + throw std::runtime_error("Error: Unknown mod id: " + std::to_string(mod_id)); + iter->name = new_name; + updateSettings(true); +} + +std::vector ModdedApplication::getFileConflicts(int deployer, + int mod_id, + bool show_disabled) const +{ + ProgressNode node(progress_callback_); + auto conflicts = deployers_[deployer]->getFileConflicts(mod_id, show_disabled, &node); + for(auto& [_, id, name] : conflicts) + name = getModName(id); + return conflicts; +} + +AppInfo ModdedApplication::getAppInfo() const +{ + AppInfo info; + info.name = name_; + info.staging_dir = staging_dir_.string(); + info.command = command_; + info.num_mods = installed_mods_.size(); + info.app_version = app_versions_[current_profile_]; + for(const auto& deployer : deployers_) + { + info.deployers.push_back(deployer->getName()); + info.deployer_types.push_back(deployer->getType()); + info.target_dirs.push_back(deployer->getDestPath()); + info.deployer_mods.push_back(deployer->getNumMods()); + info.uses_copy_deployment.push_back(deployer->usesCopyDeployment()); + } + info.tools = tools_; + for(const auto& tag : manual_tags_) + info.num_mods_per_manual_tag[tag.getName()] = tag.getNumMods(); + for(const auto& tag : auto_tags_) + { + info.num_mods_per_auto_tag[tag.getName()] = tag.getNumMods(); + info.auto_tags[tag.getName()] = { tag.getExpression(), tag.getConditions() }; + } + return info; +} + +void ModdedApplication::addTool(std::string name, std::string command) +{ + tools_.emplace_back(name, command); + updateSettings(true); +} + +void ModdedApplication::removeTool(int tool_id) +{ + if(tool_id < tools_.size() && tool_id >= 0) + { + tools_.erase(tools_.begin() + tool_id); + updateSettings(true); + } +} + +const std::vector>& ModdedApplication::getTools() const +{ + return tools_; +} + +const std::string& ModdedApplication::command() const +{ + return command_; +} + +void ModdedApplication::setCommand(const std::string& newCommand) +{ + command_ = newCommand; + updateSettings(true); +} + +void ModdedApplication::editDeployer(int deployer, const EditDeployerInfo& info) +{ + if(deployers_[deployer]->getType() == info.type) + { + deployers_[deployer]->setName(info.name); + deployers_[deployer]->setDestPath(info.target_dir); + deployers_[deployer]->setUseCopyDeployment(info.use_copy_deployment); + } + else + { + json_settings_["deployers"][deployer]["source_path"] = info.source_dir; + json_settings_["deployers"][deployer]["name"] = info.name; + json_settings_["deployers"][deployer]["dest_path"] = info.target_dir; + json_settings_["deployers"][deployer]["type"] = info.type; + json_settings_["deployers"][deployer]["use_copy_deployment"] = info.use_copy_deployment; + updateState(); + } + if(deployers_[deployer]->isAutonomous()) + deployers_[deployer]->setSourcePath(info.source_dir); + updateSettings(true); +} + +std::unordered_set ModdedApplication::getModConflicts(int deployer, int mod_id) +{ + ProgressNode node(progress_callback_); + return deployers_[deployer]->getModConflicts(mod_id, &node); +} + +void ModdedApplication::setProfile(int profile) +{ + if(profile < 0 || profile >= profile_names_.size()) + return; + bak_man_.setProfile(profile); + for(const auto& deployer : deployers_) + deployer->setProfile(profile); + current_profile_ = profile; +} + +void ModdedApplication::addProfile(const EditProfileInfo& info) +{ + profile_names_.push_back(info.name); + app_versions_.push_back(info.app_version); + for(const auto& deployer : deployers_) + deployer->addProfile(info.source); + bak_man_.addProfile(info.source); + updateSettings(true); +} + +void ModdedApplication::removeProfile(int profile) +{ + if(profile < 0 || profile >= profile_names_.size()) + return; + for(const auto& deployer : deployers_) + deployer->removeProfile(profile); + profile_names_.erase(profile_names_.begin() + profile); + app_versions_.erase(app_versions_.begin() + profile); + bak_man_.removeProfile(profile); + if(profile == current_profile_) + setProfile(0); + updateSettings(true); +} + +std::vector ModdedApplication::getProfileNames() const +{ + return profile_names_; +} + +void ModdedApplication::editProfile(int profile, const EditProfileInfo& info) +{ + if(profile < 0 || profile >= profile_names_.size()) + return; + profile_names_[profile] = info.name; + app_versions_[profile] = info.app_version; + updateSettings(true); +} + +void ModdedApplication::editTool(int tool, std::string name, std::string command) +{ + if(tool >= 0 && tool < tools_.size()) + { + std::get<0>(tools_[tool]) = name; + std::get<1>(tools_[tool]) = command; + } + updateSettings(true); +} + +std::tuple ModdedApplication::verifyDeployerDirectories() +{ + std::tuple ret{ 0, "" }; + for(const auto& depl : deployers_) + { + int cur_code = depl->verifyDirectories(); + if(cur_code) + { + ret = std::tuple{ cur_code, depl->destPath() }; + } + } + return ret; +} + +void ModdedApplication::addModToGroup(int mod_id, + int group, + std::optional progress_node) +{ + if(group < 0 || group >= groups_.size() || group_map_.contains(mod_id)) + return; + groups_[group].push_back(mod_id); + group_map_[mod_id] = group; + active_group_members_[group] = mod_id; + ProgressNode node(progress_callback_); + updateDeployerGroups(progress_node ? progress_node : &node); + updateSettings(true); +} + +void ModdedApplication::removeModFromGroup(int mod_id, + bool update_conflicts, + std::optional progress_node) +{ + if(!group_map_.contains(mod_id)) + return; + int group = group_map_[mod_id]; + groups_[group].erase(std::find(groups_[group].begin(), groups_[group].end(), mod_id)); + + if(!groups_[group].empty()) + { + active_group_members_[group] = groups_[group][0]; + std::vector> update_targets; + std::vector weights; + for(int depl = 0; depl < deployers_.size(); depl++) + { + update_targets.push_back({}); + if(deployers_[depl]->isAutonomous()) + continue; + for(int prof = 0; prof < profile_names_.size(); prof++) + { + deployers_[depl]->setProfile(prof); + auto loadorder = deployers_[depl]->getLoadorder(); + auto iter = str::find_if( + loadorder, [mod_id](const auto& tuple) { return std::get<0>(tuple) == mod_id; }); + if(iter != loadorder.end()) + { + deployers_[depl]->addMod(active_group_members_[group], std::get<1>(*iter), false); + deployers_[depl]->changeLoadorder(loadorder.size(), iter - loadorder.begin()); + update_targets[depl].push_back(prof); + weights.push_back(loadorder.size()); + } + } + deployers_[depl]->setProfile(current_profile_); + } + + ProgressNode node = progress_node ? **progress_node : ProgressNode(progress_callback_); + if(!update_conflicts) + { + node.setTotalSteps(1); + node.advance(); + } + else + { + node.addChildren(weights); + int i = 0; + for(int depl = 0; depl < update_targets.size(); depl++) + { + for(int prof : update_targets[depl]) + { + deployers_[depl]->setProfile(prof); + deployers_[depl]->updateConflictGroups(&node.child(i)); + i++; + } + deployers_[depl]->setProfile(current_profile_); + } + } + } + + if(groups_[group].size() == 1) + group_map_.erase(groups_[group][0]); + if(groups_[group].size() < 2) + { + groups_.erase(groups_.begin() + group); + active_group_members_.erase(active_group_members_.begin() + group); + for(auto& pair : group_map_) + { + if(pair.second > group) + pair.second--; + } + } + group_map_.erase(mod_id); + updateSettings(true); +} + +void ModdedApplication::createGroup(int first_mod_id, + int second_mod_id, + std::optional progress_node) +{ + if(group_map_.contains(first_mod_id)) + { + addModToGroup(second_mod_id, group_map_[first_mod_id]); + return; + } + if(group_map_.contains(second_mod_id)) + { + addModToGroup(first_mod_id, group_map_[second_mod_id]); + return; + } + groups_.push_back({ first_mod_id, second_mod_id }); + int group = groups_.size() - 1; + group_map_[first_mod_id] = group; + group_map_[second_mod_id] = group; + active_group_members_.push_back(first_mod_id); + ProgressNode node(progress_callback_); + updateDeployerGroups(progress_node ? progress_node : &node); + updateSettings(true); +} + +void ModdedApplication::changeActiveGroupMember(int group, + int mod_id, + std::optional progress_node) +{ + if(group < 0 || group >= groups_.size() || + std::find(groups_[group].begin(), groups_[group].end(), mod_id) == groups_[group].end()) + return; + active_group_members_[group] = mod_id; + ProgressNode node(progress_callback_); + updateDeployerGroups(progress_node ? progress_node : &node); + updateSettings(true); +} + +void ModdedApplication::changeModVersion(int mod_id, const std::string& new_version) +{ + auto iter = std::find_if( + installed_mods_.begin(), installed_mods_.end(), [mod_id](Mod m) { return m.id == mod_id; }); + if(iter == installed_mods_.end()) + throw std::runtime_error("Error: Unknown mod id: " + std::to_string(mod_id)); + iter->version = new_version; + updateSettings(true); +} + +int ModdedApplication::getNumGroups() +{ + return groups_.size(); +} + +bool ModdedApplication::modHasGroup(int mod_id) +{ + return group_map_.contains(mod_id); +} + +int ModdedApplication::getModGroup(int mod_id) +{ + if(!group_map_.contains(mod_id)) + return -1; + return group_map_[mod_id]; +} + +void ModdedApplication::sortModsByConflicts(int deployer) +{ + ProgressNode node(progress_callback_); + deployers_[deployer]->sortModsByConflicts(&node); + updateSettings(true); +} + +std::vector> ModdedApplication::getConflictGroups(int deployer) +{ + return deployers_[deployer]->getConflictGroups(); +} + +void ModdedApplication::updateModDeployers(const std::vector& mod_ids, + const std::vector& deployers) +{ + std::vector weights; + for(const auto& depl : deployers_) + weights.push_back(depl->isAutonomous() ? 1 : depl->getNumMods()); + ProgressNode node(progress_callback_, weights); + std::optional dummy_node{}; + for(int i = 0; i < mod_ids.size(); i++) + { + const int mod_id = mod_ids[i]; + const bool is_last_mod = i == (mod_ids.size() - 1); + for(int depl = 0; depl < deployers.size(); depl++) + { + if(deployers_[depl]->isAutonomous()) + continue; + if(deployers[depl]) + addModToDeployer(depl, mod_id, is_last_mod, is_last_mod ? &node.child(depl) : dummy_node); + else + removeModFromDeployer( + depl, mod_id, is_last_mod, is_last_mod ? &node.child(depl) : dummy_node); + } + } +} + +int ModdedApplication::verifyStagingDir(sfs::path staging_dir) +{ + try + { + Json::Value val; + std::ifstream file(staging_dir / CONFIG_FILE_NAME, std::fstream::binary); + if(file.is_open()) + file >> val; + file.close(); + } + catch(std::ios_base::failure& f) + { + return 1; + } + catch(Json::RuntimeError& e) + { + return 2; + } + return 0; +} + +void ModdedApplication::extractArchive(const sfs::path& source, const sfs::path& target) +{ + ProgressNode node(progress_callback_); + Installer::extract(source, target, &node); +} + +DeployerInfo ModdedApplication::getDeployerInfo(int deployer) +{ + if(!(deployers_[deployer]->isAutonomous())) + { + std::map mods_per_tag; + for(const auto& tag : manual_tags_) + mods_per_tag[tag.getName()] = tag.getNumMods(); + + const auto loadorder = deployers_[deployer]->getLoadorder(); + std::vector mod_names; + mod_names.reserve(loadorder.size()); + std::vector> manual_tags; + manual_tags.reserve(loadorder.size()); + std::vector> auto_tags; + manual_tags.reserve(loadorder.size()); + for(const auto& [id, e] : loadorder) + { + mod_names.push_back( + std::ranges::find_if(installed_mods_, [id = id](auto& mod) { return mod.id == id; })->name); + if(manual_tag_map_.contains(id)) + manual_tags.push_back(manual_tag_map_.at(id)); + else + manual_tags.push_back({}); + + if(auto_tag_map_.contains(id)) + auto_tags.push_back(auto_tag_map_.at(id)); + else + auto_tags.push_back({}); + } + for(const auto& tag : auto_tags_) + { + if(mods_per_tag.contains(tag.getName())) + mods_per_tag[tag.getName()] += tag.getNumMods(); + else + mods_per_tag[tag.getName()] = tag.getNumMods(); + } + return { mod_names, loadorder, deployers_[deployer]->getConflictGroups(), false, manual_tags, + auto_tags, mods_per_tag }; + } + else + { + return { deployers_[deployer]->getModNames(), + deployers_[deployer]->getLoadorder(), + deployers_[deployer]->getConflictGroups(), + true, + {}, + deployers_[deployer]->getAutoTags(), + deployers_[deployer]->getAutoTagMap() }; + } +} + +void ModdedApplication::setLog(const std::function& newLog) +{ + log_ = newLog; + for(auto& deployer : deployers_) + deployer->setLog(newLog); +} + +void ModdedApplication::addBackupTarget(const sfs::path& path, + const std::string& name, + const std::vector& backup_names) +{ + bak_man_.addTarget(path, name, backup_names); + updateSettings(true); +} + +void ModdedApplication::removeBackupTarget(int target_id) +{ + if(target_id < 0 || target_id >= bak_man_.getNumTargets()) + return; + bak_man_.removeTarget(target_id); + updateSettings(true); +} + +void ModdedApplication::removeAllBackupTargets() +{ + for(int target = 0; target < bak_man_.getNumTargets(); target++) + removeBackupTarget(target); +} + +void ModdedApplication::addBackup(int target_id, const std::string& name, int source) +{ + if(target_id < 0 || target_id >= bak_man_.getNumTargets()) + return; + bak_man_.addBackup(target_id, name, source); +} + +void ModdedApplication::removeBackup(int target_id, int backup_id) +{ + if(target_id < 0 || target_id >= bak_man_.getNumTargets() || backup_id < 0 || + backup_id >= bak_man_.getNumBackups(target_id)) + return; + bak_man_.removeBackup(target_id, backup_id); +} + +void ModdedApplication::setActiveBackup(int target_id, int backup_id) +{ + if(target_id < 0 || target_id >= bak_man_.getNumTargets() || backup_id < 0 || + backup_id >= bak_man_.getNumBackups(target_id)) + return; + bak_man_.setActiveBackup(target_id, backup_id); +} + +std::vector ModdedApplication::getBackupTargets() const +{ + return bak_man_.getTargets(); +} + +void ModdedApplication::setBackupName(int target_id, int backup_id, const std::string& name) +{ + if(target_id < 0 || target_id >= bak_man_.getNumTargets() || backup_id < 0 || + backup_id >= bak_man_.getNumBackups(target_id)) + return; + bak_man_.setBackupName(target_id, backup_id, name); +} + +void ModdedApplication::setBackupTargetName(int target_id, const std::string& name) +{ + if(target_id < 0 || target_id >= bak_man_.getNumTargets()) + return; + bak_man_.setBackupTargetName(target_id, name); +} + +void ModdedApplication::overwriteBackup(int target_id, int source_backup, int dest_backup) +{ + if(target_id < 0 || target_id >= bak_man_.getNumTargets()) + return; + bak_man_.overwriteBackup(target_id, source_backup, dest_backup); +} + +void ModdedApplication::cleanupFailedInstallation() +{ + Installer::cleanupFailedInstallation(staging_dir_, last_mod_id_); + auto iter = std::find_if(installed_mods_.begin(), + installed_mods_.end(), + [this](const Mod& m) { return m.id == this->last_mod_id_; }); + if(iter != installed_mods_.end()) + uninstallMods({ last_mod_id_ }); + last_mod_id_ = -1; +} + +void ModdedApplication::setProgressCallback(const std::function& progress_callback) +{ + progress_callback_ = progress_callback; +} + +void ModdedApplication::uninstallGroupMembers(const std::vector& mod_ids) +{ + std::vector uninstall_targets; + for(int active_id : mod_ids) + { + if(!group_map_.contains(active_id)) + continue; + for(int mod_id : groups_[group_map_[active_id]]) + { + if(mod_id != active_id) + uninstall_targets.push_back(mod_id); + } + } + uninstallMods(uninstall_targets); +} + +void ModdedApplication::addManualTag(const std::string& tag_name) +{ + if(str::find(manual_tags_, tag_name) != manual_tags_.end()) + throw std::runtime_error( + std::format("Error: A tag with the name '{}' already exists.", tag_name)); + manual_tags_.emplace_back(tag_name); + updateSettings(true); +} + +void ModdedApplication::removeManualTag(const std::string& tag_name, bool update_map) +{ + auto iter = str::find(manual_tags_, tag_name); + if(iter != manual_tags_.end()) + manual_tags_.erase(iter); + if(update_map) + updateManualTagMap(); + updateSettings(true); +} + +void ModdedApplication::changeManualTagName(const std::string& old_name, + const std::string& new_name, + bool update_map) +{ + auto old_iter = str::find(manual_tags_, old_name); + if(old_iter == manual_tags_.end()) + return; + auto new_iter = str::find(manual_tags_, new_name); + if(new_iter != manual_tags_.end()) + throw std::runtime_error( + std::format("Error: Cannot rename tag '{}', because a tag with the name '{}' already exists.", + old_name, + new_name)); + old_iter->setName(new_name); + if(update_map) + updateManualTagMap(); + updateSettings(true); +} + +void ModdedApplication::addTagsToMods(const std::vector& tag_names, + const std::vector& mod_ids) +{ + for(const auto& tag_name : tag_names) + { + auto tag = str::find(manual_tags_, tag_name); + if(tag == manual_tags_.end()) + return; + for(int mod : mod_ids) + tag->addMod(mod); + } + updateManualTagMap(); + updateSettings(true); +} + +void ModdedApplication::removeTagsFromMods(const std::vector& tag_names, + const std::vector& mod_ids) +{ + for(const auto& tag_name : tag_names) + { + auto tag = str::find(manual_tags_, tag_name); + if(tag == manual_tags_.end()) + return; + for(int mod : mod_ids) + tag->removeMod(mod); + } + updateManualTagMap(); + updateSettings(true); +} + +void ModdedApplication::setTagsForMods(const std::vector& tag_names, + const std::vector mod_ids) +{ + for(auto& tag : manual_tags_) + { + if(str::find(tag_names, tag) != tag_names.end()) + { + for(int mod : mod_ids) + tag.addMod(mod); + } + else + { + for(int mod : mod_ids) + tag.removeMod(mod); + } + } + updateManualTagMap(); + updateSettings(true); +} + +void ModdedApplication::editManualTags(const std::vector& actions) +{ + auto old_tags = manual_tags_; + try + { + for(const auto& action : actions) + { + if(action.getType() == EditManualTagAction::ActionType::add) + addManualTag(action.getName()); + else if(action.getType() == EditManualTagAction::ActionType::remove) + removeManualTag(action.getName(), false); + else if(action.getType() == EditManualTagAction::ActionType::rename) + changeManualTagName(action.getName(), action.getNewName(), false); + } + } + catch(std::runtime_error& e) + { + manual_tags_ = old_tags; + throw e; + } + updateManualTagMap(); + updateSettings(true); +} + +void ModdedApplication::addAutoTag(const std::string& tag_name, + const std::string& expression, + const std::vector& conditions, + bool update) +{ + if(std::find(auto_tags_.begin(), auto_tags_.end(), tag_name) != auto_tags_.end()) + throw std::runtime_error( + std::format("Error: A tag with the name '{}' already exists.", tag_name)); + + auto_tags_.emplace_back(tag_name, expression, conditions); + auto select_id = [](const auto& mod) { return mod.id; }; + if(expression != "") + auto_tags_.back().reapplyMods(staging_dir_, str::transform_view(installed_mods_, select_id)); + if(update) + { + updateAutoTagMap(); + updateSettings(true); + } +} + +void ModdedApplication::removeAutoTag(const std::string& tag_name, bool update) +{ + auto iter = std::find(auto_tags_.begin(), auto_tags_.end(), tag_name); + if(iter == auto_tags_.end()) + return; + auto_tags_.erase(iter); + if(update) + { + updateAutoTagMap(); + updateSettings(true); + } +} + +void ModdedApplication::renameAutoTag(const std::string& old_name, + const std::string& new_name, + bool update) +{ + auto iter = std::find(auto_tags_.begin(), auto_tags_.end(), old_name); + if(iter == auto_tags_.end()) + return; + if(std::find(auto_tags_.begin(), auto_tags_.end(), new_name) != auto_tags_.end()) + throw std::runtime_error( + std::format("Error: Cannot rename tag '{}', because a tag with the name '{}' already exists.", + old_name, + new_name)); + + iter->setName(new_name); + if(update) + { + updateAutoTagMap(); + updateSettings(true); + } +} + +void ModdedApplication::changeAutoTagEvaluator(const std::string& tag_name, + const std::string& expression, + const std::vector& conditions, + bool update) +{ + auto iter = std::find(auto_tags_.begin(), auto_tags_.end(), tag_name); + if(iter == auto_tags_.end()) + return; + + iter->setEvaluator(expression, conditions); + auto select_id = [](const auto& mod) { return mod.id; }; + if(update) + { + iter->reapplyMods(staging_dir_, str::transform_view(installed_mods_, select_id)); + updateAutoTagMap(); + updateSettings(true); + } +} + +void ModdedApplication::editAutoTags(const std::vector& actions) +{ + auto old_tags = auto_tags_; + try + { + std::vector reapply_targets; + for(const auto& action : actions) + { + if(action.getType() == EditAutoTagAction::ActionType::add) + addAutoTag(action.getName(), action.getExpression(), action.getConditions(), false); + else if(action.getType() == EditAutoTagAction::ActionType::remove) + removeAutoTag(action.getName(), false); + else if(action.getType() == EditAutoTagAction::ActionType::rename) + renameAutoTag(action.getName(), action.getNewName(), false); + else if(action.getType() == EditAutoTagAction::ActionType::change_evaluator) + { + changeAutoTagEvaluator( + action.getName(), action.getExpression(), action.getConditions(), false); + reapply_targets.push_back(action.getName()); + } + } + if(!reapply_targets.empty()) + { + log_(Log::LOG_INFO, "Reapplying auto tags with edited conditions to all mods..."); + ProgressNode node(progress_callback_); + node.addChildren({ 1.0f, std::min(8.0f, (float)reapply_targets.size()) }); + node.child(0).setTotalSteps(installed_mods_.size()); + std::vector weights; + for(const auto& tag : reapply_targets) + { + auto iter = std::find(auto_tags_.begin(), auto_tags_.end(), tag); + if(iter != auto_tags_.end()) + weights.push_back(iter->getNumConditions()); + } + node.child(1).addChildren(weights); + for(int i = 0; i < weights.size(); i++) + node.child(1).child(i).setTotalSteps(installed_mods_.size()); + + auto select_id = [](const auto& mod) { return mod.id; }; + auto mods = str::transform_view(installed_mods_, select_id); + const auto files = AutoTag::readModFiles(staging_dir_, mods, &node.child(0)); + for(int i = 0; i < reapply_targets.size(); i++) + { + auto iter = std::find(auto_tags_.begin(), auto_tags_.end(), reapply_targets[i]); + if(iter != auto_tags_.end()) + iter->reapplyMods(files, mods, &node.child(1).child(i)); + } + } + } + catch(std::runtime_error& e) + { + auto_tags_ = old_tags; + throw e; + } + updateAutoTagMap(); + updateSettings(true); +} + +void ModdedApplication::reapplyAutoTags() +{ + log_(Log::LOG_INFO, "Reapplying auto tags to all mods..."); + ProgressNode node(progress_callback_); + node.addChildren({ 1.0f, 8.0f }); + node.child(0).setTotalSteps(installed_mods_.size()); + std::vector weights; + for(auto& tag : auto_tags_) + weights.push_back(tag.getNumConditions()); + node.child(1).addChildren(weights); + for(int i = 0; i < weights.size(); i++) + node.child(1).child(i).setTotalSteps(installed_mods_.size()); + auto select_id = [](const auto& mod) { return mod.id; }; + auto mods = str::transform_view(installed_mods_, select_id); + const auto files = AutoTag::readModFiles(staging_dir_, mods, &node.child(0)); + for(int i = 0; i < auto_tags_.size(); i++) + auto_tags_[i].reapplyMods(files, mods, &node.child(1).child(i)); + updateAutoTagMap(); + updateSettings(true); +} + +void ModdedApplication::updateAutoTags(const std::vector mod_ids) +{ + log_(Log::LOG_INFO, std::format("Reapplying auto tags to {} mods...", mod_ids.size())); + ProgressNode node(progress_callback_); + node.addChildren( + { 1.0f, std::max(1.0f, 8.0f * (float)mod_ids.size() / (float)installed_mods_.size()) }); + node.child(0).setTotalSteps(mod_ids.size()); + std::vector weights; + for(auto& tag : auto_tags_) + weights.push_back(tag.getNumConditions()); + node.child(1).addChildren(weights); + for(int i = 0; i < weights.size(); i++) + node.child(1).child(i).setTotalSteps(mod_ids.size()); + const auto files = AutoTag::readModFiles(staging_dir_, mod_ids, &node.child(0)); + for(int i = 0; i < auto_tags_.size(); i++) + auto_tags_[i].updateMods(files, mod_ids, &node.child(1).child(i)); + updateAutoTagMap(); + updateSettings(true); +} + +void ModdedApplication::deleteAllData() +{ + for(int i = 0; i < deployers_.size(); i++) + removeDeployer(i, true); + for(auto mod : installed_mods_) + { + const auto path = staging_dir_ / std::to_string(mod.id); + if(sfs::exists(path)) + sfs::remove_all(path); + } + const auto path = staging_dir_ / CONFIG_FILE_NAME; + if(sfs::exists(path)) + sfs::remove(path); + + if(sfs::exists(staging_dir_ / download_dir_)) + sfs::remove_all(staging_dir_ / download_dir_); +} + +void ModdedApplication::setAppVersion(const std::string& app_version) +{ + app_versions_[current_profile_] = app_version; + updateSettings(true); +} + +void ModdedApplication::setModSources(int mod_id, + const std::string& local_source, + const std::string& remote_source) +{ + auto iter = std::find_if( + installed_mods_.begin(), installed_mods_.end(), [mod_id](Mod m) { return m.id == mod_id; }); + if(iter == installed_mods_.end()) + throw std::runtime_error("Error: Unknown mod id: " + std::to_string(mod_id)); + iter->local_source = local_source; + iter->remote_source = remote_source; + updateSettings(true); +} + +nexus::Page ModdedApplication::getNexusPage(int mod_id) +{ + auto iter = std::find_if( + installed_mods_.begin(), installed_mods_.end(), [mod_id](Mod m) { return m.id == mod_id; }); + if(iter == installed_mods_.end()) + throw std::runtime_error("Error: Unknown mod id: " + std::to_string(mod_id)); + return nexus::Api::getNexusPage(iter->remote_source); +} + +void ModdedApplication::checkForModUpdates() +{ + std::vector target_mod_indices; + for(const auto& [i, mod] : str::enumerate_view(installed_mods_)) + { + if(nexus::Api::modUrlIsValid(mod.remote_source) && mod.remote_update_time <= mod.install_time) + target_mod_indices.push_back(i); + } + performUpdateCheck(target_mod_indices); +} + +void ModdedApplication::checkModsForUpdates(const std::vector& mod_ids) +{ + std::vector target_mod_indices; + for(const auto& [i, mod] : str::enumerate_view(installed_mods_)) + { + if(str::find(mod_ids, mod.id) != mod_ids.end() && + nexus::Api::modUrlIsValid(mod.remote_source) && mod.remote_update_time <= mod.install_time) + target_mod_indices.push_back(i); + } + performUpdateCheck(target_mod_indices); +} + +void ModdedApplication::suppressUpdateNotification(const std::vector& mod_ids) +{ + for(int mod_id : mod_ids) + { + auto iter = std::find_if(installed_mods_.begin(), + installed_mods_.end(), + [mod_id](const Mod& mod) { return mod.id == mod_id; }); + if(iter != installed_mods_.end() && iter->remote_update_time > iter->install_time) + iter->suppress_update_time = + std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); + } + updateSettings(true); +} + +std::string ModdedApplication::getDownloadUrl(const std::string& nxm_url) +{ + return nexus::Api::getDownloadUrl(nxm_url); +} + +std::string ModdedApplication::getDownloadUrlForFile(int nexus_file_id, const std::string& mod_url) +{ + return nexus::Api::getDownloadUrl(mod_url, nexus_file_id); +} + +std::string ModdedApplication::getNexusPageUrl(const std::string& nxm_url) +{ + return nexus::Api::getNexusPageUrl(nxm_url); +} + +std::string ModdedApplication::downloadMod(const std::string& url, + std::function progress_callback) +{ + log_(Log::LOG_DEBUG, "Download URL: " + url); + std::regex url_regex(R"(.*/(.*)\?.*)"); + std::smatch match; + if(!std::regex_match(url, match, url_regex)) + throw std::runtime_error(std::format("Invalid download URL \"{}\"", url)); + sfs::path download_path = staging_dir_ / download_dir_; + if(!sfs::exists(download_path)) + sfs::create_directories(download_path); + sfs::path file_name = match[1].str(); + const std::string file_name_prefix = file_name.stem(); + const std::string extension = file_name.extension(); + int suffix = 1; + while(sfs::exists(download_path / file_name)) + { + file_name = file_name_prefix + "(" + std::to_string(suffix) + ")" + extension; + suffix++; + } + std::string file_name_str = file_name.string(); + auto pos = file_name_str.find("%20"); + while(pos != std::string::npos) + { + file_name_str.replace(pos, 3, " "); + pos = file_name_str.find("%20"); + } + file_name = file_name_str; + + std::ofstream fstream(download_path / file_name, std::ios::binary); + if(!fstream.is_open()) + throw std::runtime_error("Failed to write to disk."); + bool message_sent = false; + cpr::Response response = cpr::Download( + fstream, + cpr::Url(url), + cpr::ProgressCallback( + [app = this, &message_sent, &file_name, progress_callback](auto download_total, + auto download_now, + auto upload_total, + auto upload_now, + intptr_t user_data) + { + if(!message_sent && download_total > 0) + { + std::string size_string; + long last_size = 0; + long size = download_total; + int exp = 0; + const std::vector units{ "B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB" }; + while(size > 1024 && exp < units.size()) + { + last_size = size; + size /= 1024; + exp++; + } + last_size /= 1.024; + size_string = std::to_string(size); + const int first_digit = (last_size / 100) % 10; + const int second_digit = (last_size / 10) % 10; + if(first_digit != 0 || second_digit != 0) + size_string += "." + std::to_string(first_digit); + if(second_digit != 0) + size_string += std::to_string(second_digit); + size_string += units[exp]; + + app->log_(Log::LOG_INFO, + ("Downloading \"" + file_name.string() + "\" with size: ").c_str() + + size_string + "..."); + message_sent = true; + } + if(download_total != 0) + progress_callback((float)download_now / (float)download_total); + return true; + })); + if(response.status_code != 200) + { + if(sfs::exists(download_path / file_name)) + sfs::remove(download_path / file_name); + throw std::runtime_error("Download failed with response: \"" + response.status_line + + "\" (code " + std::to_string(response.status_code) + ")."); + } + fstream.close(); + return (download_path / file_name).string(); +} + +sfs::path ModdedApplication::iconPath() const +{ + return icon_path_; +} + +void ModdedApplication::setIconPath(const sfs::path& icon_path) +{ + icon_path_ = icon_path; + updateSettings(true); +} + +void ModdedApplication::updateSettings(bool write) +{ + json_settings_.clear(); + json_settings_["name"] = name_; + json_settings_["command"] = command_; + json_settings_["icon_path"] = icon_path_.string(); + for(int group = 0; group < groups_.size(); group++) + { + json_settings_["groups"][group]["active_member"] = active_group_members_[group]; + for(int i = 0; i < groups_[group].size(); i++) + { + json_settings_["groups"][group]["members"][i] = groups_[group][i]; + } + } + + for(int i = 0; i < profile_names_.size(); i++) + json_settings_["profiles"][i]["name"] = profile_names_[i]; + + for(int i = 0; i < app_versions_.size(); i++) + json_settings_["profiles"][i]["app_version"] = app_versions_[i]; + + for(int i = 0; i < installed_mods_.size(); i++) + { + json_settings_["installed_mods"][i]["id"] = installed_mods_[i].id; + json_settings_["installed_mods"][i]["name"] = installed_mods_[i].name; + json_settings_["installed_mods"][i]["version"] = installed_mods_[i].version; + json_settings_["installed_mods"][i]["installer"] = installer_map_[installed_mods_[i].id]; + json_settings_["installed_mods"][i]["install_time"] = installed_mods_[i].install_time; + json_settings_["installed_mods"][i]["local_source"] = installed_mods_[i].local_source.string(); + json_settings_["installed_mods"][i]["remote_source"] = installed_mods_[i].remote_source; + json_settings_["installed_mods"][i]["remote_update_time"] = + installed_mods_[i].remote_update_time; + json_settings_["installed_mods"][i]["size_on_disk"] = installed_mods_[i].size_on_disk; + json_settings_["installed_mods"][i]["suppress_update_time"] = + installed_mods_[i].suppress_update_time; + } + + for(int depl = 0; depl < deployers_.size(); depl++) + { + json_settings_["deployers"][depl]["dest_path"] = deployers_[depl]->getDestPath(); + json_settings_["deployers"][depl]["source_path"] = deployers_[depl]->sourcePath().string(); + json_settings_["deployers"][depl]["name"] = deployers_[depl]->getName(); + json_settings_["deployers"][depl]["type"] = deployers_[depl]->getType(); + json_settings_["deployers"][depl]["use_copy_deployment"] = + deployers_[depl]->usesCopyDeployment(); + + if(!deployers_[depl]->isAutonomous()) + { + for(int prof = 0; prof < profile_names_.size(); prof++) + { + deployers_[depl]->setProfile(prof); + json_settings_["deployers"][depl]["profiles"][prof]["name"] = profile_names_[prof]; + auto loadorder = deployers_[depl]->getLoadorder(); + for(int mod = 0; mod < loadorder.size(); mod++) + { + json_settings_["deployers"][depl]["profiles"][prof]["loadorder"][mod]["id"] = + std::get<0>(loadorder[mod]); + json_settings_["deployers"][depl]["profiles"][prof]["loadorder"][mod]["enabled"] = + std::get<1>(loadorder[mod]); + } + auto conflict_groups = deployers_[depl]->getConflictGroups(); + for(int group = 0; group < conflict_groups.size(); group++) + { + for(int i = 0; i < conflict_groups[group].size(); i++) + json_settings_["deployers"][depl]["profiles"][prof]["conflict_groups"][group][i] = + conflict_groups[group][i]; + } + } + } + deployers_[depl]->setProfile(current_profile_); + } + + for(int tool = 0; tool < tools_.size(); tool++) + { + json_settings_["tools"][tool]["name"] = std::get<0>(tools_[tool]); + json_settings_["tools"][tool]["command"] = std::get<1>(tools_[tool]); + } + + const auto targets = bak_man_.getTargets(); + for(int i = 0; i < targets.size(); i++) + json_settings_["backup_targets"][i]["path"] = targets[i].path.string(); + + for(int i = 0; i < manual_tags_.size(); i++) + json_settings_["manual_tags"][i] = manual_tags_[i].toJson(); + + for(int i = 0; i < auto_tags_.size(); i++) + { + if(!auto_tags_[i].getExpression().empty()) + json_settings_["auto_tags"][i] = auto_tags_[i].toJson(); + } + + if(write) + writeSettings(); +} + +void ModdedApplication::writeSettings() const +{ + sfs::path settings_file_path = staging_dir_ / (CONFIG_FILE_NAME + ".tmp"); + std::ofstream file(settings_file_path, std::fstream::binary); + if(!file.is_open()) + throw std::runtime_error("Error: Could not write to \"" + settings_file_path.string() + "\"."); + file << json_settings_; + file.close(); + sfs::rename(settings_file_path, staging_dir_ / CONFIG_FILE_NAME); +} + +void ModdedApplication::readSettings() +{ + json_settings_.clear(); + sfs::path settings_file_path = staging_dir_ / CONFIG_FILE_NAME; + std::ifstream file(settings_file_path, std::fstream::binary); + if(!file.is_open()) + throw std::runtime_error("Error: Could not read from \"" + settings_file_path.string() + "\"."); + file >> json_settings_; + file.close(); +} + +void ModdedApplication::updateState(bool read) +{ + installed_mods_.clear(); + deployers_.clear(); + groups_.clear(); + group_map_.clear(); + active_group_members_.clear(); + profile_names_.clear(); + bak_man_.reset(); + tools_.clear(); + profile_names_.clear(); + app_versions_.clear(); + manual_tags_.clear(); + manual_tag_map_.clear(); + auto_tags_.clear(); + auto_tag_map_.clear(); + installer_map_.clear(); + + if(read) + { + if(!sfs::exists(staging_dir_ / CONFIG_FILE_NAME)) + return; + readSettings(); + } + + if(!json_settings_.isMember("name")) + throw ParseError("Name is missing in \"" + (staging_dir_ / CONFIG_FILE_NAME).string() + "\""); + name_ = json_settings_["name"].asString(); + + if(!json_settings_.isMember("command")) + throw ParseError("Command is missing in \"" + (staging_dir_ / CONFIG_FILE_NAME).string() + + "\""); + command_ = json_settings_["command"].asString(); + + if(!json_settings_.isMember("icon_path")) + throw ParseError("Icon path is missing in \"" + (staging_dir_ / CONFIG_FILE_NAME).string() + + "\""); + icon_path_ = json_settings_["icon_path"].asString(); + + if(!json_settings_.isMember("profiles")) + throw ParseError("Profiles are missing in \"" + (staging_dir_ / CONFIG_FILE_NAME).string() + + "\""); + + Json::Value profiles = json_settings_["profiles"]; + for(int i = 0; i < profiles.size(); i++) + { + profile_names_.push_back(profiles[i]["name"].asString()); + app_versions_.push_back(profiles[i]["app_version"].asString()); + } + + Json::Value installed_mods = json_settings_["installed_mods"]; + for(int i = 0; i < installed_mods.size(); i++) + { + installed_mods_.emplace_back(installed_mods[i]["id"].asInt(), + installed_mods[i]["name"].asString(), + installed_mods[i]["version"].asString(), + installed_mods[i]["install_time"].asInt64(), + installed_mods[i]["local_source"].asString(), + installed_mods[i]["remote_source"].asString(), + installed_mods[i]["remote_update_time"].asInt64(), + installed_mods[i]["size_on_disk"].asInt64(), + installed_mods[i]["suppress_update_time"].asInt64()); + std::string installer = installed_mods[i]["installer"].asString(); + std::vector types = Installer::INSTALLER_TYPES; + if(std::find(types.begin(), types.end(), installer) == types.end()) + throw ParseError("Unknown installer type: " + installer + " in \"" + + (staging_dir_ / CONFIG_FILE_NAME).string() + "\""); + installer_map_[installed_mods[i]["id"].asInt()] = installer; + } + Json::Value groups = json_settings_["groups"]; + for(int group = 0; group < groups.size(); group++) + { + groups_.push_back(std::vector{}); + for(int i = 0; i < groups[group]["members"].size(); i++) + { + int mod_id = groups[group]["members"][i].asInt(); + if(std::find_if(installed_mods_.begin(), + installed_mods_.end(), + [mod_id](const Mod& m) { return m.id == mod_id; }) == installed_mods_.end()) + throw ParseError("Unknown mod id in group " + std::to_string(group) + ": " + + std::to_string(mod_id) + " in \"" + + (staging_dir_ / CONFIG_FILE_NAME).string() + "\""); + if(std::find(groups_[group].begin(), groups_[group].end(), mod_id) != groups_[group].end()) + throw ParseError("Duplicate mod id in group " + std::to_string(group) + ": " + + std::to_string(mod_id) + " in \"" + + (staging_dir_ / CONFIG_FILE_NAME).string() + "\""); + group_map_[mod_id] = group; + groups_[group].push_back(mod_id); + } + int active_member = groups[group]["active_member"].asInt(); + if(std::find(groups_[group].begin(), groups_[group].end(), active_member) == + groups_[group].end() || + !groups[group].isMember("active_member")) + throw ParseError("Invalid active group member: " + std::to_string(active_member) + " in \"" + + (staging_dir_ / CONFIG_FILE_NAME).string() + "\""); + active_group_members_.push_back(groups[group]["active_member"].asInt()); + } + Json::Value deployers = json_settings_["deployers"]; + for(int depl = 0; depl < deployers.size(); depl++) + { + std::vector types = DeployerFactory::DEPLOYER_TYPES; + std::string type = deployers[depl]["type"].asString(); + if(std::find(types.begin(), types.end(), type) == types.end()) + throw ParseError("Unknown deployer type: " + type + " in \"" + + (staging_dir_ / CONFIG_FILE_NAME).string() + "\""); + deployers_.push_back( + DeployerFactory::makeDeployer(type, + sfs::path(deployers[depl]["source_path"].asString()), + sfs::path(deployers[depl]["dest_path"].asString()), + deployers[depl]["name"].asString(), + deployers[depl]["use_copy_deployment"].asBool())); + if(!deployers_[depl]->isAutonomous()) + { + for(int prof = 0; prof < profile_names_.size(); prof++) + { + deployers_[depl]->addProfile(); + deployers_[depl]->setProfile(prof); + Json::Value loadorder = deployers[depl]["profiles"][prof]["loadorder"]; + for(int mod = 0; mod < loadorder.size(); mod++) + { + int mod_id = loadorder[mod]["id"].asInt(); + if(std::find_if(installed_mods_.begin(), + installed_mods_.end(), + [mod_id](const Mod& m) + { return m.id == mod_id; }) == installed_mods_.end()) + throw ParseError("Unknown mod id in deployers: " + std::to_string(mod_id) + " in \"" + + (staging_dir_ / CONFIG_FILE_NAME).string() + "\""); + if(!group_map_.contains(mod_id) || active_group_members_[group_map_[mod_id]] == mod_id && + !(deployers_[depl]->isAutonomous())) + deployers_[depl]->addMod(mod_id, loadorder[mod]["enabled"].asBool(), false); + } + Json::Value conflict_groups_json = deployers[depl]["profiles"][prof]["conflict_groups"]; + std::vector> conflict_groups; + for(int group = 0; group < conflict_groups_json.size(); group++) + { + std::vector new_group; + for(int mod = 0; mod < conflict_groups_json[group].size(); mod++) + new_group.push_back(conflict_groups_json[group][mod].asInt()); + conflict_groups.push_back(std::move(new_group)); + } + deployers_[depl]->setConflictGroups(conflict_groups); + } + } + deployers_[depl]->setProfile(current_profile_); + } + Json::Value tools = json_settings_["tools"]; + for(int tool = 0; tool < tools.size(); tool++) + tools_.emplace_back(tools[tool]["name"].asString(), tools[tool]["command"].asString()); + + for(int prof = 0; prof < profile_names_.size(); prof++) + bak_man_.addProfile(); + bak_man_.setProfile(current_profile_); + Json::Value backup_targets = json_settings_["backup_targets"]; + for(int target = 0; target < backup_targets.size(); target++) + bak_man_.addTarget(backup_targets[target]["path"].asString()); + bak_man_.setLog(log_); + + if(json_settings_.isMember("manual_tags")) + { + for(auto& tag_entry : json_settings_["manual_tags"]) + { + if(str::find_if(manual_tags_, + [name = tag_entry["name"].asString()](auto tag) + { return tag.getName() == name; }) != manual_tags_.end()) + throw ParseError( + std::format("Manual tag \"{}\" found more than once.", tag_entry["name"].asString())); + manual_tags_.emplace_back(tag_entry); + } + updateManualTagMap(); + } + + if(json_settings_.isMember("auto_tags")) + { + for(auto& tag_entry : json_settings_["auto_tags"]) + { + if(str::find_if(auto_tags_, + [name = tag_entry["name"].asString()](auto tag) + { return tag.getName() == name; }) != auto_tags_.end()) + throw ParseError( + std::format("Auto tag \"{}\" found more than once.", tag_entry["name"].asString())); + auto_tags_.emplace_back(tag_entry); + } + updateAutoTagMap(); + } +} + +std::string ModdedApplication::getModName(int mod_id) const +{ + auto iter = std::find_if( + installed_mods_.begin(), installed_mods_.end(), [mod_id](Mod m) { return m.id == mod_id; }); + if(iter == installed_mods_.end()) + return ""; + return iter->name; +} + +void ModdedApplication::updateDeployerGroups(std::optional progress_node) +{ + std::vector> update_targets; + for(int depl = 0; depl < deployers_.size(); depl++) + { + update_targets.push_back({}); + if(deployers_[depl]->isAutonomous()) + continue; + for(int profile = 0; profile < profile_names_.size(); profile++) + { + deployers_[depl]->setProfile(profile); + std::vector completed_groups(active_group_members_.size()); + std::fill(completed_groups.begin(), completed_groups.end(), false); + for(const auto [mod_id, _] : deployers_[depl]->getLoadorder()) + { + if(!group_map_.contains(mod_id)) + continue; + const int group = group_map_[mod_id]; + if(!completed_groups[group]) + { + completed_groups[group] = true; + if(deployers_[depl]->swapMod(mod_id, active_group_members_[group])) + update_targets[depl].push_back(profile); + } + else if(deployers_[depl]->removeMod(mod_id)) + update_targets[depl].push_back(profile); + } + } + deployers_[depl]->setProfile(current_profile_); + } + if(progress_node) + { + std::vector weights; + for(int depl = 0; depl < update_targets.size(); depl++) + { + for(int profile : update_targets[depl]) + { + deployers_[depl]->setProfile(profile); + weights.push_back(deployers_[depl]->getNumMods()); + } + deployers_[depl]->setProfile(current_profile_); + } + (*progress_node)->addChildren(weights); + } + int i = 0; + for(int depl = 0; depl < update_targets.size(); depl++) + { + for(int profile : update_targets[depl]) + { + deployers_[depl]->setProfile(profile); + deployers_[depl]->updateConflictGroups(progress_node ? &(*progress_node)->child(i) + : std::optional{}); + i++; + } + deployers_[depl]->setProfile(current_profile_); + } +} + +void ModdedApplication::splitMod(int mod_id, int deployer) +{ + if(deployers_[deployer]->isAutonomous()) + return; + + std::map managed_sub_dirs; + for(int i = 0; i < deployers_.size(); i++) + { + if(i == deployer || deployers_[i]->isAutonomous()) + continue; + auto cur_depl_path = deployers_[i]->getDestPath(); + if(!cur_depl_path.ends_with("/")) + cur_depl_path += "/"; + auto target_depl_path = deployers_[deployer]->getDestPath(); + if(!target_depl_path.ends_with("/")) + target_depl_path += "/"; + const auto pos = cur_depl_path.find(target_depl_path); + if(pos != std::string::npos) + { + std::string sub_dir = cur_depl_path.substr(pos + target_depl_path.size()); + if(sub_dir.starts_with("/")) + sub_dir = sub_dir.substr(1); + managed_sub_dirs[i] = sub_dir; + } + } + if(managed_sub_dirs.empty()) + return; + + for(const auto& [depl, dir] : managed_sub_dirs) + { + const auto mod_dir_optional = + pu::pathExists(dir, + staging_dir_ / std::to_string(mod_id), + deployers_[deployer]->getType() == DeployerFactory::CASEMATCHINGDEPLOYER); + if(!mod_dir_optional) + continue; + const auto mod_dir = staging_dir_ / std::to_string(mod_id) / mod_dir_optional->string(); + + AddModInfo info; + info.deployers = { depl }; + info.group = -1; + auto iter = + str::find_if(installed_mods_, [mod_id](const auto& mod) { return mod.id == mod_id; }); + if(iter == installed_mods_.end()) + throw std::runtime_error(std::format("Invalid mod id {}", mod_id)); + info.name = iter->name + " [" + deployers_[depl]->getName() + "]"; + info.version = iter->version; + info.installer = Installer::SIMPLEINSTALLER; + info.installer_flags = Installer::Flag::preserve_case | Installer::Flag::preserve_directories; + info.files = {}; + info.root_level = 0; + info.source_path = mod_dir; + log_(Log::LOG_WARNING, + std::format("Mod '{}' has been split because it contains" + " a sub-directory managed by deployer '{}'.", + iter->name, + deployers_[depl]->getName())); + installMod(info); + if(sfs::exists(mod_dir)) + sfs::remove_all(mod_dir); + } +} + +void ModdedApplication::replaceMod(const AddModInfo& info) +{ + if(!info.replace_mod || info.group == -1) + { + installMod(info); + return; + } + auto index = + str::find_if(installed_mods_, [group = info.group](const Mod& m) { return m.id == group; }); + if(index == installed_mods_.end()) + throw std::runtime_error(std::format("Invalid group '{}' for mod '{}'", info.group, info.name)); + + int mod_id = 0; + if(!installed_mods_.empty()) + mod_id = std::max_element(installed_mods_.begin(), installed_mods_.end())->id + 1; + while(sfs::exists(staging_dir_ / std::to_string(mod_id)) && + mod_id < std::numeric_limits().max()) + mod_id++; + if(mod_id == std::numeric_limits().max()) + throw std::runtime_error("Error: Could not generate new mod id."); + const sfs::path tmp_replace_dir = + staging_dir_ / (std::string("tmp_replace_") + std::to_string(mod_id)); + + const auto mod_size = Installer::install(info.source_path, + tmp_replace_dir, + info.installer_flags, + info.installer, + info.root_level, + info.files); + const sfs::path old_mod_path = staging_dir_ / std::to_string(info.group); + if(sfs::exists(old_mod_path)) + sfs::remove_all(old_mod_path); + sfs::rename(tmp_replace_dir, old_mod_path); + + index->name = info.name; + index->version = info.version; + index->remote_source = info.remote_source; + index->local_source = info.local_source; + index->install_time = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now()); + index->remote_update_time = index->install_time; + index->size_on_disk = mod_size; + + std::vector weights; + std::vector> update_targets; + for(int depl = 0; depl < deployers_.size(); depl++) + { + update_targets.push_back({}); + if(deployers_[depl]->isAutonomous()) + continue; + for(int prof = 0; prof < profile_names_.size(); prof++) + { + deployers_[depl]->setProfile(prof); + if(deployers_[depl]->hasMod(info.group)) + { + update_targets[depl].push_back(prof); + weights.push_back(deployers_[depl]->getNumMods()); + } + } + deployers_[depl]->setProfile(current_profile_); + } + ProgressNode node(progress_callback_, weights); + int i = 0; + for(int depl = 0; depl < update_targets.size(); depl++) + { + for(int prof : update_targets[depl]) + { + deployers_[depl]->setProfile(prof); + deployers_[depl]->updateConflictGroups(&node.child(i)); + i++; + } + deployers_[depl]->setProfile(current_profile_); + } + + for(auto& tag : auto_tags_) + tag.updateMods(staging_dir_, std::vector{ info.group }); + updateAutoTagMap(); + + updateSettings(true); +} + +void ModdedApplication::updateManualTagMap() +{ + manual_tag_map_.clear(); + for(const auto& mod : installed_mods_) + manual_tag_map_[mod.id] = {}; + for(const auto& tag : manual_tags_) + { + for(int mod_id : tag.getMods()) + manual_tag_map_[mod_id].push_back(tag.getName()); + } +} + +void ModdedApplication::updateAutoTagMap() +{ + auto_tag_map_.clear(); + for(const auto& mod : installed_mods_) + auto_tag_map_[mod.id] = {}; + for(const auto& tag : auto_tags_) + { + for(int mod_id : tag.getMods()) + auto_tag_map_[mod_id].push_back(tag.getName()); + } +} + +void ModdedApplication::performUpdateCheck(const std::vector& target_mod_indices) +{ + if(target_mod_indices.empty()) + { + log_(Log::LOG_INFO, "None of the selected mods has a valid remote source."); + return; + } + log_(Log::LOG_INFO, + std::format("Checking for updates for {} mod{}...", + target_mod_indices.size(), + target_mod_indices.size() > 1 ? "s" : "")); + ProgressNode node(progress_callback_); + node.setTotalSteps(target_mod_indices.size()); + int num_available_updates = 0; + for(int i : target_mod_indices) + { + installed_mods_[i].remote_update_time = + nexus::Api::getNexusPage(installed_mods_[i].remote_source).mod.updated_time; + if(installed_mods_[i].remote_update_time > installed_mods_[i].install_time) + num_available_updates++; + node.advance(); + } + if(num_available_updates > 0) + log_(Log::LOG_INFO, + std::format("Found updates for {} mod{}.", + num_available_updates, + num_available_updates == 1 ? "" : "s")); + else + log_(Log::LOG_INFO, "No mod updates found."); + updateSettings(true); +} diff --git a/src/core/moddedapplication.h b/src/core/moddedapplication.h new file mode 100644 index 0000000..fecf41c --- /dev/null +++ b/src/core/moddedapplication.h @@ -0,0 +1,729 @@ +/*! + * \file moddedapplication.h + * \brief Header for the ModdedApplication class. + */ + +#pragma once + +#include "addmodinfo.h" +#include "appinfo.h" +#include "autotag.h" +#include "backupmanager.h" +#include "deployer.h" +#include "deployerinfo.h" +#include "editautotagaction.h" +#include "editdeployerinfo.h" +#include "editmanualtagaction.h" +#include "editprofileinfo.h" +#include "log.h" +#include "manualtag.h" +#include "modinfo.h" +#include "nexus/api.h" +#include +#include +#include +#include + + +/*! + * \brief Contains all mods and Deployer objects used for one target application. + * Stores internal state in a JSON file. + */ +class ModdedApplication +{ +public: + /*! + * \brief If a JSON settings file already exists in app_mod_dir, it is + * used to construct this object. + * \param staging_dir Path to staging directory where all installed mods are stored. + * \param name Name of target application. + * \param command Command used to run target application. + * \param icon_path Path to an icon for this application. + * \throws Json::LogicError Indicates a logic error, e.g. trying to convert "123" to a bool, + * while parsing. + * \throws Json::RuntimeError Indicates a syntax error in the JSON file. + * \throws ParseError Indicates a semantic error while parsing the JSON file, e.g. + * the active member of a group is not part of that group. + */ + ModdedApplication(std::filesystem::path staging_dir, + std::string name = "", + std::string command = "", + std::filesystem::path icon_path = "", + std::string app_version = ""); + + /*! \brief Name of the file used to store this objects internal state. */ + inline static const std::string CONFIG_FILE_NAME = "lmm_mods.json"; + + /*! \brief Deploys mods using all Deployer objects of this application. */ + void deployMods(); + /*! + * \brief Deploys mods using Deployer objects with given ids. + * \param deployers The Deployer ids used for deployment. + */ + void deployModsFor(const std::vector& deployers); + /*! + * \brief Installs a new mod using the given Installer type. + * \param info Contains all data needed to install the mod. + */ + void installMod(const AddModInfo& info); + /*! + * \brief Uninstalls the given mods, this includes deleting all installed files. + * \param mod_id Ids of the mods to be uninstalled. + * \param installer_type The Installer type used. If an empty string is given, the Installer + * used during installation is used. + */ + void uninstallMods(const std::vector& mod_ids, const std::string& installer_type = ""); + /*! + * \brief Moves a mod from one position in the load order to another for given Deployer. + * \param deployer The target Deployer. + * \param from_index Index of mod to be moved. + * \param to_index Destination index. + */ + void changeLoadorder(int deployer, int from_index, int to_index); + /*! + * \brief Appends a new mod to the load order for given Deployer. + * \param deployer The target Deployer + * \param mod_id Id of the mod to be added. + * \param update_conflicts Updates the target deployers conflict groups only if this is true. + * \param progress_node Used to inform about the current progress. + */ + void addModToDeployer(int deployer, + int mod_id, + bool update_conflicts = true, + std::optional progress_node = {}); + /*! + * \brief Removes a mod from the load order for given Deployer. + * \param deployer The target Deployer + * \param mod_id Id of the mod to be removed. + * \param update_conflicts Updates the target deployers conflict groups only if this is true. + * \param progress_node Used to inform about the current progress. + */ + void removeModFromDeployer(int deployer, + int mod_id, + bool update_conflicts = true, + std::optional progress_node = {}); + /*! + * \brief Enables or disables the given mod in the load order for given Deployer. + * \param deployer The target Deployer + * \param mod_id Mod to be edited. + * \param status The new status. + */ + void setModStatus(int deployer, int mod_id, bool status); + /*! + * \brief Adds a new Deployer of given type. + * \param info Contains all data needed to create a deployer, e.g. its name. + */ + void addDeployer(const EditDeployerInfo& info); + /*! + * \brief Removes a Deployer. + * \param deployer The Deployer. + * \param cleanup If true: Remove all currently deployed files and restore backups. + */ + void removeDeployer(int deployer, bool cleanup); + /*! + * \brief Creates a vector containing the names of all Deployer objects. + * \return The vector. + */ + std::vector getDeployerNames() const; + /*! + * \brief Creates a vector containing information about all installed mods, stored in ModInfo + * objects. + * \return The vector. + */ + std::vector getModInfo() const; + /*! + * \brief Getter for the current mod load order of one Deployer. + * \param deployer The target Deployer. + * \return The load order. + */ + std::vector> getLoadorder(int deployer) const; + /*! + * \brief Getter for the path to the staging directory. This is where all installed + * mods are stored. + * \return The path. + */ + const std::filesystem::path& getStagingDir() const; + /*! + * \brief Setter for the path to the staging directory. This is where all installed + * mods are stored. + * \param staging_dir The new staging directory path. + * \param move_existing If true: Move all installed mods to the new directory. + * \throws Json::LogicError Indicates a logic error, e.g. trying to convert "123" to a bool, + * while parsing. + * \throws Json::RuntimeError Indicates a syntax error in the JSON file. + * \throws ParseError Indicates a semantic error while parsing the JSON file, e.g. + * the active member of a group is not part of that group. + */ + void setStagingDir(std::string staging_dir, bool move_existing); + /*! + * \brief Getter for the name of this application. + * \return The name. + */ + const std::string& name() const; + /*! + * \brief Setter for the name of this application. + * \param newName The new name. + */ + void setName(const std::string& newName); + /*! + * \brief Returns the number of Deployer objects for this application. + * \return The number of Deployers. + */ + int getNumDeployers() const; + /*! + * \brief Getter for the name of the file used to store this objects internal state. + * \return The name. + */ + const std::string& getConfigFileName() const; + /*! + * \brief Changes the name of an installed mod. + * \param mod_id Id of the target mod. + * \param new_name The new name. + */ + void changeModName(int mod_id, const std::string& new_name); + /*! + * \brief Checks for file conflicts of given mod with all other mods in the load order for + * one Deployer. + * \param deployer The target Deployer + * \param mod_id Mod to be checked. + * \param show_disabled If true: Also check for conflicts with disabled mods. + * \return A vector with information about conflicts with every other mod. + */ + std::vector getFileConflicts(int deployer, int mod_id, bool show_disabled) const; + /*! + * \brief Fills an AppInfo object with information about this object. + * \return The AppInfo object. + */ + AppInfo getAppInfo() const; + /*! + * \brief Adds a new tool to this application. + * \param name The tool's name. + * \param command The tool's command. + */ + void addTool(std::string name, std::string command); + /*! + * \brief Removes a tool. + * \param tool_id The tool's id. + */ + void removeTool(int tool_id); + /*! + * \brief Getter for the tools of this application. The tuples contain the name (index 0) + * and the command (index 1). + * \return The vector of tuples. + */ + const std::vector>& getTools() const; + /*! + * \brief Getter for the command used to run this application. + * \return The command. + */ + const std::string& command() const; + /*! + * \brief Setter for the command used to run this application. + * \param newCommand The new command. + */ + void setCommand(const std::string& newCommand); + /*! + * \brief Used to set type, name and target directory for one deployer. + * \param deployer Target Deployer. + * \param info Contains all data needed to edit a deployer, e.g. its new name. + */ + void editDeployer(int deployer, const EditDeployerInfo& info); + /*! + * \brief Checks for conflicts with other mods for one Deployer. + * Two mods are conflicting if they share at least one file. + * \param deployer Target Deployer. + * \param mod_id The mod to be checked. + * \return A set of mod ids which conflict with the given mod. + */ + std::unordered_set getModConflicts(int deployer, int mod_id); + /*! + * \brief Sets the currently active profile. + * \param profile The new profile. + */ + void setProfile(int profile); + /*! + * \brief Adds a new profile and optionally copies it's load order from an existing profile. + * \param info Contains the data for the new profile. + */ + void addProfile(const EditProfileInfo& info); + /*! + * \brief Removes a profile. + * \param profile The profile to be removed. + */ + void removeProfile(int profile); + /*! + * \brief Returns a vector containing the names of all profiles. + * \return The vector. + */ + std::vector getProfileNames() const; + /*! + * \brief Used to set the name of a profile. + * \param profile Target Profile + * \param info Contains the new profile data. + */ + void editProfile(int profile, const EditProfileInfo& info); + /*! + * \brief Used to set name and command for one tool. + * \param tool Target tool. + * \param name the new name. + * \param command The new command. + */ + void editTool(int tool, std::string name, std::string command); + /*! + * \brief Checks if writing to the deployment directory is possible for every Deployer. + * Creates a vector of tuples containing a code (index 0) indicating success(0), + * an IO error(1) or an error during link creation(2) and the deployers name (index 1). + * \return The vector. + */ + std::tuple verifyDeployerDirectories(); + /*! + * \brief Adds a mod to an existing group and makes the mod the active member of that group. + * \param mod_id The mod's id. + * \param group The target group. + * \param progress_node Used to inform about the current progress. + */ + void addModToGroup(int mod_id, int group, std::optional progress_node = {}); + /*! + * \brief Removes a mod from it's group. + * \param mod_id Target mod. + * \param update_conflicts If true: Update relevant conflict groups. + * \param progress_node Used to inform about the current progress. + */ + void removeModFromGroup(int mod_id, + bool update_conflicts = true, + std::optional progress_node = {}); + /*! + * \brief Creates a new group containing the two given mods. A group is a set of mods + * where only one member, the active member, will be deployed. + * \param first_mod_id First mod. This will be the active member of the new group. + * \param second_mod_id Second mod. + * \param progress_node Used to inform about the current progress. + */ + void createGroup(int first_mod_id, + int second_mod_id, + std::optional progress_node = {}); + /*! + * \brief Changes the active member of given group to given mod. + * \param group Target group. + * \param mod_id The new active member. + * \param progress_node Used to inform about the current progress. + */ + void changeActiveGroupMember(int group, + int mod_id, + std::optional progress_node = {}); + /*! + * \brief Sets the given mod's version to the given new version. + * \param mod_id Target mod. + * \param new_version The new version. + */ + void changeModVersion(int mod_id, const std::string& new_version); + /*! + * \brief Returns the number of groups. + * \return The number of groups. + */ + int getNumGroups(); + /*! + * \brief Checks if given mod belongs to any group. + * \param mod_id Target mod. + * \return True if mod belongs to a group, else: False. + */ + bool modHasGroup(int mod_id); + /*! + * \brief Returns the group to which the given mod belongs. + * \param mod_id Target mod. + * \return The group, or -1 if the mod has no group. + */ + int getModGroup(int mod_id); + /*! + * \brief Sorts the load order by grouping mods which contain conflicting files. + * \param deployer Deployer for which the currently active load order is to be sorted. + */ + void sortModsByConflicts(int deployer); + /*! + * \brief Returns the conflicts groups for the current profile of given deployer. + * \param deployer Target Deployer. + * \return The conflict info. + */ + std::vector> getConflictGroups(int deployer); + /*! + * \brief Updates which \ref Deployer "deployer" should manage given mods. + * \param mod_id Vector of mod ids to be added. + * \param deployers Bool for every deployer, indicating if the mods should be managed + * by that deployer. + */ + void updateModDeployers(const std::vector& mod_ids, const std::vector& deployers); + /*! \brief Getter for icon_path_. */ + std::filesystem::path iconPath() const; + /*! + * \brief Setter for icon_path_. + * \param icon_path The new icon path + */ + void setIconPath(const std::filesystem::path& icon_path); + + /*! + * \brief Verifies if reading/ writing to the staging directory is possible and if the + * JSON file containing information about installed mods can be parsed. + * \param staging_dir Path to the staging directory. + * \return A code indicating success(0), an IO error(1) or an error during JSON parsing(2). + */ + static int verifyStagingDir(std::filesystem::path staging_dir); + /*! + * \brief Extracts the given archive to the given location. + * \param source Source path. + * \param target Extraction target path. + */ + void extractArchive(const std::filesystem::path& source, const std::filesystem::path& target); + /*! + * \brief Creates DeployerInfo for one Deployer. + * \param deployer Target deployer. + */ + DeployerInfo getDeployerInfo(int deployer); + /*! \brief Setter for log callback. */ + void setLog(const std::function& newLog); + /*! + * \brief Adds a new target file or directory to be managed by the BackupManager. + * \param path Path to the target file or directory. + * \param name Display name for this target. + * \param backup_names Display names for initial backups. Must contain at least one. + */ + void addBackupTarget(const std::filesystem::path& path, + const std::string& name, + const std::vector& backup_names); + /*! + * \brief Removes the given backup target by deleting all backups, except for the active one, + * and all config files. + * \param target_id Target to remove. + */ + void removeBackupTarget(int target_id); + /*! + * \brief Removes all targets by deleting all backups, except for the active ones, + * and all config files. + */ + void removeAllBackupTargets(); + /*! + * \brief Adds a new backup for the given target by copying the currently active backup. + * \param target_id Target for which to create a new backup. + * \param name Display name for the new backup. + * \param source Backup from which to copy files to create the new backup. If -1: + * copy currently active backup. + */ + void addBackup(int target_id, const std::string& name, int source); + /*! + * \brief Deletes the given backup for given target. + * \param target_id Target from which to delete a backup. + * \param backup_id Backup to remove. + */ + void removeBackup(int target_id, int backup_id); + /*! + * \brief Changes the currently active backup for the given target. + * \param target_id Target for which to change the active backup. + * \param backup_id New active backup. + */ + void setActiveBackup(int target_id, int backup_id); + /*! + * \brief Returns a vector containing information about all managed backup targets. + * \return The vector. + */ + std::vector getBackupTargets() const; + /*! + * \brief Changes the name of the given backup for the given target + * \param target_id Backup target. + * \param backup_id Backup to be edited. + * \param name The new name. + */ + void setBackupName(int target_id, int backup_id, const std::string& name); + /*! + * \brief Changes the name of the given backup target + * \param target_id Backup target. + * \param name The new name. + */ + void setBackupTargetName(int target_id, const std::string& name); + /*! + * \brief Deletes all files in the dest backup and replaces them with the files + * from the source backup. + * \param target_id Backup target. + * \param source_backup Backup from which to copy files. + * \param dest_backup Target for data deletion. + */ + void overwriteBackup(int target_id, int source_backup, int dest_backup); + /*! \brief Performs a cleanup for the previous installation. */ + void cleanupFailedInstallation(); + /*! + * \brief Sets the callback function used to inform about the current task's progress. + * \param progress_callback The function. + */ + void setProgressCallback(const std::function& progress_callback); + /*! + * \brief Uninstalls all mods which are inactive group members of any group which contains + * any of the given mods. + * \param mod_ids Ids of the mods for which to uninstall group members. + */ + void uninstallGroupMembers(const std::vector& mod_ids); + /*! + * \brief Adds a new tag with the given name. Fails if a tag by that name already exists. + * \param tag_name Name for the new tag. + * \throw std::runtime_error If a tag by that name exists. + */ + void addManualTag(const std::string& tag_name); + /*! + * \brief Removes the tag with the given name, if it exists. + * \param tag_name Tag to be removed. + * \param update_map If true: Update the manual tag map. + */ + void removeManualTag(const std::string& tag_name, bool update_map = true); + /*! + * \brief Changes the name of the given tag to the given new name. + * Fails if a tag by the given name exists. + * \param old_name Name of the target tag. + * \param new_name Target tags new name. + * \param update_map If true: Update the manual tag map. + * \throw std::runtime_error If a tag with the given new_name exists. + */ + void changeManualTagName(const std::string& old_name, + const std::string& new_name, + bool update_map = true); + /*! + * \brief Adds the given tags to all given mods. + * \param tag_name Target tags name. + * \param mod_ids Target mod ids. + */ + void addTagsToMods(const std::vector& tag_names, const std::vector& mod_ids); + /*! + * \brief Removes the given tags from the given mods. + * \param tag_name Target tags name. + * \param mod_ids Target mod ids. + */ + void removeTagsFromMods(const std::vector& tag_names, + const std::vector& mod_ids); + /*! + * \brief Sets the tags for all given mods to the given tags. + * \param tag_names Names of the new tags. + * \param mod_ids Target mod ids. + */ + void setTagsForMods(const std::vector& tag_names, const std::vector mod_ids); + /*! + * \brief Performs the given editing actions on the manual tags. + * \param actions Editing actions. + */ + void editManualTags(const std::vector& actions); + /*! + * \brief Adds a new auto tag. + * \param name The new tags name. + * \param expression Expression used for the new tags evaluator. + * \param conditions Conditions used for the new tags evaluator. + * \param update If true: Update the auto tag map and the settings. + * \throw std::runtime_error If a tag by that name exists. + */ + void addAutoTag(const std::string& tag_name, + const std::string& expression, + const std::vector& conditions, + bool update); + /*! + * \brief Removes the given auto tag. + * \param name Tag to be removed. + * \param update If true: Update the auto tag map and the settings. + */ + void removeAutoTag(const std::string& tag_name, bool update); + /*! + * \brief Changes the name of the given auto tag to the given new name. + * Fails if a tag by the given name exists. + * \param old_name Name of the target tag. + * \param new_name Target tags new name. + * \param update If true: Update the auto tag map. + * \throw std::runtime_error If a tag with the given new_name exists. + */ + void renameAutoTag(const std::string& old_name, const std::string& new_name, bool update); + /*! + * \brief Changes the given tags evaluator according to the given expression and conditions. + * \param tag_name Target auto tag. + * \param expression New expression to be used. + * \param conditions Conditions for the new expression. + * \param update If true: Update the auto tag map. + */ + void changeAutoTagEvaluator(const std::string& tag_name, + const std::string& expression, + const std::vector& conditions, + bool update); + /*! + * \brief Performs the given editing actions on the auto tags. + * \param actions Editing actions. + */ + void editAutoTags(const std::vector& actions); + /*! \brief Reapply all auto tags to all mods. */ + void reapplyAutoTags(); + /*! + * \brief Reapplies auto tags to the specified mods. + * \param mod_ids Mods to which auto tags are to be reapplied. + */ + void updateAutoTags(const std::vector mod_ids); + /*! \brief Deletes all data for this app. */ + void deleteAllData(); + /*! + * \brief Sets the app version of the currently active profile to the given version. + * \param app_version The new app version. + */ + void setAppVersion(const std::string& app_version); + /*! + * \brief Sets the given mods local and remote sources to the given paths. + * \param mod_id Target mod id. + * \param local_source Path to a local archive or directory used for mod installation. + * \param remote_source Remote URL from which the mod was downloaded. + */ + void setModSources(int mod_id, const std::string& local_source, const std::string& remote_source); + /*! + * \brief Fetches data from NexusMods for the given mod. + * \param mod_id Target mod id. + * \return A Mod object containing all data from NexusMods regarding that mod. + */ + nexus::Page getNexusPage(int mod_id); + /*! \brief Checks for updates for all mods. */ + void checkForModUpdates(); + /*! + * \brief Checks for updates for mods with the given ids. + * \param mod_ids Ids of the mods for which to check for updates. + */ + void checkModsForUpdates(const std::vector& mod_ids); + /*! + * \brief Temporarily disables update notifications for the given mods. This is done + * by setting the mods remote_update_time to the installation_time. + * \param mod_ids Ids of the mods for which update notifications are to be disabled. + */ + void suppressUpdateNotification(const std::vector& mod_ids); + /*! + * \brief Generates a download URL from the given NexusMods nxm Url. + * \param nxm_url The nxm URL used. + * \return The download URL. + */ + std::string getDownloadUrl(const std::string& nxm_url); + /*! + * \brief Generates a download URL from the given NexusMods mod id and file id. + * \param nexus_file_id File id of the mod. + * \param mod_url Url to the mod page on NexusMods. + * \return The download URL. + */ + std::string getDownloadUrlForFile(int nexus_file_id, const std::string& mod_url); + /*! + * \brief Generates a NexusMods mod page URL from the given nxm URL. + * \param nxm_url The nxm Url used. This is usually generated through the NexusMods website. + * \return The NexusMods mod page URL. + */ + std::string getNexusPageUrl(const std::string& nxm_url); + /*! + * \brief Downloads the file from the given url to staging_dir_ / _download. + * \param url Url from which to download the file. + * \return The path to the downloaded file. + */ + std::string downloadMod(const std::string& url, std::function progress_callback); + +private: + /*! \brief The name of this application. */ + std::string name_; + /*! \brief Contains the internal state of this object. */ + Json::Value json_settings_; + /*! \brief The path to the staging directory containing all installed mods. */ + std::filesystem::path staging_dir_; + /*! \brief Contains all currently installed mods. */ + std::vector installed_mods_; + /*! \brief Contains every Deployer used by this application. */ + std::vector> deployers_; + /*! \brief Contains names and commands for every tool. */ + std::vector> tools_; + /*! \brief The command used to run this application. */ + std::string command_ = ""; + /*! \brief The currently active profile id. */ + int current_profile_ = 0; + /*! \brief Contains names of all profiles. */ + std::vector profile_names_; + /*! \brief For every group: A vector containing every mod in that group. */ + std::vector> groups_; + /*! \brief Maps mods to their groups. */ + std::map group_map_; + /*! \brief Contains the active member of every group. */ + std::vector active_group_members_; + /*! \brief Maps mods to the installer used during their installation. */ + std::map installer_map_; + /*! \brief Path to this applications icon. */ + std::filesystem::path icon_path_; + /*! \brief Callback for logging. */ + std::function log_ = [](Log::LogLevel a, + const std::string& b) {}; + /*! \brief Manages all backups for this application. */ + BackupManager bak_man_; + /*! \brief Id of the most recently installed mod. */ + int last_mod_id_ = -1; + /*! \brief Contains all known manually managed tags. */ + std::vector manual_tags_; + /*! \brief Maps mod ids to a vector of manual tags associated with that mod. */ + std::map> manual_tag_map_; + /*! \brief Contains all known auto tags. */ + std::vector auto_tags_; + /*! \brief Maps mod ids to a vector of auto tags associated with that mod. */ + std::map> auto_tag_map_; + /*! + * \brief For every profile: The version of the app managed by that profile. + * + * This does not refer to a ModdedApplication object but rather the actually + * modded application. + */ + std::vector app_versions_; + /*! \brief Callback used to inform about the current task's progress. */ + std::function progress_callback_ = [](float f) {}; + /*! \brief The subdirectory used to store downloads. */ + std::string download_dir_ = "_download"; + + /*! + * \brief Updates json_settings_ with the current state of this object. + * \param write If true: write json_settings_ to a file after updating. + */ + void updateSettings(bool write = false); + /*! + * \brief Writes json_settings_ to a file at app_mod_dir_/CONFIG_FILE_NAME. + */ + void writeSettings() const; + /*! + * \brief Reads json_settings_ from a file at app_mod_dir_/CONFIG_FILE_NAME. + */ + void readSettings(); + /*! + * \brief Updates the internal state of this object to the state stored in json_settings_. + * \param read If true: Read json_settings_ from a file before updating. + */ + void updateState(bool read = false); + /*! + * \brief Returns the name of a mod. + * \param mod_id The mod. + * \return The name. + * \throws Json::LogicError Indicates a logic error, e.g. trying to convert "123" to a bool, + * while parsing. + * \throws Json::RuntimeError Indicates a syntax error in the JSON file. + * \throws ParseError Indicates a semantic error while parsing the JSON file, e.g. + * the active member of a group is not part of that group. + */ + std::string getModName(int mod_id) const; + /*! + * \brief Updates the load order for every Deployer to reflect the current mod groups. + * \param progress_node Used to inform about the current progress. + */ + void updateDeployerGroups(std::optional progress_node = {}); + /*! + * \brief If given mod contains a sub-directory managed by a deployer that is not the given + * deployer, creates a new mod which contains that sub-directory. + * \param mod_id Mod to check. + * \param deployer Deployer which currently manages the given mod. + */ + void splitMod(int mod_id, int deployer); + /*! + * \brief Replaces an existing mod with the mod specified by the given argument. + * \param info Contains all data needed to install the mod. + */ + void replaceMod(const AddModInfo& info); + /*! \brief Updates manual_tag_map_ with the information contained in manual_tags_. */ + void updateManualTagMap(); + /*! \brief Updates auto_tag_map_ with the information contained in auto_tags_. */ + void updateAutoTagMap(); + /*! + * \brief Checks for available updates for mods with the given index in installed_mods_. + * \param target_mod_indices Target mod indices. + */ + void performUpdateCheck(const std::vector& target_mod_indices); +}; diff --git a/src/core/modinfo.h b/src/core/modinfo.h new file mode 100644 index 0000000..07e4f1d --- /dev/null +++ b/src/core/modinfo.h @@ -0,0 +1,84 @@ +/*! + * \file modinfo.h + * \brief Contains the ModInfo struct. + */ + +#pragma once + +#include "mod.h" +#include +#include + + +/*! + * \brief Stores information about a mod as well as the group and + * \ref Deployer "deployers" it belongs to. + */ +struct ModInfo +{ + /*! \brief Contains information about the mod itself. */ + Mod mod; + /*! \brief Names of all \ref Deployer "deployers" the mod belongs to. */ + std::vector deployers; + /*! \brief Ids of all \ref Deployer "deployers" the mod belongs to. */ + std::vector deployer_ids; + /*! \brief The mods activation status for every \ref Deployer "deployer" it belongs to. */ + std::vector deployer_statuses; + /*! \brief Group this mod belongs to. If == -1: Mod belongs to no group. */ + int group = -1; + /*! \brief If true: Mod is the active member of its group. */ + bool is_active_group_member = false; + /*! \brief Contains the names of all manual tags added to this mod. */ + std::vector manual_tags; + /*! \brief Contains the names of all auto tags added to this mod. */ + std::vector auto_tags; + + /*! + * \brief Constructor. Simply initializes members. + * \param id The mod's id. + * \param name The mod's name. + * \param version The mod's version. + * \param install_time Timestamp indicating when the mod was installed. + * \param local_source Source archive for the mod. + * \param remote_source URL from where the mod was downloaded. + * \param remote_update_time Timestamp for when the mod was updated at the remote source. + * \param size Total size of the installed mod on disk. + * \param suppress_time Timestamp for when the user requested to suppress current update + * notifications. + * \param deployer_names Names of all \ref Deployer "deployers" the mod belongs to. + * \param deployer_ids Ids of all \ref Deployer "deployers" the mod belongs to. + * \param statuses The mods activation status for every \ref Deployer "deployer" it belongs to. + * \param group Group this mod belongs to. If == -1: Mod belongs to no group. + * \param is_active_member If true: Mod is the active member of it's group. + * \param man_tags The names of all manual tags for this mod. + */ + ModInfo(int id, + const std::string& name, + const std::string& version, + const std::time_t& install_time, + const std::filesystem::path& local_source, + const std::string& remote_source, + const std::time_t& remote_update_time, + unsigned long size, + const std::time_t& suppress_time, + const std::vector& deployer_names, + const std::vector& deployer_ids, + const std::vector& statuses, + int group, + bool is_active_member, + const std::vector& man_tags, + const std::vector& au_tags) : + mod(id, + name, + version, + install_time, + local_source, + remote_source, + remote_update_time, + size, + suppress_time), + deployers(std::move(deployer_names)), deployer_ids(std::move(deployer_ids)), + deployer_statuses(statuses), group(group), is_active_group_member(is_active_member), + manual_tags(man_tags), auto_tags(au_tags) + {} +}; diff --git a/src/core/nexus/api.cpp b/src/core/nexus/api.cpp new file mode 100644 index 0000000..d93d191 --- /dev/null +++ b/src/core/nexus/api.cpp @@ -0,0 +1,334 @@ +#include "api.h" +#include "../parseerror.h" +#include +#include +#include + +using namespace nexus; +namespace str = std::ranges; + + +void Api::setApiKey(const std::string& api_key) +{ + api_key_ = api_key; +} + +bool Api::isInitialized() +{ + return !api_key_.empty(); +} + +Mod Api::getMod(const std::string& mod_url) +{ + auto domain_and_mod = extractDomainAndModId(mod_url); + if(!domain_and_mod) + throw std::runtime_error(std::format("Could not parse mod URL: \"{}\".", mod_url)); + return getMod(domain_and_mod->first, domain_and_mod->second); +} + +Mod Api::getMod(const std::string& domain_name, long mod_id) +{ + cpr::Response response = + cpr::Get(cpr::Url(std::format( + "https://api.nexusmods.com/v1/games/{}/mods/{}.json", domain_name, mod_id)), + cpr::Header{ { "apikey", api_key_ } }); + if(response.status_code != 200) + throw std::runtime_error( + std::format("Failed to get data for mod with id {} from NexusMods. Response code was {}", + mod_id, + response.status_code)); + return { response.text }; +} + +void Api::trackMod(const std::string& mod_url) +{ + auto domain_and_mod = extractDomainAndModId(mod_url); + if(!domain_and_mod) + throw std::runtime_error(std::format("Could not parse mod URL: \"{}\".", mod_url)); + const cpr::Response response = + cpr::Post(cpr::Url("https://api.nexusmods.com/v1/user/tracked_mods.json"), + cpr::Header{ { "apikey", api_key_ } }, + cpr::Parameters{ { "domain_name", domain_and_mod->first }, + { "mod_id", std::to_string(domain_and_mod->second) } }); +} + +void Api::untrackMod(const std::string& mod_url) +{ + auto domain_and_mod = extractDomainAndModId(mod_url); + if(!domain_and_mod) + throw std::runtime_error(std::format("Could not parse mod URL: \"{}\".", mod_url)); + const cpr::Response response = + cpr::Delete(cpr::Url("https://api.nexusmods.com/v1/user/tracked_mods.json"), + cpr::Header{ { "apikey", api_key_ } }, + cpr::Parameters{ { "domain_name", domain_and_mod->first }, + { "mod_id", std::to_string(domain_and_mod->second) } }); +} + +std::vector Api::getTrackedMods() +{ + cpr::Response response = cpr::Get(cpr::Url("https://api.nexusmods.com/v1/user/tracked_mods.json"), + cpr::Header{ { "apikey", api_key_ } }); + if(response.status_code != 200) + throw std::runtime_error(std::format( + "Failed to get tracked mods from NexusMods. Response code was: {}", response.status_code)); + + Json::Value json_body; + Json::Reader reader; + bool success = reader.parse(response.text.c_str(), json_body); + if(!success) + throw ParseError("Failed to parse response from NexusMods."); + + std::vector mods; + for(int i = 0; i < json_body.size(); i++) + mods.push_back( + getMod(json_body[i]["domain_name"].asString(), json_body[i]["mod_id"].asInt64())); + return mods; +} + +std::vector Api::getModFiles(const std::string& mod_url) +{ + auto domain_and_mod = extractDomainAndModId(mod_url); + if(!domain_and_mod) + throw std::runtime_error(std::format("Could not parse mod URL: \"{}\".", mod_url)); + + const auto [domain_name, mod_id] = *domain_and_mod; + cpr::Response response = + cpr::Get(cpr::Url(std::format( + "https://api.nexusmods.com/v1/games/{}/mods/{}/files.json", domain_name, mod_id)), + cpr::Header{ { "apikey", api_key_ } }); + if(response.status_code != 200) + throw std::runtime_error( + std::format("Failed to get mod files for mod with id {} from NexusMods. Response code was {}", + mod_id, + response.status_code)); + + Json::Value json_body; + Json::Reader reader; + bool success = reader.parse(response.text.c_str(), json_body); + if(!success) + throw ParseError("Failed to parse response from NexusMods."); + + std::vector files; + for(int i = 0; i < json_body["files"].size(); i++) + files.emplace_back(json_body["files"][i]); + return files; +} + +std::string Api::getDownloadUrl(const std::string& mod_url, long file_id) +{ + auto domain_and_mod = extractDomainAndModId(mod_url); + if(!domain_and_mod) + throw std::runtime_error(std::format("Could not parse mod URL: \"{}\".", mod_url)); + + const auto [domain_name, mod_id] = *domain_and_mod; + cpr::Response response = + cpr::Get(cpr::Url(std::format( + "https://api.nexusmods.com/v1/games/{}/mods/{}/files/{}/download_link.json", + domain_name, + mod_id, + file_id)), + cpr::Header{ { "apikey", api_key_ } }); + if(response.status_code == 403) + throw std::runtime_error( + "Generation of download links for NexusMods is restricted to premium accounts." + "You can download the mod on the website here:\n" + + std::format( + "https://www.nexusmods.com/{}/mods/{}?tab=files&file_id={}", domain_name, mod_id, file_id)); + else if(response.status_code == 404) + throw std::runtime_error("The requested file does not exist in NexusMods."); + else if(response.status_code != 200) + throw std::runtime_error(std::format("Failed to generate a download link for \"{}\"", mod_url)); + + Json::Value json_body; + Json::Reader reader; + bool success = reader.parse(response.text.c_str(), json_body); + if(!success) + throw ParseError("Failed to parse response from NexusMods."); + + return json_body[0]["URI"].asString(); +} + +std::string Api::getDownloadUrl(const std::string& nxm_url) +{ + const std::regex regex( + R"(nxm:\/\/(.+)\/mods\/(\d+)\/files\/(\d+)\?key=(.+)&expires=(\d+)&user_id=(\d+))"); + std::smatch match; + if(!std::regex_match(nxm_url, match, regex)) + throw std::runtime_error(std::format("Invalid NXM URL: \"{}\"", nxm_url)); + const std::string domain_name = match[1]; + const std::string mod_id = match[2]; + const std::string file_id = match[3]; + const std::string key = match[4]; + const std::string expires = match[5]; + + cpr::Response response = + cpr::Get(cpr::Url(std::format( + "https://api.nexusmods.com/v1/games/{}/mods/{}/files/{}/download_link.json", + domain_name, + mod_id, + file_id)), + cpr::Header{ { "apikey", api_key_ } }, + cpr::Parameters{ { "game_domain_name", domain_name }, + { "id", file_id }, + { "mod_id", mod_id }, + { "key", key }, + { "expires", expires } }); + if(response.status_code == 400) + throw std::runtime_error("Failed to generate download link. Check if the account used on " + "NexusMods matches the one for the API key in Limo."); + else if(response.status_code == 404) + throw std::runtime_error(std::format("File with id {} for mod with id {} for application" + "\"{}\" not found on NexusMods.", + file_id, + mod_id, + domain_name)); + else if(response.status_code == 410) + throw std::runtime_error("The NexusMods download link has expired."); + else if(response.status_code != 200) + throw std::runtime_error(std::format("Failed to generate download link for file with id {} " + "for mod with id {} for application {}.", + file_id, + mod_id, + domain_name)); + + Json::Value json_body; + Json::Reader reader; + bool success = reader.parse(response.text.c_str(), json_body); + if(!success) + throw ParseError("Failed to parse response from NexusMods."); + + return json_body[0]["URI"].asString(); +} + +std::vector>> Api::getChangelogs( + const std::string& mod_url) +{ + std::vector>> changelogs; + auto domain_and_mod = extractDomainAndModId(mod_url); + if(!domain_and_mod) + throw std::runtime_error(std::format("Could not parse mod URL: \"{}\".", mod_url)); + + const auto [domain_name, mod_id] = *domain_and_mod; + cpr::Response response = cpr::Get( + cpr::Url(std::format( + "https://api.nexusmods.com/v1/games/{}/mods/{}/changelogs.json", domain_name, mod_id)), + cpr::Header{ { "apikey", api_key_ } }); + if(response.status_code != 200) + throw std::runtime_error(std::format( + "Failed to get changelogs for mod with id {} from NexusMods. Response code was {}", + mod_id, + response.status_code)); + + Json::Value json_body; + Json::Reader reader; + bool success = reader.parse(response.text.c_str(), json_body); + if(!success) + throw ParseError("Failed to parse response from NexusMods."); + + std::string text = response.text; + if(text.starts_with('\"')) + text.erase(0, 1); + if(text.ends_with('\"')) + text.erase(text.size() - 1, 1); + + for(const auto& key : json_body.getMemberNames()) + { + std::vector changes; + auto log = json_body[key]; + for(int i = 0; i < log.size(); i++) + changes.push_back(log[i].asString()); + changelogs.emplace_back(key, changes); + } + // Jsoncpp uses a std::map to store key, value pairs. This messes up the order of the keys, so + // they have be re-sorted by version number + std::sort(changelogs.begin(), + changelogs.end(), + [](auto a, auto b) + { + std::regex regex(R"(.*?(\d+)\.?(.*))"); + std::smatch match; + std::vector a_parts; + std::vector b_parts; + std::string target = a.first; + bool found = false; + while(std::regex_search(target, match, regex)) + { + found = true; + a_parts.push_back(std::stoi(match[1])); + target = match[2]; + } + if(!found) + return a > b; + + found = false; + target = b.first; + while(std::regex_search(target, match, regex)) + { + found = true; + b_parts.push_back(std::stoi(match[1])); + target = match[2]; + } + if(!found) + return a > b; + + for(auto [a_num, b_num] : str::zip_view(a_parts, b_parts)) + { + if(a_num != b_num) + return a_num > b_num; + } + return a > b; + }); + return changelogs; +} + +bool Api::modUrlIsValid(const std::string& url) +{ + if(url.empty()) + return false; + const std::regex regex(R"((?:https:\/\/)?www\.nexusmods\.com\/(.+)\/mods\/(\d+).*)"); + return std::regex_match(url, regex); +} + +Page Api::getNexusPage(const std::string& mod_url) +{ + return { mod_url, getMod(mod_url), getChangelogs(mod_url), getModFiles(mod_url) }; +} + +std::optional> Api::validateKey(const std::string& api_key) +{ + cpr::Response response = cpr::Get(cpr::Url("https://api.nexusmods.com/v1/users/validate.json"), + cpr::Header{ { "apikey", api_key } }); + if(response.status_code != 200) + return {}; + + Json::Value json_body; + Json::Reader reader; + bool success = reader.parse(response.text.c_str(), json_body); + if(!success) + throw ParseError("Failed to parse response from NexusMods."); + + return { { json_body["name"].asString(), json_body["is_premium"].asBool() } }; +} + +std::string Api::getNexusPageUrl(const std::string& nxm_url) +{ + std::regex nxm_regex(R"(nxm:\/\/(.*)\/mods\/(\d+)\/files\/\d+\?.*)"); + std::smatch match; + if(!std::regex_match(nxm_url, match, nxm_regex)) + throw std::runtime_error("Invalid nxm url: \"" + nxm_url + "\"."); + return std::format("https://www.nexusmods.com/{}/mods/{}", match[1].str(), match[2].str()); +} + +std::string Api::getApiKey() +{ + return api_key_; +} + +std::optional> Api::extractDomainAndModId(const std::string& mod_url) +{ + const std::regex regex(R"((?:https:\/\/)?www\.nexusmods\.com\/(.+)\/mods\/(\d+).*)"); + std::smatch match; + if(std::regex_match(mod_url, match, regex)) + return { { match[1], std::stoi(match[2]) } }; + return {}; +} diff --git a/src/core/nexus/api.h b/src/core/nexus/api.h new file mode 100644 index 0000000..a01a887 --- /dev/null +++ b/src/core/nexus/api.h @@ -0,0 +1,152 @@ +/*! + * \file Api.h + * \brief Header for the nexus::Api class. + */ + +#pragma once + +#include "file.h" +#include "mod.h" +#include +#include + + +/*! + * \brief The nexus namespace contains structs and functions needed for accessing the NexusMods API. + */ +namespace nexus +{ +/*! + * \brief Contains all data for a mod available through the NexusMods api. + */ +struct Page +{ + /*! \brief URL of the mod page on NexusMods. */ + std::string url; + /*! \brief Contains an overview of of the mod page, like a description and summary. */ + Mod mod; + /*! \brief For every Version of the mod: A vector of changes in that version. */ + std::vector>> changelog; + /*! \brief Contains data on all available files for the mod. */ + std::vector files; +}; + +/*! + * \brief Provides functions for accessing the NexusMods API. + */ +class Api +{ +public: + /*! \brief This is an abstract class, so the constructor is deleted. */ + Api() = delete; + + /*! + * \brief Sets the API key to use for all operations. + * \param api_key The new API key. + */ + static void setApiKey(const std::string& api_key); + /*! + * \brief Checks if this class has been initialized with an API key. + * Does NOT check if the key works. + * \return True if an API key exists. + */ + static bool isInitialized(); + /*! + * \brief Fetches data for the mod accessible by the given NexusMods URL. + * \param mod_url URL to the mod on NexusMods. + * \return A Mod object containing all received data. + */ + static Mod getMod(const std::string& mod_url); + /*! + * \brief Fetches data for the mod specified by the NexusMods domain and mod id. + * \param domain_name The NexusMods domain containing the mod. + * \param mod_id Target mod id. + * \return A Mod object containing all received data. + */ + static Mod getMod(const std::string& domain_name, long mod_id); + /*! + * \brief Tracks the mod for the NexusMods account belonging to the API key. + * \param mod_url URL to the mod on NexusMods. + */ + static void trackMod(const std::string& mod_url); + /*! + * \brief Tracks the mod for the NexusMods account belonging to the API key. + * \param mod_url URL to the mod on NexusMods. + */ + static void untrackMod(const std::string& mod_url); + /*! + * \brief Fetches data for all mods tracked by the account belonging to the API key. + * \return A vector of Mod objects with the received data. + */ + static std::vector getTrackedMods(); + /*! + * \brief Fetches data for all available files for the given mod. + * \param mod_url URL to the mod on NexusMods. + * \return A vector of File objects containing the received data. + */ + static std::vector getModFiles(const std::string& mod_url); + /*! + * \brief Generates a download URL for the given mod file. This only works for premium accounts. + * \param mod_url URL to the mod on NexusMods. + * \param file_id Id of the file for which a link is to be generated. + * \return The download URL. + */ + static std::string getDownloadUrl(const std::string& mod_url, long file_id); + /*! + * \brief Generates a download URL from the given nxm Url. + * \param nxm_url The nxm Url used. This is usually generated through the NexusMods website. + * \return The download URL. + */ + static std::string getDownloadUrl(const std::string& nxm_url); + /*! + * \brief Fetches changelogs for the given mod. + * \param mod_url URL to the mod on NexusMods. + * \return For every Version of the mod: A vector of changes in that version. + */ + static std::vector>> getChangelogs( + const std::string& mod_url); + /*! + * \brief Checks if the given URL is a valid NexusMods mod page URL. + * Only verifies if the URL is semantically correct, not if the target exists. + * \param url URL to check. + * \return True if the URL points to a NexusMods page. + */ + static bool modUrlIsValid(const std::string& url); + /*! + * \brief Fetches data to fill a Page object for the given mod. + * \param mod_url URL to the mod on NexusMods. + * \return The generated Page object. + */ + static Page getNexusPage(const std::string& mod_url); + /*! + * \brief Checks if the NexusMods API can be accessed with the given API key. + * \param api_key API key to validate. + * \return If the key works: The account name and a bool indicating if the account is premium. + * Else: An empty std::optional. + */ + static std::optional> validateKey(const std::string& api_key); + /*! + * \brief Generates a NexusMods mod page URL from the given nxm URL. + * \param nxm_url The nxm Url used. This is usually generated through the NexusMods website. + * \return The NexusMods mod page URL. + */ + static std::string getNexusPageUrl(const std::string& nxm_url); + /*! + * \brief Getter for the API key. + * \return The API key. + */ + static std::string getApiKey(); + +private: + /*! \brief The API key used for all operations. */ + inline static std::string api_key_ = ""; + + /*! + * \brief Extracts the NexusMods domain and mod id from the given mod page URL. + * \param url URL to the mod on NexusMods. + * \return If the given URL is valid: The domain and mod id. Else an empty std::optional. + */ + static std::optional> extractDomainAndModId( + const std::string& mod_url); +}; +} diff --git a/src/core/nexus/file.cpp b/src/core/nexus/file.cpp new file mode 100644 index 0000000..1203a81 --- /dev/null +++ b/src/core/nexus/file.cpp @@ -0,0 +1,44 @@ +#include "file.h" +#include "../parseerror.h" + +using namespace nexus; + + +File::File(const std::string& http_body) +{ + Json::Value json_body; + Json::Reader reader; + bool success = reader.parse(http_body.c_str(), json_body); + if(!success) + throw ParseError("Failed to parse response from NexusMods."); + + init(json_body); +} + +File::File(const Json::Value& json_body) +{ + init(json_body); +} + +void File::init(const Json::Value& json_body) +{ + id_0 = json_body["id"][0].asInt64(); + id_1 = json_body["id"][1].asInt64(); + uid = json_body["uid"].asInt64(); + file_id = json_body["file_id"].asInt64(); + name = json_body["name"].asString(); + version = json_body["version"].asString(); + category_id = json_body["category_id"].asInt64(); + category_name = json_body["category_name"].asString(); + is_primary = json_body["is_primary"].asBool(); + size = json_body["size"].asInt64(); + file_name = json_body["file_name"].asString(); + uploaded_time = json_body["uploaded_timestamp"].asInt64(); + mod_version = json_body["mod_version"].asString(); + external_virus_scan_url = json_body["external_virus_scan_url"].asString(); + description = json_body["description"].asString(); + size_kb = json_body["size_kb"].asInt64(); + size_in_bytes = json_body["size_in_bytes"].asInt64(); + changelog_html = json_body["changelog_html"].asString(); + content_preview_link = json_body["content_preview_link"].asString(); +} diff --git a/src/core/nexus/file.h b/src/core/nexus/file.h new file mode 100644 index 0000000..7c3cff2 --- /dev/null +++ b/src/core/nexus/file.h @@ -0,0 +1,86 @@ +/*! + * \file file.h + * \brief Header for the nexus::File class. + */ + +#pragma once + +#include +#include +#include + + +/*! + * \brief The nexus namespace contains structs and functions needed for accessing the NexusMods API. + */ +namespace nexus +{ +/*! + * \brief Contains data for a file on NexusMods. + */ +class File +{ +public: + /*! + * \brief Constructor. Initializes all members from the given http response body generated + * through an API request. + * \param http_body The http response body. + */ + File(const std::string& http_body); + /*! + * \brief Constructor. Initializes all members from the given http response body in json form + * generated through an API request. + * \param http_body The http response body in json form. + */ + File(const Json::Value& json_body); + /*! \brief Default constructor. */ + File() = default; + + /*! \brief The file id. */ + long id_0; + /*! \brief The id of the domain containing mod to which the file belongs. */ + long id_1; + /*! \brief Purpose unknown. */ + long uid; + /*! \brief The file id. */ + long file_id; + /*! \brief The name of the actual file on disk. */ + std::string name; + /*! \brief The files version. */ + std::string version; + /*! \brief Id of the category to which the file belongs. */ + long category_id; + /*! \brief Name of the category to which the file belongs, e.g. MAIN. */ + std::string category_name; + /*! \brief Purpose unknown. */ + bool is_primary; + /*! \brief Size of the file in KibiBytes. */ + long size; + /*! \brief The files display name- */ + std::string file_name; + /*! \brief Timestamp for when the file was uploaded to NexusMods. */ + std::time_t uploaded_time; + /*! \brief Mod version to which the file belongs. */ + std::string mod_version; + /*! \brief Optional: The URL of a virus scanning website (like virustotal.com) for this file. */ + std::string external_virus_scan_url; + /*! \brief The description if the file. */ + std::string description; + /*! \brief Size of the file in KibiBytes. */ + long size_kb; + /*! \brief Size of the file in Bytes. */ + long size_in_bytes; + /*! \brief The changelog if the file. */ + std::string changelog_html; + /*! \brief A URL of a NexusMods site showing a preview of the files contents. */ + std::string content_preview_link; + +private: + /*! + * \brief Initializes all members from the given http response body in json form + * generated through an API request. + * \param http_body The http response body in json form. + */ + void init(const Json::Value& json_body); +}; +} diff --git a/src/core/nexus/mod.cpp b/src/core/nexus/mod.cpp new file mode 100644 index 0000000..b9d6c1b --- /dev/null +++ b/src/core/nexus/mod.cpp @@ -0,0 +1,52 @@ +#include "mod.h" +#include "../parseerror.h" +#include + +using namespace nexus; + + +Mod::Mod(const std::string& http_body) +{ + Json::Value json_body; + Json::Reader reader; + bool success = reader.parse(http_body.c_str(), json_body); + if(!success) + throw ParseError("Failed to parse response from NexusMods."); + + init(json_body); +} + +Mod::Mod(const Json::Value& json_body) +{ + init(json_body); +} + +void Mod::init(const Json::Value& json_body) +{ + name = json_body["name"].asString(); + summary = json_body["summary"].asString(); + description = json_body["description"].asString(); + picture_url = json_body["picture_url"].asString(); + mod_downloads = json_body["mod_downloads"].asInt64(); + mod_unique_downloads = json_body["mod_unique_downloads"].asInt64(); + uid = json_body["uid"].asInt64(); + mod_id = json_body["mod_id"].asInt64(); + game_id = json_body["game_id"].asInt64(); + allow_rating = json_body["allow_rating"].asBool(); + domain_name = json_body["domain_name"].asString(); + category_id = json_body["category_id"].asInt64(); + version = json_body["version"].asString(); + endorsement_count = json_body["endorsement_count"].asInt64(); + created_time = json_body["created_timestamp"].asInt64(); + updated_time = json_body["updated_timestamp"].asInt64(); + author = json_body["author"].asString(); + uploaded_by = json_body["uploaded_by"].asString(); + uploaded_users_profile_url = json_body["uploaded_users_profile_url"].asString(); + contains_adult_content = json_body["contains_adult_content"].asBool(); + status = json_body["status"].asString(); + available = json_body["available"].asBool(); + user_member_id = json_body["user"]["member_id"].asInt64(); + user_member_group_id = json_body["user"]["member_group_id"].asInt64(); + user_name = json_body["user"]["name"].asString(); + endorsement_status = json_body["endorsement"]["endorse_status"].asString(); +} diff --git a/src/core/nexus/mod.h b/src/core/nexus/mod.h new file mode 100644 index 0000000..17cb276 --- /dev/null +++ b/src/core/nexus/mod.h @@ -0,0 +1,100 @@ +/*! + * \file mod.h + * \brief Header for the nexus::Mod class. + */ + +#pragma once + +#include +#include +#include + + +/*! + * \brief The nexus namespace contains structs and functions needed for accessing the NexusMods API. + */ +namespace nexus +{ +/*! + * \brief Contains data for a mod on NexusMods. + */ +class Mod +{ +public: + /*! \brief Default constructor. */ + Mod() = default; + /*! + * \brief Constructor. Initializes all members from the given http response body generated + * through an API request. + * \param http_body The http response body. + */ + Mod(const std::string& http_body); + /*! + * \brief Constructor. Initializes all members from the given http response body in json form + * generated through an API request. + * \param http_body The http response body in json form. + */ + Mod(const Json::Value& json_body); + + /*! \brief Name of the mod. */ + std::string name; + /*! \brief A summary of the mods contents. */ + std::string summary; + /*! \brief The long form description of the mod. */ + std::string description; + /*! \brief URL of the main image representing the mod. */ + std::string picture_url; + /*! \brief Total number of downloads for the mod. */ + long mod_downloads; + /*! \brief Total number of unique downloads for the mod. */ + long mod_unique_downloads; + /*! \brief Purpose unknown. */ + long uid; + /*! \brief NexusMods mod id. */ + long mod_id; + /*! \brief Id of the NexusMods domain containing the mod. */ + long game_id; + /*! \brief If true: Mod can be rated. */ + bool allow_rating; + /*! \brief Name of the NexusMods domain containing the mod. */ + std::string domain_name; + /*! \brief Id of the NexusMods mod category for the mod. */ + long category_id; + /*! \brief Most recent mod version. */ + std::string version; + /*! \brief Number of endorsements of the mod. */ + long endorsement_count; + /*! \brief Timestamp for when the mod was first uploaded to NexusMods. */ + std::time_t created_time; + /*! \brief Timestamp for when the mod was first last updated. */ + std::time_t updated_time; + /*! \brief Name of the mods author. */ + std::string author; + /*! \brief Name of the mod uploader. */ + std::string uploaded_by; + /*! \brief URL to the NexusMods account which uploaded the mod. */ + std::string uploaded_users_profile_url; + /*! \brief True if the mod contains adult content. */ + bool contains_adult_content; + /*! \brief The current status of the mod, e.g. Published. */ + std::string status; + /*! \brief True if the mod is available........ */ + bool available; + /*! \brief User id of the uploader. */ + long user_member_id; + /*! \brief A group id for the uploader. */ + long user_member_group_id; + /*! \brief Name of the uploader. */ + std::string user_name; + /*! \brief Endorsement status of the mod for the account used to fetch the mod data. */ + std::string endorsement_status; + +private: + /*! + * \brief Initializes all members from the given http response body in json form + * generated through an API request. + * \param http_body The http response body in json form. + */ + void init(const Json::Value& json_body); +}; +} diff --git a/src/core/parseerror.h b/src/core/parseerror.h new file mode 100644 index 0000000..2a66ac0 --- /dev/null +++ b/src/core/parseerror.h @@ -0,0 +1,27 @@ +/*! + * \file parseerror.h + * \brief Contains the ParseError class. + */ + +#pragma once + +#include + + +/*! + * \brief Exception indicating an error while parsing a JSON file. + */ +class ParseError : public std::runtime_error +{ +public: + /*! + * \brief Constructor. + * \param message Message for the exception. + */ + ParseError(const char* message) : std::runtime_error(message) {} + /*! + * \brief Constructor. + * \param message Message for the exception. + */ + ParseError(const std::string& message) : std::runtime_error(message) {} +}; diff --git a/src/core/pathutils.cpp b/src/core/pathutils.cpp new file mode 100644 index 0000000..c6b89e3 --- /dev/null +++ b/src/core/pathutils.cpp @@ -0,0 +1,201 @@ +#include "pathutils.h" +#include +#include +#include + +namespace sfs = std::filesystem; + + +namespace path_utils +{ +std::optional pathExists(const sfs::path& path_to_check, + const sfs::path& base_path, + bool case_insensitive) +{ + if(sfs::exists(base_path / path_to_check)) + return path_to_check; + if(!case_insensitive) + return {}; + const sfs::path target = + path_to_check.string().ends_with("/") ? path_to_check.parent_path() : path_to_check; + + sfs::path actual_path; + int i = 0; + for(auto iter = target.begin(); iter != target.end(); iter++) + { + if(sfs::exists(base_path / actual_path / *iter)) + { + actual_path /= *iter; + continue; + } + + std::string lower_part = toLowerCase(*iter); + bool found = false; + for(const auto& dir_entry : sfs::directory_iterator(base_path / actual_path)) + { + const sfs::path path_end = *(std::prev(dir_entry.path().end())); + std::string lower_case_path_end = toLowerCase(path_end); + std::string actual_case_path_end = path_end.string(); + if(lower_case_path_end == lower_part) + { + actual_path /= actual_case_path_end; + found = true; + break; + } + } + if(!found) + return {}; + } + return actual_path; +} + +std::string toLowerCase(const sfs::path& path) +{ + auto path_string = path.string(); + std::transform(path_string.begin(), + path_string.end(), + path_string.begin(), + [](unsigned char c) { return std::tolower(c); }); + return path_string; +} + +void moveFilesToDirectory(const sfs::path& source, const sfs::path& destination, bool move) +{ + if(!sfs::exists(destination)) + sfs::create_directories(destination); + for(const auto& dir_entry : sfs::directory_iterator(source)) + { + const auto relative_path = getRelativePath(dir_entry.path(), source); + if(sfs::exists(destination / relative_path)) + { + if(sfs::is_directory(destination / relative_path)) + moveFilesToDirectory(dir_entry.path(), destination / relative_path, move); + else + { + sfs::remove(destination / relative_path); + copyOrMoveFiles(dir_entry.path(), destination / relative_path, move); + } + continue; + } + copyOrMoveFiles(dir_entry.path(), destination / relative_path, move); + } + if(sfs::exists(source) && move) + sfs::remove_all(source); +} + +std::string normalizePath(const std::string& path) +{ + return std::regex_replace(path, std::regex(R"(\\)"), "/"); +} + +std::string getRelativePath(sfs::path target, sfs::path source) +{ + std::string relative_path = target.string(); + relative_path.erase(0, source.string().size() + 1); + return relative_path; +} + +bool directoryIsEmpty(const sfs::path& directory) +{ + if(!sfs::is_directory(directory)) + return false; + for(const auto& dir_entry : sfs::recursive_directory_iterator(directory)) + { + if(!dir_entry.is_directory()) + return false; + } + return true; +} + +int getPathLength(const sfs::path& path) +{ + int length = 0; + for(const auto& e : path) + length++; + return length; +} + +std::pair removePathComponents(const sfs::path& path, int depth) +{ + sfs::path short_path; + sfs::path head; + int cur_depth = 0; + for(auto it = path.begin(); it != path.end(); it++, cur_depth++) + { + if(cur_depth >= depth) + short_path /= *it; + else + head /= *it; + } + return { head, short_path }; +} + +void renameFiles(const sfs::path& destination, + const sfs::path& source, + std::function converter) +{ + std::vector old_directories; + for(const auto& dir_entry : sfs::recursive_directory_iterator(source)) + { + auto relative_path = getRelativePath(dir_entry.path(), source); + std::string old_path = relative_path; + std::transform(relative_path.begin(), relative_path.end(), relative_path.begin(), converter); + if(dir_entry.is_directory()) + { + if(old_path != relative_path) + old_directories.push_back(dir_entry.path()); + continue; + } + if(!sfs::exists((destination / relative_path).parent_path())) + sfs::create_directories((destination / relative_path).parent_path()); + sfs::rename(dir_entry.path(), destination / relative_path); + } + if(source == destination) + { + for(const auto& dir : old_directories) + { + if(sfs::exists(dir)) + sfs::remove_all(dir); + } + } + else + sfs::remove_all(source); +} + +void moveFilesWithDepth(const sfs::path& source, const sfs::path& destination, int depth) +{ + std::set> files_to_move; + for(const auto& dir_entry : sfs::recursive_directory_iterator(source)) + { + const auto [head, short_path] = + removePathComponents(getRelativePath(dir_entry.path(), source), depth); + if(short_path != "") + files_to_move.emplace(dir_entry.path(), destination / short_path); + } + + for(const auto& [cur_source, cur_dest] : files_to_move) + { + if(sfs::is_directory(cur_source)) + sfs::create_directories(cur_dest); + else + { + if(sfs::exists(cur_dest)) + throw std::runtime_error("Error: Duplicate file detected: \"" + + getRelativePath(cur_source, source) + "\"!"); + if(cur_dest.has_parent_path()) + sfs::create_directories(cur_dest.parent_path()); + sfs::rename(cur_source, cur_dest); + } + } + sfs::remove_all(source); +} + +void copyOrMoveFiles(const sfs::path& source, const sfs::path& destination, bool move) +{ + if(move) + sfs::rename(source, destination); + else + sfs::copy(source, destination, sfs::copy_options(sfs::copy_options::recursive)); +} + +} diff --git a/src/core/pathutils.h b/src/core/pathutils.h new file mode 100644 index 0000000..ad77fe5 --- /dev/null +++ b/src/core/pathutils.h @@ -0,0 +1,110 @@ +/*! + * \file pathutils.h + * \brief Header for the path_utils namespace. + */ + +#pragma once + +#include +#include +#include + + +/*! + * \brief Contains utility functions for dealing with std::filesystem::path objects. + */ +namespace path_utils +{ +/*! + * \brief Checks if the target path exists. + * \param target Path to check. + * \param base_path If specified, target path is appended to this path during the search. + * \param case_insensitive If true: Ignore case mismatch for path search. + * \return The target path in its actual case, if found. + */ +std::optional pathExists(const std::filesystem::path& path_to_check, + const std::filesystem::path& base_path, + bool case_insensitive = true); + +/*! + * \brief Returns a string containing the given path in lower case. + * \param path Path to be converted. + * \return The lower case path. + */ +std::string toLowerCase(const std::filesystem::path& path); +/*! + * \brief Recursively moves all files from the source directory to the target directory. + * \param source Source directory. + * \param destination Target directory. + * \param move If false: Copy files instead of moving them. + */ +void moveFilesToDirectory(const std::filesystem::path& source, + const std::filesystem::path& destination, + bool move = true); +/*! + * \brief Replaces all double backslash path separators with a forward slash. + * \return The normalized path. + */ +std::string normalizePath(const std::string& path); +/*! + * \brief Determines the relative path from source to target. Only works if source.string() + * is a sub-string of target.string(). + * \param target Target path. + * \param source Source path. + * \return The relative path. + */ +std::string getRelativePath(std::filesystem::path target, std::filesystem::path source); +/*! + * \brief Returns true if directory is empty or contains only empty directories. + * \param directory Directory to check. + * \return True if empty, else false. + */ +bool directoryIsEmpty(const std::filesystem::path& directory); +/*! + * \brief Returns the number of elements in given path. + * \param path Path to be checked. + * \return The length. + */ +int getPathLength(const std::filesystem::path& path); +/*! + * \brief Removes the first components of a given path. + * \param path Source path. + * \param depth Components with depth < this will be removed. + * \return A pair of the removed components and the shortened path. + */ +std::pair removePathComponents( + const std::filesystem::path& path, + int depth); +/*! + * \brief Recursively renames all files at given source directory using given converter, + * then copies the result to given destination directory. + * \param destination Path to destination directory for renamed files. + * \param source Path to source files to be renamed. + * \param converter Function which converts one char to another, e.g. converting to + * upper case. + */ +void renameFiles(const std::filesystem::path& destination, + const std::filesystem::path& source, + std::function converter); +/*! + * \brief Recursively moves all files from source to destination, removes all + * path components with depth < root_level. + * \param source Source path. + * \param destination Destination path. + * \param depth Minimum depth for path components to keep. + */ +void moveFilesWithDepth(const std::filesystem::path& source, + const std::filesystem::path& destination, + int depth); + +/*! + * \brief Copies or moves files from source to dest. + * \param source Copy/ move source path. + * \param destination Copy/ move target path. + * \param move If true: Move files, else: Recursively copy files. + */ +void copyOrMoveFiles(const std::filesystem::path& source, + const std::filesystem::path& destination, + bool move); + +} diff --git a/src/core/progressnode.cpp b/src/core/progressnode.cpp new file mode 100644 index 0000000..494ce43 --- /dev/null +++ b/src/core/progressnode.cpp @@ -0,0 +1,107 @@ +#include "progressnode.h" +#include +#include + + +ProgressNode::ProgressNode(int id, + const std::vector& weights, + std::optional parent) : id_(id), parent_(parent) +{ + addChildren(weights); +} + +ProgressNode::ProgressNode(std::function progress_callback, + const std::vector& weights) +{ + addChildren(weights); + setProgressCallback(progress_callback); +} + +void ProgressNode::advance(uint64_t num_steps) +{ + if(!children_.empty()) + throw std::runtime_error("Cannot advance progress for a node with children."); + cur_step_ += num_steps; + if(total_steps_ == 0) + progress_ = 1.0f; + else + progress_ = std::min(static_cast(cur_step_) / total_steps_, 1.0f); + propagateProgress(); +} + +int ProgressNode::totalSteps() const +{ + return total_steps_; +} + +void ProgressNode::setTotalSteps(uint64_t total_steps) +{ + if(!children_.empty()) + throw std::runtime_error("Cannot set total steps for a node with children."); + total_steps_ = total_steps; +} + +int ProgressNode::id() const +{ + return id_; +} + +void ProgressNode::addChildren(const std::vector& weights) +{ + weights_ = weights; + float sum = std::accumulate(weights_.begin(), weights_.end(), 0.0f); + if(sum == 0.0f) + sum = 1.0f; + for(auto& weight : weights_) + weight = std::abs(weight / sum); + for(int i = 0; i < weights_.size(); i++) + children_.push_back({ i, {}, this }); +} + +ProgressNode& ProgressNode::child(int id) +{ + int* i; + return children_[id]; +} + +void ProgressNode::setProgressCallback(std::function progress_callback) +{ + set_progress_ = progress_callback; + set_progress_(progress_); +} + +float ProgressNode::updateStepSize() const +{ + return update_step_size_; +} + +void ProgressNode::setUpdateStepSize(float step_size) +{ + update_step_size_ = step_size; +} + +float ProgressNode::getProgress() const +{ + return progress_; +} + +void ProgressNode::updateProgress() +{ + progress_ = 0.0f; + for(int i = 0; i < weights_.size(); i++) + progress_ += weights_[i] * children_[i].progress_; + propagateProgress(); +} + +void ProgressNode::propagateProgress() +{ + if(parent_) + (*parent_)->updateProgress(); + else if(progress_ - prev_progress_ > update_step_size_ || + std::abs(1.0f - progress_) <= std::numeric_limits::epsilon() && + std::abs(1.0f - prev_progress_) > std::numeric_limits::epsilon()) + { + set_progress_(progress_); + prev_progress_ = progress_; + } +} diff --git a/src/core/progressnode.h b/src/core/progressnode.h new file mode 100644 index 0000000..c09b3e3 --- /dev/null +++ b/src/core/progressnode.h @@ -0,0 +1,131 @@ +/*! + * \file progressnode.h + * \brief Header for the ProgressNode class. + */ + +#pragma once + +#include +#include +#include +#include + + +/*! + * \brief Represents a node in a tree used to track the progress of a task. + * + * Each node in the tree represents the progress in a sub-task. Each sub-task has + * a weight associated to it, which should be proportional to the time this task takes + * to be completed. + */ +class ProgressNode +{ +public: + /*! + * \brief Constructor. + * \param id Id of this node. Used to index weights and children of parent. + * \param weights If not empty: Weights of sub-tasks. + * \param parent Parent of this node. If empty: This is a root node. + */ + ProgressNode(int id, const std::vector& weights, std::optional parent); + /*! + * \brief Constructor for a root node. + * \param progress_callback a callback function used by the root node to inform about + * changes in the task progress. + * \param weights If not empty: Weights of sub-tasks. + */ + ProgressNode(std::function progress_callback, + const std::vector& weights = {}); + + /*! + * \brief Advances the current progress of this node by the given amount of steps. + * This must be a leaf node. + * \param num_steps Number steps to advance. + */ + void advance(uint64_t num_steps = 1); + /*! + * \brief Returns the total number of steps in this task. + * \return The number of steps. + */ + int totalSteps() const; + /*! + * \brief Sets the total number of steps in this task. + * \param total_steps The number of steps. + */ + void setTotalSteps(uint64_t total_steps); + /*! + * \brief Returns the id of this node. + * \return The id. + */ + int id() const; + /*! + * \brief Adds new child nodes with given weights to this node. + * \param weights The child weights. + */ + void addChildren(const std::vector& weights); + /*! + * \brief Returns a reference to the child with the given id. + * \param id Target child id. + * \return The child. + */ + ProgressNode& child(int id); + /*! + * \brief Sets a callback function used by the root node to inform about changes in the + * task progress. + * \param set_progress The callback function. + */ + void setProgressCallback(std::function progress_callback); + /*! + * \brief Returns the minimal progress interval after which the progress callback is called. + * \return The interval. + */ + float updateStepSize() const; + /*! + * \brief Sets the minimal progress interval after which the progress callback is called. + * \param step_size The interval. + */ + void setUpdateStepSize(float step_size); + /*! + * \brief Returns the current progress. + * \return The progress. + */ + float getProgress() const; + +private: + /*! \brief This nodes id. */ + int id_; + /*! \brief Current step in this task. Only used for leaf nodes. */ + uint64_t cur_step_ = 0; + /*! \brief Number of total steps in this task. Only used for leaf nodes. */ + uint64_t total_steps_; + /*! \brief Current progress in this task. */ + float progress_ = 0.0f; + /*! \brief Progress at the time of the last call to \ref set_progress_. */ + float prev_progress_ = 0.0f; + /*! \brief minimal progress interval after which \ref set_progress_ is called. */ + float update_step_size_ = 0.01f; + /*! \brief The parent of this, if this is not the root. */ + std::optional parent_; + /*! \brief Weights of children. */ + std::vector weights_; + /*! \brief Children representing sub-tasks of this task. */ + std::vector children_; + + /*! + * \brief Callback function used by the root node to inform about changes in the + * task progress. + */ + std::function set_progress_ = [](float f) {}; + /*! + * \brief Sets the current progress of this node to the weighted sum of the current + * progresses of its children. + */ + void updateProgress(); + /*! + * \brief Informs this nodes parent of a change in progress. + * + * If this is a root node and the change of progress since the last update exceeds + * \ref update_step_size_ : Call \ref set_progress_. + */ + void propagateProgress(); +}; diff --git a/src/core/tag.cpp b/src/core/tag.cpp new file mode 100644 index 0000000..0ff688d --- /dev/null +++ b/src/core/tag.cpp @@ -0,0 +1,29 @@ +#include "tag.h" + +namespace str = std::ranges; + + +std::string Tag::getName() const +{ + return name_; +} + +void Tag::setName(const std::string& name) +{ + name_ = name; +} + +std::vector Tag::getMods() const +{ + return mods_; +} + +int Tag::getNumMods() const +{ + return mods_.size(); +} + +bool Tag::hasMod(int mod_id) const +{ + return str::find(mods_, mod_id) != mods_.end(); +} diff --git a/src/core/tag.h b/src/core/tag.h new file mode 100644 index 0000000..d91496c --- /dev/null +++ b/src/core/tag.h @@ -0,0 +1,57 @@ +/*! + * \file tag.h + * \brief Header for the Tag class. + */ + +#pragma once + +#include +#include +#include + + +/*! + * \brief Abstract base class for a tag assigned to a set of mods. + */ +class Tag +{ +public: + /*! + * \brief Getter for the tags name. + * \return The name. + */ + std::string getName() const; + /*! + * \brief Setter for the tags name. + * \param name The new name. + */ + void setName(const std::string& name); + /*! + * \brief Returns all mods to which this tag has been added. + * \return A vector of mods ids. + */ + std::vector getMods() const; + /*! + * \brief Returns the number of mods to which this tag has been added. + * \return The number of mods. + */ + int getNumMods() const; + /*! + * \brief Checks if this tag has been added to the given mod. + * \param mod_id Mod to be checked. + * \return True if the given mod has this tag. + */ + bool hasMod(int mod_id) const; + /*! + * \brief Serializes this tag to a json object. + * This function must be implemented by derived classes. + * \return The json object. + */ + virtual Json::Value toJson() const = 0; + +protected: + /*! \brief Name of this tag. */ + std::string name_; + /*! \brief Contains ids of all mods to which this tag has been added. */ + std::vector mods_{}; +}; diff --git a/src/core/tagcondition.h b/src/core/tagcondition.h new file mode 100644 index 0000000..1a217cc --- /dev/null +++ b/src/core/tagcondition.h @@ -0,0 +1,34 @@ +/*! + * \file tagcondition.h + * \brief Contains the TagCondition struct. + */ + +#pragma once + +#include + + +/*! + * \brief Contains data relevant to describing a single condition used for the application + * of auto tags. This is used to construct a TagConditionNode. + */ +struct TagCondition +{ + /*! \brief Represents what should be compared to the search string. */ + enum class Type + { + /*! \brief Match against relative path, including file name. */ + path, + /*! \brief Match against file name only. */ + file_name + }; + + /*! \brief If true: Matches only if condition is NOT met. */ + bool invert; + /*! \brief Describes against what the search string should be matched. */ + Type condition_type; + /*! \brief If true: Use regex matching, else use case insensitive matching with wildcards. */ + bool use_regex; + /*! \brief This string will be matched against a given path. */ + std::string search_string; +}; diff --git a/src/core/tagconditionnode.cpp b/src/core/tagconditionnode.cpp new file mode 100644 index 0000000..982c2a4 --- /dev/null +++ b/src/core/tagconditionnode.cpp @@ -0,0 +1,442 @@ +#include "tagconditionnode.h" +#include +#include +#include +#include + +namespace str = std::ranges; +namespace sfs = std::filesystem; + + +TagConditionNode::TagConditionNode() +{ + expression_ = ""; + invert_ = false; + children_ = {}; + type_ = Type::empty; + condition_ = ""; + condition_strings_ = {}; + condition_id_ = -1; + use_regex_ = false; +} + +TagConditionNode::TagConditionNode(std::string expression, + const std::vector& conditions) : + expression_(expression) +{ + if(expression == "") + { + type_ = Type::empty; + return; + } + if(!expressionIsValid(expression, conditions.size())) + throw std::runtime_error(std::format("Invalid expression '{}'", expression)); + + std::transform(expression.begin(), + expression.end(), + expression.begin(), + [](unsigned char c) { return std::tolower(c); }); + removeWhitespaces(expression); + removeEnclosingParentheses(expression); + auto tokens = tokenize(expression); + while(tokens.size() == 1 && expression.compare(0, 3, "not") == 0) + { + invert_ = !invert_; + expression.erase(expression.begin(), expression.begin() + 3); + removeEnclosingParentheses(expression); + tokens = tokenize(expression); + } + if(tokens.size() == 1) + { + if(expression.find_first_not_of("0123456789") != std::string::npos) + throw std::runtime_error( + std::format("Error: Could not parse condition in expression '{}'", expression)); + int condition_index = std::stoi(expression); + if(condition_index >= conditions.size()) + throw std::runtime_error(std::format( + "Error: Condition index {} out of range in expression '{}'", condition_index, expression)); + condition_id_ = condition_index; + type_ = conditions[condition_index].condition_type == TagCondition::Type::path + ? Type::path_matcher + : Type::file_matcher; + condition_ = conditions[condition_index].search_string; + condition_strings_ = splitString(condition_); + invert_ = conditions[condition_index].invert ? !invert_ : invert_; + use_regex_ = conditions[condition_index].use_regex; + if(!use_regex_) + std::transform(condition_.begin(), + condition_.end(), + condition_.begin(), + [](unsigned char c) { return std::tolower(c); }); + } + else + { + type_ = containsOperator(expression, "or") ? Type::or_connector : Type::and_connector; + for(auto [start, size] : tokens) + children_.emplace_back(expression.substr(start, size), conditions); + } +} + +bool TagConditionNode::evaluate(const std::vector>& files) const +{ + if(type_ == Type::empty) + return false; + std::map results; + return evaluateOnce(files, results); +} + +bool TagConditionNode::evaluateOnce(const std::vector>& files, + std::map& results) const +{ + return invert_ ? !evaluateWithoutInversion(files, results) + : evaluateWithoutInversion(files, results); +} + +bool TagConditionNode::evaluateWithoutInversion( + const std::vector>& files, + std::map& results) const +{ + if(type_ == Type::file_matcher || type_ == Type::path_matcher) + { + if(results.contains(condition_id_)) + return results[condition_id_]; + + bool result = false; + for(const auto& [path, file_name] : files) + { + std::string target = type_ == Type::file_matcher ? file_name : path; + if(use_regex_) + result = std::regex_match(target, std::regex(condition_)); + else + { + std::transform(target.begin(), + target.end(), + target.begin(), + [](unsigned char c) { return std::tolower(c); }); + result = wildcardMatch(target); + } + if(result) + break; + } + results[condition_id_] = result; + return result; + } + else if(type_ == Type::or_connector) + { + for(const auto& child : children_) + if(child.evaluateOnce(files, results)) + return true; + return false; + } + else + { + for(const auto& child : children_) + if(!child.evaluateOnce(files, results)) + return false; + return true; + } +} + +void TagConditionNode::removeEnclosingParentheses(std::string& expression) +{ + while(expression.front() == '(' && expression.back() == ')') + { + int level = 0; + for(auto [i, c] : str::enumerate_view(expression)) + { + if(c == '(') + level++; + else if(c == ')') + level--; + if(i != expression.size() - 1 && level == 0) + return; + } + expression.erase(expression.begin()); + expression.erase(expression.end() - 1); + } +} + +bool TagConditionNode::expressionIsValid(std::string expression, int num_conditions) +{ + if(expression.empty()) + return false; + std::transform(expression.begin(), + expression.end(), + expression.begin(), + [](unsigned char c) { return std::tolower(c); }); + // check for invalid operators + if(expression.find_first_not_of("notadr0123456789() ") != std::string::npos) + return false; + std::string expression_2 = expression; + removeSubstring(expression_2, "and"); + removeSubstring(expression_2, "or"); + removeSubstring(expression_2, "not"); + if(std::regex_search(expression_2, std::regex("[a-zA-Z]"))) + return false; + + removeSubstring(expression, " "); + // check for invalid parentheses + char last_c = ' '; + int level = 0; + for(auto c : expression) + { + if(c == '(') + level++; + else if(c == ')') + { + if(last_c == '(') + return false; + level--; + } + last_c = c; + } + if(level != 0) + return false; + + // check if variables exist + const std::regex num_regex(R"(\d+)"); + auto first = std::sregex_iterator(expression.begin(), expression.end(), num_regex); + auto last = std::sregex_iterator(); + for(auto iter = first; iter != last; iter++) + { + if(std::stoi(iter->str()) >= num_conditions) + return false; + } + + return operatorOrderIsValid(expression); +} + +bool TagConditionNode::containsOperator(const std::string& expression, const std::string& op) const +{ + int level = 0; + for(auto [i, c] : str::enumerate_view(expression)) + { + if(c == '(') + { + level++; + continue; + } + if(level > 0) + { + if(c == ')') + level--; + continue; + } + if(expression.compare(i, op.size(), op) == 0) + return true; + } + return false; +} + +std::vector> TagConditionNode::tokenize(const std::string& expression) const +{ + bool or_has_priority = containsOperator(expression, "or"); + bool and_has_priority = !or_has_priority && containsOperator(expression, "and"); + std::vector> tokens; + int level = 0; + int token_start = 0; + for(auto [i, c] : str::enumerate_view(expression)) + { + if(c == '(') + { + level++; + continue; + } + if(level > 0) + { + if(c == ')') + level--; + continue; + } + if(or_has_priority && expression.compare(i, 2, "or") == 0) + { + tokens.emplace_back(token_start, i - token_start); + token_start = i + 2; + } + else if(and_has_priority && expression.compare(i, 3, "and") == 0) + { + tokens.emplace_back(token_start, i - token_start); + token_start = i + 3; + } + } + tokens.emplace_back(token_start, expression.size() - token_start); + return tokens; +} + +void TagConditionNode::removeWhitespaces(std::string& expression) const +{ + auto pos = expression.find(" "); + while(pos != std::string::npos) + { + expression.erase(pos, 1); + pos = expression.find(" "); + } +} + +bool TagConditionNode::wildcardMatch(const std::string& target) const +{ + if(condition_.empty()) + return false; + if(condition_.find_first_not_of("*") == std::string::npos) + return true; + + auto condition_strings_ = splitString(condition_); + if(condition_.front() != '*' && !target.starts_with(condition_strings_[0]) || + condition_.back() != '*' && !target.ends_with(condition_strings_.back())) + return false; + + size_t target_pos = 0; + for(const auto& search_string : condition_strings_) + { + if(target_pos >= target.size()) + return false; + target_pos = target.find(search_string, target_pos); + if(target_pos == std::string::npos) + return false; + target_pos += search_string.size(); + } + return true; +} + +std::vector TagConditionNode::splitString(const std::string& input) const +{ + std::vector splits; + size_t pos = 0; + size_t old_pos = 0; + while(old_pos != input.size()) + { + pos = input.find('*', old_pos); + if(pos == std::string::npos) + { + splits.push_back(input.substr(old_pos)); + break; + } + if(pos - old_pos > 0) + splits.push_back(input.substr(old_pos, pos - old_pos)); + old_pos = pos + 1; + } + return splits; +} + +bool TagConditionNode::operatorOrderIsValid(std::string expression) +{ + constexpr int type_var = 0; + constexpr int type_op = 1; + constexpr int type_group = 2; + constexpr int type_not = 3; + + TagConditionNode::removeEnclosingParentheses(expression); + std::vector token_types; + std::vector> token_borders; + int level = 0; + int token_start = 0; + bool is_in_group = false; + bool is_in_var = false; + int i = 0; + while(i < expression.size()) + { + char c = expression[i]; + if(is_in_var) + { + if(c < '0' || c > '9') + { + token_types.push_back(type_var); + token_borders.emplace_back(token_start, i - token_start); + is_in_var = false; + } + else + { + i++; + continue; + } + } + if(is_in_group) + { + if(c == '(') + level++; + else if(c == ')') + { + level--; + if(level == 0) + { + is_in_group = false; + token_types.push_back(type_group); + token_borders.emplace_back(token_start, i - token_start + 1); + } + } + i++; + } + else if(c == '(') + { + is_in_group = true; + token_start = i; + level++; + i++; + } + else if(c == 'a') + { + token_borders.emplace_back(i, 3); + token_types.push_back(type_op); + i += 3; + } + else if(c == 'o') + { + token_borders.emplace_back(i, 2); + token_types.push_back(type_op); + i += 2; + } + else if(c == 'n') + { + token_borders.emplace_back(i, i + 2); + token_types.push_back(type_not); + i += 3; + } + else if('0' <= c && c <= '9') + { + if(!is_in_var) + token_start = i; + is_in_var = true; + i++; + } + else + i++; + } + if(is_in_var) + { + token_types.push_back(type_var); + token_borders.emplace_back(token_start, i - token_start); + } + + int prev_token = type_op; + for(int token : token_types) + { + if(token == type_op && (prev_token == type_not || prev_token == type_op)) + return false; + if(token == type_var && (prev_token == type_var || prev_token == type_group)) + return false; + if(token == type_group && (prev_token == type_var || prev_token == type_group)) + return false; + if(token == type_not && ((prev_token == type_var || prev_token == type_group))) + return false; + prev_token = token; + } + if(token_types.back() == type_not || token_types.back() == type_op) + return false; + + for(const auto& [token, borders] : str::zip_view(token_types, token_borders)) + { + if(token != type_group) + continue; + const auto [start, len] = borders; + if(!operatorOrderIsValid(expression.substr(start, len))) + return false; + } + return true; +} + +void TagConditionNode::removeSubstring(std::string& string, std::string substring) +{ + const size_t length = substring.length(); + for(auto pos = string.find(substring); pos != std::string::npos; pos = string.find(substring)) + string.erase(pos, length); +} diff --git a/src/core/tagconditionnode.h b/src/core/tagconditionnode.h new file mode 100644 index 0000000..c2bc4af --- /dev/null +++ b/src/core/tagconditionnode.h @@ -0,0 +1,156 @@ +/*! + * \file tagconditionnode.h + * \brief Header for the TagConditionNode class. + */ + +#pragma once + +#include "tagcondition.h" +#include +#include +#include + + +/*! + * \brief Represents a node in a tree used to model a boolean expression for + * evaluating if the files in a directory match a set of conditions. + */ +class TagConditionNode +{ +public: + /*! \brief Type of this node. */ + enum class Type + { + /*! \brief Node evaluates to true only if all children evaluate to true. */ + and_connector, + /*! \brief Node evaluates to true if at least one child evaluates to true. */ + or_connector, + /*! \brief Leaf node. Evaluates to true if a file name matches a pattern. */ + file_matcher, + /*! \brief Leaf node. Evaluates to true if a file path matches a pattern. */ + path_matcher, + /*! \brief Dummy node. Always evaluates to false. */ + empty + }; + + /*! \brief Constructs a node of type empty. */ + TagConditionNode(); + /*! + * \brief Constructs a new node from the given boolean expression and conditions. + * Recursively constructs children as needed. Node types are deduced from the expression. + * \param expression Expression used to construct the tree. + * \param conditions Conditions which serve as variables in the expression. + */ + TagConditionNode(std::string expression, const std::vector& conditions); + + /*! + * \brief Checks if files in the given vector satisfy + * the boolean expression modeled by this tree node. + * \param files Contains pairs of path and file names for all files of a mod. + * \return True if the directory satisfies the expression. + */ + bool evaluate(const std::vector>& files) const; + /*! + * \brief Removes all outer parentheses that serve no semantic purpose in the given expression. + * \param expression Expression to be modified. + */ + static void removeEnclosingParentheses(std::string& expression); + /*! + * \brief Checks if the given string is a syntactically valid boolean expression. + * \param exppression String to validate. + * \param num_conditions Number of conditions available in the expression. + * \return True if the expression is valid. + */ + static bool expressionIsValid(std::string expression, int num_conditions); + +private: + /*! \brief The boolean expression modeled by this tree. */ + std::string expression_; + /*! \brief If true: Invert the evaluation result. */ + bool invert_ = false; + /*! \brief Child nodes of this node. */ + std::vector children_; + /*! \brief Type of this node. */ + Type type_; + /*! \brief String used to comparisons in leaf nodes. */ + std::string condition_; + /*! \brief Used to store substrings of the expression. Split by the * wildcard. */ + std::vector condition_strings_; + /*! + * \brief If this is a leaf: Represents the condition in the tree. Used to avoid + * evaluating conditions multiple times. + */ + int condition_id_; + /*! + * \brief If true: Use regex to compare against the condition string. + * Else: Use a simple string matcher with * as a wildcard. + */ + bool use_regex_; + + /*! + * \brief Checks if files in the given vector satisfy + * the boolean expression modeled by this tree node. This check is skipped if the given + * results map contains this nodes id. + * \param files Contains pairs of path and file names for all files of a mod. + * \param results Contains results of previous evaluations. + * \return True if the directory satisfies the expression. + */ + bool evaluateOnce(const std::vector>& files, + std::map& results) const; + /*! + * \brief Checks if files in the given vector satisfy + * the boolean expression modeled by this tree node. This check is skipped if the given + * results map contains this nodes id. + * Does not invert the result even is invert_ is true. + * \param files Contains pairs of path and file names for all files of a mod. + * \param results Contains results of previous evaluations. + * \return True if the directory satisfies the expression. + */ + bool evaluateWithoutInversion(const std::vector>& files, + std::map& results) const; + /*! + * \brief Checks if the given expression contains the given boolean operator. + * Only checks the top level part of the expression. + * \param expression Expression to check. + * \param op Operator used for comparison. + * \return True if expression contains operator. + */ + bool containsOperator(const std::string& expression, const std::string& op) const; + /*! + * \brief Splits the given expression into tokens. Tokens are either condition ids, + * boolean operators or a subexpression in parentheses. + * \param expression Expression to split. + * \return Contains pairs of index and length of tokens in the given expression. + */ + std::vector> tokenize(const std::string& expression) const; + /*! + * \brief Removes all whitespaces in the given string. + * \param expression Expression to modify. + */ + void removeWhitespaces(std::string& expression) const; + /*! + * \brief Checks if the given string matches this nodes condition_ string. + * Uses * as a wildcard. + * \param target String to compare to. + * \return True if both match. + */ + bool wildcardMatch(const std::string& target) const; + /*! + * \brief Splits the given string into substrings seperated by the * wildcard. + * \param input String to split. + * \return All substrings without the * wildcard. + */ + std::vector splitString(const std::string& input) const; + /*! + * \brief Checks if the order of operators in the given boolean expression is valid. + * \param expression Expression to check. + * \return True if the order is valid. + */ + static bool operatorOrderIsValid(std::string expression); + /*! + * \brief Removes all occurrences of substring from string. + * \param string String from which to remove. + * \param substring Substring to remove. + */ + static void removeSubstring(std::string& string, std::string substring); +}; diff --git a/src/cspell.json b/src/cspell.json new file mode 100644 index 0000000..8e0125e --- /dev/null +++ b/src/cspell.json @@ -0,0 +1,27 @@ +{ + "version": "0.2", + "language": "en", + "words": [ + "deployer", + "deployers", + "Deployer", + "Deployers", + "flatpak", + "unrar", + "fomod", + "Fomod", + "loadorder", + "LOADORDER", + "Skyrim", + "Morrowind", + "Fallout", + "fomm", + "SIMPLEDEPLOYER", + "CASEMATCHINGDEPLOYER", + "LOOTDEPLOYER", + "Kibi", + "depl", + "pugi" + ], + "allowCompoundWords": true +} diff --git a/src/lmm_Doxyfile b/src/lmm_Doxyfile new file mode 100644 index 0000000..fa82c3e --- /dev/null +++ b/src/lmm_Doxyfile @@ -0,0 +1,2737 @@ +# Doxyfile 1.9.4 + +# This file describes the settings to be used by the documentation system +# doxygen (www.doxygen.org) for a project. +# +# All text after a double hash (##) is considered a comment and is placed in +# front of the TAG it is preceding. +# +# All text after a single hash (#) is considered a comment and will be ignored. +# The format is: +# TAG = value [value, ...] +# For lists, items can also be appended using: +# TAG += value [value, ...] +# Values that contain spaces should be placed between quotes (\" \"). +# +# Note: +# +# Use doxygen to compare the used configuration file with the template +# configuration file: +# doxygen -x [configFile] +# Use doxygen to compare the used configuration file with the template +# configuration file without replacing the environment variables: +# doxygen -x_noenv [configFile] + +#--------------------------------------------------------------------------- +# Project related configuration options +#--------------------------------------------------------------------------- + +# This tag specifies the encoding used for all characters in the configuration +# file that follow. The default is UTF-8 which is also the encoding used for all +# text before the first occurrence of this tag. Doxygen uses libiconv (or the +# iconv built into libc) for the transcoding. See +# https://www.gnu.org/software/libiconv/ for the list of possible encodings. +# The default value is: UTF-8. + +DOXYFILE_ENCODING = UTF-8 + +# The PROJECT_NAME tag is a single word (or a sequence of words surrounded by +# double-quotes, unless you are using Doxywizard) that should identify the +# project for which the documentation is generated. This name is used in the +# title of most generated pages and in a few other places. +# The default value is: My Project. + +PROJECT_NAME = "Limo" + +# The PROJECT_NUMBER tag can be used to enter a project or revision number. This +# could be handy for archiving the generated documentation or if some version +# control system is used. + +PROJECT_NUMBER = + +# Using the PROJECT_BRIEF tag one can provide an optional one line description +# for a project that appears at the top of each page and should give viewer a +# quick idea about the purpose of the project. Keep the description short. + +PROJECT_BRIEF = "A simple mod manager" + +# With the PROJECT_LOGO tag one can specify a logo or an icon that is included +# in the documentation. The maximum height of the logo should not exceed 55 +# pixels and the maximum width should not exceed 200 pixels. Doxygen will copy +# the logo to the output directory. + +PROJECT_LOGO = resources/logo_small.png + +# The OUTPUT_DIRECTORY tag is used to specify the (relative or absolute) path +# into which the generated documentation will be written. If a relative path is +# entered, it will be relative to the location where doxygen was started. If +# left blank the current directory will be used. + +OUTPUT_DIRECTORY = doc/ + +# If the CREATE_SUBDIRS tag is set to YES then doxygen will create up to 4096 +# sub-directories (in 2 levels) under the output directory of each output format +# and will distribute the generated files over these directories. Enabling this +# option can be useful when feeding doxygen a huge amount of source files, where +# putting all generated files in the same directory would otherwise causes +# performance problems for the file system. Adapt CREATE_SUBDIRS_LEVEL to +# control the number of sub-directories. +# The default value is: NO. + +CREATE_SUBDIRS = NO + +# Controls the number of sub-directories that will be created when +# CREATE_SUBDIRS tag is set to YES. Level 0 represents 16 directories, and every +# level increment doubles the number of directories, resulting in 4096 +# directories at level 8 which is the default and also the maximum value. The +# sub-directories are organized in 2 levels, the first level always has a fixed +# numer of 16 directories. +# Minimum value: 0, maximum value: 8, default value: 8. +# This tag requires that the tag CREATE_SUBDIRS is set to YES. + +CREATE_SUBDIRS_LEVEL = 8 + +# If the ALLOW_UNICODE_NAMES tag is set to YES, doxygen will allow non-ASCII +# characters to appear in the names of generated files. If set to NO, non-ASCII +# characters will be escaped, for example _xE3_x81_x84 will be used for Unicode +# U+3044. +# The default value is: NO. + +ALLOW_UNICODE_NAMES = NO + +# The OUTPUT_LANGUAGE tag is used to specify the language in which all +# documentation generated by doxygen is written. Doxygen will use this +# information to generate all constant output in the proper language. +# Possible values are: Afrikaans, Arabic, Armenian, Brazilian, Bulgarian, +# Catalan, Chinese, Chinese-Traditional, Croatian, Czech, Danish, Dutch, English +# (United States), Esperanto, Farsi (Persian), Finnish, French, German, Greek, +# Hindi, Hungarian, Indonesian, Italian, Japanese, Japanese-en (Japanese with +# English messages), Korean, Korean-en (Korean with English messages), Latvian, +# Lithuanian, Macedonian, Norwegian, Persian (Farsi), Polish, Portuguese, +# Romanian, Russian, Serbian, Serbian-Cyrillic, Slovak, Slovene, Spanish, +# Swedish, Turkish, Ukrainian and Vietnamese. +# The default value is: English. + +OUTPUT_LANGUAGE = English + +# If the BRIEF_MEMBER_DESC tag is set to YES, doxygen will include brief member +# descriptions after the members that are listed in the file and class +# documentation (similar to Javadoc). Set to NO to disable this. +# The default value is: YES. + +BRIEF_MEMBER_DESC = YES + +# If the REPEAT_BRIEF tag is set to YES, doxygen will prepend the brief +# description of a member or function before the detailed description +# +# Note: If both HIDE_UNDOC_MEMBERS and BRIEF_MEMBER_DESC are set to NO, the +# brief descriptions will be completely suppressed. +# The default value is: YES. + +REPEAT_BRIEF = YES + +# This tag implements a quasi-intelligent brief description abbreviator that is +# used to form the text in various listings. Each string in this list, if found +# as the leading text of the brief description, will be stripped from the text +# and the result, after processing the whole list, is used as the annotated +# text. Otherwise, the brief description is used as-is. If left blank, the +# following values are used ($name is automatically replaced with the name of +# the entity):The $name class, The $name widget, The $name file, is, provides, +# specifies, contains, represents, a, an and the. + +ABBREVIATE_BRIEF = "The $name class" \ + "The $name widget" \ + "The $name file" \ + is \ + provides \ + specifies \ + contains \ + represents \ + a \ + an \ + the + +# If the ALWAYS_DETAILED_SEC and REPEAT_BRIEF tags are both set to YES then +# doxygen will generate a detailed section even if there is only a brief +# description. +# The default value is: NO. + +ALWAYS_DETAILED_SEC = NO + +# If the INLINE_INHERITED_MEMB tag is set to YES, doxygen will show all +# inherited members of a class in the documentation of that class as if those +# members were ordinary class members. Constructors, destructors and assignment +# operators of the base classes will not be shown. +# The default value is: NO. + +INLINE_INHERITED_MEMB = NO + +# If the FULL_PATH_NAMES tag is set to YES, doxygen will prepend the full path +# before files name in the file list and in the header files. If set to NO the +# shortest path that makes the file name unique will be used +# The default value is: YES. + +FULL_PATH_NAMES = YES + +# The STRIP_FROM_PATH tag can be used to strip a user-defined part of the path. +# Stripping is only done if one of the specified strings matches the left-hand +# part of the path. The tag can be used to show relative paths in the file list. +# If left blank the directory from which doxygen is run is used as the path to +# strip. +# +# Note that you can specify absolute paths here, but also relative paths, which +# will be relative from the directory where doxygen is started. +# This tag requires that the tag FULL_PATH_NAMES is set to YES. + +STRIP_FROM_PATH = + +# The STRIP_FROM_INC_PATH tag can be used to strip a user-defined part of the +# path mentioned in the documentation of a class, which tells the reader which +# header file to include in order to use a class. If left blank only the name of +# the header file containing the class definition is used. Otherwise one should +# specify the list of include paths that are normally passed to the compiler +# using the -I flag. + +STRIP_FROM_INC_PATH = + +# If the SHORT_NAMES tag is set to YES, doxygen will generate much shorter (but +# less readable) file names. This can be useful is your file systems doesn't +# support long names like on DOS, Mac, or CD-ROM. +# The default value is: NO. + +SHORT_NAMES = NO + +# If the JAVADOC_AUTOBRIEF tag is set to YES then doxygen will interpret the +# first line (until the first dot) of a Javadoc-style comment as the brief +# description. If set to NO, the Javadoc-style will behave just like regular Qt- +# style comments (thus requiring an explicit @brief command for a brief +# description.) +# The default value is: NO. + +JAVADOC_AUTOBRIEF = NO + +# If the JAVADOC_BANNER tag is set to YES then doxygen will interpret a line +# such as +# /*************** +# as being the beginning of a Javadoc-style comment "banner". If set to NO, the +# Javadoc-style will behave just like regular comments and it will not be +# interpreted by doxygen. +# The default value is: NO. + +JAVADOC_BANNER = NO + +# If the QT_AUTOBRIEF tag is set to YES then doxygen will interpret the first +# line (until the first dot) of a Qt-style comment as the brief description. If +# set to NO, the Qt-style will behave just like regular Qt-style comments (thus +# requiring an explicit \brief command for a brief description.) +# The default value is: NO. + +QT_AUTOBRIEF = NO + +# The MULTILINE_CPP_IS_BRIEF tag can be set to YES to make doxygen treat a +# multi-line C++ special comment block (i.e. a block of //! or /// comments) as +# a brief description. This used to be the default behavior. The new default is +# to treat a multi-line C++ comment block as a detailed description. Set this +# tag to YES if you prefer the old behavior instead. +# +# Note that setting this tag to YES also means that rational rose comments are +# not recognized any more. +# The default value is: NO. + +MULTILINE_CPP_IS_BRIEF = NO + +# By default Python docstrings are displayed as preformatted text and doxygen's +# special commands cannot be used. By setting PYTHON_DOCSTRING to NO the +# doxygen's special commands can be used and the contents of the docstring +# documentation blocks is shown as doxygen documentation. +# The default value is: YES. + +PYTHON_DOCSTRING = YES + +# If the INHERIT_DOCS tag is set to YES then an undocumented member inherits the +# documentation from any documented member that it re-implements. +# The default value is: YES. + +INHERIT_DOCS = YES + +# If the SEPARATE_MEMBER_PAGES tag is set to YES then doxygen will produce a new +# page for each member. If set to NO, the documentation of a member will be part +# of the file/class/namespace that contains it. +# The default value is: NO. + +SEPARATE_MEMBER_PAGES = NO + +# The TAB_SIZE tag can be used to set the number of spaces in a tab. Doxygen +# uses this value to replace tabs by spaces in code fragments. +# Minimum value: 1, maximum value: 16, default value: 4. + +TAB_SIZE = 4 + +# This tag can be used to specify a number of aliases that act as commands in +# the documentation. An alias has the form: +# name=value +# For example adding +# "sideeffect=@par Side Effects:^^" +# will allow you to put the command \sideeffect (or @sideeffect) in the +# documentation, which will result in a user-defined paragraph with heading +# "Side Effects:". Note that you cannot put \n's in the value part of an alias +# to insert newlines (in the resulting output). You can put ^^ in the value part +# of an alias to insert a newline as if a physical newline was in the original +# file. When you need a literal { or } or , in the value part of an alias you +# have to escape them by means of a backslash (\), this can lead to conflicts +# with the commands \{ and \} for these it is advised to use the version @{ and +# @} or use a double escape (\\{ and \\}) + +ALIASES = + +# Set the OPTIMIZE_OUTPUT_FOR_C tag to YES if your project consists of C sources +# only. Doxygen will then generate output that is more tailored for C. For +# instance, some of the names that are used will be different. The list of all +# members will be omitted, etc. +# The default value is: NO. + +OPTIMIZE_OUTPUT_FOR_C = NO + +# Set the OPTIMIZE_OUTPUT_JAVA tag to YES if your project consists of Java or +# Python sources only. Doxygen will then generate output that is more tailored +# for that language. For instance, namespaces will be presented as packages, +# qualified scopes will look different, etc. +# The default value is: NO. + +OPTIMIZE_OUTPUT_JAVA = NO + +# Set the OPTIMIZE_FOR_FORTRAN tag to YES if your project consists of Fortran +# sources. Doxygen will then generate output that is tailored for Fortran. +# The default value is: NO. + +OPTIMIZE_FOR_FORTRAN = NO + +# Set the OPTIMIZE_OUTPUT_VHDL tag to YES if your project consists of VHDL +# sources. Doxygen will then generate output that is tailored for VHDL. +# The default value is: NO. + +OPTIMIZE_OUTPUT_VHDL = NO + +# Set the OPTIMIZE_OUTPUT_SLICE tag to YES if your project consists of Slice +# sources only. Doxygen will then generate output that is more tailored for that +# language. For instance, namespaces will be presented as modules, types will be +# separated into more groups, etc. +# The default value is: NO. + +OPTIMIZE_OUTPUT_SLICE = NO + +# Doxygen selects the parser to use depending on the extension of the files it +# parses. With this tag you can assign which parser to use for a given +# extension. Doxygen has a built-in mapping, but you can override or extend it +# using this tag. The format is ext=language, where ext is a file extension, and +# language is one of the parsers supported by doxygen: IDL, Java, JavaScript, +# Csharp (C#), C, C++, Lex, D, PHP, md (Markdown), Objective-C, Python, Slice, +# VHDL, Fortran (fixed format Fortran: FortranFixed, free formatted Fortran: +# FortranFree, unknown formatted Fortran: Fortran. In the later case the parser +# tries to guess whether the code is fixed or free formatted code, this is the +# default for Fortran type files). For instance to make doxygen treat .inc files +# as Fortran files (default is PHP), and .f files as C (default is Fortran), +# use: inc=Fortran f=C. +# +# Note: For files without extension you can use no_extension as a placeholder. +# +# Note that for custom extensions you also need to set FILE_PATTERNS otherwise +# the files are not read by doxygen. When specifying no_extension you should add +# * to the FILE_PATTERNS. +# +# Note see also the list of default file extension mappings. + +EXTENSION_MAPPING = + +# If the MARKDOWN_SUPPORT tag is enabled then doxygen pre-processes all comments +# according to the Markdown format, which allows for more readable +# documentation. See https://daringfireball.net/projects/markdown/ for details. +# The output of markdown processing is further processed by doxygen, so you can +# mix doxygen, HTML, and XML commands with Markdown formatting. Disable only in +# case of backward compatibilities issues. +# The default value is: YES. + +MARKDOWN_SUPPORT = YES + +# When the TOC_INCLUDE_HEADINGS tag is set to a non-zero value, all headings up +# to that level are automatically included in the table of contents, even if +# they do not have an id attribute. +# Note: This feature currently applies only to Markdown headings. +# Minimum value: 0, maximum value: 99, default value: 5. +# This tag requires that the tag MARKDOWN_SUPPORT is set to YES. + +TOC_INCLUDE_HEADINGS = 5 + +# When enabled doxygen tries to link words that correspond to documented +# classes, or namespaces to their corresponding documentation. Such a link can +# be prevented in individual cases by putting a % sign in front of the word or +# globally by setting AUTOLINK_SUPPORT to NO. +# The default value is: YES. + +AUTOLINK_SUPPORT = YES + +# If you use STL classes (i.e. std::string, std::vector, etc.) but do not want +# to include (a tag file for) the STL sources as input, then you should set this +# tag to YES in order to let doxygen match functions declarations and +# definitions whose arguments contain STL classes (e.g. func(std::string); +# versus func(std::string) {}). This also make the inheritance and collaboration +# diagrams that involve STL classes more complete and accurate. +# The default value is: NO. + +BUILTIN_STL_SUPPORT = NO + +# If you use Microsoft's C++/CLI language, you should set this option to YES to +# enable parsing support. +# The default value is: NO. + +CPP_CLI_SUPPORT = NO + +# Set the SIP_SUPPORT tag to YES if your project consists of sip (see: +# https://www.riverbankcomputing.com/software/sip/intro) sources only. Doxygen +# will parse them like normal C++ but will assume all classes use public instead +# of private inheritance when no explicit protection keyword is present. +# The default value is: NO. + +SIP_SUPPORT = NO + +# For Microsoft's IDL there are propget and propput attributes to indicate +# getter and setter methods for a property. Setting this option to YES will make +# doxygen to replace the get and set methods by a property in the documentation. +# This will only work if the methods are indeed getting or setting a simple +# type. If this is not the case, or you want to show the methods anyway, you +# should set this option to NO. +# The default value is: YES. + +IDL_PROPERTY_SUPPORT = YES + +# If member grouping is used in the documentation and the DISTRIBUTE_GROUP_DOC +# tag is set to YES then doxygen will reuse the documentation of the first +# member in the group (if any) for the other members of the group. By default +# all members of a group must be documented explicitly. +# The default value is: NO. + +DISTRIBUTE_GROUP_DOC = NO + +# If one adds a struct or class to a group and this option is enabled, then also +# any nested class or struct is added to the same group. By default this option +# is disabled and one has to add nested compounds explicitly via \ingroup. +# The default value is: NO. + +GROUP_NESTED_COMPOUNDS = NO + +# Set the SUBGROUPING tag to YES to allow class member groups of the same type +# (for instance a group of public functions) to be put as a subgroup of that +# type (e.g. under the Public Functions section). Set it to NO to prevent +# subgrouping. Alternatively, this can be done per class using the +# \nosubgrouping command. +# The default value is: YES. + +SUBGROUPING = YES + +# When the INLINE_GROUPED_CLASSES tag is set to YES, classes, structs and unions +# are shown inside the group in which they are included (e.g. using \ingroup) +# instead of on a separate page (for HTML and Man pages) or section (for LaTeX +# and RTF). +# +# Note that this feature does not work in combination with +# SEPARATE_MEMBER_PAGES. +# The default value is: NO. + +INLINE_GROUPED_CLASSES = NO + +# When the INLINE_SIMPLE_STRUCTS tag is set to YES, structs, classes, and unions +# with only public data fields or simple typedef fields will be shown inline in +# the documentation of the scope in which they are defined (i.e. file, +# namespace, or group documentation), provided this scope is documented. If set +# to NO, structs, classes, and unions are shown on a separate page (for HTML and +# Man pages) or section (for LaTeX and RTF). +# The default value is: NO. + +INLINE_SIMPLE_STRUCTS = NO + +# When TYPEDEF_HIDES_STRUCT tag is enabled, a typedef of a struct, union, or +# enum is documented as struct, union, or enum with the name of the typedef. So +# typedef struct TypeS {} TypeT, will appear in the documentation as a struct +# with name TypeT. When disabled the typedef will appear as a member of a file, +# namespace, or class. And the struct will be named TypeS. This can typically be +# useful for C code in case the coding convention dictates that all compound +# types are typedef'ed and only the typedef is referenced, never the tag name. +# The default value is: NO. + +TYPEDEF_HIDES_STRUCT = NO + +# The size of the symbol lookup cache can be set using LOOKUP_CACHE_SIZE. This +# cache is used to resolve symbols given their name and scope. Since this can be +# an expensive process and often the same symbol appears multiple times in the +# code, doxygen keeps a cache of pre-resolved symbols. If the cache is too small +# doxygen will become slower. If the cache is too large, memory is wasted. The +# cache size is given by this formula: 2^(16+LOOKUP_CACHE_SIZE). The valid range +# is 0..9, the default is 0, corresponding to a cache size of 2^16=65536 +# symbols. At the end of a run doxygen will report the cache usage and suggest +# the optimal cache size from a speed point of view. +# Minimum value: 0, maximum value: 9, default value: 0. + +LOOKUP_CACHE_SIZE = 0 + +# The NUM_PROC_THREADS specifies the number of threads doxygen is allowed to use +# during processing. When set to 0 doxygen will based this on the number of +# cores available in the system. You can set it explicitly to a value larger +# than 0 to get more control over the balance between CPU load and processing +# speed. At this moment only the input processing can be done using multiple +# threads. Since this is still an experimental feature the default is set to 1, +# which effectively disables parallel processing. Please report any issues you +# encounter. Generating dot graphs in parallel is controlled by the +# DOT_NUM_THREADS setting. +# Minimum value: 0, maximum value: 32, default value: 1. + +NUM_PROC_THREADS = 1 + +#--------------------------------------------------------------------------- +# Build related configuration options +#--------------------------------------------------------------------------- + +# If the EXTRACT_ALL tag is set to YES, doxygen will assume all entities in +# documentation are documented, even if no documentation was available. Private +# class members and static file members will be hidden unless the +# EXTRACT_PRIVATE respectively EXTRACT_STATIC tags are set to YES. +# Note: This will also disable the warnings about undocumented members that are +# normally produced when WARNINGS is set to YES. +# The default value is: NO. + +EXTRACT_ALL = NO + +# If the EXTRACT_PRIVATE tag is set to YES, all private members of a class will +# be included in the documentation. +# The default value is: NO. + +EXTRACT_PRIVATE = YES + +# If the EXTRACT_PRIV_VIRTUAL tag is set to YES, documented private virtual +# methods of a class will be included in the documentation. +# The default value is: NO. + +EXTRACT_PRIV_VIRTUAL = NO + +# If the EXTRACT_PACKAGE tag is set to YES, all members with package or internal +# scope will be included in the documentation. +# The default value is: NO. + +EXTRACT_PACKAGE = NO + +# If the EXTRACT_STATIC tag is set to YES, all static members of a file will be +# included in the documentation. +# The default value is: NO. + +EXTRACT_STATIC = YES + +# If the EXTRACT_LOCAL_CLASSES tag is set to YES, classes (and structs) defined +# locally in source files will be included in the documentation. If set to NO, +# only classes defined in header files are included. Does not have any effect +# for Java sources. +# The default value is: YES. + +EXTRACT_LOCAL_CLASSES = YES + +# This flag is only useful for Objective-C code. If set to YES, local methods, +# which are defined in the implementation section but not in the interface are +# included in the documentation. If set to NO, only methods in the interface are +# included. +# The default value is: NO. + +EXTRACT_LOCAL_METHODS = NO + +# If this flag is set to YES, the members of anonymous namespaces will be +# extracted and appear in the documentation as a namespace called +# 'anonymous_namespace{file}', where file will be replaced with the base name of +# the file that contains the anonymous namespace. By default anonymous namespace +# are hidden. +# The default value is: NO. + +EXTRACT_ANON_NSPACES = NO + +# If this flag is set to YES, the name of an unnamed parameter in a declaration +# will be determined by the corresponding definition. By default unnamed +# parameters remain unnamed in the output. +# The default value is: YES. + +RESOLVE_UNNAMED_PARAMS = YES + +# If the HIDE_UNDOC_MEMBERS tag is set to YES, doxygen will hide all +# undocumented members inside documented classes or files. If set to NO these +# members will be included in the various overviews, but no documentation +# section is generated. This option has no effect if EXTRACT_ALL is enabled. +# The default value is: NO. + +HIDE_UNDOC_MEMBERS = NO + +# If the HIDE_UNDOC_CLASSES tag is set to YES, doxygen will hide all +# undocumented classes that are normally visible in the class hierarchy. If set +# to NO, these classes will be included in the various overviews. This option +# has no effect if EXTRACT_ALL is enabled. +# The default value is: NO. + +HIDE_UNDOC_CLASSES = NO + +# If the HIDE_FRIEND_COMPOUNDS tag is set to YES, doxygen will hide all friend +# declarations. If set to NO, these declarations will be included in the +# documentation. +# The default value is: NO. + +HIDE_FRIEND_COMPOUNDS = NO + +# If the HIDE_IN_BODY_DOCS tag is set to YES, doxygen will hide any +# documentation blocks found inside the body of a function. If set to NO, these +# blocks will be appended to the function's detailed documentation block. +# The default value is: NO. + +HIDE_IN_BODY_DOCS = NO + +# The INTERNAL_DOCS tag determines if documentation that is typed after a +# \internal command is included. If the tag is set to NO then the documentation +# will be excluded. Set it to YES to include the internal documentation. +# The default value is: NO. + +INTERNAL_DOCS = NO + +# With the correct setting of option CASE_SENSE_NAMES doxygen will better be +# able to match the capabilities of the underlying filesystem. In case the +# filesystem is case sensitive (i.e. it supports files in the same directory +# whose names only differ in casing), the option must be set to YES to properly +# deal with such files in case they appear in the input. For filesystems that +# are not case sensitive the option should be set to NO to properly deal with +# output files written for symbols that only differ in casing, such as for two +# classes, one named CLASS and the other named Class, and to also support +# references to files without having to specify the exact matching casing. On +# Windows (including Cygwin) and MacOS, users should typically set this option +# to NO, whereas on Linux or other Unix flavors it should typically be set to +# YES. +# The default value is: system dependent. + +CASE_SENSE_NAMES = NO + +# If the HIDE_SCOPE_NAMES tag is set to NO then doxygen will show members with +# their full class and namespace scopes in the documentation. If set to YES, the +# scope will be hidden. +# The default value is: NO. + +HIDE_SCOPE_NAMES = NO + +# If the HIDE_COMPOUND_REFERENCE tag is set to NO (default) then doxygen will +# append additional text to a page's title, such as Class Reference. If set to +# YES the compound reference will be hidden. +# The default value is: NO. + +HIDE_COMPOUND_REFERENCE= NO + +# If the SHOW_HEADERFILE tag is set to YES then the documentation for a class +# will show which file needs to be included to use the class. +# The default value is: YES. + +SHOW_HEADERFILE = YES + +# If the SHOW_INCLUDE_FILES tag is set to YES then doxygen will put a list of +# the files that are included by a file in the documentation of that file. +# The default value is: YES. + +SHOW_INCLUDE_FILES = YES + +# If the SHOW_GROUPED_MEMB_INC tag is set to YES then Doxygen will add for each +# grouped member an include statement to the documentation, telling the reader +# which file to include in order to use the member. +# The default value is: NO. + +SHOW_GROUPED_MEMB_INC = NO + +# If the FORCE_LOCAL_INCLUDES tag is set to YES then doxygen will list include +# files with double quotes in the documentation rather than with sharp brackets. +# The default value is: NO. + +FORCE_LOCAL_INCLUDES = NO + +# If the INLINE_INFO tag is set to YES then a tag [inline] is inserted in the +# documentation for inline members. +# The default value is: YES. + +INLINE_INFO = YES + +# If the SORT_MEMBER_DOCS tag is set to YES then doxygen will sort the +# (detailed) documentation of file and class members alphabetically by member +# name. If set to NO, the members will appear in declaration order. +# The default value is: YES. + +SORT_MEMBER_DOCS = YES + +# If the SORT_BRIEF_DOCS tag is set to YES then doxygen will sort the brief +# descriptions of file, namespace and class members alphabetically by member +# name. If set to NO, the members will appear in declaration order. Note that +# this will also influence the order of the classes in the class list. +# The default value is: NO. + +SORT_BRIEF_DOCS = NO + +# If the SORT_MEMBERS_CTORS_1ST tag is set to YES then doxygen will sort the +# (brief and detailed) documentation of class members so that constructors and +# destructors are listed first. If set to NO the constructors will appear in the +# respective orders defined by SORT_BRIEF_DOCS and SORT_MEMBER_DOCS. +# Note: If SORT_BRIEF_DOCS is set to NO this option is ignored for sorting brief +# member documentation. +# Note: If SORT_MEMBER_DOCS is set to NO this option is ignored for sorting +# detailed member documentation. +# The default value is: NO. + +SORT_MEMBERS_CTORS_1ST = NO + +# If the SORT_GROUP_NAMES tag is set to YES then doxygen will sort the hierarchy +# of group names into alphabetical order. If set to NO the group names will +# appear in their defined order. +# The default value is: NO. + +SORT_GROUP_NAMES = NO + +# If the SORT_BY_SCOPE_NAME tag is set to YES, the class list will be sorted by +# fully-qualified names, including namespaces. If set to NO, the class list will +# be sorted only by class name, not including the namespace part. +# Note: This option is not very useful if HIDE_SCOPE_NAMES is set to YES. +# Note: This option applies only to the class list, not to the alphabetical +# list. +# The default value is: NO. + +SORT_BY_SCOPE_NAME = NO + +# If the STRICT_PROTO_MATCHING option is enabled and doxygen fails to do proper +# type resolution of all parameters of a function it will reject a match between +# the prototype and the implementation of a member function even if there is +# only one candidate or it is obvious which candidate to choose by doing a +# simple string match. By disabling STRICT_PROTO_MATCHING doxygen will still +# accept a match between prototype and implementation in such cases. +# The default value is: NO. + +STRICT_PROTO_MATCHING = NO + +# The GENERATE_TODOLIST tag can be used to enable (YES) or disable (NO) the todo +# list. This list is created by putting \todo commands in the documentation. +# The default value is: YES. + +GENERATE_TODOLIST = YES + +# The GENERATE_TESTLIST tag can be used to enable (YES) or disable (NO) the test +# list. This list is created by putting \test commands in the documentation. +# The default value is: YES. + +GENERATE_TESTLIST = YES + +# The GENERATE_BUGLIST tag can be used to enable (YES) or disable (NO) the bug +# list. This list is created by putting \bug commands in the documentation. +# The default value is: YES. + +GENERATE_BUGLIST = YES + +# The GENERATE_DEPRECATEDLIST tag can be used to enable (YES) or disable (NO) +# the deprecated list. This list is created by putting \deprecated commands in +# the documentation. +# The default value is: YES. + +GENERATE_DEPRECATEDLIST= YES + +# The ENABLED_SECTIONS tag can be used to enable conditional documentation +# sections, marked by \if ... \endif and \cond +# ... \endcond blocks. + +ENABLED_SECTIONS = + +# The MAX_INITIALIZER_LINES tag determines the maximum number of lines that the +# initial value of a variable or macro / define can have for it to appear in the +# documentation. If the initializer consists of more lines than specified here +# it will be hidden. Use a value of 0 to hide initializers completely. The +# appearance of the value of individual variables and macros / defines can be +# controlled using \showinitializer or \hideinitializer command in the +# documentation regardless of this setting. +# Minimum value: 0, maximum value: 10000, default value: 30. + +MAX_INITIALIZER_LINES = 30 + +# Set the SHOW_USED_FILES tag to NO to disable the list of files generated at +# the bottom of the documentation of classes and structs. If set to YES, the +# list will mention the files that were used to generate the documentation. +# The default value is: YES. + +SHOW_USED_FILES = YES + +# Set the SHOW_FILES tag to NO to disable the generation of the Files page. This +# will remove the Files entry from the Quick Index and from the Folder Tree View +# (if specified). +# The default value is: YES. + +SHOW_FILES = YES + +# Set the SHOW_NAMESPACES tag to NO to disable the generation of the Namespaces +# page. This will remove the Namespaces entry from the Quick Index and from the +# Folder Tree View (if specified). +# The default value is: YES. + +SHOW_NAMESPACES = YES + +# The FILE_VERSION_FILTER tag can be used to specify a program or script that +# doxygen should invoke to get the current version for each file (typically from +# the version control system). Doxygen will invoke the program by executing (via +# popen()) the command command input-file, where command is the value of the +# FILE_VERSION_FILTER tag, and input-file is the name of an input file provided +# by doxygen. Whatever the program writes to standard output is used as the file +# version. For an example see the documentation. + +FILE_VERSION_FILTER = + +# The LAYOUT_FILE tag can be used to specify a layout file which will be parsed +# by doxygen. The layout file controls the global structure of the generated +# output files in an output format independent way. To create the layout file +# that represents doxygen's defaults, run doxygen with the -l option. You can +# optionally specify a file name after the option, if omitted DoxygenLayout.xml +# will be used as the name of the layout file. See also section "Changing the +# layout of pages" for information. +# +# Note that if you run doxygen from a directory containing a file called +# DoxygenLayout.xml, doxygen will parse it automatically even if the LAYOUT_FILE +# tag is left empty. + +LAYOUT_FILE = + +# The CITE_BIB_FILES tag can be used to specify one or more bib files containing +# the reference definitions. This must be a list of .bib files. The .bib +# extension is automatically appended if omitted. This requires the bibtex tool +# to be installed. See also https://en.wikipedia.org/wiki/BibTeX for more info. +# For LaTeX the style of the bibliography can be controlled using +# LATEX_BIB_STYLE. To use this feature you need bibtex and perl available in the +# search path. See also \cite for info how to create references. + +CITE_BIB_FILES = + +#--------------------------------------------------------------------------- +# Configuration options related to warning and progress messages +#--------------------------------------------------------------------------- + +# The QUIET tag can be used to turn on/off the messages that are generated to +# standard output by doxygen. If QUIET is set to YES this implies that the +# messages are off. +# The default value is: NO. + +QUIET = NO + +# The WARNINGS tag can be used to turn on/off the warning messages that are +# generated to standard error (stderr) by doxygen. If WARNINGS is set to YES +# this implies that the warnings are on. +# +# Tip: Turn warnings on while writing the documentation. +# The default value is: YES. + +WARNINGS = YES + +# If the WARN_IF_UNDOCUMENTED tag is set to YES then doxygen will generate +# warnings for undocumented members. If EXTRACT_ALL is set to YES then this flag +# will automatically be disabled. +# The default value is: YES. + +WARN_IF_UNDOCUMENTED = YES + +# If the WARN_IF_DOC_ERROR tag is set to YES, doxygen will generate warnings for +# potential errors in the documentation, such as documenting some parameters in +# a documented function twice, or documenting parameters that don't exist or +# using markup commands wrongly. +# The default value is: YES. + +WARN_IF_DOC_ERROR = YES + +# If WARN_IF_INCOMPLETE_DOC is set to YES, doxygen will warn about incomplete +# function parameter documentation. If set to NO, doxygen will accept that some +# parameters have no documentation without warning. +# The default value is: YES. + +WARN_IF_INCOMPLETE_DOC = YES + +# This WARN_NO_PARAMDOC option can be enabled to get warnings for functions that +# are documented, but have no documentation for their parameters or return +# value. If set to NO, doxygen will only warn about wrong parameter +# documentation, but not about the absence of documentation. If EXTRACT_ALL is +# set to YES then this flag will automatically be disabled. See also +# WARN_IF_INCOMPLETE_DOC +# The default value is: NO. + +WARN_NO_PARAMDOC = NO + +# If the WARN_AS_ERROR tag is set to YES then doxygen will immediately stop when +# a warning is encountered. If the WARN_AS_ERROR tag is set to FAIL_ON_WARNINGS +# then doxygen will continue running as if WARN_AS_ERROR tag is set to NO, but +# at the end of the doxygen process doxygen will return with a non-zero status. +# Possible values are: NO, YES and FAIL_ON_WARNINGS. +# The default value is: NO. + +WARN_AS_ERROR = NO + +# The WARN_FORMAT tag determines the format of the warning messages that doxygen +# can produce. The string should contain the $file, $line, and $text tags, which +# will be replaced by the file and line number from which the warning originated +# and the warning text. Optionally the format may contain $version, which will +# be replaced by the version of the file (if it could be obtained via +# FILE_VERSION_FILTER) +# See also: WARN_LINE_FORMAT +# The default value is: $file:$line: $text. + +WARN_FORMAT = "$file:$line: $text" + +# In the $text part of the WARN_FORMAT command it is possible that a reference +# to a more specific place is given. To make it easier to jump to this place +# (outside of doxygen) the user can define a custom "cut" / "paste" string. +# Example: +# WARN_LINE_FORMAT = "'vi $file +$line'" +# See also: WARN_FORMAT +# The default value is: at line $line of file $file. + +WARN_LINE_FORMAT = "at line $line of file $file" + +# The WARN_LOGFILE tag can be used to specify a file to which warning and error +# messages should be written. If left blank the output is written to standard +# error (stderr). In case the file specified cannot be opened for writing the +# warning and error messages are written to standard error. When as file - is +# specified the warning and error messages are written to standard output +# (stdout). + +WARN_LOGFILE = + +#--------------------------------------------------------------------------- +# Configuration options related to the input files +#--------------------------------------------------------------------------- + +# The INPUT tag is used to specify the files and/or directories that contain +# documented source files. You may enter file names like myfile.cpp or +# directories like /usr/src/myproject. Separate the files or directories with +# spaces. See also FILE_PATTERNS and EXTENSION_MAPPING +# Note: If this tag is empty the current directory is searched. + +INPUT = src/ + +# This tag can be used to specify the character encoding of the source files +# that doxygen parses. Internally doxygen uses the UTF-8 encoding. Doxygen uses +# libiconv (or the iconv built into libc) for the transcoding. See the libiconv +# documentation (see: +# https://www.gnu.org/software/libiconv/) for the list of possible encodings. +# The default value is: UTF-8. + +INPUT_ENCODING = UTF-8 + +# If the value of the INPUT tag contains directories, you can use the +# FILE_PATTERNS tag to specify one or more wildcard patterns (like *.cpp and +# *.h) to filter out the source-files in the directories. +# +# Note that for custom extensions or not directly supported extensions you also +# need to set EXTENSION_MAPPING for the extension otherwise the files are not +# read by doxygen. +# +# Note the list of default checked file patterns might differ from the list of +# default file extension mappings. +# +# If left blank the following patterns are tested:*.c, *.cc, *.cxx, *.cpp, +# *.c++, *.java, *.ii, *.ixx, *.ipp, *.i++, *.inl, *.idl, *.ddl, *.odl, *.h, +# *.hh, *.hxx, *.hpp, *.h++, *.l, *.cs, *.d, *.php, *.php4, *.php5, *.phtml, +# *.inc, *.m, *.markdown, *.md, *.mm, *.dox (to be provided as doxygen C +# comment), *.py, *.pyw, *.f90, *.f95, *.f03, *.f08, *.f18, *.f, *.for, *.vhd, +# *.vhdl, *.ucf, *.qsf and *.ice. + +FILE_PATTERNS = *.c \ + *.cc \ + *.cxx \ + *.cpp \ + *.c++ \ + *.java \ + *.ii \ + *.ixx \ + *.ipp \ + *.i++ \ + *.inl \ + *.idl \ + *.ddl \ + *.odl \ + *.h \ + *.hh \ + *.hxx \ + *.hpp \ + *.h++ \ + *.l \ + *.cs \ + *.d \ + *.php \ + *.php4 \ + *.php5 \ + *.phtml \ + *.inc \ + *.m \ + *.markdown \ + *.md \ + *.mm \ + *.dox \ + *.py \ + *.pyw \ + *.f90 \ + *.f95 \ + *.f03 \ + *.f08 \ + *.f18 \ + *.f \ + *.for \ + *.vhd \ + *.vhdl \ + *.ucf \ + *.qsf \ + *.ice + +# The RECURSIVE tag can be used to specify whether or not subdirectories should +# be searched for input files as well. +# The default value is: NO. + +RECURSIVE = YES + +# The EXCLUDE tag can be used to specify files and/or directories that should be +# excluded from the INPUT source files. This way you can easily exclude a +# subdirectory from a directory tree whose root is specified with the INPUT tag. +# +# Note that relative paths are relative to the directory from which doxygen is +# run. + +EXCLUDE = + +# The EXCLUDE_SYMLINKS tag can be used to select whether or not files or +# directories that are symbolic links (a Unix file system feature) are excluded +# from the input. +# The default value is: NO. + +EXCLUDE_SYMLINKS = NO + +# If the value of the INPUT tag contains directories, you can use the +# EXCLUDE_PATTERNS tag to specify one or more wildcard patterns to exclude +# certain files from those directories. +# +# Note that the wildcards are matched against the file with absolute path, so to +# exclude all test directories for example use the pattern */test/* + +EXCLUDE_PATTERNS = + +# The EXCLUDE_SYMBOLS tag can be used to specify one or more symbol names +# (namespaces, classes, functions, etc.) that should be excluded from the +# output. The symbol name can be a fully qualified name, a word, or if the +# wildcard * is used, a substring. Examples: ANamespace, AClass, +# ANamespace::AClass, ANamespace::*Test +# +# Note that the wildcards are matched against the file with absolute path, so to +# exclude all test directories use the pattern */test/* + +EXCLUDE_SYMBOLS = + +# The EXAMPLE_PATH tag can be used to specify one or more files or directories +# that contain example code fragments that are included (see the \include +# command). + +EXAMPLE_PATH = + +# If the value of the EXAMPLE_PATH tag contains directories, you can use the +# EXAMPLE_PATTERNS tag to specify one or more wildcard pattern (like *.cpp and +# *.h) to filter out the source-files in the directories. If left blank all +# files are included. + +EXAMPLE_PATTERNS = * + +# If the EXAMPLE_RECURSIVE tag is set to YES then subdirectories will be +# searched for input files to be used with the \include or \dontinclude commands +# irrespective of the value of the RECURSIVE tag. +# The default value is: NO. + +EXAMPLE_RECURSIVE = NO + +# The IMAGE_PATH tag can be used to specify one or more files or directories +# that contain images that are to be included in the documentation (see the +# \image command). + +IMAGE_PATH = + +# The INPUT_FILTER tag can be used to specify a program that doxygen should +# invoke to filter for each input file. Doxygen will invoke the filter program +# by executing (via popen()) the command: +# +# +# +# where is the value of the INPUT_FILTER tag, and is the +# name of an input file. Doxygen will then use the output that the filter +# program writes to standard output. If FILTER_PATTERNS is specified, this tag +# will be ignored. +# +# Note that the filter must not add or remove lines; it is applied before the +# code is scanned, but not when the output code is generated. If lines are added +# or removed, the anchors will not be placed correctly. +# +# Note that for custom extensions or not directly supported extensions you also +# need to set EXTENSION_MAPPING for the extension otherwise the files are not +# properly processed by doxygen. + +INPUT_FILTER = + +# The FILTER_PATTERNS tag can be used to specify filters on a per file pattern +# basis. Doxygen will compare the file name with each pattern and apply the +# filter if there is a match. The filters are a list of the form: pattern=filter +# (like *.cpp=my_cpp_filter). See INPUT_FILTER for further information on how +# filters are used. If the FILTER_PATTERNS tag is empty or if none of the +# patterns match the file name, INPUT_FILTER is applied. +# +# Note that for custom extensions or not directly supported extensions you also +# need to set EXTENSION_MAPPING for the extension otherwise the files are not +# properly processed by doxygen. + +FILTER_PATTERNS = + +# If the FILTER_SOURCE_FILES tag is set to YES, the input filter (if set using +# INPUT_FILTER) will also be used to filter the input files that are used for +# producing the source files to browse (i.e. when SOURCE_BROWSER is set to YES). +# The default value is: NO. + +FILTER_SOURCE_FILES = NO + +# The FILTER_SOURCE_PATTERNS tag can be used to specify source filters per file +# pattern. A pattern will override the setting for FILTER_PATTERN (if any) and +# it is also possible to disable source filtering for a specific pattern using +# *.ext= (so without naming a filter). +# This tag requires that the tag FILTER_SOURCE_FILES is set to YES. + +FILTER_SOURCE_PATTERNS = + +# If the USE_MDFILE_AS_MAINPAGE tag refers to the name of a markdown file that +# is part of the input, its contents will be placed on the main page +# (index.html). This can be useful if you have a project on for instance GitHub +# and want to reuse the introduction page also for the doxygen output. + +USE_MDFILE_AS_MAINPAGE = + +#--------------------------------------------------------------------------- +# Configuration options related to source browsing +#--------------------------------------------------------------------------- + +# If the SOURCE_BROWSER tag is set to YES then a list of source files will be +# generated. Documented entities will be cross-referenced with these sources. +# +# Note: To get rid of all source code in the generated output, make sure that +# also VERBATIM_HEADERS is set to NO. +# The default value is: NO. + +SOURCE_BROWSER = NO + +# Setting the INLINE_SOURCES tag to YES will include the body of functions, +# classes and enums directly into the documentation. +# The default value is: NO. + +INLINE_SOURCES = NO + +# Setting the STRIP_CODE_COMMENTS tag to YES will instruct doxygen to hide any +# special comment blocks from generated source code fragments. Normal C, C++ and +# Fortran comments will always remain visible. +# The default value is: YES. + +STRIP_CODE_COMMENTS = YES + +# If the REFERENCED_BY_RELATION tag is set to YES then for each documented +# entity all documented functions referencing it will be listed. +# The default value is: NO. + +REFERENCED_BY_RELATION = NO + +# If the REFERENCES_RELATION tag is set to YES then for each documented function +# all documented entities called/used by that function will be listed. +# The default value is: NO. + +REFERENCES_RELATION = NO + +# If the REFERENCES_LINK_SOURCE tag is set to YES and SOURCE_BROWSER tag is set +# to YES then the hyperlinks from functions in REFERENCES_RELATION and +# REFERENCED_BY_RELATION lists will link to the source code. Otherwise they will +# link to the documentation. +# The default value is: YES. + +REFERENCES_LINK_SOURCE = YES + +# If SOURCE_TOOLTIPS is enabled (the default) then hovering a hyperlink in the +# source code will show a tooltip with additional information such as prototype, +# brief description and links to the definition and documentation. Since this +# will make the HTML file larger and loading of large files a bit slower, you +# can opt to disable this feature. +# The default value is: YES. +# This tag requires that the tag SOURCE_BROWSER is set to YES. + +SOURCE_TOOLTIPS = YES + +# If the USE_HTAGS tag is set to YES then the references to source code will +# point to the HTML generated by the htags(1) tool instead of doxygen built-in +# source browser. The htags tool is part of GNU's global source tagging system +# (see https://www.gnu.org/software/global/global.html). You will need version +# 4.8.6 or higher. +# +# To use it do the following: +# - Install the latest version of global +# - Enable SOURCE_BROWSER and USE_HTAGS in the configuration file +# - Make sure the INPUT points to the root of the source tree +# - Run doxygen as normal +# +# Doxygen will invoke htags (and that will in turn invoke gtags), so these +# tools must be available from the command line (i.e. in the search path). +# +# The result: instead of the source browser generated by doxygen, the links to +# source code will now point to the output of htags. +# The default value is: NO. +# This tag requires that the tag SOURCE_BROWSER is set to YES. + +USE_HTAGS = NO + +# If the VERBATIM_HEADERS tag is set the YES then doxygen will generate a +# verbatim copy of the header file for each class for which an include is +# specified. Set to NO to disable this. +# See also: Section \class. +# The default value is: YES. + +VERBATIM_HEADERS = YES + +# If the CLANG_ASSISTED_PARSING tag is set to YES then doxygen will use the +# clang parser (see: +# http://clang.llvm.org/) for more accurate parsing at the cost of reduced +# performance. This can be particularly helpful with template rich C++ code for +# which doxygen's built-in parser lacks the necessary type information. +# Note: The availability of this option depends on whether or not doxygen was +# generated with the -Duse_libclang=ON option for CMake. +# The default value is: NO. + +CLANG_ASSISTED_PARSING = NO + +# If the CLANG_ASSISTED_PARSING tag is set to YES and the CLANG_ADD_INC_PATHS +# tag is set to YES then doxygen will add the directory of each input to the +# include path. +# The default value is: YES. +# This tag requires that the tag CLANG_ASSISTED_PARSING is set to YES. + +CLANG_ADD_INC_PATHS = YES + +# If clang assisted parsing is enabled you can provide the compiler with command +# line options that you would normally use when invoking the compiler. Note that +# the include paths will already be set by doxygen for the files and directories +# specified with INPUT and INCLUDE_PATH. +# This tag requires that the tag CLANG_ASSISTED_PARSING is set to YES. + +CLANG_OPTIONS = + +# If clang assisted parsing is enabled you can provide the clang parser with the +# path to the directory containing a file called compile_commands.json. This +# file is the compilation database (see: +# http://clang.llvm.org/docs/HowToSetupToolingForLLVM.html) containing the +# options used when the source files were built. This is equivalent to +# specifying the -p option to a clang tool, such as clang-check. These options +# will then be passed to the parser. Any options specified with CLANG_OPTIONS +# will be added as well. +# Note: The availability of this option depends on whether or not doxygen was +# generated with the -Duse_libclang=ON option for CMake. + +CLANG_DATABASE_PATH = + +#--------------------------------------------------------------------------- +# Configuration options related to the alphabetical class index +#--------------------------------------------------------------------------- + +# If the ALPHABETICAL_INDEX tag is set to YES, an alphabetical index of all +# compounds will be generated. Enable this if the project contains a lot of +# classes, structs, unions or interfaces. +# The default value is: YES. + +ALPHABETICAL_INDEX = YES + +# In case all classes in a project start with a common prefix, all classes will +# be put under the same header in the alphabetical index. The IGNORE_PREFIX tag +# can be used to specify a prefix (or a list of prefixes) that should be ignored +# while generating the index headers. +# This tag requires that the tag ALPHABETICAL_INDEX is set to YES. + +IGNORE_PREFIX = + +#--------------------------------------------------------------------------- +# Configuration options related to the HTML output +#--------------------------------------------------------------------------- + +# If the GENERATE_HTML tag is set to YES, doxygen will generate HTML output +# The default value is: YES. + +GENERATE_HTML = YES + +# The HTML_OUTPUT tag is used to specify where the HTML docs will be put. If a +# relative path is entered the value of OUTPUT_DIRECTORY will be put in front of +# it. +# The default directory is: html. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_OUTPUT = html + +# The HTML_FILE_EXTENSION tag can be used to specify the file extension for each +# generated HTML page (for example: .htm, .php, .asp). +# The default value is: .html. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_FILE_EXTENSION = .html + +# The HTML_HEADER tag can be used to specify a user-defined HTML header file for +# each generated HTML page. If the tag is left blank doxygen will generate a +# standard header. +# +# To get valid HTML the header file that includes any scripts and style sheets +# that doxygen needs, which is dependent on the configuration options used (e.g. +# the setting GENERATE_TREEVIEW). It is highly recommended to start with a +# default header using +# doxygen -w html new_header.html new_footer.html new_stylesheet.css +# YourConfigFile +# and then modify the file new_header.html. See also section "Doxygen usage" +# for information on how to generate the default header that doxygen normally +# uses. +# Note: The header is subject to change so you typically have to regenerate the +# default header when upgrading to a newer version of doxygen. For a description +# of the possible markers and block names see the documentation. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_HEADER = + +# The HTML_FOOTER tag can be used to specify a user-defined HTML footer for each +# generated HTML page. If the tag is left blank doxygen will generate a standard +# footer. See HTML_HEADER for more information on how to generate a default +# footer and what special commands can be used inside the footer. See also +# section "Doxygen usage" for information on how to generate the default footer +# that doxygen normally uses. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_FOOTER = + +# The HTML_STYLESHEET tag can be used to specify a user-defined cascading style +# sheet that is used by each HTML page. It can be used to fine-tune the look of +# the HTML output. If left blank doxygen will generate a default style sheet. +# See also section "Doxygen usage" for information on how to generate the style +# sheet that doxygen normally uses. +# Note: It is recommended to use HTML_EXTRA_STYLESHEET instead of this tag, as +# it is more robust and this tag (HTML_STYLESHEET) will in the future become +# obsolete. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_STYLESHEET = + +# The HTML_EXTRA_STYLESHEET tag can be used to specify additional user-defined +# cascading style sheets that are included after the standard style sheets +# created by doxygen. Using this option one can overrule certain style aspects. +# This is preferred over using HTML_STYLESHEET since it does not replace the +# standard style sheet and is therefore more robust against future updates. +# Doxygen will copy the style sheet files to the output directory. +# Note: The order of the extra style sheet files is of importance (e.g. the last +# style sheet in the list overrules the setting of the previous ones in the +# list). For an example see the documentation. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_EXTRA_STYLESHEET = + +# The HTML_EXTRA_FILES tag can be used to specify one or more extra images or +# other source files which should be copied to the HTML output directory. Note +# that these files will be copied to the base HTML output directory. Use the +# $relpath^ marker in the HTML_HEADER and/or HTML_FOOTER files to load these +# files. In the HTML_STYLESHEET file, use the file name only. Also note that the +# files will be copied as-is; there are no commands or markers available. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_EXTRA_FILES = + +# The HTML_COLORSTYLE_HUE tag controls the color of the HTML output. Doxygen +# will adjust the colors in the style sheet and background images according to +# this color. Hue is specified as an angle on a color-wheel, see +# https://en.wikipedia.org/wiki/Hue for more information. For instance the value +# 0 represents red, 60 is yellow, 120 is green, 180 is cyan, 240 is blue, 300 +# purple, and 360 is red again. +# Minimum value: 0, maximum value: 359, default value: 220. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_COLORSTYLE_HUE = 220 + +# The HTML_COLORSTYLE_SAT tag controls the purity (or saturation) of the colors +# in the HTML output. For a value of 0 the output will use gray-scales only. A +# value of 255 will produce the most vivid colors. +# Minimum value: 0, maximum value: 255, default value: 100. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_COLORSTYLE_SAT = 100 + +# The HTML_COLORSTYLE_GAMMA tag controls the gamma correction applied to the +# luminance component of the colors in the HTML output. Values below 100 +# gradually make the output lighter, whereas values above 100 make the output +# darker. The value divided by 100 is the actual gamma applied, so 80 represents +# a gamma of 0.8, The value 220 represents a gamma of 2.2, and 100 does not +# change the gamma. +# Minimum value: 40, maximum value: 240, default value: 80. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_COLORSTYLE_GAMMA = 80 + +# If the HTML_TIMESTAMP tag is set to YES then the footer of each generated HTML +# page will contain the date and time when the page was generated. Setting this +# to YES can help to show when doxygen was last run and thus if the +# documentation is up to date. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_TIMESTAMP = NO + +# If the HTML_DYNAMIC_MENUS tag is set to YES then the generated HTML +# documentation will contain a main index with vertical navigation menus that +# are dynamically created via JavaScript. If disabled, the navigation index will +# consists of multiple levels of tabs that are statically embedded in every HTML +# page. Disable this option to support browsers that do not have JavaScript, +# like the Qt help browser. +# The default value is: YES. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_DYNAMIC_MENUS = YES + +# If the HTML_DYNAMIC_SECTIONS tag is set to YES then the generated HTML +# documentation will contain sections that can be hidden and shown after the +# page has loaded. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_DYNAMIC_SECTIONS = NO + +# With HTML_INDEX_NUM_ENTRIES one can control the preferred number of entries +# shown in the various tree structured indices initially; the user can expand +# and collapse entries dynamically later on. Doxygen will expand the tree to +# such a level that at most the specified number of entries are visible (unless +# a fully collapsed tree already exceeds this amount). So setting the number of +# entries 1 will produce a full collapsed tree by default. 0 is a special value +# representing an infinite number of entries and will result in a full expanded +# tree by default. +# Minimum value: 0, maximum value: 9999, default value: 100. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_INDEX_NUM_ENTRIES = 100 + +# If the GENERATE_DOCSET tag is set to YES, additional index files will be +# generated that can be used as input for Apple's Xcode 3 integrated development +# environment (see: +# https://developer.apple.com/xcode/), introduced with OSX 10.5 (Leopard). To +# create a documentation set, doxygen will generate a Makefile in the HTML +# output directory. Running make will produce the docset in that directory and +# running make install will install the docset in +# ~/Library/Developer/Shared/Documentation/DocSets so that Xcode will find it at +# startup. See https://developer.apple.com/library/archive/featuredarticles/Doxy +# genXcode/_index.html for more information. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +GENERATE_DOCSET = NO + +# This tag determines the name of the docset feed. A documentation feed provides +# an umbrella under which multiple documentation sets from a single provider +# (such as a company or product suite) can be grouped. +# The default value is: Doxygen generated docs. +# This tag requires that the tag GENERATE_DOCSET is set to YES. + +DOCSET_FEEDNAME = "Doxygen generated docs" + +# This tag determines the URL of the docset feed. A documentation feed provides +# an umbrella under which multiple documentation sets from a single provider +# (such as a company or product suite) can be grouped. +# This tag requires that the tag GENERATE_DOCSET is set to YES. + +DOCSET_FEEDURL = + +# This tag specifies a string that should uniquely identify the documentation +# set bundle. This should be a reverse domain-name style string, e.g. +# com.mycompany.MyDocSet. Doxygen will append .docset to the name. +# The default value is: org.doxygen.Project. +# This tag requires that the tag GENERATE_DOCSET is set to YES. + +DOCSET_BUNDLE_ID = org.doxygen.Project + +# The DOCSET_PUBLISHER_ID tag specifies a string that should uniquely identify +# the documentation publisher. This should be a reverse domain-name style +# string, e.g. com.mycompany.MyDocSet.documentation. +# The default value is: org.doxygen.Publisher. +# This tag requires that the tag GENERATE_DOCSET is set to YES. + +DOCSET_PUBLISHER_ID = org.doxygen.Publisher + +# The DOCSET_PUBLISHER_NAME tag identifies the documentation publisher. +# The default value is: Publisher. +# This tag requires that the tag GENERATE_DOCSET is set to YES. + +DOCSET_PUBLISHER_NAME = Publisher + +# If the GENERATE_HTMLHELP tag is set to YES then doxygen generates three +# additional HTML index files: index.hhp, index.hhc, and index.hhk. The +# index.hhp is a project file that can be read by Microsoft's HTML Help Workshop +# on Windows. In the beginning of 2021 Microsoft took the original page, with +# a.o. the download links, offline the HTML help workshop was already many years +# in maintenance mode). You can download the HTML help workshop from the web +# archives at Installation executable (see: +# http://web.archive.org/web/20160201063255/http://download.microsoft.com/downlo +# ad/0/A/9/0A939EF6-E31C-430F-A3DF-DFAE7960D564/htmlhelp.exe). +# +# The HTML Help Workshop contains a compiler that can convert all HTML output +# generated by doxygen into a single compiled HTML file (.chm). Compiled HTML +# files are now used as the Windows 98 help format, and will replace the old +# Windows help format (.hlp) on all Windows platforms in the future. Compressed +# HTML files also contain an index, a table of contents, and you can search for +# words in the documentation. The HTML workshop also contains a viewer for +# compressed HTML files. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +GENERATE_HTMLHELP = NO + +# The CHM_FILE tag can be used to specify the file name of the resulting .chm +# file. You can add a path in front of the file if the result should not be +# written to the html output directory. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +CHM_FILE = + +# The HHC_LOCATION tag can be used to specify the location (absolute path +# including file name) of the HTML help compiler (hhc.exe). If non-empty, +# doxygen will try to run the HTML help compiler on the generated index.hhp. +# The file has to be specified with full path. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +HHC_LOCATION = + +# The GENERATE_CHI flag controls if a separate .chi index file is generated +# (YES) or that it should be included in the main .chm file (NO). +# The default value is: NO. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +GENERATE_CHI = NO + +# The CHM_INDEX_ENCODING is used to encode HtmlHelp index (hhk), content (hhc) +# and project file content. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +CHM_INDEX_ENCODING = + +# The BINARY_TOC flag controls whether a binary table of contents is generated +# (YES) or a normal table of contents (NO) in the .chm file. Furthermore it +# enables the Previous and Next buttons. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +BINARY_TOC = NO + +# The TOC_EXPAND flag can be set to YES to add extra items for group members to +# the table of contents of the HTML help documentation and to the tree view. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTMLHELP is set to YES. + +TOC_EXPAND = NO + +# If the GENERATE_QHP tag is set to YES and both QHP_NAMESPACE and +# QHP_VIRTUAL_FOLDER are set, an additional index file will be generated that +# can be used as input for Qt's qhelpgenerator to generate a Qt Compressed Help +# (.qch) of the generated HTML documentation. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +GENERATE_QHP = NO + +# If the QHG_LOCATION tag is specified, the QCH_FILE tag can be used to specify +# the file name of the resulting .qch file. The path specified is relative to +# the HTML output folder. +# This tag requires that the tag GENERATE_QHP is set to YES. + +QCH_FILE = + +# The QHP_NAMESPACE tag specifies the namespace to use when generating Qt Help +# Project output. For more information please see Qt Help Project / Namespace +# (see: +# https://doc.qt.io/archives/qt-4.8/qthelpproject.html#namespace). +# The default value is: org.doxygen.Project. +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHP_NAMESPACE = org.doxygen.Project + +# The QHP_VIRTUAL_FOLDER tag specifies the namespace to use when generating Qt +# Help Project output. For more information please see Qt Help Project / Virtual +# Folders (see: +# https://doc.qt.io/archives/qt-4.8/qthelpproject.html#virtual-folders). +# The default value is: doc. +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHP_VIRTUAL_FOLDER = doc + +# If the QHP_CUST_FILTER_NAME tag is set, it specifies the name of a custom +# filter to add. For more information please see Qt Help Project / Custom +# Filters (see: +# https://doc.qt.io/archives/qt-4.8/qthelpproject.html#custom-filters). +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHP_CUST_FILTER_NAME = + +# The QHP_CUST_FILTER_ATTRS tag specifies the list of the attributes of the +# custom filter to add. For more information please see Qt Help Project / Custom +# Filters (see: +# https://doc.qt.io/archives/qt-4.8/qthelpproject.html#custom-filters). +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHP_CUST_FILTER_ATTRS = + +# The QHP_SECT_FILTER_ATTRS tag specifies the list of the attributes this +# project's filter section matches. Qt Help Project / Filter Attributes (see: +# https://doc.qt.io/archives/qt-4.8/qthelpproject.html#filter-attributes). +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHP_SECT_FILTER_ATTRS = + +# The QHG_LOCATION tag can be used to specify the location (absolute path +# including file name) of Qt's qhelpgenerator. If non-empty doxygen will try to +# run qhelpgenerator on the generated .qhp file. +# This tag requires that the tag GENERATE_QHP is set to YES. + +QHG_LOCATION = + +# If the GENERATE_ECLIPSEHELP tag is set to YES, additional index files will be +# generated, together with the HTML files, they form an Eclipse help plugin. To +# install this plugin and make it available under the help contents menu in +# Eclipse, the contents of the directory containing the HTML and XML files needs +# to be copied into the plugins directory of eclipse. The name of the directory +# within the plugins directory should be the same as the ECLIPSE_DOC_ID value. +# After copying Eclipse needs to be restarted before the help appears. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +GENERATE_ECLIPSEHELP = NO + +# A unique identifier for the Eclipse help plugin. When installing the plugin +# the directory name containing the HTML and XML files should also have this +# name. Each documentation set should have its own identifier. +# The default value is: org.doxygen.Project. +# This tag requires that the tag GENERATE_ECLIPSEHELP is set to YES. + +ECLIPSE_DOC_ID = org.doxygen.Project + +# If you want full control over the layout of the generated HTML pages it might +# be necessary to disable the index and replace it with your own. The +# DISABLE_INDEX tag can be used to turn on/off the condensed index (tabs) at top +# of each HTML page. A value of NO enables the index and the value YES disables +# it. Since the tabs in the index contain the same information as the navigation +# tree, you can set this option to YES if you also set GENERATE_TREEVIEW to YES. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +DISABLE_INDEX = NO + +# The GENERATE_TREEVIEW tag is used to specify whether a tree-like index +# structure should be generated to display hierarchical information. If the tag +# value is set to YES, a side panel will be generated containing a tree-like +# index structure (just like the one that is generated for HTML Help). For this +# to work a browser that supports JavaScript, DHTML, CSS and frames is required +# (i.e. any modern browser). Windows users are probably better off using the +# HTML help feature. Via custom style sheets (see HTML_EXTRA_STYLESHEET) one can +# further fine tune the look of the index (see "Fine-tuning the output"). As an +# example, the default style sheet generated by doxygen has an example that +# shows how to put an image at the root of the tree instead of the PROJECT_NAME. +# Since the tree basically has the same information as the tab index, you could +# consider setting DISABLE_INDEX to YES when enabling this option. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +GENERATE_TREEVIEW = YES + +# When both GENERATE_TREEVIEW and DISABLE_INDEX are set to YES, then the +# FULL_SIDEBAR option determines if the side bar is limited to only the treeview +# area (value NO) or if it should extend to the full height of the window (value +# YES). Setting this to YES gives a layout similar to +# https://docs.readthedocs.io with more room for contents, but less room for the +# project logo, title, and description. If either GENERATE_TREEVIEW or +# DISABLE_INDEX is set to NO, this option has no effect. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +FULL_SIDEBAR = NO + +# The ENUM_VALUES_PER_LINE tag can be used to set the number of enum values that +# doxygen will group on one line in the generated HTML documentation. +# +# Note that a value of 0 will completely suppress the enum values from appearing +# in the overview section. +# Minimum value: 0, maximum value: 20, default value: 4. +# This tag requires that the tag GENERATE_HTML is set to YES. + +ENUM_VALUES_PER_LINE = 4 + +# If the treeview is enabled (see GENERATE_TREEVIEW) then this tag can be used +# to set the initial width (in pixels) of the frame in which the tree is shown. +# Minimum value: 0, maximum value: 1500, default value: 250. +# This tag requires that the tag GENERATE_HTML is set to YES. + +TREEVIEW_WIDTH = 250 + +# If the EXT_LINKS_IN_WINDOW option is set to YES, doxygen will open links to +# external symbols imported via tag files in a separate window. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +EXT_LINKS_IN_WINDOW = NO + +# If the OBFUSCATE_EMAILS tag is set to YES, doxygen will obfuscate email +# addresses. +# The default value is: YES. +# This tag requires that the tag GENERATE_HTML is set to YES. + +OBFUSCATE_EMAILS = YES + +# If the HTML_FORMULA_FORMAT option is set to svg, doxygen will use the pdf2svg +# tool (see https://github.com/dawbarton/pdf2svg) or inkscape (see +# https://inkscape.org) to generate formulas as SVG images instead of PNGs for +# the HTML output. These images will generally look nicer at scaled resolutions. +# Possible values are: png (the default) and svg (looks nicer but requires the +# pdf2svg or inkscape tool). +# The default value is: png. +# This tag requires that the tag GENERATE_HTML is set to YES. + +HTML_FORMULA_FORMAT = png + +# Use this tag to change the font size of LaTeX formulas included as images in +# the HTML documentation. When you change the font size after a successful +# doxygen run you need to manually remove any form_*.png images from the HTML +# output directory to force them to be regenerated. +# Minimum value: 8, maximum value: 50, default value: 10. +# This tag requires that the tag GENERATE_HTML is set to YES. + +FORMULA_FONTSIZE = 10 + +# Use the FORMULA_TRANSPARENT tag to determine whether or not the images +# generated for formulas are transparent PNGs. Transparent PNGs are not +# supported properly for IE 6.0, but are supported on all modern browsers. +# +# Note that when changing this option you need to delete any form_*.png files in +# the HTML output directory before the changes have effect. +# The default value is: YES. +# This tag requires that the tag GENERATE_HTML is set to YES. + +FORMULA_TRANSPARENT = YES + +# The FORMULA_MACROFILE can contain LaTeX \newcommand and \renewcommand commands +# to create new LaTeX commands to be used in formulas as building blocks. See +# the section "Including formulas" for details. + +FORMULA_MACROFILE = + +# Enable the USE_MATHJAX option to render LaTeX formulas using MathJax (see +# https://www.mathjax.org) which uses client side JavaScript for the rendering +# instead of using pre-rendered bitmaps. Use this if you do not have LaTeX +# installed or if you want to formulas look prettier in the HTML output. When +# enabled you may also need to install MathJax separately and configure the path +# to it using the MATHJAX_RELPATH option. +# The default value is: NO. +# This tag requires that the tag GENERATE_HTML is set to YES. + +USE_MATHJAX = NO + +# With MATHJAX_VERSION it is possible to specify the MathJax version to be used. +# Note that the different versions of MathJax have different requirements with +# regards to the different settings, so it is possible that also other MathJax +# settings have to be changed when switching between the different MathJax +# versions. +# Possible values are: MathJax_2 and MathJax_3. +# The default value is: MathJax_2. +# This tag requires that the tag USE_MATHJAX is set to YES. + +MATHJAX_VERSION = MathJax_2 + +# When MathJax is enabled you can set the default output format to be used for +# the MathJax output. For more details about the output format see MathJax +# version 2 (see: +# http://docs.mathjax.org/en/v2.7-latest/output.html) and MathJax version 3 +# (see: +# http://docs.mathjax.org/en/latest/web/components/output.html). +# Possible values are: HTML-CSS (which is slower, but has the best +# compatibility. This is the name for Mathjax version 2, for MathJax version 3 +# this will be translated into chtml), NativeMML (i.e. MathML. Only supported +# for NathJax 2. For MathJax version 3 chtml will be used instead.), chtml (This +# is the name for Mathjax version 3, for MathJax version 2 this will be +# translated into HTML-CSS) and SVG. +# The default value is: HTML-CSS. +# This tag requires that the tag USE_MATHJAX is set to YES. + +MATHJAX_FORMAT = HTML-CSS + +# When MathJax is enabled you need to specify the location relative to the HTML +# output directory using the MATHJAX_RELPATH option. The destination directory +# should contain the MathJax.js script. For instance, if the mathjax directory +# is located at the same level as the HTML output directory, then +# MATHJAX_RELPATH should be ../mathjax. The default value points to the MathJax +# Content Delivery Network so you can quickly see the result without installing +# MathJax. However, it is strongly recommended to install a local copy of +# MathJax from https://www.mathjax.org before deployment. The default value is: +# - in case of MathJax version 2: https://cdn.jsdelivr.net/npm/mathjax@2 +# - in case of MathJax version 3: https://cdn.jsdelivr.net/npm/mathjax@3 +# This tag requires that the tag USE_MATHJAX is set to YES. + +MATHJAX_RELPATH = + +# The MATHJAX_EXTENSIONS tag can be used to specify one or more MathJax +# extension names that should be enabled during MathJax rendering. For example +# for MathJax version 2 (see +# https://docs.mathjax.org/en/v2.7-latest/tex.html#tex-and-latex-extensions): +# MATHJAX_EXTENSIONS = TeX/AMSmath TeX/AMSsymbols +# For example for MathJax version 3 (see +# http://docs.mathjax.org/en/latest/input/tex/extensions/index.html): +# MATHJAX_EXTENSIONS = ams +# This tag requires that the tag USE_MATHJAX is set to YES. + +MATHJAX_EXTENSIONS = + +# The MATHJAX_CODEFILE tag can be used to specify a file with javascript pieces +# of code that will be used on startup of the MathJax code. See the MathJax site +# (see: +# http://docs.mathjax.org/en/v2.7-latest/output.html) for more details. For an +# example see the documentation. +# This tag requires that the tag USE_MATHJAX is set to YES. + +MATHJAX_CODEFILE = + +# When the SEARCHENGINE tag is enabled doxygen will generate a search box for +# the HTML output. The underlying search engine uses javascript and DHTML and +# should work on any modern browser. Note that when using HTML help +# (GENERATE_HTMLHELP), Qt help (GENERATE_QHP), or docsets (GENERATE_DOCSET) +# there is already a search function so this one should typically be disabled. +# For large projects the javascript based search engine can be slow, then +# enabling SERVER_BASED_SEARCH may provide a better solution. It is possible to +# search using the keyboard; to jump to the search box use + S +# (what the is depends on the OS and browser, but it is typically +# , /Link To NexusMods Page", + page.mod.domain_name, + page.mod.mod_id) + .c_str(); + ui->link_label_desc->setText(mod_link); + ui->link_label_changelog->setText(mod_link); + ui->link_label_files->setText( + std::format("" + "Link To NexusMods " + "Page", + page.mod.domain_name, + page.mod.mod_id) + .c_str()); + + std::vector files = page.files; + std::sort(files.begin(), + files.end(), + [](nexus::File f1, nexus::File f2) + { + if(f1.category_id != f2.category_id) + return f1.category_id < f2.category_id; + return f1.name < f2.name; + }); + auto base_layout = new QVBoxLayout(); + auto old_layout = ui->files_widget->layout(); + delete old_layout; + ui->files_widget->setLayout(base_layout); + long cur_cat_id = -1; + QString cur_cat_name = ""; + for(const auto& file : files) + { + if(file.category_id != cur_cat_id) + { + cur_cat_id = file.category_id; + cur_cat_name = file.category_name.empty() ? "Other" : file.category_name.c_str(); + auto label = new QLabel(); + label->setTextFormat(Qt::RichText); + label->setText(R"()" + cur_cat_name + " Files"); + base_layout->addWidget(label); + } + auto frame = new QFrame(); + frame->setFrameShadow(QFrame::Plain); + frame->setFrameShape(QFrame::Panel); + auto frame_layout = new QVBoxLayout(); + frame->setLayout(frame_layout); + QString mod_text; + mod_text.append((R"()" + file.name + "").c_str()); + if(!file.version.empty()) + mod_text.append(("
Version: " + file.version + "").c_str()); + std::stringstream ss; + ss << std::put_time(std::localtime(&file.uploaded_time), "%F %T"); + mod_text.append(("
Upload Time: " + ss.str() + "").c_str()); + QString size_string; + long size = file.size_in_bytes; + if(size < 1024) + size_string = QString::number(size); + else + { + long last_size = 0; + int exp = 0; + const std::vector units{ "B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB" }; + while(size > 1024 && exp < units.size()) + { + last_size = size; + size /= 1024; + exp++; + } + last_size /= 1.024; + size_string = QString::number(size); + const int first_digit = (last_size / 100) % 10; + const int second_digit = (last_size / 10) % 10; + if(first_digit != 0 || second_digit != 0) + size_string += "." + QString::number(first_digit); + if(second_digit != 0) + size_string += QString::number(second_digit); + size_string += " " + units[exp]; + } + mod_text.append("
Size: " + size_string + "
"); + if(file.external_virus_scan_url.empty()) + mod_text.append("No external virus scan
"); + else + mod_text.append( + ("Virus scan link
") + .c_str()); + mod_text.append("

" + bbcodeToHtml(file.description.c_str()) + "
"); + if(!file.changelog_html.empty()) + mod_text.append("
Changes
" + bbcodeToHtml(file.changelog_html.c_str()) + + "
"); + auto text_label = new QLabel(); + text_label->setTextFormat(Qt::RichText); + text_label->setText(mod_text); + text_label->setWordWrap(true); + text_label->setTextInteractionFlags(text_label->textInteractionFlags() | + Qt::TextSelectableByMouse); + frame_layout->addWidget(text_label); + + auto manual_link_label = new QLabel(); + manual_link_label->setTextFormat(Qt::RichText); + manual_link_label->setText( + std::format("" + "Manual Download Link", + page.mod.domain_name, + page.mod.mod_id, + file.file_id) + .c_str()); + manual_link_label->setOpenExternalLinks(true); + frame_layout->addWidget(manual_link_label); + + auto manager_link_label = new QLabel(); + manager_link_label->setTextFormat(Qt::RichText); + manager_link_label->setText( + std::format("" + "Mod Manager Download Link", + page.mod.domain_name, + page.mod.mod_id, + file.file_id) + .c_str()); + manager_link_label->setOpenExternalLinks(true); + frame_layout->addWidget(manager_link_label); + QSettings settings(QCoreApplication::applicationName()); + settings.beginGroup("nexus"); + const bool is_premium = settings.value("info_is_premium", false).toBool(); + settings.endGroup(); + if(is_premium) + { + auto download_button = new TablePushButton(file.file_id, file.file_id); + download_button->setText("Download"); + download_button->setIcon(QIcon::fromTheme("edit-download")); + connect( + download_button, &TablePushButton::clickedAt, this, &NexusModDialog::onDownloadClicked); + + frame_layout->addWidget(download_button); + } + frame_layout->addStretch(); + base_layout->addWidget(frame); + } + base_layout->addStretch(); +} + +QString NexusModDialog::bbcodeToHtml(const QString& bbcode) +{ + QString html = bbcode; + html.remove(QChar(0xFEFF)); + html.remove("\ufeff"); + html.replace("\xa0", " "); + html.remove("\n"); + + std::vector> tokens = { + { R"(\[center\])", R"(\[/center\])", R"(
\1
)" }, + { R"(\[b\])", R"(\[/b\])", R"(\1)" }, + { R"(\[i\])", R"(\[/i\])", R"(\1)" }, + { R"(\[u\])", R"(\[/u\])", R"(\1)" }, + { R"(\[s\])", R"(\[/s\])", R"(\1)" }, + { R"(\[url\])", R"(\[/url\])", R"(\1)" }, + { R"(\[url=(.*?)\])", R"(\[/url\])", R"(\2)" }, + { R"(\[youtube\])", + R"(\[/youtube\])", + R"(https://www.youtube.com/watch?v=\1)" }, + // { R"(\[img\])", R"(\[/img\])", R"()" }, + { R"(\[img\])", R"(\[/img\])", R"( [\1] )" }, + { R"(\[quote\])", R"(\[/quote\])", R"(
\1
)" }, + { R"(\[quote=(.*?)\])", R"(\[/quote\])", R"(
\1\2
)" }, + { R"(\[code\])", R"(\[/code\])", R"(
\1
)" }, + { R"(\[list\])", R"(\[/list\])", R"(
    \1
)" }, + { R"(\[list=1\])", R"(\[/list\])", R"(
    \1
)" }, + { R"(\[li\])", R"(\[/li\])", R"(
  • \1
  • )" }, + { R"(\[color=(.*?)\])", R"(\[/color\])", R"(\2)" }, + { R"(\[size=(.*?)\])", R"(\[/size\])", R"(\2)" }, + { R"(\[left\])", R"(\[/left\])", R"(\1)" } + }; + + for(const auto& [begin, end, replace] : tokens) + { + while(html.contains(QRegularExpression(begin + R"((.*?))" + end))) + html.replace(QRegularExpression(begin + "((?!" + begin + R"().*?))" + end), replace); + } + + return html; +} + +void NexusModDialog::onDownloadClicked(int file_id, int file_id_copy) +{ + emit modDownloadRequested(app_id_, mod_id_, file_id, page_.url.c_str()); +} diff --git a/src/ui/nexusmoddialog.h b/src/ui/nexusmoddialog.h new file mode 100644 index 0000000..c1b85ee --- /dev/null +++ b/src/ui/nexusmoddialog.h @@ -0,0 +1,80 @@ +/*! + * \file nexusmoddialog.h + * \brief Header for the NexusModDialog class. + */ + +#pragma once + +#include "src/core/nexus/api.h" +#include "tabletoolbutton.h" +#include +#include +#include +#include + + +namespace Ui +{ +class NexusModDialog; +} + +/*! + * \brief Dialog used to display the descrition page, the changelogs and all available files + * for a mod on NexusMods. + */ +class NexusModDialog : public QDialog +{ + Q_OBJECT + +public: + /*! + * \brief Initializes the UI. + * \param parent Parent for this widget, this is passed to the constructor of QDialog. + */ + explicit NexusModDialog(QWidget* parent = nullptr); + /*! \brief Deletes the UI. */ + ~NexusModDialog(); + + /*! + * \brief Initializes the dialog with the data for the given Page. + * \param app_id ModdedApplication to which the mod, whose page is being shown, belongs. + * \param mod_id Limo mod id for the mod to which the page belongs. + * \param page Contains all data necessary for the dialog. + */ + void setupDialog(int app_id, int mod_id, const nexus::Page& page); + +private: + /*! \brief Contains auto-generated UI elements. */ + Ui::NexusModDialog* ui; + /*! \brief ModdedApplication to which the mod, whose page is being shown, belongs. */ + int app_id_; + /*! \brief Limo mod id for the mod to which the page belongs. */ + int mod_id_; + /*! \brief Contains all data necessary for the dialog. */ + nexus::Page page_; + + /*! + * \brief Converts the given BBCode formatted string to a string formatted with HTML tags. + * \param bbcode String to convert. + * \return The converted string. + */ + QString bbcodeToHtml(const QString& bbcode); + +private slots: + /*! + * \brief Sends a download request for the given file. + * \param file_id Id of the NexusMods file to download. + * \param file_id_copy Id of the NexusMods file to download. + */ + void onDownloadClicked(int file_id, int file_id_copy); + +signals: + /*! + * \brief Sends a download request for the given file. + * \param app_id ModdedApplication to which the mod, whose page is being shown, belongs. + * \param mod_id Limo mod id for the mod to which the page belongs. + * \param file_id NexusMods file id used for the download. + * \param mod_url URL of the mod page on NexusMods. + */ + void modDownloadRequested(int app_id, int mod_id, int file_id, QString mod_url); +}; diff --git a/src/ui/nexusmoddialog.ui b/src/ui/nexusmoddialog.ui new file mode 100644 index 0000000..1856582 --- /dev/null +++ b/src/ui/nexusmoddialog.ui @@ -0,0 +1,134 @@ + + + NexusModDialog + + + + 0 + 0 + 776 + 631 + + + + NexusMods Mod Details + + + + + + 0 + + + + Description + + + + + + + + + Qt::RichText + + + Qt::AlignCenter + + + true + + + + + + + QTextEdit::WidgetWidth + + + 0 + + + true + + + + + + + + Changelog + + + + + + + + + Qt::RichText + + + Qt::AlignCenter + + + true + + + + + + + Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + + Files + + + + + + + + + Qt::RichText + + + Qt::AlignCenter + + + true + + + + + + + true + + + + + 0 + 0 + 734 + 545 + + + + + + + + + + + + + + diff --git a/src/ui/overwritebackupdialog.cpp b/src/ui/overwritebackupdialog.cpp new file mode 100644 index 0000000..ae9ccc8 --- /dev/null +++ b/src/ui/overwritebackupdialog.cpp @@ -0,0 +1,69 @@ +#include "overwritebackupdialog.h" +#include "colors.h" +#include "ui_overwritebackupdialog.h" +#include + + +OverwriteBackupDialog::OverwriteBackupDialog(QWidget* parent) : + QDialog(parent), ui(new Ui::OverwriteBackupDialog) +{ + ui->setupUi(this); + ui->backup_field->setCustomValidator([names = &backup_names_](QString s) + { return names->contains(s); }); + ui->backup_field->setValidationMode(ValidatingLineEdit::VALID_CUSTOM); +} + +OverwriteBackupDialog::~OverwriteBackupDialog() +{ + delete ui; +} + +void OverwriteBackupDialog::setupDialog(const QStringList& backup_names, + int target_id, + int dest_backup) +{ + backup_names_.clear(); + for(int i = 0; i < backup_names.size(); i++) + { + if(i != dest_backup) + backup_names_.append(backup_names[i]); + } + backup_target_ = target_id; + dest_backup_ = dest_backup; + ui->warning_label->setText("WARNING: All files in '" + backup_names[dest_backup] + + "' will be overwritten!"); + QPalette palette = ui->warning_label->palette(); + palette.setColor(QPalette::WindowText, colors::RED); + ui->warning_label->setPalette(palette); + completer_ = std::make_unique(backup_names_); + completer_->setCaseSensitivity(Qt::CaseInsensitive); + completer_->setFilterMode(Qt::MatchContains); + ui->backup_field->setCompleter(completer_.get()); + ui->backup_field->clear(); + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + dialog_completed_ = false; +} + +void OverwriteBackupDialog::on_backup_field_textChanged(const QString& text) +{ + if(backup_names_.contains(text)) + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true); + else + ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); +} + +void OverwriteBackupDialog::on_buttonBox_accepted() +{ + if(dialog_completed_) + return; + dialog_completed_ = true; + + const QString backup = ui->backup_field->text(); + if(backup_names_.contains(backup)) + { + int source = backup_names_.indexOf(backup); + if(source >= dest_backup_) + source++; + emit backupOverwritten(backup_target_, source, dest_backup_); + } +} diff --git a/src/ui/overwritebackupdialog.h b/src/ui/overwritebackupdialog.h new file mode 100644 index 0000000..2ff54de --- /dev/null +++ b/src/ui/overwritebackupdialog.h @@ -0,0 +1,71 @@ +/*! + * \file overwritedialog.h + * \brief Header for the OverwriteBackupDialog class. + */ + +#pragma once + +#include +#include + + +namespace Ui +{ +class OverwriteBackupDialog; +} + +/*! + * \brief Dialog for overwriting backups. + */ +class OverwriteBackupDialog : public QDialog +{ + Q_OBJECT + +public: + /*! + * \brief Initializes the UI. + * \param parent Parent for this widget, this is passed to the constructor of QDialog. + */ + explicit OverwriteBackupDialog(QWidget* parent = nullptr); + /*! \brief Deletes the UI. */ + ~OverwriteBackupDialog(); + /*! + * \brief Initializes the dialog. + * \param backup_names Contains names for all backups. + * \param target_id Id of the backup target for which to overwrite a backup. + * \param dest_backup Backup to be overwritten. + */ + void setupDialog(const QStringList& backup_names, int target_id, int dest_backup); + +private: + /*! \brief Contains auto-generated UI elements. */ + Ui::OverwriteBackupDialog* ui; + /*! \brief Id of the backup target for which to overwrite a backup. */ + int backup_target_; + /*! \brief Backup to be overwritten. */ + int dest_backup_; + /*! \brief Contains names for all backups. */ + QStringList backup_names_; + /*! \brief Completer used for backup names. */ + std::unique_ptr completer_; + /*! \brief Indicates whether the dialog has been completed. */ + bool dialog_completed_ = false; + +private slots: + /*! + * \brief Ensures Ok button is only available when a valid backup has been selected. + * \param text The new input. + */ + void on_backup_field_textChanged(const QString& text); + /*! \brief Closes the dialog and emits \ref backupOverwritten. */ + void on_buttonBox_accepted(); + +signals: + /*! + * \brief Signals completion of the dialog. + * \param target_id Id of the backup target for which to overwrite a backup. + * \param source_backup Backup from which to copy files. + * \param dest_backup Backup to be overwritten. + */ + void backupOverwritten(int target_id, int source_backup, int dest_backup); +}; diff --git a/src/ui/overwritebackupdialog.ui b/src/ui/overwritebackupdialog.ui new file mode 100644 index 0000000..ff97fda --- /dev/null +++ b/src/ui/overwritebackupdialog.ui @@ -0,0 +1,98 @@ + + + OverwriteBackupDialog + + + + 0 + 0 + 400 + 106 + + + + Overwrite Backup + + + + + + WARNING + + + + + + + enter backup name + + + + + + + Qt::Vertical + + + + 20 + 7 + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + ValidatingLineEdit + QLineEdit +
    ui/validatinglineedit.h
    +
    +
    + + + + buttonBox + accepted() + OverwriteBackupDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + OverwriteBackupDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + +
    diff --git a/src/ui/passwordfield.cpp b/src/ui/passwordfield.cpp new file mode 100644 index 0000000..59b436e --- /dev/null +++ b/src/ui/passwordfield.cpp @@ -0,0 +1,96 @@ +#include "passwordfield.h" +#include +#include + + +PasswordField::PasswordField(QWidget* parent) : QWidget{ parent } +{ + auto layout = new QHBoxLayout(); + layout->setContentsMargins(0, 0, 0, 0); + password_line_edit_ = new ValidatingLineEdit(this, ValidatingLineEdit::VALID_NONE); + password_line_edit_->setEchoMode(QLineEdit::Password); + password_line_edit_->setPlaceholderText("Enter Password"); + connect(password_line_edit_, &QLineEdit::textEdited, this, &PasswordField::onPasswordEdited); + layout->addWidget(password_line_edit_); + view_button_ = new QPushButton(this); + view_button_->setText(""); + view_button_->setIcon(show_icon); + view_button_->setToolTip("Show Password"); + connect(view_button_, &QPushButton::pressed, this, &PasswordField::onViewButtonPressed); + layout->addWidget(view_button_); + setLayout(layout); +} + +QString PasswordField::getPassword() const +{ + return password_line_edit_->text(); +} + +ValidatingLineEdit* PasswordField::getPasswordLineEdit() const +{ + return password_line_edit_; +} + +void PasswordField::setPartnerField(PasswordField* partner, Role partner_role) +{ + partner_ = partner; + if(partner_role == main) + { + role_ = repeat; + password_line_edit_->setCustomValidator([partner, this](auto pw) + { return pw == partner->getPassword(); }); + password_line_edit_->setValidationMode(ValidatingLineEdit::VALID_CUSTOM); + password_line_edit_->setPlaceholderText("Repeat Password"); + connect(partner, &PasswordField::passwordEdited, this, &PasswordField::onPartnerFieldChanged); + } + else + { + role_ = main; + password_line_edit_->setValidationMode(ValidatingLineEdit::VALID_NONE); + password_line_edit_->setPlaceholderText("Enter Password"); + partner->setPartnerField(this, main); + } +} + +void PasswordField::updateValidationStatus() +{ + if(role_ == main) + return; + + const bool is_valid_now = password_line_edit_->hasValidText(); + if(is_valid_ != is_valid_now) + { + is_valid_ = is_valid_now; + emit passwordValidityChanged(is_valid_now); + } +} + +void PasswordField::onPartnerFieldChanged(QString new_password) +{ + if(role_ == repeat) + password_line_edit_->updateValidation(); + updateValidationStatus(); +} + +void PasswordField::onPasswordEdited(QString new_password) +{ + updateValidationStatus(); + emit passwordEdited(new_password); +} + +void PasswordField::onViewButtonPressed() +{ + const auto mode = password_line_edit_->echoMode(); + if(mode == QLineEdit::Password) + { + password_line_edit_->setEchoMode(QLineEdit::Normal); + view_button_->setToolTip("Hide Password"); + view_button_->setIcon(hide_icon); + } + else + { + password_line_edit_->setEchoMode(QLineEdit::Password); + view_button_->setToolTip("Show Password"); + view_button_->setIcon(show_icon); + } +} diff --git a/src/ui/passwordfield.h b/src/ui/passwordfield.h new file mode 100644 index 0000000..90c6c4d --- /dev/null +++ b/src/ui/passwordfield.h @@ -0,0 +1,99 @@ +/*! + * \file passwordfield.h + * \brief Header for the PasswordField class. + */ +#pragma once + +#include "validatinglineedit.h" +#include +#include + + +/*! + * \brief Widget used to enter passwords. Contains a line field for input and a button + * to show/ hide the password. Can be paired with another PasswordField as repetition + * check. + */ +class PasswordField : public QWidget +{ + Q_OBJECT +public: + /*! \brief Role of this field. */ + enum Role + { + /*! \brief This is the main PasswordField. */ + main, + /*! \brief This PasswordField is meant for repetition checking. */ + repeat + }; + + /*! + * \brief Initializes the UI. + * \param parent Parent for this widget, this is passed to the constructor of QDialog. + */ + explicit PasswordField(QWidget* parent = nullptr); + + /*! + * \brief Returns the password currently in the password field.. + * \return The password. + */ + QString getPassword() const; + /*! + * \brief Returns the ValidatingLineEdit used for input. + * \return The line edit. + */ + ValidatingLineEdit* getPasswordLineEdit() const; + /*! + * \brief Sets a partner PasswordField for repetition checking. + * \param partner The partner. + * \param partner_role Role of the partner. + */ + void setPartnerField(PasswordField* partner, Role partner_role); + +private: + /*! \brief Used for input. Gives visual feedback is this has a repeat role and the passwords dont match. */ + ValidatingLineEdit* password_line_edit_; + /*! \brief Button used to show/ hide the password. */ + QPushButton* view_button_; + /*! \brief Icon used to indicate that the password is to be shown. */ + const QIcon show_icon = QIcon::fromTheme("view-visible"); + /*! \brief Icon used to indicate that the password is to be hidden. */ + const QIcon hide_icon = QIcon::fromTheme("view-hidden"); + /*! \brief Partner PasswordField. */ + PasswordField* partner_ = nullptr; + /*! \brief Role if this field. */ + Role role_ = main; + /*! \brief Always true if role_ == main. Else only true if both passwords match. */ + bool is_valid_ = true; + + /*! \brief Updates the is_valid_ flag to be false if role_ == repeat and the passwords mismatch. */ + void updateValidationStatus(); + +public slots: + /*! + * \brief Updates the visual feedback for matching passwords. + * \param new_password The new partner input. + */ + void onPartnerFieldChanged(QString new_password); + +private slots: + /*! + * \brief Updates the visual feedback for matching passwords. + * \param new_password The new partner password. + */ + void onPasswordEdited(QString new_password); + /*! \brief Toggles password visibility. */ + void onViewButtonPressed(); + +signals: + /*! + * \brief Signals that the current password has been edited by the user. + * \param new_password The new password as entered by the user. + */ + void passwordEdited(QString new_password); + /*! + * \brief Sent when the is_valid_ status of this field changes. + * \param is_valid The new status. + */ + void passwordValidityChanged(bool is_valid); +}; diff --git a/src/ui/settingsdialog.cpp b/src/ui/settingsdialog.cpp new file mode 100644 index 0000000..473d52e --- /dev/null +++ b/src/ui/settingsdialog.cpp @@ -0,0 +1,433 @@ +#include "settingsdialog.h" +#include "../core/log.h" +#include "../core/lootdeployer.h" +#include "../core/nexus/api.h" +#include "addapikeydialog.h" +#include "changeapipwdialog.h" +#include "core/cryptography.h" +#include "core/installer.h" +#include "core/parseerror.h" +#include "enterapipwdialog.h" +#include "ui_settingsdialog.h" +#include +#include +#include +#include +#include +#include + +namespace str = std::ranges; + + +SettingsDialog::SettingsDialog(QWidget* parent) : QDialog(parent), ui(new Ui::SettingsDialog) +{ + ui->setupUi(this); + ui->show_api_key_button->setIcon(show_icon); + ui->show_api_key_button->setToolTip("Show API Key"); + ui->api_key_label->setText(api_key_hidden_string); +} + +SettingsDialog::~SettingsDialog() +{ + delete ui; +} + +void SettingsDialog::init() +{ + QSettings settings(QCoreApplication::applicationName()); + ui->remove_mod_cb->setCheckState(settings.value("ask_remove_mod", true).toBool() ? Qt::Checked + : Qt::Unchecked); + ui->remove_from_dep_cb->setCheckState( + settings.value("ask_remove_from_deployer", true).toBool() ? Qt::Checked : Qt::Unchecked); + ui->remove_prof_cb->setCheckState( + settings.value("ask_remove_profile", true).toBool() ? Qt::Checked : Qt::Unchecked); + ui->remove_bak_target_cb->setCheckState( + settings.value("ask_remove_backup_target", true).toBool() ? Qt::Checked : Qt::Unchecked); + ui->remove_bak_cb->setCheckState( + settings.value("ask_remove_backup", true).toBool() ? Qt::Checked : Qt::Unchecked); + ui->log_level_box->setCurrentIndex(settings.value("log_level", Log::LogLevel::LOG_INFO).toInt() - + 2); + ui->fo3_url_field->setText( + settings.value("fo3_url", LootDeployer::DEFAULT_LIST_URLS.at(loot::GameType::fo3).c_str()) + .toString()); + ui->fo4_url_field->setText( + settings.value("fo4_url", LootDeployer::DEFAULT_LIST_URLS.at(loot::GameType::fo4).c_str()) + .toString()); + ui->fo4vr_url_field->setText( + settings.value("fo4vr_url", LootDeployer::DEFAULT_LIST_URLS.at(loot::GameType::fo4vr).c_str()) + .toString()); + ui->fonv_url_field->setText( + settings.value("fonv_url", LootDeployer::DEFAULT_LIST_URLS.at(loot::GameType::fonv).c_str()) + .toString()); + ui->starfield_url_field->setText( + settings + .value("starfield_url", LootDeployer::DEFAULT_LIST_URLS.at(loot::GameType::starfield).c_str()) + .toString()); + ui->tes3_url_field->setText( + settings.value("tes3_url", LootDeployer::DEFAULT_LIST_URLS.at(loot::GameType::tes3).c_str()) + .toString()); + ui->tes4_url_field->setText( + settings.value("tes4_url", LootDeployer::DEFAULT_LIST_URLS.at(loot::GameType::tes4).c_str()) + .toString()); + ui->tes5_url_field->setText( + settings.value("tes5_url", LootDeployer::DEFAULT_LIST_URLS.at(loot::GameType::tes5).c_str()) + .toString()); + ui->tes5se_url_field->setText( + settings.value("tes5se_url", LootDeployer::DEFAULT_LIST_URLS.at(loot::GameType::tes5se).c_str()) + .toString()); + ui->tes5vr_url_field->setText( + settings.value("tes5vr_url", LootDeployer::DEFAULT_LIST_URLS.at(loot::GameType::tes5vr).c_str()) + .toString()); + ui->show_warning_cb->setCheckState( + settings.value("log_on_warning", true).toBool() ? Qt::Checked : Qt::Unchecked); + ui->show_error_cb->setCheckState(settings.value("log_on_error", true).toBool() ? Qt::Checked + : Qt::Unchecked); + ui->deploy_for_box->setCurrentIndex(settings.value("deploy_for_all", true).toBool() ? 0 : 1); + ui->unrar_path_field->setText(Installer::UNRAR_PATH.c_str()); + + settings.beginGroup("nexus"); + ui->premium_user_label->setText( + settings.value("info_is_premium").toBool() ? "Account is premium" : "Account is NOT premium"); + const bool key_is_valid = settings.value("info_is_valid").toBool(); + ui->valid_api_key_label->setText(key_is_valid ? "Nexus API key is valid." + : "No valid API key set"); + ui->api_key_label->setVisible(key_is_valid); + ui->show_api_key_button->setVisible(key_is_valid); + const int cipher_size = settings.beginReadArray("info_c"); + settings.endArray(); + const int nonce_size = settings.beginReadArray("info_n"); + settings.endArray(); + const int tag_size = settings.beginReadArray("info_t"); + settings.endArray(); + if(cipher_size == 0 || nonce_size == 0 || tag_size == 0) + ui->change_api_pw_button->setVisible(false); + else + ui->change_api_pw_button->setVisible(true); + settings.endGroup(); + dialog_completed_ = false; +} + +void SettingsDialog::on_buttonBox_accepted() +{ + if(dialog_completed_) + return; + dialog_completed_ = true; + + QSettings settings(QCoreApplication::applicationName()); + + settings.setValue("fo3_url", ui->fo3_url_field->text()); + LootDeployer::LIST_URLS[loot::GameType::fo3] = ui->fo3_url_field->text().toStdString(); + settings.setValue("fo4_url", ui->fo4_url_field->text()); + LootDeployer::LIST_URLS[loot::GameType::fo4] = ui->fo4_url_field->text().toStdString(); + settings.setValue("fo4vr_url", ui->fo4vr_url_field->text()); + LootDeployer::LIST_URLS[loot::GameType::fo4vr] = ui->fo4vr_url_field->text().toStdString(); + settings.setValue("fonv_url", ui->fonv_url_field->text()); + LootDeployer::LIST_URLS[loot::GameType::fonv] = ui->fonv_url_field->text().toStdString(); + settings.setValue("starfield_url", ui->starfield_url_field->text()); + LootDeployer::LIST_URLS[loot::GameType::starfield] = + ui->starfield_url_field->text().toStdString(); + settings.setValue("tes3_url", ui->tes3_url_field->text()); + LootDeployer::LIST_URLS[loot::GameType::tes3] = ui->tes3_url_field->text().toStdString(); + settings.setValue("tes4_url", ui->tes4_url_field->text()); + LootDeployer::LIST_URLS[loot::GameType::tes4] = ui->tes4_url_field->text().toStdString(); + settings.setValue("tes5_url", ui->tes5_url_field->text()); + LootDeployer::LIST_URLS[loot::GameType::tes5] = ui->tes5_url_field->text().toStdString(); + settings.setValue("tes5se_url", ui->tes5se_url_field->text()); + LootDeployer::LIST_URLS[loot::GameType::tes5se] = ui->tes5se_url_field->text().toStdString(); + settings.setValue("tes5vr_url", ui->tes5vr_url_field->text()); + LootDeployer::LIST_URLS[loot::GameType::tes5vr] = ui->tes5vr_url_field->text().toStdString(); + + Log::log_level = static_cast(ui->log_level_box->currentIndex() + 2); + settings.setValue("log_level", Log::log_level); + + deploy_all_ = ui->deploy_for_box->currentIndex() == 0; + settings.setValue("deploy_for_all", deploy_all_); + + log_on_error_ = ui->show_error_cb->isChecked(); + settings.setValue("log_on_error", log_on_error_); + + log_on_warning_ = ui->show_warning_cb->isChecked(); + settings.setValue("log_on_warning", log_on_warning_); + + ask_remove_from_deployer_ = ui->remove_from_dep_cb->isChecked(); + settings.setValue("ask_remove_from_deployer", ask_remove_from_deployer_); + + ask_remove_mod_ = ui->remove_mod_cb->isChecked(); + settings.setValue("ask_remove_mod", ask_remove_mod_); + + ask_remove_profile_ = ui->remove_prof_cb->isChecked(); + settings.setValue("ask_remove_profile", ask_remove_profile_); + + ask_remove_backup_target_ = ui->remove_bak_target_cb->isChecked(); + settings.setValue("ask_remove_backup_target", ask_remove_backup_target_); + + ask_remove_backup_ = ui->remove_bak_cb->isChecked(); + settings.setValue("ask_remove_backup", ask_remove_backup_); + + Installer::UNRAR_PATH = ui->unrar_path_field->text().toStdString(); + settings.setValue("unrar_path", ui->unrar_path_field->text()); + + emit settingsDialogAccepted(); +} + +bool SettingsDialog::askRemoveBackup() const +{ + return ask_remove_backup_; +} + +std::optional> +SettingsDialog::getNexusApiKeyDetails() +{ + QSettings settings(QCoreApplication::applicationName()); + settings.beginGroup("nexus"); + const int cipher_size = settings.beginReadArray("info_c"); + settings.endArray(); + const int nonce_size = settings.beginReadArray("info_n"); + settings.endArray(); + const int tag_size = settings.beginReadArray("info_t"); + settings.endArray(); + if(cipher_size == 0 || nonce_size == 0 || tag_size == 0) + return {}; + + int array_size = settings.beginReadArray("info_c"); + std::string cipher; + cipher.reserve(array_size); + for(int i = 0; i < array_size; i++) + { + settings.setArrayIndex(i); + cipher.append({ static_cast(settings.value("uchar").toUInt()) }); + } + settings.endArray(); + + array_size = settings.beginReadArray("info_n"); + std::string nonce; + nonce.reserve(array_size); + for(int i = 0; i < array_size; i++) + { + settings.setArrayIndex(i); + nonce.append({ static_cast(settings.value("uchar").toUInt()) }); + } + settings.endArray(); + + array_size = settings.beginReadArray("info_t"); + std::string tag; + tag.reserve(array_size); + for(int i = 0; i < array_size; i++) + { + settings.setArrayIndex(i); + tag.append({ static_cast(settings.value("uchar").toUInt()) }); + } + settings.endArray(); + + const bool is_default_pw = settings.value("info_is_default").toBool(); + settings.endGroup(); + return { { cipher, nonce, tag, is_default_pw } }; +} + +void SettingsDialog::setNexusCryptographyFields(const std::string& cipher, + const std::string& nonce, + const std::string& tag, + bool uses_default_pw) +{ + QSettings settings(QCoreApplication::applicationName()); + settings.beginGroup("nexus"); + settings.beginWriteArray("info_c", cipher.size()); + for(auto [i, c] : str::enumerate_view(cipher)) + { + settings.setArrayIndex(i); + settings.setValue("uchar", static_cast(c)); + } + settings.endArray(); + settings.beginWriteArray("info_n", nonce.size()); + for(auto [i, c] : str::enumerate_view(nonce)) + { + settings.setArrayIndex(i); + settings.setValue("uchar", static_cast(c)); + } + settings.endArray(); + settings.beginWriteArray("info_t", tag.size()); + for(auto [i, c] : str::enumerate_view(tag)) + { + settings.setArrayIndex(i); + settings.setValue("uchar", static_cast(c)); + } + settings.endArray(); + settings.setValue("info_is_default", uses_default_pw); + settings.endGroup(); +} + +bool SettingsDialog::askRemoveBackupTarget() const +{ + return ask_remove_backup_target_; +} + +bool SettingsDialog::logOnWarning() const +{ + return log_on_warning_; +} + +bool SettingsDialog::logOnError() const +{ + return log_on_error_; +} + +bool SettingsDialog::deployAll() const +{ + return deploy_all_; +} + +bool SettingsDialog::askRemoveProfile() const +{ + return ask_remove_profile_; +} + +bool SettingsDialog::askRemoveFromDeployer() const +{ + return ask_remove_from_deployer_; +} + +bool SettingsDialog::askRemoveMod() const +{ + return ask_remove_mod_; +} + +void SettingsDialog::on_unrar_path_button_clicked() +{ + QString starting_dir = QStandardPaths::writableLocation(QStandardPaths::HomeLocation); + QString current_path = ui->unrar_path_field->text(); + if(!current_path.isEmpty() && std::filesystem::exists(current_path.toStdString())) + starting_dir = std::filesystem::path(current_path.toStdString()).parent_path().c_str(); + auto dialog = new QFileDialog; + dialog->setFileMode(QFileDialog::ExistingFile); + dialog->setWindowTitle("Select unrar Executable"); + connect(dialog, &QFileDialog::fileSelected, this, &SettingsDialog::onUnrarPathSelected); + dialog->exec(); +} + +void SettingsDialog::onUnrarPathSelected(const QString& path) +{ + ui->unrar_path_field->setText(path); +} + +void SettingsDialog::on_set_api_key_button_clicked() +{ + AddApiKeyDialog dialog; + if(dialog.exec() == QDialog::Rejected) + return; + std::optional> user_info{}; + auto handle_error = [](auto& e) + { + const QString message = "Error while parsing the Answer from NexusMods: \n" + QString(e.what()); + Log::error(message.toStdString()); + QMessageBox error_box(QMessageBox::Critical, "Network Error", message, QMessageBox::Ok); + error_box.exec(); + }; + const std::string api_key = dialog.getApiKey().toStdString(); + try + { + user_info = nexus::Api::validateKey(api_key); + } + catch(ParseError& e) + { + handle_error(e); + } + catch(Json::RuntimeError& e) + { + handle_error(e); + } + catch(Json::LogicError& e) + { + handle_error(e); + } + if(!user_info) + { + const QString message = "Api key failed to validate."; + Log::error(message.toStdString()); + QMessageBox error_box(QMessageBox::Critical, "Error", message, QMessageBox::Ok); + error_box.exec(); + return; + } + std::string password = + dialog.getPassword().isEmpty() ? cryptography::default_key : dialog.getPassword().toStdString(); + + std::tuple res; + try + { + res = cryptography::encrypt(api_key, password); + } + catch(CryptographyError& e) + { + const QString message = "Error during key encryption"; + Log::error(message.toStdString()); + QMessageBox error_box(QMessageBox::Critical, "Error", message, QMessageBox::Ok); + error_box.exec(); + return; + } + ui->valid_api_key_label->setText( + ("Valid API key for user: \"" + user_info->first + "\"").c_str()); + ui->premium_user_label->setText(user_info->second ? "Account is premium" + : "Account is NOT premium"); + nexus::Api::setApiKey(api_key); + const auto& [cipher, nonce, tag] = res; + setNexusCryptographyFields(cipher, nonce, tag, dialog.getPassword().isEmpty()); + QSettings settings(QCoreApplication::applicationName()); + settings.beginGroup("nexus"); + settings.setValue("info_is_valid", true); + settings.setValue("info_is_premium", user_info->second); + settings.endGroup(); +} + +void SettingsDialog::on_change_api_pw_button_clicked() +{ + auto res = getNexusApiKeyDetails(); + if(!res) + return; + const auto [cipher, nonce, tag, is_default_pw] = *res; + auto dialog = ChangeApiPwDialog(is_default_pw, cipher, nonce, tag, this); + connect(&dialog, + &ChangeApiPwDialog::keyEncryptionUpdated, + this, + &SettingsDialog::setNexusCryptographyFields); + dialog.exec(); +} + +void SettingsDialog::on_show_api_key_button_clicked() +{ + const bool is_hidden = ui->api_key_label->text() == api_key_hidden_string; + if(!is_hidden) + { + ui->show_api_key_button->setIcon(show_icon); + ui->show_api_key_button->setToolTip("Show API Key"); + ui->api_key_label->setText(api_key_hidden_string); + return; + } + std::string api_key; + if(nexus::Api::isInitialized()) + api_key = nexus::Api::getApiKey(); + else + { + auto res = getNexusApiKeyDetails(); + if(!res) + { + const QString message = "Could not find an API key. Please enter one in the settings dialog."; + Log::error(message.toStdString()); + QMessageBox error_box(QMessageBox::Critical, "Error", message, QMessageBox::Ok); + error_box.exec(); + return; + } + const auto [cipher, nonce, tag, is_default_pw] = *res; + std::string pw = cryptography::default_key; + if(!is_default_pw) + { + EnterApiPwDialog dialog(cipher, nonce, tag, this); + dialog.exec(); + if(!dialog.wasSuccessful()) + return; + api_key = dialog.getApiKey(); + nexus::Api::setApiKey(api_key); + } + } + ui->api_key_label->setText(("API Key: " + api_key).c_str()); + ui->show_api_key_button->setIcon(hide_icon); + ui->show_api_key_button->setToolTip("Hide API Key"); +} diff --git a/src/ui/settingsdialog.h b/src/ui/settingsdialog.h new file mode 100644 index 0000000..6bd8e4a --- /dev/null +++ b/src/ui/settingsdialog.h @@ -0,0 +1,151 @@ +/*! + * \file settingsdialog.h + * \brief Header for the SettingsDialog class. + */ + +#pragma once + +#include "loot/api.h" +#include +#include +#include + + +namespace Ui +{ +class SettingsDialog; +} + +/*! + * \brief Dialog for changing various application settings. + */ +class SettingsDialog : public QDialog +{ + Q_OBJECT + +public: + /*! + * \brief Initializes the UI. + * \param parent Parent for this widget, this is passed to the constructor of QDialog. + */ + explicit SettingsDialog(QWidget* parent = nullptr); + /*! \brief Deletes the UI. */ + ~SettingsDialog(); + + /*! \brief Initializes the dialog with the settings stored on disc. */ + void init(); + + /*! + * \brief Returns true if the ask when removing a mod option has been selected. + * \return The selection. + */ + bool askRemoveMod() const; + /*! + * \brief Returns true if the ask when removing a mod from deployer option has been selected. + * \return The selection. + */ + bool askRemoveFromDeployer() const; + /*! + * \brief Returns true if the ask when removing a profile option has been selected. + * \return The selection. + */ + bool askRemoveProfile() const; + /*! + * \brief Returns true if the deploy for all option has been selected. + * \return The selection. + */ + bool deployAll() const; + /*! + * \brief Returns true if the log on error option has been selected. + * \return The selection. + */ + bool logOnError() const; + /*! + * \brief Returns true if the log on warning option has been selected. + * \return The selection. + */ + bool logOnWarning() const; + /*! + * \brief Returns true if the ask when removing a backup target option has been selected. + * \return The selection. + */ + bool askRemoveBackupTarget() const; + /*! + * \brief Returns true if the ask when removing a backup option has been selected. + * \return The selection. + */ + bool askRemoveBackup() const; + /*! + * \brief Reads the key cipher, nonce, tag and the is_default flag for the Nexus API key from + * the settings file. + * \return The key cipher, nonce, tag and the is_default flag. An empty optional if no key exists + * in the settings. + */ + static std::optional> + getNexusApiKeyDetails(); + +private slots: + /*! + * \brief Updates the settings on disc with the selection made in this dialog. + * Emits \ref settingsDialogAccepted. + */ + void on_buttonBox_accepted(); + /*! \brief Shows a file dialog to pick the unrar path. */ + void on_unrar_path_button_clicked(); + /*! + * \brief Updates the current unrar path to the given one. + * \param path New unrar path. + */ + void onUnrarPathSelected(const QString& path); + /*! \brief Opens a AddApiKeyDialog to add a new api key. */ + void on_set_api_key_button_clicked(); + /*! \brief Initializes and executes a ChangeApiPwDialog. */ + void on_change_api_pw_button_clicked(); + /*! \brief Toggles visibility of the API key. */ + void on_show_api_key_button_clicked(); + +public slots: + /*! + * \brief Writes details about the encrypted Nexus API key to the settings file. + * \param cipher Cypher text of the API key. + * \param nonce AES-GCM nonce used during encryption. + * \param tag AES-GCM authorization tag generated during encryption. + * \param uses_default_pw If true: User set no password. + */ + void setNexusCryptographyFields(const std::string& cipher, + const std::string& nonce, + const std::string& tag, + bool uses_default_pw); + +private: + /*! \brief Contains auto-generated UI elements. */ + Ui::SettingsDialog* ui; + /*! \brief True if the ask when removing a mod option has been selected. */ + bool ask_remove_mod_ = true; + /*! \brief True if the ask when removing a mod from deployer option has been selected. */ + bool ask_remove_from_deployer_ = true; + /*! \brief True if the ask when removing a profile option has been selected. */ + bool ask_remove_profile_ = true; + /*! \brief True if the deploy for all option has been selected. */ + bool deploy_all_ = true; + /*! \brief True if the log on error option has been selected. */ + bool log_on_error_ = true; + /*! \brief True if the log on warning option has been selected. */ + bool log_on_warning_ = true; + /*! \brief True if the ask when removing a backup target option has been selected. */ + bool ask_remove_backup_target_ = true; + /*! \brief True if the ask when removing a backup option has been selected. */ + bool ask_remove_backup_ = true; + /*! \brief Indicates whether the dialog has been completed. */ + bool dialog_completed_ = false; + /*! \brief Icon used to indicate that the password is to be shown. */ + const QIcon show_icon = QIcon::fromTheme("view-visible"); + /*! \brief Icon used to indicate that the password is to be hidden. */ + const QIcon hide_icon = QIcon::fromTheme("view-hidden"); + /*! \brief Text shown instead of an API key when the visibility is set to hidden. */ + const QString api_key_hidden_string = "API Key: ***"; + +signals: + /*! \brief Signals dialog completion. */ + void settingsDialogAccepted(); +}; diff --git a/src/ui/settingsdialog.ui b/src/ui/settingsdialog.ui new file mode 100644 index 0000000..d85f67f --- /dev/null +++ b/src/ui/settingsdialog.ui @@ -0,0 +1,528 @@ + + + SettingsDialog + + + + 0 + 0 + 500 + 350 + + + + Settings + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + 0 + + + + + + + Deployers + + + + + + + + Deploy mods for: + + + + + + + + All Deployers + + + + + Current Deployer + + + + + + + + + + true + + + + + 0 + 0 + 444 + 492 + + + + + + + Fallout 3 + + + + + + + + + + Fallout 4 + + + + + + + + + + Fallout 4 VR + + + + + + + + + + Fallout New Vegas + + + + + + + + + + Morrowing + + + + + + + + + + Oblivion + + + + + + + + + + Skyrim + + + + + + + + + + Skyrim SE + + + + + + + + + + Skyrim VR + + + + + + + + + + Starfield + + + + + + + + + + + + + + LOOT masterlist.yaml URLS + + + Qt::AlignCenter + + + + + + + + Messages + + + + + + Ask when... + + + + + + + removing a profle + + + + + + + removing a mod + + + + + + + removing a mod from a deployer + + + + + + + removing a backup target + + + + + + + removing a backup + + + + + + + Qt::Vertical + + + + 20 + 155 + + + + + + + + + Log + + + + + + + + Log Level: + + + + + + + + Info + + + + + Warning + + + + + Error + + + + + + + + + + Show log on warning + + + + + + + Show log on error + + + + + + + Qt::Vertical + + + + 20 + 171 + + + + + + + + + Unrar + + + + + + + + Used to extract certain rar archives. Executed as /PATH/TO/UNRAR x /PATH/TO/ARCHIVE + + + Path to Unrar binary: + + + + + + + + + + + + + + .. + + + + + + + + + Qt::Vertical + + + + 20 + 221 + + + + + + + + + NexusMods + + + + + + No valid API key set + + + + + + + Account is not premium + + + + + + + + + API Key: *** + + + true + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Set API key + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Change Password + + + + + + + + + Qt::Vertical + + + + 20 + 183 + + + + + + + + + + + + + + buttonBox + accepted() + SettingsDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + SettingsDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/ui/tablecelldelegate.cpp b/src/ui/tablecelldelegate.cpp new file mode 100644 index 0000000..38e5941 --- /dev/null +++ b/src/ui/tablecelldelegate.cpp @@ -0,0 +1,92 @@ +#include "tablecelldelegate.h" +#include "modlistmodel.h" +#include +#include + + +TableCellDelegate::TableCellDelegate(QSortFilterProxyModel* proxy, QObject* parent) : + QStyledItemDelegate{ parent }, proxy_model_(proxy), + parent_view_(static_cast(parent)) +{} + +void TableCellDelegate::paint(QPainter* painter, + const QStyleOptionViewItem& option, + const QModelIndex& view_index) const +{ + auto model_index = proxy_model_ == nullptr ? view_index : proxy_model_->mapToSource(view_index); + QStyleOption cell; + QRect rect = option.rect; + cell.rect = rect; + const bool is_even_row = view_index.row() % 2 == 0; + const int mouse_row = parent_view_->getHoverRow(); + const bool row_is_selected = + parent_view_->selectionModel()->rowIntersectsSelection(view_index.row()); + if(row_is_selected) + { + cell.palette.setBrush( + QPalette::Base, + option.palette.color(parent_view_->hasFocus() ? QPalette::Active : QPalette::Inactive, + QPalette::Highlight)); + } + else if(mouse_row == view_index.row() && !parent_view_->isInDragDrop()) + { + const float color_ratio = 0.8; + auto hl_color = option.palette.color(QPalette::Highlight); + auto bg_color = option.palette.color(is_even_row ? QPalette::Base : QPalette::AlternateBase); + auto mix_color = hl_color; + mix_color.setRed(hl_color.red() * (1 - color_ratio) + bg_color.red() * color_ratio); + mix_color.setGreen(hl_color.green() * (1 - color_ratio) + bg_color.green() * color_ratio); + mix_color.setBlue(hl_color.blue() * (1 - color_ratio) + bg_color.blue() * color_ratio); + cell.palette.setBrush(QPalette::Base, QBrush(mix_color)); + } + else if(!is_even_row) + cell.palette.setBrush(QPalette::Base, option.palette.alternateBase()); + QPixmap map(rect.width(), rect.height()); + map.fill(cell.palette.color(QPalette::Base)); + painter->drawPixmap(rect, map); + auto icon_var = model_index.data(ModListModel::icon_role); + if(icon_var.isNull()) + { + auto fg_brush_var = view_index.data(Qt::ForegroundRole); + auto old_pen = painter->pen(); + if(row_is_selected) + painter->setPen(option.palette.color(QPalette::HighlightedText)); + else if(!fg_brush_var.isNull()) + painter->setPen(fg_brush_var.value().color()); + auto icon_rect = rect; + icon_rect.setLeft(rect.left() + 3); + icon_rect.setBottom(rect.bottom() - 1); + QApplication::style()->drawItemText(painter, + icon_rect, + Qt::AlignLeft | Qt::AlignVCenter, + option.palette, + true, + model_index.data().toString()); + painter->setPen(old_pen); + } + else + { + auto icon = icon_var.value(); + const int icon_width = 16; + const int icon_height = 16; + const QPixmap icon_map = icon.pixmap(icon_width, icon_height); + const auto center = rect.center(); + painter->drawPixmap(center.x() - icon_width / 2 + 1, center.y() - icon_height / 2, icon_map); + } + if(parent_view_->isInDragDrop()) + { + if(parent_view_->mouseInUpperHalfOfRow() && mouse_row == view_index.row() || + !parent_view_->mouseInUpperHalfOfRow() && mouse_row + 1 == view_index.row()) + painter->drawLine(rect.topLeft(), rect.topRight()); + else if(!parent_view_->mouseInUpperHalfOfRow() && mouse_row == view_index.row() || + parent_view_->mouseInUpperHalfOfRow() && mouse_row - 1 == view_index.row()) + painter->drawLine(rect.bottomLeft(), rect.bottomRight()); + } + if(!parent_view_->selectionModel()->rowIntersectsSelection(view_index.row()) && + parent_view_->selectionModel()->currentIndex().row() == view_index.row()) + { + QStyleOptionFocusRect indicator; + indicator.rect = option.rect; + QApplication::style()->drawPrimitive(QStyle::PE_FrameFocusRect, &indicator, painter); + } +} diff --git a/src/ui/tablecelldelegate.h b/src/ui/tablecelldelegate.h new file mode 100644 index 0000000..b4e6eaf --- /dev/null +++ b/src/ui/tablecelldelegate.h @@ -0,0 +1,45 @@ +/*! + * \file tablecelldelegate.h + * \brief Header for the TableCellDelegate class. + */ + +#pragma once + +#include "modlistproxymodel.h" +#include "modlistview.h" +#include + + +/*! + * \brief Paints a cell containing text or an icon in a ModListView. + */ +class TableCellDelegate : public QStyledItemDelegate +{ + Q_OBJECT +public: + /*! + * \brief Constructor. + * \param parent Parent of this object. + * \param proxy Proxy model used, or nullptr if non is used. + */ + explicit TableCellDelegate(QSortFilterProxyModel* proxy, QObject* parent); + + /*! + * \brief Paints the cells background and text or icon. + * + * Uses alternating row colors and highlight / mouse hover colors, + * depending on the selection status of the cell. + * \param painter Painter used to draw. + * \param option Style options. + * \param view_index The target views index. + */ + void paint(QPainter* painter, + const QStyleOptionViewItem& option, + const QModelIndex& view_index) const override; + +protected: + /*! \brief Proxy model used to sort or filter the underlying model. */ + QSortFilterProxyModel* proxy_model_ = nullptr; + /*! \brief Convenience pointer to parent view. Points to the same address as this->parent. */ + ModListView* parent_view_; +}; diff --git a/src/ui/tablepushbutton.cpp b/src/ui/tablepushbutton.cpp new file mode 100644 index 0000000..5191677 --- /dev/null +++ b/src/ui/tablepushbutton.cpp @@ -0,0 +1,11 @@ +#include "tablepushbutton.h" + +TablePushButton::TablePushButton(int row, int col) : row_(row), col_(col) +{ + connect(this, &QPushButton::clicked, this, &TablePushButton::onClickedAt); +} + +void TablePushButton::onClickedAt() +{ + emit clickedAt(row_, col_); +} diff --git a/src/ui/tablepushbutton.h b/src/ui/tablepushbutton.h new file mode 100644 index 0000000..163a6ec --- /dev/null +++ b/src/ui/tablepushbutton.h @@ -0,0 +1,38 @@ +/*! + * \file tablepushbutton.h + * \brief Header for the TablePushButton class. + */ + +#pragma once + +#include + + +/*! + * \brief QPushButton derivative for use in a QTableWidget cell. This button knows it's + * position in the table. + */ +class TablePushButton : public QPushButton +{ + Q_OBJECT +public: + TablePushButton(int row, int col); + +private: + /*! \brief Row of the QTableWidget which contains this button. */ + const int row_; + /*! \brief Column of the QTableWidget which contains this button. */ + const int col_; + +private slots: + /*! \brief Called when the button is clicked. Emits clickedAt. */ + void onClickedAt(); + +signals: + /*! + * \brief Signals button has been clicked at a specific table position. + * \param row Table row. + * \param col Table column. + */ + void clickedAt(int row, int col); +}; diff --git a/src/ui/tabletoolbutton.cpp b/src/ui/tabletoolbutton.cpp new file mode 100644 index 0000000..faa3bf7 --- /dev/null +++ b/src/ui/tabletoolbutton.cpp @@ -0,0 +1,13 @@ +#include "tabletoolbutton.h" + +TableToolButton::TableToolButton(int row) : row_(row) {} + +void TableToolButton::onRunClicked() +{ + emit clickedRunAt(row_); +} + +void TableToolButton::onRemoveClicked() +{ + emit clickedRemoveAt(row_); +} diff --git a/src/ui/tabletoolbutton.h b/src/ui/tabletoolbutton.h new file mode 100644 index 0000000..1abc012 --- /dev/null +++ b/src/ui/tabletoolbutton.h @@ -0,0 +1,48 @@ +/*! + * \file tabletoolbutton.h + * \brief Header for the TableToolButton class. + */ + +#pragma once + +#include +#include + + +/*! + * \brief QToolButton derivative for use in a QTableWidget cell. This button knows it's + * position in the table. + */ +class TableToolButton : public QToolButton +{ + Q_OBJECT +public: + /*! + * \brief Since QTableWidget takes ownership of it's cell widgets, it is not necessary + * to take a parent object or delete this object manually. + * \param row Row in a QTableWidget which contains this button. + */ + TableToolButton(int row); + +private: + /*! \brief Row of the QTableWidget which contains this button. */ + const int row_; + +public slots: + /*! \brief Called when the Run tool action is clicked. */ + void onRunClicked(); + /*! \brief Called then the Remove tool action is clicked. */ + void onRemoveClicked(); + +signals: + /*! + * \brief Signals the Run tool action has been clicked. + * \param row Row containing this button. + */ + void clickedRunAt(int row); + /*! + * \brief Signals the Remove tool action has been clicked. + * \param row Row containing this button. + */ + void clickedRemoveAt(int row); +}; diff --git a/src/ui/tagcheckbox.cpp b/src/ui/tagcheckbox.cpp new file mode 100644 index 0000000..1619a6e --- /dev/null +++ b/src/ui/tagcheckbox.cpp @@ -0,0 +1,16 @@ +#include "tagcheckbox.h" + + +TagCheckBox::TagCheckBox(const QString& tag_name, int num_mods) +{ + tag_name_ = tag_name; + setText(tag_name + " [" + QString::number(num_mods) + " mod" + (num_mods != 1 ? "s]" : "]")); + setTristate(true); + connect(this, &QCheckBox::stateChanged, this, &TagCheckBox::onChecked); + setStyleSheet(style_sheet); +} + +void TagCheckBox::onChecked(int state) +{ + emit tagBoxChecked(tag_name_, state); +} diff --git a/src/ui/tagcheckbox.h b/src/ui/tagcheckbox.h new file mode 100644 index 0000000..9e04cc2 --- /dev/null +++ b/src/ui/tagcheckbox.h @@ -0,0 +1,64 @@ +/*! + * \file tagcheckbox.h + * \brief Header for the TagCheckBox class. + */ + +#pragma once + +#include + + +/*! + * \brief When clicked: Emits a signal containing its own text as well as the new check state. + */ +class TagCheckBox : public QCheckBox +{ + Q_OBJECT +public: + /*! + * \brief Constructor. Initializes the style sheet and sets this checkbox to a tristate box. + * \param text Display text for this checkbox. + */ + TagCheckBox(const QString& tag_name, int num_mods); + + /*! \brief The style sheet used for this checkbox. */ + // clang-format off + static constexpr char style_sheet[] = + "QCheckBox::indicator:indeterminate {" + "image: url(:/filter_accept.svg);" + "width:16px;" + "height:16px;" + "margin-left: 2;" + "margin-right: 2;" + "spacing: 5;" + "}" + "QCheckBox::indicator:checked {" + "image: url(:/filter_reject.svg);" + "width:16px;" + "height:16px;" + "margin-left: 2;" + "margin-right: 2;" + "spacing: 5;" + "}"; + // clang-format on + +private: + /*! \brief Name of the displayed tag. */ + QString tag_name_; + +private slots: + /*! + * \brief Connected to stateChanged. + * Emits tagBoxChecked with the display text and the check state as arguments. + * \param state The new check state. + */ + void onChecked(int state); + +signals: + /*! + * \brief Signals check state has been chenged. + * \param tag_name Display text of this check box. + * \param state New check state. + */ + void tagBoxChecked(QString tag_name, int state); +}; diff --git a/src/ui/validatinglineedit.cpp b/src/ui/validatinglineedit.cpp new file mode 100644 index 0000000..479f582 --- /dev/null +++ b/src/ui/validatinglineedit.cpp @@ -0,0 +1,69 @@ +#include "validatinglineedit.h" +#include "colors.h" +#include +#include + + +ValidatingLineEdit::ValidatingLineEdit(QWidget* parent, ValidationMode mode) : + QLineEdit(parent), validation_mode_(mode) +{ + updateValidation(); + connect(this, &ValidatingLineEdit::textChanged, this, &ValidatingLineEdit::onTextChanged); +} + +ValidatingLineEdit::ValidatingLineEdit(const QString& contents, + QWidget* parent, + ValidationMode mode) : + QLineEdit(contents, parent), validation_mode_(mode) +{ + onTextChanged(contents); + connect(this, &ValidatingLineEdit::textChanged, this, &ValidatingLineEdit::onTextChanged); +} + +bool ValidatingLineEdit::hasValidText() +{ + if(!isEnabled() || isHidden()) + return true; + if(validation_mode_ == VALID_NONE) + return true; + if(validation_mode_ == VALID_NOT_EMPTY) + return !text().isEmpty(); + if(validation_mode_ == VALID_CUSTOM) + return validator_(text()); + QString path = text(); + if(path.isEmpty()) + return false; + return std::filesystem::exists(path.toStdString()); +} + +void ValidatingLineEdit::setValidationMode(ValidationMode mode) +{ + validation_mode_ = mode; + updateValidation(); +} + +void ValidatingLineEdit::setCustomValidator(std::function validator) +{ + validator_ = validator; +} + +void ValidatingLineEdit::updateValidation() +{ + onTextChanged(text()); +} + +void ValidatingLineEdit::onTextChanged(const QString& new_text) +{ + auto palette = QApplication::palette(); + if(!hasValidText()) + { + QColor base = palette.color(QPalette::Base); + QColor invalid = colors::LIGHT_RED; + const float ratio = 0.5; + QColor mix_color(ratio * base.red() + (1 - ratio) * invalid.red(), + ratio * base.green() + (1 - ratio) * invalid.green(), + ratio * base.blue() + (1 - ratio) * invalid.blue()); + palette.setColor(QPalette::Base, mix_color); + } + setPalette(palette); +} diff --git a/src/ui/validatinglineedit.h b/src/ui/validatinglineedit.h new file mode 100644 index 0000000..454e774 --- /dev/null +++ b/src/ui/validatinglineedit.h @@ -0,0 +1,81 @@ +/*! + * \file validatinglineedit.h + * \brief Header for the ValidatingLineEdit class. + */ + +#pragma once + +#include + +/*! + * \brief A line edit which automatically validates its input and shows a visual + * indicator for invalid inputs. + */ +class ValidatingLineEdit : public QLineEdit +{ + Q_OBJECT +public: + /*! \brief Type of validation to be applied. */ + enum ValidationMode + { + /*! \brief All text is valid. */ + VALID_NONE = 0, + /*! \brief Requires text to not be an empty string. */ + VALID_NOT_EMPTY = 1, + /*! \brief Requires text to be a path to an existing file or directory. */ + VALID_PATH_EXISTS = 2, + /*! \brief Uses a custom validation function. */ + VALID_CUSTOM = 3 + }; + + /*! + * \brief Calls QLineEdit constructor and sets the validation mode. + * \param parent Parent widget. + * \param mode Validation mode to use. + */ + ValidatingLineEdit(QWidget* parent = nullptr, ValidationMode mode = VALID_NOT_EMPTY); + /*! + * \brief Calls QLineEdit constructor and sets the validation mode. + * \param contents Initial text for this line edit. + * \param parent Parent widget. + * \param mode Validation mode to use. + */ + ValidatingLineEdit(const QString& contents, + QWidget* parent = nullptr, + ValidationMode mode = VALID_NOT_EMPTY); + + /*! + * \brief Checks if the current text is valid. + * \return True if the text is valid. + */ + bool hasValidText(); + /*! + * \brief Changes the validation mode to the new mode. + * \param mode New validation mode. + */ + void setValidationMode(ValidationMode mode); + /*! + * \brief Sets a new custom validator function. Does not affect validation mode. + * \param validator If the validation mode is set to VALID_CUSTOM, this function will be called + * and be passed the current input text as argument, whenever the text + * is changed. The new text is valid, if the function returns true. + */ + void setCustomValidator(std::function validator); + /*! \brief Updates the visual indicator using the current text. */ + void updateValidation(); + +private: + /*! \brief Determines how the input text is validated, see \ref ValidationMode modes. */ + ValidationMode validation_mode_; + /*! \brief If the validation mode is set to VALID_CUSTOM, this function will be called + * and be passed the current input text as argument, whenever the text + * is changed. The new text is valid, if the function returns true. */ + std::function validator_ = [](QString s) { return true; }; + +private slots: + /*! + * \brief Connected to this line edits textChanged signal. Updates the visual indicator. + * \param new_text The new display text + */ + void onTextChanged(const QString& new_text); +}; diff --git a/src/ui/versionboxdelegate.cpp b/src/ui/versionboxdelegate.cpp new file mode 100644 index 0000000..e567a26 --- /dev/null +++ b/src/ui/versionboxdelegate.cpp @@ -0,0 +1,176 @@ +#include "versionboxdelegate.h" +#include "backuplistmodel.h" +#include "modlistmodel.h" +#include "modlistview.h" +#include "ui/colors.h" +#include +#include +#include +#include +#include +#include + + +VersionBoxDelegate::VersionBoxDelegate(ModListProxyModel* proxy, QObject* parent) : + QStyledItemDelegate{ parent }, proxy_model_(proxy), + parent_view_(static_cast(parent)) +{} + +QWidget* VersionBoxDelegate::createEditor(QWidget* parent, + const QStyleOptionViewItem& option, + const QModelIndex& index) const +{ + const auto versions = index.data(Qt::UserRole).value(); + if(versions.size() > 1) + { + auto box = new QComboBox(parent); + box->addItems(versions); + box->setCurrentIndex(index.data(ModListModel::active_index_role).value()); + box->adjustSize(); + box->setGeometry(option.rect); + const int cursor_x_pos = parent_view_->mapFromGlobal(QCursor::pos()).x(); + if(option.rect.right() - 18 < cursor_x_pos) + box->showPopup(); + else + box->setEditable(true); + return box; + } + else + { + auto line_edit = new QLineEdit(parent); + line_edit->setText(versions[0]); + return line_edit; + } +} + +void VersionBoxDelegate::setEditorData(QWidget* editor, const QModelIndex& index) const {} + +void VersionBoxDelegate::setModelData(QWidget* editor, + QAbstractItemModel* model, + const QModelIndex& index) const +{ + const auto versions = index.data(ModListModel::version_list_role).value(); + if(!is_backup_delegate_) + { + if(versions.size() > 1) + { + auto box = static_cast(editor); + int box_index = box->currentIndex(); + int cur_active = index.data(ModListModel::active_index_role).value(); + if(box_index != cur_active && + box_index < index.data(Qt::UserRole).value().size()) + { + const int group = index.data(ModListModel::mod_group_role).value(); + const auto ids = index.data(ModListModel::group_members_role).value>(); + emit activeGroupMemberChanged(group, ids[box_index]); + } + else if(box->currentText() != versions[cur_active]) + emit modVersionChanged(index.data(ModListModel::mod_id_role).value(), + box->currentText()); + } + else + { + const QString text = static_cast(editor)->text(); + emit modVersionChanged(index.data(ModListModel::mod_id_role).value(), text); + } + } + else + { + if(versions.size() > 1) + { + auto box = static_cast(editor); + // for some reason, if editing was completed by pressing enter, + // one item is temporarily appended to the combo box and the boxes current index is + // set to that new entry + int box_index = box->currentIndex(); + int cur_active = index.data(ModListModel::active_index_role).value(); + if(box_index != cur_active && + box_index < index.data(BackupListModel::num_backups_role).value()) + { + emit activeBackupChanged(index.row(), box_index); + } + else if(box->currentText() != versions[cur_active]) + emit backupNameEdited(index.row(), cur_active, box->currentText()); + } + else + { + const QString text = static_cast(editor)->text(); + emit backupNameEdited(index.row(), 0, text); + } + } +} + +void VersionBoxDelegate::updateEditorGeometry(QWidget* editor, + const QStyleOptionViewItem& option, + const QModelIndex& index) const +{ + editor->setGeometry(option.rect); +} + +void VersionBoxDelegate::paint(QPainter* painter, + const QStyleOptionViewItem& option, + const QModelIndex& view_index) const +{ + auto model_index = proxy_model_ == nullptr ? view_index : proxy_model_->mapToSource(view_index); + QStyleOptionComboBox box; + const QRect rect = option.rect; + box.rect = rect; + box.currentText = model_index.data().toString(); + box.editable = true; + box.state |= QStyle::State_Enabled; + const int mouse_row = parent_view_->getHoverRow(); + if(!is_backup_delegate_ && + !parent_view_->selectionModel()->rowIntersectsSelection(view_index.row())) + { + auto fg_brush = view_index.data(Qt::ForegroundRole); + if(!fg_brush.isNull()) + { + box.palette.setBrush(QPalette::WindowText, fg_brush.value()); + box.palette.setBrush(QPalette::ButtonText, fg_brush.value()); + } + } + if(parent_view_->selectionModel()->rowIntersectsSelection(view_index.row())) + { + box.palette.setBrush( + QPalette::Base, + option.palette.color(parent_view_->hasFocus() ? QPalette::Active : QPalette::Inactive, + QPalette::Highlight)); + } + else if(mouse_row == view_index.row()) + { + const float color_ratio = 0.8; + auto hl_color = option.palette.color(QPalette::Highlight); + auto bg_color = + option.palette.color(view_index.row() % 2 ? QPalette::AlternateBase : QPalette::Base); + auto mix_color = hl_color; + mix_color.setRed(hl_color.red() * (1 - color_ratio) + bg_color.red() * color_ratio); + mix_color.setGreen(hl_color.green() * (1 - color_ratio) + bg_color.green() * color_ratio); + mix_color.setBlue(hl_color.blue() * (1 - color_ratio) + bg_color.blue() * color_ratio); + box.palette.setBrush(QPalette::Base, QBrush(mix_color)); + } + else if(view_index.row() % 2) + box.palette.setBrush(QPalette::Base, option.palette.alternateBase()); + if(!is_backup_delegate_ && model_index.data(ModListModel::mod_group_role).value() >= 0 || + is_backup_delegate_ && model_index.data(BackupListModel::num_backups_role).value() > 1) + QApplication::style()->drawComplexControl(QStyle::CC_ComboBox, &box, painter); + else + { + QPixmap map(rect.width(), rect.height()); + map.fill(box.palette.color(QPalette::Base)); + painter->drawPixmap(rect, map); + } + box.editable = false; + QApplication::style()->drawControl(QStyle::CE_ComboBoxLabel, &box, painter); + if(!parent_view_->selectionModel()->rowIntersectsSelection(view_index.row()) && + parent_view_->selectionModel()->currentIndex().row() == view_index.row()) + { + QStyleOptionFocusRect indicator; + indicator.rect = option.rect; + QApplication::style()->drawPrimitive(QStyle::PE_FrameFocusRect, &indicator, painter); + } +} + +void VersionBoxDelegate::setIsBackupDelegate(bool is_backup) +{ + is_backup_delegate_ = is_backup; +} diff --git a/src/ui/versionboxdelegate.h b/src/ui/versionboxdelegate.h new file mode 100644 index 0000000..0aae535 --- /dev/null +++ b/src/ui/versionboxdelegate.h @@ -0,0 +1,115 @@ +/*! + * \file versionboxdelegate.h + * \brief Header for the VersionBoxDelegate class. + */ + +#pragma once + +#include "modlistproxymodel.h" +#include "ui/modlistview.h" +#include +#include + + +/*! + * \brief Provides either a QLineEdit or a QComboBox to edit a mods version. + */ +class VersionBoxDelegate : public QStyledItemDelegate +{ + Q_OBJECT +public: + /*! + * \brief Constructor. + * \param proxy Proxy model used to sort or filter the underlying model. + * \param parent Parent view of this delegate. + */ + explicit VersionBoxDelegate(ModListProxyModel* proxy, QObject* parent); + + /*! + * \brief Creates either a QLineEdit or a QComboBox depending on + * whether or not the mod belongs to a group. + * \param parent Parent view. + * \param option Style options. + * \param index Index at which to create the editor. + * \return + */ + QWidget* createEditor(QWidget* parent, + const QStyleOptionViewItem& option, + const QModelIndex& index) const override; + /*! + * \brief Initializes either the line edit with the mods version or the combo box + * with the versions of all mods belonging to the same group. + * \param editor Target editor. + * \param index Index for the editor. + */ + void setEditorData(QWidget* editor, const QModelIndex& index) const override; + /*! + * \brief Emits \ref modVersionChanged if the mod version has been edited or + * \ref activeGroupMemberChanged if the editor is a combo box and its index has been changed. + * \param editor Editor used to change the data. + * \param model Ignored. + * \param index Index for the edited mod version. + */ + void setModelData(QWidget* editor, + QAbstractItemModel* model, + const QModelIndex& index) const override; + /*! + * \brief Updates the given editors geometry. + * \param editor Target editor. + * \param option Style options. + * \param index Index for the editor. + */ + void updateEditorGeometry(QWidget* editor, + const QStyleOptionViewItem& option, + const QModelIndex& index) const override; + /*! + * \brief If the target index references a mod in a group: Paints an editable combo box + * at the given index into the given view. + * \param painter Painter used to draw. + * \param option Style options. + * \param view_index The target views index. + */ + void paint(QPainter* painter, + const QStyleOptionViewItem& option, + const QModelIndex& view_index) const override; + /*! + * \brief Sets if this is used to handle the backup list. + * \param is_backup True for backup, else false. + */ + void setIsBackupDelegate(bool is_backup); + +private: + /*! \brief Proxy model used to sort or filter the underlying model. */ + ModListProxyModel* proxy_model_ = nullptr; + /*! \brief Indicates if this is used to manage the backup list. */ + bool is_backup_delegate_ = false; + /*! \brief Convenience pointer to parent view. Points to the same address as this->parent. */ + ModListView* parent_view_; + +signals: + /*! + * \brief Signals that the active member of a group has changed. + * \param group Target group. + * \param new_id New active member. + */ + void activeGroupMemberChanged(int group, int new_id) const; + /*! + * \brief Signals that the version string of a mod has changed. + * \param mod_id Target mod. + * \param version New version string. + */ + void modVersionChanged(int mod_id, QString version) const; + /*! + * \brief Signals that the active backup for the current backup target has changed. + * \param target Current target. + * \param backup New active backup. + */ + void activeBackupChanged(int target, int backup) const; + /*! + * \brief Signals that the name of a backup has been edited by the user. + * \param target Target to which the backup belongs. + * \param backup The edited backup. + * \param name The new name. + */ + void backupNameEdited(int target, int backup, QString name) const; +}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..fd55b16 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,109 @@ +cmake_minimum_required(VERSION 3.25) +project(LMM_Tests LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_BUILD_TYPE RelWithDebInfo) + +# libarchive +find_package(LibArchive REQUIRED) + +# jsoncpp +find_package(PkgConfig REQUIRED) +pkg_check_modules(JSONCPP jsoncpp) + +# pugixml +find_package(pugixml REQUIRED) + +# catch2 +find_package(Catch2 3 REQUIRED) + +# cpr +find_package(cpr REQUIRED) + +# OpenSSL +find_package(OpenSSL REQUIRED) + +set(TEST_SOURCES + tests.cpp + test_installer.cpp + test_deployer.cpp + test_moddedapplication.cpp + test_utils.h + test_utils.cpp + test_fomodinstaller.cpp + test_lootdeployer.cpp + test_backupmanager.cpp + test_tagconditionnode.cpp + test_cryptography.cpp + ../src/core/deployer.h + ../src/core/deployer.cpp + ../src/core/installer.h + ../src/core/installer.cpp + ../src/core/moddedapplication.h + ../src/core/moddedapplication.cpp + ../src/core/deployerfactory.h + ../src/core/deployerfactory.cpp + ../src/core/casematchingdeployer.h + ../src/core/casematchingdeployer.cpp + ../src/core/fomod/file.h + ../src/core/fomod/plugin.h + ../src/core/fomod/plugindependency.h + ../src/core/fomod/plugingroup.h + ../src/core/fomod/plugintype.h + ../src/core/fomod/fomodinstaller.h + ../src/core/fomod/fomodinstaller.cpp + ../src/core/fomod/dependency.h + ../src/core/fomod/dependency.cpp + ../src/core/lootdeployer.h + ../src/core/lootdeployer.cpp + ../src/core/backupmanager.h + ../src/core/backupmanager.cpp + ../src/core/backuptarget.h + ../src/core/backuptarget.cpp + ../src/core/progressnode.h + ../src/core/progressnode.cpp + ../src/core/pathutils.h + ../src/core/pathutils.cpp + ../src/core/log.h + ../src/core/log.cpp + ../src/core/tagconditionnode.h + ../src/core/tagconditionnode.cpp + ../src/core/tagcondition.h + ../src/core/editmanualtagaction.h + ../src/core/editmanualtagaction.cpp + ../src/core/manualtag.h + ../src/core/manualtag.cpp + ../src/core/tag.h + ../src/core/tag.cpp + ../src/core/autotag.h + ../src/core/autotag.cpp + ../src/core/editautotagaction.h + ../src/core/editautotagaction.cpp + ../src/core/nexus/api.h + ../src/core/nexus/api.cpp + ../src/core/nexus/file.h + ../src/core/nexus/file.cpp + ../src/core/nexus/mod.h + ../src/core/nexus/mod.cpp + ../src/core/parseerror.h + ../src/core/cryptography.h + ../src/core/cryptography.cpp + ../src/core/conflictinfo.h + ../src/core/mod.h ../src/core/mod.cpp + ../src/core/modinfo.h + ../src/core/appinfo.h + ../src/core/deployerinfo.h + ../src/core/editapplicationinfo.h + ../src/core/editdeployerinfo.h + ../src/core/addmodinfo.h + ../src/core/editprofileinfo.h + ../src/core/importmodinfo.h +) + +configure_file(test_utils.h.in test_utils.h) + +add_executable(tests ${TEST_SOURCES}) +target_include_directories(tests PUBLIC "${PROJECT_BINARY_DIR}" PRIVATE ${LibArchive_INCLUDE_DIRS} PRIVATE /usr/include/loot PRIVATE ${JSONCPP_INCLUDE_DIRS}) +target_link_libraries(tests PRIVATE Catch2::Catch2WithMain PRIVATE ${JSONCPP_LIBRARIES} PRIVATE ${LibArchive_LIBRARIES} PRIVATE pugixml PRIVATE libloot.so + PRIVATE cpr::cpr PRIVATE OpenSSL::SSL) diff --git a/tests/data/app/.a.lmmbakman.json b/tests/data/app/.a.lmmbakman.json new file mode 100644 index 0000000..09bc5d7 --- /dev/null +++ b/tests/data/app/.a.lmmbakman.json @@ -0,0 +1,13 @@ +{ + "active_members" : + [ + 0 + ], + "backup_names" : + [ + "b0", + "b1" + ], + "path" : "/home/christian/workspaces/Qt/limo_github_new/tests/data/app/a", + "target_name" : "t" +} \ No newline at end of file diff --git a/tests/data/app/0.txt b/tests/data/app/0.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/app/0.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/app/a-Fil _3 b/tests/data/app/a-Fil _3 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/app/a-Fil _3 @@ -0,0 +1 @@ +dest diff --git a/tests/data/app/a.1.lmmbakman/2.txt b/tests/data/app/a.1.lmmbakman/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/app/a.1.lmmbakman/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/app/a.1.lmmbakman/a-Fil _3 b/tests/data/app/a.1.lmmbakman/a-Fil _3 new file mode 100644 index 0000000..b5dc6b8 --- /dev/null +++ b/tests/data/app/a.1.lmmbakman/a-Fil _3 @@ -0,0 +1 @@ +MODIFIED diff --git a/tests/data/app/a.1.lmmbakman/file.cfg b/tests/data/app/a.1.lmmbakman/file.cfg new file mode 100644 index 0000000..c3ad8fa --- /dev/null +++ b/tests/data/app/a.1.lmmbakman/file.cfg @@ -0,0 +1 @@ +some text diff --git a/tests/data/app/a/2.txt b/tests/data/app/a/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/app/a/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/app/a/a-Fil _3 b/tests/data/app/a/a-Fil _3 new file mode 100644 index 0000000..b5dc6b8 --- /dev/null +++ b/tests/data/app/a/a-Fil _3 @@ -0,0 +1 @@ +MODIFIED diff --git a/tests/data/app/a/file.cfg b/tests/data/app/a/file.cfg new file mode 100644 index 0000000..c3ad8fa --- /dev/null +++ b/tests/data/app/a/file.cfg @@ -0,0 +1 @@ +some text diff --git a/tests/data/app/b/3aBc b/tests/data/app/b/3aBc new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/app/b/3aBc @@ -0,0 +1 @@ +dest diff --git a/tests/data/app/c/0 b/tests/data/app/c/0 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/app/c/0 @@ -0,0 +1 @@ +dest diff --git a/tests/data/app/c/wasd b/tests/data/app/c/wasd new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/app/c/wasd @@ -0,0 +1 @@ +dest diff --git a/tests/data/source/0/0.txt b/tests/data/source/0/0.txt new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/data/source/0/0.txt @@ -0,0 +1 @@ +0 diff --git a/tests/data/source/0/1.txt b/tests/data/source/0/1.txt new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/data/source/0/1.txt @@ -0,0 +1 @@ +1 diff --git a/tests/data/source/0/a-Fil _3 b/tests/data/source/0/a-Fil _3 new file mode 100644 index 0000000..13191c3 --- /dev/null +++ b/tests/data/source/0/a-Fil _3 @@ -0,0 +1 @@ +a-Fil _3 - diff --git a/tests/data/source/0/a/0.txt b/tests/data/source/0/a/0.txt new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/data/source/0/a/0.txt @@ -0,0 +1 @@ +0 diff --git a/tests/data/source/0/a/2.txt b/tests/data/source/0/a/2.txt new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/tests/data/source/0/a/2.txt @@ -0,0 +1 @@ +2 diff --git a/tests/data/source/0/a/b/1.txt b/tests/data/source/0/a/b/1.txt new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/data/source/0/a/b/1.txt @@ -0,0 +1 @@ +1 diff --git a/tests/data/source/0/a/b/2.txt b/tests/data/source/0/a/b/2.txt new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/tests/data/source/0/a/b/2.txt @@ -0,0 +1 @@ +2 diff --git a/tests/data/source/0/b/3 b/tests/data/source/0/b/3 new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tests/data/source/0/b/3 @@ -0,0 +1 @@ +3 diff --git a/tests/data/source/0/b/3aBc b/tests/data/source/0/b/3aBc new file mode 100644 index 0000000..5a0efc0 --- /dev/null +++ b/tests/data/source/0/b/3aBc @@ -0,0 +1 @@ +3aBc diff --git a/tests/data/source/1/6 b/tests/data/source/1/6 new file mode 100644 index 0000000..4d8452f --- /dev/null +++ b/tests/data/source/1/6 @@ -0,0 +1 @@ +mod1 diff --git a/tests/data/source/1/7 b/tests/data/source/1/7 new file mode 100644 index 0000000..4d8452f --- /dev/null +++ b/tests/data/source/1/7 @@ -0,0 +1 @@ +mod1 diff --git a/tests/data/source/1/f/b c.t b/tests/data/source/1/f/b c.t new file mode 100644 index 0000000..4d8452f --- /dev/null +++ b/tests/data/source/1/f/b c.t @@ -0,0 +1 @@ +mod1 diff --git a/tests/data/source/1/f/g/0 b/tests/data/source/1/f/g/0 new file mode 100644 index 0000000..4d8452f --- /dev/null +++ b/tests/data/source/1/f/g/0 @@ -0,0 +1 @@ +mod1 diff --git a/tests/data/source/2/0 b/tests/data/source/2/0 new file mode 100644 index 0000000..fa938d9 --- /dev/null +++ b/tests/data/source/2/0 @@ -0,0 +1 @@ +mod2 diff --git a/tests/data/source/2/0.txt b/tests/data/source/2/0.txt new file mode 100644 index 0000000..fa938d9 --- /dev/null +++ b/tests/data/source/2/0.txt @@ -0,0 +1 @@ +mod2 diff --git a/tests/data/source/2/a/1.txt b/tests/data/source/2/a/1.txt new file mode 100644 index 0000000..fa938d9 --- /dev/null +++ b/tests/data/source/2/a/1.txt @@ -0,0 +1 @@ +mod2 diff --git a/tests/data/source/2/a/b/2.txt b/tests/data/source/2/a/b/2.txt new file mode 100644 index 0000000..fa938d9 --- /dev/null +++ b/tests/data/source/2/a/b/2.txt @@ -0,0 +1 @@ +mod2 diff --git a/tests/data/source/2/b/3 b/tests/data/source/2/b/3 new file mode 100644 index 0000000..dd904c8 --- /dev/null +++ b/tests/data/source/2/b/3 @@ -0,0 +1 @@ +mod2 diff --git a/tests/data/source/app/0.txt b/tests/data/source/app/0.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/source/app/0.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/source/app/a-Fil _3 b/tests/data/source/app/a-Fil _3 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/source/app/a-Fil _3 @@ -0,0 +1 @@ +dest diff --git a/tests/data/source/app/a/2.txt b/tests/data/source/app/a/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/source/app/a/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/source/app/a/file.cfg b/tests/data/source/app/a/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/source/app/a/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/source/app/b/3aBc b/tests/data/source/app/b/3aBc new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/source/app/b/3aBc @@ -0,0 +1 @@ +dest diff --git a/tests/data/source/app/c/0 b/tests/data/source/app/c/0 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/source/app/c/0 @@ -0,0 +1 @@ +dest diff --git a/tests/data/source/app/c/wasd b/tests/data/source/app/c/wasd new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/source/app/c/wasd @@ -0,0 +1 @@ +dest diff --git a/tests/data/source/auto_tags/0/asd b/tests/data/source/auto_tags/0/asd new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/auto_tags/0/dir/12argdar3wahtdh b/tests/data/source/auto_tags/0/dir/12argdar3wahtdh new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/auto_tags/0/dir/abc/a b/tests/data/source/auto_tags/0/dir/abc/a new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/auto_tags/0/dir/abc/abc_123 b/tests/data/source/auto_tags/0/dir/abc/abc_123 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/auto_tags/0/dir/s.txt b/tests/data/source/auto_tags/0/dir/s.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/auto_tags/0/dir/s.txt @@ -0,0 +1 @@ + diff --git a/tests/data/source/auto_tags/0/dir/some_12_file_abc b/tests/data/source/auto_tags/0/dir/some_12_file_abc new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/auto_tags/0/rw3 b/tests/data/source/auto_tags/0/rw3 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/auto_tags/1/asd b/tests/data/source/auto_tags/1/asd new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/auto_tags/1/dir/not_an_image.png b/tests/data/source/auto_tags/1/dir/not_an_image.png new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/auto_tags/1/dir/stxt b/tests/data/source/auto_tags/1/dir/stxt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/auto_tags/1/dir/stxt @@ -0,0 +1 @@ + diff --git a/tests/data/source/auto_tags/1/rw3 b/tests/data/source/auto_tags/1/rw3 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/auto_tags/2/j/A_file b/tests/data/source/auto_tags/2/j/A_file new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/auto_tags/2/qwert b/tests/data/source/auto_tags/2/qwert new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/auto_tags/2/unique_file b/tests/data/source/auto_tags/2/unique_file new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/bak_man/a-Fil _3 b/tests/data/source/bak_man/a-Fil _3 new file mode 100644 index 0000000..b5dc6b8 --- /dev/null +++ b/tests/data/source/bak_man/a-Fil _3 @@ -0,0 +1 @@ +MODIFIED diff --git a/tests/data/source/bak_man/file.cfg b/tests/data/source/bak_man/file.cfg new file mode 100644 index 0000000..c3ad8fa --- /dev/null +++ b/tests/data/source/bak_man/file.cfg @@ -0,0 +1 @@ +some text diff --git a/tests/data/source/case_matching/0/0.txt b/tests/data/source/case_matching/0/0.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/0/0.txt @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/0/0file b/tests/data/source/case_matching/0/0file new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/case_matching/0/1.txt b/tests/data/source/case_matching/0/1.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/0/1.txt @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/0/a-Fil _3 b/tests/data/source/case_matching/0/a-Fil _3 new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/0/a-Fil _3 @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/0/b/3aBc b/tests/data/source/case_matching/0/b/3aBc new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/0/b/3aBc @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/0/new_dir/aB/someFile.txt b/tests/data/source/case_matching/0/new_dir/aB/someFile.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/0/new_dir/aB/someFile.txt @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/0/new_dir/new_file b/tests/data/source/case_matching/0/new_dir/new_file new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/0/new_dir/new_file @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/1/0.txt b/tests/data/source/case_matching/1/0.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/1/0.txt @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/1/1.txt b/tests/data/source/case_matching/1/1.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/1/1.txt @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/1/123/456/789 b/tests/data/source/case_matching/1/123/456/789 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/case_matching/1/a-Fil _3 b/tests/data/source/case_matching/1/a-Fil _3 new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/1/a-Fil _3 @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/1/b/3aBc b/tests/data/source/case_matching/1/b/3aBc new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/1/b/3aBc @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/1/new_dir/aB/someFile.txt b/tests/data/source/case_matching/1/new_dir/aB/someFile.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/1/new_dir/aB/someFile.txt @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/1/new_dir/new_file b/tests/data/source/case_matching/1/new_dir/new_file new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/1/new_dir/new_file @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/orig_0/0.TxT b/tests/data/source/case_matching/orig_0/0.TxT new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/orig_0/0.TxT @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/orig_0/0file b/tests/data/source/case_matching/orig_0/0file new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/case_matching/orig_0/1.txt b/tests/data/source/case_matching/orig_0/1.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/orig_0/1.txt @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/orig_0/B/3abc b/tests/data/source/case_matching/orig_0/B/3abc new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/orig_0/B/3abc @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/orig_0/a-fil _3 b/tests/data/source/case_matching/orig_0/a-fil _3 new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/orig_0/a-fil _3 @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/orig_0/new_dir/aB/someFile.txt b/tests/data/source/case_matching/orig_0/new_dir/aB/someFile.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/orig_0/new_dir/aB/someFile.txt @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/orig_0/new_dir/new_file b/tests/data/source/case_matching/orig_0/new_dir/new_file new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/orig_0/new_dir/new_file @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/orig_1/0.TxT b/tests/data/source/case_matching/orig_1/0.TxT new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/orig_1/0.TxT @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/orig_1/1.txt b/tests/data/source/case_matching/orig_1/1.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/orig_1/1.txt @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/orig_1/123/456/789 b/tests/data/source/case_matching/orig_1/123/456/789 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/case_matching/orig_1/B/3abc b/tests/data/source/case_matching/orig_1/B/3abc new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/orig_1/B/3abc @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/orig_1/a-fil _3 b/tests/data/source/case_matching/orig_1/a-fil _3 new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/orig_1/a-fil _3 @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/orig_1/neW_dir/NeW_fiLe b/tests/data/source/case_matching/orig_1/neW_dir/NeW_fiLe new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/orig_1/neW_dir/NeW_fiLe @@ -0,0 +1 @@ + diff --git a/tests/data/source/case_matching/orig_1/neW_dir/ab/SOMefile.txt b/tests/data/source/case_matching/orig_1/neW_dir/ab/SOMefile.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/case_matching/orig_1/neW_dir/ab/SOMefile.txt @@ -0,0 +1 @@ + diff --git a/tests/data/source/conflicts/0/0 b/tests/data/source/conflicts/0/0 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/conflicts/1/2 b/tests/data/source/conflicts/1/2 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/conflicts/2/0 b/tests/data/source/conflicts/2/0 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/conflicts/2/2 b/tests/data/source/conflicts/2/2 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/conflicts/3/3 b/tests/data/source/conflicts/3/3 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/conflicts/4/4 b/tests/data/source/conflicts/4/4 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/conflicts/5/2 b/tests/data/source/conflicts/5/2 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/conflicts/5/3 b/tests/data/source/conflicts/5/3 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/conflicts/5/5 b/tests/data/source/conflicts/5/5 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/conflicts/6/4 b/tests/data/source/conflicts/6/4 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/conflicts/6/6 b/tests/data/source/conflicts/6/6 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/conflicts/7/7 b/tests/data/source/conflicts/7/7 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/fomod/another_example.plugin b/tests/data/source/fomod/another_example.plugin new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/fomod/example.plugin b/tests/data/source/fomod/example.plugin new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/fomod/example.plugin @@ -0,0 +1 @@ + diff --git a/tests/data/source/fomod/fomod/matrix.xml b/tests/data/source/fomod/fomod/matrix.xml new file mode 100644 index 0000000..dd6d8a4 --- /dev/null +++ b/tests/data/source/fomod/fomod/matrix.xml @@ -0,0 +1,123 @@ + + + Example Mod + + + + + + + + + + + + + + + + + + Select this to install Option A! + + + selected + + + + + + + + Select this to install Option B! + + + selected + + + + + + + + + + + + + + Select this to install Texture Blue! + + + selected + + + + + + + + Select this to install Texture Red! + + + selected + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/source/fomod/fomod/simple.xml b/tests/data/source/fomod/fomod/simple.xml new file mode 100644 index 0000000..8cdb4c1 --- /dev/null +++ b/tests/data/source/fomod/fomod/simple.xml @@ -0,0 +1,24 @@ + + + Example Mod + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/source/fomod/fomod/steps.xml b/tests/data/source/fomod/fomod/steps.xml new file mode 100644 index 0000000..226345e --- /dev/null +++ b/tests/data/source/fomod/fomod/steps.xml @@ -0,0 +1,126 @@ + + + Example Mod + + + + + + + + + + + + + + + + + + Select this to install Option A! + + + + + + selected + + + + + + + + Select this to install Option B! + + + + + + selected + + + + + + + + + + + + + + + + + + + + + Select this to install Texture Blue! + + + + + + + + + + + Select this to install Texture Red! + + + + + + + + + + + + + + + + + + + + + + + + Select this to install Texture Blue! + + + + + + + + + + + Select this to install Texture Red! + + + + + + + + + + + + + + + + + diff --git a/tests/data/source/fomod/texture_red_b b/tests/data/source/fomod/texture_red_b new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/loot/loadorder.txt b/tests/data/source/loot/loadorder.txt new file mode 100644 index 0000000..8fa34f5 --- /dev/null +++ b/tests/data/source/loot/loadorder.txt @@ -0,0 +1,5 @@ +Morrowind.esm +p.esp +a.esp +b.esp +c.esp diff --git a/tests/data/source/loot/plugins.txt b/tests/data/source/loot/plugins.txt new file mode 100644 index 0000000..882a98a --- /dev/null +++ b/tests/data/source/loot/plugins.txt @@ -0,0 +1,3 @@ +*a.esp +*b.esp +c.esp diff --git a/tests/data/source/mod0.tar.gz b/tests/data/source/mod0.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..e17b796ede7035e3fd1a6a65d561fbe353a56174 GIT binary patch literal 348 zcmV-i0i*sOiwFSy9c5$y1MQj1PQx$|MYHBB`~xNP@MF)Szk%Fth=f?QLE`gC(h{Lc z5P2~!$oFi#NF}?yJ$5{k`_3F9N-4N$D*W#Q;Ubi}GcoeTC$>$>;zm`~GE zjxVR-JP!HohtF+2zkZK({3qo8VEzv%$$x$@vO_O^vw!h4OUf%z`P=Nn6Ao$-j z+5Ge`^#5}H3wiio1DyYDV_*F#tN(E*Zg#}onw3d4>$RH!v0@}Iei=f z?K1gqK1a#>Uy!uy{|WcMDk$V{8vE)`;r&mR{%?u`|7(EyPup1cerJmOSGo9){!bOq u|81MR+Yhhq+4K^y=|Ai0f4ug8l;M93zVdfFYy%bNl?^1s280^GAie{Z2LSq(O}GF6 literal 0 HcmV?d00001 diff --git a/tests/data/source/mod2.tar.gz b/tests/data/source/mod2.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..3240c114e50b0afa4e67cbe5b2bbef531194beb3 GIT binary patch literal 259 zcmV+e0sQ_SiwFRUuV`cd1MQf>3c@f9hI{rY`UEyj)Ao6)9`vBXHo?cY)iFd+=^+(q zzDouhG+X$;mL;Ru0e~32&ecW_&vijtB4;sJkEp@Ae66(SZ%wJoIHe)CvfU4daXO^a zo9DjWpWkDte?l^LJzXc(HS+fefj-UOzs#Qyg3`d+)lBE}Kc08E`gIe+&q!v&z5~?w zKOhwUdnf*HLYY4@?*KLaf6SiU--3;C>}!1Ye|Vig z0f_%wQ2eiD1L&3(8LK(}k#i;g)=U1IAm_in0rtOtmH8)I@*jhj{I@{zF9?FrCpXKd JFEjui007WmhBp8J literal 0 HcmV?d00001 diff --git a/tests/data/source/split/mod/123 b/tests/data/source/split/mod/123 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/split/mod/D/d.txt b/tests/data/source/split/mod/D/d.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/split/mod/D/d.txt @@ -0,0 +1 @@ + diff --git a/tests/data/source/split/mod/a/B/123/123.txt b/tests/data/source/split/mod/a/B/123/123.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/source/split/mod/a/B/123/123.txt @@ -0,0 +1 @@ + diff --git a/tests/data/source/split/mod/a/B/wer b/tests/data/source/split/mod/a/B/wer new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/split/mod/a/C/ghj b/tests/data/source/split/mod/a/C/ghj new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/source/split/mod/a/abc b/tests/data/source/split/mod/a/abc new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/bak_man/change_bak/.a-Fil _3.lmmbakman.json b/tests/data/target/bak_man/change_bak/.a-Fil _3.lmmbakman.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/bak_man/change_bak/.a.lmmbakman.json b/tests/data/target/bak_man/change_bak/.a.lmmbakman.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/bak_man/change_bak/0.txt b/tests/data/target/bak_man/change_bak/0.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/change_bak/0.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/change_bak/a-Fil _3 b/tests/data/target/bak_man/change_bak/a-Fil _3 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/change_bak/a-Fil _3 @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/change_bak/a-Fil _3.0.lmmbakman b/tests/data/target/bak_man/change_bak/a-Fil _3.0.lmmbakman new file mode 100644 index 0000000..b5dc6b8 --- /dev/null +++ b/tests/data/target/bak_man/change_bak/a-Fil _3.0.lmmbakman @@ -0,0 +1 @@ +MODIFIED diff --git a/tests/data/target/bak_man/change_bak/a.0.lmmbakman/file.cfg b/tests/data/target/bak_man/change_bak/a.0.lmmbakman/file.cfg new file mode 100644 index 0000000..c3ad8fa --- /dev/null +++ b/tests/data/target/bak_man/change_bak/a.0.lmmbakman/file.cfg @@ -0,0 +1 @@ +some text diff --git a/tests/data/target/bak_man/change_bak/a/2.txt b/tests/data/target/bak_man/change_bak/a/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/change_bak/a/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/change_bak/a/file.cfg b/tests/data/target/bak_man/change_bak/a/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/change_bak/a/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/change_bak/b/3aBc b/tests/data/target/bak_man/change_bak/b/3aBc new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/change_bak/b/3aBc @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/change_bak/c/0 b/tests/data/target/bak_man/change_bak/c/0 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/change_bak/c/0 @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/change_bak/c/wasd b/tests/data/target/bak_man/change_bak/c/wasd new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/change_bak/c/wasd @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/create_bak/.0.txt.lmmbakman.json b/tests/data/target/bak_man/create_bak/.0.txt.lmmbakman.json new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/create_bak/.0.txt.lmmbakman.json @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/create_bak/.a.lmmbakman.json b/tests/data/target/bak_man/create_bak/.a.lmmbakman.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/bak_man/create_bak/0.txt b/tests/data/target/bak_man/create_bak/0.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/create_bak/0.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/create_bak/0.txt.1.lmmbakman b/tests/data/target/bak_man/create_bak/0.txt.1.lmmbakman new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/create_bak/0.txt.1.lmmbakman @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/create_bak/a-Fil _3 b/tests/data/target/bak_man/create_bak/a-Fil _3 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/create_bak/a-Fil _3 @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/create_bak/a.1.lmmbakman/2.txt b/tests/data/target/bak_man/create_bak/a.1.lmmbakman/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/create_bak/a.1.lmmbakman/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/create_bak/a.1.lmmbakman/file.cfg b/tests/data/target/bak_man/create_bak/a.1.lmmbakman/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/create_bak/a.1.lmmbakman/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/create_bak/a/2.txt b/tests/data/target/bak_man/create_bak/a/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/create_bak/a/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/create_bak/a/file.cfg b/tests/data/target/bak_man/create_bak/a/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/create_bak/a/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/create_bak/b/3aBc b/tests/data/target/bak_man/create_bak/b/3aBc new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/create_bak/b/3aBc @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/create_bak/c/0 b/tests/data/target/bak_man/create_bak/c/0 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/create_bak/c/0 @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/create_bak/c/wasd b/tests/data/target/bak_man/create_bak/c/wasd new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/create_bak/c/wasd @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/invalid_state/.a-Fil _3.lmmbakman.json b/tests/data/target/bak_man/invalid_state/.a-Fil _3.lmmbakman.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/bak_man/invalid_state/.a.lmmbakman.json b/tests/data/target/bak_man/invalid_state/.a.lmmbakman.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/bak_man/invalid_state/0.txt b/tests/data/target/bak_man/invalid_state/0.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/invalid_state/0.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/invalid_state/a-Fil _3 b/tests/data/target/bak_man/invalid_state/a-Fil _3 new file mode 100644 index 0000000..b5dc6b8 --- /dev/null +++ b/tests/data/target/bak_man/invalid_state/a-Fil _3 @@ -0,0 +1 @@ +MODIFIED diff --git a/tests/data/target/bak_man/invalid_state/a.0.lmmbakman/2.txt b/tests/data/target/bak_man/invalid_state/a.0.lmmbakman/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/invalid_state/a.0.lmmbakman/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/invalid_state/a.0.lmmbakman/file.cfg b/tests/data/target/bak_man/invalid_state/a.0.lmmbakman/file.cfg new file mode 100644 index 0000000..c3ad8fa --- /dev/null +++ b/tests/data/target/bak_man/invalid_state/a.0.lmmbakman/file.cfg @@ -0,0 +1 @@ +some text diff --git a/tests/data/target/bak_man/invalid_state/a.1.lmmbakman/2.txt b/tests/data/target/bak_man/invalid_state/a.1.lmmbakman/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/invalid_state/a.1.lmmbakman/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/invalid_state/a.15.lmmbakmanOLD/2.txt b/tests/data/target/bak_man/invalid_state/a.15.lmmbakmanOLD/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/invalid_state/a.15.lmmbakmanOLD/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/invalid_state/a.15.lmmbakmanOLD/file.cfg b/tests/data/target/bak_man/invalid_state/a.15.lmmbakmanOLD/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/invalid_state/a.15.lmmbakmanOLD/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/invalid_state/a.3.lmmbakman/2.txt b/tests/data/target/bak_man/invalid_state/a.3.lmmbakman/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/invalid_state/a.3.lmmbakman/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/invalid_state/a.3.lmmbakman/file.cfg b/tests/data/target/bak_man/invalid_state/a.3.lmmbakman/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/invalid_state/a.3.lmmbakman/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/invalid_state/a.3.lmmbakman/newfile b/tests/data/target/bak_man/invalid_state/a.3.lmmbakman/newfile new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/invalid_state/a.3.lmmbakman/newfile @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/invalid_state/a.8.lmmbakmanOLD/2.txt b/tests/data/target/bak_man/invalid_state/a.8.lmmbakmanOLD/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/invalid_state/a.8.lmmbakmanOLD/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/invalid_state/a.8.lmmbakmanOLD/file.cfg b/tests/data/target/bak_man/invalid_state/a.8.lmmbakmanOLD/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/invalid_state/a.8.lmmbakmanOLD/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/invalid_state/a/2.txt b/tests/data/target/bak_man/invalid_state/a/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/invalid_state/a/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/invalid_state/a/file.cfg b/tests/data/target/bak_man/invalid_state/a/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/invalid_state/a/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/invalid_state/b/3aBc b/tests/data/target/bak_man/invalid_state/b/3aBc new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/invalid_state/b/3aBc @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/invalid_state/c/0 b/tests/data/target/bak_man/invalid_state/c/0 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/invalid_state/c/0 @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/invalid_state/c/wasd b/tests/data/target/bak_man/invalid_state/c/wasd new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/invalid_state/c/wasd @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/overwrite0/2.txt b/tests/data/target/bak_man/overwrite0/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/overwrite0/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/overwrite1/2.txt b/tests/data/target/bak_man/overwrite1/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/overwrite1/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/overwrite1/a-Fil _3 b/tests/data/target/bak_man/overwrite1/a-Fil _3 new file mode 100644 index 0000000..b5dc6b8 --- /dev/null +++ b/tests/data/target/bak_man/overwrite1/a-Fil _3 @@ -0,0 +1 @@ +MODIFIED diff --git a/tests/data/target/bak_man/overwrite1/file.cfg b/tests/data/target/bak_man/overwrite1/file.cfg new file mode 100644 index 0000000..c3ad8fa --- /dev/null +++ b/tests/data/target/bak_man/overwrite1/file.cfg @@ -0,0 +1 @@ +some text diff --git a/tests/data/target/bak_man/profiles_0/.a-Fil _3.lmmbakman.json b/tests/data/target/bak_man/profiles_0/.a-Fil _3.lmmbakman.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/bak_man/profiles_0/.a.lmmbakman.json b/tests/data/target/bak_man/profiles_0/.a.lmmbakman.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/bak_man/profiles_0/0.txt b/tests/data/target/bak_man/profiles_0/0.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_0/0.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_0/a-Fil _3 b/tests/data/target/bak_man/profiles_0/a-Fil _3 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_0/a-Fil _3 @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_0/a-Fil _3.0.lmmbakman b/tests/data/target/bak_man/profiles_0/a-Fil _3.0.lmmbakman new file mode 100644 index 0000000..b5dc6b8 --- /dev/null +++ b/tests/data/target/bak_man/profiles_0/a-Fil _3.0.lmmbakman @@ -0,0 +1 @@ +MODIFIED diff --git a/tests/data/target/bak_man/profiles_0/a.0.lmmbakman/2.txt b/tests/data/target/bak_man/profiles_0/a.0.lmmbakman/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_0/a.0.lmmbakman/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_0/a.0.lmmbakman/file.cfg b/tests/data/target/bak_man/profiles_0/a.0.lmmbakman/file.cfg new file mode 100644 index 0000000..c3ad8fa --- /dev/null +++ b/tests/data/target/bak_man/profiles_0/a.0.lmmbakman/file.cfg @@ -0,0 +1 @@ +some text diff --git a/tests/data/target/bak_man/profiles_0/a.1.lmmbakman/2.txt b/tests/data/target/bak_man/profiles_0/a.1.lmmbakman/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_0/a.1.lmmbakman/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_0/a.3.lmmbakman/file.cfg b/tests/data/target/bak_man/profiles_0/a.3.lmmbakman/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_0/a.3.lmmbakman/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_0/a.4.lmmbakman/2.txt b/tests/data/target/bak_man/profiles_0/a.4.lmmbakman/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_0/a.4.lmmbakman/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_0/a.4.lmmbakman/file.cfg b/tests/data/target/bak_man/profiles_0/a.4.lmmbakman/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_0/a.4.lmmbakman/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_0/a.4.lmmbakman/newfile b/tests/data/target/bak_man/profiles_0/a.4.lmmbakman/newfile new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_0/a.4.lmmbakman/newfile @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_0/a/2.txt b/tests/data/target/bak_man/profiles_0/a/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_0/a/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_0/a/file.cfg b/tests/data/target/bak_man/profiles_0/a/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_0/a/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_0/b/3aBc b/tests/data/target/bak_man/profiles_0/b/3aBc new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_0/b/3aBc @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_0/c/0 b/tests/data/target/bak_man/profiles_0/c/0 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_0/c/0 @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_0/c/wasd b/tests/data/target/bak_man/profiles_0/c/wasd new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_0/c/wasd @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_1/.a-Fil _3.lmmbakman.json b/tests/data/target/bak_man/profiles_1/.a-Fil _3.lmmbakman.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/bak_man/profiles_1/.a.lmmbakman.json b/tests/data/target/bak_man/profiles_1/.a.lmmbakman.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/bak_man/profiles_1/0.txt b/tests/data/target/bak_man/profiles_1/0.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_1/0.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_1/a-Fil _3 b/tests/data/target/bak_man/profiles_1/a-Fil _3 new file mode 100644 index 0000000..b5dc6b8 --- /dev/null +++ b/tests/data/target/bak_man/profiles_1/a-Fil _3 @@ -0,0 +1 @@ +MODIFIED diff --git a/tests/data/target/bak_man/profiles_1/a-Fil _3.1.lmmbakman b/tests/data/target/bak_man/profiles_1/a-Fil _3.1.lmmbakman new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_1/a-Fil _3.1.lmmbakman @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_1/a.0.lmmbakman/2.txt b/tests/data/target/bak_man/profiles_1/a.0.lmmbakman/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_1/a.0.lmmbakman/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_1/a.0.lmmbakman/file.cfg b/tests/data/target/bak_man/profiles_1/a.0.lmmbakman/file.cfg new file mode 100644 index 0000000..c3ad8fa --- /dev/null +++ b/tests/data/target/bak_man/profiles_1/a.0.lmmbakman/file.cfg @@ -0,0 +1 @@ +some text diff --git a/tests/data/target/bak_man/profiles_1/a.2.lmmbakman/2.txt b/tests/data/target/bak_man/profiles_1/a.2.lmmbakman/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_1/a.2.lmmbakman/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_1/a.2.lmmbakman/file.cfg b/tests/data/target/bak_man/profiles_1/a.2.lmmbakman/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_1/a.2.lmmbakman/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_1/a.3.lmmbakman/file.cfg b/tests/data/target/bak_man/profiles_1/a.3.lmmbakman/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_1/a.3.lmmbakman/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_1/a.4.lmmbakman/2.txt b/tests/data/target/bak_man/profiles_1/a.4.lmmbakman/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_1/a.4.lmmbakman/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_1/a.4.lmmbakman/file.cfg b/tests/data/target/bak_man/profiles_1/a.4.lmmbakman/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_1/a.4.lmmbakman/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_1/a.4.lmmbakman/newfile b/tests/data/target/bak_man/profiles_1/a.4.lmmbakman/newfile new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_1/a.4.lmmbakman/newfile @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_1/a/2.txt b/tests/data/target/bak_man/profiles_1/a/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_1/a/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_1/b/3aBc b/tests/data/target/bak_man/profiles_1/b/3aBc new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_1/b/3aBc @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_1/c/0 b/tests/data/target/bak_man/profiles_1/c/0 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_1/c/0 @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_1/c/wasd b/tests/data/target/bak_man/profiles_1/c/wasd new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_1/c/wasd @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_2/.a-Fil _3.lmmbakman.json b/tests/data/target/bak_man/profiles_2/.a-Fil _3.lmmbakman.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/bak_man/profiles_2/.a.lmmbakman.json b/tests/data/target/bak_man/profiles_2/.a.lmmbakman.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/bak_man/profiles_2/0.txt b/tests/data/target/bak_man/profiles_2/0.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_2/0.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_2/a-Fil _3 b/tests/data/target/bak_man/profiles_2/a-Fil _3 new file mode 100644 index 0000000..b5dc6b8 --- /dev/null +++ b/tests/data/target/bak_man/profiles_2/a-Fil _3 @@ -0,0 +1 @@ +MODIFIED diff --git a/tests/data/target/bak_man/profiles_2/a-Fil _3.1.lmmbakman b/tests/data/target/bak_man/profiles_2/a-Fil _3.1.lmmbakman new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_2/a-Fil _3.1.lmmbakman @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_2/a.1.lmmbakman/2.txt b/tests/data/target/bak_man/profiles_2/a.1.lmmbakman/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_2/a.1.lmmbakman/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_2/a.2.lmmbakman/2.txt b/tests/data/target/bak_man/profiles_2/a.2.lmmbakman/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_2/a.2.lmmbakman/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_2/a.2.lmmbakman/file.cfg b/tests/data/target/bak_man/profiles_2/a.2.lmmbakman/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_2/a.2.lmmbakman/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_2/a.3.lmmbakman/file.cfg b/tests/data/target/bak_man/profiles_2/a.3.lmmbakman/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_2/a.3.lmmbakman/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_2/a.4.lmmbakman/2.txt b/tests/data/target/bak_man/profiles_2/a.4.lmmbakman/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_2/a.4.lmmbakman/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_2/a.4.lmmbakman/file.cfg b/tests/data/target/bak_man/profiles_2/a.4.lmmbakman/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_2/a.4.lmmbakman/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_2/a.4.lmmbakman/newfile b/tests/data/target/bak_man/profiles_2/a.4.lmmbakman/newfile new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_2/a.4.lmmbakman/newfile @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_2/a/2.txt b/tests/data/target/bak_man/profiles_2/a/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_2/a/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_2/a/file.cfg b/tests/data/target/bak_man/profiles_2/a/file.cfg new file mode 100644 index 0000000..c3ad8fa --- /dev/null +++ b/tests/data/target/bak_man/profiles_2/a/file.cfg @@ -0,0 +1 @@ +some text diff --git a/tests/data/target/bak_man/profiles_2/b/3aBc b/tests/data/target/bak_man/profiles_2/b/3aBc new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_2/b/3aBc @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_2/c/0 b/tests/data/target/bak_man/profiles_2/c/0 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_2/c/0 @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/profiles_2/c/wasd b/tests/data/target/bak_man/profiles_2/c/wasd new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/profiles_2/c/wasd @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_0/.a.lmmbakman.json b/tests/data/target/bak_man/remove_bak_0/.a.lmmbakman.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/bak_man/remove_bak_0/0.txt b/tests/data/target/bak_man/remove_bak_0/0.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_0/0.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_0/a-Fil _3 b/tests/data/target/bak_man/remove_bak_0/a-Fil _3 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_0/a-Fil _3 @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_0/a.1.lmmbakman/2.txt b/tests/data/target/bak_man/remove_bak_0/a.1.lmmbakman/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_0/a.1.lmmbakman/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_0/a.2.lmmbakman/file.cfg b/tests/data/target/bak_man/remove_bak_0/a.2.lmmbakman/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_0/a.2.lmmbakman/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_0/a.3.lmmbakman/2.txt b/tests/data/target/bak_man/remove_bak_0/a.3.lmmbakman/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_0/a.3.lmmbakman/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_0/a.3.lmmbakman/file.cfg b/tests/data/target/bak_man/remove_bak_0/a.3.lmmbakman/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_0/a.3.lmmbakman/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_0/a.3.lmmbakman/newfile b/tests/data/target/bak_man/remove_bak_0/a.3.lmmbakman/newfile new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_0/a.3.lmmbakman/newfile @@ -0,0 +1 @@ + diff --git a/tests/data/target/bak_man/remove_bak_0/a/2.txt b/tests/data/target/bak_man/remove_bak_0/a/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_0/a/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_0/a/file.cfg b/tests/data/target/bak_man/remove_bak_0/a/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_0/a/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_0/b/3aBc b/tests/data/target/bak_man/remove_bak_0/b/3aBc new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_0/b/3aBc @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_0/c/0 b/tests/data/target/bak_man/remove_bak_0/c/0 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_0/c/0 @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_0/c/wasd b/tests/data/target/bak_man/remove_bak_0/c/wasd new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_0/c/wasd @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_1/.a.lmmbakman.json b/tests/data/target/bak_man/remove_bak_1/.a.lmmbakman.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/bak_man/remove_bak_1/0.txt b/tests/data/target/bak_man/remove_bak_1/0.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_1/0.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_1/a-Fil _3 b/tests/data/target/bak_man/remove_bak_1/a-Fil _3 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_1/a-Fil _3 @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_1/a.1.lmmbakman/file.cfg b/tests/data/target/bak_man/remove_bak_1/a.1.lmmbakman/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_1/a.1.lmmbakman/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_1/a.2.lmmbakman/2.txt b/tests/data/target/bak_man/remove_bak_1/a.2.lmmbakman/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_1/a.2.lmmbakman/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_1/a.2.lmmbakman/file.cfg b/tests/data/target/bak_man/remove_bak_1/a.2.lmmbakman/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_1/a.2.lmmbakman/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_1/a.2.lmmbakman/newfile b/tests/data/target/bak_man/remove_bak_1/a.2.lmmbakman/newfile new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_1/a.2.lmmbakman/newfile @@ -0,0 +1 @@ + diff --git a/tests/data/target/bak_man/remove_bak_1/a/2.txt b/tests/data/target/bak_man/remove_bak_1/a/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_1/a/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_1/b/3aBc b/tests/data/target/bak_man/remove_bak_1/b/3aBc new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_1/b/3aBc @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_1/c/0 b/tests/data/target/bak_man/remove_bak_1/c/0 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_1/c/0 @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_1/c/wasd b/tests/data/target/bak_man/remove_bak_1/c/wasd new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_1/c/wasd @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_2/.a.lmmbakman.json b/tests/data/target/bak_man/remove_bak_2/.a.lmmbakman.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/bak_man/remove_bak_2/0.txt b/tests/data/target/bak_man/remove_bak_2/0.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_2/0.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_2/a-Fil _3 b/tests/data/target/bak_man/remove_bak_2/a-Fil _3 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_2/a-Fil _3 @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_2/a.1.lmmbakman/2.txt b/tests/data/target/bak_man/remove_bak_2/a.1.lmmbakman/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_2/a.1.lmmbakman/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_2/a.1.lmmbakman/file.cfg b/tests/data/target/bak_man/remove_bak_2/a.1.lmmbakman/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_2/a.1.lmmbakman/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_2/a.1.lmmbakman/newfile b/tests/data/target/bak_man/remove_bak_2/a.1.lmmbakman/newfile new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_2/a.1.lmmbakman/newfile @@ -0,0 +1 @@ + diff --git a/tests/data/target/bak_man/remove_bak_2/a/2.txt b/tests/data/target/bak_man/remove_bak_2/a/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_2/a/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_2/b/3aBc b/tests/data/target/bak_man/remove_bak_2/b/3aBc new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_2/b/3aBc @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_2/c/0 b/tests/data/target/bak_man/remove_bak_2/c/0 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_2/c/0 @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_bak_2/c/wasd b/tests/data/target/bak_man/remove_bak_2/c/wasd new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_bak_2/c/wasd @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_target/.a-Fil _3.lmmbakman.json b/tests/data/target/bak_man/remove_target/.a-Fil _3.lmmbakman.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/bak_man/remove_target/0.txt b/tests/data/target/bak_man/remove_target/0.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_target/0.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_target/a-Fil _3 b/tests/data/target/bak_man/remove_target/a-Fil _3 new file mode 100644 index 0000000..b5dc6b8 --- /dev/null +++ b/tests/data/target/bak_man/remove_target/a-Fil _3 @@ -0,0 +1 @@ +MODIFIED diff --git a/tests/data/target/bak_man/remove_target/a-Fil _3.1.lmmbakman b/tests/data/target/bak_man/remove_target/a-Fil _3.1.lmmbakman new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_target/a-Fil _3.1.lmmbakman @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_target/a/2.txt b/tests/data/target/bak_man/remove_target/a/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_target/a/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_target/a/file.cfg b/tests/data/target/bak_man/remove_target/a/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_target/a/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_target/b/3aBc b/tests/data/target/bak_man/remove_target/b/3aBc new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_target/b/3aBc @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_target/c/0 b/tests/data/target/bak_man/remove_target/c/0 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_target/c/0 @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/bak_man/remove_target/c/wasd b/tests/data/target/bak_man/remove_target/c/wasd new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/bak_man/remove_target/c/wasd @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/case_matching/0/0.txt b/tests/data/target/case_matching/0/0.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/target/case_matching/0/0.txt @@ -0,0 +1 @@ + diff --git a/tests/data/target/case_matching/0/0file b/tests/data/target/case_matching/0/0file new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/case_matching/0/1.txt b/tests/data/target/case_matching/0/1.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/target/case_matching/0/1.txt @@ -0,0 +1 @@ + diff --git a/tests/data/target/case_matching/0/a-Fil _3 b/tests/data/target/case_matching/0/a-Fil _3 new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/target/case_matching/0/a-Fil _3 @@ -0,0 +1 @@ + diff --git a/tests/data/target/case_matching/0/b/3aBc b/tests/data/target/case_matching/0/b/3aBc new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/target/case_matching/0/b/3aBc @@ -0,0 +1 @@ + diff --git a/tests/data/target/case_matching/0/new_dir/aB/someFile.txt b/tests/data/target/case_matching/0/new_dir/aB/someFile.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/target/case_matching/0/new_dir/aB/someFile.txt @@ -0,0 +1 @@ + diff --git a/tests/data/target/case_matching/0/new_dir/new_file b/tests/data/target/case_matching/0/new_dir/new_file new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/target/case_matching/0/new_dir/new_file @@ -0,0 +1 @@ + diff --git a/tests/data/target/case_matching/1/0.txt b/tests/data/target/case_matching/1/0.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/target/case_matching/1/0.txt @@ -0,0 +1 @@ + diff --git a/tests/data/target/case_matching/1/1.txt b/tests/data/target/case_matching/1/1.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/target/case_matching/1/1.txt @@ -0,0 +1 @@ + diff --git a/tests/data/target/case_matching/1/123/456/789 b/tests/data/target/case_matching/1/123/456/789 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/case_matching/1/a-Fil _3 b/tests/data/target/case_matching/1/a-Fil _3 new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/target/case_matching/1/a-Fil _3 @@ -0,0 +1 @@ + diff --git a/tests/data/target/case_matching/1/b/3aBc b/tests/data/target/case_matching/1/b/3aBc new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/target/case_matching/1/b/3aBc @@ -0,0 +1 @@ + diff --git a/tests/data/target/case_matching/1/new_dir/aB/someFile.txt b/tests/data/target/case_matching/1/new_dir/aB/someFile.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/target/case_matching/1/new_dir/aB/someFile.txt @@ -0,0 +1 @@ + diff --git a/tests/data/target/case_matching/1/new_dir/new_file b/tests/data/target/case_matching/1/new_dir/new_file new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/target/case_matching/1/new_dir/new_file @@ -0,0 +1 @@ + diff --git a/tests/data/target/case_matching/123/456/789 b/tests/data/target/case_matching/123/456/789 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/loot/profiles/.lmmconfig b/tests/data/target/loot/profiles/.lmmconfig new file mode 100644 index 0000000..2645099 --- /dev/null +++ b/tests/data/target/loot/profiles/.lmmconfig @@ -0,0 +1,6 @@ +{ + "auto_update_master_list" : true, + "current_profile" : 2, + "list_download_time" : 0, + "num_profiles" : 3 +} \ No newline at end of file diff --git a/tests/data/target/loot/profiles/.loadorder.txt.lmmprof0 b/tests/data/target/loot/profiles/.loadorder.txt.lmmprof0 new file mode 100644 index 0000000..22d8d09 --- /dev/null +++ b/tests/data/target/loot/profiles/.loadorder.txt.lmmprof0 @@ -0,0 +1,5 @@ +Morrowind.esm +p.esp +a.esp +c.esp +d.esp diff --git a/tests/data/target/loot/profiles/.loadorder.txt.lmmprof1 b/tests/data/target/loot/profiles/.loadorder.txt.lmmprof1 new file mode 100644 index 0000000..22d8d09 --- /dev/null +++ b/tests/data/target/loot/profiles/.loadorder.txt.lmmprof1 @@ -0,0 +1,5 @@ +Morrowind.esm +p.esp +a.esp +c.esp +d.esp diff --git a/tests/data/target/loot/profiles/.plugins.txt.lmmprof0 b/tests/data/target/loot/profiles/.plugins.txt.lmmprof0 new file mode 100644 index 0000000..24da6b0 --- /dev/null +++ b/tests/data/target/loot/profiles/.plugins.txt.lmmprof0 @@ -0,0 +1,3 @@ +a.esp +c.esp +*d.esp diff --git a/tests/data/target/loot/profiles/.plugins.txt.lmmprof1 b/tests/data/target/loot/profiles/.plugins.txt.lmmprof1 new file mode 100644 index 0000000..678a20a --- /dev/null +++ b/tests/data/target/loot/profiles/.plugins.txt.lmmprof1 @@ -0,0 +1,3 @@ +*a.esp +c.esp +*d.esp diff --git a/tests/data/target/loot/profiles/loadorder.txt b/tests/data/target/loot/profiles/loadorder.txt new file mode 100644 index 0000000..22d8d09 --- /dev/null +++ b/tests/data/target/loot/profiles/loadorder.txt @@ -0,0 +1,5 @@ +Morrowind.esm +p.esp +a.esp +c.esp +d.esp diff --git a/tests/data/target/loot/profiles/plugins.txt b/tests/data/target/loot/profiles/plugins.txt new file mode 100644 index 0000000..24da6b0 --- /dev/null +++ b/tests/data/target/loot/profiles/plugins.txt @@ -0,0 +1,3 @@ +a.esp +c.esp +*d.esp diff --git a/tests/data/target/loot/source/Morrowind.esm b/tests/data/target/loot/source/Morrowind.esm new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/loot/source/a.esp b/tests/data/target/loot/source/a.esp new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/loot/source/c.esp b/tests/data/target/loot/source/c.esp new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/loot/source/d.esp b/tests/data/target/loot/source/d.esp new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/loot/target/.lmmconfig b/tests/data/target/loot/target/.lmmconfig new file mode 100644 index 0000000..2645099 --- /dev/null +++ b/tests/data/target/loot/target/.lmmconfig @@ -0,0 +1,6 @@ +{ + "auto_update_master_list" : true, + "current_profile" : 2, + "list_download_time" : 0, + "num_profiles" : 3 +} \ No newline at end of file diff --git a/tests/data/target/loot/target/.loadorder.txt.lmmprof0 b/tests/data/target/loot/target/.loadorder.txt.lmmprof0 new file mode 100644 index 0000000..22d8d09 --- /dev/null +++ b/tests/data/target/loot/target/.loadorder.txt.lmmprof0 @@ -0,0 +1,5 @@ +Morrowind.esm +p.esp +a.esp +c.esp +d.esp diff --git a/tests/data/target/loot/target/.loadorder.txt.lmmprof1 b/tests/data/target/loot/target/.loadorder.txt.lmmprof1 new file mode 100644 index 0000000..22d8d09 --- /dev/null +++ b/tests/data/target/loot/target/.loadorder.txt.lmmprof1 @@ -0,0 +1,5 @@ +Morrowind.esm +p.esp +a.esp +c.esp +d.esp diff --git a/tests/data/target/loot/target/.plugins.txt.lmmprof0 b/tests/data/target/loot/target/.plugins.txt.lmmprof0 new file mode 100644 index 0000000..24da6b0 --- /dev/null +++ b/tests/data/target/loot/target/.plugins.txt.lmmprof0 @@ -0,0 +1,3 @@ +a.esp +c.esp +*d.esp diff --git a/tests/data/target/loot/target/.plugins.txt.lmmprof1 b/tests/data/target/loot/target/.plugins.txt.lmmprof1 new file mode 100644 index 0000000..678a20a --- /dev/null +++ b/tests/data/target/loot/target/.plugins.txt.lmmprof1 @@ -0,0 +1,3 @@ +*a.esp +c.esp +*d.esp diff --git a/tests/data/target/loot/target/loadorder.txt b/tests/data/target/loot/target/loadorder.txt new file mode 100644 index 0000000..22d8d09 --- /dev/null +++ b/tests/data/target/loot/target/loadorder.txt @@ -0,0 +1,5 @@ +Morrowind.esm +p.esp +a.esp +c.esp +d.esp diff --git a/tests/data/target/loot/target/plugins.txt b/tests/data/target/loot/target/plugins.txt new file mode 100644 index 0000000..24da6b0 --- /dev/null +++ b/tests/data/target/loot/target/plugins.txt @@ -0,0 +1,3 @@ +a.esp +c.esp +*d.esp diff --git a/tests/data/target/lower/0.txt b/tests/data/target/lower/0.txt new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/data/target/lower/0.txt @@ -0,0 +1 @@ +0 diff --git a/tests/data/target/lower/1.txt b/tests/data/target/lower/1.txt new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/data/target/lower/1.txt @@ -0,0 +1 @@ +1 diff --git a/tests/data/target/lower/a-fil _3 b/tests/data/target/lower/a-fil _3 new file mode 100644 index 0000000..13191c3 --- /dev/null +++ b/tests/data/target/lower/a-fil _3 @@ -0,0 +1 @@ +a-Fil _3 - diff --git a/tests/data/target/lower/a/0.txt b/tests/data/target/lower/a/0.txt new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/data/target/lower/a/0.txt @@ -0,0 +1 @@ +0 diff --git a/tests/data/target/lower/a/2.txt b/tests/data/target/lower/a/2.txt new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/tests/data/target/lower/a/2.txt @@ -0,0 +1 @@ +2 diff --git a/tests/data/target/lower/a/b/1.txt b/tests/data/target/lower/a/b/1.txt new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/data/target/lower/a/b/1.txt @@ -0,0 +1 @@ +1 diff --git a/tests/data/target/lower/a/b/2.txt b/tests/data/target/lower/a/b/2.txt new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/tests/data/target/lower/a/b/2.txt @@ -0,0 +1 @@ +2 diff --git a/tests/data/target/lower/b/3 b/tests/data/target/lower/b/3 new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tests/data/target/lower/b/3 @@ -0,0 +1 @@ +3 diff --git a/tests/data/target/lower/b/3abc b/tests/data/target/lower/b/3abc new file mode 100644 index 0000000..5a0efc0 --- /dev/null +++ b/tests/data/target/lower/b/3abc @@ -0,0 +1 @@ +3aBc diff --git a/tests/data/target/mod012/0 b/tests/data/target/mod012/0 new file mode 100644 index 0000000..fa938d9 --- /dev/null +++ b/tests/data/target/mod012/0 @@ -0,0 +1 @@ +mod2 diff --git a/tests/data/target/mod012/0.txt b/tests/data/target/mod012/0.txt new file mode 100644 index 0000000..fa938d9 --- /dev/null +++ b/tests/data/target/mod012/0.txt @@ -0,0 +1 @@ +mod2 diff --git a/tests/data/target/mod012/0.txt.lmmbak b/tests/data/target/mod012/0.txt.lmmbak new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/mod012/0.txt.lmmbak @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/mod012/1.txt b/tests/data/target/mod012/1.txt new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/data/target/mod012/1.txt @@ -0,0 +1 @@ +1 diff --git a/tests/data/target/mod012/6 b/tests/data/target/mod012/6 new file mode 100644 index 0000000..4d8452f --- /dev/null +++ b/tests/data/target/mod012/6 @@ -0,0 +1 @@ +mod1 diff --git a/tests/data/target/mod012/7 b/tests/data/target/mod012/7 new file mode 100644 index 0000000..4d8452f --- /dev/null +++ b/tests/data/target/mod012/7 @@ -0,0 +1 @@ +mod1 diff --git a/tests/data/target/mod012/a-Fil _3 b/tests/data/target/mod012/a-Fil _3 new file mode 100644 index 0000000..13191c3 --- /dev/null +++ b/tests/data/target/mod012/a-Fil _3 @@ -0,0 +1 @@ +a-Fil _3 - diff --git a/tests/data/target/mod012/a-Fil _3.lmmbak b/tests/data/target/mod012/a-Fil _3.lmmbak new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/mod012/a-Fil _3.lmmbak @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/mod012/a/0.txt b/tests/data/target/mod012/a/0.txt new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/data/target/mod012/a/0.txt @@ -0,0 +1 @@ +0 diff --git a/tests/data/target/mod012/a/1.txt b/tests/data/target/mod012/a/1.txt new file mode 100644 index 0000000..fa938d9 --- /dev/null +++ b/tests/data/target/mod012/a/1.txt @@ -0,0 +1 @@ +mod2 diff --git a/tests/data/target/mod012/a/2.txt b/tests/data/target/mod012/a/2.txt new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/tests/data/target/mod012/a/2.txt @@ -0,0 +1 @@ +2 diff --git a/tests/data/target/mod012/a/2.txt.lmmbak b/tests/data/target/mod012/a/2.txt.lmmbak new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/mod012/a/2.txt.lmmbak @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/mod012/a/b/1.txt b/tests/data/target/mod012/a/b/1.txt new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/data/target/mod012/a/b/1.txt @@ -0,0 +1 @@ +1 diff --git a/tests/data/target/mod012/a/b/2.txt b/tests/data/target/mod012/a/b/2.txt new file mode 100644 index 0000000..fa938d9 --- /dev/null +++ b/tests/data/target/mod012/a/b/2.txt @@ -0,0 +1 @@ +mod2 diff --git a/tests/data/target/mod012/a/file.cfg b/tests/data/target/mod012/a/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/mod012/a/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/mod012/b/3 b/tests/data/target/mod012/b/3 new file mode 100644 index 0000000..dd904c8 --- /dev/null +++ b/tests/data/target/mod012/b/3 @@ -0,0 +1 @@ +mod2 diff --git a/tests/data/target/mod012/b/3aBc b/tests/data/target/mod012/b/3aBc new file mode 100644 index 0000000..5a0efc0 --- /dev/null +++ b/tests/data/target/mod012/b/3aBc @@ -0,0 +1 @@ +3aBc diff --git a/tests/data/target/mod012/b/3aBc.lmmbak b/tests/data/target/mod012/b/3aBc.lmmbak new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/mod012/b/3aBc.lmmbak @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/mod012/c/0 b/tests/data/target/mod012/c/0 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/mod012/c/0 @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/mod012/c/wasd b/tests/data/target/mod012/c/wasd new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/mod012/c/wasd @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/mod012/f/b c.t b/tests/data/target/mod012/f/b c.t new file mode 100644 index 0000000..4d8452f --- /dev/null +++ b/tests/data/target/mod012/f/b c.t @@ -0,0 +1 @@ +mod1 diff --git a/tests/data/target/mod012/f/g/0 b/tests/data/target/mod012/f/g/0 new file mode 100644 index 0000000..4d8452f --- /dev/null +++ b/tests/data/target/mod012/f/g/0 @@ -0,0 +1 @@ +mod1 diff --git a/tests/data/target/mod1/0.txt b/tests/data/target/mod1/0.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/mod1/0.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/mod1/6 b/tests/data/target/mod1/6 new file mode 100644 index 0000000..4d8452f --- /dev/null +++ b/tests/data/target/mod1/6 @@ -0,0 +1 @@ +mod1 diff --git a/tests/data/target/mod1/7 b/tests/data/target/mod1/7 new file mode 100644 index 0000000..4d8452f --- /dev/null +++ b/tests/data/target/mod1/7 @@ -0,0 +1 @@ +mod1 diff --git a/tests/data/target/mod1/a-Fil _3 b/tests/data/target/mod1/a-Fil _3 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/mod1/a-Fil _3 @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/mod1/a/2.txt b/tests/data/target/mod1/a/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/mod1/a/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/mod1/a/file.cfg b/tests/data/target/mod1/a/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/mod1/a/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/mod1/b/3aBc b/tests/data/target/mod1/b/3aBc new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/mod1/b/3aBc @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/mod1/c/0 b/tests/data/target/mod1/c/0 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/mod1/c/0 @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/mod1/c/wasd b/tests/data/target/mod1/c/wasd new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/mod1/c/wasd @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/mod1/f/b c.t b/tests/data/target/mod1/f/b c.t new file mode 100644 index 0000000..4d8452f --- /dev/null +++ b/tests/data/target/mod1/f/b c.t @@ -0,0 +1 @@ +mod1 diff --git a/tests/data/target/mod1/f/g/0 b/tests/data/target/mod1/f/g/0 new file mode 100644 index 0000000..4d8452f --- /dev/null +++ b/tests/data/target/mod1/f/g/0 @@ -0,0 +1 @@ +mod1 diff --git a/tests/data/target/remove/simple/.lmm_mods.json.bak b/tests/data/target/remove/simple/.lmm_mods.json.bak new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/remove/simple/1/6 b/tests/data/target/remove/simple/1/6 new file mode 100644 index 0000000..4d8452f --- /dev/null +++ b/tests/data/target/remove/simple/1/6 @@ -0,0 +1 @@ +mod1 diff --git a/tests/data/target/remove/simple/1/7 b/tests/data/target/remove/simple/1/7 new file mode 100644 index 0000000..4d8452f --- /dev/null +++ b/tests/data/target/remove/simple/1/7 @@ -0,0 +1 @@ +mod1 diff --git a/tests/data/target/remove/simple/1/f/b c.t b/tests/data/target/remove/simple/1/f/b c.t new file mode 100644 index 0000000..4d8452f --- /dev/null +++ b/tests/data/target/remove/simple/1/f/b c.t @@ -0,0 +1 @@ +mod1 diff --git a/tests/data/target/remove/simple/1/f/g/0 b/tests/data/target/remove/simple/1/f/g/0 new file mode 100644 index 0000000..4d8452f --- /dev/null +++ b/tests/data/target/remove/simple/1/f/g/0 @@ -0,0 +1 @@ +mod1 diff --git a/tests/data/target/remove/simple/lmm_mods.json b/tests/data/target/remove/simple/lmm_mods.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/remove/version/.lmm_mods.json.bak b/tests/data/target/remove/version/.lmm_mods.json.bak new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/remove/version/1/6 b/tests/data/target/remove/version/1/6 new file mode 100644 index 0000000..4d8452f --- /dev/null +++ b/tests/data/target/remove/version/1/6 @@ -0,0 +1 @@ +mod1 diff --git a/tests/data/target/remove/version/1/7 b/tests/data/target/remove/version/1/7 new file mode 100644 index 0000000..4d8452f --- /dev/null +++ b/tests/data/target/remove/version/1/7 @@ -0,0 +1 @@ +mod1 diff --git a/tests/data/target/remove/version/1/f/b c.t b/tests/data/target/remove/version/1/f/b c.t new file mode 100644 index 0000000..4d8452f --- /dev/null +++ b/tests/data/target/remove/version/1/f/b c.t @@ -0,0 +1 @@ +mod1 diff --git a/tests/data/target/remove/version/1/f/g/0 b/tests/data/target/remove/version/1/f/g/0 new file mode 100644 index 0000000..4d8452f --- /dev/null +++ b/tests/data/target/remove/version/1/f/g/0 @@ -0,0 +1 @@ +mod1 diff --git a/tests/data/target/remove/version/2/0.txt b/tests/data/target/remove/version/2/0.txt new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/data/target/remove/version/2/0.txt @@ -0,0 +1 @@ +0 diff --git a/tests/data/target/remove/version/2/1.txt b/tests/data/target/remove/version/2/1.txt new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/data/target/remove/version/2/1.txt @@ -0,0 +1 @@ +1 diff --git a/tests/data/target/remove/version/2/a-Fil _3 b/tests/data/target/remove/version/2/a-Fil _3 new file mode 100644 index 0000000..13191c3 --- /dev/null +++ b/tests/data/target/remove/version/2/a-Fil _3 @@ -0,0 +1 @@ +a-Fil _3 - diff --git a/tests/data/target/remove/version/2/a/0.txt b/tests/data/target/remove/version/2/a/0.txt new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/data/target/remove/version/2/a/0.txt @@ -0,0 +1 @@ +0 diff --git a/tests/data/target/remove/version/2/a/2.txt b/tests/data/target/remove/version/2/a/2.txt new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/tests/data/target/remove/version/2/a/2.txt @@ -0,0 +1 @@ +2 diff --git a/tests/data/target/remove/version/2/a/b/1.txt b/tests/data/target/remove/version/2/a/b/1.txt new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/data/target/remove/version/2/a/b/1.txt @@ -0,0 +1 @@ +1 diff --git a/tests/data/target/remove/version/2/a/b/2.txt b/tests/data/target/remove/version/2/a/b/2.txt new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/tests/data/target/remove/version/2/a/b/2.txt @@ -0,0 +1 @@ +2 diff --git a/tests/data/target/remove/version/2/b/3 b/tests/data/target/remove/version/2/b/3 new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tests/data/target/remove/version/2/b/3 @@ -0,0 +1 @@ +3 diff --git a/tests/data/target/remove/version/2/b/3aBc b/tests/data/target/remove/version/2/b/3aBc new file mode 100644 index 0000000..5a0efc0 --- /dev/null +++ b/tests/data/target/remove/version/2/b/3aBc @@ -0,0 +1 @@ +3aBc diff --git a/tests/data/target/remove/version/lmm_mods.json b/tests/data/target/remove/version/lmm_mods.json new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/root_level/0/0.txt b/tests/data/target/root_level/0/0.txt new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/data/target/root_level/0/0.txt @@ -0,0 +1 @@ +0 diff --git a/tests/data/target/root_level/0/1.txt b/tests/data/target/root_level/0/1.txt new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/data/target/root_level/0/1.txt @@ -0,0 +1 @@ +1 diff --git a/tests/data/target/root_level/0/a-Fil _3 b/tests/data/target/root_level/0/a-Fil _3 new file mode 100644 index 0000000..13191c3 --- /dev/null +++ b/tests/data/target/root_level/0/a-Fil _3 @@ -0,0 +1 @@ +a-Fil _3 - diff --git a/tests/data/target/root_level/0/a/0.txt b/tests/data/target/root_level/0/a/0.txt new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/data/target/root_level/0/a/0.txt @@ -0,0 +1 @@ +0 diff --git a/tests/data/target/root_level/0/a/2.txt b/tests/data/target/root_level/0/a/2.txt new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/tests/data/target/root_level/0/a/2.txt @@ -0,0 +1 @@ +2 diff --git a/tests/data/target/root_level/0/a/b/1.txt b/tests/data/target/root_level/0/a/b/1.txt new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/data/target/root_level/0/a/b/1.txt @@ -0,0 +1 @@ +1 diff --git a/tests/data/target/root_level/0/a/b/2.txt b/tests/data/target/root_level/0/a/b/2.txt new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/tests/data/target/root_level/0/a/b/2.txt @@ -0,0 +1 @@ +2 diff --git a/tests/data/target/root_level/0/b/3 b/tests/data/target/root_level/0/b/3 new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tests/data/target/root_level/0/b/3 @@ -0,0 +1 @@ +3 diff --git a/tests/data/target/root_level/0/b/3aBc b/tests/data/target/root_level/0/b/3aBc new file mode 100644 index 0000000..5a0efc0 --- /dev/null +++ b/tests/data/target/root_level/0/b/3aBc @@ -0,0 +1 @@ +3aBc diff --git a/tests/data/target/root_level/1/0.txt b/tests/data/target/root_level/1/0.txt new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/data/target/root_level/1/0.txt @@ -0,0 +1 @@ +0 diff --git a/tests/data/target/root_level/1/2.txt b/tests/data/target/root_level/1/2.txt new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/tests/data/target/root_level/1/2.txt @@ -0,0 +1 @@ +2 diff --git a/tests/data/target/root_level/1/3 b/tests/data/target/root_level/1/3 new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tests/data/target/root_level/1/3 @@ -0,0 +1 @@ +3 diff --git a/tests/data/target/root_level/1/3aBc b/tests/data/target/root_level/1/3aBc new file mode 100644 index 0000000..5a0efc0 --- /dev/null +++ b/tests/data/target/root_level/1/3aBc @@ -0,0 +1 @@ +3aBc diff --git a/tests/data/target/root_level/1/b/1.txt b/tests/data/target/root_level/1/b/1.txt new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/data/target/root_level/1/b/1.txt @@ -0,0 +1 @@ +1 diff --git a/tests/data/target/root_level/1/b/2.txt b/tests/data/target/root_level/1/b/2.txt new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/tests/data/target/root_level/1/b/2.txt @@ -0,0 +1 @@ +2 diff --git a/tests/data/target/root_level/2/1.txt b/tests/data/target/root_level/2/1.txt new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/data/target/root_level/2/1.txt @@ -0,0 +1 @@ +1 diff --git a/tests/data/target/root_level/2/2.txt b/tests/data/target/root_level/2/2.txt new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/tests/data/target/root_level/2/2.txt @@ -0,0 +1 @@ +2 diff --git a/tests/data/target/single_dir/0.txt b/tests/data/target/single_dir/0.txt new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/data/target/single_dir/0.txt @@ -0,0 +1 @@ +0 diff --git a/tests/data/target/single_dir/1.txt b/tests/data/target/single_dir/1.txt new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/data/target/single_dir/1.txt @@ -0,0 +1 @@ +1 diff --git a/tests/data/target/single_dir/2.txt b/tests/data/target/single_dir/2.txt new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/tests/data/target/single_dir/2.txt @@ -0,0 +1 @@ +2 diff --git a/tests/data/target/single_dir/3 b/tests/data/target/single_dir/3 new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tests/data/target/single_dir/3 @@ -0,0 +1 @@ +3 diff --git a/tests/data/target/single_dir/3aBc b/tests/data/target/single_dir/3aBc new file mode 100644 index 0000000..5a0efc0 --- /dev/null +++ b/tests/data/target/single_dir/3aBc @@ -0,0 +1 @@ +3aBc diff --git a/tests/data/target/single_dir/a-Fil _3 b/tests/data/target/single_dir/a-Fil _3 new file mode 100644 index 0000000..13191c3 --- /dev/null +++ b/tests/data/target/single_dir/a-Fil _3 @@ -0,0 +1 @@ +a-Fil _3 - diff --git a/tests/data/target/split/0/123 b/tests/data/target/split/0/123 new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/split/0/D/d.txt b/tests/data/target/split/0/D/d.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/target/split/0/D/d.txt @@ -0,0 +1 @@ + diff --git a/tests/data/target/split/1/abc b/tests/data/target/split/1/abc new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/split/2/wer b/tests/data/target/split/2/wer new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/split/3/123.txt b/tests/data/target/split/3/123.txt new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/tests/data/target/split/3/123.txt @@ -0,0 +1 @@ + diff --git a/tests/data/target/split/4/ghj b/tests/data/target/split/4/ghj new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/target/upper/0.TXT b/tests/data/target/upper/0.TXT new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/data/target/upper/0.TXT @@ -0,0 +1 @@ +0 diff --git a/tests/data/target/upper/1.TXT b/tests/data/target/upper/1.TXT new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/data/target/upper/1.TXT @@ -0,0 +1 @@ +1 diff --git a/tests/data/target/upper/A-FIL _3 b/tests/data/target/upper/A-FIL _3 new file mode 100644 index 0000000..13191c3 --- /dev/null +++ b/tests/data/target/upper/A-FIL _3 @@ -0,0 +1 @@ +a-Fil _3 - diff --git a/tests/data/target/upper/A/0.TXT b/tests/data/target/upper/A/0.TXT new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/data/target/upper/A/0.TXT @@ -0,0 +1 @@ +0 diff --git a/tests/data/target/upper/A/2.TXT b/tests/data/target/upper/A/2.TXT new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/tests/data/target/upper/A/2.TXT @@ -0,0 +1 @@ +2 diff --git a/tests/data/target/upper/A/B/1.TXT b/tests/data/target/upper/A/B/1.TXT new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/data/target/upper/A/B/1.TXT @@ -0,0 +1 @@ +1 diff --git a/tests/data/target/upper/A/B/2.TXT b/tests/data/target/upper/A/B/2.TXT new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/tests/data/target/upper/A/B/2.TXT @@ -0,0 +1 @@ +2 diff --git a/tests/data/target/upper/B/3 b/tests/data/target/upper/B/3 new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tests/data/target/upper/B/3 @@ -0,0 +1 @@ +3 diff --git a/tests/data/target/upper/B/3ABC b/tests/data/target/upper/B/3ABC new file mode 100644 index 0000000..5a0efc0 --- /dev/null +++ b/tests/data/target/upper/B/3ABC @@ -0,0 +1 @@ +3aBc diff --git a/tests/data/target/upper_single/0.TXT b/tests/data/target/upper_single/0.TXT new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/data/target/upper_single/0.TXT @@ -0,0 +1 @@ +0 diff --git a/tests/data/target/upper_single/1.TXT b/tests/data/target/upper_single/1.TXT new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/data/target/upper_single/1.TXT @@ -0,0 +1 @@ +1 diff --git a/tests/data/target/upper_single/2.TXT b/tests/data/target/upper_single/2.TXT new file mode 100644 index 0000000..0cfbf08 --- /dev/null +++ b/tests/data/target/upper_single/2.TXT @@ -0,0 +1 @@ +2 diff --git a/tests/data/target/upper_single/3 b/tests/data/target/upper_single/3 new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tests/data/target/upper_single/3 @@ -0,0 +1 @@ +3 diff --git a/tests/data/target/upper_single/3ABC b/tests/data/target/upper_single/3ABC new file mode 100644 index 0000000..5a0efc0 --- /dev/null +++ b/tests/data/target/upper_single/3ABC @@ -0,0 +1 @@ +3aBc diff --git a/tests/data/target/upper_single/A-FIL _3 b/tests/data/target/upper_single/A-FIL _3 new file mode 100644 index 0000000..13191c3 --- /dev/null +++ b/tests/data/target/upper_single/A-FIL _3 @@ -0,0 +1 @@ +a-Fil _3 - diff --git a/tests/data/target/vanilla/0.txt b/tests/data/target/vanilla/0.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/vanilla/0.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/vanilla/a-Fil _3 b/tests/data/target/vanilla/a-Fil _3 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/vanilla/a-Fil _3 @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/vanilla/a/2.txt b/tests/data/target/vanilla/a/2.txt new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/vanilla/a/2.txt @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/vanilla/a/file.cfg b/tests/data/target/vanilla/a/file.cfg new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/vanilla/a/file.cfg @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/vanilla/b/3aBc b/tests/data/target/vanilla/b/3aBc new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/vanilla/b/3aBc @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/vanilla/c/0 b/tests/data/target/vanilla/c/0 new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/vanilla/c/0 @@ -0,0 +1 @@ +dest diff --git a/tests/data/target/vanilla/c/wasd b/tests/data/target/vanilla/c/wasd new file mode 100644 index 0000000..89ea643 --- /dev/null +++ b/tests/data/target/vanilla/c/wasd @@ -0,0 +1 @@ +dest diff --git a/tests/test_backupmanager.cpp b/tests/test_backupmanager.cpp new file mode 100644 index 0000000..7f2b829 --- /dev/null +++ b/tests/test_backupmanager.cpp @@ -0,0 +1,209 @@ +#include "../src/core/backupmanager.h" +#include "test_utils.h" +#include +#include +#include + + +TEST_CASE("Backups are created", "[.backup]") +{ + resetAppDir(); + BackupManager bak_man; + bak_man.addProfile(); + bak_man.addTarget(DATA_DIR / "app" / "a", "t", { "b0", "b1" }); + bak_man.addTarget(DATA_DIR / "app" / "0.txt", "t2", { "b0", "b1" }); + verifyFilesAreEqual(DATA_DIR / "app" / "0.txt", DATA_DIR / "app" / "0.txt.1.lmmbakman"); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "target" / "bak_man" / "create_bak"); + verifyDirsAreEqual(DATA_DIR / "app" / "a", DATA_DIR / "app" / "a.1.lmmbakman", true); + sfs::remove(DATA_DIR / "app" / "a.1.lmmbakman" / "2.txt"); + bak_man.addBackup(0, "b2"); + verifyDirsAreEqual(DATA_DIR / "app" / "a", DATA_DIR / "app" / "a.2.lmmbakman", true); + bak_man.addBackup(0, "b3", 1); + verifyDirsAreEqual(DATA_DIR / "app" / "a.1.lmmbakman", DATA_DIR / "app" / "a.3.lmmbakman", true); + sfs::remove(DATA_DIR / "app" / "a.1.lmmbakman" / "file.cfg"); + bak_man.addBackup(0, "b4", 0); + verifyDirsAreEqual(DATA_DIR / "app" / "a", DATA_DIR / "app" / "a.4.lmmbakman", true); +} + +TEST_CASE("Backups are activated", "[.backup]") +{ + resetAppDir(); + BackupManager bak_man; + bak_man.addProfile(); + bak_man.addTarget(DATA_DIR / "app" / "a", "t", { "b0", "b1" }); + bak_man.addTarget(DATA_DIR / "app" / "a-Fil _3", "t2", { "b0", "b1" }); + sfs::copy(DATA_DIR / "source" / "bak_man" / "a-Fil _3", + DATA_DIR / "app", + sfs::copy_options::overwrite_existing); + verifyFilesAreEqual(DATA_DIR / "app" / "a-Fil _3", + DATA_DIR / "target" / "bak_man" / "change_bak" / "a-Fil _3.0.lmmbakman"); + bak_man.setActiveBackup(1, 1); + verifyFilesAreEqual(DATA_DIR / "app" / "a-Fil _3", + DATA_DIR / "target" / "bak_man" / "change_bak" / "a-Fil _3"); + verifyFilesAreEqual(DATA_DIR / "app" / "a-Fil _3.0.lmmbakman", + DATA_DIR / "target" / "bak_man" / "change_bak" / "a-Fil _3.0.lmmbakman"); + sfs::remove(DATA_DIR / "app" / "a" / "2.txt"); + sfs::copy(DATA_DIR / "source" / "bak_man" / "file.cfg", + DATA_DIR / "app" / "a", + sfs::copy_options::overwrite_existing); + bak_man.setActiveBackup(0, 1); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "target" / "bak_man" / "change_bak"); + verifyDirsAreEqual(DATA_DIR / "app" / "a.0.lmmbakman", + DATA_DIR / "target" / "bak_man" / "change_bak" / "a.0.lmmbakman", + true); + bak_man.setActiveBackup(0, 0); + verifyDirsAreEqual( + DATA_DIR / "target" / "bak_man" / "change_bak" / "a.0.lmmbakman", DATA_DIR / "app" / "a", true); + verifyDirsAreEqual( + DATA_DIR / "target" / "bak_man" / "change_bak" / "a", DATA_DIR / "app" / "a.1.lmmbakman", true); +} + +TEST_CASE("Backups are removed", "[.backup]") +{ + resetAppDir(); + BackupManager bak_man; + bak_man.addProfile(); + bak_man.addTarget(DATA_DIR / "app" / "a", "t", { "b0", "b1", "b2", "b3", "b4" }); + sfs::remove(DATA_DIR / "app" / "a.3.lmmbakman" / "2.txt"); + sfs::remove(DATA_DIR / "app" / "a.1.lmmbakman" / "file.cfg"); + sfs::copy(DATA_DIR / "app" / "a.4.lmmbakman" / "2.txt", + DATA_DIR / "app" / "a.4.lmmbakman" / "newfile"); + bak_man.removeBackup(0, 2); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "target" / "bak_man" / "remove_bak_0"); + + bak_man.removeBackup(0, 0); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "target" / "bak_man" / "remove_bak_1"); + bak_man.setActiveBackup(0, 1); + bak_man.removeBackup(0, 1); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "target" / "bak_man" / "remove_bak_2"); +} + +TEST_CASE("Profiles are working", "[.backup]") +{ + resetAppDir(); + BackupManager bak_man; + bak_man.addProfile(); + bak_man.addTarget(DATA_DIR / "app" / "a", "t", { "b0", "b1", "b2", "b3", "b4" }); + sfs::remove(DATA_DIR / "app" / "a.3.lmmbakman" / "2.txt"); + sfs::remove(DATA_DIR / "app" / "a.1.lmmbakman" / "file.cfg"); + sfs::copy(DATA_DIR / "app" / "a.4.lmmbakman" / "2.txt", + DATA_DIR / "app" / "a.4.lmmbakman" / "newfile"); + bak_man.addTarget(DATA_DIR / "app" / "a-Fil _3", "t2", { "b0", "b1" }); + sfs::copy(DATA_DIR / "source" / "bak_man" / "a-Fil _3", + DATA_DIR / "app", + sfs::copy_options::overwrite_existing); + bak_man.setActiveBackup(0, 2); + bak_man.setActiveBackup(1, 1); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "target" / "bak_man" / "profiles_0"); + verifyFilesAreEqual(DATA_DIR / "app" / "a-Fil _3", + DATA_DIR / "target" / "bak_man" / "profiles_0" / "a-Fil _3"); + bak_man.addProfile(0); + bak_man.addProfile(-1); + bak_man.setActiveBackup(0, 1); + bak_man.setActiveBackup(1, 0); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "target" / "bak_man" / "profiles_1"); + verifyFilesAreEqual(DATA_DIR / "app" / "a-Fil _3", + DATA_DIR / "target" / "bak_man" / "profiles_1" / "a-Fil _3"); + bak_man.setProfile(1); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "target" / "bak_man" / "profiles_0"); + verifyFilesAreEqual(DATA_DIR / "app" / "a-Fil _3", + DATA_DIR / "target" / "bak_man" / "profiles_0" / "a-Fil _3"); + bak_man.setProfile(2); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "target" / "bak_man" / "profiles_2"); + verifyFilesAreEqual(DATA_DIR / "app" / "a-Fil _3", + DATA_DIR / "target" / "bak_man" / "profiles_2" / "a-Fil _3"); + bak_man.removeProfile(2); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "target" / "bak_man" / "profiles_1"); + verifyFilesAreEqual(DATA_DIR / "app" / "a-Fil _3", + DATA_DIR / "target" / "bak_man" / "profiles_1" / "a-Fil _3"); +} + +TEST_CASE("State is saved", "[.backup]") +{ + resetAppDir(); + BackupManager bak_man; + bak_man.addProfile(); + bak_man.addTarget(DATA_DIR / "app" / "a", "t", { "b0", "b1", "b2", "b3", "b4" }); + sfs::remove(DATA_DIR / "app" / "a.3.lmmbakman" / "2.txt"); + sfs::remove(DATA_DIR / "app" / "a.1.lmmbakman" / "file.cfg"); + sfs::copy(DATA_DIR / "app" / "a.4.lmmbakman" / "2.txt", + DATA_DIR / "app" / "a.4.lmmbakman" / "newfile"); + bak_man.addTarget(DATA_DIR / "app" / "a-Fil _3", "t2", { "b0", "b1" }); + sfs::copy(DATA_DIR / "source" / "bak_man" / "a-Fil _3", + DATA_DIR / "app", + sfs::copy_options::overwrite_existing); + bak_man.setActiveBackup(0, 2); + bak_man.setActiveBackup(1, 1); + bak_man.addProfile(0); + bak_man.setActiveBackup(0, 1); + bak_man.setActiveBackup(1, 0); + const auto targets_orig = bak_man.getTargets(); + BackupManager bak_man2; + bak_man2.addProfile(); + bak_man2.addProfile(); + bak_man2.addTarget(DATA_DIR / "app" / "a", "t", { "b0", "b1", "b2", "b3", "b4" }); + bak_man2.addTarget(DATA_DIR / "app" / "a-Fil _3", "t2", { "b0", "b1" }); + const auto targets_new = bak_man2.getTargets(); + REQUIRE_THAT(targets_orig, Catch::Matchers::Equals(targets_new)); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "target" / "bak_man" / "profiles_1"); + verifyFilesAreEqual(DATA_DIR / "app" / "a-Fil _3", + DATA_DIR / "target" / "bak_man" / "profiles_1" / "a-Fil _3"); + bak_man2.setProfile(1); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "target" / "bak_man" / "profiles_0"); + verifyFilesAreEqual(DATA_DIR / "app" / "a-Fil _3", + DATA_DIR / "target" / "bak_man" / "profiles_0" / "a-Fil _3"); +} + +TEST_CASE("Invalid state is repaired", "[.backup]") +{ + resetAppDir(); + BackupManager bak_man; + bak_man.addProfile(); + bak_man.addTarget(DATA_DIR / "app" / "a", "t", { "b0", "b1", "b2", "b3", "b4" }); + sfs::remove(DATA_DIR / "app" / "a.3.lmmbakman" / "2.txt"); + sfs::remove(DATA_DIR / "app" / "a.1.lmmbakman" / "file.cfg"); + sfs::copy(DATA_DIR / "app" / "a.4.lmmbakman" / "2.txt", + DATA_DIR / "app" / "a.4.lmmbakman" / "newfile"); + bak_man.addTarget(DATA_DIR / "app" / "a-Fil _3", "t2", { "b0", "b1" }); + sfs::remove_all(DATA_DIR / "app" / "a.3.lmmbakman"); + sfs::copy(DATA_DIR / "app" / "a.2.lmmbakman", DATA_DIR / "app" / "a.8.lmmbakman"); + sfs::copy(DATA_DIR / "app" / "a.2.lmmbakman", DATA_DIR / "app" / "a.15.lmmbakman"); + sfs::remove(DATA_DIR / "app" / "a-Fil _3.1.lmmbakman"); + bak_man.setActiveBackup(0, 2); + bak_man.setActiveBackup(1, 0); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "target" / "bak_man" / "invalid_state"); +} + +TEST_CASE("Targets are removed", "[.backup]") +{ + resetAppDir(); + BackupManager bak_man; + bak_man.addProfile(); + bak_man.addTarget(DATA_DIR / "app" / "a", "t", { "b0", "b1", "b2", "b3", "b4" }); + sfs::remove(DATA_DIR / "app" / "a.3.lmmbakman" / "2.txt"); + sfs::remove(DATA_DIR / "app" / "a.1.lmmbakman" / "file.cfg"); + sfs::copy(DATA_DIR / "app" / "a.4.lmmbakman" / "2.txt", + DATA_DIR / "app" / "a.4.lmmbakman" / "newfile"); + bak_man.addTarget(DATA_DIR / "app" / "a-Fil _3", "t2", { "b0", "b1" }); + bak_man.setActiveBackup(0, 2); + bak_man.removeTarget(0); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "target" / "bak_man" / "remove_target"); + bak_man.removeTarget(0); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "source" / "app", true); +} + +TEST_CASE("Backups are overwritten", "[.backup]") +{ + resetAppDir(); + BackupManager bak_man; + bak_man.addProfile(); + bak_man.addTarget(DATA_DIR / "app" / "a", "t", { "b0", "b1" }); + sfs::remove(DATA_DIR / "app" / "a" / "file.cfg"); + sfs::copy(DATA_DIR / "source" / "bak_man" / "file.cfg", + DATA_DIR / "app" / "a.1.lmmbakman", + sfs::copy_options::overwrite_existing); + sfs::copy(DATA_DIR / "source" / "bak_man" / "a-Fil _3", DATA_DIR / "app" / "a.1.lmmbakman"); + verifyDirsAreEqual(DATA_DIR / "app" / "a", DATA_DIR / "target" / "bak_man" / "overwrite0", true); + bak_man.overwriteBackup(0, 1, 0); + verifyDirsAreEqual(DATA_DIR / "app" / "a", DATA_DIR / "target" / "bak_man" / "overwrite1", true); +} diff --git a/tests/test_cryptography.cpp b/tests/test_cryptography.cpp new file mode 100644 index 0000000..64ad8b9 --- /dev/null +++ b/tests/test_cryptography.cpp @@ -0,0 +1,49 @@ +#include "../src/core/cryptography.h" +#include "test_utils.h" +#include +#include +#include + + +std::string generateRandomString(std::default_random_engine& e) +{ + std::uniform_int_distribution length_dist(1, 100); + std::uniform_int_distribution char_dist(0, 255); + std::string str; + const int str_len = length_dist(e); + for(int j = 0; j < str_len; j++) + str += char_dist(e); + return str; +} + +TEST_CASE("String are encrypted", "[.crypto]") +{ + std::random_device r; + std::default_random_engine e(r()); + std::vector> text_key_pairs{ { "this is a super secret text", + "some key" } }; + + for(int i = 0; i < 10; i++) + text_key_pairs.emplace_back(generateRandomString(e), generateRandomString(e)); + + for(const auto& [plain_text, key] : text_key_pairs) + { + const auto [cipher, nonce, tag] = cryptography::encrypt(plain_text, key); + REQUIRE(!cipher.empty()); + REQUIRE(!nonce.empty()); + REQUIRE(!tag.empty()); + REQUIRE(cipher != plain_text); + std::string decrypted_text = cryptography::decrypt(cipher, key, nonce, tag); + REQUIRE(decrypted_text == plain_text); + } + + const std::string key = "my key"; + const std::string plain_text = "some text"; + const auto [cipher, nonce, tag] = cryptography::encrypt(plain_text, key); + REQUIRE_THROWS_AS(cryptography::decrypt(cipher + "a", key, nonce, tag), CryptographyError); + REQUIRE_THROWS_AS(cryptography::decrypt(cipher, key + "a", nonce, tag), CryptographyError); + REQUIRE_THROWS_AS(cryptography::decrypt(cipher, key, nonce == "a" ? "b" : "a", tag), + CryptographyError); + REQUIRE_THROWS_AS(cryptography::decrypt(cipher, key, nonce, tag == "a" ? "b" : "a"), + CryptographyError); +} diff --git a/tests/test_deployer.cpp b/tests/test_deployer.cpp new file mode 100644 index 0000000..52c1028 --- /dev/null +++ b/tests/test_deployer.cpp @@ -0,0 +1,201 @@ +#include "../src/core/casematchingdeployer.h" +#include "../src/core/deployer.h" +#include "test_utils.h" +#include + + +TEST_CASE("Mods are added and removed", "[.deployer]") +{ + Deployer depl = Deployer(DATA_DIR / "source", DATA_DIR / "app", ""); + depl.addProfile(); + depl.addMod(2, true); + REQUIRE(depl.getNumMods() == 1); + depl.removeMod(2); + REQUIRE(depl.getNumMods() == 0); +} + +TEST_CASE("Mods are being deployed", "[.deployer]") +{ + resetAppDir(); + Deployer depl = Deployer(DATA_DIR / "source", DATA_DIR / "app", ""); + depl.addProfile(); + depl.addMod(1, true); + depl.deploy(); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "target" / "mod1", true); +} + +TEST_CASE("Mod status works", "[.deployer]") +{ + resetAppDir(); + Deployer depl = Deployer(DATA_DIR / "source", DATA_DIR / "app", ""); + depl.addProfile(); + depl.addMod(1, false); + depl.setModStatus(1, true); + depl.addMod(0, true); + depl.setModStatus(0, false); + depl.deploy(); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "target" / "mod1", true); +} + +TEST_CASE("Deployed mods are removed", "[.deployer]") +{ + resetAppDir(); + Deployer depl = Deployer(DATA_DIR / "source", DATA_DIR / "app", ""); + depl.addProfile(); + depl.addMod(1, true); + depl.deploy(); + depl.setModStatus(1, false); + depl.deploy(); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "source" / "app", true); +} + +TEST_CASE("Conflicts are resolved", "[.deployer]") +{ + resetAppDir(); + Deployer depl = Deployer(DATA_DIR / "source", DATA_DIR / "app", ""); + depl.addProfile(); + depl.addMod(0, true); + depl.addMod(1, true); + depl.addMod(2, true); + depl.deploy(); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "target" / "mod012", true); +} + +TEST_CASE("Files are restored", "[.deployer]") +{ + resetAppDir(); + Deployer depl = Deployer(DATA_DIR / "source", DATA_DIR / "app", ""); + depl.addProfile(); + depl.addMod(0, true); + depl.addMod(1, true); + depl.addMod(2, true); + depl.deploy(); + depl.setModStatus(0, false); + depl.setModStatus(1, false); + depl.setModStatus(2, false); + depl.deploy(); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "source" / "app", true); +} + +TEST_CASE("Loadorder is being changed", "[.deployer]") +{ + resetAppDir(); + Deployer depl = Deployer(DATA_DIR / "source", DATA_DIR / "app", ""); + depl.addProfile(); + depl.addMod(2, true); + depl.addMod(0, true); + depl.addMod(1, true); + depl.changeLoadorder(1, 0); + depl.changeLoadorder(1, 2); + depl.deploy(); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "target" / "mod012", true); +} + +TEST_CASE("Profiles", "[.deployer]") +{ + resetAppDir(); + Deployer depl = Deployer(DATA_DIR / "source", DATA_DIR / "app", ""); + depl.addProfile(); + depl.addMod(1, true); + depl.deploy(); + depl.addProfile(0); + depl.setProfile(1); + depl.addMod(0, true); + depl.addMod(2, true); + depl.changeLoadorder(0, 1); + depl.deploy(); + SECTION("Copy profile") + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "target" / "mod012", true); + SECTION("Create new profile") + { + depl.addProfile(); + depl.setProfile(2); + depl.deploy(); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "source" / "app", true); + } +} + +TEST_CASE("Get mod conflicts", "[.deployer]") +{ + Deployer depl = Deployer(DATA_DIR / "source", DATA_DIR / "app", ""); + depl.addProfile(); + depl.addMod(0, true); + depl.addMod(1, true); + depl.addMod(2, true); + auto conflicts = depl.getModConflicts(1); + REQUIRE(conflicts.size() == 1); + REQUIRE(conflicts.contains(1)); + conflicts = depl.getModConflicts(0); + REQUIRE(conflicts.size() == 2); + REQUIRE(conflicts.contains(2)); + REQUIRE(conflicts.contains(0)); +} + +TEST_CASE("Get file conflicts", "[.deployer]") +{ + Deployer depl = Deployer(DATA_DIR / "source", DATA_DIR / "app", ""); + depl.addProfile(); + depl.addMod(0, true); + depl.addMod(1, true); + depl.addMod(2, true); + auto conflicts = depl.getFileConflicts(1); + REQUIRE(conflicts.size() == 0); + conflicts = depl.getFileConflicts(0); + REQUIRE(conflicts.size() == 3); +} + +TEST_CASE("Conflict groups are created", "[.deployer]") +{ + Deployer depl(DATA_DIR / "source" / "conflicts", DATA_DIR / "app", ""); + depl.addProfile(); + for(int i : { 0, 1, 2, 3, 4, 5, 6, 7 }) + depl.addMod(i, true); + depl.updateConflictGroups(); + auto groups = depl.getConflictGroups(); + REQUIRE_THAT(groups, + Catch::Matchers::UnorderedEquals( + std::vector>{ { 0, 1, 2, 3, 5 }, { 4, 6 }, { 7 } })); +} + +TEST_CASE("Mods are sorted", "[.deployer]") +{ + Deployer depl(DATA_DIR / "source" / "conflicts", DATA_DIR / "app", ""); + depl.addProfile(); + for(int i : { 5, 6, 0, 7, 4, 2, 1, 3 }) + depl.addMod(i, true); + depl.sortModsByConflicts(); + REQUIRE_THAT(depl.getLoadorder(), + Catch::Matchers::UnorderedEquals(std::vector>{ { 5, true }, + { 0, true }, + { 2, true }, + { 1, true }, + { 3, true }, + { 6, true }, + { 4, true }, + { 7, true } })); +} + +TEST_CASE("Case matching deployer", "[.deployer]") +{ + resetAppDir(); + sfs::remove_all(DATA_DIR / "source" / "case_matching" / "0"); + sfs::remove_all(DATA_DIR / "source" / "case_matching" / "1"); + sfs::copy_options options(sfs::copy_options::recursive | sfs::copy_options::overwrite_existing); + sfs::copy(DATA_DIR / "source" / "case_matching" / "orig_0", + DATA_DIR / "source" / "case_matching" / "0", + options); + sfs::copy(DATA_DIR / "source" / "case_matching" / "orig_1", + DATA_DIR / "source" / "case_matching" / "1", + options); + CaseMatchingDeployer depl(DATA_DIR / "source" / "case_matching", DATA_DIR / "app", ""); + depl.addProfile(); + depl.addMod(0, true); + depl.addMod(1, true); + depl.deploy({ 0, 1 }); + verifyDirsAreEqual(DATA_DIR / "source" / "case_matching" / "0", + DATA_DIR / "target" / "case_matching" / "0", + false); + verifyDirsAreEqual(DATA_DIR / "source" / "case_matching" / "1", + DATA_DIR / "target" / "case_matching" / "1", + false); +} diff --git a/tests/test_fomodinstaller.cpp b/tests/test_fomodinstaller.cpp new file mode 100644 index 0000000..1096ca9 --- /dev/null +++ b/tests/test_fomodinstaller.cpp @@ -0,0 +1,59 @@ +#include "../src/core/fomod/fomodinstaller.h" +#include "test_utils.h" +#include + +TEST_CASE("Required files are detected", "[.fomod]") +{ + fomod::FomodInstaller installer; + installer.init(DATA_DIR / "source" / "fomod" / "fomod" / "simple.xml"); + REQUIRE(installer.hasNoSteps()); + auto files = installer.getInstallationFiles({}); + REQUIRE(files.size() == 2); + REQUIRE(files[0].first == "example.plugin"); + std::vector> target = { + { "example.plugin", "example.plugin" }, { "another_example.plugin", "another_example.plugin" } + }; + REQUIRE_THAT(files, Catch::Matchers::Equals(target)); +} + +TEST_CASE("Steps are executed", "[.fomod]") +{ + fomod::FomodInstaller installer; + installer.init(DATA_DIR / "source" / "fomod" / "fomod" / "steps.xml"); + REQUIRE(!installer.hasNoSteps()); + auto step = installer.step(); + REQUIRE(step); + REQUIRE(step->groups.size() == 1); + REQUIRE(step->groups[0].plugins.size() == 2); + REQUIRE(step->groups[0].plugins[0].name == "Option A"); + REQUIRE(step->groups[0].plugins[1].name == "Option B"); + REQUIRE(installer.hasNextStep({ { false, true } })); + step = installer.step({ { false, true } }); + REQUIRE(step); + REQUIRE(step->groups[0].name == "Select a texture:"); + REQUIRE(step->groups[0].type == fomod::PluginGroup::exactly_one); + REQUIRE(step->groups[0].plugins[0].name == "Texture Blue"); + REQUIRE(!installer.hasNextStep({ { true, false } })); + step = installer.step({ { false, true } }); + REQUIRE(!step); + auto result = installer.getInstallationFiles(); + REQUIRE(result.size() == 2); + std::vector> target = { + { "option_b", "option_b" }, { "texture_red_b", "texture_red_b" } + }; + REQUIRE_THAT(result, Catch::Matchers::Equals(target)); +} + +TEST_CASE("Installation matrix is parsed", "[.fomod]") +{ + fomod::FomodInstaller installer; + installer.init(DATA_DIR / "source" / "fomod" / "fomod" / "matrix.xml"); + installer.step(); + installer.step({ { false, true }, { false, true } }); + auto result = installer.getInstallationFiles(); + REQUIRE(result.size() == 2); + std::vector> target = { + { "option_b", "option_b" }, { "texture_red_b", "texture_red_b" } + }; + REQUIRE_THAT(result, Catch::Matchers::UnorderedEquals(target)); +} diff --git a/tests/test_installer.cpp b/tests/test_installer.cpp new file mode 100644 index 0000000..bfb54c0 --- /dev/null +++ b/tests/test_installer.cpp @@ -0,0 +1,104 @@ +#include "../src/core/installer.h" +#include "test_utils.h" +#include +#include +#include +#include + + +TEST_CASE("Files are extracted", "[.installer]") +{ + resetStagingDir(); + Installer::extract(DATA_DIR / "source" / "mod0.tar.gz", DATA_DIR / "staging" / "extract"); + verifyDirsAreEqual(DATA_DIR / "source" / "0", DATA_DIR / "staging" / "extract"); +} + +TEST_CASE("Mods are (un)installed", "[.installer]") +{ + resetStagingDir(); + Installer::install(DATA_DIR / "source" / "mod0.tar.gz", + DATA_DIR / "staging", + Installer::preserve_case | Installer::preserve_directories); + + SECTION("Simple installer") + verifyDirsAreEqual(DATA_DIR / "source/0", DATA_DIR / "staging"); + SECTION("Uninstallation") + { + Installer::uninstall(DATA_DIR / "staging", Installer::SIMPLEINSTALLER); + REQUIRE_FALSE(sfs::exists(DATA_DIR / "staging")); + } +} + +TEST_CASE("Installer options", "[.installer]") +{ + resetStagingDir(); + SECTION("Upper case conversion") + { + Installer::install(DATA_DIR / "source" / "mod0.tar.gz", + DATA_DIR / "staging" / "upper", + Installer::upper_case | Installer::preserve_directories); + verifyDirsAreEqual(DATA_DIR / "target" / "upper", DATA_DIR / "staging" / "upper"); + } + SECTION("lower case conversion") + { + Installer::install(DATA_DIR / "source" / "mod0.tar.gz", + DATA_DIR / "staging" / "lower", + Installer::lower_case | Installer::preserve_directories); + verifyDirsAreEqual(DATA_DIR / "target" / "lower", DATA_DIR / "staging" / "lower"); + } + SECTION("Single directory") + { + Installer::install(DATA_DIR / "source" / "mod0.tar.gz", + DATA_DIR / "staging" / "single_dir", + Installer::preserve_case | Installer::single_directory); + verifyDirsAreEqual(DATA_DIR / "target" / "single_dir", DATA_DIR / "staging" / "single_dir"); + } + SECTION("Upper case and single directory") + { + Installer::install(DATA_DIR / "source" / "mod0.tar.gz", + DATA_DIR / "staging" / "upper_single", + Installer::upper_case | Installer::single_directory); + verifyDirsAreEqual(DATA_DIR / "target" / "upper_single", DATA_DIR / "staging" / "upper_single"); + } +} + +TEST_CASE("Root levels", "[.installer]") +{ + resetStagingDir(); + SECTION("Level 0") + { + Installer::install(DATA_DIR / "source" / "mod0.tar.gz", + DATA_DIR / "staging" / "0", + Installer::preserve_case | Installer::preserve_directories, + Installer::SIMPLEINSTALLER, + 0); + verifyDirsAreEqual(DATA_DIR / "target" / "root_level" / "0", DATA_DIR / "staging" / "0"); + } + SECTION("Level 1") + { + Installer::install(DATA_DIR / "source" / "mod0.tar.gz", + DATA_DIR / "staging" / "1", + Installer::preserve_case | Installer::preserve_directories, + Installer::SIMPLEINSTALLER, + 1); + verifyDirsAreEqual(DATA_DIR / "target" / "root_level" / "1", DATA_DIR / "staging" / "1"); + } + SECTION("Level 2") + { + Installer::install(DATA_DIR / "source" / "mod0.tar.gz", + DATA_DIR / "staging" / "2", + Installer::preserve_case | Installer::preserve_directories, + Installer::SIMPLEINSTALLER, + 2); + verifyDirsAreEqual(DATA_DIR / "target" / "root_level" / "2", DATA_DIR / "staging" / "2"); + } + SECTION("Level 3") + { + Installer::install(DATA_DIR / "source" / "mod0.tar.gz", + DATA_DIR / "staging" / "3", + Installer::preserve_case | Installer::preserve_directories, + Installer::SIMPLEINSTALLER, + 3); + verifyDirsAreEqual(DATA_DIR / "target" / "root_level" / "3", DATA_DIR / "staging" / "3"); + } +} diff --git a/tests/test_lootdeployer.cpp b/tests/test_lootdeployer.cpp new file mode 100644 index 0000000..46e6720 --- /dev/null +++ b/tests/test_lootdeployer.cpp @@ -0,0 +1,75 @@ +#include "../src/core/lootdeployer.h" +#include "test_utils.h" +#include +#include + + +void resetFiles() +{ + sfs::path plugin_target = DATA_DIR / "target" / "loot" / "target" / "plugins.txt"; + sfs::path plugin_source = DATA_DIR / "source" / "loot" / "plugins.txt"; + sfs::path load_order_target = DATA_DIR / "target" / "loot" / "target" / "loadorder.txt"; + sfs::path load_order_source = DATA_DIR / "source" / "loot" / "loadorder.txt"; + for(const auto& dir_entry : sfs::directory_iterator(DATA_DIR / "target" / "loot" / "target")) + sfs::remove(dir_entry.path()); + sfs::copy(plugin_source, plugin_target); + sfs::copy(load_order_source, load_order_target); +} + + +TEST_CASE("State is read", "[.loot]") +{ + resetFiles(); + LootDeployer depl( + DATA_DIR / "target" / "loot" / "source", DATA_DIR / "target" / "loot" / "target", "", false); + REQUIRE(depl.getNumMods() == 3); + REQUIRE_THAT(depl.getModNames(), + Catch::Matchers::Equals(std::vector{ "a.esp", "c.esp", "d.esp" })); + REQUIRE_THAT(depl.getLoadorder(), + Catch::Matchers::Equals( + std::vector>{ { 0, true }, { 1, false }, { 2, true } })); +} + +TEST_CASE("Load order can be edited", "[.loot]") +{ + resetFiles(); + LootDeployer depl( + DATA_DIR / "target" / "loot" / "source", DATA_DIR / "target" / "loot" / "target", "", false); + depl.changeLoadorder(0, 2); + depl.setModStatus(1, true); + depl.setModStatus(0, false); + depl.changeLoadorder(2, 1); + REQUIRE_THAT(depl.getModNames(), + Catch::Matchers::Equals(std::vector{ "c.esp", "a.esp", "d.esp" })); + REQUIRE_THAT(depl.getLoadorder(), + Catch::Matchers::Equals( + std::vector>{ { 0, false }, { 1, true }, { 2, true } })); + LootDeployer depl2( + DATA_DIR / "target" / "loot" / "source", DATA_DIR / "target" / "loot" / "target", "", false); + REQUIRE_THAT(depl.getModNames(), Catch::Matchers::Equals(depl2.getModNames())); + REQUIRE_THAT(depl.getLoadorder(), Catch::Matchers::Equals(depl2.getLoadorder())); +} + +TEST_CASE("Profiles are managed", "[.loot]") +{ + resetFiles(); + LootDeployer depl( + DATA_DIR / "target" / "loot" / "source", DATA_DIR / "target" / "loot" / "target", "", false); + depl.addProfile(5); + depl.addProfile(0); + depl.setModStatus(0, false); + REQUIRE_THAT(depl.getLoadorder(), + Catch::Matchers::Equals( + std::vector>{ { 0, false }, { 1, false }, { 2, true } })); + depl.setProfile(1); + REQUIRE_THAT(depl.getLoadorder(), + Catch::Matchers::Equals( + std::vector>{ { 0, true }, { 1, false }, { 2, true } })); + depl.addProfile(0); + depl.setProfile(2); + REQUIRE_THAT(depl.getLoadorder(), + Catch::Matchers::Equals( + std::vector>{ { 0, false }, { 1, false }, { 2, true } })); + verifyDirsAreEqual( + DATA_DIR / "target" / "loot" / "target", DATA_DIR / "target" / "loot" / "profiles", true); +} diff --git a/tests/test_moddedapplication.cpp b/tests/test_moddedapplication.cpp new file mode 100644 index 0000000..4c44f10 --- /dev/null +++ b/tests/test_moddedapplication.cpp @@ -0,0 +1,250 @@ +#include "../src/core/deployerfactory.h" +#include "../src/core/installer.h" +#include "../src/core/moddedapplication.h" +#include "test_utils.h" +#include +#include +#include + + +const int INSTALLER_FLAGS = Installer::preserve_case | Installer::preserve_directories; + +TEST_CASE("Mods are installed", "[.app]") +{ + resetStagingDir(); + ModdedApplication app(DATA_DIR / "staging", "test"); + AddModInfo info{ + "mod 0", "1.0", Installer::SIMPLEINSTALLER, DATA_DIR / "source" / "mod0.tar.gz", {}, -1, + INSTALLER_FLAGS, 0 + }; + app.installMod(info); + verifyDirsAreEqual(DATA_DIR / "staging" / "0", DATA_DIR / "source" / "0"); + info.name = "mod 2"; + info.source_path = DATA_DIR / "source" / "mod2.tar.gz"; + app.installMod(info); + verifyDirsAreEqual(DATA_DIR / "staging" / "1", DATA_DIR / "source" / "2"); + info.name = "mod 1"; + info.source_path = DATA_DIR / "source" / "mod1.zip"; + app.installMod(info); + verifyDirsAreEqual(DATA_DIR / "staging" / "2", DATA_DIR / "source" / "1"); + + info.name = "mod 0->2"; + info.source_path = DATA_DIR / "source" / "mod2.tar.gz"; + info.group = 0; + info.replace_mod = true; + app.installMod(info); + verifyDirsAreEqual(DATA_DIR / "staging" / "0", DATA_DIR / "source" / "2"); + auto mod_info = app.getModInfo(); + REQUIRE(mod_info.size() == 3); + REQUIRE(mod_info[0].mod.name == "mod 0->2"); +} + +TEST_CASE("Deployers are added", "[.app]") +{ + resetStagingDir(); + resetAppDir(); + ModdedApplication app(DATA_DIR / "staging", "test"); + app.addDeployer({ DeployerFactory::SIMPLEDEPLOYER, "depl0", DATA_DIR / "app", false }); + AddModInfo info{ "mod 0", + "1.0", + Installer::SIMPLEINSTALLER, + DATA_DIR / "source" / "mod0.tar.gz", + { 0 }, + -1, + INSTALLER_FLAGS, + 0 }; + app.installMod(info); + info.name = "mod 1"; + info.source_path = DATA_DIR / "source" / "mod1.zip"; + app.installMod(info); + info.name = "mod 2"; + info.source_path = DATA_DIR / "source" / "mod2.tar.gz"; + app.installMod(info); + app.deployMods(); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "target" / "mod012", true); +} + +TEST_CASE("State is saved", "[.app]") +{ + resetStagingDir(); + resetAppDir(); + ModdedApplication app(DATA_DIR / "staging", "test"); + app.addDeployer({ DeployerFactory::SIMPLEDEPLOYER, "depl0", DATA_DIR / "app", false }); + app.addDeployer({ DeployerFactory::SIMPLEDEPLOYER, "depl1", DATA_DIR / "app_2", false }); + app.addProfile(EditProfileInfo{ "test profile", "", -1 }); + app.addTool("a tool", "a command"); + app.addTool("another tool", "another command"); + AddModInfo info{ "mod 0", + "1.0", + Installer::SIMPLEINSTALLER, + DATA_DIR / "source" / "mod0.tar.gz", + { 0 }, + -1, + INSTALLER_FLAGS, + 0 }; + app.installMod(info); + info.name = "mod 1"; + info.source_path = DATA_DIR / "source" / "mod1.zip"; + app.installMod(info); + info.name = "mod 2"; + info.source_path = DATA_DIR / "source" / "mod2.tar.gz"; + info.deployers = { 0, 1 }; + app.installMod(info); + + ModdedApplication app2(DATA_DIR / "staging", "test2"); + REQUIRE_THAT(app.getDeployerNames(), Catch::Matchers::Equals(app2.getDeployerNames())); + REQUIRE_THAT(app.getProfileNames(), Catch::Matchers::Equals(app2.getProfileNames())); + REQUIRE_THAT(app.getLoadorder(0), Catch::Matchers::Equals(app2.getLoadorder(0))); + auto app_tools = app.getAppInfo().tools; + auto app2_tools = app2.getAppInfo().tools; + REQUIRE(app_tools.size() == app2_tools.size()); + for(int i = 0; i < app_tools.size(); i++) + { + const auto& [name, command] = app_tools[i]; + const auto& [name2, command2] = app2_tools[i]; + REQUIRE(name == name2); + REQUIRE(command == command2); + } + sfs::create_directories(DATA_DIR / "app_2"); + app2.deployMods(); + verifyDirsAreEqual(DATA_DIR / "app", DATA_DIR / "target" / "mod012", true); + verifyDirsAreEqual(DATA_DIR / "app_2", DATA_DIR / "source" / "2", true); + sfs::remove_all(DATA_DIR / "app_2"); +} + +TEST_CASE("Groups update loadorders", "[.app]") +{ + resetStagingDir(); + ModdedApplication app(DATA_DIR / "staging", "test"); + app.addDeployer({ DeployerFactory::SIMPLEDEPLOYER, "depl0", DATA_DIR / "app", false }); + app.addDeployer({ DeployerFactory::SIMPLEDEPLOYER, "depl1", DATA_DIR / "app_2", false }); + AddModInfo info{ "mod 0", + "1.0", + Installer::SIMPLEINSTALLER, + DATA_DIR / "source" / "mod0.tar.gz", + { 0 }, + -1, + INSTALLER_FLAGS, + 0 }; + app.installMod(info); + info.name = "mod 1"; + info.deployers = { 0, 1 }; + info.source_path = DATA_DIR / "source" / "mod1.zip"; + app.installMod(info); + app.createGroup(1, 0); + REQUIRE_THAT(app.getLoadorder(0), Catch::Matchers::Equals(app.getLoadorder(1))); + REQUIRE_THAT(app.getLoadorder(0), + Catch::Matchers::Equals(std::vector>{ { 1, true } })); + app.changeActiveGroupMember(0, 0); + REQUIRE_THAT(app.getLoadorder(0), + Catch::Matchers::Equals(std::vector>{ { 0, true } })); + info.name = "mod 2"; + info.source_path = DATA_DIR / "source" / "mod2.tar.gz"; + app.installMod(info); + REQUIRE_THAT( + app.getLoadorder(0), + Catch::Matchers::Equals(std::vector>{ { 0, true }, { 2, true } })); + app.addModToGroup(2, 0); + REQUIRE_THAT(app.getLoadorder(0), + Catch::Matchers::Equals(std::vector>{ { 2, true } })); +} + +TEST_CASE("Mods are split", "[.app]") +{ + resetStagingDir(); + ModdedApplication app(DATA_DIR / "staging", "test"); + app.addDeployer( + { DeployerFactory::SIMPLEDEPLOYER, "depl0", DATA_DIR / "source" / "split" / "targets", false }); + app.addDeployer({ DeployerFactory::CASEMATCHINGDEPLOYER, + "depl1", + DATA_DIR / "source" / "split" / "targets" / "a", + false }); + app.addDeployer({ DeployerFactory::SIMPLEDEPLOYER, + "depl3", + DATA_DIR / "source" / "split" / "targets" / "a" / "b", + false }); + app.addDeployer({ DeployerFactory::SIMPLEDEPLOYER, + "depl3", + DATA_DIR / "source" / "split" / "targets" / "a" / "b" / "123", + false }); + app.addDeployer({ DeployerFactory::SIMPLEDEPLOYER, + "depl4", + DATA_DIR / "source" / "split" / "targets" / "a" / "c", + false }); + app.addDeployer({ DeployerFactory::SIMPLEDEPLOYER, + "depl2", + DATA_DIR / "source" / "split" / "targets" / "d", + false }); + AddModInfo info{ "mod 0", + "1.0", + Installer::SIMPLEINSTALLER, + DATA_DIR / "source" / "split" / "mod", + { 0 }, + -1, + INSTALLER_FLAGS, + 0 }; + app.installMod(info); + sfs::remove(DATA_DIR / "staging" / "lmm_mods.json"); + sfs::remove(DATA_DIR / "staging" / ".lmm_mods.json.bak"); + verifyDirsAreEqual(DATA_DIR / "staging", DATA_DIR / "target" / "split"); +} + +TEST_CASE("Mods are uninstalled", "[.app]") +{ + resetStagingDir(); + ModdedApplication app(DATA_DIR / "staging", "test"); + app.addDeployer({ DeployerFactory::SIMPLEDEPLOYER, "depl0", DATA_DIR / "app", false }); + app.addDeployer({ DeployerFactory::SIMPLEDEPLOYER, "depl1", DATA_DIR / "app_2", false }); + AddModInfo info{ "mod 0", + "1.0", + Installer::SIMPLEINSTALLER, + DATA_DIR / "source" / "mod0.tar.gz", + { 0 }, + -1, + INSTALLER_FLAGS, + 0 }; + app.installMod(info); + info.name = "mod 1"; + info.source_path = DATA_DIR / "source" / "mod1.zip"; + app.installMod(info); + info.name = "mod 2"; + info.source_path = DATA_DIR / "source" / "mod2.tar.gz"; + info.deployers = { 0, 1 }; + info.group = 1; + app.installMod(info); + app.uninstallMods({ 0, 2 }); + auto mod_info = app.getModInfo(); + REQUIRE(mod_info.size() == 1); + REQUIRE(mod_info[0].mod.id == 1); + REQUIRE(mod_info[0].mod.name == "mod 1"); + REQUIRE_THAT(mod_info[0].deployer_ids, Catch::Matchers::Equals(std::vector{ 0, 1 })); + REQUIRE_THAT(app.getLoadorder(0), + Catch::Matchers::Equals(std::vector>{ { 1, true } })); + REQUIRE_THAT(app.getLoadorder(1), + Catch::Matchers::Equals(std::vector>{ { 1, true } })); + verifyDirsAreEqual(DATA_DIR / "staging", DATA_DIR / "target" / "remove" / "simple"); + + info.deployers = { 0 }; + info.group = -1; + info.name = "mod 0"; + info.source_path = DATA_DIR / "source" / "mod0.tar.gz"; + app.installMod(info); + info.name = "mod 2"; + info.source_path = DATA_DIR / "source" / "mod2.tar.gz"; + info.group = 1; + app.installMod(info); + app.uninstallGroupMembers({ 1 }); + mod_info = app.getModInfo(); + REQUIRE(mod_info.size() == 2); + REQUIRE(mod_info[0].mod.id == 1); + REQUIRE(mod_info[0].mod.name == "mod 1"); + REQUIRE(mod_info[0].group == -1); + REQUIRE(mod_info[1].mod.id == 2); + REQUIRE(mod_info[1].mod.name == "mod 0"); + REQUIRE_THAT( + app.getLoadorder(0), + Catch::Matchers::Equals(std::vector>{ { 1, true }, { 2, true } })); + REQUIRE_THAT(app.getLoadorder(1), + Catch::Matchers::Equals(std::vector>{ { 1, true } })); + verifyDirsAreEqual(DATA_DIR / "staging", DATA_DIR / "target" / "remove" / "version"); +} diff --git a/tests/test_tagconditionnode.cpp b/tests/test_tagconditionnode.cpp new file mode 100644 index 0000000..e5670da --- /dev/null +++ b/tests/test_tagconditionnode.cpp @@ -0,0 +1,164 @@ +#include "../src/core/autotag.h" +#include "../src/core/tagconditionnode.h" +#include "test_utils.h" +#include + + +TEST_CASE("Expressions are validated", "[.tags]") +{ + REQUIRE_FALSE(TagConditionNode::expressionIsValid("", 1)); + REQUIRE_FALSE(TagConditionNode::expressionIsValid("and", 1)); + REQUIRE_FALSE(TagConditionNode::expressionIsValid("or", 1)); + REQUIRE_FALSE(TagConditionNode::expressionIsValid("not", 1)); + REQUIRE_FALSE(TagConditionNode::expressionIsValid("1", 1)); + REQUIRE_FALSE(TagConditionNode::expressionIsValid("0 and", 1)); + REQUIRE_FALSE(TagConditionNode::expressionIsValid("0 and and 0", 1)); + REQUIRE_FALSE(TagConditionNode::expressionIsValid("0 (and 0)", 1)); + REQUIRE_FALSE(TagConditionNode::expressionIsValid("0 not 0", 1)); + REQUIRE_FALSE(TagConditionNode::expressionIsValid("0()", 1)); + REQUIRE_FALSE(TagConditionNode::expressionIsValid("0 (or) 0", 1)); + REQUIRE_FALSE(TagConditionNode::expressionIsValid("(0 or 0))", 1)); + REQUIRE_FALSE(TagConditionNode::expressionIsValid("(0 (not 0)", 1)); + REQUIRE_FALSE(TagConditionNode::expressionIsValid("0 or not 0F", 1)); + REQUIRE_FALSE(TagConditionNode::expressionIsValid("0 an 0", 1)); + REQUIRE_FALSE(TagConditionNode::expressionIsValid("0 and not 1", 1)); + REQUIRE_FALSE(TagConditionNode::expressionIsValid("0 and 2 or 3 and not 4 and 5", 5)); + REQUIRE_FALSE(TagConditionNode::expressionIsValid("obviously invalid", 1)); + REQUIRE_FALSE(TagConditionNode::expressionIsValid("0 not and 1", 2)); + REQUIRE_FALSE(TagConditionNode::expressionIsValid("0 an d 1", 2)); + + REQUIRE(TagConditionNode::expressionIsValid("0", 1)); + REQUIRE(TagConditionNode::expressionIsValid("not1", 2)); + REQUIRE(TagConditionNode::expressionIsValid("0 and 0", 1)); + REQUIRE(TagConditionNode::expressionIsValid("((0)or(0))", 1)); + REQUIRE(TagConditionNode::expressionIsValid("0 and not 1", 2)); + REQUIRE(TagConditionNode::expressionIsValid("notnotnot0 and not not1 or not00001", 2)); + REQUIRE(TagConditionNode::expressionIsValid("(0 or not 1) andnot 2 and (0 or 0)", 3)); + REQUIRE(TagConditionNode::expressionIsValid("not(not0) and (1) or 2", 3)); +} + +TEST_CASE("Single node detects files", "[.tags]") +{ + std::vector conditions{ + { false, TagCondition::Type::file_name, false, "*.txt" }, + { false, TagCondition::Type::file_name, false, "*12*abc" }, + { false, TagCondition::Type::path, false, "dir/abc/*c_1*" }, + { true, TagCondition::Type::file_name, false, "fawefw*fQFQ*3q*" }, + { false, TagCondition::Type::file_name, true, R"(some_\d+_file_.b.)" }, + { false, TagCondition::Type::path, true, R"(d\wr/some_\d+_file_.b.)" }, + { false, TagCondition::Type::file_name, false, "*a*a*a*" } + }; + const auto files = + AutoTag::readModFiles(DATA_DIR / "source" / "auto_tags", std::vector{ 0, 1 }); + + TagConditionNode node("0", conditions); + REQUIRE(node.evaluate(files.at(0))); + REQUIRE_FALSE(node.evaluate(files.at(1))); + + TagConditionNode node_2("1", conditions); + REQUIRE(node_2.evaluate(files.at(0))); + REQUIRE_FALSE(node_2.evaluate(files.at(1))); + + TagConditionNode node_3("2", conditions); + REQUIRE(node_3.evaluate(files.at(0))); + REQUIRE_FALSE(node_3.evaluate(files.at(1))); + + TagConditionNode node_4("3", conditions); + REQUIRE(node_4.evaluate(files.at(0))); + REQUIRE(node_4.evaluate(files.at(1))); + + TagConditionNode node_5("4", conditions); + REQUIRE(node_5.evaluate(files.at(0))); + REQUIRE_FALSE(node_5.evaluate(files.at(1))); + + TagConditionNode node_6("5", conditions); + REQUIRE(node_6.evaluate(files.at(0))); + REQUIRE_FALSE(node_6.evaluate(files.at(1))); + + TagConditionNode node_7("6", conditions); + REQUIRE(node_7.evaluate(files.at(0))); + REQUIRE_FALSE(node_7.evaluate(files.at(1))); +} + +TEST_CASE("Expressions of depth 1 are parsed", "[.tags]") +{ + std::vector conditions{ { false, TagCondition::Type::file_name, false, "*.txt" }, + { false, TagCondition::Type::file_name, false, "*12*abc" }, + { false, TagCondition::Type::path, false, "dir/abc/*c_1*" }, + { false, TagCondition::Type::file_name, false, "r*3" } }; + const auto files = + AutoTag::readModFiles(DATA_DIR / "source" / "auto_tags", std::vector{ 0, 1 }); + + TagConditionNode node("0 and 1 and 2 and 3", conditions); + REQUIRE(node.evaluate(files.at(0))); + REQUIRE_FALSE(node.evaluate(files.at(1))); + + TagConditionNode node2("0 or 1 or 2 or 3", conditions); + REQUIRE(node2.evaluate(files.at(0))); + REQUIRE(node2.evaluate(files.at(1))); +} + +TEST_CASE("Complex expressions are parsed", "[.tags]") +{ + std::vector conditions{ + { false, TagCondition::Type::file_name, false, "*.txt" }, + { false, TagCondition::Type::file_name, false, "*12*abc" }, + { false, TagCondition::Type::path, false, "dir/abc/*c_1*" }, + { true, TagCondition::Type::file_name, false, "fawefw*fQFQ*3q*" }, + { false, TagCondition::Type::file_name, true, R"(some_\d+_file_.b.)" }, + { false, TagCondition::Type::path, true, R"(d\wr/.*\.png)" }, + { false, TagCondition::Type::file_name, false, "*rw3*" }, + { false, TagCondition::Type::file_name, false, "unique_*f*" }, + { false, TagCondition::Type::path, false, "j/n" }, + { false, TagCondition::Type::path, false, "qwert" }, + { false, TagCondition::Type::path, true, R"(j/a_fi.*)" } + }; + const auto files = + AutoTag::readModFiles(DATA_DIR / "source" / "auto_tags", std::vector{ 0, 1, 2 }); + + TagConditionNode node("not(not(not(0 and 1 and 2 and not not 3) or 4 and 3)) ", conditions); + REQUIRE(node.evaluate(files.at(0))); + REQUIRE(node.evaluate(files.at(1))); + + TagConditionNode node2("(not(not(0 and 1 and 2 and not not 3) or 4 and 3)) ", conditions); + REQUIRE_FALSE(node2.evaluate(files.at(0))); + REQUIRE_FALSE(node2.evaluate(files.at(1))); + + TagConditionNode node3("0 and 1 and 2 and 3 and 4", conditions); + TagConditionNode node4("5 and 6", conditions); + TagConditionNode node5("not 9 and 10 or 7 and 8", conditions); + REQUIRE(node3.evaluate(files.at(0))); + REQUIRE(node4.evaluate(files.at(1))); + REQUIRE(node5.evaluate(files.at(2))); + + TagConditionNode node6( + "0 and 1 and 2 and 3 and 4 and not(5 and 6) and not (not 9 and 10 or 7 and 8)", conditions); + TagConditionNode node7( + "not(0 and 1 and 2 and 3 and 4) and 5 and 6 and not(not 9 and 10 or 7 and 8)", conditions); + TagConditionNode node8( + "not(0 and 1 and 2 and 3 and 4) and not(5 and 6) and (not 9 and 10 or 7 and 8)", conditions); + REQUIRE(node6.evaluate(files.at(0))); + REQUIRE_FALSE(node7.evaluate(files.at(0))); + REQUIRE_FALSE(node8.evaluate(files.at(0))); + REQUIRE_FALSE(node6.evaluate(files.at(1))); + REQUIRE(node7.evaluate(files.at(1))); + REQUIRE_FALSE(node8.evaluate(files.at(1))); + REQUIRE_FALSE(node6.evaluate(files.at(2))); + REQUIRE_FALSE(node7.evaluate(files.at(2))); + REQUIRE(node8.evaluate(files.at(2))); + + TagConditionNode node9( + "not not(0 and not not 1 and 2 and 3 and 4 or 5 and 6 or (not 9 and 10 or 7 and 8))", + conditions); + REQUIRE(node9.evaluate(files.at(0))); + REQUIRE(node9.evaluate(files.at(1))); + REQUIRE(node9.evaluate(files.at(2))); + + TagConditionNode node10("0 or 6 and 5", conditions); + REQUIRE(node10.evaluate(files.at(0))); + REQUIRE(node10.evaluate(files.at(1))); + + TagConditionNode node11("(0 or 6) and 5", conditions); + REQUIRE_FALSE(node11.evaluate(files.at(0))); + REQUIRE(node11.evaluate(files.at(1))); +} diff --git a/tests/test_utils.cpp b/tests/test_utils.cpp new file mode 100644 index 0000000..6a7e049 --- /dev/null +++ b/tests/test_utils.cpp @@ -0,0 +1,67 @@ +#include "test_utils.h" +#include +#include + + +std::vector getFiles(sfs::path dir, bool get_contents = false) +{ + std::vector files; + for(const auto& dir_entry : sfs::recursive_directory_iterator(dir)) + { + if(dir_entry.path().filename() == ".lmmfiles") + continue; + std::string entry = dir_entry.path().string().erase(0, dir.string().size()); + if(get_contents && dir_entry.is_regular_file()) + { + std::ifstream file(dir_entry.path()); + entry.append(std::istreambuf_iterator(file), std::istreambuf_iterator()); + file.close(); + } + files.push_back(entry); + } + return files; +} + +void verifyDirsAreEqual(sfs::path first_dir, sfs::path second_dir, bool test_content) +{ + std::vector first_dir_files = getFiles(first_dir, test_content); + std::vector second_dir_files = getFiles(second_dir, test_content); + + CAPTURE(first_dir.string()); + CAPTURE(second_dir.string()); + REQUIRE(second_dir_files.size() == first_dir_files.size()); + REQUIRE_THAT(first_dir_files, Catch::Matchers::UnorderedEquals(second_dir_files)); +} + +void resetAppDir() +{ + if(sfs::exists(DATA_DIR / "app")) + sfs::remove_all(DATA_DIR / "app"); + sfs::copy_options options(sfs::copy_options::recursive | sfs::copy_options::overwrite_existing); + sfs::copy(DATA_DIR / "source" / "app", DATA_DIR / "app", options); +} + +void resetStagingDir() +{ + if(sfs::exists(DATA_DIR / "staging")) + sfs::remove_all(DATA_DIR / "staging"); + sfs::create_directories(DATA_DIR / "staging"); +} + +void verifyFilesAreEqual(sfs::path first_file, sfs::path second_file) +{ + CAPTURE(first_file.string()); + CAPTURE(second_file.string()); + REQUIRE(sfs::exists(first_file)); + REQUIRE(sfs::exists(second_file)); + std::string content_first_file = ""; + std::ifstream file(first_file); + content_first_file.append(std::istreambuf_iterator(file), std::istreambuf_iterator()); + file.close(); + std::string content_second_file = ""; + file.open(second_file); + content_second_file.append(std::istreambuf_iterator(file), + std::istreambuf_iterator()); + file.close(); + REQUIRE(content_first_file == content_second_file); +} diff --git a/tests/test_utils.h.in b/tests/test_utils.h.in new file mode 100644 index 0000000..73b4b68 --- /dev/null +++ b/tests/test_utils.h.in @@ -0,0 +1,17 @@ +#pragma once + +#define BASE_PATH sfs::path("@PROJECT_SOURCE_DIR@") + +#include +#include +#include +#include + +namespace sfs = std::filesystem; + + +const sfs::path DATA_DIR = BASE_PATH / "data"; +void verifyDirsAreEqual(sfs::path first_dir, sfs::path second_dir, bool test_content = false); +void resetAppDir(); +void resetStagingDir(); +void verifyFilesAreEqual(sfs::path first_file, sfs::path second_file); diff --git a/tests/tests.cpp b/tests/tests.cpp new file mode 100644 index 0000000..95e9b8e --- /dev/null +++ b/tests/tests.cpp @@ -0,0 +1,5 @@ +/* + * This file contains the test main function, which is auto generated by catch2 + */ + +#include