| | | 1 | | using System.Text.Json; |
| | | 2 | | |
| | | 3 | | namespace Punch.CLI; |
| | | 4 | | |
| | | 5 | | internal static class PunchStorage |
| | | 6 | | { |
| | 580 | 7 | | internal static string? DataDirectoryOverride { get; set; } |
| | | 8 | | |
| | | 9 | | public static string GetDataDirectory() |
| | 177 | 10 | | { |
| | 177 | 11 | | if (!string.IsNullOrEmpty(DataDirectoryOverride)) |
| | 177 | 12 | | return DataDirectoryOverride; |
| | 0 | 13 | | return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".punch", "data"); |
| | 177 | 14 | | } |
| | | 15 | | |
| | | 16 | | public static string GetFilePath(DateOnly date) |
| | 52 | 17 | | { |
| | 52 | 18 | | return Path.Combine(GetDataDirectory(), $"{date:yyyy-MM-dd}.json"); |
| | 52 | 19 | | } |
| | | 20 | | |
| | | 21 | | public static string GetDisplayPath(DateOnly date) |
| | 5 | 22 | | { |
| | 5 | 23 | | var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); |
| | 5 | 24 | | var full = GetFilePath(date); |
| | 5 | 25 | | return full.StartsWith(home) ? "~" + full[home.Length..] : full; |
| | 5 | 26 | | } |
| | | 27 | | |
| | | 28 | | // The manually-maintained tickets list sits alongside the data dir, e.g. |
| | | 29 | | // ~/.punch/tickets.txt (parent of ~/.punch/data). |
| | | 30 | | public static string GetTicketsFilePath() |
| | 94 | 31 | | { |
| | 94 | 32 | | var dataDir = GetDataDirectory(); |
| | 94 | 33 | | var baseDir = Directory.GetParent(dataDir)?.FullName ?? dataDir; |
| | 94 | 34 | | return Path.Combine(baseDir, "tickets.txt"); |
| | 94 | 35 | | } |
| | | 36 | | |
| | | 37 | | // Loads the tickets list: one ticket per line, tab- or comma-delimited into |
| | | 38 | | // "ticket<sep>title". Blank lines and '#' comments are skipped, as are rows |
| | | 39 | | // with an empty ticket. Returns an empty list if the file is missing. |
| | | 40 | | public static List<TicketEntry> LoadTickets() |
| | 15 | 41 | | { |
| | 15 | 42 | | var path = GetTicketsFilePath(); |
| | 15 | 43 | | if (!File.Exists(path)) |
| | 3 | 44 | | return new List<TicketEntry>(); |
| | | 45 | | |
| | 12 | 46 | | var result = new List<TicketEntry>(); |
| | 86 | 47 | | foreach (var raw in File.ReadAllLines(path)) |
| | 25 | 48 | | { |
| | 25 | 49 | | var line = raw.Trim(); |
| | 25 | 50 | | if (line.Length == 0 || line.StartsWith('#')) |
| | 4 | 51 | | continue; |
| | | 52 | | |
| | 21 | 53 | | var parts = line.Split(new[] { '\t', ',' }, 2); |
| | 21 | 54 | | var ticket = parts[0].Trim(); |
| | 21 | 55 | | if (ticket.Length == 0) |
| | 1 | 56 | | continue; |
| | | 57 | | |
| | 20 | 58 | | var title = parts.Length > 1 ? parts[1].Trim() : ""; |
| | 20 | 59 | | result.Add(new TicketEntry(ticket, title)); |
| | 20 | 60 | | } |
| | 12 | 61 | | return result; |
| | 15 | 62 | | } |
| | | 63 | | |
| | | 64 | | // The settings file sits alongside the data dir, e.g. ~/.punch/settings.json |
| | | 65 | | // (parent of ~/.punch/data). |
| | | 66 | | public static string GetSettingsFilePath() |
| | 17 | 67 | | { |
| | 17 | 68 | | var dataDir = GetDataDirectory(); |
| | 17 | 69 | | var baseDir = Directory.GetParent(dataDir)?.FullName ?? dataDir; |
| | 17 | 70 | | return Path.Combine(baseDir, "settings.json"); |
| | 17 | 71 | | } |
| | | 72 | | |
| | | 73 | | // Loads user settings from settings.json. Returns defaults if the file is |
| | | 74 | | // missing or cannot be parsed. TargetHours is clamped to a minimum of 1 to |
| | | 75 | | // avoid a divide-by-zero when computing the workday percentage. |
| | | 76 | | public static PunchSettings LoadSettings() |
| | 8 | 77 | | { |
| | 8 | 78 | | var path = GetSettingsFilePath(); |
| | 8 | 79 | | if (!File.Exists(path)) |
| | 1 | 80 | | return new PunchSettings(); |
| | | 81 | | |
| | | 82 | | try |
| | 7 | 83 | | { |
| | 7 | 84 | | var json = File.ReadAllText(path); |
| | 7 | 85 | | var settings = JsonSerializer.Deserialize<PunchSettings>(json, |
| | 7 | 86 | | new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new PunchSettings(); |
| | 6 | 87 | | if (settings.TargetHours < 1) |
| | 1 | 88 | | settings.TargetHours = 1; |
| | 6 | 89 | | return settings; |
| | | 90 | | } |
| | 1 | 91 | | catch (JsonException) |
| | 1 | 92 | | { |
| | 1 | 93 | | return new PunchSettings(); |
| | | 94 | | } |
| | 8 | 95 | | } |
| | | 96 | | |
| | | 97 | | public static List<TimeBlock> Load(DateOnly date) |
| | 20 | 98 | | { |
| | 20 | 99 | | var path = GetFilePath(date); |
| | 20 | 100 | | if (!File.Exists(path)) |
| | 2 | 101 | | return new List<TimeBlock>(); |
| | | 102 | | |
| | | 103 | | try |
| | 18 | 104 | | { |
| | 18 | 105 | | var json = File.ReadAllText(path); |
| | 18 | 106 | | var data = JsonSerializer.Deserialize<PunchData>(json); |
| | 14 | 107 | | if (data?.Blocks == null) |
| | 2 | 108 | | return new List<TimeBlock>(); |
| | | 109 | | |
| | 12 | 110 | | var result = new List<TimeBlock>(); |
| | 12 | 111 | | var occupied = new bool[96]; |
| | 70 | 112 | | foreach (var dto in data.Blocks) |
| | 17 | 113 | | { |
| | 17 | 114 | | if (dto.StartSlot < 0 || dto.StartSlot >= 96 || dto.Length < 1 || dto.StartSlot + dto.Length > 96) |
| | 4 | 115 | | continue; |
| | | 116 | | |
| | 13 | 117 | | var overlaps = false; |
| | 130 | 118 | | for (var s = dto.StartSlot; s < dto.StartSlot + dto.Length; s++) |
| | 53 | 119 | | { |
| | 56 | 120 | | if (occupied[s]) { overlaps = true; break; } |
| | 52 | 121 | | } |
| | 14 | 122 | | if (overlaps) continue; |
| | | 123 | | |
| | 128 | 124 | | for (var s = dto.StartSlot; s < dto.StartSlot + dto.Length; s++) |
| | 52 | 125 | | occupied[s] = true; |
| | | 126 | | |
| | 12 | 127 | | result.Add(new TimeBlock(dto.StartSlot, dto.Length, dto.Label, dto.Ticket)); |
| | 12 | 128 | | } |
| | 12 | 129 | | return result; |
| | | 130 | | } |
| | 4 | 131 | | catch (JsonException ex) |
| | 4 | 132 | | { |
| | 4 | 133 | | var backupPath = path + ".bak"; |
| | | 134 | | try |
| | 4 | 135 | | { |
| | 4 | 136 | | File.Copy(path, backupPath, overwrite: true); |
| | 4 | 137 | | } |
| | 0 | 138 | | catch (Exception backupEx) |
| | 0 | 139 | | { |
| | 0 | 140 | | throw new InvalidOperationException($"The daily log file is corrupted and could not be backed up ({backu |
| | | 141 | | } |
| | 4 | 142 | | throw new InvalidOperationException($"The daily log file is corrupted. A backup has been created at '{backup |
| | | 143 | | } |
| | 16 | 144 | | } |
| | | 145 | | |
| | | 146 | | public static void Save(DateOnly date, IReadOnlyList<TimeBlock> blocks) |
| | 14 | 147 | | { |
| | 14 | 148 | | var dir = GetDataDirectory(); |
| | 14 | 149 | | Directory.CreateDirectory(dir); |
| | | 150 | | |
| | 14 | 151 | | var data = new PunchData |
| | 14 | 152 | | { |
| | 15 | 153 | | Blocks = blocks.Select(b => new TimeBlockDto |
| | 15 | 154 | | { |
| | 15 | 155 | | StartSlot = b.StartSlot, |
| | 15 | 156 | | Length = b.Length, |
| | 15 | 157 | | Label = b.Label, |
| | 15 | 158 | | Ticket = b.Ticket |
| | 15 | 159 | | }).ToList() |
| | 14 | 160 | | }; |
| | | 161 | | |
| | 14 | 162 | | var json = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }); |
| | 14 | 163 | | var path = GetFilePath(date); |
| | 14 | 164 | | var tmpPath = path + ".tmp"; |
| | 14 | 165 | | File.WriteAllText(tmpPath, json); |
| | 14 | 166 | | File.Move(tmpPath, path, overwrite: true); |
| | 14 | 167 | | } |
| | | 168 | | } |