Sign in

    moon-vcard

    Offline vCard 3.0/4.0 contact text parser and writer

    vcard
    contacts
    address-book
    parser
    Download zip
    Author
    Version
    0.3.0
    License
    MIT
    Last updated
    18 days ago
    Downloads
    14

    #Moon vCard

    MoonBit library for offline reading and writing of common vCard 3.0 and 4.0 contact text. It handles multiple cards in one input, folded lines, text escapes, quoted-printable values, FN, N, TEL, EMAIL, and the attribute parameters written on those lines (TYPE, PREF, and others). Parsing returns accepted contacts alongside diagnostic codes. No network or platform contact permissions are required.

    #Install and run

    Install the MoonBit toolchain and run these commands from this directory:

    moon check --target all moon build --target wasm moon test --target wasm moon run --target wasm examples/demo

    examples/demo parses one vCard 3.0 card whose name is quoted-printable encoded and one vCard 4.0 card, then prints every field with its parameters, with PREF order applied:

    Contacts: 2 Diagnostics: 0 Name: 张三 (v3.0) TEL +8613900000001 [TYPE=home] [PREF=1] TEL +8613900000002 [TYPE=cell] [PREF=2] EMAIL zhang@example.org [TYPE=work] Name: Bob (v4.0)

    The package is published on mooncakes.io as haol-05/moon-vcard. From another module, add it and import it under the same name:

    moon add haol-05/moon-vcard

    #Use

    let card = "BEGIN:VCARD\nVERSION:4.0\nFN:Alice Smith\nN:Smith;Alice;;;\nTEL;TYPE=cell;PREF=1:+8613800000000\nEND:VCARD"
    let result = parse(card)
    for contact in result.contacts {
    println(contact.full_name)
    for phone in contact.phones {
    println(phone.value)
    for param in phone.params {
    println(param.name + "=" + param.values.join(","))
    }
    }
    let normalized = format(contact)
    }

    parse accepts LF or CRLF input and returns diagnostics such as missing-fn:4, invalid-version:4, invalid-quoted-printable:3, unsupported-charset:3, or unclosed-card. format returns a CRLF-terminated vCard when the version and full name are valid, and writes the parameters back on the same line. Review diagnostics before importing contacts into an address book.

    Each TEL and EMAIL line becomes a Field:

    TypeFieldsMeaning
    Fieldvalue, paramsone property value together with the parameters on its line
    Paramname, valuesone parameter; values is empty for a bare parameter such as ;PREF, and holds one entry per comma-separated value otherwise

    Use sort_by_preference when a card carries several numbers or addresses and the caller wants the preferred one first. It orders numeric PREF values ascending, puts fields without a usable PREF last, keeps the original order within each group, and leaves the parsed array untouched:

    let ordered = sort_by_preference(result.contacts[0].phones)
    println(ordered[0].value)

    #Packages

    The root package keeps exactly three public functions (parse, format, sort_by_preference) and the Contact, Field, Param model. Everything around it lives in sibling packages, so a caller that only needs the core stays small:

    PackageWhat it adds
    haol-05/moon-vcardthe parser, the writer and preference ordering
    .../paramsread, add, drop, merge, sort and render parameters of a field
    .../normalizecomparison keys for phones, emails and names, plus masking and grouping helpers
    .../querya small filter expression language (has:email,type=cell,-name~test)
    .../toolsstatistics, duplicate detection, merging, dedupe, search, sorting and diffing
    .../csvRFC 4180 reading and writing, with contact rows
    .../reportmarkdown, plain-text and summary renderers
    .../jsonJSON encoders and decoders for the model, built on the toolchain's JSON package

    Because the model types are pub(all), a sibling package can build derived contacts (a merged duplicate, for example) instead of only reading them. The root package remains the only parser.

    #API change in 0.2.0

    Contact.phones and Contact.emails are Array[Field] instead of Array[String]: read contact.phones[0].value where 0.1.x code read contact.phones[0]. Parameters such as TYPE and PREF are preserved and written back instead of being dropped.

    #Scope

    This is a deliberately small text-field core, not full RFC 6350 coverage. It keeps the parameters of TEL and EMAIL lines, including quoted values such as TYPE="work,voice", and writes them back. Values marked ENCODING=QUOTED-PRINTABLE are decoded, soft line breaks included, and the consumed ENCODING parameter is dropped so that the written line never claims an encoding the value no longer has; format never re-encodes quoted-printable. Only UTF-8 and ASCII are decoded: a CHARSET naming anything else reports unsupported-charset and its bytes are decoded as UTF-8 with replacement characters rather than silently guessed. Base64 payloads, property groups such as item1.TEL, and the parameters of FN or N are not supported, and unknown properties are ignored. Serialization writes the supported fields only. Test data contains fictional contacts only.

    The implementation refers to the public RFC 6350 and vCard 3.0 RFC 2426 format descriptions; it does not copy parser code. MIT license.

    A GitHub repository search for moonbit vcard, vcard moonbit and moonbit contacts returned no matching repository on 2026-09-25. A mooncakes.io search for vcard on the same date returned one package, angela/vcf@0.0.5 ("Parse vCard strings", Apache-2.0, last published 2026-01-12). Reading that package's source showed it parses a single vCard, accepts only VERSION:3.0, and rejects an input holding more than one card; it does not unfold folded lines or decode text escapes, it keeps println debug output inside parse and validate, its tests are the toolchain template's fib/sum rather than vCard cases, and its manifests still use the older moon.mod.json/moon.pkg.json form. Moon vCard differs by accepting multiple cards, both 3.0 and 4.0, folded lines and text escapes, non-fatal diagnostics, and CRLF serialization, with no third-party dependency. Both searches are limited and are not proof of uniqueness.

    #Environment note

    Verified on 2026-09-26 with moon 0.1.20260920 and moonc v0.10.14 on Windows, in an installation kept outside the user profile so the machine's older toolchain stays untouched: moon check --deny-warn --target all, moon build and moon test --deny-warn on wasm, wasm-gc and js (237 tests each, of which the root package keeps 16), the demo through moon run, and moon fmt / moon info leaving the working tree unchanged. The native target does not build on that host because the toolchain's own runtime source <moon-home>/lib/runtime/env.c calls rand_s without a declaration; a three-line test package fails identically, so this is a toolchain issue on that machine, not a defect in this library.

    GitHub Actions runs the same steps on ubuntu-latest with the latest released toolchain, including the native target, and passes. One difference between toolchain releases is worth knowing:

    • moon fmt output differs: the older 0.1.20260807 release strips the trailing comma of an untagged struct literal while 0.1.20260920 keeps it, so this repository stores the form produced by the newer release that CI installs, and formatting with the older release rewrites a few lines.
    • vcard.mbt declares the trait methods that derive(Eq, Debug) promotes with explicit pub extend blocks, which is what 0.1.20260920 asks for, so moon check --deny-warn --target all is clean without implicit_impl_as_method warnings.

    See CHANGELOG.md for the release history.

    Contact

    pub(all) struct Contact {
    version : String
    full_name : String
    family_name : String
    given_name : String
    phones : Array[Field]
    emails : Array[Field]
    } derive(Eq,
    Debug
    )

    Contact::equal

    fn Contact::equal(Contact, Contact) -> Bool

    Contact::not_equal

    fn Contact::not_equal(x : Contact, y : Contact) -> Bool

    Contact::to_repr

    Field

    pub(all) struct Field {
    value : String
    params : Array[Param]
    } derive(Eq,
    Debug
    )

    A property value together with the parameters written on its own line.

    Field::equal

    fn Field::equal(Field, Field) -> Bool

    Field::not_equal

    fn Field::not_equal(x : Field, y : Field) -> Bool

    Field::to_repr

    Param

    pub(all) struct Param {
    name : String
    values : Array[String]
    } derive(Eq,
    Debug
    )

    A single attribute parameter, for example TYPE=cell or a bare PREF.

    Param::equal

    fn Param::equal(Param, Param) -> Bool

    Param::not_equal

    fn Param::not_equal(x : Param, y : Param) -> Bool

    Param::to_repr

    ParseResult

    pub(all) struct ParseResult {
    contacts : Array[Contact]
    diagnostics : Array[String]
    } derive(Eq,
    Debug
    )

    ParseResult::equal

    fn ParseResult::equal(ParseResult, ParseResult) -> Bool

    ParseResult::not_equal

    fn ParseResult::not_equal(x : ParseResult, y : ParseResult) -> Bool

    format

    fn format(contact : Contact) -> String?

    parse

    fn parse(input : String) -> ParseResult

    sort_by_preference

    fn sort_by_preference(fields : Array[Field]) -> Array[Field]

    Orders multi-value fields by PREF: numeric preferences ascending, then every field whose PREF is missing or not a number. Fields that share a rank keep their original order, and the input array is left untouched.

    Source Files