This commit is contained in:
Nick Beckley 2026-06-01 09:23:44 +01:00
parent 6e237b7287
commit 0496c4d242
42 changed files with 986 additions and 75 deletions

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSTT2/cNhDFv4rAsyjzn0hRN2Odogs38SJZpEVvFGcUE5apBUU5CYz97gG1GzjprRdBGOjNvPeb0SuJ7hlJT25Pp3cvGPNCapLDVhJMaMo0ZfzITN+2vZIN10YKLv4lNQn3+J30xCjvubdIJWqkClhLBzl6CoCWMw5MtKr0dF8W0r8SFxrAl+CxmZfPmJYwR9KTv0OE+WuZ7ULjp3mFJs0T7uOSXfTFzIf97p7+dXs4PhwuXy24FHETgPSEGyaEUIZ2ykuqOhhpB5ZT3Y6ghnZw3rqLLMSMKbqpWeDpbT7MOWL2vWiEbBgVlpxrAi67YnlwCx6/n4qLDdFdqddbeXvtX8kLJtKL+ifMS7sbP4Wb52VYwwQ3l2d2y1NJeUrzCVMOuDHZYk6Ty2GO1XXSbgoYM6nJw6fqMLk8zun5N1CfLvn3/yP+sUx/9w39mhF28xpz0UpDavLe+ccQsdrfkZ4wjgJkh3QUXJd2jrpWWqq11CNyb43gpCa7OeYQ13ldqn3M+CVtAUhP/nDTghfrt8k/how+r6mk+keXWzikGVafqzf+nDWskYwV0ZpPa64+IoSEPmPJd0zrtd9/JEKLTbRbU8KYq4PLj9WfbnkkPbGu89xZZ53T3o/YMhwZMKeZAdSgEZQAA8oq0Moyr5UbuALX8mEsFVITLOuutguz0jA5qIGyDhlVQijaGddSawZjOxgkUwXxG8fqYSo65zumECS1OAJVljtqlZQUZOuM0KMxYvwFyXX7nBXAgC+Xf2VzoAE6BM2pULylylpOO9saOphusIPusPXF8z2miNMvoN4Hn+ZlHnN1PZ7qN3Qf11j+92o7pK8h0m/bju5m/4SpKjt2IZbzvq71fD6ffwAAAP//AwCvA78iNgQAAA==

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTwW7bOBD9FYFnUSEpihJ1C5xd1Ei7MdqgLXobcUY1UYUyKCptEfjfF5RdpN3b3ohHvpk37w1fWIAnYj27PZ3+eqaQFlay5DdICWW4MFzIR9H2TdPrupKtakXTfGEl8/f0k/Ws1c5JZ4nXZIhrFA0f6tFxRLJSSBSq0bkmfF1Y/8LAV0jP3lE1Lx8pLn4OrGeffMD5e+4NvnLTvGIV54n2YUkQXBbzz353z9/eHh4fDpdXCy2ZXHlkPZOtUErplnfa1Vx3OPIOreSmGVEPzQDOwoXmQ6IYYKoW/PbaH+cUKLleVaquBFeWnUuGkCBLHmChx5+nrGKz6C7j5QZvx/6FPVNkvSp/mXkpd+Mmf+PmpycIeDP64JcjK9kpzieKydNmxzbhNEHycyiuTXaTp5BYyR4+FIcJ0jjHpz88+nAZff8/Jn8H7ugDFfs71jMhSWHdER+VNJkDHJracmNqM5J0tlWSlWw3h+TDOq9LsQ+JvsZNJevZ3zAtdNF3G93RJ3JpjVn6Z5Ozph8+7WbMgGAlO8QZV5eKV7ulqERVi3z5sKbTmor3hD6SS5Rneozrtfx/KMqojbRbY6SQigOkY/EGliPrmYXOSbBgAYxzIzWCRoECjGiRDBpCrbBFbTUabYUzGgapERo5jBnJunO6xbZQ2liDQkqOYDquW6E52LrhQ9MNDvQgqM4WvdpaPEyZB64TmrDmlkbk2krgVtc1x7qBVpmxbdX4myXXxKXIxZCeL19jU2AQO0IjudKy4dpayTvbtHxou8EOpqPGZc33FANNvxn1zrs4L/OYiuvCFH9Y934N+XsX2/J894H/2CK7m903ikWOHHzI23xN+Xw+n/8FAAD//wMAJ8SPOyUEAAA=

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUy27kNhD8FYHXiDJfokjdHNuLDBw/sDNIgtxaZMtmLFMDirJ3YfjfA2ns9WMRIBdBaJJd1dXV/UQi3CNpyfF+f/aAMU+kJDmsIcGEpkxTxnesaeu6VbLiDau15n+TkoRz/E5a0ijnuLNIJWqkyrOadrJ31Hu0nHHPRK2WnHAzkfaJQKg8PgSH1Tj9gWkKYyQt+TNEPz4u2BAqN4yzr9I44CZOGaJbyFxuTs7p78fXu6vrw60Jp+VxFTxpCW+YEEI11CgnqTK+p8ZbTnXde9XVHTgLh2chZkwRhmryd2/4fswRs2tFJWTFqLDkuSQeMiyUO5hw932/sFglOl3i5Rpef9sn8oCJtKJ8FfOQ7sgN4eh+6uYw+KP1S0qyT+MeUw64qnG1Ld5IcFaxSmjBGCnJBbjbELHYnBZXw1IiOMMUekkt9p4qy4FaJSX1soZG6L5pRE9Kco4p4vAu60VwaZzGPhcvIhcfcLbHJ2cRugEXjC8wTEhKspm2GaKHYYx49g3dnA+5dmlejk/GmEOcx3kqNjHjTYKX89f3J3NKGHNxDfm2+A2mW9ISC8ZxsGABtHM91gx75hlo1njUXqNXwjdeWeW1ssxpBR1XHmre9UuElOTXRcPt7BxO0xub1STDsHIoXvp0MgSM+YOMpCWMo/DSIO0F14tLgEItLdVa6h65s43grzA7SDeYSUt603Udc64TynIHwoL0PZNe91aIpgHVy7pRQuuaNeB80wiuvGzQgewlM4KU5DqNfnb5c6vl2oDrNP6DLi9SkZZoVtfGQdcJrbgBa1BxrYy22FjVA1PSdcBrA13HGdTMWSMccG1Bdb7uf6h0Fm9CxNMw7Qf4/g7XVLqSvzgAw3tQn65/SXCPj2O6uzzYuLo825GSfJ3jshGKzWKRxxDpN71M9MU85LC7TQge/cXo8Wcf4TIuxWFCbW28YpLqpkaqhOPUcGVo4xB75TrXWU1Kcjq6O0zF4jAIcZmqH8mutsVxcrcho8tzWvj9tfK42hbXA+R+TPcfNsm7wj4LUAnJ2dIZjw+HbbRy1N4b9JpToXhNlbWcGls3tGtMZzttsHaLYldz3s+5+Io+JHR5rfjFiyvmyS26u5/FeHXBi0U5W7y2PWyxVdr/tcSeS3KPMM0J79dt3T6RTYyYVuTT+TCKm3gRhiFM6MboJ9Jq0ZjKGmZfGP7nPVnbSinJn5+fn/8FAAD//wMA8VM0JxsGAAA=

View File

