Revision 255f5f86 trunk/Pithos.Client.WPF/Shell/ShellViewModel.cs
b/trunk/Pithos.Client.WPF/Shell/ShellViewModel.cs | ||
---|---|---|
1 |
using System.Collections.Concurrent; |
|
1 |
#region |
|
2 |
/* ----------------------------------------------------------------------- |
|
3 |
* <copyright file="ShellViewModel.cs" company="GRNet"> |
|
4 |
* |
|
5 |
* Copyright 2011-2012 GRNET S.A. All rights reserved. |
|
6 |
* |
|
7 |
* Redistribution and use in source and binary forms, with or |
|
8 |
* without modification, are permitted provided that the following |
|
9 |
* conditions are met: |
|
10 |
* |
|
11 |
* 1. Redistributions of source code must retain the above |
|
12 |
* copyright notice, this list of conditions and the following |
|
13 |
* disclaimer. |
|
14 |
* |
|
15 |
* 2. Redistributions in binary form must reproduce the above |
|
16 |
* copyright notice, this list of conditions and the following |
|
17 |
* disclaimer in the documentation and/or other materials |
|
18 |
* provided with the distribution. |
|
19 |
* |
|
20 |
* |
|
21 |
* THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS |
|
22 |
* OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED |
|
23 |
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR |
|
24 |
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR |
|
25 |
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, |
|
26 |
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT |
|
27 |
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF |
|
28 |
* USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED |
|
29 |
* AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT |
|
30 |
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN |
|
31 |
* ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE |
|
32 |
* POSSIBILITY OF SUCH DAMAGE. |
|
33 |
* |
|
34 |
* The views and conclusions contained in the software and |
|
35 |
* documentation are those of the authors and should not be |
|
36 |
* interpreted as representing official policies, either expressed |
|
37 |
* or implied, of GRNET S.A. |
|
38 |
* </copyright> |
|
39 |
* ----------------------------------------------------------------------- |
|
40 |
*/ |
|
41 |
#endregion |
|
42 |
using System.Collections.Concurrent; |
|
2 | 43 |
using System.Diagnostics; |
3 | 44 |
using System.Diagnostics.Contracts; |
4 | 45 |
using System.IO; |
... | ... | |
54 | 95 |
public PithosSettings Settings { get; private set; } |
55 | 96 |
|
56 | 97 |
|
57 |
private readonly ConcurrentDictionary<string, PithosMonitor> _monitors = new ConcurrentDictionary<string, PithosMonitor>();
|
|
98 |
private readonly ConcurrentDictionary<string, PithosMonitor> _monitors = new ConcurrentDictionary<string, PithosMonitor>();
|
|
58 | 99 |
///<summary> |
59 | 100 |
/// Dictionary of account monitors, keyed by account |
60 | 101 |
///</summary> |
... | ... | |
64 | 105 |
///</remarks> |
65 | 106 |
// TODO: Does the Shell REALLY need access to the monitors? Could we achieve the same results with a better design? |
66 | 107 |
// TODO: The monitors should be internal to Pithos.Core, even though exposing them makes coding of the Object and Container windows easier |
67 |
public ConcurrentDictionary<string, PithosMonitor> Monitors
|
|
108 |
public ConcurrentDictionary<string, PithosMonitor> Monitors
|
|
68 | 109 |
{ |
69 | 110 |
get { return _monitors; } |
70 | 111 |
} |
71 | 112 |
|
72 | 113 |
|
73 |
///<summary>
|
|
74 |
/// The status service is used by Shell extensions to retrieve file status information
|
|
75 |
///</summary>
|
|
76 |
//TODO: CODE SMELL! This is the shell! While hosting in the shell makes executing start/stop commands easier, it is still a smell
|
|
77 |
private ServiceHost _statusService;
|
|
114 |
///<summary>
|
|
115 |
/// The status service is used by Shell extensions to retrieve file status information
|
|
116 |
///</summary>
|
|
117 |
//TODO: CODE SMELL! This is the shell! While hosting in the shell makes executing start/stop commands easier, it is still a smell
|
|
118 |
private ServiceHost _statusService;
|
|
78 | 119 |
|
79 | 120 |
//Logging in the Pithos client is provided by log4net |
80 | 121 |
private static readonly log4net.ILog Log = log4net.LogManager.GetLogger("Pithos"); |
81 | 122 |
|
82 |
//Lazily initialized File Version info. This is done once and lazily to avoid blocking the UI
|
|
83 |
private Lazy<FileVersionInfo> _fileVersion;
|
|
123 |
//Lazily initialized File Version info. This is done once and lazily to avoid blocking the UI
|
|
124 |
private Lazy<FileVersionInfo> _fileVersion;
|
|
84 | 125 |
|
85 | 126 |
///<summary> |
86 | 127 |
/// The Shell depends on MEF to provide implementations for windowManager, events, the status checker service and the settings |
... | ... | |
104 | 145 |
|
105 | 146 |
Settings = settings; |
106 | 147 |
|
107 |
Proxy.SetFromSettings(settings);
|
|
148 |
Proxy.SetFromSettings(settings);
|
|
108 | 149 |
|
109 | 150 |
StatusMessage = "In Synch"; |
110 | 151 |
|
111 |
_fileVersion= new Lazy<FileVersionInfo>(() =>
|
|
112 |
{
|
|
113 |
Assembly assembly = Assembly.GetExecutingAssembly();
|
|
114 |
var fileVersion = FileVersionInfo.GetVersionInfo(assembly.Location);
|
|
115 |
return fileVersion;
|
|
116 |
});
|
|
152 |
_fileVersion= new Lazy<FileVersionInfo>(() =>
|
|
153 |
{
|
|
154 |
Assembly assembly = Assembly.GetExecutingAssembly();
|
|
155 |
var fileVersion = FileVersionInfo.GetVersionInfo(assembly.Location);
|
|
156 |
return fileVersion;
|
|
157 |
});
|
|
117 | 158 |
_accounts.CollectionChanged += (sender, e) => |
118 | 159 |
{ |
119 | 160 |
NotifyOfPropertyChange(() => OpenFolderCaption); |
... | ... | |
133 | 174 |
{ |
134 | 175 |
base.OnActivate(); |
135 | 176 |
|
136 |
|
|
177 |
|
|
137 | 178 |
|
138 | 179 |
StartMonitoring(); |
139 | 180 |
} |
... | ... | |
200 | 241 |
return; |
201 | 242 |
} |
202 | 243 |
|
203 |
|
|
244 |
|
|
204 | 245 |
//Create a new monitor/ Can't use MEF here, it would return a single instance for all monitors |
205 | 246 |
monitor = new PithosMonitor |
206 | 247 |
{ |
... | ... | |
212 | 253 |
//PithosMonitor uses MEF so we need to resolve it |
213 | 254 |
IoC.BuildUp(monitor); |
214 | 255 |
|
215 |
monitor.AuthenticationUrl = account.ServerUrl;
|
|
256 |
monitor.AuthenticationUrl = account.ServerUrl;
|
|
216 | 257 |
|
217 | 258 |
_monitors[accountName] = monitor; |
218 | 259 |
|
... | ... | |
296 | 337 |
get { return _statusIcon; } |
297 | 338 |
set |
298 | 339 |
{ |
299 |
//TODO: Ensure all status icons use the Pithos logo
|
|
340 |
//TODO: Ensure all status icons use the Pithos logo
|
|
300 | 341 |
_statusIcon = value; |
301 | 342 |
NotifyOfPropertyChange(() => StatusIcon); |
302 | 343 |
} |
... | ... | |
379 | 420 |
var pair=(from monitor in Monitors |
380 | 421 |
where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase) |
381 | 422 |
select monitor).FirstOrDefault(); |
382 |
var accountMonitor = pair.Value;
|
|
423 |
var accountMonitor = pair.Value;
|
|
383 | 424 |
|
384 | 425 |
if (accountMonitor == null) |
385 | 426 |
return; |
... | ... | |
413 | 454 |
var pair=(from monitor in Monitors |
414 | 455 |
where filePath.StartsWith(monitor.Value.RootPath, StringComparison.InvariantCultureIgnoreCase) |
415 | 456 |
select monitor).FirstOrDefault(); |
416 |
var accountMonitor = pair.Value;
|
|
457 |
var accountMonitor = pair.Value;
|
|
417 | 458 |
var info = accountMonitor.GetContainerInfo(filePath); |
418 | 459 |
|
419 | 460 |
|
... | ... | |
422 | 463 |
_windowManager.ShowWindow(containerProperties); |
423 | 464 |
} |
424 | 465 |
|
425 |
public void SynchNow()
|
|
426 |
{
|
|
427 |
var agent = IoC.Get<NetworkAgent>();
|
|
428 |
agent.SynchNow();
|
|
429 |
}
|
|
466 |
public void SynchNow()
|
|
467 |
{
|
|
468 |
var agent = IoC.Get<NetworkAgent>();
|
|
469 |
agent.SynchNow();
|
|
470 |
}
|
|
430 | 471 |
|
431 | 472 |
public ObjectInfo RefreshObjectInfo(ObjectInfo currentInfo) |
432 | 473 |
{ |
... | ... | |
487 | 528 |
}.ToDictionary(s => s.Status); |
488 | 529 |
|
489 | 530 |
readonly IWindowManager _windowManager; |
490 |
|
|
531 |
|
|
491 | 532 |
|
492 | 533 |
///<summary> |
493 | 534 |
/// Updates the visual status indicators of the application depending on status changes, e.g. icon, stat |
... | ... | |
528 | 569 |
if (AbandonRetry(monitor, retries)) |
529 | 570 |
return; |
530 | 571 |
|
531 |
HttpStatusCode statusCode =HttpStatusCode.OK;
|
|
532 |
var response = exc.Response as HttpWebResponse;
|
|
533 |
if(response!=null)
|
|
534 |
statusCode = response.StatusCode;
|
|
535 |
|
|
536 |
switch (statusCode)
|
|
537 |
{
|
|
538 |
case HttpStatusCode.Unauthorized:
|
|
539 |
var message = String.Format("API Key Expired for {0}. Starting Renewal",
|
|
540 |
monitor.UserName);
|
|
541 |
Log.Error(message, exc);
|
|
542 |
TryAuthorize(monitor, retries).Wait();
|
|
543 |
break;
|
|
544 |
case HttpStatusCode.ProxyAuthenticationRequired:
|
|
545 |
TryAuthenticateProxy(monitor,retries);
|
|
546 |
break;
|
|
547 |
default:
|
|
548 |
TryLater(monitor, exc, retries);
|
|
549 |
break;
|
|
550 |
}
|
|
572 |
HttpStatusCode statusCode =HttpStatusCode.OK;
|
|
573 |
var response = exc.Response as HttpWebResponse;
|
|
574 |
if(response!=null)
|
|
575 |
statusCode = response.StatusCode;
|
|
576 |
|
|
577 |
switch (statusCode)
|
|
578 |
{
|
|
579 |
case HttpStatusCode.Unauthorized:
|
|
580 |
var message = String.Format("API Key Expired for {0}. Starting Renewal",
|
|
581 |
monitor.UserName);
|
|
582 |
Log.Error(message, exc);
|
|
583 |
TryAuthorize(monitor, retries).Wait();
|
|
584 |
break;
|
|
585 |
case HttpStatusCode.ProxyAuthenticationRequired:
|
|
586 |
TryAuthenticateProxy(monitor,retries);
|
|
587 |
break;
|
|
588 |
default:
|
|
589 |
TryLater(monitor, exc, retries);
|
|
590 |
break;
|
|
591 |
}
|
|
551 | 592 |
} |
552 | 593 |
catch (Exception exc) |
553 | 594 |
{ |
... | ... | |
560 | 601 |
}); |
561 | 602 |
} |
562 | 603 |
|
563 |
private void TryAuthenticateProxy(PithosMonitor monitor,int retries)
|
|
564 |
{
|
|
565 |
Execute.OnUIThread(() =>
|
|
566 |
{
|
|
567 |
var proxyAccount = IoC.Get<ProxyAccountViewModel>();
|
|
568 |
proxyAccount.Settings = this.Settings;
|
|
569 |
if (true != _windowManager.ShowDialog(proxyAccount))
|
|
570 |
return;
|
|
571 |
StartMonitor(monitor, retries);
|
|
572 |
NotifyOfPropertyChange(() => Accounts);
|
|
573 |
});
|
|
574 |
}
|
|
604 |
private void TryAuthenticateProxy(PithosMonitor monitor,int retries)
|
|
605 |
{
|
|
606 |
Execute.OnUIThread(() =>
|
|
607 |
{
|
|
608 |
var proxyAccount = IoC.Get<ProxyAccountViewModel>();
|
|
609 |
proxyAccount.Settings = this.Settings;
|
|
610 |
if (true != _windowManager.ShowDialog(proxyAccount))
|
|
611 |
return;
|
|
612 |
StartMonitor(monitor, retries);
|
|
613 |
NotifyOfPropertyChange(() => Accounts);
|
|
614 |
});
|
|
615 |
}
|
|
575 | 616 |
|
576 |
private bool AbandonRetry(PithosMonitor monitor, int retries)
|
|
617 |
private bool AbandonRetry(PithosMonitor monitor, int retries)
|
|
577 | 618 |
{ |
578 | 619 |
if (retries > 1) |
579 | 620 |
{ |
... | ... | |
665 | 706 |
//TODO: What happens to an existing account whose Token has changed? |
666 | 707 |
account.SiteUri= String.Format("{0}/ui/?token={1}&user={2}", |
667 | 708 |
account.SiteUri, Uri.EscapeDataString(account.Token), |
668 |
Uri.EscapeDataString(account.UserName));
|
|
709 |
Uri.EscapeDataString(account.UserName));
|
|
669 | 710 |
|
670 | 711 |
if (Accounts.All(item => item.UserName != account.UserName)) |
671 | 712 |
Accounts.TryAdd(account); |
672 | 713 |
|
673 | 714 |
} |
674 | 715 |
|
675 |
public void NotifyConflicts(IEnumerable<FileSystemInfo> conflictFiles, string message)
|
|
676 |
{
|
|
677 |
if (conflictFiles == null)
|
|
678 |
return;
|
|
679 |
if (!conflictFiles.Any())
|
|
680 |
return;
|
|
716 |
public void NotifyConflicts(IEnumerable<FileSystemInfo> conflictFiles, string message)
|
|
717 |
{
|
|
718 |
if (conflictFiles == null)
|
|
719 |
return;
|
|
720 |
if (!conflictFiles.Any())
|
|
721 |
return;
|
|
681 | 722 |
|
682 |
UpdateStatus();
|
|
683 |
//TODO: Create a more specific message. For now, just show a warning
|
|
684 |
NotifyForFiles(conflictFiles,message,TraceLevel.Warning);
|
|
723 |
UpdateStatus();
|
|
724 |
//TODO: Create a more specific message. For now, just show a warning
|
|
725 |
NotifyForFiles(conflictFiles,message,TraceLevel.Warning);
|
|
685 | 726 |
|
686 |
}
|
|
727 |
}
|
|
687 | 728 |
|
688 |
public void NotifyForFiles(IEnumerable<FileSystemInfo> files, string message,TraceLevel level=TraceLevel.Info)
|
|
689 |
{
|
|
690 |
if (files == null)
|
|
691 |
return;
|
|
692 |
if (!files.Any())
|
|
693 |
return;
|
|
729 |
public void NotifyForFiles(IEnumerable<FileSystemInfo> files, string message,TraceLevel level=TraceLevel.Info)
|
|
730 |
{
|
|
731 |
if (files == null)
|
|
732 |
return;
|
|
733 |
if (!files.Any())
|
|
734 |
return;
|
|
694 | 735 |
|
695 |
StatusMessage = message;
|
|
736 |
StatusMessage = message;
|
|
696 | 737 |
|
697 |
_events.Publish(new Notification { Title = "Pithos", Message = message, Level = level});
|
|
698 |
}
|
|
738 |
_events.Publish(new Notification { Title = "Pithos", Message = message, Level = level});
|
|
739 |
}
|
|
699 | 740 |
|
700 |
public void Notify(Notification notification)
|
|
701 |
{
|
|
702 |
_events.Publish(notification);
|
|
703 |
}
|
|
741 |
public void Notify(Notification notification)
|
|
742 |
{
|
|
743 |
_events.Publish(notification);
|
|
744 |
}
|
|
704 | 745 |
|
705 | 746 |
|
706 |
public void RemoveMonitor(string accountName)
|
|
747 |
public void RemoveMonitor(string accountName)
|
|
707 | 748 |
{ |
708 | 749 |
if (String.IsNullOrWhiteSpace(accountName)) |
709 | 750 |
return; |
... | ... | |
763 | 804 |
} |
764 | 805 |
|
765 | 806 |
|
766 |
private bool _pollStarted = false;
|
|
807 |
private bool _pollStarted = false;
|
|
767 | 808 |
|
768 |
//SMELL: Doing so much work for notifications in the shell is wrong
|
|
769 |
//The notifications should be moved to their own view/viewmodel pair
|
|
770 |
//and different templates should be used for different message types
|
|
771 |
//This will also allow the addition of extra functionality, eg. actions
|
|
772 |
//
|
|
809 |
//SMELL: Doing so much work for notifications in the shell is wrong
|
|
810 |
//The notifications should be moved to their own view/viewmodel pair
|
|
811 |
//and different templates should be used for different message types
|
|
812 |
//This will also allow the addition of extra functionality, eg. actions
|
|
813 |
//
|
|
773 | 814 |
public void Handle(Notification notification) |
774 | 815 |
{ |
775 |
UpdateStatus();
|
|
816 |
UpdateStatus();
|
|
776 | 817 |
|
777 | 818 |
if (!Settings.ShowDesktopNotifications) |
778 | 819 |
return; |
779 | 820 |
|
780 |
if (notification is PollNotification)
|
|
781 |
{
|
|
782 |
_pollStarted = true;
|
|
783 |
return;
|
|
784 |
}
|
|
785 |
if (notification is CloudNotification)
|
|
786 |
{
|
|
787 |
if (!_pollStarted)
|
|
788 |
return;
|
|
789 |
_pollStarted= false;
|
|
790 |
notification.Title = "Pithos";
|
|
791 |
notification.Message = "Start Synchronisation";
|
|
792 |
}
|
|
793 |
|
|
794 |
if (String.IsNullOrWhiteSpace(notification.Message) && String.IsNullOrWhiteSpace(notification.Title))
|
|
795 |
return;
|
|
821 |
if (notification is PollNotification)
|
|
822 |
{
|
|
823 |
_pollStarted = true;
|
|
824 |
return;
|
|
825 |
}
|
|
826 |
if (notification is CloudNotification)
|
|
827 |
{
|
|
828 |
if (!_pollStarted)
|
|
829 |
return;
|
|
830 |
_pollStarted= false;
|
|
831 |
notification.Title = "Pithos";
|
|
832 |
notification.Message = "Start Synchronisation";
|
|
833 |
}
|
|
834 |
|
|
835 |
if (String.IsNullOrWhiteSpace(notification.Message) && String.IsNullOrWhiteSpace(notification.Title))
|
|
836 |
return;
|
|
796 | 837 |
|
797 | 838 |
BalloonIcon icon; |
798 | 839 |
switch (notification.Level) |
... | ... | |
811 | 852 |
icon = BalloonIcon.None; |
812 | 853 |
break; |
813 | 854 |
} |
814 |
|
|
855 |
|
|
815 | 856 |
if (Settings.ShowDesktopNotifications) |
816 | 857 |
{ |
817 | 858 |
var tv = (ShellView) GetView(); |
818 |
var balloon=new PithosBalloon{Title=notification.Title,Message=notification.Message,Icon=icon};
|
|
819 |
tv.TaskbarView.ShowCustomBalloon(balloon,PopupAnimation.Fade,4000);
|
|
859 |
var balloon=new PithosBalloon{Title=notification.Title,Message=notification.Message,Icon=icon};
|
|
860 |
tv.TaskbarView.ShowCustomBalloon(balloon,PopupAnimation.Fade,4000);
|
|
820 | 861 |
// tv.TaskbarView.ShowBalloonTip(notification.Title, notification.Message, icon); |
821 | 862 |
} |
822 | 863 |
} |
... | ... | |
831 | 872 |
Contract.EndContractBlock(); |
832 | 873 |
|
833 | 874 |
var fileName = message.FileName; |
834 |
//TODO: Display file properties for non-container folders
|
|
875 |
//TODO: Display file properties for non-container folders
|
|
835 | 876 |
if (File.Exists(fileName)) |
836 |
//Retrieve the full name with exact casing. Pithos names are case sensitive
|
|
837 |
ShowFileProperties(FileInfoExtensions.GetProperFilePathCapitalization(fileName));
|
|
877 |
//Retrieve the full name with exact casing. Pithos names are case sensitive
|
|
878 |
ShowFileProperties(FileInfoExtensions.GetProperFilePathCapitalization(fileName));
|
|
838 | 879 |
else if (Directory.Exists(fileName)) |
839 |
//Retrieve the full name with exact casing. Pithos names are case sensitive
|
|
880 |
//Retrieve the full name with exact casing. Pithos names are case sensitive
|
|
840 | 881 |
{ |
841 |
var path = FileInfoExtensions.GetProperDirectoryCapitalization(fileName);
|
|
842 |
if (IsContainer(path))
|
|
843 |
ShowContainerProperties(path);
|
|
844 |
else
|
|
845 |
ShowFileProperties(path);
|
|
882 |
var path = FileInfoExtensions.GetProperDirectoryCapitalization(fileName);
|
|
883 |
if (IsContainer(path))
|
|
884 |
ShowContainerProperties(path);
|
|
885 |
else
|
|
886 |
ShowFileProperties(path);
|
|
846 | 887 |
} |
847 | 888 |
} |
848 | 889 |
|
849 |
private bool IsContainer(string path)
|
|
850 |
{
|
|
851 |
var matchingFolders = from account in _accounts
|
|
852 |
from rootFolder in Directory.GetDirectories(account.AccountPath)
|
|
853 |
where rootFolder.Equals(path, StringComparison.InvariantCultureIgnoreCase)
|
|
854 |
select rootFolder;
|
|
855 |
return matchingFolders.Any();
|
|
856 |
}
|
|
857 |
|
|
858 |
public FileStatus GetFileStatus(string localFileName)
|
|
859 |
{
|
|
860 |
if (String.IsNullOrWhiteSpace(localFileName))
|
|
861 |
throw new ArgumentNullException("localFileName");
|
|
862 |
Contract.EndContractBlock();
|
|
863 |
|
|
864 |
var statusKeeper = IoC.Get<IStatusKeeper>();
|
|
865 |
var status=statusKeeper.GetFileStatus(localFileName);
|
|
866 |
return status;
|
|
867 |
}
|
|
890 |
private bool IsContainer(string path)
|
|
891 |
{
|
|
892 |
var matchingFolders = from account in _accounts
|
|
893 |
from rootFolder in Directory.GetDirectories(account.AccountPath)
|
|
894 |
where rootFolder.Equals(path, StringComparison.InvariantCultureIgnoreCase)
|
|
895 |
select rootFolder;
|
|
896 |
return matchingFolders.Any();
|
|
897 |
}
|
|
898 |
|
|
899 |
public FileStatus GetFileStatus(string localFileName)
|
|
900 |
{
|
|
901 |
if (String.IsNullOrWhiteSpace(localFileName))
|
|
902 |
throw new ArgumentNullException("localFileName");
|
|
903 |
Contract.EndContractBlock();
|
|
904 |
|
|
905 |
var statusKeeper = IoC.Get<IStatusKeeper>();
|
|
906 |
var status=statusKeeper.GetFileStatus(localFileName);
|
|
907 |
return status;
|
|
908 |
}
|
|
868 | 909 |
} |
869 | 910 |
} |
Also available in: Unified diff