Skip to content

Commit bd6a197

Browse files
Toilettraumaclaude
andcommitted
feat(model)!: date precision via ModelDate + optional dates, drop epoch sentinel
Every date field (release_time/update_time across manga/anime/image, plus AiredStatus and Comment) becomes std::optional<ModelDate>. ModelDate pairs the instant with a DatePrecision (Day/Month/Quarter/Year): a source that knows only a year (or an anime season) pins the instant to that bucket's first day and marks the precision, so a consumer renders "2025" / a season instead of a fabricated 1 January. An absent date is nullopt — the unknown_time epoch sentinel is removed. Also takes the doxygen build to zero warnings: qualifies @ref targets with the aniparse:: namespace (ModelDate, and the pre-existing url_encode / Parser::auth_info / AuthKeys / AuthenticationData / total_chapters / Tag::axis) and drops a stray colon. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f8e75e9 commit bd6a197

9 files changed

Lines changed: 79 additions & 54 deletions

File tree

include/aniparse/anime/AnimeModel.hpp

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
*
3333
* It parallels the manga model deliberately (episodes for chapters, playable sources
3434
* for pages) and follows the same two rules: an absent value (an empty string, a
35-
* nullopt, @ref aniparse::unknown_time) means the source did not state it in that
35+
* nullopt, @c nullopt) means the source did not state it in that
3636
* response, not that the anime lacks it; and identity is the opaque handle
3737
* (AnimeEpisodeInfo::ref()), not the episode number, which is for display.
3838
*
@@ -135,12 +135,12 @@ struct AnimeInfo {
135135
AiredStatus status;
136136

137137
/// When the anime was last touched on the source (a new episode, an edit).
138-
/// @ref aniparse::unknown_time = not stated. Orders items within one source only, since
138+
/// @c nullopt = not stated. Orders items within one source only, since
139139
/// sources differ on what counts as an update.
140-
std::chrono::system_clock::time_point update_time = unknown_time;
141-
/// When it first aired. @ref aniparse::unknown_time = the source states no exact date —
140+
std::optional<ModelDate> update_time;
141+
/// When it first aired. @c nullopt = the source states no exact date —
142142
/// common, since many sources give only @ref season and @ref year.
143-
std::chrono::system_clock::time_point release_time = unknown_time;
143+
std::optional<ModelDate> release_time;
144144

145145
/// Opaque change marker for the whole anime; @see MangaInfo::revision.
146146
std::string revision;
@@ -258,11 +258,11 @@ struct AnimeEpisodeInfo {
258258
/// descriptors, not the video — that comes from episode_sources(). @see Image
259259
std::vector<Image> previews;
260260
/// When the episode entry was last edited/re-uploaded on the source;
261-
/// @ref aniparse::unknown_time = not stated.
262-
std::chrono::system_clock::time_point update_time = unknown_time;
263-
/// When the episode aired or was posted; @ref aniparse::unknown_time = not stated. Sources
261+
/// @c nullopt = not stated.
262+
std::optional<ModelDate> update_time;
263+
/// When the episode aired or was posted; @c nullopt = not stated. Sources
264264
/// differ on which of the two they report, so it dates the entry, not the broadcast.
265-
std::chrono::system_clock::time_point release_time = unknown_time;
265+
std::optional<ModelDate> release_time;
266266

267267
/**
268268
* @brief Identity for the episode_sources() round-trip.

include/aniparse/images/ImageModel.hpp

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
* holding a single item and the other many. The items are not necessarily still
3434
* pictures, so each declares its own @ref aniparse::ImageItemKind. Absent values
3535
* follow the model-wide rule: an empty string, a nullopt or
36-
* @ref aniparse::unknown_time means the source did not state it.
36+
* @c nullopt means the source did not state it.
3737
*/
3838

3939
namespace aniparse {
@@ -128,12 +128,12 @@ struct ImageContainerInfo {
128128
/// View counters. nullopt = the source publishes none. @see ViewStats
129129
std::optional<ViewStats> views;
130130

131-
/// When the container was last changed on the source. @ref aniparse::unknown_time = not
131+
/// When the container was last changed on the source. @c nullopt = not
132132
/// stated. For change detection prefer @ref revision, which is defined for
133133
/// equality comparison; this field is for display.
134-
std::chrono::system_clock::time_point update_time = unknown_time;
135-
/// When the container was posted. @ref aniparse::unknown_time = the source does not state it.
136-
std::chrono::system_clock::time_point release_time = unknown_time;
134+
std::optional<ModelDate> update_time;
135+
/// When the container was posted. @c nullopt = the source does not state it.
136+
std::optional<ModelDate> release_time;
137137

138138
/// Opaque change marker for the whole container; @see MangaInfo::revision.
139139
std::string revision;

include/aniparse/manga/MangaModel.hpp

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
* field here; the getters do the fetching, these types only carry what came back.
3131
*
3232
* Two rules run through the file. An absent value (an empty string, a nullopt,
33-
* @ref aniparse::unknown_time) means the source did not state it in that response,
33+
* @c nullopt) means the source did not state it in that response,
3434
* never that the manga lacks it — the same struct is filled shallowly for a search
3535
* card and fully for a detail fetch. And identity is the opaque handle, not the
3636
* numbers: a chapter is addressed by MangaChapterInfo::ref(), not by its volume and
@@ -137,12 +137,12 @@ struct MangaInfo {
137137
AttributedText description;
138138

139139
/// When the manga was last touched on the source (a new chapter, an edit).
140-
/// @ref aniparse::unknown_time = the source does not state it. Sources differ on what
140+
/// @c nullopt = the source does not state it. Sources differ on what
141141
/// counts as an update, so this orders items within one source only.
142-
std::chrono::system_clock::time_point update_time = unknown_time;
143-
/// When the manga was first published. @ref aniparse::unknown_time = the source does not
142+
std::optional<ModelDate> update_time;
143+
/// When the manga was first published. @c nullopt = the source does not
144144
/// state it — common when only a year is known.
145-
std::chrono::system_clock::time_point release_time = unknown_time;
145+
std::optional<ModelDate> release_time;
146146
/// Publication state (ongoing / released / announced / source-specific). A
147147
/// default-constructed status (empty name) = the source states none, and reads as
148148
/// DefaultAiredStatuses::Other rather than as "released". @see AiredStatus
@@ -204,7 +204,7 @@ struct MangaInfo {
204204

205205
/// How many pages the work has, when the source states it up front — meaningful for
206206
/// one-shots and galleries (a booru pool, a doujin) whose whole length is one number.
207-
/// nullopt = the source does not report it. Distinct from @ref total_chapters: a work
207+
/// nullopt = the source does not report it. Distinct from @ref aniparse::MangaInfo::total_chapters — a work
208208
/// is a count of chapters OR, when it has none, a count of pages.
209209
std::optional<long> total_pages;
210210

@@ -286,12 +286,12 @@ struct MangaChapterInfo {
286286
/// none. Fetch descriptors, not the chapter's pages — those come from
287287
/// chapter_pages(). @see Image
288288
std::vector<Image> previews;
289-
/// When the chapter was last edited/re-uploaded on the source; @ref aniparse::unknown_time
289+
/// When the chapter was last edited/re-uploaded on the source; @c nullopt
290290
/// = not stated.
291-
std::chrono::system_clock::time_point update_time = unknown_time;
292-
/// When the chapter was published on the source; @ref aniparse::unknown_time = not stated.
291+
std::optional<ModelDate> update_time;
292+
/// When the chapter was published on the source; @c nullopt = not stated.
293293
/// This is the source's posting date, not the original serialization date.
294-
std::chrono::system_clock::time_point release_time = unknown_time;
294+
std::optional<ModelDate> release_time;
295295

296296
/**
297297
* @brief Identity for the chapter_pages() round-trip

include/aniparse/types/Authentication.hpp

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ void import_auth(ParserConfig& config, const AuthState& state);
9292

9393
/**
9494
* @brief How a source expects a user to sign in — the shape the client collects.
95-
* Declared by @ref Parser::auth_info so the client picks the right
96-
* @ref AuthenticationData variant and UI instead of guessing.
95+
* Declared by @ref aniparse::Parser::auth_info so the client picks the right
96+
* @ref aniparse::AuthenticationData variant and UI instead of guessing.
9797
*/
9898
enum class AuthMethod {
9999
/// A username + password fed to Parser::authenticate_context, which logs in and
@@ -115,7 +115,7 @@ enum class AuthMethod {
115115
* Describes a directly-injected credential — a token, an API key, a session
116116
* cookie — not the username/password fed to an interactive login (those drive a
117117
* handshake, they are not injected). @ref auth_keys_from_fields turns a list of
118-
* these into the @ref AuthKeys a session persists by, so a parser declares its
118+
* these into the @ref aniparse::AuthKeys a session persists by, so a parser declares its
119119
* credential shape once.
120120
*/
121121
struct AuthField {
@@ -172,7 +172,7 @@ struct AuthInfo {
172172
};
173173

174174
/**
175-
* @brief Derive the persistable @ref AuthKeys from a credential-field list by
175+
* @brief Derive the persistable @ref aniparse::AuthKeys from a credential-field list by
176176
* grouping each field's target under its channel. Lets a parser declare its
177177
* credential shape once (in AuthInfo::fields) and get auth_keys() for free.
178178
*/

include/aniparse/types/Model.hpp

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#include <span>
1313
#include <array>
1414
#include <chrono>
15+
#include <cstdint>
1516
#include <optional>
1617
#include <string>
1718
#include <string_view>
@@ -25,7 +26,7 @@
2526
* These types are shared deliberately — a tag means the same thing whether it hangs
2627
* off a manga, an anime or an image container, so a consumer learns them once. Two
2728
* conventions run through the file and are worth reading before the individual
28-
* fields: an absent value (an empty string, a nullopt, @ref aniparse::unknown_time)
29+
* fields: an absent value (an empty string, a @c nullopt @ref aniparse::ModelDate)
2930
* always means "the source did not state it", never "the item does not have it"; and
3031
* anything named @c ref is an opaque parser-owned handle, to be stored and passed
3132
* back, never parsed.
@@ -305,6 +306,38 @@ inline constexpr std::string_view aired_status_ongoing = "ongoing";
305306
/// Canonical @ref AiredStatus::name for @ref aniparse::DefaultAiredStatuses::Announced.
306307
inline constexpr std::string_view aired_status_announced = "announced";
307308

309+
/**
310+
* @brief How precisely a source dated something — the granularity of a @ref aniparse::ModelDate.
311+
*
312+
* A source may give a full calendar day, or only a coarser bucket: a month, a quarter
313+
* (an anime "season" — Winter/Spring/Summer/Fall map onto Q1..Q4), or a bare year. The
314+
* date's @ref aniparse::ModelDate::time is pinned to the FIRST instant of that bucket (a year →
315+
* Jan 1, Q3 → Jul 1), so ordering still works; this field says how much of it is real,
316+
* so a consumer renders "2025" / "Q1 2025" / "March 2025" / the full date instead of a
317+
* fabricated January 1.
318+
*/
319+
enum class DatePrecision : std::uint8_t {
320+
Day, ///< A full calendar day is known (the default). Render the whole date.
321+
Month, ///< Only the month is known — render e.g. "March 2025".
322+
Quarter, ///< Only the quarter / anime season — @ref aniparse::ModelDate::time is its first day.
323+
Year, ///< Only the year is known — render e.g. "2025".
324+
};
325+
326+
/**
327+
* @brief A moment a source attaches to an item, carrying how precisely it is known.
328+
*
329+
* Wrapped rather than a bare time_point so the precision cannot drift from the value.
330+
* A date field is @c std::optional<ModelDate>: @c nullopt means the source gave no date
331+
* at all — an absent date is absent, not a sentinel epoch — and a present value is
332+
* always a real moment plus its @ref aniparse::DatePrecision.
333+
*/
334+
struct ModelDate {
335+
/// The instant, pinned to the first moment of the precision bucket (@ref aniparse::DatePrecision).
336+
std::chrono::system_clock::time_point time;
337+
/// How much of @ref aniparse::ModelDate::time the source actually stated.
338+
DatePrecision precision = DatePrecision::Day;
339+
};
340+
308341
/**
309342
* @brief Where an item stands in its publication/airing life: released, ongoing,
310343
* announced, or something only that source names.
@@ -322,11 +355,11 @@ struct AiredStatus {
322355
/// status; that reads as @ref aniparse::DefaultAiredStatuses::Other, so an item whose
323356
/// status is unknown is not silently reported as released.
324357
std::string name;
325-
/// The moment the source attaches to that state, when it gives one. Equal to
326-
/// @ref aniparse::unknown_time (the default) when the source states only the state and no
327-
/// date — the common case, so treat a date here as a bonus and never as a
328-
/// reliable ordering key. Not to be confused with the item's own release_time.
329-
std::chrono::system_clock::time_point time;
358+
/// The moment the source attaches to that state, when it gives one. @c nullopt (the
359+
/// default) when the source states only the state and no date — the common case, so
360+
/// treat a date here as a bonus and never as a reliable ordering key. Not to be
361+
/// confused with the item's own release_time.
362+
std::optional<ModelDate> time;
330363

331364
/**
332365
* @brief Classify @ref name into the well-known set.
@@ -337,14 +370,6 @@ struct AiredStatus {
337370
DefaultAiredStatuses to_enum() const;
338371
};
339372

340-
/**
341-
* The value every time_point in the model carries when the source states no time:
342-
* the system_clock epoch, not a real timestamp. Every unset date field (an item's
343-
* release_time, a chapter's update_time, @ref aniparse::AiredStatus::time) compares equal to
344-
* this, so "unknown" is testable rather than merely early. A consumer must check for
345-
* it before formatting a date, or it will show 1970.
346-
*/
347-
inline constexpr std::chrono::system_clock::time_point unknown_time{std::chrono::system_clock::duration{0}};
348373

349374
/**
350375
* Structure representing rating (score) of the item (release, manga, etc.)
@@ -526,9 +551,9 @@ struct Comment {
526551
RelatedUser author;
527552
/// The body, with any links the source marked up preserved as attributes.
528553
AttributedText text;
529-
/// When it was posted; @ref aniparse::unknown_time when the source does not date it
554+
/// When it was posted; @c nullopt when the source does not date it
530555
/// (some report only a relative "2 days ago" the parser will not guess from).
531-
std::chrono::system_clock::time_point time = unknown_time;
556+
std::optional<ModelDate> time;
532557
/// Net score / likes, if the source exposes one. Source-defined and possibly
533558
/// negative where downvotes exist. nullopt = the source has no voting on
534559
/// comments, or does not report it — never "zero votes".

include/aniparse/types/Search.hpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ inline constexpr std::string_view episodes = pages;
292292
/// Filter by Tag. TextQuery for free-text sources; ItemSelection where the
293293
/// source enumerates its tags, keyed by the opaque token that equals Tag::ref
294294
/// so a tag from MangaInfo searches directly. @see ItemSelection
295-
/// The default @ref Tag::axis: a Tag with an empty axis filters through this key.
295+
/// The default @ref aniparse::Tag::axis a Tag with an empty axis filters through this key.
296296
inline constexpr std::string_view tag = "tag";
297297
/// Filter by genre — the coarse editorial category axis a source keeps SEPARATE
298298
/// from its finer tag axis (a source with one flat tag list uses @ref tag alone
@@ -322,7 +322,7 @@ inline constexpr std::string_view age_restriction = "age_res";
322322
// for search and autocomplete. A source uses whichever apply. @see suggest
323323
//
324324
// These keys — together with @ref tag and @ref genre above — are the vocabulary of
325-
// @ref Tag::axis. A Tag carries the key of the axis it belongs to; the pair
325+
// @ref aniparse::Tag::axis. A Tag carries the key of the axis it belongs to; the pair
326326
// (Tag::axis, Tag::ref) then names one option in the supported_filters group keyed by
327327
// that axis, so a tag read off an item routes straight back into the right filter and
328328
// a consumer may section its tag display by axis. An empty Tag::axis means @ref tag.

include/aniparse/utility/Time.hpp

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ namespace aniparse {
3030
* - a zone: 'Z', "+03:00", "+0300", or none, in which case UTC is assumed.
3131
*
3232
* @param text The timestamp, with no surrounding whitespace.
33-
* @return The instant, or nullopt if @p text is not a timestamp. Note that nullopt is
34-
* NOT @ref aniparse::unknown_time — a caller that models "unknown" as the epoch maps it
35-
* over itself, so that a source sending "1970-01-01" is not mistaken for a
36-
* source sending nothing.
33+
* @return The instant, or nullopt if @p text is not a timestamp. Parsing assigns no
34+
* "unknown" meaning to any value: "1970-01-01" parses to a real epoch instant, so
35+
* a caller must distinguish it from an absent date (a @c nullopt @ref aniparse::ModelDate),
36+
* never from the timestamp itself.
3737
*/
3838
[[nodiscard]] std::optional<std::chrono::system_clock::time_point> parse_iso8601(
3939
std::string_view text) noexcept;

include/aniparse/utility/UrlEncode.hpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ inline std::string url_encode(std::string_view value) {
4646
}
4747

4848
/**
49-
* @brief Percent-decode a string per RFC 3986 — the inverse of @ref url_encode.
49+
* @brief Percent-decode a string per RFC 3986 — the inverse of @ref aniparse::url_encode.
5050
* Each "%XX" (two hex digits) becomes the byte it names; a malformed or truncated
5151
* escape is left verbatim. '+' is passed through unchanged: it is a literal here,
5252
* NOT a space — this decodes a path or opaque segment, not an

tests/TimeTest.cpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,9 @@ TEST_CASE("parse_iso8601 rejects what is not a timestamp") {
8080
}
8181

8282
TEST_CASE("parse_iso8601 does not confuse absent with the epoch") {
83-
// unknown_time is the epoch, so a source that genuinely sends 1970 must not be read
84-
// as a source that sent nothing. That distinction is the caller's to make, which it
85-
// can only do if the parser hands back an empty optional rather than a zero.
83+
// A source that genuinely sends 1970 must not be read as a source that sent nothing.
84+
// That distinction is the caller's to make, which it can only do if the parser hands
85+
// back an empty optional rather than a zero (an absent date is a nullopt ModelDate).
8686
const auto epoch = parse_iso8601("1970-01-01T00:00:00Z");
8787
REQUIRE(epoch.has_value());
8888
CHECK(*epoch == system_clock::time_point{});

0 commit comments

Comments
 (0)