@ -1,5 +0,0 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSU0W7cKhCGX8Xi2jiAMTa+izatukrbrJpVe3TuxjBuUBy8Apw2ivbdK+yt2lwdnRuExvzDP98MfiUenpD05Pp0eveMPkVSkuTWkGBCUaYo40fW9k3Ty7riqlGiVf+SkrhbfCE9aaUx3GikNSqk0rKGDvVoqLWoOeOWiUbmnPA9kv6VgKssPjuD1Ry/Yohu9qQn35y38498N7jKTPNiqzBPuPcxgTfZzOf97pZ+vD4c7w7bqYgxiytnSU94y4QQsqWdNDWVnR1pZzWnqhmtHJoBjIZN5nzC4GGqon38c7+dk8dkelGJumJUaHIuiYUE2fIAEY8vp+xiRXST4+UaXrf9K3nGQHpR/oa5pbsyk7t6isPiJnu1rQniY1zXEUyawwspySnMJwzJ4crnOkZ8GqaXI8TH99uZvI3vfqJZEtrdvPiUK64z1RXQNEFysy8uHneTQ5/Wjyk4H535r1w1KcndfXGYII1zeHrTj/sN8/5/UP4E5sF5LPY3pCeMo7B1h3QUXGUNUGhqTZWq1Yjc6FZwUpLd7JPzy7zEYu8Tfg9rSaQn72GKuPm7DubBJTRpCbnOf1QmcAizXUwq/vSSs4pVNWNZtKTTkoovaF1AkzAXcQzLJd/XtxKhxCraLSGgT8UB0kPxAeID6YmGznDQoAGUMSM2DEdmGSjWWlRWoZXCtlZqaZXUzCgJA5cWGj6MOUJKgnl0inValWlbVMJSQItUKom0q7uRGgVty0dRKz284VjcTVkHpmMSbU01jpZKzYFqWdfU1g20Qo1tK8a/kFzmgbMM2OLz9u42B9Z2aBWnQvKGSq057XTT0qHtBj2oDhuTPd9i8Dj9BeqTM2GO85iKy4QUb9B9WXz+dxTrtPxwnv5ce3Qzm0cMRe4xOJ+fyqWt5/P5/AsAAP//AwC9ok9gggQAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/cRgyG/4owZ4083x+6GXaLum7qRbxIi95GQ8oWopUWo9GmgbH/PRjtBol7600g9JKchy/5RqZwQNKS2+PxlxNOeSE1ycMWEkwYygxlfM9cy03LXCO5lU7rf0hNhkf8SlpiVYw8eqQSDVIFTNNO9pECoOeMAxNalZzhZSHtGwlDA3gaIjbz8gnTMswTaclfwwTzl1I7DE0c5xWaNI/4MC05TLE08+fD3SP943a3f9pd/lpwKeJmANISMJYzNI468JYqxYB2PQiqteLIHWoX4kU2TBnTFMZmgc8/6sOcJ8yxFY2QDaPCk3NNIORQWu7Cgvuvx9LFhui+xOstvH22b+SEibSi/g7zku4mjsNNno8jnnA8hrRguonz4RAmIDU5pvmIKQ+4YdleOo4hD/NUXYvdjQNOmdTk6bnajSH3czq8Y/V8QfDwPwicMHWkJZJpaaxFJaUM3nSCIVgTjRFdDMz2nUAekWmvrOeWcekF81Ib6ywE5kuml3Huwvj7svHzgnUOmBVGoEIvlDE+MpQOe22RQTCmMy56kM4K5cDJ4EPgvkMbvYnFIB9CfB0mrB7uSUsYRwHSIe0FN1Q5CDRo6akx0vTIo7eCk5rczVMepnVel+phyviSNn6kJb+GccELudsUX4eMMa+pQP3blGK7NMMac/XDAZw1rJGMFdGaj2uuPiIMCWPGgnef1mu+/0iEEZvobk0Jp1ztQn6tfgvLa6ESXOTBl6eaGHvUDHsGLBhmAQ0YBCXAgvIKjPIsGhU6riBo3vUlQmqCxXDV5nEThWGscxSl7qgCkNQF7ymXDHqhe+fxPcfqaSy6EB1TCJJ67IEqzwP1SkoKUgcrTG+t6H9CcjUfZwUw4OmyrZcOAByC4VQorqnynlPntaWddZ3vjEMdS8+PmCYcfwL1YYhpXuY+V1fvVu/QfVyncnGqzcdfhon+u83ofo6fMVVlxmGYyoJdx3quyQHDsiY8bAerfSO7sl3VfrtbQjTKS1WT5xxSXo/XsJaN0uZ8Pp+/AQAA//8DAL7aIIL0BAAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUTW/bOBD9KwLPpkJKFEUK2IPhtFgjTWMk7u5ibxQ5jInIokFRToMi/31BWa6dooe9CaP5ePPeG/5AvdoDatDycPh0hD4OaIGim0IFKTgmHBO6JaKhvCEirymVRFT/ogVyd/CGGlQzramWgEvggJkhFW5Lq7ExICmhhhQVSz3V84CaH0i53MDRacj98BeEwfkeNehv1xv/mmYrl+vOjyYPvoN1P0TV6wTm63p1h78sN9uHzSlrgCEV586gBhleUwJcYGFkjRkjBrfWFLiqGAUqoBJKn8pcHyH0qssH83KZb3zsIeqmyIsyJ7iQ6H2BjIoqQW7VANu3Q0IxUXSb4ospPH02P9ARAmqKxZnMU7sb3bmb/dCOrjM3UYVniDaoPbz68AJH1aEFOgR/gBAdTNw8PGUXSJTkJC94QQhaoHuld66HbH2bPXRpYaUFYWBKLMEazCRVWLKyxKasVF1wW9eFRQt0B6GH7qrrvdPBD97GbKY8+zBn5fvo+tGPQ7buIzwHFU91n1U3AFqgzdh2btg9gjJvW/84pn/92KVdVmMI0Mdso+Iu+1MNO9QgqYSmSiqpFNfaQkXAEkMUJ7UBbjgYVpjaMMkMZ5JozlRLmVEVbW2KoAV6GONhjGf+v1+B2Aa334O5INhOHG86Fa0P+7WBPjrrkjTnjMlQXTdtlc09V52DPn4gGTWIUChMKQDbgnLMhFFYVaXEnJfcAtWyLuiJMOuex588EWsNK20lBG9lSwkUthaM1LYlRttSS1O1UBc1qQUIqQlpK0ZFKzUxQCtm0nbBm1HHX61QTgI9jn26zmyd1n51Pf7O2YWRTfDRa98lwVwHT29DhP3vVFv5/cEPLqb97SzttwHufZ/+zvc/czYXL328xCDdQTadHjChLVUMM2FrzLQlWIlS4lobLaTmrGUFWqBbr18gZMlgyvWTJmdPPTxly6B3LoKOY0iT/5mWOsn5+XwyFz7yr5+2Kx9geTgs5ugfx0TSqdnZAB/elW9DSt/5IW0Rw3jloyfo7BnXlZsMHE8vlUtBbowAwykuGK0wk5JiIasat7VoZcsFVPri1uwRjAug49Rve5o2E/s7Wy5DdFbpOKTb+eL15KbZnnPKryCvVPtZfRp+pdzspLkTJcmxT6d3czLQ/3o239/f3/8DAAD//wMAkF/C9ycGAAA=

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUT2/cNhDFv4rAsyjzP0XdDLtFF27qRbxog94ozigWoqUWFLVpYOx3L6jdInFvuQkDveHjb97wjUR/RNKR+9PplzPGvJCa5HErCSYMZYYyfmBtx03H2kZqYSQzf5OajE/4jXTEqhB4cEglGqQKmKa9HAIFQMcZBya0Kj3954V0b8SPDeB5DNjMy5+YlnGOpCN/jRHmr+VsPzZhmldo0jzhLi7Zx1DM/LF7eKK/3+8Pz/vrXwsuRdyMQDoCxnKGpqUtOEuVYkD7AQTVWnHkLerWh6tsjBlT9FOzwJfv58OcI+bQiUbIhlHhyKUm4LMvlnu/4OHbqbjYED2Wer2Vt8/ujZwxkU7U/8G8trsL03i3rP2EZ5xOPi2Y7sJ8PPoIpCanNJ8w5RE3LGdM/byMuRANwfq25Rxab5W20EotpAXJEFpmNThw3igziKD1AH3rrBoUCK7AoFK9JTXZwE2Tz+Mcq5v3h2nEmElNnl+q/eTzMKfjO/QvV6K7nwBabJOOSKalsRaVlNI70wuGYE0wRvTBMzv0AnlApp2yjlvGpRPMSW1sa8EzVzp98OF1jFjtHklHGEcBskU6CG6oasFTr6WjxkgzIA/OCk5q8jDHPMZ1XpdqFzN+Ttt9SUd+9dOC15vep/A6Zgx5TQXCJ1PCuE8zrCFX3wPAWcMayVgRrfm05uojwpgwZCw4Dmm99fufRBixiR7WlDDmau/za/WbX15JR5xvA/fOO+9NCANqhgMD5g2zgAYMghJgQTkFRjkWjPI9V+A174dSITXBkrdqi3gYQLlgy4oZSVWvgPa9GGiv0QYuuIbwnmP1PBWdDy1TCJI6HIAqxz11SkoKUnsrzGCtGH5AcgsLZwUw4Pm6rJsDA9AiGE6F4poq5zhtnba0t23vetOiDsXzE6aI0w+gPowhzcs85OqWteoduo9rLA9OteXu6xjpP9uMHufwBVNVZuzHWPbrNtZLTY7olzXhcXuvujeyL8tVHbZnS4hGOalq8pJ9yuvpVtayUdpcLpfLvwAAAP//AwCQDjtA8wQAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTTW/UMBD9K9Gc16ntOE6cW7UFsSrQFawAcXM8E2o1dVaOU0DV/nfk7CIoNy6WNfKbeR/jZwj2kaCD6+Px1ROFNMMGkl9LkkvNuGZcHHjbCd0JWbZtY5SSX2ED/pZ+QgeNck44Q6wiTUwhr1lfDY4hkhFcIJe1yj3ttxm6Z7C+RHryjspp/kRx9lOADj77gNP3PNv60o3TgmWcRtqFOdngMpn3u+0te3u9P9ztz69mmjO49AgdoG4EJ92yFk3DlOLI+gElq2slSLRUt9adYT4kisGO5YwPf+bjlAIl18lSViVn0sBpA2iTzZR7O9Ph5zGzWC26yfXNWl6v3TM8UYRObn6beW535UZ/9Tj3ix/x6nwmOz9klcc4HSkmT6snq8xxtMlPobhM2o6eQoIN3H0s9qNNwxQfXxj18ax/9x/yD3n6qx/klkS4nZaQoAOhBGzgnXX3PlCxu4EOuCCJVUtskEIz1aJltq4M07rSAwlnGpkx2ykkH5ZpmYtdSPQtrgKgg9d2nOlM/Tq6e5/IpSVmVV903oV9nHBxqfjjv+AlLyvOM2hJxyUVHwh9JJco6zvE5dLvH4jUcgVtlxgppGJv033xxs730IGxrRPWWGOtdm6gmtPAkVvNGySNmlBJbFAZhVoZ7rSyvVBoa9EPuQIboBx3sW6YrlBKRQ0joxumGj0w2/Ka1a6ukUvVV3J44WNxN2acdS1XhBUzNCBTRlhmVFUxrGrbSD00zYr7bcklfcGzwUhP579yZoDYEmrBpBI1U8YI1pq6YX3T9qbXLdUuc76lGGj8y6h33sVpnoZUXJaneGHdhyXk/16si/TdB/Zjzehmcg8Ui5yx9SGv9yXW0+l0+gUAAP//AwB3KvwmNgQAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRU0W7cKBT9Fes+GwcwxuC3aNJqR9luRu2oW/UNw3WD4uAR4LRRlH9f4ZlVmqfVviB0zbmce87BLxDMI8IA16fThycMOUEN2W8lTrkkVBLKjlQNTA6MN0p1fU/pd6jB3+IzDNALa5nVSFqUSISjHRnbyRLnUDPKHOWdKD3NjwTDCxjfOHzyFpslfcWY/BJggL99cMvPcrfxjZ2X1TVxmXEfUjbBFjJ/7Xe35M/rw/HucD6VMBVw4x0M4GTPKEpFlNM9EYI6Mk6Ok64TDJnCThl7hvmQMQYzN8k9vN3vlhww24E3vG0o4Rpea3Amm0J5NAmPz6fCYpPoptTrrbxthxd4wggDr/8V89zuys7+6jGNq5/d1XnNJj2kbZ2MzUt8hhpOcTlhzB43fa5Twsdxfj6a9PDxfKZs04dfaNeMbresIcMArFVQwybQPJvsl1BdOO5mjyFvH3P0IXn7X71aqOHuS3WYTZ6W+PjOjy9nmff/Q+VPxt77gNX+BgagDLlrFZKJM0mEcoaYrtVEylZOyKzuOYMadkvIPqzLmqp9yPgjbiPBAB/NnPDM7zrae5/R5jWWOb/JkqtDXNxqc/XmJaMNbVpKC2jNpzVXn9H5iDZjGeIY10u/r+8hXPINtFtjxJCrg8n31R8m3cMA2ijLjDbaGGnthB3FiTpqJO0dSifRCe56J7RwUmhqpTAjE850bJxKBWrAEp1qS6swlis9dURaPRJh+o6M1o5komPPuFLj1BYibzpWd3PBGauoQNcSjZMjQjNDtGhb4trO9FxOfc+n3yS55IHRIrDDp/O72xhI5xQ6yQgXrCNCa0aU7noy9mrUo1TY2cL5FmPA+TehPnkbl7RMubokpHon3ec1lH9HtaXlpw/k1+bRzWIfMFbFY+NDeSoXW19fX1//AQAA//8DAIIp/ySCBAAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUTW/jNhD9KwKvFRWSoihSt9TJokaaxFgbbdHbiBzFbGTKoKhkF0H+eyE52XwsCvQiCEPOvMc3b+aJBDggacj58Xj5gCGNJCfJLyHBhKJMUcZ3TDdcNVwU2rBSK/E3yYm/wu+kIbW0lluDtESFVDpW0bbsLHUODWfcMVHJuSbcjaR5IuALhw/eYjGMf2Ac/RBIQ/70wQ2PMzb4wvbD5Io49LgOY4JgZzI369UV/f18s7vdnG6NOM7JhXekIU7VnKHSVDtTUymZo23nBK0qyZFrrDTYU5oPCWOAvhjd/Ru+G1LAZBtRiLJgVBjynBMHCWbKLYy4+36cWSwSXczxfAkvv80TecBIGpG/inkqd2Z7f3YY28n37mz5kpwc43DEmDwuatxuszcSnBWsEEowRnJyDXbvA2bri+y2n58IVjOJrqQGO0el4UCNLEvqygpqobq6Fh3JyRXGgP27qtfexmEcupS9iJx9wNmery4DtD3OGF+gH5HkZD1uEwQH/RDw8hvaKZ1q7eI0H6+GkHyYhmnM1iHhXYSX89f81RQjhpRtIO2z32Dck4YY0JaDAQOgrO2wYtgxx0Cx2qFyCp0UrnbSSKekYVZJaLl0UPG2myMkJ7/OGm4na3Ec39gsJun7hUP20qdV7zGkDzKShjCOwpUaaSe4olI7oFCVhipVqg65NbXgrzA7iHeYSEM63bYts7YV0nALwkDpOlY61Rkh6hpkV1a1FEpVrAbr6lpw6coaLZRdybQgOdnEwU02fW51uTRgE4d/0KZZKtIQxapKW2hboSTXYDRKrqRWBmsjO2CytC3wSkPbcgYVs0YLC1wZkK2ruh8qXYY7H/DCj8cevr/D1YUqyl8sgOYdyE/Xv0Q44OMQ729ONi5uLnckJ1+nMG+EbD1b5NEH+k3NE3099cnv9hHBobseHP7sI5zHJVsmlAkUlbSKMqcZlSUqCpZz2iFjqmSWt3bOuBjsPcZsdhj4ME/Vj2K32+w82r1PaNMUZ35/LTxut9mmh9QN8fBhk7x72GcBClFyNnfG4cNpGy0clXManeJUSF5RaQyn2lQ1bWvdmlZprOys2O2UjlPKvqLzEW1aXvzixQVztUd7/7MYry54sShns9e2py22SPu/lthzTg4I4xTxsGzr5omsQ8C4IF9Mp1Fch2vf935EOwQ3kkZWghVcK/XC8L/vKVMwWern5+fnfwEAAP//AwAzeLKQGwYAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTwY7cKBD9FYuz8QDGGHwb9ewqrUl2WskoWe0NU+U0igdaGE8SjfrfV7g7Sia33NCDV/XqveKFBPuEZCC3p9NfzxjyQmqS/QYJJhRlijL+yPTA1cBFo7XRSvL/SE38PX4nA+mlc9wZpC0qpBJYR8d2chQADWccmOhkqWk/L2R4IdY3gM/eYROXj5gWHwMZyCcfIH4tva1v3BxXaFKccR+WbIMrYv7Z7+7p29vD48Ph8mrBpZAbD2QgoHrOUGmqwfRUSgZ0nEDQrpMcucZOW3eh+ZAxBTs3C3z52R9iDpjdIBrRNowKQ841AZttkTzaBR+/n4qKzaK7gtcbvB2HF/KMiQyi/mHmpdyNm/2Ni09PNsDN5INfjqQmpxRPmLLHzY5twnm22cdQXZvsZo8hk5o8fKgOs81TTE+vPPpwGX3/B5O/s+7oA1b7OzIQxlFAq5FOgisqNVhqu9ZQpVo1IXemF5zUZBdD9mGN61LtQ8bPaVNJBvK3nRe86LtN7ugzurymIv1fVbLGbz7vIhSAkZocUoTV5eqn3Zw1rGlZuXxY82nN1XsEn9BlLDM9pvVa/jeKUGIj7daUMOTqYPOxemOXIxmIsdpxa6yxVjk3YcdwYsCsYj2gAoUgBfQgjQQlDXNK2pFLsB0fp4IU3SXdaluo3ox25I5RbSZDpQSguuOacrBWt1b0QohXtlYPc+FZp5lEaKnBCag03FIj25ZC29leqKnvxfSLJdfEOSt+Az5fvsamQAFoBMWpkLyj0hhOtel6OvZ6NKPS2Lmi+R5TwPkXo955l+ISp1xdF6Z6Zd37NZTvXW3L89UH+m2L7C66L5iqErn1oWzzNeXz+Xz+HwAA//8DAFXn8oElBAAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/cNhCG/4rAsyjzS6Som2G3qOumXsSLtOhtxBnZQrTSgqI2DYz97wG1GyTurTdhoHdm+Mw788YmOBBr2e3x+MuJprSwkqVhCymhLBeWC7kXTStda1RVW1tL3/zDSjY80lfWMmdCkMET12SJGxQ173QfOCJ5KSQKVZucE14W1r4xGCqk0xCompdPFJdhnljL/homnL/k2jBUYZxXrOI80sO0JJhCbubPh7tH/sftbv+0u/y10JLF1YCsZYRW9p0Q3AVCbpoOOGCN3IL2tledsK67yIYpUZxgrBb8/KM+zmmiFFpVKV0Jrjw7lwwhQW65g4X2X4+5iw3RfY6XW3j7bN/YiSJrVfkd5iXdTRiHmzQfRzrReIS4ULwJ8+EAE7KSHeN8pJgG2rBsLx1HSMM8Fddid+NAU2Ile3oudiOkfo6Hd6yeLwge/geBE8WOtUyLWlvnyGitwdtOCUJng7WqCyBc3ymSgUTtjfPSCam9El7X1jUOQfjASvYyzh2Mvy8bP69E16Bwyioy5JWx1gdBuqG+diQQrO1sEzzqxinTYKPBA0jfkQvehmyQDxBeh4mKh3vWMiFJoW6I90pabhoEDrX23Fpte5LBOyVZye7mKQ3TOq9L8TAleokbP9ayX2Fc6ELuNobXIVFIa8xQ/7a52C7OuIZU/HCAFJWotBBZtKbjmoqPhEOkkCjj3cf1mu8/EmXVJrpbY6QpFTtIr8VvsLxmKtAECT4/1YbQUy2oFyjACodk0RIahQ6NN2iNF8Ea6KRBqGXX5wgrGWXDFZvHmxCEbJTg2iJcF42k4gGdlb3uAIJ/x7F4GrMOQiMMoeaeeuTGS+DeaM1R1+CU7Z1T/U9IruaTIgNGOl22devAIjbZZ1wZWXPjveSNrx3vXNP5zjZUbx08Upxo/AnUhyHEeZn7VFy9W7xD93Gd8sUpNh9/GSb+7zaj+zl8pljkGcMw5QW7jvVcsgPBskY6bAerfWO7vF3Ffrtbqq5qqWTJnhPEtB6vYV1XWtTyfD6fvwEAAP//AwB9Sf0i9QQAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUT2/cNhDFv4rAsyjzP0XdDLtFDTfNIjbaorcRZxQL0UoLito0MPzdC2q3SNxbb8JAb/j4mzd8ZTMciXXs9nT66UxzXlnN8riXlFCOC8eFfBZtJ31nVOOEFcGqv1jNxkf6xjrmTYwyBuKaHHGDwvJeD5EjUpBColDWlJ7weWXdK4OxQTqPkZpl/Z3SOi4z69gf44zL13I2jE2clg2btEz0MK8Z5ljM/PZw98h/vT08fzxc/lppLeJmRNYxQieHXgjuIyE3bQ8c0CJ3oIMbVC+c7y+ycc6UZpiaFb98Px+XPFOOnWqUbgRXgb3VDCFDsdzDSs/fTsXFjui+1Ou9vH92r+xMiXWq/hfmpd1NnMabdesnOtN0grRSuonL8Qgzspqd0nKilEfasZwp9cs65kI0Rg9tKyW24I312GqrtEctCFvhLQYM4IwbVLR2wL4N3gwGlTToyJjes5rt4KYJ8rjM1dX73TTSnFnNPj5VhwnysKTjO/RPF6IP/wNosc06poXVznsyWmsIrleC0LvonOojCD/0imQkYYPxQXohdVAiaOt86xFEiKxmHyC+jDNVD/esY0KSQt0SH5R03LQIHKwO3DntBpIxeCVZze6WOY/ztmxr9TBn+pz2+7KO/QzTSpeb3qb4MmaKeUsFwp+uhPGQFtxirr4HQIpGNFqIItryacvVJ8IxUcxUcDyn7drvPxLl1C6621KiOVcHyC/VL7C+sI4FaKOEAAHAxTiQFTQIFOCER3LoCI1CjyYYdCaI6Az00iBY2Q+lwmpGJW/VHvFBOhVBIxcGFDfDQLwV0vAgWgAysoc+vONYfZyKDmIrDKHmgQbkJkjgwWjNUVvwyg3eq+EHJNewSFEAI50vy7o7cIhtyQVXRlpuQpC8Ddbz3rd96F1LNhYHj5Rmmn4A9WGMaVmXIVfXrFXv0H3a5vLgVHvuvo4z/3uf0f0Sv1CqyoxhnMt+Xcf6VrMjwbolOu7vVffKDmW5quf92VK2sVLJmj1lSHk7XcvaNlpY+fb29vYPAAAA//8DAGVfs2P0BAAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/jNhCG/4rAs6iQFEV93FKnixrZbIyNuy16G5Ejh4hMGhSVbRD4vy8oO4izix56E0Z85+OZl3wlDvZIOnJ9OPz+jC5OJCfRLiHBhKJMUca3rOl43UlRqFJWrOH/kJzYW3whHaml1ly3SEtUSKVhFe3LQVNjsOWMGyYqmXLCbiLdKwFbGHy2Ggs/fcMwWe9IR/6yzvjvqTbYQo9+NkXwI67dFMHp1MyX9eqWfr7ebO83p1MTTklcWEM6gkbxoWeM1hoNlU0PFExlqIKyVYPomar7k8y6iMHBWEzm6b2+8dFh1J0oRFkwKlpyzImBCKnlHibcvhxSFwuimxTPl/Dy2b2SZwykE/kbzFO6Kz3aq/3Uz3Y0V6Pf7azbrbwb7G4OEFPhnByCP2CIFhc4v1kH4eWz3+1SPvIJxglJTrYY9tbBePpxgwPMY3zwc1jITObpv86QjsAcPcnJQnIcl7LZeZjVaNHFX7QXhe8fss0IcfBh/2FJDyf26/+B/g70o3WYrW9IRxhHYcoG6SC4orIxQKEqW6pUqQbkuq0FJzlZeRetm/08ZWsXcXem9qG/66AfbUQd55Bm+lsls22CN7OO2fuCOStYUTKWRHM8zDH7isYG1BHTENswn/P9JBFKLKJPdsRf8KzmENDFbAPxMfsDpkfSkRYazaGFFkBpPWDFcGCGgWK1QWUUGilMbWQrjZIt00pCz6WBivdDipCcYDJZtvi6VVojlDWtS5HgNoL2VWOo4IzLpi4la4YPcLP7MelAN0yiKWmLg6Gy5UBbWZbUlBXUQg11LYYLTmdDcJaoX5rwzwnN2UtfTtZ+m93g8+keL30qY5pkBCokr6hsW06btqppXzd926sGK50mW3k3+TeQ3zD0frIxPSJ31tk9jD8fed/MLQaH48V27qwOfvJDzM62zD7s6+vs0iuWLRb9bh39dzHGjddPGLJkLLDuYpnH4/H4AwAA//8DAPdjXrgMBQAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUwW7bOBD9FYFnUSElihIF7MFwWqyRpjESd3exN4ocxkRk0aAop0GRf19Qkmun6GFvwogz8+a9N/MD9fIAqEGr4/HTCfowoBQFO4VyknNMOCZ0R+qGVg0rMkIKQqv6X5QiewdvqEEVU4oqAbgADphpUuK2MAprDYISqkleslhTPg+o+YGkzTScrILMDX+BH6zrUYP+tr12r7G3tJnq3Kgz7zrY9EOQvYpgvm7Wd/jLart72M6vBhhicmY1ahBoTk1LCK4UaMzqVmKpS425LAQ3eUt41c5ptg/ge9llg3659Ncu9BBUk2d5kRGcC/SeIi2DjJBbOcDu7RhRTBTdxng6hafP5gc6gUdNnp7JnMvdqM7eHIZ2tJ2+CdI/QzBeHuDV+Rc4yQ6l6OjdEXywMHHz8JRcIFGSkSznOSEoRfdS7W0PyeY2eejiwFLVhIEusACjMRNUYsGKAuuilFXOTVXlBqXoDnwP3VXVe6u8G5wJyUJ58qHP2vXB9qMbh2TTB3j2Msx5n2U3AErRdmw7O+wfQeq3nXsc479+7OIs69F76EOylWGf/CmHPWqQkLWiUkghJVfKQEnAEE0kJ5UGrjlolutKM8E0Z4IozmRLmZYlbU2MoBQ9jOE4hjP/369A7Lw9HEBfEOwmjredDMb5w0ZDH6yxUZrzi8lQXTdNlSw1152FPnwgGTWIUMh1UQM2OeWY1VpiWRYCc15wA1SJKqczYcY+jz95IsZoVpiyrnkrWkogN1XNSGVaopUplNBlC1VekaqGWihC2pLRuhWKaKAl03E67/Sowq9WKCaBHsc+bmeyiWO/2h5/5+zCyNa74JTromC2g6e3IcDhd6qt3eHoBhvi/GaR9tsA966Pf5f9XzhbklcuXGIQ9yCZVq+VUAsiWqyKimHGCo5bCQTzStVEVXmeG4VSdOvUC/gkGkzaftLk7KmHp2Tl1d4GUGH0sfM/01CznJ/PK3PhI/v6abd2HlbHY7pE/zhFkuZiZwN8uCvfhvh874Y4RfDjlY+eoDNnXFdu0nCaL5WNQa51Ha8MzhktMROC4lqUFW6ruhUtr6FUF7cmj6CtBxWmeru520Ls72y58sEaqcIQd+eLU5ObFnsuT34FeaXaz+y5+ZVyi5OWSpRExz7Nd3My0P86m+/v7+//AQAA//8DACUyB6cnBgAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTTW/cNhD9KwLPS5mkKFLUzVin6MJNvUgWbdHbiDOKCcvUgqKcBIb/e0HtFk5664UgBnwz72P4yiI8E+vZ7fn84YViXtiO5bCVlFCGC8OFPImul7bXtm6dUMq6v9mOhXv6znpmtffSO+INGeIaRcuHZvQckZwUEoVqdekJXxbWvzIINdJL8FTPyx+UljBH1rM/Q8T5a5kNofbTvGKd5okOcckQfSHz+2F/z3+7PZ4ejpdXCy0FXAdkPSM0chyE4NYTct0NwAFb5AYaZ0Y1CGOHCyzETCnCVC/49D4f5xwp+17VqqkFV4697RhChkJ5gIVO38+FxWbRXanvtvJ27V/ZCyXWq92/Zl7a3fgp3DwvwxomvLmcGZanovKc5jOlHGjzZJM5TZDDHKvrpP0UKGa2Yw+fq+MEeZzT809Gfb7oP/wP+acy/cM38msm3M9rzKxnUku2Yx/BP4ZI1eGO9UxIUth0xEclDdcdAoe2cdyYxowkvbOqYPZzzCGu87pUh5jpS9oEsJ79AtNCF+q3yT+GTD6vqaj6y5RdOKYZV5+rd/+lqEXdCFFAaz6vufpEGBL5TEXfKa3Xfv+BKKM20H5NiWKujpAfq19heWQ9c9B5CQ4cgPF+pFbQKFCAERbJoCHUCi1qp9FoJ7zRMEiN0MphLBW2Y1TirrYNE9ZJbTvLSUnHNYLlTg3ArTWeCIVzWIi8+1g9TAUHvhOasOGORuTaSeBONw3HpgWrzGitGn+w5Jq+FMVgpJfLX9kYGMSuBM2Vli3XzkneudbywXaDG0xHrS+c7ylFmn4w6mPwaV7mMVfX5al+su7TGst/r7ZF+hoi/7ZldDf7J0pVyRhCLOt9jfXt7e3tHwAAAP//AwDJ0IwBNgQAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTwW7bOBD9FYFnUSEpihJ1C5xd1Ei7MdqgXextxBnVRBXKoKi0ReB/X1B20aa33ohHvpk37w1fWIAnYj27PZ3+eqaQFlay5DdICWW4MFzIR9H1su11WzVWdVqJ/1jJ/D19Zz1rtXPSWeI1GeIaRcOHenQckawUEoVqdK4JnxfWvzDwFdKzd1TNy0eKi58D69knH3D+mnuDr9w0r1jFeaJ9WBIEl8X8s9/d87e3h8eHw+XVQksmVx5ZzwiNHAcheOsIue4G4IANcgO1NaMahGmHC82HRDHAVC345Wd/nFOg5HpVqboSXFl2LhlCgix5gIUev5+yis2iu4yXG7wd+xf2TJH1qvxh5qXcjZv8jZufniDgzeiDX46sZKc4nygmT5sd24TTBMnPobg22U2eQmIle/hQHCZI4xyfXnn04TL6/g8mfwfu6AMV+zvWMyFJYd0RH5U0XHcIHJracmNqM5J0tlWSlWw3h+TDOq9LsQ+JPsdNJevZ3zAtdNF3G93RJ3JpjVn6vyZnTd982s2YAcFKdogzri4VP+2WohJVLfLlw5pOayreE/pILlGe6TGu1/K/UZRRG2m3xkghFQdIx+INLEfWMwudk2DBAhjnRmoEjQIFGNEiGTSEWmGL2mo02gpnNAxSIzRyGDOSded0i22hwOihHYThjRuA604PHFqpuTbWNZ3WtZXqla3Fw7TxXCc0Yc0tjci1lcCtrmuOdQOtMmPbqvEXS66JS5H9Rnq+fI1NgUHscrhcadlwba3knW1aPrTdYAfTUeOy5nuKgaZfjHrnXZyXeUzFdWGKV9a9X0P+3sW2PF994N+2yO5m94VikSMHH/I2X1M+n8/n/wEAAP//AwA4KzhMJQQAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRUwW7cNhD9FYFnUSYlihJ1M9YJunBTL5JFGuQ24oxiwjK1ICknhuF/L6jdwvGp6IUgRnzDN+896oV5eCQ2sOvT6cMT+RRZyZLbSrWoNReaC3kU/SC7QXVV23ddI8V3VjJ3S89sYJ2yVlpDvCFNXKFo+dhMliOSkUKiqFuVe8KPyIYXBq5CenKWqiV+pRDd4tnA/nYel5/5bnCVnZcVq7DMtPcxgbeZzF/73S3/8/pwvDucT0WKGVw5ZAMj1HIaheCdJeSqH4EDtsg1NEZP9Sh0N55hzicKHuYq4sPb/bgkT8kOdVU3leC1Ya8lQ0iQKY8Q6fh8yiw2iW5yvdzK23Z4YU8U2FCX/4p5bndlZ3f1GMfVzXh1XhPEh7itE9i0hGdWslNYThSSo02f6xjpcZyfjxAfPp7P5G388Ivsmgh3y+oTG5hselayTaB5huQWX1w47mZHPm0fU3A+OvtfvRpWsrsvxWGGNC3h8Z0fX84y7/+Hyp/A3jtPxf6GDUxIqrHpiU+11Fz1CBzaxnCtGz2RtKarJSvZbvHJ+XVZY7H3iX6EbSQ2sI8wRzrzuw723iWyaQ15zm865+oQFlxtKt68lKISVSNEBq3ptKbiM6ELZBPlIY5hvfT7+h5S63oD7dYQyKfiAOm++APiPRuYgd5KMGAAtLUTtYImgQK06JA0akJVY4fKKNTKCKsVjFIhtHKccoWVjHJ0ii2tPUHb2ElzM0rJVYcNH7EGjgCd0FMPasyjvelY3M0ZB7YXirDhhibkykjgRjUNx6aFrtZT19XTb5Jc8iBFFhjp6fzuNgYasc9u8lrJlitjJO9N2/Gx60cz6p5amznfUvA0/ybUJ2fDEpcpFZeEFO+k+7z6/O8otrT8dJ7/2jy6WewDhSJ7DM7np3Kx9fX19fUfAAAA//8DALXdmSSCBAAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUTW/cNhCG/4rAsyjzS/zQzbBb1HVTL+JFWvQ2JEe2EK20oKhNA2P/e0DtBol7600YaGZePvPOvJEJDkg6cns8/nLCKS+kJnnYQoIJTZmmjO+Z7bjtmGu4MIIr8Q+pyfCIX0lHjAqBB4dUokaqImupl32gMaLjjEcmWlVqwstCujcCQxPxNARs5uUTpmWYJ9KRv4Ypzl9KbxiaMM5rbNI84sO0ZJhCEfPnw90j/eN2t3/aXf5acCnJzRCLVO0db7mngclNRE8dj0CjaNtoZKtAi0vaMGVME4zNEj//6B/nPGEOnWiEbBgVjpxrEiFDkexhwf3XY1GxIbov8XoLb5/dGzlhIp2ov8O8lLsJ43CT5+OIJxyPkBZMN2E+HGCKpCbHNB8x5QE3LNtLxxHyME/VtdndOOCUSU2enqvdCLmf0+Edq+cLgof/QeCEyZOOSNZKbQwqKSU47QXDaHTQWvgAzPReIA/IWqeM44Zx6QRzstXGmgjMBVKTl3H2MP6+bPycYN5GZoQWqNAJpbULDKXFvjXIImjttQ0uSmuEstFKcADceTTB6VAM8gHC6zBh9XBPOsI4iigt0l5wTZWNQKGVjmotdY88OCM4qcndPOVhWud1qR6mjC9p40c68iuMC17I3abwOmQMeU0F6t+6NNulOa4hVz8cwFnDGslYSVrzcc3VR4xDwpCx4N2n9VrvPylCiy3pbk0Jp1ztIL9Wv8HyWqiADRxceaoOoceWYc8iA81MRB01RiWiicqpqJVjQSvwXEVoue9LhNQEi+GqzeMWTBDSSSrAOKo0s9QLj9RLq4D3HgK851g9jSUPgmUKo6QO+0iV40CdkpJG2YIRujdG9D8huZqPswI44umyrZsCHaPFqDkVirdUOcepda2h3ljvvLbYhqL5EdOE40+gPgwhzcvc5+rq3eoduo/rVC5Otfn4yzDRf7cZ3c/hM6aqzBiGqSzYdaznmhwQljXhYTtY3RvZle2q9tvdErwxzLqaPGdIeT1ew1I0znB1Pp/P3wAAAP//AwCSsevS9QQAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUTW/bOBD9KwLPokJS1AcF7MFwWqyRpjESd3exN4ocxkRk0aAop0GR/76gJNdO0cPehNF8vHnvDX+gXh4ANWh1PH46QR8GlKJgpxAjrMSkxITuSN3QuiEiK6goRFX/i1Jk7+ANNajiSlElAOdQAuaaFLjNjcJag6CEasIKHnvK5wE1P5C0mYaTVZC54S/wg3U9atDfttfuNc6WNlOdG3XmXQebfgiyVxHM1836Dn9ZbXcP2zlrgCEWZ1ZHqGUraEFbrEg+gTBYUC2xZkWhq7zgsmRzme0D+F522aBfLvO1Cz0E1bCM5RnBTKD3FGkZZITcygF2b8eIYqLoNsbTKTx9Nj/QCTxqWHomc253ozp7cxja0Xb6Jkj/DMF4eYBX51/gJDuUoqN3R/DBwsTNw1NygURJRjJWMkJQiu6l2tseks1t8tDFhaWqCQedYwFGYy6oxILnOdZ5IStWmqpiBqXoDnwP3VXXe6u8G5wJyUJ58mHO2vXB9qMbh2TTB3j2Msx1n2U3AErRdmw7O+wfQeq3nXsc479+7OIu69F76EOylWGf/CmHPWqQkLWiUkghZamUgYKAIZrIklQaSl2C5kxXmguuSy6IKrlsKdeyoK2JEZSihzEcx3Dm//sViJ23hwPoC4LdxPG2k8E4f9ho6IM1NkpzzpgM1XXTVsnSc91Z6MMHklGDCAWm8xqwYbTEvNYSyyIXuCzz0gBVomJ0JszY5/EnT8QYzXNT1HXZipYSYKaqOalMS7QyuRK6aKFiFalqqIUipC04rVuhiAZacB23806PKvxqhXwS6HHs43Umm7j2q+3x95JfGNl6F5xyXRTMdvD0NgQ4/E61tTsc3WBD3N8s0n4b4N718e9y/wtnS/HKhUsM4h0k0+mpvFVVWzGsas4xb3OBhWoZrkUlWKFl3kK8llunXsAn0WDS9pMmZ089PCUrr/Y2gAqjj5P/mZaa5fx8PpkLH9nXT7u187A6HtMl+scpkjQ3Oxvgw7vybYjpezfELYIfr3z0BJ0547pyk4bT/FLZGCy1rkGXFDNOC8yFoLgWRYXbqm5FW9ZQqItbk0fQ1oMKU7/dPG0h9ne2XPlgjVRhiLfzxanJTYs9l5RfQV6p9rN6Hn6l3OKkpRMl0bFP87s5Geh/PZvv7+/v/wEAAP//AwB74N4kJwYAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRUwW7jNhD9FYFnUSEpiRJ1S50uamSzMTbutuiN4owcIjJpUFS2QeB/X1B2EGcXPfQmjPhm3rz3yFfi9B5JR64Ph9+f0cWJ5CTapSSYkJRJyviWtR1vO6YK3taKSf4PyYm9xRfSkaYyhhuFtESJtAJW074cDAVAxRkHJuoq9dS7iXSvRNsC8NkaLPz0DcNkvSMd+cs68N/TbG0LM/oZiuBHXLspamcSmS/r1S39fL3Z3m9OpyacEriwkKjKXvGa99SwciExUMVBUxB1DU1ZV1qKE8y6iMHpsZjg6X0++Ogwmk4UoiwYFYoccwI66kS51xNuXw6JxSLRTarnS3n57F7JMwbSifxNzFO7KzPaq/3Uz3aEq9HvdtbtVt4NdjcHHdPgnByCP2CIFhdxfrNOh5fPfrdL/cgnPU5IcrLFsLdOj6cfNzjoeYwPfg6LMhM8/dcZ0hE9R09ysig5jsvY7LzMarTo4i/Yi8H3D9lm1HHwYf/BpIeT9uv/If2dNo/WYba+IR1hHAWULdJBcEmrFjTVdamolKUckBvVCE5ysvIuWjf7ecrWLuLurNoHftfBPNqIJs4h7fS3TGHbBA+zidm7wZwVrCgZS6A5HuaYfUWwAU3EtMQ2zOd+P0GEFAvokx3xF3lWcwjoYrbR8TH7Q0+PpCNKt4ZrpZXW0pgBa4YDA6YlawAlSIRKQAOVqkBWihlZ6Z5XoGveD6lCcoIpZNmS6x5kDQicctn0tBJoaN8C0KrhopRSaibhg7jZ/Zhw2rSsQiipwgFopbimqipLCmWtGyGHphHDhU7nQHCWVL8M4Z8TwjlLX07Rftsd8Pl0jxeeEqBFkJyKite0UorTVtUN7Zu2V71ssTZps5V3k38T8huG3k82pkfkzjq71+PPR96ducXgcLxw586a4Cc/xOwcy+yDX19nl16xbInod+vov0swbrx5wpClYGnrLsw8Ho/HHwAAAP//AwAfRIPiDAUAAA==

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpSUT2/cNhDFv4rAsyjzP0XdDLtFDTfNIjbaorcRZxQL0UoLito0MPzdC2q3SNxbb8JAb/j4mzd8ZTMciXXs9nT66UxzXlnN8riXlFCOC8eFfBZtJ9tOhEY65Vtl/2I1Gx/pG+uYNzHKGIhrcsQNCst7PUSOSEEKiUJZU3rC55V1rwzGBuk8RmqW9XdK67jMrGN/jDMuX8vZMDZxWjZs0jLRw7xmmGMx89vD3SP/9fbw/PFw+WultYibEYtV1wdpZc+j0LuJgQeJwFFZi15bA05dZOOcKc0wNSt++X4+LnmmHDvVKN0IrgJ7qxlChmK5h5Wev52Kix3RfanXe3n/7F7ZmRLrVP0vzEu7mziNN+vWT3Sm6QRppXQTl+MRZmQ1O6XlRCmPtGM5U+qXdcyFaIwe2lZKbMEb67HVVmmPWhC2wlsMGMAZN6ho7YB9G7wZDCpp0JExvWc128FNE+Rxmaur97tppDmzmn18qg4T5GFJx3fony5EH/4H0GKbdUwLq533ZLTWEFyvBKF30TnVRxB+6BXJSMIG44P0QuqgRNDW+dYjiBBZzT5AfBlnqh7uWceEJIW6JT4o6bhpEThYHbhz2g0kY/BKsprdLXMe523Z1uphzvQ57fdlHfsZppUuN71N8WXMFPOWCoQ/XQnjIS24xVx9D4AUjWi0EEW05dOWq0+EY6KYqeB4Ttu1338kyqlddLelRHOuDpBfql9gfWEdC9BGCQECgItxICtoECjACY/k0BEahR5NMOhMENEZ6KVBsLIfSoXVjEreqj3iAFZI0Ja3WgE3PiIPLbW8d857EBCGIN5xrD5Ouy62whBqHmhAboIEHozWHLUFr9zgvRp+QHINixQFMNL5sqy7A4fYEjrJlZGWmxAkb4P1vPdtH3rXko3F8yOlmaYfQH0YY1rWZcjVNWvVO3Sftrk8ONWeu6/jzP/eZ3S/xC+UqjJjGOeyX9exvtXsSLBuiY77e9W9skNZrup5f7aUbLxoQ82eMqS8na5lrZrgpXl7e3v7BwAA//8DAEKX/830BAAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRUTW/cNhT8KwLPoswvUaJuxjpBF27qRbJIi94ovqeYsEwtSMqJYex/L6jdIvGp6IUgnjiP82aGeiPBPiMZyO3p9OEFQ06kJtlvJcGEpkxTxo+sH3g/cNVwKbTu+d+kJv4eX8lAOuUcdwapRI1UAWvpKCdHAdBwxoGJVpWe9lsiwxuxvgF88Q6bJX3FmPwSyED+9AGW7+Vu6xs3Lys0cZlxH1K2wRUyf+x39/T328Px4XA5lTAVcOOhUNWj4S0fqWNyIzFRw8FSEG0LnWyV1eIC8yFjDHZuEjz9vB+WHDC7QTRCNowKQ841AZttoTzahMfXU2GxSXRX6vVW3rbDG3nBSAZR/yvmpd2Nm/3NcxpXP8PNZc02PaVtnazLS3wlNTnF5YQxe9z0uU0Jn8f59WjT08fLmbJNH36gWzPCbllDJgPhsic12QSaZ5v9Eqorx93sMeTtY44+JO/+q5ckNXn4Uh1mm6clPr/z48tF5v3/UPmTdY8+YLW/IwNhHAXIHukkuKaqB0ttKw3VWuoJuTOd4KQmuyVkH9ZlTdU+ZPwWt5HIQD7aOeGF3210jz6jy2ssc/6lS64OcYHV5eqnl5w1rJGMFdCaT2uuPiP4iC5jGeIY12u/r+8hQosNtFtjxJCrg82P1W82PZKBGNs7bo011mrnJmwZTgyY1awD1KARlIAOlFGglWFOKztyBbbl41QqpCZYolNtaUXrWsU6R+XYS6qMsrR3XNBOShBGtcClfqdj9TAXnHU9UwiSGpyAKsMtNUpKCrK1ndBT14npF0mueeCsCAz4cnl3GwMN0CNoToXiLVXGcNqbtqNj149m1D22rnC+xxhw/kWoT97FJS1Trq4Jqd5J93kN5d9RbWn57gP9sXl0t7gnjFXx2PpQnsrV1vP5fP4HAAD//wMAGpaA+4IEAAA=

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACoxUTW/cNhD9KwKvXcr8Einq5toOunAdG9lFW/Q2Ikc2a5laUJSTwPB/L6i1EztBgV4EYUjOe/PmzTyRCA9IOnJ6OFw8Yswz2ZAc1pBgQlOmKeN71na87biquWwbpfTfZEPCJX4lHTHKOe4sUokaqfKsob0cHPUeLWfcM9GokhNuZ9I9EQi1x8fgsJ7mPzDNYYqkI3+G6KfPBRtC7cZp8XWaRtzGOUN0hczH7dkl/f30Zn99c7w141we18EXqrq3vOE9dUyuJAZquQfqRdN4IxsFWhyfhZgxRRjr2d9/x/dTjphdJ2oha0aFJc8b4iFDodzDjPuvh8Jilei8xDdreP3tnsgjJtKJzauYx3QnbgwnD3O/hNGfrF+yIYc0HTDlgKsa17vqOwnOalYLLRgjG3IF7i5ErLbn1fVYSgTXMoVeUouDp8pyoFZJSb1swAg9GCMGsiGXmCKOb7JeBZemeRpy9SJy9Q5nd3p2EaEfsWB8gHFGsiHbeZchehiniBdf0C35mGuflnJ8NsUc4jItc7WNGW8TvJy/vj9bUsKYqxvId9VvMN+RjlhoHQcLFkA7N2DDcGCegWbGo/YavRLeeGWV18oypxX0XHloeD+UCNmQX4uGu8U5nOfvbFaTjOPKoXrp09kYMOZ3MpKOMI7CyxbpILimqvVAoZGWai31gNxZI/grzB7SLWbSkaHt+5451wtluQNhQfqBSa8HK4QxoAbZGCW0bpgB540RXHlp0IEcJGuL7W7S5BeXf2y1XBtwk6Z/0OUiFemIZk3TOuh7oRVvwbaouFattmisGoAp6XrgTQt9zxk0zNlWOODagup9M3xT6SLehojnYT6M8PUNblvrWv7iAFo+gPrh+ocED/h5SvcfjzauP17syYZ8WmLZCNW2WORziPSLLhN9tYw57O8Sgkd/NXn82UdYxqVaJ9QraZVhjlpjkCrrOO0lADUWemNK0UMZqvPJ3WOqisMgxDJV35Jd76rT5O5CRpeXVPj9tfK43lU3I+RhSg/vNsmbwn4UoBaSs9IZj4/HbbRy1N636DWnQvGGKms5bW1jaG/a3va6xcYVxa6XfFhy9Ql9SOjyWvGLF1fMszt09z+L8eqCF4tyVry2O26xVdr/tcSeN+QBYV4SPqzbunsi2xgxrcjny3EUt/EqjGOY0U3Rz6RTVrNaq0a+MPyvew1jpm6sFM/Pz8//AgAA//8DAJAvYc0bBgAA

