using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
namespace AcDream.Core.Journal;
/// The outcome of reading a journal file.
/// The pages read, oldest first.
///
/// Retail's own message when the file is unusable, or null on success. An
/// ABSENT file is success with no pages, not an error — a character who has
/// never written a page has no file.
///
public readonly record struct JournalReadResult(
IReadOnlyList Pages,
string? Error);
///
/// Retail's journal file — gmJournalUI::LoadPages @0x00496AC0 and
/// SavePages @0x00497270 .
///
///
///
/// A plain tagged text file, one tag per line, <NEWP> opening each
/// page. Retail writes it with fopen mode w+ — the whole file is
/// rewritten, never appended.
///
///
/// A file that does not OPEN with a page marker is refused outright, with its
/// own message. That is retail being strict about a file the player could have
/// hand-edited, and it is ported rather than softened: silently accepting a
/// malformed file would scatter the first page's text into no page at all.
///
///
public static class JournalFile
{
private const string NewPage = "";
private const string PageNumber = "";
private const string Label = "";
private const string Title = "";
private const string Notes = "";
private const string Days = "";
private const string Hours = "";
private const string Minutes = "";
private const string LocationX = "";
private const string LocationY = "";
private const string RunningTime = "";
/// Retail's own load failure, byte-decoded from the paired binary.
public const string MalformedFileMessage =
"Problem loading journal: Your journal file does not create a new page!";
///
/// The per-character file name — retail's "%s%s-%s-%s.txt" with the
/// literal prefix "Journal" both call sites pass.
///
public static string FileNameFor(string serverName, string characterName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(serverName);
ArgumentException.ThrowIfNullOrWhiteSpace(characterName);
return $"Journal-{Sanitize(serverName)}-{Sanitize(characterName)}.txt";
}
///
/// Strips anything a file name cannot carry. Retail had no need for this —
/// its server and character names never contained a path separator — but a
/// name is outside data here and must not be able to redirect a write.
///
/// The set is FIXED, not Path.GetInvalidFileNameChars() : that
/// API is platform-dependent (on Linux it is only '/' and NUL), so a
/// backslash survived sanitisation there and the same character produced
/// two different file names on the two CI platforms (linux-portable,
/// 2026-08-23). Windows' set is the strictest of the supported platforms;
/// applying it everywhere keeps one name mapping to one file name.
///
private static readonly char[] InvalidFileNameChars =
['"', '<', '>', '|', ':', '*', '?', '\\', '/'];
private static string Sanitize(string value)
{
var text = new StringBuilder(value.Length);
foreach (char c in value)
{
bool invalid = c < ' '
|| Array.IndexOf(InvalidFileNameChars, c) >= 0;
text.Append(invalid ? '_' : c);
}
return text.ToString();
}
public static JournalReadResult Read(string text)
{
ArgumentNullException.ThrowIfNull(text);
var pages = new List();
JournalPage? current = null;
bool sawAnyContent = false;
foreach (string rawLine in text.Split('\n'))
{
string line = rawLine.TrimEnd('\r');
if (line.Length == 0)
continue;
if (!line.StartsWith('<'))
{
// Retail's reader is tag-driven; a line with no tag belongs to
// nothing and is skipped rather than guessed at.
sawAnyContent = true;
continue;
}
string tag = line.Length >= 6 ? line[..6] : line;
string value = line.Length > 7 ? line[7..] : string.Empty;
if (tag == NewPage)
{
if (current is not null)
pages.Add(current);
current = JournalPage.Empty;
sawAnyContent = true;
continue;
}
if (current is null)
{
// Content before the first page marker: the file does not
// create a new page.
return new JournalReadResult([], MalformedFileMessage);
}
sawAnyContent = true;
current = tag switch
{
Label => current with { Label = value },
Title => current with { Title = value },
Notes => current with { Notes = value },
Days => current with { TimerDays = ParseInt(value) },
Hours => current with { TimerHours = ParseInt(value) },
Minutes => current with { TimerMinutes = ParseInt(value) },
LocationX => current with
{
LocationX = ParseFloat(value),
HasLocation = true,
},
LocationY => current with
{
LocationY = ParseFloat(value),
HasLocation = true,
},
RunningTime => current with { RunningTimerSeconds = ParseFloat(value) },
// is written but carries no information the reader
// needs: page order IS file order. Anything unrecognised is
// skipped for the same reason a newer client's tag should not
// make an older one refuse the file.
_ => current,
};
}
if (current is not null)
pages.Add(current);
// An empty file is a character who has never written a page.
if (pages.Count == 0 && sawAnyContent)
return new JournalReadResult([], MalformedFileMessage);
for (int i = 0; i < pages.Count; i++)
pages[i] = pages[i].Clipped();
return new JournalReadResult(pages, null);
}
///
/// Writes the pages in retail's tag order. Every page is written whole,
/// including empty fields, because that is what retail's own writer does
/// and a reader keyed on tag presence must not have to infer absence.
///
public static string Write(IReadOnlyList pages)
{
ArgumentNullException.ThrowIfNull(pages);
var text = new StringBuilder();
for (int i = 0; i < pages.Count; i++)
{
JournalPage page = pages[i];
text.Append(NewPage).Append('\n');
Append(text, PageNumber, (i + 1).ToString(CultureInfo.InvariantCulture));
Append(text, Label, page.Label);
Append(text, Title, page.Title);
Append(text, Notes, page.Notes);
Append(text, Days, page.TimerDays.ToString(CultureInfo.InvariantCulture));
Append(text, Hours, page.TimerHours.ToString(CultureInfo.InvariantCulture));
Append(text, Minutes, page.TimerMinutes.ToString(CultureInfo.InvariantCulture));
if (page.HasLocation)
{
Append(text, LocationX, Format(page.LocationX));
Append(text, LocationY, Format(page.LocationY));
}
Append(text, RunningTime, Format(page.RunningTimerSeconds));
}
return text.ToString();
static void Append(StringBuilder text, string tag, string value)
{
// A newline inside a value would read back as a separate line and
// silently truncate the field. Retail's single-line edit boxes
// cannot produce one; the multi-line notes box can, so they are
// folded to spaces rather than corrupting the file.
text.Append(tag).Append(' ')
.Append(value.Replace('\n', ' ').Replace('\r', ' '))
.Append('\n');
}
}
private static string Format(double value) =>
value.ToString("0.######", CultureInfo.InvariantCulture);
private static int ParseInt(string value) =>
int.TryParse(value.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int result)
? result
: 0;
private static float ParseFloat(string value) =>
float.TryParse(value.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out float result)
? result
: 0f;
}