Project restore working

This commit is contained in:
Nick Beckley 2026-06-05 16:40:25 +01:00
parent aebaa00371
commit f8bf5af17f
18 changed files with 549 additions and 36 deletions

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUzW7bOBB+FYFnUaEoihIF7MFwWtRI0xiJu7vojSKHMRFZNCjKaRD43ReU5dopetibMCJnvr/hO+rlDlCDFvv9pwP0YUApCnYqUUI5JhwTtslFw3hDaJYLzhkVP1CK7B28oQZVTKlcCcAFcMBMkxK3hVFYaxA5yTWhJYs95fOAmnckbabhYBVkbvgb/GBdjxr0j+21e42zpc1U50adedfBqh+C7FUE8221vMNfF+vNw/p0aoAhXs6sRg0qZNFWihhcKOCYFUbiGpTBVEhWAi9pySMIaTPbB/C97LJBv1zmaxd6CKqhGS0ygqlAxxRpGWSE3MoBNm/7iGKS6DbW06k8fTbv6AAeNTQ9i3lqd6M6e7Mb2tF2+iZI/wzBeLmDV+df4CA7lKK9d3vwwcKkzcNTcoGUk4xklFNCUIrupdraHpLVbfLQRcJS1YSBLrAAozETucSCFQXWRSkryk1VUYNSdAe+h+6q671V3g3OhGSWPPkwZ+n6YPvRjUOy6gM8exlO9z7LbgCUovXYdnbYPoLUbxv3OMZ//dhFLsvRe+hDspZhm3yRwxY1SMha5VJIISVXykBJwBBNJCeVBq45aEZ1pZlgmjNBFGeyzZmWZd6aWEEpehjDfgxn/X9egdh4u9uBviDYTBqvOxmM87uVhj5YY6M15xNToLpuYpXMPZedhT58EBk1iORAdVEDNjTnmNVaYlkWAnNecAO5EhXNT4IZ+zz+0okYo1lhyrrmrWhzAtRUNSOVaYlWplBCly1UtCJVDbVQhLQly+tWKKIhL5mO7LzTowq/R6GYDHoc+7idySrSfrU9/jkFe1Zk7V1wynXRMNvB09sQYPcn15Zut3eDDZG/ma39PsC96+Pfef9nzebLCxcuNYh7kEyrJ2jBCsY1JqximDFTY1GWGjNatsKYNudFgVJ069QL+CQGTNp+8uScqYenZOHV1gZQYfRx8r8TqZOdn88rc9Ej+/Zps3QeFvt9Olf/OkSRTs3OAfjwrnwf4vGtG8IV5ZnaE3TmDOwqThoOp6fKxiLXugbNc0xZXmImRI5rUVa4repWtLyGUl3imjyCth5UmPpt/Binzcr+KZcLH6yRKgxxeb46NcVpzud85HeQV7b9un0afmXdHKW5U05iZJ9OD+eUoP/1bh6Px+N/AAAA//8DACFE8GcoBgAA

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSTTW/cOAyG/4rBs+Xo25ZvwaSLHWTbDNpBd7E3WaIbIY48kOW0RTD/vZBnijZ724sgECL5vg+pV4j2GaGH29Pp3QvGvEANOWwhTrkmVBMqj8z0UveMNqoTujP6X6gh3ON36KGVzjFnkAjUSKSnigxidMR7NIwyT7mSpab9skD/CjY0Hl+Cw2ZePmNawhyhh79D9PPX0tuGxk3z6ps0T7iPS7bRFTEf9rt78tft4fhwuLxacCnJTfDQg7BiaB0diXCoiRSjJR26kXBjpUKtuNJFhA1NiBlTtFOz+Kdf/f2cI2bX84aLhhJu4FyDt9kWyYNd8Pj9VFRsiO5KvN7C27V/hRdM0PP6J8xLuRs3hZvnZVjD5G8uZ7bLU3F5SvMJUw64MdlsTpPNYY7VtdNuChgz1PDwqTpMNo9zen4D6tPF//5/2D+W7u++oVsz+t28xgw9MEmhhvfWPYaI1f4OeqAMuRcdkpEzTWTnLbFKGKK10CMyZ1rOoIbdHHOI67wu1T5m/JI2A9DDH3Za8CL9NrnHkNHlNRVX/2w6Dmn2q8vVL/6MNrQRtCh5WPNpzdVH9CGhy1j8HdN6rfefFK75lrRbU8KYq4PNj9WfdnmEHoztHLPGGmu1cyMqiiP11GraetReo5fct14a6bU01GlpBya9VWwYSwRqwDLuatswL5VrR8mJ4iMSKbwgHSpBqOCqVa1sFdVvOFYPU8mzrqMSvSAGR0+kYZYYKQTxQtmW67Ft+fgbkuv0GS2APb5c/sqmQHvfodeMcMkUkcYw0hnVkqHtBjPoDpUrmu8xRZx+A/U+uDQv85ir6/JUb9B9XGP579W2SF9DJN+2Gd3N7glTVWZsQyzrfR3r+Xw+/wAAAP//AwD+oJloNgQAAA==

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/cNhCG/4rAsyjzS5Som7FO0IWbepEs0iC3ETmKCcvUgqScGIb/e0HtFo5PRS8EMeLMvHzmpV5IgEckA7k+nT48YciJ1CT7LSSY0JRpytSRm0HpgbOm7TnTSnwnNfG3+EwG0ilruTVIJWqkyrGWjnKy1Dk0nHHHRKtKTfiRyPBCwDcOn7zFZklfMSa/BDKQv31wy8/SG3xj52V1TVxm3IeUIdgi5q/97pb+eX043h3OpxKmktx4RwYiQY6dZROVFjVVcgLao52oMKBa1K1odREBvvEhYwwwN8k9vPV3Sw6Y7SAaIRtGhSGvNXGQoUgeIeHx+VRUbIhuSrzewtt2eCFPGMkg6n9hnstd2dlfPaZx9bO7Oq8Z0kPa1glsXuIzqckpLieM2ePG5zolfBzn5yOkh4/nM2WbPvxCu2Z0u2UNmQyEy47UZAM0z5D9EqqLxt3sMeTtY44+JG//q5YkNbn7Uh1myNMSH9/N48sZ8/5/UP4E9t4HrPY3ZCCMo3CyRzoJrqnqHVBopaFaSz0ht6YTnNRkt4Tsw7qsqdqHjD/idiUykI8wJzzru4723me0eY3lnt+2Zoe4uNXm6m2WnDWskYyVpDWf1lx9Rucj2ozlEse4Xup9fZ8itNiSdmuMGHJ1gHxf/QHpngzEQG85GDAA2toJW4YTcww06xxqp9Ep4TqnjHJaGWa1gpErBy0fpxIhNcFinWpz69RxJjTr6WR5R5XTnILkhgqpuetlxwwUg71xrO7mkge2ZwqdpAYnR5XhQI2SkjrZQif01HVi+g3JxQ+cFcAOn87vblOgneux9BWKt1QZw2lv2o6OXT+aUffY2qL5FmPA+TdQn7yNS1qmXF0cUr1D93kN5d9RbW756QP9tc3oZrEPGKsyY/ChPJXLWF9fX1//AQAA//8DAO7pQJCCBAAA

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUXW/lJhD9KxavNV7AGIPf0iSrXm2zifZetVXfxjAkNA6+wji7qyj/vcI32U9V6otlDTDnzJkz80QiPCAZyNnxePmIMS+kJjlsIcGEokxRJg/cDFINnDWdVsIw8TepSXiHn8lAemkttwZpiwqpdKyjY+stdQ4NZ9wx0cmSE24XMjwRCI3Dx2CxmZc/MC1hjmQgf4bo5o8FG0Jjp3l1TZon3MUlQ7SFzPvd+Tv6+9nN4frmdGvBpTxugiMDaaEde8s8bS0qKlsPVKP1VBiQHapOdKqQgNCEmDFFmJrF3X/Fd3OOmO0gGtE2jApDnmviIEOhPMKCh8/HwmKT6KLE6y28/Q5P5BETGUT9KuYp3Rs7hTcPy7iGyb3ZvqQmxzQfMeWAmxrX++orCc4a1gglGCM1uQJ7FyJWu4vqeiolgtVMomupQe+oNByokW1LXdtBL5Tve+FJTd5hijh9k/Uq2DQvs8/Vi8jVdzj7s/PLCOOEBeMtTAuSmuyWfYboYJojXn5Cu+ZTrkNay/H5HHOI67wu1S5mvE3wcv76/nxNCWOubiDfVb/BckcGYkBbDgYMgLLWY8fQM8dAsd6hcgqdFK530kinpGFWSRi5dNDx0ZcIqcmvRcP9ai0uy1c2m0mmaeNQvfTpfAoY83cykoEwjsK1GqkXXFGpHVDoWkOVapVHbk0v+CvMAdItZjIQr8dxZNaOQhpuQRhonWetU94I0fcgfdv1UijVsR6s63vBpWt7tND6lmlBanKTZrfa/GOr260BN2n+B20uUpGBKNZ12sI4CiW5BqNRciW1Mtgb6YHJ1o7AOw3jyBl0zBotLHBlQI6u819Uuoy3IeJFWI4TfP4GVzeqaX+xAJp7kD9cf5vgAT/O6f79ycbN+8sDqcmHNZaNUO2KRT6GSD9tw3S1Tjkc7hKCQ3c1O/zZR1jGpdomVCjF9QgjZWpkVPrOU4Oqox57zk2vi6FJTS5me4+pKg6DEMtUfUl2va/Okr0LGW1eU+H318bjel/dTJD9nB6+2yTfFPajAI1oOSudcfh42kYbR+WcRqc4FZJ3VBrDqTZdT8dej2ZUGjtbFLte83HN1Qd0IaHNW8UvXtwwz+/Q3v8sxqsLXizKWfHa/rTFNmn/1xJ7rskDwrImfNi29fBEdjFi2pAv1tMo7uJVmKawoJ2jW8igdcubUs8Lw/++Z1SjtODPz8/P/wIAAP//AwA2Sn9NGwYAAA==

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTy27cOBD8FYFnUeZLlKibMd5FBk7Wg8RIgtxaZCtDRCYHFOUkMObfF9RM4GRveyOK7O7qquILCfCEZCC3p9NfzxjyQmqS/QYJJjRlmjL1yM2g9MBZ0/a9Ml37hdTE3+NPMpBOWcutQSpRI1WOtXSUk6XOoeGMOyZaVXrC14UMLwR84/DZW2zi8hHT4mMgA/nkg4vfy2zwjZ3j6poUZ9yHJUOwhcw/+909fXt7eHw4XF4tuJTixjsyEAly7CybqLSoqZIT0B7tRIUB1aJuRasLCfCNDxlTgLlZ3LfX+S7mgNkOohGyYVQYcq6JgwyF8ggLPv48FRabRHcFrzd4Ow4v5BkTGUT9S8xLuxs7+xsbn54guJvJB78cSU1OKZ4wZY+bHNuG8wzZx1Bdh+xmjyGTmjx8qA4z5Cmmpz80+nBZff8/Nn8H9ugDVvs7MhDGUTjZI50E11T1Dii00lCtpZ6QW9MJTmqyiyH7sMZ1qfYh49e0sSQD+RvmBS/8bpM9+ow2r6lQ/7wNwx8+76IrACM1OaToVpurV7k5a1gjWbl8WPNpzdV7dD6hzVh2ekzrtf1/SoQWW9FuTQlDrg6Qj9UbWI5kIAZ6y8GAAdDWTtgynJhjoFnnUDuNTgnXOWWU08owqxWMXDlo+TgVpPAu7lZboJzknXYMqBXAqBIcqXFC0rGfhATTj1KWDLzKWj3MpQ5szxQ6SQ1OjirDgRolJXWyhU7oqevE9JskV8c5K3o7fL58jY2Bdq5HpzkVirdUGcNpb9qOjl0/mlH32NrC+R5TwPk3od55m+ISp1xdA1P9Id37NZTvXW3h+e4D/bFZdhftN0xVsRx8KGm+unw+n8//AgAA//8DAMd6qbMlBAAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUS2/bOBD+KwLPokJSFPUA9mA4LdZI0xiJu7vYG0UOYyKyaFCU06DIf19Qkmun6GFvwmhe32P4A/XyAKhBq+Px0wn6MKAUBTuFGGECE4FJsaNFk4uGFxnjlFQV+xelyN7BG2pQyZWiqgacgwDMNSlwmxuFtYaaEqoJK3jsKZ8H1PxA0mYaTlZB5oa/wA/W9ahBf9teu9c4W9pMdW7UmXcdbPohyF7FZb5u1nf4y2q7e9jOWQMMsTizGjWIaEoo1QxrSjjmOaW4FVBgnatCqoqJnJRzme0D+F522aBfLvO1Cz0E1bCM5RnBrEbvKdIyyLhyKwfYvR3jFhNFtzGeTuHps/mBTuBRw9IzmXO7G9XZm8PQjrbTN0H6ZwjGywO8Ov8CJ9mhFB29O4IPFiZuHp6Sy0qUZCRjghGCUnQv1d72kGxuk4cuApaqIhx0jmswGvOaSlzzPMc6L2TJhClLZlCK7sD30F11vbfKu8GZkCyUJx/mrF0fbD+6cUg2fYBnL8Nc91l2A6AUbce2s8P+EaR+27nHMf7rxy5iWY/eQx+SrQz75E857FGDalkpKmtZSymUMlAQMEQTKUipQWgBmjNdal5zLXhNlOCypVzLgrYmRlCKHsZwHMOZ/+9XS+y8PRxAXzbYTRxvOxmM84eNhj5YY6M054zJUF03oUqWnuvOQh8+kBwdRYHpvAJsGBWYV1piWeQ1FiIXBqiqS0Znwox9Hn/yRIzRPDdFVYm2bikBZsqKk9K0RCuTq1oXLZSsJGUFVa0IaQtOq7ZWRAMtuI7ovNOjCr9aIZ8Eehz7eJ3JJsJ+tT3+LviFka13wSnXRcFsB09vQ4DD71Rbu8PRDTZE/GaR9tsA966Pf5f7XzhbilcuXGIQ7yCZTk+DKouKaMxNLjBvKcdSCIrrqmV5wQmRJKK6deoFfBINJm0/aXL21MNTsvJqbwOoMPo4+Z8J1Czn5/PJXPjIvn7arZ2H1fGYLtE/TpGkudnZAB/elW9DTN+7IaIIfrzy0RN05rzXlZs0nOaXysag0LoCLShmnBaY1zXFVV2UuC2rtm5FBYW6uDV5BG09qDD1283TFmJ/Z8uVD9ZIFYZ4O1+cmty02HNJ+XXJK9V+Vs/Dr5RbnLR0oiQ69ml+NycD/a9n8/39/f0/AAAA//8DADRAO7QnBgAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTTW/bOBD9KwLPpsIvUaJugbOLGmk3Rhu0i72NOaOaqEIZFJW2CPzfF5RdtOmtN+KRb+bNe8MXFuGJWM9uT6e/ninmmW1YDiukhLJcWC6aR9n02vaNq7Vx1mjzH9uwcE/fWc9a4730jrgmS9ygaPhBD54jkpNColCNKTXh88z6FwahRnoOnupp/khpDlNkPfsUIk5fS28ItR+nBes0jbSLc4boi5h/dtt7/vZ2//iwv7yaaS7kOiDrmUAppETFUQrDjZaSHyw1HLVvwHfKatFeaCFmShHGesYvP/vjlCNl36ta6Vpw5dh5wxAyFMkHmOnx+6moWC26K/hmhddj/8KeKbFebX6YeSl348dw46enJ4h4M4QY5iPbsFOaTpRyoNWOdcJxhBymWF2bbMdAMbMNe/hQ7UfIw5SeXnn04TL67g8mfwf+GCJVu7vCkaRQd8QHJS03HQKHRjturbYDSe9aJdmGbaeYQ1ymZa52MdPntKpkPfsbxpku+m6TP4ZMPi+pSP/XlqzpW8jbCQsg2Ibt04SLz9VPu6WoRa1FuXxY8mnJ1XvCkMhnKjM9puVa/jeKsmolbZeUKOZqD/lYvYH5yHrmoPMSHDgA6/1AjaBBoAArWiSLltAobNE4g9Y44a2BgzQIjTwMBSm6S7rVulAtEBljJTe+G7jRGrjrPHLTkIHOOeiwe2Vr9TAWHvhOGELNHQ3IjZPAndGao26gVXZoWzX8Ysk1cSmK30jPl6+xKrCIHaGVXBnZcOOc5J1rWn5ou4M72I4aXzTfU4o0/mLUu+DTNE9Drq4LU72y7v0Sy/eu1uX5GiL/tkZ2N/kvlKoSOYRYtvma8vl8Pv8PAAD//wMAz/peqCUEAAA=

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRUy27cOBD8FYFnUeZLlKibMU6wA2/Wg2SQDXKj2K2YsEwNSMqJYfjfF9TMwvFpsReCaLGK1dVFvZBgH5EM5Pp0+vCEISdSk+y3kmBCU6Ypa4+8HaQeWtNIJZQS6jupib/FZzKQTjnHnUEqUSNVwFo6yslRADSccWCiVYXT/khkeCHWN4BP3mGzpK8Yk18CGcjfPsDys9xtfePmZYUmLjPuQ8o2uCLmr/3ulv55fTjeHc6nEqYCbjyQgTDgjHMQFDhTVEnO6aixpSBda10vtGTdGeZDxhjs3CR4eLsflhwwu0E0QjaMCkNeawI22yJ5tAmPz6eiYrPoptTrrbxthxfyhJEMov7XzDPdlZv91WMaVz/D1XnNNj2kbZ2sy0t8JjU5xeWEMXvc/LlOCR/H+flo08PH85myTR9+oVszwm5ZQyYD4dKQmmwGzbPNfgnVReNu9hjy9jFHH5J3/8UlSU3uvlSH2eZpiY/v5vHlbPP+f7j8ybp7H7Da3xQMRwGyRzoJrqnqwVLbSkO1lnpC7kwnOKnJbgnZh3VZU7UPGX/ErSUykI92TnjWdx3dvc/o8hpLn990ydUhLrC6XL3NkrOGNZKxAlrzac3VZwQf0WUsTRzjeuH7+h4itNhAuzVGDLk62Hxf/WHTPRmIsb3j1lhjrXZuwpbhxIBZzTpADRpBCehAGQVaGea0siNXYFs+TqVCaoIlOtWWVt6P0qLsKBN9S1VnOO07N1HdOt0rw1ppiydvPlZ3c8FZ1zOFIKnBCagy3FKjpKQgW9sJPXWdmH6z5JIHzgoZ4NP53W0KNECPoDkVirdUmaLAtB0du340o+6xdUXzLcaA829GffIuLmmZcnVJSPXOus9rKP+OakvLTx/or21GN4t7wFiVGVsfylO5jPX19fX1HwAAAP//AwD4jl43ggQAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTS2/cNhD+KwLPosy3RN2MdYou3MSLZJEWvVGcUUxYphYU5SQw9r8H1G6QuLdeCGLAb+Z7DF9JdM9IenJ7Or17wZgXUpMctpJgwlBmKNNHrntpem0bqbRpefsvqUm4x++kJ63ynnuLVKJBqoBpOsjRUwC0nHFgQqvS031ZSP9KXGgAX4LHZl4+Y1rCHElP/g4R5q9ltguNn+YVmjRPuI9LdtEXMh/2u3v61+3h+HC4vFpwKeAmAOkJA844B0GBM0WV5JwOBjUF6bXznTCStRdYiBlTdFOzwNOv+TDniNn3ohGyYVRYcq4JuOwK5cEtePx+Kiw2i+5Kvd7K27V/JS+YSC/qn2Ze2t34Kdw8L8MaJri5nNktT0XlKc0nTDng5skmc5pcDnOsrpN2U8CYSU0ePlWHyeVxTs9vjPp00b//H/KPZfq7b+jXjLCb15hJT7gSpCbvnX8MEav9XWnHUYDskI6CG6o6cNRpaakx0ozIvW0FJzXZzTGHuM7rUu1jxi9pE0B68oebFrxQv03+MWT0eU1F1T+m7MIhzbD6XP3yn7OGNZKxAlrzac3VR4SQ0Gcs+o5pvfb7D0QYsYF2a0oYc3Vw+bH60y2PpCfWdZ4766xzxvsRNcORAXOGtYAGDIIS0IKyCoyyzBvlBq7AaT6MpUJqgiXuatswZ0SHWkuqBzZSpe1IreSOdpYJdNYh9/6Nj9XDtOF8xxSCpBZHoMpyR62SkoLUrhVmbFsx/mbJNX3OisGAL5e/sjEwAB2C4VQorqmyltPO6pYObTfYwXSofeF8jyni9JtR74NP8zKPubouT/XGuo9rLP+92hbpa4j025bR3eyfMFUlYxdiWe9rrOfz+fwDAAD//wMA+zzinzYEAAA=