View File

@ -0,0 +1,5 @@
https://dc.services.visualstudio.com/v2/track
Content-Type:application/x-json-stream
Content-Encoding:gzip
H4sIAAAAAAAACpRTyW7bMBD9FYFnU+EmStQtcFrUSNsYidEWvVGcUU1EoQyKyoLA/15QdtCmt14IYsA385bhKwn2AUlLLg+HD48Y0kRWJPmlJJjQlGnK+I41LW9arkouK6mb6idZEX+NL6QltXKOO4NUokaqgFW0k72jAGg448BEpXJP+2si7SuxvgR89A7LcfqGcfJjIC357gOMT3m29aUbxhnKOA64CVOywWUyXzfra/r5cru72Z5eTThlcOkhU9Wd4RXvqGNyIdFTw8FSEFUFtayU1eIE8yFhDHYoJ7j/Mx/GFDC5VpRClowKQ44rAjbZTLmzE+5eDpnFYtFVrq+W8nJtX8kjRtKK1ZuZp3YXbvAXD1M3+wEuTmey031WeYjjAWPyuHiyyBwGm/wYivOk9eAxJLIiN3fFdrCpH+PDO6PuTvo3/yF/l6d/eEY3J4T1OIdEWsIVJyvyxbq9D1hsrkhLGEcBskHaC66pasBSW0lDtZa6R+5MLTJmPYbkwzzOU7EJCX/FRQBpyUc7THiifhnd3id0aY5Z1Q+dd2EbR5hdKv74z1nJSslYBs3pMKfiFsFHdAmzvl2cz/3+gQgtFtB6jhFDKrY27YtPdtqTlhjbOG6NNdZq53qsGPYMmNWsBtSgEZSAGpRRoJVhTivbcQW24l2fK2RFMMddLBumK5CubhzVtZNUMe5o5zpHXd9YxVhTC96/87G4GTLOuoYpBEkN9kCV4ZYaJSUFWdla6L6uRca9WXJOn7NsMODj6a+cGAA0CJpToXhFlTGcNqaqaVc3nel0g5XLnK8xBhz+MuqLd3Gcxj4V5+Up3ll3O4f834tlkZ58oM9LRleju8dY5IytD3m9z7Eej8fjbwAAAP//AwBleZTlNgQAAA==

