Created
November 9, 2017 14:46
-
-
Save chrismckelt/a8a7fb7cbf768e1423eb5da15070daa7 to your computer and use it in GitHub Desktop.
Scrum for Trello data extractor
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// <summary> | |
/// This script will connect to the Trello API and the Google Spreadsheet in use by Scrum For Trello | |
/// It will output current sprint data, estimates vs remaining | |
/// burndown chart for development board | |
/// https://trello.com/1/members/me/boards?fields=name; | |
/// </summary> | |
/// INSTRUCTIONS - (after pasting in the google oauth token & refresh token) | |
/// update trello | |
/// 1. ENSURE THE TRELLO BOARD HAS SYNCED TO THE SPREADSHEET | |
/// 2. run | |
/// 3. on the trello board - clear items from the DONE DONE column | |
public bool IsSlackPostEnabled = false; | |
public string GoogleOAuthAccessToken = " "; // first exception paste token here from google (after accepting) | |
public string GoogleRefreshToken = " 1AGoCpnOMMOYJ3Er8hVKyICAfyNhOBcb1xe7u_u-to4"; // after running it again, in the output console copy the value OAuth Access Token | |
public const string TrelloToken = "1c323aa8792d493f6b3a9be910f29a1df7362c76ae50d95480346597ab99f683"; // every 30 days trello oauth token expires | |
public const string GoogleSpreadSheetUrl = @"https://docs.google.com/spreadsheets/d/1k95abhxhnKA_VAtMqczz1OxH0hyTpeBBK4Z3e-pB6cY/edit#gid=0"; | |
public IList<string> ExcludeLists = new List<string>() { "Backlog", "V2", "Tech-Debt", "Archived" }; | |
public string BoardId = "5982c43f60768e5dd1fa6a8c"; //55a8cdfd9536d1d4a332691f | |
const string DoneDone = "Done Done"; | |
// from trello directly | |
public static Trello Trello { get; set; } | |
public static Board Board { get; set; } | |
public static IEnumerable<TrelloNet.Label> Labels { get; set; } | |
public static IEnumerable<TrelloNet.List> TrelloLists = new List<TrelloNet.List>(); | |
public static IList<TrelloNet.Card> Cards = new List<TrelloNet.Card>(); | |
// google spreadsheet (synced via plus for trello) | |
public IEnumerable<SpreadSheetItem> Spreadsheet { get; set; } | |
public IEnumerable<Card> SprintCards { get; set; } | |
public IEnumerable<Card> DoneCards { get; set; } | |
public decimal Inaccuracy = 0; | |
public decimal Velocity = 0; | |
public decimal Remaining = 0; | |
void Main() | |
{ | |
// extraction use google spreadsheet to get all card estimates | |
Cards = ExtractTrello().ToList(); | |
Debug.Assert(Cards.Any()); | |
Spreadsheet = GetGoogleSpreadsheet(); | |
var firstEstimates = Spreadsheet | |
.Where(x => x.ListName == DoneDone) | |
.Select(x => new { Card = x.Card, FirstEstimate = GetFirstEstimate(x.Card) }) | |
.Distinct() | |
.Dump("First Estimates"); | |
Velocity = firstEstimates.Sum(x => x.FirstEstimate); | |
Spreadsheet | |
.Where(x => x.ListName != DoneDone) | |
.Select(x => new { Card = x.Card, Remaining = GetRemainingPoints(x.Card) }) | |
.Distinct() | |
.Dump("Remaining points per card"); | |
Remaining = Spreadsheet | |
.Where(a => !ExcludeLists.Contains(a.ListName)) | |
.Where(x => x.ListName != DoneDone) | |
.Select(x => new { Card = x.Card, Remaining = GetRemainingPoints(x.Card) }) | |
.Distinct() | |
.Sum(x => x.Remaining); | |
// var cardsPerList = Spreadsheet | |
// .Where(a => !ExcludeLists.Contains(a.ListName)) | |
// .Where(x => x.ListName != DoneDone) | |
// .Select(x => new { Card = x.Card, ListName = x.ListName }); | |
// | |
// var listCount = from p in cardsPerList | |
// group p by new { p.ListName, p.Card } into g | |
// select new { ListName = g.Key.ListName, Total = g.Count() }; | |
// listCount.OrderByDescending(x => x.Total).Dump("Cards per list"); | |
Spreadsheet | |
.Where(a => !ExcludeLists.Contains(a.ListName)) | |
.Where(x => x.ListName != DoneDone) | |
.Select(x => new { Card = x.Card, Remaining = GetRemainingPoints(x.Card) }) | |
.Distinct() | |
.Sum(x => x.Remaining); | |
var estimateDifference = Spreadsheet | |
.Where(a => !ExcludeLists.Contains(a.ListName)) | |
.Select(x => new { Card = x.Card, Difference = GetEstimateDifference(x.Card) }) | |
.OrderByDescending(x=>x.Difference) | |
.Distinct() | |
.Dump("Estimate Difference"); | |
//Inaccuracy = firstEstimates.Sum(x=>x.FirstEstimate) / estimateDifference.Sum(x=>x.Difference); | |
Breakdown(); | |
// stats | |
Message.AppendLine($"sprint velocity {Velocity} points (total first estimates in done column)"); | |
//Message.AppendLine($"estimation inaccuracy {Math.Abs(Inaccuracy).ToString("P2")} (change between first estimate and final estimate)"); | |
Message.AppendLine($"total cards {Spreadsheet.Where(x=>!ExcludeLists.Contains(x.ListName)).Select(y => y.Card).Distinct().Count()}"); | |
Message.AppendLine($"done cards {Spreadsheet.Where(x => x.ListName == DoneDone).Select(y => y.Card).Distinct().Count()}"); | |
Message.AppendLine($"remaining points {Remaining}"); | |
PostToSlack("Sprint stats"); | |
Spreadsheet.Dump("Spreadsheet"); | |
SaveStatsToFile(); | |
SaveToDisk(Spreadsheet.ToList()); | |
} | |
#region Methods | |
private void Breakdown() | |
{ | |
var kvpAllCardsList = new List<KeyValuePair<string, decimal>>(); | |
foreach (var card in Cards) | |
{ | |
foreach (var l in card.Labels) | |
{ | |
var kvp = new KeyValuePair<string, decimal>(l.Name, GetRemainingPoints(card.Name)); | |
kvpAllCardsList.Add(kvp); | |
} | |
} | |
kvpAllCardsList | |
.GroupBy(x => x.Key) | |
.Select(x => new { Name = x.Key, Total = x.Count() }) | |
.OrderBy(x => x.Name) | |
.ThenByDescending(x => x.Total) | |
.Dump("Total Label counts"); | |
kvpAllCardsList | |
.GroupBy(x => x.Key) | |
.Select(x => new { Name = x.Key, Total = x.Sum(y => y.Value) }) | |
.OrderByDescending(x => x.Total) | |
.Dump("Total Label summed"); | |
var inclusive = Spreadsheet.Where(x => x.ListName != DoneDone); | |
var kvpRemainingCardsList = new List<KeyValuePair<string, decimal>>(); | |
foreach (var card in Cards.Where(x=>inclusive.Select(y=>y.Card).Contains(x.Name))) | |
{ | |
foreach (var l in card.Labels) | |
{ | |
var kvp = new KeyValuePair<string, decimal>(l.Name, GetRemainingPoints(card.Name)); | |
kvpRemainingCardsList.Add(kvp); | |
} | |
} | |
kvpRemainingCardsList | |
.GroupBy(x => x.Key) | |
.Select(x => new { Name = x.Key, Total = x.Count() }) | |
.OrderBy(x => x.Name) | |
.ThenByDescending(x => x.Total) | |
.Dump("Remaining Label counts"); | |
kvpRemainingCardsList | |
.GroupBy(x => x.Key) | |
.Select(x => new { Name = x.Key, Total = x.Sum(y => y.Value) }) | |
.OrderByDescending(x => x.Total) | |
.Dump("Remaining Label summed"); | |
} | |
private IEnumerable<Card> ExtractTrello(bool filterColumns = true) | |
{ | |
Trello = new Trello("7b17eb1ed849a91c051da9c924f93cfb"); | |
var url = Trello.GetAuthorizationUrl("userstorydataloader", Scope.ReadWrite); | |
var cards = new List<Card>(); | |
try | |
{ | |
string token = TrelloToken; | |
Trello.Authorize(token); | |
Board = Trello.Boards.WithId(BoardId); | |
TrelloLists = Trello.Lists.ForBoard(Board); | |
foreach (var list in TrelloLists) | |
{ | |
cards.AddRange(Trello.Cards.ForList(list, CardFilter.Open)); | |
} | |
} | |
catch | |
{ | |
Console.WriteLine("Trello token expired"); | |
Process.Start(url.AbsoluteUri); // paste token below | |
throw; | |
} | |
return cards; | |
} | |
private IList<SpreadSheetItem> GetGoogleSpreadsheet() | |
{ | |
var list = new List<SpreadSheetItem>(); | |
//////////////////////////////////////////////////////////////////////////// | |
// STEP 1: Configure how to perform OAuth 2.0 | |
//////////////////////////////////////////////////////////////////////////// | |
// TODO: Update the following information with that obtained from | |
// https://code.google.com/apis/console. After registering | |
// your application, these will be provided for you. | |
string CLIENT_ID = "677552916440-b3v9e9kcoook1luvrsequet2r29rt5fn.apps.googleusercontent.com"; | |
// This is the OAuth 2.0 Client Secret retrieved | |
// above. Be sure to store this value securely. Leaking this | |
// value would enable others to act on behalf of your application! | |
string CLIENT_SECRET = "BgshShGv9lSzE2ZRY0PRka-1"; | |
// Space separated list of scopes for which to request access. | |
string SCOPE = "https://spreadsheets.google.com/feeds https://docs.google.com/feeds"; | |
// This is the Redirect URI for installed applications. | |
// If you are building a web application, you have to set your | |
// Redirect URI at https://code.google.com/apis/console. | |
string REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob"; | |
//////////////////////////////////////////////////////////////////////////// | |
// STEP 2: Set up the OAuth 2.0 object | |
//////////////////////////////////////////////////////////////////////////// | |
// OAuth2Parameters holds all the parameters related to OAuth 2.0. | |
OAuth2Parameters parameters = new OAuth2Parameters(); | |
// Set your OAuth 2.0 Client Id (which you can register at | |
// https://code.google.com/apis/console). | |
parameters.ClientId = CLIENT_ID; | |
// Set your OAuth 2.0 Client Secret, which can be obtained at | |
// https://code.google.com/apis/console. | |
parameters.ClientSecret = CLIENT_SECRET; | |
// Set your Redirect URI, which can be registered at | |
// https://code.google.com/apis/console. | |
parameters.RedirectUri = REDIRECT_URI; | |
//////////////////////////////////////////////////////////////////////////// | |
// STEP 3: Get the Authorization URL | |
//////////////////////////////////////////////////////////////////////////// | |
// Set the scope for this particular service. | |
parameters.Scope = SCOPE; | |
string authorizationUrl = OAuthUtil.CreateOAuth2AuthorizationUrl(parameters); | |
/// step 1 - paste access token here (if the token has expired) | |
parameters.AccessCode = GoogleOAuthAccessToken; | |
/// step 2 - paste refresh token here | |
parameters.RefreshToken = GoogleRefreshToken; | |
/////////////////////////////////////////////////////////////////////////// | |
// STEP 4: Get the Access Token | |
//////////////////////////////////////////////////////////////////////////// | |
// Once the user authorizes with Google, the request token can be exchanged | |
// for a long-lived access token. If you are building a browser-based | |
// application, you should parse the incoming request token from the url and | |
// set it in OAuthParameters before calling GetAccessToken(). | |
try | |
{ | |
OAuthUtil.RefreshAccessToken(parameters); | |
} | |
catch (Exception) | |
{ | |
Process.Start(authorizationUrl); | |
OAuthUtil.GetAccessToken(parameters); | |
string accessToken = parameters.AccessToken; | |
Console.WriteLine("OAuth Access Token: " + accessToken); | |
} | |
//////////////////////////////////////////////////////////////////////////// | |
// STEP 5: Make an OAuth authorized request to Google | |
//////////////////////////////////////////////////////////////////////////// | |
// Initialize the variables needed to make the request | |
GOAuth2RequestFactory requestFactory = new GOAuth2RequestFactory(null, "emerald-griffin-130607", parameters); | |
SpreadsheetsService service = new SpreadsheetsService("emerald-griffin-130607"); | |
service.RequestFactory = requestFactory; | |
// var service = AuthenticateGoogleSpreadsheets(); | |
// Instantiate a SpreadsheetQuery object to retrieve spreadsheets. | |
SpreadsheetQuery query = new SpreadsheetQuery(); | |
// Make a request to the API and get all spreadsheets. | |
SpreadsheetFeed feed = service.Query(query); | |
var trelloSheet = (SpreadsheetEntry)feed.Entries.First(x => x.Title.Text == "Plus for Trello sync spreadsheet"); | |
// Make a request to the API to fetch information about all | |
// worksheets in the spreadsheet. | |
WorksheetFeed wsFeed = trelloSheet.Worksheets; | |
var sheetDataList = new List<SheetData>(); | |
// Iterate through each worksheet in the spreadsheet. | |
foreach (WorksheetEntry entry in wsFeed.Entries) | |
{ | |
// Fetch the cell feed of the worksheet. | |
CellQuery cq = new CellQuery(entry.CellFeedLink); | |
CellFeed cf = service.Query(cq); | |
// Iterate through each cell, updating its value if necessary. | |
foreach (CellEntry cellEntry in cf.Entries) | |
{ | |
// In this case, cellEntry.Value is null - and almost everything else in the object is null too. | |
var sd = new SheetData(); | |
sd.CellEntry = cellEntry; | |
sd.Column = cellEntry.Column; | |
sd.Row = cellEntry.Row; | |
if (!string.IsNullOrEmpty(cellEntry.Cell.Value)) | |
sheetDataList.Add(sd); | |
} | |
} | |
uint maxRow = sheetDataList.Max(x => x.Row); | |
uint maxColumn = sheetDataList.Max(x => x.Column); | |
for (uint row = 2; row <= maxRow; row++) // first row is headers | |
{ | |
var item = new SpreadSheetItem(); | |
var strDate = sheetDataList.SingleOrDefault(a => a.Row == row && a.Column == 1).CellEntry.Value; | |
item.Order = GetOrder(strDate); // this includes an ID - parse it off for ordering | |
item.Date = GetDate(strDate); // dito | |
item.Board = Convert.ToString(sheetDataList.SingleOrDefault(a => a.Row == row && a.Column == 2).CellEntry.Value); | |
item.Card = Convert.ToString(sheetDataList.SingleOrDefault(a => a.Row == row && a.Column == 3).CellEntry.Value); | |
item.SpentHours = Convert.ToDecimal(sheetDataList.SingleOrDefault(a => a.Row == row && a.Column == 4).CellEntry.Value); | |
item.EstimatedHours = Convert.ToDecimal(sheetDataList.SingleOrDefault(a => a.Row == row && a.Column == 5).CellEntry.Value); | |
item.Who = Convert.ToString(sheetDataList.SingleOrDefault(a => a.Row == row && a.Column == 6).CellEntry.Value); | |
item.Url = Convert.ToString(sheetDataList.SingleOrDefault(a => a.Row == row && a.Column == 7).CellEntry.Value); | |
item.IdTrello = Convert.ToString(sheetDataList.SingleOrDefault(a => a.Row == row && a.Column == 11).CellEntry.Value); | |
try | |
{ | |
item.ListName = TrelloLists.FirstOrDefault(a => a.Id == Cards.FirstOrDefault(x => x.Name == item.Card).IdList).Name; | |
} | |
catch | |
{ | |
item.ListName = "Archived"; | |
} | |
item.Labels = Cards.FirstOrDefault(x => x.Name == item.Card)?.Labels.Select(x => x.Name); | |
list.Add(item); | |
} | |
return list; | |
} | |
private decimal GetFirstEstimate(string cardName) | |
{ | |
var items = Spreadsheet.Where(a => a.Card == cardName).OrderBy(b => b.Date); | |
decimal total = 0; | |
foreach (var item in items.Where(x => x.SpentHours == 0).Take(1)) | |
{ | |
total += Math.Abs(item.EstimatedHours); | |
} | |
if (total == 0) | |
{ | |
if (items.Any(x => x.EstimatedHours > 0)) | |
{ | |
total = items.Max(x => x.EstimatedHours); | |
} | |
else | |
{ | |
total = items.Max(x => x.SpentHours); | |
} | |
} | |
return total; | |
} | |
private decimal GetRemainingPoints(string cardName) | |
{ | |
var items = Spreadsheet.Where(a => a.Card == cardName).OrderBy(b => b.Date); | |
decimal total = 0; | |
foreach (var item in items.Where(x => x.EstimatedHours > 0).Take(1)) | |
{ | |
total += Math.Abs(item.EstimatedHours); | |
} | |
return total; | |
} | |
private decimal GetEstimateDifference(string cardName) | |
{ | |
var items = Spreadsheet.Where(a => a.Card == cardName).OrderBy(b => b.Date); | |
var max = items.Max(x=>x.EstimatedHours); | |
var min = items.Min(x=>x.EstimatedHours); | |
return max - min; | |
} | |
private string FindFolder() | |
{ | |
string folder = @"D:\work\Dropbox (FairGo 3.0)\development\SprintStats"; | |
if (!Directory.Exists(folder)) | |
{ | |
folder = @"d:\Dropbox (FairGo 3.0)\development\SprintStats"; | |
} | |
if (!Directory.Exists(folder)) | |
{ | |
throw new DirectoryNotFoundException(folder); | |
} | |
return folder; | |
} | |
private void SaveStatsToFile() | |
{ | |
if (!IsSlackPostEnabled) return; | |
string folder = FindFolder(); | |
string filename = "stats.log"; | |
string fullName = Path.Combine(folder, filename); | |
var log = new StringBuilder(); | |
log.AppendLine("---------------------------------------------------------"); | |
log.AppendLine("Created: " + DateTime.Now.ToShortDateString()); | |
log.Append(Message.ToString()); | |
log.AppendLine("---------------------------------------------------------"); | |
log.AppendLine(" "); | |
System.IO.File.AppendAllText(fullName, log.ToString()); | |
} | |
private void SaveToDisk(IList<SpreadSheetItem> items) | |
{ | |
string folder = FindFolder(); | |
string filename = string.Concat("burndown_", DateTime.Now.ToString("yyyyMMdd"), ".csv"); | |
string fullName = Path.Combine(folder, filename); | |
if (File.Exists(fullName)) File.Delete(fullName); | |
Util.WriteCsv(items, fullName); | |
} | |
/// an order ID is appended to the data - 05/17/2016 12:35:91 91 is not seconds its an ID | |
private int GetOrder(string dateString) | |
{ | |
var trimmedId = dateString.Substring(17); | |
return Int16.Parse(trimmedId); | |
} | |
/// an order ID is appended to the data - 05/17/2016 12:35:91 91 is not seconds | |
private DateTime GetDate(string dateString) | |
{ | |
var trimmedDateString = dateString.Substring(0, 16); | |
DateTime dt = DateTime.Now; | |
CultureInfo enUS = new CultureInfo("en-US"); | |
DateTimeRoutines.ParsedDateTime pdt; | |
var formatStrings = new string[] { "MM/dd/yyyy hh:mm:ss tt", "yyyy-MM-dd hh:mm:ss", "" }; | |
if (DateTime.TryParseExact(trimmedDateString, formatStrings, enUS, DateTimeStyles.None, out dt)) | |
{ | |
//Console.WriteLine(".net datetime " + dt.ToString()); | |
return dt; | |
} | |
else if (DateTimeRoutines.TryParseDateTime(trimmedDateString, DateTimeRoutines.DateTimeFormat.USA_DATE, out pdt)) | |
{ | |
//Console.WriteLine("date time parsed as " + pdt.DateTime.ToString()); | |
return pdt.DateTime; | |
} | |
else | |
{ | |
throw new InvalidDataException(dateString); | |
} | |
} | |
#endregion | |
#region Slack poster | |
public StringBuilder Message = new StringBuilder(); | |
private void PostToSlack(string title) | |
{ | |
if (IsSlackPostEnabled) | |
{ | |
string urlWithAccessToken = "https://hooks.slack.com/services/T06EV35NK/B0H260AE5/xxxxxxxxxxxx"; | |
SlackClient client = new SlackClient(urlWithAccessToken); | |
var slackMessage = new SlackMessage(); | |
slackMessage.AsUser("itnotifications"); | |
Console.WriteLine(Message.ToString()); | |
string msg = Message.ToString(); | |
if (!string.IsNullOrEmpty(title) && !string.IsNullOrEmpty(msg)) | |
{ | |
slackMessage.Field(title, Message.ToString(), false); | |
slackMessage.FallBack(title + " " + Message.ToString()); | |
client.Send(slackMessage); | |
} | |
} | |
else | |
{ | |
Console.WriteLine(); | |
Console.WriteLine("--------------------------"); | |
Console.WriteLine(title); | |
Console.WriteLine("--------------------------"); | |
Console.WriteLine(); | |
Console.WriteLine(Message.ToString()); | |
Console.WriteLine(); | |
} | |
} | |
#endregion | |
#region Classes | |
public class SpreadSheetItem | |
{ | |
public DateTime Date { get; set; } | |
public string Board { get; set; } | |
public string Card { get; set; } | |
public string ListName { get; set; } | |
public IEnumerable<string> Labels { get; set; } | |
public decimal SpentHours { get; set; } | |
public decimal EstimatedHours { get; set; } | |
public string Who { get; set; } | |
public string Url { get; set; } | |
public string IdTrello { get; set; } | |
public int Order { get; set; } | |
} | |
public class SheetData | |
{ | |
public uint Column { get; set; } | |
public uint Row { get; set; } | |
public CellEntry CellEntry { get; set; } | |
} | |
#endregion | |
#region date time parser | |
/// <summary> | |
/// Miscellaneous and parsing methods for DateTime | |
/// </summary> | |
public static class DateTimeRoutines | |
{ | |
#region miscellaneous methods | |
/// <summary> | |
/// Amount of seconds elapsed between 1970-01-01 00:00:00 and the date-time. | |
/// </summary> | |
/// <param name="date_time">date-time</param> | |
/// <returns>seconds</returns> | |
public static uint GetSecondsSinceUnixEpoch(this DateTime date_time) | |
{ | |
TimeSpan t = date_time - new DateTime(1970, 1, 1); | |
int ss = (int)t.TotalSeconds; | |
if (ss < 0) | |
return 0; | |
return (uint)ss; | |
} | |
#endregion | |
#region parsing definitions | |
/// <summary> | |
/// Defines a substring where date-time was found and result of conversion | |
/// </summary> | |
public class ParsedDateTime | |
{ | |
/// <summary> | |
/// Index of first char of a date substring found in the string | |
/// </summary> | |
readonly public int IndexOfDate = -1; | |
/// <summary> | |
/// Length a date substring found in the string | |
/// </summary> | |
readonly public int LengthOfDate = -1; | |
/// <summary> | |
/// Index of first char of a time substring found in the string | |
/// </summary> | |
readonly public int IndexOfTime = -1; | |
/// <summary> | |
/// Length of a time substring found in the string | |
/// </summary> | |
readonly public int LengthOfTime = -1; | |
/// <summary> | |
/// DateTime found in the string | |
/// </summary> | |
readonly public DateTime DateTime; | |
/// <summary> | |
/// True if a date was found within the string | |
/// </summary> | |
readonly public bool IsDateFound; | |
/// <summary> | |
/// True if a time was found within the string | |
/// </summary> | |
readonly public bool IsTimeFound; | |
/// <summary> | |
/// UTC offset if it was found within the string | |
/// </summary> | |
readonly public TimeSpan UtcOffset; | |
/// <summary> | |
/// True if UTC offset was found in the string | |
/// </summary> | |
readonly public bool IsUtcOffsetFound; | |
/// <summary> | |
/// Utc gotten from DateTime if IsUtcOffsetFound is True | |
/// </summary> | |
public DateTime UtcDateTime; | |
internal ParsedDateTime(int index_of_date, int length_of_date, int index_of_time, int length_of_time, DateTime date_time) | |
{ | |
IndexOfDate = index_of_date; | |
LengthOfDate = length_of_date; | |
IndexOfTime = index_of_time; | |
LengthOfTime = length_of_time; | |
DateTime = date_time; | |
IsDateFound = index_of_date > -1; | |
IsTimeFound = index_of_time > -1; | |
UtcOffset = new TimeSpan(25, 0, 0); | |
IsUtcOffsetFound = false; | |
UtcDateTime = new DateTime(1, 1, 1); | |
} | |
internal ParsedDateTime(int index_of_date, int length_of_date, int index_of_time, int length_of_time, DateTime date_time, TimeSpan utc_offset) | |
{ | |
IndexOfDate = index_of_date; | |
LengthOfDate = length_of_date; | |
IndexOfTime = index_of_time; | |
LengthOfTime = length_of_time; | |
DateTime = date_time; | |
IsDateFound = index_of_date > -1; | |
IsTimeFound = index_of_time > -1; | |
UtcOffset = utc_offset; | |
IsUtcOffsetFound = Math.Abs(utc_offset.TotalHours) < 12; | |
if (!IsUtcOffsetFound) | |
UtcDateTime = new DateTime(1, 1, 1); | |
else | |
{ | |
if (index_of_date < 0)//to avoid negative date exception when date is undefined | |
{ | |
TimeSpan ts = date_time.TimeOfDay + utc_offset; | |
if (ts < new TimeSpan(0)) | |
UtcDateTime = new DateTime(1, 1, 2) + ts; | |
else | |
UtcDateTime = new DateTime(1, 1, 1) + ts; | |
} | |
else | |
UtcDateTime = date_time + utc_offset; | |
} | |
} | |
} | |
/// <summary> | |
/// Date that is accepted in the following cases: | |
/// - no date was parsed by TryParseDateOrTime(); | |
/// - no year was found by TryParseDate(); | |
/// It is ignored if DefaultDateIsNow = true was set after DefaultDate | |
/// </summary> | |
public static DateTime DefaultDate | |
{ | |
set | |
{ | |
_DefaultDate = value; | |
DefaultDateIsNow = false; | |
} | |
get | |
{ | |
if (DefaultDateIsNow) | |
return DateTime.Now; | |
else | |
return _DefaultDate; | |
} | |
} | |
static DateTime _DefaultDate = DateTime.Now; | |
/// <summary> | |
/// If true then DefaultDate property is ignored and DefaultDate is always DateTime.Now | |
/// </summary> | |
public static bool DefaultDateIsNow = true; | |
/// <summary> | |
/// Defines default date-time format. | |
/// </summary> | |
public enum DateTimeFormat | |
{ | |
/// <summary> | |
/// month number goes before day number | |
/// </summary> | |
USA_DATE, | |
/// <summary> | |
/// day number goes before month number | |
/// </summary> | |
UK_DATE, | |
///// <summary> | |
///// time is specifed through AM or PM | |
///// </summary> | |
//USA_TIME, | |
} | |
#endregion | |
#region parsing derived methods for DateTime output | |
/// <summary> | |
/// Tries to find date and time within the passed string and return it as DateTime structure. | |
/// </summary> | |
/// <param name="str">string that contains date and/or time</param> | |
/// <param name="default_format">format to be used preferably in ambivalent instances</param> | |
/// <param name="date_time">parsed date-time output</param> | |
/// <returns>true if both date and time were found, else false</returns> | |
static public bool TryParseDateTime(this string str, DateTimeFormat default_format, out DateTime date_time) | |
{ | |
ParsedDateTime parsed_date_time; | |
if (!TryParseDateTime(str, default_format, out parsed_date_time)) | |
{ | |
date_time = new DateTime(1, 1, 1); | |
return false; | |
} | |
date_time = parsed_date_time.DateTime; | |
return true; | |
} | |
/// <summary> | |
/// Tries to find date and/or time within the passed string and return it as DateTime structure. | |
/// If only date was found, time in the returned DateTime is always 0:0:0. | |
/// If only time was found, date in the returned DateTime is DefaultDate. | |
/// </summary> | |
/// <param name="str">string that contains date and(or) time</param> | |
/// <param name="default_format">format to be used preferably in ambivalent instances</param> | |
/// <param name="date_time">parsed date-time output</param> | |
/// <returns>true if date and/or time was found, else false</returns> | |
static public bool TryParseDateOrTime(this string str, DateTimeFormat default_format, out DateTime date_time) | |
{ | |
ParsedDateTime parsed_date_time; | |
if (!TryParseDateOrTime(str, default_format, out parsed_date_time)) | |
{ | |
date_time = new DateTime(1, 1, 1); | |
return false; | |
} | |
date_time = parsed_date_time.DateTime; | |
return true; | |
} | |
/// <summary> | |
/// Tries to find time within the passed string and return it as DateTime structure. | |
/// It recognizes only time while ignoring date, so date in the returned DateTime is always 1/1/1. | |
/// </summary> | |
/// <param name="str">string that contains time</param> | |
/// <param name="default_format">format to be used preferably in ambivalent instances</param> | |
/// <param name="time">parsed time output</param> | |
/// <returns>true if time was found, else false</returns> | |
public static bool TryParseTime(this string str, DateTimeFormat default_format, out DateTime time) | |
{ | |
ParsedDateTime parsed_time; | |
if (!TryParseTime(str, default_format, out parsed_time, null)) | |
{ | |
time = new DateTime(1, 1, 1); | |
return false; | |
} | |
time = parsed_time.DateTime; | |
return true; | |
} | |
/// <summary> | |
/// Tries to find date within the passed string and return it as DateTime structure. | |
/// It recognizes only date while ignoring time, so time in the returned DateTime is always 0:0:0. | |
/// If year of the date was not found then it accepts the current year. | |
/// </summary> | |
/// <param name="str">string that contains date</param> | |
/// <param name="default_format">format to be used preferably in ambivalent instances</param> | |
/// <param name="date">parsed date output</param> | |
/// <returns>true if date was found, else false</returns> | |
static public bool TryParseDate(this string str, DateTimeFormat default_format, out DateTime date) | |
{ | |
ParsedDateTime parsed_date; | |
if (!TryParseDate(str, default_format, out parsed_date)) | |
{ | |
date = new DateTime(1, 1, 1); | |
return false; | |
} | |
date = parsed_date.DateTime; | |
return true; | |
} | |
#endregion | |
#region parsing derived methods for ParsedDateTime output | |
/// <summary> | |
/// Tries to find date and time within the passed string and return it as ParsedDateTime object. | |
/// </summary> | |
/// <param name="str">string that contains date-time</param> | |
/// <param name="default_format">format to be used preferably in ambivalent instances</param> | |
/// <param name="parsed_date_time">parsed date-time output</param> | |
/// <returns>true if both date and time were found, else false</returns> | |
static public bool TryParseDateTime(this string str, DateTimeFormat default_format, out ParsedDateTime parsed_date_time) | |
{ | |
if (DateTimeRoutines.TryParseDateOrTime(str, default_format, out parsed_date_time) | |
&& parsed_date_time.IsDateFound | |
&& parsed_date_time.IsTimeFound | |
) | |
return true; | |
parsed_date_time = null; | |
return false; | |
} | |
/// <summary> | |
/// Tries to find time within the passed string and return it as ParsedDateTime object. | |
/// It recognizes only time while ignoring date, so date in the returned ParsedDateTime is always 1/1/1 | |
/// </summary> | |
/// <param name="str">string that contains date-time</param> | |
/// <param name="default_format">format to be used preferably in ambivalent instances</param> | |
/// <param name="parsed_time">parsed date-time output</param> | |
/// <returns>true if time was found, else false</returns> | |
static public bool TryParseTime(this string str, DateTimeFormat default_format, out ParsedDateTime parsed_time) | |
{ | |
return TryParseTime(str, default_format, out parsed_time, null); | |
} | |
/// <summary> | |
/// Tries to find date and/or time within the passed string and return it as ParsedDateTime object. | |
/// If only date was found, time in the returned ParsedDateTime is always 0:0:0. | |
/// If only time was found, date in the returned ParsedDateTime is DefaultDate. | |
/// </summary> | |
/// <param name="str">string that contains date-time</param> | |
/// <param name="default_format">format to be used preferably in ambivalent instances</param> | |
/// <param name="parsed_date_time">parsed date-time output</param> | |
/// <returns>true if date or time was found, else false</returns> | |
static public bool TryParseDateOrTime(this string str, DateTimeFormat default_format, out ParsedDateTime parsed_date_time) | |
{ | |
parsed_date_time = null; | |
ParsedDateTime parsed_date; | |
ParsedDateTime parsed_time; | |
if (!TryParseDate(str, default_format, out parsed_date)) | |
{ | |
if (!TryParseTime(str, default_format, out parsed_time, null)) | |
return false; | |
DateTime date_time = new DateTime(DefaultDate.Year, DefaultDate.Month, DefaultDate.Day, parsed_time.DateTime.Hour, parsed_time.DateTime.Minute, parsed_time.DateTime.Second); | |
parsed_date_time = new ParsedDateTime(-1, -1, parsed_time.IndexOfTime, parsed_time.LengthOfTime, date_time, parsed_time.UtcOffset); | |
} | |
else | |
{ | |
if (!TryParseTime(str, default_format, out parsed_time, parsed_date)) | |
{ | |
DateTime date_time = new DateTime(parsed_date.DateTime.Year, parsed_date.DateTime.Month, parsed_date.DateTime.Day, 0, 0, 0); | |
parsed_date_time = new ParsedDateTime(parsed_date.IndexOfDate, parsed_date.LengthOfDate, -1, -1, date_time); | |
} | |
else | |
{ | |
DateTime date_time = new DateTime(parsed_date.DateTime.Year, parsed_date.DateTime.Month, parsed_date.DateTime.Day, parsed_time.DateTime.Hour, parsed_time.DateTime.Minute, parsed_time.DateTime.Second); | |
parsed_date_time = new ParsedDateTime(parsed_date.IndexOfDate, parsed_date.LengthOfDate, parsed_time.IndexOfTime, parsed_time.LengthOfTime, date_time, parsed_time.UtcOffset); | |
} | |
} | |
return true; | |
} | |
#endregion | |
#region parsing base methods | |
/// <summary> | |
/// Tries to find time within the passed string (relatively to the passed parsed_date if any) and return it as ParsedDateTime object. | |
/// It recognizes only time while ignoring date, so date in the returned ParsedDateTime is always 1/1/1 | |
/// </summary> | |
/// <param name="str">string that contains date</param> | |
/// <param name="default_format">format to be used preferably in ambivalent instances</param> | |
/// <param name="parsed_time">parsed date-time output</param> | |
/// <param name="parsed_date">ParsedDateTime object if the date was found within this string, else NULL</param> | |
/// <returns>true if time was found, else false</returns> | |
public static bool TryParseTime(this string str, DateTimeFormat default_format, out ParsedDateTime parsed_time, ParsedDateTime parsed_date) | |
{ | |
parsed_time = null; | |
string time_zone_r; | |
if (default_format == DateTimeFormat.USA_DATE) | |
time_zone_r = @"(?:\s*(?'time_zone'UTC|GMT|CST|EST))?"; | |
else | |
time_zone_r = @"(?:\s*(?'time_zone'UTC|GMT))?"; | |
Match m; | |
if (parsed_date != null && parsed_date.IndexOfDate > -1) | |
{//look around the found date | |
//look for <date> hh:mm:ss <UTC offset> | |
m = Regex.Match(str.Substring(parsed_date.IndexOfDate + parsed_date.LengthOfDate), @"(?<=^\s*,?\s+|^\s*at\s*|^\s*[T\-]\s*)(?'hour'\d{2})\s*:\s*(?'minute'\d{2})\s*:\s*(?'second'\d{2})\s+(?'offset_sign'[\+\-])(?'offset_hh'\d{2}):?(?'offset_mm'\d{2})(?=$|[^\d\w])", RegexOptions.Compiled); | |
if (!m.Success) | |
//look for <date> [h]h:mm[:ss] [PM/AM] [UTC/GMT] | |
m = Regex.Match(str.Substring(parsed_date.IndexOfDate + parsed_date.LengthOfDate), @"(?<=^\s*,?\s+|^\s*at\s*|^\s*[T\-]\s*)(?'hour'\d{1,2})\s*:\s*(?'minute'\d{2})\s*(?::\s*(?'second'\d{2}))?(?:\s*(?'ampm'AM|am|PM|pm))?" + time_zone_r + @"(?=$|[^\d\w])", RegexOptions.Compiled); | |
if (!m.Success) | |
//look for [h]h:mm:ss [PM/AM] [UTC/GMT] <date> | |
m = Regex.Match(str.Substring(0, parsed_date.IndexOfDate), @"(?<=^|[^\d])(?'hour'\d{1,2})\s*:\s*(?'minute'\d{2})\s*(?::\s*(?'second'\d{2}))?(?:\s*(?'ampm'AM|am|PM|pm))?" + time_zone_r + @"(?=$|[\s,]+)", RegexOptions.Compiled); | |
if (!m.Success) | |
//look for [h]h:mm:ss [PM/AM] [UTC/GMT] within <date> | |
m = Regex.Match(str.Substring(parsed_date.IndexOfDate, parsed_date.LengthOfDate), @"(?<=^|[^\d])(?'hour'\d{1,2})\s*:\s*(?'minute'\d{2})\s*(?::\s*(?'second'\d{2}))?(?:\s*(?'ampm'AM|am|PM|pm))?" + time_zone_r + @"(?=$|[\s,]+)", RegexOptions.Compiled); | |
} | |
else//look anywhere within string | |
{ | |
//look for hh:mm:ss <UTC offset> | |
m = Regex.Match(str, @"(?<=^|\s+|\s*T\s*)(?'hour'\d{2})\s*:\s*(?'minute'\d{2})\s*:\s*(?'second'\d{2})\s+(?'offset_sign'[\+\-])(?'offset_hh'\d{2}):?(?'offset_mm'\d{2})?(?=$|[^\d\w])", RegexOptions.Compiled); | |
if (!m.Success) | |
//look for [h]h:mm[:ss] [PM/AM] [UTC/GMT] | |
m = Regex.Match(str, @"(?<=^|\s+|\s*T\s*)(?'hour'\d{1,2})\s*:\s*(?'minute'\d{2})\s*(?::\s*(?'second'\d{2}))?(?:\s*(?'ampm'AM|am|PM|pm))?" + time_zone_r + @"(?=$|[^\d\w])", RegexOptions.Compiled); | |
} | |
if (!m.Success) | |
return false; | |
//try | |
//{ | |
int hour = int.Parse(m.Groups["hour"].Value); | |
if (hour < 0 || hour > 23) | |
return false; | |
int minute = int.Parse(m.Groups["minute"].Value); | |
if (minute < 0 || minute > 59) | |
return false; | |
int second = 0; | |
if (!string.IsNullOrEmpty(m.Groups["second"].Value)) | |
{ | |
second = int.Parse(m.Groups["second"].Value); | |
if (second < 0 || second > 59) | |
return false; | |
} | |
if (string.Compare(m.Groups["ampm"].Value, "PM", true) == 0 && hour < 12) | |
hour += 12; | |
else if (string.Compare(m.Groups["ampm"].Value, "AM", true) == 0 && hour == 12) | |
hour -= 12; | |
DateTime date_time = new DateTime(1, 1, 1, hour, minute, second); | |
if (m.Groups["offset_hh"].Success) | |
{ | |
int offset_hh = int.Parse(m.Groups["offset_hh"].Value); | |
int offset_mm = 0; | |
if (m.Groups["offset_mm"].Success) | |
offset_mm = int.Parse(m.Groups["offset_mm"].Value); | |
TimeSpan utc_offset = new TimeSpan(offset_hh, offset_mm, 0); | |
if (m.Groups["offset_sign"].Value == "-") | |
utc_offset = -utc_offset; | |
parsed_time = new ParsedDateTime(-1, -1, m.Index, m.Length, date_time, utc_offset); | |
return true; | |
} | |
if (m.Groups["time_zone"].Success) | |
{ | |
TimeSpan utc_offset; | |
switch (m.Groups["time_zone"].Value) | |
{ | |
case "UTC": | |
case "GMT": | |
utc_offset = new TimeSpan(0, 0, 0); | |
break; | |
case "CST": | |
utc_offset = new TimeSpan(-6, 0, 0); | |
break; | |
case "EST": | |
utc_offset = new TimeSpan(-5, 0, 0); | |
break; | |
default: | |
throw new Exception("Time zone: " + m.Groups["time_zone"].Value + " is not defined."); | |
} | |
parsed_time = new ParsedDateTime(-1, -1, m.Index, m.Length, date_time, utc_offset); | |
return true; | |
} | |
parsed_time = new ParsedDateTime(-1, -1, m.Index, m.Length, date_time); | |
//} | |
//catch(Exception e) | |
//{ | |
// return false; | |
//} | |
return true; | |
} | |
/// <summary> | |
/// Tries to find date within the passed string and return it as ParsedDateTime object. | |
/// It recognizes only date while ignoring time, so time in the returned ParsedDateTime is always 0:0:0. | |
/// If year of the date was not found then it accepts the current year. | |
/// </summary> | |
/// <param name="str">string that contains date</param> | |
/// <param name="default_format">format to be used preferably in ambivalent instances</param> | |
/// <param name="parsed_date">parsed date output</param> | |
/// <returns>true if date was found, else false</returns> | |
static public bool TryParseDate(this string str, DateTimeFormat default_format, out ParsedDateTime parsed_date) | |
{ | |
parsed_date = null; | |
if (string.IsNullOrEmpty(str)) | |
return false; | |
//look for dd/mm/yy | |
Match m = Regex.Match(str, @"(?<=^|[^\d])(?'day'\d{1,2})\s*(?'separator'[\\/\.])+\s*(?'month'\d{1,2})\s*\'separator'+\s*(?'year'\d{2}|\d{4})(?=$|[^\d])", RegexOptions.Compiled | RegexOptions.IgnoreCase); | |
if (m.Success) | |
{ | |
DateTime date; | |
if ((default_format ^ DateTimeFormat.USA_DATE) == DateTimeFormat.USA_DATE) | |
{ | |
if (!convert_to_date(int.Parse(m.Groups["year"].Value), int.Parse(m.Groups["day"].Value), int.Parse(m.Groups["month"].Value), out date)) | |
return false; | |
} | |
else | |
{ | |
if (!convert_to_date(int.Parse(m.Groups["year"].Value), int.Parse(m.Groups["month"].Value), int.Parse(m.Groups["day"].Value), out date)) | |
return false; | |
} | |
parsed_date = new ParsedDateTime(m.Index, m.Length, -1, -1, date); | |
return true; | |
} | |
//look for [yy]yy-mm-dd | |
m = Regex.Match(str, @"(?<=^|[^\d])(?'year'\d{2}|\d{4})\s*(?'separator'[\-])\s*(?'month'\d{1,2})\s*\'separator'+\s*(?'day'\d{1,2})(?=$|[^\d])", RegexOptions.Compiled | RegexOptions.IgnoreCase); | |
if (m.Success) | |
{ | |
DateTime date; | |
if (!convert_to_date(int.Parse(m.Groups["year"].Value), int.Parse(m.Groups["month"].Value), int.Parse(m.Groups["day"].Value), out date)) | |
return false; | |
parsed_date = new ParsedDateTime(m.Index, m.Length, -1, -1, date); | |
return true; | |
} | |
//look for month dd yyyy | |
m = Regex.Match(str, @"(?:^|[^\d\w])(?'month'Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[uarychilestmbro]*\s+(?'day'\d{1,2})(?:-?st|-?th|-?rd|-?nd)?\s*,?\s*(?'year'\d{4})(?=$|[^\d\w])", RegexOptions.Compiled | RegexOptions.IgnoreCase); | |
if (!m.Success) | |
//look for dd month [yy]yy | |
m = Regex.Match(str, @"(?:^|[^\d\w:])(?'day'\d{1,2})(?:-?st\s+|-?th\s+|-?rd\s+|-?nd\s+|-|\s+)(?'month'Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[uarychilestmbro]*(?:\s*,?\s*|-)'?(?'year'\d{2}|\d{4})(?=$|[^\d\w])", RegexOptions.Compiled | RegexOptions.IgnoreCase); | |
if (!m.Success) | |
//look for yyyy month dd | |
m = Regex.Match(str, @"(?:^|[^\d\w])(?'year'\d{4})\s+(?'month'Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[uarychilestmbro]*\s+(?'day'\d{1,2})(?:-?st|-?th|-?rd|-?nd)?(?=$|[^\d\w])", RegexOptions.Compiled | RegexOptions.IgnoreCase); | |
if (!m.Success) | |
//look for month dd hh:mm:ss MDT|UTC yyyy | |
m = Regex.Match(str, @"(?:^|[^\d\w])(?'month'Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[uarychilestmbro]*\s+(?'day'\d{1,2})\s+\d{2}\:\d{2}\:\d{2}\s+(?:MDT|UTC)\s+(?'year'\d{4})(?=$|[^\d\w])", RegexOptions.Compiled | RegexOptions.IgnoreCase); | |
if (!m.Success) | |
//look for month dd [yyyy] | |
m = Regex.Match(str, @"(?:^|[^\d\w])(?'month'Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[uarychilestmbro]*\s+(?'day'\d{1,2})(?:-?st|-?th|-?rd|-?nd)?(?:\s*,?\s*(?'year'\d{4}))?(?=$|[^\d\w])", RegexOptions.Compiled | RegexOptions.IgnoreCase); | |
if (m.Success) | |
{ | |
int month = -1; | |
int index_of_date = m.Index; | |
int length_of_date = m.Length; | |
switch (m.Groups["month"].Value) | |
{ | |
case "Jan": | |
case "JAN": | |
month = 1; | |
break; | |
case "Feb": | |
case "FEB": | |
month = 2; | |
break; | |
case "Mar": | |
case "MAR": | |
month = 3; | |
break; | |
case "Apr": | |
case "APR": | |
month = 4; | |
break; | |
case "May": | |
case "MAY": | |
month = 5; | |
break; | |
case "Jun": | |
case "JUN": | |
month = 6; | |
break; | |
case "Jul": | |
month = 7; | |
break; | |
case "Aug": | |
case "AUG": | |
month = 8; | |
break; | |
case "Sep": | |
case "SEP": | |
month = 9; | |
break; | |
case "Oct": | |
case "OCT": | |
month = 10; | |
break; | |
case "Nov": | |
case "NOV": | |
month = 11; | |
break; | |
case "Dec": | |
case "DEC": | |
month = 12; | |
break; | |
} | |
int year; | |
if (!string.IsNullOrEmpty(m.Groups["year"].Value)) | |
year = int.Parse(m.Groups["year"].Value); | |
else | |
year = DefaultDate.Year; | |
DateTime date; | |
if (!convert_to_date(year, month, int.Parse(m.Groups["day"].Value), out date)) | |
return false; | |
parsed_date = new ParsedDateTime(index_of_date, length_of_date, -1, -1, date); | |
return true; | |
} | |
return false; | |
} | |
static bool convert_to_date(int year, int month, int day, out DateTime date) | |
{ | |
if (year >= 100) | |
{ | |
if (year < 1000) | |
{ | |
date = new DateTime(1, 1, 1); | |
return false; | |
} | |
} | |
else | |
if (year > 30) | |
year += 1900; | |
else | |
year += 2000; | |
try | |
{ | |
date = new DateTime(year, month, day); | |
} | |
catch | |
{ | |
date = new DateTime(1, 1, 1); | |
return false; | |
} | |
return true; | |
} | |
#endregion | |
} | |
#endregion |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment