2020-08-31 14:02:41 +00:00
/ *
buypeeb - a program to track yahoo jp auctions of peebus .
Copyright ( C ) 2020 lynnesbian
This program is free software : you can redistribute it and / or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation , either version 3 of the License , or
( at your option ) any later version .
This program is distributed in the hope that it will be useful ,
but WITHOUT ANY WARRANTY ; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE . See the
GNU General Public License for more details .
You should have received a copy of the GNU General Public License
along with this program . If not , see < https : //www.gnu.org/licenses/>.
* /
2020-08-28 16:04:37 +00:00
using System ;
2020-09-01 10:41:29 +00:00
using System.Collections.Generic ;
2020-09-03 13:47:36 +00:00
using System.IO ;
2020-09-01 09:58:35 +00:00
using System.Text.RegularExpressions ;
2020-09-03 13:47:36 +00:00
using System.Text.Json ;
2020-09-03 05:23:23 +00:00
using System.Threading ;
using System.Threading.Tasks ;
2020-09-03 16:08:31 +00:00
using System.Net ;
2020-09-05 05:02:37 +00:00
using System.Linq ;
2020-09-04 07:23:32 +00:00
using System.Diagnostics ;
2020-09-05 05:02:37 +00:00
using CsvHelper ;
2020-08-28 16:04:37 +00:00
using Gtk ;
namespace Buypeeb {
class MainWindow : Window {
2020-08-31 14:20:56 +00:00
2020-09-03 13:47:36 +00:00
private string location ;
2020-09-05 05:02:37 +00:00
private JsonSerializerOptions jsonOptions ;
2020-09-03 13:47:36 +00:00
2020-09-02 03:21:32 +00:00
private ListStore items ;
private Settings settings ;
2020-09-03 06:02:56 +00:00
private TreeView itemTreeView ;
2020-09-04 07:23:32 +00:00
private Builder builder ;
// TODO: whenever we get something from the builder, cache it for later
// that way we don't need to constantly do "builder.GetObject"s
2020-09-04 10:48:47 +00:00
// when that is done, you can use the cache array to replace everything from here...
private Box selectionViewBox ;
private Label endingLabel ;
2020-09-05 03:45:18 +00:00
private bool queueActive ;
2020-09-05 07:10:35 +00:00
private SearchEntry searchEntry ;
2020-09-05 06:51:28 +00:00
private Dictionary < string , CheckButton > filterChecks = new Dictionary < string , CheckButton > ( ) ;
2020-09-04 10:48:47 +00:00
// ...to here.
2020-08-31 14:20:56 +00:00
2020-09-05 03:47:15 +00:00
static SemaphoreSlim taskLimit = new SemaphoreSlim ( 6 ) ;
2020-09-05 03:45:18 +00:00
private Queue < string > updateQueue = new Queue < string > ( ) ;
2020-09-06 05:27:11 +00:00
private IEnumerable < YahooAuctionsItem > filterQuery {
get {
// father forgive me for i have lynned
return
from item in this . settings . watchlist . Values . ToList ( )
where ( item . favourite ! = this . filterChecks [ "Favourites" ] . Active | |
item . favourite = = this . filterChecks [ "NonFavourites" ] . Active ) & &
( item . available ! = this . filterChecks [ "Active" ] . Active | |
item . available = = this . filterChecks [ "Ended" ] . Active ) & &
( item . endingToday ! = this . filterChecks [ "EndingToday" ] . Active | |
item . endingToday = = this . filterChecks [ "EndingAfterToday" ] . Active ) & &
( item . hasWinPrice ! = this . filterChecks [ "WithWinPrice" ] . Active | |
item . hasWinPrice = = this . filterChecks [ "WithNoWinPrice" ] . Active ) & &
( String . IsNullOrWhiteSpace ( this . searchEntry . Text ) | |
item . name . ToLower ( ) . Contains ( this . searchEntry . Text . ToLower ( ) ) | |
item . originalName . ToLower ( ) . Contains ( this . searchEntry . Text . ToLower ( ) ) )
select item ;
}
}
private IEnumerable < YahooAuctionsItem > outdatedItemQuery {
get {
// only returns items that meet all of the following:
// - marked as "ready", as in, they aren't in the process of updating
// - not updated since the interval
return
from item in this . settings . watchlist . Values . ToList ( )
where item . ready & & this . settings . ItemNotUpdatedSinceInterval ( item )
select item ;
}
}
2020-09-03 05:23:23 +00:00
2020-09-04 09:17:59 +00:00
private YahooAuctionsItem selectedItem {
2020-09-04 07:23:32 +00:00
get {
2020-09-04 09:17:59 +00:00
if ( this . itemTreeView . Selection . CountSelectedRows ( ) = = 0 ) {
// avoids incurring the wrath of Gtk-CRITICAL **
return null ;
}
2020-09-04 07:23:32 +00:00
this . itemTreeView . Selection . GetSelected ( out TreeIter iter ) ;
2020-09-04 09:13:16 +00:00
return ( YahooAuctionsItem ) this . itemTreeView . Model . GetValue ( iter , 0 ) ;
2020-09-04 07:23:32 +00:00
}
}
2020-09-01 09:58:35 +00:00
public MainWindow ( ) : this ( new Builder ( "main.glade" ) ) { }
2020-08-28 16:04:37 +00:00
private MainWindow ( Builder builder ) : base ( builder . GetObject ( "wndMain" ) . Handle ) {
2020-09-05 05:02:37 +00:00
this . jsonOptions = new JsonSerializerOptions {
Encoder = System . Text . Encodings . Web . JavaScriptEncoder . Create ( System . Text . Unicode . UnicodeRanges . All )
} ;
2020-09-03 13:47:36 +00:00
if ( Environment . OSVersion . Platform = = PlatformID . Win32NT ) {
// C:\Users\Beebus\AppData\Roaming\Lynnear Software\buypeeb
this . location = System . IO . Path . Combine ( Environment . ExpandEnvironmentVariables ( "%APPDATA%" ) , "Lynnear Software" , "buypeeb" ) ;
}
else {
// ~/.config/Lynnear Software/buypeeb
this . location = System . IO . Path . Combine ( Environment . GetEnvironmentVariable ( "HOME" ) , ".config" , "Lynnear Software" , "buypeeb" ) ;
}
string userdata = System . IO . Path . Combine ( location , "userdata.json" ) ;
if ( File . Exists ( userdata ) ) {
try {
string j = File . ReadAllText ( userdata ) ;
this . settings = JsonSerializer . Deserialize < Settings > ( j ) ;
}
catch {
// ???
Console . WriteLine ( "oops" ) ;
Application . Quit ( ) ;
}
}
else {
this . settings = new Settings ( ) ;
}
this . SaveSettings ( ) ;
2020-08-31 14:20:56 +00:00
this . Title = "Buypeeb" ;
2020-09-03 05:23:23 +00:00
2020-09-04 07:23:32 +00:00
this . builder = builder ;
2020-08-28 16:04:37 +00:00
builder . Autoconnect ( this ) ;
2020-09-03 06:02:56 +00:00
2020-09-06 04:39:30 +00:00
var menuButtonFilter = ( MenuButton ) builder . GetObject ( "MenuButtonFilter" ) ;
var menuButtonSort = ( MenuButton ) builder . GetObject ( "MenuButtonSort" ) ;
menuButtonFilter . Child = ( Image ) builder . GetObject ( "ImageFilter" ) ;
menuButtonSort . Child = ( Image ) builder . GetObject ( "ImageSort" ) ;
2020-09-04 10:48:47 +00:00
this . selectionViewBox = ( Box ) builder . GetObject ( "SelectionViewBox" ) ;
this . endingLabel = ( Label ) builder . GetObject ( "LabelSelectedEnding" ) ;
2020-09-05 07:10:35 +00:00
this . searchEntry = ( SearchEntry ) builder . GetObject ( "FilterSearchEntry" ) ;
2020-09-05 06:51:28 +00:00
foreach ( var name in new List < string > { "Favourites" , "NonFavourites" , "Active" , "Ended" , "EndingToday" , "EndingAfterToday" , "WithWinPrice" , "WithNoWinPrice" } ) {
this . filterChecks . Add ( name , ( CheckButton ) builder . GetObject ( $"CheckButtonFilter{name}" ) ) ;
this . filterChecks [ name ] . Active = false ;
}
2020-09-03 06:02:56 +00:00
// bind treeview columns to watchlist instead of needing to manually sync its liststore
this . itemTreeView = ( TreeView ) builder . GetObject ( "TreeViewItems" ) ;
2020-09-04 09:13:16 +00:00
this . items = new ListStore ( typeof ( YahooAuctionsItem ) ) ;
2020-09-05 06:51:28 +00:00
var filteredItems = new TreeModelFilter ( this . items , null ) ;
filteredItems . VisibleFunc = this . ItemFilter ;
2020-09-03 05:23:23 +00:00
2020-09-05 06:51:28 +00:00
this . itemTreeView . Model = filteredItems ;
2020-09-03 16:08:31 +00:00
TreeCellDataFunc [ ] funcs = {
2020-09-05 03:45:18 +00:00
new TreeCellDataFunc ( this . RenderColumnFavourite ) ,
2020-09-03 16:08:31 +00:00
new TreeCellDataFunc ( this . RenderColumnName ) ,
new TreeCellDataFunc ( this . RenderColumnPriceYen ) ,
new TreeCellDataFunc ( this . RenderColumnPriceAUD ) ,
new TreeCellDataFunc ( this . RenderColumnEnding )
} ;
2020-09-03 06:34:42 +00:00
for ( int i = 0 ; i < this . itemTreeView . Columns . Length ; i + + ) {
var c = this . itemTreeView . Columns [ i ] ;
2020-09-03 16:08:31 +00:00
c . SetCellDataFunc ( c . Cells [ 0 ] , funcs [ i ] ) ;
2020-09-03 06:34:42 +00:00
}
2020-09-05 06:51:28 +00:00
this . RenderList ( ) ;
2020-09-03 16:08:31 +00:00
this . UpdateItems ( ) ;
2020-09-04 10:48:47 +00:00
GLib . Timeout . Add ( 1000 , new GLib . TimeoutHandler ( UpdateSelectionEndTime ) ) ;
2020-09-05 18:36:17 +00:00
GLib . Timeout . Add ( 10000 , new GLib . TimeoutHandler ( AutoUpdateItems ) ) ;
2020-09-03 06:02:56 +00:00
2020-09-04 09:17:59 +00:00
DeleteEvent + = WindowShutdown ;
2020-08-28 16:04:37 +00:00
}
2020-09-04 09:17:59 +00:00
private void WindowShutdown ( object sender , DeleteEventArgs args ) {
2020-09-03 14:38:41 +00:00
SaveSettings ( ) ;
2020-08-28 16:04:37 +00:00
Application . Quit ( ) ;
}
2020-09-01 10:41:29 +00:00
// general behaviour
2020-08-28 16:58:14 +00:00
2020-09-06 10:35:37 +00:00
/// <summary>
/// gets the path and iter for a given item id.
/// </summary>
/// <param name="id">the item id to find in the treeview</param>
/// <returns>a tuple of (TreePath, TreeIter)</returns>
2020-09-03 16:09:19 +00:00
private ( TreePath path , TreeIter iter ) GetRow ( string id ) {
// TODO: surely there's a better way to do this
TreeIter iter ;
this . itemTreeView . Model . GetIterFirst ( out iter ) ;
2020-09-05 06:51:28 +00:00
var m = ( TreeModelFilter ) this . itemTreeView . Model ;
2020-09-03 16:09:19 +00:00
for ( int i = 0 ; i < this . itemTreeView . Model . IterNChildren ( ) ; i + + ) {
2020-09-04 09:13:16 +00:00
var x = ( YahooAuctionsItem ) this . itemTreeView . Model . GetValue ( iter , 0 ) ;
2020-09-03 16:09:19 +00:00
if ( x . id = = id ) {
2020-09-05 06:51:28 +00:00
return ( m . ConvertPathToChildPath ( m . GetPath ( iter ) ) , m . ConvertIterToChildIter ( iter ) ) ;
2020-09-03 16:09:19 +00:00
}
else {
this . itemTreeView . Model . IterNext ( ref iter ) ;
}
}
Console . WriteLine ( $"Couldn't find {id}!" ) ;
return ( null , iter ) ;
}
2020-09-06 10:35:37 +00:00
/// <summary>
/// saves the settings to userdata.json.
/// </summary>
2020-09-03 13:47:36 +00:00
private void SaveSettings ( ) {
2020-09-05 05:02:37 +00:00
string j = JsonSerializer . Serialize ( this . settings , this . jsonOptions ) ;
2020-09-03 14:17:28 +00:00
string p = System . IO . Path . Combine ( this . location , "userdata.json" ) ;
2020-09-03 13:47:36 +00:00
Console . WriteLine ( j ) ;
2020-09-03 14:19:13 +00:00
if ( ! Directory . Exists ( this . location ) ) {
Directory . CreateDirectory ( this . location ) ;
}
2020-09-05 18:51:47 +00:00
using ( StreamWriter fs = File . CreateText ( p ) ) {
fs . Write ( j ) ;
2020-09-03 14:17:28 +00:00
}
2020-09-05 18:51:47 +00:00
2020-09-03 13:47:36 +00:00
}
2020-09-06 10:35:37 +00:00
/// <summary>
/// updates the item with the given id. this method blocks and is intended to be run from a task.
/// </summary>
/// <param name="id">the id of the item to update</param>
2020-09-03 05:23:23 +00:00
private void UpdateThread ( string id ) {
var item = this . settings . watchlist [ id ] ;
2020-09-05 03:45:18 +00:00
// Console.WriteLine($"Updating {id}...");
2020-09-03 13:47:36 +00:00
// set item.ready to false to show that it's still being updated
// this changes a few behaviours, such as displaying the price as "..." instead of whatever's currently stored
2020-09-03 05:23:23 +00:00
item . ready = false ;
2020-09-03 16:09:19 +00:00
2020-09-03 05:23:23 +00:00
Gtk . Application . Invoke ( delegate {
2020-09-03 16:09:19 +00:00
// TODO: find a way to not have to do this. i think we need to avoid actually modifying the items outside of the main thread :/
var pathAndIter = this . GetRow ( id ) ;
2020-09-05 06:51:28 +00:00
if ( pathAndIter . path ! = null ) {
this . items . EmitRowChanged ( pathAndIter . path , pathAndIter . iter ) ;
}
2020-09-03 05:23:23 +00:00
} ) ;
2020-09-03 16:09:19 +00:00
using ( WebClient client = new WebClient ( ) ) {
// TODO: download should have timeout
2020-09-05 06:52:09 +00:00
item . Update ( client . DownloadString ( item . url ) ) ;
// Thread.Sleep(5000);
// item.Update(File.ReadAllText("yahoo.html"));
2020-09-03 16:09:19 +00:00
}
Gtk . Application . Invoke ( delegate {
var pathAndIter = this . GetRow ( id ) ;
2020-09-05 06:51:28 +00:00
if ( pathAndIter . path ! = null ) {
this . items . EmitRowChanged ( pathAndIter . path , pathAndIter . iter ) ;
}
2020-09-04 09:17:59 +00:00
if ( item = = this . selectedItem ) {
2020-09-05 07:37:41 +00:00
// if the user has this item selected and it just became ready, enable the selection box and redraw the info
2020-09-04 10:48:47 +00:00
this . selectionViewBox . Sensitive = true ;
2020-09-05 07:37:41 +00:00
this . UpdateSelectionView ( ) ;
2020-09-04 07:23:32 +00:00
}
2020-09-03 16:09:19 +00:00
} ) ;
item . ready = true ;
2020-09-05 03:45:18 +00:00
// Console.WriteLine($"{id} updated.");
2020-09-03 05:23:23 +00:00
}
2020-09-06 10:35:37 +00:00
/// <summary>
/// recursively processes the update queue. this is a blocking function.
/// </summary>
2020-09-05 03:45:18 +00:00
private void ProcessUpdateQueue ( ) {
this . queueActive = true ;
this . UpdateItem ( this . updateQueue . Dequeue ( ) ) ;
if ( this . updateQueue . TryPeek ( out string _ ) ) {
this . ProcessUpdateQueue ( ) ;
}
else {
this . queueActive = false ;
}
}
2020-09-06 10:35:37 +00:00
/// <summary>
/// updates an item with the given id with a new task.
/// </summary>
/// <param name="id">the id of the task to update</param>
/// <param name="renderListWhenDone">whether or not to call this.RenderList() after updating the item</param>
2020-09-06 05:30:34 +00:00
private void UpdateItem ( string id , bool renderListWhenDone = false ) {
2020-09-05 03:45:18 +00:00
var item = this . settings . watchlist [ id ] ;
2020-09-06 05:27:11 +00:00
if ( item . updatedRecently ) {
2020-09-04 10:16:42 +00:00
// the item has been updated recently, and force is not true
return ;
}
2020-09-05 03:45:18 +00:00
// don't start a new task if there are more than `tasklimit` tasks currently running
// this makes sure we don't make 1000 simultaneous requests to yahoo auctions if there are 1000 items on the watchlist
2020-09-04 09:17:59 +00:00
taskLimit . Wait ( ) ;
2020-09-03 05:23:23 +00:00
var t = Task . Factory . StartNew ( ( ) = > {
this . UpdateThread ( id ) ;
2020-09-06 05:27:11 +00:00
} ) . ContinueWith ( task = > {
taskLimit . Release ( ) ;
2020-09-06 05:30:34 +00:00
if ( renderListWhenDone ) {
2020-09-06 05:41:14 +00:00
Gtk . Application . Invoke ( delegate {
this . RenderList ( ) ;
} ) ;
2020-09-06 05:27:11 +00:00
}
} ) ;
2020-09-03 05:23:23 +00:00
}
2020-09-06 10:35:37 +00:00
/// <summary>
/// add every item in the watchlist to the update queue. if the update queue is already being processed (this.queueActive), this will do nothing.
/// </summary>
2020-09-03 05:23:23 +00:00
private void UpdateItems ( ) {
2020-09-05 03:45:18 +00:00
if ( this . queueActive ) {
return ;
}
2020-09-04 10:48:47 +00:00
this . selectionViewBox . Sensitive = false ;
2020-09-05 03:45:18 +00:00
foreach ( var item in this . settings . watchlist ) {
if ( ! this . updateQueue . Contains ( item . Key ) ) {
// set everything to not ready first
// ensures other actions don't attempt to display the items even if UpdateItem hasn't started yet
item . Value . ready = false ;
this . updateQueue . Enqueue ( item . Key ) ;
}
}
2020-09-04 07:23:32 +00:00
2020-09-05 03:45:18 +00:00
if ( ! this . updateQueue . TryPeek ( out string _ ) ) {
// queue is empty
return ;
}
this . itemTreeView . QueueDraw ( ) ;
2020-09-03 05:23:23 +00:00
var t = Task . Factory . StartNew ( ( ) = > {
2020-09-05 03:45:18 +00:00
this . ProcessUpdateQueue ( ) ;
2020-09-03 05:23:23 +00:00
} ) ;
2020-09-04 07:23:32 +00:00
}
2020-09-06 10:35:37 +00:00
/// <summary>
/// updates the selection view, displaying the id, name, etc. for the currently selected item.
/// </summary>
2020-09-04 07:23:32 +00:00
private void UpdateSelectionView ( ) {
// get the currently selected item
2020-09-04 09:17:59 +00:00
var item = this . selectedItem ;
2020-09-04 07:23:32 +00:00
var infobox = ( Box ) this . builder . GetObject ( "SelectionInfoBox" ) ;
if ( item = = null ) {
2020-09-04 10:48:47 +00:00
this . selectionViewBox . Sensitive = false ;
2020-09-04 07:23:32 +00:00
infobox . Visible = false ;
2020-09-06 05:46:34 +00:00
( this . builder . GetObject ( "LabelSelectedName" ) as Label ) . Text = "buypeeb" ;
2020-09-04 07:23:32 +00:00
return ;
}
2020-09-04 10:48:47 +00:00
this . selectionViewBox . Sensitive = item . ready ;
2020-09-04 07:23:32 +00:00
infobox . Visible = true ;
2020-09-05 02:45:23 +00:00
var info = new Dictionary < string , string > {
{ "Name" , item . name } ,
{ "YahooName" , item . originalName } ,
2020-09-05 05:02:37 +00:00
{ "Price" , item . priceJPY } ,
{ "PriceAUD" , item . priceAUD } ,
2020-09-05 07:36:15 +00:00
{ "Ending" , "Please wait..." } ,
2020-09-05 02:45:23 +00:00
{ "Bids" , $"{item.bids}" } ,
2020-09-05 05:02:37 +00:00
{ "BuyItNow" , item . winPrice = = 0 ? "No" : $"{item.winPriceJPY} ({item.winPriceAUD})" } ,
2020-09-05 02:45:23 +00:00
{ "AutoExtension" , item . autoExtension ? "Yes" : "No" } ,
2020-09-05 07:36:15 +00:00
{ "LastUpdated" , $"Last updated: {(item.ready ? item.lastUpdated.ToString(" MMM dd , HH : mm : ss ") : " Right now ! ")}" }
2020-09-05 02:45:23 +00:00
} ;
foreach ( var row in info ) {
2020-09-06 05:46:34 +00:00
( this . builder . GetObject ( $"LabelSelected{row.Key}" ) as Label ) . Text = row . Value ;
2020-09-04 07:23:32 +00:00
}
2020-09-06 04:39:30 +00:00
this . UpdateSelectionEndTime ( ) ;
2020-09-04 07:23:32 +00:00
2020-09-06 03:33:03 +00:00
var noteBuffer = ( TextBuffer ) this . builder . GetObject ( "TextBufferSelectedNotes" ) ;
noteBuffer . Clear ( ) ;
if ( ! String . IsNullOrWhiteSpace ( item . notes ) ) {
noteBuffer . Text = item . notes ;
}
2020-09-06 05:46:34 +00:00
( this . builder . GetObject ( "ButtonSelectedFavourite" ) as ToggleButton ) . Active = item . favourite ;
2020-09-04 11:18:51 +00:00
2020-09-04 07:23:32 +00:00
}
2020-09-06 10:35:37 +00:00
/// <summary>
/// opens a URL in the user's browser.
/// </summary>
/// <param name="url">the url to open</param>
2020-09-04 07:23:32 +00:00
private void OpenUrl ( string url ) {
// https://github.com/dotnet/runtime/issues/17938
if ( Environment . OSVersion . Platform = = PlatformID . Win32NT ) {
ProcessStartInfo psi = new ProcessStartInfo {
FileName = url ,
UseShellExecute = true
} ;
Process . Start ( psi ) ;
}
else {
// let's hope you have xdg-open installed
Process . Start ( "xdg-open" , url ) ;
}
2020-09-03 05:23:23 +00:00
}
2020-09-06 10:35:37 +00:00
/// <summary>
/// a simple MessageDialog constructor.
/// </summary>
/// <param name="message">the MessageDialog's format</param>
/// <param name="buttonsType">the MessageDialog's bt</param>
/// <returns></returns>
2020-09-05 04:15:50 +00:00
private MessageDialog MsgBox ( string message , ButtonsType buttonsType = ButtonsType . OkCancel ) {
2020-09-04 09:10:55 +00:00
var md = new MessageDialog (
parent_window : this ,
flags : DialogFlags . DestroyWithParent | DialogFlags . Modal ,
type : MessageType . Question ,
2020-09-05 04:15:50 +00:00
bt : buttonsType ,
2020-09-04 09:10:55 +00:00
format : message
) ;
md . KeepAbove = true ;
md . Resizable = false ;
md . FocusOnMap = true ;
2020-09-05 03:54:25 +00:00
md . Title = "Buypeeb" ;
2020-09-04 09:10:55 +00:00
return md ;
}
2020-09-06 10:35:37 +00:00
/// <summary>
/// show a simple entry dialogue that allows the user to enter text and either cancel or submit it.
/// </summary>
/// <param name="title">the title of the entry dialogue</param>
/// <param name="message">the prompt that should be presented to the user</param>
/// <param name="prefill">a string to prefill the input box with</param>
/// <returns></returns>
2020-09-04 08:06:45 +00:00
private ( Boolean accepted , string response ) EntryDialogue ( string title = "Buypeeb" , string message = "Hi there!" , string prefill = null ) {
2020-09-05 03:54:25 +00:00
Dialog ed = new Dialog (
title : title ,
parent : this ,
flags : DialogFlags . DestroyWithParent | DialogFlags . Modal ,
/* button_data: */ "Cancel" , ResponseType . Cancel , "OK" , ResponseType . Ok
) ;
2020-08-31 14:34:31 +00:00
ed . DefaultResponse = ResponseType . Ok ;
2020-09-04 08:06:45 +00:00
ed . KeepAbove = true ;
2020-08-28 16:58:14 +00:00
Label edLabel = new Label ( message ) ;
Entry edEntry = new Entry ( ) ;
2020-09-04 08:06:45 +00:00
if ( ! String . IsNullOrWhiteSpace ( prefill ) ) {
edEntry . Text = prefill ;
}
2020-08-31 14:34:31 +00:00
edEntry . ActivatesDefault = true ;
2020-08-28 16:58:14 +00:00
ed . ContentArea . PackStart ( edLabel , true , true , 2 ) ;
edLabel . Show ( ) ;
ed . ContentArea . PackStart ( edEntry , true , true , 10 ) ;
edEntry . Show ( ) ;
2020-09-04 08:06:45 +00:00
ed . ContentArea . MarginBottom = 5 ;
ed . ContentArea . MarginTop = 5 ;
ed . ContentArea . MarginStart = 5 ;
ed . ContentArea . MarginEnd = 5 ;
2020-09-04 09:20:14 +00:00
ed . MarginBottom = 5 ;
2020-09-04 08:06:45 +00:00
2020-08-28 16:58:14 +00:00
ResponseType accepted = ( ResponseType ) ed . Run ( ) ;
string response = edEntry . Text ;
2020-09-01 07:12:17 +00:00
ed . Dispose ( ) ;
2020-08-28 16:58:14 +00:00
return ( accepted = = ResponseType . Ok , response ) ;
}
2020-09-06 10:35:37 +00:00
/// <summary>
/// gets the sort type selected by the user - "NameDescending", "EndingAscending", etc.
/// </summary>
/// <returns>the id of the radiobutton without the "Sort" prefix</returns>
2020-09-06 04:39:30 +00:00
private string GetSortType ( ) {
foreach ( var name in new List < string > { "NameDescending" , "NameAscending" , "PriceDescending" , "PriceAscending" , "EndingDescending" , "EndingAscending" } ) {
var radio = ( RadioMenuItem ) this . builder . GetObject ( $"Sort{name}" ) ;
if ( radio . Active ) {
return name ;
}
}
return "NameAscending" ;
}
2020-09-06 10:35:37 +00:00
/// <summary>
/// clears the treeview's liststore and adds everything in the watchlist to it, obeying sort order. tries to reselect the item that the user had selected, if possible.
/// </summary>
2020-09-02 03:21:32 +00:00
private void RenderList ( ) {
2020-09-06 05:41:14 +00:00
string id = null ;
if ( this . selectedItem ! = null ) {
id = this . selectedItem . id ;
}
2020-09-02 03:21:32 +00:00
this . items . Clear ( ) ;
2020-09-06 04:39:30 +00:00
var values = this . settings . watchlist . Values ;
IOrderedEnumerable < YahooAuctionsItem > sorted ;
var type = this . GetSortType ( ) ;
if ( type = = "NameDescending" ) {
sorted = values . OrderByDescending ( item = > item . name ) ;
}
else if ( type = = "NameAscending" ) {
sorted = values . OrderBy ( item = > item . name ) ;
}
else if ( type = = "PriceDescending" ) {
sorted = values . OrderByDescending ( item = > item . price ) ;
}
else if ( type = = "PriceAscending" ) {
sorted = values . OrderBy ( item = > item . price ) ;
}
else if ( type = = "EndingDescending" ) {
sorted = values . OrderByDescending ( item = > item . endDate ) ;
}
else {
sorted = values . OrderBy ( item = > item . endDate ) ;
}
if ( settings . displayFavouritesAtTopOfList ) {
foreach ( var item in sorted . Where ( item = > item . favourite ) ) {
items . AppendValues ( item ) ;
}
foreach ( var item in sorted . Where ( item = > ! item . favourite ) ) {
items . AppendValues ( item ) ;
}
}
else {
foreach ( var item in sorted ) {
items . AppendValues ( item ) ;
}
2020-09-02 03:21:32 +00:00
}
2020-09-06 04:39:30 +00:00
2020-09-06 05:46:34 +00:00
( this . itemTreeView . Model as TreeModelFilter ) . Refilter ( ) ;
2020-09-06 05:41:14 +00:00
if ( id ! = null ) {
// attempt to reselect the item we were just looking at
var pathAndIter = this . GetRow ( id ) ;
if ( pathAndIter . path ! = null ) {
this . itemTreeView . Selection . SelectPath ( pathAndIter . path ) ;
}
}
2020-09-02 03:21:32 +00:00
}
2020-09-04 07:23:32 +00:00
// event handlers
2020-08-28 16:04:37 +00:00
private void ButtonAddClicked ( object sender , EventArgs a ) {
2020-09-01 09:58:35 +00:00
// Console.WriteLine("ButtonAddClicked");
AddItemDialogue aid = new AddItemDialogue ( ) ;
2020-09-04 08:06:45 +00:00
aid . Title = "Buypeeb" ;
2020-09-01 09:58:35 +00:00
ResponseType accepted = ( ResponseType ) aid . Run ( ) ;
string url = aid . GetURL ( ) ;
string name = aid . GetName ( ) ;
aid . Dispose ( ) ;
// vry simpl url validation for simpol creachers
2020-09-05 07:42:51 +00:00
// TODO: better. do better.
2020-09-01 09:58:35 +00:00
Regex rx = new Regex ( @"^http.+yahoo.+" ) ;
if ( rx . IsMatch ( url ) ) {
2020-09-06 05:30:34 +00:00
this . UpdateItem ( this . settings . Watch ( url , name ) . id , true ) ;
2020-09-02 03:21:32 +00:00
this . RenderList ( ) ;
2020-09-01 09:58:35 +00:00
}
else {
2020-09-05 07:42:51 +00:00
var md = this . MsgBox ( $"\" { url } \ " is not a valid Buyee or Yahoo! Auctions Japan URL." , ButtonsType . Ok ) ;
2020-09-05 07:40:28 +00:00
md . Run ( ) ;
md . Dispose ( ) ;
2020-09-01 09:58:35 +00:00
}
2020-08-28 16:04:37 +00:00
}
private void ButtonUpdateAllClicked ( object sender , EventArgs a ) {
2020-09-03 05:23:23 +00:00
this . UpdateItems ( ) ;
2020-08-28 16:04:37 +00:00
}
private void ButtonClearEndedClicked ( object sender , EventArgs a ) {
2020-09-05 04:15:50 +00:00
var md = this . MsgBox ( "Are you sure you want to remove all ended auctions from the list?" ) ;
2020-09-04 13:42:22 +00:00
var r = ( ResponseType ) md . Run ( ) ;
md . Dispose ( ) ;
if ( r ! = ResponseType . Ok ) {
return ;
2020-09-05 02:45:23 +00:00
}
2020-09-04 13:42:22 +00:00
var removeMe = new List < string > ( ) ;
2020-09-05 02:45:23 +00:00
foreach ( var item in this . settings . watchlist ) {
2020-09-04 13:42:22 +00:00
if ( ! item . Value . available ) {
removeMe . Add ( item . Key ) ;
2020-09-05 02:45:23 +00:00
}
}
2020-09-04 13:42:22 +00:00
2020-09-05 02:45:23 +00:00
foreach ( var id in removeMe ) {
2020-09-04 13:42:22 +00:00
this . settings . watchlist . Remove ( id ) ;
2020-09-05 02:45:23 +00:00
}
2020-09-04 13:42:22 +00:00
this . RenderList ( ) ;
2020-08-28 16:04:37 +00:00
}
private void ButtonClearAllClicked ( object sender , EventArgs a ) {
2020-09-05 07:42:51 +00:00
var md = this . MsgBox ( "Are you sure you want to clear ALL items?" ) ;
if ( md . Run ( ) = = ( int ) ResponseType . Ok ) {
this . settings . watchlist . Clear ( ) ;
this . RenderList ( ) ;
}
md . Dispose ( ) ;
2020-08-28 16:04:37 +00:00
}
2020-09-05 04:15:50 +00:00
private void ButtonOpenClicked ( object sender , EventArgs a ) {
var od = new FileChooserDialog (
title : "Open userdata.json" ,
parent : this ,
action : FileChooserAction . Open ,
"Cancel" , ResponseType . Cancel , "Open" , ResponseType . Accept
) ;
var odf = new FileFilter ( ) ;
odf . Name = "JSON files" ;
odf . AddMimeType ( "application/json" ) ;
odf . AddPattern ( "*.json" ) ;
od . AddFilter ( odf ) ;
if ( od . Run ( ) = = ( int ) ResponseType . Accept ) {
try {
string j = File . ReadAllText ( od . Filename ) ;
this . settings = JsonSerializer . Deserialize < Settings > ( j ) ;
this . RenderList ( ) ;
this . UpdateItems ( ) ;
}
catch ( Exception e ) {
Console . WriteLine ( e ) ;
2020-09-05 05:02:37 +00:00
var md = MsgBox ( $"Failed to load {od.Filename}!\n{e.Message}" , ButtonsType . Ok ) ;
md . Run ( ) ;
md . Dispose ( ) ;
2020-09-05 04:15:50 +00:00
}
}
od . Dispose ( ) ;
}
2020-08-28 16:04:37 +00:00
private void ButtonSaveClicked ( object sender , EventArgs a ) {
2020-09-03 14:38:41 +00:00
this . SaveSettings ( ) ;
2020-08-28 16:04:37 +00:00
}
2020-09-05 04:15:50 +00:00
private void ButtonSaveAsClicked ( object sender , EventArgs a ) {
var sd = new FileChooserDialog (
title : "Save userdata.json" ,
parent : this ,
action : FileChooserAction . Save ,
"Cancel" , ResponseType . Cancel , "Save" , ResponseType . Accept
) ;
sd . CurrentName = "userdata.json" ;
var sdf = new FileFilter ( ) ;
sdf . Name = "JSON files" ;
sdf . AddMimeType ( "application/json" ) ;
sdf . AddPattern ( "*.json" ) ;
sd . AddFilter ( sdf ) ;
if ( sd . Run ( ) = = ( int ) ResponseType . Accept ) {
try {
if ( ! File . Exists ( sd . Filename ) ) {
2020-09-05 18:51:47 +00:00
using ( StreamWriter fs = File . CreateText ( sd . Filename ) ) {
fs . Write ( JsonSerializer . Serialize ( this . settings , jsonOptions ) ) ;
}
2020-09-05 04:15:50 +00:00
}
2020-09-05 05:02:37 +00:00
}
catch ( Exception e ) {
Console . WriteLine ( e ) ;
var md = MsgBox ( $"Failed to write {sd.Filename}!\n{e.Message}." , ButtonsType . Ok ) ;
md . Run ( ) ;
md . Dispose ( ) ;
}
}
sd . Dispose ( ) ;
}
private void ButtonExportClicked ( object sender , EventArgs a ) {
var readyQuery = from item in this . settings . watchlist . Values . ToList ( ) where ! item . ready select item ;
if ( readyQuery . Count ( ) ! = 0 ) {
var md = MsgBox ( "Please wait for all items to update before exporting a CSV." , ButtonsType . Ok ) ;
md . Run ( ) ;
md . Dispose ( ) ;
return ;
}
var sd = new FileChooserDialog (
title : "Export watchlist as CSV" ,
parent : this ,
action : FileChooserAction . Save ,
"Cancel" , ResponseType . Cancel , "Save" , ResponseType . Accept
) ;
sd . CurrentName = "buypeeb.csv" ;
var sdf = new FileFilter ( ) ;
sdf . Name = "CSV files" ;
sdf . AddMimeType ( "text/csv" ) ;
sdf . AddPattern ( "*.csv" ) ;
sd . AddFilter ( sdf ) ;
if ( sd . Run ( ) = = ( int ) ResponseType . Accept ) {
try {
using ( var writer = new StreamWriter ( sd . Filename ) )
using ( var csv = new CsvWriter ( writer , System . Globalization . CultureInfo . InvariantCulture ) ) {
2020-09-06 03:10:00 +00:00
2020-09-05 05:02:37 +00:00
csv . WriteRecords ( this . settings . watchlist ) ;
}
2020-09-05 04:15:50 +00:00
}
catch ( Exception e ) {
Console . WriteLine ( e ) ;
2020-09-06 05:27:11 +00:00
var md = MsgBox ( $"Failed to write {sd.Filename}!\n{e.Message}." , ButtonsType . Ok ) ;
md . Run ( ) ;
md . Dispose ( ) ;
2020-09-05 04:15:50 +00:00
}
}
sd . Dispose ( ) ;
}
2020-09-06 05:27:11 +00:00
private void ButtonHelpClicked ( object sender , EventArgs args ) {
Console . WriteLine ( "Watchlist:" ) ;
foreach ( var item in this . settings . watchlist ) {
Console . WriteLine ( item ) ;
}
Console . WriteLine ( "---\nFilter results:" ) ;
foreach ( var item in this . filterQuery ) {
Console . WriteLine ( item ) ;
}
Console . WriteLine ( "---\nListstore contents:" ) ;
foreach ( object [ ] item in this . items ) {
Console . WriteLine ( item [ 0 ] ) ;
}
}
2020-08-28 16:04:37 +00:00
private void ButtonQuitClicked ( object sender , EventArgs a ) {
2020-09-05 04:15:50 +00:00
var md = this . MsgBox ( "Are you sure you want to quit?" ) ;
2020-09-04 08:06:45 +00:00
2020-08-28 16:04:37 +00:00
ResponseType response = ( ResponseType ) md . Run ( ) ;
2020-09-01 07:12:17 +00:00
md . Dispose ( ) ;
2020-08-28 16:04:37 +00:00
if ( response = = ResponseType . Ok ) {
2020-09-03 14:38:41 +00:00
this . SaveSettings ( ) ;
2020-08-28 16:04:37 +00:00
Application . Quit ( ) ;
}
}
2020-09-04 07:23:32 +00:00
private void TreeViewItemsSelectionChanged ( object sender , EventArgs a ) {
this . UpdateSelectionView ( ) ;
2020-08-28 16:04:37 +00:00
}
private void ButtonViewBuyeeClicked ( object sender , EventArgs a ) {
2020-09-04 09:17:59 +00:00
this . OpenUrl ( this . selectedItem . buyeeUrl ) ;
2020-08-28 16:04:37 +00:00
}
private void ButtonViewYahooClicked ( object sender , EventArgs a ) {
2020-09-04 09:17:59 +00:00
this . OpenUrl ( this . selectedItem . url ) ;
2020-08-28 16:04:37 +00:00
}
private void ButtonSelectedRemoveClicked ( object sender , EventArgs a ) {
2020-09-04 09:17:59 +00:00
var item = this . selectedItem ;
2020-09-04 08:06:45 +00:00
2020-09-05 04:15:50 +00:00
var md = this . MsgBox ( $"Are you sure you want to remove the item \" { item . name } \ "?" ) ; // TODO: this looks bad being all on one line
2020-09-04 08:06:45 +00:00
ResponseType response = ( ResponseType ) md . Run ( ) ;
md . Dispose ( ) ;
if ( response = = ResponseType . Ok ) {
this . settings . watchlist . Remove ( item . id ) ;
this . RenderList ( ) ;
}
2020-08-28 16:04:37 +00:00
}
private void ButtonSelectedRenameClicked ( object sender , EventArgs a ) {
2020-09-04 09:17:59 +00:00
var item = this . selectedItem ;
2020-09-04 08:06:45 +00:00
( bool accepted , string response ) = this . EntryDialogue ( "Rename item" , $"Enter a new name for the item \" { item . name } \ "." , item . name ) ;
if ( accepted ) {
item . name = response ;
this . UpdateSelectionView ( ) ;
}
2020-08-28 16:04:37 +00:00
}
2020-09-04 07:23:32 +00:00
private void ButtonSelectedUpdateClicked ( object sender , EventArgs args ) {
2020-09-04 10:48:47 +00:00
this . selectionViewBox . Sensitive = false ;
2020-09-05 03:45:18 +00:00
if ( this . updateQueue . Contains ( this . selectedItem . id ) ) {
// the item is already waiting to be updated
return ;
}
2020-09-04 09:17:59 +00:00
this . UpdateItem ( this . selectedItem . id ) ;
2020-09-04 07:23:32 +00:00
}
2020-09-04 11:18:51 +00:00
private void ButtonSelectedFavouriteToggled ( object sender , EventArgs args ) {
ToggleButton s = ( ToggleButton ) sender ;
this . selectedItem . favourite = s . Active ;
2020-09-05 03:45:18 +00:00
2020-09-06 04:39:30 +00:00
if ( this . settings . displayFavouritesAtTopOfList ) {
var id = this . selectedItem . id ;
this . RenderList ( ) ;
}
else {
// i don't know why this is necessary
var pathAndIter = this . GetRow ( this . selectedItem . id ) ;
if ( pathAndIter . path ! = null ) {
this . items . EmitRowChanged ( pathAndIter . path , pathAndIter . iter ) ;
}
2020-09-05 06:51:28 +00:00
}
2020-09-06 04:39:30 +00:00
2020-09-04 11:18:51 +00:00
}
2020-09-06 03:33:03 +00:00
private void ButtonSelectedNotesClearClicked ( object sender , EventArgs args ) {
var item = this . selectedItem ;
var md = this . MsgBox ( $"Are you sure you want to clear the notes for \" { item . name } \ "?" ) ;
if ( md . Run ( ) = = ( int ) ResponseType . Ok ) {
var noteBuffer = ( TextBuffer ) this . builder . GetObject ( "TextBufferSelectedNotes" ) ;
noteBuffer . Clear ( ) ;
this . selectedItem . notes = null ;
}
md . Dispose ( ) ;
}
private void TextViewSelectedNotesFocusOut ( object sender , FocusOutEventArgs args ) {
// the "save" button does nothing, however, when you click the save button, you transfer focus to it, firing this event!
// how very sneaky
var noteBuffer = ( TextBuffer ) this . builder . GetObject ( "TextBufferSelectedNotes" ) ;
if ( this . selectedItem ! = null ) {
this . selectedItem . notes = String . IsNullOrWhiteSpace ( noteBuffer . Text ) ? null : noteBuffer . Text ;
}
}
2020-09-06 04:39:30 +00:00
private void SortMenuClosed ( object sender , EventArgs args ) {
this . RenderList ( ) ;
}
2020-09-04 11:13:40 +00:00
// timers
2020-09-06 10:35:37 +00:00
/// <summary>
/// updates the end time displayed in the selection box. runs every second to update the countdown timer.
/// </summary>
/// <returns>true</returns>
2020-09-04 11:13:40 +00:00
private bool UpdateSelectionEndTime ( ) {
if ( ! this . selectionViewBox . IsSensitive ) {
return true ;
}
var item = this . selectedItem ;
if ( ! item . ready ) {
return true ;
}
string ending = "" ;
if ( item . available ) {
var now = DateTime . Now ;
var end = item . endDate . ToLocalTime ( ) ;
var span = end . Subtract ( now ) ;
if ( span . Days > 0 ) {
ending + = span . ToString ( "dd' days, '" ) ; // will format twelve days as "12 days, "
}
// timespan objects don't contain definitions for the time or date separators, so the colons need to be escaped
// `HH` doesn't exist, but `hh` behaves identically
2020-09-05 02:45:23 +00:00
// see https://docs.microsoft.com/en-us/dotnet/standard/base-types/standard-timespan-format-strings
2020-09-04 11:13:40 +00:00
ending + = span . ToString ( @"hh\:mm\:ss" ) ;
}
else {
ending = "Auction has ended" ;
}
this . endingLabel . Text = ending ;
return true ;
}
2020-09-06 10:35:37 +00:00
/// <summary>
/// updates all items that need updating. runs every ten seconds.
/// </summary>
/// <returns>true</returns>
2020-09-05 18:36:17 +00:00
private bool AutoUpdateItems ( ) {
if ( this . queueActive ) {
// don't autoupdate if the queue is active
return true ;
}
foreach ( var item in this . outdatedItemQuery ) {
updateQueue . Enqueue ( item . id ) ;
}
if ( updateQueue . TryPeek ( out string _ ) ) {
// there's at least one item in the queue
this . ProcessUpdateQueue ( ) ;
}
return true ;
}
2020-09-03 06:02:56 +00:00
// column renderers
2020-09-05 03:45:18 +00:00
private void RenderColumnFavourite ( Gtk . TreeViewColumn column , Gtk . CellRenderer cell , Gtk . ITreeModel model , Gtk . TreeIter iter ) {
YahooAuctionsItem item = ( YahooAuctionsItem ) model . GetValue ( iter , 0 ) ;
( cell as Gtk . CellRendererText ) . Text = item . favourite ? "♥" : "" ;
}
2020-09-03 06:02:56 +00:00
private void RenderColumnName ( Gtk . TreeViewColumn column , Gtk . CellRenderer cell , Gtk . ITreeModel model , Gtk . TreeIter iter ) {
2020-09-04 09:13:16 +00:00
YahooAuctionsItem item = ( YahooAuctionsItem ) model . GetValue ( iter , 0 ) ;
2020-09-03 06:02:56 +00:00
( cell as Gtk . CellRendererText ) . Text = item . name ? ? "Loading..." ;
}
private void RenderColumnPriceYen ( Gtk . TreeViewColumn column , Gtk . CellRenderer cell , Gtk . ITreeModel model , Gtk . TreeIter iter ) {
2020-09-04 09:13:16 +00:00
YahooAuctionsItem item = ( YahooAuctionsItem ) model . GetValue ( iter , 0 ) ;
2020-09-05 05:02:37 +00:00
( cell as Gtk . CellRendererText ) . Text = item . ready ? item . priceJPY : "..." ;
2020-09-03 06:02:56 +00:00
}
private void RenderColumnPriceAUD ( Gtk . TreeViewColumn column , Gtk . CellRenderer cell , Gtk . ITreeModel model , Gtk . TreeIter iter ) {
2020-09-04 09:13:16 +00:00
YahooAuctionsItem item = ( YahooAuctionsItem ) model . GetValue ( iter , 0 ) ;
2020-09-05 05:02:37 +00:00
( cell as Gtk . CellRendererText ) . Text = item . ready ? item . priceAUD : "..." ;
2020-09-03 06:02:56 +00:00
}
private void RenderColumnEnding ( Gtk . TreeViewColumn column , Gtk . CellRenderer cell , Gtk . ITreeModel model , Gtk . TreeIter iter ) {
2020-09-04 09:13:16 +00:00
YahooAuctionsItem item = ( YahooAuctionsItem ) model . GetValue ( iter , 0 ) ;
2020-09-04 10:16:42 +00:00
string ending = "" ;
if ( item . ready ) {
2020-09-04 13:27:56 +00:00
if ( ! item . available ) {
ending = "Ended" ;
2020-09-05 02:45:23 +00:00
}
else {
2020-09-04 13:27:56 +00:00
var now = DateTime . Now ;
var end = item . endDate . ToLocalTime ( ) ;
// TODO: should we show the year if the auction ends next year? 0uo
2020-09-05 02:45:23 +00:00
if ( end . DayOfYear ! = now . DayOfYear ) {
2020-09-04 13:27:56 +00:00
// the auction isn't ending today, so we should show the day it's ending on for clarity
ending + = end . ToString ( "MMM d " ) ;
}
ending + = end . ToString ( "HH:mm" ) ;
2020-09-05 02:45:23 +00:00
if ( this . settings . displaySecondsInList ) {
2020-09-04 13:27:56 +00:00
// add the seconds on to the end
ending + = end . ToString ( ":ss" ) ;
}
2020-09-04 10:16:42 +00:00
}
}
( cell as Gtk . CellRendererText ) . Text = item . ready ? ending : "..." ;
2020-09-03 06:02:56 +00:00
}
2020-09-05 06:51:28 +00:00
// tree filter
private bool ItemFilter ( ITreeModel model , TreeIter iter ) {
var item = ( YahooAuctionsItem ) model . GetValue ( iter , 0 ) ;
2020-09-06 05:27:11 +00:00
if ( item = = null ) {
return true ;
}
if ( item . price = = 0 ) {
// the item has only just been added, so we can't compare it against the filters, since it's missing most of its data
return true ;
}
2020-09-05 06:51:28 +00:00
bool Filtered ( string name ) {
return this . filterChecks [ name ] . Active ;
}
// first, check to see if any filters are set that would exclude everything, such as hiding both active and ended auctions
// if so, there's no need to run the more expensive linq query
if (
( Filtered ( "Favourites" ) & & Filtered ( "NonFavourites" ) ) | |
( Filtered ( "Active" ) & & Filtered ( "Ended" ) ) | |
( Filtered ( "EndingToday" ) & & Filtered ( "EndingAfterToday" ) ) | |
( Filtered ( "WithWinPrice" ) & & Filtered ( "WithNoWinPrice" ) )
) {
return false ;
}
return this . filterQuery . Contains ( item ) ;
}
private void RunFilter ( object sender , EventArgs a ) {
2020-09-06 05:46:34 +00:00
( this . itemTreeView . Model as TreeModelFilter ) . Refilter ( ) ;
2020-09-05 06:51:28 +00:00
}
2020-08-28 16:04:37 +00:00
}
}