View File

@ -1,13 +1,50 @@
using Microsoft.AspNetCore.Mvc;
using PlotLine.Models;
using PlotLine.Services;
using PlotLine.ViewModels;
namespace PlotLine.Controllers;
public sealed class TimelineController(ITimelineService timeline) : Controller
{
public async Task<IActionResult> Index(int projectId, int? bookId, int? selectedSceneId)
public async Task<IActionResult> Index([FromQuery] TimelineFilterViewModel filter)
{
var model = await timeline.GetAsync(projectId, bookId, selectedSceneId);
if (filter.ProjectID == 0)
{
return BadRequest("ProjectID is required.");
}
var model = await timeline.GetAsync(filter);
return model is null ? NotFound() : View(model);
}
[HttpPost]
public async Task<IActionResult> SavePreset(TimelinePresetSaveViewModel model)
{
await timeline.SavePresetAsync(model);
return RedirectToAction(nameof(Index), new { projectId = model.ProjectID, bookId = model.BookID });
}
public async Task<IActionResult> LoadPreset(int id)
{
var preset = await timeline.GetPresetAsync(id);
if (preset is null)
{
return NotFound();
}
var filter = System.Text.Json.JsonSerializer.Deserialize<TimelineFilterViewModel>(preset.FilterJson) ?? new TimelineFilterViewModel();
filter.ProjectID = preset.ProjectID;
filter.BookID = preset.BookID;
filter.FocusType = preset.FocusType;
filter.FocusID = preset.FocusID;
return RedirectToAction(nameof(Index), filter);
}
[HttpPost]
public async Task<IActionResult> DeletePreset(int id, int projectId, int? bookId)
{
await timeline.ArchivePresetAsync(id);
return RedirectToAction(nameof(Index), new { projectId, bookId });
}
}

