From 0de62ce010e2e811d3ac13b319e0de59c44d70dd Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Mon, 11 Jul 2022 00:13:32 -0600 Subject: [PATCH 01/47] Port Form1 to Avalonia --- Source/LibationWinForms/AvaloniaUI/App.axaml | 16 + .../LibationWinForms/AvaloniaUI/App.axaml.cs | 28 ++ .../AvaloniaUI/Assets/cancel.png | Bin 0 -> 39382 bytes .../AvaloniaUI/Assets/completed.png | Bin 0 -> 11521 bytes .../AvaloniaUI/Assets/down.png | Bin 0 -> 1360 bytes .../AvaloniaUI/Assets/errored.png | Bin 0 -> 10215 bytes .../AvaloniaUI/Assets/first.png | Bin 0 -> 1398 bytes .../AvaloniaUI/Assets/import_16x16.png | Bin 0 -> 383 bytes .../AvaloniaUI/Assets/last.png | Bin 0 -> 1402 bytes .../AvaloniaUI/Assets/queued.png | Bin 0 -> 11564 bytes .../LibationWinForms/AvaloniaUI/Assets/up.png | Bin 0 -> 1355 bytes .../AvaloniaUI/AsyncNotifyPropertyChanged2.cs | 14 + .../Controls/DataGridCheckBoxColumnExt.axaml | 5 + .../DataGridCheckBoxColumnExt.axaml.cs | 32 ++ .../Controls/FormattableMenuItem.axaml | 5 + .../Controls/FormattableMenuItem.axaml.cs | 29 ++ .../Controls/FormattableTextBlock.axaml | 5 + .../Controls/FormattableTextBlock.axaml.cs | 27 ++ .../AvaloniaUI/ViewLocator.cs | 30 ++ .../AvaloniaUI/ViewModels/BookTags.cs | 52 +++ .../AvaloniaUI/ViewModels/GridEntry2.cs | 171 ++++++++ .../ViewModels/GridEntryBindingList2.cs | 233 ++++++++++ .../ViewModels/ItemsRepeaterPageViewModel.cs | 160 +++++++ .../ViewModels/LiberateButtonStatus2.cs | 128 ++++++ .../ViewModels/LibraryBookEntry2.cs | 175 ++++++++ .../ViewModels/MainWindowViewModel.cs | 47 ++ .../AvaloniaUI/ViewModels/ProcessBook2.cs | 385 +++++++++++++++++ .../ViewModels/ProcessQueueViewModel.cs | 22 + .../ViewModels/ProductsDisplayViewModel.cs | 46 ++ .../AvaloniaUI/ViewModels/QueryExtensions.cs | 44 ++ .../AvaloniaUI/ViewModels/SeriesEntrys2.cs | 141 ++++++ .../AvaloniaUI/ViewModels/TrackedQueue2[T].cs | 240 +++++++++++ .../AvaloniaUI/ViewModels/ViewModelBase.cs | 11 + .../MainWindow.BackupCounts.axaml.cs | 121 ++++++ .../MainWindow/MainWindow.Export.axaml.cs | 52 +++ .../MainWindow/MainWindow.Filter.axaml.cs | 53 +++ .../MainWindow/MainWindow.Liberate.axaml.cs | 63 +++ .../MainWindow.ProcessQueue.axaml.cs | 66 +++ .../MainWindow.QuickFilters.axaml.cs | 72 ++++ .../MainWindow.RemoveBooks.axaml.cs | 101 +++++ .../MainWindow/MainWindow.ScanAuto.axaml.cs | 93 ++++ .../MainWindow/MainWindow.ScanManual.axaml.cs | 94 ++++ .../MainWindow.ScanNotification.axaml.cs | 48 +++ .../MainWindow/MainWindow.Settings.axaml.cs | 23 + .../MainWindow.VisibleBooks.axaml.cs | 153 +++++++ .../MainWindow/MainWindow._NoUI.axaml.cs | 24 ++ .../Views/MainWindow/MainWindow.axaml | 110 +++++ .../Views/MainWindow/MainWindow.axaml.cs | 186 ++++++++ .../Views/ProcessBookControl2.axaml | 55 +++ .../Views/ProcessBookControl2.axaml.cs | 43 ++ .../Views/ProcessQueueControl2.axaml | 70 +++ .../Views/ProcessQueueControl2.axaml.cs | 326 ++++++++++++++ .../AvaloniaUI/Views/ProductsDisplay2.axaml | 159 +++++++ .../Views/ProductsDisplay2.axaml.cs | 400 ++++++++++++++++++ Source/LibationWinForms/Form1.BackupCounts.cs | 2 +- .../LibationWinForms/LibationWinForms.csproj | 36 +- Source/LibationWinForms/Program.cs | 19 +- ...ationWinForm.RemovableGridEntry.datasource | 10 - 58 files changed, 4408 insertions(+), 17 deletions(-) create mode 100644 Source/LibationWinForms/AvaloniaUI/App.axaml create mode 100644 Source/LibationWinForms/AvaloniaUI/App.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Assets/cancel.png create mode 100644 Source/LibationWinForms/AvaloniaUI/Assets/completed.png create mode 100644 Source/LibationWinForms/AvaloniaUI/Assets/down.png create mode 100644 Source/LibationWinForms/AvaloniaUI/Assets/errored.png create mode 100644 Source/LibationWinForms/AvaloniaUI/Assets/first.png create mode 100644 Source/LibationWinForms/AvaloniaUI/Assets/import_16x16.png create mode 100644 Source/LibationWinForms/AvaloniaUI/Assets/last.png create mode 100644 Source/LibationWinForms/AvaloniaUI/Assets/queued.png create mode 100644 Source/LibationWinForms/AvaloniaUI/Assets/up.png create mode 100644 Source/LibationWinForms/AvaloniaUI/AsyncNotifyPropertyChanged2.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml create mode 100644 Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Controls/FormattableMenuItem.axaml create mode 100644 Source/LibationWinForms/AvaloniaUI/Controls/FormattableMenuItem.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Controls/FormattableTextBlock.axaml create mode 100644 Source/LibationWinForms/AvaloniaUI/Controls/FormattableTextBlock.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/ViewLocator.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/ViewModels/BookTags.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/ViewModels/ItemsRepeaterPageViewModel.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus2.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/ViewModels/MainWindowViewModel.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessBook2.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessQueueViewModel.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/ViewModels/QueryExtensions.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/ViewModels/TrackedQueue2[T].cs create mode 100644 Source/LibationWinForms/AvaloniaUI/ViewModels/ViewModelBase.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.BackupCounts.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Export.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Filter.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Liberate.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ProcessQueue.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.QuickFilters.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.RemoveBooks.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanAuto.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanManual.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanNotification.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Settings.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.VisibleBooks.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow._NoUI.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProcessBookControl2.axaml create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProcessBookControl2.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs delete mode 100644 Source/LibationWinForms/Properties/DataSources/LibationWinForm.RemovableGridEntry.datasource diff --git a/Source/LibationWinForms/AvaloniaUI/App.axaml b/Source/LibationWinForms/AvaloniaUI/App.axaml new file mode 100644 index 00000000..dadc8c76 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/App.axaml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/Source/LibationWinForms/AvaloniaUI/App.axaml.cs b/Source/LibationWinForms/AvaloniaUI/App.axaml.cs new file mode 100644 index 00000000..44aab666 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/App.axaml.cs @@ -0,0 +1,28 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using LibationWinForms.AvaloniaUI.Views; + +namespace LibationWinForms.AvaloniaUI +{ + public class App : Application + { + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow + { + + }; + } + + base.OnFrameworkInitializationCompleted(); + } + } +} \ No newline at end of file diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/cancel.png b/Source/LibationWinForms/AvaloniaUI/Assets/cancel.png new file mode 100644 index 0000000000000000000000000000000000000000..fa34f9356f1ee654c44da5acc0d243f95a6c5761 GIT binary patch literal 39382 zcmYIwbyU>f6X+sJh`<60N-iKEQqs!O-KnI2bT`Y=pdjIrDxrW9B8zl4DBYkgy}*|a zVG$&x-(7$2y!VI4~FE1MV!fGchF`@ zqMCAF>s5A4c6fOBby{5*$t##SRsN*xt9$L2TrYf;bP4339MH-5rVY|US$PfEOQ?L{ zPRlmu3{v>gCrUVD#a^r_=qQzY;C^A54;=^q$q-0qTB@8%`^eDVI&qHv z@naN3RBN9aCn-pR3W=DIYc_WcjTf1m)*$`|oFvnq1=m)HiM%O;?z5?bRriLqaV1)P zBZ8L_SzIE%?-$b!C&Q|29A-Ut0`^d>L%;Q1}1)(%&f7C!d;AS+(ByeqzSs z!9zetVF+^@o6DcpBZs%TcaA@5j^c3b?_ZtH0e}W5#(nUUAe%7kJGak-Ps~#qU3DMsd_>WB*cKS<4 zgAoaoK)Sy35m)ap zV;kCGtSD4UCv!=qT5>M8{3YG0e0Vyyx4YBjF}d)M0BA^m)@iy}#ShFJcVgMAC%G)k z%gL~4r=OI8a+5R%>GhUZ<^x)?W+o^Y@bKY_Y;riLlfD2IOY-W0xtoQVNTpRgzx9_P zl0bvb%l@#-r;_ayJpVw?+AGyNGiMn0m-W&-C|)O;w36a~Z%}Yp{xo&gAd(SS3LUsl z^{0$w|3!I$oX)&v@}<2pB_3uvQ6$Mhjr}-AB&AU^4i;_v)Qo>NlbuC|wP!6LP`Zez z5)TC#mfFFx<`181zVEX5@z5PZ^n<>yoRfwMK;u!%;RP9hoSLkdm6zy^*``=G#`@XoQ$VZC2)ut}RtAAN_Hb<2r|6P$n7l)nr zR1x^}J$E}J8?AYXoFPhVok8m)>P^bOCvjARuBvC5eIkDwT%2}~(ybCog|b5>6?jNe z?coUxgd>wgl#i%ap@IqOv(6L&`aR47J~&Sv5BzGM3Vk-}69GMJNH;=61yG>*BG7J# zsL;bB#wgC$9nSMV4lcYLs`4S*W^y0SQ0VL|tQm5~s_$0d1{VRpWU&nc4cq%!F7|6s z!F;ii#gvCgw+5dk(}1f0L5%bXMNO-L+m5j?JUXV(&?S3Z{|3M5`?_Subi6eS*vy@K(B4%5IqHo>q@Sz8IW~1y4je zJi}pkd3S!%q4*3>fbWM7!y$4RH=M-MvlJA0+IMO5Qzd;9#>(t}!h(`kAS>i9P7ZoayQp z17Kh6tJRXa7cM)GuSzI(_HGhO7Jlbh-o>pojN%>6z5L-2efmh1d^vQIijf%&Brtp*?s+38qs>=Z>9|3g>FqZ z3Y_ynI79=c5mc|*Cv{0wJzxmS%*>ya&2ZrTDL?t)vfHp#kRn1 zS^i|X!+Ur9qkC<#B&qQr0;XS!*#83zU51kT+U>y-2LU&&zq#J|?OPK=t^xLpQZ>Sn z#?W;|aC|ld&n>cu>APuxaRlz z`EPBnEVPEHJh6tNVrTl~-KBRQ!j5>?<_RFO_x+Rq;4hprosr_QvMV;y;O4pxUO|;Y z;Nj6NQr|q=@+C8VNJAYa|I$(fd&D==$OQAJ&+oU3@h7ds6XQh@)bGKNZ zGVco}@cgu-J{JMLLggE;Zw}{y*Q);F;XK}J{CB=}c-yMuE;wPI8-e2z3Li>-UfkOC zH7X`1-E>=PSai=n<3>tm7|`hNN<_s1wGR{deV-`+{0$e!NRHufd3 z8WVcv#SGZLI6j7o8B?vd3E%{lf-s~R^#KdcjwRa6|*zoX*9fj!vl-U5{0q`ni`m?|M|M#SbWlmMtAw9|CEInnz! zFpsm&hkk=K6=@o(SWJ+-U zdO2X>i(?Rb3ZAbz;RZdj_dh+m9cUBfKXis0UO znuNFpvp}sCBd|dYLTk8`Z_iD@i+BD-+xW#_ZFV4n*F}(TfO{!}I8Tx2np9e8YnSYE zaHybO_iv)C7McccPKYdh^0)tpyhN*jF2__L-^8so?yXV^b^6T1feQxy2`2=3tFgP* zieD$y?>=m#UW<|eUR-R85F$&>?Ss?gJ|oNjEpAVNzw+%cySRy^Ux-c{o00arl}(cn^b5rK8*k_s^1{v(v!N z#IRFumZn*^%DrmhA0Xj70cWQ>9E1FUopFYxCrK~>4&q?FJ?)RHJZ&0 zXJ=z$v;RV8$Q|^pVYWCP9Dfhib-b>>_IXAKRelA?6R>mpH^>)*kN@6q;ldj<)$G%f zn=9&iItdhfb6`EDq+ajJoMmwd*esuSl%H&Umf8F7VuCz1(WAFy@`uUooB+R{CtnFJ zM;p~Tlkoh&?_<&nyxy`l8%LJ~@17sD1(pnA%;;9&y8#p(6JwUQ&nhpYSjJ?^%qMnSA?>olJ z*KMbKk15?%?8oAGa2uspOY`}v?t{GV!BUJKMw_mX>U7=d?)VP~jrW zZ9a=BXK2s#T}Zfp_Dc;iV`D2|TNO3)elTo2`4(oiO%0K){{F)5B~LXPpkw>2*D@Zy zulgi^c-F&>1|lnxoO=*M%gg`z1;AFHKh8eZyM%fwNCRPursd?*rQUQVMSztUH&cPu zT)OvFe%B${_4{7#9(#eBlO68SqXP{(W@gtQx{TG(6bW%9G6Ump@_>@g z@f2p~HxKJN8_GP7w^4=uwAGnWtxtT!d{4_sUfn8hUSrjS_#y@6yP)W}h7&vKe^a^|F z0Iw{b=5)AOWYb481|moj125*u1A~7Y3#(asJ?9b;92`*tLcJ`7>-{NgITTffM4a>G zBdlzkPn=o&41cwVy+RtBft^hHs_sx8Qbc;mH0H+HenE>->)tJTMn%ndjPziNn2!-3 z%jMX#FbEl0uPG|bD$6t3yNph{ri%^~nKr&9BBnC1vfmCeakg~oo`Cr&{_1j8Y-fV_ z-r#1W?E9*S?kGNB$mpQ>b3ea=6m44B>ETI&j7fi7(|Rx)`@Ozcch_ zm>p|XaHBm-Qrcsi9`bcOId?bdRs!Fv>()V-Zu?2Eo{+nkYmW5{!5Fv~zpm1i5J=B?e91Tzw5oop-HW9BOSl zwx%vznHMOi-q^b2@P~UwH~eTfKUQ6Ul_y-^DN` zsx%T1DdwTlf*LEs4Pg2AW zM#4D3JB@jAQPhZ0LVmnp{2P|crki1M6FO5A)rc%>lm7LtQBwrV#~?N+W5ls_#LA|u zCW9FpGr9JQF@9`p?q)mFs%%C>(D1YrL{)-*v(uUX?Tab;NC|W0w;E!iBK{iDH&_9` zZO4A64CvBjFSwt~UoxM-so=T${*Ck~PvRusPpoYpbfdHMGTsMAHbSI~x+sw|9xNxP zR8*+^ahWWW0R)=NvAA$o!Deztc`Jq%_o4QX67o7KO5#!PKVc6w`14{f{h2RpriqzN-n&&GFK%bH~VRRaa7OaEpi4ia|l95!t}A{2wRAYMk~< zA*th+vVsP=PF3{42$|{SUF?tmpPh+>pZfN(?TwPuL{cO~O^$wVr|)?SpmoZ0U6&5p z?89`J38f;l^3?YF_RBJ)rOx;FvvwxGei*iI$)lL>YIuEajyA%rL;uZ~F?fh>2T#m}`HH$WlEgeAcqg-!mRQrJ4cqpC25YRTB$= z((z*W*nsRSb6~Em$FTw}AoJQO>JzZ|T@?}Sbinbi&7*Abb-V3tb) zPu;-9)qjysP~#xh;f(0$S5+jy4AF1;PU#pZMAXoH&&~PLZ^_&>$i%ify{cR1SMns& zkt(w3;&cb-Iv)Slj)Unb2}G|$es+J5@OFii^PB7(_r7eoi^`Ap3@r5);<>=ZDaPm@ z?hOjJG>WqzX7g~TTl2bTyJrnJl4!m2Ij_T!96)Hug4Tl3hA={RkVw9r6H@udS#*buv8i_9!%qu2(FKeKqbnp<|Jj( zsd43RTJvbGjhkMP6-*-Bm8+=jdLBE;Uqd>I3^a* zr|O>(0CVGF5bG0C+;TNs-e40urr%lQ8ZTnqL{3jHMIeL2KQvQ3Ia_T{s^|mms|yKZ zWRs-7y0}V)06Z)4+#R6@C0rOQ6)kH8ny*dcF_WokWzTuFdbCa1=UlfrA+qCT%+dC+(T-dbU zjGe_N5gs1A@MuDbqEe5KQo8f-cBBNkq|@_(aCU1ApbKyRyqb~km`yJ#-BXvhAnUBH z_7pde#7yf#dO7lk4^ksaxc~g8fPH_Gu>G4xXMdzcm7#I@Ex>1I-uUT0<{I!XJMot* z0zlvH%qJ(+rPbs@lE{{`1LZ0BTTJyP<)UwYiE5k}dDK&6(u$2b-meJlzgc#9aY}=O z{`?Zz$U#W_nICfv(ECPAO+avRS`V_$OTFp-mQbTi|EAwp)+qYO#_6#lH-8<0z_M29 zpe?z21Ik>f5*o{}=Re2N3U)v$l}G0(;KX#>dmJ%NYF*NloTB#22W z#OS!4cw}SZ4@TS}R(Kzw(7BAxaX%Cufo=*pqQ9_#jm`r1d&ZOlnz7T5A&vqqnWvAr z>qNg&Y(_U)p0!*05t`*Lxc6xY1YFMe&5TY^QZ1FSY+jsulvUJ@Cj>jHb#%wiEeGu! zSQeB|P*#ZYVK>~M+BJT+Ak*^i|46N5-Q(7U^YZv$WY366tdYTm%G8yuq@7CMS?A5Do7?^tt6 zL)L#O)_1sKJdHN7L3_P!P)KbQx8XFg2W`tJ#zj+F7DqI0~Y$l_;T6YkrragriiNID?30H$`Id5%77q`H!B_OorXsEp2-t0SnT3)`Fm{dDk*#JH{d%VgWW9DN5CxZ}X&X`cVij9aSu=+oBPOYwbtJolFj=o79L2*!~$A6f*-MrBuLd=jA*|Zw}qngHcnwQ8Q{skVjR^yiwk8Rkau4(h|Wdqz( zx~UBn8gLNe!FIz^LMZyD4@*OAshXYv?3#`gU4YraAHa+-Wj`-i0xb)(G;7~WkF zvuu@w>`2L*cY=t3b~u|>$be4DHv^a@Dd!QMo;~_0qt<8J4`k1FGk>wc|1v~MVBgEJ zSK-c&-2O(Qa~2Y!hy>g8$?_7Tny3o0l=(WvYCOgBI~$}*_=sY}D-C2g;o@`}-wemT zRb0vufA8%K@1Lq_u>y29tD;eqq64V`Wyo!HJ|GjPY7I4AkX;?4TLTx zTURiokvt8Gc;Q)~lFs^UP93O6O$E`4wz1_xd7}Rnz}E1eOOq?$kaF|VJC45 zGJ30d2gsHvDPmVe;Ic%+5vb(Uj1b1_JKA5r$va;hH|;`-tbf9XCy&{n1#^7tOpGP%?p5ip>6}Hebu1kEIGikbMP!VkW=~x*Fz-w1r z{KjJ4VO)$*kuJ+3HOo(0QlfPlCApnZnz{((7edtfMNk#8P>1CrR0ndlQIDX3C2- z=;X~`6qXh5bIt~E;dckFh#$PaIWAAANanO!`h8<%FpEx)xUOR2>C#xKsa*&2-Q-oz zx*q17Qv$Y7|BAQoE>Gxk_!q7&;JV_r6DQ?cdW7p_!`E|u_KB4+)-GAUA_vh>5NOME z%r3TuoZH!3of2BVKTw#Z(eVd4fnTTW>+hc8e?DH?-Xe4#Qm-hg`3d(?(164Sz{ zMpE&4O|-@x37VXNL2&B~4op7H>|En4Df0H`!{Pklq4jP)DC~Z(_AwW?u|yVQ<4?wFID;U*Y*aaBah3mjm#zbmxe`$#Sa*raMW>z|!*yIB*a~-CwfJ@{g~X zfV?aiY{;_Z%Iv1rbS)Iyjg%k(>4()F>d%IA%P&$zL`8z@UqAPZzGEuTV#?fb?aEE! zDT1aNEH_n6zywW~lXW)7h0tAoSU5NE2~Fa&ic*n@{|OlEO#Pf!LB~%1*rQ~b|BseL z*VDG=G})MDD=tP3y~IdOqHu)TNR({PZxg)bjAjTr7|XP|kps-R+FT;%lG0I8KARhSU;;%|O6DK~p4y@TehL}|rR(v=94q*=n4h99#?nH}mEhbn{CZ zZ-o?N_BpuiDQGgn2OpQ&?XtUc5O||*W_a;_Q)Ke>o$CEz)dB9x?Q0+58SPc~2*YRY z93&>|2Mk{RxR1bV`l^pLi;xDnSA%Ygp9ixje%e7dQ(Mj|FDck%9SY5B`We_4v@@2< zLns9MH$&lD^-Jl65t$YCXZ{|)lU4L-z8iI9?OTkP!6`-qY@<)&wx0eHLW*gog`!XY z_K+fye1P@w>%Jo*bekta*9h3Q?LJ6@)p@nkMXQpQV%6u)h8+_#iqS{?nGD_3gT6(8 zJJH3#OXTuQ%h$4YVOXoDnD?sah~V5)+69Uy88sQwUmLPYM2UMTrjkpk8m`;p6^oy8 zSIn)Fl^AyPSioW{{w~(4bL{=Y-#o}EU7*K+%~b4>-T z3Y(>Ai(<&fbj97?lXF43opqm-;Bq8tf*yr z_DeN8=!GtRW6McR8Pz6C=&$(J`7#$N()ebE!tC#br&PDu527>e(7s9@*z?p<}N*M?y~KY z9h?7nd(2Mq>0M@5H_5Lm$yrM}i=WGxNZu6julb*6`j8-?0luq>X8yR7P}usKmqsbAN124+ZZA`32x z;$S4?`b-%WJda=F(*=?6m+Y{+eOOPLhe>5rCv(HC4CHLT+Gdjcnbo-)`JiI<{U}ZHe^<+))<@Qd@w%)Hr zjLhXbUE)~Tqa_m^={t>OHr`_<@Qsb0crXCqvdilBRsxy4broR+C*jnQXSz#8j;#6+ z_zH;@0niw*{m5Q`kdHWema^`v6i3A0g(WO zVn+H#*d-jqzjrAm6CYJ!|_Bq)(}1_6Ms%vqcjQC`V$75CyY? zo6KFNss9oyuL>VK6MO`27P<+0vfiLCL8DOL@c>w+juH!)(%&Oc#L8FR-nD~Mme;bp z*3?;Rce99Q}Xw$0KcwQNDa7Zj}dL#Vqw{7y*ZosJDVBT_b zb}auv4v|n$sMjbP%5nd8WriIhyW25)aeCULI$04B>s4VJ9>PZEoeKqJ3jl7ItT)ZtiVN3k|0Tq0+rmulJ4Fu zxs2dQ?-K^0Vde{6;!!aPA3$a+2yCN3qO6>l!m7fe<)(>#Pw>fM%cBCa80R|p#xBp$ z*4;L)&NLX|)@(6*9Wp(lOpXMBIpG`K1M)IZd08mQ5b3LZ2&s-H7k1ERpee>ExA-p} z(^A4pdzBe`UCk>oxkd40ITSEBa04r|8=V_v){|jj9z&BAwfO|u?7zE>yvKN9GY3-c zVNe^}q|@gd`=jx3y9THdu}?1ZVe|bDAhr?1>rmWl8|Wr*8QDIdV#GtrrBnDuYh`%$ znIOSpCuGfU+;-U2-1~++lSUlGTLFp_xf#jibDQ#9_oz>YdK2OeLNq-(OU-(XY<+=- zb2-LjS>zkeKjrbS@L6&>jW!HJuzXwUGg0WKL1a_ko@7ou>JQAc})gz2o z6J2<7&H`?h!Q(ELLv?`UoL5Zt?Z!4%uCk)OMeYh_g%Uh`d&Oo+U2`kGUW$6&7*Bh+ zse~rH2dODC3fh?sDeA*o?JY{TPV@3jbr3AVikxh~JY@X)1_K@*v zgMw@P&yQ1$fAN+7RWLmv>kzdneJQz6+Dnw9ouLMaA(E@llkPSfqTm?TDt}0zCo2{= zJiamJCNEB}YzD01VVwmq1cv+su={@(a1CqniROcCrYyifd{<1=C`pWL^5>Z;Q)3>f zGbZR#79#;XdNLpT0pI_S2q(!~aPPKBtk^<-CGrTQ+Jxk4{9a9qf?u%+PTAcyN8QrZ zw6%F)MrRPQqI7WUmLW3%?2E|bCtliW1a=&RvcV+n=2Lz%R5Np}-x|Dd zYPtHQT!URbu7plYv#s!8b}Osk$*8Z6Q$m=H%Bv7nPKlq2^}p)zHIty)me-IZMPRv( z+v!M{j8VuSBjT22nie%9NfJ%XLkW`A%^hw22f^f}7iDupv- z{aG_(l_blrsMoaZYxx_AcI3-I*M!Fml4M`)W!3lHK4R=fQDHI!1}rX0Tgfb|=1mV7 zUxI9Dl3azyDk}c_0}@0t899XS`+M1X7q2ET6^l^Xj3fcogBhcz3oYq-8V~5zwaQX^-tc~qt?eZX2ny&Wi|aC&*9$ywvHWx2VYu4+ zDr8iwkx`IuL0e(AeXa%Hb3L3Ogt%sHa%eOoJsM*@>Ezs2{%hm=AS>33s(;fQLuS@m zZu}G^4*?g)b42n?5L-~8kq*MWTlXvo%V61j|9-2C2;#^B%r^~=?F;7c;I$S-b@~-1 zC5vWDGx=^3bdL5!xDlW6vlU`U_7d;r%*4m<>elr?+nk2+<#Y(l<%MV3A zTDZFZZKZ6tFy?JO8UDVm!N2>3manps5VYkRCB3Z5EMm!!Cv}6Vx)YDhT*9~^vVNFS z(-p1O%TVxqZzb9u8{IU0{QQs&z87F^Gl_cj64-$BqB2elfh@i%+_Xh)id_6GW*I2~ z+8hTdAn$Pu%<8)3DDXOF-ujOHF1|)W;GD`=B?M41_%IfZRq#O_ z^W)&53yscv)#Md7Rsll~`(Lk$#CY(IFNXrMmoV(xC5Q!vH(OU^Y^>`^16#xO1SbE#vx|BM^B+`Zd; z^H@=TZirH+Qc%THJ%E-0D6gn5TRaEp#Ju;B0LSDs=f4mnBAC@8t zxBiJ4{f}>J>W!z7?Mh8qa>b@GN-!z*(>%j~g2hANgl^l*oQvT1B#h^%g`MTG-1ofO{6J#SA z2trv~G-#VLgmsW7lOpLimGPbW7T-cC1u)?bl3=gP3yb-XLssl9wSd`)eLoxwB@BfL z3{pOq1*R-;RU^Qd!Mri0PwxP!k8A|F{nk4Ih+;G_bAb^F580|itwP6Aq85i} z9bmE+nMOjEyn}5Z?;SEboeMIg2cxeqqnf;AE(5;14;QVSv;@or0ItXIHcB>MJ|~VE zf502_lXp0rjqtD_=%EqAp1jouz8OC=4^Vjs`cm8VWL`8kM@6lP;ww;35pf>G!aH$?ObvLoVw3r}35Qmpi3}UK7N21S6#_fFy zFd>dP!uENfA^i+5as`=-PxL_#I@b~Xvk@Kg)R?KGoqOiL0F>y)DwupvqqDVIC5wD1 zN`e&SF*I%f({;3R^#eyG&=>C{z&j|Wz%U#AMdcDv*XrawX6$!4TYAa7q^l6$bvv@O z*JyAM*cZq8pYN)JU&7BDA`&Hkn8Cer&y@ic-yd7*pi$|5zakj;=04Gp(nPddz<3kr zampXOK#2;%rqNaH6hMjur6BBDGw4j%w2?w!fpIN|rt;=jQw1`_C}d zO4-28d;h*~BpecRpB^U?hY-U$wftl_!@qX_)f{aXLu0;^`?MS0L7<|>^l6w1aB4w&^ zogoq~rAG&Wy}G(knnz9vXj}YB3?zHPWx|{kAy|Ia(c{90`xLFe7HHRc`?i)SHvW8S zq_`P8FeN$ioKmuUHa;=bd?d?{4Opj7-*qQ~>Sh5Z{o1jsDENk~zy%Hv3BEfy z+bJ~YU#RpFOv30nOqE-ikt6vOW;k@0)g*r=clsby;=czztFx)XEF#A$Fn@=nwU|NF&0fCy>%lwI(@R$z)95NEf z)nCPO(1yK6ZJ|eAwm@#PoO^<94*vCc>F0bK?4UTzI~~ad(Q(YA5neC4LQee859dFE zu7Dl9)oGXO-PuG#$a3OSmct&KW=t-KVEfOH%pQTUIv~`)s*dJZq8g`7Pj7&cxKUm4 z-=hko2vYr8yAb4BLhbFhlKq*dHM8>sG}*K)QWeNl5ltXzieRJzaj7?a-0247`{XX_1=L9# zd(R>SFNi>~TzJIz9e!Fqyd)>b{r8%T4qBtWGGwIY;akRK%BG&rcLkrHEa1Wko4Z0z zO+g3>ARnCR9=#NRZt7?6_X?OYs^4h5;bT9^g6h-jnEl#8!HxwZk&tTnuKkV3M&E|g zpx3E3{Ve-R6@^_aF{DpWH-WZ6AkQ>_T{#;oqu$j2Wbsf)Z$NM*u@U|!v2&R|vgwIO zo0}c}yCm4`*;a6@4$Qn0Q@l=)!;*l z*zhwKUpp*!ylJ^i`6vbT`P0u;Pw~L&6j1;Y?yJ}2oFcwxHqVC1H#Se;4p{d_seq2w zVsyuatY$PlE{ZXvx(z5a{xb(hbkH_@o)wdWBMjkX?xH(pr;R-KN@o_uws`k*11SI_ z?Bt?6MxZ5SGDUgcx`ol;BHX|@D)tMlD43`NgP+f%Hk~~%U38$2X)>f(VUb|cE~M$2 z!-CV68Y{A>?CfU-P_mVyx<`kJq04w{I!$R6m}2p;74V|3>IRKWZdB96u*86+5B`Qp zci$__*3Lv7%B8#KQzoo=YX&Uxc690jf-bUlpf~^m(~JA(uWG;8=lyud-(!1+(_Kpj zo8^;W1R7*1@&F|K?;b8*Ja7e!Urisuh}0<=+g8%|KAA+tnmy^5MT3wgjSql<#6kwM zT^=z%6L}yR^Sm0&TSLT9RKy;~z?x5g49VnvB$Q|+rMxU!$S;(JyUazKWHm{g{iAocbyJQ4$Oo(j*|_B>+jNcICDiW|Q> zT7JY`Top9xZ$;#G6|Td+;d*KfZweMT*Z&IpN-&q{@S#Eo|J_HDWop~W7x5U~=z!YJ z1~{L#60uWjP^6Se=Tr1TDa$`)x&C!i(5CsD3X>ALL;i0L&&83Cdj zkAuky!&JPnkpSaIdD;6lJ*Lg~nfLDUFNzTx^NM0OO#S~c+xIOP)oDq__pLf|c>*?( zv}LcNC6nodKHj!wJ?NO-?Fn+>(ykUF2S=47Ht%9uj?Z_%RNnoBO8+O4zFLmGGzz?E zkcd56xf%8d|F~brNAUrPvf31#%Rtu5ESup%vgJj7IaieNjhcsv54T1BA+A=p(TW$} zTttx~ZgU@C#CETsOkS!`#K=TR33ggd1hxsL#7p_Am|$zb)r-B2gFJReHuw+@r^;b49Rna}Nbt(F%Agph{}M;vcRXUGY;J z@yoe_=dzc;TWj@=Y+R#U{ZAS+I1JXH8MMw?HewM(wfP{mKQyQee^lG=`FA=HK?>*k z49@{R!$UlvljiAwWi!b5z_U)?KgD^X%#T$`h z-$}%^R9+gsg&zr`ERL71tx>$T9KU^d7rK;98)WXI^N&0VpC-hglkGN{915GV4-Q}+ zDUC05C6RiqmG1B=bjcR_ug5};cdk$)5zK+iZ&`UJ&OQd-&=tx@&zMP$yJ9K~P;LAZ z>4y_-)8zmR;$`!%ucVn-DKl`n7k3sVXw}9jm;lBoA&|v^N>Ka9x605RcN>@a*J^7> zQiJyIN+mJb!Qb_G+DA3X7LxhtzM`yYEE#wS_62iaLaGtm&i7q!BCe2r^L>kpJB zKMF3j5IE;EGUW+a1JQe_8||d)J;6cS;hlK)d!{{<+nO5VS>Un{##}-1&Ti;0XnJd+ zE-P=R27TIPg6@$~(d1E3)8jzV)2;)5J;iI}hz=b|FeqB6TRCBivU=peaJ(?k0Zw|2 z`L>v^6Ev;2NsrcEaH((@`-p;b@*D97dwaZ&2)<>mZVDjxT8Pyv! z1wFo2fsad|1Y(2l6wn=*!U?gB1*{2ek9YLhe?=XkU}~GZeRj{tqT=7D?bqW$i|?P- zKE8i?F7-dK0^u+!>mOrsw!Pm~P6uQF_Uv-#CUo`z%BDdfbUiN4@P1Mi|gSrXCeSVMRL;r)nYl8@uY*s??YAWmQR9te7X3YDGF(PY5j-q@s37lhk?l}PzzsmY0xeCCV#7Do`4I*s|lQjOEC4TGaF_#a< zk^>*4hnc<|BVx?HEZ|DW1olVp_9=V|%riJqAO^=-qj&@w?%gDi0|EMEvm z(Ha6Ac*V8t3u-%M%Au$r=a@VnUNkxErEC!1ie_JoCR!*B?I2>v#j{sR^Q42N`2*f@ zE4V83*^Zb>4lq9&J&8W|RKyljsM>^G_*^pL#mxjK>6)V=UC*Y%h%n&!f4Ly6NBen-kD)p5R(Q z0bG!5rT#dlt2%)_2bX3gbCb%==746EM{{WT&}&0i1qhS)no@3Gao{a^ie0sA);6lTLCC^-R`exE%V?`K3@ zQ|62z#n)SSY%>KE)**+J9T~gpd1B3gIiI4h(Qi^bQe^ls>P1a8Q?{Q%KASnyv1oeA zjl@BZSGE4_0`?c*YR5ZF{VtGAfzwxYThRHo)nz76U1au#%!R{lv)LE0;11_9@RSw; zyO5NZh=-fAG{%EywGI7cjhQ5kc-M3-KVNJM;J!;K&jdaJT-x+PxooSn?!FvkiHylI z00V6rtUwNAsZE6aK!sQ^QC;9TwX5ckeO`oPE1K1M4f8Dlk3`L8yEAs~_ZuBN8ofTp zl)z*Sl&hNo4%@Sw;oK2=o6TL$j2s==3Ldnoqn|4fHT^NoRS)$LOii2Q)7OV<0 ztPcIL_%)rP8*Oa7Q$9H^M!f~>EI~{RWyS_ z$3LVZh+pFY9v{M_fheo0mlyFNOHoR74+<>tu}Q(Rdf#=^FT$69Q%&G5GvKLYvjvi`NGi zfbuBGv%GmY6l2VYm>Pf^S!|4zS$cHv0`6F)d;S>;Mca^aT35fd*+cw)P)USrh#7E4 z^*}atNjT?yKvhAok-z%yxWtLlo%zTZDqZ4nK`?SAfC8`4%k^fbb>>&YpJ8y-BLg+#KpdzEv@~35RP{u zoBEFCTsj27y$*qoSSqlsYvSt$R0wTlKm`Lg|0^1>!xJ%j2j9v9*NIa1b2+#0`yU2J zb^KEdmzOtV89aY~1NXRnzsK#@QRPpas1O=9t=7fi-c=cqtVWNJuG#;TgJZ z1QlfNub4S$+1h_&1sL<)u*19!U-JX1)?_T<2nCo{)i3En1)BOmHX?K9P%Gwk2;MV~ zG8@J*Zjejwse*?e9RE$fVe8?0aWLpa zf`}p23v8?69d=We_R?OZW3xWPc#8in?`T$%3YvZZ7zxY44S@;0n7%&}D4&e)VS|6! z6ypjd96{L(0(&+~%;MkCy3YX+w*%F%#2-YlAKl)1n`+4kWCaw=t{oud`a}f+^bdaY z;0o_j%`#`=&TQ1W=~%4?$K-;4>!}>CJqFA^A(1y%>;4-a(^nqtQ)YsPS{f}EQ10|E zT5z8P;Wm$D*lZN|PaRYV8R+=;l|KYb?!L2(iRpVlf-IT~9Eb`6 za6(}6r1`%qmoH4MmaedIc8k)kiLn!3oc$k`t~(H`?~T7GBiS;;iy{#jkv$`&D3wii z*<0D!rHrx~b{W}wQ+CMe^;$0?duC_$NFwJx>6 zbCF0EjbTr}IlsDHa&uN+Y}RdRW-4?ZdR~mLBtYGyg)LX#nG7Dz{FeQWPMGV_HK84f zeGVnHbR=@<8e6Xq=5bP;a^ZRgxu7>V0;$as#|=E(W|se$W1XPdZY{RBZazdIh^nufA*9!c8R@gmkt8 zor%2Po0avfbsGi48~UOantWgJWlU0PivIehp=rbp3G*mLX8G=h4Oqt+!1No;!34xz z!HR3m;C>efC^Wfz?eIhvSFg#;p9q=-N=MQ!TxuoSv)zNWNxP>*;|)un5`#HOhL6!~ z-sOAr2jLh3Mb*wY%Q+0X(`wc~kGz&4oYQw#s&jL}cA6xow)?jEZjbHZ3sK@6{hA9;KH;tG4Cdt#&?2~ zT&PpXsfr&7jV6fI~oMF5FjXkp(_)Zg6#!Wy17BwT~9I5EQ(0ts~AejlUWqC!ylyAS&Ic2W0N1d3D3G1E)~vAOBKTB78U7%SOUC;AR?i8NzOy!bhNl!JPOdHXMRAN16Nt?hQ;5?FV0uFfj5po$xYr4QSk*x*bF72r!1Q9Ul>$ zmxC@+b-AdQC<`;KgfY$;2?wOG2Q|G!V;l*blwSKYEzMXQadMcj_d8^C#9K)1WtQFs zV_^Z*6PRGHqwk*Sn=C-?eP0&0Tofj@QP`Se3$b&qj$pD4Lx}?im`cjG{NcYA>U#p| z_$W$qx~=&?F{=dxv;FKQpUrrx ze@-1c{Q208Ncz*q^T=BA%1*J{1XK<}IJ5gtZ&@F!a?_= z(j$Dd?c}3=1TxMd#I(Qx9cBmW|L+hs`Md@8}|HW~Lb+BFK87L?X-%VteZy zK$p;dGP~HUX?SM$6o<*(evO?4F#xO)_%)@`2QqmN0Sa6R;&<|Dwa;$tAa-Qt7MZI+ zl2=i=Tf-@9*o5DbA^g%T%m=Z}NuF-RAPLf2X{M#vZO!#!!P0ytsFlMk&L%F_SDt%> ztb{Lyl$zbJ{t!c=4=gog=SK{MC@c+w2WklmZ=@Fa;MHbEqL`KvJN~JFN(78!&qTg;*zieDa#k5s6rith=8svkr{s1{qiToR{)Cks-|e`cnnLIxT=}9LR5pP-@F=Em!=Ck#ILewynmq3ZwhStH(N$ z&`~82>O+Svzh6-%bK^ewq-wI5QJ?C82S)a}V3wy|i60;4*LMQd3j{!fMsMUbRsQ$s z)nIzc`MlrmQG|K36iL0*g?U_VsndpRjF(S0GW(9gDF^mhWGRt*_-lTCf(i1Xrs~yV z4l9&BuGBKRXM6>GrzvJgR~;a?w;dXJeOxHD0XqXkiBZYV)M^ghU>xlp8h8(Z z%phz`%_dHR;BQv-Psc(0@e>TE#s!z%eeg&&8V$4)l$E0Sw5CHOZX6k#&CsKvoyHFK z2L1Dgr1ZL;uuC_iiQSD$nW*mhk9vi#mX)>F>KrDwu231t?&u?L-IQrmKgR62TncaA=@NS4fyJ$Ezi(ts&LW;O(;@-Ox^ zi}f;m(I&iFD?wPO7dsByO%h~H-R~pAu-w&Tgh_yJBIH-!clxP29+z`0kx~hYw>z$oNiAKqzn5=_g=HxBR$JQQwb;_XKQMSa_p}V$ob6P?T-Z3s z#+#5+gc;z)#_RTlG>K4=4leJdC&&e_J<^6#hKRR72;%I2Lt}pWEbuTxm>B&d$7}hT zeVbq_5GiT$HlO`>Lh9w-fR2wK^^WTMfh^d#aWFG9%ctOE6g9J2h@2}JvQzR9&K0z| z|0+=vdB2jwD{w4g`lhE)M@eN06k>>qE!}~5HS-vB^@eX@=of{%1b^Tg`cMXIw$kh6 zjN*g7lR#cpz)WLT-@#Gcg-|spph5mdlitt>?du@hmeC^!F!$KJun4H-e3wy z?uAdM@pXCA?oPFLB1bNUz^Wa)%4X}F>|*xwaNjgQNKEem$8T`t0l3C=(=xBToYY$2 zzG{5qc9OD~YsecXlettP54fFI0tad*hqxdw0uE(;miX4=1uam}_njzU6x-&n3?*8$#+P-hDxDq8$!N0*wDOd%J z2A@L@=2V!F5-mtB0&b=_9e)^h(&#!j(&$6%t2F-FXjtW_1O2Um%8Np>Si)nIdQ+n1gK#nzTyVg+q-LAuoh7dtmCG)-C&$6^vlrZC!a zn$+ex%(6Ktrj<T}I{NrUj&uzagAn(DBR zDz{=+jp;$Qie-v)QMitZiu7}E)r+J(TCm6{j-bRvSP6V}5+nmNFcKcu%P%d3piO*W zWc^vCZ5Ut+3?+wj+NR;rdfegKWg8?S#;cQ?^3!whblZ0cA*A&YX8$xY%g_rsjAC8P z9!MmxiGxS_kJm{=ZsCuy>?x{^GzjdR@QzR@lkbof0GSawRq++V|AK@@!AKmg7H}rJ zgQlX0h@%o4nY$t^fzw(ZtwelfrEk0%wJLQ3d3Au}Cf zL~m2BxOkB=@4&_Ff{*kd7|I7$eYeHuy(}@6q&?+=OvT3C{{o^Bke;c9tu|hE`rii= z$K1mm^rV+5(30np!K4`xLX9iaJz;O5D zS+_|0oVI`Rq47aKQ}W8Nz@Y@^QFwRE^U%2+CQ)VKfg(j=1y*oM&P#GBgu`UNAI}yi z-~&z~$`2zrA1(=lTj{)P&}D0VFW0n9%)R5Eap)kPE1DVHN5jKlGSMa&n9rnS?$eS> zu<hix2PZIsI=p1Bu4V z{(jxb1`xnjhwx@V0B;{L0T&_4Ypx^8V0x>36URDw7(#x`E8I~d}EC}QyE zwm(?MmntqLJ9Ua6RyOR(5WDPL43aF-)8(kcmS&e5ujn(^L2L9oP$j8>bLuU4IX|Zw zzJh15((8(>|J*cY%cC%mvvB{ z5s*_WmiTuzR-@czPJ=lXc@sTwJr1zt2fvsbS`3;*Mj9)(Aouh=RcAJm?VlNPMK+Lb z2Cp)QL+j+?2Wm+xWU{+v4J?zfxAHou%)1O&*GVZgGr%;2u--a(NCImvH6W_n&9l&T zNT^fH%FrM!z64j{5wT2N0ft;KOiRd~S&w-SP`H0`4w;jx4K;u1Y;YykH4(|3`23yh zGzyHT^9*HLm~maTYXpOXnu0Mp$sC5v=|4o!J=(F%p9`;$?%oJ}sikNz?!AL7Zxk(M ztsLV7c`tAeVkJLU)?S>VXXnx-bM@XH?)bO{7NQgGC6@vu&EXisO&-V=rj^0uL<4bRQL z-L61#ACC4udI#CbFp=u~;6sI`3t1_7vv(z*Yp&D|1k=D>FNtm>dUh@P{(fLQ6_>#53z6c8^ywuQ$x6&o3d4WxF6Q!?@i6 z+Ro0I*}0`DU|j)0IOVyrrk(~v*ca{XQPG@#z+OKUa+1wnkh?nbHM^$e0~0d`()nGr zgTvL@M9CE(2!DPafixxhX^Et?moRw0bJ-5QW#{LVq`-GWO@<epMr!tmBVQSC4aXAlFv8(<&E5QS$-%dy&duH(&D1&(ZjcW))>4h&REL+YJ=H)tAx z2k^(fvj60cATd#Bd65TNo~HSg;8hfAdGzzpRj)@JG>luw;5A)`vuIO!-890zXoTCM zE}WLOgfVq2@2t$=bDL(X&G64XRC@hJJ~R81^hmob(u9o6eH8@P^wmzAW>4kZzVctQ z^L#PJ>Zb%>Svj%+ukB>%(;{qZ1e6YR+k~QSQPl#;9uqC&NF%AM8!yqy_aKA20kbXZTmn#Zzd^RzM?TRd*Eu+;pPZ54Lg{eN5CY5 za91xR_y}+GSG^Vb#l##I7gak~Yhy0+^-0N6Z-1r?0}4OFMMFpc?c&h#a{?OoG>H-` zi&p;jg0A?C`b%d^hY^C;Z^5%0A^1^J9JpTUE$pId9CS-JZ4sKMmXL~oWtg3#IL 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 LtDnm{r-UW|ljW{r literal 0 HcmV?d00001 diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/completed.png b/Source/LibationWinForms/AvaloniaUI/Assets/completed.png new file mode 100644 index 0000000000000000000000000000000000000000..3cd619816f85e1d9d1dc658c65af13b46c3ae378 GIT binary patch literal 11521 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 VZYd0J1)c-L&?Yv<4~$M;{y*)&(QyC( literal 0 HcmV?d00001 diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/down.png b/Source/LibationWinForms/AvaloniaUI/Assets/down.png new file mode 100644 index 0000000000000000000000000000000000000000..2536c961061d45baa09ae1371fc1fbb7a69da22a GIT binary patch literal 1360 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 M@TXq?eAkKp0m>RS=l}o! literal 0 HcmV?d00001 diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/first.png b/Source/LibationWinForms/AvaloniaUI/Assets/first.png new file mode 100644 index 0000000000000000000000000000000000000000..e470c69787ea02414bedea1390f2601f4f100226 GIT binary patch literal 1398 zcmeAS@N?(olHy`uVBq!ia0vp^CxAGTgAGVlCuLUvDTQQ@AYTTCDpdxChGqtapZ|gM zO9qBg0|tgy2@DKYGZ+}e^C!h0bpxv9EbxddW?tdxf@+ju)mpf)LRctS&D8%2guEOM4@e?`O~5 z{Ji}A@9+8lzkPq?oUSSR{Lb{f(~4iedi5%5TD9AMhMp()Jo!)Qd;Jpc{?8%7%F3vv z!4cr-*ucfb6cQk?ps9&LRD@;40tE+Br6zMbO*^0ad8uZ-=vh zoGO|3X?o@538F=pnm=_5MViRZtoMy<(%fg@FTE)si2u=niLQ%6Usea#y*R%#{JE;L zn8zze84IK8ElQc8TJ~?&e)(0CapH`pZ3nY^T6dVzTDPa{HF`21jMcv?@X5?njcQ#c z_h{`ulXBjfiYwU*ADlRPk?TrP$M#=$3J%22xYBG$+CE$4>tai z$XctY%2WJrZ+*$CP4I9AyJPdFCyMvFnD;@=!wAZBPF8iq+?N*#9gBtKttASgZ83{pE@}LzxfLKxQ?&uXbPGUZKrb z;SCIv9gd4boweP69+W(LfxQMKz_?h{d9~ZmM9HfgAOZ(J?fZFrio6g{#ewBOi=|bR zWgo3=vw9E=3})U5fjr&Q#e) zVEOBXaVAy2PRGk#yZD!JJ}{8hUEY&!ab)}Tw-Dau7n%Q$P1&!K!0lr2@2#5k1rpuc*mC)2gPd}Qt@7-jyi@XG$CkWxj6U)T6o=cB zzAULrvN$4`IXmJj#Kxd6Q~sr#lIIGz6t?je*spo5dy*}V7#_`n#1QM}o}Z_t*fS(_ z9fi1y`)TN5#XfJHDsYU=wv>4^O0`Rr-n_@8+Q$j{WiSY~t@Z1lTB z1s%z`w}SNSz!4lPz*m^#db?}ev}d67l-)6J$L}_0ndr;kE|q`{w>GZ2RcG?loHJ+T zOR#gx-R_yy&;B%<`P+jxkf3OJAyA)iDxc%V;*$_Js6P<}x?-LWBn{maunm=$<}3W- z#lC0T{OrlVczZYNj-maGPxWkHJZ|^@lbHyLOT8z$KuyXQ#g^E6&yfMAAh}j&8TaM( t{x%S|MgoI`E7zT$|Hoyyn7hB^&AoenYMnTC23R;Uc)I$ztaD0e0sw2had7|u literal 0 HcmV?d00001 diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/import_16x16.png b/Source/LibationWinForms/AvaloniaUI/Assets/import_16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..40b582b16654136d4a237a1383f300b94fc914bc GIT binary patch literal 383 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`EX7WqAsj$Z!;#Vf4nJ zFh2xgM(1riUI7IqOI#yLg7ec#$`gxH8OqDc^)mCai<1)zQuXqS(r3T3kpe3EGf%qpALc3O3>W`%-^<*IZ%jZE-aEmpdVmtNO;# zq!ad!_8gK+)LwJ{!g1fWV4274^BF68MWXp#6Xe=0n)&Z66czmXQRd|Kre}K_S$RH$ zGHtu4_x8EhRo1eGvNgS%Pkd~-AE0|&ZQAPV-I6uQ@^>%&)UpcwXuz>% literal 0 HcmV?d00001 diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/last.png b/Source/LibationWinForms/AvaloniaUI/Assets/last.png new file mode 100644 index 0000000000000000000000000000000000000000..3c3ea886e12dca476c50761bb02445be474219a8 GIT binary patch literal 1402 zcmeAS@N?(olHy`uVBq!ia0vp^CxAGTgAGVlCuLUvDTQQ@AYTTCDpdxChGqtapZ|gM zO9qBg0|tgy2@DKYGZ+}e^C!h0bpxv9EbxddW?w(oYmUu5cJI7Q z^16-BW_|u!G4K0cy>;@hr|k{TK7ZRd@Aa!+zkVI~UwQb?>5zsUsr82OJ+=kKA3D;{ zyg&1e-}%m@`7`}ztz^&Re$y7ooZ&KWnD&Dk#>YB;|0%G9Feig6(4B~$^Ow{(|R$=o}Y9pOjhc395nH}_?; ziCeTzFjjT4Fi1(+ob{w2*>|>l8hc44_d!}T|-_c&>jOe|Jgi04BwencB&OAkBqKKtD=kPtrB!g#+u7V7)t!wvE0-?c-cr{2;=UWeV7YvC_3G>8mqEdDgP-Te?Z2n? zr02~8Mau6c#`-(He30P%-NN|)#90`}b$N?l*SEu)%*|~f1|P6~c8~3k9whFV1*{C~ zjBfK^47+<)U_B`O8T$Ns{m*28%D}K=bcr+GZua1-)w?{1V;YikG7r2C zz4H{RMO2yp$9>YRO~H#KdXQn8#x zYun=wOnMP=>ExAZioXt|bm%+WZoYO{f8DxwulKv&nfd*;+!nbyN5_KsK>|mdxSAq7 z90j;FIg%7v9d(u{bhL`FY@E=fAV!9yxyX}Wuj08ZCP-@rEwcp{kPM!#elF{r5}E*U CW0SuC literal 0 HcmV?d00001 diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/queued.png b/Source/LibationWinForms/AvaloniaUI/Assets/queued.png new file mode 100644 index 0000000000000000000000000000000000000000..f30221c3cd461013a17faf70e20fbd4ccd5319b2 GIT binary patch literal 11564 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 bm#9SF(-B*bE&%`1Op5$CDnj&8!qNW#X*l~v literal 0 HcmV?d00001 diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/up.png b/Source/LibationWinForms/AvaloniaUI/Assets/up.png new file mode 100644 index 0000000000000000000000000000000000000000..7c00155a7675f9d32cd5385338aed24533eb5310 GIT binary patch literal 1355 zcmX9;e^ioJ82%iQrU;+9)T}_(zV~~d=Xsxd&po)78|i58 zVUHk)V{{Z}J&4!AT4OgGd`G?g-h<$o619E}g6#1|kb*)4`8W-t0YP>KBFM{)2ohF- zAZ|NKg;!UBf9Hsz60;G+frKv`X1EtXo7EG|iHH*mjK_ox_3;a&GP;*n*kSn#0*ADe z6S?qeAcw;l^7N9*tM#aplT#&`&Ee2Iy=qR$k0k5Ij_0H{4rGrF=+#weO=*+u0shza z1`T!$4O$@^&;6aXEMC0) zbI|JI%tvEvfg8_#fH1el_rI<5hB&^o__N;=#Zn@~s>xfB#j5lEam09=a2fr}b}@%H zi(5z2T4FkXpiHoE^jmL4UYU#QgyS0+!w#M}c1KsV?&qVDt`EHgN#rIa$(o}Dp zU45vOjU`Yf6p%T23&{*v6Ci8xX@)NQeSxsx^*;L^O!1XW+)Ono;g(yk7S-P+O!>kt z7g3GqOs%j@FzKI1Up5Daedn(FMzzSGY|PnBJSTf*18a8NQmRzkd{Ra=?ZYu2Fe5@& z_6FQJJ>|@04sQl?m313>UZMJ(R+|7Yx9Ur-Bln15^z6P@)nPaxq0M!QT2CvliENilWb0D!n%=w} zSQ1(4CuhcBGdQ+DThCLy%Lhoj)P%OGukZYY2UPX(v5qWC9`Dc}i7AE2;Kf6tS8_Sc Itd0Bs2j0_Sh5!Hn literal 0 HcmV?d00001 diff --git a/Source/LibationWinForms/AvaloniaUI/AsyncNotifyPropertyChanged2.cs b/Source/LibationWinForms/AvaloniaUI/AsyncNotifyPropertyChanged2.cs new file mode 100644 index 00000000..6913fe0d --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/AsyncNotifyPropertyChanged2.cs @@ -0,0 +1,14 @@ +using Dinah.Core.Threading; +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace LibationWinForms.AvaloniaUI +{ + public abstract class AsyncNotifyPropertyChanged2 : INotifyPropertyChanged + { + // see also notes in Libation/Source/_ARCHITECTURE NOTES.txt :: MVVM + public event PropertyChangedEventHandler PropertyChanged; + public void NotifyPropertyChanged([CallerMemberName] string propertyName = "") + => Avalonia.Threading.Dispatcher.UIThread.Post(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName))); + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml b/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml new file mode 100644 index 00000000..d67603fe --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml @@ -0,0 +1,5 @@ + + + diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs new file mode 100644 index 00000000..f70a91c0 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs @@ -0,0 +1,32 @@ + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Interactivity; +using Avalonia.Styling; +using LibationWinForms.AvaloniaUI.ViewModels; +using System; + +namespace LibationWinForms.AvaloniaUI.Controls +{ + public partial class DataGridCheckBoxColumnExt : DataGridCheckBoxColumn + { + protected override object PrepareCellForEdit(IControl editingElement, RoutedEventArgs editingEventArgs) + { + return base.PrepareCellForEdit(editingElement, editingEventArgs); + } + protected override IControl GenerateEditingElementDirect(DataGridCell cell, object dataItem) + { + var ele = base.GenerateEditingElementDirect(cell, dataItem) as CheckBox; + ele.Checked += EditingElement_Checked; + return ele; + } + + private void EditingElement_Checked(object sender, RoutedEventArgs e) + { + var cbox = sender as CheckBox; + var gEntry = cbox.DataContext as GridEntry2; + gEntry.Remove = cbox.IsChecked; + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/FormattableMenuItem.axaml b/Source/LibationWinForms/AvaloniaUI/Controls/FormattableMenuItem.axaml new file mode 100644 index 00000000..6607fa93 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Controls/FormattableMenuItem.axaml @@ -0,0 +1,5 @@ + + + diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/FormattableMenuItem.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Controls/FormattableMenuItem.axaml.cs new file mode 100644 index 00000000..485f3a7b --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Controls/FormattableMenuItem.axaml.cs @@ -0,0 +1,29 @@ +using Avalonia.Controls; +using Avalonia.Styling; +using System; + +namespace LibationWinForms.AvaloniaUI.Controls +{ + public partial class FormattableMenuItem : MenuItem, IStyleable + { + Type IStyleable.StyleKey => typeof(MenuItem); + + private string _formatText; + public string FormatText + { + get => _formatText; + set + { + _formatText = value; + Header = value; + } + } + + public string Format(params object[] args) + { + var formatText = string.Format(FormatText, args); + Header = formatText; + return formatText; + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/FormattableTextBlock.axaml b/Source/LibationWinForms/AvaloniaUI/Controls/FormattableTextBlock.axaml new file mode 100644 index 00000000..cffbf31e --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Controls/FormattableTextBlock.axaml @@ -0,0 +1,5 @@ + + + diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/FormattableTextBlock.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Controls/FormattableTextBlock.axaml.cs new file mode 100644 index 00000000..38834f20 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Controls/FormattableTextBlock.axaml.cs @@ -0,0 +1,27 @@ +using Avalonia.Controls; +using Avalonia.Styling; +using System; + +namespace LibationWinForms.AvaloniaUI.Controls +{ + public partial class FormattableTextBlock : TextBlock, IStyleable + { + Type IStyleable.StyleKey => typeof(TextBlock); + + private string _formatText; + public string FormatText + { + get => _formatText; + set + { + _formatText = value; + Text = value; + } + } + + public string Format(params object[] args) + { + return Text = string.Format(FormatText, args); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewLocator.cs b/Source/LibationWinForms/AvaloniaUI/ViewLocator.cs new file mode 100644 index 00000000..a17b206c --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewLocator.cs @@ -0,0 +1,30 @@ +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using LibationWinForms.AvaloniaUI.ViewModels; +using System; + +namespace LibationWinForms.AvaloniaUI +{ + public class ViewLocator : IDataTemplate + { + public IControl Build(object data) + { + var name = data.GetType().FullName!.Replace("ViewModel", "View"); + var type = Type.GetType(name); + + if (type != null) + { + return (Control)Activator.CreateInstance(type)!; + } + else + { + return new TextBlock { Text = "Not Found: " + name }; + } + } + + public bool Match(object data) + { + return data is ViewModelBase; + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/BookTags.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/BookTags.cs new file mode 100644 index 00000000..33d89e2d --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/BookTags.cs @@ -0,0 +1,52 @@ +using Avalonia.Controls; +using Avalonia.Media.Imaging; +using System; +using System.ComponentModel; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + public class BookTags + { + private static Bitmap _buttonImage; + + static BookTags() + { + var memoryStream = new System.IO.MemoryStream(); + + Properties.Resources.edit_25x25.Save(memoryStream, System.Drawing.Imaging.ImageFormat.Png); + memoryStream.Position = 0; + _buttonImage = new Bitmap(memoryStream); + + } + + public string Tags { get; init; } + public bool IsSeries { get; init; } + + public Control Control + { + get + { + if (IsSeries) + return null; + + if (string.IsNullOrEmpty(Tags)) + { + return new Image + { + Stretch = Avalonia.Media.Stretch.None, + Source = _buttonImage + }; + } + else + { + return new TextBlock + { + Text = Tags, + Margin = new Avalonia.Thickness(0, 0), + TextWrapping = Avalonia.Media.TextWrapping.WrapWithOverflow + }; + } + } + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs new file mode 100644 index 00000000..afd46f72 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs @@ -0,0 +1,171 @@ +using DataLayer; +using Dinah.Core; +using Dinah.Core.DataBinding; +using Dinah.Core.Drawing; +using LibationFileManager; +using LibationWinForms.GridView; +using ReactiveUI; +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Drawing; +using System.Linq; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + public enum RemoveStatus + { + NotRemoved, + Removed, + SomeRemoved + } + /// The View Model base for the DataGridView + public abstract class GridEntry2 : AsyncNotifyPropertyChanged2, IMemberComparable + { + [Browsable(false)] public string AudibleProductId => Book.AudibleProductId; + [Browsable(false)] public LibraryBook LibraryBook { get; protected set; } + [Browsable(false)] public float SeriesIndex { get; protected set; } + [Browsable(false)] public string LongDescription { get; protected set; } + [Browsable(false)] public abstract DateTime DateAdded { get; } + [Browsable(false)] protected Book Book => LibraryBook.Book; + + #region Model properties exposed to the view + + protected bool? _remove = false; + public abstract bool? Remove { get; set; } + + public abstract LiberateButtonStatus2 Liberate { get; } + public Avalonia.Media.Imaging.Bitmap Cover + { + get => _cover; + protected set + { + _cover = value; + NotifyPropertyChanged(); + } + } + public string PurchaseDate { get; protected set; } + public string Series { get; protected set; } + public string Title { get; protected set; } + public string Length { get; protected set; } + public string Authors { get; set; } + public string Narrators { get; protected set; } + public string Category { get; protected set; } + public string Misc { get; protected set; } + public string Description { get; protected set; } + public string ProductRating { get; protected set; } + public string MyRating { get; protected set; } + public abstract BookTags BookTags { get; } + + #endregion + + #region Sorting + + public GridEntry2() => _memberValues = CreateMemberValueDictionary(); + + // These methods are implementation of Dinah.Core.DataBinding.IMemberComparable + // Used by GridEntryBindingList for all sorting + public virtual object GetMemberValue(string memberName) => _memberValues[memberName](); + public IComparer GetMemberComparer(Type memberType) => _memberTypeComparers[memberType]; + protected abstract Dictionary> CreateMemberValueDictionary(); + private Dictionary> _memberValues { get; set; } + + // Instantiate comparers for every exposed member object type. + private static readonly Dictionary _memberTypeComparers = new() + { + { typeof(RemoveStatus), new ObjectComparer() }, + { typeof(string), new ObjectComparer() }, + { typeof(int), new ObjectComparer() }, + { typeof(float), new ObjectComparer() }, + { typeof(bool), new ObjectComparer() }, + { typeof(DateTime), new ObjectComparer() }, + { typeof(LiberateButtonStatus2), new ObjectComparer() }, + }; + + #endregion + + #region Cover Art + + private Avalonia.Media.Imaging.Bitmap _cover; + protected void LoadCover() + { + // Get cover art. If it's default, subscribe to PictureCached + (bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(Book.PictureId, PictureSize._80x80)); + + if (isDefault) + PictureStorage.PictureCached += PictureStorage_PictureCached; + + // Mutable property. Set the field so PropertyChanged isn't fired. + using var ms = new System.IO.MemoryStream(picture); + _cover = new Avalonia.Media.Imaging.Bitmap(ms); + } + + private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e) + { + if (e.Definition.PictureId == Book.PictureId) + { + using var ms = new System.IO.MemoryStream(e.Picture); + Cover = new Avalonia.Media.Imaging.Bitmap(ms); + PictureStorage.PictureCached -= PictureStorage_PictureCached; + } + } + + #endregion + + #region Static library display functions + + /// This information should not change during lifetime, so call only once. + protected static string GetDescriptionDisplay(Book book) + { + var doc = new HtmlAgilityPack.HtmlDocument(); + doc.LoadHtml(book?.Description?.Replace("

", "\r\n\r\n

") ?? ""); + return doc.DocumentNode.InnerText.Trim(); + } + + protected static string TrimTextToWord(string text, int maxLength) + { + return + text.Length <= maxLength ? + text : + text.Substring(0, maxLength - 3) + "..."; + } + + + /// + /// This information should not change during lifetime, so call only once. + /// Maximum of 5 text rows will fit in 80-pixel row height. + /// + protected static string GetMiscDisplay(LibraryBook libraryBook) + { + var details = new List(); + + var locale = libraryBook.Book.Locale.DefaultIfNullOrWhiteSpace("[unknown]"); + var acct = libraryBook.Account.DefaultIfNullOrWhiteSpace("[unknown]"); + + details.Add($"Account: {locale} - {acct}"); + + if (libraryBook.Book.HasPdf()) + details.Add("Has PDF"); + if (libraryBook.Book.IsAbridged) + details.Add("Abridged"); + if (libraryBook.Book.DatePublished.HasValue) + details.Add($"Date pub'd: {libraryBook.Book.DatePublished.Value:MM/dd/yyyy}"); + // this goes last since it's most likely to have a line-break + if (!string.IsNullOrWhiteSpace(libraryBook.Book.Publisher)) + details.Add($"Pub: {libraryBook.Book.Publisher.Trim()}"); + + if (!details.Any()) + return "[details not imported]"; + + return string.Join("\r\n", details); + } + + #endregion + + ~GridEntry2() + { + PictureStorage.PictureCached -= PictureStorage_PictureCached; + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs new file mode 100644 index 00000000..1de83350 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs @@ -0,0 +1,233 @@ +using ApplicationServices; +using Dinah.Core.DataBinding; +using LibationSearchEngine; +using LibationWinForms.GridView; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + /* + * Allows filtering and sorting of the underlying BindingList + * by implementing IBindingListView and using SearchEngineCommands + * + * When filtering is applied, the filtered-out items are removed + * from the base list and added to the private FilterRemoved list. + * When filtering is removed, items in the FilterRemoved list are + * added back to the base list. + * + * Remove is overridden to ensure that removed items are removed from + * the base list (visible items) as well as the FilterRemoved list. + */ + public class GridEntryBindingList2 : ObservableCollection + { + public GridEntryBindingList2(IEnumerable enumeration) : base(new List(enumeration)) + { + foreach (var item in enumeration) + item.PropertyChanged += Item_PropertyChanged; + } + /// All items in the list, including those filtered out. + public List AllItems() => Items.Concat(FilterRemoved).ToList(); + + /// When true, itms will not be checked filtered by search criteria on item changed + public bool SuspendFilteringOnUpdate { get; set; } + public string Filter { get => FilterString; set => ApplyFilter(value); } + protected MemberComparer Comparer { get; } = new(); + + /// Items that were removed from the base list due to filtering + private readonly List FilterRemoved = new(); + private string FilterString; + private SearchResultSet SearchResults; + private bool isSorted; + + #region Items Management + + public new void Remove(GridEntry2 entry) + { + entry.PropertyChanged -= Item_PropertyChanged; + FilterRemoved.Add(entry); + base.Remove(entry); + } + + protected override void RemoveItem(int index) + { + var item = Items[index]; + item.PropertyChanged -= Item_PropertyChanged; + base.RemoveItem(index); + } + + protected override void ClearItems() + { + foreach (var item in Items) + item.PropertyChanged -= Item_PropertyChanged; + base.ClearItems(); + } + + protected override void InsertItem(int index, GridEntry2 item) + { + item.PropertyChanged += Item_PropertyChanged; + FilterRemoved.Remove(item); + base.InsertItem(index, item); + } + + private void Item_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + //Don't audo-sort Remove column or else Avalonia will crash. + if (isSorted && e.PropertyName == Comparer.PropertyName && e.PropertyName != nameof(GridEntry.Remove)) + { + Sort(); + + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + return; + } + } + + #endregion + + #region Filtering + + private void ApplyFilter(string filterString) + { + if (filterString != FilterString) + RemoveFilter(); + + FilterString = filterString; + SearchResults = SearchEngineCommands.Search(filterString); + + var booksFilteredIn = Items.BookEntries().Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (GridEntry2)lbe); + + //Find all series containing children that match the search criteria + var seriesFilteredIn = Items.SeriesEntries().Where(s => s.Children.Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any()); + + var filteredOut = Items.Except(booksFilteredIn.Concat(seriesFilteredIn)).ToList(); + + foreach (var item in filteredOut) + { + Remove(item); + } + } + + public void RemoveFilter() + { + if (FilterString is null) return; + + int visibleCount = Items.Count; + + foreach (var item in FilterRemoved.ToList()) + { + if (item is SeriesEntrys2 || item is LibraryBookEntry2 lbe && (lbe.Parent is null || lbe.Parent.Liberate.Expanded)) + { + InsertItem(visibleCount++, item); + } + } + + if (isSorted) + Sort(); + else + { + //No user sort is applied, so do default sorting by DateAdded, descending + Comparer.PropertyName = nameof(GridEntry.DateAdded); + Comparer.Direction = ListSortDirection.Descending; + Sort(); + } + + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + + FilterString = null; + SearchResults = null; + } + + #endregion + + #region Expand/Collapse + + public void CollapseAll() + { + foreach (var series in Items.SeriesEntries().ToList()) + CollapseItem(series); + } + + public void ExpandAll() + { + foreach (var series in Items.SeriesEntries().ToList()) + ExpandItem(series); + } + + public void CollapseItem(SeriesEntrys2 sEntry) + { + foreach (var episode in Items.BookEntries().Where(b => b.Parent == sEntry).ToList()) + { + Remove(episode); + } + + sEntry.Liberate.Expanded = false; + } + + public void ExpandItem(SeriesEntrys2 sEntry) + { + var sindex = Items.IndexOf(sEntry); + + foreach (var episode in FilterRemoved.BookEntries().Where(b => b.Parent == sEntry).ToList()) + { + if (SearchResults is null || SearchResults.Docs.Any(d => d.ProductId == episode.AudibleProductId)) + { + InsertItem(++sindex, episode); + } + } + sEntry.Liberate.Expanded = true; + } + + #endregion + + #region Sorting + + public void DoSortCore(string propertyName) + { + if (isSorted && Comparer.PropertyName == propertyName) + { + Comparer.Direction = ~Comparer.Direction & ListSortDirection.Descending; + } + else + { + Comparer.PropertyName = propertyName; + Comparer.Direction = ListSortDirection.Descending; + } + + Sort(); + + isSorted = true; + + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + + protected void Sort() + { + var itemsList = (List)Items; + + var children = itemsList.BookEntries().Where(i => i.Parent is not null).ToList(); + + var sortedItems = itemsList.Except(children).OrderBy(ge => ge, Comparer).ToList(); + + itemsList.Clear(); + + //Only add parentless items at this stage. After these items are added in the + //correct sorting order, go back and add the children beneath their parents. + itemsList.AddRange(sortedItems); + + foreach (var parent in children.Select(c => c.Parent).Distinct()) + { + var pIndex = itemsList.IndexOf(parent); + + //children should always be sorted by series index. + foreach (var c in children.Where(c => c.Parent == parent).OrderBy(c => c.SeriesIndex)) + itemsList.Insert(++pIndex, c); + } + } + + #endregion + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ItemsRepeaterPageViewModel.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ItemsRepeaterPageViewModel.cs new file mode 100644 index 00000000..22f14569 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ItemsRepeaterPageViewModel.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Collections.ObjectModel; +using Avalonia.Media; +using ReactiveUI; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + public class ProcessQueueItems : ObservableCollection + { + public ProcessQueueItems(IEnumerable items) :base(items) { } + + public void MoveFirst(ItemsRepeaterPageViewModel.Item item) + { + var index = Items.IndexOf(item); + if (index < 1) return; + + Move(index, 0); + } + public void MoveUp(ItemsRepeaterPageViewModel.Item item) + { + var index = Items.IndexOf(item); + if (index < 1) return; + + Move(index, index - 1); + } + public void MoveDown(ItemsRepeaterPageViewModel.Item item) + { + var index = Items.IndexOf(item); + if (index < 0 || index > Items.Count - 2) return; + + Move(index, index + 1); + } + + public void MoveLast(ItemsRepeaterPageViewModel.Item item) + { + var index = Items.IndexOf(item); + if (index < 0 || index > Items.Count - 2) return; + + Move(index, Items.Count - 1); + } + } + + + public class ItemsRepeaterPageViewModel : ViewModelBase + { + private int _newItemIndex = 1; + private int _newGenerationIndex = 0; + private ProcessQueueItems _items; + + public ItemsRepeaterPageViewModel() + { + _items = CreateItems(); + } + + public ProcessQueueItems Items + { + get => _items; + set => this.RaiseAndSetIfChanged(ref _items, value); + } + + public Item? SelectedItem { get; set; } + + public void AddItem() + { + var index = SelectedItem != null ? Items.IndexOf(SelectedItem) : -1; + Items.Insert(index + 1, new Item(index + 1, $"New Item {_newItemIndex++}")); + } + + public void RemoveItem() + { + if (SelectedItem is not null) + { + Items.Remove(SelectedItem); + SelectedItem = null; + } + else if (Items.Count > 0) + { + Items.RemoveAt(Items.Count - 1); + } + } + + public void RandomizeHeights() + { + var random = new Random(); + + foreach (var i in Items) + { + i.Height = random.Next(240) + 10; + } + } + + public void ResetItems() + { + Items = CreateItems(); + } + + private ProcessQueueItems CreateItems() + { + var suffix = _newGenerationIndex == 0 ? string.Empty : $"[{_newGenerationIndex.ToString()}]"; + + _newGenerationIndex++; + + return new ProcessQueueItems( + Enumerable.Range(1, 100).Select(i => new Item(i, $"Item {i.ToString()} {suffix}"))); + } + + public class Item : ViewModelBase + { + private double _height = double.NaN; + static Random rnd = new Random(); + + public Item(int index, string text) + { + Index = index; + Text = text; + Narrator = "Narrator " + index; + Author = "Author " + index; + Title = "Book " + index + ": This is a book title.\r\nThis is line 2 of the book title"; + + Progress = rnd.Next(0, 101); + ETA = "ETA: 01:14"; + + IsDownloading = rnd.Next(0, 2) == 0; + + if (!IsDownloading) + IsFinished = rnd.Next(0, 2) == 0; + + if (IsDownloading) + Title += "\r\nDOWNLOADING"; + else if (IsFinished) + Title += "\r\nFINISHED"; + else + Title += "\r\nQUEUED"; + } + + public bool IsFinished { get; } + public bool IsDownloading { get; } + public bool Queued => !IsFinished && !IsDownloading; + + + public int Index { get; } + public string Text { get; } + public string ETA { get; } + public string Narrator { get; } + public string Author { get; } + public string Title { get; } + public int Progress { get; } + + public double Height + { + get => _height; + set => this.RaiseAndSetIfChanged(ref _height, value); + } + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus2.cs new file mode 100644 index 00000000..5d044633 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus2.cs @@ -0,0 +1,128 @@ +using Avalonia.Media.Imaging; +using DataLayer; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + public class LiberateButtonStatus2 : IComparable, INotifyPropertyChanged + { + public LiberatedStatus BookStatus { get; set; } + public LiberatedStatus? PdfStatus { get; set; } + + private bool _expanded; + public bool Expanded + { + get => _expanded; + set + { + _expanded = value; + NotifyPropertyChanged(); + NotifyPropertyChanged(nameof(Image)); + NotifyPropertyChanged(nameof(ToolTip)); + } + } + public bool IsSeries { get; init; } + public Bitmap Image => GetLiberateIcon(); + public string ToolTip => GetTooltip(); + + static Dictionary images = new(); + + public event PropertyChangedEventHandler PropertyChanged; + + public void NotifyPropertyChanged([CallerMemberName] string propertyName = "") => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + + /// + /// Defines the Liberate column's sorting behavior + /// + public int CompareTo(object obj) + { + if (obj is not LiberateButtonStatus2 second) return -1; + + if (IsSeries && !second.IsSeries) return -1; + else if (!IsSeries && second.IsSeries) return 1; + else if (IsSeries && second.IsSeries) return 0; + else if (BookStatus == LiberatedStatus.Liberated && second.BookStatus != LiberatedStatus.Liberated) return -1; + else if (BookStatus != LiberatedStatus.Liberated && second.BookStatus == LiberatedStatus.Liberated) return 1; + else return BookStatus.CompareTo(second.BookStatus); + } + + + private Bitmap GetLiberateIcon() + { + if (IsSeries) + return Expanded ? GetFromresc("minus") : GetFromresc("plus"); + + if (BookStatus == LiberatedStatus.Error) + return GetFromresc("error"); + + string image_lib = BookStatus switch + { + LiberatedStatus.Liberated => "green", + LiberatedStatus.PartialDownload => "yellow", + LiberatedStatus.NotLiberated => "red", + _ => throw new Exception("Unexpected liberation state") + }; + + string image_pdf = PdfStatus switch + { + LiberatedStatus.Liberated => "_pdf_yes", + LiberatedStatus.NotLiberated => "_pdf_no", + LiberatedStatus.Error => "_pdf_no", + null => "", + _ => throw new Exception("Unexpected PDF state") + }; + + return GetFromresc($"liberate_{image_lib}{image_pdf}"); + } + private string GetTooltip() + { + if (IsSeries) + return Expanded ? "Click to Collpase" : "Click to Expand"; + + if (BookStatus == LiberatedStatus.Error) + return "Book downloaded ERROR"; + + string libState = BookStatus switch + { + LiberatedStatus.Liberated => "Liberated", + LiberatedStatus.PartialDownload => "File has been at least\r\npartially downloaded", + LiberatedStatus.NotLiberated => "Book NOT downloaded", + _ => throw new Exception("Unexpected liberation state") + }; + + string pdfState = PdfStatus switch + { + LiberatedStatus.Liberated => "\r\nPDF downloaded", + LiberatedStatus.NotLiberated => "\r\nPDF NOT downloaded", + LiberatedStatus.Error => "\r\nPDF downloaded ERROR", + null => "", + _ => throw new Exception("Unexpected PDF state") + }; + + + var mouseoverText = libState + pdfState; + + if (BookStatus == LiberatedStatus.NotLiberated || + BookStatus == LiberatedStatus.PartialDownload || + PdfStatus == LiberatedStatus.NotLiberated) + mouseoverText += "\r\nClick to complete"; + + return mouseoverText; + } + + private static Bitmap GetFromresc(string rescName) + { + if (images.ContainsKey(rescName)) return images[rescName]; + + var memoryStream = new System.IO.MemoryStream(); + + ((System.Drawing.Bitmap)Properties.Resources.ResourceManager.GetObject(rescName)).Save(memoryStream, System.Drawing.Imaging.ImageFormat.Png); + memoryStream.Position = 0; + images[rescName] = new Bitmap(memoryStream); + return images[rescName]; + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs new file mode 100644 index 00000000..be7f5cf5 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs @@ -0,0 +1,175 @@ +using ApplicationServices; +using DataLayer; +using Dinah.Core; +using LibationWinForms.GridView; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + /// The View Model for a LibraryBook that is ContentType.Product or ContentType.Episode + public class LibraryBookEntry2 : GridEntry2 + { + [Browsable(false)] public override DateTime DateAdded => LibraryBook.DateAdded; + [Browsable(false)] public SeriesEntrys2 Parent { get; init; } + + #region Model properties exposed to the view + + private DateTime lastStatusUpdate = default; + private LiberatedStatus _bookStatus; + private LiberatedStatus? _pdfStatus; + + public override bool? Remove + { + get => _remove; + set + { + _remove = value.HasValue ? value.Value : false; + Parent?.ChildRemoveUpdate(); + NotifyPropertyChanged(); + } + } + + public override LiberateButtonStatus2 Liberate + { + get + { + //Cache these statuses for faster sorting. + if ((DateTime.Now - lastStatusUpdate).TotalSeconds > 2) + { + _bookStatus = LibraryCommands.Liberated_Status(LibraryBook.Book); + _pdfStatus = LibraryCommands.Pdf_Status(LibraryBook.Book); + lastStatusUpdate = DateTime.Now; + } + return new LiberateButtonStatus2 { BookStatus = _bookStatus, PdfStatus = _pdfStatus, IsSeries = false }; + } + } + + public override BookTags BookTags => new() { Tags = string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated) }; + + #endregion + + public LibraryBookEntry2(LibraryBook libraryBook) + { + setLibraryBook(libraryBook); + LoadCover(); + } + + public void UpdateLibraryBook(LibraryBook libraryBook) + { + if (AudibleProductId != libraryBook.Book.AudibleProductId) + throw new Exception("Invalid grid entry update. IDs must match"); + + UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged; + setLibraryBook(libraryBook); + } + + private void setLibraryBook(LibraryBook libraryBook) + { + LibraryBook = libraryBook; + + Title = Book.Title; + Series = Book.SeriesNames(); + Length = Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min"; + MyRating = Book.UserDefinedItem.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); + PurchaseDate = libraryBook.DateAdded.ToString("d"); + ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); + Authors = Book.AuthorNames(); + Narrators = Book.NarratorNames(); + Category = string.Join(" > ", Book.CategoriesNames()); + Misc = GetMiscDisplay(libraryBook); + LongDescription = GetDescriptionDisplay(Book); + Description = TrimTextToWord(LongDescription, 62); + SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0; + + NotifyPropertyChanged(nameof(Title)); + NotifyPropertyChanged(nameof(Series)); + NotifyPropertyChanged(nameof(Length)); + NotifyPropertyChanged(nameof(MyRating)); + NotifyPropertyChanged(nameof(PurchaseDate)); + NotifyPropertyChanged(nameof(ProductRating)); + NotifyPropertyChanged(nameof(Authors)); + NotifyPropertyChanged(nameof(Narrators)); + NotifyPropertyChanged(nameof(Category)); + NotifyPropertyChanged(nameof(Misc)); + NotifyPropertyChanged(nameof(LongDescription)); + NotifyPropertyChanged(nameof(Description)); + NotifyPropertyChanged(nameof(SeriesIndex)); + + UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged; + } + + #region detect changes to the model, update the view, and save to database. + + /// + /// This event handler receives notifications from the model that it has changed. + /// Notify the view that it's changed. + /// + private void UserDefinedItem_ItemChanged(object sender, string itemName) + { + var udi = sender as UserDefinedItem; + + if (udi.Book.AudibleProductId != Book.AudibleProductId) + return; + + // UDI changed, possibly in a different context/view. Update this viewmodel. Call NotifyPropertyChanged to notify view. + // - This method responds to tons of incidental changes. Do not persist to db from here. Committing to db must be a volitional action by the caller, not incidental. Otherwise batch changes would be impossible; we would only have slow one-offs + // - Don't restrict notifying view to 'only if property changed'. This same book instance can get passed to a different view, then changed there. When the chain of events makes its way back here, the property is unchanged (because it's the same instance), but this view is out of sync. NotifyPropertyChanged will then update this view. + switch (itemName) + { + case nameof(udi.Tags): + Book.UserDefinedItem.Tags = udi.Tags; + NotifyPropertyChanged(nameof(BookTags)); + break; + case nameof(udi.BookStatus): + Book.UserDefinedItem.BookStatus = udi.BookStatus; + _bookStatus = udi.BookStatus; + NotifyPropertyChanged(nameof(Liberate)); + break; + case nameof(udi.PdfStatus): + Book.UserDefinedItem.PdfStatus = udi.PdfStatus; + _pdfStatus = udi.PdfStatus; + NotifyPropertyChanged(nameof(Liberate)); + break; + } + } + + /// Save edits to the database + public void Commit(string newTags, LiberatedStatus bookStatus, LiberatedStatus? pdfStatus) + // MVVM pass-through + => Book.UpdateBook(newTags, bookStatus: bookStatus, pdfStatus: pdfStatus); + + #endregion + + #region Data Sorting + + /// Create getters for all member object values by name + protected override Dictionary> CreateMemberValueDictionary() => new() + { + { nameof(Remove), () => Remove.HasValue ? Remove.Value ? RemoveStatus.Removed : RemoveStatus.NotRemoved : RemoveStatus.SomeRemoved }, + { nameof(Title), () => Book.TitleSortable() }, + { nameof(Series), () => Book.SeriesSortable() }, + { nameof(Length), () => Book.LengthInMinutes }, + { nameof(MyRating), () => Book.UserDefinedItem.Rating.FirstScore() }, + { nameof(PurchaseDate), () => LibraryBook.DateAdded }, + { nameof(ProductRating), () => Book.Rating.FirstScore() }, + { nameof(Authors), () => Authors }, + { nameof(Narrators), () => Narrators }, + { nameof(Description), () => Description }, + { nameof(Category), () => Category }, + { nameof(Misc), () => Misc }, + { nameof(BookTags), () => BookTags?.Tags ?? string.Empty }, + { nameof(Liberate), () => Liberate }, + { nameof(DateAdded), () => DateAdded }, + }; + + #endregion + + ~LibraryBookEntry2() + { + UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged; + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/MainWindowViewModel.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/MainWindowViewModel.cs new file mode 100644 index 00000000..8f281313 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,47 @@ +using ApplicationServices; +using Avalonia.Collections; +using DataLayer; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Linq; +using System.Text; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + public class MainWindowViewModel : ViewModelBase + { + public string Greeting => "Welcome to Avalonia!"; + public GridEntryBindingList2 People { get; set; } + public MainWindowViewModel(IEnumerable dbBooks) + { + var geList = dbBooks + .Where(lb => lb.Book.IsProduct()) + .Select(b => new LibraryBookEntry2(b)) + .Cast() + .ToList(); + + var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild()); + + var seriesBooks = dbBooks.Where(lb => lb.Book.IsEpisodeParent()).ToList(); + + foreach (var parent in seriesBooks) + { + var seriesEpisodes = episodes.FindChildren(parent); + + if (!seriesEpisodes.Any()) continue; + + var seriesEntry = new SeriesEntrys2(parent, seriesEpisodes); + + geList.Add(seriesEntry); + geList.AddRange(seriesEntry.Children); + } + + People = new GridEntryBindingList2(geList.OrderByDescending(e => e.DateAdded)); + People.CollapseAll(); + + } + } + +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessBook2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessBook2.cs new file mode 100644 index 00000000..167523a3 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessBook2.cs @@ -0,0 +1,385 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using ApplicationServices; +using Avalonia.Media.Imaging; +using DataLayer; +using Dinah.Core; +using FileLiberator; +using LibationFileManager; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + public enum ProcessBookResult + { + None, + Success, + Cancelled, + ValidationFail, + FailedRetry, + FailedSkip, + FailedAbort + } + + public enum ProcessBookStatus + { + Queued, + Cancelled, + Working, + Completed, + Failed + } + + /// + /// This is the viewmodel for queued processables + /// + public class ProcessBook2 : INotifyPropertyChanged + { + public event EventHandler Completed; + public event PropertyChangedEventHandler PropertyChanged; + + public LibraryBook LibraryBook { get; private set; } + + private ProcessBookResult _result = ProcessBookResult.None; + private ProcessBookStatus _status = ProcessBookStatus.Queued; + private string _narrator; + private string _author; + private string _title; + private int _progress; + private string _eta; + private Bitmap _cover; + + #region Properties exposed to the view + public ProcessBookResult Result { get => _result; private set { _result = value; NotifyPropertyChanged(); NotifyPropertyChanged(nameof(StatusText)); } } + public ProcessBookStatus Status { get => _status; private set { _status = value; NotifyPropertyChanged(); NotifyPropertyChanged(nameof(BackgroundColor)); NotifyPropertyChanged(nameof(IsFinished)); NotifyPropertyChanged(nameof(IsDownloading)); NotifyPropertyChanged(nameof(Queued)); } } + public string Narrator { get => _narrator; set { _narrator = value; NotifyPropertyChanged(); } } + public string Author { get => _author; set { _author = value; NotifyPropertyChanged(); } } + public string Title { get => _title; set { _title = value; NotifyPropertyChanged(); } } + public int Progress { get => _progress; private set { _progress = value; NotifyPropertyChanged(); } } + public string ETA { get => _eta; private set { _eta = value; NotifyPropertyChanged(); } } + public Bitmap Cover { get => _cover; private set { _cover = value; NotifyPropertyChanged(); } } + public bool IsFinished => Status is not ProcessBookStatus.Queued and not ProcessBookStatus.Working; + public bool IsDownloading => Status is ProcessBookStatus.Working; + public bool Queued => Status is ProcessBookStatus.Queued; + + public string BackgroundColor => Status switch + { + ProcessBookStatus.Cancelled => "Khaki", + ProcessBookStatus.Completed => "PaleGreen", + ProcessBookStatus.Failed => "LightCoral", + _ => string.Empty, + }; + public string StatusText => Result switch + { + ProcessBookResult.Success => "Finished", + ProcessBookResult.Cancelled => "Cancelled", + ProcessBookResult.ValidationFail => "Validion fail", + ProcessBookResult.FailedRetry => "Error, will retry later", + ProcessBookResult.FailedSkip => "Error, Skippping", + ProcessBookResult.FailedAbort => "Error, Abort", + _ => Status.ToString(), + }; + + #endregion + + private TimeSpan TimeRemaining { set { ETA = $"ETA: {value:mm\\:ss}"; } } + private Processable CurrentProcessable => _currentProcessable ??= Processes.Dequeue().Invoke(); + private Processable NextProcessable() => _currentProcessable = null; + private Processable _currentProcessable; + private readonly Queue> Processes = new(); + private readonly ProcessQueue.LogMe Logger; + public void NotifyPropertyChanged([CallerMemberName] string propertyName = "") + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + + public ProcessBook2(LibraryBook libraryBook, ProcessQueue.LogMe logme) + { + LibraryBook = libraryBook; + Logger = logme; + + _title = LibraryBook.Book.Title; + _author = LibraryBook.Book.AuthorNames(); + _narrator = LibraryBook.Book.NarratorNames(); + + (bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._80x80)); + + if (isDefault) + PictureStorage.PictureCached += PictureStorage_PictureCached; + + // Mutable property. Set the field so PropertyChanged isn't fired. + using var ms = new System.IO.MemoryStream(picture); + _cover = new Bitmap(ms); + } + + private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e) + { + if (e.Definition.PictureId == LibraryBook.Book.PictureId) + { + using var ms = new System.IO.MemoryStream(e.Picture); + Cover = new Bitmap(ms); + PictureStorage.PictureCached -= PictureStorage_PictureCached; + } + } + + public async Task ProcessOneAsync() + { + string procName = CurrentProcessable.Name; + try + { + LinkProcessable(CurrentProcessable); + + var statusHandler = await CurrentProcessable.ProcessSingleAsync(LibraryBook, validate: true); + + if (statusHandler.IsSuccess) + return Result = ProcessBookResult.Success; + else if (statusHandler.Errors.Contains("Cancelled")) + { + Logger.Info($"{procName}: Process was cancelled {LibraryBook.Book}"); + return Result = ProcessBookResult.Cancelled; + } + else if (statusHandler.Errors.Contains("Validation failed")) + { + Logger.Info($"{procName}: Validation failed {LibraryBook.Book}"); + return Result = ProcessBookResult.ValidationFail; + } + + foreach (var errorMessage in statusHandler.Errors) + Logger.Error($"{procName}: {errorMessage}"); + } + catch (Exception ex) + { + Logger.Error(ex, procName); + } + finally + { + if (Result == ProcessBookResult.None) + Result = showRetry(LibraryBook); + + Status = Result switch + { + ProcessBookResult.Success => ProcessBookStatus.Completed, + ProcessBookResult.Cancelled => ProcessBookStatus.Cancelled, + _ => ProcessBookStatus.Failed, + }; + } + + return Result; + } + + public async Task CancelAsync() + { + try + { + if (CurrentProcessable is AudioDecodable audioDecodable) + await audioDecodable.CancelAsync(); + } + catch (Exception ex) + { + Logger.Error(ex, $"{CurrentProcessable.Name}: Error while cancelling"); + } + } + + public void AddDownloadPdf() => AddProcessable(); + public void AddDownloadDecryptBook() => AddProcessable(); + public void AddConvertToMp3() => AddProcessable(); + + private void AddProcessable() where T : Processable, new() + { + Processes.Enqueue(() => new T()); + } + + public override string ToString() => LibraryBook.ToString(); + + #region Subscribers and Unsubscribers + + private void LinkProcessable(Processable processable) + { + processable.Begin += Processable_Begin; + processable.Completed += Processable_Completed; + processable.StreamingProgressChanged += Streamable_StreamingProgressChanged; + processable.StreamingTimeRemaining += Streamable_StreamingTimeRemaining; + + if (processable is AudioDecodable audioDecodable) + { + audioDecodable.RequestCoverArt += AudioDecodable_RequestCoverArt; + audioDecodable.TitleDiscovered += AudioDecodable_TitleDiscovered; + audioDecodable.AuthorsDiscovered += AudioDecodable_AuthorsDiscovered; + audioDecodable.NarratorsDiscovered += AudioDecodable_NarratorsDiscovered; + audioDecodable.CoverImageDiscovered += AudioDecodable_CoverImageDiscovered; + } + } + + private void UnlinkProcessable(Processable processable) + { + processable.Begin -= Processable_Begin; + processable.Completed -= Processable_Completed; + processable.StreamingProgressChanged -= Streamable_StreamingProgressChanged; + processable.StreamingTimeRemaining -= Streamable_StreamingTimeRemaining; + + if (processable is AudioDecodable audioDecodable) + { + audioDecodable.RequestCoverArt -= AudioDecodable_RequestCoverArt; + audioDecodable.TitleDiscovered -= AudioDecodable_TitleDiscovered; + audioDecodable.AuthorsDiscovered -= AudioDecodable_AuthorsDiscovered; + audioDecodable.NarratorsDiscovered -= AudioDecodable_NarratorsDiscovered; + audioDecodable.CoverImageDiscovered -= AudioDecodable_CoverImageDiscovered; + } + } + + #endregion + + #region AudioDecodable event handlers + + private void AudioDecodable_TitleDiscovered(object sender, string title) => Title = title; + + private void AudioDecodable_AuthorsDiscovered(object sender, string authors) => Author = authors; + + private void AudioDecodable_NarratorsDiscovered(object sender, string narrators) => Narrator = narrators; + + + private byte[] AudioDecodable_RequestCoverArt(object sender, EventArgs e) + { + byte[] coverData = PictureStorage + .GetPictureSynchronously( + new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._500x500)); + + AudioDecodable_CoverImageDiscovered(this, coverData); + return coverData; + } + + private void AudioDecodable_CoverImageDiscovered(object sender, byte[] coverArt) + { + using var ms = new System.IO.MemoryStream(coverArt); + Cover = new Avalonia.Media.Imaging.Bitmap(ms); + } + + #endregion + + #region Streamable event handlers + private void Streamable_StreamingTimeRemaining(object sender, TimeSpan timeRemaining) => TimeRemaining = timeRemaining; + + + private void Streamable_StreamingProgressChanged(object sender, Dinah.Core.Net.Http.DownloadProgress downloadProgress) + { + if (!downloadProgress.ProgressPercentage.HasValue) + return; + + if (downloadProgress.ProgressPercentage == 0) + TimeRemaining = TimeSpan.Zero; + else + Progress = (int)downloadProgress.ProgressPercentage; + } + + #endregion + + #region Processable event handlers + + private void Processable_Begin(object sender, LibraryBook libraryBook) + { + Status = ProcessBookStatus.Working; + + Logger.Info($"{Environment.NewLine}{((Processable)sender).Name} Step, Begin: {libraryBook.Book}"); + + Title = libraryBook.Book.Title; + Author = libraryBook.Book.AuthorNames(); + Narrator = libraryBook.Book.NarratorNames(); + } + + private async void Processable_Completed(object sender, LibraryBook libraryBook) + { + Logger.Info($"{((Processable)sender).Name} Step, Completed: {libraryBook.Book}"); + UnlinkProcessable((Processable)sender); + + if (Processes.Count > 0) + { + NextProcessable(); + LinkProcessable(CurrentProcessable); + var result = await CurrentProcessable.ProcessSingleAsync(libraryBook, validate: true); + + if (result.HasErrors) + { + foreach (var errorMessage in result.Errors.Where(e => e != "Validation failed")) + Logger.Error(errorMessage); + + Completed?.Invoke(this, EventArgs.Empty); + } + } + else + { + Completed?.Invoke(this, EventArgs.Empty); + } + } + + #endregion + + #region Failure Handler + + private ProcessBookResult showRetry(LibraryBook libraryBook) + { + Logger.Error("ERROR. All books have not been processed. Most recent book: processing failed"); + + System.Windows.Forms.DialogResult? dialogResult = Configuration.Instance.BadBook switch + { + Configuration.BadBookAction.Abort => System.Windows.Forms.DialogResult.Abort, + Configuration.BadBookAction.Retry => System.Windows.Forms.DialogResult.Retry, + Configuration.BadBookAction.Ignore => System.Windows.Forms.DialogResult.Ignore, + Configuration.BadBookAction.Ask => null, + _ => null + }; + + string details; + try + { + static string trunc(string str) + => string.IsNullOrWhiteSpace(str) ? "[empty]" + : (str.Length > 50) ? $"{str.Truncate(47)}..." + : str; + + details = +$@" Title: {libraryBook.Book.Title} + ID: {libraryBook.Book.AudibleProductId} + Author: {trunc(libraryBook.Book.AuthorNames())} + Narr: {trunc(libraryBook.Book.NarratorNames())}"; + } + catch + { + details = "[Error retrieving details]"; + } + + // if null then ask user + dialogResult ??= System.Windows.Forms.MessageBox.Show(string.Format(SkipDialogText + "\r\n\r\nSee Settings to avoid this box in the future.", details), "Skip importing this book?", SkipDialogButtons, System.Windows.Forms.MessageBoxIcon.Question, SkipDialogDefaultButton); + + if (dialogResult == System.Windows.Forms.DialogResult.Abort) + return ProcessBookResult.FailedAbort; + + if (dialogResult == SkipResult) + { + libraryBook.Book.UpdateBookStatus(LiberatedStatus.Error); + + Logger.Info($"Error. Skip: [{libraryBook.Book.AudibleProductId}] {libraryBook.Book.Title}"); + + return ProcessBookResult.FailedSkip; + } + + return ProcessBookResult.FailedRetry; + } + + private string SkipDialogText => @" +An error occurred while trying to process this book. +{0} + +- ABORT: Stop processing books. + +- RETRY: retry this book later. Just skip it for now. Continue processing books. (Will try this book again later.) + +- IGNORE: Permanently ignore this book. Continue processing books. (Will not try this book again later.) +".Trim(); + private System.Windows.Forms.MessageBoxButtons SkipDialogButtons => System.Windows.Forms.MessageBoxButtons.AbortRetryIgnore; + private System.Windows.Forms.MessageBoxDefaultButton SkipDialogDefaultButton => System.Windows.Forms.MessageBoxDefaultButton.Button1; + private System.Windows.Forms.DialogResult SkipResult => System.Windows.Forms.DialogResult.Ignore; + } + + #endregion +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessQueueViewModel.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessQueueViewModel.cs new file mode 100644 index 00000000..049968e7 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessQueueViewModel.cs @@ -0,0 +1,22 @@ +using ReactiveUI; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + public class ProcessQueueViewModel : ViewModelBase + { + private TrackedQueue2 _items = new(); + public ProcessQueueViewModel() { } + public TrackedQueue2 Items + { + get => _items; + set => this.RaiseAndSetIfChanged(ref _items, value); + } + + public ProcessBook2 SelectedItem { get; set; } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs new file mode 100644 index 00000000..a804b9db --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs @@ -0,0 +1,46 @@ +using ApplicationServices; +using Avalonia.Collections; +using DataLayer; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Linq; +using System.Text; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + public class ProductsDisplayViewModel : ViewModelBase + { + public string Greeting => "Welcome to Avalonia!"; + public GridEntryBindingList2 People { get; set; } + public ProductsDisplayViewModel(IEnumerable dbBooks) + { + var geList = dbBooks + .Where(lb => lb.Book.IsProduct()) + .Select(b => new LibraryBookEntry2(b)) + .Cast() + .ToList(); + + var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild()); + + var seriesBooks = dbBooks.Where(lb => lb.Book.IsEpisodeParent()).ToList(); + + foreach (var parent in seriesBooks) + { + var seriesEpisodes = episodes.FindChildren(parent); + + if (!seriesEpisodes.Any()) continue; + + var seriesEntry = new SeriesEntrys2(parent, seriesEpisodes); + + geList.Add(seriesEntry); + geList.AddRange(seriesEntry.Children); + } + + People = new GridEntryBindingList2(geList.OrderByDescending(e => e.DateAdded)); + People.CollapseAll(); + } + } + +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/QueryExtensions.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/QueryExtensions.cs new file mode 100644 index 00000000..df401514 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/QueryExtensions.cs @@ -0,0 +1,44 @@ +using DataLayer; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ +#nullable enable + internal static class QueryExtensions + { + public static IEnumerable BookEntries(this IEnumerable gridEntries) + => gridEntries.OfType(); + + public static IEnumerable SeriesEntries(this IEnumerable gridEntries) + => gridEntries.OfType(); + + public static T? FindByAsin(this IEnumerable gridEntries, string audibleProductID) where T : GridEntry2 + => gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID); + + public static IEnumerable EmptySeries(this IEnumerable gridEntries) + => gridEntries.SeriesEntries().Where(i => i.Children.Count == 0); + + public static SeriesEntrys2? FindSeriesParent(this IEnumerable gridEntries, LibraryBook seriesEpisode) + { + if (seriesEpisode.Book.SeriesLink is null) return null; + + try + { + //Parent books will always have exactly 1 SeriesBook due to how + //they are imported in ApiExtended.getChildEpisodesAsync() + return gridEntries.SeriesEntries().FirstOrDefault( + lb => + seriesEpisode.Book.SeriesLink.Any( + s => s.Series.AudibleSeriesId == lb.LibraryBook.Book.SeriesLink.Single().Series.AudibleSeriesId)); + } + catch (Exception ex) + { + Serilog.Log.Error(ex, "Query error in {0}", nameof(FindSeriesParent)); + return null; + } + } + } +#nullable disable +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs new file mode 100644 index 00000000..8a268652 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs @@ -0,0 +1,141 @@ +using DataLayer; +using Dinah.Core; +using LibationWinForms.GridView; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + /// The View Model for a LibraryBook that is ContentType.Parent + public class SeriesEntrys2 : GridEntry2 + { + [Browsable(false)] public List Children { get; } + [Browsable(false)] public override DateTime DateAdded => Children.Max(c => c.DateAdded); + + private bool suspendCounting = false; + public void ChildRemoveUpdate() + { + if (suspendCounting) return; + + var removeCount = Children.Count(c => c.Remove == true); + + if (removeCount == 0) + _remove = false; + else if (removeCount == Children.Count) + _remove = true; + else + _remove = null; + NotifyPropertyChanged(nameof(Remove)); + } + + #region Model properties exposed to the view + public override bool? Remove + { + get => _remove; + set + { + _remove = value.HasValue ? value : false; + + suspendCounting = true; + + foreach (var item in Children) + item.Remove = value; + + suspendCounting = false; + + NotifyPropertyChanged(); + } + } + + public override LiberateButtonStatus2 Liberate { get; } + public override BookTags BookTags { get; } = new() { IsSeries = true }; + + #endregion + + private SeriesEntrys2(LibraryBook parent) + { + Liberate = new LiberateButtonStatus2 { IsSeries = true }; + SeriesIndex = -1; + LibraryBook = parent; + LoadCover(); + } + + public SeriesEntrys2(LibraryBook parent, IEnumerable children) : this(parent) + { + Children = children + .Select(c => new LibraryBookEntry2(c) { Parent = this }) + .OrderBy(c => c.SeriesIndex) + .ToList(); + UpdateSeries(parent); + } + + public SeriesEntrys2(LibraryBook parent, LibraryBook child) : this(parent) + { + Children = new() { new LibraryBookEntry2(child) { Parent = this } }; + UpdateSeries(parent); + } + + public void UpdateSeries(LibraryBook parent) + { + LibraryBook = parent; + + Title = Book.Title; + Series = Book.SeriesNames(); + MyRating = Book.UserDefinedItem.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); + PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d"); + ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); + Authors = Book.AuthorNames(); + Narrators = Book.NarratorNames(); + Category = string.Join(" > ", Book.CategoriesNames()); + Misc = GetMiscDisplay(LibraryBook); + LongDescription = GetDescriptionDisplay(Book); + Description = TrimTextToWord(LongDescription, 62); + + int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes); + Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min"; + + + + NotifyPropertyChanged(nameof(Title)); + NotifyPropertyChanged(nameof(Series)); + NotifyPropertyChanged(nameof(Length)); + NotifyPropertyChanged(nameof(MyRating)); + NotifyPropertyChanged(nameof(PurchaseDate)); + NotifyPropertyChanged(nameof(ProductRating)); + NotifyPropertyChanged(nameof(Authors)); + NotifyPropertyChanged(nameof(Narrators)); + NotifyPropertyChanged(nameof(Category)); + NotifyPropertyChanged(nameof(Misc)); + NotifyPropertyChanged(nameof(LongDescription)); + NotifyPropertyChanged(nameof(Description)); + + NotifyPropertyChanged(); + } + + #region Data Sorting + + /// Create getters for all member object values by name + protected override Dictionary> CreateMemberValueDictionary() => new() + { + { nameof(Remove), () => Remove.HasValue ? Remove.Value ? RemoveStatus.Removed : RemoveStatus.NotRemoved : RemoveStatus.SomeRemoved }, + { nameof(Title), () => Book.TitleSortable() }, + { nameof(Series), () => Book.SeriesSortable() }, + { nameof(Length), () => Children.Sum(c => c.LibraryBook.Book.LengthInMinutes) }, + { nameof(MyRating), () => Book.UserDefinedItem.Rating.FirstScore() }, + { nameof(PurchaseDate), () => Children.Min(c => c.LibraryBook.DateAdded) }, + { nameof(ProductRating), () => Book.Rating.FirstScore() }, + { nameof(Authors), () => Authors }, + { nameof(Narrators), () => Narrators }, + { nameof(Description), () => Description }, + { nameof(Category), () => Category }, + { nameof(Misc), () => Misc }, + { nameof(BookTags), () => BookTags?.Tags ?? string.Empty }, + { nameof(Liberate), () => Liberate }, + { nameof(DateAdded), () => DateAdded }, + }; + + #endregion + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/TrackedQueue2[T].cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/TrackedQueue2[T].cs new file mode 100644 index 00000000..f44f0130 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/TrackedQueue2[T].cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + public enum QueuePosition + { + Fisrt, + OneUp, + OneDown, + Last + } + + /* + * This data structure is like lifting a metal chain one link at a time. + * Each time you grab and lift a new link (MoveNext call): + * + * 1) you're holding a new link in your hand (Current) + * 2) the remaining chain to be lifted shortens by 1 link (Queued) + * 3) the pile of chain at your feet grows by 1 link (Completed) + * + * The index is the link position from the first link you lifted to the + * last one in the chain. + * + * + * For this to work with Avalonia's ItemsRepeater, it must be an ObservableCollection + * (not merely a Collection with INotifyCollectionChanged, INotifyPropertyChanged). + * So TrackedQueue maintains 2 copies of the list. The primary copy of the list is + * split into Completed, Current and Queued and is used by ProcessQueue to keep track + * of what's what. The secondary copy is a concatenation of primary's three sources + * and is stored in ObservableCollection.Items. When the primary list changes, the + * secondary list is cleared and reset to match the primary. + */ + public class TrackedQueue2 : ObservableCollection where T : class + { + public event EventHandler CompletedCountChanged; + public event EventHandler QueuededCountChanged; + + public T Current { get; private set; } + + public IReadOnlyList Queued => _queued; + public IReadOnlyList Completed => _completed; + + private readonly List _queued = new(); + private readonly List _completed = new(); + private readonly object lockObject = new(); + + public bool RemoveQueued(T item) + { + bool itemsRemoved; + int queuedCount; + + lock (lockObject) + { + itemsRemoved = _queued.Remove(item); + queuedCount = _queued.Count; + } + + if (itemsRemoved) + { + QueuededCountChanged?.Invoke(this, queuedCount); + RebuildSecondary(); + } + return itemsRemoved; + } + + public void ClearCurrent() + { + lock(lockObject) + Current = null; + RebuildSecondary(); + } + + public bool RemoveCompleted(T item) + { + bool itemsRemoved; + int completedCount; + + lock (lockObject) + { + itemsRemoved = _completed.Remove(item); + completedCount = _completed.Count; + } + + if (itemsRemoved) + { + CompletedCountChanged?.Invoke(this, completedCount); + RebuildSecondary(); + } + return itemsRemoved; + } + + public void ClearQueue() + { + lock (lockObject) + _queued.Clear(); + QueuededCountChanged?.Invoke(this, 0); + RebuildSecondary(); + } + + public void ClearCompleted() + { + lock (lockObject) + _completed.Clear(); + CompletedCountChanged?.Invoke(this, 0); + RebuildSecondary(); + } + + public bool Any(Func predicate) + { + lock (lockObject) + { + return (Current != null && predicate(Current)) || _completed.Any(predicate) || _queued.Any(predicate); + } + } + + public void MoveQueuePosition(T item, QueuePosition requestedPosition) + { + lock (lockObject) + { + if (_queued.Count == 0 || !_queued.Contains(item)) return; + + if ((requestedPosition == QueuePosition.Fisrt || requestedPosition == QueuePosition.OneUp) && _queued[0] == item) + return; + if ((requestedPosition == QueuePosition.Last || requestedPosition == QueuePosition.OneDown) && _queued[^1] == item) + return; + + int queueIndex = _queued.IndexOf(item); + + if (requestedPosition == QueuePosition.OneUp) + { + _queued.RemoveAt(queueIndex); + _queued.Insert(queueIndex - 1, item); + } + else if (requestedPosition == QueuePosition.OneDown) + { + _queued.RemoveAt(queueIndex); + _queued.Insert(queueIndex + 1, item); + } + else if (requestedPosition == QueuePosition.Fisrt) + { + _queued.RemoveAt(queueIndex); + _queued.Insert(0, item); + } + else + { + _queued.RemoveAt(queueIndex); + _queued.Insert(_queued.Count, item); + } + } + RebuildSecondary(); + } + + public bool MoveNext() + { + int completedCount = 0, queuedCount = 0; + bool completedChanged = false; + try + { + lock (lockObject) + { + if (Current != null) + { + _completed.Add(Current); + completedCount = _completed.Count; + completedChanged = true; + } + if (_queued.Count == 0) + { + Current = null; + return false; + } + Current = _queued[0]; + _queued.RemoveAt(0); + + queuedCount = _queued.Count; + return true; + } + } + finally + { + if (completedChanged) + CompletedCountChanged?.Invoke(this, completedCount); + QueuededCountChanged?.Invoke(this, queuedCount); + RebuildSecondary(); + } + } + + public bool TryPeek(out T item) + { + lock (lockObject) + { + if (_queued.Count == 0) + { + item = null; + return false; + } + item = _queued[0]; + return true; + } + } + + public T Peek() + { + lock (lockObject) + { + if (_queued.Count == 0) throw new InvalidOperationException("Queue empty"); + return _queued.Count > 0 ? _queued[0] : default; + } + } + + public void Enqueue(IEnumerable item) + { + int queueCount; + lock (lockObject) + { + _queued.AddRange(item); + queueCount = _queued.Count; + } + foreach (var i in item) + base.Add(i); + QueuededCountChanged?.Invoke(this, queueCount); + } + + private void RebuildSecondary() + { + base.ClearItems(); + foreach (var item in GetAllItems()) + base.Add(item); + } + + public IEnumerable GetAllItems() + { + if (Current is null) return Completed.Concat(Queued); + return Completed.Concat(new List { Current }).Concat(Queued); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ViewModelBase.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ViewModelBase.cs new file mode 100644 index 00000000..29d0d3fc --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ViewModelBase.cs @@ -0,0 +1,11 @@ +using ReactiveUI; +using System; +using System.Collections.Generic; +using System.Text; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + public class ViewModelBase : ReactiveObject + { + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.BackupCounts.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.BackupCounts.axaml.cs new file mode 100644 index 00000000..0cfbad94 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.BackupCounts.axaml.cs @@ -0,0 +1,121 @@ +using ApplicationServices; +using Avalonia.Controls; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Avalonia.Threading; +using Dinah.Core; + +namespace LibationWinForms.AvaloniaUI.Views +{ + public partial class MainWindow + { + private System.ComponentModel.BackgroundWorker updateCountsBw = new(); + private void Configure_BackupCounts() + { + // init formattable + beginBookBackupsToolStripMenuItem.Format(0); + beginPdfBackupsToolStripMenuItem.Format(0); + pdfsCountsLbl.Text = "| [Calculating backed up PDFs]"; + + Opened += setBackupCounts; + LibraryCommands.LibrarySizeChanged += setBackupCounts; + LibraryCommands.BookUserDefinedItemCommitted += setBackupCounts; + + updateCountsBw.DoWork += UpdateCountsBw_DoWork; + updateCountsBw.RunWorkerCompleted += exportMenuEnable; + updateCountsBw.RunWorkerCompleted += updateBottomBookNumbers; + updateCountsBw.RunWorkerCompleted += update_BeginBookBackups_menuItem; + updateCountsBw.RunWorkerCompleted += updateBottomPdfNumbersAsync; + updateCountsBw.RunWorkerCompleted += udpate_BeginPdfOnlyBackups_menuItem; + } + private bool runBackupCountsAgain; + private void setBackupCounts(object _, object __) + { + runBackupCountsAgain = true; + + if (!updateCountsBw.IsBusy) + updateCountsBw.RunWorkerAsync(); + } + private void UpdateCountsBw_DoWork(object sender, System.ComponentModel.DoWorkEventArgs e) + { + while (runBackupCountsAgain) + { + runBackupCountsAgain = false; + e.Result = LibraryCommands.GetCounts(); + } + } + + private void exportMenuEnable(object _, System.ComponentModel.RunWorkerCompletedEventArgs e) + { + var libraryStats = e.Result as LibraryCommands.LibraryStats; + Dispatcher.UIThread.Post(() => exportLibraryToolStripMenuItem.IsEnabled = libraryStats.HasBookResults); + } + + // this cannot be cleanly be FormattableToolStripMenuItem because of the optional "Errors" text + private const string backupsCountsLbl_Format = "BACKUPS: No progress: {0} In process: {1} Fully backed up: {2}"; + + private void updateBottomBookNumbers(object _, System.ComponentModel.RunWorkerCompletedEventArgs e) + { + var libraryStats = e.Result as LibraryCommands.LibraryStats; + + var formatString + = !libraryStats.HasBookResults ? "No books. Begin by importing your library" + : libraryStats.booksError > 0 ? backupsCountsLbl_Format + " Errors: {3}" + : libraryStats.HasPendingBooks ? backupsCountsLbl_Format + : $"All {"book".PluralizeWithCount(libraryStats.booksFullyBackedUp)} backed up"; + var statusStripText = string.Format(formatString, + libraryStats.booksNoProgress, + libraryStats.booksDownloadedOnly, + libraryStats.booksFullyBackedUp, + libraryStats.booksError); + Dispatcher.UIThread.InvokeAsync(() => backupsCountsLbl.Text = statusStripText); + } + + // update 'begin book backups' menu item + private void update_BeginBookBackups_menuItem(object _, System.ComponentModel.RunWorkerCompletedEventArgs e) + { + var libraryStats = e.Result as LibraryCommands.LibraryStats; + + var menuItemText + = libraryStats.HasPendingBooks + ? $"{libraryStats.PendingBooks} remaining" + : "All books have been liberated"; + Dispatcher.UIThread.InvokeAsync(() => + { + beginBookBackupsToolStripMenuItem.Format(menuItemText); + beginBookBackupsToolStripMenuItem.IsEnabled = libraryStats.HasPendingBooks; + }); + } + + private async void updateBottomPdfNumbersAsync(object _, System.ComponentModel.RunWorkerCompletedEventArgs e) + { + var libraryStats = e.Result as LibraryCommands.LibraryStats; + + // don't need to assign the output of Format(). It just makes this logic cleaner + var statusStripText + = !libraryStats.HasPdfResults ? "" + : libraryStats.pdfsNotDownloaded > 0 ? await Dispatcher.UIThread.InvokeAsync(()=> pdfsCountsLbl.Format(libraryStats.pdfsNotDownloaded, libraryStats.pdfsDownloaded)) + : $" | All {libraryStats.pdfsDownloaded} PDFs downloaded"; + await Dispatcher.UIThread.InvokeAsync(() => pdfsCountsLbl.Text = statusStripText); + } + + // update 'begin pdf only backups' menu item + private void udpate_BeginPdfOnlyBackups_menuItem(object _, System.ComponentModel.RunWorkerCompletedEventArgs e) + { + var libraryStats = e.Result as LibraryCommands.LibraryStats; + + var menuItemText + = libraryStats.pdfsNotDownloaded > 0 + ? $"{libraryStats.pdfsNotDownloaded} remaining" + : "All PDFs have been downloaded"; + Dispatcher.UIThread.InvokeAsync(() => + { + beginPdfBackupsToolStripMenuItem.Format(menuItemText); + beginPdfBackupsToolStripMenuItem.IsEnabled = libraryStats.pdfsNotDownloaded > 0; + }); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Export.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Export.axaml.cs new file mode 100644 index 00000000..be054276 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Export.axaml.cs @@ -0,0 +1,52 @@ +using ApplicationServices; +using Avalonia.Controls; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views +{ + //DONE + public partial class MainWindow + { + private void Configure_Export() { } + + public void exportLibraryToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + try + { + var saveFileDialog = new System.Windows.Forms.SaveFileDialog + { + Title = "Where to export Library", + Filter = "Excel Workbook (*.xlsx)|*.xlsx|CSV files (*.csv)|*.csv|JSON files (*.json)|*.json" // + "|All files (*.*)|*.*" + }; + + if (saveFileDialog.ShowDialog() != System.Windows.Forms.DialogResult.OK) + return; + + // FilterIndex is 1-based, NOT 0-based + switch (saveFileDialog.FilterIndex) + { + case 1: // xlsx + default: + LibraryExporter.ToXlsx(saveFileDialog.FileName); + break; + case 2: // csv + LibraryExporter.ToCsv(saveFileDialog.FileName); + break; + case 3: // json + LibraryExporter.ToJson(saveFileDialog.FileName); + break; + } + + System.Windows.Forms.MessageBox.Show("Library exported to:\r\n" + saveFileDialog.FileName); + } + catch (Exception ex) + { + MessageBoxLib.ShowAdminAlert(null, "Error attempting to export your library.", "Error exporting", ex); + } + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Filter.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Filter.axaml.cs new file mode 100644 index 00000000..e099d99b --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Filter.axaml.cs @@ -0,0 +1,53 @@ +using Avalonia.Controls; +using Avalonia.Input; +using LibationWinForms.Dialogs; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views +{ + //DONE + public partial class MainWindow + { + protected void Configure_Filter() { } + + public void filterHelpBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => new SearchSyntaxDialog().ShowDialog(); + + public void filterSearchTb_KeyPress(object sender, KeyEventArgs e) + { + if (e.Key == Key.Return) + { + performFilter(this.filterSearchTb.Text); + + // silence the 'ding' + e.Handled = true; + } + } + + public void filterBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => performFilter(this.filterSearchTb.Text); + + private string lastGoodFilter = ""; + private void performFilter(string filterString) + { + this.filterSearchTb.Text = filterString; + + try + { + productsDisplay.Filter(filterString); + lastGoodFilter = filterString; + } + catch (Exception ex) + { + System.Windows.Forms.MessageBox.Show($"Bad filter string:\r\n\r\n{ex.Message}", "Bad filter string", System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Error); + + // re-apply last good filter + performFilter(lastGoodFilter); + } + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Liberate.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Liberate.axaml.cs new file mode 100644 index 00000000..c69c275b --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Liberate.axaml.cs @@ -0,0 +1,63 @@ +using Avalonia.Controls; +using DataLayer; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views +{ + //DONE + public partial class MainWindow + { + private void Configure_Liberate() { } + + //GetLibrary_Flat_NoTracking() may take a long time on a hugh library. so run in new thread + public void beginBookBackupsToolStripMenuItem_Click(object _ = null, Avalonia.Interactivity.RoutedEventArgs __ = null) + { + try + { + SetQueueCollapseState(false); + + Serilog.Log.Logger.Information("Begin backing up all library books"); + + processBookQueue1.AddDownloadDecrypt( + ApplicationServices.DbContexts + .GetLibrary_Flat_NoTracking() + .UnLiberated() + ); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "An error occurred while backing up all library books"); + } + } + + public async void beginPdfBackupsToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + SetQueueCollapseState(false); + await Task.Run(() => processBookQueue1.AddDownloadPdf(ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking() + .Where(lb => lb.Book.UserDefinedItem.PdfStatus is DataLayer.LiberatedStatus.NotLiberated))); + } + + public async void convertAllM4bToMp3ToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + var result = System.Windows.Forms.MessageBox.Show( + "This converts all m4b titles in your library to mp3 files. Original files are not deleted." + + "\r\nFor large libraries this will take a long time and will take up more disk space." + + "\r\n\r\nContinue?" + + "\r\n\r\n(To always download titles as mp3 instead of m4b, go to Settings: Download my books as .MP3 files)", + "Convert all M4b => Mp3?", + System.Windows.Forms.MessageBoxButtons.YesNo, + System.Windows.Forms.MessageBoxIcon.Warning); + if (result == System.Windows.Forms.DialogResult.Yes) + { + SetQueueCollapseState(false); + await Task.Run(() => processBookQueue1.AddConvertMp3(ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking() + .Where(lb => lb.Book.UserDefinedItem.BookStatus is DataLayer.LiberatedStatus.Liberated && lb.Book.ContentType is DataLayer.ContentType.Product))); + } + //Only Queue Liberated books for conversion. This isn't a perfect filter, but it's better than nothing. + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ProcessQueue.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ProcessQueue.axaml.cs new file mode 100644 index 00000000..132f39e2 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ProcessQueue.axaml.cs @@ -0,0 +1,66 @@ +using Avalonia.Controls; +using DataLayer; +using Dinah.Core; +using LibationFileManager; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views +{ + //DONE + public partial class MainWindow + { + private void Configure_ProcessQueue() + { + var collapseState = !Configuration.Instance.GetNonString(nameof(splitContainer1.IsPaneOpen)); + SetQueueCollapseState(collapseState); + } + + public void ProductsDisplay_LiberateClicked(object sender, LibraryBook libraryBook) + { + try + { + if (libraryBook.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload) + { + Serilog.Log.Logger.Information("Begin single book backup of {libraryBook}", libraryBook); + SetQueueCollapseState(false); + processBookQueue1.AddDownloadDecrypt(libraryBook); + } + else if (libraryBook.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) + { + Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", libraryBook); + SetQueueCollapseState(false); + processBookQueue1.AddDownloadPdf(libraryBook); + } + else if (libraryBook.Book.Audio_Exists()) + { + // liberated: open explorer to file + var filePath = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId); + if (!Go.To.File(filePath?.ShortPathName)) + { + var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}"; + System.Windows.Forms.MessageBox.Show($"File not found" + suffix); + } + } + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "An error occurred while handling the stop light button click for {libraryBook}", libraryBook); + } + } + private void SetQueueCollapseState(bool collapsed) + { + splitContainer1.IsPaneOpen = !collapsed; + toggleQueueHideBtn.Content = splitContainer1.IsPaneOpen ? "❱❱❱" : "❰❰❰"; + } + + public void ToggleQueueHideBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + SetQueueCollapseState(splitContainer1.IsPaneOpen); + Configuration.Instance.SetObject(nameof(splitContainer1.IsPaneOpen), splitContainer1.IsPaneOpen); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.QuickFilters.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.QuickFilters.axaml.cs new file mode 100644 index 00000000..061a9f58 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.QuickFilters.axaml.cs @@ -0,0 +1,72 @@ +using Avalonia.Controls; +using LibationFileManager; +using LibationWinForms.Dialogs; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views +{ + //DONE + public partial class MainWindow + { + private void Configure_QuickFilters() + { + Opened += updateFirstFilterIsDefaultToolStripMenuItem; + Opened += updateFiltersMenu; + QuickFilters.UseDefaultChanged += updateFirstFilterIsDefaultToolStripMenuItem; + QuickFilters.Updated += updateFiltersMenu; + } + + private object quickFilterTag { get; } = new(); + private void updateFiltersMenu(object _ = null, object __ = null) + { + var allItems = quickFiltersToolStripMenuItem + .Items + .Cast() + .ToList(); + + var toRemove = allItems + .OfType() + .Where(mi => mi.Tag == quickFilterTag) + .ToList(); + + allItems = allItems + .Except(toRemove) + .ToList(); + + // re-populate + var index = 0; + foreach (var filter in QuickFilters.Filters) + { + var quickFilterMenuItem = new MenuItem + { + Tag = quickFilterTag, + Header = $"_{++index}: {filter}" + }; + quickFilterMenuItem.Click += (_, __) => performFilter(filter); + allItems.Add(quickFilterMenuItem); + } + quickFiltersToolStripMenuItem.Items = allItems; + } + + private void updateFirstFilterIsDefaultToolStripMenuItem(object sender, EventArgs e) + => firstFilterIsDefaultToolStripMenuItem_Checkbox.IsChecked = QuickFilters.UseDefault; + + public void firstFilterIsDefaultToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => QuickFilters.UseDefault = firstFilterIsDefaultToolStripMenuItem_Checkbox.IsChecked != true; + + public void addQuickFilterBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => QuickFilters.Add(this.filterSearchTb.Text); + + public void editQuickFiltersToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) => new EditQuickFilters().ShowDialog(); + + public void productsDisplay_Initialized(object sender, EventArgs e) + { + if (QuickFilters.UseDefault) + performFilter(QuickFilters.Filters.FirstOrDefault()); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.RemoveBooks.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.RemoveBooks.axaml.cs new file mode 100644 index 00000000..1043eb25 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.RemoveBooks.axaml.cs @@ -0,0 +1,101 @@ +using AudibleUtilities; +using Avalonia.Controls; +using LibationWinForms.Dialogs; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views +{ + //WORKING + public partial class MainWindow + { + private void Configure_RemoveBooks() + { + removeBooksBtn.IsVisible = false; + doneRemovingBtn.IsVisible = false; + } + + public async void removeBooksBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + await productsDisplay.RemoveCheckedBooksAsync(); + } + + public void doneRemovingBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + removeBooksBtn.IsVisible = false; + doneRemovingBtn.IsVisible = false; + + productsDisplay.CloseRemoveBooksColumn(); + + //Restore the filter + filterSearchTb.IsEnabled = true; + filterSearchTb.IsVisible = true; + performFilter(filterSearchTb.Text); + } + + public void removeLibraryBooksToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + // if 0 accounts, this will not be visible + // if 1 account, run scanLibrariesRemovedBooks() on this account + // if multiple accounts, another menu set will open. do not run scanLibrariesRemovedBooks() + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var accounts = persister.AccountsSettings.GetAll(); + + if (accounts.Count != 1) + return; + + var firstAccount = accounts.Single(); + scanLibrariesRemovedBooks(firstAccount); + } + + // selectively remove books from all accounts + public void removeAllAccountsToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var allAccounts = persister.AccountsSettings.GetAll(); + scanLibrariesRemovedBooks(allAccounts.ToArray()); + } + + // selectively remove books from some accounts + public void removeSomeAccountsToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + using var scanAccountsDialog = new ScanAccountsDialog(); + + if (scanAccountsDialog.ShowDialog() != System.Windows.Forms.DialogResult.OK) + return; + + if (!scanAccountsDialog.CheckedAccounts.Any()) + return; + + scanLibrariesRemovedBooks(scanAccountsDialog.CheckedAccounts.ToArray()); + } + + private async void scanLibrariesRemovedBooks(params Account[] accounts) + { + //This action is meant to operate on the entire library. + //For removing books within a filter set, use + //Visible Books > Remove from library + + filterSearchTb.IsEnabled = false; + filterSearchTb.IsVisible = false; + productsDisplay.Filter(null); + + removeBooksBtn.IsVisible = true; + doneRemovingBtn.IsVisible = true; + + await productsDisplay.ScanAndRemoveBooksAsync(accounts); + } + + public void productsDisplay_RemovableCountChanged(object sender, int removeCount) + { + removeBooksBtn.Content = removeCount switch + { + 1 => "Remove 1 Book from Libation", + _ => $"Remove {removeCount} Books from Libation" + }; + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanAuto.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanAuto.axaml.cs new file mode 100644 index 00000000..0f3a0f3a --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanAuto.axaml.cs @@ -0,0 +1,93 @@ +using ApplicationServices; +using AudibleUtilities; +using Avalonia.Controls; +using Dinah.Core; +using LibationFileManager; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views +{ + //DONE + public partial class MainWindow + { + private InterruptableTimer autoScanTimer; + + private void Configure_ScanAuto() + { + // creating InterruptableTimer inside 'Configure_' is a break from the pattern. As long as no one else needs to access or subscribe to it, this is ok + var hours = 0; + var minutes = 5; + var seconds = 0; + var _5_minutes = new TimeSpan(hours, minutes, seconds); + autoScanTimer = new InterruptableTimer(_5_minutes); + + // subscribe as async/non-blocking. I'd actually rather prefer blocking but real-world testing found that caused a deadlock in the AudibleAPI + autoScanTimer.Elapsed += async (_, __) => + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var accounts = persister.AccountsSettings + .GetAll() + .Where(a => a.LibraryScan) + .ToArray(); + + // in autoScan, new books SHALL NOT show dialog + try + { + await LibraryCommands.ImportAccountAsync(Login.WinformLoginChoiceEager.ApiExtendedFunc, accounts); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Error invoking auto-scan"); + } + }; + + // load init state to menu checkbox + Opened += updateAutoScanLibraryToolStripMenuItem; + // if enabled: begin on load + Opened += startAutoScan; + + // if new 'default' account is added, run autoscan + AccountsSettingsPersister.Saving += accountsPreSave; + AccountsSettingsPersister.Saved += accountsPostSave; + + // when autoscan setting is changed, update menu checkbox and run autoscan + Configuration.Instance.AutoScanChanged += updateAutoScanLibraryToolStripMenuItem; + Configuration.Instance.AutoScanChanged += startAutoScan; + } + + private List<(string AccountId, string LocaleName)> preSaveDefaultAccounts; + private List<(string AccountId, string LocaleName)> getDefaultAccounts() + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + return persister.AccountsSettings + .GetAll() + .Where(a => a.LibraryScan) + .Select(a => (a.AccountId, a.Locale.Name)) + .ToList(); + } + private void accountsPreSave(object sender = null, EventArgs e = null) + => preSaveDefaultAccounts = getDefaultAccounts(); + private void accountsPostSave(object sender = null, EventArgs e = null) + { + var postSaveDefaultAccounts = getDefaultAccounts(); + var newDefaultAccounts = postSaveDefaultAccounts.Except(preSaveDefaultAccounts).ToList(); + + if (newDefaultAccounts.Any()) + startAutoScan(); + } + + private void startAutoScan(object sender = null, EventArgs e = null) + { + if (Configuration.Instance.AutoScan) + autoScanTimer.PerformNow(); + else + autoScanTimer.Stop(); + } + private void updateAutoScanLibraryToolStripMenuItem(object sender, EventArgs e) => autoScanLibraryToolStripMenuItemCheckbox.IsChecked = Configuration.Instance.AutoScan; + private void autoScanLibraryToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) => Configuration.Instance.AutoScan = autoScanLibraryToolStripMenuItemCheckbox.IsChecked != true; + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanManual.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanManual.axaml.cs new file mode 100644 index 00000000..bf012a24 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanManual.axaml.cs @@ -0,0 +1,94 @@ +using ApplicationServices; +using AudibleUtilities; +using Avalonia.Controls; +using LibationFileManager; +using LibationWinForms.Dialogs; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views +{ + //DONE + public partial class MainWindow + { + private void Configure_ScanManual() + { + Opened += refreshImportMenu; + AccountsSettingsPersister.Saved += refreshImportMenu; + } + + private void refreshImportMenu(object _, EventArgs __) + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var count = persister.AccountsSettings.Accounts.Count; + + autoScanLibraryToolStripMenuItem.IsVisible = count > 0; + + noAccountsYetAddAccountToolStripMenuItem.IsVisible = count == 0; + scanLibraryToolStripMenuItem.IsVisible = count == 1; + scanLibraryOfAllAccountsToolStripMenuItem.IsVisible = count > 1; + scanLibraryOfSomeAccountsToolStripMenuItem.IsVisible = count > 1; + + removeLibraryBooksToolStripMenuItem.IsVisible = count > 0; + removeSomeAccountsToolStripMenuItem.IsVisible = count > 1; + removeAllAccountsToolStripMenuItem.IsVisible = count > 1; + } + + public void noAccountsYetAddAccountToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + System.Windows.Forms.MessageBox.Show("To load your Audible library, come back here to the Import menu after adding your account"); + new AccountsDialog().ShowDialog(); + } + + public async void scanLibraryToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var firstAccount = persister.AccountsSettings.GetAll().FirstOrDefault(); + await scanLibrariesAsync(firstAccount); + } + + public async void scanLibraryOfAllAccountsToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); + var allAccounts = persister.AccountsSettings.GetAll(); + await scanLibrariesAsync(allAccounts); + } + + public async void scanLibraryOfSomeAccountsToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + using var scanAccountsDialog = new ScanAccountsDialog(); + + if (scanAccountsDialog.ShowDialog() != System.Windows.Forms.DialogResult.OK) + return; + + if (!scanAccountsDialog.CheckedAccounts.Any()) + return; + + await scanLibrariesAsync(scanAccountsDialog.CheckedAccounts); + } + + private async Task scanLibrariesAsync(IEnumerable accounts) => await scanLibrariesAsync(accounts.ToArray()); + private async Task scanLibrariesAsync(params Account[] accounts) + { + try + { + var (totalProcessed, newAdded) = await LibraryCommands.ImportAccountAsync(Login.WinformLoginChoiceEager.ApiExtendedFunc, accounts); + + // this is here instead of ScanEnd so that the following is only possible when it's user-initiated, not automatic loop + if (Configuration.Instance.ShowImportedStats && newAdded > 0) + System.Windows.Forms.MessageBox.Show($"Total processed: {totalProcessed}\r\nNew: {newAdded}"); + } + catch (Exception ex) + { + MessageBoxLib.ShowAdminAlert( + null, + "Error importing library. Please try again. If this still happens after 2 or 3 tries, stop and contact administrator", + "Error importing library", + ex); + } + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanNotification.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanNotification.axaml.cs new file mode 100644 index 00000000..a3594d47 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanNotification.axaml.cs @@ -0,0 +1,48 @@ +using ApplicationServices; +using Avalonia.Controls; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views +{ + //DONE + public partial class MainWindow + { + private void Configure_ScanNotification() + { + scanningToolStripMenuItem.IsVisible = false; + LibraryCommands.ScanBegin += LibraryCommands_ScanBegin; + LibraryCommands.ScanEnd += LibraryCommands_ScanEnd; + } + private void LibraryCommands_ScanBegin(object sender, int accountsLength) + { + removeLibraryBooksToolStripMenuItem.IsEnabled = false; + removeAllAccountsToolStripMenuItem.IsEnabled = false; + removeSomeAccountsToolStripMenuItem.IsEnabled = false; + scanLibraryToolStripMenuItem.IsEnabled = false; + scanLibraryOfAllAccountsToolStripMenuItem.IsEnabled = false; + scanLibraryOfSomeAccountsToolStripMenuItem.IsEnabled = false; + + this.scanningToolStripMenuItem.IsVisible = true; + this.scanningToolStripMenuItem_Text.Text + = (accountsLength == 1) + ? "Scanning..." + : $"Scanning {accountsLength} accounts..."; + } + + private void LibraryCommands_ScanEnd(object sender, EventArgs e) + { + removeLibraryBooksToolStripMenuItem.IsEnabled = true; + removeAllAccountsToolStripMenuItem.IsEnabled = true; + removeSomeAccountsToolStripMenuItem.IsEnabled = true; + scanLibraryToolStripMenuItem.IsEnabled = true; + scanLibraryOfAllAccountsToolStripMenuItem.IsEnabled = true; + scanLibraryOfSomeAccountsToolStripMenuItem.IsEnabled = true; + + this.scanningToolStripMenuItem.IsVisible = false; + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Settings.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Settings.axaml.cs new file mode 100644 index 00000000..7957676e --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Settings.axaml.cs @@ -0,0 +1,23 @@ +using Avalonia.Controls; +using LibationWinForms.Dialogs; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views +{ + //DONE + public partial class MainWindow + { + private void Configure_Settings() { } + + public void accountsToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) => new AccountsDialog().ShowDialog(); + + public void basicSettingsToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) => new SettingsDialog().ShowDialog(); + + public void aboutToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => System.Windows.Forms.MessageBox.Show($"Running Libation version {AppScaffolding.LibationScaffolding.BuildVersion}", $"Libation v{AppScaffolding.LibationScaffolding.BuildVersion}"); + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.VisibleBooks.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.VisibleBooks.axaml.cs new file mode 100644 index 00000000..4523ce71 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.VisibleBooks.axaml.cs @@ -0,0 +1,153 @@ +using ApplicationServices; +using Avalonia.Controls; +using Avalonia.Threading; +using DataLayer; +using LibationWinForms.Dialogs; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views +{ + //DONE + public partial class MainWindow + { + private void Configure_VisibleBooks() + { + // init formattable + visibleCountLbl.Format(0); + liberateVisibleToolStripMenuItem_VisibleBooksMenu.Format(0); + liberateVisibleToolStripMenuItem_LiberateMenu.Format(0); + + // top menu strip + visibleBooksToolStripMenuItem.Format(0); + + LibraryCommands.BookUserDefinedItemCommitted += setLiberatedVisibleMenuItemAsync; + } + + private async void setLiberatedVisibleMenuItemAsync(object _, object __) + => await Task.Run(setLiberatedVisibleMenuItem); + + public void liberateVisible(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + try + { + SetQueueCollapseState(false); + + Serilog.Log.Logger.Information("Begin backing up visible library books"); + + processBookQueue1.AddDownloadDecrypt( + productsDisplay + .GetVisible() + .UnLiberated() + ); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "An error occurred while backing up visible library books"); + } + } + public void replaceTagsToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + var dialog = new TagsBatchDialog(); + var result = dialog.ShowDialog(); + if (result != System.Windows.Forms.DialogResult.OK) + return; + + var visibleLibraryBooks = productsDisplay.GetVisible(); + + var confirmationResult = MessageBoxLib.ShowConfirmationDialog( + visibleLibraryBooks, + $"Are you sure you want to replace tags in {0}?", + "Replace tags?"); + + if (confirmationResult != System.Windows.Forms.DialogResult.Yes) + return; + + foreach (var libraryBook in visibleLibraryBooks) + libraryBook.Book.UserDefinedItem.Tags = dialog.NewTags; + LibraryCommands.UpdateUserDefinedItem(visibleLibraryBooks.Select(lb => lb.Book)); + } + + public void setDownloadedToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + var dialog = new LiberatedStatusBatchDialog(); + var result = dialog.ShowDialog(); + if (result != System.Windows.Forms.DialogResult.OK) + return; + + var visibleLibraryBooks = productsDisplay.GetVisible(); + + var confirmationResult = MessageBoxLib.ShowConfirmationDialog( + visibleLibraryBooks, + $"Are you sure you want to replace downloaded status in {0}?", + "Replace downloaded status?"); + + if (confirmationResult != System.Windows.Forms.DialogResult.Yes) + return; + + foreach (var libraryBook in visibleLibraryBooks) + libraryBook.Book.UserDefinedItem.BookStatus = dialog.BookLiberatedStatus; + LibraryCommands.UpdateUserDefinedItem(visibleLibraryBooks.Select(lb => lb.Book)); + } + + public async void removeToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + var visibleLibraryBooks = productsDisplay.GetVisible(); + + var confirmationResult = MessageBoxLib.ShowConfirmationDialog( + visibleLibraryBooks, + $"Are you sure you want to remove {0} from Libation's library?", + "Remove books from Libation?"); + + if (confirmationResult != System.Windows.Forms.DialogResult.Yes) + return; + + var visibleIds = visibleLibraryBooks.Select(lb => lb.Book.AudibleProductId).ToList(); + await LibraryCommands.RemoveBooksAsync(visibleIds); + } + public async void productsDisplay_VisibleCountChanged(object sender, int qty) + { + Dispatcher.UIThread.Post(() => + { + // bottom-left visible count + visibleCountLbl.Format(qty); + + // top menu strip + visibleBooksToolStripMenuItem.Format(qty); + visibleBooksToolStripMenuItem.IsEnabled = qty > 0; + }); + + //Not used for anything? + var notLiberatedCount = productsDisplay.GetVisible().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated); + + await Task.Run(setLiberatedVisibleMenuItem); + } + void setLiberatedVisibleMenuItem() + { + var notLiberated = productsDisplay.GetVisible().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated); + + Dispatcher.UIThread.Post(() => + { + if (notLiberated > 0) + { + liberateVisibleToolStripMenuItem_VisibleBooksMenu.Format(notLiberated); + liberateVisibleToolStripMenuItem_VisibleBooksMenu.IsEnabled = true; + + liberateVisibleToolStripMenuItem_LiberateMenu.Format(notLiberated); + liberateVisibleToolStripMenuItem_LiberateMenu.IsEnabled = true; + } + else + { + liberateVisibleToolStripMenuItem_VisibleBooksMenu.Header = "All visible books are liberated"; + liberateVisibleToolStripMenuItem_VisibleBooksMenu.IsEnabled = false; + + liberateVisibleToolStripMenuItem_LiberateMenu.Header = "All visible books are liberated"; + liberateVisibleToolStripMenuItem_LiberateMenu.IsEnabled = false; + } + }); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow._NoUI.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow._NoUI.axaml.cs new file mode 100644 index 00000000..6ec36db2 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow._NoUI.axaml.cs @@ -0,0 +1,24 @@ +using Avalonia.Controls; +using Dinah.Core.Drawing; +using LibationFileManager; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views +{ + public partial class MainWindow + { + private void Configure_NonUI() + { + // init default/placeholder cover art + var format = System.Drawing.Imaging.ImageFormat.Jpeg; + PictureStorage.SetDefaultImage(PictureSize._80x80, Properties.Resources.default_cover_80x80.ToBytes(format)); + PictureStorage.SetDefaultImage(PictureSize._300x300, Properties.Resources.default_cover_300x300.ToBytes(format)); + PictureStorage.SetDefaultImage(PictureSize._500x500, Properties.Resources.default_cover_500x500.ToBytes(format)); + PictureStorage.SetDefaultImage(PictureSize.Native, Properties.Resources.default_cover_500x500.ToBytes(format)); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml new file mode 100644 index 00000000..8cfe327b --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + Toggle _Me0 + + + + + + + + + + + + + + + + + + + + + + + + Toggle _Me0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProcessBookControl2.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProcessBookControl2.axaml.cs new file mode 100644 index 00000000..9519374d --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProcessBookControl2.axaml.cs @@ -0,0 +1,43 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using LibationWinForms.AvaloniaUI.ViewModels; + +namespace LibationWinForms.AvaloniaUI.Views +{ + public enum QueueButton + { + Cancel, + MoveFirst, + MoveUp, + MoveDown, + MoveLast + } + public delegate void QueueItemButtonClicked(ProcessBook2 item, QueueButton queueButton); + public partial class ProcessBookControl2 : UserControl + { + public static event QueueItemButtonClicked ButtonClicked; + public ProcessBookControl2() + { + InitializeComponent(); + } + + private ProcessBook2 DataItem => DataContext is null ? null : DataContext as ProcessBook2; + + public void Cancel_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => ButtonClicked?.Invoke(DataItem, QueueButton.Cancel); + public void MoveFirst_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => ButtonClicked?.Invoke(DataItem, QueueButton.MoveFirst); + public void MoveUp_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => ButtonClicked?.Invoke(DataItem, QueueButton.MoveUp); + public void MoveDown_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => ButtonClicked?.Invoke(DataItem, QueueButton.MoveDown); + public void MoveLast_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => ButtonClicked?.Invoke(DataItem, QueueButton.MoveLast); + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml b/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml new file mode 100644 index 00000000..f4cd64fe --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + Process Queue + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 00:00:25 + + + + diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml.cs new file mode 100644 index 00000000..9760cfc4 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml.cs @@ -0,0 +1,326 @@ +using ApplicationServices; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; +using LibationWinForms.AvaloniaUI.ViewModels; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views +{ + public partial class ProcessQueueControl2 : UserControl, ProcessQueue.ILogForm + { + private readonly ProcessQueueViewModel _viewModel; + private ItemsRepeater _repeater; + private ScrollViewer _scroller; + private int _selectedIndex; + private Random _random = new Random(0); + + + private TrackedQueue2 Queue => _viewModel.Items; + + private readonly ProcessQueue.LogMe Logger; + private int QueuedCount + { + set + { + queueNumberLbl_Text.Text = value.ToString(); + queueNumberLbl_Text.IsVisible = value > 0; + queueNumberLbl_Icon.IsVisible = value > 0; + } + } + private int ErrorCount + { + set + { + errorNumberLbl_Text.Text = value.ToString(); + errorNumberLbl_Text.IsVisible = value > 0; + errorNumberLbl_Icon.IsVisible = value > 0; + } + } + + private int CompletedCount + { + set + { + completedNumberLbl_Text.Text = value.ToString(); + completedNumberLbl_Text.IsVisible = value > 0; + completedNumberLbl_Icon.IsVisible = value > 0; + } + } + + public Task QueueRunner { get; private set; } + public bool Running => !QueueRunner?.IsCompleted ?? false; + + public ProcessQueueControl2() + { + InitializeComponent(); + _repeater = this.Get("repeater"); + _scroller = this.Get("scroller"); + _repeater.PointerPressed += RepeaterClick; + _repeater.KeyDown += RepeaterOnKeyDown; + DataContext = _viewModel = new ProcessQueueViewModel(); + + ProcessBookControl2.ButtonClicked += ProcessBookControl2_ButtonClicked; + + queueNumberLbl_Icon = this.FindControl(nameof(queueNumberLbl_Icon)); + errorNumberLbl_Icon = this.FindControl(nameof(errorNumberLbl_Icon)); + completedNumberLbl_Icon = this.FindControl(nameof(completedNumberLbl_Icon)); + + queueNumberLbl_Text = this.FindControl(nameof(queueNumberLbl_Text)); + errorNumberLbl_Text = this.FindControl(nameof(errorNumberLbl_Text)); + completedNumberLbl_Text = this.FindControl(nameof(completedNumberLbl_Text)); + + runningTimeLbl = this.FindControl(nameof(runningTimeLbl)); + + toolStripProgressBar1 = this.FindControl(nameof(toolStripProgressBar1)); + + Logger = ProcessQueue.LogMe.RegisterForm(this); + + Queue.QueuededCountChanged += Queue_QueuededCountChanged; + Queue.CompletedCountChanged += Queue_CompletedCountChanged; + + if (Design.IsDesignMode) + return; + + runningTimeLbl.Text = string.Empty; + QueuedCount = 0; + ErrorCount = 0; + CompletedCount = 0; + + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private async void ProcessBookControl2_ButtonClicked(ProcessBook2 item, QueueButton queueButton) + { + switch (queueButton) + { + case QueueButton.MoveFirst: + Queue.MoveQueuePosition(item, QueuePosition.Fisrt); + break; + case QueueButton.MoveUp: + Queue.MoveQueuePosition(item, QueuePosition.OneUp); + break; + case QueueButton.MoveDown: + Queue.MoveQueuePosition(item, QueuePosition.OneDown); + break; + case QueueButton.MoveLast: + Queue.MoveQueuePosition(item, QueuePosition.Last); + break; + case QueueButton.Cancel: + if (item is not null) + await item.CancelAsync(); + Queue.RemoveQueued(item); + break; + } + } + + private void RepeaterClick(object sender, PointerPressedEventArgs e) + { + if ((e.Source as TextBlock)?.DataContext is ProcessBook2 item) + { + _viewModel.SelectedItem = item; + _selectedIndex = _viewModel.Items.IndexOf(item); + } + } + + private void RepeaterOnKeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.F5) + { + //_viewModel.ResetItems(); + } + } + public async void CancelAllBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + Queue.ClearQueue(); + if (Queue.Current is not null) + await Queue.Current.CancelAsync(); + } + + public void ClearFinishedBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + Queue.ClearCompleted(); + + if (!Running) + runningTimeLbl.Text = string.Empty; + } + + private bool isBookInQueue(DataLayer.LibraryBook libraryBook) + => Queue.Any(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId); + + public void AddDownloadPdf(DataLayer.LibraryBook libraryBook) + => AddDownloadPdf(new List() { libraryBook }); + + public void AddDownloadDecrypt(DataLayer.LibraryBook libraryBook) + => AddDownloadDecrypt(new List() { libraryBook }); + + public void AddConvertMp3(DataLayer.LibraryBook libraryBook) + => AddConvertMp3(new List() { libraryBook }); + + public void AddDownloadPdf(IEnumerable entries) + { + List procs = new(); + foreach (var entry in entries) + { + if (isBookInQueue(entry)) + continue; + + ProcessBook2 pbook = new(entry, Logger); + pbook.AddDownloadPdf(); + procs.Add(pbook); + } + + Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); + AddToQueue(procs); + } + + public void AddDownloadDecrypt(IEnumerable entries) + { + List procs = new(); + foreach (var entry in entries) + { + if (isBookInQueue(entry)) + continue; + + ProcessBook2 pbook = new(entry, Logger); + pbook.AddDownloadDecryptBook(); + pbook.AddDownloadPdf(); + procs.Add(pbook); + } + + Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); + AddToQueue(procs); + } + + public void AddConvertMp3(IEnumerable entries) + { + List procs = new(); + foreach (var entry in entries) + { + if (isBookInQueue(entry)) + continue; + + ProcessBook2 pbook = new(entry, Logger); + pbook.AddConvertToMp3(); + procs.Add(pbook); + } + + Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); + AddToQueue(procs); + } + private void AddToQueue(IEnumerable pbook) + { + Dispatcher.UIThread.Post(() => + { + Queue.Enqueue(pbook); + if (!Running) + QueueRunner = QueueLoop(); + }); + } + + DateTime StartingTime; + private async Task QueueLoop() + { + try + { + Serilog.Log.Logger.Information("Begin processing queue"); + + StartingTime = DateTime.Now; + + using var counterTimer = new System.Threading.Timer(CounterTimer_Tick, null, 0, 500); + + while (Queue.MoveNext()) + { + var nextBook = Queue.Current; + + Serilog.Log.Logger.Information("Begin processing queued item. {item_LibraryBook}", nextBook?.LibraryBook); + + var result = await nextBook.ProcessOneAsync(); + + Serilog.Log.Logger.Information("Completed processing queued item: {item_LibraryBook}\r\nResult: {result}", nextBook?.LibraryBook, result); + + if (result == ProcessBookResult.ValidationFail) + Queue.ClearCurrent(); + else if (result == ProcessBookResult.FailedAbort) + Queue.ClearQueue(); + else if (result == ProcessBookResult.FailedSkip) + nextBook.LibraryBook.Book.UpdateBookStatus(DataLayer.LiberatedStatus.Error); + } + Serilog.Log.Logger.Information("Completed processing queue"); + + Queue_CompletedCountChanged(this, 0); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "An error was encountered while processing queued items"); + } + } + + public void WriteLine(string text) + { + + } + + #region Control event handlers + + private void Queue_CompletedCountChanged(object sender, int e) + { + int errCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.FailedAbort or ProcessBookResult.FailedSkip or ProcessBookResult.FailedRetry or ProcessBookResult.ValidationFail); + int completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success); + + ErrorCount = errCount; + CompletedCount = completeCount; + UpdateProgressBar(); + } + private void Queue_QueuededCountChanged(object sender, int cueCount) + { + QueuedCount = cueCount; + UpdateProgressBar(); + } + private void UpdateProgressBar() + { + double percent = 100d * Queue.Completed.Count / Queue.Count; + toolStripProgressBar1.Value = percent; + } + + private async void cancelAllBtn_Click(object sender, EventArgs e) + { + Queue.ClearQueue(); + if (Queue.Current is not null) + await Queue.Current.CancelAsync(); + } + + private void btnClearFinished_Click(object sender, EventArgs e) + { + Queue.ClearCompleted(); + + if (!Running) + runningTimeLbl.Text = string.Empty; + } + + private void CounterTimer_Tick(object? state) + { + string timeToStr(TimeSpan time) + { + string minsSecs = $"{time:mm\\:ss}"; + if (time.TotalHours >= 1) + return $"{time.TotalHours:F0}:{minsSecs}"; + return minsSecs; + } + + if (Running) + Dispatcher.UIThread.Post(() => runningTimeLbl.Text = timeToStr(DateTime.Now - StartingTime)); + } + + #endregion + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml new file mode 100644 index 00000000..9065dd7f --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + Queue Log + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml.cs index 9760cfc4..e708737d 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml.cs @@ -7,6 +7,7 @@ using Avalonia.Threading; using LibationWinForms.AvaloniaUI.ViewModels; using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; @@ -65,7 +66,8 @@ namespace LibationWinForms.AvaloniaUI.Views _repeater.KeyDown += RepeaterOnKeyDown; DataContext = _viewModel = new ProcessQueueViewModel(); - ProcessBookControl2.ButtonClicked += ProcessBookControl2_ButtonClicked; + ProcessBookControl2.PositionButtonClicked += ProcessBookControl2_ButtonClicked; + ProcessBookControl2.CancelButtonClicked += ProcessBookControl2_CancelButtonClicked; queueNumberLbl_Icon = this.FindControl(nameof(queueNumberLbl_Icon)); errorNumberLbl_Icon = this.FindControl(nameof(errorNumberLbl_Icon)); @@ -99,29 +101,17 @@ namespace LibationWinForms.AvaloniaUI.Views AvaloniaXamlLoader.Load(this); } - private async void ProcessBookControl2_ButtonClicked(ProcessBook2 item, QueueButton queueButton) + private async void ProcessBookControl2_CancelButtonClicked(ProcessBook2 item) + { + if (item is not null) + await item.CancelAsync(); + Queue.RemoveQueued(item); + } + + private void ProcessBookControl2_ButtonClicked(ProcessBook2 item, QueuePosition queueButton) { - switch (queueButton) - { - case QueueButton.MoveFirst: - Queue.MoveQueuePosition(item, QueuePosition.Fisrt); - break; - case QueueButton.MoveUp: - Queue.MoveQueuePosition(item, QueuePosition.OneUp); - break; - case QueueButton.MoveDown: - Queue.MoveQueuePosition(item, QueuePosition.OneDown); - break; - case QueueButton.MoveLast: - Queue.MoveQueuePosition(item, QueuePosition.Last); - break; - case QueueButton.Cancel: - if (item is not null) - await item.CancelAsync(); - Queue.RemoveQueued(item); - break; - } - } + Queue.MoveQueuePosition(item, queueButton); + } private void RepeaterClick(object sender, PointerPressedEventArgs e) { @@ -154,6 +144,18 @@ namespace LibationWinForms.AvaloniaUI.Views runningTimeLbl.Text = string.Empty; } + public void ClearLogBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + _viewModel.LogEntries.Clear(); + } + + private void LogCopyBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + string logText = string.Join("\r\n", _viewModel.LogEntries.Select(r => $"{r.LogDate.ToShortDateString()} {r.LogDate.ToShortTimeString()}\t{r.LogMessage}")); + System.Windows.Forms.Clipboard.SetDataObject(logText, false, 5, 150); + } + + private bool isBookInQueue(DataLayer.LibraryBook libraryBook) => Queue.Any(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId); @@ -267,7 +269,12 @@ namespace LibationWinForms.AvaloniaUI.Views public void WriteLine(string text) { - + Dispatcher.UIThread.Post(() => + _viewModel.LogEntries.Add(new() + { + LogDate = DateTime.Now, + LogMessage = text.Trim() + })); } #region Control event handlers diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml index ed3449e4..e6b36885 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml @@ -13,7 +13,7 @@ - + From f8e9c16bc14dd6501ff30c14b0568bf32da1f9d0 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Mon, 11 Jul 2022 21:57:41 -0600 Subject: [PATCH 05/47] Change some defaults --- .../AvaloniaUI/ViewModels/LibraryBookEntry2.cs | 2 +- .../AvaloniaUI/ViewModels/SeriesEntrys2.cs | 2 +- .../AvaloniaUI/Views/ProductsDisplay2.axaml | 8 ++++---- .../AvaloniaUI/Views/ProductsDisplay2.axaml.cs | 2 ++ 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs index be7f5cf5..84a77d82 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs @@ -81,7 +81,7 @@ namespace LibationWinForms.AvaloniaUI.ViewModels Category = string.Join(" > ", Book.CategoriesNames()); Misc = GetMiscDisplay(libraryBook); LongDescription = GetDescriptionDisplay(Book); - Description = TrimTextToWord(LongDescription, 62); + Description = LongDescription; SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0; NotifyPropertyChanged(nameof(Title)); diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs index 8a268652..118dfe46 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs @@ -91,7 +91,7 @@ namespace LibationWinForms.AvaloniaUI.ViewModels Category = string.Join(" > ", Book.CategoriesNames()); Misc = GetMiscDisplay(LibraryBook); LongDescription = GetDescriptionDisplay(Book); - Description = TrimTextToWord(LongDescription, 62); + Description = LongDescription; int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes); Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min"; diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml index e6b36885..1c621246 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml @@ -86,7 +86,7 @@ - + @@ -106,7 +106,7 @@ - + @@ -116,7 +116,7 @@ - + @@ -126,7 +126,7 @@ - + diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs index ae2c344c..23481a26 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs @@ -267,6 +267,8 @@ namespace LibationWinForms.AvaloniaUI.Views RemoveBooks(selectedBooks); var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList(); var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove); + + RemovableCountChanged?.Invoke(this, GetAllBookEntries().Count(lbe => lbe.Remove is true)); } public async Task ScanAndRemoveBooksAsync(params Account[] accounts) { From 5f45d28b9ff6ece9ad98bdec4b19d5895810cf21 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Tue, 12 Jul 2022 00:18:56 -0600 Subject: [PATCH 06/47] Refinements --- .../AvaloniaUI/Assets/edit_25x25.png | Bin 0 -> 747 bytes .../AvaloniaUI/ViewModels/BookTags.cs | 47 +-------- .../ViewModels/LibraryBookEntry2.cs | 2 +- .../AvaloniaUI/ViewModels/ProcessBook2.cs | 4 +- .../ViewModels/ProcessQueueViewModel.cs | 20 ++-- .../AvaloniaUI/ViewModels/SeriesEntrys2.cs | 6 +- .../Views/ProcessQueueControl2.axaml | 2 +- .../Views/ProcessQueueControl2.axaml.cs | 96 +++++++++++++----- .../AvaloniaUI/Views/ProductsDisplay2.axaml | 26 ++--- .../Views/ProductsDisplay2.axaml.cs | 18 ++-- .../LibationWinForms/LibationWinForms.csproj | 1 + 11 files changed, 115 insertions(+), 107 deletions(-) create mode 100644 Source/LibationWinForms/AvaloniaUI/Assets/edit_25x25.png diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/edit_25x25.png b/Source/LibationWinForms/AvaloniaUI/Assets/edit_25x25.png new file mode 100644 index 0000000000000000000000000000000000000000..12e70d0f1edeab1507607513178535e4a578dda6 GIT binary patch literal 747 zcmVP000>X1^@s6#OZ}&00001b5ch_0Itp) z=>Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0)RR$bUivCZTjzmrLD5;A$TUHOGC_tl)=J@?$1GHsyJBO?x3xrFWn{Fy~2 zN)_MVP)N%2#+djKx=uJlA4u$IqA^&P_N7v0ci|@JQkK~0NnxLKBv9pSuBjho9ln@PxTG7X+cEBYfNQ_Sc7W@%_2u- z;d_X1d8S;hDdZ0(hU!b*^%Z-_vN7+G!3~TR5O>`3a2_cYXMLX((%k(D{0)v~)6{QF zpHH}Mp;iqvxMtBQ1Jw^WH{+LIhC>`l1oDal=Va+iDQJvagc>A}ffjiZj>|wdISRn* za2U!o+OBE^ z5iSJftG4#W^Sp4TO+6vF$6J_bnuVSNbQM!k&`&rQ)PjO*%$O0`6i&CQuj7Iaf;Nrz z89-Oi>P^&ZnzdrzISJ<$UW~Ez>KCFqjI5rZLQm1Z0OwJIf?=b<^|^_S)f*zRjBm|C zy#VKy7MCALV2bFg&cIz?Nqs*jal-E_Xm-P&aCFD!DwU=BLPUcc*P8YJK%zA%vZYhZ zcA%PAHgjlp!Lc=oLKfkVaC6+Rj==h_qAR3~w)6X7Fueuj1R(mLwn%Go4)*J#wMzp( d>;xbp`U|)M@ !string.IsNullOrEmpty(Tags); } } diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs index 84a77d82..be7f5cf5 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs @@ -81,7 +81,7 @@ namespace LibationWinForms.AvaloniaUI.ViewModels Category = string.Join(" > ", Book.CategoriesNames()); Misc = GetMiscDisplay(libraryBook); LongDescription = GetDescriptionDisplay(Book); - Description = LongDescription; + Description = TrimTextToWord(LongDescription, 62); SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0; NotifyPropertyChanged(nameof(Title)); diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessBook2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessBook2.cs index 97241615..067fff42 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessBook2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessBook2.cs @@ -53,8 +53,8 @@ namespace LibationWinForms.AvaloniaUI.ViewModels private Bitmap _cover; #region Properties exposed to the view - public ProcessBookResult Result { get => _result; private set { _result = value; NotifyPropertyChanged(); NotifyPropertyChanged(nameof(StatusText)); } } - public ProcessBookStatus Status { get => _status; private set { _status = value; NotifyPropertyChanged(); NotifyPropertyChanged(nameof(BackgroundColor)); NotifyPropertyChanged(nameof(IsFinished)); NotifyPropertyChanged(nameof(IsDownloading)); NotifyPropertyChanged(nameof(Queued)); } } + public ProcessBookResult Result { get => _result; set { _result = value; NotifyPropertyChanged(); NotifyPropertyChanged(nameof(StatusText)); } } + public ProcessBookStatus Status { get => _status; set { _status = value; NotifyPropertyChanged(); NotifyPropertyChanged(nameof(BackgroundColor)); NotifyPropertyChanged(nameof(IsFinished)); NotifyPropertyChanged(nameof(IsDownloading)); NotifyPropertyChanged(nameof(Queued)); } } public string Narrator { get => _narrator; set { _narrator = value; NotifyPropertyChanged(); } } public string Author { get => _author; set { _author = value; NotifyPropertyChanged(); } } public string Title { get => _title; set { _title = value; NotifyPropertyChanged(); } } diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessQueueViewModel.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessQueueViewModel.cs index 1d610264..8d30937d 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessQueueViewModel.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessQueueViewModel.cs @@ -1,14 +1,11 @@ -using ReactiveUI; +using Avalonia.Threading; +using ReactiveUI; using System; -using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace LibationWinForms.AvaloniaUI.ViewModels { - public class ProcessQueueViewModel : ViewModelBase + public class ProcessQueueViewModel : ViewModelBase, ProcessQueue.ILogForm { public string QueueHeader => "this is a header!"; @@ -24,6 +21,17 @@ namespace LibationWinForms.AvaloniaUI.ViewModels public ObservableCollection LogEntries { get; } = new(); public ProcessBook2 SelectedItem { get; set; } + + public void WriteLine(string text) + { + Dispatcher.UIThread.Post(() => + LogEntries.Add(new() + { + LogDate = DateTime.Now, + LogMessage = text.Trim() + })); + } + } public class LogEntry diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs index 118dfe46..3a403335 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs @@ -91,13 +91,11 @@ namespace LibationWinForms.AvaloniaUI.ViewModels Category = string.Join(" > ", Book.CategoriesNames()); Misc = GetMiscDisplay(LibraryBook); LongDescription = GetDescriptionDisplay(Book); - Description = LongDescription; + Description = TrimTextToWord(LongDescription, 62); int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes); Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min"; - - NotifyPropertyChanged(nameof(Title)); NotifyPropertyChanged(nameof(Series)); NotifyPropertyChanged(nameof(Length)); @@ -110,8 +108,6 @@ namespace LibationWinForms.AvaloniaUI.ViewModels NotifyPropertyChanged(nameof(Misc)); NotifyPropertyChanged(nameof(LongDescription)); NotifyPropertyChanged(nameof(Description)); - - NotifyPropertyChanged(); } #region Data Sorting diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml b/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml index 0145e46b..8bb8c708 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml @@ -5,7 +5,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:views="clr-namespace:LibationWinForms.AvaloniaUI.Views" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="700" + mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="850" x:Class="LibationWinForms.AvaloniaUI.Views.ProcessQueueControl2"> diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml.cs index e708737d..5c2200c5 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml.cs @@ -4,6 +4,7 @@ using Avalonia.Controls; using Avalonia.Input; using Avalonia.Markup.Xaml; using Avalonia.Threading; +using DataLayer; using LibationWinForms.AvaloniaUI.ViewModels; using System; using System.Collections.Generic; @@ -13,7 +14,7 @@ using System.Threading.Tasks; namespace LibationWinForms.AvaloniaUI.Views { - public partial class ProcessQueueControl2 : UserControl, ProcessQueue.ILogForm + public partial class ProcessQueueControl2 : UserControl { private readonly ProcessQueueViewModel _viewModel; private ItemsRepeater _repeater; @@ -65,6 +66,7 @@ namespace LibationWinForms.AvaloniaUI.Views _repeater.PointerPressed += RepeaterClick; _repeater.KeyDown += RepeaterOnKeyDown; DataContext = _viewModel = new ProcessQueueViewModel(); + Logger = ProcessQueue.LogMe.RegisterForm(_viewModel); ProcessBookControl2.PositionButtonClicked += ProcessBookControl2_ButtonClicked; ProcessBookControl2.CancelButtonClicked += ProcessBookControl2_CancelButtonClicked; @@ -81,19 +83,68 @@ namespace LibationWinForms.AvaloniaUI.Views toolStripProgressBar1 = this.FindControl(nameof(toolStripProgressBar1)); - Logger = ProcessQueue.LogMe.RegisterForm(this); Queue.QueuededCountChanged += Queue_QueuededCountChanged; Queue.CompletedCountChanged += Queue_CompletedCountChanged; + #region Design Mode Testing if (Design.IsDesignMode) - return; + { + using var context = DbContexts.GetContext(); + var book = context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"); + List testList = new() + { + new ProcessBook2(book, Logger) + { + Result = ProcessBookResult.FailedAbort, + Status = ProcessBookStatus.Failed, + }, + new ProcessBook2(book, Logger) + { + Result = ProcessBookResult.FailedSkip, + Status = ProcessBookStatus.Failed, + }, + new ProcessBook2(book, Logger) + { + Result = ProcessBookResult.FailedRetry, + Status = ProcessBookStatus.Failed, + }, + new ProcessBook2(book, Logger) + { + Result = ProcessBookResult.ValidationFail, + Status = ProcessBookStatus.Failed, + }, + new ProcessBook2(book, Logger) + { + Result = ProcessBookResult.Cancelled, + Status = ProcessBookStatus.Cancelled, + }, + new ProcessBook2(book, Logger) + { + Result = ProcessBookResult.Success, + Status = ProcessBookStatus.Completed, + }, + new ProcessBook2(book, Logger) + { + Result = ProcessBookResult.None, + Status = ProcessBookStatus.Working, + }, + new ProcessBook2(book, Logger) + { + Result = ProcessBookResult.None, + Status = ProcessBookStatus.Queued, + }, + }; - runningTimeLbl.Text = string.Empty; - QueuedCount = 0; - ErrorCount = 0; - CompletedCount = 0; + _viewModel.Items.Enqueue(testList); + return; + } + #endregion + runningTimeLbl.Text = string.Empty; + QueuedCount = 0; + ErrorCount = 0; + CompletedCount = 0; } private void InitializeComponent() @@ -156,19 +207,19 @@ namespace LibationWinForms.AvaloniaUI.Views } - private bool isBookInQueue(DataLayer.LibraryBook libraryBook) + private bool isBookInQueue(LibraryBook libraryBook) => Queue.Any(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId); - public void AddDownloadPdf(DataLayer.LibraryBook libraryBook) - => AddDownloadPdf(new List() { libraryBook }); + public void AddDownloadPdf(LibraryBook libraryBook) + => AddDownloadPdf(new List() { libraryBook }); - public void AddDownloadDecrypt(DataLayer.LibraryBook libraryBook) - => AddDownloadDecrypt(new List() { libraryBook }); + public void AddDownloadDecrypt(LibraryBook libraryBook) + => AddDownloadDecrypt(new List() { libraryBook }); - public void AddConvertMp3(DataLayer.LibraryBook libraryBook) - => AddConvertMp3(new List() { libraryBook }); + public void AddConvertMp3(LibraryBook libraryBook) + => AddConvertMp3(new List() { libraryBook }); - public void AddDownloadPdf(IEnumerable entries) + public void AddDownloadPdf(IEnumerable entries) { List procs = new(); foreach (var entry in entries) @@ -185,7 +236,7 @@ namespace LibationWinForms.AvaloniaUI.Views AddToQueue(procs); } - public void AddDownloadDecrypt(IEnumerable entries) + public void AddDownloadDecrypt(IEnumerable entries) { List procs = new(); foreach (var entry in entries) @@ -203,7 +254,7 @@ namespace LibationWinForms.AvaloniaUI.Views AddToQueue(procs); } - public void AddConvertMp3(IEnumerable entries) + public void AddConvertMp3(IEnumerable entries) { List procs = new(); foreach (var entry in entries) @@ -266,17 +317,6 @@ namespace LibationWinForms.AvaloniaUI.Views Serilog.Log.Logger.Error(ex, "An error was encountered while processing queued items"); } } - - public void WriteLine(string text) - { - Dispatcher.UIThread.Post(() => - _viewModel.LogEntries.Add(new() - { - LogDate = DateTime.Now, - LogMessage = text.Trim() - })); - } - #region Control event handlers private void Queue_CompletedCountChanged(object sender, int e) diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml index 1c621246..15f17211 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml @@ -6,13 +6,11 @@ xmlns:controls="clr-namespace:LibationWinForms.AvaloniaUI.Controls" mc:Ignorable="d" d:DesignWidth="1560" d:DesignHeight="400" x:Class="LibationWinForms.AvaloniaUI.Views.ProductsDisplay2"> - - + - - + @@ -25,8 +23,7 @@ - - + @@ -36,7 +33,7 @@ - + @@ -46,7 +43,7 @@ - + @@ -56,7 +53,7 @@ - + @@ -66,7 +63,7 @@ - + @@ -76,7 +73,7 @@ - + @@ -150,7 +147,12 @@ - diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs index 23481a26..61db5b9c 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs @@ -3,6 +3,7 @@ using AudibleUtilities; using Avalonia; using Avalonia.Controls; using Avalonia.Markup.Xaml; +using Avalonia.Media; using DataLayer; using Dinah.Core.DataBinding; using FileLiberator; @@ -26,7 +27,7 @@ namespace LibationWinForms.AvaloniaUI.Views public event EventHandler InitialLoaded; private ProductsDisplayViewModel _viewModel; - private GridEntryBindingList2 bindingList => productsGrid.Items as GridEntryBindingList2; + private GridEntryBindingList2 bindingList => _viewModel.GridEntries; private IEnumerable GetAllBookEntries() => bindingList.AllItems().BookEntries(); @@ -46,12 +47,14 @@ namespace LibationWinForms.AvaloniaUI.Views productsGrid.CanUserSortColumns = true; removeGVColumn = productsGrid.Columns[0]; - } - public override void EndInit() - { - base.EndInit(); - } + if (Design.IsDesignMode) + { + using var context = DbContexts.GetContext(); + var book = context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"); + productsGrid.DataContext = _viewModel = new ProductsDisplayViewModel(new List { book }); + } + } private void InitializeComponent() { AvaloniaXamlLoader.Load(this); @@ -325,7 +328,8 @@ namespace LibationWinForms.AvaloniaUI.Views InitialLoaded?.Invoke(this, EventArgs.Empty); VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); } - UpdateGrid(dbBooks); + else + UpdateGrid(dbBooks); } catch (Exception ex) { diff --git a/Source/LibationWinForms/LibationWinForms.csproj b/Source/LibationWinForms/LibationWinForms.csproj index 579d53ad..a2854712 100644 --- a/Source/LibationWinForms/LibationWinForms.csproj +++ b/Source/LibationWinForms/LibationWinForms.csproj @@ -43,6 +43,7 @@ + From 6e091230cfc86472b5ff232fb6ea447379023263 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Tue, 12 Jul 2022 18:55:10 -0600 Subject: [PATCH 07/47] Use ReactiveUI. Sort of fix remove book checkbox column. --- .../DataGridCheckBoxColumnExt.axaml.cs | 30 +---- .../AvaloniaUI/ViewModels/BookTags.cs | 6 +- .../AvaloniaUI/ViewModels/GridEntry2.cs | 50 ++++---- .../ViewModels/LiberateButtonStatus2.cs | 29 ++--- .../ViewModels/LibraryBookEntry2.cs | 26 ++--- .../AvaloniaUI/ViewModels/ProcessBook2.cs | 24 ++-- .../ViewModels/ProcessQueueViewModel.cs | 19 +-- .../AvaloniaUI/ViewModels/SeriesEntrys2.cs | 31 +---- .../AvaloniaUI/ViewModels/ViewModelBase.cs | 2 + .../AvaloniaUI/Views/ProductsDisplay2.axaml | 4 +- .../Views/ProductsDisplay2.axaml.cs | 109 +++++++++++++++--- 11 files changed, 171 insertions(+), 159 deletions(-) diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs index be8157a0..9e82e369 100644 --- a/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs @@ -1,35 +1,15 @@ using Avalonia.Controls; +using Avalonia.Controls.Utils; using Avalonia.Interactivity; using LibationWinForms.AvaloniaUI.ViewModels; +using System; +using System.Linq; namespace LibationWinForms.AvaloniaUI.Controls { - /// The purpose of this extension is to immediately commit any check state changes to the viewmodel + /// The purpose of this extension WAS to immediately commit any check state changes to the viewmodel, but for the life of me I cannot get it to work! public partial class DataGridCheckBoxColumnExt : DataGridCheckBoxColumn { - protected override IControl GenerateEditingElementDirect(DataGridCell cell, object dataItem) - { - var ele = base.GenerateEditingElementDirect(cell, dataItem) as CheckBox; - ele.Checked += EditingElement_Checked; - ele.Unchecked += EditingElement_Checked; - ele.Indeterminate += EditingElement_Checked; - return ele; - } - - private void EditingElement_Checked(object sender, RoutedEventArgs e) - { - if (sender is CheckBox cbox && cbox.DataContext is GridEntry2 gentry) - { - gentry.Remove = cbox.IsChecked; - FindDataGridParent(cbox)?.CommitEdit(DataGridEditingUnit.Cell, false); - } - } - - DataGrid? FindDataGridParent(IControl? control) - { - if (control?.Parent is null) return null; - else if (control?.Parent is DataGrid dg) return dg; - else return FindDataGridParent(control?.Parent); - } + } } diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/BookTags.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/BookTags.cs index ab9717d4..cf2c7664 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/BookTags.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/BookTags.cs @@ -2,8 +2,8 @@ { public class BookTags { - public string Tags { get; init; } - public bool IsSeries { get; init; } - public bool HasTags => !string.IsNullOrEmpty(Tags); + private string _tags; + public string Tags { get => _tags; init { _tags = value; HasTags = !string.IsNullOrEmpty(_tags); } } + public bool HasTags { get; init; } } } diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs index afd46f72..21e684b1 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs @@ -21,7 +21,7 @@ namespace LibationWinForms.AvaloniaUI.ViewModels SomeRemoved } /// The View Model base for the DataGridView - public abstract class GridEntry2 : AsyncNotifyPropertyChanged2, IMemberComparable + public abstract class GridEntry2 : ViewModelBase { [Browsable(false)] public string AudibleProductId => Book.AudibleProductId; [Browsable(false)] public LibraryBook LibraryBook { get; protected set; } @@ -32,30 +32,35 @@ namespace LibationWinForms.AvaloniaUI.ViewModels #region Model properties exposed to the view + private Avalonia.Media.Imaging.Bitmap _cover; + private string _purchaseDate; + private string _series; + private string _title; + private string _length; + private string _authors; + private string _narrators; + private string _category; + private string _misc; + private string _description; + private string _productRating; + private string _myRating; + public Avalonia.Media.Imaging.Bitmap Cover { get => _cover; protected set { this.RaiseAndSetIfChanged(ref _cover, value); } } + public string PurchaseDate { get => _purchaseDate; protected set { this.RaiseAndSetIfChanged(ref _purchaseDate, value); } } + public string Series { get => _series; protected set { this.RaiseAndSetIfChanged(ref _series, value); } } + public string Title { get => _title; protected set { this.RaiseAndSetIfChanged(ref _title, value); } } + public string Length { get => _length; protected set { this.RaiseAndSetIfChanged(ref _length, value); } } + public string Authors { get => _authors; protected set { this.RaiseAndSetIfChanged(ref _authors, value); } } + public string Narrators { get => _narrators; protected set { this.RaiseAndSetIfChanged(ref _narrators, value); } } + public string Category { get => _category; protected set { this.RaiseAndSetIfChanged(ref _category, value); } } + public string Misc { get => _misc; protected set { this.RaiseAndSetIfChanged(ref _misc, value); } } + public string Description { get => _description; protected set { this.RaiseAndSetIfChanged(ref _description, value); } } + public string ProductRating { get => _productRating; protected set { this.RaiseAndSetIfChanged(ref _productRating, value); } } + public string MyRating { get => _myRating; protected set { this.RaiseAndSetIfChanged(ref _myRating, value); } } + + protected bool? _remove = false; public abstract bool? Remove { get; set; } - public abstract LiberateButtonStatus2 Liberate { get; } - public Avalonia.Media.Imaging.Bitmap Cover - { - get => _cover; - protected set - { - _cover = value; - NotifyPropertyChanged(); - } - } - public string PurchaseDate { get; protected set; } - public string Series { get; protected set; } - public string Title { get; protected set; } - public string Length { get; protected set; } - public string Authors { get; set; } - public string Narrators { get; protected set; } - public string Category { get; protected set; } - public string Misc { get; protected set; } - public string Description { get; protected set; } - public string ProductRating { get; protected set; } - public string MyRating { get; protected set; } public abstract BookTags BookTags { get; } #endregion @@ -87,7 +92,6 @@ namespace LibationWinForms.AvaloniaUI.ViewModels #region Cover Art - private Avalonia.Media.Imaging.Bitmap _cover; protected void LoadCover() { // Get cover art. If it's default, subscribe to PictureCached diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus2.cs index 5d044633..f17c519c 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus2.cs @@ -1,13 +1,12 @@ using Avalonia.Media.Imaging; using DataLayer; +using ReactiveUI; using System; using System.Collections.Generic; -using System.ComponentModel; -using System.Runtime.CompilerServices; namespace LibationWinForms.AvaloniaUI.ViewModels { - public class LiberateButtonStatus2 : IComparable, INotifyPropertyChanged + public class LiberateButtonStatus2 : ViewModelBase, IComparable { public LiberatedStatus BookStatus { get; set; } public LiberatedStatus? PdfStatus { get; set; } @@ -18,10 +17,9 @@ namespace LibationWinForms.AvaloniaUI.ViewModels get => _expanded; set { - _expanded = value; - NotifyPropertyChanged(); - NotifyPropertyChanged(nameof(Image)); - NotifyPropertyChanged(nameof(ToolTip)); + this.RaiseAndSetIfChanged(ref _expanded, value); + this.RaisePropertyChanged(nameof(Image)); + this.RaisePropertyChanged(nameof(ToolTip)); } } public bool IsSeries { get; init; } @@ -30,13 +28,7 @@ namespace LibationWinForms.AvaloniaUI.ViewModels static Dictionary images = new(); - public event PropertyChangedEventHandler PropertyChanged; - - public void NotifyPropertyChanged([CallerMemberName] string propertyName = "") => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - - /// - /// Defines the Liberate column's sorting behavior - /// + /// Defines the Liberate column's sorting behavior public int CompareTo(object obj) { if (obj is not LiberateButtonStatus2 second) return -1; @@ -49,14 +41,13 @@ namespace LibationWinForms.AvaloniaUI.ViewModels else return BookStatus.CompareTo(second.BookStatus); } - private Bitmap GetLiberateIcon() { if (IsSeries) - return Expanded ? GetFromresc("minus") : GetFromresc("plus"); + return Expanded ? GetFromResources("minus") : GetFromResources("plus"); if (BookStatus == LiberatedStatus.Error) - return GetFromresc("error"); + return GetFromResources("error"); string image_lib = BookStatus switch { @@ -75,7 +66,7 @@ namespace LibationWinForms.AvaloniaUI.ViewModels _ => throw new Exception("Unexpected PDF state") }; - return GetFromresc($"liberate_{image_lib}{image_pdf}"); + return GetFromResources($"liberate_{image_lib}{image_pdf}"); } private string GetTooltip() { @@ -113,7 +104,7 @@ namespace LibationWinForms.AvaloniaUI.ViewModels return mouseoverText; } - private static Bitmap GetFromresc(string rescName) + private static Bitmap GetFromResources(string rescName) { if (images.ContainsKey(rescName)) return images[rescName]; diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs index be7f5cf5..7e46c741 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs @@ -2,6 +2,7 @@ using DataLayer; using Dinah.Core; using LibationWinForms.GridView; +using ReactiveUI; using System; using System.Collections.Generic; using System.ComponentModel; @@ -27,8 +28,9 @@ namespace LibationWinForms.AvaloniaUI.ViewModels set { _remove = value.HasValue ? value.Value : false; + Parent?.ChildRemoveUpdate(); - NotifyPropertyChanged(); + this.RaisePropertyChanged(nameof(Remove)); } } @@ -84,20 +86,6 @@ namespace LibationWinForms.AvaloniaUI.ViewModels Description = TrimTextToWord(LongDescription, 62); SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0; - NotifyPropertyChanged(nameof(Title)); - NotifyPropertyChanged(nameof(Series)); - NotifyPropertyChanged(nameof(Length)); - NotifyPropertyChanged(nameof(MyRating)); - NotifyPropertyChanged(nameof(PurchaseDate)); - NotifyPropertyChanged(nameof(ProductRating)); - NotifyPropertyChanged(nameof(Authors)); - NotifyPropertyChanged(nameof(Narrators)); - NotifyPropertyChanged(nameof(Category)); - NotifyPropertyChanged(nameof(Misc)); - NotifyPropertyChanged(nameof(LongDescription)); - NotifyPropertyChanged(nameof(Description)); - NotifyPropertyChanged(nameof(SeriesIndex)); - UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged; } @@ -120,18 +108,18 @@ namespace LibationWinForms.AvaloniaUI.ViewModels switch (itemName) { case nameof(udi.Tags): - Book.UserDefinedItem.Tags = udi.Tags; - NotifyPropertyChanged(nameof(BookTags)); + Book.UserDefinedItem.Tags = udi.Tags; + this.RaisePropertyChanged(nameof(BookTags)); break; case nameof(udi.BookStatus): Book.UserDefinedItem.BookStatus = udi.BookStatus; _bookStatus = udi.BookStatus; - NotifyPropertyChanged(nameof(Liberate)); + this.RaisePropertyChanged(nameof(Liberate)); break; case nameof(udi.PdfStatus): Book.UserDefinedItem.PdfStatus = udi.PdfStatus; _pdfStatus = udi.PdfStatus; - NotifyPropertyChanged(nameof(Liberate)); + this.RaisePropertyChanged(nameof(Liberate)); break; } } diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessBook2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessBook2.cs index 067fff42..be11604d 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessBook2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessBook2.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; -using System.ComponentModel; using System.Linq; -using System.Runtime.CompilerServices; using System.Threading.Tasks; using ApplicationServices; using Avalonia.Media.Imaging; @@ -10,6 +8,7 @@ using DataLayer; using Dinah.Core; using FileLiberator; using LibationFileManager; +using ReactiveUI; namespace LibationWinForms.AvaloniaUI.ViewModels { @@ -36,10 +35,9 @@ namespace LibationWinForms.AvaloniaUI.ViewModels /// /// This is the viewmodel for queued processables /// - public class ProcessBook2 : INotifyPropertyChanged + public class ProcessBook2 : ViewModelBase { public event EventHandler Completed; - public event PropertyChangedEventHandler PropertyChanged; public LibraryBook LibraryBook { get; private set; } @@ -53,14 +51,14 @@ namespace LibationWinForms.AvaloniaUI.ViewModels private Bitmap _cover; #region Properties exposed to the view - public ProcessBookResult Result { get => _result; set { _result = value; NotifyPropertyChanged(); NotifyPropertyChanged(nameof(StatusText)); } } - public ProcessBookStatus Status { get => _status; set { _status = value; NotifyPropertyChanged(); NotifyPropertyChanged(nameof(BackgroundColor)); NotifyPropertyChanged(nameof(IsFinished)); NotifyPropertyChanged(nameof(IsDownloading)); NotifyPropertyChanged(nameof(Queued)); } } - public string Narrator { get => _narrator; set { _narrator = value; NotifyPropertyChanged(); } } - public string Author { get => _author; set { _author = value; NotifyPropertyChanged(); } } - public string Title { get => _title; set { _title = value; NotifyPropertyChanged(); } } - public int Progress { get => _progress; private set { _progress = value; NotifyPropertyChanged(); } } - public string ETA { get => _eta; private set { _eta = value; NotifyPropertyChanged(); } } - public Bitmap Cover { get => _cover; private set { _cover = value; NotifyPropertyChanged(); } } + public ProcessBookResult Result { get => _result; set { this.RaiseAndSetIfChanged(ref _result, value); this.RaisePropertyChanged(nameof(StatusText)); } } + public ProcessBookStatus Status { get => _status; set { this.RaiseAndSetIfChanged(ref _status, value); this.RaisePropertyChanged(nameof(BackgroundColor)); this.RaisePropertyChanged(nameof(IsFinished)); this.RaisePropertyChanged(nameof(IsDownloading)); this.RaisePropertyChanged(nameof(Queued)); } } + public string Narrator { get => _narrator; set { this.RaiseAndSetIfChanged(ref _narrator, value); } } + public string Author { get => _author; set { this.RaiseAndSetIfChanged(ref _author, value); } } + public string Title { get => _title; set { this.RaiseAndSetIfChanged(ref _title, value); } } + public int Progress { get => _progress; private set { this.RaiseAndSetIfChanged(ref _progress, value); } } + public string ETA { get => _eta; private set { this.RaiseAndSetIfChanged(ref _eta, value); } } + public Bitmap Cover { get => _cover; private set { this.RaiseAndSetIfChanged(ref _cover, value); } } public bool IsFinished => Status is not ProcessBookStatus.Queued and not ProcessBookStatus.Working; public bool IsDownloading => Status is ProcessBookStatus.Working; public bool Queued => Status is ProcessBookStatus.Queued; @@ -91,8 +89,6 @@ namespace LibationWinForms.AvaloniaUI.ViewModels private Processable _currentProcessable; private readonly Queue> Processes = new(); private readonly ProcessQueue.LogMe Logger; - public void NotifyPropertyChanged([CallerMemberName] string propertyName = "") - => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); public ProcessBook2(LibraryBook libraryBook, ProcessQueue.LogMe logme) { diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessQueueViewModel.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessQueueViewModel.cs index 8d30937d..acde7ab6 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessQueueViewModel.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessQueueViewModel.cs @@ -7,31 +7,25 @@ namespace LibationWinForms.AvaloniaUI.ViewModels { public class ProcessQueueViewModel : ViewModelBase, ProcessQueue.ILogForm { - - public string QueueHeader => "this is a header!"; + public ObservableCollection LogEntries { get; } = new(); private TrackedQueue2 _items = new(); - public ProcessQueueViewModel() { } public TrackedQueue2 Items { get => _items; set => this.RaiseAndSetIfChanged(ref _items, value); } - - public ObservableCollection LogEntries { get; } = new(); - public ProcessBook2 SelectedItem { get; set; } public void WriteLine(string text) { Dispatcher.UIThread.Post(() => - LogEntries.Add(new() - { - LogDate = DateTime.Now, - LogMessage = text.Trim() - })); + LogEntries.Add(new() + { + LogDate = DateTime.Now, + LogMessage = text.Trim() + })); } - } public class LogEntry @@ -40,5 +34,4 @@ namespace LibationWinForms.AvaloniaUI.ViewModels public string LogDateString => LogDate.ToShortTimeString(); public string LogMessage { get; init; } } - } diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs index 3a403335..21c41bcb 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs @@ -1,6 +1,6 @@ using DataLayer; using Dinah.Core; -using LibationWinForms.GridView; +using ReactiveUI; using System; using System.Collections.Generic; using System.ComponentModel; @@ -21,13 +21,8 @@ namespace LibationWinForms.AvaloniaUI.ViewModels var removeCount = Children.Count(c => c.Remove == true); - if (removeCount == 0) - _remove = false; - else if (removeCount == Children.Count) - _remove = true; - else - _remove = null; - NotifyPropertyChanged(nameof(Remove)); + _remove = removeCount == 0 ? false : (removeCount == Children.Count ? true : null); + this.RaisePropertyChanged(nameof(Remove)); } #region Model properties exposed to the view @@ -36,7 +31,7 @@ namespace LibationWinForms.AvaloniaUI.ViewModels get => _remove; set { - _remove = value.HasValue ? value : false; + _remove = value.HasValue ? value.Value : false; suspendCounting = true; @@ -44,13 +39,12 @@ namespace LibationWinForms.AvaloniaUI.ViewModels item.Remove = value; suspendCounting = false; - - NotifyPropertyChanged(); + this.RaisePropertyChanged(nameof(Remove)); } } public override LiberateButtonStatus2 Liberate { get; } - public override BookTags BookTags { get; } = new() { IsSeries = true }; + public override BookTags BookTags { get; } = new(); #endregion @@ -95,19 +89,6 @@ namespace LibationWinForms.AvaloniaUI.ViewModels int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes); Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min"; - - NotifyPropertyChanged(nameof(Title)); - NotifyPropertyChanged(nameof(Series)); - NotifyPropertyChanged(nameof(Length)); - NotifyPropertyChanged(nameof(MyRating)); - NotifyPropertyChanged(nameof(PurchaseDate)); - NotifyPropertyChanged(nameof(ProductRating)); - NotifyPropertyChanged(nameof(Authors)); - NotifyPropertyChanged(nameof(Narrators)); - NotifyPropertyChanged(nameof(Category)); - NotifyPropertyChanged(nameof(Misc)); - NotifyPropertyChanged(nameof(LongDescription)); - NotifyPropertyChanged(nameof(Description)); } #region Data Sorting diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ViewModelBase.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ViewModelBase.cs index 29d0d3fc..060934f0 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/ViewModelBase.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ViewModelBase.cs @@ -1,3 +1,4 @@ +using Avalonia.Controls; using ReactiveUI; using System; using System.Collections.Generic; @@ -7,5 +8,6 @@ namespace LibationWinForms.AvaloniaUI.ViewModels { public class ViewModelBase : ReactiveObject { + } } diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml index 15f17211..55a4323f 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml @@ -11,7 +11,7 @@ - + @@ -147,7 +147,7 @@ - @@ -47,9 +48,10 @@ - - + + + diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml b/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml index 8bb8c708..c5d0be64 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl2.axaml @@ -10,31 +10,30 @@ - + - + - - - - - + + + + Process Queue - + + Queue Log - + @@ -84,11 +84,11 @@ - + diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml index 55a4323f..50361033 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml @@ -11,7 +11,7 @@ - + diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs index a4c123eb..5c40f5b1 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs @@ -38,6 +38,7 @@ namespace LibationWinForms.AvaloniaUI.Views .Select(lbe => lbe.LibraryBook) .ToList(); + DataGridColumn removeGVColumn; DataGridColumn liberateGVColumn; DataGridColumn coverGVColumn; @@ -53,12 +54,28 @@ namespace LibationWinForms.AvaloniaUI.Views DataGridColumn myRatingGVColumn; DataGridColumn miscGVColumn; DataGridColumn tagAndDetailsGVColumn; + + #region Init + public ProductsDisplay2() { InitializeComponent(); + + + if (Design.IsDesignMode) + { + using var context = DbContexts.GetContext(); + var book = context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"); + productsGrid.DataContext = _viewModel = new ProductsDisplayViewModel(new List { book }); + return; + } + } + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); productsGrid = this.FindControl(nameof(productsGrid)); - productsGrid.Sorting += Dg1_Sorting; + productsGrid.Sorting += ProductsGrid_Sorting; productsGrid.CanUserSortColumns = true; productsGrid.LoadingRow += ProductsGrid_LoadingRow; @@ -78,6 +95,42 @@ namespace LibationWinForms.AvaloniaUI.Views miscGVColumn = productsGrid.Columns[13]; tagAndDetailsGVColumn = productsGrid.Columns[14]; + RegisterCustomColumnComparers(); + } + + #endregion + + #region Apply Background Brush Style to Series Books Rows + + private static object tagObj = new(); + private void ProductsGrid_LoadingRow(object sender, DataGridRowEventArgs e) + { + if (e.Row.Tag == tagObj) + return; + e.Row.Tag = tagObj; + + static IBrush GetRowColor(DataGridRow row) + => row.DataContext is GridEntry2 gEntry + && gEntry is LibraryBookEntry2 lbEntry + && lbEntry.Parent is not null + ? App.SeriesEntryGridBackgroundBrush + : null; + + e.Row.Background = GetRowColor(e.Row); + e.Row.DataContextChanged += (sender, e) => + { + var row = sender as DataGridRow; + row.Background = GetRowColor(row); + }; + } + + #endregion + + #region Sorting + + private void RegisterCustomColumnComparers() + { + removeGVColumn.CustomSortComparer = new RowComparer(removeGVColumn); liberateGVColumn.CustomSortComparer = new RowComparer(liberateGVColumn); titleGVColumn.CustomSortComparer = new RowComparer(titleGVColumn); @@ -92,50 +145,27 @@ namespace LibationWinForms.AvaloniaUI.Views myRatingGVColumn.CustomSortComparer = new RowComparer(myRatingGVColumn); miscGVColumn.CustomSortComparer = new RowComparer(miscGVColumn); tagAndDetailsGVColumn.CustomSortComparer = new RowComparer(tagAndDetailsGVColumn); - - removeGVColumn.PropertyChanged += RemoveGVColumn_PropertyChanged; - - if (Design.IsDesignMode) - { - using var context = DbContexts.GetContext(); - var book = context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"); - productsGrid.DataContext = _viewModel = new ProductsDisplayViewModel(new List { book }); - return; - } } - private void RemoveGVColumn_PropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e) + private void ReSort() { - + if (CurrentSortColumn is null) + bindingList.InternalList.Sort((i1, i2) => i2.DateAdded.CompareTo(i1.DateAdded)); + else + CurrentSortColumn.Sort(((RowComparer)CurrentSortColumn.CustomSortComparer).SortDirection ?? ListSortDirection.Ascending); } - private static object tagObj = new(); - private static readonly IBrush SeriesBgColor = Brush.Parse("#ffe6ffe6"); - private void ProductsGrid_LoadingRow(object sender, DataGridRowEventArgs e) + private DataGridColumn CurrentSortColumn; + + private void ProductsGrid_Sorting(object sender, DataGridColumnEventArgs e) { - if (e.Row.Tag == tagObj) - return; - e.Row.Tag = tagObj; - - static IBrush GetRowColor(DataGridRow row) - => row.DataContext is GridEntry2 gEntry - && gEntry is LibraryBookEntry2 lbEntry - && lbEntry.Parent is not null - ? SeriesBgColor - : null; - - e.Row.Background = GetRowColor(e.Row); - e.Row.DataContextChanged += (sender, e) => - { - var row = sender as DataGridRow; - row.Background = GetRowColor(row); - }; - } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); + var comparer = e.Column.CustomSortComparer as RowComparer; + //Force the comparer to get the current sort order. We can't + //retrieve it from inside this event handler because Avalonia + //doesn't set the property until after this event. + comparer.SortDirection = null; + CurrentSortColumn = e.Column; } private class RowComparer : IComparer @@ -167,7 +197,7 @@ namespace LibationWinForms.AvaloniaUI.Views var geA = (GridEntry2)x; var geB = (GridEntry2)y; - SortDirection ??= GetSortOrder(Column); + SortDirection ??= GetSortOrder(); SeriesEntrys2 parentA = null; SeriesEntrys2 parentB = null; @@ -209,8 +239,8 @@ namespace LibationWinForms.AvaloniaUI.Views return Compare(parentA, parentB); } - private static ListSortDirection? GetSortOrder(DataGridColumn column) - => CurrentSortingStatePi.GetValue(HeaderCellPi.GetValue(column)) as ListSortDirection?; + private ListSortDirection? GetSortOrder() + => CurrentSortingStatePi.GetValue(HeaderCellPi.GetValue(Column)) as ListSortDirection?; private int Compare(GridEntry2 x, GridEntry2 y) { @@ -221,17 +251,7 @@ namespace LibationWinForms.AvaloniaUI.Views } } - DataGridColumn CurrentSortColumn; - - private void Dg1_Sorting(object sender, DataGridColumnEventArgs e) - { - var comparer = e.Column.CustomSortComparer as RowComparer; - //Force the comparer to get the current sort order. We can't - //retrieve it from inside this event handler because Avalonia - //doesn't set the property until after this event. - comparer.SortDirection = null; - CurrentSortColumn = e.Column; - } + #endregion #region Button controls @@ -244,7 +264,10 @@ namespace LibationWinForms.AvaloniaUI.Views if (sEntry.Liberate.Expanded) bindingList.CollapseItem(sEntry); else + { bindingList.ExpandItem(sEntry); + ReSort(); + } VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); } @@ -565,14 +588,9 @@ namespace LibationWinForms.AvaloniaUI.Views VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); //Re-sort after filtering - if (CurrentSortColumn is null) - bindingList.InternalList.Sort((i1, i2) => i2.DateAdded.CompareTo(i1.DateAdded)); - else - { - CurrentSortColumn.Sort(((RowComparer)CurrentSortColumn.CustomSortComparer).SortDirection ?? ListSortDirection.Ascending); - } - + bindingList.ResetCollection(); + ReSort(); } #endregion diff --git a/Source/LibationWinForms/LibationWinForms.csproj b/Source/LibationWinForms/LibationWinForms.csproj index a2854712..d08592d7 100644 --- a/Source/LibationWinForms/LibationWinForms.csproj +++ b/Source/LibationWinForms/LibationWinForms.csproj @@ -48,6 +48,7 @@ + @@ -79,6 +80,12 @@ SettingsDialog.cs + + + + + MSBuild:Compile + From eb49dcfc54947ab9b95b8c801faaaa41f8f713a6 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Wed, 13 Jul 2022 01:14:05 -0600 Subject: [PATCH 09/47] Incremental prgress. --- .../DataGridCheckBoxColumnExt.axaml.cs | 72 +++++++++- .../AvaloniaUI/ViewModels/GridEntry2.cs | 1 - .../ViewModels/GridEntryBindingList2.cs | 4 +- .../AvaloniaUI/ViewModels/RowComparer.cs | 99 +++++++++++++ .../AvaloniaUI/Views/ProductsDisplay2.axaml | 2 +- .../Views/ProductsDisplay2.axaml.cs | 131 ++++-------------- 6 files changed, 194 insertions(+), 115 deletions(-) create mode 100644 Source/LibationWinForms/AvaloniaUI/ViewModels/RowComparer.cs diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs index 9e82e369..b481dda4 100644 --- a/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs @@ -1,15 +1,79 @@ using Avalonia.Controls; -using Avalonia.Controls.Utils; using Avalonia.Interactivity; using LibationWinForms.AvaloniaUI.ViewModels; using System; -using System.Linq; +using System.Reflection; namespace LibationWinForms.AvaloniaUI.Controls { - /// The purpose of this extension WAS to immediately commit any check state changes to the viewmodel, but for the life of me I cannot get it to work! + /// The purpose of this extension it to immediately commit any check + /// state changes to the viewmodel. There must be a better way to do this, but + /// I sure as shit can't find it. public partial class DataGridCheckBoxColumnExt : DataGridCheckBoxColumn { - + Func _owningGrid_get; + Func _endCellEdit; + Func _waitForLostFocus; + public DataGrid OwningGrid + { + get + { + if (_owningGrid_get == null) + { + var pi = typeof(DataGridColumn).GetProperty(nameof(OwningGrid), BindingFlags.NonPublic | BindingFlags.Instance); + var mi = pi.GetGetMethod(true); + _owningGrid_get = mi.CreateDelegate>(this); + } + return _owningGrid_get(); + } + } + + public Func WaitForLostFocus + { + get + { + if (_endCellEdit == null) + { + var mi = typeof(DataGrid).GetMethod(nameof(WaitForLostFocus), BindingFlags.NonPublic | BindingFlags.Instance); + _waitForLostFocus = mi.CreateDelegate>(OwningGrid); + } + return _waitForLostFocus; + } + } + + public Func EndCellEdit + { + get + { + if (_endCellEdit == null) + { + var mi = typeof(DataGrid).GetMethod(nameof(EndCellEdit), BindingFlags.NonPublic | BindingFlags.Instance); + _endCellEdit = mi.CreateDelegate>(OwningGrid); + } + return _endCellEdit; + } + } + + protected override IControl GenerateEditingElementDirect(DataGridCell cell, object dataItem) + { + var ele = base.GenerateEditingElementDirect(cell, dataItem) as CheckBox; + ele.Checked += EditingElement_Checked; + ele.Unchecked += EditingElement_Checked; + ele.Indeterminate += EditingElement_Checked; + return ele; + } + + private void EditingElement_Checked(object sender, RoutedEventArgs e) + { + if (sender is CheckBox cbox && cbox.DataContext is GridEntry2 gentry) + { + var check = cbox.IsChecked; + WaitForLostFocus(() => + { + EndCellEdit(DataGridEditAction.Cancel, true, true, false); + gentry.Remove = check; + }); + } + } } } diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs index 21e684b1..3f866bf3 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs @@ -57,7 +57,6 @@ namespace LibationWinForms.AvaloniaUI.ViewModels public string ProductRating { get => _productRating; protected set { this.RaiseAndSetIfChanged(ref _productRating, value); } } public string MyRating { get => _myRating; protected set { this.RaiseAndSetIfChanged(ref _myRating, value); } } - protected bool? _remove = false; public abstract bool? Remove { get; set; } public abstract LiberateButtonStatus2 Liberate { get; } diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs index b2c42b57..9a881515 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs @@ -128,13 +128,11 @@ namespace LibationWinForms.AvaloniaUI.ViewModels public void ExpandItem(SeriesEntrys2 sEntry) { - var sindex = Items.IndexOf(sEntry); - foreach (var episode in FilterRemoved.BookEntries().Where(b => b.Parent == sEntry).ToList()) { if (SearchResults is null || SearchResults.Docs.Any(d => d.ProductId == episode.AudibleProductId)) { - InsertItem(++sindex, episode); + Add(episode); } } sEntry.Liberate.Expanded = true; diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/RowComparer.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/RowComparer.cs new file mode 100644 index 00000000..8a3585bc --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/RowComparer.cs @@ -0,0 +1,99 @@ +using Avalonia.Controls; +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + //TODO keep episodes beneath their parents when other entries compare equal (because as it stands, children always compare > parents. + /// + /// This compare class ensures that all top-level grid entries (standalone books or series parents) + /// are sorted by PropertyName while all episodes remain immediately beneath their parents and remain + /// sorted by series index, ascending. + /// + internal class RowComparer : IComparer + { + private static readonly System.Reflection.PropertyInfo HeaderCellPi = typeof(DataGridColumn).GetProperty("HeaderCell", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + private static readonly System.Reflection.PropertyInfo CurrentSortingStatePi = typeof(DataGridColumnHeader).GetProperty("CurrentSortingState", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + public DataGridColumn Column { get; init; } + public string PropertyName { get; private set; } + public ListSortDirection? SortDirection { get; set; } + + public RowComparer(DataGridColumn column) + { + Column = column; + PropertyName = Column.SortMemberPath; + } + + + public int Compare(object x, object y) + { + if (x is null && y is not null) return -1; + if (x is not null && y is null) return 1; + if (x is null && y is null) return 0; + + var geA = (GridEntry2)x; + var geB = (GridEntry2)y; + + SortDirection ??= GetSortOrder(); + + SeriesEntrys2 parentA = null; + SeriesEntrys2 parentB = null; + + if (geA is LibraryBookEntry2 lbA && lbA.Parent is SeriesEntrys2 seA) + parentA = seA; + if (geB is LibraryBookEntry2 lbB && lbB.Parent is SeriesEntrys2 seB) + parentB = seB; + + + + //both a and b are standalone + if (parentA is null && parentB is null) + return Compare(geA, geB); + + //a is a standalone, b is a child + if (parentA is null && parentB is not null) + { + // b is a child of a, parent is always first + if (parentB == geA) + return SortDirection is ListSortDirection.Ascending ? -1 : 1; + else + return Compare(geA, parentB); + } + + //a is a child, b is a standalone + if (parentA is not null && parentB is null) + { + // a is a child of b, parent is always first + if (parentA == geB) + return SortDirection is ListSortDirection.Ascending ? 1 : -1; + else + return Compare(parentA, geB); + } + + //both are children of the same series, always present in order of series index, ascending + if (parentA == parentB) + return geA.SeriesIndex.CompareTo(geB.SeriesIndex) * (SortDirection is ListSortDirection.Ascending ? 1 : -1); + + //a and b are children of different series. + return Compare(parentA, parentB); + } + + //Avalonia doesn't expose the column's CurrentSortingState, so we must get it through reflection + private ListSortDirection? GetSortOrder() + => CurrentSortingStatePi.GetValue(HeaderCellPi.GetValue(Column)) as ListSortDirection?; + + private int Compare(GridEntry2 x, GridEntry2 y) + { + var val1 = x.GetMemberValue(PropertyName); + var val2 = y.GetMemberValue(PropertyName); + + return x.GetMemberComparer(val1.GetType()).Compare(val1, val2); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml index 50361033..55a4323f 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml @@ -11,7 +11,7 @@ - + diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs index 5c40f5b1..4c8b6bf3 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs @@ -124,7 +124,27 @@ namespace LibationWinForms.AvaloniaUI.Views }; } - #endregion + #endregion + + #region Filter + + public void Filter(string searchString) + { + int visibleCount = bindingList.Count; + + if (string.IsNullOrEmpty(searchString)) + bindingList.RemoveFilter(); + else + bindingList.Filter = searchString; + + if (visibleCount != bindingList.Count) + VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); + + //Re-sort after filtering + ReSort(); + } + + #endregion #region Sorting @@ -150,7 +170,10 @@ namespace LibationWinForms.AvaloniaUI.Views private void ReSort() { if (CurrentSortColumn is null) + { bindingList.InternalList.Sort((i1, i2) => i2.DateAdded.CompareTo(i1.DateAdded)); + bindingList.ResetCollection(); + } else CurrentSortColumn.Sort(((RowComparer)CurrentSortColumn.CustomSortComparer).SortDirection ?? ListSortDirection.Ascending); } @@ -168,88 +191,6 @@ namespace LibationWinForms.AvaloniaUI.Views CurrentSortColumn = e.Column; } - private class RowComparer : IComparer - { - private static readonly System.Reflection.PropertyInfo HeaderCellPi = typeof(DataGridColumn).GetProperty("HeaderCell", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - private static readonly System.Reflection.PropertyInfo CurrentSortingStatePi = typeof(DataGridColumnHeader).GetProperty("CurrentSortingState", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - - public DataGridColumn Column { get; init; } - public string PropertyName { get; init; } - public ListSortDirection? SortDirection { get; set; } - - public RowComparer(DataGridColumn column) - { - Column = column; - PropertyName = column.SortMemberPath; - } - - /// - /// This compare method ensures that all top-level grid entries (standalone books or series parents) - /// are sorted by PropertyName while all episodes remain immediately beneath their parents and remain - /// sorted by series index, ascending. - /// - public int Compare(object x, object y) - { - if (x is null) return -1; - if (y is null) return 1; - if (x is null && y is null) return 0; - - var geA = (GridEntry2)x; - var geB = (GridEntry2)y; - - SortDirection ??= GetSortOrder(); - - SeriesEntrys2 parentA = null; - SeriesEntrys2 parentB = null; - - if (geA is LibraryBookEntry2 lbA && lbA.Parent is SeriesEntrys2 seA) - parentA = seA; - if (geB is LibraryBookEntry2 lbB && lbB.Parent is SeriesEntrys2 seB) - parentB = seB; - - //both a and b are standalone - if (parentA is null && parentB is null) - return Compare(geA, geB); - - //a is a standalone, b is a child - if (parentA is null && parentB is not null) - { - // b is a child of a, parent is always first - if (parentB == geA) - return SortDirection is ListSortDirection.Ascending ? -1 : 1; - else - return Compare(geA, parentB); - } - - //a is a child, b is a standalone - if (parentA is not null && parentB is null) - { - // a is a child of b, parent is always first - if (parentA == geB) - return SortDirection is ListSortDirection.Ascending ? 1 : -1; - else - return Compare(parentA, geB); - } - - //both are children of the same series, always present in order of series index, ascending - if (parentA == parentB) - return geA.SeriesIndex.CompareTo(geB.SeriesIndex) * (SortDirection is ListSortDirection.Ascending ? 1 : -1); - - //a and b are children of different series. - return Compare(parentA, parentB); - } - - private ListSortDirection? GetSortOrder() - => CurrentSortingStatePi.GetValue(HeaderCellPi.GetValue(Column)) as ListSortDirection?; - - private int Compare(GridEntry2 x, GridEntry2 y) - { - var val1 = x.GetMemberValue(PropertyName); - var val2 = y.GetMemberValue(PropertyName); - - return x.GetMemberComparer(val1.GetType()).Compare(val1, val2); - } - } #endregion @@ -571,29 +512,7 @@ namespace LibationWinForms.AvaloniaUI.Views existingEpisodeEntry.UpdateLibraryBook(episodeBook); } - #endregion - - #region Filter - - public void Filter(string searchString) - { - int visibleCount = bindingList.Count; - - if (string.IsNullOrEmpty(searchString)) - bindingList.RemoveFilter(); - else - bindingList.Filter = searchString; - - if (visibleCount != bindingList.Count) - VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); - - //Re-sort after filtering - - bindingList.ResetCollection(); - ReSort(); - } - - #endregion + #endregion #region Column Customizations From aa8e3ac09b54c6dee81a23acd036a0b51002ede3 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Wed, 13 Jul 2022 02:21:05 -0600 Subject: [PATCH 10/47] More sorting hacking --- .../ViewModels/GridEntryBindingList2.cs | 8 +- .../AvaloniaUI/ViewModels/RowComparer.cs | 84 +++++++++++++++++-- .../Views/ProductsDisplay2.axaml.cs | 2 +- 3 files changed, 81 insertions(+), 13 deletions(-) diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs index 9a881515..48b3f90d 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs @@ -118,7 +118,7 @@ namespace LibationWinForms.AvaloniaUI.ViewModels public void CollapseItem(SeriesEntrys2 sEntry) { - foreach (var episode in Items.BookEntries().Where(b => b.Parent == sEntry).ToList()) + foreach (var episode in Items.BookEntries().Where(b => b.Parent == sEntry).OrderByDescending(lbe => lbe.SeriesIndex).ToList()) { Remove(episode); } @@ -128,11 +128,13 @@ namespace LibationWinForms.AvaloniaUI.ViewModels public void ExpandItem(SeriesEntrys2 sEntry) { - foreach (var episode in FilterRemoved.BookEntries().Where(b => b.Parent == sEntry).ToList()) + var sindex = Items.IndexOf(sEntry); + + foreach (var episode in FilterRemoved.BookEntries().Where(b => b.Parent == sEntry).OrderByDescending(lbe => lbe.SeriesIndex).ToList()) { if (SearchResults is null || SearchResults.Docs.Any(d => d.ProductId == episode.AudibleProductId)) { - Add(episode); + InsertItem(++sindex, episode); } } sEntry.Liberate.Expanded = true; diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/RowComparer.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/RowComparer.cs index 8a3585bc..cdc6c274 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/RowComparer.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/RowComparer.cs @@ -9,13 +9,12 @@ using System.Threading.Tasks; namespace LibationWinForms.AvaloniaUI.ViewModels { - //TODO keep episodes beneath their parents when other entries compare equal (because as it stands, children always compare > parents. /// /// This compare class ensures that all top-level grid entries (standalone books or series parents) /// are sorted by PropertyName while all episodes remain immediately beneath their parents and remain /// sorted by series index, ascending. /// - internal class RowComparer : IComparer + internal class RowComparer : IComparer, IComparer { private static readonly System.Reflection.PropertyInfo HeaderCellPi = typeof(DataGridColumn).GetProperty("HeaderCell", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); private static readonly System.Reflection.PropertyInfo CurrentSortingStatePi = typeof(DataGridColumnHeader).GetProperty("CurrentSortingState", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); @@ -29,6 +28,11 @@ namespace LibationWinForms.AvaloniaUI.ViewModels Column = column; PropertyName = Column.SortMemberPath; } + public RowComparer(ListSortDirection direction, string propertyName) + { + SortDirection = direction; + PropertyName = propertyName; + } public int Compare(object x, object y) @@ -50,11 +54,26 @@ namespace LibationWinForms.AvaloniaUI.ViewModels if (geB is LibraryBookEntry2 lbB && lbB.Parent is SeriesEntrys2 seB) parentB = seB; - + if (geA is SeriesEntrys2 && geB is SeriesEntrys2) + { + //Both are parents. Make sure they never compare equal. + var comparison = InternalCompare(geA, geB); + if (comparison == 0) + { + var propBackup = PropertyName; + PropertyName = nameof(GridEntry2.Series); + comparison = InternalCompare(geA, geB); + PropertyName = propBackup; + return comparison; + } + return comparison; + } + + //both a and b are standalone if (parentA is null && parentB is null) - return Compare(geA, geB); + return InternalCompare(geA, geB); //a is a standalone, b is a child if (parentA is null && parentB is not null) @@ -62,8 +81,22 @@ namespace LibationWinForms.AvaloniaUI.ViewModels // b is a child of a, parent is always first if (parentB == geA) return SortDirection is ListSortDirection.Ascending ? -1 : 1; + else if (geA is SeriesEntrys2) + { + //Both are parents. Make sure they never compare equal. + var comparison = InternalCompare(geA, parentB); + if (comparison == 0) + { + var propBackup = PropertyName; + PropertyName = nameof(GridEntry2.Series); + comparison = InternalCompare(geA, parentB); + PropertyName = propBackup; + return comparison; + } + return comparison; + } else - return Compare(geA, parentB); + return InternalCompare(geA, parentB); } //a is a child, b is a standalone @@ -72,28 +105,61 @@ namespace LibationWinForms.AvaloniaUI.ViewModels // a is a child of b, parent is always first if (parentA == geB) return SortDirection is ListSortDirection.Ascending ? 1 : -1; + else if (geB is SeriesEntrys2) + { + //Both are parents. Make sure they never compare equal. + var comparison = InternalCompare(parentA, geB); + if (comparison == 0) + { + var propBackup = PropertyName; + PropertyName = nameof(GridEntry2.Series); + comparison = InternalCompare(parentA, geB); + PropertyName = propBackup; + return comparison; + } + return comparison; + } else - return Compare(parentA, geB); + return InternalCompare(parentA, geB); } //both are children of the same series, always present in order of series index, ascending if (parentA == parentB) return geA.SeriesIndex.CompareTo(geB.SeriesIndex) * (SortDirection is ListSortDirection.Ascending ? 1 : -1); - //a and b are children of different series. - return Compare(parentA, parentB); + //a and b are children of different series. Make sure their parents never compare equal. + var comparison2 = InternalCompare(parentA, parentB); + if (comparison2 == 0) + { + var propBackup = PropertyName; + PropertyName = nameof(GridEntry2.Series); + comparison2 = InternalCompare(parentA, parentB); + PropertyName = propBackup; + return comparison2; + } + return comparison2; } //Avalonia doesn't expose the column's CurrentSortingState, so we must get it through reflection private ListSortDirection? GetSortOrder() => CurrentSortingStatePi.GetValue(HeaderCellPi.GetValue(Column)) as ListSortDirection?; - private int Compare(GridEntry2 x, GridEntry2 y) + private int InternalCompare(GridEntry2 x, GridEntry2 y) { var val1 = x.GetMemberValue(PropertyName); var val2 = y.GetMemberValue(PropertyName); return x.GetMemberComparer(val1.GetType()).Compare(val1, val2); } + + public int CompareTo(GridEntry2 other) + { + return Compare(this, other); + } + + public int Compare(GridEntry2 x, GridEntry2 y) + { + return Compare((object)x, (object)y) * (SortDirection is ListSortDirection.Ascending ? 1 : -1); + } } } diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs index 4c8b6bf3..d74526d5 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs @@ -171,7 +171,7 @@ namespace LibationWinForms.AvaloniaUI.Views { if (CurrentSortColumn is null) { - bindingList.InternalList.Sort((i1, i2) => i2.DateAdded.CompareTo(i1.DateAdded)); + bindingList.InternalList.Sort(new RowComparer(ListSortDirection.Descending, nameof(GridEntry2.DateAdded))); bindingList.ResetCollection(); } else From e33fd6ea1bf25f13384f8448837f24625ecfd79b Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Wed, 13 Jul 2022 02:23:55 -0600 Subject: [PATCH 11/47] Default invisible --- Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml index 55a4323f..50361033 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml @@ -11,7 +11,7 @@ - + From 3a61c3288127c51e471885b8fc0255ecd6f85099 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Wed, 13 Jul 2022 11:48:17 -0600 Subject: [PATCH 12/47] Fix sorting and refactor --- .../DataGridCheckBoxColumnExt.axaml.cs | 66 +-- .../AvaloniaUI/ViewModels/GridEntry2.cs | 40 +- .../ViewModels/GridEntryBindingList2.cs | 36 +- .../ViewModels/LiberateButtonStatus2.cs | 14 +- .../ViewModels/LibraryBookEntry2.cs | 25 +- .../ViewModels/ProductsDisplayViewModel.cs | 33 +- .../AvaloniaUI/ViewModels/RowComparer.cs | 95 +--- .../AvaloniaUI/ViewModels/SeriesEntrys2.cs | 34 +- .../MainWindow.VisibleBooks.axaml.cs | 12 +- .../Views/MainWindow/MainWindow.axaml | 3 +- .../Views/MainWindow/MainWindow.axaml.cs | 1 + .../Views/ProductsDisplay2.axaml.cs | 523 ------------------ .../ProductsDisplay2.Buttons.xaml.cs | 109 ++++ ...oductsDisplay2.ColumnCustomization.xaml.cs | 13 + .../ProductsDisplay2.Display.xaml.cs | 62 +++ .../ProductsDisplay2.Filtering.xaml.cs | 27 + .../ProductsDisplay2.ScanAndRemove.xaml.cs | 106 ++++ .../ProductsDisplay2.Sorting.xaml.cs | 65 +++ .../{ => ProductsGrid}/ProductsDisplay2.axaml | 12 +- .../ProductsGrid/ProductsDisplay2.axaml.cs | 121 ++++ 20 files changed, 649 insertions(+), 748 deletions(-) delete mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Buttons.xaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ColumnCustomization.xaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Display.xaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Filtering.xaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ScanAndRemove.xaml.cs create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Sorting.xaml.cs rename Source/LibationWinForms/AvaloniaUI/Views/{ => ProductsGrid}/ProductsDisplay2.axaml (96%) create mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.axaml.cs diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs index b481dda4..d19b222d 100644 --- a/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs @@ -1,79 +1,17 @@ using Avalonia.Controls; -using Avalonia.Interactivity; using LibationWinForms.AvaloniaUI.ViewModels; using System; -using System.Reflection; namespace LibationWinForms.AvaloniaUI.Controls { - /// The purpose of this extension it to immediately commit any check - /// state changes to the viewmodel. There must be a better way to do this, but - /// I sure as shit can't find it. public partial class DataGridCheckBoxColumnExt : DataGridCheckBoxColumn { - Func _owningGrid_get; - Func _endCellEdit; - Func _waitForLostFocus; - public DataGrid OwningGrid - { - get - { - if (_owningGrid_get == null) - { - var pi = typeof(DataGridColumn).GetProperty(nameof(OwningGrid), BindingFlags.NonPublic | BindingFlags.Instance); - var mi = pi.GetGetMethod(true); - _owningGrid_get = mi.CreateDelegate>(this); - } - return _owningGrid_get(); - } - } - - public Func WaitForLostFocus - { - get - { - if (_endCellEdit == null) - { - var mi = typeof(DataGrid).GetMethod(nameof(WaitForLostFocus), BindingFlags.NonPublic | BindingFlags.Instance); - _waitForLostFocus = mi.CreateDelegate>(OwningGrid); - } - return _waitForLostFocus; - } - } - - public Func EndCellEdit - { - get - { - if (_endCellEdit == null) - { - var mi = typeof(DataGrid).GetMethod(nameof(EndCellEdit), BindingFlags.NonPublic | BindingFlags.Instance); - _endCellEdit = mi.CreateDelegate>(OwningGrid); - } - return _endCellEdit; - } - } - protected override IControl GenerateEditingElementDirect(DataGridCell cell, object dataItem) { + //Only SeriesEntry types have three-state checks, individual LibraryEntry books are binary. var ele = base.GenerateEditingElementDirect(cell, dataItem) as CheckBox; - ele.Checked += EditingElement_Checked; - ele.Unchecked += EditingElement_Checked; - ele.Indeterminate += EditingElement_Checked; + ele.IsThreeState = dataItem is SeriesEntrys2; return ele; } - - private void EditingElement_Checked(object sender, RoutedEventArgs e) - { - if (sender is CheckBox cbox && cbox.DataContext is GridEntry2 gentry) - { - var check = cbox.IsChecked; - WaitForLostFocus(() => - { - EndCellEdit(DataGridEditAction.Cancel, true, true, false); - gentry.Remove = check; - }); - } - } } } diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs index 3f866bf3..2b85abd6 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry2.cs @@ -1,4 +1,5 @@ -using DataLayer; +using Avalonia.Media; +using DataLayer; using Dinah.Core; using Dinah.Core.DataBinding; using Dinah.Core.Drawing; @@ -28,39 +29,32 @@ namespace LibationWinForms.AvaloniaUI.ViewModels [Browsable(false)] public float SeriesIndex { get; protected set; } [Browsable(false)] public string LongDescription { get; protected set; } [Browsable(false)] public abstract DateTime DateAdded { get; } + [Browsable(false)] public int ListIndex { get; set; } [Browsable(false)] protected Book Book => LibraryBook.Book; #region Model properties exposed to the view private Avalonia.Media.Imaging.Bitmap _cover; - private string _purchaseDate; - private string _series; - private string _title; - private string _length; - private string _authors; - private string _narrators; - private string _category; - private string _misc; - private string _description; - private string _productRating; - private string _myRating; public Avalonia.Media.Imaging.Bitmap Cover { get => _cover; protected set { this.RaiseAndSetIfChanged(ref _cover, value); } } - public string PurchaseDate { get => _purchaseDate; protected set { this.RaiseAndSetIfChanged(ref _purchaseDate, value); } } - public string Series { get => _series; protected set { this.RaiseAndSetIfChanged(ref _series, value); } } - public string Title { get => _title; protected set { this.RaiseAndSetIfChanged(ref _title, value); } } - public string Length { get => _length; protected set { this.RaiseAndSetIfChanged(ref _length, value); } } - public string Authors { get => _authors; protected set { this.RaiseAndSetIfChanged(ref _authors, value); } } - public string Narrators { get => _narrators; protected set { this.RaiseAndSetIfChanged(ref _narrators, value); } } - public string Category { get => _category; protected set { this.RaiseAndSetIfChanged(ref _category, value); } } - public string Misc { get => _misc; protected set { this.RaiseAndSetIfChanged(ref _misc, value); } } - public string Description { get => _description; protected set { this.RaiseAndSetIfChanged(ref _description, value); } } - public string ProductRating { get => _productRating; protected set { this.RaiseAndSetIfChanged(ref _productRating, value); } } - public string MyRating { get => _myRating; protected set { this.RaiseAndSetIfChanged(ref _myRating, value); } } + public string PurchaseDate { get; protected set; } + public string Series { get; protected set; } + public string Title { get; protected set; } + public string Length { get; protected set; } + public string Authors { get; protected set; } + public string Narrators { get; protected set; } + public string Category { get; protected set; } + public string Misc { get; protected set; } + public string Description { get; protected set; } + public string ProductRating { get; protected set; } + public string MyRating { get; protected set; } protected bool? _remove = false; public abstract bool? Remove { get; set; } public abstract LiberateButtonStatus2 Liberate { get; } public abstract BookTags BookTags { get; } + public abstract bool IsSeries { get; } + public abstract bool IsEpisode { get; } + public abstract bool IsBook { get; } #endregion diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs index 48b3f90d..3448e88b 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs @@ -48,6 +48,13 @@ namespace LibationWinForms.AvaloniaUI.ViewModels base.Remove(entry); } + public void ReplaceList(IEnumerable newItems) + { + Items.Clear(); + ((List)Items).AddRange(newItems); + ResetCollection(); + } + protected override void InsertItem(int index, GridEntry2 item) { FilterRemoved.Remove(item); @@ -120,10 +127,22 @@ namespace LibationWinForms.AvaloniaUI.ViewModels { foreach (var episode in Items.BookEntries().Where(b => b.Parent == sEntry).OrderByDescending(lbe => lbe.SeriesIndex).ToList()) { - Remove(episode); + /* + * Bypass ObservationCollection's InsertItem methos so that CollectionChanged isn't + * fired. When adding many items at once, Avalonia's CollectionChanged event handler + * causes serious performance problems. And unfotrunately, Avalonia doesn't respect + * the NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction action, IList? changedItems) + * overload that would fire only once for all changed items. + * + * Doing this requires resetting the list so the view knows it needs to rebuild its display. + */ + + FilterRemoved.Add(episode); + Items.Remove(episode); } sEntry.Liberate.Expanded = false; + ResetCollection(); } public void ExpandItem(SeriesEntrys2 sEntry) @@ -134,10 +153,23 @@ namespace LibationWinForms.AvaloniaUI.ViewModels { if (SearchResults is null || SearchResults.Docs.Any(d => d.ProductId == episode.AudibleProductId)) { - InsertItem(++sindex, episode); + /* + * Bypass ObservationCollection's InsertItem methos so that CollectionChanged isn't + * fired. When adding many items at once, Avalonia's CollectionChanged event handler + * causes serious performance problems. And unfotrunately, Avalonia doesn't respect + * the NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction action, IList? changedItems) + * overload that would fire only once for all changed items. + * + * Doing this requires resetting the list so the view knows it needs to rebuild its display. + */ + + FilterRemoved.Remove(episode); + Items.Insert(++sindex, episode); } } + sEntry.Liberate.Expanded = true; + ResetCollection(); } #endregion diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus2.cs index f17c519c..2f506133 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus2.cs @@ -8,6 +8,10 @@ namespace LibationWinForms.AvaloniaUI.ViewModels { public class LiberateButtonStatus2 : ViewModelBase, IComparable { + public LiberateButtonStatus2(bool isSeries) + { + IsSeries = isSeries; + } public LiberatedStatus BookStatus { get; set; } public LiberatedStatus? PdfStatus { get; set; } @@ -22,11 +26,11 @@ namespace LibationWinForms.AvaloniaUI.ViewModels this.RaisePropertyChanged(nameof(ToolTip)); } } - public bool IsSeries { get; init; } + private bool IsSeries { get; } public Bitmap Image => GetLiberateIcon(); public string ToolTip => GetTooltip(); - static Dictionary images = new(); + static Dictionary iconCache = new(); /// Defines the Liberate column's sorting behavior public int CompareTo(object obj) @@ -106,14 +110,14 @@ namespace LibationWinForms.AvaloniaUI.ViewModels private static Bitmap GetFromResources(string rescName) { - if (images.ContainsKey(rescName)) return images[rescName]; + if (iconCache.ContainsKey(rescName)) return iconCache[rescName]; var memoryStream = new System.IO.MemoryStream(); ((System.Drawing.Bitmap)Properties.Resources.ResourceManager.GetObject(rescName)).Save(memoryStream, System.Drawing.Imaging.ImageFormat.Png); memoryStream.Position = 0; - images[rescName] = new Bitmap(memoryStream); - return images[rescName]; + iconCache[rescName] = new Bitmap(memoryStream); + return iconCache[rescName]; } } } diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs index 7e46c741..0b5c2f61 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry2.cs @@ -1,7 +1,6 @@ using ApplicationServices; using DataLayer; using Dinah.Core; -using LibationWinForms.GridView; using ReactiveUI; using System; using System.Collections.Generic; @@ -27,7 +26,7 @@ namespace LibationWinForms.AvaloniaUI.ViewModels get => _remove; set { - _remove = value.HasValue ? value.Value : false; + _remove = value ?? false; Parent?.ChildRemoveUpdate(); this.RaisePropertyChanged(nameof(Remove)); @@ -45,32 +44,22 @@ namespace LibationWinForms.AvaloniaUI.ViewModels _pdfStatus = LibraryCommands.Pdf_Status(LibraryBook.Book); lastStatusUpdate = DateTime.Now; } - return new LiberateButtonStatus2 { BookStatus = _bookStatus, PdfStatus = _pdfStatus, IsSeries = false }; + return new LiberateButtonStatus2(IsSeries) { BookStatus = _bookStatus, PdfStatus = _pdfStatus }; } } public override BookTags BookTags => new() { Tags = string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated) }; + public override bool IsSeries => false; + public override bool IsEpisode => Parent is not null; + public override bool IsBook => Parent is null; + #endregion public LibraryBookEntry2(LibraryBook libraryBook) - { - setLibraryBook(libraryBook); - LoadCover(); - } - - public void UpdateLibraryBook(LibraryBook libraryBook) - { - if (AudibleProductId != libraryBook.Book.AudibleProductId) - throw new Exception("Invalid grid entry update. IDs must match"); - - UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged; - setLibraryBook(libraryBook); - } - - private void setLibraryBook(LibraryBook libraryBook) { LibraryBook = libraryBook; + LoadCover(); Title = Book.Title; Series = Book.SeriesNames(); diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs index d0cd021f..de7dfb21 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs @@ -13,7 +13,21 @@ namespace LibationWinForms.AvaloniaUI.ViewModels public class ProductsDisplayViewModel : ViewModelBase { public GridEntryBindingList2 GridEntries { get; set; } + public DataGridCollectionView GridCollectionView { get; set; } public ProductsDisplayViewModel(IEnumerable dbBooks) + { + GridEntries = new GridEntryBindingList2(CreateGridEntries(dbBooks)); + GridEntries.CollapseAll(); + + /* + * Would be nice to use built-in groups, but Avalonia doesn't yet let you customize the row group header. + * + GridCollectionView = new DataGridCollectionView(GridEntries); + GridCollectionView.GroupDescriptions.Add(new CustonGroupDescription()); + */ + } + + public static IEnumerable CreateGridEntries(IEnumerable dbBooks) { var geList = dbBooks .Where(lb => lb.Book.IsProduct()) @@ -36,9 +50,22 @@ namespace LibationWinForms.AvaloniaUI.ViewModels geList.Add(seriesEntry); geList.AddRange(seriesEntry.Children); } - - GridEntries = new GridEntryBindingList2(geList.OrderByDescending(e => e.DateAdded)); - GridEntries.CollapseAll(); + return geList.OrderByDescending(e => e.DateAdded); + } + } + class CustonGroupDescription : DataGridGroupDescription + { + public override object GroupKeyFromItem(object item, int level, CultureInfo culture) + { + if (item is SeriesEntrys2 sEntry) + return sEntry; + else if (item is LibraryBookEntry2 lbEntry && lbEntry.Parent is SeriesEntrys2 sEntry2) + return sEntry2; + else return null; + } + public override bool KeysMatch(object groupKey, object itemKey) + { + return base.KeysMatch(groupKey, itemKey); } } } diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/RowComparer.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/RowComparer.cs index cdc6c274..9738a18c 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/RowComparer.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/RowComparer.cs @@ -3,21 +3,20 @@ using System; using System.Collections; using System.Collections.Generic; using System.ComponentModel; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Reflection; namespace LibationWinForms.AvaloniaUI.ViewModels { /// /// This compare class ensures that all top-level grid entries (standalone books or series parents) /// are sorted by PropertyName while all episodes remain immediately beneath their parents and remain - /// sorted by series index, ascending. + /// sorted by series index, ascending. Stable sorting is achieved by comparing the GridEntry.ListIndex + /// properties when 2 items compare equal. /// - internal class RowComparer : IComparer, IComparer + internal class RowComparer : IComparer { - private static readonly System.Reflection.PropertyInfo HeaderCellPi = typeof(DataGridColumn).GetProperty("HeaderCell", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - private static readonly System.Reflection.PropertyInfo CurrentSortingStatePi = typeof(DataGridColumnHeader).GetProperty("CurrentSortingState", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + private static readonly PropertyInfo HeaderCellPi = typeof(DataGridColumn).GetProperty("HeaderCell", BindingFlags.NonPublic | BindingFlags.Instance); + private static readonly PropertyInfo CurrentSortingStatePi = typeof(DataGridColumnHeader).GetProperty("CurrentSortingState", BindingFlags.NonPublic | BindingFlags.Instance); public DataGridColumn Column { get; init; } public string PropertyName { get; private set; } @@ -34,7 +33,6 @@ namespace LibationWinForms.AvaloniaUI.ViewModels PropertyName = propertyName; } - public int Compare(object x, object y) { if (x is null && y is not null) return -1; @@ -54,71 +52,26 @@ namespace LibationWinForms.AvaloniaUI.ViewModels if (geB is LibraryBookEntry2 lbB && lbB.Parent is SeriesEntrys2 seB) parentB = seB; - if (geA is SeriesEntrys2 && geB is SeriesEntrys2) - { - //Both are parents. Make sure they never compare equal. - var comparison = InternalCompare(geA, geB); - if (comparison == 0) - { - var propBackup = PropertyName; - PropertyName = nameof(GridEntry2.Series); - comparison = InternalCompare(geA, geB); - PropertyName = propBackup; - return comparison; - } - return comparison; - } - - - - //both a and b are standalone + //both a and b are top-level grid entries if (parentA is null && parentB is null) return InternalCompare(geA, geB); - //a is a standalone, b is a child + //a is top-level, b is a child if (parentA is null && parentB is not null) { // b is a child of a, parent is always first if (parentB == geA) return SortDirection is ListSortDirection.Ascending ? -1 : 1; - else if (geA is SeriesEntrys2) - { - //Both are parents. Make sure they never compare equal. - var comparison = InternalCompare(geA, parentB); - if (comparison == 0) - { - var propBackup = PropertyName; - PropertyName = nameof(GridEntry2.Series); - comparison = InternalCompare(geA, parentB); - PropertyName = propBackup; - return comparison; - } - return comparison; - } else return InternalCompare(geA, parentB); } - //a is a child, b is a standalone + //a is a child, b is a top-level if (parentA is not null && parentB is null) { // a is a child of b, parent is always first if (parentA == geB) return SortDirection is ListSortDirection.Ascending ? 1 : -1; - else if (geB is SeriesEntrys2) - { - //Both are parents. Make sure they never compare equal. - var comparison = InternalCompare(parentA, geB); - if (comparison == 0) - { - var propBackup = PropertyName; - PropertyName = nameof(GridEntry2.Series); - comparison = InternalCompare(parentA, geB); - PropertyName = propBackup; - return comparison; - } - return comparison; - } else return InternalCompare(parentA, geB); } @@ -127,17 +80,8 @@ namespace LibationWinForms.AvaloniaUI.ViewModels if (parentA == parentB) return geA.SeriesIndex.CompareTo(geB.SeriesIndex) * (SortDirection is ListSortDirection.Ascending ? 1 : -1); - //a and b are children of different series. Make sure their parents never compare equal. - var comparison2 = InternalCompare(parentA, parentB); - if (comparison2 == 0) - { - var propBackup = PropertyName; - PropertyName = nameof(GridEntry2.Series); - comparison2 = InternalCompare(parentA, parentB); - PropertyName = propBackup; - return comparison2; - } - return comparison2; + //a and b are children of different series. + return Compare(parentA, parentB); } //Avalonia doesn't expose the column's CurrentSortingState, so we must get it through reflection @@ -149,17 +93,14 @@ namespace LibationWinForms.AvaloniaUI.ViewModels var val1 = x.GetMemberValue(PropertyName); var val2 = y.GetMemberValue(PropertyName); - return x.GetMemberComparer(val1.GetType()).Compare(val1, val2); - } + var compareResult = x.GetMemberComparer(val1.GetType()).Compare(val1, val2); - public int CompareTo(GridEntry2 other) - { - return Compare(this, other); - } - - public int Compare(GridEntry2 x, GridEntry2 y) - { - return Compare((object)x, (object)y) * (SortDirection is ListSortDirection.Ascending ? 1 : -1); + //If items compare equal, compare them by their positions in the the list. + //This is how you achieve a stable sort. + if (compareResult == 0) + return x.ListIndex.CompareTo(y.ListIndex); + else + return compareResult; } } } diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs index 21c41bcb..4cec5386 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs @@ -1,4 +1,5 @@ -using DataLayer; +using Avalonia.Media; +using DataLayer; using Dinah.Core; using ReactiveUI; using System; @@ -31,7 +32,7 @@ namespace LibationWinForms.AvaloniaUI.ViewModels get => _remove; set { - _remove = value.HasValue ? value.Value : false; + _remove = value ?? false; suspendCounting = true; @@ -46,39 +47,28 @@ namespace LibationWinForms.AvaloniaUI.ViewModels public override LiberateButtonStatus2 Liberate { get; } public override BookTags BookTags { get; } = new(); + public override bool IsSeries => true; + public override bool IsEpisode => false; + public override bool IsBook => false; + #endregion - private SeriesEntrys2(LibraryBook parent) + public SeriesEntrys2(LibraryBook parent, IEnumerable children) { - Liberate = new LiberateButtonStatus2 { IsSeries = true }; + Liberate = new LiberateButtonStatus2(IsSeries); SeriesIndex = -1; LibraryBook = parent; - LoadCover(); - } - public SeriesEntrys2(LibraryBook parent, IEnumerable children) : this(parent) - { + LoadCover(); + Children = children .Select(c => new LibraryBookEntry2(c) { Parent = this }) .OrderBy(c => c.SeriesIndex) .ToList(); - UpdateSeries(parent); - } - - public SeriesEntrys2(LibraryBook parent, LibraryBook child) : this(parent) - { - Children = new() { new LibraryBookEntry2(child) { Parent = this } }; - UpdateSeries(parent); - } - - public void UpdateSeries(LibraryBook parent) - { - LibraryBook = parent; Title = Book.Title; Series = Book.SeriesNames(); MyRating = Book.UserDefinedItem.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); - PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d"); ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); Authors = Book.AuthorNames(); Narrators = Book.NarratorNames(); @@ -87,10 +77,12 @@ namespace LibationWinForms.AvaloniaUI.ViewModels LongDescription = GetDescriptionDisplay(Book); Description = TrimTextToWord(LongDescription, 62); + PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d"); int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes); Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min"; } + #region Data Sorting /// Create getters for all member object values by name diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.VisibleBooks.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.VisibleBooks.axaml.cs index 4523ce71..8b45239b 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.VisibleBooks.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.VisibleBooks.axaml.cs @@ -40,7 +40,7 @@ namespace LibationWinForms.AvaloniaUI.Views processBookQueue1.AddDownloadDecrypt( productsDisplay - .GetVisible() + .GetVisibleBookEntries() .UnLiberated() ); } @@ -56,7 +56,7 @@ namespace LibationWinForms.AvaloniaUI.Views if (result != System.Windows.Forms.DialogResult.OK) return; - var visibleLibraryBooks = productsDisplay.GetVisible(); + var visibleLibraryBooks = productsDisplay.GetVisibleBookEntries(); var confirmationResult = MessageBoxLib.ShowConfirmationDialog( visibleLibraryBooks, @@ -78,7 +78,7 @@ namespace LibationWinForms.AvaloniaUI.Views if (result != System.Windows.Forms.DialogResult.OK) return; - var visibleLibraryBooks = productsDisplay.GetVisible(); + var visibleLibraryBooks = productsDisplay.GetVisibleBookEntries(); var confirmationResult = MessageBoxLib.ShowConfirmationDialog( visibleLibraryBooks, @@ -95,7 +95,7 @@ namespace LibationWinForms.AvaloniaUI.Views public async void removeToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) { - var visibleLibraryBooks = productsDisplay.GetVisible(); + var visibleLibraryBooks = productsDisplay.GetVisibleBookEntries(); var confirmationResult = MessageBoxLib.ShowConfirmationDialog( visibleLibraryBooks, @@ -121,13 +121,13 @@ namespace LibationWinForms.AvaloniaUI.Views }); //Not used for anything? - var notLiberatedCount = productsDisplay.GetVisible().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated); + var notLiberatedCount = productsDisplay.GetVisibleBookEntries().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated); await Task.Run(setLiberatedVisibleMenuItem); } void setLiberatedVisibleMenuItem() { - var notLiberated = productsDisplay.GetVisible().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated); + var notLiberated = productsDisplay.GetVisibleBookEntries().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated); Dispatcher.UIThread.Post(() => { diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml index 47c66173..fcdb7ff1 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml @@ -5,6 +5,7 @@ xmlns:vm="clr-namespace:LibationWinForms.AvaloniaUI.ViewModels" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:views="clr-namespace:LibationWinForms.AvaloniaUI.Views" + xmlns:prgid="clr-namespace:LibationWinForms.AvaloniaUI.Views.ProductsGrid" xmlns:controls="clr-namespace:LibationWinForms.AvaloniaUI.Controls" mc:Ignorable="d" d:DesignWidth="2000" d:DesignHeight="700" x:Class="LibationWinForms.AvaloniaUI.Views.MainWindow" Title="MainWindow"> @@ -145,7 +146,7 @@ - Number of visible rows has changed - public event EventHandler VisibleCountChanged; - public event EventHandler RemovableCountChanged; - public event EventHandler LiberateClicked; - public event EventHandler InitialLoaded; - - private ProductsDisplayViewModel _viewModel; - private GridEntryBindingList2 bindingList => _viewModel.GridEntries; - private IEnumerable GetAllBookEntries() - => bindingList.AllItems().BookEntries(); - - internal List GetVisible() - => bindingList - .BookEntries() - .Select(lbe => lbe.LibraryBook) - .ToList(); - - - DataGridColumn removeGVColumn; - DataGridColumn liberateGVColumn; - DataGridColumn coverGVColumn; - DataGridColumn titleGVColumn; - DataGridColumn authorsGVColumn; - DataGridColumn narratorsGVColumn; - DataGridColumn lengthGVColumn; - DataGridColumn seriesGVColumn; - DataGridColumn descriptionGVColumn; - DataGridColumn categoryGVColumn; - DataGridColumn productRatingGVColumn; - DataGridColumn purchaseDateGVColumn; - DataGridColumn myRatingGVColumn; - DataGridColumn miscGVColumn; - DataGridColumn tagAndDetailsGVColumn; - - #region Init - - public ProductsDisplay2() - { - InitializeComponent(); - - - if (Design.IsDesignMode) - { - using var context = DbContexts.GetContext(); - var book = context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"); - productsGrid.DataContext = _viewModel = new ProductsDisplayViewModel(new List { book }); - return; - } - } - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - - productsGrid = this.FindControl(nameof(productsGrid)); - productsGrid.Sorting += ProductsGrid_Sorting; - productsGrid.CanUserSortColumns = true; - productsGrid.LoadingRow += ProductsGrid_LoadingRow; - - removeGVColumn = productsGrid.Columns[0]; - liberateGVColumn = productsGrid.Columns[1]; - coverGVColumn = productsGrid.Columns[2]; - titleGVColumn = productsGrid.Columns[3]; - authorsGVColumn = productsGrid.Columns[4]; - narratorsGVColumn = productsGrid.Columns[5]; - lengthGVColumn = productsGrid.Columns[6]; - seriesGVColumn = productsGrid.Columns[7]; - descriptionGVColumn = productsGrid.Columns[8]; - categoryGVColumn = productsGrid.Columns[9]; - productRatingGVColumn = productsGrid.Columns[10]; - purchaseDateGVColumn = productsGrid.Columns[11]; - myRatingGVColumn = productsGrid.Columns[12]; - miscGVColumn = productsGrid.Columns[13]; - tagAndDetailsGVColumn = productsGrid.Columns[14]; - - RegisterCustomColumnComparers(); - } - - #endregion - - #region Apply Background Brush Style to Series Books Rows - - private static object tagObj = new(); - private void ProductsGrid_LoadingRow(object sender, DataGridRowEventArgs e) - { - if (e.Row.Tag == tagObj) - return; - e.Row.Tag = tagObj; - - static IBrush GetRowColor(DataGridRow row) - => row.DataContext is GridEntry2 gEntry - && gEntry is LibraryBookEntry2 lbEntry - && lbEntry.Parent is not null - ? App.SeriesEntryGridBackgroundBrush - : null; - - e.Row.Background = GetRowColor(e.Row); - e.Row.DataContextChanged += (sender, e) => - { - var row = sender as DataGridRow; - row.Background = GetRowColor(row); - }; - } - - #endregion - - #region Filter - - public void Filter(string searchString) - { - int visibleCount = bindingList.Count; - - if (string.IsNullOrEmpty(searchString)) - bindingList.RemoveFilter(); - else - bindingList.Filter = searchString; - - if (visibleCount != bindingList.Count) - VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); - - //Re-sort after filtering - ReSort(); - } - - #endregion - - #region Sorting - - private void RegisterCustomColumnComparers() - { - - removeGVColumn.CustomSortComparer = new RowComparer(removeGVColumn); - liberateGVColumn.CustomSortComparer = new RowComparer(liberateGVColumn); - titleGVColumn.CustomSortComparer = new RowComparer(titleGVColumn); - authorsGVColumn.CustomSortComparer = new RowComparer(authorsGVColumn); - narratorsGVColumn.CustomSortComparer = new RowComparer(narratorsGVColumn); - lengthGVColumn.CustomSortComparer = new RowComparer(lengthGVColumn); - seriesGVColumn.CustomSortComparer = new RowComparer(seriesGVColumn); - descriptionGVColumn.CustomSortComparer = new RowComparer(descriptionGVColumn); - categoryGVColumn.CustomSortComparer = new RowComparer(categoryGVColumn); - productRatingGVColumn.CustomSortComparer = new RowComparer(productRatingGVColumn); - purchaseDateGVColumn.CustomSortComparer = new RowComparer(purchaseDateGVColumn); - myRatingGVColumn.CustomSortComparer = new RowComparer(myRatingGVColumn); - miscGVColumn.CustomSortComparer = new RowComparer(miscGVColumn); - tagAndDetailsGVColumn.CustomSortComparer = new RowComparer(tagAndDetailsGVColumn); - } - - private void ReSort() - { - if (CurrentSortColumn is null) - { - bindingList.InternalList.Sort(new RowComparer(ListSortDirection.Descending, nameof(GridEntry2.DateAdded))); - bindingList.ResetCollection(); - } - else - CurrentSortColumn.Sort(((RowComparer)CurrentSortColumn.CustomSortComparer).SortDirection ?? ListSortDirection.Ascending); - } - - - private DataGridColumn CurrentSortColumn; - - private void ProductsGrid_Sorting(object sender, DataGridColumnEventArgs e) - { - var comparer = e.Column.CustomSortComparer as RowComparer; - //Force the comparer to get the current sort order. We can't - //retrieve it from inside this event handler because Avalonia - //doesn't set the property until after this event. - comparer.SortDirection = null; - CurrentSortColumn = e.Column; - } - - - #endregion - - #region Button controls - - public void LiberateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) - { - var button = args.Source as Button; - - if (button.DataContext is SeriesEntrys2 sEntry) - { - if (sEntry.Liberate.Expanded) - bindingList.CollapseItem(sEntry); - else - { - bindingList.ExpandItem(sEntry); - ReSort(); - } - - VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); - } - else if (button.DataContext is LibraryBookEntry2 lbEntry) - { - LiberateClicked?.Invoke(this, lbEntry.LibraryBook); - } - } - - private GridView.ImageDisplay imageDisplay; - public async void Cover_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) - { - if (sender is not Image tblock || tblock.DataContext is not GridEntry2 gEntry) - return; - - var picDefinition = new PictureDefinition(gEntry.LibraryBook.Book.PictureLarge ?? gEntry.LibraryBook.Book.PictureId, PictureSize.Native); - var picDlTask = Task.Run(() => PictureStorage.GetPictureSynchronously(picDefinition)); - - (_, byte[] initialImageBts) = PictureStorage.GetPicture(new PictureDefinition(gEntry.LibraryBook.Book.PictureId, PictureSize._80x80)); - var windowTitle = $"{gEntry.Title} - Cover"; - - if (imageDisplay is null || imageDisplay.IsDisposed || !imageDisplay.Visible) - { - imageDisplay = new GridView.ImageDisplay(); - imageDisplay.RestoreSizeAndLocation(Configuration.Instance); - imageDisplay.FormClosed += (_, _) => imageDisplay.SaveSizeAndLocation(Configuration.Instance); - imageDisplay.Show(null); - } - - imageDisplay.BookSaveDirectory = AudibleFileStorage.Audio.GetDestinationDirectory(gEntry.LibraryBook); - imageDisplay.PictureFileName = System.IO.Path.GetFileName(AudibleFileStorage.Audio.GetBooksDirectoryFilename(gEntry.LibraryBook, ".jpg")); - imageDisplay.Text = windowTitle; - imageDisplay.CoverPicture = initialImageBts; - imageDisplay.CoverPicture = await picDlTask; - } - - public void Description_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) - { - if (sender is TextBlock tblock && tblock.DataContext is GridEntry2 gEntry) - { - var pt = tblock.Parent.PointToScreen(tblock.Parent.Bounds.TopRight); - var displayWindow = new GridView.DescriptionDisplay - { - SpawnLocation = new System.Drawing.Point(pt.X, pt.Y), - DescriptionText = gEntry.LongDescription, - BorderThickness = 2, - }; - - void CloseWindow(object o, DataGridRowEventArgs e) - { - displayWindow.Close(); - } - productsGrid.LoadingRow += CloseWindow; - displayWindow.FormClosed += (_, _) => - { - productsGrid.LoadingRow -= CloseWindow; - }; - - displayWindow.Show(); - } - } - - public void OnTagsButtonClick(object sender, Avalonia.Interactivity.RoutedEventArgs args) - { - var button = args.Source as Button; - - if (button.DataContext is LibraryBookEntry2 lbEntry) - { - var bookDetailsForm = new Dialogs.BookDetailsDialog(lbEntry.LibraryBook); - if (bookDetailsForm.ShowDialog() == System.Windows.Forms.DialogResult.OK) - lbEntry.Commit(bookDetailsForm.NewTags, bookDetailsForm.BookLiberatedStatus, bookDetailsForm.PdfLiberatedStatus); - } - } - - #endregion - - #region Scan and Remove Books - - public void CloseRemoveBooksColumn() - => removeGVColumn.IsVisible = false; - - public async Task RemoveCheckedBooksAsync() - { - var selectedBooks = GetAllBookEntries().Where(lbe => lbe.Remove == true).ToList(); - - if (selectedBooks.Count == 0) - return; - - var libraryBooks = selectedBooks.Select(rge => rge.LibraryBook).ToList(); - var result = MessageBoxLib.ShowConfirmationDialog( - libraryBooks, - $"Are you sure you want to remove {selectedBooks.Count} books from Libation's library?", - "Remove books from Libation?"); - - if (result != System.Windows.Forms.DialogResult.Yes) - return; - - RemoveBooks(selectedBooks); - var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList(); - var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove); - - RemovableCountChanged?.Invoke(this, GetAllBookEntries().Count(lbe => lbe.Remove is true)); - } - public async Task ScanAndRemoveBooksAsync(params Account[] accounts) - { - RemovableCountChanged?.Invoke(this, 0); - removeGVColumn.IsVisible = true; - - try - { - if (accounts is null || accounts.Length == 0) - return; - - var allBooks = GetAllBookEntries(); - - foreach (var b in allBooks) - b.Remove = false; - - var lib = allBooks - .Select(lbe => lbe.LibraryBook) - .Where(lb => !lb.Book.HasLiberated()); - - var removedBooks = await LibraryCommands.FindInactiveBooks(Login.WinformLoginChoiceEager.ApiExtendedFunc, lib, accounts); - - var removable = allBooks.Where(lbe => removedBooks.Any(rb => rb.Book.AudibleProductId == lbe.AudibleProductId)).ToList(); - - foreach (var r in removable) - r.Remove = true; - - RemovableCountChanged?.Invoke(this, GetAllBookEntries().Count(lbe => lbe.Remove is true)); - } - catch (Exception ex) - { - MessageBoxLib.ShowAdminAlert( - null, - "Error scanning library. You may still manually select books to remove from Libation's library.", - "Error scanning library", - ex); - } - } - - - #endregion - - #region UI display functions - - public void Display() - { - try - { - // don't return early if lib size == 0. this will not update correctly if all books are removed - var dbBooks = DbContexts.GetLibrary_Flat_NoTracking(includeParents: true); - if (productsGrid.DataContext is null) - { - productsGrid.DataContext = _viewModel = new ProductsDisplayViewModel(dbBooks); - InitialLoaded?.Invoke(this, EventArgs.Empty); - VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); - } - else - UpdateGrid(dbBooks); - } - catch (Exception ex) - { - Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplay2)); - } - } - - private void UpdateGrid(List dbBooks) - { - #region Add new or update existing grid entries - - //Remove filter prior to adding/updating boooks - string existingFilter = bindingList.Filter; - Filter(null); - - bindingList.SuspendFilteringOnUpdate = true; - - //Add absent entries to grid, or update existing entry - - var allEntries = bindingList.AllItems().BookEntries(); - var seriesEntries = bindingList.AllItems().SeriesEntries().ToList(); - var parentedEpisodes = dbBooks.ParentedEpisodes(); - - foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded)) - { - var existingEntry = allEntries.FindByAsin(libraryBook.Book.AudibleProductId); - - if (libraryBook.Book.IsProduct()) - AddOrUpdateBook(libraryBook, existingEntry); - else if (parentedEpisodes.Any(lb => lb == libraryBook)) - //Only try to add or update is this LibraryBook is a know child of a parent - AddOrUpdateEpisode(libraryBook, existingEntry, seriesEntries, dbBooks); - } - - bindingList.SuspendFilteringOnUpdate = false; - - //Re-apply filter after adding new/updating existing books to capture any changes - Filter(existingFilter); - - #endregion - - // remove deleted from grid. - // note: actual deletion from db must still occur via the RemoveBook feature. deleting from audible will not trigger this - var removedBooks = - bindingList - .AllItems() - .BookEntries() - .ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId); - - RemoveBooks(removedBooks); - } - - private void RemoveBooks(IEnumerable removedBooks) - { - //Remove books in series from their parents' Children list - foreach (var removed in removedBooks.Where(b => b.Parent is not null)) - { - removed.Parent.Children.Remove(removed); - - //In Avalonia, if you fire PropertyChanged with an empty or invalid property name, nothing is updated. - //So we must notify for specific properties that we believed changed. - removed.Parent.RaisePropertyChanged(nameof(SeriesEntrys2.Length)); - removed.Parent.RaisePropertyChanged(nameof(SeriesEntrys2.PurchaseDate)); - } - - //Remove series that have no children - var removedSeries = - bindingList - .AllItems() - .EmptySeries(); - - foreach (var removed in removedBooks.Cast().Concat(removedSeries)) - //no need to re-filter for removed books - bindingList.Remove(removed); - - VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); - } - - private void AddOrUpdateBook(LibraryBook book, LibraryBookEntry2 existingBookEntry) - { - if (existingBookEntry is null) - // Add the new product to top - bindingList.Insert(0, new LibraryBookEntry2(book)); - else - // update existing - existingBookEntry.UpdateLibraryBook(book); - } - - private void AddOrUpdateEpisode(LibraryBook episodeBook, LibraryBookEntry2 existingEpisodeEntry, List seriesEntries, IEnumerable dbBooks) - { - if (existingEpisodeEntry is null) - { - LibraryBookEntry2 episodeEntry; - - var seriesEntry = seriesEntries.FindSeriesParent(episodeBook); - - if (seriesEntry is null) - { - //Series doesn't exist yet, so create and add it - var seriesBook = dbBooks.FindSeriesParent(episodeBook); - - if (seriesBook is null) - { - //This is only possible if the user's db has some malformed - //entries from earlier Libation releases that could not be - //automatically fixed. Log, but don't throw. - Serilog.Log.Logger.Error("Episode={0}, Episode Series: {1}", episodeBook, episodeBook.Book.SeriesNames()); - return; - } - - - seriesEntry = new SeriesEntrys2(seriesBook, episodeBook); - seriesEntries.Add(seriesEntry); - - episodeEntry = seriesEntry.Children[0]; - seriesEntry.Liberate.Expanded = true; - bindingList.Insert(0, seriesEntry); - } - else - { - //Series exists. Create and add episode child then update the SeriesEntry - episodeEntry = new(episodeBook) { Parent = seriesEntry }; - seriesEntry.Children.Add(episodeEntry); - var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId); - seriesEntry.UpdateSeries(seriesBook); - } - - //Add episode to the grid beneath the parent - int seriesIndex = bindingList.IndexOf(seriesEntry); - bindingList.Insert(seriesIndex + 1, episodeEntry); - - if (seriesEntry.Liberate.Expanded) - bindingList.ExpandItem(seriesEntry); - else - bindingList.CollapseItem(seriesEntry); - - seriesEntry.RaisePropertyChanged(nameof(SeriesEntrys2.Length)); - seriesEntry.RaisePropertyChanged(nameof(SeriesEntrys2.PurchaseDate)); - } - else - existingEpisodeEntry.UpdateLibraryBook(episodeBook); - } - - #endregion - - #region Column Customizations - - - - #endregion - } -} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Buttons.xaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Buttons.xaml.cs new file mode 100644 index 00000000..bdfd6148 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Buttons.xaml.cs @@ -0,0 +1,109 @@ +using Avalonia; +using Avalonia.Controls; +using FileLiberator; +using LibationFileManager; +using LibationWinForms.AvaloniaUI.ViewModels; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid +{ + public partial class ProductsDisplay2 + { + + private GridView.ImageDisplay imageDisplay; + private void Configure_Buttons() { } + + public void LiberateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + var button = args.Source as Button; + + if (button.DataContext is SeriesEntrys2 sEntry) + { + if (sEntry.Liberate.Expanded) + { + bindingList.CollapseItem(sEntry); + } + else + { + bindingList.ExpandItem(sEntry); + } + + VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); + + //Expanding and collapsing reset the list, which will cause focus to shift + //to the topright cell. Reset focus onto the clicked button's cell. + ((sender as Control).Parent.Parent as DataGridCell)?.Focus(); + } + else if (button.DataContext is LibraryBookEntry2 lbEntry) + { + LiberateClicked?.Invoke(this, lbEntry.LibraryBook); + } + } + + public async void Cover_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + if (sender is not Image tblock || tblock.DataContext is not GridEntry2 gEntry) + return; + + var picDefinition = new PictureDefinition(gEntry.LibraryBook.Book.PictureLarge ?? gEntry.LibraryBook.Book.PictureId, PictureSize.Native); + var picDlTask = Task.Run(() => PictureStorage.GetPictureSynchronously(picDefinition)); + + (_, byte[] initialImageBts) = PictureStorage.GetPicture(new PictureDefinition(gEntry.LibraryBook.Book.PictureId, PictureSize._80x80)); + var windowTitle = $"{gEntry.Title} - Cover"; + + if (imageDisplay is null || imageDisplay.IsDisposed || !imageDisplay.Visible) + { + imageDisplay = new GridView.ImageDisplay(); + imageDisplay.RestoreSizeAndLocation(Configuration.Instance); + imageDisplay.FormClosed += (_, _) => imageDisplay.SaveSizeAndLocation(Configuration.Instance); + imageDisplay.Show(null); + } + + imageDisplay.BookSaveDirectory = AudibleFileStorage.Audio.GetDestinationDirectory(gEntry.LibraryBook); + imageDisplay.PictureFileName = System.IO.Path.GetFileName(AudibleFileStorage.Audio.GetBooksDirectoryFilename(gEntry.LibraryBook, ".jpg")); + imageDisplay.Text = windowTitle; + imageDisplay.CoverPicture = initialImageBts; + imageDisplay.CoverPicture = await picDlTask; + } + + public void Description_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + if (sender is TextBlock tblock && tblock.DataContext is GridEntry2 gEntry) + { + var pt = tblock.Parent.PointToScreen(tblock.Parent.Bounds.TopRight); + var displayWindow = new GridView.DescriptionDisplay + { + SpawnLocation = new System.Drawing.Point(pt.X, pt.Y), + DescriptionText = gEntry.LongDescription, + BorderThickness = 2, + }; + + void CloseWindow(object o, DataGridRowEventArgs e) + { + displayWindow.Close(); + } + productsGrid.LoadingRow += CloseWindow; + displayWindow.FormClosed += (_, _) => + { + productsGrid.LoadingRow -= CloseWindow; + }; + + displayWindow.Show(); + } + } + + public void OnTagsButtonClick(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + var button = args.Source as Button; + + if (button.DataContext is LibraryBookEntry2 lbEntry) + { + var bookDetailsForm = new Dialogs.BookDetailsDialog(lbEntry.LibraryBook); + if (bookDetailsForm.ShowDialog() == System.Windows.Forms.DialogResult.OK) + lbEntry.Commit(bookDetailsForm.NewTags, bookDetailsForm.BookLiberatedStatus, bookDetailsForm.PdfLiberatedStatus); + } + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ColumnCustomization.xaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ColumnCustomization.xaml.cs new file mode 100644 index 00000000..50fe68d2 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ColumnCustomization.xaml.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid +{ + public partial class ProductsDisplay2 + { + private void Configure_ColumnCustomization() { } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Display.xaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Display.xaml.cs new file mode 100644 index 00000000..5c934399 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Display.xaml.cs @@ -0,0 +1,62 @@ +using ApplicationServices; +using Avalonia.Controls; +using LibationWinForms.AvaloniaUI.ViewModels; +using System; +using System.Collections; +using System.Linq; +using System.Reflection; + +namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid +{ + public partial class ProductsDisplay2 + { + private void Configure_Display() { } + + public void Display() + { + try + { + var dbBooks = DbContexts.GetLibrary_Flat_NoTracking(includeParents: true); + + if (_viewModel is null) + { + _viewModel = new ProductsDisplayViewModel(dbBooks); + InitialLoaded?.Invoke(this, EventArgs.Empty); + VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); + + //Avalonia displays items in the DataConncetion from an internal copy of + //the bound list, not the actual bound list. so we need to reflect to get + //the current display order and set the GridEntry.ListIndex correctly. + var DataConnection_PI = typeof(DataGrid).GetProperty("DataConnection", BindingFlags.NonPublic | BindingFlags.Instance); + var DataSource_PI = DataConnection_PI.PropertyType.GetProperty("DataSource", BindingFlags.Public | BindingFlags.Instance); + + bindingList.CollectionChanged += (s, e) => + { + var displayListGE = ((IEnumerable)DataSource_PI.GetValue(DataConnection_PI.GetValue(productsGrid))).Cast(); + int index = 0; + foreach (var di in displayListGE) + { + di.ListIndex = index++; + } + }; + + //Assign the viewmodel after we subscribe to CollectionChanged + //to ensure that out handler executes first. + productsGrid.DataContext = _viewModel; + } + else + { + string existingFilter = _viewModel?.GridEntries?.Filter; + bindingList.ReplaceList(ProductsDisplayViewModel.CreateGridEntries(dbBooks)); + bindingList.Filter = existingFilter; + ReSort(); + } + + } + catch (Exception ex) + { + Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplay2)); + } + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Filtering.xaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Filtering.xaml.cs new file mode 100644 index 00000000..17388697 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Filtering.xaml.cs @@ -0,0 +1,27 @@ +using LibationWinForms.AvaloniaUI.ViewModels; +using System; +using System.Linq; + +namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid +{ + public partial class ProductsDisplay2 + { + private void Configure_Filtering() { } + + public void Filter(string searchString) + { + int visibleCount = bindingList.Count; + + if (string.IsNullOrEmpty(searchString)) + bindingList.RemoveFilter(); + else + bindingList.Filter = searchString; + + if (visibleCount != bindingList.Count) + VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); + + //Re-sort after filtering + ReSort(); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ScanAndRemove.xaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ScanAndRemove.xaml.cs new file mode 100644 index 00000000..025ed866 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ScanAndRemove.xaml.cs @@ -0,0 +1,106 @@ +using ApplicationServices; +using AudibleUtilities; +using DataLayer; +using LibationWinForms.AvaloniaUI.ViewModels; +using ReactiveUI; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid +{ + public partial class ProductsDisplay2 + { + private void Configure_ScanAndRemove() { } + + public void CloseRemoveBooksColumn() + => removeGVColumn.IsVisible = false; + + public async Task RemoveCheckedBooksAsync() + { + var selectedBooks = GetAllBookEntries().Where(lbe => lbe.Remove == true).ToList(); + + if (selectedBooks.Count == 0) + return; + + var libraryBooks = selectedBooks.Select(rge => rge.LibraryBook).ToList(); + var result = MessageBoxLib.ShowConfirmationDialog( + libraryBooks, + $"Are you sure you want to remove {selectedBooks.Count} books from Libation's library?", + "Remove books from Libation?"); + + if (result != System.Windows.Forms.DialogResult.Yes) + return; + + RemoveBooks(selectedBooks); + var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList(); + var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove); + + RemovableCountChanged?.Invoke(this, GetAllBookEntries().Count(lbe => lbe.Remove is true)); + } + public async Task ScanAndRemoveBooksAsync(params Account[] accounts) + { + RemovableCountChanged?.Invoke(this, 0); + removeGVColumn.IsVisible = true; + + try + { + if (accounts is null || accounts.Length == 0) + return; + + var allBooks = GetAllBookEntries(); + + foreach (var b in allBooks) + b.Remove = false; + + var lib = allBooks + .Select(lbe => lbe.LibraryBook) + .Where(lb => !lb.Book.HasLiberated()); + + var removedBooks = await LibraryCommands.FindInactiveBooks(Login.WinformLoginChoiceEager.ApiExtendedFunc, lib, accounts); + + var removable = allBooks.Where(lbe => removedBooks.Any(rb => rb.Book.AudibleProductId == lbe.AudibleProductId)).ToList(); + + foreach (var r in removable) + r.Remove = true; + + RemovableCountChanged?.Invoke(this, GetAllBookEntries().Count(lbe => lbe.Remove is true)); + } + catch (Exception ex) + { + MessageBoxLib.ShowAdminAlert( + null, + "Error scanning library. You may still manually select books to remove from Libation's library.", + "Error scanning library", + ex); + } + } + + private void RemoveBooks(IEnumerable removedBooks) + { + //Remove books in series from their parents' Children list + foreach (var removed in removedBooks.Where(b => b.Parent is not null)) + { + removed.Parent.Children.Remove(removed); + + //In Avalonia, if you fire PropertyChanged with an empty or invalid property name, nothing is updated. + //So we must notify for specific properties that we believed changed. + removed.Parent.RaisePropertyChanged(nameof(SeriesEntrys2.Length)); + removed.Parent.RaisePropertyChanged(nameof(SeriesEntrys2.PurchaseDate)); + } + + //Remove series that have no children + var removedSeries = + bindingList + .AllItems() + .EmptySeries(); + + foreach (var removed in removedBooks.Cast().Concat(removedSeries)) + //no need to re-filter for removed books + bindingList.Remove(removed); + + VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Sorting.xaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Sorting.xaml.cs new file mode 100644 index 00000000..9e4fe5b5 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Sorting.xaml.cs @@ -0,0 +1,65 @@ +using Avalonia.Controls; +using LibationWinForms.AvaloniaUI.ViewModels; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid +{ + public partial class ProductsDisplay2 + { + private void Configure_Sorting() { } + + + private void RegisterCustomColumnComparers() + { + + removeGVColumn.CustomSortComparer = new RowComparer(removeGVColumn); + liberateGVColumn.CustomSortComparer = new RowComparer(liberateGVColumn); + titleGVColumn.CustomSortComparer = new RowComparer(titleGVColumn); + authorsGVColumn.CustomSortComparer = new RowComparer(authorsGVColumn); + narratorsGVColumn.CustomSortComparer = new RowComparer(narratorsGVColumn); + lengthGVColumn.CustomSortComparer = new RowComparer(lengthGVColumn); + seriesGVColumn.CustomSortComparer = new RowComparer(seriesGVColumn); + descriptionGVColumn.CustomSortComparer = new RowComparer(descriptionGVColumn); + categoryGVColumn.CustomSortComparer = new RowComparer(categoryGVColumn); + productRatingGVColumn.CustomSortComparer = new RowComparer(productRatingGVColumn); + purchaseDateGVColumn.CustomSortComparer = new RowComparer(purchaseDateGVColumn); + myRatingGVColumn.CustomSortComparer = new RowComparer(myRatingGVColumn); + miscGVColumn.CustomSortComparer = new RowComparer(miscGVColumn); + tagAndDetailsGVColumn.CustomSortComparer = new RowComparer(tagAndDetailsGVColumn); + } + + private void ReSort() + { + if (CurrentSortColumn is null) + { + bindingList.InternalList.Sort((i1, i2) => i2.DateAdded.CompareTo(i1.DateAdded)); + bindingList.ResetCollection(); + } + else + { + CurrentSortColumn.Sort(((RowComparer)CurrentSortColumn.CustomSortComparer).SortDirection ?? ListSortDirection.Ascending); + } + } + + + + private DataGridColumn CurrentSortColumn; + + + private void ProductsGrid_Sorting(object sender, DataGridColumnEventArgs e) + { + //Force the comparer to get the current sort order. We can't + //retrieve it from inside this event handler because Avalonia + //doesn't set the property until after this event. + var comparer = e.Column.CustomSortComparer as RowComparer; + comparer.SortDirection = null; + + CurrentSortColumn = e.Column; + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.axaml similarity index 96% rename from Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml rename to Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.axaml index 50361033..82c68edd 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay2.axaml +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.axaml @@ -5,11 +5,12 @@ xmlns:views="clr-namespace:LibationWinForms.AvaloniaUI.Views" xmlns:controls="clr-namespace:LibationWinForms.AvaloniaUI.Controls" mc:Ignorable="d" d:DesignWidth="1560" d:DesignHeight="400" - x:Class="LibationWinForms.AvaloniaUI.Views.ProductsDisplay2"> + x:Class="LibationWinForms.AvaloniaUI.Views.ProductsGrid.ProductsDisplay2"> - - - + + + + @@ -147,7 +148,7 @@ - + + + + + diff --git a/Source/LibationWinForms/AvaloniaUI/Views/Dialogs/MessageBoxWindow.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/Dialogs/MessageBoxWindow.axaml.cs new file mode 100644 index 00000000..f0e61464 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/Dialogs/MessageBoxWindow.axaml.cs @@ -0,0 +1,115 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; +using LibationWinForms.AvaloniaUI.ViewModels.Dialogs; + +namespace LibationWinForms.AvaloniaUI.Views.Dialogs +{ + + public enum DialogResult + { + None = 0, + OK = 1, + Cancel = 2, + Abort = 3, + Retry = 4, + Ignore = 5, + Yes = 6, + No = 7, + TryAgain = 10, + Continue = 11 + } + + + public enum MessageBoxIcon + { + None = 0, + Error = 16, + Hand = 16, + Stop = 16, + Question = 32, + Exclamation = 48, + Warning = 48, + Asterisk = 64, + Information = 64 + } + public enum MessageBoxButtons + { + OK, + OKCancel, + AbortRetryIgnore, + YesNoCancel, + YesNo, + RetryCancel, + CancelTryContinue + } + + public enum MessageBoxDefaultButton + { + Button1, + Button2 = 256, + Button3 = 512, + } + public partial class MessageBoxWindow : ReactiveWindow + { + public MessageBoxWindow() + { + InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + public DialogResult DialogResult { get; private set; } + + public void Button1_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + var vm = DataContext as MessageBoxViewModel; + DialogResult = vm.Buttons switch + { + MessageBoxButtons.OK => DialogResult.OK, + MessageBoxButtons.OKCancel => DialogResult.OK, + MessageBoxButtons.AbortRetryIgnore => DialogResult.Abort, + MessageBoxButtons.YesNoCancel => DialogResult.Yes, + MessageBoxButtons.YesNo => DialogResult.Yes, + MessageBoxButtons.RetryCancel => DialogResult.Retry, + MessageBoxButtons.CancelTryContinue => DialogResult.Cancel, + _ => DialogResult.None + }; + Close(DialogResult); + } + public void Button2_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + var vm = DataContext as MessageBoxViewModel; + DialogResult = vm.Buttons switch + { + MessageBoxButtons.OKCancel => DialogResult.Cancel, + MessageBoxButtons.AbortRetryIgnore => DialogResult.Retry, + MessageBoxButtons.YesNoCancel => DialogResult.No, + MessageBoxButtons.YesNo => DialogResult.No, + MessageBoxButtons.RetryCancel => DialogResult.Cancel, + MessageBoxButtons.CancelTryContinue => DialogResult.TryAgain, + _ => DialogResult.None + }; + Close(DialogResult); + } + public void Button3_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + var vm = DataContext as MessageBoxViewModel; + DialogResult = vm.Buttons switch + { + MessageBoxButtons.AbortRetryIgnore => DialogResult.Ignore, + MessageBoxButtons.YesNoCancel => DialogResult.Cancel, + MessageBoxButtons.CancelTryContinue => DialogResult.Continue, + _ => DialogResult.None + }; + Close(DialogResult); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Export.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Export.axaml.cs index 445ba0cd..bac291bf 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Export.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Export.axaml.cs @@ -1,4 +1,5 @@ using ApplicationServices; +using LibationWinForms.AvaloniaUI.Views.Dialogs; using System; using System.Linq; @@ -9,7 +10,7 @@ namespace LibationWinForms.AvaloniaUI.Views { private void Configure_Export() { } - public void exportLibraryToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + public async void exportLibraryToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) { try { @@ -37,7 +38,7 @@ namespace LibationWinForms.AvaloniaUI.Views break; } - System.Windows.Forms.MessageBox.Show("Library exported to:\r\n" + saveFileDialog.FileName); + await MessageBox.Show("Library exported to:\r\n" + saveFileDialog.FileName); } catch (Exception ex) { diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Filter.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Filter.axaml.cs index cb97905f..e9f08819 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Filter.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Filter.axaml.cs @@ -1,7 +1,9 @@ using Avalonia.Input; +using LibationWinForms.AvaloniaUI.Views.Dialogs; using LibationWinForms.Dialogs; using System; using System.Linq; +using System.Threading.Tasks; namespace LibationWinForms.AvaloniaUI.Views { @@ -13,22 +15,22 @@ namespace LibationWinForms.AvaloniaUI.Views public void filterHelpBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) => new SearchSyntaxDialog().ShowDialog(); - public void filterSearchTb_KeyPress(object sender, KeyEventArgs e) + public async void filterSearchTb_KeyPress(object sender, KeyEventArgs e) { if (e.Key == Key.Return) { - performFilter(this.filterSearchTb.Text); + await performFilter(this.filterSearchTb.Text); // silence the 'ding' e.Handled = true; } } - public void filterBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) - => performFilter(this.filterSearchTb.Text); + public async void filterBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => await performFilter(this.filterSearchTb.Text); private string lastGoodFilter = ""; - private void performFilter(string filterString) + private async Task performFilter(string filterString) { this.filterSearchTb.Text = filterString; @@ -39,10 +41,10 @@ namespace LibationWinForms.AvaloniaUI.Views } catch (Exception ex) { - System.Windows.Forms.MessageBox.Show($"Bad filter string:\r\n\r\n{ex.Message}", "Bad filter string", System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Error); + await MessageBox.Show($"Bad filter string:\r\n\r\n{ex.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error); // re-apply last good filter - performFilter(lastGoodFilter); + await performFilter(lastGoodFilter); } } } diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Liberate.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Liberate.axaml.cs index a9da48d6..82c27a02 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Liberate.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Liberate.axaml.cs @@ -1,4 +1,5 @@ using DataLayer; +using LibationWinForms.AvaloniaUI.Views.Dialogs; using System; using System.Linq; using System.Threading.Tasks; @@ -40,15 +41,15 @@ namespace LibationWinForms.AvaloniaUI.Views public async void convertAllM4bToMp3ToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) { - var result = System.Windows.Forms.MessageBox.Show( + var result = await MessageBox.Show( "This converts all m4b titles in your library to mp3 files. Original files are not deleted." + "\r\nFor large libraries this will take a long time and will take up more disk space." + "\r\n\r\nContinue?" + "\r\n\r\n(To always download titles as mp3 instead of m4b, go to Settings: Download my books as .MP3 files)", "Convert all M4b => Mp3?", - System.Windows.Forms.MessageBoxButtons.YesNo, - System.Windows.Forms.MessageBoxIcon.Warning); - if (result == System.Windows.Forms.DialogResult.Yes) + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning); + if (result == DialogResult.Yes) { SetQueueCollapseState(false); await Task.Run(() => processBookQueue1.AddConvertMp3(ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking() diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ProcessQueue.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ProcessQueue.axaml.cs index 5d166aba..d2d069f3 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ProcessQueue.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ProcessQueue.axaml.cs @@ -1,6 +1,7 @@ using DataLayer; using Dinah.Core; using LibationFileManager; +using LibationWinForms.AvaloniaUI.Views.Dialogs; using System; using System.Linq; @@ -15,7 +16,7 @@ namespace LibationWinForms.AvaloniaUI.Views SetQueueCollapseState(collapseState); } - public void ProductsDisplay_LiberateClicked(object sender, LibraryBook libraryBook) + public async void ProductsDisplay_LiberateClicked(object sender, LibraryBook libraryBook) { try { @@ -38,7 +39,7 @@ namespace LibationWinForms.AvaloniaUI.Views if (!Go.To.File(filePath?.ShortPathName)) { var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}"; - System.Windows.Forms.MessageBox.Show($"File not found" + suffix); + await MessageBox.Show($"File not found" + suffix); } } } diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanManual.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanManual.axaml.cs index 719b7e1a..ff158e0c 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanManual.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.ScanManual.axaml.cs @@ -2,6 +2,7 @@ using AudibleUtilities; using Avalonia.Controls; using LibationFileManager; +using LibationWinForms.AvaloniaUI.Views.Dialogs; using LibationWinForms.Dialogs; using System; using System.Collections.Generic; @@ -49,9 +50,9 @@ namespace LibationWinForms.AvaloniaUI.Views } } - public void noAccountsYetAddAccountToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + public async void noAccountsYetAddAccountToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) { - System.Windows.Forms.MessageBox.Show("To load your Audible library, come back here to the Import menu after adding your account"); + await MessageBox.Show("To load your Audible library, come back here to the Import menu after adding your account"); new AccountsDialog().ShowDialog(); } @@ -91,7 +92,7 @@ namespace LibationWinForms.AvaloniaUI.Views // this is here instead of ScanEnd so that the following is only possible when it's user-initiated, not automatic loop if (Configuration.Instance.ShowImportedStats && newAdded > 0) - System.Windows.Forms.MessageBox.Show($"Total processed: {totalProcessed}\r\nNew: {newAdded}"); + await MessageBox.Show($"Total processed: {totalProcessed}\r\nNew: {newAdded}"); } catch (Exception ex) { diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Settings.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Settings.axaml.cs index cc84bbe8..f0ac8324 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Settings.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Settings.axaml.cs @@ -1,4 +1,5 @@ -using LibationWinForms.Dialogs; +using LibationWinForms.AvaloniaUI.Views.Dialogs; +using LibationWinForms.Dialogs; using System; using System.Linq; @@ -13,7 +14,7 @@ namespace LibationWinForms.AvaloniaUI.Views public void basicSettingsToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) => new SettingsDialog().ShowDialog(); - public void aboutToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) - => System.Windows.Forms.MessageBox.Show($"Running Libation version {AppScaffolding.LibationScaffolding.BuildVersion}", $"Libation v{AppScaffolding.LibationScaffolding.BuildVersion}"); + public async void aboutToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => await MessageBox.Show($"Running Libation version {AppScaffolding.LibationScaffolding.BuildVersion}", $"Libation v{AppScaffolding.LibationScaffolding.BuildVersion}"); } } diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml index 22204a5a..d6853ad0 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml @@ -14,7 +14,7 @@ - + @@ -106,7 +106,9 @@ + - - - + + + + + + + diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml.cs index 4d5a3c90..2ae8ca96 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml.cs @@ -8,6 +8,7 @@ using Avalonia.Threading; using LibationWinForms.AvaloniaUI.Views.ProductsGrid; using Avalonia.ReactiveUI; using LibationWinForms.AvaloniaUI.ViewModels; +using System.Threading.Tasks; namespace LibationWinForms.AvaloniaUI.Views { @@ -21,7 +22,6 @@ namespace LibationWinForms.AvaloniaUI.Views #endif this.FindAllControls(); - // eg: if one of these init'd productsGrid, then another can't reliably subscribe to it Configure_BackupCounts(); Configure_ScanAuto(); @@ -38,11 +38,21 @@ namespace LibationWinForms.AvaloniaUI.Views // misc which belongs in winforms app but doesn't have a UI element Configure_NonUI(); + async void DoDisplay(object _, EventArgs __) { - this.Load += (_, _) => productsDisplay.Display(); - LibraryCommands.LibrarySizeChanged += (_, __) => Dispatcher.UIThread.Post(() => productsDisplay.Display()); + await productsDisplay.Display(); + } + { + this.Load += DoDisplay; + LibraryCommands.LibrarySizeChanged += DoDisplay; } } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + public event EventHandler Load; public void OnLoad() => Load?.Invoke(this, EventArgs.Empty); @@ -114,10 +124,5 @@ namespace LibationWinForms.AvaloniaUI.Views { base.OnDataContextChanged(e); } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); - } } } diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Display.xaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Display.xaml.cs index b255567b..824f26b6 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Display.xaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Display.xaml.cs @@ -5,6 +5,8 @@ using System; using System.Collections; using System.Linq; using System.Reflection; +using Avalonia.Threading; +using System.Threading.Tasks; namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid { @@ -12,7 +14,7 @@ namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid { private void Configure_Display() { } - public void Display() + public async Task Display() { try { @@ -21,8 +23,10 @@ namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid if (_viewModel is null) { _viewModel = new ProductsDisplayViewModel(dbBooks); - InitialLoaded?.Invoke(this, EventArgs.Empty); - VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); + await Dispatcher.UIThread.InvokeAsync(() => InitialLoaded?.Invoke(this, EventArgs.Empty)); + + int bookEntryCount = bindingList.BookEntries().Count(); + await Dispatcher.UIThread.InvokeAsync(() => VisibleCountChanged?.Invoke(this, bookEntryCount)); //Avalonia displays items in the DataConncetion from an internal copy of //the bound list, not the actual bound list. So we need to reflect to get @@ -48,9 +52,13 @@ namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid { //List is already displayed. Replace all items with new ones, refilter, and re-sort string existingFilter = _viewModel?.GridEntries?.Filter; - bindingList.ReplaceList(ProductsDisplayViewModel.CreateGridEntries(dbBooks)); - bindingList.Filter = existingFilter; - ReSort(); + var newEntries = ProductsDisplayViewModel.CreateGridEntries(dbBooks); + await Dispatcher.UIThread.InvokeAsync(() => + { + bindingList.ReplaceList(newEntries); + bindingList.Filter = existingFilter; + ReSort(); + }); } } catch (Exception ex) diff --git a/Source/LibationWinForms/Form1.cs b/Source/LibationWinForms/Form1.cs index ae4628a4..209f2c48 100644 --- a/Source/LibationWinForms/Form1.cs +++ b/Source/LibationWinForms/Form1.cs @@ -65,6 +65,27 @@ namespace LibationWinForms if (this.DesignMode) return; + string message = +@"What’s funny is how the alt-right completely misses the point with Rome. + +What’s funny though is that Rome was actually quite diverse. Sure the power came from Italy but without Egypt, Spain, North Africa, and Gaul it's unlikely Rome would have been the power they are. + +Rome’s great strength was its diversity. The fact they could work with countless cultures without prejudice allowed them to maintain such a massive Empire in the first place. They would adopt foreign gods and foreign cultures with ease and it allowed them to distill the best pieces of the Mediterranean world for themselves. + +In the final years of the Western Roman Empire, there was this sudden focus on ethnic make up. Where Rome had once accepted all cultures and assimilated them they refused to do so with the new Germanic migrants like the Goths, Vandals, Burgundians, and Franks. + +The Goths in particular just wanted a seat at the table. They were willing to fight for Rome and defend the borders. The 2 greatest generals of the final decades- Stilicho and Aetius- were both half barbarian themselves. + +In previous periods of collapse, Rome would find ethnically new emperors to help turn the tide. In the third century crisis, the Illyrian Empires (Aurelian, Probus, Diocletian) rose to the challenge and restored the Empire fully. In the tumultuous collapse of the Flavian dynasty, the Spanish Emporers (Trajan, Hadrian) would lead Rome to its peak of power. + +Had Rome accepted the Goths, assimilated them and treated them fairly, and brought them into the Empire they may have survived. Great men like Stilicho would have become Emperor and maybe they could have restored the Empire. + +Instead, Rome chose to keep them at arm's length and forced them into desperation within turn led to the Goths sacking Rome. + +Put simply the Rome that accepted all peoples regardless of race was the Rome of Trajan and glory- the Rome that demanded ethnic purity was the Rome of Honorius and collapse."; + + System.Windows.Forms.MessageBox.Show("funny", "Caption", MessageBoxButtons.CancelTryContinue, MessageBoxIcon.Warning); + // I'm leaving this empty call here as a reminder that if we use this, it should probably be after DesignMode check } } diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index df6a5ec0..f7905a4e 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -357,7 +357,9 @@ namespace LibationWinForms.GridView { var column = gridEntryDataGridView.Columns .Cast() - .Single(c => c.DataPropertyName == itemName); + .SingleOrDefault(c => c.DataPropertyName == itemName); + + if (column is null) continue; column.DisplayIndex = displayIndices.GetValueOrDefault(itemName, column.Index); } diff --git a/Source/LibationWinForms/Program.cs b/Source/LibationWinForms/Program.cs index 0555bd5c..42441865 100644 --- a/Source/LibationWinForms/Program.cs +++ b/Source/LibationWinForms/Program.cs @@ -41,7 +41,7 @@ namespace LibationWinForms //Otherwise we just ignore all the Avalonia app build stuff and continue with winforms. //For debug purposes, always run AvaloniaUI. - if (true)// (startupTask.Result.useBeta) + if (true) //(startupTask.Result.useBeta) { await Task.WhenAll(appBuilderTask, classicLifetimeTask, startupTask); From de3524d6889bd9628358798cb22579286fd9d3ef Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Thu, 14 Jul 2022 13:26:36 -0600 Subject: [PATCH 22/47] refine message box. --- .../LibationWinForms/AvaloniaUI/MessageBox.cs | 38 +++++++++++++++++++ .../Views/Dialogs/MessageBoxWindow.axaml | 5 +++ 2 files changed, 43 insertions(+) diff --git a/Source/LibationWinForms/AvaloniaUI/MessageBox.cs b/Source/LibationWinForms/AvaloniaUI/MessageBox.cs index 810999d3..235c8a3f 100644 --- a/Source/LibationWinForms/AvaloniaUI/MessageBox.cs +++ b/Source/LibationWinForms/AvaloniaUI/MessageBox.cs @@ -211,6 +211,11 @@ namespace LibationWinForms.AvaloniaUI private static async Task ShowCore2(Window owner, string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton) { var dialog = new MessageBoxWindow(); + +#if WINDOWS7_0 + HideMinMaxBtns(dialog.PlatformImpl.Handle.Handle); +#endif + var vm = new MessageBoxViewModel(message, caption, buttons, icon, defaultButton); dialog.DataContext = vm; dialog.CanResize = false; @@ -236,6 +241,22 @@ namespace LibationWinForms.AvaloniaUI dialog.Height = dialog.MinHeight; dialog.Width = dialog.MinWidth; + dialog.Opened += (_, _) => + { + switch (defaultButton) + { + case MessageBoxDefaultButton.Button1: + dialog.FindControl - internal class RowComparer : IComparer + internal class RowComparer : IComparer, IComparer { private static readonly PropertyInfo HeaderCellPi = typeof(DataGridColumn).GetProperty("HeaderCell", BindingFlags.NonPublic | BindingFlags.Instance); private static readonly PropertyInfo CurrentSortingStatePi = typeof(DataGridColumnHeader).GetProperty("CurrentSortingState", BindingFlags.NonPublic | BindingFlags.Instance); @@ -81,7 +81,7 @@ namespace LibationWinForms.AvaloniaUI.ViewModels return geA.SeriesIndex.CompareTo(geB.SeriesIndex) * (SortDirection is ListSortDirection.Ascending ? 1 : -1); //a and b are children of different series. - return Compare(parentA, parentB); + return InternalCompare(parentA, parentB); } //Avalonia doesn't expose the column's CurrentSortingState, so we must get it through reflection @@ -102,5 +102,10 @@ namespace LibationWinForms.AvaloniaUI.ViewModels else return compareResult; } + + public int Compare(GridEntry2 x, GridEntry2 y) + { + return Compare((object)x, y); + } } } diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs index 4cec5386..886131e8 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntrys2.cs @@ -55,7 +55,7 @@ namespace LibationWinForms.AvaloniaUI.ViewModels public SeriesEntrys2(LibraryBook parent, IEnumerable children) { - Liberate = new LiberateButtonStatus2(IsSeries); + Liberate = new LiberateButtonStatus2(IsSeries) { Expanded = true }; SeriesIndex = -1; LibraryBook = parent; diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.RemoveBooks.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.RemoveBooks.axaml.cs index b108aaea..9d1a0abc 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.RemoveBooks.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.RemoveBooks.axaml.cs @@ -10,8 +10,10 @@ namespace LibationWinForms.AvaloniaUI.Views { private void Configure_RemoveBooks() { - removeBooksBtn.IsVisible = false; - doneRemovingBtn.IsVisible = false; + if (Avalonia.Controls.Design.IsDesignMode) + return; + + _viewModel.RemoveButtonsVisible = false; removeLibraryBooksToolStripMenuItem.Click += removeLibraryBooksToolStripMenuItem_Click; } @@ -22,8 +24,7 @@ namespace LibationWinForms.AvaloniaUI.Views public async void doneRemovingBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) { - removeBooksBtn.IsVisible = false; - doneRemovingBtn.IsVisible = false; + _viewModel.RemoveButtonsVisible = false; productsDisplay.CloseRemoveBooksColumn(); @@ -80,15 +81,14 @@ namespace LibationWinForms.AvaloniaUI.Views filterSearchTb.IsVisible = false; productsDisplay.Filter(null); - removeBooksBtn.IsVisible = true; - doneRemovingBtn.IsVisible = true; + _viewModel.RemoveButtonsVisible = true; await productsDisplay.ScanAndRemoveBooksAsync(accounts); } public void productsDisplay_RemovableCountChanged(object sender, int removeCount) { - removeBooksBtn.Content = removeCount switch + _viewModel.RemoveBooksButtonText = removeCount switch { 1 => "Remove 1 Book from Libation", _ => $"Remove {removeCount} Books from Libation" diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml index 864fc60f..61053fe3 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml @@ -9,7 +9,7 @@ xmlns:controls="clr-namespace:LibationWinForms.AvaloniaUI.Controls" mc:Ignorable="d" d:DesignWidth="1850" d:DesignHeight="700" x:Class="LibationWinForms.AvaloniaUI.Views.MainWindow" - Title="MainWindow" + Title="Libation" Name="Form1" Icon="/AvaloniaUI/Assets/glass-with-glow_16.png"> @@ -128,8 +128,8 @@ -