···1414module V1 : sig
1515 (** Version 1 of the contact schema (current stable version). *)
16161717+ (** ISO 8601 date handling. *)
1818+ module Date = Sortal_schema_date
1919+1720 (** Temporal validity support for time-bounded fields. *)
1821 module Temporal = Sortal_schema_temporal
1922···3033 When V2 is introduced, these will continue pointing to V1 for
3134 backward compatibility. *)
32353636+module Date = V1.Date
3337module Temporal = V1.Temporal
3438module Feed = V1.Feed
3539module Contact = V1.Contact
+44
lib/schema/sortal_schema_date.ml
···11+type t = Ptime.date
22+33+let parse s =
44+ (* Try parsing as RFC 3339 (which may include time) *)
55+ match Ptime.of_rfc3339 s with
66+ | Ok (ptime, _, _) -> Some (Ptime.to_date ptime)
77+ | Error _ ->
88+ (* Try parsing as just a date string YYYY-MM-DD *)
99+ match String.split_on_char '-' s with
1010+ | [year_s; month_s; day_s] -> (
1111+ try
1212+ let year = int_of_string year_s in
1313+ let month = int_of_string month_s in
1414+ let day = int_of_string day_s in
1515+ if month >= 1 && month <= 12 && day >= 1 && day <= 31 then
1616+ Some (year, month, day)
1717+ else None
1818+ with Failure _ -> None)
1919+ | _ -> None
2020+2121+let format (year, month, day) =
2222+ Printf.sprintf "%04d-%02d-%02d" year month day
2323+2424+let pp ppf t = Format.pp_print_string ppf (format t)
2525+2626+let to_string = format
2727+let of_string = parse
2828+2929+let today () =
3030+ let open Unix in
3131+ let tm = localtime (time ()) in
3232+ (tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday)
3333+3434+let jsont =
3535+ let kind = "ISO 8601 date" in
3636+ let doc = "An ISO 8601 date string (YYYY-MM-DD)" in
3737+ let dec s =
3838+ match parse s with
3939+ | Some t -> t
4040+ | None ->
4141+ Jsont.Error.msgf Jsont.Meta.none "%s: invalid ISO 8601 date: %S" kind s
4242+ in
4343+ let enc = format in
4444+ Jsont.map ~kind ~doc ~dec ~enc Jsont.string
+52
lib/schema/sortal_schema_date.mli
···11+(** ISO 8601 date handling for contact data.
22+33+ Provides parsing, formatting, and jsont combinators for ISO 8601 dates.
44+ Time components in parsed strings are ignored, only the date is extracted.
55+66+ @see <https://www.iso.org/iso-8601-date-and-time-format.html> ISO 8601 *)
77+88+type t = Ptime.date
99+(** [t] represents a date as (year, month, day) tuple. *)
1010+1111+val jsont : t Jsont.t
1212+(** [jsont] is a bidirectional JSON type for ISO 8601 dates.
1313+1414+ On decode: accepts JSON strings in ISO 8601 date format (e.g., "2024-11-03")
1515+ or RFC 3339 format with times (e.g., "2024-11-03T10:30:00Z"), extracting
1616+ only the date component.
1717+1818+ On encode: produces dates in YYYY-MM-DD format.
1919+2020+ {b Example:}
2121+ {[
2222+ let date = (2024, 11, 3) in
2323+ Jsont_bytesrw.encode_string Date.jsont date
2424+ (* Returns: "2024-11-03" *)
2525+ ]} *)
2626+2727+val parse : string -> t option
2828+(** [parse s] parses an ISO 8601 date string.
2929+3030+ Accepts various formats:
3131+ - "2024-11-03" (date only)
3232+ - "2024-11-03T10:30:00Z" (RFC 3339 with time, extracts date)
3333+ - "2024-11-03T10:30:00-08:00" (with timezone, extracts date)
3434+3535+ Returns [None] if the string is not a valid date format. *)
3636+3737+val format : t -> string
3838+(** [format t] formats a date as ISO 8601 (YYYY-MM-DD).
3939+4040+ {b Example output:} ["2024-11-03"] *)
4141+4242+val pp : Format.formatter -> t -> unit
4343+(** [pp ppf t] pretty prints a date in ISO 8601 format. *)
4444+4545+val to_string : t -> string
4646+(** [to_string t] is an alias for {!format}. *)
4747+4848+val of_string : string -> t option
4949+(** [of_string s] is an alias for {!parse}. *)
5050+5151+val today : unit -> t
5252+(** [today ()] returns today's date in the local timezone. *)
+42
test/test_schema.ml
···11+(** Schema-only tests (no I/O dependencies) *)
22+33+let test_temporal () =
44+ let r = Sortal_schema.Temporal.make ~from:"2020-01" ~until:"2023-12" () in
55+ assert (Sortal_schema.Temporal.valid_at (Some r) ~date:"2021-06");
66+ assert (not (Sortal_schema.Temporal.valid_at (Some r) ~date:"2024-01"));
77+ print_endline "✓ Temporal ranges work"
88+99+let test_feed_types () =
1010+ let feed = Sortal_schema.Feed.make ~feed_type:Atom ~url:"https://example.com/feed" () in
1111+ assert (Sortal_schema.Feed.url feed = "https://example.com/feed");
1212+ print_endline "✓ Feed types work"
1313+1414+let test_contact_construction () =
1515+ let c = Sortal_schema.Contact.make
1616+ ~handle:"test"
1717+ ~names:["Test User"]
1818+ ~emails:[Sortal_schema.Contact.email_of_string "test@example.com"]
1919+ () in
2020+ assert (Sortal_schema.Contact.handle c = "test");
2121+ assert (Sortal_schema.Contact.name c = "Test User");
2222+ print_endline "✓ Contact construction works"
2323+2424+let test_json_roundtrip () =
2525+ let c = Sortal_schema.Contact.make ~handle:"json" ~names:["JSON Test"] () in
2626+ match Jsont_bytesrw.encode_string Sortal_schema.Contact.json_t c with
2727+ | Ok json ->
2828+ (match Jsont_bytesrw.decode_string Sortal_schema.Contact.json_t json with
2929+ | Ok decoded ->
3030+ assert (Sortal_schema.Contact.handle decoded = "json");
3131+ assert (Sortal_schema.Contact.name decoded = "JSON Test");
3232+ print_endline "✓ JSON roundtrip works"
3333+ | Error e -> failwith ("Decode failed: " ^ e))
3434+ | Error e -> failwith ("Encode failed: " ^ e)
3535+3636+let () =
3737+ print_endline "\n=== Schema Tests ===\n";
3838+ test_temporal ();
3939+ test_feed_types ();
4040+ test_contact_construction ();
4141+ test_json_roundtrip ();
4242+ print_endline "\n=== All Schema Tests Passed ===\n"