View File

@ -53,6 +53,14 @@ public interface ITimelineRepository
Task<TimelineData> GetByProjectAsync(int projectId, int? bookId);
}
public interface ITimelinePresetRepository
{
Task<IReadOnlyList<TimelineViewPreset>> ListAsync(int projectId, int? bookId);
Task<TimelineViewPreset?> GetAsync(int timelineViewPresetId);
Task<int> SaveAsync(TimelineViewPreset preset);
Task ArchiveAsync(int timelineViewPresetId);
}
public interface IPlotRepository
{
Task<PlotLookupData> GetLookupsAsync();
@ -803,6 +811,56 @@ public sealed class TimelineRepository(ISqlConnectionFactory connectionFactory)
}
}
public sealed class TimelinePresetRepository(ISqlConnectionFactory connectionFactory) : ITimelinePresetRepository
{
public async Task<IReadOnlyList<TimelineViewPreset>> ListAsync(int projectId, int? bookId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<TimelineViewPreset>(
"dbo.TimelineViewPreset_List",
new { ProjectID = projectId, BookID = bookId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<TimelineViewPreset?> GetAsync(int timelineViewPresetId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<TimelineViewPreset>(
"dbo.TimelineViewPreset_Get",
new { TimelineViewPresetID = timelineViewPresetId },
commandType: CommandType.StoredProcedure);
}
public async Task<int> SaveAsync(TimelineViewPreset preset)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.TimelineViewPreset_Save",
new
{
preset.TimelineViewPresetID,
preset.ProjectID,
preset.BookID,
preset.PresetName,
preset.FilterJson,
preset.VisibilityJson,
preset.FocusType,
preset.FocusID
},
commandType: CommandType.StoredProcedure);
}
public async Task ArchiveAsync(int timelineViewPresetId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.TimelineViewPreset_Archive",
new { TimelineViewPresetID = timelineViewPresetId },
commandType: CommandType.StoredProcedure);
}
}
public sealed class PlotRepository(ISqlConnectionFactory connectionFactory) : IPlotRepository
{
public async Task<PlotLookupData> GetLookupsAsync()

View File

@ -168,6 +168,21 @@ public sealed class TimelineMetricPoint
public string? Notes { get; set; }
}
public sealed class TimelineViewPreset
{
public int TimelineViewPresetID { get; set; }
public int ProjectID { get; set; }
public int? BookID { get; set; }
public string PresetName { get; set; } = string.Empty;
public string FilterJson { get; set; } = "{}";
public string VisibilityJson { get; set; } = "{}";
public string? FocusType { get; set; }
public int? FocusID { get; set; }
public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; }
public bool IsArchived { get; set; }
}
public sealed class PlotLineType
{
public int PlotLineTypeID { get; set; }

View File

@ -24,6 +24,7 @@ public class Program
builder.Services.AddScoped<ISceneRepository, SceneRepository>();
builder.Services.AddScoped<ILookupRepository, LookupRepository>();
builder.Services.AddScoped<ITimelineRepository, TimelineRepository>();
builder.Services.AddScoped<ITimelinePresetRepository, TimelinePresetRepository>();
builder.Services.AddScoped<IPlotRepository, PlotRepository>();
builder.Services.AddScoped<IAssetRepository, AssetRepository>();
builder.Services.AddScoped<ICharacterRepository, CharacterRepository>();

View File

@ -127,7 +127,10 @@ public interface ICharacterService
public interface ITimelineService
{
Task<TimelineViewModel?> GetAsync(int projectId, int? bookId, int? selectedSceneId);
Task<TimelineViewModel?> GetAsync(TimelineFilterViewModel filter);
Task<int> SavePresetAsync(TimelinePresetSaveViewModel model);
Task<TimelineViewPreset?> GetPresetAsync(int timelineViewPresetId);
Task ArchivePresetAsync(int timelineViewPresetId);
}
public interface IAnalyticsService
@ -738,12 +741,26 @@ public sealed class SceneService(
}
}
public sealed class TimelineService(ITimelineRepository timelineRepository, IBookRepository books, ISceneService scenes, IAssetRepository assets, ICharacterRepository characters, IWarningRepository warnings, ISceneDependencyRepository sceneDependencies) : ITimelineService
public sealed class TimelineService(
ITimelineRepository timelineRepository,
ITimelinePresetRepository presets,
IBookRepository books,
ISceneService scenes,
IAssetRepository assets,
ICharacterRepository characters,
ILocationRepository locations,
IPlotRepository plots,
ILookupRepository lookups,
IWarningRepository warnings,
ISceneDependencyRepository sceneDependencies) : ITimelineService
{
private static readonly string[] DefaultGraphMetrics = ["Overall Intensity", "Tension", "Emotional Weight", "Action"];
public async Task<TimelineViewModel?> GetAsync(int projectId, int? bookId, int? selectedSceneId)
public async Task<TimelineViewModel?> GetAsync(TimelineFilterViewModel filter)
{
var projectId = filter.ProjectID;
var bookId = filter.BookID;
var selectedSceneId = filter.SelectedSceneID;
var timeline = await timelineRepository.GetByProjectAsync(projectId, bookId);
if (timeline.Project is null)
{
@ -751,6 +768,13 @@ public sealed class TimelineService(ITimelineRepository timelineRepository, IBoo
}
var allBooks = await books.ListByProjectAsync(projectId);
var lookupData = await lookups.GetAllAsync();
var plotLines = await plots.ListPlotLinesAsync(projectId);
var plotThreads = await plots.ListPlotThreadsByProjectAsync(projectId);
var allAssets = await assets.ListAssetsAsync(projectId);
var allCharacters = await characters.ListCharactersAsync(projectId);
var allLocations = await locations.ListByProjectAsync(projectId);
var warningLookups = await warnings.GetLookupsAsync();
var chaptersByBook = timeline.Chapters.GroupBy(x => x.BookID).ToDictionary(x => x.Key, x => x.ToList());
var scenesByChapter = timeline.Scenes.GroupBy(x => x.ChapterID).ToDictionary(x => x.Key, x => x.ToList());
var purposeLabelsByScene = timeline.PurposeLabels.GroupBy(x => x.SceneID).ToDictionary(x => x.Key, x => x.OrderBy(p => p.SortOrder).ToList());
@ -767,6 +791,9 @@ public sealed class TimelineService(ITimelineRepository timelineRepository, IBoo
var assetTimeline = await assets.GetTimelineAsync(projectId, bookId);
var characterTimeline = await characters.GetTimelineAsync(projectId, bookId);
var matchingSceneIds = await BuildMatchingSceneIdsAsync(filter, orderedScenes, timeline.ThreadEvents, assetTimeline.Events, characterTimeline.Appearances, projectId, bookId);
var focusSceneIds = await BuildFocusSceneIdsAsync(filter, orderedScenes, timeline.ThreadEvents, assetTimeline.Events, characterTimeline.Appearances, projectId, bookId);
var focusLabel = BuildFocusLabel(filter, plotLines, plotThreads, allAssets, allCharacters, allLocations);
var selectedScene = selectedSceneId.HasValue ? await scenes.GetEditAsync(selectedSceneId.Value) : null;
if (selectedScene is not null)
{
@ -787,7 +814,25 @@ public sealed class TimelineService(ITimelineRepository timelineRepository, IBoo
SelectedBookID = bookId,
SelectedSceneID = selectedScene?.SceneID,
SelectedScene = selectedScene,
Filter = filter,
BookOptions = allBooks.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString(), x.BookID == bookId)).ToList(),
ChapterOptions = timeline.Chapters.Select(x => new SelectListItem($"Ch {x.ChapterNumber}: {x.ChapterTitle}", x.ChapterID.ToString())).ToList(),
ChapterFromOptions = timeline.Chapters.Select(x => new SelectListItem($"Ch {x.ChapterNumber}: {x.ChapterTitle}", x.ChapterID.ToString(), x.ChapterID == filter.ChapterFromID)).ToList(),
ChapterToOptions = timeline.Chapters.Select(x => new SelectListItem($"Ch {x.ChapterNumber}: {x.ChapterTitle}", x.ChapterID.ToString(), x.ChapterID == filter.ChapterToID)).ToList(),
SceneOptions = orderedScenes.Select(x => new SelectListItem($"Scene {x.SceneNumber}: {x.SceneTitle}", x.SceneID.ToString())).ToList(),
SceneFromOptions = orderedScenes.Select(x => new SelectListItem($"Scene {x.SceneNumber}: {x.SceneTitle}", x.SceneID.ToString(), x.SceneID == filter.SceneFromID)).ToList(),
SceneToOptions = orderedScenes.Select(x => new SelectListItem($"Scene {x.SceneNumber}: {x.SceneTitle}", x.SceneID.ToString(), x.SceneID == filter.SceneToID)).ToList(),
PlotLineOptions = plotLines.Select(x => new SelectListItem(x.PlotLineName, x.PlotLineID.ToString(), x.PlotLineID == filter.PlotLineID)).ToList(),
PlotThreadOptions = plotThreads.Select(x => new SelectListItem(x.ThreadTitle, x.PlotThreadID.ToString(), x.PlotThreadID == filter.PlotThreadID)).ToList(),
AssetOptions = allAssets.Select(x => new SelectListItem(x.AssetName, x.StoryAssetID.ToString(), x.StoryAssetID == filter.StoryAssetID)).ToList(),
CharacterOptions = allCharacters.Select(x => new SelectListItem(x.CharacterName, x.CharacterID.ToString(), x.CharacterID == filter.CharacterID)).ToList(),
PovCharacterOptions = allCharacters.Select(x => new SelectListItem(x.CharacterName, x.CharacterID.ToString(), x.CharacterID == filter.POVCharacterID)).ToList(),
LocationOptions = allLocations.Select(x => new SelectListItem(x.LocationPath, x.LocationID.ToString(), x.LocationID == filter.LocationID)).ToList(),
WarningSeverityOptions = warningLookups.Severities.Select(x => new SelectListItem(x.SeverityName, x.WarningSeverityID.ToString(), x.WarningSeverityID == filter.WarningSeverityID)).ToList(),
WarningTypeOptions = warningLookups.WarningTypes.Select(x => new SelectListItem(x.TypeName, x.WarningTypeID.ToString(), x.WarningTypeID == filter.WarningTypeID)).ToList(),
RevisionStatusOptions = lookupData.RevisionStatuses.Select(x => new SelectListItem(x.StatusName, x.RevisionStatusID.ToString(), x.RevisionStatusID == filter.RevisionStatusID)).ToList(),
ScenePurposeOptions = lookupData.ScenePurposeTypes.Select(x => new SelectListItem(x.PurposeName, x.ScenePurposeTypeID.ToString(), x.ScenePurposeTypeID == filter.ScenePurposeTypeID)).ToList(),
ViewPresets = await presets.ListAsync(projectId, bookId),
Books = timeline.Books.Select(book => new BookTimelineViewModel
{
Book = book,
@ -803,10 +848,137 @@ public sealed class TimelineService(ITimelineRepository timelineRepository, IBoo
MetricGraphs = BuildMetricGraphs(timeline, orderedScenes),
PlotLanes = BuildPlotLanes(timeline, orderedScenes),
AssetLanes = BuildAssetLanes(assetTimeline.Assets, assetTimeline.Events, orderedScenes),
CharacterLanes = BuildCharacterLanes(characterTimeline.Characters, characterTimeline.Appearances, orderedScenes)
CharacterLanes = BuildCharacterLanes(characterTimeline.Characters, characterTimeline.Appearances, orderedScenes),
MatchingSceneIDs = matchingSceneIds,
FocusSceneIDs = focusSceneIds,
FocusLabel = focusLabel
};
}
public Task<TimelineViewPreset?> GetPresetAsync(int timelineViewPresetId) => presets.GetAsync(timelineViewPresetId);
public Task ArchivePresetAsync(int timelineViewPresetId) => presets.ArchiveAsync(timelineViewPresetId);
public Task<int> SavePresetAsync(TimelinePresetSaveViewModel model)
{
var filter = model.Filter;
filter.ProjectID = model.ProjectID;
filter.BookID = model.BookID;
return presets.SaveAsync(new TimelineViewPreset
{
ProjectID = model.ProjectID,
BookID = model.BookID,
PresetName = string.IsNullOrWhiteSpace(model.PresetName) ? "Untitled view" : model.PresetName.Trim(),
FilterJson = System.Text.Json.JsonSerializer.Serialize(filter),
VisibilityJson = string.IsNullOrWhiteSpace(model.VisibilityJson) ? "{}" : model.VisibilityJson,
FocusType = filter.FocusType,
FocusID = filter.FocusID
});
}
private async Task<IReadOnlySet<int>> BuildMatchingSceneIdsAsync(
TimelineFilterViewModel filter,
IReadOnlyList<Scene> orderedScenes,
IReadOnlyList<ThreadEvent> threadEvents,
IReadOnlyList<AssetEvent> assetEvents,
IReadOnlyList<SceneCharacter> characterAppearances,
int projectId,
int? bookId)
{
var matches = orderedScenes.Select(x => x.SceneID).ToHashSet();
void Intersect(IEnumerable<int> sceneIds) => matches.IntersectWith(sceneIds.ToHashSet());
if (filter.ChapterFromID.HasValue)
{
var fromIndex = orderedScenes.Select((scene, index) => new { scene, index }).FirstOrDefault(x => x.scene.ChapterID == filter.ChapterFromID)?.index;
if (fromIndex.HasValue) Intersect(orderedScenes.Skip(fromIndex.Value).Select(x => x.SceneID));
}
if (filter.ChapterToID.HasValue)
{
var toIndex = orderedScenes.Select((scene, index) => new { scene, index }).LastOrDefault(x => x.scene.ChapterID == filter.ChapterToID)?.index;
if (toIndex.HasValue) Intersect(orderedScenes.Take(toIndex.Value + 1).Select(x => x.SceneID));
}
if (filter.SceneFromID.HasValue)
{
var fromIndex = orderedScenes.Select((scene, index) => new { scene, index }).FirstOrDefault(x => x.scene.SceneID == filter.SceneFromID)?.index;
if (fromIndex.HasValue) Intersect(orderedScenes.Skip(fromIndex.Value).Select(x => x.SceneID));
}
if (filter.SceneToID.HasValue)
{
var toIndex = orderedScenes.Select((scene, index) => new { scene, index }).FirstOrDefault(x => x.scene.SceneID == filter.SceneToID)?.index;
if (toIndex.HasValue) Intersect(orderedScenes.Take(toIndex.Value + 1).Select(x => x.SceneID));
}
if (filter.RevisionStatusID.HasValue) Intersect(orderedScenes.Where(x => x.RevisionStatusID == filter.RevisionStatusID).Select(x => x.SceneID));
if (filter.ScenePurposeTypeID.HasValue) Intersect(orderedScenes.Where(x => x.PurposeLabels.Any(p => p.ScenePurposeTypeID == filter.ScenePurposeTypeID)).Select(x => x.SceneID));
if (filter.POVCharacterID.HasValue) Intersect(orderedScenes.Where(x => x.POVCharacterID == filter.POVCharacterID).Select(x => x.SceneID));
if (filter.PlotLineID.HasValue) Intersect(threadEvents.Where(x => x.PlotLineID == filter.PlotLineID).Select(x => x.SceneID));
if (filter.PlotThreadID.HasValue) Intersect(threadEvents.Where(x => x.PlotThreadID == filter.PlotThreadID).Select(x => x.SceneID));
if (filter.StoryAssetID.HasValue) Intersect(assetEvents.Where(x => x.StoryAssetID == filter.StoryAssetID).Select(x => x.SceneID));
if (filter.CharacterID.HasValue) Intersect(characterAppearances.Where(x => x.CharacterID == filter.CharacterID).Select(x => x.SceneID));
if (filter.LocationID.HasValue)
{
var locationScenes = (await locations.ListScenesByLocationAsync(filter.LocationID.Value)).Select(x => x.SceneID);
var characterLocationScenes = (await locations.ListCharactersByLocationAsync(filter.LocationID.Value)).Select(x => x.SceneID);
var assetLocationScenes = (await locations.ListAssetsByLocationAsync(filter.LocationID.Value)).Select(x => x.SceneID);
Intersect(locationScenes.Concat(characterLocationScenes).Concat(assetLocationScenes));
}
if (filter.WarningSeverityID.HasValue || filter.WarningTypeID.HasValue || filter.ShowWarningsOnly)
{
var warningScenes = (await warnings.ListAsync(projectId, bookId, null, filter.WarningTypeID, filter.WarningSeverityID, null, filter.IncludeDismissedWarnings, filter.IncludeIntentionalWarnings))
.Where(x => x.SceneID.HasValue)
.Select(x => x.SceneID!.Value);
Intersect(warningScenes);
}
return matches;
}
private async Task<IReadOnlySet<int>> BuildFocusSceneIdsAsync(
TimelineFilterViewModel filter,
IReadOnlyList<Scene> orderedScenes,
IReadOnlyList<ThreadEvent> threadEvents,
IReadOnlyList<AssetEvent> assetEvents,
IReadOnlyList<SceneCharacter> characterAppearances,
int projectId,
int? bookId)
{
if (string.IsNullOrWhiteSpace(filter.FocusType) || !filter.FocusID.HasValue)
{
return new HashSet<int>();
}
var focusType = filter.FocusType.ToLowerInvariant();
return focusType switch
{
"character" => characterAppearances.Where(x => x.CharacterID == filter.FocusID).Select(x => x.SceneID).ToHashSet(),
"asset" => assetEvents.Where(x => x.StoryAssetID == filter.FocusID).Select(x => x.SceneID).ToHashSet(),
"plotline" => threadEvents.Where(x => x.PlotLineID == filter.FocusID).Select(x => x.SceneID).ToHashSet(),
"plotthread" => threadEvents.Where(x => x.PlotThreadID == filter.FocusID).Select(x => x.SceneID).ToHashSet(),
"location" => (await locations.ListScenesByLocationAsync(filter.FocusID.Value)).Select(x => x.SceneID)
.Concat((await locations.ListCharactersByLocationAsync(filter.FocusID.Value)).Select(x => x.SceneID))
.Concat((await locations.ListAssetsByLocationAsync(filter.FocusID.Value)).Select(x => x.SceneID))
.ToHashSet(),
"scene" => orderedScenes.Where(x => x.SceneID == filter.FocusID).Select(x => x.SceneID).ToHashSet(),
_ => new HashSet<int>()
};
}
private static string? BuildFocusLabel(TimelineFilterViewModel filter, IReadOnlyList<PlotLineItem> plotLines, IReadOnlyList<PlotThread> plotThreads, IReadOnlyList<StoryAsset> assets, IReadOnlyList<Character> characters, IReadOnlyList<LocationItem> locations)
{
if (string.IsNullOrWhiteSpace(filter.FocusType) || !filter.FocusID.HasValue) return null;
var name = filter.FocusType.ToLowerInvariant() switch
{
"character" => characters.FirstOrDefault(x => x.CharacterID == filter.FocusID)?.CharacterName,
"asset" => assets.FirstOrDefault(x => x.StoryAssetID == filter.FocusID)?.AssetName,
"plotline" => plotLines.FirstOrDefault(x => x.PlotLineID == filter.FocusID)?.PlotLineName,
"plotthread" => plotThreads.FirstOrDefault(x => x.PlotThreadID == filter.FocusID)?.ThreadTitle,
"location" => locations.FirstOrDefault(x => x.LocationID == filter.FocusID)?.LocationPath,
"scene" => "Selected scene",
_ => null
};
return string.IsNullOrWhiteSpace(name) ? null : $"{filter.FocusType}: {name}";
}
private static IReadOnlyList<MetricGraphViewModel> BuildMetricGraphs(TimelineData timeline, IReadOnlyList<Scene> orderedScenes)
{
var pointsByMetric = timeline.MetricPoints

View File

@ -0,0 +1,109 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.TimelineViewPresets', N'U') IS NULL
BEGIN
CREATE TABLE dbo.TimelineViewPresets
(
TimelineViewPresetID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_TimelineViewPresets PRIMARY KEY,
ProjectID int NOT NULL,
BookID int NULL,
PresetName nvarchar(200) NOT NULL,
FilterJson nvarchar(max) NOT NULL,
VisibilityJson nvarchar(max) NOT NULL,
FocusType nvarchar(50) NULL,
FocusID int NULL,
CreatedDate datetime2 NOT NULL CONSTRAINT DF_TimelineViewPresets_CreatedDate DEFAULT SYSUTCDATETIME(),
UpdatedDate datetime2 NOT NULL CONSTRAINT DF_TimelineViewPresets_UpdatedDate DEFAULT SYSUTCDATETIME(),
IsArchived bit NOT NULL CONSTRAINT DF_TimelineViewPresets_IsArchived DEFAULT 0,
CONSTRAINT FK_TimelineViewPresets_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID),
CONSTRAINT FK_TimelineViewPresets_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID)
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_TimelineViewPresets_ProjectBook' AND object_id = OBJECT_ID(N'dbo.TimelineViewPresets'))
CREATE INDEX IX_TimelineViewPresets_ProjectBook ON dbo.TimelineViewPresets(ProjectID, BookID, IsArchived, PresetName);
GO
CREATE OR ALTER PROCEDURE dbo.TimelineViewPreset_List
@ProjectID int,
@BookID int = NULL
AS
BEGIN
SET NOCOUNT ON;
SELECT TimelineViewPresetID, ProjectID, BookID, PresetName, FilterJson, VisibilityJson, FocusType, FocusID,
CreatedDate, UpdatedDate, IsArchived
FROM dbo.TimelineViewPresets
WHERE ProjectID = @ProjectID
AND IsArchived = 0
AND (@BookID IS NULL OR BookID IS NULL OR BookID = @BookID)
ORDER BY PresetName;
END;
GO
CREATE OR ALTER PROCEDURE dbo.TimelineViewPreset_Get
@TimelineViewPresetID int
AS
BEGIN
SET NOCOUNT ON;
SELECT TimelineViewPresetID, ProjectID, BookID, PresetName, FilterJson, VisibilityJson, FocusType, FocusID,
CreatedDate, UpdatedDate, IsArchived
FROM dbo.TimelineViewPresets
WHERE TimelineViewPresetID = @TimelineViewPresetID AND IsArchived = 0;
END;
GO
CREATE OR ALTER PROCEDURE dbo.TimelineViewPreset_Save
@TimelineViewPresetID int = 0,
@ProjectID int,
@BookID int = NULL,
@PresetName nvarchar(200),
@FilterJson nvarchar(max),
@VisibilityJson nvarchar(max),
@FocusType nvarchar(50) = NULL,
@FocusID int = NULL
AS
BEGIN
SET NOCOUNT ON;
IF @TimelineViewPresetID IS NULL OR @TimelineViewPresetID = 0
BEGIN
INSERT dbo.TimelineViewPresets (ProjectID, BookID, PresetName, FilterJson, VisibilityJson, FocusType, FocusID)
VALUES (@ProjectID, @BookID, @PresetName, @FilterJson, @VisibilityJson, @FocusType, @FocusID);
SELECT CAST(SCOPE_IDENTITY() AS int);
RETURN;
END;
UPDATE dbo.TimelineViewPresets
SET ProjectID = @ProjectID,
BookID = @BookID,
PresetName = @PresetName,
FilterJson = @FilterJson,
VisibilityJson = @VisibilityJson,
FocusType = @FocusType,
FocusID = @FocusID,
UpdatedDate = SYSUTCDATETIME()
WHERE TimelineViewPresetID = @TimelineViewPresetID;
SELECT @TimelineViewPresetID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.TimelineViewPreset_Archive
@TimelineViewPresetID int
AS
BEGIN
SET NOCOUNT ON;
UPDATE dbo.TimelineViewPresets
SET IsArchived = 1,
UpdatedDate = SYSUTCDATETIME()
WHERE TimelineViewPresetID = @TimelineViewPresetID;
END;
GO

View File

@ -191,16 +191,79 @@ public sealed class TimelineViewModel
public int? SelectedBookID { get; set; }
public int? SelectedSceneID { get; set; }
public SceneEditViewModel? SelectedScene { get; set; }
public TimelineFilterViewModel Filter { get; set; } = new();
public IReadOnlyList<BookTimelineViewModel> Books { get; set; } = [];
public IReadOnlyList<SelectListItem> BookOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> ChapterOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> ChapterFromOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> ChapterToOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> SceneOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> SceneFromOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> SceneToOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> PlotLineOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> PlotThreadOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> AssetOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> CharacterOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> PovCharacterOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> LocationOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> WarningSeverityOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> WarningTypeOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> RevisionStatusOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> ScenePurposeOptions { get; set; } = [];
public IReadOnlyList<TimelineViewPreset> ViewPresets { get; set; } = [];
public IReadOnlyList<Scene> OrderedScenes { get; set; } = [];
public IReadOnlyList<MetricGraphViewModel> MetricGraphs { get; set; } = [];
public IReadOnlyList<PlotLineLaneViewModel> PlotLanes { get; set; } = [];
public IReadOnlyList<AssetLaneViewModel> AssetLanes { get; set; } = [];
public IReadOnlyList<CharacterLaneViewModel> CharacterLanes { get; set; } = [];
public IReadOnlySet<int> MatchingSceneIDs { get; set; } = new HashSet<int>();
public IReadOnlySet<int> FocusSceneIDs { get; set; } = new HashSet<int>();
public string? FocusLabel { get; set; }
public ContinuityValidationSummary? LastValidationSummary { get; set; }
}
public sealed class TimelineFilterViewModel
{
public int ProjectID { get; set; }
public int? BookID { get; set; }
public int? SelectedSceneID { get; set; }
public int? ChapterFromID { get; set; }
public int? ChapterToID { get; set; }
public int? SceneFromID { get; set; }
public int? SceneToID { get; set; }
public int? PlotLineID { get; set; }
public int? PlotThreadID { get; set; }
public int? StoryAssetID { get; set; }
public int? CharacterID { get; set; }
public int? LocationID { get; set; }
public int? WarningSeverityID { get; set; }
public int? WarningTypeID { get; set; }
public int? RevisionStatusID { get; set; }
public int? ScenePurposeTypeID { get; set; }
public int? POVCharacterID { get; set; }
public string? FocusType { get; set; }
public int? FocusID { get; set; }
public bool ShowSceneCards { get; set; } = true;
public bool ShowPlotLines { get; set; } = true;
public bool ShowAssets { get; set; } = true;
public bool ShowCharacters { get; set; } = true;
public bool ShowLocations { get; set; } = true;
public bool ShowWarnings { get; set; } = true;
public bool ShowMetrics { get; set; } = true;
public bool ShowWarningsOnly { get; set; }
public bool IncludeDismissedWarnings { get; set; }
public bool IncludeIntentionalWarnings { get; set; }
}
public sealed class TimelinePresetSaveViewModel
{
public int ProjectID { get; set; }
public int? BookID { get; set; }
public string PresetName { get; set; } = string.Empty;
public TimelineFilterViewModel Filter { get; set; } = new();
public string VisibilityJson { get; set; } = "{}";
}
public sealed class BookTimelineViewModel
{
public Book Book { get; set; } = new();

View File

@ -17,7 +17,7 @@
sceneNumber = point.SceneNumber.ToString("g"),
sceneTitle = point.SceneTitle,
bookTitle = point.BookTitle,
timelineUrl = Url.Action("Index", "Timeline", new { projectId = Model.Project.ProjectID, bookId = point.BookID, selectedSceneId = point.SceneID })
timelineUrl = Url.Action("Index", "Timeline", new { ProjectID = Model.Project.ProjectID, BookID = point.BookID, SelectedSceneID = point.SceneID, FocusType = "scene", FocusID = point.SceneID })
})
.ToList();
var metricShapePayload = new
@ -132,9 +132,11 @@
title="@graph.MetricName - Scene @point.SceneNumber: @point.SceneTitle (@point.Value)"
asp-controller="Timeline"
asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-bookId="@point.BookID"
asp-route-selectedSceneId="@point.SceneID">
asp-route-ProjectID="@Model.Project.ProjectID"
asp-route-BookID="@point.BookID"
asp-route-SelectedSceneID="@point.SceneID"
asp-route-FocusType="scene"
asp-route-FocusID="@point.SceneID">
<span>@point.Value</span>
</a>
}
@ -179,7 +181,7 @@
<h3>Needs attention</h3>
@foreach (var scene in Model.RevisionAttentionScenes.Take(8))
{
<a class="analytics-link-row" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@scene.BookID" asp-route-selectedSceneId="@scene.SceneID">
<a class="analytics-link-row" asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-BookID="@scene.BookID" asp-route-SelectedSceneID="@scene.SceneID" asp-route-FocusType="scene" asp-route-FocusID="@scene.SceneID">
<span>@scene.RevisionStatusName</span>
<strong>Ch @scene.ChapterNumber / Scene @scene.SceneNumber: @scene.SceneTitle</strong>
</a>
@ -201,7 +203,7 @@
<h3>Missing purpose/outcome</h3>
@foreach (var gap in Model.SceneGaps.Take(8))
{
<a class="analytics-link-row" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@gap.BookID" asp-route-selectedSceneId="@gap.SceneID">
<a class="analytics-link-row" asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-BookID="@gap.BookID" asp-route-SelectedSceneID="@gap.SceneID" asp-route-FocusType="scene" asp-route-FocusID="@gap.SceneID">
<span>@(gap.MissingPurpose ? "No purpose" : "") @(gap.MissingOutcome ? "No outcome" : "")</span>
<strong>Ch @gap.ChapterNumber / Scene @gap.SceneNumber: @gap.SceneTitle</strong>
</a>

View File

@ -17,7 +17,7 @@
<strong>@Model.Project.ProjectName</strong>
</div>
<nav class="context-nav" aria-label="Project navigation">
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@firstAppearance?.SceneID">Show on timeline</a>
<a asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-SelectedSceneID="@firstAppearance?.SceneID" asp-route-FocusType="character" asp-route-FocusID="@Model.Character.CharacterID">Show on timeline</a>
<a asp-controller="Analytics" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story health</a>
<a asp-controller="Warnings" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Warnings</a>
<a asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Characters</a>
@ -32,7 +32,7 @@
</div>
<div class="button-row">
<a class="btn btn-outline-secondary" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Characters</a>
<a class="btn btn-outline-secondary" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@firstAppearance?.SceneID">Back to timeline</a>
<a class="btn btn-outline-secondary" asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-SelectedSceneID="@firstAppearance?.SceneID" asp-route-FocusType="character" asp-route-FocusID="@Model.Character.CharacterID">Back to timeline</a>
<a class="btn btn-outline-primary" asp-action="Edit" asp-route-id="@Model.Character.CharacterID">Edit character</a>
</div>
</div>
@ -82,7 +82,7 @@
<strong>@appearance.BookTitle / Ch @appearance.ChapterNumber / Scene @appearance.SceneNumber: @appearance.SceneTitle</strong>
<span>@appearance.RoleInSceneTypeName / @appearance.PresenceTypeName</span>
<span>@appearance.OutfitDescription @appearance.PhysicalCondition @appearance.EmotionalState</span>
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@appearance.SceneID">Open in timeline</a>
<a asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-SelectedSceneID="@appearance.SceneID" asp-route-FocusType="character" asp-route-FocusID="@Model.Character.CharacterID">Open in timeline</a>
</div>
}
</div>
@ -104,7 +104,7 @@
<strong>@item.AttributeName: @item.AttributeValue</strong>
<span>@item.BookTitle / Ch @item.ChapterNumber / Scene @item.SceneNumber: @item.SceneTitle</span>
<span>@item.Description</span>
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@item.SceneID">Open in timeline</a>
<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>
@ -126,7 +126,7 @@
<strong>@item.KnowledgeStateName: @(item.AssetName ?? item.ThreadTitle)</strong>
<span>@item.BookTitle / Ch @item.ChapterNumber / Scene @item.SceneNumber: @item.SceneTitle</span>
<span>@item.Description</span>
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@item.SceneID">Open in timeline</a>
<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>
@ -148,7 +148,7 @@
<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">Open in timeline</a>
<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>

View File

@ -17,7 +17,7 @@
<strong>@Model.Project.ProjectName</strong>
</div>
<nav class="context-nav" aria-label="Project navigation">
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@firstScene?.SceneID">Show scene usage</a>
<a asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-SelectedSceneID="@firstScene?.SceneID" asp-route-FocusType="location" asp-route-FocusID="@Model.Location.LocationID">Show scene usage</a>
<a asp-controller="Analytics" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story health</a>
<a asp-controller="Warnings" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Warnings</a>
<a asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Locations</a>
@ -32,7 +32,7 @@
</div>
<div class="button-row">
<a class="btn btn-outline-secondary" asp-action="Edit" asp-route-id="@Model.Location.LocationID">Edit</a>
<a class="btn btn-outline-secondary" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@firstScene?.SceneID">Back to timeline</a>
<a class="btn btn-outline-secondary" asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-SelectedSceneID="@firstScene?.SceneID" asp-route-FocusType="location" asp-route-FocusID="@Model.Location.LocationID">Back to timeline</a>
<a class="btn btn-outline-primary" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">All locations</a>
</div>
</div>
@ -142,7 +142,7 @@
<h3>Scenes</h3>
@foreach (var scene in Model.Scenes)
{
<p><strong>Scene @scene.SceneNumber</strong> <a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@scene.SceneID">@scene.SceneTitle</a></p>
<p><strong>Scene @scene.SceneNumber</strong> <a asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-SelectedSceneID="@scene.SceneID" asp-route-FocusType="location" asp-route-FocusID="@Model.Location.LocationID">@scene.SceneTitle</a></p>
}
@if (!Model.Scenes.Any()) { <p class="muted">No scenes set here.</p> }
</div>

View File

@ -54,6 +54,7 @@
<td>@(plotLine.BookTitle ?? "Project-wide")</td>
<td>@(plotLine.IsVisibleOnTimeline ? "Visible" : "Hidden")</td>
<td class="text-end">
<a class="btn btn-outline-secondary btn-sm" asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-FocusType="plotline" asp-route-FocusID="@plotLine.PlotLineID">Focus</a>
<a class="btn btn-outline-secondary btn-sm" asp-controller="PlotThreads" asp-action="Create" asp-route-projectId="@Model.Project.ProjectID" asp-route-plotLineId="@plotLine.PlotLineID">Add thread</a>
<a class="btn btn-outline-secondary btn-sm" asp-action="Edit" asp-route-id="@plotLine.PlotLineID">Edit</a>
</td>

View File

@ -56,7 +56,10 @@
<td>@thread.ThreadTypeName</td>
<td><span class="status-pill">@thread.ThreadStatusName</span></td>
<td>@thread.Importance</td>
<td class="text-end"><a class="btn btn-outline-secondary btn-sm" asp-action="Edit" asp-route-id="@thread.PlotThreadID">Edit</a></td>
<td class="text-end">
<a class="btn btn-outline-secondary btn-sm" asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-FocusType="plotthread" asp-route-FocusID="@thread.PlotThreadID">Focus</a>
<a class="btn btn-outline-secondary btn-sm" asp-action="Edit" asp-route-id="@thread.PlotThreadID">Edit</a>
</td>
</tr>
}
</tbody>

View File

@ -19,7 +19,7 @@
<strong>@Model.Project.ProjectName</strong>
</div>
<nav class="context-nav" aria-label="Project navigation">
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@firstSceneId">Show on timeline</a>
<a asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-SelectedSceneID="@firstSceneId" asp-route-FocusType="asset" asp-route-FocusID="@Model.Asset.StoryAssetID">Show on timeline</a>
<a asp-controller="Analytics" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story health</a>
<a asp-controller="Warnings" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Warnings</a>
<a asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Assets</a>
@ -34,7 +34,7 @@
</div>
<div class="button-row">
<a class="btn btn-outline-secondary" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story assets</a>
<a class="btn btn-outline-secondary" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@firstSceneId">Back to timeline</a>
<a class="btn btn-outline-secondary" asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-SelectedSceneID="@firstSceneId" asp-route-FocusType="asset" asp-route-FocusID="@Model.Asset.StoryAssetID">Back to timeline</a>
<a class="btn btn-outline-primary" asp-action="Edit" asp-route-id="@Model.Asset.StoryAssetID">Edit asset</a>
</div>
</div>
@ -127,7 +127,7 @@
<strong>@assetEvent.EventTitle</strong>
<span>@assetEvent.BookTitle / Ch @assetEvent.ChapterNumber / Scene @assetEvent.SceneNumber: @assetEvent.SceneTitle</span>
<span>@assetEvent.FromStateName -> @assetEvent.ToStateName</span>
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@assetEvent.SceneID">Open in timeline</a>
<a asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-SelectedSceneID="@assetEvent.SceneID" asp-route-FocusType="asset" asp-route-FocusID="@Model.Asset.StoryAssetID">Open in timeline</a>
</div>
</div>
}
@ -150,7 +150,7 @@
<strong>@custody.CustodyEventTypeName</strong>
<span>@custody.CustodianSummary</span>
<span>@custody.BookTitle / Ch @custody.ChapterNumber / Scene @custody.SceneNumber: @custody.SceneTitle</span>
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@custody.SceneID">Open in timeline</a>
<a asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-SelectedSceneID="@custody.SceneID" asp-route-FocusType="asset" asp-route-FocusID="@Model.Asset.StoryAssetID">Open in timeline</a>
</div>
}
</div>

