Add check for the prefix of an ISBN 13

# Static Module functions to work with library "standard numbers" ISSN, ISBN, and LCCN
module StdNum

  # Helper methods common to ISBN/ISSN
  module Helpers

    # The pattern we use to try and find an ISBN/ISSN. Ditch everthing before the first
    # digit, then take all the digits/hyphens, optionally followed by an 'X'
    # Since the shortest possible string is 7 digits followed by a checksum digit
    # for an ISSN, we'll make sure they're at least that long. Still imperfect
    # (would fine "5------", for example) but should work in most cases.
    STDNUMPAT = /^.*?(\d[\d\-]{6,}[xX]?)/

    # Extract the most likely looking number from the string. This will be the first
    # string of digits-and-hyphens-and-maybe-a-trailing-X, with the hypens removed
    # @param [String] str The string from which to extract an ISBN/ISSN
    # @return [String] The extracted identifier
    def extractNumber str
      match = STDNUMPAT.match str
      return nil unless match
      return (match[1].gsub(/\-/, '')).upcase
    end

    # Same as STDNUMPAT but allowing for all numbers in the provided string
    STDNUMPAT_MULTIPLE = /.*?(\d[\d\-]{6,}[xX]?)/

    # Extract the most likely looking numbers from the string. This will be each
    # string with digits-and-hyphens-and-maybe-a-trailing-X, with the hypens removed
    # @param [String] str The string from which to extract the ISBN/ISSNs
    # @return [Array] An array of extracted identifiers
    def extract_multiple_numbers(str)
      return [] if str == '' || str.nil?
      str.scan(STDNUMPAT_MULTIPLE).flatten.map{ |i| i.gsub(/\-/, '').upcase }
    end

    # Given any string, extract what looks like the most likely ISBN/ISSN
    # of the given size(s), or nil if nothing matches at the correct size.
    # @param [String] rawnum The raw string containing (hopefully) an ISSN/ISBN
    # @param [Integer, Array<Integer>, nil] valid_sizes An integer or array of integers of valid sizes
    # for this type (e.g., 10 or 13 for ISBN, 8 for ISSN)
    # @return [String,nil] the reduced and verified number, or nil if there's no match at the right size
    def reduce_to_basics rawnum, valid_sizes = nil
      return nil if rawnum.nil?

      num = extractNumber rawnum

      # Does it even look like a number?
      return nil unless num

      # Return what we've got if we don't care about the size
      return num unless valid_sizes

      # Check for valid size(s)
      [valid_sizes].flatten.each do |s|
        return num if num.size == s
      end

      # Didn't check out size-wise. Return nil
      return nil
    end
  end

  # Validate, convert, and normalize ISBNs (10-digit or 13-digit)
  module ISBN
    extend Helpers

    # Does it even look like an ISBN?
    def self.at_least_trying? isbn
      reduce_to_basics(isbn, [10,13]) ? true : false
    end

    # Compute check digits for 10 or 13-digit ISBNs. See algorithm at
    # http://en.wikipedia.org/wiki/International_Standard_Book_Number
    # @param [String] isbn The ISBN (we'll try to clean it up if possible)
    # @param [Boolean] preprocessed Set to true if the ISBN has already been through reduce_to_basics
    # @return [String,nil] the one-character checkdigit, or nil if it's not an ISBN string
    def self.checkdigit isbn, preprocessed = false
      isbn = reduce_to_basics isbn, [10,13] unless preprocessed
      return nil unless isbn

      checkdigit = 0
      if isbn.size == 10
        digits = isbn[0..8].split(//).map {|i| i.to_i}
        (1..9).each do |i|
          checkdigit += digits[i-1] * i
        end
        checkdigit = checkdigit % 11
        return 'X' if checkdigit == 10
        return checkdigit.to_s
      else # size == 13
        checkdigit = 0
        digits = isbn[0..11].split(//).map {|i| i.to_i}
        6.times do
          checkdigit += digits.shift
          checkdigit += digits.shift * 3
        end
        check = 10 - (checkdigit % 10)
        check = 0 if check == 10
        return check.to_s
      end
    end

    # Check to see if the checkdigit is correct
    # @param [String] isbn The ISBN (we'll try to clean it up if possible)
    # @param [Boolean] preprocessed Set to true if the ISBN has already been through reduce_to_basics
    # @return [Boolean] Whether or not the checkdigit is correct. Sneakily, return 'nil' for
    #  values that don't even look like ISBNs, and 'false' for those that look possible but
    #  don't normalize / have bad checkdigits
    def self.valid? isbn, preprocessed = false
      return nil if isbn.nil?
      isbn = reduce_to_basics(isbn, [10,13]) unless preprocessed
      return nil unless isbn
      return false unless isbn[-1..-1] == self.checkdigit(isbn, true)
      return true
    end

    # For an ISBN, normalizing it is the same as converting to ISBN 13
    # and making sure it's valid
    #
    # @param [String] rawisbn The ISBN to normalize
    # @return [String, nil] the normalized (to 13 digit) ISBN, or nil on failure
    def self.normalize rawisbn
      isbn = convert_to_13 rawisbn
      if isbn
        return isbn
      else
        return nil
      end
    end

    # To convert to an ISBN13, throw a '978' on the front and
    # compute the checkdigit
    # We leave 13-digit numbers alone, figuring they're already ok. NO CHECKSUM CHECK IS DONE FOR 13-DIGIT ISBNS!
    # and return nil on anything that's not the right length
    # @param [String] isbn The ISBN (we'll try to clean it up if possible)
    # @return [String, nil] The converted 13-character ISBN, nil if something looks wrong, or whatever was passed in if it already looked like a 13-digit ISBN
    def self.convert_to_13 isbn
      isbn = reduce_to_basics isbn, [10,13]
      return nil unless isbn
      return nil unless valid?(isbn, true)
      return isbn if isbn.size == 13
      prefix = '978' + isbn[0..8]
      return prefix + self.checkdigit(prefix + '0', true)
    end

    # Convert to 10 if it's 13 digits and the first three digits are 978.
    # Pass through anything 10-digits, and return nil for everything else.
    # @param [String] isbn The ISBN (we'll try to clean it up if possible)
    # @return [String] The converted 10-character ISBN, nil if something looks wrong, or whatever was passed in if it already looked like a 10-digit ISBN
    def self.convert_to_10 isbn
      isbn = reduce_to_basics isbn, [10,13]

      # Already 10 digits? Just return
      return isbn if isbn.size == 10

      # Can't be converted to ISBN-10? Bail
      return nil unless isbn[0..2] == '978'

      prefix = isbn[3..11]
      return prefix + self.checkdigit(prefix + '0')
    end

    # Return an array of the ISBN13 and ISBN10 (in that order) for the passed in value. You'll
    # only get one value back if it's a 13-digit
    # ISBN that can't be converted to an ISBN10.
    # @param [String] isbn The original ISBN, in 10-character or 13-digit format
    # @return [Array<String,String>, nil] Either the (one or two) normalized ISBNs, or nil if
    # it can't be recognized.
    #
    # @example Get the normalized values and index them (if valid) or original value (if not)
    #   norms = StdNum::ISBN.allNormalizedValues(rawisbn)
    #   doc['isbn'] = norms ? norms : [rawisbn]
    def self.allNormalizedValues isbn
      isbn = reduce_to_basics isbn, [10,13]
      return [] unless isbn
      case isbn.size
      when 10
        return [self.convert_to_13(isbn), isbn]
      when 13
        return [isbn, self.convert_to_10(isbn)].compact
      end
    end

  end

  # Validate and and normalize ISSNs
  module ISSN
    extend Helpers

    # Does it even look like an ISSN?
    def self.at_least_trying? issn
      return !(reduce_to_basics(issn, 8))
    end

    # Compute the checkdigit of an ISSN
    # @param [String] issn The ISSN (we'll try to clean it up if possible)
    # @param [Boolean] preprocessed Set to true if the number has already been through reduce_to_basic
    # @return [String] the one-character checkdigit

    def self.checkdigit issn, preprocessed = false
      issn = reduce_to_basics issn, 8 unless preprocessed
      return nil unless issn

      digits = issn[0..6].split(//).map {|i| i.to_i}
      checkdigit = 0
      (0..6).each do |i|
        checkdigit += digits[i] * (8 - i)
      end
      checkdigit = checkdigit % 11
      return '0' if checkdigit == 0
      checkdigit = 11 - checkdigit
      return 'X' if checkdigit == 10
      return checkdigit.to_s
    end

    # Check to see if the checkdigit is correct
    # @param [String] issn The ISSN (we'll try to clean it up if possible)
    # @param [Boolean] preprocessed Set to true if the number has already been through reduce_to_basic
    # @return [Boolean] Whether or not the checkdigit is correct. Sneakily, return 'nil' for
    #  values that don't even look like ISBNs, and 'false' for those that look possible but
    #  don't normalize / have bad checkdigits

    def self.valid? issn, preprocessed = false
      issn = reduce_to_basics issn, 8 unless preprocessed
      return nil unless issn
      return issn[-1..-1] == self.checkdigit(issn, true)
    end

    # Make sure it's valid, remove the dashes, uppercase the X, and return
    # @param [String] rawissn The ISSN to normalize
    # @return [String, nil] the normalized ISSN, or nil on failure
    def self.normalize rawissn
      issn = reduce_to_basics rawissn, 8
      if issn and valid?(issn, true)
        return issn
      else
        return nil
      end
    end

  end

  # Validate and and normalize LCCNs
  module LCCN

    # Get a string ready for processing as an LCCN
    # @param [String] str The possible lccn
    # @return [String] The munged string, ready for normalization

    def self.reduce_to_basic str
      rv = str.gsub(/\s/, '')  # ditch spaces
      rv.gsub!('http://lccn.loc.gov/', '') # remove URI prefix
      rv.gsub!(/\/.*$/, '') # ditch everything after the first '/' (including the slash)
      return rv
    end

    # Normalize based on data at http://www.loc.gov/marc/lccn-namespace.html#syntax
    # @param [String] rawlccn The possible LCCN to normalize
    # @return [String, nil] the normalized LCCN, or nil if it looks malformed
    def self.normalize rawlccn
      lccn = reduce_to_basic(rawlccn)
      # If there's a dash in it, deal with that.
      if lccn =~ /^(.*?)\-(.+)/
        pre =  $1
        post = $2
        return nil unless post =~ /^\d+$/ # must be all digits
        lccn = "%s%06d" % [pre, post.to_i]
      end

      if valid?(lccn, true)
        return lccn
      else
        return nil
      end
    end

    # The rules for validity according to http://www.loc.gov/marc/lccn-namespace.html#syntax:
    #
    # A normalized LCCN is a character string eight to twelve characters in length. (For purposes of this description characters are ordered from left to right -- "first" means "leftmost".)
    # The rightmost eight characters are always digits.
    # If the length is 9, then the first character must be alphabetic.
    # If the length is 10, then the first two characters must be either both digits or both alphabetic.
    # If the length is 11, then the first character must be alphabetic and the next two characters must be either both digits or both alphabetic.
    # If the length is 12, then the first two characters must be alphabetic and the remaining characters digits.
    #
    # @param [String] lccn The lccn to attempt to validate
    # @param [Boolean] preprocessed Set to true if the number has already been normalized
    # @return [Boolean] Whether or not the syntax seems ok

    def self.valid? lccn, preprocessed = false
      lccn = normalize(lccn) unless preprocessed
      return false unless lccn
      clean = lccn.gsub(/\-/, '')
      suffix = clean[-8..-1] # "the rightmost eight characters are always digits"
      return false unless suffix and suffix =~ /^\d+$/
      case clean.size # "...is a character string eight to twelve digits in length"
      when 8
        return true
      when 9
        return true if clean =~ /^[A-Za-z]/
      when 10
        return true if clean =~ /^\d{2}/ or clean =~ /^[A-Za-z]{2}/
      when 11
        return true if clean =~ /^[A-Za-z](\d{2}|[A-Za-z]{2})/
      when 12
        return true if clean =~ /^[A-Za-z]{2}\d{2}/
      else
        return false
      end

      return false
    end

  end

end

require 'spec_helper'

describe "Extract" do
  it "should leave a number alone" do
    StdNum::ISBN.extractNumber('1234567').must_equal '1234567'
  end

  it "should skip leading and trailing crap" do
    StdNum::ISBN.extractNumber(' 1234567 (online)').must_equal '1234567'
  end

  it "should allow hyphens" do
    StdNum::ISBN.extractNumber(' 1-234-5').must_equal '12345'
  end

  it "should return nil on a non-match" do
    StdNum::ISBN.extractNumber('bill dueber').must_equal nil
  end

  it "should allow a trailing X" do
    StdNum::ISBN.extractNumber('1-234-5-X').must_equal '12345X'
  end

  it "should upcase any trailing X" do
    StdNum::ISBN.extractNumber('1-234-56-x').must_equal '123456X'
  end

  it "only allows a single trailing X" do
    StdNum::ISBN.extractNumber('123456-X-X').must_equal '123456X'
  end

  it "doesn't allow numbers that are too short" do
    StdNum::ISBN.extractNumber('12345').must_equal nil
  end

  let(:identifiers_string) { '9780987115423 (print ed) 9780987115430 (web ed)' }
  it "will extract multiple identifiers" do
    StdNum::ISBN.extract_multiple_numbers(identifiers_string).must_be_kind_of Array
    StdNum::ISBN.extract_multiple_numbers(identifiers_string).count.must_equal 2
    StdNum::ISBN.extract_multiple_numbers(identifiers_string)[0].must_equal '9780987115423'
    StdNum::ISBN.extract_multiple_numbers(identifiers_string)[1].must_equal '9780987115430'
  end

  let(:string_with_no_identifiers) { 'This has no identifiers' }
  it "will return an empty array when no identifiers are in the supplied string " do
    StdNum::ISBN.extract_multiple_numbers(string_with_no_identifiers).must_be_kind_of Array
    StdNum::ISBN.extract_multiple_numbers(string_with_no_identifiers).count.must_equal 0

    StdNum::ISBN.extract_multiple_numbers('').must_be_kind_of Array
    StdNum::ISBN.extract_multiple_numbers('').count.must_equal 0
  end
  it "skips over short prefixing numbers" do
    StdNum::ISBN.extractNumber('ISBN13: 1234567890123').must_equal '1234567890123'
  end

end

describe "ISBN" do
  it "computes 10-digit checksum" do
    StdNum::ISBN.checkdigit('0-306-40615-X').must_equal '2'
  end

  it "correctly uses X for checksum" do
    StdNum::ISBN.checkdigit('061871460X').must_equal 'X'
  end

  it "finds a zero checkdigit" do
    StdNum::ISBN.checkdigit('0139381430').must_equal '0'
  end

  it "computes 13-digit checksum" do
    StdNum::ISBN.checkdigit('9780306406157').must_equal '7'
  end

  it "computes a 13-digit checksum that is 0" do
    StdNum::ISBN.checkdigit('9783837612950').must_equal '0'
  end

  it "finds a good number valid" do
    StdNum::ISBN.valid?('9780306406157').must_equal true
  end

  it "says a good number is trying" do
    StdNum::ISBN.at_least_trying?('9780306406157').must_equal true
  end

  it "says bad data is not trying" do
    StdNum::ISBN.at_least_trying?('978006406157').must_equal false
    StdNum::ISBN.at_least_trying?('406157').must_equal false
    StdNum::ISBN.at_least_trying?('$22').must_equal false
    StdNum::ISBN.at_least_trying?('hello').must_equal false
  end

  it "finds a bad number invalid" do
    StdNum::ISBN.valid?('9780306406154').must_equal false
  end

  it "returns nil when computing checksum for bad ISBN" do
    StdNum::ISBN.checkdigit('12345').must_equal nil
  end

  it "converts 10 to 13" do
    StdNum::ISBN.convert_to_13('0-306-40615-2').must_equal '9780306406157'
  end

  it "passes through 13 digit number instead of converting to 13" do
    StdNum::ISBN.convert_to_13('9780306406157').must_equal '9780306406157'
  end

  it "converts 13 to 10" do
    StdNum::ISBN.convert_to_10('978-0-306-40615-7').must_equal '0306406152'
  end

  it "normalizes" do
    StdNum::ISBN.normalize('0-306-40615-2').must_equal '9780306406157'
    StdNum::ISBN.normalize('0-306-40615-X').must_equal nil
    StdNum::ISBN.normalize('ISBN: 978-0-306-40615-7').must_equal '9780306406157'
    StdNum::ISBN.normalize('ISBN: 978-0-306-40615-3').must_equal nil
  end

  it "gets both normalized values" do
    a = StdNum::ISBN.allNormalizedValues('978-0-306-40615-7')
    a.sort.must_equal ['9780306406157', '0306406152' ].sort

    a = StdNum::ISBN.allNormalizedValues('0-306-40615-2')
    a.sort.must_equal ['9780306406157', '0306406152' ].sort
  end

end

describe 'ISSN' do
  it "computes checksum" do
    StdNum::ISSN.checkdigit('0378-5955').must_equal '5'
  end

  it "normalizes" do
    StdNum::ISSN.normalize('0378-5955').must_equal '03785955'
  end
end

describe 'LCCN basics' do

  # Tests take from http://www.loc.gov/marc/lccn-namespace.html#syntax
  test = {
    "n78-890351" => "n78890351",
    "n78-89035" => "n78089035",
    "n 78890351 " => "n78890351",
    " 85000002 " => "85000002",
    "85-2 " => "85000002",
    "2001-000002" => "2001000002",
    "75-425165//r75" => "75425165",
    " 79139101 /AC/r932" => "79139101",
  }

  test.each do |k, v|
    it "normalizes #{k}" do
      StdNum::LCCN.normalize(k.dup).must_equal v
    end
  end

  it "validates correctly" do
    StdNum::LCCN.valid?("n78-890351").must_equal true
    StdNum::LCCN.valid?("n78-89035100444").must_equal false, "Too long"
    StdNum::LCCN.valid?("n78").must_equal false, "Too short"
    StdNum::LCCN.valid?("na078-890351").must_equal false, "naa78-890351 should start with three letters or digits"
    StdNum::LCCN.valid?("n078-890351").must_equal false, "n078-890351 should start with two letters or two digits"
    StdNum::LCCN.valid?("na078-890351").must_equal false, "na078-890351 should start with three letters or digits"
    StdNum::LCCN.valid?("0an78-890351").must_equal false, "0an78-890351 should start with three letters or digits"
    StdNum::LCCN.valid?("n78-89c0351").must_equal false, "n78-89c0351 has a letter after the dash"
  end

end

describe "LCCN tests from Business::LCCN perl module" do
  tests = [
    {  :orig => 'n78-890351',
                 :canonical => 'n  78890351 ',
                 :normalized => 'n78890351',
                 :prefix => 'n',
                 :year_cataloged => 1978,
                 :serial => '890351',
              },
              {  :orig => 'n 78890351 ',
                 :canonical => 'n  78890351 ',
                 :normalized => 'n78890351',
                 :prefix => 'n',
                 :year_cataloged => 1978,
                 :serial => '890351',
              },
              {  :orig => ' 85000002 ',
                 :canonical => '   85000002 ',
                 :normalized => '85000002',
                 :year_cataloged => 1985,
                 :serial => '000002',
              },
              {  :orig => '85-2 ',
                 :canonical => '   85000002 ',
                 :normalized => '85000002',
                 :year_cataloged => 1985,
                 :serial => '000002',
              },
              {  :orig => '2001-000002',
                 :canonical => '  2001000002',
                 :normalized => '2001000002',
                 :year_cataloged => 2001,
                 :serial => '000002',
              },
              {  :orig => '75-425165//r75',
                 :canonical => '   75425165 //r75',
                 :normalized => '75425165',
                 :prefix => '',
                 :year_cataloged => nil,
                 :serial => '425165',
                 :revision_year => 1975,
                 :revision_year_encoded => '75',
                 :revision_number => nil,
              },
              {  :orig => ' 79139101 /AC/r932',
                 :canonical => '   79139101 /AC/r932',
                 :normalized => '79139101',
                 :prefix => '',
                 :year_cataloged => nil,
                 :serial => '139101',
                 :suffix_encoded => '/AC',
                 :revision_year => 1993,
                 :revision_year_encoded => '93',
                 :revision_number => 2,
              },
              {  :orig => '89-4',
                 :canonical => '   89000004 ',
                 :normalized => '89000004',
                 :year_cataloged => 1989,
                 :serial => '000004',
              },
              {  :orig => '89-45',
                 :canonical => '   89000045 ',
                 :normalized => '89000045',
                 :year_cataloged => 1989,
                 :serial => '000045',
              },
              {  :orig => '89-456',
                 :canonical => '   89000456 ',
                 :normalized => '89000456',
                 :year_cataloged => 1989,
                 :serial => '000456',
              },
              {  :orig => '89-1234',
                 :canonical => '   89001234 ',
                 :normalized => '89001234',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => '89-001234',
                 :canonical => '   89001234 ',
                 :normalized => '89001234',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => '89001234',
                 :canonical => '   89001234 ',
                 :normalized => '89001234',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => '2002-1234',
                 :canonical => '  2002001234',
                 :normalized => '2002001234',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => '2002-001234',
                 :canonical => '  2002001234',
                 :normalized => '2002001234',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => '2002001234',
                 :canonical => '  2002001234',
                 :normalized => '2002001234',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => '   89001234 ',
                 :canonical => '   89001234 ',
                 :normalized => '89001234',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => '  2002001234',
                 :canonical => '  2002001234',
                 :normalized => '2002001234',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'a89-1234',
                 :canonical => 'a  89001234 ',
                 :normalized => 'a89001234',
                 :prefix => 'a',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'a89-001234',
                 :canonical => 'a  89001234 ',
                 :normalized => 'a89001234',
                 :prefix => 'a',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'a89001234',
                 :canonical => 'a  89001234 ',
                 :normalized => 'a89001234',
                 :prefix => 'a',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'a2002-1234',
                 :canonical => 'a 2002001234',
                 :normalized => 'a2002001234',
                 :prefix => 'a',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'a2002-001234',
                 :canonical => 'a 2002001234',
                 :normalized => 'a2002001234',
                 :prefix => 'a',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'a2002001234',
                 :canonical => 'a 2002001234',
                 :normalized => 'a2002001234',
                 :prefix => 'a',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'a 89001234 ',
                 :canonical => 'a  89001234 ',
                 :normalized => 'a89001234',
                 :prefix => 'a',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'a 89-001234 ',
                 :canonical => 'a  89001234 ',
                 :normalized => 'a89001234',
                 :prefix => 'a',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'a 2002001234',
                 :canonical => 'a 2002001234',
                 :normalized => 'a2002001234',
                 :prefix => 'a',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'ab89-1234',
                 :canonical => 'ab 89001234 ',
                 :normalized => 'ab89001234',
                 :prefix => 'ab',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'ab89-001234',
                 :canonical => 'ab 89001234 ',
                 :normalized => 'ab89001234',
                 :prefix => 'ab',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'ab89001234',
                 :canonical => 'ab 89001234 ',
                 :normalized => 'ab89001234',
                 :prefix => 'ab',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'ab2002-1234',
                 :canonical => 'ab2002001234',
                 :normalized => 'ab2002001234',
                 :prefix => 'ab',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'ab2002-001234',
                 :canonical => 'ab2002001234',
                 :normalized => 'ab2002001234',
                 :prefix => 'ab',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'ab2002001234',
                 :canonical => 'ab2002001234',
                 :normalized => 'ab2002001234',
                 :prefix => 'ab',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'ab 89001234 ',
                 :canonical => 'ab 89001234 ',
                 :normalized => 'ab89001234',
                 :prefix => 'ab',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'ab 2002001234',
                 :canonical => 'ab2002001234',
                 :normalized => 'ab2002001234',
                 :prefix => 'ab',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'ab 89-1234',
                 :canonical => 'ab 89001234 ',
                 :normalized => 'ab89001234',
                 :prefix => 'ab',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'abc89-1234',
                 :canonical => 'abc89001234 ',
                 :normalized => 'abc89001234',
                 :prefix => 'abc',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'abc89-001234',
                 :canonical => 'abc89001234 ',
                 :normalized => 'abc89001234',
                 :prefix => 'abc',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'abc89001234',
                 :canonical => 'abc89001234 ',
                 :normalized => 'abc89001234',
                 :prefix => 'abc',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'abc89001234 ',
                 :canonical => 'abc89001234 ',
                 :normalized => 'abc89001234',
                 :prefix => 'abc',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'http://lccn.loc.gov/89001234',
                 :canonical => '   89001234 ',
                 :normalized => '89001234',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'http://lccn.loc.gov/a89001234',
                 :canonical => 'a  89001234 ',
                 :normalized => 'a89001234',
                 :serial => '001234',
                 :prefix => 'a',
                 :year_cataloged => 1989,
              },
              {  :orig => 'http://lccn.loc.gov/ab89001234',
                 :canonical => 'ab 89001234 ',
                 :normalized => 'ab89001234',
                 :prefix => 'ab',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'http://lccn.loc.gov/abc89001234',
                 :canonical => 'abc89001234 ',
                 :normalized => 'abc89001234',
                 :prefix => 'abc',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'http://lccn.loc.gov/2002001234',
                 :canonical => '  2002001234',
                 :normalized => '2002001234',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'http://lccn.loc.gov/a2002001234',
                 :canonical => 'a 2002001234',
                 :normalized => 'a2002001234',
                 :prefix => 'a',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'http://lccn.loc.gov/ab2002001234',
                 :canonical => 'ab2002001234',
                 :normalized => 'ab2002001234',
                 :prefix => 'ab',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => '00-21595',
                 :canonical => '   00021595 ',
                 :normalized => '00021595',
                 :year_cataloged => 2000,
                 :serial => '021595',
              },
              {  :orig => '2001001599',
                 :canonical => '  2001001599',
                 :normalized => '2001001599',
                 :year_cataloged => 2001,
                 :serial => '001599',
              },
              {  :orig => '99-18233',
                 :canonical => '   99018233 ',
                 :normalized => '99018233',
                 :year_cataloged => 1999,
                 :serial => '018233',
              },
              {  :orig => '98000595',
                 :canonical => '   98000595 ',
                 :normalized => '98000595',
                 :year_cataloged => 1898,
                 :serial => '000595',
              },
              {  :orig => '99005074',
                 :canonical => '   99005074 ',
                 :normalized => '99005074',
                 :year_cataloged => 1899,
                 :serial => '005074',
              },
              {  :orig => '00003373',
                 :canonical => '   00003373 ',
                 :normalized => '00003373',
                 :year_cataloged => 1900,
                 :serial => '003373',
              },
              {  :orig => '01001599',
                 :canonical => '   01001599 ',
                 :normalized => '01001599',
                 :year_cataloged => 1901,
                 :serial => '001599',
              },
              {  :orig => '   95156543 ',
                 :canonical => '   95156543 ',
                 :normalized => '95156543',
                 :year_cataloged => 1995,
                 :serial => '156543',
              },
              {  :orig => '   94014580 /AC/r95',
                 :canonical => '   94014580 /AC/r95',
                 :normalized => '94014580',
                 :year_cataloged => 1994,
                 :serial => '014580',
                 :suffix_encoded => '/AC',
                 :revision_year_encoded => '95',
                 :revision_year => 1995,
              },
              {  :orig => '   79310919 //r86',
                 :canonical => '   79310919 //r86',
                 :normalized => '79310919',
                 :year_cataloged => 1979,
                 :serial => '310919',
                 :revision_year_encoded => '86',
                 :revision_year => 1986,
              },
              {  :orig => 'gm 71005810  ',
                 :canonical => 'gm 71005810 ',
                 :normalized => 'gm71005810',
                 :prefix => 'gm',
                 :year_cataloged => 1971,
                 :serial => '005810',
              },
              {  :orig => 'sn2006058112  ',
                 :canonical => 'sn2006058112',
                 :normalized => 'sn2006058112',
                 :prefix => 'sn',
                 :year_cataloged => 2006,
                 :serial => '058112',
              },
              {  :orig => 'gm 71-2450',
                 :canonical => 'gm 71002450 ',
                 :normalized => 'gm71002450',
                 :prefix => 'gm',
                 :year_cataloged => 1971,
                 :serial => '002450',
              },
              {  :orig => '2001-1114',
                 :canonical => '  2001001114',
                 :normalized => '2001001114',
                 :year_cataloged => 2001,
                 :serial => '001114',
              },
  ]
  tests.each do |h|
    it "normalizes #{h[:orig]}" do
      StdNum::LCCN.normalize(h[:orig]).must_equal h[:normalized], "#{h[:orig]} doesn't normalize to #{h[:normalized]}"
    end
  end
end

# Static Module functions to work with library "standard numbers" ISSN, ISBN, and LCCN
module StdNum

  # Helper methods common to ISBN/ISSN
  module Helpers

    # The pattern we use to try and find an ISBN/ISSN. Ditch everthing before the first
    # digit, then take all the digits/hyphens, optionally followed by an 'X'
    # Since the shortest possible string is 7 digits followed by a checksum digit
    # for an ISSN, we'll make sure they're at least that long. Still imperfect
    # (would fine "5------", for example) but should work in most cases.
    STDNUMPAT = /^.*?(\d[\d\-]{6,}[xX]?)/

    # Extract the most likely looking number from the string. This will be the first
    # string of digits-and-hyphens-and-maybe-a-trailing-X, with the hypens removed
    # @param [String] str The string from which to extract an ISBN/ISSN
    # @return [String] The extracted identifier
    def extractNumber str
      match = STDNUMPAT.match str
      return nil unless match
      return (match[1].gsub(/\-/, '')).upcase
    end

    # Same as STDNUMPAT but allowing for all numbers in the provided string
    STDNUMPAT_MULTIPLE = /.*?(\d[\d\-]{6,}[xX]?)/

    # Extract the most likely looking numbers from the string. This will be each
    # string with digits-and-hyphens-and-maybe-a-trailing-X, with the hypens removed
    # @param [String] str The string from which to extract the ISBN/ISSNs
    # @return [Array] An array of extracted identifiers
    def extract_multiple_numbers(str)
      return [] if str == '' || str.nil?
      str.scan(STDNUMPAT_MULTIPLE).flatten.map{ |i| i.gsub(/\-/, '').upcase }
    end

    # Given any string, extract what looks like the most likely ISBN/ISSN
    # of the given size(s), or nil if nothing matches at the correct size.
    # @param [String] rawnum The raw string containing (hopefully) an ISSN/ISBN
    # @param [Integer, Array<Integer>, nil] valid_sizes An integer or array of integers of valid sizes
    # for this type (e.g., 10 or 13 for ISBN, 8 for ISSN)
    # @return [String,nil] the reduced and verified number, or nil if there's no match at the right size
    def reduce_to_basics rawnum, valid_sizes = nil
      return nil if rawnum.nil?

      num = extractNumber rawnum

      # Does it even look like a number?
      return nil unless num

      # Return what we've got if we don't care about the size
      return num unless valid_sizes

      # Check for valid size(s)
      [valid_sizes].flatten.each do |s|
        return num if num.size == s
      end

      # Didn't check out size-wise. Return nil
      return nil
    end
  end

  # Validate, convert, and normalize ISBNs (10-digit or 13-digit)
  module ISBN
    extend Helpers

    # Does it even look like an ISBN?
    def self.at_least_trying? isbn
      reduce_to_basics(isbn, [10,13]) ? true : false
    end

    # Compute check digits for 10 or 13-digit ISBNs. See algorithm at
    # http://en.wikipedia.org/wiki/International_Standard_Book_Number
    # @param [String] isbn The ISBN (we'll try to clean it up if possible)
    # @param [Boolean] preprocessed Set to true if the ISBN has already been through reduce_to_basics
    # @return [String,nil] the one-character checkdigit, or nil if it's not an ISBN string
    def self.checkdigit isbn, preprocessed = false
      isbn = reduce_to_basics isbn, [10,13] unless preprocessed
      return nil unless isbn

      checkdigit = 0
      if isbn.size == 10
        digits = isbn[0..8].split(//).map {|i| i.to_i}
        (1..9).each do |i|
          checkdigit += digits[i-1] * i
        end
        checkdigit = checkdigit % 11
        return 'X' if checkdigit == 10
        return checkdigit.to_s
      else # size == 13
        checkdigit = 0
        digits = isbn[0..11].split(//).map {|i| i.to_i}
        6.times do
          checkdigit += digits.shift
          checkdigit += digits.shift * 3
        end
        check = 10 - (checkdigit % 10)
        check = 0 if check == 10
        return check.to_s
      end
    end

    # Check to see if the checkdigit is correct
    # @param [String] isbn The ISBN (we'll try to clean it up if possible)
    # @param [Boolean] preprocessed Set to true if the ISBN has already been through reduce_to_basics
    # @return [Boolean] Whether or not the checkdigit is correct. Sneakily, return 'nil' for
    #  values that don't even look like ISBNs, and 'false' for those that look possible but
    #  don't normalize / have bad checkdigits
    def self.valid? isbn, preprocessed = false
      return nil if isbn.nil?
      isbn = reduce_to_basics(isbn, [10,13]) unless preprocessed
      return nil unless isbn
      return false unless isbn[-1..-1] == self.checkdigit(isbn, true)
      return false unless isbn.size == 10 || valid_isbn13_prefix?(isbn)
      return true
    end

    # For an ISBN, normalizing it is the same as converting to ISBN 13
    # and making sure it's valid
    #
    # @param [String] rawisbn The ISBN to normalize
    # @return [String, nil] the normalized (to 13 digit) ISBN, or nil on failure
    def self.normalize rawisbn
      isbn = convert_to_13 rawisbn
      if isbn
        return isbn
      else
        return nil
      end
    end

    # To convert to an ISBN13, throw a '978' on the front and
    # compute the checkdigit
    # We leave 13-digit numbers alone, figuring they're already ok. NO CHECKSUM CHECK IS DONE FOR 13-DIGIT ISBNS!
    # and return nil on anything that's not the right length
    # @param [String] isbn The ISBN (we'll try to clean it up if possible)
    # @return [String, nil] The converted 13-character ISBN, nil if something looks wrong, or whatever was passed in if it already looked like a 13-digit ISBN
    def self.convert_to_13 isbn
      isbn = reduce_to_basics isbn, [10,13]
      return nil unless isbn
      return nil unless valid?(isbn, true)
      return isbn if isbn.size == 13
      prefix = '978' + isbn[0..8]
      return prefix + self.checkdigit(prefix + '0', true)
    end

    # Convert to 10 if it's 13 digits and the first three digits are 978.
    # Pass through anything 10-digits, and return nil for everything else.
    # @param [String] isbn The ISBN (we'll try to clean it up if possible)
    # @return [String] The converted 10-character ISBN, nil if something looks wrong, or whatever was passed in if it already looked like a 10-digit ISBN
    def self.convert_to_10 isbn
      isbn = reduce_to_basics isbn, [10,13]

      # Already 10 digits? Just return
      return isbn if isbn.size == 10

      # Can't be converted to ISBN-10? Bail
      return nil unless isbn[0..2] == '978'

      prefix = isbn[3..11]
      return prefix + self.checkdigit(prefix + '0')
    end

    # Return an array of the ISBN13 and ISBN10 (in that order) for the passed in value. You'll
    # only get one value back if it's a 13-digit
    # ISBN that can't be converted to an ISBN10.
    # @param [String] isbn The original ISBN, in 10-character or 13-digit format
    # @return [Array<String,String>, nil] Either the (one or two) normalized ISBNs, or nil if
    # it can't be recognized.
    #
    # @example Get the normalized values and index them (if valid) or original value (if not)
    #   norms = StdNum::ISBN.allNormalizedValues(rawisbn)
    #   doc['isbn'] = norms ? norms : [rawisbn]
    def self.allNormalizedValues isbn
      isbn = reduce_to_basics isbn, [10,13]
      return [] unless isbn
      case isbn.size
      when 10
        return [self.convert_to_13(isbn), isbn]
      when 13
        return [isbn, self.convert_to_10(isbn)].compact
      end
    end

    # Checks for a valid ISBN13 prefix
    # ISBN13 always starts with 978 or 979. For example: 1000000000012 has a valid check digit, but
    # is not a valid ISBN13.
    # @param [String] isbn13 The ISBN13 to be checked.
    # @return [Boolean] If true then the prefix is valid
    def self.valid_isbn13_prefix?(isbn13)
      return false unless isbn13.size == 13
      ['978', '979'].map { |prefix| isbn13.start_with?(prefix) }.any?
    end
  end

  # Validate and and normalize ISSNs
  module ISSN
    extend Helpers

    # Does it even look like an ISSN?
    def self.at_least_trying? issn
      return !(reduce_to_basics(issn, 8))
    end

    # Compute the checkdigit of an ISSN
    # @param [String] issn The ISSN (we'll try to clean it up if possible)
    # @param [Boolean] preprocessed Set to true if the number has already been through reduce_to_basic
    # @return [String] the one-character checkdigit

    def self.checkdigit issn, preprocessed = false
      issn = reduce_to_basics issn, 8 unless preprocessed
      return nil unless issn

      digits = issn[0..6].split(//).map {|i| i.to_i}
      checkdigit = 0
      (0..6).each do |i|
        checkdigit += digits[i] * (8 - i)
      end
      checkdigit = checkdigit % 11
      return '0' if checkdigit == 0
      checkdigit = 11 - checkdigit
      return 'X' if checkdigit == 10
      return checkdigit.to_s
    end

    # Check to see if the checkdigit is correct
    # @param [String] issn The ISSN (we'll try to clean it up if possible)
    # @param [Boolean] preprocessed Set to true if the number has already been through reduce_to_basic
    # @return [Boolean] Whether or not the checkdigit is correct. Sneakily, return 'nil' for
    #  values that don't even look like ISBNs, and 'false' for those that look possible but
    #  don't normalize / have bad checkdigits

    def self.valid? issn, preprocessed = false
      issn = reduce_to_basics issn, 8 unless preprocessed
      return nil unless issn
      return issn[-1..-1] == self.checkdigit(issn, true)
    end

    # Make sure it's valid, remove the dashes, uppercase the X, and return
    # @param [String] rawissn The ISSN to normalize
    # @return [String, nil] the normalized ISSN, or nil on failure
    def self.normalize rawissn
      issn = reduce_to_basics rawissn, 8
      if issn and valid?(issn, true)
        return issn
      else
        return nil
      end
    end

  end

  # Validate and and normalize LCCNs
  module LCCN

    # Get a string ready for processing as an LCCN
    # @param [String] str The possible lccn
    # @return [String] The munged string, ready for normalization

    def self.reduce_to_basic str
      rv = str.gsub(/\s/, '')  # ditch spaces
      rv.gsub!('http://lccn.loc.gov/', '') # remove URI prefix
      rv.gsub!(/\/.*$/, '') # ditch everything after the first '/' (including the slash)
      return rv
    end

    # Normalize based on data at http://www.loc.gov/marc/lccn-namespace.html#syntax
    # @param [String] rawlccn The possible LCCN to normalize
    # @return [String, nil] the normalized LCCN, or nil if it looks malformed
    def self.normalize rawlccn
      lccn = reduce_to_basic(rawlccn)
      # If there's a dash in it, deal with that.
      if lccn =~ /^(.*?)\-(.+)/
        pre =  $1
        post = $2
        return nil unless post =~ /^\d+$/ # must be all digits
        lccn = "%s%06d" % [pre, post.to_i]
      end

      if valid?(lccn, true)
        return lccn
      else
        return nil
      end
    end

    # The rules for validity according to http://www.loc.gov/marc/lccn-namespace.html#syntax:
    #
    # A normalized LCCN is a character string eight to twelve characters in length. (For purposes of this description characters are ordered from left to right -- "first" means "leftmost".)
    # The rightmost eight characters are always digits.
    # If the length is 9, then the first character must be alphabetic.
    # If the length is 10, then the first two characters must be either both digits or both alphabetic.
    # If the length is 11, then the first character must be alphabetic and the next two characters must be either both digits or both alphabetic.
    # If the length is 12, then the first two characters must be alphabetic and the remaining characters digits.
    #
    # @param [String] lccn The lccn to attempt to validate
    # @param [Boolean] preprocessed Set to true if the number has already been normalized
    # @return [Boolean] Whether or not the syntax seems ok

    def self.valid? lccn, preprocessed = false
      lccn = normalize(lccn) unless preprocessed
      return false unless lccn
      clean = lccn.gsub(/\-/, '')
      suffix = clean[-8..-1] # "the rightmost eight characters are always digits"
      return false unless suffix and suffix =~ /^\d+$/
      case clean.size # "...is a character string eight to twelve digits in length"
      when 8
        return true
      when 9
        return true if clean =~ /^[A-Za-z]/
      when 10
        return true if clean =~ /^\d{2}/ or clean =~ /^[A-Za-z]{2}/
      when 11
        return true if clean =~ /^[A-Za-z](\d{2}|[A-Za-z]{2})/
      when 12
        return true if clean =~ /^[A-Za-z]{2}\d{2}/
      else
        return false
      end

      return false
    end

  end

end

require 'spec_helper'

describe "Extract" do
  it "should leave a number alone" do
    StdNum::ISBN.extractNumber('1234567').must_equal '1234567'
  end

  it "should skip leading and trailing crap" do
    StdNum::ISBN.extractNumber(' 1234567 (online)').must_equal '1234567'
  end

  it "should allow hyphens" do
    StdNum::ISBN.extractNumber(' 1-234-5').must_equal '12345'
  end

  it "should return nil on a non-match" do
    StdNum::ISBN.extractNumber('bill dueber').must_equal nil
  end

  it "should allow a trailing X" do
    StdNum::ISBN.extractNumber('1-234-5-X').must_equal '12345X'
  end

  it "should upcase any trailing X" do
    StdNum::ISBN.extractNumber('1-234-56-x').must_equal '123456X'
  end

  it "only allows a single trailing X" do
    StdNum::ISBN.extractNumber('123456-X-X').must_equal '123456X'
  end

  it "doesn't allow numbers that are too short" do
    StdNum::ISBN.extractNumber('12345').must_equal nil
  end

  let(:identifiers_string) { '9780987115423 (print ed) 9780987115430 (web ed)' }
  it "will extract multiple identifiers" do
    StdNum::ISBN.extract_multiple_numbers(identifiers_string).must_be_kind_of Array
    StdNum::ISBN.extract_multiple_numbers(identifiers_string).count.must_equal 2
    StdNum::ISBN.extract_multiple_numbers(identifiers_string)[0].must_equal '9780987115423'
    StdNum::ISBN.extract_multiple_numbers(identifiers_string)[1].must_equal '9780987115430'
  end

  let(:string_with_no_identifiers) { 'This has no identifiers' }
  it "will return an empty array when no identifiers are in the supplied string " do
    StdNum::ISBN.extract_multiple_numbers(string_with_no_identifiers).must_be_kind_of Array
    StdNum::ISBN.extract_multiple_numbers(string_with_no_identifiers).count.must_equal 0

    StdNum::ISBN.extract_multiple_numbers('').must_be_kind_of Array
    StdNum::ISBN.extract_multiple_numbers('').count.must_equal 0
  end
  it "skips over short prefixing numbers" do
    StdNum::ISBN.extractNumber('ISBN13: 1234567890123').must_equal '1234567890123'
  end

end

describe "ISBN" do
  it "computes 10-digit checksum" do
    StdNum::ISBN.checkdigit('0-306-40615-X').must_equal '2'
  end

  it "correctly uses X for checksum" do
    StdNum::ISBN.checkdigit('061871460X').must_equal 'X'
  end

  it "finds a zero checkdigit" do
    StdNum::ISBN.checkdigit('0139381430').must_equal '0'
  end

  it "computes 13-digit checksum" do
    StdNum::ISBN.checkdigit('9780306406157').must_equal '7'
  end

  it "computes a 13-digit checksum that is 0" do
    StdNum::ISBN.checkdigit('9783837612950').must_equal '0'
  end

  it "finds a good number valid" do
    StdNum::ISBN.valid?('9780306406157').must_equal true
  end

  it "says a good number is trying" do
    StdNum::ISBN.at_least_trying?('9780306406157').must_equal true
  end

  it "says bad data is not trying" do
    StdNum::ISBN.at_least_trying?('978006406157').must_equal false
    StdNum::ISBN.at_least_trying?('406157').must_equal false
    StdNum::ISBN.at_least_trying?('$22').must_equal false
    StdNum::ISBN.at_least_trying?('hello').must_equal false
  end

  it "finds a bad number invalid" do
    StdNum::ISBN.valid?('9780306406154').must_equal false
  end

  it "returns nil when computing checksum for bad ISBN" do
    StdNum::ISBN.checkdigit('12345').must_equal nil
  end

  it "converts 10 to 13" do
    StdNum::ISBN.convert_to_13('0-306-40615-2').must_equal '9780306406157'
  end

  it "passes through 13 digit number instead of converting to 13" do
    StdNum::ISBN.convert_to_13('9780306406157').must_equal '9780306406157'
  end

  it "converts 13 to 10" do
    StdNum::ISBN.convert_to_10('978-0-306-40615-7').must_equal '0306406152'
  end

  it "normalizes" do
    StdNum::ISBN.normalize('0-306-40615-2').must_equal '9780306406157'
    StdNum::ISBN.normalize('0-306-40615-X').must_equal nil
    StdNum::ISBN.normalize('ISBN: 978-0-306-40615-7').must_equal '9780306406157'
    StdNum::ISBN.normalize('ISBN: 978-0-306-40615-3').must_equal nil
  end

  it "gets both normalized values" do
    a = StdNum::ISBN.allNormalizedValues('978-0-306-40615-7')
    a.sort.must_equal ['9780306406157', '0306406152' ].sort

    a = StdNum::ISBN.allNormalizedValues('0-306-40615-2')
    a.sort.must_equal ['9780306406157', '0306406152' ].sort
  end

  it "identifies an invalid ISBN13 due to the prefix" do
    StdNum::ISBN.valid_isbn13_prefix?('9780000000002').must_equal true
    StdNum::ISBN.valid?('1000000000012').must_equal false
  end

end

describe 'ISSN' do
  it "computes checksum" do
    StdNum::ISSN.checkdigit('0378-5955').must_equal '5'
  end

  it "normalizes" do
    StdNum::ISSN.normalize('0378-5955').must_equal '03785955'
  end
end

describe 'LCCN basics' do

  # Tests take from http://www.loc.gov/marc/lccn-namespace.html#syntax
  test = {
    "n78-890351" => "n78890351",
    "n78-89035" => "n78089035",
    "n 78890351 " => "n78890351",
    " 85000002 " => "85000002",
    "85-2 " => "85000002",
    "2001-000002" => "2001000002",
    "75-425165//r75" => "75425165",
    " 79139101 /AC/r932" => "79139101",
  }

  test.each do |k, v|
    it "normalizes #{k}" do
      StdNum::LCCN.normalize(k.dup).must_equal v
    end
  end

  it "validates correctly" do
    StdNum::LCCN.valid?("n78-890351").must_equal true
    StdNum::LCCN.valid?("n78-89035100444").must_equal false, "Too long"
    StdNum::LCCN.valid?("n78").must_equal false, "Too short"
    StdNum::LCCN.valid?("na078-890351").must_equal false, "naa78-890351 should start with three letters or digits"
    StdNum::LCCN.valid?("n078-890351").must_equal false, "n078-890351 should start with two letters or two digits"
    StdNum::LCCN.valid?("na078-890351").must_equal false, "na078-890351 should start with three letters or digits"
    StdNum::LCCN.valid?("0an78-890351").must_equal false, "0an78-890351 should start with three letters or digits"
    StdNum::LCCN.valid?("n78-89c0351").must_equal false, "n78-89c0351 has a letter after the dash"
  end

end

describe "LCCN tests from Business::LCCN perl module" do
  tests = [
    {  :orig => 'n78-890351',
                 :canonical => 'n  78890351 ',
                 :normalized => 'n78890351',
                 :prefix => 'n',
                 :year_cataloged => 1978,
                 :serial => '890351',
              },
              {  :orig => 'n 78890351 ',
                 :canonical => 'n  78890351 ',
                 :normalized => 'n78890351',
                 :prefix => 'n',
                 :year_cataloged => 1978,
                 :serial => '890351',
              },
              {  :orig => ' 85000002 ',
                 :canonical => '   85000002 ',
                 :normalized => '85000002',
                 :year_cataloged => 1985,
                 :serial => '000002',
              },
              {  :orig => '85-2 ',
                 :canonical => '   85000002 ',
                 :normalized => '85000002',
                 :year_cataloged => 1985,
                 :serial => '000002',
              },
              {  :orig => '2001-000002',
                 :canonical => '  2001000002',
                 :normalized => '2001000002',
                 :year_cataloged => 2001,
                 :serial => '000002',
              },
              {  :orig => '75-425165//r75',
                 :canonical => '   75425165 //r75',
                 :normalized => '75425165',
                 :prefix => '',
                 :year_cataloged => nil,
                 :serial => '425165',
                 :revision_year => 1975,
                 :revision_year_encoded => '75',
                 :revision_number => nil,
              },
              {  :orig => ' 79139101 /AC/r932',
                 :canonical => '   79139101 /AC/r932',
                 :normalized => '79139101',
                 :prefix => '',
                 :year_cataloged => nil,
                 :serial => '139101',
                 :suffix_encoded => '/AC',
                 :revision_year => 1993,
                 :revision_year_encoded => '93',
                 :revision_number => 2,
              },
              {  :orig => '89-4',
                 :canonical => '   89000004 ',
                 :normalized => '89000004',
                 :year_cataloged => 1989,
                 :serial => '000004',
              },
              {  :orig => '89-45',
                 :canonical => '   89000045 ',
                 :normalized => '89000045',
                 :year_cataloged => 1989,
                 :serial => '000045',
              },
              {  :orig => '89-456',
                 :canonical => '   89000456 ',
                 :normalized => '89000456',
                 :year_cataloged => 1989,
                 :serial => '000456',
              },
              {  :orig => '89-1234',
                 :canonical => '   89001234 ',
                 :normalized => '89001234',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => '89-001234',
                 :canonical => '   89001234 ',
                 :normalized => '89001234',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => '89001234',
                 :canonical => '   89001234 ',
                 :normalized => '89001234',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => '2002-1234',
                 :canonical => '  2002001234',
                 :normalized => '2002001234',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => '2002-001234',
                 :canonical => '  2002001234',
                 :normalized => '2002001234',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => '2002001234',
                 :canonical => '  2002001234',
                 :normalized => '2002001234',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => '   89001234 ',
                 :canonical => '   89001234 ',
                 :normalized => '89001234',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => '  2002001234',
                 :canonical => '  2002001234',
                 :normalized => '2002001234',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'a89-1234',
                 :canonical => 'a  89001234 ',
                 :normalized => 'a89001234',
                 :prefix => 'a',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'a89-001234',
                 :canonical => 'a  89001234 ',
                 :normalized => 'a89001234',
                 :prefix => 'a',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'a89001234',
                 :canonical => 'a  89001234 ',
                 :normalized => 'a89001234',
                 :prefix => 'a',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'a2002-1234',
                 :canonical => 'a 2002001234',
                 :normalized => 'a2002001234',
                 :prefix => 'a',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'a2002-001234',
                 :canonical => 'a 2002001234',
                 :normalized => 'a2002001234',
                 :prefix => 'a',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'a2002001234',
                 :canonical => 'a 2002001234',
                 :normalized => 'a2002001234',
                 :prefix => 'a',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'a 89001234 ',
                 :canonical => 'a  89001234 ',
                 :normalized => 'a89001234',
                 :prefix => 'a',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'a 89-001234 ',
                 :canonical => 'a  89001234 ',
                 :normalized => 'a89001234',
                 :prefix => 'a',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'a 2002001234',
                 :canonical => 'a 2002001234',
                 :normalized => 'a2002001234',
                 :prefix => 'a',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'ab89-1234',
                 :canonical => 'ab 89001234 ',
                 :normalized => 'ab89001234',
                 :prefix => 'ab',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'ab89-001234',
                 :canonical => 'ab 89001234 ',
                 :normalized => 'ab89001234',
                 :prefix => 'ab',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'ab89001234',
                 :canonical => 'ab 89001234 ',
                 :normalized => 'ab89001234',
                 :prefix => 'ab',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'ab2002-1234',
                 :canonical => 'ab2002001234',
                 :normalized => 'ab2002001234',
                 :prefix => 'ab',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'ab2002-001234',
                 :canonical => 'ab2002001234',
                 :normalized => 'ab2002001234',
                 :prefix => 'ab',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'ab2002001234',
                 :canonical => 'ab2002001234',
                 :normalized => 'ab2002001234',
                 :prefix => 'ab',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'ab 89001234 ',
                 :canonical => 'ab 89001234 ',
                 :normalized => 'ab89001234',
                 :prefix => 'ab',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'ab 2002001234',
                 :canonical => 'ab2002001234',
                 :normalized => 'ab2002001234',
                 :prefix => 'ab',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'ab 89-1234',
                 :canonical => 'ab 89001234 ',
                 :normalized => 'ab89001234',
                 :prefix => 'ab',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'abc89-1234',
                 :canonical => 'abc89001234 ',
                 :normalized => 'abc89001234',
                 :prefix => 'abc',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'abc89-001234',
                 :canonical => 'abc89001234 ',
                 :normalized => 'abc89001234',
                 :prefix => 'abc',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'abc89001234',
                 :canonical => 'abc89001234 ',
                 :normalized => 'abc89001234',
                 :prefix => 'abc',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'abc89001234 ',
                 :canonical => 'abc89001234 ',
                 :normalized => 'abc89001234',
                 :prefix => 'abc',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'http://lccn.loc.gov/89001234',
                 :canonical => '   89001234 ',
                 :normalized => '89001234',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'http://lccn.loc.gov/a89001234',
                 :canonical => 'a  89001234 ',
                 :normalized => 'a89001234',
                 :serial => '001234',
                 :prefix => 'a',
                 :year_cataloged => 1989,
              },
              {  :orig => 'http://lccn.loc.gov/ab89001234',
                 :canonical => 'ab 89001234 ',
                 :normalized => 'ab89001234',
                 :prefix => 'ab',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'http://lccn.loc.gov/abc89001234',
                 :canonical => 'abc89001234 ',
                 :normalized => 'abc89001234',
                 :prefix => 'abc',
                 :year_cataloged => 1989,
                 :serial => '001234',
              },
              {  :orig => 'http://lccn.loc.gov/2002001234',
                 :canonical => '  2002001234',
                 :normalized => '2002001234',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'http://lccn.loc.gov/a2002001234',
                 :canonical => 'a 2002001234',
                 :normalized => 'a2002001234',
                 :prefix => 'a',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => 'http://lccn.loc.gov/ab2002001234',
                 :canonical => 'ab2002001234',
                 :normalized => 'ab2002001234',
                 :prefix => 'ab',
                 :year_cataloged => 2002,
                 :serial => '001234',
              },
              {  :orig => '00-21595',
                 :canonical => '   00021595 ',
                 :normalized => '00021595',
                 :year_cataloged => 2000,
                 :serial => '021595',
              },
              {  :orig => '2001001599',
                 :canonical => '  2001001599',
                 :normalized => '2001001599',
                 :year_cataloged => 2001,
                 :serial => '001599',
              },
              {  :orig => '99-18233',
                 :canonical => '   99018233 ',
                 :normalized => '99018233',
                 :year_cataloged => 1999,
                 :serial => '018233',
              },
              {  :orig => '98000595',
                 :canonical => '   98000595 ',
                 :normalized => '98000595',
                 :year_cataloged => 1898,
                 :serial => '000595',
              },
              {  :orig => '99005074',
                 :canonical => '   99005074 ',
                 :normalized => '99005074',
                 :year_cataloged => 1899,
                 :serial => '005074',
              },
              {  :orig => '00003373',
                 :canonical => '   00003373 ',
                 :normalized => '00003373',
                 :year_cataloged => 1900,
                 :serial => '003373',
              },
              {  :orig => '01001599',
                 :canonical => '   01001599 ',
                 :normalized => '01001599',
                 :year_cataloged => 1901,
                 :serial => '001599',
              },
              {  :orig => '   95156543 ',
                 :canonical => '   95156543 ',
                 :normalized => '95156543',
                 :year_cataloged => 1995,
                 :serial => '156543',
              },
              {  :orig => '   94014580 /AC/r95',
                 :canonical => '   94014580 /AC/r95',
                 :normalized => '94014580',
                 :year_cataloged => 1994,
                 :serial => '014580',
                 :suffix_encoded => '/AC',
                 :revision_year_encoded => '95',
                 :revision_year => 1995,
              },
              {  :orig => '   79310919 //r86',
                 :canonical => '   79310919 //r86',
                 :normalized => '79310919',
                 :year_cataloged => 1979,
                 :serial => '310919',
                 :revision_year_encoded => '86',
                 :revision_year => 1986,
              },
              {  :orig => 'gm 71005810  ',
                 :canonical => 'gm 71005810 ',
                 :normalized => 'gm71005810',
                 :prefix => 'gm',
                 :year_cataloged => 1971,
                 :serial => '005810',
              },
              {  :orig => 'sn2006058112  ',
                 :canonical => 'sn2006058112',
                 :normalized => 'sn2006058112',
                 :prefix => 'sn',
                 :year_cataloged => 2006,
                 :serial => '058112',
              },
              {  :orig => 'gm 71-2450',
                 :canonical => 'gm 71002450 ',
                 :normalized => 'gm71002450',
                 :prefix => 'gm',
                 :year_cataloged => 1971,
                 :serial => '002450',
              },
              {  :orig => '2001-1114',
                 :canonical => '  2001001114',
                 :normalized => '2001001114',
                 :year_cataloged => 2001,
                 :serial => '001114',
              },
  ]
  tests.each do |h|
    it "normalizes #{h[:orig]}" do
      StdNum::LCCN.normalize(h[:orig]).must_equal h[:normalized], "#{h[:orig]} doesn't normalize to #{h[:normalized]}"
    end
  end
end


Avoid developer log warning in EE 2.6 and up

<?php if ( ! defined('BASEPATH')) exit('No direct script access allowed');

/**
 * NSM TinyMCE Fieldtype
 *
 * @package			NsmTinyMCE
 * @version			1.1.1
 * @author			Leevi Graham <http://leevigraham.com> - Technical Director, Newism
 * @copyright 		Copyright (c) 2007-2010 Newism <http://newism.com.au>
 * @license 		Commercial - please see LICENSE file included with this distribution
 * @link			http://ee-garage.com/nsm-tiny-mce
 * @see				http://expressionengine.com/public_beta/docs/development/fieldtypes.html
 */
class Nsm_tiny_mce_ft extends EE_Fieldtype
{
	/**
	 * Field info - Required
	 *
	 * @access public
	 * @var array
	 */
	public $info = array(
		'name'		=> 'NSM TinyMCE',
		'version'	=> '1.1.1'
	);

	/**
	 * The field settings array
	 *
	 * @access public
	 * @var array
	 */
	public $settings = array();

	/**
	 * Path to the TinyMCE config files. Set in the constructor
	 *
	 * @access private
	 * @var string
	 */
	private $tiny_mce_config_path = "";

	/**
	 * The field type - used for form field prefixes. Must be unique and match the class name. Set in the constructor
	 *
	 * @access private
	 * @var string
	 */
	private $field_type = '';

	/**
	 * Constructor
	 *
	 * @access public
	 *
	 * Calls the parent constructor
	 * Sets the tiny_mce_config_path using the PATH_THRID variable
	 */
	public function __construct()
	{
		parent::EE_Fieldtype();

		$this->tiny_mce_config_path = PATH_THIRD_THEMES . "nsm_tiny_mce/scripts/tiny_mce_config/";

		$this->field_type = $this->addon_id = strtolower(substr(__CLASS__, 0, -3));

		if(!isset($this->EE->session->cache[__CLASS__]))
		{
			$this->EE->session->cache[__CLASS__]['loaded_configs'] = array();
		}
	}

	/**
	 * Replaces the custom field tag
	 *
	 * @access public
	 * @param $data string Contains the field data (or prepped data, if using pre_process)
	 * @param $params array Contains field parameters (if any)
	 * @param $tagdata mixed Contains data between tag (for tag pairs) FALSE for single tags
	 * @return string The HTML replacing the tag
	 *
	 */
	public function replace_tag($data, $params = FALSE, $tagdata = FALSE)
	{
		return $data;
	}

	/**
	 * Display the field in the publish form
	 *
	 * @access public
	 * @param $data String Contains the current field data. Blank for new entries.
	 * @return String The custom field HTML
	 *
	 * Includes the TinyMCE base script and the field specific configuration.
	 * Returns a standard textarea with a configuration specific class
	 */
	public function display_field($data, $field_id = false)
	{
		$this->_addConfJs($this->settings["conf"]);

		if(!$field_id)
			$field_id = $this->field_name;

		$this->EE->cp->add_to_foot('');

		return form_textarea(array(
			'name'	=> $this->field_name,
			'id'	=> $field_id,
			'value'	=> $data,
			'rows' => ' ',
			'style' => "height: {$this->settings['height']}px"
		));
	}

	/**
	 * Displays the cell
	 *
	 * @access public
	 * @param $data The cell data
	 */
	public function display_cell($data)
	{
		$this->_addConfJs($this->settings["conf"]);

		if(!isset($this->EE->session->cache[__CLASS__]['cell_js_loaded']))
		{
			$theme_url = $this->_getThemeUrl();
			$this->EE->cp->add_to_foot("");
			$this->EE->session->cache[__CLASS__]['cell_js_loaded'] = TRUE;
		}

		$this->EE->cp->add_to_foot('');

		return '<textarea name="'.$this->cell_name.'" id="'.$this->col_id.'" style="height: '.$this->settings['height'].'px">'.$data.'</textarea>';
	}

	/**
	 * Displays the Low Variable field
	 *
	 * @access public
	 * @param $var_data The variable data
	 * @see http://loweblog.com/software/low-variables/docs/fieldtype-bridge/
	 */
	public function display_var_field($var_data)
	{
		return $this->display_field($var_data, "nsm_tiny_mce_"  . substr($this->field_name, 4, 1));
	}

	/**
	 * Publish form validation
	 *
	 * @param $data array Contains the submitted field data.
	 * @return mixed TRUE or an error message
	 */
	public function validate($data)
	{
		return TRUE;
	}

	/**
	 * Default field settings
	 *
	 * @access private
	 * @return The default field settings
	 */
	private function _defaultFieldSettings(){
		return array(
			"conf" => FALSE,
			"height" => 300
		);
	}

	/**
	 * Save the custom field settings
	 *
	 * @param $data array Not sure what this is yet, probably the submitted post data.
	 * @return boolean Valid or not
	 */
	public function save_settings($field_settings)
	{
		$field_settings = array_merge($this->_defaultFieldSettings(), $this->EE->input->post('nsm_tiny_mce'));

		// Force formatting
		$field_settings['field_fmt'] = 'none';
		$field_settings['field_show_fmt'] = 'n';
		$field_settings['field_type'] = 'nsm_tiny_mce';

		// Cleanup
		unset($_POST['nsm_tiny_mce']);
		foreach (array_keys($field_settings) as $setting)
		{
			if (isset($_POST["nsm_tiny_mce_{$setting}"]))
			{
				unset($_POST["nsm_tiny_mce_{$setting}"]);
			}
		}

		return $field_settings;
	}

	/**
	 * Process the cell settings before saving
	 *
	 * @access public
	 * @param $col_settings array The settings for the column
	 * @return array The new settings
	 */
	public function save_cell_settings($col_settings)
	{
		$col_settings = $col_settings['nsm_tiny_mce'];
		return $col_settings;
	}

	/**
	 * Save the Low variable settings
	 *
	 * @access public
	 * @param $var_settings The variable settings
	 * @see http://loweblog.com/software/low-variables/docs/fieldtype-bridge/
	 */
	public function save_var_settings($var_settings)
	{
		return $this->EE->input->post('nsm_tiny_mce');
	}

	/**
	 * Prepares settings array for fields and matrix cells
	 *
	 * @access public
	 * @param $settings array The field / cell settings
	 * @return array Labels and form inputs
	 */
	private function _fieldSettings($settings)
	{
		$r = array();

		// TinyMCE height
		$r[] = array(
			lang('Height in px', 'nsm_tiny_mce_height'),
			form_input("nsm_tiny_mce[height]", $settings['height'], "id='nsm_tiny_mce_height' class='matrix-textarea'")
		);

		// Configs
		if($configs = $this->_readTinyMCEConfigs())
		{
			foreach ($configs as $key => $value)
			{
				$options[$key] = ucfirst(str_replace(array("_", ".js"), array(""), $key));
			}
			$confs = form_dropdown("nsm_tiny_mce[conf]", $options, $settings['conf'], "id='nsm_tiny_mce_conf'");
		}
		else
		{
			$confs = "

							No configuration files could be found. Check that
							".$this->tiny_mce_config_path."
							is readable and contains at least one configuration file.

";
			$confs .= form_hidden("nsm_tiny_mce[conf]", '');
		}

		$r[] = array(
					lang('Configuration', 'nsm_tiny_mce_conf'),
					$confs
				);

		return $r;
	}

	/**
	 * Display the settings form for each custom field
	 *
	 * @access public
	 * @param $data mixed Not sure what this data is yet :S
	 * @return string Override the field custom settings with custom html
	 *
	 * In this case we add an extra row to the table. Not sure how the table is built
	 */
	public function display_settings($field_settings)
	{
		$field_settings = array_merge($this->_defaultFieldSettings(), $field_settings);
		$rows = $this->_fieldSettings($field_settings);

		// add the rows
		foreach ($rows as $row)
		{
			$this->EE->table->add_row($row[0], $row[1]);
		}
	}

	/**
	 * Display Cell Settings
	 *
	 * @access public
	 * @param $cell_settings array The cell settings
	 * @return array Label and form inputs
	 */
	public function display_cell_settings($cell_settings)
	{
		$cell_settings = array_merge($this->_defaultFieldSettings(), $cell_settings);
		return $this->_fieldSettings($cell_settings);
	}

	/**
	 * Display Variable Settings
	 *
	 * @access public
	 * @param $var_settings array The variable settings
	 * @return array Label and form inputs
	 */
	public function display_var_settings($var_settings)
	{
		$var_settings = array_merge($this->_defaultFieldSettings(), $var_settings);
		return $this->_fieldSettings($var_settings);
	}

	/**
	 * Adds the TinyMCE configuration to the CP
	 */
	private function _addConfJs($conf, $cell = FALSE)
	{

		$theme_url = $this->_getThemeUrl();

		if(!isset($this->EE->session->cache[__CLASS__]['tiny_mce_loaded']))
		{
			$this->EE->cp->add_to_head("<link rel='stylesheet' href='{$theme_url}/styles/admin.css' type='text/css' media='screen' charset='utf-8' />");

			$this->EE->cp->add_to_foot("");
			$this->EE->cp->add_to_foot('');
			$this->EE->session->cache[__CLASS__]['tiny_mce_loaded'] = TRUE;
		}

		if(!in_array($conf, $this->EE->session->cache[__CLASS__]['loaded_configs']))
		{
			$this->EE->session->cache[__CLASS__]['loaded_configs'][] = $conf;
			$this->EE->cp->add_to_foot("");

		}
	}

	/**
	 * Get the current themes URL from the theme folder + / + the addon id
	 *
	 * @access private
	 * @return string The theme URL
	 */
	private function _getThemeUrl()
	{
		$EE =& get_instance();
		if(!isset($EE->session->cache[$this->addon_id]['theme_url']))
		{
			$theme_url = URL_THIRD_THEMES;
			if (substr($theme_url, -1) != '/') $theme_url .= '/';
			$theme_url .= $this->addon_id;
			$EE->session->cache[$this->addon_id]['theme_url'] = $theme_url;
		}
		return $EE->session->cache[$this->addon_id]['theme_url'];
	}

	/**
	 * Reads the custom TinyMCE configs from the directory
	 *
	 * @access private
	 * @return mixed FALSE if no configs are found or an array of filenames
	 */
	private function _readTinyMCEConfigs()
	{
		// have the configs been processed?
		if(isset($this->EE->session->cache[__CLASS__]['tiny_mce_configs']) === FALSE)
		{
			// assume there are no configs
			$configs = FALSE;
			// if the provided string an actual directory
			if(is_dir($dir = $this->tiny_mce_config_path))
			{
				// open the directory and assign it to a handle
				$dir_handle = opendir($dir);
				// if there is a dir handle
				if($dir_handle)
				{
					/* This is the correct way to loop over the directory. */
		    		// loop over the files
					while (false !== ($file = readdir($dir_handle)))
					{
						// if this is a real file
						if ($file != "." && $file != ".." && $file != "Thumb.db" && substr($file, 0, 1) != '-' && substr($file, -3) == ".js")
						{
							// add the config to the list
							$configs[$file] = file_get_contents($dir.$file);
						}
					}
				}
			}
			// assign the configs to a session var
			$this->EE->session->cache[__CLASS__]['tiny_mce_configs'] = $configs;
		}

		// return the session var
		return $this->EE->session->cache[__CLASS__]['tiny_mce_configs'];
	}

}
//END CLASS

<?php if ( ! defined('BASEPATH')) exit('No direct script access allowed');

/**
 * NSM TinyMCE Fieldtype
 *
 * @package			NsmTinyMCE
 * @version			1.1.1
 * @author			Leevi Graham <http://leevigraham.com> - Technical Director, Newism
 * @copyright 		Copyright (c) 2007-2010 Newism <http://newism.com.au>
 * @license 		Commercial - please see LICENSE file included with this distribution
 * @link			http://ee-garage.com/nsm-tiny-mce
 * @see				http://expressionengine.com/public_beta/docs/development/fieldtypes.html
 */
class Nsm_tiny_mce_ft extends EE_Fieldtype
{
	/**
	 * Field info - Required
	 *
	 * @access public
	 * @var array
	 */
	public $info = array(
		'name'		=> 'NSM TinyMCE',
		'version'	=> '1.1.1'
	);

	/**
	 * The field settings array
	 *
	 * @access public
	 * @var array
	 */
	public $settings = array();

	/**
	 * Path to the TinyMCE config files. Set in the constructor
	 *
	 * @access private
	 * @var string
	 */
	private $tiny_mce_config_path = "";

	/**
	 * The field type - used for form field prefixes. Must be unique and match the class name. Set in the constructor
	 *
	 * @access private
	 * @var string
	 */
	private $field_type = '';

	/**
	 * Constructor
	 *
	 * @access public
	 *
	 * Calls the parent constructor
	 * Sets the tiny_mce_config_path using the PATH_THRID variable
	 */
	public function __construct()
	{
		parent::__construct();

		$this->tiny_mce_config_path = PATH_THIRD_THEMES . "nsm_tiny_mce/scripts/tiny_mce_config/";

		$this->field_type = $this->addon_id = strtolower(substr(__CLASS__, 0, -3));

		if(!isset($this->EE->session->cache[__CLASS__]))
		{
			$this->EE->session->cache[__CLASS__]['loaded_configs'] = array();
		}
	}

	/**
	 * Replaces the custom field tag
	 *
	 * @access public
	 * @param $data string Contains the field data (or prepped data, if using pre_process)
	 * @param $params array Contains field parameters (if any)
	 * @param $tagdata mixed Contains data between tag (for tag pairs) FALSE for single tags
	 * @return string The HTML replacing the tag
	 *
	 */
	public function replace_tag($data, $params = FALSE, $tagdata = FALSE)
	{
		return $data;
	}

	/**
	 * Display the field in the publish form
	 *
	 * @access public
	 * @param $data String Contains the current field data. Blank for new entries.
	 * @return String The custom field HTML
	 *
	 * Includes the TinyMCE base script and the field specific configuration.
	 * Returns a standard textarea with a configuration specific class
	 */
	public function display_field($data, $field_id = false)
	{
		$this->_addConfJs($this->settings["conf"]);

		if(!$field_id)
			$field_id = $this->field_name;

		$this->EE->cp->add_to_foot('');

		return form_textarea(array(
			'name'	=> $this->field_name,
			'id'	=> $field_id,
			'value'	=> $data,
			'rows' => ' ',
			'style' => "height: {$this->settings['height']}px"
		));
	}

	/**
	 * Displays the cell
	 *
	 * @access public
	 * @param $data The cell data
	 */
	public function display_cell($data)
	{
		$this->_addConfJs($this->settings["conf"]);

		if(!isset($this->EE->session->cache[__CLASS__]['cell_js_loaded']))
		{
			$theme_url = $this->_getThemeUrl();
			$this->EE->cp->add_to_foot("");
			$this->EE->session->cache[__CLASS__]['cell_js_loaded'] = TRUE;
		}

		$this->EE->cp->add_to_foot('');

		return '<textarea name="'.$this->cell_name.'" id="'.$this->col_id.'" style="height: '.$this->settings['height'].'px">'.$data.'</textarea>';
	}

	/**
	 * Displays the Low Variable field
	 *
	 * @access public
	 * @param $var_data The variable data
	 * @see http://loweblog.com/software/low-variables/docs/fieldtype-bridge/
	 */
	public function display_var_field($var_data)
	{
		return $this->display_field($var_data, "nsm_tiny_mce_"  . substr($this->field_name, 4, 1));
	}

	/**
	 * Publish form validation
	 *
	 * @param $data array Contains the submitted field data.
	 * @return mixed TRUE or an error message
	 */
	public function validate($data)
	{
		return TRUE;
	}

	/**
	 * Default field settings
	 *
	 * @access private
	 * @return The default field settings
	 */
	private function _defaultFieldSettings(){
		return array(
			"conf" => FALSE,
			"height" => 300
		);
	}

	/**
	 * Save the custom field settings
	 *
	 * @param $data array Not sure what this is yet, probably the submitted post data.
	 * @return boolean Valid or not
	 */
	public function save_settings($field_settings)
	{
		$field_settings = array_merge($this->_defaultFieldSettings(), $this->EE->input->post('nsm_tiny_mce'));

		// Force formatting
		$field_settings['field_fmt'] = 'none';
		$field_settings['field_show_fmt'] = 'n';
		$field_settings['field_type'] = 'nsm_tiny_mce';

		// Cleanup
		unset($_POST['nsm_tiny_mce']);
		foreach (array_keys($field_settings) as $setting)
		{
			if (isset($_POST["nsm_tiny_mce_{$setting}"]))
			{
				unset($_POST["nsm_tiny_mce_{$setting}"]);
			}
		}

		return $field_settings;
	}

	/**
	 * Process the cell settings before saving
	 *
	 * @access public
	 * @param $col_settings array The settings for the column
	 * @return array The new settings
	 */
	public function save_cell_settings($col_settings)
	{
		$col_settings = $col_settings['nsm_tiny_mce'];
		return $col_settings;
	}

	/**
	 * Save the Low variable settings
	 *
	 * @access public
	 * @param $var_settings The variable settings
	 * @see http://loweblog.com/software/low-variables/docs/fieldtype-bridge/
	 */
	public function save_var_settings($var_settings)
	{
		return $this->EE->input->post('nsm_tiny_mce');
	}

	/**
	 * Prepares settings array for fields and matrix cells
	 *
	 * @access public
	 * @param $settings array The field / cell settings
	 * @return array Labels and form inputs
	 */
	private function _fieldSettings($settings)
	{
		$r = array();

		// TinyMCE height
		$r[] = array(
			lang('Height in px', 'nsm_tiny_mce_height'),
			form_input("nsm_tiny_mce[height]", $settings['height'], "id='nsm_tiny_mce_height' class='matrix-textarea'")
		);

		// Configs
		if($configs = $this->_readTinyMCEConfigs())
		{
			foreach ($configs as $key => $value)
			{
				$options[$key] = ucfirst(str_replace(array("_", ".js"), array(""), $key));
			}
			$confs = form_dropdown("nsm_tiny_mce[conf]", $options, $settings['conf'], "id='nsm_tiny_mce_conf'");
		}
		else
		{
			$confs = "

							No configuration files could be found. Check that
							".$this->tiny_mce_config_path."
							is readable and contains at least one configuration file.

";
			$confs .= form_hidden("nsm_tiny_mce[conf]", '');
		}

		$r[] = array(
					lang('Configuration', 'nsm_tiny_mce_conf'),
					$confs
				);

		return $r;
	}

	/**
	 * Display the settings form for each custom field
	 *
	 * @access public
	 * @param $data mixed Not sure what this data is yet :S
	 * @return string Override the field custom settings with custom html
	 *
	 * In this case we add an extra row to the table. Not sure how the table is built
	 */
	public function display_settings($field_settings)
	{
		$field_settings = array_merge($this->_defaultFieldSettings(), $field_settings);
		$rows = $this->_fieldSettings($field_settings);

		// add the rows
		foreach ($rows as $row)
		{
			$this->EE->table->add_row($row[0], $row[1]);
		}
	}

	/**
	 * Display Cell Settings
	 *
	 * @access public
	 * @param $cell_settings array The cell settings
	 * @return array Label and form inputs
	 */
	public function display_cell_settings($cell_settings)
	{
		$cell_settings = array_merge($this->_defaultFieldSettings(), $cell_settings);
		return $this->_fieldSettings($cell_settings);
	}

	/**
	 * Display Variable Settings
	 *
	 * @access public
	 * @param $var_settings array The variable settings
	 * @return array Label and form inputs
	 */
	public function display_var_settings($var_settings)
	{
		$var_settings = array_merge($this->_defaultFieldSettings(), $var_settings);
		return $this->_fieldSettings($var_settings);
	}

	/**
	 * Adds the TinyMCE configuration to the CP
	 */
	private function _addConfJs($conf, $cell = FALSE)
	{

		$theme_url = $this->_getThemeUrl();

		if(!isset($this->EE->session->cache[__CLASS__]['tiny_mce_loaded']))
		{
			$this->EE->cp->add_to_head("<link rel='stylesheet' href='{$theme_url}/styles/admin.css' type='text/css' media='screen' charset='utf-8' />");

			$this->EE->cp->add_to_foot("");
			$this->EE->cp->add_to_foot('');
			$this->EE->session->cache[__CLASS__]['tiny_mce_loaded'] = TRUE;
		}

		if(!in_array($conf, $this->EE->session->cache[__CLASS__]['loaded_configs']))
		{
			$this->EE->session->cache[__CLASS__]['loaded_configs'][] = $conf;
			$this->EE->cp->add_to_foot("");

		}
	}

	/**
	 * Get the current themes URL from the theme folder + / + the addon id
	 *
	 * @access private
	 * @return string The theme URL
	 */
	private function _getThemeUrl()
	{
		$EE =& get_instance();
		if(!isset($EE->session->cache[$this->addon_id]['theme_url']))
		{
			$theme_url = URL_THIRD_THEMES;
			if (substr($theme_url, -1) != '/') $theme_url .= '/';
			$theme_url .= $this->addon_id;
			$EE->session->cache[$this->addon_id]['theme_url'] = $theme_url;
		}
		return $EE->session->cache[$this->addon_id]['theme_url'];
	}

	/**
	 * Reads the custom TinyMCE configs from the directory
	 *
	 * @access private
	 * @return mixed FALSE if no configs are found or an array of filenames
	 */
	private function _readTinyMCEConfigs()
	{
		// have the configs been processed?
		if(isset($this->EE->session->cache[__CLASS__]['tiny_mce_configs']) === FALSE)
		{
			// assume there are no configs
			$configs = FALSE;
			// if the provided string an actual directory
			if(is_dir($dir = $this->tiny_mce_config_path))
			{
				// open the directory and assign it to a handle
				$dir_handle = opendir($dir);
				// if there is a dir handle
				if($dir_handle)
				{
					/* This is the correct way to loop over the directory. */
		    		// loop over the files
					while (false !== ($file = readdir($dir_handle)))
					{
						// if this is a real file
						if ($file != "." && $file != ".." && $file != "Thumb.db" && substr($file, 0, 1) != '-' && substr($file, -3) == ".js")
						{
							// add the config to the list
							$configs[$file] = file_get_contents($dir.$file);
						}
					}
				}
			}
			// assign the configs to a session var
			$this->EE->session->cache[__CLASS__]['tiny_mce_configs'] = $configs;
		}

		// return the session var
		return $this->EE->session->cache[__CLASS__]['tiny_mce_configs'];
	}

}
//END CLASS