{
  "id": "P004",
  "slug": "contacts",
  "key": "P004-contacts",
  "title": "Contacts",
  "summary": "Add, find, edit and delete contacts, kept in name order by binary search; find by part of a name or part of a number, with every phone number checked - from a text menu.",
  "entry": "main.eml",
  "ui": "terminal",
  "readme": "# P004 - Contacts\n\nAdds contacts with a name and a phone number, finds them by part of a name or\npart of a number, edits and deletes them. The book is always in name order,\nignoring case. A text menu; the contacts live while the program runs.\n\n- `main.eml` - the menu loop and the checks on what is typed\n- `book.eml` - the contact book: a binary search finds where a contact\n  belongs, so a new or renamed contact goes straight into its place and the\n  list never needs sorting; the same search finds a name that is already there\n- `text.eml` - trimming, lower case, the digits of a number, and the phone check\n\nFinding: a fragment with a digit in it is looked for in the numbers, by\ndigits only, so `0912 345` finds 0912-345-678; any other fragment is looked\nfor in the names, ignoring case.\n\nWhat is checked: a name is not empty, has at most 30 characters and is not\nalready in the book in any case; a phone number has only digits, spaces and\ndashes, a + only at the start, and 7 to 15 digits. When editing, Enter keeps\nthe current name or number, and an edit that fails a check changes nothing.\n\nSessions: `sessions/basic.in` adds four contacts out of order, lists them,\nfinds by a name fragment, a spaced number fragment and a fragment two numbers\nshare, renames one (it moves to its new place), changes a number and deletes\none; `sessions/bad-input.in` types an unknown choice, an empty search, an\nempty and a too-long name, phone numbers with letters, too few and too many\ndigits and a + in the middle, a name that differs only in case, ids that are\nnot there, and an edit with a bad number.\n\nBuilt on the verified corpus cases `contact-book` (contacts kept and looked\nup by name) and `binary-search` (halving the part of a sorted list the answer\ncan still be in).\n",
  "modules": [
    {
      "name": "main.eml",
      "eml": "# P004 contacts: add contacts, find them by part of a name or part of a\n# number, edit and delete them; the book is always shown in name order. The\n# contacts live while the program runs; the first twenty projects do not\n# save to files.\nimport book\nimport text\n\ndef show(title, contacts):\n    \"\" ^0\n    (\"-- \" + title + \": \" + str(len(contacts)) + \" --\") ^0\n    if len(contacts) == 0:\n        \"  (nothing here)\" ^0\n    for c in contacts:\n        (\"  \" + (\"%-5s\" % (\"#\" + str(c[0]))) + (\"%-22s\" % c[1]) + c[2]) ^0\n\ndef ask_id(contacts):\n    # The contact whose id is typed, or None once the problem has been shown.\n    text.trim(input(\"id> \")) => typed\n    None => c\n    if typed != \"\" and text.digits_of(typed) == typed:\n        book.find(contacts, int(typed)) => c\n    if c == None:\n        \"No contact with that id.\" ^0\n    return c\n\ndef name_problem(contacts, name, own_id):\n    # What is wrong with name for a contact, or \"\" if nothing is. own_id is\n    # the contact being edited (0 for a new one): it may keep its own name.\n    if name == \"\":\n        return \"A contact needs a name.\"\n    if len(name) > 30:\n        return \"A name has at most 30 characters.\"\n    book.named(contacts, name) => other\n    if other != None and other[0] != own_id:\n        return \"There is already a contact named \" + other[1] + \".\"\n    return \"\"\n\n[] => contacts\n1 => next_id\nTrue => running\nwhile running:\n    \"\" ^0\n    (\"== Contacts (\" + str(len(contacts)) + \") ==\") ^0\n    \"1) add  2) list  3) find  4) edit  5) delete  6) quit\" ^0\n    text.trim(input(\"choice> \")) => choice\n    if choice == \"1\":\n        text.trim(input(\"name> \")) => name\n        name_problem(contacts, name, 0) => problem\n        if problem != \"\":\n            problem ^0\n        else:\n            text.trim(input(\"phone> \")) => phone\n            text.phone_problem(phone) => problem\n            if problem != \"\":\n                problem ^0\n            else:\n                book.insert(contacts, [next_id, name, phone]) => contacts\n                (\"Added #\" + str(next_id) + \": \" + name + \".\") ^0\n                next_id + 1 => next_id\n    elif choice == \"2\":\n        show(\"A to Z\", contacts)\n    elif choice == \"3\":\n        text.trim(input(\"part of a name or number> \")) => fragment\n        if fragment == \"\":\n            \"Type part of a name or a number.\" ^0\n        else:\n            show(\"found\", book.search(contacts, fragment))\n    elif choice == \"4\":\n        ask_id(contacts) => c\n        if c != None:\n            text.trim(input(\"name (Enter keeps \" + c[1] + \")> \")) => name\n            if name == \"\":\n                c[1] => name\n            name_problem(contacts, name, c[0]) => problem\n            if problem != \"\":\n                problem ^0\n            else:\n                text.trim(input(\"phone (Enter keeps \" + c[2] + \")> \")) => phone\n                if phone == \"\":\n                    c[2] => phone\n                text.phone_problem(phone) => problem\n                if problem != \"\":\n                    problem ^0\n                else:\n                    # Out and back in, so a new name lands in its place.\n                    book.insert(book.remove(contacts, c[0]), [c[0], name, phone]) => contacts\n                    (\"Updated #\" + str(c[0]) + \".\") ^0\n    elif choice == \"5\":\n        ask_id(contacts) => c\n        if c != None:\n            book.remove(contacts, c[0]) => contacts\n            (\"Deleted #\" + str(c[0]) + \": \" + c[1] + \".\") ^0\n    elif choice == \"6\":\n        False => running\n    else:\n        \"Pick a number from 1 to 6.\" ^0\n\"Bye.\" ^0\n",
      "python": "import book\nimport text\n\ndef show(title, contacts):\n    print(\"\")\n    print(\"-- \" + title + \": \" + str(len(contacts)) + \" --\")\n    if len(contacts) == 0:\n        print(\"  (nothing here)\")\n    for c in contacts:\n        print(\"  \" + \"%-5s\" % (\"#\" + str(c[0])) + \"%-22s\" % c[1] + c[2])\n\ndef ask_id(contacts):\n    typed = text.trim(input(\"id> \"))\n    c = None\n    if typed != \"\" and text.digits_of(typed) == typed:\n        c = book.find(contacts, int(typed))\n    if c == None:\n        print(\"No contact with that id.\")\n    return c\n\ndef name_problem(contacts, name, own_id):\n    if name == \"\":\n        return \"A contact needs a name.\"\n    if len(name) > 30:\n        return \"A name has at most 30 characters.\"\n    other = book.named(contacts, name)\n    if other != None and other[0] != own_id:\n        return \"There is already a contact named \" + other[1] + \".\"\n    return \"\"\n\ncontacts = []\nnext_id = 1\nrunning = True\nwhile running:\n    print(\"\")\n    print(\"== Contacts (\" + str(len(contacts)) + \") ==\")\n    print(\"1) add  2) list  3) find  4) edit  5) delete  6) quit\")\n    choice = text.trim(input(\"choice> \"))\n    if choice == \"1\":\n        name = text.trim(input(\"name> \"))\n        problem = name_problem(contacts, name, 0)\n        if problem != \"\":\n            print(problem)\n        else:\n            phone = text.trim(input(\"phone> \"))\n            problem = text.phone_problem(phone)\n            if problem != \"\":\n                print(problem)\n            else:\n                contacts = book.insert(contacts, [next_id, name, phone])\n                print(\"Added #\" + str(next_id) + \": \" + name + \".\")\n                next_id = next_id + 1\n    elif choice == \"2\":\n        show(\"A to Z\", contacts)\n    elif choice == \"3\":\n        fragment = text.trim(input(\"part of a name or number> \"))\n        if fragment == \"\":\n            print(\"Type part of a name or a number.\")\n        else:\n            show(\"found\", book.search(contacts, fragment))\n    elif choice == \"4\":\n        c = ask_id(contacts)\n        if c != None:\n            name = text.trim(input(\"name (Enter keeps \" + c[1] + \")> \"))\n            if name == \"\":\n                name = c[1]\n            problem = name_problem(contacts, name, c[0])\n            if problem != \"\":\n                print(problem)\n            else:\n                phone = text.trim(input(\"phone (Enter keeps \" + c[2] + \")> \"))\n                if phone == \"\":\n                    phone = c[2]\n                problem = text.phone_problem(phone)\n                if problem != \"\":\n                    print(problem)\n                else:\n                    contacts = book.insert(book.remove(contacts, c[0]), [c[0], name, phone])\n                    print(\"Updated #\" + str(c[0]) + \".\")\n    elif choice == \"5\":\n        c = ask_id(contacts)\n        if c != None:\n            contacts = book.remove(contacts, c[0])\n            print(\"Deleted #\" + str(c[0]) + \": \" + c[1] + \".\")\n    elif choice == \"6\":\n        running = False\n    else:\n        print(\"Pick a number from 1 to 6.\")\nprint(\"Bye.\")\n"
    },
    {
      "name": "book.eml",
      "eml": "# P004 contacts - the contact book. A contact is a list [id, name, phone].\n# The book is kept in name order, ignoring case, and a new or renamed contact\n# goes in at the place a binary search finds, so the list never needs sorting.\n# Two contacts cannot share a name that differs only in case. Ids are never\n# reused.\nimport text\n\ndef position(book, name):\n    # Where a contact with this name belongs: the index of the first contact\n    # whose name, ignoring case, is not before it. Binary search: each step\n    # halves the part of the list the answer can still be in.\n    text.lower(name) => key\n    0 => lo\n    len(book) => hi\n    while lo < hi:\n        int((lo + hi) / 2) => mid\n        if text.lower(book[mid][1]) < key:\n            mid + 1 => lo\n        else:\n            mid => hi\n    return lo\n\ndef named(book, name):\n    # The contact with this name, ignoring case, or None.\n    position(book, name) => p\n    if p < len(book) and text.lower(book[p][1]) == text.lower(name):\n        return book[p]\n    return None\n\ndef insert(book, contact):\n    # A new book with the contact in its place.\n    position(book, contact[1]) => p\n    return book[0:p] + [contact] + book[p:len(book)]\n\ndef find(book, contact_id):\n    for c in book:\n        if c[0] == contact_id:\n            return c\n    return None\n\ndef remove(book, contact_id):\n    return [c for c in book if c[0] != contact_id]\n\ndef search(book, fragment):\n    # Contacts matching part of a name or part of a number. A fragment with a\n    # digit in it is looked for in the numbers, digits only - \"0912 345\"\n    # finds 0912-345-678 - and any other fragment in the names, ignoring case.\n    text.digits_of(fragment) => digits\n    [] => found\n    for c in book:\n        if digits != \"\":\n            if digits in text.digits_of(c[2]):\n                found + [c] => found\n        elif text.lower(fragment) in text.lower(c[1]):\n            found + [c] => found\n    return found\n",
      "python": "import text\n\ndef position(book, name):\n    key = text.lower(name)\n    lo = 0\n    hi = len(book)\n    while lo < hi:\n        mid = int((lo + hi) / 2)\n        if text.lower(book[mid][1]) < key:\n            lo = mid + 1\n        else:\n            hi = mid\n    return lo\n\ndef named(book, name):\n    p = position(book, name)\n    if p < len(book) and text.lower(book[p][1]) == text.lower(name):\n        return book[p]\n    return None\n\ndef insert(book, contact):\n    p = position(book, contact[1])\n    return book[0:p] + [contact] + book[p:len(book)]\n\ndef find(book, contact_id):\n    for c in book:\n        if c[0] == contact_id:\n            return c\n    return None\n\ndef remove(book, contact_id):\n    return [c for c in book if c[0] != contact_id]\n\ndef search(book, fragment):\n    digits = text.digits_of(fragment)\n    found = []\n    for c in book:\n        if digits != \"\":\n            if digits in text.digits_of(c[2]):\n                found = found + [c]\n        elif text.lower(fragment) in text.lower(c[1]):\n            found = found + [c]\n    return found\n"
    },
    {
      "name": "text.eml",
      "eml": "# P004 contacts - text helpers. The interpreter that checks every session\n# does not run string methods yet, so these are written out.\n\ndef trim(s):\n    # s without the spaces at either end.\n    0 => i\n    len(s) => j\n    while i < j and s[i] == \" \":\n        i + 1 => i\n    while j > i and s[j - 1] == \" \":\n        j - 1 => j\n    return s[i:j]\n\ndef lower(s):\n    # s with A-Z turned into a-z; everything else as it was.\n    \"ABCDEFGHIJKLMNOPQRSTUVWXYZ\" => upper_letters\n    \"abcdefghijklmnopqrstuvwxyz\" => lower_letters\n    \"\" => out\n    for c in s:\n        c => d\n        for i in [0:25]:\n            if upper_letters[i] == c:\n                lower_letters[i] => d\n        out + d => out\n    return out\n\ndef digits_of(s):\n    # Only the digits of s, in order.\n    \"\" => out\n    for c in s:\n        if c in \"0123456789\":\n            out + c => out\n    return out\n\ndef phone_problem(phone):\n    # What is wrong with phone as a phone number, or \"\" if nothing is: digits,\n    # spaces and dashes, a + only at the very start, and 7 to 15 digits.\n    if phone == \"\":\n        return \"Type a phone number.\"\n    0 => i\n    for c in phone:\n        if c == \"+\":\n            if i != 0:\n                return \"A + can only come first.\"\n        elif not (c in \"0123456789 -\"):\n            return \"A phone number has only digits, spaces and dashes (and a + first).\"\n        i + 1 => i\n    len(digits_of(phone)) => n\n    if n < 7 or n > 15:\n        return \"A phone number has 7 to 15 digits; this one has \" + str(n) + \".\"\n    return \"\"\n",
      "python": "def trim(s):\n    i = 0\n    j = len(s)\n    while i < j and s[i] == \" \":\n        i = i + 1\n    while j > i and s[j - 1] == \" \":\n        j = j - 1\n    return s[i:j]\n\ndef lower(s):\n    upper_letters = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ\"\n    lower_letters = \"abcdefghijklmnopqrstuvwxyz\"\n    out = \"\"\n    for c in s:\n        d = c\n        for i in range(0, 26):\n            if upper_letters[i] == c:\n                d = lower_letters[i]\n        out = out + d\n    return out\n\ndef digits_of(s):\n    out = \"\"\n    for c in s:\n        if c in \"0123456789\":\n            out = out + c\n    return out\n\ndef phone_problem(phone):\n    if phone == \"\":\n        return \"Type a phone number.\"\n    i = 0\n    for c in phone:\n        if c == \"+\":\n            if i != 0:\n                return \"A + can only come first.\"\n        elif not c in \"0123456789 -\":\n            return \"A phone number has only digits, spaces and dashes (and a + first).\"\n        i = i + 1\n    n = len(digits_of(phone))\n    if n < 7 or n > 15:\n        return \"A phone number has 7 to 15 digits; this one has \" + str(n) + \".\"\n    return \"\"\n"
    }
  ],
  "sessions": [
    {
      "name": "bad-input",
      "input": "9\n3\n\n1\n\n1\nThis Name Is Far Too Long For The Book\n1\nAnn Lee\n0912-abc\n1\nAnn Lee\n12-34\n1\nAnn Lee\n0912+345678\n1\nAnn Lee\n1234567890123456\n1\nAnn Lee\n0912 345 678\n1\nANN LEE\n3\nzz\n4\nx\n4\n7\n4\n1\n\n123\n5\n0\n2\n6\n",
      "screen": "\n== Contacts (0) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 9\nPick a number from 1 to 6.\n\n== Contacts (0) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 3\npart of a name or number> \nType part of a name or a number.\n\n== Contacts (0) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 1\nname> \nA contact needs a name.\n\n== Contacts (0) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 1\nname> This Name Is Far Too Long For The Book\nA name has at most 30 characters.\n\n== Contacts (0) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 1\nname> Ann Lee\nphone> 0912-abc\nA phone number has only digits, spaces and dashes (and a + first).\n\n== Contacts (0) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 1\nname> Ann Lee\nphone> 12-34\nA phone number has 7 to 15 digits; this one has 4.\n\n== Contacts (0) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 1\nname> Ann Lee\nphone> 0912+345678\nA + can only come first.\n\n== Contacts (0) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 1\nname> Ann Lee\nphone> 1234567890123456\nA phone number has 7 to 15 digits; this one has 16.\n\n== Contacts (0) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 1\nname> Ann Lee\nphone> 0912 345 678\nAdded #1: Ann Lee.\n\n== Contacts (1) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 1\nname> ANN LEE\nThere is already a contact named Ann Lee.\n\n== Contacts (1) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 3\npart of a name or number> zz\n\n-- found: 0 --\n  (nothing here)\n\n== Contacts (1) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 4\nid> x\nNo contact with that id.\n\n== Contacts (1) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 4\nid> 7\nNo contact with that id.\n\n== Contacts (1) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 4\nid> 1\nname (Enter keeps Ann Lee)> \nphone (Enter keeps 0912 345 678)> 123\nA phone number has 7 to 15 digits; this one has 3.\n\n== Contacts (1) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 5\nid> 0\nNo contact with that id.\n\n== Contacts (1) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 2\n\n-- A to Z: 1 --\n  #1   Ann Lee               0912 345 678\n\n== Contacts (1) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 6\nBye.\n",
      "interpreter": "equal"
    },
    {
      "name": "basic",
      "input": "1\nWen Chen\n0912-345-678\n1\nann lee\n+886 2 2345 6789\n1\nBob Marsh\n02-8765-4321\n1\nCarla Diaz\n0933 222 111\n2\n3\nan\n3\n0912 345\n3\n23\n4\n1\nAnna Wen\n\n4\n3\n\n02-8765-0000\n5\n4\n2\n6\n",
      "screen": "\n== Contacts (0) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 1\nname> Wen Chen\nphone> 0912-345-678\nAdded #1: Wen Chen.\n\n== Contacts (1) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 1\nname> ann lee\nphone> +886 2 2345 6789\nAdded #2: ann lee.\n\n== Contacts (2) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 1\nname> Bob Marsh\nphone> 02-8765-4321\nAdded #3: Bob Marsh.\n\n== Contacts (3) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 1\nname> Carla Diaz\nphone> 0933 222 111\nAdded #4: Carla Diaz.\n\n== Contacts (4) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 2\n\n-- A to Z: 4 --\n  #2   ann lee               +886 2 2345 6789\n  #3   Bob Marsh             02-8765-4321\n  #4   Carla Diaz            0933 222 111\n  #1   Wen Chen              0912-345-678\n\n== Contacts (4) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 3\npart of a name or number> an\n\n-- found: 1 --\n  #2   ann lee               +886 2 2345 6789\n\n== Contacts (4) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 3\npart of a name or number> 0912 345\n\n-- found: 1 --\n  #1   Wen Chen              0912-345-678\n\n== Contacts (4) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 3\npart of a name or number> 23\n\n-- found: 2 --\n  #2   ann lee               +886 2 2345 6789\n  #1   Wen Chen              0912-345-678\n\n== Contacts (4) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 4\nid> 1\nname (Enter keeps Wen Chen)> Anna Wen\nphone (Enter keeps 0912-345-678)> \nUpdated #1.\n\n== Contacts (4) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 4\nid> 3\nname (Enter keeps Bob Marsh)> \nphone (Enter keeps 02-8765-4321)> 02-8765-0000\nUpdated #3.\n\n== Contacts (4) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 5\nid> 4\nDeleted #4: Carla Diaz.\n\n== Contacts (3) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 2\n\n-- A to Z: 3 --\n  #2   ann lee               +886 2 2345 6789\n  #1   Anna Wen              0912-345-678\n  #3   Bob Marsh             02-8765-0000\n\n== Contacts (3) ==\n1) add  2) list  3) find  4) edit  5) delete  6) quit\nchoice> 6\nBye.\n",
      "interpreter": "equal"
    }
  ],
  "builtOn": [
    {
      "slug": "contact-book",
      "caseId": "017-contact-book",
      "title": "Contact book"
    },
    {
      "slug": "binary-search",
      "caseId": "050-binary-search",
      "title": "Binary search"
    }
  ],
  "updated": "2026-09-28"
}
