diff --git a/Org2Ics/Org2Ics.csproj b/Org2Ics/Org2Ics.csproj index 85b4959..2f4fc77 100644 --- a/Org2Ics/Org2Ics.csproj +++ b/Org2Ics/Org2Ics.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net8.0 enable enable diff --git a/Org2Ics/Program.cs b/Org2Ics/Program.cs index 3751555..a512a1d 100644 --- a/Org2Ics/Program.cs +++ b/Org2Ics/Program.cs @@ -1,2 +1,343 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!"); +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace Org2Ics; + +class Program +{ + static void Main(string[] args) + { + if (args.Length < 1) + { + Console.WriteLine("Usage: Org2Ics [output.ics|output.org]"); + Console.WriteLine("Converts between .org diary files and .ics calendar files."); + Console.WriteLine(" - .org to .ics: Converts org diary entries to calendar events"); + Console.WriteLine(" - .ics to .org: Converts calendar events to org diary format"); + return; + } + + string inputFile = args[0]; + string inputExt = Path.GetExtension(inputFile).ToLowerInvariant(); + + string outputFile; + if (args.Length > 1) + { + outputFile = args[1]; + } + else + { + // Auto-determine output format based on input + outputFile = inputExt == ".org" + ? Path.ChangeExtension(inputFile, ".ics") + : Path.ChangeExtension(inputFile, ".org"); + } + + try + { + var converter = new CalendarConverter(); + + if (inputExt == ".org") + { + converter.ConvertOrgToIcs(inputFile, outputFile); + } + else if (inputExt == ".ics") + { + converter.ConvertIcsToOrg(inputFile, outputFile); + } + else + { + throw new ArgumentException($"Unsupported file format: {inputExt}. Only .org and .ics files are supported."); + } + + Console.WriteLine($"Successfully converted {inputFile} to {outputFile}"); + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } + } +} + +public class CalendarConverter +{ + private readonly Regex _dateHeaderRegex = new(@"^\*{3}\s+(\d{4}-\d{2}-\d{2})\s*$", RegexOptions.Compiled); + + public void ConvertOrgToIcs(string inputFile, string outputFile) + { + if (!File.Exists(inputFile)) + throw new FileNotFoundException($"Input file not found: {inputFile}"); + + var entries = ParseOrgFile(inputFile); + GenerateIcsFile(entries, outputFile); + } + + public void ConvertIcsToOrg(string inputFile, string outputFile) + { + if (!File.Exists(inputFile)) + throw new FileNotFoundException($"Input file not found: {inputFile}"); + + var entries = ParseIcsFile(inputFile); + GenerateOrgFile(entries, outputFile); + } + + private List ParseOrgFile(string inputFile) + { + var entries = new List(); + var lines = File.ReadAllLines(inputFile); + + DiaryEntry? currentEntry = null; + var contentBuilder = new StringBuilder(); + + for (int i = 0; i < lines.Length; i++) + { + string line = lines[i]; + var match = _dateHeaderRegex.Match(line); + + if (match.Success) + { + // Save previous entry if exists + if (currentEntry != null) + { + currentEntry.Content = contentBuilder.ToString().Trim(); + entries.Add(currentEntry); + contentBuilder.Clear(); + } + + // Start new entry + if (DateTime.TryParseExact(match.Groups[1].Value, "yyyy-MM-dd", + CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime date)) + { + currentEntry = new DiaryEntry + { + Date = date, + Content = string.Empty + }; + } + } + else if (currentEntry != null) + { + // Check if this is another header that ends the current entry + if (line.StartsWith("*")) + { + // This is another header, save current entry and reset + currentEntry.Content = contentBuilder.ToString().Trim(); + entries.Add(currentEntry); + currentEntry = null; + contentBuilder.Clear(); + } + else + { + // Add content to current entry + contentBuilder.AppendLine(line); + } + } + } + + // Save the last entry if exists + if (currentEntry != null) + { + currentEntry.Content = contentBuilder.ToString().Trim(); + entries.Add(currentEntry); + } + + return entries; + } + + private void GenerateIcsFile(List entries, string outputFile) + { + var icsBuilder = new StringBuilder(); + + // ICS header + icsBuilder.AppendLine("BEGIN:VCALENDAR"); + icsBuilder.AppendLine("VERSION:2.0"); + icsBuilder.AppendLine("PRODID:-//Org2Ics//Org2Ics//EN"); + icsBuilder.AppendLine("CALSCALE:GREGORIAN"); + icsBuilder.AppendLine("METHOD:PUBLISH"); + + // Generate events + foreach (var entry in entries) + { + GenerateEvent(icsBuilder, entry); + } + + // ICS footer + icsBuilder.AppendLine("END:VCALENDAR"); + + File.WriteAllText(outputFile, icsBuilder.ToString(), Encoding.UTF8); + } + + private void GenerateEvent(StringBuilder icsBuilder, DiaryEntry entry) + { + string uid = $"chronolog-{entry.Date:yyyy-MM-dd}@org2ics"; + string dateStamp = DateTime.UtcNow.ToString("yyyyMMddTHHmmssZ"); + string eventDate = entry.Date.ToString("yyyyMMdd"); + + icsBuilder.AppendLine("BEGIN:VEVENT"); + icsBuilder.AppendLine($"UID:{uid}"); + icsBuilder.AppendLine($"DTSTAMP:{dateStamp}"); + icsBuilder.AppendLine($"DTSTART;VALUE=DATE:{eventDate}"); + icsBuilder.AppendLine($"DTEND;VALUE=DATE:{entry.Date.AddDays(1):yyyyMMdd}"); + icsBuilder.AppendLine("SUMMARY:Chronolog"); + + if (!string.IsNullOrWhiteSpace(entry.Content)) + { + // Escape special characters in description + string description = EscapeIcsText(entry.Content); + icsBuilder.AppendLine($"DESCRIPTION:{description}"); + } + + icsBuilder.AppendLine("END:VEVENT"); + } + + private string EscapeIcsText(string text) + { + if (string.IsNullOrEmpty(text)) + return string.Empty; + + // Replace line breaks and escape special characters according to RFC 5545 + return text + .Replace("\\", "\\\\") + .Replace(",", "\\,") + .Replace(";", "\\;") + .Replace("\r\n", "\\n") + .Replace("\n", "\\n") + .Replace("\r", "\\n"); + } + + private List ParseIcsFile(string inputFile) + { + var entries = new List(); + var lines = File.ReadAllLines(inputFile); + + bool inEvent = false; + DiaryEntry? currentEntry = null; + + for (int i = 0; i < lines.Length; i++) + { + string line = lines[i].Trim(); + + if (line == "BEGIN:VEVENT") + { + inEvent = true; + currentEntry = new DiaryEntry(); + } + else if (line == "END:VEVENT" && inEvent && currentEntry != null) + { + if (currentEntry.Date != default && + (string.IsNullOrEmpty(currentEntry.Content) || currentEntry.Content != "Unknown Event")) + { + entries.Add(currentEntry); + } + inEvent = false; + currentEntry = null; + } + else if (inEvent && currentEntry != null) + { + if (line.StartsWith("DTSTART;VALUE=DATE:")) + { + string dateStr = line.Substring("DTSTART;VALUE=DATE:".Length); + if (DateTime.TryParseExact(dateStr, "yyyyMMdd", + CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime date)) + { + currentEntry.Date = date; + } + } + else if (line.StartsWith("DTSTART:")) + { + // Handle datetime format YYYYMMDDTHHMMSSZ + string dateTimeStr = line.Substring("DTSTART:".Length); + if (dateTimeStr.Contains("T")) + { + string dateStr = dateTimeStr.Substring(0, 8); + if (DateTime.TryParseExact(dateStr, "yyyyMMdd", + CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime date)) + { + currentEntry.Date = date; + } + } + } + else if (line.StartsWith("DESCRIPTION:")) + { + string description = line.Substring("DESCRIPTION:".Length); + currentEntry.Content = UnescapeIcsText(description); + } + else if (line.StartsWith("SUMMARY:") && !line.Contains("Chronolog")) + { + // If summary is not "Chronolog", use it as content if no description exists + string summary = line.Substring("SUMMARY:".Length); + if (string.IsNullOrEmpty(currentEntry.Content)) + { + currentEntry.Content = UnescapeIcsText(summary); + } + } + } + } + + return entries.OrderBy(e => e.Date).ToList(); + } + + private string UnescapeIcsText(string text) + { + if (string.IsNullOrEmpty(text)) + return string.Empty; + + // Unescape special characters according to RFC 5545 + return text + .Replace("\\n", Environment.NewLine) + .Replace("\\;", ";") + .Replace("\\,", ",") + .Replace("\\\\", "\\"); + } + + private void GenerateOrgFile(List entries, string outputFile) + { + var orgBuilder = new StringBuilder(); + + // Org file header + orgBuilder.AppendLine("* Diary"); + orgBuilder.AppendLine(); + orgBuilder.AppendLine("Converted from calendar entries."); + orgBuilder.AppendLine(); + + // Group entries by year and month for better organization + var groupedEntries = entries + .GroupBy(e => new { e.Date.Year, e.Date.Month }) + .OrderBy(g => g.Key.Year) + .ThenBy(g => g.Key.Month); + + foreach (var monthGroup in groupedEntries) + { + string monthName = new DateTime(monthGroup.Key.Year, monthGroup.Key.Month, 1) + .ToString("MMMM yyyy", CultureInfo.InvariantCulture); + + orgBuilder.AppendLine($"** {monthName}"); + orgBuilder.AppendLine(); + + foreach (var entry in monthGroup.OrderBy(e => e.Date)) + { + orgBuilder.AppendLine($"*** {entry.Date:yyyy-MM-dd}"); + orgBuilder.AppendLine(); + + if (!string.IsNullOrWhiteSpace(entry.Content)) + { + orgBuilder.AppendLine(entry.Content); + } + else + { + orgBuilder.AppendLine("(No content)"); + } + + orgBuilder.AppendLine(); + } + } + + File.WriteAllText(outputFile, orgBuilder.ToString(), Encoding.UTF8); + } +} + +public class DiaryEntry +{ + public DateTime Date { get; set; } + public string Content { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b88e7e4 --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# Org2Ics - Bidirectional Org/iCalendar Converter + +A C# console application that provides bidirectional conversion between .org diary files and .ics (iCalendar) format. + +## Description + +This tool converts between .org files containing diary entries and iCalendar (.ics) files. It supports: +- **Org to ICS**: Convert diary entries to all-day calendar events named "Chronolog" +- **ICS to Org**: Convert calendar events back to org diary format with proper date headers + +## Features + +- **Bidirectional conversion** between .org and .ics formats +- **Auto-detection** of input file format based on file extension +- **Org to ICS conversion**: + - Parses .org files with level 3 headings (***) containing dates in YYYY-MM-DD format + - Extracts diary content for each date entry + - Creates all-day events with the title "Chronolog" + - Includes diary content as event descriptions +- **ICS to Org conversion**: + - Parses standard .ics calendar files + - Extracts event dates and descriptions + - Organizes entries by year and month + - Creates proper org-mode structure with level 3 date headers +- **Proper character escaping** for both formats +- **Round-trip compatibility** for seamless conversion between formats + +## Usage + +```bash +dotnet run [output.ics|output.org] +``` + +### Parameters + +- `input.org|input.ics`: Path to the input file (required) + - `.org` files will be converted to `.ics` format + - `.ics` files will be converted to `.org` format +- `output.ics|output.org`: Path to the output file (optional, auto-determined if not specified) + +### Examples + +```bash +# Convert diary.org to diary.ics +dotnet run diary.org + +# Convert calendar.ics to calendar.org +dotnet run calendar.ics + +# Specify custom output filename +dotnet run diary.org my-calendar.ics +dotnet run calendar.ics my-diary.org + +# Round-trip conversion +dotnet run diary.org calendar.ics +dotnet run calendar.ics diary-restored.org +``` + +## File Format Support + +### Org File Format (.org → .ics) + +The converter expects .org files with the following structure: + +```org +* My Diary + +** Month Section + +*** 2025-01-15 + +This is the diary content for January 15th, 2025. +It can span multiple lines and paragraphs. + +*** 2025-01-16 + +Another diary entry for the next day. + +** Another Section + +*** 2025-02-01 + +February entry content here. +``` + +**Requirements:** +- Level 3 headings (starting with `***`) must contain dates in `YYYY-MM-DD` format +- Everything after a date heading until the next heading becomes the diary content for that date +- Non-date headings are ignored + +### ICS File Format (.ics → .org) + +The converter can parse standard iCalendar files and extracts: +- Event dates (both all-day and timed events) +- Event descriptions or summaries as diary content +- Events are organized chronologically and grouped by month + +**Supported:** +- `DTSTART;VALUE=DATE:` (all-day events) +- `DTSTART:` (timed events - date portion used) +- `DESCRIPTION:` field content +- `SUMMARY:` field content (when no description available) +- Proper unescaping of iCalendar text formatting + +## Output Formats + +### Generated .ics Files (from .org) +- Standard iCalendar format (RFC 5545 compliant) +- All-day events spanning from the diary date to the next day +- Event title: "Chronolog" +- Event description: The diary content for that date +- Unique identifiers for each event + +### Generated .org Files (from .ics) +- Proper org-mode structure with hierarchical headings +- Level 1: "Diary" (root heading) +- Level 2: Month and year groupings (e.g., "January 2025") +- Level 3: Individual date entries (e.g., "*** 2025-01-15") +- Content: Event descriptions properly formatted for org-mode + +## Building + +```bash +dotnet build +``` + +## Requirements + +- .NET 8.0 or higher +- No additional dependencies required + +## License + +This project is provided as-is for educational and personal use. diff --git a/samples/sample-diary-converted.org b/samples/sample-diary-converted.org new file mode 100644 index 0000000..671d72f --- /dev/null +++ b/samples/sample-diary-converted.org @@ -0,0 +1,38 @@ +* Diary + +Converted from calendar entries. + +** January 2025 + +*** 2025-01-15 + +Today was a great day! I had a wonderful meeting with my colleagues and we discussed the new project plans. The weather was sunny and I went for a walk in the park. + +I also read a good book in the evening. + +*** 2025-01-16 + +Worked on the new features for our application. Made good progress on the user interface improvements. + +Had dinner with friends at the new restaurant downtown. + +** February 2025 + +*** 2025-02-01 + +Start of a new month! Set new goals for February: +- Complete the project documentation +- Exercise regularly +- Learn a new programming language + +*** 2025-02-14 + +Valentine's Day! Spent time with family and had a lovely dinner. + +*** 2025-02-28 + +Last day of February. Reflecting on the month's achievements: +- Successfully completed 3 major tasks +- Improved my coding skills +- Read 2 interesting books + diff --git a/samples/sample-diary.ics b/samples/sample-diary.ics new file mode 100644 index 0000000..5317765 --- /dev/null +++ b/samples/sample-diary.ics @@ -0,0 +1,46 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Org2Ics//Org2Ics//EN +CALSCALE:GREGORIAN +METHOD:PUBLISH +BEGIN:VEVENT +UID:chronolog-2025-01-15@org2ics +DTSTAMP:20250830T081602Z +DTSTART;VALUE=DATE:20250115 +DTEND;VALUE=DATE:20250116 +SUMMARY:Chronolog +DESCRIPTION:Today was a great day! I had a wonderful meeting with my colleagues and we discussed the new project plans. The weather was sunny and I went for a walk in the park.\n\nI also read a good book in the evening. +END:VEVENT +BEGIN:VEVENT +UID:chronolog-2025-01-16@org2ics +DTSTAMP:20250830T081602Z +DTSTART;VALUE=DATE:20250116 +DTEND;VALUE=DATE:20250117 +SUMMARY:Chronolog +DESCRIPTION:Worked on the new features for our application. Made good progress on the user interface improvements.\n\nHad dinner with friends at the new restaurant downtown. +END:VEVENT +BEGIN:VEVENT +UID:chronolog-2025-02-01@org2ics +DTSTAMP:20250830T081602Z +DTSTART;VALUE=DATE:20250201 +DTEND;VALUE=DATE:20250202 +SUMMARY:Chronolog +DESCRIPTION:Start of a new month! Set new goals for February:\n- Complete the project documentation\n- Exercise regularly\n- Learn a new programming language +END:VEVENT +BEGIN:VEVENT +UID:chronolog-2025-02-14@org2ics +DTSTAMP:20250830T081602Z +DTSTART;VALUE=DATE:20250214 +DTEND;VALUE=DATE:20250215 +SUMMARY:Chronolog +DESCRIPTION:Valentine's Day! Spent time with family and had a lovely dinner. +END:VEVENT +BEGIN:VEVENT +UID:chronolog-2025-02-28@org2ics +DTSTAMP:20250830T081602Z +DTSTART;VALUE=DATE:20250228 +DTEND;VALUE=DATE:20250301 +SUMMARY:Chronolog +DESCRIPTION:Last day of February. Reflecting on the month's achievements:\n- Successfully completed 3 major tasks\n- Improved my coding skills\n- Read 2 interesting books +END:VEVENT +END:VCALENDAR diff --git a/samples/sample-diary.org b/samples/sample-diary.org new file mode 100644 index 0000000..134ab75 --- /dev/null +++ b/samples/sample-diary.org @@ -0,0 +1,41 @@ +* My Diary + +This is my personal diary file. + +** January 2025 + +*** 2025-01-15 + +Today was a great day! I had a wonderful meeting with my colleagues and we discussed the new project plans. The weather was sunny and I went for a walk in the park. + +I also read a good book in the evening. + +*** 2025-01-16 + +Worked on the new features for our application. Made good progress on the user interface improvements. + +Had dinner with friends at the new restaurant downtown. + +** February 2025 + +*** 2025-02-01 + +Start of a new month! Set new goals for February: +- Complete the project documentation +- Exercise regularly +- Learn a new programming language + +*** 2025-02-14 + +Valentine's Day! Spent time with family and had a lovely dinner. + +* Some other section + +This is not a diary entry and should be ignored. + +*** 2025-02-28 + +Last day of February. Reflecting on the month's achievements: +- Successfully completed 3 major tasks +- Improved my coding skills +- Read 2 interesting books diff --git a/samples/test-calendar-converted.org b/samples/test-calendar-converted.org new file mode 100644 index 0000000..1170eea --- /dev/null +++ b/samples/test-calendar-converted.org @@ -0,0 +1,24 @@ +* Diary + +Converted from calendar entries. + +** September 2025 + +*** 2025-09-01 + +Spent the weekend relaxing at home. Watched some movies and caught up on reading. + +*** 2025-09-03 + +First day back after the long weekend. Had several meetings and started planning the new quarter. + +*** 2025-09-15 + +Company organized a team building event at the local park. Great fun with colleagues, lots of games and good food. + +** October 2025 + +*** 2025-10-01 + +Important client meeting to discuss project requirements and timeline. + diff --git a/samples/test-calendar-roundtrip.ics b/samples/test-calendar-roundtrip.ics new file mode 100644 index 0000000..099ff0f --- /dev/null +++ b/samples/test-calendar-roundtrip.ics @@ -0,0 +1,38 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Org2Ics//Org2Ics//EN +CALSCALE:GREGORIAN +METHOD:PUBLISH +BEGIN:VEVENT +UID:chronolog-2025-09-01@org2ics +DTSTAMP:20250830T082255Z +DTSTART;VALUE=DATE:20250901 +DTEND;VALUE=DATE:20250902 +SUMMARY:Chronolog +DESCRIPTION:Spent the weekend relaxing at home. Watched some movies and caught up on reading. +END:VEVENT +BEGIN:VEVENT +UID:chronolog-2025-09-03@org2ics +DTSTAMP:20250830T082255Z +DTSTART;VALUE=DATE:20250903 +DTEND;VALUE=DATE:20250904 +SUMMARY:Chronolog +DESCRIPTION:First day back after the long weekend. Had several meetings and started planning the new quarter. +END:VEVENT +BEGIN:VEVENT +UID:chronolog-2025-09-15@org2ics +DTSTAMP:20250830T082255Z +DTSTART;VALUE=DATE:20250915 +DTEND;VALUE=DATE:20250916 +SUMMARY:Chronolog +DESCRIPTION:Company organized a team building event at the local park. Great fun with colleagues\, lots of games and good food. +END:VEVENT +BEGIN:VEVENT +UID:chronolog-2025-10-01@org2ics +DTSTAMP:20250830T082255Z +DTSTART;VALUE=DATE:20251001 +DTEND;VALUE=DATE:20251002 +SUMMARY:Chronolog +DESCRIPTION:Important client meeting to discuss project requirements and timeline. +END:VEVENT +END:VCALENDAR diff --git a/samples/test-calendar.ics b/samples/test-calendar.ics new file mode 100644 index 0000000..80fa1ff --- /dev/null +++ b/samples/test-calendar.ics @@ -0,0 +1,38 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp//CalDAV Client//EN +CALSCALE:GREGORIAN +METHOD:PUBLISH +BEGIN:VEVENT +UID:event1@example.com +DTSTAMP:20250830T100000Z +DTSTART;VALUE=DATE:20250901 +DTEND;VALUE=DATE:20250902 +SUMMARY:Labor Day Weekend +DESCRIPTION:Spent the weekend relaxing at home. Watched some movies and caught up on reading. +END:VEVENT +BEGIN:VEVENT +UID:event2@example.com +DTSTAMP:20250830T100000Z +DTSTART;VALUE=DATE:20250903 +DTEND;VALUE=DATE:20250904 +SUMMARY:Back to Work +DESCRIPTION:First day back after the long weekend. Had several meetings and started planning the new quarter. +END:VEVENT +BEGIN:VEVENT +UID:event3@example.com +DTSTAMP:20250830T100000Z +DTSTART;VALUE=DATE:20250915 +DTEND;VALUE=DATE:20250916 +SUMMARY:Team Building Event +DESCRIPTION:Company organized a team building event at the local park. Great fun with colleagues\, lots of games and good food. +END:VEVENT +BEGIN:VEVENT +UID:event4@example.com +DTSTAMP:20250830T100000Z +DTSTART:20251001T140000Z +DTEND:20251001T160000Z +SUMMARY:Meeting with Client +DESCRIPTION:Important client meeting to discuss project requirements and timeline. +END:VEVENT +END:VCALENDAR