This commit is contained in:
Nick Beckley 2026-06-03 21:56:09 +01:00
parent 24dd787536
commit 92ab864471
26 changed files with 788 additions and 65 deletions

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUS2/bOBD+KwLPpkJRFCUK2IPhtFgjTWMk7u5ibxQ5jInIpEFRToMi/31BWa6dooe9CaN5fY/hD+TkHlCLlofDpyO4OKAFinYKUUI5JhyTcluwtqJt1eSiFpyU7F+0QPYO3lCLaqZUoQTgEjhgpkmFu9IorDWIghSa0IqlnvJ5QO0PJG2u4WgV5H74C8JgvUMt+ts67V/TbGlz1ftR58H3sHZDlE6lZb6uV3f4y3KzfdicsgYYUnFuNWqRrlmheSOwMlRgxiTBHaEGC6U1F6STpjOnMusiBCf7fNAvl/naRwdRtTSnZU4wFeh9gbSMMq3cyQG2b4e0xUTRbYovpvD02f5ARwiopYszmad2N6q3N/uhG22vb6IMzxBNkHt49eEFjrJHC3QI/gAhWpi4eXjKLisVJCc55ZQQtED3Uu2sg2x9mz30CbBUDWGgSyzAaMxEIbFgZYl1WcmaclPXNAG+g+Cgv+p6b1XwgzcxmynPPsxZeRetG/04ZGsX4TnIeKr7LPsB0AJtxq63w+4RpH7b+scx/XNjn7CsxhDAxWwj4y77Uw471CIhG1VIIYWUXCkDFQFDNJGc1Bq45qAZ1bVmgmnOBFGcya5gWlZFZ1IELdDDGA9jPPP//WqJbbD7PejLBtuJ400vo/Fhv9bgojU2SXPOmAzV9xOqbO656i24+IFk1CJSANVlA9jQgmPWaIllVQrMeckNFErUtDgRZuzz+JMnYoxmpamahneiKwhQUzeM1KYjWplSCV11UNOa1A00QhHSVaxoOqGIhqJiOqELXo8q/mqFchLocXTpOrN1gv1qHf7O2YWRTfDRK98nwWwPT29DhP3vVFv5/cEPNib8Zpb22wD33qW/8/3PnM3FSx8vMUh3kE2nR6uOUMI4po0ymOma4g4kwaKsGK8bIAAKLdCtVy8QsmQwad2kydlTD0/ZMqidjaDiGNLkfyZQJzk/n0/mwkf+9dN25QMsD4fFHP3jmEg6NTsb4MO78m1I6Ts/xCvIM7Qn6M15sSs7aTieniqbglzrBjQvMGVFhZkQBW5EVeOubjrR8QYqdbFr9gjaBlBx6rcNY5o2M/s7Xy5DtEaqOKTj+eLVZKfZn3PKr0teyfaz+jT8SrrZSnOngiTLPp0ezslB/+vdfH9/f/8PAAD//wMABJt80ygGAAA=

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUwW7cNhCGX0WYsyiTEkWJuhnrBF24qRfJIi16ozijmLBMLUjKiWHsuxfUbpD6VPRCECPO8J9vfuoNvHkmGOD2dPrwQj5FKCG5LVTzWjGuGG+OQg5tM/C+kk0tRdv/DSW4e3qFATpprbCaWEOKmETesrGZLEMkLbhAXrcy1zTfIgxvYFyF9OIsVUv8SiG6xcMAfzqPy/d8t3GVnZcVq7DMtPcxGW+zmD/2u3v2++3h+HC4nIoUc3LlEAbATgpUvWZ2qjWT0nA28npi2iIqzUczjdMlzflEwZu5ivj0635ckqdkh7qqm4qzWsO5BDTJZMmjiXR8PWUVG6K7HC+38LYd3uCFAgx1+RPmpdyNnd3NcxxXN+PNZU0mPsVtnYxNS3iFEk5hOVFIjjY+tzHS8zi/Hk18+ng5k7fxww+yayLcLatPMIBoOihhAzTPJrnFF1eNu9mRT9vHFJyPzv5XrQZKePhSHGaTpiU8v5vHlwvm/f+g/MnYR+ep2N/BAFxQjU1PbKqFYrJHw0zbaKZUoyYSVne1gBJ2i0/Or8sai71P9C1sLcEAH80c6aLvNthHl8imNeQ+/1LZV4ew4GpT8WuWgle8ajjPSWs6ran4TOgC2US5iWNYr/W+vk+pVb0l7dYQyKfiYNJj8ZuJjzCANr0VRhttjLJ2opbTxJEbxTskhYpQ1tih1BKV1NwqaUYh0bRinHIESqBsnWJz69hMulNSsNaaiUkpiBmFxPpOj42Smqwc33EsHuacZ2zPJWHDNE3IpBaGadk0DJvWdLWauq7O/H8iufpB8AwY6eXy7jYFCrEnVILl18yk1oL1uu3Y2PWjHlVPrc2a7yl4mv8F6pOzYYnLlIqrQ4p36D6vPv87is0t351nP7YZ3S32iUKRZ2ycz0/lOtbz+Xz+BwAA//8DAK3VVNOCBAAA

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSTQW/cNhCF/4owZ1EmKYoSdTPWKbpwEy+SRVr0RnFGMWGZWlCUk8DY/x5Qu4GT3noRhAEf571vhq8Q7DNBD7en07sXCmmBEpLfSpJLzbhmvD4K1Td1z7tK1Uoa3v4LJfh7+g49tMo54QyxmjQxhbxhQz06hkhGcIFcNirfab8s0L+C9RXSi3dUzctnioufA/Twtw84f829ra/cNK9YxXmifViSDS6b+bDf3bO/bg/Hh8Pl1EJLFlceoQdslUDdGeZGaZhSlrOBy5EZh6gNH+w4jBeZD4lisFO14NNbf5xToOR6Wcm64kwaOJeANtlsebALHb+fsosN0V2ul1t5++1f4YUi9LL8CfNy3Y2b/M3zMqx+wpvLN9nlKac8xflEMXnamGwxp8kmP4fi2mk3eQoJSnj4VBwmm8Y5Pv8G6tMl//5/xD/m7u++kVsT4W5eQ4IehOJQwnvrHn2gYn8HPXBBEuuO2CiFZqpDy2xTG6Z1rUcSzrRSQAm7OSQf1nldin1I9CVuAaCHP+y00MX6bXSPPpFLa8yp/tF5Fw5xxtWl4o2/4BWvap6dPKzptKbiI6GP5BLlfMe4Xu/7j0RquYl2a4wUUnGw6bH40y6P0IOxnRPWWGOtdm6khtPIkVvNWySNmlBJbFEZhVoZ7rSyg1BoGzGMuQIlUB53sW2YwIGjVophbTRTQ9My46xkrtV80AYFbYjfOBYPU9ZZ13FFWDNDIzJlhGVG1TXDurGt1GPbyqz7ieQ6fcEzYKSXy1vZHGjEjlALJpVomDJGsM40LRvabjCD7qhx2fM9xUDTL6DeexfnZR5TcV2e4jd0H9eQ33uxLdJXH9i3bUZ3s3uiWOQZWx/yel/Hej6fzz8AAAD//wMAtGYnyDYEAAA=

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTwY7cKBD9FYuz8QDG2Pg26tlVWpPstJJRstobpoo0igdaGE8SjfrfV7g7Sia33NCDV/XqveKFBPOEZCS3p9NfzxjyQmqS/QYJJhRlirL2kcuxa0c2NLLVnZTyP1ITf4/fyUh6aS23GmmLCqkE1tGpdZYCoOaMAxOdLDXN54WML8T4BvDZW2zi8hHT4mMgI/nkA8SvpbfxjZ3jCk2KM+7Dkk2wRcw/+909fXt7eHw4XF4tuBRy44GMBHrJQQ2aWic0ldIwOjHhqLYASrPJuMldaD5kTMHMzQJffvaHmANmO4pGtA2jQpNzTcBkUyRPZsHH76eiYrPoruD1Bm/H8YU8YyKjqH+YeSl3Y2d/Y+PTkwlw43zwy5HU5JTiCVP2uNmxTTjPJvsYqmuT3ewxZFKThw/VYTbZxfT0yqMPl9H3fzD5O2OPPmC1vyMjYRwFtANSJ7iicgBDTddqqlSrHHKre8FJTXYxZB/WuC7VPmT8nDaVZCR/m3nBi77bZI8+o81rKtL/VSVr/ObzLkIBGKnJIUVYba5+2s1Zw5qWlcuHNZ/WXL1H8AltxjLTY1qv5X+jCCU20m5NCUOuDiYfqzdmOZKRaDNYbrTRxihrHXYMHQNmFOsBFSgEKaAHqSUoqZlV0kxcgun45ApSdJd0q22hBqE4KieoGGRLpRWc6nZCOvUd4tA5KUT3ytbqYS48YwcmEVqq0QGVmhuqZdtSaDvTC+X6XpQ4flhyTZyz4jfg8+VrbAoUwICgOBWSd1Rqzemgu55O/TDpSQ3Y2aL5HlPA+Rej3nmb4hJdrq4LU72y7v0ayveutuX56gP9tkV2F+0XTFWJ3PhQtvma8vl8Pv8PAAD//wMAZYD86yUEAAA=

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUXW/sJhD9KxavXRzAGBu/pZtcdZUmWd1dtVXfBhgnNA5eYZzcqyj/vcKb3HxcVeqLZQ0w58yZM/NEAtwj6cjp4XD+gCFNZEWSX0KCCUWZoqzac9nVVcfaUlZKK9X8TVbEX+B30pFGWsutRlqhQiodq6mpekudQ80Zd0zUMueEm4l0TwR86fDBWyzH6Q+Mkx8D6cifPrjxMWODL+0wzq6M44CbMCUINpO52qwv6O+n2/319nhrwik/Lr0jHXGN5E61mtpeaColMGqY6Km2zinNDPSmPz7zIWEMMJSTu3vDd2MKmGwnSlGVjApNnlfEQYJM2cCE+++HzGKR6CzHV0t4+e2eyANG0onVq5jHdCd28Cf3k5n94E6WL1mRQxwPGJPHRY3rXfFGgrOSlUIJxsiKXIK99QGLzVlxPeQSwbZMoquoxt5RqTlQLauKuqqGRqi+aUQu8QJjwOFd1ktv4ziNfSpeRC4+4OxO1+cBzIAZ4wsME5IV2Uy7BMHBMAY8/4Z2Tsdc+zjn4/UYkg/zOE/FJiS8ifBy/vp+PceIIRVbSLfFbzDdko5oaC0HDRpAWdtjzbBnjoFijUPlFDopXOOklk5JzaySYLh0UHPT5whZkV+zhrvZWpymNzaLSYZh4VC89Gk9eAzpg4ykI4yjcFWLtBdcUdk6oFBXmipVqR651Y3grzB7iDeYSEf61hjDrDVCam5BaKhczyqnei1E04Dsq7qRQqmaNWBd0wguXdWghaqvWCvIimzj6GabPre6WhqwjeM/aFOWinREsbpuLRgjlOQt6BYlV7JVGhste2CysgZ43YIxnEHNrG6FBa40SOPq/odK5+HGBzzz02GA7+9w21KV1S8WoOU9yE/Xv0S4x8cx3l0dbVxene/JinydQ94IxSZb5NEH+k3lib6ch+T3txHBobscHf7sI8zjUiwTKpVrNUpOeyF6KoWoKZjWUGF45ZwxTOvM5my0dxiL7DDwIU/Vj2TXu+I02luf0KY5Zn5/LTyud8V2gNSP8f7DJnlX2GcBSlFxljvj8OG4jRaOyrkWneJUSF5TqTWnra4baprWaKNarG3meD2nw5yKr+h8RJuWil+8uGCub9He/SzGqwteLMpZ9truuMUWaf/XEntekXuEaY54v2zr7olsQsC4IJ/Nx1HchEs/DH5COwY3kY4zplmpdatfKP73Ra5ZqVQjn5+fn/8FAAD//wMAY5ugbB0GAAA=

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxU227cNhD9FYGvFRXeRJF6c20HXaSOjeyiLfo2Ikc2a5laUJSTwPC/F9TauaJAXwRhSM45c+bMPJEID0h6cnY8Xj5izAupSQ5bSDChKdOUyYNgfct6ZZqOiVZI/TepSXiHn0lPOuUcdxapRI1UedbSQY6Oeo+WM+6ZaFXJCbcL6Z8IhMbjY3DYzMsfmJYwR9KTP0P088eCDaFx07z6Js0T7uKSIbpC5v3u/B39/ezmcH1zurXgUh43wZOeuNGC9+1InWGaKi4dNc4Jav2gZQuWeV9IQGhCzJgiTM3i77/i+zlHzK4XjZANo8KS55p4yFAoD7Dg4fOxsNgkuijxegtvv/0TecREelG/inlK98ZN4c3DMqxh8m+2L6nJMc1HTDngpsb1vvpKgrOGNUILxkhNrsDdhYjV7qK6nkqJ4AxT6CW1OHqqLAdqlZTUyxY6oceuEyOpyTtMEadvsl4Fl+ZlHnP1InL1Hc7+7PwywjBhwXgL04KkJrtlnyF6mOaIl5/QrfmU65DWcnw+xxziOq9LtYsZbxO8nL++P19TwpirG8h31W+w3JGeWDCOgwULoJ0bsWU4Ms9As86j9hq9Er7zyiqvlWVOKxi48tDyYSwRUpNfi4b71Tlclq9sNpNM08aheunT+RQw5u9kJD1hHIWXBukouKbKeKDQSku1lnpE7mwn+CvMAdItZtKT0QzDwJwbhLLcgbAg/cik16MVoutAjbLtlNC6ZR0433WCKy87dCBHyYwgNblJs19d/rHVcmvATZr/QZeLVKQnmrWtcTAMQituwBpUXCujLXZWjcCUdAPw1sAwcAYtc9YIB1xbUINvxy8qXcbbEPEiLMcJPn+DaxrdyF8cgOEjqB+uv03wgB/ndP/+ZOPm/eWB1OTDGstGqHbFIh9DpJ90GaardcrhcJcQPPqr2ePPPsIyLtU2odC6jolBUtWagapxRGqE41Sq0QzauRZ4eXExu3tMVXEYhFim6kuy6311ltxdyOjymgq/vzYe1/vqZoI8zunhu03yTWE/CtAIyVnpjMfH0zbaOGrvDXrNqVC8pcpaTo1tOzp0ZrCDNti6otj1mo9rrj6gDwld3ip+8eKGeX6H7v5nMV5d8GJRzorX9qcttkn7v5bYc00eEJY14cO2rfsnsosR04Z8sZ5GcRevwjSFBd0c/UJ627W8kZK/EPzPa6Y1jVTCPD8/P/8LAAD//wMA2D18ZxoGAAA=

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSTwW7cOAyGX8Xg2XIkWZYt34JJFzvIthm0g+5ib7JIN0IceSDLaYtg3r2QZ4o2e9uLIBAi+f8fqVcI9pmgh9vT6d0LhbRACclvIcmlZlwzXh8l7xveq67SxnSqFv9CCf6evkMPrXJOOEOsJk1MIW/YUI+OIZIRXCCXjco17ZcF+lewvkJ68Y6qeflMcfFzgB7+9gHnr7m39ZWb5hWrOE+0D0uywWUxH/a7e/bX7eH4cLi8WmjJyZVH6MGNxiI2I3Md10yJ2rHOOckMDrpurOGIWYT1lQ+JYrBTteDTr/44p0DJ9bKSdcWZNHAuAW2yWfJgFzp+P2UVG6K7HC+38HbtX+GFIvSy/AnzUu7GTf7meRlWP+HN5Ux2ecouT3E+UUyeNiabzWmyyc+huHbaTZ5CghIePhWHyaZxjs9vQH26+N//D/vH3P3dN3JrItzNa0jQg1AcSnhv3aMPVOzvoAcuSGLdERul0Ex1aJltasO0rvVIwplWCihhN4fkwzqvS7EPib7EzQD08IedFrpIv43u0SdyaY3Z1T866zjEGVeXil/8Ba94VfOs5GFNpzUVHwl9JJco+zvG9VrvPylSyy1pt8ZIIRUHmx6LP+3yCD0Y2zlhjTXWaudGajiNHLnVvEXSqAmVxBaVUaiV4U4rOwiFthHDmCNQAuVxF9uGDW0nBFeOjdIZpnCUbGikYGpQTav1MNa2ecOxeJhynnUdV4Q1MzQiU0ZYZlRdM6wb20o9tq0cf0Nynb7gGTDSy+WvbAo0YkeoBZNKNEwZI1hnmpYNbTeYQXfUuKz5nmKg6TdQ772L8zKPqbguT/EG3cc15P9ebIv01Qf2bZvR3eyeKBZ5xtaHvN7XsZ7P5/MPAAAA//8DAK6M6YU2BAAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSU227cNhCGX0XgtSjzJB50Z9gt6rqpF/EiLXpHcUa2EC21oKhNA2PfPaB2g8S9650w0D+Hb/7hG4n+gKQjt8fjLyeMeSE1yeMWEkxoyjRlci9Y14qOy8ZI4ZTR/5CajI/4lXTEqBB4cEglaqQKWEt7OQQKgI4zDky0quT0Lwvp3ogfG8DTGLCZl0+YlnGOpCN/jRHmL6W2H5swzSs0aZ7wIS7Zx1Ca+fPh7pH+cbvbP+0ufy24FHEzAukICwi8B00HGThVjvXUOQsUFPPGuNahMBfZGDOm6Kdmgc8/6sOcI+bQiUbIhlHhyLkm4LMvLfd+wf3XY+liQ3Rf4vUW3j67N3LCRDpRf4d5SXcTpvEmz8cJTzgdfVow3YT5cPARSE2OaT5iyiNuWLZJp8nncY7VtdjdNGLMpCZPz9Vu8nmY0+Edq+cLgof/QeCEqScdkayV2hhUUkrvdC8YgtFBa9EHz8zQC+QBWeuUcdwwLp1gTrbaWAOeuUBq8jLNvZ9+XzZ+TrDeAjNCC1TohNLaBYbS4tAaZOC17rUNDqQ1Qlmw0jvvuevRBKdDMcgHH17HiNXDfZmGowBpkQ6Ca6oseOpb6ajWUg/IgzOCk5rczTGPcZ3XpXqIGV/Sxo905Fc/LXghd5vC65gx5DUVqH/rUmyXZlhDrn44gLOGNZKxIlrzcc3VR4QxYchY8O7Tes33H4nQYhPdrSlhzNXO59fqN7+8FireBu5dGVWHMGDLcGDAvGYGUINGUAIMKKdAK8eCVr7nCnzL+6FESE2wGK7aPD4w5wUoQyXDlipoLbVCIRUtSik1NyrYdxyrp6nofLBMIUjqcACqHPfUKSkpyNYboQdjxPATkqv5OCuAAU+Xa9060AAWQXMqFG+pco5T61pDe2N712uLbSg9P2KKOP0E6sMY0rzMQ66u3q3eofu4xvLiVJuPv4yR/rvt6H4OnzFVZcd+jOXArms91+SAflkTHrYHq3sju3Jd1X57t4RthDK6Js/Zp7wer2HpGqWZOZ/P528AAAD//wMAJUGrCvUEAAA=

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/cNhCG/4rAsyjzS6Som2G36MJNvYgXbdAbxRnFRLTUgqI2DYz97wW1WyTuLTdhoJl555mX80aiOyLpyf3p9MsZY15ITXLYQoIJTZmmTB4E61vRc9mYjjHD9N+kJuEJv5GeGOU99xapRI1UAWvpIEdPAdByxoGJVpWa7vNC+jfiQgN4Dh6befkT0xLmSHryV4gwfy29XWj8NK/QpHnCXVyyi76I+WP38ER/v98fnvfXvxZcSnITgPSEeQQ+gKaj9JwqywZqbQcUFHPG2NaiMNe0EDOm6KZmgS/f+8OcI2bfi0bIhlFhyaUm4LIrkge34OHbqajYED2WeL2Ft8/+jZwxkV7U/8G8lrvzU7hb1mHCM04nlxZMd34+Hl0EUpNTmk+YcsANyxnTMC8hF6LeG9d1nEPnjGoNdLIV0oBkCB0zLViwTis9Ct+2IwydNWpUILgCjUoNZdAN3DS5HOZY3bQ/TAFjJjV5fqn2k8vjnI7v0L9cie5+AmiRTXoiWSu1MaiklM7qQTAEo73WYvCOmXEQyD2y1ipjuWFcWsGsbLXpDDhmPanJB+dfQ8Rq91i6cxQgO6Sj4JqqDhx1rbRUa6lH5N4awUlNHuaYQ1zndal2MePntM1LevKrmxa8Tnqf/GvI6POaCoRPuphxn2ZYfa6+G4CzhjWSsZK05tOaq48IIaHPWHAc0nqr978UocWW9LCmhDFXe5dfq9/c8kp6Yl3nubPOOqe9H7FlODJgTjMDqEEjKAEGlFWglWVeKzdwBa7lw1gipCZY/FZtFh8QmJNS0VEwQxVrgdrRSOql6vzoQIyGveNYPU8lz/mOKQRJLY5AleWOWiUlBdk6I/RojBh/QHIzC2cFMOD5+lg3BRqgQ9CcCsVbqqzltLOtoYPpBjvoDltfND9hijj9AOpD8Gle5jFXN69V79B9XGM5ONXmu68h0n+2HT3O/gumquzYhVje122tl5oc0S1rwuN2r/o3si+PqzpsZ0t0jVBG1+Qlu5TX0y0sbaM0M5fL5fIvAAAA//8DAKi9M8D0BAAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUXW/jKBSG/4rFtXEBY2x81013tFGn02ianV3tHYbjFNWBCHBnqyr/fYSTqumM9mLvrGPe8/GcF16RU3tAPbo+HH5/BpciKlGyS4gRJjARmNRbRvqG9bSuOlqTtqH/oBLZW3hBPWq51lRLwDUIwNyQBg/1qLExICmhhrCG55xqF1H/ipStDDxbDZWP3yBE6x3q0V/WGf8911a20pOfTRX8BGsXk3I6N/NlvbrFn6832/vN6VSEmMWVNahHRIOhgxF4rDXFXJIBS9kZbDhRbSsbCaw9yaxLEJyaqmie3usbnxwk3bOK1RXBTKJjiYxKKrc8qAjbl0PuYkF0k+PlEl4++1f0DAH1rHyDeUp3pSd7tY/DbCdzNfndzrrdyrvR7uagUi5cokPwBwjJwgLnN+tUePnsd7ucD31SUwRUoi2EvXVqOv24gVHNU3rwc1jIRPP0X2dQj9ScPCrRQnKalrLFeZjVZMGlX7QXhe8fis2k0ujD/sOSHk7s1/8D/Z3Sj9ZBsb7JGgrM1B3gkVGBeWcUVk0tsRC1GIFq2TKKSrTyLlk3+zkWa5dgd6b2ob/roB9tAp3mkGf6W2SzbYI3s07F+4IpqUhVE5JFczrMqfgKxgbQCfIQ2zCf8/0kYYItok92gl/wrOYQwKVio9Jj8YeKj6hHUnWaKqmkUkLrERoCIzFECdIaEEaA4cy0hktuBJdEC64Gyo1q6DDmCCoRZJMVi69H08qRK4EJAYY5yA4rLmoMAxsbomhXM/IBbnE/ZZ3SHeFgaixhNJhLqrDkdY1N3aiWibFt2XjB6WwISjL1SxP+GcGcvfTlZO232Q08n+7x0qcwpgMjKGacNphLSXEnmxYPbTfIQXTQ6DzZyrvo30B+gzD4aFN+RO6ss3s1/XzkfTO3EBxMF9u5szr46MdUnG1ZfNjX19nlV6xYLPrdOvzvYowbr58gFNlYyrqLZR6Px+MPAAAA//8DAHv/g4QMBQAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRU0W7cKBT9FYtn4wDG2PgtmrTaUbabUTvqrvYNc68bFAdGgNNG0fz7Cs+s2jyt9gWha87l3HMOfiPePCMZye3p9OEFfU6kJtltJcGEokxR1h4FGzsxctkMsheKq79JTdw9vpKR9NJabjXSFhVSCayjUztbCoCaMw5MdLL0NN8SGd+IcQ3gi7PYhPQVY3LBk5H86TyE7+Vu4xq7hBWaGBbc+5SNt4XMH/vdPf399nB8OFxOJUwF3DggI2EWgU+g6NxaTqVmE9V6AAqSmb7XnUbRX2DOZ4zeLE2Cp5/3Q8gesx1FI9qGUaHJuSZgsimUJ5Pw+HoqLDaJ7kq93srbdnwjLxjJKOp/xby0u7GLu3lO0+oWuLms2aSntK2zsTnEV1KTUwwnjNnhps9tSvg8La9Hk54+Xs6UbfrwA+2aEXZh9ZmMhLcdqckm0LKY7IKvrhx3i0Oft485Op+c/a9eLanJw5fqsJg8h/j8zo8vF5n3/0PlT8Y+Oo/V/q5gOApoB6Sz4IrKAQw1XaupUq2akVvdC05qsgs+O7+GNVV7n/Fb3EYiI/loloQXfrfRPrqMNq+xzPmXKrk6xACrzdVPLzlrWNMyVkBrPq25+ozgItqMZYhjXK/9vr6HCCU20G6NEX2uDiY/Vr+Z9EhGos1gudFGG6OsnbFjODNgRrEeUIFCkAJ6kFqCkppZJc3EJZiOT3OpkJpgiU61pVXOOEvVWipkK6nkglPDUFGrJ637iTOtxDsdq4el4IwdmERoqcYZqNTcUC3blkLbmV6oue/F/Isk1zxwVgQGfLm8u42BAhgQFKdC8o5KrTkddNfTqR8mPakBO1s432P0uPwi1CdnY0hhztU1IdU76T6vvvw7qi0t352nPzaP7oJ9wlgVj43z5alcbT2fz+d/AAAA//8DAHLELVyCBAAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUS2/bOBD+KwLPpkJSFCUK2IPhtFgjTWMk7u5ibxQ5jInIokFRToMi/31BWa6dooe9CaN5fY/hD9SrPaAGLQ+HT0fo44AWKLopxAgTmAhMii0jTckaynPGikKw8l+0QO4O3lCDKq411RJwAQIwN6TEbWE1NgYkJdQQVvLUUz0PqPmBlMsNHJ2G3A9/QRic71GD/na98a9ptnK57vxo8uA7WPdDVL1Oy3xdr+7wl+Vm+7A5ZQ0wpOLcGdQgosHQ1ghsC00xl6TFUtYGG05UVclSAqtOZa6PEHrV5YN5ucw3PvYQdcNyVuQEM4neF8ioqNLKrRpg+3ZIW0wU3ab4YgpPn80PdISAGrY4k3lqd6M7d7Mf2tF15iaq8AzRBrWHVx9e4Kg6tECH4A8QooOJm4en7LISJTnJmWCEoAW6V3rnesjWt9lDlwArXRMOpsASrMFcUoUlLwpsilJVTNiqYhYt0B2EHrqrrvdOBz94G7OZ8uzDnJXvo+tHPw7Zuo/wHFQ81X1W3QBogTZj27lh9wjKvG3945j+9WOXsKzGEKCP2UbFXfanGnaoQVLVmiqppFJCawslAUsMUYJUBoQRYDgzleGSG8El0YKrlnKjStraFEEL9DDGwxjP/H+/WmIb3H4P5rLBduJ406lofdivDfTRWZekOWdMhuq6CVU291x1Dvr4geTkKArMFDVgy6jAvDYKq7KQWIhCWKBaVoyeCLPuefzJE7HW8MKWdS1a2VICzFY1J5VtidG20NKULVSsIlUNtdSEtCWndSs1MUBLbhK64M2o469WKCaBHsc+XWe2TrBfXY+/C35hZBN89Np3STDXwdPbEGH/O9VWfn/wg4sJv52l/TbAve/T3/n+Z87m4qWPlxikO8im05M1qbRmLabGUMw1lbitoMVVVZd1KWxZ6ETUrdcvELJkMOX6SZOzpx6esmXQOxdBxzGkyf9MoE5yfj6fzIWP/Oun7coHWB4Oizn6xzGRdGp2NsCHd+XbkNJ3fohXkGdoT9DZ82JXdjJwPD1VLgWFMTUYQTHjtMRcSoprWVa4repWtqKGUl/smj2CcQF0nPptw5imzcz+zpfLEJ1VOg7peL54Pdlp9uec8uuSV7L9rD4Nv5JuttLciZKkxNPp4Zwc9L/ezff39/f/AAAA//8DAKqnhx8oBgAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSTQW/cIBCF/4rF2TiAMTa+RZtWXaVtVk3UVr1hZtxFdWCFcZoo2v9e4d0qSW+9WWPe8OZ7wzPx5h5JTy4Ph3cP6NNMSpLcWhJMKMoUZfWdYH0jei6rrulq2ckfpCTuGp9IT1ppLbcaaY0KqQTW0KEeLQVAzRkHJhqZe5qfM+mfiXEV4IOzWIX5K8bZBU968s15CL/z3cZVdgoLVDFMuPVzMt5mM5+3m2v68XJ3d7M7nZpxzuLKAekJswh8AEXH2nIqNRuo1h1QkMy0rW40ivYkcz5h9GaqZvj1cj+E5DHZXlSirhgVmhxLAiaZbHkwM949HbKLFdFVrpdref3sn8kDRtKL8i/MU7sLO7kLG+7vjYeL0Xk370lJDjEcMCaHK451wmkyyQVfnC/ZTA59IiW5uS12k0ljiPdvGN2eRt/+x+SfjN07j8X2Kms4Cqg7pKPgisoODDVNralStRqRW90KTkqyCT45v4RlLrY+4c+4uiQ9eW+mGU/+LqPdu4Q2LTFb/65y1vjo0iZALjBSkl0MsNhUvODmrGJVzfLPmyUdllR8QXARbcI8011czu3/kQglVtFmiRF9KnYm7YsPZt6TnmjTWW600cYoa0dsGI4MmFGsBVSgEKSAFqSWoKRmVkkzcAmm4cOYK9l3TrdYF0qhaZtWCjrYQVNZjxnRwChvOrRdbaHVedIXrMXNlHXGdkwi1FTjCFRqbqiWdU2hbkwr1Ni2YnyF5Jw4Z5k34MPpaZwcAHQIilMheUOl1px2umnp0HaDHlSHjc2erzF6nF6B+uRsDHMYU3FemOINui+Lz8+7WJfnt/P0cY3sKthfGIscuXE+b/M55ePxePwDAAD//wMAoa2QhCUEAAA=

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxU227jNhD9FYGvFRXeRIl6S50saqRJjLXRFn0bkaOYjUwZFJXsIsi/F5KTzWVRoC+CMCTnnDlzZp5IgAOShpwfj5cPGNJIcpL8EhJMaMo0ZXInWFOKhquiLquKCf43yYm/wu+kIZWylluDVKJGqhwraSs7S51Dwxl3TJRqzgl3I2meCPjC4YO3WAzjHxhHPwTSkD99cMPjjA2+sP0wuSIOPa7DmCDYmczNenVFfz/f7G43p1sjjvPjwjvSEGbR8dZp2knLqTKspcbUjjrFoKpMaVBUp2c+JIwB+mJ092/4bkgBk21EIWTBqDDkOScOEsyUWxhx9/04s1gkupjj+RJefpsn8oCRNCJ/FfOU7sz2/uwwtpPv3dnyJTk5xuGIMXlc1LjdZm8kOCtYIbRgjOTkGuzeB8zWF9ltP5cItmYKnaQGO0eV4UCNkpI6WUIldFdVoiM5ucIYsH+X9drbOIxDl7IXkbMPONvz1WWAtscZ4wv0I5KcrMdtguCgHwJefkM7pVOuXZzm49UQkg/TMI3ZOiS8i/By/vp+NcWIIWUbSPvsNxj3pCEGasvBgAHQ1nZYMuyYY6BZ5VA7jU4JVzlllNPKMKsVtFw5KHnbzRGSk19nDbeTtTiOb2wWk/T9wiF76dOq9xjSBxlnl3AUTtZIO8E1VbUDCqU0VGupO+TWVIK/wuwg3mEiDenqtm2Zta1QhlsQBqTrmHS6M0JUFahOlpUSWpesAuuqSnDlZIUWZCdZLUhONnFwk02fWy2XBmzi8A/aNEtFGqJZWdYW2lZoxWswNSquVa0NVkZ1wJS0LfCyhrblDEpmTS0scG1Ata7sfqh0Ge58wAs/Hnv4/g63LnQhf7EANe9Afbr+JcIBH4d4f3OycXFzuSM5+TqFeSNk69kijz7Qb3qe6OupT363jwgO3fXg8Gcf4Twu2WlCEZC5rqNStZyq1nbUlK2ilTOVlqhQLmwuBnuPMZsdBj7MU/Uj2e02O4927xPaNMWZ318Lj9tttukhdUM8fNgk7wr7LEAhJGdzZxw+nLbRwlE7V6PTnArFS6qM4bQ2ZUXbqm5Nq2ss7czxdkrHKWVf0fmINi0Vv3hxwVzt0d7/LMarC14sytnste1piy3S/q8l9pyTA8I4RTws27p5IusQMC7IF9NpFNfh2ve9H9EOwY2k4axkBTOleGH4n/c454ViSj0/Pz//CwAA//8DADl0x2IbBgAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTTW/cNhD9K8KcRZmkKErUzVin6MJNvEgWadEbxRnFhGVqQVFOAmP/e0DtBol764UgBnwz72P4CsE+E/Rwezq9e6GQFigh+a0kudSMa8bro+R9I3uhqk6ZlvPmXyjB39N36KFVzglniNWkiSnkDRvq0TFEMoIL5LJRuaf9skD/CtZXSC/eUTUvnykufg7Qw98+4Pw1z7a+ctO8YhXnifZhSTa4TObDfnfP/ro9HB8Ol1cLLRlceYQeuCMUA2o21k4wZfjAjOmQoeK2bU1jSLYXmA+JYrBTteDTr/k4p0DJ9bKSdcWZNHAuAW2ymfJgFzp+P2UWm0V3uV5u5e3av8ILRehl+dPMS7sbN/mb52VY/YQ3lzPZ5SmrPMX5RDF52jzZZE6TTX4OxXXSbvIUEpTw8Kk4TDaNc3x+Y9Sni/79/5B/zNPffSO3JsLdvIYEPYi6gxLeW/foAxX7u9xOkMS6IzZKoZnq0DLb1IZpXeuRhDOtFFDCbg7Jh3Vel2IfEn2JmwDo4Q87LXShfhvdo0/k0hqzqn903oVDnHF1qfjlv+AVr2rOM2hNpzUVHwl9JJco6zvG9drvPxCp5QbarTFSSMXBpsfiT7s8Qg/Gdk5YY4212rmRGk4jR241b5E0akIlsUVlFGpluNPKDkKhbcQw5gqUQDnuYtswzQV2TS2YJeyYItMwI7uRdQpHQW1jsHFvfCwepoyzruOKsGaGRmTKCMuMqmuGdWNbqce2leNvllzTFzwbjPRy+SsXBogdoRZMKtEwZYxgnWlaNrTdYAbdUeMy53uKgabfjHrvXZyXeUzFdXmKN9Z9XEP+78W2SF99YN+2jO5m90SxyBlbH/J6X2M9n8/nHwAAAP//AwCAbbUbNgQAAA==

Binary file not shown.

View File

@ -52,6 +52,15 @@ public sealed class CharactersController(ICharacterService characters) : Control
return RedirectToAction(nameof(Index), new { projectId });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveInitialRelationship(InitialRelationshipEditViewModel model)
{
await characters.SaveInitialRelationshipAsync(model);
TempData["RelationshipMessage"] = model.CharacterRelationshipID == 0 ? "Initial relationship added." : "Initial relationship updated.";
return RedirectToAction(nameof(Details), new { id = model.ReturnCharacterID ?? model.CharacterID });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ArchiveRelationship(int id, int characterId)

View File

@ -143,6 +143,7 @@ public interface ICharacterRepository
Task DeleteKnowledgeAsync(int characterKnowledgeId);
Task<IReadOnlyList<CharacterRelationship>> ListRelationshipsByProjectAsync(int projectId);
Task<IReadOnlyList<CharacterRelationship>> ListRelationshipsByCharacterAsync(int characterId);
Task<IReadOnlyList<CharacterRelationship>> ListInitialRelationshipsByCharacterAsync(int characterId);
Task<int> SaveRelationshipAsync(CharacterRelationship relationship);
Task ArchiveRelationshipAsync(int characterRelationshipId);
Task<IReadOnlyList<RelationshipEvent>> ListRelationshipEventsBySceneAsync(int sceneId);
@ -2132,11 +2133,24 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory)
relationship.StartSceneID,
relationship.EndSceneID,
relationship.IsKnownToReader,
relationship.Notes
relationship.Notes,
relationship.IsInitialRelationship,
relationship.InitialBookID,
relationship.ReaderInitiallyKnows,
relationship.InitialRelationshipStateID,
relationship.InitialIntensity,
relationship.IsReciprocal
},
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<CharacterRelationship>> ListInitialRelationshipsByCharacterAsync(int characterId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<CharacterRelationship>("dbo.Relationship_ListInitialByCharacter", new { CharacterID = characterId }, commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task ArchiveRelationshipAsync(int characterRelationshipId)
{
using var connection = connectionFactory.CreateConnection();

View File

@ -651,6 +651,14 @@ public sealed class CharacterRelationship
public int? EndSceneID { get; set; }
public bool IsKnownToReader { get; set; }
public string? Notes { get; set; }
public bool IsInitialRelationship { get; set; }
public int? InitialBookID { get; set; }
public string? InitialBookTitle { get; set; }
public bool ReaderInitiallyKnows { get; set; }
public int? InitialRelationshipStateID { get; set; }
public string? InitialRelationshipStateName { get; set; }
public int? InitialIntensity { get; set; }
public bool IsReciprocal { get; set; }
public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; }
public bool IsArchived { get; set; }
@ -666,6 +674,7 @@ public sealed class RelationshipEvent
public int CharacterBID { get; set; }
public string CharacterBName { get; set; } = string.Empty;
public string RelationshipTypeName { get; set; } = string.Empty;
public bool IsReciprocal { get; set; }
public int SceneID { get; set; }
public decimal SceneNumber { get; set; }
public string SceneTitle { get; set; } = string.Empty;
@ -1121,7 +1130,11 @@ public sealed class StoryBibleRelationshipSummary
public int CharacterBID { get; set; }
public string CharacterBName { get; set; } = string.Empty;
public string RelationshipTypeName { get; set; } = string.Empty;
public string? InitialRelationshipName { get; set; }
public string? InitialStateName { get; set; }
public int? InitialIntensity { get; set; }
public string? CurrentStateName { get; set; }
public int? CurrentIntensity { get; set; }
public int EventCount { get; set; }
public string? FirstEventLabel { get; set; }
public string? EventHistory { get; set; }

View File

@ -124,6 +124,7 @@ public interface ICharacterService
Task<int> SaveCharacterAsync(CharacterEditViewModel model);
Task ArchiveCharacterAsync(int characterId);
Task ArchiveRelationshipAsync(int characterRelationshipId);
Task<int> SaveInitialRelationshipAsync(InitialRelationshipEditViewModel model);
Task<int> AddSceneCharacterAsync(SceneCharacterCreateViewModel model);
Task<int> UpdateSceneCharacterAsync(SceneCharacterEditViewModel model);
Task DeleteSceneCharacterAsync(int sceneCharacterId);
@ -2438,7 +2439,15 @@ public sealed class ExportService(IExportRepository exports) : IExportService
{
if (!rows.Any()) return;
builder.AppendLine("## Relationships").AppendLine();
foreach (var r in rows) builder.AppendLine($"- {r.CharacterAName} / {r.CharacterBName}: {r.RelationshipTypeName}; {r.CurrentStateName ?? "No state"}");
foreach (var r in rows)
{
var initial = r.InitialRelationshipName ?? r.RelationshipTypeName;
if (!string.IsNullOrWhiteSpace(r.InitialStateName)) initial += $" / {r.InitialStateName}";
if (r.InitialIntensity.HasValue) initial += $" (intensity {r.InitialIntensity})";
var current = r.CurrentStateName ?? "No current state";
if (r.CurrentIntensity.HasValue) current += $" (intensity {r.CurrentIntensity})";
builder.AppendLine($"- {r.CharacterAName} / {r.CharacterBName}: Initial: {initial}; Current: {current}; Events: {r.EventHistory ?? "No event history"}");
}
builder.AppendLine();
}
@ -3112,7 +3121,7 @@ public sealed class LocationService(IProjectRepository projects, ILocationReposi
}
}
public sealed class CharacterService(IProjectRepository projects, ICharacterRepository characters) : ICharacterService
public sealed class CharacterService(IProjectRepository projects, IBookRepository books, ICharacterRepository characters) : ICharacterService
{
public async Task<CharacterListViewModel?> GetCharactersAsync(int projectId)
{
@ -3170,6 +3179,17 @@ public sealed class CharacterService(IProjectRepository projects, ICharacterRepo
return null;
}
var projectCharacters = await characters.ListCharactersAsync(project.ProjectID);
var relationshipLookups = await characters.GetLookupsAsync();
var projectBooks = await books.ListByProjectAsync(project.ProjectID);
var relationshipContainers = await characters.ListRelationshipsByCharacterAsync(character.CharacterID);
var initialRelationships = await characters.ListInitialRelationshipsByCharacterAsync(character.CharacterID);
var relationshipEvents = await characters.ListRelationshipEventsByCharacterAsync(character.CharacterID);
var eventsByRelationship = relationshipEvents
.GroupBy(x => x.CharacterRelationshipID)
.ToDictionary(x => x.Key, x => (IReadOnlyList<RelationshipEvent>)x.ToList());
return new CharacterDetailViewModel
{
Project = project,
@ -3178,8 +3198,37 @@ public sealed class CharacterService(IProjectRepository projects, ICharacterRepo
Appearances = await GetAppearancesByCharacterAsync(character.CharacterID),
AttributeEvents = await characters.ListAttributeEventsByCharacterAsync(character.CharacterID),
KnowledgeItems = await characters.ListKnowledgeByCharacterAsync(character.CharacterID),
Relationships = await characters.ListRelationshipsByCharacterAsync(character.CharacterID),
RelationshipEvents = await characters.ListRelationshipEventsByCharacterAsync(character.CharacterID)
InitialRelationships = initialRelationships,
CurrentRelationships = relationshipContainers
.Select(relationship =>
{
eventsByRelationship.TryGetValue(relationship.CharacterRelationshipID, out var events);
events ??= [];
return new CurrentRelationshipViewModel
{
Relationship = relationship,
LatestEvent = events.LastOrDefault(),
Events = events
};
})
.OrderBy(x => x.Relationship.CharacterAName)
.ThenBy(x => x.Relationship.CharacterBName)
.ThenBy(x => x.Relationship.RelationshipTypeName)
.ToList(),
RelationshipEvents = relationshipEvents,
NewInitialRelationship = new InitialRelationshipEditViewModel
{
ProjectID = project.ProjectID,
CharacterID = character.CharacterID,
ReturnCharacterID = character.CharacterID,
InitialIntensity = 5,
ReaderInitiallyKnows = true,
IsReciprocal = true
},
CharacterOptions = projectCharacters.Select(x => new SelectListItem(x.CharacterName, x.CharacterID.ToString())).ToList(),
RelationshipTypeOptions = ChapterService.ToSelectList(relationshipLookups.RelationshipTypes, x => x.RelationshipTypeID, x => x.TypeName),
RelationshipStateOptions = relationshipLookups.RelationshipStates.Select(x => new SelectListItem(x.StateName, x.RelationshipStateID.ToString())).ToList(),
BookOptions = projectBooks.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString())).ToList()
};
}
@ -3203,6 +3252,37 @@ public sealed class CharacterService(IProjectRepository projects, ICharacterRepo
public Task ArchiveRelationshipAsync(int characterRelationshipId) => characters.ArchiveRelationshipAsync(characterRelationshipId);
public async Task<int> SaveInitialRelationshipAsync(InitialRelationshipEditViewModel model)
{
if (model.RelatedCharacterID == 0)
{
throw new InvalidOperationException("Choose a related character.");
}
if (model.RelationshipTypeID == 0)
{
throw new InvalidOperationException("Choose a relationship type.");
}
return await characters.SaveRelationshipAsync(new CharacterRelationship
{
CharacterRelationshipID = model.CharacterRelationshipID,
ProjectID = model.ProjectID,
CharacterAID = model.CharacterID,
CharacterBID = model.RelatedCharacterID,
RelationshipTypeID = model.RelationshipTypeID,
IsPermanent = true,
IsKnownToReader = model.ReaderInitiallyKnows,
Notes = model.Notes,
IsInitialRelationship = true,
InitialBookID = model.InitialBookID,
ReaderInitiallyKnows = model.ReaderInitiallyKnows,
InitialRelationshipStateID = model.InitialRelationshipStateID,
InitialIntensity = model.InitialIntensity,
IsReciprocal = model.IsReciprocal
});
}
public async Task<int> AddSceneCharacterAsync(SceneCharacterCreateViewModel model)
{
var characterId = model.CharacterID.GetValueOrDefault();

View File

@ -0,0 +1,248 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF COL_LENGTH(N'dbo.CharacterRelationships', N'IsInitialRelationship') IS NULL
BEGIN
ALTER TABLE dbo.CharacterRelationships
ADD IsInitialRelationship bit NOT NULL CONSTRAINT DF_CharacterRelationships_IsInitialRelationship DEFAULT 0;
END;
GO
IF COL_LENGTH(N'dbo.CharacterRelationships', N'InitialBookID') IS NULL
BEGIN
ALTER TABLE dbo.CharacterRelationships
ADD InitialBookID int NULL;
END;
GO
IF COL_LENGTH(N'dbo.CharacterRelationships', N'InitialBookID') IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_CharacterRelationships_InitialBook')
BEGIN
ALTER TABLE dbo.CharacterRelationships
ADD CONSTRAINT FK_CharacterRelationships_InitialBook FOREIGN KEY (InitialBookID) REFERENCES dbo.Books(BookID);
END;
GO
IF COL_LENGTH(N'dbo.CharacterRelationships', N'ReaderInitiallyKnows') IS NULL
BEGIN
ALTER TABLE dbo.CharacterRelationships
ADD ReaderInitiallyKnows bit NOT NULL CONSTRAINT DF_CharacterRelationships_ReaderInitiallyKnows DEFAULT 0;
END;
GO
IF COL_LENGTH(N'dbo.CharacterRelationships', N'InitialRelationshipStateID') IS NULL
BEGIN
ALTER TABLE dbo.CharacterRelationships
ADD InitialRelationshipStateID int NULL;
END;
GO
IF COL_LENGTH(N'dbo.CharacterRelationships', N'InitialRelationshipStateID') IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_CharacterRelationships_InitialState')
BEGIN
ALTER TABLE dbo.CharacterRelationships
ADD CONSTRAINT FK_CharacterRelationships_InitialState FOREIGN KEY (InitialRelationshipStateID) REFERENCES dbo.RelationshipStates(RelationshipStateID);
END;
GO
IF COL_LENGTH(N'dbo.CharacterRelationships', N'InitialIntensity') IS NULL
BEGIN
ALTER TABLE dbo.CharacterRelationships
ADD InitialIntensity int NULL;
END;
GO
IF COL_LENGTH(N'dbo.CharacterRelationships', N'InitialIntensity') IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM sys.check_constraints WHERE name = N'CK_CharacterRelationships_InitialIntensity')
BEGIN
ALTER TABLE dbo.CharacterRelationships
ADD CONSTRAINT CK_CharacterRelationships_InitialIntensity CHECK (InitialIntensity IS NULL OR InitialIntensity BETWEEN 1 AND 10);
END;
GO
IF COL_LENGTH(N'dbo.CharacterRelationships', N'IsReciprocal') IS NULL
BEGIN
ALTER TABLE dbo.CharacterRelationships
ADD IsReciprocal bit NOT NULL CONSTRAINT DF_CharacterRelationships_IsReciprocal DEFAULT 0;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Relationship_ListInitialByCharacter
@CharacterID int
AS
BEGIN
SET NOCOUNT ON;
SELECT cr.CharacterRelationshipID, cr.ProjectID, cr.CharacterAID, ca.CharacterName AS CharacterAName,
cr.CharacterBID, cb.CharacterName AS CharacterBName, cr.RelationshipTypeID, rt.TypeName AS RelationshipTypeName,
cr.IsPermanent, cr.StartSceneID, cr.EndSceneID, cr.IsKnownToReader, cr.Notes,
cr.IsInitialRelationship, cr.InitialBookID, b.BookTitle AS InitialBookTitle, cr.ReaderInitiallyKnows,
cr.InitialRelationshipStateID, rs.StateName AS InitialRelationshipStateName, cr.InitialIntensity, cr.IsReciprocal,
cr.CreatedDate, cr.UpdatedDate, cr.IsArchived
FROM dbo.CharacterRelationships cr
INNER JOIN dbo.Characters ca ON ca.CharacterID = cr.CharacterAID
INNER JOIN dbo.Characters cb ON cb.CharacterID = cr.CharacterBID
INNER JOIN dbo.RelationshipTypes rt ON rt.RelationshipTypeID = cr.RelationshipTypeID
LEFT JOIN dbo.Books b ON b.BookID = cr.InitialBookID
LEFT JOIN dbo.RelationshipStates rs ON rs.RelationshipStateID = cr.InitialRelationshipStateID
WHERE cr.IsArchived = 0
AND cr.IsInitialRelationship = 1
AND @CharacterID IN (cr.CharacterAID, cr.CharacterBID)
ORDER BY COALESCE(b.SortOrder, 0), ca.CharacterName, cb.CharacterName, rt.SortOrder;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Relationship_ListByCharacter
@CharacterID int
AS
BEGIN
SET NOCOUNT ON;
SELECT cr.CharacterRelationshipID, cr.ProjectID, cr.CharacterAID, ca.CharacterName AS CharacterAName,
cr.CharacterBID, cb.CharacterName AS CharacterBName, cr.RelationshipTypeID, rt.TypeName AS RelationshipTypeName,
cr.IsPermanent, cr.StartSceneID, cr.EndSceneID, cr.IsKnownToReader, cr.Notes,
cr.IsInitialRelationship, cr.InitialBookID, b.BookTitle AS InitialBookTitle, cr.ReaderInitiallyKnows,
cr.InitialRelationshipStateID, rs.StateName AS InitialRelationshipStateName, cr.InitialIntensity, cr.IsReciprocal,
cr.CreatedDate, cr.UpdatedDate, cr.IsArchived
FROM dbo.CharacterRelationships cr
INNER JOIN dbo.Characters ca ON ca.CharacterID = cr.CharacterAID
INNER JOIN dbo.Characters cb ON cb.CharacterID = cr.CharacterBID
INNER JOIN dbo.RelationshipTypes rt ON rt.RelationshipTypeID = cr.RelationshipTypeID
LEFT JOIN dbo.Books b ON b.BookID = cr.InitialBookID
LEFT JOIN dbo.RelationshipStates rs ON rs.RelationshipStateID = cr.InitialRelationshipStateID
WHERE cr.IsArchived = 0 AND @CharacterID IN (cr.CharacterAID, cr.CharacterBID)
ORDER BY ca.CharacterName, cb.CharacterName, rt.SortOrder;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Relationship_ListByProject
@ProjectID int
AS
BEGIN
SET NOCOUNT ON;
SELECT cr.CharacterRelationshipID, cr.ProjectID, cr.CharacterAID, ca.CharacterName AS CharacterAName,
cr.CharacterBID, cb.CharacterName AS CharacterBName, cr.RelationshipTypeID, rt.TypeName AS RelationshipTypeName,
cr.IsPermanent, cr.StartSceneID, cr.EndSceneID, cr.IsKnownToReader, cr.Notes,
cr.IsInitialRelationship, cr.InitialBookID, b.BookTitle AS InitialBookTitle, cr.ReaderInitiallyKnows,
cr.InitialRelationshipStateID, rs.StateName AS InitialRelationshipStateName, cr.InitialIntensity, cr.IsReciprocal,
cr.CreatedDate, cr.UpdatedDate, cr.IsArchived
FROM dbo.CharacterRelationships cr
INNER JOIN dbo.Characters ca ON ca.CharacterID = cr.CharacterAID
INNER JOIN dbo.Characters cb ON cb.CharacterID = cr.CharacterBID
INNER JOIN dbo.RelationshipTypes rt ON rt.RelationshipTypeID = cr.RelationshipTypeID
LEFT JOIN dbo.Books b ON b.BookID = cr.InitialBookID
LEFT JOIN dbo.RelationshipStates rs ON rs.RelationshipStateID = cr.InitialRelationshipStateID
WHERE cr.ProjectID = @ProjectID AND cr.IsArchived = 0
ORDER BY ca.CharacterName, cb.CharacterName, rt.SortOrder;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Relationship_Save
@CharacterRelationshipID int = NULL,
@ProjectID int,
@CharacterAID int,
@CharacterBID int,
@RelationshipTypeID int,
@IsPermanent bit,
@StartSceneID int = NULL,
@EndSceneID int = NULL,
@IsKnownToReader bit,
@Notes nvarchar(max) = NULL,
@IsInitialRelationship bit = 0,
@InitialBookID int = NULL,
@ReaderInitiallyKnows bit = 0,
@InitialRelationshipStateID int = NULL,
@InitialIntensity int = NULL,
@IsReciprocal bit = 0
AS
BEGIN
SET NOCOUNT ON;
IF @InitialIntensity IS NOT NULL AND (@InitialIntensity < 1 OR @InitialIntensity > 10)
THROW 53000, 'Initial relationship intensity must be between 1 and 10.', 1;
IF @CharacterRelationshipID IS NULL OR @CharacterRelationshipID = 0
BEGIN
INSERT dbo.CharacterRelationships
(ProjectID, CharacterAID, CharacterBID, RelationshipTypeID, IsPermanent, StartSceneID, EndSceneID, IsKnownToReader, Notes,
IsInitialRelationship, InitialBookID, ReaderInitiallyKnows, InitialRelationshipStateID, InitialIntensity, IsReciprocal)
VALUES
(@ProjectID, @CharacterAID, @CharacterBID, @RelationshipTypeID, @IsPermanent, @StartSceneID, @EndSceneID, @IsKnownToReader, @Notes,
@IsInitialRelationship, @InitialBookID, @ReaderInitiallyKnows, @InitialRelationshipStateID, @InitialIntensity, @IsReciprocal);
SET @CharacterRelationshipID = CAST(SCOPE_IDENTITY() AS int);
END
ELSE
BEGIN
UPDATE dbo.CharacterRelationships
SET CharacterAID = @CharacterAID,
CharacterBID = @CharacterBID,
RelationshipTypeID = @RelationshipTypeID,
IsPermanent = @IsPermanent,
StartSceneID = @StartSceneID,
EndSceneID = @EndSceneID,
IsKnownToReader = @IsKnownToReader,
Notes = @Notes,
IsInitialRelationship = @IsInitialRelationship,
InitialBookID = @InitialBookID,
ReaderInitiallyKnows = @ReaderInitiallyKnows,
InitialRelationshipStateID = @InitialRelationshipStateID,
InitialIntensity = @InitialIntensity,
IsReciprocal = @IsReciprocal,
UpdatedDate = SYSUTCDATETIME()
WHERE CharacterRelationshipID = @CharacterRelationshipID
AND ProjectID = @ProjectID;
END
SELECT @CharacterRelationshipID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.RelationshipEvent_ListByCharacter
@CharacterID int
AS
BEGIN
SET NOCOUNT ON;
SELECT re.RelationshipEventID, re.CharacterRelationshipID, cr.ProjectID, cr.CharacterAID, ca.CharacterName AS CharacterAName,
cr.CharacterBID, cb.CharacterName AS CharacterBName, rt.TypeName AS RelationshipTypeName,
cr.IsReciprocal, re.SceneID, s.SceneNumber, s.SceneTitle, ch.ChapterNumber, b.BookTitle,
re.RelationshipStateID, rs.StateName AS RelationshipStateName, re.Intensity, re.IsKnownToOtherCharacter,
re.Description, re.CreatedDate, re.UpdatedDate
FROM dbo.RelationshipEvents re
INNER JOIN dbo.CharacterRelationships cr ON cr.CharacterRelationshipID = re.CharacterRelationshipID
INNER JOIN dbo.Characters ca ON ca.CharacterID = cr.CharacterAID
INNER JOIN dbo.Characters cb ON cb.CharacterID = cr.CharacterBID
INNER JOIN dbo.RelationshipTypes rt ON rt.RelationshipTypeID = cr.RelationshipTypeID
INNER JOIN dbo.RelationshipStates rs ON rs.RelationshipStateID = re.RelationshipStateID
INNER JOIN dbo.Scenes s ON s.SceneID = re.SceneID
INNER JOIN dbo.Chapters ch ON ch.ChapterID = s.ChapterID
INNER JOIN dbo.Books b ON b.BookID = ch.BookID
WHERE @CharacterID IN (cr.CharacterAID, cr.CharacterBID)
ORDER BY b.SortOrder, ch.SortOrder, s.SortOrder, re.CreatedDate, re.RelationshipEventID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.RelationshipEvent_ListByScene
@SceneID int
AS
BEGIN
SET NOCOUNT ON;
SELECT re.RelationshipEventID, re.CharacterRelationshipID, cr.ProjectID, cr.CharacterAID, ca.CharacterName AS CharacterAName,
cr.CharacterBID, cb.CharacterName AS CharacterBName, rt.TypeName AS RelationshipTypeName,
cr.IsReciprocal, re.SceneID, re.RelationshipStateID, rs.StateName AS RelationshipStateName, re.Intensity, re.IsKnownToOtherCharacter,
re.Description, re.CreatedDate, re.UpdatedDate
FROM dbo.RelationshipEvents re
INNER JOIN dbo.CharacterRelationships cr ON cr.CharacterRelationshipID = re.CharacterRelationshipID
INNER JOIN dbo.Characters ca ON ca.CharacterID = cr.CharacterAID
INNER JOIN dbo.Characters cb ON cb.CharacterID = cr.CharacterBID
INNER JOIN dbo.RelationshipTypes rt ON rt.RelationshipTypeID = cr.RelationshipTypeID
INNER JOIN dbo.RelationshipStates rs ON rs.RelationshipStateID = re.RelationshipStateID
WHERE re.SceneID = @SceneID
ORDER BY ca.CharacterName, cb.CharacterName, re.CreatedDate, re.RelationshipEventID;
END;
GO

View File

@ -0,0 +1,69 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
CREATE OR ALTER PROCEDURE dbo.StoryBible_RelationshipSummaries
@ProjectID int
AS
BEGIN
SET NOCOUNT ON;
SELECT cr.CharacterRelationshipID,
cr.CharacterAID,
ca.CharacterName AS CharacterAName,
cr.CharacterBID,
cb.CharacterName AS CharacterBName,
rt.TypeName AS RelationshipTypeName,
CASE WHEN cr.IsInitialRelationship = 1 THEN rt.TypeName ELSE NULL END AS InitialRelationshipName,
initialState.StateName AS InitialStateName,
cr.InitialIntensity,
COALESCE(currentEvent.StateName, initialState.StateName, rt.TypeName) AS CurrentStateName,
COALESCE(currentEvent.Intensity, cr.InitialIntensity) AS CurrentIntensity,
ISNULL(events.EventCount, 0) AS EventCount,
firstEvent.SceneLabel AS FirstEventLabel,
events.EventHistory
FROM dbo.CharacterRelationships cr
INNER JOIN dbo.Characters ca ON ca.CharacterID = cr.CharacterAID
INNER JOIN dbo.Characters cb ON cb.CharacterID = cr.CharacterBID
INNER JOIN dbo.RelationshipTypes rt ON rt.RelationshipTypeID = cr.RelationshipTypeID
LEFT JOIN dbo.RelationshipStates initialState ON initialState.RelationshipStateID = cr.InitialRelationshipStateID
OUTER APPLY
(
SELECT TOP (1) rs.StateName, re.Intensity
FROM dbo.RelationshipEvents re
INNER JOIN dbo.RelationshipStates rs ON rs.RelationshipStateID = re.RelationshipStateID
INNER JOIN dbo.Scenes s ON s.SceneID = re.SceneID AND s.IsArchived = 0
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID AND c.IsArchived = 0
INNER JOIN dbo.Books b ON b.BookID = c.BookID AND b.IsArchived = 0
WHERE re.CharacterRelationshipID = cr.CharacterRelationshipID
ORDER BY b.SortOrder DESC, c.SortOrder DESC, s.SortOrder DESC, re.CreatedDate DESC, re.RelationshipEventID DESC
) currentEvent
OUTER APPLY
(
SELECT COUNT(*) AS EventCount,
STRING_AGG(
CONVERT(nvarchar(max), CONCAT(b.BookTitle, N' / Ch ', c.ChapterNumber, N' / Scene ', s.SceneNumber, N': ', rs.StateName, ISNULL(N' (' + CONVERT(nvarchar(20), re.Intensity) + N')', N''), ISNULL(N' - ' + re.Description, N''))),
N' -> '
) WITHIN GROUP (ORDER BY b.SortOrder, c.SortOrder, s.SortOrder, re.CreatedDate, re.RelationshipEventID) AS EventHistory
FROM dbo.RelationshipEvents re
INNER JOIN dbo.RelationshipStates rs ON rs.RelationshipStateID = re.RelationshipStateID
INNER JOIN dbo.Scenes s ON s.SceneID = re.SceneID AND s.IsArchived = 0
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID AND c.IsArchived = 0
INNER JOIN dbo.Books b ON b.BookID = c.BookID AND b.IsArchived = 0
WHERE re.CharacterRelationshipID = cr.CharacterRelationshipID
) events
OUTER APPLY
(
SELECT TOP (1) CONCAT(b.BookTitle, N' / Ch ', c.ChapterNumber, N' / Scene ', s.SceneNumber, N': ', s.SceneTitle) AS SceneLabel
FROM dbo.RelationshipEvents re
INNER JOIN dbo.Scenes s ON s.SceneID = re.SceneID AND s.IsArchived = 0
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID AND c.IsArchived = 0
INNER JOIN dbo.Books b ON b.BookID = c.BookID AND b.IsArchived = 0
WHERE re.CharacterRelationshipID = cr.CharacterRelationshipID
ORDER BY b.SortOrder, c.SortOrder, s.SortOrder, re.CreatedDate, re.RelationshipEventID
) firstEvent
WHERE cr.ProjectID = @ProjectID AND cr.IsArchived = 0
ORDER BY ca.CharacterName, cb.CharacterName;
END;
GO

View File

@ -938,8 +938,42 @@ public sealed class CharacterDetailViewModel
public IReadOnlyList<SceneCharacter> Appearances { get; set; } = [];
public IReadOnlyList<CharacterAttributeEvent> AttributeEvents { get; set; } = [];
public IReadOnlyList<CharacterKnowledgeItem> KnowledgeItems { get; set; } = [];
public IReadOnlyList<CharacterRelationship> Relationships { get; set; } = [];
public IReadOnlyList<CharacterRelationship> InitialRelationships { get; set; } = [];
public IReadOnlyList<CurrentRelationshipViewModel> CurrentRelationships { get; set; } = [];
public IReadOnlyList<RelationshipEvent> RelationshipEvents { get; set; } = [];
public InitialRelationshipEditViewModel NewInitialRelationship { get; set; } = new();
public IReadOnlyList<SelectListItem> CharacterOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> RelationshipTypeOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> RelationshipStateOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> BookOptions { get; set; } = [];
}
public sealed class CurrentRelationshipViewModel
{
public CharacterRelationship Relationship { get; set; } = new();
public RelationshipEvent? LatestEvent { get; set; }
public IReadOnlyList<RelationshipEvent> Events { get; set; } = [];
public string CurrentStateName => LatestEvent?.RelationshipStateName
?? Relationship.InitialRelationshipStateName
?? Relationship.RelationshipTypeName;
public int? CurrentIntensity => LatestEvent?.Intensity ?? Relationship.InitialIntensity;
public string? CurrentNotes => LatestEvent?.Description ?? Relationship.Notes;
}
public sealed class InitialRelationshipEditViewModel
{
public int CharacterRelationshipID { get; set; }
public int ProjectID { get; set; }
public int CharacterID { get; set; }
public int? ReturnCharacterID { get; set; }
public int RelatedCharacterID { get; set; }
public int RelationshipTypeID { get; set; }
public int? InitialBookID { get; set; }
public int? InitialRelationshipStateID { get; set; }
public int? InitialIntensity { get; set; }
public bool IsReciprocal { get; set; }
public bool ReaderInitiallyKnows { get; set; }
public string? Notes { get; set; }
}
public sealed class SceneCharacterCreateViewModel

View File

@ -53,6 +53,11 @@
</div>
</div>
@if (TempData["RelationshipMessage"] is string relationshipMessage)
{
<div class="alert alert-info">@relationshipMessage</div>
}
<section class="asset-detail-grid">
<article class="plain-card">
<h2>Profile</h2>
@ -64,32 +69,265 @@
<p><strong>Story importance:</strong> @storyImportance</p>
</article>
<article class="plain-card">
<h2>Relationships</h2>
@if (!Model.Relationships.Any())
<h2>Initial Relationships</h2>
@if (!Model.InitialRelationships.Any())
{
<p class="muted">No relationships yet.</p>
<p class="muted">No initial relationships yet.</p>
}
else
{
<div class="asset-mini-list">
@foreach (var relationship in Model.Relationships)
@foreach (var relationship in Model.InitialRelationships)
{
var initialSourceId = relationship.CharacterAID;
var initialRelatedId = relationship.CharacterBID;
<div class="asset-mini-item">
<strong>@relationship.CharacterAName -> @relationship.CharacterBName</strong>
<span>@relationship.RelationshipTypeName</span>
<span>Reciprocal: @(relationship.IsReciprocal ? "Yes" : "No")</span>
<span>Reader initially knows: @(relationship.ReaderInitiallyKnows ? "Yes" : "No")</span>
@if (!string.IsNullOrWhiteSpace(relationship.InitialRelationshipStateName) || relationship.InitialIntensity.HasValue)
{
<span>@relationship.InitialRelationshipStateName@if (relationship.InitialIntensity.HasValue) { <text> / Intensity @relationship.InitialIntensity</text> }</span>
}
@if (!string.IsNullOrWhiteSpace(relationship.InitialBookTitle))
{
<span>Initial book: @relationship.InitialBookTitle</span>
}
@if (!string.IsNullOrWhiteSpace(relationship.Notes))
{
<span>@relationship.Notes</span>
}
<a asp-action="Details" asp-route-id="@(relationship.CharacterAID == Model.Character.CharacterID ? relationship.CharacterBID : relationship.CharacterAID)">Open related character</a>
<a asp-controller="Dynamics" asp-action="Relationship" asp-route-id="@relationship.CharacterRelationshipID">Evolution</a>
<details>
<summary>Edit</summary>
<form asp-action="SaveInitialRelationship" method="post" class="row g-2 mt-2">
<input type="hidden" name="CharacterRelationshipID" value="@relationship.CharacterRelationshipID" />
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
<input type="hidden" name="CharacterID" value="@initialSourceId" />
<input type="hidden" name="ReturnCharacterID" value="@Model.Character.CharacterID" />
<div class="col-md-6">
<label class="form-label">Related character</label>
<select class="form-select form-select-sm" name="RelatedCharacterID" required>
@foreach (var option in Model.CharacterOptions.Where(x => x.Value != initialSourceId.ToString()))
{
<option value="@option.Value" selected="@(option.Value == initialRelatedId.ToString())">@option.Text</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Relationship type</label>
<select class="form-select form-select-sm" name="RelationshipTypeID">
@foreach (var option in Model.RelationshipTypeOptions)
{
<option value="@option.Value" selected="@(option.Value == relationship.RelationshipTypeID.ToString())">@option.Text</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Awareness</label>
<select class="form-select form-select-sm" name="InitialRelationshipStateID">
<option value="">No awareness state</option>
@foreach (var option in Model.RelationshipStateOptions)
{
<option value="@option.Value" selected="@(option.Value == relationship.InitialRelationshipStateID?.ToString())">@option.Text</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Initial book</label>
<select class="form-select form-select-sm" name="InitialBookID">
<option value="">Project start</option>
@foreach (var option in Model.BookOptions)
{
<option value="@option.Value" selected="@(option.Value == relationship.InitialBookID?.ToString())">@option.Text</option>
}
</select>
</div>
<div class="col-md-8">
<label class="form-label">Confidence / intensity <output>@(relationship.InitialIntensity ?? 5)</output></label>
<input class="form-range" name="InitialIntensity" type="range" min="1" max="10" step="1" value="@(relationship.InitialIntensity ?? 5)" oninput="this.previousElementSibling.querySelector('output').value = this.value" />
</div>
<div class="col-md-4">
<label class="form-check mt-4">
<input type="hidden" name="IsReciprocal" value="false" />
<input class="form-check-input" name="IsReciprocal" type="checkbox" value="true" checked="@relationship.IsReciprocal" />
<span class="form-check-label">Reciprocal</span>
</label>
<label class="form-check">
<input type="hidden" name="ReaderInitiallyKnows" value="false" />
<input class="form-check-input" name="ReaderInitiallyKnows" type="checkbox" value="true" checked="@relationship.ReaderInitiallyKnows" />
<span class="form-check-label">Reader initially knows</span>
</label>
</div>
<div class="col-12">
<label class="form-label">Notes</label>
<textarea class="form-control form-control-sm" name="Notes" rows="2">@relationship.Notes</textarea>
</div>
<div class="col-12">
<button class="btn btn-outline-primary btn-sm" type="submit">Save initial relationship</button>
</div>
</form>
</details>
<form asp-action="ArchiveRelationship" method="post" class="archive-button-form" data-confirm-message="Archive this relationship?&#10;&#10;This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
<input type="hidden" name="id" value="@relationship.CharacterRelationshipID" />
<input type="hidden" name="characterId" value="@Model.Character.CharacterID" />
<button class="btn btn-outline-danger btn-sm" type="submit">Archive</button>
<button class="btn btn-outline-danger btn-sm" type="submit">Archive / Remove</button>
</form>
</div>
}
</div>
}
<details class="mt-3">
<summary>Add initial relationship</summary>
<form asp-action="SaveInitialRelationship" method="post" class="row g-2 mt-2">
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
<input type="hidden" name="CharacterID" value="@Model.Character.CharacterID" />
<input type="hidden" name="ReturnCharacterID" value="@Model.Character.CharacterID" />
<div class="col-md-6">
<label class="form-label">Related character</label>
<select class="form-select form-select-sm" name="RelatedCharacterID" required>
<option value="">Choose character</option>
@foreach (var option in Model.CharacterOptions.Where(x => x.Value != Model.Character.CharacterID.ToString()))
{
<option value="@option.Value">@option.Text</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Relationship type</label>
<select class="form-select form-select-sm" name="RelationshipTypeID" asp-items="Model.RelationshipTypeOptions"></select>
</div>
<div class="col-md-6">
<label class="form-label">Awareness</label>
<select class="form-select form-select-sm" name="InitialRelationshipStateID" asp-items="Model.RelationshipStateOptions">
<option value="">No awareness state</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Initial book</label>
<select class="form-select form-select-sm" name="InitialBookID" asp-items="Model.BookOptions">
<option value="">Project start</option>
</select>
</div>
<div class="col-md-8">
<label class="form-label">Confidence / intensity <output>5</output></label>
<input class="form-range" name="InitialIntensity" type="range" min="1" max="10" step="1" value="5" oninput="this.previousElementSibling.querySelector('output').value = this.value" />
</div>
<div class="col-md-4">
<label class="form-check mt-4">
<input type="hidden" name="IsReciprocal" value="false" />
<input class="form-check-input" name="IsReciprocal" type="checkbox" value="true" checked />
<span class="form-check-label">Reciprocal</span>
</label>
<label class="form-check">
<input type="hidden" name="ReaderInitiallyKnows" value="false" />
<input class="form-check-input" name="ReaderInitiallyKnows" type="checkbox" value="true" checked />
<span class="form-check-label">Reader initially knows</span>
</label>
</div>
<div class="col-12">
<label class="form-label">Notes</label>
<textarea class="form-control form-control-sm" name="Notes" rows="2"></textarea>
</div>
<div class="col-12">
<button class="btn btn-outline-primary btn-sm" type="submit">Add initial relationship</button>
</div>
</form>
</details>
</article>
</section>
<section class="list-section mt-3">
<h2>Current Relationships</h2>
@if (!Model.CurrentRelationships.Any())
{
<p class="muted">No current relationship state yet.</p>
}
else
{
<div class="asset-mini-list">
@foreach (var current in Model.CurrentRelationships)
{
var relationship = current.Relationship;
var relatedCharacterId = relationship.CharacterAID == Model.Character.CharacterID ? relationship.CharacterBID : relationship.CharacterAID;
var relatedCharacterName = relationship.CharacterAID == Model.Character.CharacterID ? relationship.CharacterBName : relationship.CharacterAName;
<div class="asset-mini-item">
<strong>@relatedCharacterName</strong>
<span>@relationship.RelationshipTypeName · Current: @current.CurrentStateName</span>
<span>Reciprocal: @(relationship.IsReciprocal ? "Yes" : "No") · Confidence / intensity: @(current.CurrentIntensity?.ToString() ?? "Unknown")</span>
@if (!string.IsNullOrWhiteSpace(current.CurrentNotes))
{
<span>@current.CurrentNotes</span>
}
<a asp-action="Details" asp-route-id="@relatedCharacterId">Open related character</a>
<a asp-controller="Dynamics" asp-action="Relationship" asp-route-id="@relationship.CharacterRelationshipID">Evolution</a>
<details>
<summary>View History</summary>
<div class="asset-mini-list mt-2">
<div class="asset-mini-item">
<strong>Initial: @relationship.RelationshipTypeName</strong>
<span>@(relationship.InitialRelationshipStateName ?? "No awareness state")@if (relationship.InitialIntensity.HasValue) { <text> · Intensity @relationship.InitialIntensity</text> }</span>
<span>Reader initially knows: @(relationship.ReaderInitiallyKnows ? "Yes" : "No")</span>
@if (!string.IsNullOrWhiteSpace(relationship.Notes))
{
<span>@relationship.Notes</span>
}
</div>
@foreach (var item in current.Events)
{
<div class="asset-mini-item">
<strong>@item.BookTitle / Ch @item.ChapterNumber / Scene @item.SceneNumber: @item.SceneTitle</strong>
<span>@item.RelationshipStateName · Intensity @(item.Intensity?.ToString() ?? "Unknown")</span>
<span>Awareness: @(item.IsKnownToOtherCharacter ? "known to other character" : "not known to other character")</span>
@if (!string.IsNullOrWhiteSpace(item.Description))
{
<span>@item.Description</span>
}
<a asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-SelectedSceneID="@item.SceneID" asp-route-FocusType="character" asp-route-FocusID="@Model.Character.CharacterID">Open scene</a>
</div>
}
<div class="asset-mini-item">
<strong>Current: @current.CurrentStateName</strong>
<span>Confidence / intensity: @(current.CurrentIntensity?.ToString() ?? "Unknown")</span>
</div>
</div>
</details>
</div>
}
</div>
}
</section>
<section class="list-section mt-3">
<h2>Relationship Events</h2>
@if (!Model.RelationshipEvents.Any())
{
<p class="muted">No relationship events yet.</p>
}
else
{
<div class="asset-mini-list">
@foreach (var item in Model.RelationshipEvents)
{
<div class="asset-mini-item">
<strong>@item.CharacterAName -> @item.CharacterBName: @item.RelationshipStateName</strong>
<span>@item.BookTitle / Ch @item.ChapterNumber / Scene @item.SceneNumber: @item.SceneTitle</span>
<span>@item.RelationshipTypeName · Awareness: @(item.IsKnownToOtherCharacter ? "known to other character" : "not known to other character")</span>
<span>Reciprocal: @(item.IsReciprocal ? "Yes" : "No") · Confidence / intensity: @(item.Intensity?.ToString() ?? "Unknown")</span>
@if (!string.IsNullOrWhiteSpace(item.Description))
{
<span>@item.Description</span>
}
<a asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-SelectedSceneID="@item.SceneID" asp-route-FocusType="character" asp-route-FocusID="@Model.Character.CharacterID">Open in timeline</a>
<a asp-action="Details" asp-route-id="@(item.CharacterAID == Model.Character.CharacterID ? item.CharacterBID : item.CharacterAID)">Open related character</a>
</div>
}
</div>
}
</section>
<section class="list-section mt-3">
<h2>Scene appearances</h2>
@if (!Model.Appearances.Any())
@ -156,24 +394,3 @@
}
</section>
<section class="list-section mt-3">
<h2>Relationship events</h2>
@if (!Model.RelationshipEvents.Any())
{
<p class="muted">No relationship events yet.</p>
}
else
{
<div class="asset-mini-list">
@foreach (var item in Model.RelationshipEvents)
{
<div class="asset-mini-item">
<strong>@item.CharacterAName -> @item.CharacterBName: @item.RelationshipStateName</strong>
<span>@item.BookTitle / Ch @item.ChapterNumber / Scene @item.SceneNumber: @item.SceneTitle</span>
<span>Intensity @item.Intensity. @item.Description</span>
<a asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-SelectedSceneID="@item.SceneID" asp-route-FocusType="character" asp-route-FocusID="@Model.Character.CharacterID">Open in timeline</a>
</div>
}
</div>
}
</section>

View File

@ -1274,16 +1274,29 @@
<input type="hidden" name="ReturnProjectID" value="@Model.ReturnProjectID" />
<input type="hidden" name="ReturnBookID" value="@Model.ReturnBookID" />
<div class="row g-2">
@if (Model.RelationshipOptions.Count == 1)
{
<div class="col-12">
<input type="hidden" name="CharacterRelationshipID" value="@Model.RelationshipOptions[0].Value" />
<span class="form-control form-control-sm">@Model.RelationshipOptions[0].Text</span>
</div>
}
else
{
<div class="col-12">
<select class="form-select form-select-sm" name="CharacterRelationshipID" asp-items="Model.RelationshipOptions">
<option value="">Create or choose relationship</option>
</select>
</div>
}
<div class="col-6"><select class="form-select form-select-sm" name="CharacterAID" asp-items="Model.CharacterOptions"><option value="">Character A</option></select></div>
<div class="col-6"><select class="form-select form-select-sm" name="CharacterBID" asp-items="Model.CharacterOptions"><option value="">Character B</option></select></div>
<div class="col-12"><select class="form-select form-select-sm" name="RelationshipTypeID" asp-items="Model.RelationshipTypeOptions"><option value="">Relationship type</option></select></div>
<div class="col-8"><select class="form-select form-select-sm" name="RelationshipStateID" asp-items="Model.RelationshipStateOptions"></select></div>
<div class="col-4"><input class="form-control form-control-sm" name="Intensity" type="number" min="1" max="10" placeholder="1-10" /></div>
<div class="col-4">
<label class="form-label">Intensity <output>5</output></label>
<input class="form-range" name="Intensity" type="range" min="1" max="10" step="1" value="5" oninput="this.previousElementSibling.querySelector('output').value = this.value" />
</div>
<div class="col-12">
<label class="form-check">
<input class="form-check-input" name="IsKnownToOtherCharacter" type="checkbox" value="true" />

View File

@ -246,10 +246,11 @@
{
<li>
<a asp-controller="Characters" asp-action="Details" asp-route-id="@relationship.CharacterBID">@relationship.CharacterBName</a>
<span>@relationship.RelationshipTypeName · @(relationship.CurrentStateName ?? "No current state")</span>
<span>Initial: @(relationship.InitialRelationshipName ?? relationship.RelationshipTypeName)@if (!string.IsNullOrWhiteSpace(relationship.InitialStateName)) { <text> / @relationship.InitialStateName</text> }@if (relationship.InitialIntensity.HasValue) { <text> · Intensity @relationship.InitialIntensity</text> }</span>
<span>Current: @(relationship.CurrentStateName ?? "No current state")@if (relationship.CurrentIntensity.HasValue) { <text> · Intensity @relationship.CurrentIntensity</text> }</span>
@if (!string.IsNullOrWhiteSpace(relationship.EventHistory))
{
<span>@relationship.EventHistory</span>
<span>Relationship Event History: @relationship.EventHistory</span>
}
</li>
}