View File

@ -3,7 +3,10 @@ using PlotLine.Services;
namespace PlotLine.Controllers;
public sealed class ProjectRestorePointsController(IProjectRestorePointService restorePoints, ILogger<ProjectRestorePointsController> logger) : Controller
public sealed class ProjectRestorePointsController(
IProjectRestorePointService restorePoints,
IProjectRestoreService projectRestore,
ILogger<ProjectRestorePointsController> logger) : Controller
{
public async Task<IActionResult> Index(int projectId)
{
@ -51,4 +54,22 @@ public sealed class ProjectRestorePointsController(IProjectRestorePointService r
return RedirectToAction(nameof(Index), new { projectId });
}
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Restore(int projectId, int id)
{
try
{
var result = await projectRestore.RestoreAsync(projectId, id);
TempData["ProjectRestorePointMessage"] = result.Message;
}
catch (Exception ex)
{
logger.LogError(ex, "Project restore failed unexpectedly for restore point {ProjectRestorePointID} in project {ProjectID}.", id, projectId);
TempData["ProjectRestorePointMessage"] = "Restore failed. No project data was changed.";
}
return RedirectToAction(nameof(Index), new { projectId });
}
}

View File

@ -9,6 +9,7 @@ public interface IProjectRestorePointRepository
Task<IReadOnlyList<ProjectRestorePoint>> ListAsync(int projectId);
Task<ProjectRestorePoint?> GetAsync(int projectRestorePointId, int projectId);
Task<ProjectRestorePoint> CreateAsync(int projectId, string restorePointName, string restorePointType, string jsonData, string? notes);
Task RestoreAsync(int projectId, int sourceRestorePointId, int safetyRestorePointId, string jsonData);
}
public sealed class ProjectRestorePointRepository(ISqlConnectionFactory connectionFactory) : IProjectRestorePointRepository
@ -47,4 +48,20 @@ public sealed class ProjectRestorePointRepository(ISqlConnectionFactory connecti
},
commandType: CommandType.StoredProcedure);
}
public async Task RestoreAsync(int projectId, int sourceRestorePointId, int safetyRestorePointId, string jsonData)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.ProjectRestore_ApplyFromJson",
new
{
ProjectID = projectId,
SourceRestorePointID = sourceRestorePointId,
SafetyRestorePointID = safetyRestorePointId,
JsonData = jsonData
},
commandType: CommandType.StoredProcedure,
commandTimeout: 180);
}
}

