001  (ns ^{:doc "Internationalisation."
                
                002        :author "Simon Brooke"}
                
                003   scot.weft.i18n.core
                
                004    (:require [clojure.java.io :as io]
                
                005              [clojure.pprint :refer [pprint]]
                
                006              [clojure.string :refer [join]]
                
                007              [instaparse.core :as insta]
                
                008              [taoensso.timbre :as timbre]
                
                009              [trptr.java-wrapper.locale :as locale])
                
                010    (:import [clojure.lang Keyword]))
                
                011  
                
                012  ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
                
                013  ;;;;
                
                014  ;;;; scot.weft.i18n: a simple internationalisation library for Clojure.
                
                015  ;;;;
                
                016  ;;;; This library is distributed under the Eclipse Licence in the hope
                
                017  ;;;; that it may be useful, but without guarantee.
                
                018  ;;;;
                
                019  ;;;; Copyright (C) 2017 Simon Brooke
                
                020  ;;;;
                
                021  ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
                
                022  
                
                023  (def ^:dynamic *resource-path*
                
                024    "The default path within the resources space on which translation files 
                
                025     will be sought."
                
                026    "i18n")
                
                027  
                
                028  (def ^:dynamic *default-language*
                
                029    "The default language to seek."
                
                030    (-> (locale/get-default) locale/to-language-tag))
                
                031  
                
                032  (def accept-language-grammar
                
                033    "Grammar for `Accept-Language` headers"
                
                034    "HEADER := SPECIFIER | SPECIFIERS;
                
                035    SPECIFIERS:= SPECIFIER | SPECIFIER SPEC-SEP SPECIFIERS;
                
                036    SPEC-SEP := #',\\s*';
                
                037    SPECIFIER := LANGUAGE-TAG | LANGUAGE-TAG Q-SEP Q-VALUE;
                
                038    LANGUAGE-TAG := PRIMARY-TAG | PRIMARY-TAG '-' SUB-TAGS;
                
                039    PRIMARY-TAG := #'[a-zA-Z]+';
                
                040    SUB-TAGS := SUB-TAG | SUB-TAG '-' SUB-TAGS;
                
                041    SUB-TAG := #'[a-zA-Z0-9]+';
                
                042    Q-SEP := #';\\s*q='
                
                043    Q-VALUE := '1' | #'0.[0-9]+';")
                
                044  
                
                045  (def parse-accept-language-header
                
                046    "Parse an `Accept-Language` header"
                
                047    (insta/parser accept-language-grammar))
                
                048  
                
                049  (defn generate-accept-languages
                
                050    "From a `parse-tree` generated by the `language-specifier-grammar`, generate
                
                051    a list of maps each having a `:language` key, a `:preference` key and a
                
                052    `:qualifier` key."
                
                053    {:doc/format :markdown}
                
                054    [parse-tree]
                
                055    (if
                
                056     (nil? parse-tree)
                
                057      nil
                
                058      (case
                
                059       (first parse-tree)
                
                060        :HEADER (generate-accept-languages (second parse-tree))
                
                061        :SPECIFIERS (cons
                
                062                     (generate-accept-languages (second parse-tree))
                
                063                     (when (>= (count parse-tree) 3)
                
                064                       (generate-accept-languages (nth parse-tree 3))))
                
                065        :SPEC-SEP nil
                
                066        :SPECIFIER (assoc
                
                067                    (generate-accept-languages (second parse-tree))
                
                068                    :preference
                
                069                    (if
                
                070                     (>= (count parse-tree) 3)
                
                071                      (generate-accept-languages (nth parse-tree 3))
                
                072                      1))
                
                073        :LANGUAGE-TAG (if
                
                074                       (>= (count parse-tree) 3)
                
                075                        (assoc
                
                076                         (generate-accept-languages (second parse-tree))
                
                077                         :qualifier
                
                078                         (generate-accept-languages (nth parse-tree 3)))
                
                079                        (generate-accept-languages (second parse-tree)))
                
                080        :PRIMARY-TAG {:language (second parse-tree) :qualifier "*"}
                
                081        :SUB-TAGS (if
                
                082                   (>= (count parse-tree) 3)
                
                083                    (str
                
                084                     (generate-accept-languages (second parse-tree))
                
                085                     "-"
                
                086                     (generate-accept-languages (nth parse-tree 3)))
                
                087                    (generate-accept-languages (second parse-tree)))
                
                088        :SUB-TAG (second parse-tree)
                
                089        :Q-SEP nil
                
                090        :Q-VALUE (read-string (second parse-tree))
                
                091        ;; default
                
                092        (do
                
                093          (timbre/error "Unable to parse header.")
                
                094          nil))))
                
                095  
                
                096  (defn acceptable-languages
                
                097    "Generate an ordered list of acceptable languages, most-preferred first.
                
                098  
                
                099    * `accept-language-header` should be the value of an RFC2616 `Accept-Language` header.
                
                100  
                
                101    Returns a list of maps as generated by `generate-accept-languages`, in descending order
                
                102    of preference."
                
                103    {:doc/format :markdown}
                
                104    [accept-language-header]
                
                105    (let [parse-tree (parse-accept-language-header accept-language-header)]
                
                106      (if (vector? parse-tree)
                
                107        (reverse
                
                108         (sort-by
                
                109          :preference
                
                110          (generate-accept-languages
                
                111           parse-tree)))
                
                112        (timbre/error "Failed to parse Accept-Language header '" accept-language-header "':\n" (str parse-tree)))))
                
                113  
                
                114  
                
                115  (defn slurp-resource
                
                116    "Slurp the resource of this name and return its contents as a string; but if it doesn't
                
                117     exist log the fact and return nil, rather than throwing an exception."
                
                118    [name]
                
                119    (try
                
                120      (slurp (io/resource name))
                
                121      (catch Exception _
                
                122        (timbre/error (str "Resource at " name " does not exist."))
                
                123        nil)))
                
                124  
                
                125  
                
                126  (defn find-language-file-name
                
                127    "Find the name of a messages file on this resource path which matches this `language-spec`.
                
                128  
                
                129    * `language-spec` should be either a map as generated by `generate-accept-languages`, or
                
                130    else a string;
                
                131    * `resource-path` should be the path name of the directory in which message files are stored,
                
                132    within the resources on the classpath.
                
                133  
                
                134    Returns the name of an appropriate file if any is found, else nil."
                
                135    {:doc/format :markdown}
                
                136    [language-spec resource-path]
                
                137    (let [file-path (when
                
                138                     (string? language-spec)
                
                139                      (join
                
                140                       java.io.File/separator
                
                141                       [resource-path (str language-spec ".edn")]))
                
                142          contents (when file-path (slurp-resource file-path))]
                
                143      (cond
                
                144        contents
                
                145        file-path
                
                146        (map? language-spec)
                
                147        (or
                
                148         (find-language-file-name
                
                149          (str (:language language-spec) "-" (:qualifier language-spec))
                
                150          resource-path)
                
                151         (find-language-file-name
                
                152          (:language language-spec)
                
                153          resource-path)))))
                
                154  
                
                155  
                
                156  (defn raw-get-messages
                
                157    "Return the most acceptable messages collection we have given this `accept-language-header`.
                
                158    Do not use this function directly, use the memoized variant `get-messages`, as performance
                
                159    will be very much better.
                
                160  
                
                161    * `accept-language-header` should be the value of an RFC2616 `Accept-Language` header;
                
                162    * `resource-path` should be the fully-qualified path name of the directory in which
                
                163    message files are stored;
                
                164    * `default-locale` should be a locale specifier to use if no acceptable locale can be
                
                165    identified.
                
                166  
                
                167    Returns a map of message keys to strings; if no useable file is found, returns nil."
                
                168    {:doc/format :markdown}
                
                169    [^String accept-language-header ^String resource-path ^String default-locale]
                
                170    (let [file-path (first
                
                171                     (remove
                
                172                      nil?
                
                173                      (map
                
                174                       #(find-language-file-name % resource-path)
                
                175                       (acceptable-languages accept-language-header))))]
                
                176      (timbre/debug (str "Found i18n file at '" file-path "'"))
                
                177      (try
                
                178        (read-string
                
                179         (slurp-resource
                
                180          (or
                
                181           file-path
                
                182           (join java.io.File/separator
                
                183                 [resource-path
                
                184                  (str default-locale ".edn")]))))
                
                185        (catch Exception any
                
                186          (timbre/error (str "Failed to load internationalisation because " (.getMessage any)))
                
                187          nil))))
                
                188  
                
                189  (def get-messages
                
                190    "Return the most acceptable messages collection we have given this `accept-language-header`
                
                191  
                
                192    * `accept-language-header` should be the value of an RFC2616 `Accept-Language` header;
                
                193    * `resource-path` should be the fully-qualified path name of the directory in which
                
                194    message files are stored;
                
                195    * `default-locale` should be a locale specifier to use if no acceptable locale can be
                
                196    identified.
                
                197  
                
                198    Returns a map of message keys to strings.; if no useable file is found, returns nil."
                
                199    (memoize raw-get-messages))
                
                200  
                
                201  (def get-message
                
                202    "Return the message keyed by this `token` from the most acceptable messages collection  
                
                203     we have given this `accept-language-header`.
                
                204     
                
                205     * `token` should be a clojure keyword identifying the message to be retrieved;
                
                206     * `accept-language-header` should be the value of an RFC2616 `Accept-Language` header;
                
                207     * `resource-path` should be the fully-qualified path name of the directory in which
                
                208       message files are stored;
                
                209     * `default-locale` should be a locale specifier to use if no acceptable locale can be
                
                210       identified."
                
                211    (fn ([^Keyword token ^String accept-language-header ^String resource-path ^String default-locale]
                
                212         ((get-messages accept-language-header resource-path default-locale) token))
                
                213      ([^Keyword token ^String accept-language-header]
                
                214       (get-message token accept-language-header *resource-path* *default-language*))
                
                215      ([^Keyword token]
                
                216       (get-message token nil *resource-path* *default-language*))))