Families.AutoWriter 1.0.0.0
Families 콘텐츠를 반복 작업 없이 작성·등록하도록 돕는 자동화 작성 도구입니다.
로딩중...
검색중...
일치하는것 없음
PostWriterService.cs
이 파일의 문서화 페이지로 가기
1using System.IO;
2using System.Text.RegularExpressions;
3using System.Text.Json;
5
6
8
17public sealed class PostWriterService
18{
19 // JsonStringEnumConverter 미사용 → MediaPosition을 숫자(0/1)로 저장
20 // Families.Web이 숫자 형식을 기본으로 읽기 때문
29 private static readonly JsonSerializerOptions _opts = new()
30 {
31 WriteIndented = true,
32 PropertyNameCaseInsensitive = true,
33 };
34
43 public string AppDataRoot { get; set; } = "";
44
69 public IReadOnlyList<AlbumInfo> GetAlbums(string slug)
70 {
71 var dir = Path.Combine(AppDataRoot, "Family", slug, "albums");
72 if (!Directory.Exists(dir)) return [];
73 var list = new List<AlbumInfo>();
74 foreach (var f in Directory.GetFiles(dir, "*.json"))
75 {
76 try
77 {
78 var json = File.ReadAllText(f);
79 var a = JsonSerializer.Deserialize<AlbumInfo>(json, _opts);
80 if (a != null) list.Add(a);
81 }
82 catch { }
83 }
84 return list.OrderBy(a => a.SortOrder).ThenBy(a => a.Name).ToList();
85 }
86
103 public IReadOnlyList<string> GetSlugs()
104 {
105 var root = Path.Combine(AppDataRoot, "Family");
106 if (!Directory.Exists(root)) return [];
107 return Directory.GetDirectories(root)
108 .Select(Path.GetFileName)
109 .Where(s => s != null)
110 .Cast<string>()
111 .ToList();
112 }
113
170 public async Task<bool> SavePostAsync(string slug, PostEntry post, IEnumerable<string> localPhotoPaths)
171 {
172 if (string.IsNullOrWhiteSpace(AppDataRoot))
173 throw new InvalidOperationException("App_Data 경로가 비어 있습니다.");
174 if (string.IsNullOrWhiteSpace(slug))
175 throw new InvalidOperationException("가족 슬러그가 비어 있습니다.");
176 if (string.IsNullOrWhiteSpace(post.Id))
177 post.Id = Guid.NewGuid().ToString("N");
178
179 var tenantDir = Path.Combine(AppDataRoot, "Family", slug);
180 var postsDir = Path.Combine(tenantDir, "posts");
181 var mediaDir = Path.Combine(tenantDir, "media", post.Id);
182
183 Directory.CreateDirectory(postsDir);
184 Directory.CreateDirectory(mediaDir);
185
186 // 로컬 사진 복사
187 foreach (var src in localPhotoPaths)
188 {
189 if (!File.Exists(src)) continue;
190 var fn = Path.GetFileName(src);
191 var dst = Path.Combine(mediaDir, fn);
192 File.Copy(src, dst, overwrite: true);
193 if (!post.PhotoFileNames.Contains(fn))
194 post.PhotoFileNames.Add(fn);
195 }
196
197 var path = Path.Combine(postsDir, $"{Sanitize(post.Id)}.json");
198 var tmp = Path.Combine(postsDir, $"{Sanitize(post.Id)}.{Guid.NewGuid():N}.tmp");
199 try
200 {
201 await using (var fs = File.Create(tmp))
202 await JsonSerializer.SerializeAsync(fs, post, _opts);
203 File.Move(tmp, path, overwrite: true);
204 }
205 catch (Exception ex)
206 {
207 throw new IOException($"JSON 저장 실패: {path} ({ex.Message})", ex);
208 }
209 finally
210 {
211 try
212 {
213 if (File.Exists(tmp)) File.Delete(tmp);
214 }
215 catch { }
216 }
217 return true;
218 }
219
220 // 기존 포스트 타이틀 목록 — 중복 방지용
245 public IReadOnlyList<string> GetExistingTitles(string slug)
246 {
247 var dir = Path.Combine(AppDataRoot, "Family", slug, "posts");
248 if (!Directory.Exists(dir)) return [];
249 var titles = new List<string>();
250 foreach (var f in Directory.GetFiles(dir, "*.json"))
251 {
252 try
253 {
254 using var doc = JsonDocument.Parse(File.ReadAllText(f));
255 if (TryGetPropertyIgnoreCase(doc.RootElement, "title", out var t))
256 {
257 var v = t.GetString();
258 if (!string.IsNullOrWhiteSpace(v)) titles.Add(v);
259 }
260 }
261 catch { }
262 }
263 return titles;
264 }
265
290 public (DateTime? LastPostedAt, int LastNumber) GetCookingTimelineState(string slug)
291 {
292 var dir = Path.Combine(AppDataRoot, "Family", slug, "posts");
293 if (!Directory.Exists(dir)) return (null, 0);
294
295 DateTime? lastPostedAt = null;
296 var lastNumber = 0;
297
298 foreach (var f in Directory.GetFiles(dir, "*.json"))
299 {
300 try
301 {
302 var post = JsonSerializer.Deserialize<PostEntry>(File.ReadAllText(f), _opts);
303 if (post is null || !IsCookingPost(post.Title)) continue;
304
305 if (lastPostedAt is null || post.PostedAt > lastPostedAt.Value)
306 lastPostedAt = post.PostedAt;
307
308 lastNumber = Math.Max(lastNumber, ExtractCookingNumber(post.Title));
309 }
310 catch { }
311 }
312
313 return (lastPostedAt, lastNumber);
314 }
315
340 private static bool IsCookingPost(string title) =>
341 title.Contains("1983", StringComparison.OrdinalIgnoreCase) &&
342 title.Contains("집밥육아", StringComparison.OrdinalIgnoreCase);
343
368 private static int ExtractCookingNumber(string title)
369 {
370 var match = Regex.Match(title, @"#(?<n>\d+)");
371 return match.Success && int.TryParse(match.Groups["n"].Value, out var n) ? n : 0;
372 }
373
414 private static bool TryGetPropertyIgnoreCase(JsonElement element, string name, out JsonElement value)
415 {
416 foreach (var prop in element.EnumerateObject())
417 {
418 if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
419 {
420 value = prop.Value;
421 return true;
422 }
423 }
424
425 value = default;
426 return false;
427 }
428
453 private static string Sanitize(string id) =>
454 string.Concat(id.Split(Path.GetInvalidFileNameChars()));
455}
IReadOnlyList< AlbumInfo > GetAlbums(string slug)
async Task< bool > SavePostAsync(string slug, PostEntry post, IEnumerable< string > localPhotoPaths)
static bool TryGetPropertyIgnoreCase(JsonElement element, string name, out JsonElement value)
static readonly JsonSerializerOptions _opts
DateTime? int LastNumber GetCookingTimelineState(string slug)
IReadOnlyList< string > GetExistingTitles(string slug)