View File

@ -19,3 +19,10 @@ public sealed class ProjectRestorePointDownload
public string ContentType { get; init; } = "application/json";
public string Json { get; init; } = string.Empty;
}
public sealed class ProjectRestoreResult
{
public bool Succeeded { get; init; }
public string Message { get; init; } = string.Empty;
public ProjectRestorePoint? SafetyBackup { get; init; }
}

View File

@ -62,6 +62,7 @@ public class Program
builder.Services.AddScoped<IExportService, ExportService>();
builder.Services.AddScoped<IProjectExportService, ProjectExportService>();
builder.Services.AddScoped<IProjectRestorePointService, ProjectRestorePointService>();
builder.Services.AddScoped<IProjectRestoreService, ProjectRestoreService>();
builder.Services.AddScoped<IDynamicsService, DynamicsService>();
builder.Services.AddScoped<IScenarioService, ScenarioService>();
builder.Services.AddScoped<IArchiveService, ArchiveService>();

View File

@ -8,6 +8,7 @@ public interface IProjectRestorePointService
{
Task<ProjectRestorePointListViewModel?> GetAsync(int projectId);
Task<ProjectRestorePoint?> CreateManualAsync(int projectId, string? restorePointName, string? notes);
Task<ProjectRestorePoint?> CreatePreRestoreAsync(int projectId, string sourceRestorePointName);
Task<ProjectRestorePointDownload?> DownloadAsync(int projectRestorePointId, int projectId);
}
@ -18,6 +19,7 @@ public sealed class ProjectRestorePointService(
ILogger<ProjectRestorePointService> logger) : IProjectRestorePointService
{
private const string ManualRestorePointType = "Manual";
private const string PreRestorePointType = "PreRestore";
public async Task<ProjectRestorePointListViewModel?> GetAsync(int projectId)
{
@ -36,19 +38,32 @@ public sealed class ProjectRestorePointService(
public async Task<ProjectRestorePoint?> CreateManualAsync(int projectId, string? restorePointName, string? notes)
{
var export = await projectExports.ExportAsync(projectId);
if (export is null)
{
logger.LogWarning("Manual restore point requested for missing project {ProjectID}.", projectId);
return null;
}
var name = string.IsNullOrWhiteSpace(restorePointName)
? $"Manual restore point - {DateTime.Now:yyyy-MM-dd HH:mm}"
: restorePointName.Trim();
var created = await restorePoints.CreateAsync(projectId, name, ManualRestorePointType, export.Json, Clean(notes));
logger.LogInformation("Manual restore point {ProjectRestorePointID} created for project {ProjectID} with {JsonSizeBytes} JSON bytes.",
return await CreateAsync(projectId, name, ManualRestorePointType, Clean(notes));
}
public async Task<ProjectRestorePoint?> CreatePreRestoreAsync(int projectId, string sourceRestorePointName)
{
var name = $"Automatic Pre-Restore Backup - {DateTime.Now:yyyy-MM-dd HH:mm}";
var notes = $"Created automatically before restoring from \"{sourceRestorePointName}\".";
return await CreateAsync(projectId, name, PreRestorePointType, notes);
}
private async Task<ProjectRestorePoint?> CreateAsync(int projectId, string restorePointName, string restorePointType, string? notes)
{
var export = await projectExports.ExportAsync(projectId);
if (export is null)
{
logger.LogWarning("{RestorePointType} restore point requested for missing project {ProjectID}.", restorePointType, projectId);
return null;
}
var created = await restorePoints.CreateAsync(projectId, restorePointName, restorePointType, export.Json, notes);
logger.LogInformation("{RestorePointType} restore point {ProjectRestorePointID} created for project {ProjectID} with {JsonSizeBytes} JSON bytes.",
restorePointType,
created.ProjectRestorePointID,
projectId,
created.JsonSizeBytes);

View File

@ -0,0 +1,123 @@
using System.Text.Json;
using PlotLine.Data;
using PlotLine.Models;
namespace PlotLine.Services;
public interface IProjectRestoreService
{
Task<ProjectRestoreResult> RestoreAsync(int projectId, int projectRestorePointId);
}
public sealed class ProjectRestoreService(
IProjectRestorePointRepository restorePointRepository,
IProjectRestorePointService restorePointService,
ILogger<ProjectRestoreService> logger) : IProjectRestoreService
{
private static readonly JsonSerializerOptions ReadOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
private static readonly JsonSerializerOptions RestoreSqlOptions = new()
{
WriteIndented = false
};
public async Task<ProjectRestoreResult> RestoreAsync(int projectId, int projectRestorePointId)
{
var restorePoint = await restorePointRepository.GetAsync(projectRestorePointId, projectId);
if (restorePoint?.JsonData is null)
{
return Failure("Restore point could not be found.");
}
ProjectBackupExport? backup;
try
{
backup = JsonSerializer.Deserialize<ProjectBackupExport>(restorePoint.JsonData, ReadOptions);
}
catch (JsonException ex)
{
logger.LogWarning(ex, "Restore point {ProjectRestorePointID} contains invalid JSON.", projectRestorePointId);
return Failure("Restore point JSON is invalid. No project data was changed.");
}
var validationMessage = ValidateBackup(projectId, backup);
if (validationMessage is not null)
{
return Failure(validationMessage);
}
var safetyBackup = await restorePointService.CreatePreRestoreAsync(projectId, restorePoint.RestorePointName);
if (safetyBackup is null)
{
logger.LogError("PreRestore backup creation failed for project {ProjectID}. Restore was aborted.", projectId);
return Failure("A safety backup could not be created. Restore was aborted and no project data was changed.");
}
try
{
var restoreJson = JsonSerializer.Serialize(backup, RestoreSqlOptions);
await restorePointRepository.RestoreAsync(projectId, projectRestorePointId, safetyBackup.ProjectRestorePointID, restoreJson);
logger.LogInformation("Project {ProjectID} restored from restore point {ProjectRestorePointID}. Safety backup {SafetyRestorePointID} was created first.",
projectId,
projectRestorePointId,
safetyBackup.ProjectRestorePointID);
return new ProjectRestoreResult
{
Succeeded = true,
Message = "Project restored. A safety backup of the previous state was created first.",
SafetyBackup = safetyBackup
};
}
catch (Exception ex)
{
logger.LogError(ex, "Project restore failed for project {ProjectID} from restore point {ProjectRestorePointID}. Safety backup {SafetyRestorePointID} was retained.",
projectId,
projectRestorePointId,
safetyBackup.ProjectRestorePointID);
return new ProjectRestoreResult
{
Succeeded = false,
Message = "Restore failed. The safety backup was created, and the project data was left unchanged.",
SafetyBackup = safetyBackup
};
}
}
private static string? ValidateBackup(int projectId, ProjectBackupExport? backup)
{
if (backup is null)
{
return "Restore point JSON is invalid. No project data was changed.";
}
if (!string.Equals(backup.Format, "PlotWeaver.ProjectBackup", StringComparison.Ordinal))
{
return "Restore point format is not recognised. No project data was changed.";
}
if (!string.Equals(backup.FormatVersion, "1.0", StringComparison.Ordinal))
{
return "Restore point format version is not supported. No project data was changed.";
}
if (backup.Project?.ProjectID != projectId)
{
return "This restore point does not belong to the selected project. No project data was changed.";
}
return null;
}
private static ProjectRestoreResult Failure(string message) => new()
{
Succeeded = false,
Message = message
};
}

View File

@ -0,0 +1,306 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.ProjectRestoreHistory', N'U') IS NULL
BEGIN
CREATE TABLE dbo.ProjectRestoreHistory
(
ProjectRestoreHistoryID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_ProjectRestoreHistory PRIMARY KEY,
ProjectID int NOT NULL,
SourceRestorePointID int NOT NULL,
SafetyRestorePointID int NOT NULL,
RestoredDateUtc datetime2 NOT NULL CONSTRAINT DF_ProjectRestoreHistory_RestoredDateUtc DEFAULT SYSUTCDATETIME(),
PerformedBy nvarchar(100) NULL,
Notes nvarchar(max) NULL,
CONSTRAINT FK_ProjectRestoreHistory_Projects FOREIGN KEY(ProjectID) REFERENCES dbo.Projects(ProjectID),
CONSTRAINT FK_ProjectRestoreHistory_SourceRestorePoint FOREIGN KEY(SourceRestorePointID) REFERENCES dbo.ProjectRestorePoints(ProjectRestorePointID),
CONSTRAINT FK_ProjectRestoreHistory_SafetyRestorePoint FOREIGN KEY(SafetyRestorePointID) REFERENCES dbo.ProjectRestorePoints(ProjectRestorePointID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_ProjectRestoreHistory_ProjectDate' AND object_id = OBJECT_ID(N'dbo.ProjectRestoreHistory'))
CREATE INDEX IX_ProjectRestoreHistory_ProjectDate ON dbo.ProjectRestoreHistory(ProjectID, RestoredDateUtc DESC);
GO
CREATE OR ALTER PROCEDURE dbo.ProjectRestore_InsertJsonTable
@TableName sysname,
@JsonPath nvarchar(400),
@JsonData nvarchar(max),
@ProjectID int,
@ProjectScopedOnly bit = 0
AS
BEGIN
SET NOCOUNT ON;
DECLARE @FullTableName nvarchar(300) = N'dbo.' + QUOTENAME(@TableName);
DECLARE @ObjectID int = OBJECT_ID(@FullTableName, N'U');
IF @ObjectID IS NULL
THROW 51000, 'Restore table not found.', 1;
IF NOT EXISTS (SELECT 1 FROM OPENJSON(@JsonData, @JsonPath))
RETURN;
DECLARE @ColumnList nvarchar(max);
DECLARE @WithList nvarchar(max);
DECLARE @SelectList nvarchar(max);
DECLARE @ProjectFilter nvarchar(max) = N'';
DECLARE @HasIdentity bit = 0;
DECLARE @Columns table
(
Name sysname NOT NULL,
ColumnID int NOT NULL,
IsIdentity bit NOT NULL,
TypeName nvarchar(100) NOT NULL
);
INSERT INTO @Columns (Name, ColumnID, IsIdentity, TypeName)
SELECT
c.name,
c.column_id,
c.is_identity,
CASE
WHEN t.name IN (N'nvarchar', N'nchar') AND c.max_length = -1 THEN t.name + N'(max)'
WHEN t.name IN (N'nvarchar', N'nchar') THEN t.name + N'(' + CONVERT(nvarchar(20), c.max_length / 2) + N')'
WHEN t.name IN (N'varchar', N'char', N'varbinary', N'binary') AND c.max_length = -1 THEN t.name + N'(max)'
WHEN t.name IN (N'varchar', N'char', N'varbinary', N'binary') THEN t.name + N'(' + CONVERT(nvarchar(20), c.max_length) + N')'
WHEN t.name IN (N'decimal', N'numeric') THEN t.name + N'(' + CONVERT(nvarchar(20), c.precision) + N',' + CONVERT(nvarchar(20), c.scale) + N')'
WHEN t.name IN (N'datetime2', N'time', N'datetimeoffset') THEN t.name + N'(' + CONVERT(nvarchar(20), c.scale) + N')'
ELSE t.name
END
FROM sys.columns c
INNER JOIN sys.types t ON t.user_type_id = c.user_type_id
WHERE c.object_id = @ObjectID
AND c.is_computed = 0
AND t.name <> N'timestamp'
AND EXISTS
(
SELECT 1
FROM OPENJSON(@JsonData, @JsonPath) rowJson
CROSS APPLY OPENJSON(rowJson.value) propertyJson
WHERE propertyJson.[key] COLLATE DATABASE_DEFAULT = c.name COLLATE DATABASE_DEFAULT
);
SELECT @ColumnList = STUFF((SELECT N', ' + QUOTENAME(Name) FROM @Columns ORDER BY ColumnID FOR XML PATH(N''), TYPE).value(N'.', N'nvarchar(max)'), 1, 2, N'');
SELECT @WithList = STUFF((SELECT N', ' + QUOTENAME(Name) + N' ' + TypeName + N' ''$.' + Name + N'''' FROM @Columns ORDER BY ColumnID FOR XML PATH(N''), TYPE).value(N'.', N'nvarchar(max)'), 1, 2, N'');
SELECT @SelectList = STUFF((SELECT N', src.' + QUOTENAME(Name) FROM @Columns ORDER BY ColumnID FOR XML PATH(N''), TYPE).value(N'.', N'nvarchar(max)'), 1, 2, N'');
SELECT @HasIdentity = CONVERT(bit, MAX(CONVERT(int, IsIdentity))) FROM @Columns;
IF @ProjectScopedOnly = 1
SET @ProjectFilter = N' WHERE src.ProjectID = @ProjectID';
DECLARE @Sql nvarchar(max) = N'';
IF @HasIdentity = 1
SET @Sql += N'SET IDENTITY_INSERT dbo.' + QUOTENAME(@TableName) + N' ON;';
SET @Sql += N'
INSERT INTO dbo.' + QUOTENAME(@TableName) + N' (' + @ColumnList + N')
SELECT ' + @SelectList + N'
FROM OPENJSON(@JsonData, @JsonPath)
WITH (' + @WithList + N') AS src' + @ProjectFilter + N';';
IF @HasIdentity = 1
SET @Sql += N'SET IDENTITY_INSERT dbo.' + QUOTENAME(@TableName) + N' OFF;';
EXEC sp_executesql
@Sql,
N'@JsonData nvarchar(max), @JsonPath nvarchar(400), @ProjectID int',
@JsonData = @JsonData,
@JsonPath = @JsonPath,
@ProjectID = @ProjectID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.ProjectRestore_ApplyFromJson
@ProjectID int,
@SourceRestorePointID int,
@SafetyRestorePointID int,
@JsonData nvarchar(max)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF ISJSON(@JsonData) <> 1
THROW 51001, 'Restore JSON is invalid.', 1;
IF JSON_VALUE(@JsonData, '$.Format') <> N'PlotWeaver.ProjectBackup'
THROW 51002, 'Restore JSON format is not recognised.', 1;
IF JSON_VALUE(@JsonData, '$.FormatVersion') <> N'1.0'
THROW 51003, 'Restore JSON format version is not supported.', 1;
IF TRY_CONVERT(int, JSON_VALUE(@JsonData, '$.Project.ProjectID')) <> @ProjectID
THROW 51004, 'Restore point does not belong to this project.', 1;
DECLARE @RestoreTables table (TableName sysname NOT NULL PRIMARY KEY);
INSERT INTO @RestoreTables (TableName)
VALUES
(N'ProjectTimelineSettings'), (N'ProjectTimelineMetricSettings'), (N'TimelineViewPresets'),
(N'SceneMetricTypes'), (N'SceneMetricValues'), (N'Books'), (N'Chapters'), (N'Scenes'),
(N'ScenePurposes'), (N'SceneOutcomes'), (N'PlotLines'), (N'PlotThreads'), (N'ThreadEvents'),
(N'AssetStates'), (N'StoryAssets'), (N'AssetEvents'), (N'AssetDependencies'),
(N'AssetCustodyEvents'), (N'AssetCustodyEventCharacters'), (N'Characters'), (N'SceneCharacters'),
(N'CharacterAttributeTypes'), (N'CharacterAttributeEvents'), (N'CharacterKnowledge'),
(N'CharacterRelationships'), (N'RelationshipEvents'), (N'Locations'), (N'LocationRelationships'),
(N'SceneAssetLocations'), (N'SceneDependencies'), (N'ValidationRuns'), (N'ContinuityWarnings'),
(N'SceneNotes'), (N'SceneWorkflow'), (N'SceneChecklistItems'), (N'SceneAttachments'),
(N'ChapterGoals'), (N'Scenarios'), (N'ScenarioSceneOrder'), (N'ScenarioValidationRuns'),
(N'ScenarioWarnings');
BEGIN TRANSACTION;
DECLARE @TableName sysname;
DECLARE DisableCursor CURSOR LOCAL FAST_FORWARD FOR SELECT TableName FROM @RestoreTables;
OPEN DisableCursor;
FETCH NEXT FROM DisableCursor INTO @TableName;
WHILE @@FETCH_STATUS = 0
BEGIN
DECLARE @DisableSql nvarchar(max) = N'ALTER TABLE dbo.' + QUOTENAME(@TableName) + N' NOCHECK CONSTRAINT ALL;';
EXEC (@DisableSql);
FETCH NEXT FROM DisableCursor INTO @TableName;
END
CLOSE DisableCursor;
DEALLOCATE DisableCursor;
DELETE sw FROM dbo.ScenarioWarnings sw INNER JOIN dbo.Scenarios sc ON sc.ScenarioID = sw.ScenarioID WHERE sc.ProjectID = @ProjectID;
DELETE svr FROM dbo.ScenarioValidationRuns svr INNER JOIN dbo.Scenarios sc ON sc.ScenarioID = svr.ScenarioID WHERE sc.ProjectID = @ProjectID;
DELETE sso FROM dbo.ScenarioSceneOrder sso INNER JOIN dbo.Scenarios sc ON sc.ScenarioID = sso.ScenarioID WHERE sc.ProjectID = @ProjectID;
DELETE FROM dbo.Scenarios WHERE ProjectID = @ProjectID;
DELETE FROM dbo.ContinuityWarnings WHERE ProjectID = @ProjectID;
DELETE FROM dbo.ValidationRuns WHERE ProjectID = @ProjectID;
DELETE sa FROM dbo.SceneAttachments sa INNER JOIN dbo.Scenes s ON s.SceneID = sa.SceneID INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE b.ProjectID = @ProjectID;
DELETE sci FROM dbo.SceneChecklistItems sci INNER JOIN dbo.Scenes s ON s.SceneID = sci.SceneID INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE b.ProjectID = @ProjectID;
DELETE sw FROM dbo.SceneWorkflow sw INNER JOIN dbo.Scenes s ON s.SceneID = sw.SceneID INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE b.ProjectID = @ProjectID;
DELETE sn FROM dbo.SceneNotes sn INNER JOIN dbo.Scenes s ON s.SceneID = sn.SceneID INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE b.ProjectID = @ProjectID;
DELETE cg FROM dbo.ChapterGoals cg INNER JOIN dbo.Chapters c ON c.ChapterID = cg.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE b.ProjectID = @ProjectID;
DELETE sd FROM dbo.SceneDependencies sd
LEFT JOIN dbo.Scenes ss ON ss.SceneID = sd.SourceSceneID
LEFT JOIN dbo.Chapters sc ON sc.ChapterID = ss.ChapterID
LEFT JOIN dbo.Books sb ON sb.BookID = sc.BookID
LEFT JOIN dbo.Scenes ts ON ts.SceneID = sd.TargetSceneID
LEFT JOIN dbo.Chapters tc ON tc.ChapterID = ts.ChapterID
LEFT JOIN dbo.Books tb ON tb.BookID = tc.BookID
WHERE sd.ProjectID = @ProjectID OR sb.ProjectID = @ProjectID OR tb.ProjectID = @ProjectID;
DELETE re FROM dbo.RelationshipEvents re INNER JOIN dbo.CharacterRelationships cr ON cr.CharacterRelationshipID = re.CharacterRelationshipID WHERE cr.ProjectID = @ProjectID;
DELETE FROM dbo.CharacterRelationships WHERE ProjectID = @ProjectID;
DELETE ck FROM dbo.CharacterKnowledge ck INNER JOIN dbo.Characters ch ON ch.CharacterID = ck.CharacterID WHERE ch.ProjectID = @ProjectID;
DELETE cae FROM dbo.CharacterAttributeEvents cae INNER JOIN dbo.Characters ch ON ch.CharacterID = cae.CharacterID WHERE ch.ProjectID = @ProjectID;
DELETE sc FROM dbo.SceneCharacters sc INNER JOIN dbo.Characters ch ON ch.CharacterID = sc.CharacterID WHERE ch.ProjectID = @ProjectID;
DELETE acec FROM dbo.AssetCustodyEventCharacters acec INNER JOIN dbo.AssetCustodyEvents ace ON ace.AssetCustodyEventID = acec.AssetCustodyEventID INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ace.StoryAssetID WHERE sa.ProjectID = @ProjectID;
DELETE ace FROM dbo.AssetCustodyEvents ace INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ace.StoryAssetID WHERE sa.ProjectID = @ProjectID;
DELETE ad FROM dbo.AssetDependencies ad INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ad.SourceAssetID WHERE sa.ProjectID = @ProjectID;
DELETE ae FROM dbo.AssetEvents ae INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ae.StoryAssetID WHERE sa.ProjectID = @ProjectID;
DELETE sal FROM dbo.SceneAssetLocations sal INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = sal.StoryAssetID WHERE sa.ProjectID = @ProjectID;
DELETE FROM dbo.StoryAssets WHERE ProjectID = @ProjectID;
DELETE lr FROM dbo.LocationRelationships lr INNER JOIN dbo.Locations l ON l.LocationID = lr.FromLocationID WHERE l.ProjectID = @ProjectID;
DELETE FROM dbo.Locations WHERE ProjectID = @ProjectID;
DELETE te FROM dbo.ThreadEvents te INNER JOIN dbo.PlotThreads pt ON pt.PlotThreadID = te.PlotThreadID INNER JOIN dbo.PlotLines pl ON pl.PlotLineID = pt.PlotLineID WHERE pl.ProjectID = @ProjectID;
DELETE pt FROM dbo.PlotThreads pt INNER JOIN dbo.PlotLines pl ON pl.PlotLineID = pt.PlotLineID WHERE pl.ProjectID = @ProjectID;
DELETE FROM dbo.PlotLines WHERE ProjectID = @ProjectID;
DELETE FROM dbo.TimelineViewPresets WHERE ProjectID = @ProjectID;
DELETE FROM dbo.ProjectTimelineMetricSettings WHERE ProjectID = @ProjectID;
DELETE FROM dbo.ProjectTimelineSettings WHERE ProjectID = @ProjectID;
DELETE smv FROM dbo.SceneMetricValues smv INNER JOIN dbo.Scenes s ON s.SceneID = smv.SceneID INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE b.ProjectID = @ProjectID;
DELETE FROM dbo.SceneMetricTypes WHERE ProjectID = @ProjectID;
DELETE FROM dbo.CharacterAttributeTypes WHERE ProjectID = @ProjectID;
DELETE FROM dbo.AssetStates WHERE ProjectID = @ProjectID;
DELETE sp FROM dbo.ScenePurposes sp INNER JOIN dbo.Scenes s ON s.SceneID = sp.SceneID INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE b.ProjectID = @ProjectID;
DELETE so FROM dbo.SceneOutcomes so INNER JOIN dbo.Scenes s ON s.SceneID = so.SceneID INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE b.ProjectID = @ProjectID;
DELETE s FROM dbo.Scenes s INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE b.ProjectID = @ProjectID;
DELETE c FROM dbo.Chapters c INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE b.ProjectID = @ProjectID;
DELETE FROM dbo.Books WHERE ProjectID = @ProjectID;
DELETE FROM dbo.Characters WHERE ProjectID = @ProjectID;
UPDATE p
SET ProjectName = src.ProjectName,
Description = src.Description,
CreatedDate = src.CreatedDate,
UpdatedDate = SYSUTCDATETIME(),
IsArchived = src.IsArchived
FROM dbo.Projects p
CROSS APPLY OPENJSON(@JsonData, '$.Project')
WITH
(
ProjectID int '$.ProjectID',
ProjectName nvarchar(200) '$.ProjectName',
Description nvarchar(max) '$.Description',
CreatedDate datetime2 '$.CreatedDate',
IsArchived bit '$.IsArchived'
) src
WHERE p.ProjectID = @ProjectID AND src.ProjectID = @ProjectID;
EXEC dbo.ProjectRestore_InsertJsonTable N'SceneMetricTypes', N'$.Data.SceneMetricTypes', @JsonData, @ProjectID, 1;
EXEC dbo.ProjectRestore_InsertJsonTable N'AssetStates', N'$.Data.AssetStates', @JsonData, @ProjectID, 1;
EXEC dbo.ProjectRestore_InsertJsonTable N'CharacterAttributeTypes', N'$.Data.CharacterAttributeTypes', @JsonData, @ProjectID, 1;
EXEC dbo.ProjectRestore_InsertJsonTable N'Books', N'$.Data.Books', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'Chapters', N'$.Data.Chapters', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'Characters', N'$.Data.Characters', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'Locations', N'$.Data.Locations', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'Scenes', N'$.Data.Scenes', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'ScenePurposes', N'$.Data.ScenePurposes', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'SceneOutcomes', N'$.Data.SceneOutcomes', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'SceneMetricValues', N'$.Data.SceneMetricValues', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'ProjectTimelineSettings', N'$.Data.TimelineSettings', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'ProjectTimelineMetricSettings', N'$.Data.TimelineMetricSettings', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'TimelineViewPresets', N'$.Data.TimelineViewPresets', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'PlotLines', N'$.Data.PlotLines', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'PlotThreads', N'$.Data.PlotThreads', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'ThreadEvents', N'$.Data.ThreadEvents', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'StoryAssets', N'$.Data.StoryAssets', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'AssetEvents', N'$.Data.AssetEvents', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'AssetDependencies', N'$.Data.AssetDependencies', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'AssetCustodyEvents', N'$.Data.AssetCustodyEvents', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'AssetCustodyEventCharacters', N'$.Data.AssetCustodyCharacters', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'SceneCharacters', N'$.Data.SceneCharacters', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'CharacterAttributeEvents', N'$.Data.CharacterAttributeEvents', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'CharacterKnowledge', N'$.Data.CharacterKnowledge', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'CharacterRelationships', N'$.Data.CharacterRelationships', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'RelationshipEvents', N'$.Data.RelationshipEvents', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'LocationRelationships', N'$.Data.LocationRelationships', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'SceneAssetLocations', N'$.Data.SceneAssetLocations', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'SceneDependencies', N'$.Data.SceneDependencies', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'ValidationRuns', N'$.Data.ValidationRuns', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'ContinuityWarnings', N'$.Data.ContinuityWarnings', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'SceneNotes', N'$.Data.SceneNotes', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'SceneWorkflow', N'$.Data.SceneWorkflow', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'SceneChecklistItems', N'$.Data.SceneChecklistItems', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'SceneAttachments', N'$.Data.SceneAttachments', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'ChapterGoals', N'$.Data.ChapterGoals', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'Scenarios', N'$.Data.Scenarios', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'ScenarioSceneOrder', N'$.Data.ScenarioSceneOrder', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'ScenarioValidationRuns', N'$.Data.ScenarioValidationRuns', @JsonData, @ProjectID, 0;
EXEC dbo.ProjectRestore_InsertJsonTable N'ScenarioWarnings', N'$.Data.ScenarioWarnings', @JsonData, @ProjectID, 0;
DECLARE EnableCursor CURSOR LOCAL FAST_FORWARD FOR SELECT TableName FROM @RestoreTables;
OPEN EnableCursor;
FETCH NEXT FROM EnableCursor INTO @TableName;
WHILE @@FETCH_STATUS = 0
BEGIN
DECLARE @EnableSql nvarchar(max) = N'ALTER TABLE dbo.' + QUOTENAME(@TableName) + N' WITH CHECK CHECK CONSTRAINT ALL;';
EXEC (@EnableSql);
FETCH NEXT FROM EnableCursor INTO @TableName;
END
CLOSE EnableCursor;
DEALLOCATE EnableCursor;
INSERT INTO dbo.ProjectRestoreHistory (ProjectID, SourceRestorePointID, SafetyRestorePointID, PerformedBy, Notes)
VALUES (@ProjectID, @SourceRestorePointID, @SafetyRestorePointID, SUSER_SNAME(), N'Project restored from saved restore point.');
COMMIT TRANSACTION;
END;
GO

View File

@ -97,11 +97,39 @@
<td>@restorePoint.Notes</td>
<td class="text-end">
<a class="btn btn-outline-secondary btn-sm" asp-action="Download" asp-route-projectId="@Model.Project.ProjectID" asp-route-id="@restorePoint.ProjectRestorePointID">Download</a>
<button class="btn btn-outline-danger btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#restorePointModal-@restorePoint.ProjectRestorePointID">Restore</button>
</td>
</tr>
}
</tbody>
</table>
</div>
@foreach (var restorePoint in Model.RestorePoints)
{
<div class="modal fade" id="restorePointModal-@restorePoint.ProjectRestorePointID" tabindex="-1" aria-labelledby="restorePointModalLabel-@restorePoint.ProjectRestorePointID" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title fs-5" id="restorePointModalLabel-@restorePoint.ProjectRestorePointID">Restore Project</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>This will replace the current contents of this project with the selected restore point.</p>
<p>A safety backup of the current project will be created automatically before the restore begins.</p>
<p class="mb-0">This operation may overwrite recent changes.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<form asp-action="Restore" method="post" data-confirm-skip="true">
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<input type="hidden" name="id" value="@restorePoint.ProjectRestorePointID" />
<button class="btn btn-danger" type="submit">Restore Project</button>
</form>
</div>
</div>
</div>
</div>
}
}
</section>

View File

@ -99,7 +99,7 @@
document.addEventListener("submit", (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement) || form.dataset.confirmed === "true") {
if (!(form instanceof HTMLFormElement) || form.dataset.confirmed === "true" || form.dataset.confirmSkip === "true") {
return;
}