View File

@ -24,6 +24,7 @@
points = graph.Points.Select(point => new { sceneId = point.SceneID, value = point.Value })
})
};
var hasActiveFilter = Model.MatchingSceneIDs.Count != Model.OrderedScenes.Count || !string.IsNullOrWhiteSpace(Model.FocusLabel);
}
<section class="context-header">
@ -49,8 +50,8 @@
<h1>@Model.Project.ProjectName</h1>
</div>
<form method="get" class="timeline-filter">
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<select name="bookId" asp-items="Model.BookOptions" class="form-select">
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
<select name="BookID" asp-items="Model.BookOptions" class="form-select">
<option value="">All books</option>
</select>
<button class="btn btn-outline-primary" type="submit">View</button>
@ -74,6 +75,179 @@
</div>
</div>
<section class="timeline-control-panel" data-timeline-controls>
<details class="timeline-filter-drawer" @(hasActiveFilter ? "open" : "")>
<summary>Filters and focus</summary>
<form method="get" class="timeline-filter-grid">
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
<div>
<label class="form-label">Book</label>
<select name="BookID" asp-items="Model.BookOptions" class="form-select form-select-sm">
<option value="">All books</option>
</select>
</div>
<div>
<label class="form-label">From chapter</label>
<select name="ChapterFromID" asp-items="Model.ChapterFromOptions" class="form-select form-select-sm">
<option value="">Start</option>
</select>
</div>
<div>
<label class="form-label">To chapter</label>
<select name="ChapterToID" asp-items="Model.ChapterToOptions" class="form-select form-select-sm">
<option value="">End</option>
</select>
</div>
<div>
<label class="form-label">From scene</label>
<select name="SceneFromID" asp-items="Model.SceneFromOptions" class="form-select form-select-sm">
<option value="">Start</option>
</select>
</div>
<div>
<label class="form-label">To scene</label>
<select name="SceneToID" asp-items="Model.SceneToOptions" class="form-select form-select-sm">
<option value="">End</option>
</select>
</div>
<div>
<label class="form-label">Plot line</label>
<select name="PlotLineID" asp-items="Model.PlotLineOptions" class="form-select form-select-sm"><option value="">Any</option></select>
</div>
<div>
<label class="form-label">Plot thread</label>
<select name="PlotThreadID" asp-items="Model.PlotThreadOptions" class="form-select form-select-sm"><option value="">Any</option></select>
</div>
<div>
<label class="form-label">Story asset</label>
<select name="StoryAssetID" asp-items="Model.AssetOptions" class="form-select form-select-sm"><option value="">Any</option></select>
</div>
<div>
<label class="form-label">Character</label>
<select name="CharacterID" asp-items="Model.CharacterOptions" class="form-select form-select-sm"><option value="">Any</option></select>
</div>
<div>
<label class="form-label">Location</label>
<select name="LocationID" asp-items="Model.LocationOptions" class="form-select form-select-sm"><option value="">Any</option></select>
</div>
<div>
<label class="form-label">Warning severity</label>
<select name="WarningSeverityID" asp-items="Model.WarningSeverityOptions" class="form-select form-select-sm"><option value="">Any</option></select>
</div>
<div>
<label class="form-label">Warning type</label>
<select name="WarningTypeID" asp-items="Model.WarningTypeOptions" class="form-select form-select-sm"><option value="">Any</option></select>
</div>
<div>
<label class="form-label">Revision status</label>
<select name="RevisionStatusID" asp-items="Model.RevisionStatusOptions" class="form-select form-select-sm"><option value="">Any</option></select>
</div>
<div>
<label class="form-label">Scene purpose</label>
<select name="ScenePurposeTypeID" asp-items="Model.ScenePurposeOptions" class="form-select form-select-sm"><option value="">Any</option></select>
</div>
<div>
<label class="form-label">POV character</label>
<select name="POVCharacterID" asp-items="Model.PovCharacterOptions" class="form-select form-select-sm"><option value="">Any</option></select>
</div>
<div>
<label class="form-label">Focus type</label>
<select name="FocusType" class="form-select form-select-sm">
<option value="">No focus</option>
<option value="character" selected="@(Model.Filter.FocusType == "character")">Character</option>
<option value="asset" selected="@(Model.Filter.FocusType == "asset")">Story asset</option>
<option value="plotline" selected="@(Model.Filter.FocusType == "plotline")">Plot line</option>
<option value="plotthread" selected="@(Model.Filter.FocusType == "plotthread")">Plot thread</option>
<option value="location" selected="@(Model.Filter.FocusType == "location")">Location</option>
<option value="scene" selected="@(Model.Filter.FocusType == "scene")">Scene</option>
</select>
</div>
<div>
<label class="form-label">Focus ID</label>
<input name="FocusID" value="@Model.Filter.FocusID" class="form-control form-control-sm" placeholder="Entity ID" />
</div>
<div class="timeline-filter-checks">
<label><input type="checkbox" name="ShowWarningsOnly" value="true" checked="@Model.Filter.ShowWarningsOnly" /> Warnings only</label>
<label><input type="checkbox" name="IncludeDismissedWarnings" value="true" checked="@Model.Filter.IncludeDismissedWarnings" /> Include dismissed</label>
<label><input type="checkbox" name="IncludeIntentionalWarnings" value="true" checked="@Model.Filter.IncludeIntentionalWarnings" /> Include intentional</label>
</div>
<div class="timeline-filter-actions">
<button class="btn btn-outline-primary btn-sm" type="submit">Apply filters</button>
<a class="btn btn-outline-secondary btn-sm" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.SelectedBookID">Clear filters</a>
</div>
</form>
</details>
<div class="timeline-visibility-panel">
<span class="eyebrow">Visibility</span>
<label><input type="checkbox" data-timeline-toggle="scene-cards" checked /> Scene cards</label>
<label><input type="checkbox" data-timeline-toggle="plot-lines" checked /> Plot lines</label>
<label><input type="checkbox" data-timeline-toggle="assets" checked /> Story assets</label>
<label><input type="checkbox" data-timeline-toggle="characters" checked /> Characters</label>
<label><input type="checkbox" data-timeline-toggle="locations" checked /> Locations</label>
<label><input type="checkbox" data-timeline-toggle="warnings" checked /> Warnings</label>
<label><input type="checkbox" data-timeline-toggle="metrics" checked /> Metrics</label>
</div>
<div class="timeline-preset-panel">
<form asp-action="SavePreset" method="post" data-timeline-preset-form>
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
<input type="hidden" name="BookID" value="@Model.SelectedBookID" />
<input type="hidden" name="VisibilityJson" value="{}" data-visibility-json />
<input type="hidden" name="Filter.ProjectID" value="@Model.Project.ProjectID" />
<input type="hidden" name="Filter.BookID" value="@Model.Filter.BookID" />
<input type="hidden" name="Filter.SelectedSceneID" value="@Model.Filter.SelectedSceneID" />
<input type="hidden" name="Filter.ChapterFromID" value="@Model.Filter.ChapterFromID" />
<input type="hidden" name="Filter.ChapterToID" value="@Model.Filter.ChapterToID" />
<input type="hidden" name="Filter.SceneFromID" value="@Model.Filter.SceneFromID" />
<input type="hidden" name="Filter.SceneToID" value="@Model.Filter.SceneToID" />
<input type="hidden" name="Filter.PlotLineID" value="@Model.Filter.PlotLineID" />
<input type="hidden" name="Filter.PlotThreadID" value="@Model.Filter.PlotThreadID" />
<input type="hidden" name="Filter.StoryAssetID" value="@Model.Filter.StoryAssetID" />
<input type="hidden" name="Filter.CharacterID" value="@Model.Filter.CharacterID" />
<input type="hidden" name="Filter.LocationID" value="@Model.Filter.LocationID" />
<input type="hidden" name="Filter.WarningSeverityID" value="@Model.Filter.WarningSeverityID" />
<input type="hidden" name="Filter.WarningTypeID" value="@Model.Filter.WarningTypeID" />
<input type="hidden" name="Filter.RevisionStatusID" value="@Model.Filter.RevisionStatusID" />
<input type="hidden" name="Filter.ScenePurposeTypeID" value="@Model.Filter.ScenePurposeTypeID" />
<input type="hidden" name="Filter.POVCharacterID" value="@Model.Filter.POVCharacterID" />
<input type="hidden" name="Filter.FocusType" value="@Model.Filter.FocusType" />
<input type="hidden" name="Filter.FocusID" value="@Model.Filter.FocusID" />
<input type="hidden" name="Filter.ShowWarningsOnly" value="@Model.Filter.ShowWarningsOnly.ToString().ToLowerInvariant()" />
<input type="hidden" name="Filter.IncludeDismissedWarnings" value="@Model.Filter.IncludeDismissedWarnings.ToString().ToLowerInvariant()" />
<input type="hidden" name="Filter.IncludeIntentionalWarnings" value="@Model.Filter.IncludeIntentionalWarnings.ToString().ToLowerInvariant()" />
<input class="form-control form-control-sm" name="PresetName" placeholder="View name" />
<button class="btn btn-outline-primary btn-sm" type="submit">Save view</button>
</form>
@if (Model.ViewPresets.Any())
{
<div class="preset-chip-row">
@foreach (var preset in Model.ViewPresets)
{
<span class="preset-chip">
<a asp-action="LoadPreset" asp-route-id="@preset.TimelineViewPresetID">@preset.PresetName</a>
<form asp-action="DeletePreset" method="post">
<input type="hidden" name="id" value="@preset.TimelineViewPresetID" />
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<input type="hidden" name="bookId" value="@Model.SelectedBookID" />
<button type="submit" title="Delete view">x</button>
</form>
</span>
}
</div>
}
</div>
</section>
@if (!string.IsNullOrWhiteSpace(Model.FocusLabel))
{
<section class="timeline-focus-banner">
<strong>Focus: @Model.FocusLabel</strong>
<span>@Model.FocusSceneIDs.Count matching scene@(Model.FocusSceneIDs.Count == 1 ? "" : "s")</span>
<a class="btn btn-outline-secondary btn-sm" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.SelectedBookID">Clear focus</a>
</section>
}
@if (!Model.Books.Any())
{
<section class="empty-panel">
@ -83,9 +257,9 @@
}
else
{
<div class="workspace-layout">
<div class="workspace-layout timeline-filtered-root" data-timeline-root>
<main class="timeline-workspace">
<section class="timeline-shell workspace-timeline">
<section class="timeline-shell workspace-timeline" data-timeline-section="scene-cards">
@foreach (var book in Model.Books)
{
<div class="timeline-book">
@ -109,19 +283,24 @@ else
@foreach (var scene in chapter.Scenes)
{
var selectedClass = scene.SceneID == Model.SelectedSceneID ? "selected" : "";
<a class="timeline-scene-card @selectedClass"
var matchClass = Model.MatchingSceneIDs.Contains(scene.SceneID) ? "filter-match" : "filter-dim";
var focusClass = Model.FocusSceneIDs.Contains(scene.SceneID) ? "focus-match" : (Model.FocusSceneIDs.Any() ? "focus-dim" : "");
<a class="timeline-scene-card @selectedClass @matchClass @focusClass"
data-scene-id="@scene.SceneID"
asp-controller="Timeline"
asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-bookId="@Model.SelectedBookID"
asp-route-selectedSceneId="@scene.SceneID">
asp-route-ProjectID="@Model.Project.ProjectID"
asp-route-BookID="@Model.SelectedBookID"
asp-route-SelectedSceneID="@scene.SceneID"
asp-route-FocusType="scene"
asp-route-FocusID="@scene.SceneID">
<span class="scene-number">Scene @scene.SceneNumber</span>
<strong>@scene.SceneTitle</strong>
<div class="scene-badge-row">
<span class="status-pill">@scene.RevisionStatusName</span>
@if (scene.WarningCount > 0)
{
<span class="warning-placeholder active">@scene.WarningCount warning@(scene.WarningCount == 1 ? "" : "s")</span>
<span class="warning-placeholder active" data-warning-marker>@scene.WarningCount warning@(scene.WarningCount == 1 ? "" : "s")</span>
}
else
{
@ -135,7 +314,7 @@ else
<span class="time-label">@scene.TimeLabel</span>
@if (!string.IsNullOrWhiteSpace(scene.PrimaryLocationName))
{
<span class="location-pill" title="@scene.PrimaryLocationPath">@scene.PrimaryLocationName</span>
<span class="location-pill" data-location-marker title="@scene.PrimaryLocationPath">@scene.PrimaryLocationName</span>
}
@if (scene.PurposeLabels.Any())
{
@ -159,14 +338,15 @@ else
}
@if (Model.PlotLanes.Any() && Model.OrderedScenes.Any())
{
<div class="plot-lane-board" style="--scene-count:@Model.OrderedScenes.Count">
<div class="plot-lane-board" data-timeline-section="plot-lines" style="--scene-count:@Model.OrderedScenes.Count">
<div class="plot-lane-board-heading">
<span>Plot line lanes</span>
<small>Thread event markers by scene</small>
</div>
@foreach (var lane in Model.PlotLanes)
{
<div class="plot-lane-row" style="--plot-colour:@lane.PlotLine.Colour">
var laneFocusClass = Model.Filter.FocusType == "plotline" && Model.Filter.FocusID.HasValue && Model.Filter.FocusID != lane.PlotLine.PlotLineID ? "lane-dim" : "";
<div class="plot-lane-row @laneFocusClass" style="--plot-colour:@lane.PlotLine.Colour">
<a class="plot-lane-label" asp-controller="PlotLines" asp-action="Edit" asp-route-id="@lane.PlotLine.PlotLineID">
<span class="colour-swatch" style="background:@lane.PlotLine.Colour"></span>
<strong>@lane.PlotLine.PlotLineName</strong>
@ -185,9 +365,11 @@ else
<a class="thread-marker @markerClass"
asp-controller="Timeline"
asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-bookId="@Model.SelectedBookID"
asp-route-selectedSceneId="@slot.Scene.SceneID"
asp-route-ProjectID="@Model.Project.ProjectID"
asp-route-BookID="@Model.SelectedBookID"
asp-route-SelectedSceneID="@slot.Scene.SceneID"
asp-route-FocusType="plotthread"
asp-route-FocusID="@threadEvent.PlotThreadID"
title="@threadEvent.ThreadTitle: @threadEvent.EventTitle">
@threadEvent.MarkerText
</a>
@ -207,14 +389,15 @@ else
}
@if (Model.AssetLanes.Any() && Model.OrderedScenes.Any())
{
<div class="asset-lane-board" style="--scene-count:@Model.OrderedScenes.Count">
<div class="asset-lane-board" data-timeline-section="assets" style="--scene-count:@Model.OrderedScenes.Count">
<div class="plot-lane-board-heading">
<span>Story asset lanes</span>
<small>Asset event markers by scene</small>
</div>
@foreach (var lane in Model.AssetLanes)
{
<div class="asset-lane-row">
var assetFocusClass = Model.Filter.FocusType == "asset" && Model.Filter.FocusID.HasValue && Model.Filter.FocusID != lane.Asset.StoryAssetID ? "lane-dim" : "";
<div class="asset-lane-row @assetFocusClass">
<a class="asset-lane-label" asp-controller="StoryAssets" asp-action="Details" asp-route-id="@lane.Asset.StoryAssetID">
<strong>@lane.Asset.AssetName</strong>
<span>@lane.Asset.KindName / @lane.Asset.Importance</span>
@ -226,9 +409,13 @@ else
@foreach (var assetEvent in slot.Events)
{
<a class="asset-marker"
asp-controller="StoryAssets"
asp-action="Details"
asp-route-id="@assetEvent.StoryAssetID"
asp-controller="Timeline"
asp-action="Index"
asp-route-ProjectID="@Model.Project.ProjectID"
asp-route-BookID="@Model.SelectedBookID"
asp-route-SelectedSceneID="@slot.Scene.SceneID"
asp-route-FocusType="asset"
asp-route-FocusID="@assetEvent.StoryAssetID"
title="@assetEvent.AssetName: @assetEvent.EventTitle">
@assetEvent.MarkerText
</a>
@ -248,14 +435,15 @@ else
}
@if (Model.CharacterLanes.Any() && Model.OrderedScenes.Any())
{
<div class="character-lane-board" style="--scene-count:@Model.OrderedScenes.Count">
<div class="character-lane-board" data-timeline-section="characters" style="--scene-count:@Model.OrderedScenes.Count">
<div class="plot-lane-board-heading">
<span>Character appearances</span>
<small>First twelve project characters</small>
</div>
@foreach (var lane in Model.CharacterLanes)
{
<div class="character-lane-row">
var characterFocusClass = Model.Filter.FocusType == "character" && Model.Filter.FocusID.HasValue && Model.Filter.FocusID != lane.Character.CharacterID ? "lane-dim" : "";
<div class="character-lane-row @characterFocusClass">
<a class="character-lane-label" asp-controller="Characters" asp-action="Details" asp-route-id="@lane.Character.CharacterID">
<strong>@lane.Character.CharacterName</strong>
<span>@(lane.Character.ShortName ?? "Character")</span>
@ -269,9 +457,11 @@ else
<a class="character-marker"
asp-controller="Timeline"
asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-bookId="@Model.SelectedBookID"
asp-route-selectedSceneId="@slot.Scene.SceneID"
asp-route-ProjectID="@Model.Project.ProjectID"
asp-route-BookID="@Model.SelectedBookID"
asp-route-SelectedSceneID="@slot.Scene.SceneID"
asp-route-FocusType="character"
asp-route-FocusID="@appearance.CharacterID"
title="@appearance.CharacterName: @appearance.RoleInSceneTypeName / @appearance.PresenceTypeName">
@(!string.IsNullOrWhiteSpace(appearance.PresenceTypeName) ? appearance.PresenceTypeName.Substring(0, 1) : "C")
</a>
@ -291,7 +481,7 @@ else
}
</section>
<section class="pacing-panel">
<section class="pacing-panel" data-timeline-section="metrics">
<div class="pacing-heading">
<div>
<p class="eyebrow">Pacing</p>

View File

@ -157,9 +157,11 @@
<a class="btn btn-outline-primary btn-sm"
asp-controller="Timeline"
asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-bookId="@warning.BookID"
asp-route-selectedSceneId="@warning.SceneID">Open scene</a>
asp-route-ProjectID="@Model.Project.ProjectID"
asp-route-BookID="@warning.BookID"
asp-route-SelectedSceneID="@warning.SceneID"
asp-route-FocusType="scene"
asp-route-FocusID="@warning.SceneID">Open scene</a>
}
@if (warning.IsDismissed || warning.IsIntentional)
{

View File

@ -1511,6 +1511,117 @@ a {
cursor: pointer;
}
.timeline-control-panel {
display: grid;
gap: 12px;
border: 1px solid var(--plotline-line);
border-radius: 10px;
margin-bottom: 16px;
padding: 12px;
background: rgba(255, 255, 255, 0.84);
box-shadow: 0 10px 24px rgba(55, 48, 39, 0.05);
}
.timeline-filter-drawer summary {
color: var(--plotline-ink);
font-weight: 900;
cursor: pointer;
}
.timeline-filter-grid {
display: grid;
grid-template-columns: repeat(6, minmax(130px, 1fr));
gap: 10px;
margin-top: 12px;
}
.timeline-filter-checks,
.timeline-filter-actions,
.timeline-visibility-panel,
.timeline-preset-panel,
.preset-chip-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.timeline-filter-checks {
grid-column: span 3;
color: var(--plotline-muted);
font-size: 0.82rem;
font-weight: 700;
}
.timeline-filter-actions {
grid-column: span 3;
justify-content: flex-end;
}
.timeline-visibility-panel label,
.preset-chip {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--plotline-line);
border-radius: 999px;
padding: 4px 8px;
color: var(--plotline-muted);
font-size: 0.78rem;
font-weight: 800;
background: rgba(255, 255, 255, 0.76);
}
.timeline-preset-panel form {
display: inline-flex;
align-items: center;
gap: 6px;
}
.preset-chip a {
color: var(--plotline-accent-dark);
text-decoration: none;
}
.preset-chip button {
border: 0;
color: var(--plotline-muted);
background: transparent;
font-weight: 900;
}
.timeline-focus-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border: 1px solid rgba(47, 111, 99, 0.22);
border-radius: 10px;
margin-bottom: 16px;
padding: 10px 12px;
background: rgba(47, 111, 99, 0.08);
}
.timeline-scene-card.filter-dim,
.timeline-scene-card.focus-dim,
.lane-dim {
opacity: 0.35;
}
.timeline-scene-card.focus-match,
.timeline-scene-card.filter-match.focus-match {
outline: 3px solid rgba(47, 111, 99, 0.32);
box-shadow: 0 12px 26px rgba(47, 111, 99, 0.16);
}
.timeline-section-hidden {
display: none !important;
}
.hide-scene-cards .timeline-scene-card {
display: none;
}
.analytics-metric-row {
display: grid;
grid-template-columns: 170px minmax(560px, 1fr);

View File

@ -280,3 +280,65 @@
});
});
})();
(() => {
const root = document.querySelector("[data-timeline-root]");
const controls = document.querySelector("[data-timeline-controls]");
if (!root || !controls) {
return;
}
const storageKey = "plotline.timeline.visibility";
const defaultState = {
"scene-cards": true,
"plot-lines": true,
assets: true,
characters: true,
locations: true,
warnings: true,
metrics: true
};
const readState = () => {
try {
return { ...defaultState, ...JSON.parse(localStorage.getItem(storageKey) || "{}") };
} catch {
return { ...defaultState };
}
};
const applyState = (state) => {
Object.entries(state).forEach(([key, visible]) => {
root.classList.toggle(`hide-${key}`, !visible);
document.querySelectorAll(`[data-timeline-section='${key}']`).forEach((section) => {
section.classList.toggle("timeline-section-hidden", !visible);
});
});
document.querySelectorAll("[data-location-marker]").forEach((marker) => {
marker.classList.toggle("timeline-section-hidden", !state.locations);
});
document.querySelectorAll("[data-warning-marker]").forEach((marker) => {
marker.classList.toggle("timeline-section-hidden", !state.warnings);
});
document.querySelector("[data-visibility-json]")?.setAttribute("value", JSON.stringify(state));
};
const state = readState();
controls.querySelectorAll("[data-timeline-toggle]").forEach((toggle) => {
const key = toggle.dataset.timelineToggle;
toggle.checked = state[key] ?? true;
toggle.addEventListener("change", () => {
state[key] = toggle.checked;
localStorage.setItem(storageKey, JSON.stringify(state));
applyState(state);
});
});
controls.querySelector("[data-timeline-preset-form]")?.addEventListener("submit", () => {
document.querySelector("[data-visibility-json]")?.setAttribute("value", JSON.stringify(state));
});
applyState(state);
})();