notmuch-web 0.1.1 → 0.1.2
raw patch · 37 files changed
+1990/−1380 lines, 37 filesdep +HUnitdep +markdowndep −monad-controldep −pandocdep −shakespeare-cssdep ~xss-sanitize
Dependencies added: HUnit, markdown
Dependencies removed: monad-control, pandoc, shakespeare-css, shakespeare-js, shakespeare-text, yesod-form
Dependency ranges changed: xss-sanitize
Files
- README.md +22/−7
- config/routes +4/−2
- config/settings.yml +36/−8
- main.hs +15/−5
- messages/en.msg +10/−0
- notmuch-web.cabal +114/−57
- package.sh +6/−1
- src/Application.hs +18/−13
- src/FilterHtml.hs +15/−59
- src/Foundation.hs +26/−59
- src/Handler/Abook.hs +47/−0
- src/Handler/Compose.hs +212/−188
- src/Handler/ComposeFields.hs +168/−0
- src/Handler/Home.hs +4/−6
- src/Handler/Pager.hs +5/−8
- src/Handler/Raw.hs +2/−3
- src/Handler/Tags.hs +19/−13
- src/Handler/View.hs +34/−38
- src/Import.hs +17/−12
- src/NotmuchCmd.hs +20/−6
- src/Settings.hs +24/−49
- src/StaticFiles.hs +4/−9
- static/js/jquery-pjax-1-7-0.js +0/−822
- static/js/jquery-pjax-1-7-3.js +838/−0
- static/js/select2-3-4-1.min.js +22/−0
- templates/abook.julius +44/−0
- templates/compose.hamlet +11/−1
- templates/compose.julius +130/−1
- templates/compose.lucius +5/−5
- templates/default-layout-wrapper.hamlet +7/−0
- templates/default-layout.cassius +2/−0
- templates/default-layout.hamlet +6/−5
- templates/google-contacts.hamlet +8/−0
- templates/google-contacts.julius +80/−0
- templates/plain-contacts.hamlet +6/−0
- templates/search.hamlet +4/−1
- tests/main.hs +5/−2
README.md view
@@ -2,12 +2,12 @@ This project is an email client for [notmuch](http://notmuchmail.org) written in [Haskell](http://www.haskell.org) and [Yesod](http://www.yesodweb.com). The code implements a web-server and uses [bootstrap](http://twitter.github.com/bootstrap/) and [jquery](http://jquery.com/)+server and uses [bootstrap](http://twbs.github.io/bootstrap/) and [jquery](http://jquery.com/) for the UI. The current features are -* Search messages -- [search screenshot](https://bitbucket.org/wuzzeb/notmuch-web/src/tip/screenshots/search.png) and- [pager screenshot](https://bitbucket.org/wuzzeb/notmuch-web/src/tip/screenshots/pager.png)+* Search messages - [search+ screenshot](https://bitbucket.org/wuzzeb/notmuch-web/src/tip/screenshots/search.png) and [pager+ screenshot](https://bitbucket.org/wuzzeb/notmuch-web/src/tip/screenshots/pager.png) * link default searches from the navigation bar * view results in a table, with customizable buttons for retagging each thread * view results in a pager, which shows the thread content together with a navigation bar with@@ -17,17 +17,27 @@ * a visible tree structure and the ability to collapse individual messages * download attachments from messages * customizable tagging buttons on each message+ * On small screens (under 700 pixels width like phones), parse text messages as markdown and+ display the original text message together with the resulting markdown HTML. This allows the+ message to be flowed to fit the small screen, but still allows one to view the original+ message. On larger screens, the original message is displayed by default but the markdown+ HTML is available in a tab. * Compose email * sending with attachments * supports reply and reply all- * for compose, I recommend [Its All+ * for composing messages, I recommend [Its All Text](https://addons.mozilla.org/en-us/firefox/addon/its-all-text/) to use your favorite editor.+ * An optional address book. Addresses can either be loaded from+ [abook](http://abook.sourceforge.net/) on the server (abook must be in the path) or from+ Google Contacts (loading from Google contacts requires a setting change in settings.yml). * Execute a raw notmuch command and view the results +[ChangeLog](https://bitbucket.org/wuzzeb/notmuch-web/src/tip/ChangeLog)+ # Quickstart -To quickly test out the client on linux, make sure you have libgmp, zlib, and libicu installed,+To quickly test out the client on linux, make sure you have libgmp and zlib installed, [download the latest binary](https://bitbucket.org/wuzzeb/notmuch-web/downloads), extract the tarball, then run @@ -45,7 +55,12 @@ One nice feature of GHC (the Haskell compiler) is the ability to statically link binaries to not require Haskell to be installed. I have therefore built the latest release; you can find the tarballs on the [download page](https://bitbucket.org/wuzzeb/notmuch-web/downloads). The only-prerequisites are glibc, libgmp, libicu, and zlib.+prerequisites are glibc, libgmp, and zlib. The binaries are missing one feature: use of+[libicu](http://site.icu-project.org/) to decode text/html message parts that have a charset that is+not ISO-8859 or UTF-8 (UTF-8 and ISO-8859 are decoded internally without the help of libicu). The+reason for this restriction is that libicu is not generally binary compatible between different+versions, so I could not link against a version of libicu that worked on many distributions. I am+working on removing this restriction so future versions should include all features. #### Source, latest released version
config/routes view
@@ -8,11 +8,11 @@ / HomeR GET /search SearchPostR POST /search/#String SearchR GET-/retag/thread/#ThreadID/#T.Text RetagThreadR POST+/retag/thread/#ThreadID/#Text RetagThreadR POST /customretag/thread/#ThreadID CustomRetagThreadR POST /thread/#ThreadID ThreadR GET-/retag/message/#MessageID/#T.Text RetagMessageR POST+/retag/message/#MessageID/#Text RetagMessageR POST /customretag/message/#MessageID CustomRetagMessageR POST /messagepart/#MessageID/#Int MessagePartR GET @@ -23,3 +23,5 @@ /compose ComposeR GET POST /reply/#MessageID ReplyR GET /replyall/#MessageID ReplyAllR GET+/previewmsg PreviewMessageR POST+/abook/query AbookQueryR GET
config/settings.yml view
@@ -1,7 +1,11 @@ Default: &defaults host: "*4" # any IPv4 host- port: 3000+ port: 3000 # can be overridden via the PORT environment variable or --port command line argument + # AGPLv3 requires a link to the source code on every page. If you have not changed the+ # source, you can leave it linked to my bitbucket page.+ source-link: https://bitbucket.org/wuzzeb/notmuch-web/src+ # The folders are the notmuch searches that are linked from the navigation bar. # The first folder is displayed on the home page. folders:@@ -13,7 +17,7 @@ search: tag:lists and tag:unread # The retag buttons which appear for threads and messages. The icon name is from- # the bootstrap glyphicons-halflings (http://twitter.github.com/bootstrap/base-css.html#icons).+ # the bootstrap glyphicons-halflings (http://twbs.github.io/bootstrap/2.3.2/base-css.html#icons) retag: - name: Mark Read icon: icon-ok@@ -34,13 +38,33 @@ #message-id-domain: example.com + # Google Contact Support + #+ # The address fields (To, CC, BCC) on the compose form can optionally search+ # contacts either from abook locally or can search from your google contacts.+ # To enable google contacts, you must visit+ # https://code.google.com/apis/console/ click on "API Access", click on+ # "Create an OAuth Client ID", and fill in the data. The application type+ # must be "Web application" since the contacts will be loaded directly by+ # javascript running in the browser. The site/hostname must match where you+ # serve notmuch-web from because of CORS protection+ # (http://en.wikipedia.org/wiki/Cross-origin_resource_sharing). The Redirect+ # URIs can be ignored since we will access google only via javascript. The+ # Client ID can then be copied into the following setting. Alternatively, an+ # environment variable NOTMUCH_WEB_GOOGLE_CLIENTID will also be searched+ # during startup for the client ID (useful for development).++ #google-client-id: ""++ Production: # The approot is the full URL to root of notmuch web. approot: "http://localhost:3000" - # The from address for compose. Currently only a single from address is allowed. Format should be- # Name <abc@example.com>- from-address: "x"+ # List of one or more from addresses for compose. Format should be Name <abc@example.com>+ from-addresses:+ - "Name <abc@example.com>"+ - "Name <abc@other.example.com>" # You must set your hashed password. # See http://hackage.haskell.org/packages/archive/pwstore-fast/latest/doc/html/Crypto-PasswordStore.html@@ -59,7 +83,9 @@ Development: approot: "http://localhost:3000"- from-address: "Test <test@example.com>"+ from-addresses:+ - "Test <test@example.com>"+ - "Addr2 <addr2@example.com>" # For development and testing, the password is hunter2 hashed-password: "sha256|12|lMzlNz0XK9eiPIYPY96QCQ==|1ZJ/R3qLEF0oCBVNtvNKLwZLpXPM7bLEy/Nc6QBxWro=" sent-box: sent@@ -67,7 +93,8 @@ Testing: approot: "http://localhost:3000"- from-address: "Test <test@example.com>"+ from-addresses:+ - "Test <test@example.com>" # For development and testing, the password is hunter2 hashed-password: "sha256|12|lMzlNz0XK9eiPIYPY96QCQ==|1ZJ/R3qLEF0oCBVNtvNKLwZLpXPM7bLEy/Nc6QBxWro=" sent-box: sent@@ -75,6 +102,7 @@ Staging: approot: "http://localhost:3000"- from-address: "x"+ from-address:+ - "Test <test@example.com>" hashed-password: "x" <<: *defaults
main.hs view
@@ -14,16 +14,21 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. -}+import Prelude hiding (putStrLn, getLine)+ import Application (makeApplication)-import Prelude (IO, elem, Bool(..), ($), putStr)+import Control.Monad (when)+import Crypto.PasswordStore (makePassword)+import Data.Text.Encoding (encodeUtf8, decodeUtf8)+import Data.Text.IO (getLine, putStrLn)+import Data.Version (Version(..))+import NotmuchCmd (notmuchVersion) import Settings (parseExtra) import System.Environment (getArgs)-import System.IO (hSetEcho, hFlush, stdin, stdout)+import System.Exit (exitWith, ExitCode(..))+import System.IO (hSetEcho, hFlush, stdin, stdout, hPutStrLn, stderr) import Yesod.Default.Config (fromArgs) import Yesod.Default.Main (defaultMain)-import Data.Text.IO (getLine, putStrLn)-import Data.Text.Encoding (encodeUtf8, decodeUtf8)-import Crypto.PasswordStore (makePassword) genPassword :: IO () genPassword = do@@ -38,6 +43,11 @@ main :: IO () main = do+ v <- notmuchVersion+ when (v < Version {versionBranch = [0,15], versionTags = []}) $ do+ hPutStrLn stderr "notmuch-web requires at least notmuch 0.15"+ exitWith $ ExitFailure 1+ a <- getArgs if "--make-password" `elem` a then genPassword
messages/en.msg view
@@ -7,6 +7,7 @@ Date: Date Num: Num Subject: Subject+Body: Body Authors: Authors Attach: Attachments Tags: Tags@@ -27,3 +28,12 @@ Retag: Retag TagsToAdd: Tags to add TagsToRemove: Tags to remove+BodyFormat: Interpret body as+Preview: Preview Html+AddrBookType: Address Book Type+NoAddressBook: No address book+Abook: Abook on server+Google: Google Contacts+GoogleAuth: Authorize Google Contacts+AddrHelpTitle: Entering Email Addresses+AddrHelp: Email addresses should either be entered directly or can be entered as "Name <email@example.com>" without the quotes. To save the email address and start entering a new one, you can press comma. Double clicking on an existing entry will allow you to edit it.
notmuch-web.cabal view
@@ -1,5 +1,5 @@ name: notmuch-web-version: 0.1.1+version: 0.1.2 cabal-version: >= 1.8 build-type: Simple synopsis: A web interface to the notmuch email indexer@@ -13,7 +13,8 @@ description: An email client for the notmuch email indexer (<http://notmuchmail.org>), built using Yesod. This project implements a web server and uses bootstrap and jquery for the UI. The client is fully functional, with searching, viewing,- and composing email messages.+ and composing email messages. See+ <https://bitbucket.org/wuzzeb/notmuch-web/src/tip/ChangeLog> for recent changes. data-files: README.md config/favicon.ico@@ -46,6 +47,10 @@ Description: Build for use with "yesod devel" Default: False +Flag no-icu+ Description: Do not link against libicu+ Default: False+ library hs-source-dirs: src exposed-modules: Application@@ -55,12 +60,14 @@ StaticFiles NotmuchCmd FilterHtml+ Handler.Abook+ Handler.Compose+ Handler.ComposeFields Handler.Home- Handler.Tags- Handler.View Handler.Pager Handler.Raw- Handler.Compose+ Handler.Tags+ Handler.View if flag(dev) || flag(library-only) cpp-options: -DDEVELOPMENT@@ -68,6 +75,11 @@ else ghc-options: -Wall -O2 + if flag(no-icu)+ cpp-options: -DNO_ICU+ else+ cpp-options: -DUSE_ICU+ extensions: TemplateHaskell QuasiQuotes OverloadedStrings@@ -81,60 +93,102 @@ EmptyDataDecls NoMonomorphismRestriction - build-depends: base >= 4 && < 5+ if flag(no-icu)+ build-depends: base >= 4 && < 5 - , aeson- , attoparsec- , attoparsec-conduit- , blaze-builder- , blaze-html- , blaze-markup- , bytestring- , case-insensitive- , conduit- , containers- , data-default- , directory- , email-validate- , filepath- , hamlet- -- https://github.com/bos/aeson/issues/125 and https://github.com/tibbe/hashable/issues/66- , hashable <= 1.2.0.5 || > 1.2.0.7- , hjsmin- , http-conduit- , http-types- , lifted-base- , mime-mail- , monad-control- , network- , old-locale- , pandoc >= 1.11 && < 1.12- , process- , process-conduit >= 0.5- , pwstore-fast- , random- , shakespeare-css- , shakespeare-js- , shakespeare-text- , tagsoup- , template-haskell- , text- , text-icu- , time- , transformers- , unordered-containers- , vector- , wai- , wai-extra- , warp- , xss-sanitize- , yaml- , yesod >= 1.2 && < 1.3- , yesod-auth- , yesod-core- , yesod-form- , yesod-static+ , aeson+ , attoparsec+ , attoparsec-conduit+ , blaze-builder+ , blaze-html+ , blaze-markup+ , bytestring+ , case-insensitive+ , conduit+ , containers+ , directory+ , data-default+ , email-validate+ , filepath+ , hamlet+ -- https://github.com/bos/aeson/issues/125 and https://github.com/tibbe/hashable/issues/66+ , hashable <= 1.2.0.5 || > 1.2.0.7+ , hjsmin+ , http-conduit+ , http-types+ , lifted-base+ , markdown >= 0.1.5+ , mime-mail+ , network+ , old-locale+ , process+ , process-conduit >= 0.5+ , pwstore-fast+ , random+ , tagsoup+ , template-haskell+ , text+ , time+ , transformers+ , unordered-containers+ , vector+ , wai+ , wai-extra+ , warp+ , xss-sanitize >= 0.3.4+ , yaml+ , yesod >= 1.2 && < 1.3+ , yesod-auth+ , yesod-static+ else+ build-depends: base >= 4 && < 5 + , aeson+ , attoparsec+ , attoparsec-conduit+ , blaze-builder+ , blaze-html+ , blaze-markup+ , bytestring+ , case-insensitive+ , conduit+ , containers+ , data-default+ , directory+ , email-validate+ , filepath+ , hamlet+ -- https://github.com/bos/aeson/issues/125 and https://github.com/tibbe/hashable/issues/66+ , hashable <= 1.2.0.5 || > 1.2.0.7+ , hjsmin+ , http-conduit+ , http-types+ , lifted-base+ , markdown >= 0.1.5+ , mime-mail+ , network+ , old-locale+ , process+ , process-conduit >= 0.5+ , pwstore-fast+ , random+ , tagsoup+ , template-haskell+ , text+ , text-icu+ , time+ , transformers+ , unordered-containers+ , vector+ , wai+ , wai-extra+ , warp+ , xss-sanitize >= 0.3.4+ , yaml+ , yesod >= 1.2 && < 1.3+ , yesod-auth+ , yesod-static+ executable notmuch-web if flag(library-only) Buildable: False@@ -157,6 +211,9 @@ build-depends: base , notmuch-web , hspec+ , HUnit+ , mime-mail+ , text , yesod , yesod-test , yesod-core
package.sh view
@@ -1,10 +1,15 @@ #!/bin/bash +args=+if [ "$1" != "--icu" ]; then+ args="-fno-icu"+fi+ f="notmuch-web-`grep ^version notmuch-web.cabal | awk '{print $2}'`" r="releases/$f" cabal clean-cabal configure+cabal configure $args cabal build strip dist/build/notmuch-web/notmuch-web mkdir -p $r
src/Application.hs view
@@ -22,9 +22,9 @@ , makeFoundation ) where -import Import hiding (loadConfig)-import Settings+import Import import StaticFiles (staticSite)+import System.Environment (lookupEnv) import Yesod.Auth (getAuth) import Yesod.Default.Config import Yesod.Default.Main@@ -36,22 +36,16 @@ -- Import all relevant handler modules here. -- Don't forget to add new modules to your cabal file!+import Handler.Abook+import Handler.Compose import Handler.Home-import Handler.Tags-import Handler.View import Handler.Pager import Handler.Raw-import Handler.Compose+import Handler.Tags+import Handler.View --- This line actually creates our YesodDispatch instance. It is the second half--- of the call to mkYesodData which occurs in Foundation.hs. Please see the--- comments there for more details. mkYesodDispatch "App" resourcesApp --- This function allocates resources (such as a database connection pool),--- performs initialization and creates a WAI application. This is also the--- place to put your migrate statements to have automatic database--- migrations handled by Yesod. makeApplication :: AppConfig DefaultEnv Extra -> IO Application makeApplication conf = do foundation <- makeFoundation conf@@ -65,11 +59,22 @@ makeFoundation conf = do manager <- newManager def s <- staticSite++ -- Load message ID domain if it has not been set conf' <- case (extraMessageIDDomain $ appExtra conf) of "" -> do u <- NS.getHostName return $ conf { appExtra = (appExtra conf) { extraMessageIDDomain = T.pack u} } _ -> return conf- return $ App conf' s manager $ extraHashedPwd $ appExtra conf'++ -- Load google client id if it has not been set+ conf'' <- case (extraGoogleClientId $ appExtra conf') of+ Nothing -> do mi <- lookupEnv "NOTMUCH_WEB_GOOGLE_CLIENTID"+ case mi of+ Just i -> return $ conf' { appExtra = (appExtra conf') { extraGoogleClientId = Just $ T.pack i } }+ Nothing -> return conf'+ Just _ -> return conf'++ return $ App conf'' s manager $ extraHashedPwd $ appExtra conf'' -- for yesod devel getApplicationDev :: IO (Int, Application)
src/FilterHtml.hs view
@@ -16,72 +16,28 @@ -} module FilterHtml (filterHtml) where --- Based on the xss-sanitize project http://hackage.haskell.org/package/xss-sanitize--- but a lot more restrictive.- import Import-import Data.Maybe (mapMaybe)-import Data.String (IsString)-import Text.HTML.SanitizeXSS (sanitizeAttribute)+import Data.Text (Text)+import Text.HTML.SanitizeXSS import Text.HTML.TagSoup-import qualified Data.Map as Map-import qualified Data.Text.Lazy as TL -- | Filter Html-filterHtml :: TL.Text -> TL.Text-filterHtml = renderTags . balance Map.empty . safeTags . parseTags+filterHtml :: Text -> Text+filterHtml = filterTags (balanceTags . allowedTags . safeTags) -- | List of allowed tags.-allowedTags :: [TL.Text]-allowedTags = ["div", "p", "br", "blockquote"]+allowed :: [Text]+allowed = ["div", "p", "br", "blockquote"] -- | Filter which tags are allowed-safeTags :: [Tag TL.Text] -> [Tag TL.Text]-safeTags [] = []-safeTags (TagOpen name attrs:ts) | name `elem` allowedTags = TagOpen name (safeAttrs attrs) : safeTags ts-safeTags (TagOpen _ _ :ts) = safeTags ts--safeTags (t@(TagClose name):ts) | name `elem` allowedTags = t : safeTags ts-safeTags (TagClose _:ts) = safeTags ts--safeTags (t:ts) = t : safeTags ts---- | Filter the attributes.-safeAttrs :: [Attribute TL.Text] -> [Attribute TL.Text]-safeAttrs = sanitizeAttribute' . filterAttrs- where- filterAttrs = filter $ \(n,_) -> n `elem` okAttrs- okAttrs = ["style", "class"]+allowedTags :: [Tag Text] -> [Tag Text]+allowedTags [] = []+allowedTags (t@(TagOpen name _):ts)+ | name `elem` allowed = t : allowedTags ts+ | otherwise = allowedTags ts --- | Wrapper around xss-sanitize's sanitizeAttribute. --- --- This clears bad attributes but also filters the CSS within the style attribute, removing--- things like urls and so forth.-sanitizeAttribute' :: [Attribute TL.Text] -> [Attribute TL.Text]-sanitizeAttribute' = map fromStrict . mapMaybe sanitizeAttribute . map toStrict- where- fromStrict (a,b) = (TL.fromStrict a, TL.fromStrict b)- toStrict (a,b) = (TL.toStrict a, TL.toStrict b)+allowedTags (t@(TagClose name):ts)+ | name `elem` allowed = t : allowedTags ts+ | otherwise = allowedTags ts --- Copied from xss-sanitize and updated to work with lazy text by changing the type signature.-balance :: (IsString a, Ord a) => Map.Map a Int -> [Tag a] -> [Tag a]-balance m [] =- concatMap go $ Map.toList m- where- go (name, i) | name == "br" = []- | otherwise = replicate i $ TagClose name-balance m (t@(TagClose name):tags) =- case Map.lookup name m of- Nothing -> TagOpen name [] : TagClose name : balance m tags- Just i ->- let m' = if i == 1- then Map.delete name m- else Map.insert name (i - 1) m- in t : balance m' tags-balance m (TagOpen name as : tags) =- TagOpen name as : balance m' tags- where- m' = case Map.lookup name m of- Nothing -> Map.insert name 1 m- Just i -> Map.insert name (i + 1) m-balance m (t:ts) = t : balance m ts+allowedTags (t:ts) = t : allowedTags ts
src/Foundation.hs view
@@ -17,68 +17,41 @@ module Foundation where import Prelude++import Blaze.ByteString.Builder.Char.Utf8 (fromText)+import Control.Applicative+import Data.ByteString (ByteString)+import Data.Maybe (isJust)+import Data.Monoid (mappend)+import Data.Text (Text)+import Data.Text.Encoding (encodeUtf8)+import Network.HTTP.Conduit (Manager)+import Network.Wai (requestHeaders)+import NotmuchCmd (ThreadID, MessageID)+import Settings (widgetFile, Extra (..), development)+import StaticFiles+import Text.Hamlet (hamletFile)+import Text.Jasmine (minifym) import Yesod import Yesod.Auth-import Yesod.Static import Yesod.Default.Config import Yesod.Default.Util (addStaticContentExternal)-import Network.HTTP.Conduit (Manager)-import Data.Monoid (mappend)-import qualified Settings-import StaticFiles-import Settings (widgetFile, Extra (..))-import Network.Wai (requestHeaders)-import Data.Maybe (isJust)-import Text.Jasmine (minifym)-import Text.Hamlet (hamletFile)-import NotmuchCmd (ThreadID, MessageID)-import Control.Applicative-import Blaze.ByteString.Builder.Char.Utf8 (fromText)-import Data.Text (Text)+import Yesod.Static import qualified Crypto.PasswordStore as PS-import qualified Data.ByteString as B-import qualified Data.Text as T-import qualified Data.Text.Encoding as T --- | The site argument for your application. This can be a good place to--- keep settings and values requiring initialization before your application--- starts running, such as database connections. Every handler will have--- access to the data present here. data App = App { settings :: AppConfig DefaultEnv Extra , getStatic :: Static -- ^ Settings for static file serving. , httpManager :: Manager- , passwordHash :: B.ByteString -- ^ hashed password from "Crypto.PasswordStore"+ , passwordHash :: ByteString -- ^ hashed password from "Crypto.PasswordStore" } --- Set up i18n messages. See the message folder. mkMessage "App" "messages" "en" --- This is where we define all of the routes in our application. For a full--- explanation of the syntax, please see:--- http://www.yesodweb.com/book/handler------ This function does three things:------ * Creates the route datatype AppRoute. Every valid URL in your--- application can be represented as a value of this type.--- * Creates the associated type:--- type instance Route App = AppRoute--- * Creates the value resourcesApp which contains information on the--- resources declared below. This is used in Handler.hs by the call to--- mkYesodDispatch------ What this function does *not* do is create a YesodSite instance for--- App. Creating that instance requires all of the handler functions--- for our application to be in scope. However, the handler functions--- usually require access to the AppRoute datatype. Therefore, we--- split these actions into two functions and place them in separate files. mkYesodData "App" $(parseRoutesFile "config/routes") type Form x = Html -> MForm (HandlerT App IO) (FormResult x, Widget) --- Please see the documentation for the Yesod typeclass. There are a number--- of settings which can be configured by overriding methods here. instance Yesod App where approot = ApprootMaster $ appRoot . settings @@ -103,11 +76,12 @@ master <- getYesod mmsg <- getMessage let folders = extraFolders $ appExtra $ settings master+ sourceLink <- extraSourceLink <$> getExtra pjax <- isPjax if pjax then do pc <- widgetToPageContent widget- hamletToRepHtml $ pageBody pc+ giveUrlRenderer $ pageBody pc else do pc <- widgetToPageContent $ do addStylesheet $ StaticR css_bootstrap_min_css@@ -121,12 +95,7 @@ $(widgetFile "default-layout") - hamletToRepHtml $(hamletFile "templates/default-layout-wrapper.hamlet")-- -- This is done to provide an optimization for serving static files from- -- a separate domain. Please see the staticRoot setting in Settings.hs- urlRenderOverride y (StaticR s) =- Just $ uncurry (joinPath y (Settings.staticRoot $ settings y)) $ renderRoute s+ giveUrlRenderer $(hamletFile "templates/default-layout-wrapper.hamlet") -- The opensearch.xml file must include {searchTerms} in the url template, but -- the default url renderer percent encodes the braces which doesn't work. So we@@ -143,8 +112,8 @@ -- and names them based on a hash of their content. This allows -- expiration dates to be set far in the future without worry of -- users receiving stale content.- addStaticContent = addStaticContentExternal mini base64md5 Settings.staticDir (StaticR . flip StaticRoute [])- where mini = if Settings.development then Right else minifym+ addStaticContent = addStaticContentExternal mini base64md5 "static" (StaticR . flip StaticRoute [])+ where mini = if development then Right else minifym -- Place Javascript at bottom of the body tag so the rest of the page loads first jsLoader _ = BottomOfBody@@ -152,10 +121,8 @@ -- What messages should be logged. The following includes all messages when -- in development, and warnings and errors in production. shouldLog _ _source level =- Settings.development || level == LevelWarn || level == LevelError+ development || level == LevelWarn || level == LevelError --- This instance is required to use forms. You can modify renderMessage to--- achieve customized and internationalized form validation messages. instance RenderMessage App FormMessage where renderMessage _ _ = defaultFormMessage @@ -168,12 +135,12 @@ isPjax = do r <- waiRequest return $ isJust $ lookup "X-PJAX" $ requestHeaders r -loginForm :: AForm (HandlerT App IO) B.ByteString-loginForm = T.encodeUtf8 <$> areq passwordField pwd Nothing+loginForm :: AForm (HandlerT App IO) ByteString+loginForm = encodeUtf8 <$> areq passwordField pwd Nothing where pwd = FieldSettings (SomeMessage MsgPassword) Nothing (Just "Password") Nothing [] instance YesodAuth App where- type AuthId App = T.Text+ type AuthId App = Text loginDest _ = HomeR logoutDest _ = HomeR getAuthId (Creds _ n _) = return $ Just n
+ src/Handler/Abook.hs view
@@ -0,0 +1,47 @@+{-+Copyright (C) 2013 John Lenz <lenz@math.uic.edu>++This program is free software: you can redistribute it and/or modify+it under the terms of the GNU General Public License as published by+the Free Software Foundation, either version 3 of the License, or+(at your option) any later version.++This program is distributed in the hope that it will be useful,+but WITHOUT ANY WARRANTY; without even the implied warranty of+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the+GNU General Public License for more details.++You should have received a copy of the GNU General Public License+along with this program. If not, see <http://www.gnu.org/licenses/>.+-}+module Handler.Abook (getAbookQueryR) where++import Import+import Control.Exception.Lifted (try)+import Data.Conduit.Process+import System.Exit (ExitCode(..))+import qualified Data.Text as T+import qualified Data.Vector as V+import qualified Data.Conduit.List as C+import qualified Data.Conduit.Text as C++-- | Abook returns an entry as "<email addr><tab><name><tab>". This function parses+-- such a line into a JSON value.+parseLine :: T.Text -> Value+parseLine x = case T.split (=='\t') x of+ [] -> Null+ [n] -> String n+ (email:name:_) -> String $ T.concat [name, " <", email, ">"]++-- | Query abook for the given string+getAbookQueryR :: Handler Value+getAbookQueryR = do+ q <- fromMaybe "" <$> lookupGetParam "q"+ let p = proc "abook" ["--mutt-query", T.unpack q]+ rs <- try $ runResourceT $ sourceProcess p $= C.decode C.utf8 $= C.lines $$ C.consume+ return $ case rs of+ Left (ExitFailure 1) -> Null -- exit code 1 is for no results+ Left e -> object [ "error" .= show e ]+ Right [] -> Null+ Right [_] -> Null -- first line is a blank line+ Right (_:qs) -> Array $ V.fromList $ map parseLine qs
src/Handler/Compose.hs view
@@ -1,3 +1,4 @@+{-# OPTIONS_GHC -fno-warn-orphans #-} {- Copyright (C) 2013 John Lenz <lenz@math.uic.edu> @@ -14,236 +15,261 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. -}-module Handler.Compose (getComposeR, postComposeR, getReplyR, getReplyAllR) where+module Handler.Compose (+ getComposeR+ , postComposeR+ , getReplyR+ , getReplyAllR+ , postPreviewMessageR+) where import Import-import Settings-import Network.Mail.Mime+import Handler.ComposeFields++import Data.String (fromString) import Data.Time+import Network.Mail.Mime hiding (partContent)+import System.FilePath ((</>)) import System.Locale import System.Random (randomIO)-import System.FilePath ((</>))-import qualified Data.ByteString as B+import Text.Blaze.Html.Renderer.Utf8 (renderHtml)+import Text.Markdown+ import qualified Data.ByteString.Lazy as BL+import qualified Data.CaseInsensitive as CI import qualified Data.Conduit.List as CL+import qualified Data.Map as M import qualified Data.Text as T import qualified Data.Text.Encoding as T import qualified Data.Text.Lazy as TL import qualified Data.Text.Lazy.Encoding as TL import qualified Data.Text.Lazy.IO as TL-import qualified Text.Email.Validate as E-import qualified Data.Map as M-import qualified Data.CaseInsensitive as CI-import Data.Attoparsec.Text-import Data.String (fromString)-import NotmuchCmd --- | Parse an email address in angle brackets-emailInBrackets :: Parser T.Text-emailInBrackets = do- void $ char '<'- y <- takeTill (=='>') <?> "Email address"- void (char '>' <?> "Expecting '>'")- skipSpace- return y+-----------------------------------------------------------------------------------------+-- Compose Form+----------------------------------------------------------------------------------------- --- | Parses an email address, which can either just be a direct email--- address or can be an address in the form "Name <email>"-address :: Parser Address-address = do- x <- T.strip <$> takeTill (\c -> c == '<' || c == ',')- y <- Just <$> emailInBrackets <|> return Nothing- case y of- Just e -> return $ Address (Just x) e- Nothing -> return $ Address Nothing x+instance Eq Address where+ (Address a1 a2) == (Address b1 b2) = a1 == b1 && a2 == b2 --- | Parse a list of addresses seperated by commas-addresses :: Parser [Address]-addresses = do as <- sepBy address (char ',')- endOfInput <?> "Expecting ',' or '>'"- return as+-- | Lookup From addresses in the settings+fromAddresses :: (MonadHandler m, HandlerSite m ~ App) => m (OptionList Address)+fromAddresses = mkOptionList <$> do+ addrs <- extraFromAddresses <$> getExtra+ forM addrs $ \a ->+ case parseAddress a of+ Left err -> do+ setMessageI err+ return $ Option ("Invalid " <> a) (Address Nothing "") ""+ Right a' -> return $ Option a a' a --- | A version of parseOnly which includes the context of the failure.-parseOnly' :: Parser a -> T.Text -> Either String a-parseOnly' p t = checkRes (parse p t)- where checkRes result = case result of- Fail _ ctx err -> Left $ show ctx ++ " " ++ err- Partial f -> checkRes $ f ""- Done _ x -> Right $ x+-- | Parse an address header like To: and CC: into a list of address+parseAddrHeader :: (MonadHandler m, HandlerSite m ~ App) => CI.CI T.Text -> Reply -> m [Address]+parseAddrHeader hdr reply = + case M.lookup hdr (replyHeaders reply) of+ Nothing -> return []+ Just x -> case parseAddresses x of+ Left err -> do setMessageI err+ return []+ Right addr -> return addr -parseAddresses :: T.Text -> Either (SomeMessage App) (Maybe [Address])-parseAddresses t = case parseOnly' addresses t of- Left err -> Left $ fromString err- Right [] -> Right $ Nothing- Right addrs -> Just <$> mapM checkAddr addrs- where- checkAddr a@(Address _ e) | E.isValid (T.encodeUtf8 e) = Right a- checkAddr (Address _ e) | otherwise = Left $ SomeMessage $ MsgInvalidEmail e+-- | Search for the first part which is a body+findBodyText :: [MessagePart] -> [T.Text]+findBodyText [] = []+findBodyText ((MessagePart {partContent = ContentText t}):_) = map (\x -> "> " <> x <> "\n") $ T.lines t+findBodyText ((MessagePart {partContent = ContentMsgRFC822 _}):_) = []+findBodyText ((MessagePart {partContent = ContentMultipart sub}):ms) =+ case findBodyText sub of+ [] -> findBodyText ms+ x -> x -showAddresses :: [Address] -> T.Text-showAddresses as = T.intercalate ", " $ map showA as- where showA (Address {addressName = Just name, addressEmail = e}) = T.concat [name, " <", e, ">"]- showA (Address {addressName = Nothing, addressEmail = e}) = e+-- | Parse a reply into a mail message and the reply body+parseReply :: (MonadHandler m, HandlerSite m ~ App) => Reply -> m (Mail, T.Text)+parseReply reply = do+ to <- parseAddrHeader "to" reply+ cc <- parseAddrHeader "cc" reply -addressField :: Field (HandlerT App IO) [Address]-addressField = Field { fieldParse = \x _ -> case x of- [] -> return $ Right Nothing- ("":_) -> return $ Right Nothing- (n:_) -> return $ parseAddresses n- , fieldView = \theId name attrs val isReq -> [whamlet|-<input id=#{theId} name=#{name} *{attrs} type=text :isReq:required value=#{either id showAddresses val}>-|]- , fieldEnctype = UrlEncoded- }+ let extra = foldr M.delete (replyHeaders reply) ["to", "cc", "subject", "from"] -header :: Parser (B.ByteString, T.Text)-header = do- k <- takeWhile1 (\c -> not (isEndOfLine c) && c /= ':')- void $ char ':'- skipSpace- v <- takeTill isEndOfLine- return (T.encodeUtf8 k, v)+ let mail = Mail (Address Nothing "")+ to+ cc+ [] -- bcc+ [(T.encodeUtf8 $ CI.original k, v) | (k,v) <- M.toList extra]+ [] -- parts -headers :: Parser [(B.ByteString, T.Text)]-headers = sepBy header endOfLine <?> "Headers"+ tz <- liftIO getCurrentTimeZone+ let t = utcToZonedTime tz $ messageTime $ replyOriginal reply+ let ts = formatTime defaultTimeLocale "%a %b %e %R %z %Y" t -parseHeaders :: T.Text -> Either (SomeMessage App) (Maybe [(B.ByteString,T.Text)])-parseHeaders t = case parseOnly' headers t of- Left err -> Left $ fromString err- Right [] -> Right $ Nothing- Right h -> Right $ Just h+ let body = T.concat $+ [ "On "+ , T.pack ts+ , ", "+ , fromMaybe "" $ M.lookup "from" $ messageHeaders $ replyOriginal reply+ , " wrote:\n"+ ] ++ (findBodyText $ messageBody $ replyOriginal reply) -showHeaders :: [(B.ByteString, T.Text)] -> T.Text-showHeaders hs = T.intercalate "\n" $ map showH hs- where showH (x,y) = T.concat [T.decodeUtf8 x, ": ", y]+ return (mail, body) -headerField :: Field (HandlerT App IO) [(B.ByteString,T.Text)]-headerField = Field { fieldParse = \x _ -> case x of- [] -> return $ Right Nothing- ("":_) -> return $ Right Nothing- (n:_) -> return $ parseHeaders n- , fieldView = \theId name attrs val isReq -> [whamlet|-<textarea id=#{theId} name=#{name} *{attrs} rows=4 cols=50 wrap=off :isReq:required>- #{either id showHeaders val}-|]- , fieldEnctype = UrlEncoded- }+data EmailBodyFormat = EmailBodyQuotedPrintable+ | EmailBodyPlain+ | EmailBodyMarkdown+ deriving (Eq, Enum, Bounded) -multiFile :: Field (HandlerT master IO) [FileInfo]-multiFile = Field p view Multipart- where- p _ fs = return $ Right $ Just fs- view fId name attrs _ _ = [whamlet|-<input type=file name=#{name} ##{fId} multiple *{attrs}>-|]+instance Show EmailBodyFormat where+ show EmailBodyQuotedPrintable = "Send as text/plain, UTF-8 encoded with quoted printable"+ show EmailBodyPlain = "Send as text/plain, UTF-8, no encoding"+ show EmailBodyMarkdown = "Parse body as markdown; send text and html parts" -findBodyText :: [MessagePart] -> Maybe T.Text-findBodyText [] = Nothing-findBodyText ((MessagePart {NotmuchCmd.partContent = ContentText t}):_) = Just t-findBodyText ((MessagePart {NotmuchCmd.partContent = ContentMsgRFC822 _}):_) = Nothing-findBodyText ((MessagePart {NotmuchCmd.partContent = ContentMultipart sub}):ms) =- case findBodyText sub of- Nothing -> findBodyText ms- Just x -> Just x+markdownSettings :: MarkdownSettings+markdownSettings = def { msXssProtect = False -- Input is trusted from compose form+ , msBlankBeforeBlockquote = False+ } -replyBody :: Reply -> T.Text-replyBody (Reply {replyOriginal = m}) = T.concat [onmsg, "\n", bdy]- where- origfrom = maybe "" id $ M.lookup "from" $ messageHeaders m- onmsg = T.concat ["On ", T.pack (show $ messageTime m), ", ", origfrom, " wrote: "]- origlines = T.lines $ maybe "" id $ findBodyText $ messageBody m- addquote = map (T.append "> ") origlines- bdy = T.unlines addquote+-- | Read the body as markdown and create html+markdownToHtml :: T.Text -> Html+markdownToHtml = markdown markdownSettings . TL.fromStrict -fileToAttach :: FileInfo -> ResourceT IO Alternatives-fileToAttach f = do- content <- fileSource f $$ CL.consume- return [Part (fileContentType f) Base64 (Just $ fileName f) [] (BL.fromChunks content)]+-- | Create the body of the outgoing message+createBody :: (MonadHandler m, HandlerSite m ~ App)+ => EmailBodyFormat -> Textarea -> [FileInfo] -> m [Alternatives]+createBody fmt bodytext attach = do+ attachParts <- liftResourceT $ forM attach $ \f -> do+ content <- fileSource f $$ CL.consume+ return [Part (fileContentType f) Base64 (Just $ fileName f) [] (BL.fromChunks content)] -composeForm :: Address -> Maybe Reply -> Form Mail-composeForm from mreply fmsg = do- let mheaders = replyHeaders <$> mreply- mto = M.lookup "to" =<< mheaders- mtoaddr = either (const Nothing) id . parseAddresses =<< mto- mcc = M.lookup "cc" =<< mheaders- mccaddr = either (const Nothing) Just . parseAddresses =<< mcc- --from = maybe (Just f) $ M.lookup "from" =<< extraheaders- msubj = M.lookup "subject" =<< mheaders- mbody = Textarea . replyBody <$> mreply- mextramap = M.delete "to" . M.delete "cc" . M.delete "subject" . M.delete "from" <$> mheaders- mextra = (\hs -> Just [ (T.encodeUtf8 $ CI.original k, v) | (k,v) <- M.toList hs ]) <$> mextramap+ let b = Part "text/plain; charset=UTF-8" None Nothing [] $ BL.fromChunks [T.encodeUtf8 $ unTextarea bodytext]+ bq = b { partEncoding = QuotedPrintableText }+ html = renderHtml $ markdownToHtml $ unTextarea bodytext+ hpart = Part "text/html; charset=UTF-8" QuotedPrintableText Nothing [] html - (to,toView) <- mreq addressField (FieldSettings "To" Nothing (Just "to") Nothing []) mtoaddr- (cc,ccView) <- mopt addressField (FieldSettings "CC" Nothing (Just "cc") Nothing []) mccaddr- (bcc,bccView) <- mopt addressField (FieldSettings "BCC" Nothing (Just "bcc") Nothing []) Nothing- (subject,sView) <- mreq textField (FieldSettings (SomeMessage MsgSubject) Nothing (Just "subject") Nothing []) msubj- (head,hView) <- mopt headerField (FieldSettings (SomeMessage MsgExtraHeader) Nothing (Just "extraheaders") Nothing []) mextra- (body,bView) <- mreq textareaField (FieldSettings "Body" Nothing (Just "body") Nothing []) mbody- (attach,attachView) <- mopt multiFile (FieldSettings (SomeMessage MsgAttach) Nothing (Just "attach") Nothing []) Nothing+ let body = case fmt of+ EmailBodyQuotedPrintable -> [bq]+ EmailBodyPlain -> [b]+ EmailBodyMarkdown -> [bq, hpart] - attachParts <- case attach of- FormSuccess (Just xs) -> liftResourceT $ mapM fileToAttach xs- _ -> return []+ return $ body : attachParts - let mkParts b = [[Part "text/plain" QuotedPrintableText Nothing [] $ BL.fromChunks [T.encodeUtf8 $ unTextarea b]]] ++ attachParts- mkHeaders s e = ("Subject", s) : maybe [] id e- mail = Mail <$> pure from+-- | Create a new message ID+messageID :: (MonadHandler m, HandlerSite m ~ App) => m T.Text+messageID = do t <- liftIO getCurrentTime+ let ts = formatTime defaultTimeLocale "%s" t+ i <- abs <$> liftIO (randomIO :: IO Int)+ domain <- extraMessageIDDomain <$> getExtra+ case domain of+ "" -> return ""+ _ -> return $ T.concat ["<notmuch-web-", T.pack ts, ".", T.pack (show i), "@", domain, ">"]++-- | Create a field settings from a string. The default IsString instance does not set the id.+fStr :: T.Text -> FieldSettings site+fStr i = FieldSettings (fromString $ T.unpack i) Nothing (Just i) Nothing []++-- | Create a field setting from a message+fI :: AppMessage -> T.Text -> FieldSettings App+fI m i = FieldSettings (SomeMessage m) Nothing (Just i) Nothing []++-- | A helper widget to display a form element in bootstrap markup+formElem :: Bool -> FieldView App -> Widget+formElem includeHelp v = [whamlet|+<div .control-group>+ <label .control-label for=#{fvId v}>#{fvLabel v}+ <div .controls>+ ^{fvInput v}+ $if includeHelp+ <i .addr-help .icon-question-sign data-title=_{MsgAddrHelpTitle} data-content=_{MsgAddrHelp}>+|]++-- | A widget to select the address book type+addressBookWidget :: Widget+addressBookWidget = do+ $(widgetFile "abook")+ mID <- extraGoogleClientId <$> getExtra+ case mID of+ Nothing -> $(widgetFile "plain-contacts")+ Just clientID -> $(widgetFile "google-contacts")++-- | The compose form+composeForm :: Maybe Reply -> Form Mail+composeForm mreply fmsg = do+ mmail <- lift $ maybe (return Nothing) (\x -> Just <$> parseReply x) mreply++ (from,fromView) <- mreq (selectField fromAddresses) (fStr "From") Nothing+ (to,toView) <- mreq addressField (fStr "To") (mailTo . fst <$> mmail)+ (cc,ccView) <- mopt addressField (fStr "CC") (Just . mailCc . fst <$> mmail)+ (bcc,bccView) <- mopt addressField (fStr "BCC") Nothing+ (subject,sView) <- mreq textField (fI MsgSubject "subj") $ M.lookup "subject" =<< (replyHeaders <$> mreply)+ (head,hView) <- mopt headerField (fI MsgExtraHeader "hdrs") (Just . mailHeaders . fst <$> mmail)+ (bfmt,fmtView) <- mreq (selectField optionsEnum) (fI MsgBodyFormat "bdyfmt") Nothing+ (body,bView) <- mreq textareaField (fI MsgBody "Body") (Textarea . snd <$> mmail)+ (attach,attachView) <- mopt multiFile (fI MsgAttach "attch") Nothing++ parts <- case (,,) <$> bfmt <*> body <*> attach of+ FormSuccess (f,b,a) -> FormSuccess <$> createBody f b (fromMaybe [] a)+ FormFailure err -> return $ FormFailure err+ FormMissing -> return $ FormMissing+ + mid <- lift messageID++ let mkHeaders s e = ("Subject", s) : ("Message-ID", mid) : fromMaybe [] e+ mail = Mail <$> from <*> to- <*> (maybe [] id <$> cc)- <*> (maybe [] id <$> bcc)+ <*> (fromMaybe [] <$> cc)+ <*> (fromMaybe [] <$> bcc) <*> (mkHeaders <$> subject <*> head)- <*> (mkParts <$> body)+ <*> parts - widget = [whamlet|- #{fmsg}- $forall v <- [toView, ccView, bccView, sView, hView]- <div .control-group .span10>- <label .control-label for=#{fvId v}>#{fvLabel v}- <div .controls>- ^{fvInput v}- <div .control-group>- ^{fvInput bView}- <div .control-group>- <label .control-label for=#{fvId attachView}>#{fvLabel attachView}- <div .controls>- ^{fvInput attachView}+ widget = [whamlet|#{fmsg}+<div .row>+ <div .span6>+ ^{formElem False fromView}+ ^{formElem True toView}+ ^{formElem False ccView}+ ^{formElem False bccView}+ <div .span6>+ ^{addressBookWidget}+ $forall v <- [hView, fmtView]+ ^{formElem False v}+^{formElem False sView}+^{formElem False bView}+^{formElem False attachView} |] return (mail, widget) --- | Lookup the configured from address-fromAddress :: Handler Address-fromAddress = do- from <- extraFromAddress <$> getExtra- case parseAddresses from of- Left err -> invalidArgsI [err]- Right (Just [x]) -> return x- Right _ -> invalidArgs ["From address is invalid"]+previewForm :: Form T.Text+previewForm = renderDivs $ areq textField (fStr "previewtext") Nothing -getComposeR :: Handler RepHtml+-----------------------------------------------------------------------------------------+-- Handlers+-----------------------------------------------------------------------------------------++getComposeR :: Handler Html getComposeR = do- from <- fromAddress- ((_,widget),enctype) <- runFormPost $ composeForm from Nothing+ ((_,widget),enctype) <- runFormPost $ composeForm Nothing+ (previewWidget,previewEnctype) <- generateFormPost previewForm defaultLayout $ do setTitleI MsgCompose let err = [] :: [String] $(widgetFile "compose") -replyHandler :: ReplyTo -> MessageID -> Handler RepHtml+-- | Helper function for reply and reply all+replyHandler :: ReplyTo -> MessageID -> Handler Html replyHandler rto m = do- from <- fromAddress reply <- notmuchReply m rto- ((_,widget),enctype) <- runFormPost $ composeForm from $ Just reply+ ((_,widget),enctype) <- runFormPost $ composeForm $ Just reply+ (previewWidget,previewEnctype) <- generateFormPost previewForm defaultLayout $ do setTitleI MsgCompose let err = [] :: [String] $(widgetFile "compose") -getReplyR :: MessageID -> Handler RepHtml+getReplyR :: MessageID -> Handler Html getReplyR = replyHandler ReplySender -getReplyAllR :: MessageID -> Handler RepHtml+getReplyAllR :: MessageID -> Handler Html getReplyAllR = replyHandler ReplyAll +-- | Create a unique filename and a date header from the current time filenameAndDate :: IO (FilePath, TL.Text) filenameAndDate = do t <- getCurrentTime >>= utcToLocalZonedTime let ts = formatTime defaultTimeLocale "%F-%T%z" t@@ -251,23 +277,13 @@ i <- randomIO :: IO Int return (ts ++ "-" ++ show i, "Date: " <> TL.pack ds <> "\n") -messageID :: Handler T.Text-messageID = do t <- liftIO getCurrentTime- let ts = formatTime defaultTimeLocale "%s" t- i <- abs <$> liftIO (randomIO :: IO Int)- domain <- extraMessageIDDomain <$> getExtra- case domain of- "" -> return ""- _ -> return $ T.concat ["<notmuch-web-", T.pack ts, ".", T.pack (show i), "@", domain, ">"]--postComposeR :: Handler RepHtml+postComposeR :: Handler Html postComposeR = do- from <- fromAddress- ((result,widget),enctype) <- runFormPost $ composeForm from Nothing+ ((result,widget),enctype) <- runFormPost $ composeForm Nothing+ (previewWidget,previewEnctype) <- generateFormPost previewForm case result of FormSuccess m -> do- mid <- messageID- msg <- liftIO $ renderMail' $ m {mailHeaders = mailHeaders m ++ [("Message-ID", mid)]}+ msg <- liftIO $ renderMail' m let tmsg = TL.decodeUtf8 msg when production $ do@@ -286,3 +302,11 @@ FormFailure err -> defaultLayout $ do setTitleI MsgCompose $(widgetFile "compose")++postPreviewMessageR :: Handler Html+postPreviewMessageR = do+ ((result,_),_) <- runFormPost previewForm+ case result of+ FormMissing -> invalidArgs ["Form is missing"]+ FormFailure err -> invalidArgs err+ FormSuccess h -> return $ markdownToHtml h
+ src/Handler/ComposeFields.hs view
@@ -0,0 +1,168 @@+{-+Copyright (C) 2013 John Lenz <lenz@math.uic.edu>++This program is free software: you can redistribute it and/or modify+it under the terms of the GNU Affero General Public License as published by+the Free Software Foundation, either version 3 of the License, or+(at your option) any later version.++This program is distributed in the hope that it will be useful,+but WITHOUT ANY WARRANTY; without even the implied warranty of+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the+GNU Affero General Public License for more details.++You should have received a copy of the GNU Affero General Public License+along with this program. If not, see <http://www.gnu.org/licenses/>.+-}+module Handler.ComposeFields (+ addressField+ , parseAddress+ , parseAddresses+ , showAddress+ , headerField+ , multiFile+) where++import Import+import StaticFiles++import Data.Attoparsec.Text+import Data.String (fromString)+import Network.Mail.Mime (Address(..))++import qualified Data.ByteString as B+import qualified Data.Text as T+import qualified Data.Text.Encoding as T+import qualified Text.Email.Validate as E++-- | Parse an email address in angle brackets+emailInBrackets :: Parser T.Text+emailInBrackets = do+ void $ char '<'+ y <- takeTill (=='>') <?> "Email address"+ void (char '>' <?> "Expecting '>'")+ skipSpace+ return y++quotedName :: Parser T.Text+quotedName = do+ void $ char '"'+ x <- takeTill (=='"')+ void $ char '"'+ skipSpace+ let parts = T.split (==',') x+ return $ case parts of+ (p:ps) -> T.unwords $ map T.strip $ ps ++ [p]+ [] -> ""++unquotedName :: Parser T.Text+unquotedName = T.strip <$> takeTill (\x -> x == '<' || x == ',')++-- | Parses an email address, which can either just be a direct email+-- address or can be an address in the form "Name <email>"+address :: Parser Address+address = do+ skipSpace+ x <- quotedName <|> unquotedName+ y <- Just <$> emailInBrackets <|> return Nothing+ case y of+ Just e -> return $ Address (Just x) e+ Nothing -> return $ Address Nothing x++-- | Parse a list of addresses seperated by commas+addresses :: Parser [Address]+addresses = do as <- address `sepBy1` char ','+ endOfInput <?> "Expecting ',' or '>'"+ return as++-- | Checks if an email is valid+checkAddr :: Address -> Either (SomeMessage App) Address+checkAddr a@(Address _ e) | E.isValid (T.encodeUtf8 e) = Right a+checkAddr (Address _ e) = Left $ SomeMessage $ MsgInvalidEmail e++-- | Parse a single address+parseAddress :: T.Text -> Either (SomeMessage App) Address+parseAddress t = case parseOnly' address t of+ Left err -> Left $ fromString $ concat ["Error parsing ", T.unpack t, ": ", err]+ Right a -> checkAddr a++-- | Parse a list of addresses separated by commas+parseAddresses :: T.Text -> Either (SomeMessage App) [Address]+parseAddresses t = case parseOnly' addresses t of+ Left err -> Left $ fromString $ concat ["Error parsing ", T.unpack t, ": ", err]+ Right [(Address Nothing "")] -> Right []+ Right a -> mapM checkAddr a++showAddress :: Address -> T.Text+showAddress (Address {addressName = Just name, addressEmail = e}) = T.concat [name, " <", e, ">"]+showAddress (Address {addressName = Nothing, addressEmail = e}) = e++addrWidget :: FieldViewFunc (HandlerT App IO) [Address]+addrWidget theID name attrs val isReq = do + addStylesheet $ StaticR css_select2_css+ addScript $ StaticR js_select2_3_4_1_min_js++ let addrs = either id (T.intercalate "," . map showAddress) val++ [whamlet|+ <input type=text ##{theID} name=#{name} .address-field :isReq:required value="#{addrs}" *{attrs}>+ |]++addressField :: Field (HandlerT App IO) [Address]+addressField = Field+ { fieldParse = \addr _ -> case addr of+ [] -> return $ Right Nothing+ (a:_) -> return $ Just <$> parseAddresses a+ , fieldView = addrWidget+ , fieldEnctype = UrlEncoded+ }++-- | Parse a header+header :: Parser (B.ByteString, T.Text)+header = do+ k <- takeWhile1 (\c -> not (isEndOfLine c) && c /= ':')+ void $ char ':'+ skipSpace+ v <- takeTill isEndOfLine+ return (T.encodeUtf8 k, v)++-- | Parse a list of headers+headers :: Parser [(B.ByteString, T.Text)]+headers = sepBy header endOfLine <?> "Headers"++headerField :: Field (HandlerT App IO) [(B.ByteString,T.Text)]+headerField = Field + { fieldParse = \x _ -> case x of+ [] -> return $ Right Nothing+ ("":_) -> return $ Right Nothing+ (n:_) -> return $ case parseOnly' headers n of+ Left err -> Left $ fromString err+ Right [] -> Right $ Nothing+ Right h -> Right $ Just h+ , fieldView = \theId name attrs val isReq -> do+ let hdrs = case val of+ Left txt -> txt+ Right vals -> T.intercalate "\n" [ T.decodeUtf8 x <> ": " <> y | (x,y) <- vals]+ [whamlet|+<textarea id=#{theId} name=#{name} *{attrs} rows=4 cols=50 wrap=off :isReq:required>+ #{hdrs}+|]+ , fieldEnctype = UrlEncoded+ }++multiFile :: Field (HandlerT master IO) [FileInfo]+multiFile = Field p view Multipart+ where+ p _ fs = return $ Right $ Just fs+ view fId name attrs _ _ = [whamlet|+<input type=file name=#{name} ##{fId} multiple *{attrs}>+|]+++-- | A version of parseOnly which includes the context of the failure.+parseOnly' :: Parser a -> T.Text -> Either String a+parseOnly' p t = checkRes (parse p t)+ where checkRes result = case result of+ Fail _ ctx err -> Left $ show ctx ++ " " ++ err+ Partial f -> checkRes $ f ""+ Done _ x -> Right x
src/Handler/Home.hs view
@@ -23,13 +23,11 @@ ) where import Import-import Settings+import Handler.Tags+ import Text.Hamlet (hamletFile) import qualified Data.Text as T -import Handler.Tags-import NotmuchCmd- searchTable :: String -> Widget searchTable s = do tagHeader@@ -37,7 +35,7 @@ $(widgetFile "search") -- | The GET handler for the home page-getHomeR :: Handler RepHtml+getHomeR :: Handler Html getHomeR = defaultLayout $ do folders <- extraFolders <$> getExtra case folders of@@ -46,7 +44,7 @@ _ -> setTitle "Notmuch" -- | The GET handler for the search page-getSearchR :: String -> Handler RepHtml+getSearchR :: String -> Handler Html getSearchR s = defaultLayout $ do setTitle $ toHtml $ "Notmuch - " ++ s searchTable s
src/Handler/Pager.hs view
@@ -18,16 +18,13 @@ module Handler.Pager (getThreadPagerR) where import Import-import Prelude (tail)-import Settings import StaticFiles-import NotmuchCmd-import Data.Maybe (listToMaybe) import Handler.View++import Data.Aeson (encode)+import Text.Blaze (unsafeLazyByteString) import qualified Data.Text as T import qualified Data.HashMap.Strict as H-import Text.Blaze (unsafeLazyByteString)-import Data.Aeson (encode) encodeSearch :: (Route App -> T.Text) -> [SearchResult] -> Html encodeSearch ur search = unsafeLazyByteString $ encode $ map enc search@@ -37,10 +34,10 @@ Object o -> o _ -> error "Search is not an object" -getThreadPagerR :: String -> Handler RepHtml+getThreadPagerR :: String -> Handler Html getThreadPagerR s = defaultLayout $ do search <- notmuchSearch s ur <- getUrlRender retags <- extraRetag <$> getExtra- addScript $ StaticR js_jquery_pjax_1_7_0_js+ addScript $ StaticR js_jquery_pjax_1_7_3_js $(widgetFile "pager")
src/Handler/Raw.hs view
@@ -17,14 +17,13 @@ module Handler.Raw (getRawCommandR, postRawCommandR) where import Import-import NotmuchCmd import qualified Data.Text as T rawForm :: AForm (HandlerT App IO) String rawForm = T.unpack <$> areq textField cmd Nothing where cmd = FieldSettings (SomeMessage MsgRaw) Nothing (Just "Command") Nothing [] -getRawCommandR :: Handler RepHtml+getRawCommandR :: Handler Html getRawCommandR = defaultLayout $ do setTitleI MsgRaw ((result,widget),enctype) <- liftHandlerT $ runFormPost $ renderBootstrap rawForm@@ -41,7 +40,7 @@ <input .btn .primary type=submit value=_{MsgRunCmd}> |] -postRawCommandR :: Handler RepHtml+postRawCommandR :: Handler Html postRawCommandR = do ((result,_),_) <- runFormPost $ renderBootstrap rawForm command <- case result of
src/Handler/Tags.hs view
@@ -24,14 +24,20 @@ ) where import Import-import Settings+--import Data.Hashable (hash) import qualified Data.Text as T -import NotmuchCmd- displayTag :: T.Text -> WidgetT App IO () displayTag "unread" = [whamlet|<span .label .label-important .label-tag>unread|]-displayTag t = [whamlet|<span .label .label-info .label-tag>#{t}|]+displayTag t = [whamlet|<span .label .label-info .label-tag>#{t}|]+{-+displayTag t = case hash t `mod` 5 of+ 0 -> [whamlet|<span .label .label-success .label-tag>#{t}|]+ 1 -> [whamlet|<span .label .label-tag>#{t}|]+ 2 -> [whamlet|<span .label .label-warning .label-tag>#{t}|]+ 3 -> [whamlet|<span .label .label-inverse .label-tag>#{t}|]+ _ -> [whamlet|<span .label .label-info .label-tag>#{t}|]+-} -- | A widget consisting of the retag buttons followed by the current tag list. tagWidget :: Either SearchResult Message -> WidgetT App IO ()@@ -48,7 +54,7 @@ retags <- extraRetag <$> getExtra [whamlet| <span .tags>- <div .btn-group>+ <span .btn-group .hide-noscript> $forall r <- retags <button .btn .retag-button .btn-link title="#{retagName r}" data-notmuch-url="@{url (retagName r)}"> <i class="#{retagIcon r}">@@ -79,7 +85,7 @@ (customWidget,customEnc) <- liftHandlerT $ generateFormPost customRetagForm $(widgetFile "tag-header") -postRetagThreadR :: ThreadID -> T.Text -> Handler RepJson+postRetagThreadR :: ThreadID -> T.Text -> Handler Value postRetagThreadR t name = do ((result,_),_) <- runFormPost retagForm case result of@@ -92,9 +98,9 @@ case retag of [] -> notFound (r:_) -> do notmuchTagThread (retagAdd r) (retagRemove r) t- return $ repJson $ toJSON r+ return $ toJSON r -postRetagMessageR :: MessageID -> T.Text -> Handler RepJson+postRetagMessageR :: MessageID -> T.Text -> Handler Value postRetagMessageR m name = do ((result,_),_) <- runFormPost retagForm case result of@@ -107,9 +113,9 @@ case retag of [] -> notFound (r:_) -> do notmuchTagMessage (retagAdd r) (retagRemove r) m- return $ repJson $ toJSON r+ return $ toJSON r -postCustomRetagThreadR :: ThreadID -> Handler RepJson+postCustomRetagThreadR :: ThreadID -> Handler Value postCustomRetagThreadR t = do ((result,_),_) <- runFormPost customRetagForm (add, remove) <- case result of@@ -120,9 +126,9 @@ rem' = maybe [] words remove notmuchTagThread add' rem' t- return $ repJson $ object [ "add" .= add', "remove" .= rem' ]+ return $ object [ "add" .= add', "remove" .= rem' ] -postCustomRetagMessageR :: MessageID -> Handler RepJson+postCustomRetagMessageR :: MessageID -> Handler Value postCustomRetagMessageR m = do ((result,_),_) <- runFormPost customRetagForm (add, remove) <- case result of@@ -133,4 +139,4 @@ rem' = maybe [] words remove notmuchTagMessage add' rem' m- return $ repJson $ object [ "add" .= add', "remove" .= rem' ]+ return $ object [ "add" .= add', "remove" .= rem' ]
src/Handler/View.hs view
@@ -23,52 +23,56 @@ ) where import Import-import Blaze.ByteString.Builder (fromByteString)-import Control.Monad (replicateM, unless)-import Data.List (find) import FilterHtml+import StaticFiles import Handler.Tags++import Blaze.ByteString.Builder (fromByteString) import Network.HTTP.Types (status200) import Network.Wai (Response(..))-import NotmuchCmd-import Settings-import StaticFiles-import Text.Blaze.Html5 (preEscapedToHtml)-import Text.Pandoc+import Text.Blaze.Html.Renderer.Text (renderHtml)+import Text.Markdown import qualified Data.ByteString as B-import qualified Data.Conduit as C+import qualified Data.CaseInsensitive as CI import qualified Data.Conduit.List as C import qualified Data.Conduit.Text as C+import qualified Data.Map as M import qualified Data.Text as T import qualified Data.Text.Encoding as T import qualified Data.Text.Lazy as TL-import qualified Data.Text.ICU.Convert as ICU-import qualified Data.Map as M-import qualified Data.Set as S import qualified Data.Tree as TR-import qualified Data.CaseInsensitive as CI +#ifdef USE_ICU+import qualified Data.Text.ICU.Convert as ICU+#endif+ decodePart :: (MonadLogger m, MonadHandler m) - => Maybe T.Text -> C.Source (C.ResourceT IO) B.ByteString -> m TL.Text+ => Maybe (CI.CI T.Text) -> Source (ResourceT IO) B.ByteString -> m TL.Text decodePart charset src = case charset of Just "ISO-8859-1" -> decodeConduit C.iso8859_1 Just "UTF-8" -> decodeConduit C.utf8- Just x -> decodeICU x- Nothing -> decodeConduit C.utf8+#ifdef USE_ICU+ Just x -> decodeICU $ CI.original x+#endif+ _ -> decodeConduit C.utf8+ where decodeConduit c = TL.fromChunks <$> liftResourceT (src $= C.decode c $$ C.consume)++#ifdef USE_ICU decodeICU x = do $(logInfo) ("Decoding using ICU: " `T.append` x) raw <- liftResourceT (src $$ C.consume) c <- liftIO $ ICU.open (T.unpack x) (Just True)- return $ TL.fromChunks [ICU.toUnicode c $ B.concat raw]+ return $ TL.fromStrict $ ICU.toUnicode c $ B.concat raw+#endif messagePart :: MessageID -> Bool -> MessagePart -> Widget messagePart mid _ p@(MessagePart {partContentType = "text/html"}) = do let ((_ :: IO MessagePart), src) = notmuchMessagePart mid $ partID p- html <- decodePart (partContentCharset p) src+ html <- TL.toStrict <$> decodePart (partContentCharset p) src [whamlet| <div .message-part .message-html>- #{preEscapedToHtml $ filterHtml html}+ #{preEscapedToMarkup $ filterHtml html} |] messagePart mid _ m@(MessagePart {partContent = ContentText ""}) = [whamlet|@@ -80,7 +84,7 @@ #{f} $of Nothing <span>No filename- (#{partContentType m})+ (#{CI.original $ partContentType m}) |] -- Text which is part of an alternative@@ -92,7 +96,7 @@ -- Text not part of an alternative messagePart _ False (MessagePart {partContent = ContentText txt}) = do- let html = TL.pack $ writeHtmlString pandocWriterOpts $ readMarkdown pandocReaderOpts $ T.unpack txt+ let html = TL.toStrict $ renderHtml $ markdown markdownSettings $ TL.fromStrict txt htmlId <- newIdent txtId <- newIdent [whamlet|@@ -104,7 +108,7 @@ <a data-toggle=tab data-target="##{txtId}">Text <div .tab-content> <div .tab-pane ##{htmlId}>- #{preEscapedToHtml $ filterHtml html}+ #{preEscapedToMarkup $ filterHtml html} <div .tab-pane .active ##{txtId}> <pre> #{txt}@@ -123,7 +127,7 @@ <ul .nav .nav-tabs> $forall (i,p) <- zip ids alternatives <li :isActive p:.active>- <a data-toggle=tab data-target="##{i}">#{partContentType p}+ <a data-toggle=tab data-target="##{i}">#{CI.original $ partContentType p} <div .tab-content> $forall (i,p) <- zip ids alternatives <div .tab-pane :isActive p:.active ##{i}>@@ -194,7 +198,7 @@ tagHeader $(widgetFile "thread-header") -getThreadR :: ThreadID -> Handler RepHtml+getThreadR :: ThreadID -> Handler Html getThreadR t = defaultLayout $ do pjax <- isPjax unless pjax threadHeader@@ -208,22 +212,14 @@ let contentdisp = case partContentFilename msg of Just f -> [("Content-Disposition", "attachment;filename=\"" <> T.encodeUtf8 f <> "\"")] Nothing -> []- let contenttype = [("Content-Type", T.encodeUtf8 $ partContentType msg)]+ let contenttype = [("Content-Type", T.encodeUtf8 $ CI.original $ partContentType msg)] let source = rawMsg $= C.map (Chunk . fromByteString) sendWaiResponse $ ResponseSource status200 (contentdisp ++ contenttype) source --pandocWriterOpts :: WriterOptions-pandocWriterOpts = def- { writerHtml5 = True- , writerWrapText = False- }--pandocReaderOpts :: ReaderOptions-pandocReaderOpts = def- { readerExtensions = S.delete Ext_blank_before_blockquote $ readerExtensions def- , readerSmart = True- , readerParseRaw = False- }+markdownSettings :: MarkdownSettings+markdownSettings = def { msLinkNewTab = True+ , msXssProtect = False -- We protect using our own filtering in FilterHtml.hs+ , msBlankBeforeBlockquote = False+ }
src/Import.hs view
@@ -16,29 +16,34 @@ -} module Import ( module Prelude- , module Foundation- , module Settings- , module Data.Conduit- , module Data.Monoid , module Control.Applicative , module Control.Exception.Lifted , module Control.Monad+ , module Data.Conduit+ , module Data.List+ , module Data.Maybe+ , module Data.Monoid+ , module Foundation+ , module NotmuchCmd+ , module Settings , module Yesod ) where #if __GLASGOW_HASKELL__ >= 706-import Prelude hiding (writeFile, readFile, head, tail, init, last)+import Prelude hiding (writeFile, readFile, head, init, last) #else-import Prelude hiding (writeFile, readFile, head, tail, init, last, catch)+import Prelude hiding (writeFile, readFile, head, init, last, catch) #endif import Foundation-import Settings (development, production)+import Settings+import NotmuchCmd -import Data.Conduit-import Data.Monoid (Monoid (mappend, mempty, mconcat), (<>)) import Control.Applicative ((<$>), (<*>), pure, (<|>)) import Control.Exception.Lifted (catch)-import Control.Monad (void, when)--import Yesod+import Control.Monad (void, when, forM, replicateM, unless)+import Data.Conduit+import Data.List (find)+import Data.Monoid (Monoid (mappend, mempty, mconcat), (<>))+import Data.Maybe (listToMaybe, fromMaybe, catMaybes)+import Yesod hiding (loadConfig)
src/NotmuchCmd.hs view
@@ -47,6 +47,7 @@ -- * Utils , notmuchRaw , notmuchJson+ , notmuchVersion ) where import Prelude@@ -59,11 +60,14 @@ import Data.Conduit import Data.Conduit.Attoparsec (sinkParser) import Data.Conduit.Process+import Data.Monoid ((<>)) import Data.Time.Calendar (Day(..)) import Data.Time.Clock import Data.Time.Clock.POSIX import Data.Typeable (Typeable)+import Data.Version (Version(..), parseVersion) import Text.Blaze (ToMarkup(..))+import Text.ParserCombinators.ReadP (readP_to_S) import System.Process import System.Exit import Yesod (PathPiece)@@ -133,8 +137,8 @@ data MessagePart = MessagePart { partID :: Int- , partContentType :: T.Text- , partContentCharset :: Maybe T.Text+ , partContentType :: CI.CI T.Text+ , partContentCharset :: Maybe (CI.CI T.Text) , partContentFilename :: Maybe T.Text , partContent :: MessageContent } deriving (Show,Eq)@@ -150,16 +154,16 @@ instance FromJSON MessagePart where parseJSON (Object v) = do i <- v .: "id"- t <- v .: "content-type"+ t <- CI.mk . T.toLower <$> v .: "content-type" x <- v .:? "content" f <- v .:? "filename"- cs <- v .:? "content-charset"- let ctype = T.takeWhile (/= '/') t+ cs <- fmap CI.mk <$> v .:? "content-charset"+ let ctype = CI.map (T.takeWhile (/= '/')) t case (ctype, x) of ("multipart", Just (Array _)) -> MessagePart i t cs f . ContentMultipart <$> v .: "content" ("message", Just (Array lst)) | t == "message/rfc822" -> MessagePart i t cs f <$> parseRFC822 lst (_, Just (String c)) -> return $ MessagePart i t cs f $ ContentText c- (_, Just _) -> return $ MessagePart i t cs f $ ContentText $ "Unknown content-type: " `T.append` t+ (_, Just _) -> return $ MessagePart i t cs f $ ContentText $ "Unknown content-type: " <> CI.original t (_, Nothing) -> return $ MessagePart i t cs f $ ContentText "" parseJSON x = fail $ "Error parsing part: " ++ show x@@ -286,3 +290,13 @@ case fromJSON v of Error e -> throw $ NotmuchError $ "Error parsing for " ++ show args ++ " : " ++ e Success x -> return x++-- | The version of notmuch+notmuchVersion :: MonadIO m => m Version+notmuchVersion = do+ out <- liftIO $ readProcess "notmuch" ["--version"] ""+ let vStr = words out !! 1+ let vs = filter (\(_,r) -> r == "") $ readP_to_S parseVersion vStr+ case vs of+ ((v,_):_) -> return v+ _ -> throw $ NotmuchError $ "Unable to parse version: " ++ vStr
src/Settings.hs view
@@ -15,52 +15,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. -} --- | Settings are centralized, as much as possible, into this file. This--- includes database connection settings, static file locations, etc.--- In addition, you can configure a number of different aspects of Yesod--- by overriding methods in the Yesod typeclass. That instance is--- declared in the Foundation.hs file. module Settings where import Prelude++import Control.Applicative import Data.Aeson-import Text.Shakespeare.Text (st)-import Language.Haskell.TH.Syntax-import Yesod.Default.Config-import Yesod.Default.Util-import Data.Text (Text)-import qualified Data.Text.Encoding as TE import Data.ByteString (ByteString)-import Data.Yaml-import Control.Applicative import Data.Default (def)+import Data.Maybe (fromMaybe)+import Data.Text (Text)+import Data.Text.Encoding (encodeUtf8)+import Data.Yaml+import Language.Haskell.TH.Syntax (Q, Exp) import Text.Hamlet---- Static setting below. Changing these requires a recompile---- | The location of static files on your system. This is a file system--- path. The default value works properly with your scaffolded site.-staticDir :: FilePath-staticDir = "static"---- | The base URL for your static files. As you can see by the default--- value, this can simply be "static" appended to your application root.--- A powerful optimization can be serving static files from a separate--- domain name. This allows you to use a web server optimized for static--- files, more easily set expires and cache values, and avoid possibly--- costly transference of cookies on static files. For more information,--- please see:--- http://code.google.com/speed/page-speed/docs/request.html#ServeFromCookielessDomain------ If you change the resource pattern for StaticR in Foundation.hs, you will--- have to make a corresponding change here.------ To see how this value is used, see urlRenderOverride in Foundation.hs-staticRoot :: AppConfig DefaultEnv x -> Text-staticRoot conf = [st|#{appRoot conf}/static|]+import Yesod.Default.Config+import Yesod.Default.Util --- | Settings for 'widgetFile', such as which template languages to support and--- default Hamlet settings. widgetFileSettings :: WidgetFileSettings widgetFileSettings = def { wfsHamletSettings = defaultHamletSettings@@ -68,9 +39,6 @@ } } --- The rest of this file contains settings which rarely need changing by a--- user.- widgetFile :: String -> Q Exp widgetFile = (if development then widgetFileReload else widgetFileNoReload)@@ -100,19 +68,26 @@ { extraHashedPwd :: ByteString , extraFolders :: [(Text, String)] , extraRetag :: [RetagEntry]- , extraFromAddress :: Text+ , extraFromAddresses :: [Text] , extraSentBox :: Maybe FilePath , extraMessageIDDomain :: Text+ , extraGoogleClientId :: Maybe Text+ , extraSourceLink :: Text } deriving Show parseExtra :: DefaultEnv -> Object -> Parser Extra-parseExtra _ o = Extra- <$> (TE.encodeUtf8 <$> o .: "hashed-password")- <*> (o .: "folders" >>= mapM parseFolder)- <*> o .: "retag"- <*> o .: "from-address"- <*> o .:? "sent-box"- <*> o .:? "message-id-domain" .!= "" +parseExtra _ o = do+ pwd <- encodeUtf8 <$> o .: "hashed-password"+ fdl <- o .: "folders" >>= mapM parseFolder+ retag <- o .: "retag"+ from <- o .:? "from-address" .!= "<test@example.com>"+ fromlst <- o .:? "from-addresses"+ let f = fromMaybe [from] fromlst+ sent <- o .:? "sent-box"+ dom <- o .:? "message-id-domain" .!= ""+ gcid <- o .:? "google-client-id"+ sl <- o .:? "source-link" .!= "https://bitbucket.org/wuzzeb/notmuch-web/src"+ return $ Extra pwd fdl retag f sent dom gcid sl parseFolder :: Object -> Parser (Text, String) parseFolder o = (,) <$> o .: "name"
src/StaticFiles.hs view
@@ -19,15 +19,10 @@ import Prelude (IO) import Yesod.Static import qualified Yesod.Static as Static-import Settings (staticDir, development)+import Settings (development) --- | use this to create your static file serving site staticSite :: IO Static.Static-staticSite = if development then Static.staticDevel staticDir- else Static.static staticDir+staticSite = if development then Static.staticDevel "static"+ else Static.static "static" --- | This generates easy references to files in the static directory at compile time,--- giving you compile-time verification that referenced files exist.--- Warning: any files added to your static directory during run-time can't be--- accessed this way. You'll have to use their FilePath or URL to access them.-$(staticFiles Settings.staticDir)+$(staticFiles "static")
− static/js/jquery-pjax-1-7-0.js
@@ -1,822 +0,0 @@-// jquery.pjax.js-// copyright chris wanstrath-// https://github.com/defunkt/jquery-pjax--(function($){--// When called on a container with a selector, fetches the href with-// ajax into the container or with the data-pjax attribute on the link-// itself.-//-// Tries to make sure the back button and ctrl+click work the way-// you'd expect.-//-// Exported as $.fn.pjax-//-// Accepts a jQuery ajax options object that may include these-// pjax specific options:-//-//-// container - Where to stick the response body. Usually a String selector.-// $(container).html(xhr.responseBody)-// (default: current jquery context)-// push - Whether to pushState the URL. Defaults to true (of course).-// replace - Want to use replaceState instead? That's cool.-//-// For convenience the second parameter can be either the container or-// the options object.-//-// Returns the jQuery object-function fnPjax(selector, container, options) {- var context = this- return this.on('click.pjax', selector, function(event) {- var opts = $.extend({}, optionsFor(container, options))- if (!opts.container)- opts.container = $(this).attr('data-pjax') || context- handleClick(event, opts)- })-}--// Public: pjax on click handler-//-// Exported as $.pjax.click.-//-// event - "click" jQuery.Event-// options - pjax options-//-// Examples-//-// $(document).on('click', 'a', $.pjax.click)-// // is the same as-// $(document).pjax('a')-//-// $(document).on('click', 'a', function(event) {-// var container = $(this).closest('[data-pjax-container]')-// $.pjax.click(event, container)-// })-//-// Returns nothing.-function handleClick(event, container, options) {- options = optionsFor(container, options)-- var link = event.currentTarget-- if (link.tagName.toUpperCase() !== 'A')- throw "$.fn.pjax or $.pjax.click requires an anchor element"-- // Middle click, cmd click, and ctrl click should open- // links in a new tab as normal.- if ( event.which > 1 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey )- return-- // Ignore cross origin links- if ( location.protocol !== link.protocol || location.hostname !== link.hostname )- return-- // Ignore anchors on the same page- if (link.hash && link.href.replace(link.hash, '') ===- location.href.replace(location.hash, ''))- return-- // Ignore empty anchor "foo.html#"- if (link.href === location.href + '#')- return-- var defaults = {- url: link.href,- container: $(link).attr('data-pjax'),- target: link,- fragment: null- }-- var opts = $.extend({}, defaults, options)- var clickEvent = $.Event('pjax:click')- $(link).trigger(clickEvent, [opts])-- if (!clickEvent.isDefaultPrevented()) {- pjax(opts)- event.preventDefault()- }-}--// Public: pjax on form submit handler-//-// Exported as $.pjax.submit-//-// event - "click" jQuery.Event-// options - pjax options-//-// Examples-//-// $(document).on('submit', 'form', function(event) {-// var container = $(this).closest('[data-pjax-container]')-// $.pjax.submit(event, container)-// })-//-// Returns nothing.-function handleSubmit(event, container, options) {- options = optionsFor(container, options)-- var form = event.currentTarget-- if (form.tagName.toUpperCase() !== 'FORM')- throw "$.pjax.submit requires a form element"-- var defaults = {- type: form.method.toUpperCase(),- url: form.action,- data: $(form).serializeArray(),- container: $(form).attr('data-pjax'),- target: form,- fragment: null- }-- pjax($.extend({}, defaults, options))-- event.preventDefault()-}--// Loads a URL with ajax, puts the response body inside a container,-// then pushState()'s the loaded URL.-//-// Works just like $.ajax in that it accepts a jQuery ajax-// settings object (with keys like url, type, data, etc).-//-// Accepts these extra keys:-//-// container - Where to stick the response body.-// $(container).html(xhr.responseBody)-// push - Whether to pushState the URL. Defaults to true (of course).-// replace - Want to use replaceState instead? That's cool.-//-// Use it just like $.ajax:-//-// var xhr = $.pjax({ url: this.href, container: '#main' })-// console.log( xhr.readyState )-//-// Returns whatever $.ajax returns.-function pjax(options) {- options = $.extend(true, {}, $.ajaxSettings, pjax.defaults, options)-- if ($.isFunction(options.url)) {- options.url = options.url()- }-- var target = options.target-- var hash = parseURL(options.url).hash-- var context = options.context = findContainerFor(options.container)-- // We want the browser to maintain two separate internal caches: one- // for pjax'd partial page loads and one for normal page loads.- // Without adding this secret parameter, some browsers will often- // confuse the two.- if (!options.data) options.data = {}- options.data._pjax = context.selector-- function fire(type, args) {- var event = $.Event(type, { relatedTarget: target })- context.trigger(event, args)- return !event.isDefaultPrevented()- }-- var timeoutTimer-- options.beforeSend = function(xhr, settings) {- // No timeout for non-GET requests- // Its not safe to request the resource again with a fallback method.- if (settings.type !== 'GET') {- settings.timeout = 0- }-- xhr.setRequestHeader('X-PJAX', 'true')- xhr.setRequestHeader('X-PJAX-Container', context.selector)-- if (!fire('pjax:beforeSend', [xhr, settings]))- return false-- if (settings.timeout > 0) {- timeoutTimer = setTimeout(function() {- if (fire('pjax:timeout', [xhr, options]))- xhr.abort('timeout')- }, settings.timeout)-- // Clear timeout setting so jquerys internal timeout isn't invoked- settings.timeout = 0- }-- options.requestUrl = parseURL(settings.url).href- }-- options.complete = function(xhr, textStatus) {- if (timeoutTimer)- clearTimeout(timeoutTimer)-- fire('pjax:complete', [xhr, textStatus, options])-- fire('pjax:end', [xhr, options])- }-- options.error = function(xhr, textStatus, errorThrown) {- var container = extractContainer("", xhr, options)-- var allowed = fire('pjax:error', [xhr, textStatus, errorThrown, options])- if (options.type == 'GET' && textStatus !== 'abort' && allowed) {- locationReplace(container.url)- }- }-- options.success = function(data, status, xhr) {- // If $.pjax.defaults.version is a function, invoke it first.- // Otherwise it can be a static string.- var currentVersion = (typeof $.pjax.defaults.version === 'function') ?- $.pjax.defaults.version() :- $.pjax.defaults.version-- var latestVersion = xhr.getResponseHeader('X-PJAX-Version')-- var container = extractContainer(data, xhr, options)-- // If there is a layout version mismatch, hard load the new url- if (currentVersion && latestVersion && currentVersion !== latestVersion) {- locationReplace(container.url)- return- }-- // If the new response is missing a body, hard load the page- if (!container.contents) {- locationReplace(container.url)- return- }-- pjax.state = {- id: options.id || uniqueId(),- url: container.url,- title: container.title,- container: context.selector,- fragment: options.fragment,- timeout: options.timeout- }-- if (options.push || options.replace) {- window.history.replaceState(pjax.state, container.title, container.url)- }-- if (container.title) document.title = container.title- context.html(container.contents)- executeScriptTags(container.scripts)-- // Scroll to top by default- if (typeof options.scrollTo === 'number')- $(window).scrollTop(options.scrollTo)-- // If the URL has a hash in it, make sure the browser- // knows to navigate to the hash.- if ( hash !== '' ) {- // Avoid using simple hash set here. Will add another history- // entry. Replace the url with replaceState and scroll to target- // by hand.- //- // window.location.hash = hash- var url = parseURL(container.url)- url.hash = hash-- pjax.state.url = url.href- window.history.replaceState(pjax.state, container.title, url.href)-- var target = $(url.hash)- if (target.length) $(window).scrollTop(target.offset().top)- }-- fire('pjax:success', [data, status, xhr, options])- }--- // Initialize pjax.state for the initial page load. Assume we're- // using the container and options of the link we're loading for the- // back button to the initial page. This ensures good back button- // behavior.- if (!pjax.state) {- pjax.state = {- id: uniqueId(),- url: window.location.href,- title: document.title,- container: context.selector,- fragment: options.fragment,- timeout: options.timeout- }- window.history.replaceState(pjax.state, document.title)- }-- // Cancel the current request if we're already pjaxing- var xhr = pjax.xhr- if ( xhr && xhr.readyState < 4) {- xhr.onreadystatechange = $.noop- xhr.abort()- }-- pjax.options = options- var xhr = pjax.xhr = $.ajax(options)-- if (xhr.readyState > 0) {- if (options.push && !options.replace) {- // Cache current container element before replacing it- cachePush(pjax.state.id, context.clone().contents())-- window.history.pushState(null, "", stripPjaxParam(options.requestUrl))- }-- fire('pjax:start', [xhr, options])- fire('pjax:send', [xhr, options])- }-- return pjax.xhr-}--// Public: Reload current page with pjax.-//-// Returns whatever $.pjax returns.-function pjaxReload(container, options) {- var defaults = {- url: window.location.href,- push: false,- replace: true,- scrollTo: false- }-- return pjax($.extend(defaults, optionsFor(container, options)))-}--// Internal: Hard replace current state with url.-//-// Work for around WebKit-// https://bugs.webkit.org/show_bug.cgi?id=93506-//-// Returns nothing.-function locationReplace(url) {- window.history.replaceState(null, "", "#")- window.location.replace(url)-}---var initialPop = true-var initialURL = window.location.href-var initialState = window.history.state--// Initialize $.pjax.state if possible-// Happens when reloading a page and coming forward from a different-// session history.-if (initialState && initialState.container) {- pjax.state = initialState-}--// Non-webkit browsers don't fire an initial popstate event-if ('state' in window.history) {- initialPop = false-}--// popstate handler takes care of the back and forward buttons-//-// You probably shouldn't use pjax on pages with other pushState-// stuff yet.-function onPjaxPopstate(event) {- var state = event.state-- if (state && state.container) {- // When coming forward from a seperate history session, will get an- // initial pop with a state we are already at. Skip reloading the current- // page.- if (initialPop && initialURL == state.url) return-- var container = $(state.container)- if (container.length) {- var direction, contents = cacheMapping[state.id]-- if (pjax.state) {- // Since state ids always increase, we can deduce the history- // direction from the previous state.- direction = pjax.state.id < state.id ? 'forward' : 'back'-- // Cache current container before replacement and inform the- // cache which direction the history shifted.- cachePop(direction, pjax.state.id, container.clone().contents())- }-- var popstateEvent = $.Event('pjax:popstate', {- state: state,- direction: direction- })- container.trigger(popstateEvent)-- var options = {- id: state.id,- url: state.url,- container: container,- push: false,- fragment: state.fragment,- timeout: state.timeout,- scrollTo: false- }-- if (contents) {- container.trigger('pjax:start', [null, options])-- if (state.title) document.title = state.title- container.html(contents)- pjax.state = state-- container.trigger('pjax:end', [null, options])- } else {- pjax(options)- }-- // Force reflow/relayout before the browser tries to restore the- // scroll position.- container[0].offsetHeight- } else {- locationReplace(location.href)- }- }- initialPop = false-}--// Fallback version of main pjax function for browsers that don't-// support pushState.-//-// Returns nothing since it retriggers a hard form submission.-function fallbackPjax(options) {- var url = $.isFunction(options.url) ? options.url() : options.url,- method = options.type ? options.type.toUpperCase() : 'GET'-- var form = $('<form>', {- method: method === 'GET' ? 'GET' : 'POST',- action: url,- style: 'display:none'- })-- if (method !== 'GET' && method !== 'POST') {- form.append($('<input>', {- type: 'hidden',- name: '_method',- value: method.toLowerCase()- }))- }-- var data = options.data- if (typeof data === 'string') {- $.each(data.split('&'), function(index, value) {- var pair = value.split('=')- form.append($('<input>', {type: 'hidden', name: pair[0], value: pair[1]}))- })- } else if (typeof data === 'object') {- for (key in data)- form.append($('<input>', {type: 'hidden', name: key, value: data[key]}))- }-- $(document.body).append(form)- form.submit()-}--// Internal: Generate unique id for state object.-//-// Use a timestamp instead of a counter since ids should still be-// unique across page loads.-//-// Returns Number.-function uniqueId() {- return (new Date).getTime()-}--// Internal: Strips _pjax param from url-//-// url - String-//-// Returns String.-function stripPjaxParam(url) {- return url- .replace(/\?_pjax=[^&]+&?/, '?')- .replace(/_pjax=[^&]+&?/, '')- .replace(/[\?&]$/, '')-}--// Internal: Parse URL components and returns a Locationish object.-//-// url - String URL-//-// Returns HTMLAnchorElement that acts like Location.-function parseURL(url) {- var a = document.createElement('a')- a.href = url- return a-}--// Internal: Build options Object for arguments.-//-// For convenience the first parameter can be either the container or-// the options object.-//-// Examples-//-// optionsFor('#container')-// // => {container: '#container'}-//-// optionsFor('#container', {push: true})-// // => {container: '#container', push: true}-//-// optionsFor({container: '#container', push: true})-// // => {container: '#container', push: true}-//-// Returns options Object.-function optionsFor(container, options) {- // Both container and options- if ( container && options )- options.container = container-- // First argument is options Object- else if ( $.isPlainObject(container) )- options = container-- // Only container- else- options = {container: container}-- // Find and validate container- if (options.container)- options.container = findContainerFor(options.container)-- return options-}--// Internal: Find container element for a variety of inputs.-//-// Because we can't persist elements using the history API, we must be-// able to find a String selector that will consistently find the Element.-//-// container - A selector String, jQuery object, or DOM Element.-//-// Returns a jQuery object whose context is `document` and has a selector.-function findContainerFor(container) {- container = $(container)-- if ( !container.length ) {- throw "no pjax container for " + container.selector- } else if ( container.selector !== '' && container.context === document ) {- return container- } else if ( container.attr('id') ) {- return $('#' + container.attr('id'))- } else {- throw "cant get selector for pjax container!"- }-}--// Internal: Filter and find all elements matching the selector.-//-// Where $.fn.find only matches descendants, findAll will test all the-// top level elements in the jQuery object as well.-//-// elems - jQuery object of Elements-// selector - String selector to match-//-// Returns a jQuery object.-function findAll(elems, selector) {- return elems.filter(selector).add(elems.find(selector));-}--function parseHTML(html) {- return $.parseHTML(html, document, true)-}--// Internal: Extracts container and metadata from response.-//-// 1. Extracts X-PJAX-URL header if set-// 2. Extracts inline <title> tags-// 3. Builds response Element and extracts fragment if set-//-// data - String response data-// xhr - XHR response-// options - pjax options Object-//-// Returns an Object with url, title, and contents keys.-function extractContainer(data, xhr, options) {- var obj = {}-- // Prefer X-PJAX-URL header if it was set, otherwise fallback to- // using the original requested url.- obj.url = stripPjaxParam(xhr.getResponseHeader('X-PJAX-URL') || options.requestUrl)-- // Attempt to parse response html into elements- if (/<html/i.test(data)) {- var $head = $(parseHTML(data.match(/<head[^>]*>([\s\S.]*)<\/head>/i)[0]))- var $body = $(parseHTML(data.match(/<body[^>]*>([\s\S.]*)<\/body>/i)[0]))- } else {- var $head = $body = $(parseHTML(data))- }-- // If response data is empty, return fast- if ($body.length === 0)- return obj-- // If there's a <title> tag in the header, use it as- // the page's title.- obj.title = findAll($head, 'title').last().text()-- if (options.fragment) {- // If they specified a fragment, look for it in the response- // and pull it out.- if (options.fragment === 'body') {- var $fragment = $body- } else {- var $fragment = findAll($body, options.fragment).first()- }-- if ($fragment.length) {- obj.contents = $fragment.contents()-- // If there's no title, look for data-title and title attributes- // on the fragment- if (!obj.title)- obj.title = $fragment.attr('title') || $fragment.data('title')- }-- } else if (!/<html/i.test(data)) {- obj.contents = $body- }-- // Clean up any <title> tags- if (obj.contents) {- // Remove any parent title elements- obj.contents = obj.contents.not(function() { return $(this).is('title') })-- // Then scrub any titles from their descendents- obj.contents.find('title').remove()-- // Gather all script[src] elements- obj.scripts = findAll(obj.contents, 'script[src]').remove()- obj.contents = obj.contents.not(obj.scripts)- }-- // Trim any whitespace off the title- if (obj.title) obj.title = $.trim(obj.title)-- return obj-}--// Load an execute scripts using standard script request.-//-// Avoids jQuery's traditional $.getScript which does a XHR request and-// globalEval.-//-// scripts - jQuery object of script Elements-//-// Returns nothing.-function executeScriptTags(scripts) {- if (!scripts) return-- var existingScripts = $('script[src]')-- scripts.each(function() {- var src = this.src- var matchedScripts = existingScripts.filter(function() {- return this.src === src- })- if (matchedScripts.length) return-- var script = document.createElement('script')- script.type = $(this).attr('type')- script.src = $(this).attr('src')- document.head.appendChild(script)- })-}--// Internal: History DOM caching class.-var cacheMapping = {}-var cacheForwardStack = []-var cacheBackStack = []--// Push previous state id and container contents into the history-// cache. Should be called in conjunction with `pushState` to save the-// previous container contents.-//-// id - State ID Number-// value - DOM Element to cache-//-// Returns nothing.-function cachePush(id, value) {- cacheMapping[id] = value- cacheBackStack.push(id)-- // Remove all entires in forward history stack after pushing- // a new page.- while (cacheForwardStack.length)- delete cacheMapping[cacheForwardStack.shift()]-- // Trim back history stack to max cache length.- while (cacheBackStack.length > pjax.defaults.maxCacheLength)- delete cacheMapping[cacheBackStack.shift()]-}--// Shifts cache from directional history cache. Should be-// called on `popstate` with the previous state id and container-// contents.-//-// direction - "forward" or "back" String-// id - State ID Number-// value - DOM Element to cache-//-// Returns nothing.-function cachePop(direction, id, value) {- var pushStack, popStack- cacheMapping[id] = value-- if (direction === 'forward') {- pushStack = cacheBackStack- popStack = cacheForwardStack- } else {- pushStack = cacheForwardStack- popStack = cacheBackStack- }-- pushStack.push(id)- if (id = popStack.pop())- delete cacheMapping[id]-}--// Public: Find version identifier for the initial page load.-//-// Returns String version or undefined.-function findVersion() {- return $('meta').filter(function() {- var name = $(this).attr('http-equiv')- return name && name.toUpperCase() === 'X-PJAX-VERSION'- }).attr('content')-}--// Install pjax functions on $.pjax to enable pushState behavior.-//-// Does nothing if already enabled.-//-// Examples-//-// $.pjax.enable()-//-// Returns nothing.-function enable() {- $.fn.pjax = fnPjax- $.pjax = pjax- $.pjax.enable = $.noop- $.pjax.disable = disable- $.pjax.click = handleClick- $.pjax.submit = handleSubmit- $.pjax.reload = pjaxReload- $.pjax.defaults = {- timeout: 650,- push: true,- replace: false,- type: 'GET',- dataType: 'html',- scrollTo: 0,- maxCacheLength: 20,- version: findVersion- }- $(window).on('popstate.pjax', onPjaxPopstate)-}--// Disable pushState behavior.-//-// This is the case when a browser doesn't support pushState. It is-// sometimes useful to disable pushState for debugging on a modern-// browser.-//-// Examples-//-// $.pjax.disable()-//-// Returns nothing.-function disable() {- $.fn.pjax = function() { return this }- $.pjax = fallbackPjax- $.pjax.enable = enable- $.pjax.disable = $.noop- $.pjax.click = $.noop- $.pjax.submit = $.noop- $.pjax.reload = function() { window.location.reload() }-- $(window).off('popstate.pjax', onPjaxPopstate)-}---// Add the state property to jQuery's event object so we can use it in-// $(window).bind('popstate')-if ( $.inArray('state', $.event.props) < 0 )- $.event.props.push('state')-- // Is pjax supported by this browser?-$.support.pjax =- window.history && window.history.pushState && window.history.replaceState &&- // pushState isn't reliable on iOS until 5.- !navigator.userAgent.match(/((iPod|iPhone|iPad).+\bOS\s+[1-4]|WebApps\/.+CFNetwork)/)--$.support.pjax ? enable() : disable()--})(jQuery);
+ static/js/jquery-pjax-1-7-3.js view
@@ -0,0 +1,838 @@+// jquery.pjax.js+// copyright chris wanstrath+// https://github.com/defunkt/jquery-pjax++(function($){++// When called on a container with a selector, fetches the href with+// ajax into the container or with the data-pjax attribute on the link+// itself.+//+// Tries to make sure the back button and ctrl+click work the way+// you'd expect.+//+// Exported as $.fn.pjax+//+// Accepts a jQuery ajax options object that may include these+// pjax specific options:+//+//+// container - Where to stick the response body. Usually a String selector.+// $(container).html(xhr.responseBody)+// (default: current jquery context)+// push - Whether to pushState the URL. Defaults to true (of course).+// replace - Want to use replaceState instead? That's cool.+//+// For convenience the second parameter can be either the container or+// the options object.+//+// Returns the jQuery object+function fnPjax(selector, container, options) {+ var context = this+ return this.on('click.pjax', selector, function(event) {+ var opts = $.extend({}, optionsFor(container, options))+ if (!opts.container)+ opts.container = $(this).attr('data-pjax') || context+ handleClick(event, opts)+ })+}++// Public: pjax on click handler+//+// Exported as $.pjax.click.+//+// event - "click" jQuery.Event+// options - pjax options+//+// Examples+//+// $(document).on('click', 'a', $.pjax.click)+// // is the same as+// $(document).pjax('a')+//+// $(document).on('click', 'a', function(event) {+// var container = $(this).closest('[data-pjax-container]')+// $.pjax.click(event, container)+// })+//+// Returns nothing.+function handleClick(event, container, options) {+ options = optionsFor(container, options)++ var link = event.currentTarget++ if (link.tagName.toUpperCase() !== 'A')+ throw "$.fn.pjax or $.pjax.click requires an anchor element"++ // Middle click, cmd click, and ctrl click should open+ // links in a new tab as normal.+ if ( event.which > 1 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey )+ return++ // Ignore cross origin links+ if ( location.protocol !== link.protocol || location.hostname !== link.hostname )+ return++ // Ignore anchors on the same page+ if (link.hash && link.href.replace(link.hash, '') ===+ location.href.replace(location.hash, ''))+ return++ // Ignore empty anchor "foo.html#"+ if (link.href === location.href + '#')+ return++ var defaults = {+ url: link.href,+ container: $(link).attr('data-pjax'),+ target: link+ }++ var opts = $.extend({}, defaults, options)+ var clickEvent = $.Event('pjax:click')+ $(link).trigger(clickEvent, [opts])++ if (!clickEvent.isDefaultPrevented()) {+ pjax(opts)+ event.preventDefault()+ }+}++// Public: pjax on form submit handler+//+// Exported as $.pjax.submit+//+// event - "click" jQuery.Event+// options - pjax options+//+// Examples+//+// $(document).on('submit', 'form', function(event) {+// var container = $(this).closest('[data-pjax-container]')+// $.pjax.submit(event, container)+// })+//+// Returns nothing.+function handleSubmit(event, container, options) {+ options = optionsFor(container, options)++ var form = event.currentTarget++ if (form.tagName.toUpperCase() !== 'FORM')+ throw "$.pjax.submit requires a form element"++ var defaults = {+ type: form.method.toUpperCase(),+ url: form.action,+ data: $(form).serializeArray(),+ container: $(form).attr('data-pjax'),+ target: form+ }++ pjax($.extend({}, defaults, options))++ event.preventDefault()+}++// Loads a URL with ajax, puts the response body inside a container,+// then pushState()'s the loaded URL.+//+// Works just like $.ajax in that it accepts a jQuery ajax+// settings object (with keys like url, type, data, etc).+//+// Accepts these extra keys:+//+// container - Where to stick the response body.+// $(container).html(xhr.responseBody)+// push - Whether to pushState the URL. Defaults to true (of course).+// replace - Want to use replaceState instead? That's cool.+//+// Use it just like $.ajax:+//+// var xhr = $.pjax({ url: this.href, container: '#main' })+// console.log( xhr.readyState )+//+// Returns whatever $.ajax returns.+function pjax(options) {+ options = $.extend(true, {}, $.ajaxSettings, pjax.defaults, options)++ if ($.isFunction(options.url)) {+ options.url = options.url()+ }++ var target = options.target++ var hash = parseURL(options.url).hash++ var context = options.context = findContainerFor(options.container)++ // We want the browser to maintain two separate internal caches: one+ // for pjax'd partial page loads and one for normal page loads.+ // Without adding this secret parameter, some browsers will often+ // confuse the two.+ if (!options.data) options.data = {}+ options.data._pjax = context.selector++ function fire(type, args) {+ var event = $.Event(type, { relatedTarget: target })+ context.trigger(event, args)+ return !event.isDefaultPrevented()+ }++ var timeoutTimer++ options.beforeSend = function(xhr, settings) {+ // No timeout for non-GET requests+ // Its not safe to request the resource again with a fallback method.+ if (settings.type !== 'GET') {+ settings.timeout = 0+ }++ xhr.setRequestHeader('X-PJAX', 'true')+ xhr.setRequestHeader('X-PJAX-Container', context.selector)++ if (!fire('pjax:beforeSend', [xhr, settings]))+ return false++ if (settings.timeout > 0) {+ timeoutTimer = setTimeout(function() {+ if (fire('pjax:timeout', [xhr, options]))+ xhr.abort('timeout')+ }, settings.timeout)++ // Clear timeout setting so jquerys internal timeout isn't invoked+ settings.timeout = 0+ }++ options.requestUrl = parseURL(settings.url).href+ }++ options.complete = function(xhr, textStatus) {+ if (timeoutTimer)+ clearTimeout(timeoutTimer)++ fire('pjax:complete', [xhr, textStatus, options])++ fire('pjax:end', [xhr, options])+ }++ options.error = function(xhr, textStatus, errorThrown) {+ var container = extractContainer("", xhr, options)++ var allowed = fire('pjax:error', [xhr, textStatus, errorThrown, options])+ if (options.type == 'GET' && textStatus !== 'abort' && allowed) {+ locationReplace(container.url)+ }+ }++ options.success = function(data, status, xhr) {+ // If $.pjax.defaults.version is a function, invoke it first.+ // Otherwise it can be a static string.+ var currentVersion = (typeof $.pjax.defaults.version === 'function') ?+ $.pjax.defaults.version() :+ $.pjax.defaults.version++ var latestVersion = xhr.getResponseHeader('X-PJAX-Version')++ var container = extractContainer(data, xhr, options)++ // If there is a layout version mismatch, hard load the new url+ if (currentVersion && latestVersion && currentVersion !== latestVersion) {+ locationReplace(container.url)+ return+ }++ // If the new response is missing a body, hard load the page+ if (!container.contents) {+ locationReplace(container.url)+ return+ }++ pjax.state = {+ id: options.id || uniqueId(),+ url: container.url,+ title: container.title,+ container: context.selector,+ fragment: options.fragment,+ timeout: options.timeout+ }++ if (options.push || options.replace) {+ window.history.replaceState(pjax.state, container.title, container.url)+ }++ // Clear out any focused controls before inserting new page contents.+ document.activeElement.blur()++ if (container.title) document.title = container.title+ context.html(container.contents)++ // FF bug: Won't autofocus fields that are inserted via JS.+ // This behavior is incorrect. So if theres no current focus, autofocus+ // the last field.+ //+ // http://www.w3.org/html/wg/drafts/html/master/forms.html+ var autofocusEl = context.find('input[autofocus], textarea[autofocus]').last()[0]+ if (autofocusEl && document.activeElement !== autofocusEl) {+ autofocusEl.focus();+ }++ executeScriptTags(container.scripts)++ // Scroll to top by default+ if (typeof options.scrollTo === 'number')+ $(window).scrollTop(options.scrollTo)++ // If the URL has a hash in it, make sure the browser+ // knows to navigate to the hash.+ if ( hash !== '' ) {+ // Avoid using simple hash set here. Will add another history+ // entry. Replace the url with replaceState and scroll to target+ // by hand.+ //+ // window.location.hash = hash+ var url = parseURL(container.url)+ url.hash = hash++ pjax.state.url = url.href+ window.history.replaceState(pjax.state, container.title, url.href)++ var target = $(url.hash)+ if (target.length) $(window).scrollTop(target.offset().top)+ }++ fire('pjax:success', [data, status, xhr, options])+ }+++ // Initialize pjax.state for the initial page load. Assume we're+ // using the container and options of the link we're loading for the+ // back button to the initial page. This ensures good back button+ // behavior.+ if (!pjax.state) {+ pjax.state = {+ id: uniqueId(),+ url: window.location.href,+ title: document.title,+ container: context.selector,+ fragment: options.fragment,+ timeout: options.timeout+ }+ window.history.replaceState(pjax.state, document.title)+ }++ // Cancel the current request if we're already pjaxing+ var xhr = pjax.xhr+ if ( xhr && xhr.readyState < 4) {+ xhr.onreadystatechange = $.noop+ xhr.abort()+ }++ pjax.options = options+ var xhr = pjax.xhr = $.ajax(options)++ if (xhr.readyState > 0) {+ if (options.push && !options.replace) {+ // Cache current container element before replacing it+ cachePush(pjax.state.id, context.clone().contents())++ window.history.pushState(null, "", stripPjaxParam(options.requestUrl))+ }++ fire('pjax:start', [xhr, options])+ fire('pjax:send', [xhr, options])+ }++ return pjax.xhr+}++// Public: Reload current page with pjax.+//+// Returns whatever $.pjax returns.+function pjaxReload(container, options) {+ var defaults = {+ url: window.location.href,+ push: false,+ replace: true,+ scrollTo: false+ }++ return pjax($.extend(defaults, optionsFor(container, options)))+}++// Internal: Hard replace current state with url.+//+// Work for around WebKit+// https://bugs.webkit.org/show_bug.cgi?id=93506+//+// Returns nothing.+function locationReplace(url) {+ window.history.replaceState(null, "", "#")+ window.location.replace(url)+}+++var initialPop = true+var initialURL = window.location.href+var initialState = window.history.state++// Initialize $.pjax.state if possible+// Happens when reloading a page and coming forward from a different+// session history.+if (initialState && initialState.container) {+ pjax.state = initialState+}++// Non-webkit browsers don't fire an initial popstate event+if ('state' in window.history) {+ initialPop = false+}++// popstate handler takes care of the back and forward buttons+//+// You probably shouldn't use pjax on pages with other pushState+// stuff yet.+function onPjaxPopstate(event) {+ var state = event.state++ if (state && state.container) {+ // When coming forward from a separate history session, will get an+ // initial pop with a state we are already at. Skip reloading the current+ // page.+ if (initialPop && initialURL == state.url) return++ // If popping back to the same state, just skip.+ // Could be clicking back from hashchange rather than a pushState.+ if (pjax.state.id === state.id) return++ var container = $(state.container)+ if (container.length) {+ var direction, contents = cacheMapping[state.id]++ if (pjax.state) {+ // Since state ids always increase, we can deduce the history+ // direction from the previous state.+ direction = pjax.state.id < state.id ? 'forward' : 'back'++ // Cache current container before replacement and inform the+ // cache which direction the history shifted.+ cachePop(direction, pjax.state.id, container.clone().contents())+ }++ var popstateEvent = $.Event('pjax:popstate', {+ state: state,+ direction: direction+ })+ container.trigger(popstateEvent)++ var options = {+ id: state.id,+ url: state.url,+ container: container,+ push: false,+ fragment: state.fragment,+ timeout: state.timeout,+ scrollTo: false+ }++ if (contents) {+ container.trigger('pjax:start', [null, options])++ if (state.title) document.title = state.title+ container.html(contents)+ pjax.state = state++ container.trigger('pjax:end', [null, options])+ } else {+ pjax(options)+ }++ // Force reflow/relayout before the browser tries to restore the+ // scroll position.+ container[0].offsetHeight+ } else {+ locationReplace(location.href)+ }+ }+ initialPop = false+}++// Fallback version of main pjax function for browsers that don't+// support pushState.+//+// Returns nothing since it retriggers a hard form submission.+function fallbackPjax(options) {+ var url = $.isFunction(options.url) ? options.url() : options.url,+ method = options.type ? options.type.toUpperCase() : 'GET'++ var form = $('<form>', {+ method: method === 'GET' ? 'GET' : 'POST',+ action: url,+ style: 'display:none'+ })++ if (method !== 'GET' && method !== 'POST') {+ form.append($('<input>', {+ type: 'hidden',+ name: '_method',+ value: method.toLowerCase()+ }))+ }++ var data = options.data+ if (typeof data === 'string') {+ $.each(data.split('&'), function(index, value) {+ var pair = value.split('=')+ form.append($('<input>', {type: 'hidden', name: pair[0], value: pair[1]}))+ })+ } else if (typeof data === 'object') {+ for (key in data)+ form.append($('<input>', {type: 'hidden', name: key, value: data[key]}))+ }++ $(document.body).append(form)+ form.submit()+}++// Internal: Generate unique id for state object.+//+// Use a timestamp instead of a counter since ids should still be+// unique across page loads.+//+// Returns Number.+function uniqueId() {+ return (new Date).getTime()+}++// Internal: Strips _pjax param from url+//+// url - String+//+// Returns String.+function stripPjaxParam(url) {+ return url+ .replace(/\?_pjax=[^&]+&?/, '?')+ .replace(/_pjax=[^&]+&?/, '')+ .replace(/[\?&]$/, '')+}++// Internal: Parse URL components and returns a Locationish object.+//+// url - String URL+//+// Returns HTMLAnchorElement that acts like Location.+function parseURL(url) {+ var a = document.createElement('a')+ a.href = url+ return a+}++// Internal: Build options Object for arguments.+//+// For convenience the first parameter can be either the container or+// the options object.+//+// Examples+//+// optionsFor('#container')+// // => {container: '#container'}+//+// optionsFor('#container', {push: true})+// // => {container: '#container', push: true}+//+// optionsFor({container: '#container', push: true})+// // => {container: '#container', push: true}+//+// Returns options Object.+function optionsFor(container, options) {+ // Both container and options+ if ( container && options )+ options.container = container++ // First argument is options Object+ else if ( $.isPlainObject(container) )+ options = container++ // Only container+ else+ options = {container: container}++ // Find and validate container+ if (options.container)+ options.container = findContainerFor(options.container)++ return options+}++// Internal: Find container element for a variety of inputs.+//+// Because we can't persist elements using the history API, we must be+// able to find a String selector that will consistently find the Element.+//+// container - A selector String, jQuery object, or DOM Element.+//+// Returns a jQuery object whose context is `document` and has a selector.+function findContainerFor(container) {+ container = $(container)++ if ( !container.length ) {+ throw "no pjax container for " + container.selector+ } else if ( container.selector !== '' && container.context === document ) {+ return container+ } else if ( container.attr('id') ) {+ return $('#' + container.attr('id'))+ } else {+ throw "cant get selector for pjax container!"+ }+}++// Internal: Filter and find all elements matching the selector.+//+// Where $.fn.find only matches descendants, findAll will test all the+// top level elements in the jQuery object as well.+//+// elems - jQuery object of Elements+// selector - String selector to match+//+// Returns a jQuery object.+function findAll(elems, selector) {+ return elems.filter(selector).add(elems.find(selector));+}++function parseHTML(html) {+ return $.parseHTML(html, document, true)+}++// Internal: Extracts container and metadata from response.+//+// 1. Extracts X-PJAX-URL header if set+// 2. Extracts inline <title> tags+// 3. Builds response Element and extracts fragment if set+//+// data - String response data+// xhr - XHR response+// options - pjax options Object+//+// Returns an Object with url, title, and contents keys.+function extractContainer(data, xhr, options) {+ var obj = {}++ // Prefer X-PJAX-URL header if it was set, otherwise fallback to+ // using the original requested url.+ obj.url = stripPjaxParam(xhr.getResponseHeader('X-PJAX-URL') || options.requestUrl)++ // Attempt to parse response html into elements+ if (/<html/i.test(data)) {+ var $head = $(parseHTML(data.match(/<head[^>]*>([\s\S.]*)<\/head>/i)[0]))+ var $body = $(parseHTML(data.match(/<body[^>]*>([\s\S.]*)<\/body>/i)[0]))+ } else {+ var $head = $body = $(parseHTML(data))+ }++ // If response data is empty, return fast+ if ($body.length === 0)+ return obj++ // If there's a <title> tag in the header, use it as+ // the page's title.+ obj.title = findAll($head, 'title').last().text()++ if (options.fragment) {+ // If they specified a fragment, look for it in the response+ // and pull it out.+ if (options.fragment === 'body') {+ var $fragment = $body+ } else {+ var $fragment = findAll($body, options.fragment).first()+ }++ if ($fragment.length) {+ obj.contents = $fragment.contents()++ // If there's no title, look for data-title and title attributes+ // on the fragment+ if (!obj.title)+ obj.title = $fragment.attr('title') || $fragment.data('title')+ }++ } else if (!/<html/i.test(data)) {+ obj.contents = $body+ }++ // Clean up any <title> tags+ if (obj.contents) {+ // Remove any parent title elements+ obj.contents = obj.contents.not(function() { return $(this).is('title') })++ // Then scrub any titles from their descendants+ obj.contents.find('title').remove()++ // Gather all script[src] elements+ obj.scripts = findAll(obj.contents, 'script[src]').remove()+ obj.contents = obj.contents.not(obj.scripts)+ }++ // Trim any whitespace off the title+ if (obj.title) obj.title = $.trim(obj.title)++ return obj+}++// Load an execute scripts using standard script request.+//+// Avoids jQuery's traditional $.getScript which does a XHR request and+// globalEval.+//+// scripts - jQuery object of script Elements+//+// Returns nothing.+function executeScriptTags(scripts) {+ if (!scripts) return++ var existingScripts = $('script[src]')++ scripts.each(function() {+ var src = this.src+ var matchedScripts = existingScripts.filter(function() {+ return this.src === src+ })+ if (matchedScripts.length) return++ var script = document.createElement('script')+ script.type = $(this).attr('type')+ script.src = $(this).attr('src')+ document.head.appendChild(script)+ })+}++// Internal: History DOM caching class.+var cacheMapping = {}+var cacheForwardStack = []+var cacheBackStack = []++// Push previous state id and container contents into the history+// cache. Should be called in conjunction with `pushState` to save the+// previous container contents.+//+// id - State ID Number+// value - DOM Element to cache+//+// Returns nothing.+function cachePush(id, value) {+ cacheMapping[id] = value+ cacheBackStack.push(id)++ // Remove all entires in forward history stack after pushing+ // a new page.+ while (cacheForwardStack.length)+ delete cacheMapping[cacheForwardStack.shift()]++ // Trim back history stack to max cache length.+ while (cacheBackStack.length > pjax.defaults.maxCacheLength)+ delete cacheMapping[cacheBackStack.shift()]+}++// Shifts cache from directional history cache. Should be+// called on `popstate` with the previous state id and container+// contents.+//+// direction - "forward" or "back" String+// id - State ID Number+// value - DOM Element to cache+//+// Returns nothing.+function cachePop(direction, id, value) {+ var pushStack, popStack+ cacheMapping[id] = value++ if (direction === 'forward') {+ pushStack = cacheBackStack+ popStack = cacheForwardStack+ } else {+ pushStack = cacheForwardStack+ popStack = cacheBackStack+ }++ pushStack.push(id)+ if (id = popStack.pop())+ delete cacheMapping[id]+}++// Public: Find version identifier for the initial page load.+//+// Returns String version or undefined.+function findVersion() {+ return $('meta').filter(function() {+ var name = $(this).attr('http-equiv')+ return name && name.toUpperCase() === 'X-PJAX-VERSION'+ }).attr('content')+}++// Install pjax functions on $.pjax to enable pushState behavior.+//+// Does nothing if already enabled.+//+// Examples+//+// $.pjax.enable()+//+// Returns nothing.+function enable() {+ $.fn.pjax = fnPjax+ $.pjax = pjax+ $.pjax.enable = $.noop+ $.pjax.disable = disable+ $.pjax.click = handleClick+ $.pjax.submit = handleSubmit+ $.pjax.reload = pjaxReload+ $.pjax.defaults = {+ timeout: 650,+ push: true,+ replace: false,+ type: 'GET',+ dataType: 'html',+ scrollTo: 0,+ maxCacheLength: 20,+ version: findVersion+ }+ $(window).on('popstate.pjax', onPjaxPopstate)+}++// Disable pushState behavior.+//+// This is the case when a browser doesn't support pushState. It is+// sometimes useful to disable pushState for debugging on a modern+// browser.+//+// Examples+//+// $.pjax.disable()+//+// Returns nothing.+function disable() {+ $.fn.pjax = function() { return this }+ $.pjax = fallbackPjax+ $.pjax.enable = enable+ $.pjax.disable = $.noop+ $.pjax.click = $.noop+ $.pjax.submit = $.noop+ $.pjax.reload = function() { window.location.reload() }++ $(window).off('popstate.pjax', onPjaxPopstate)+}+++// Add the state property to jQuery's event object so we can use it in+// $(window).bind('popstate')+if ( $.inArray('state', $.event.props) < 0 )+ $.event.props.push('state')++// Is pjax supported by this browser?+$.support.pjax =+ window.history && window.history.pushState && window.history.replaceState &&+ // pushState isn't reliable on iOS until 5.+ !navigator.userAgent.match(/((iPod|iPhone|iPad).+\bOS\s+[1-4]|WebApps\/.+CFNetwork)/)++$.support.pjax ? enable() : disable()++})(jQuery);
+ static/js/select2-3-4-1.min.js view
@@ -0,0 +1,22 @@+/*+Copyright 2012 Igor Vaynberg++Version: 3.4.1 Timestamp: Thu Jun 27 18:02:10 PDT 2013++This software is licensed under the Apache License, Version 2.0 (the "Apache License") or the GNU+General Public License version 2 (the "GPL License"). You may choose either license to govern your+use of this software only upon the condition that you accept all of the terms of either the Apache+License or the GPL License.++You may obtain a copy of the Apache License and the GPL License at:++http://www.apache.org/licenses/LICENSE-2.0+http://www.gnu.org/licenses/gpl-2.0.html++Unless required by applicable law or agreed to in writing, software distributed under the Apache License+or the GPL Licesnse is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,+either express or implied. See the Apache License and the GPL License for the specific language governing+permissions and limitations under the Apache License and the GPL License.+*/+(function(a){a.fn.each2===void 0&&a.fn.extend({each2:function(b){for(var c=a([0]),d=-1,e=this.length;e>++d&&(c.context=c[0]=this[d])&&b.call(c[0],d,c)!==!1;);return this}})})(jQuery),function(a,b){"use strict";function m(a,b){for(var c=0,d=b.length;d>c;c+=1)if(o(a,b[c]))return c;return-1}function n(){var b=a(l);b.appendTo("body");var c={width:b.width()-b[0].clientWidth,height:b.height()-b[0].clientHeight};return b.remove(),c}function o(a,c){return a===c?!0:a===b||c===b?!1:null===a||null===c?!1:a.constructor===String?a+""==c+"":c.constructor===String?c+""==a+"":!1}function p(b,c){var d,e,f;if(null===b||1>b.length)return[];for(d=b.split(c),e=0,f=d.length;f>e;e+=1)d[e]=a.trim(d[e]);return d}function q(a){return a.outerWidth(!1)-a.width()}function r(c){var d="keyup-change-value";c.on("keydown",function(){a.data(c,d)===b&&a.data(c,d,c.val())}),c.on("keyup",function(){var e=a.data(c,d);e!==b&&c.val()!==e&&(a.removeData(c,d),c.trigger("keyup-change"))})}function s(c){c.on("mousemove",function(c){var d=i;(d===b||d.x!==c.pageX||d.y!==c.pageY)&&a(c.target).trigger("mousemove-filtered",c)})}function t(a,c,d){d=d||b;var e;return function(){var b=arguments;window.clearTimeout(e),e=window.setTimeout(function(){c.apply(d,b)},a)}}function u(a){var c,b=!1;return function(){return b===!1&&(c=a(),b=!0),c}}function v(a,b){var c=t(a,function(a){b.trigger("scroll-debounced",a)});b.on("scroll",function(a){m(a.target,b.get())>=0&&c(a)})}function w(a){a[0]!==document.activeElement&&window.setTimeout(function(){var d,b=a[0],c=a.val().length;a.focus(),a.is(":visible")&&b===document.activeElement&&(b.setSelectionRange?b.setSelectionRange(c,c):b.createTextRange&&(d=b.createTextRange(),d.collapse(!1),d.select()))},0)}function x(b){b=a(b)[0];var c=0,d=0;if("selectionStart"in b)c=b.selectionStart,d=b.selectionEnd-c;else if("selection"in document){b.focus();var e=document.selection.createRange();d=document.selection.createRange().text.length,e.moveStart("character",-b.value.length),c=e.text.length-d}return{offset:c,length:d}}function y(a){a.preventDefault(),a.stopPropagation()}function z(a){a.preventDefault(),a.stopImmediatePropagation()}function A(b){if(!h){var c=b[0].currentStyle||window.getComputedStyle(b[0],null);h=a(document.createElement("div")).css({position:"absolute",left:"-10000px",top:"-10000px",display:"none",fontSize:c.fontSize,fontFamily:c.fontFamily,fontStyle:c.fontStyle,fontWeight:c.fontWeight,letterSpacing:c.letterSpacing,textTransform:c.textTransform,whiteSpace:"nowrap"}),h.attr("class","select2-sizer"),a("body").append(h)}return h.text(b.val()),h.width()}function B(b,c,d){var e,g,f=[];e=b.attr("class"),e&&(e=""+e,a(e.split(" ")).each2(function(){0===this.indexOf("select2-")&&f.push(this)})),e=c.attr("class"),e&&(e=""+e,a(e.split(" ")).each2(function(){0!==this.indexOf("select2-")&&(g=d(this),g&&f.push(this))})),b.attr("class",f.join(" "))}function C(a,c,d,e){var f=a.toUpperCase().indexOf(c.toUpperCase()),g=c.length;return 0>f?(d.push(e(a)),b):(d.push(e(a.substring(0,f))),d.push("<span class='select2-match'>"),d.push(e(a.substring(f,f+g))),d.push("</span>"),d.push(e(a.substring(f+g,a.length))),b)}function D(a){var b={"\\":"\","&":"&","<":"<",">":">",'"':""","'":"'","/":"/"};return(a+"").replace(/[&<>"'\/\\]/g,function(a){return b[a]})}function E(c){var d,e=0,f=null,g=c.quietMillis||100,h=c.url,i=this;return function(j){window.clearTimeout(d),d=window.setTimeout(function(){e+=1;var d=e,g=c.data,k=h,l=c.transport||a.fn.select2.ajaxDefaults.transport,m={type:c.type||"GET",cache:c.cache||!1,jsonpCallback:c.jsonpCallback||b,dataType:c.dataType||"json"},n=a.extend({},a.fn.select2.ajaxDefaults.params,m);g=g?g.call(i,j.term,j.page,j.context):null,k="function"==typeof k?k.call(i,j.term,j.page,j.context):k,f&&f.abort(),c.params&&(a.isFunction(c.params)?a.extend(n,c.params.call(i)):a.extend(n,c.params)),a.extend(n,{url:k,dataType:c.dataType,data:g,success:function(a){if(!(e>d)){var b=c.results(a,j.page);j.callback(b)}}}),f=l.call(i,n)},g)}}function F(c){var e,f,d=c,g=function(a){return""+a.text};a.isArray(d)&&(f=d,d={results:f}),a.isFunction(d)===!1&&(f=d,d=function(){return f});var h=d();return h.text&&(g=h.text,a.isFunction(g)||(e=h.text,g=function(a){return a[e]})),function(c){var h,e=c.term,f={results:[]};return""===e?(c.callback(d()),b):(h=function(b,d){var f,i;if(b=b[0],b.children){f={};for(i in b)b.hasOwnProperty(i)&&(f[i]=b[i]);f.children=[],a(b.children).each2(function(a,b){h(b,f.children)}),(f.children.length||c.matcher(e,g(f),b))&&d.push(f)}else c.matcher(e,g(b),b)&&d.push(b)},a(d().results).each2(function(a,b){h(b,f.results)}),c.callback(f),b)}}function G(c){var d=a.isFunction(c);return function(e){var f=e.term,g={results:[]};a(d?c():c).each(function(){var a=this.text!==b,c=a?this.text:this;(""===f||e.matcher(f,c))&&g.results.push(a?this:{id:this,text:this})}),e.callback(g)}}function H(b,c){if(a.isFunction(b))return!0;if(!b)return!1;throw Error(c+" must be a function or a falsy value")}function I(b){return a.isFunction(b)?b():b}function J(b){var c=0;return a.each(b,function(a,b){b.children?c+=J(b.children):c++}),c}function K(a,c,d,e){var h,i,j,k,l,f=a,g=!1;if(!e.createSearchChoice||!e.tokenSeparators||1>e.tokenSeparators.length)return b;for(;;){for(i=-1,j=0,k=e.tokenSeparators.length;k>j&&(l=e.tokenSeparators[j],i=a.indexOf(l),!(i>=0));j++);if(0>i)break;if(h=a.substring(0,i),a=a.substring(i+l.length),h.length>0&&(h=e.createSearchChoice.call(this,h,c),h!==b&&null!==h&&e.id(h)!==b&&null!==e.id(h))){for(g=!1,j=0,k=c.length;k>j;j++)if(o(e.id(h),e.id(c[j]))){g=!0;break}g||d(h)}}return f!==a?a:b}function L(b,c){var d=function(){};return d.prototype=new b,d.prototype.constructor=d,d.prototype.parent=b.prototype,d.prototype=a.extend(d.prototype,c),d}if(window.Select2===b){var c,d,e,f,g,h,j,k,i={x:0,y:0},c={TAB:9,ENTER:13,ESC:27,SPACE:32,LEFT:37,UP:38,RIGHT:39,DOWN:40,SHIFT:16,CTRL:17,ALT:18,PAGE_UP:33,PAGE_DOWN:34,HOME:36,END:35,BACKSPACE:8,DELETE:46,isArrow:function(a){switch(a=a.which?a.which:a){case c.LEFT:case c.RIGHT:case c.UP:case c.DOWN:return!0}return!1},isControl:function(a){var b=a.which;switch(b){case c.SHIFT:case c.CTRL:case c.ALT:return!0}return a.metaKey?!0:!1},isFunctionKey:function(a){return a=a.which?a.which:a,a>=112&&123>=a}},l="<div class='select2-measure-scrollbar'></div>";j=a(document),g=function(){var a=1;return function(){return a++}}(),j.on("mousemove",function(a){i.x=a.pageX,i.y=a.pageY}),d=L(Object,{bind:function(a){var b=this;return function(){a.apply(b,arguments)}},init:function(c){var d,e,h,i,f=".select2-results";this.opts=c=this.prepareOpts(c),this.id=c.id,c.element.data("select2")!==b&&null!==c.element.data("select2")&&c.element.data("select2").destroy(),this.container=this.createContainer(),this.containerId="s2id_"+(c.element.attr("id")||"autogen"+g()),this.containerSelector="#"+this.containerId.replace(/([;&,\.\+\*\~':"\!\^#$%@\[\]\(\)=>\|])/g,"\\$1"),this.container.attr("id",this.containerId),this.body=u(function(){return c.element.closest("body")}),B(this.container,this.opts.element,this.opts.adaptContainerCssClass),this.container.css(I(c.containerCss)),this.container.addClass(I(c.containerCssClass)),this.elementTabIndex=this.opts.element.attr("tabindex"),this.opts.element.data("select2",this).attr("tabindex","-1").before(this.container),this.container.data("select2",this),this.dropdown=this.container.find(".select2-drop"),this.dropdown.addClass(I(c.dropdownCssClass)),this.dropdown.data("select2",this),this.results=d=this.container.find(f),this.search=e=this.container.find("input.select2-input"),this.resultsPage=0,this.context=null,this.initContainer(),s(this.results),this.dropdown.on("mousemove-filtered touchstart touchmove touchend",f,this.bind(this.highlightUnderEvent)),v(80,this.results),this.dropdown.on("scroll-debounced",f,this.bind(this.loadMoreIfNeeded)),a(this.container).on("change",".select2-input",function(a){a.stopPropagation()}),a(this.dropdown).on("change",".select2-input",function(a){a.stopPropagation()}),a.fn.mousewheel&&d.mousewheel(function(a,b,c,e){var f=d.scrollTop();e>0&&0>=f-e?(d.scrollTop(0),y(a)):0>e&&d.get(0).scrollHeight-d.scrollTop()+e<=d.height()&&(d.scrollTop(d.get(0).scrollHeight-d.height()),y(a))}),r(e),e.on("keyup-change input paste",this.bind(this.updateResults)),e.on("focus",function(){e.addClass("select2-focused")}),e.on("blur",function(){e.removeClass("select2-focused")}),this.dropdown.on("mouseup",f,this.bind(function(b){a(b.target).closest(".select2-result-selectable").length>0&&(this.highlightUnderEvent(b),this.selectHighlighted(b))})),this.dropdown.on("click mouseup mousedown",function(a){a.stopPropagation()}),a.isFunction(this.opts.initSelection)&&(this.initSelection(),this.monitorSource()),null!==c.maximumInputLength&&this.search.attr("maxlength",c.maximumInputLength);var h=c.element.prop("disabled");h===b&&(h=!1),this.enable(!h);var i=c.element.prop("readonly");i===b&&(i=!1),this.readonly(i),k=k||n(),this.autofocus=c.element.prop("autofocus"),c.element.prop("autofocus",!1),this.autofocus&&this.focus()},destroy:function(){var a=this.opts.element,c=a.data("select2");this.propertyObserver&&(delete this.propertyObserver,this.propertyObserver=null),c!==b&&(c.container.remove(),c.dropdown.remove(),a.removeClass("select2-offscreen").removeData("select2").off(".select2").prop("autofocus",this.autofocus||!1),this.elementTabIndex?a.attr({tabindex:this.elementTabIndex}):a.removeAttr("tabindex"),a.show())},optionToData:function(a){return a.is("option")?{id:a.prop("value"),text:a.text(),element:a.get(),css:a.attr("class"),disabled:a.prop("disabled"),locked:o(a.attr("locked"),"locked")||o(a.data("locked"),!0)}:a.is("optgroup")?{text:a.attr("label"),children:[],element:a.get(),css:a.attr("class")}:b},prepareOpts:function(c){var d,e,f,g,h=this;if(d=c.element,"select"===d.get(0).tagName.toLowerCase()&&(this.select=e=c.element),e&&a.each(["id","multiple","ajax","query","createSearchChoice","initSelection","data","tags"],function(){if(this in c)throw Error("Option '"+this+"' is not allowed for Select2 when attached to a <select> element.")}),c=a.extend({},{populateResults:function(d,e,f){var g,l=this.opts.id;g=function(d,e,i){var j,k,m,n,o,p,q,r,s,t;for(d=c.sortResults(d,e,f),j=0,k=d.length;k>j;j+=1)m=d[j],o=m.disabled===!0,n=!o&&l(m)!==b,p=m.children&&m.children.length>0,q=a("<li></li>"),q.addClass("select2-results-dept-"+i),q.addClass("select2-result"),q.addClass(n?"select2-result-selectable":"select2-result-unselectable"),o&&q.addClass("select2-disabled"),p&&q.addClass("select2-result-with-children"),q.addClass(h.opts.formatResultCssClass(m)),r=a(document.createElement("div")),r.addClass("select2-result-label"),t=c.formatResult(m,r,f,h.opts.escapeMarkup),t!==b&&r.html(t),q.append(r),p&&(s=a("<ul></ul>"),s.addClass("select2-result-sub"),g(m.children,s,i+1),q.append(s)),q.data("select2-data",m),e.append(q)},g(e,d,0)}},a.fn.select2.defaults,c),"function"!=typeof c.id&&(f=c.id,c.id=function(a){return a[f]}),a.isArray(c.element.data("select2Tags"))){if("tags"in c)throw"tags specified as both an attribute 'data-select2-tags' and in options of Select2 "+c.element.attr("id");c.tags=c.element.data("select2Tags")}if(e?(c.query=this.bind(function(a){var f,g,i,c={results:[],more:!1},e=a.term;i=function(b,c){var d;b.is("option")?a.matcher(e,b.text(),b)&&c.push(h.optionToData(b)):b.is("optgroup")&&(d=h.optionToData(b),b.children().each2(function(a,b){i(b,d.children)}),d.children.length>0&&c.push(d))},f=d.children(),this.getPlaceholder()!==b&&f.length>0&&(g=this.getPlaceholderOption(),g&&(f=f.not(g))),f.each2(function(a,b){i(b,c.results)}),a.callback(c)}),c.id=function(a){return a.id},c.formatResultCssClass=function(a){return a.css}):"query"in c||("ajax"in c?(g=c.element.data("ajax-url"),g&&g.length>0&&(c.ajax.url=g),c.query=E.call(c.element,c.ajax)):"data"in c?c.query=F(c.data):"tags"in c&&(c.query=G(c.tags),c.createSearchChoice===b&&(c.createSearchChoice=function(a){return{id:a,text:a}}),c.initSelection===b&&(c.initSelection=function(d,e){var f=[];a(p(d.val(),c.separator)).each(function(){var d=this,e=this,g=c.tags;a.isFunction(g)&&(g=g()),a(g).each(function(){return o(this.id,d)?(e=this.text,!1):b}),f.push({id:d,text:e})}),e(f)}))),"function"!=typeof c.query)throw"query function not defined for Select2 "+c.element.attr("id");return c},monitorSource:function(){var c,a=this.opts.element;a.on("change.select2",this.bind(function(){this.opts.element.data("select2-change-triggered")!==!0&&this.initSelection()})),c=this.bind(function(){var d,f=a.prop("disabled");f===b&&(f=!1),this.enable(!f);var d=a.prop("readonly");d===b&&(d=!1),this.readonly(d),B(this.container,this.opts.element,this.opts.adaptContainerCssClass),this.container.addClass(I(this.opts.containerCssClass)),B(this.dropdown,this.opts.element,this.opts.adaptDropdownCssClass),this.dropdown.addClass(I(this.opts.dropdownCssClass))}),a.on("propertychange.select2 DOMAttrModified.select2",c),this.mutationCallback===b&&(this.mutationCallback=function(a){a.forEach(c)}),"undefined"!=typeof WebKitMutationObserver&&(this.propertyObserver&&(delete this.propertyObserver,this.propertyObserver=null),this.propertyObserver=new WebKitMutationObserver(this.mutationCallback),this.propertyObserver.observe(a.get(0),{attributes:!0,subtree:!1}))},triggerSelect:function(b){var c=a.Event("select2-selecting",{val:this.id(b),object:b});return this.opts.element.trigger(c),!c.isDefaultPrevented()},triggerChange:function(b){b=b||{},b=a.extend({},b,{type:"change",val:this.val()}),this.opts.element.data("select2-change-triggered",!0),this.opts.element.trigger(b),this.opts.element.data("select2-change-triggered",!1),this.opts.element.click(),this.opts.blurOnChange&&this.opts.element.blur()},isInterfaceEnabled:function(){return this.enabledInterface===!0},enableInterface:function(){var a=this._enabled&&!this._readonly,b=!a;return a===this.enabledInterface?!1:(this.container.toggleClass("select2-container-disabled",b),this.close(),this.enabledInterface=a,!0)},enable:function(a){return a===b&&(a=!0),this._enabled===a?!1:(this._enabled=a,this.opts.element.prop("disabled",!a),this.enableInterface(),!0)},readonly:function(a){return a===b&&(a=!1),this._readonly===a?!1:(this._readonly=a,this.opts.element.prop("readonly",a),this.enableInterface(),!0)},opened:function(){return this.container.hasClass("select2-dropdown-open")},positionDropdown:function(){var q,r,s,t,b=this.dropdown,c=this.container.offset(),d=this.container.outerHeight(!1),e=this.container.outerWidth(!1),f=b.outerHeight(!1),g=a(window).scrollLeft()+a(window).width(),h=a(window).scrollTop()+a(window).height(),i=c.top+d,j=c.left,l=h>=i+f,m=c.top-f>=this.body().scrollTop(),n=b.outerWidth(!1),o=g>=j+n,p=b.hasClass("select2-drop-above");this.opts.dropdownAutoWidth?(t=a(".select2-results",b)[0],b.addClass("select2-drop-auto-width"),b.css("width",""),n=b.outerWidth(!1)+(t.scrollHeight===t.clientHeight?0:k.width),n>e?e=n:n=e,o=g>=j+n):this.container.removeClass("select2-drop-auto-width"),"static"!==this.body().css("position")&&(q=this.body().offset(),i-=q.top,j-=q.left),p?(r=!0,!m&&l&&(r=!1)):(r=!1,!l&&m&&(r=!0)),o||(j=c.left+e-n),r?(i=c.top-f,this.container.addClass("select2-drop-above"),b.addClass("select2-drop-above")):(this.container.removeClass("select2-drop-above"),b.removeClass("select2-drop-above")),s=a.extend({top:i,left:j,width:e},I(this.opts.dropdownCss)),b.css(s)},shouldOpen:function(){var b;return this.opened()?!1:this._enabled===!1||this._readonly===!0?!1:(b=a.Event("select2-opening"),this.opts.element.trigger(b),!b.isDefaultPrevented())},clearDropdownAlignmentPreference:function(){this.container.removeClass("select2-drop-above"),this.dropdown.removeClass("select2-drop-above")},open:function(){return this.shouldOpen()?(this.opening(),!0):!1},opening:function(){function i(){return{width:Math.max(document.documentElement.scrollWidth,a(window).width()),height:Math.max(document.documentElement.scrollHeight,a(window).height())}}var f,g,b=this.containerId,c="scroll."+b,d="resize."+b,e="orientationchange."+b;this.container.addClass("select2-dropdown-open").addClass("select2-container-active"),this.clearDropdownAlignmentPreference(),this.dropdown[0]!==this.body().children().last()[0]&&this.dropdown.detach().appendTo(this.body()),f=a("#select2-drop-mask"),0==f.length&&(f=a(document.createElement("div")),f.attr("id","select2-drop-mask").attr("class","select2-drop-mask"),f.hide(),f.appendTo(this.body()),f.on("mousedown touchstart click",function(b){var d,c=a("#select2-drop");c.length>0&&(d=c.data("select2"),d.opts.selectOnBlur&&d.selectHighlighted({noFocus:!0}),d.close(),b.preventDefault(),b.stopPropagation())})),this.dropdown.prev()[0]!==f[0]&&this.dropdown.before(f),a("#select2-drop").removeAttr("id"),this.dropdown.attr("id","select2-drop"),g=i(),f.css(g).show(),this.dropdown.show(),this.positionDropdown(),this.dropdown.addClass("select2-drop-active");var h=this;this.container.parents().add(window).each(function(){a(this).on(d+" "+c+" "+e,function(){var c=i();a("#select2-drop-mask").css(c),h.positionDropdown()})})},close:function(){if(this.opened()){var b=this.containerId,c="scroll."+b,d="resize."+b,e="orientationchange."+b;this.container.parents().add(window).each(function(){a(this).off(c).off(d).off(e)}),this.clearDropdownAlignmentPreference(),a("#select2-drop-mask").hide(),this.dropdown.removeAttr("id"),this.dropdown.hide(),this.container.removeClass("select2-dropdown-open"),this.results.empty(),this.clearSearch(),this.search.removeClass("select2-active"),this.opts.element.trigger(a.Event("select2-close"))}},externalSearch:function(a){this.open(),this.search.val(a),this.updateResults(!1)},clearSearch:function(){},getMaximumSelectionSize:function(){return I(this.opts.maximumSelectionSize)},ensureHighlightVisible:function(){var d,e,f,g,h,i,j,c=this.results;if(e=this.highlight(),!(0>e)){if(0==e)return c.scrollTop(0),b;d=this.findHighlightableChoices().find(".select2-result-label"),f=a(d[e]),g=f.offset().top+f.outerHeight(!0),e===d.length-1&&(j=c.find("li.select2-more-results"),j.length>0&&(g=j.offset().top+j.outerHeight(!0))),h=c.offset().top+c.outerHeight(!0),g>h&&c.scrollTop(c.scrollTop()+(g-h)),i=f.offset().top-c.offset().top,0>i&&"none"!=f.css("display")&&c.scrollTop(c.scrollTop()+i)}},findHighlightableChoices:function(){return this.results.find(".select2-result-selectable:not(.select2-selected):not(.select2-disabled)")},moveHighlight:function(b){for(var c=this.findHighlightableChoices(),d=this.highlight();d>-1&&c.length>d;){d+=b;var e=a(c[d]);if(e.hasClass("select2-result-selectable")&&!e.hasClass("select2-disabled")&&!e.hasClass("select2-selected")){this.highlight(d);break}}},highlight:function(c){var e,f,d=this.findHighlightableChoices();return 0===arguments.length?m(d.filter(".select2-highlighted")[0],d.get()):(c>=d.length&&(c=d.length-1),0>c&&(c=0),this.results.find(".select2-highlighted").removeClass("select2-highlighted"),e=a(d[c]),e.addClass("select2-highlighted"),this.ensureHighlightVisible(),f=e.data("select2-data"),f&&this.opts.element.trigger({type:"select2-highlight",val:this.id(f),choice:f}),b)},countSelectableResults:function(){return this.findHighlightableChoices().length},highlightUnderEvent:function(b){var c=a(b.target).closest(".select2-result-selectable");if(c.length>0&&!c.is(".select2-highlighted")){var d=this.findHighlightableChoices();this.highlight(d.index(c))}else 0==c.length&&this.results.find(".select2-highlighted").removeClass("select2-highlighted")},loadMoreIfNeeded:function(){var c,a=this.results,b=a.find("li.select2-more-results"),e=this.resultsPage+1,f=this,g=this.search.val(),h=this.context;0!==b.length&&(c=b.offset().top-a.offset().top-a.height(),this.opts.loadMorePadding>=c&&(b.addClass("select2-active"),this.opts.query({element:this.opts.element,term:g,page:e,context:h,matcher:this.opts.matcher,callback:this.bind(function(c){f.opened()&&(f.opts.populateResults.call(this,a,c.results,{term:g,page:e,context:h}),f.postprocessResults(c,!1,!1),c.more===!0?(b.detach().appendTo(a).text(f.opts.formatLoadMore(e+1)),window.setTimeout(function(){f.loadMoreIfNeeded()},10)):b.remove(),f.positionDropdown(),f.resultsPage=e,f.context=c.context)})})))},tokenize:function(){},updateResults:function(c){function l(){d.removeClass("select2-active"),h.positionDropdown()}function m(a){e.html(a),l()}var g,i,d=this.search,e=this.results,f=this.opts,h=this,j=d.val(),k=a.data(this.container,"select2-last-term");if((c===!0||!k||!o(j,k))&&(a.data(this.container,"select2-last-term",j),c===!0||this.showSearchInput!==!1&&this.opened())){var n=this.getMaximumSelectionSize();if(n>=1&&(g=this.data(),a.isArray(g)&&g.length>=n&&H(f.formatSelectionTooBig,"formatSelectionTooBig")))return m("<li class='select2-selection-limit'>"+f.formatSelectionTooBig(n)+"</li>"),b;if(d.val().length<f.minimumInputLength)return H(f.formatInputTooShort,"formatInputTooShort")?m("<li class='select2-no-results'>"+f.formatInputTooShort(d.val(),f.minimumInputLength)+"</li>"):m(""),c&&this.showSearch&&this.showSearch(!0),b;if(f.maximumInputLength&&d.val().length>f.maximumInputLength)return H(f.formatInputTooLong,"formatInputTooLong")?m("<li class='select2-no-results'>"+f.formatInputTooLong(d.val(),f.maximumInputLength)+"</li>"):m(""),b;f.formatSearching&&0===this.findHighlightableChoices().length&&m("<li class='select2-searching'>"+f.formatSearching()+"</li>"),d.addClass("select2-active"),i=this.tokenize(),i!=b&&null!=i&&d.val(i),this.resultsPage=1,f.query({element:f.element,term:d.val(),page:this.resultsPage,context:null,matcher:f.matcher,callback:this.bind(function(g){var i;return this.opened()?(this.context=g.context===b?null:g.context,this.opts.createSearchChoice&&""!==d.val()&&(i=this.opts.createSearchChoice.call(h,d.val(),g.results),i!==b&&null!==i&&h.id(i)!==b&&null!==h.id(i)&&0===a(g.results).filter(function(){return o(h.id(this),h.id(i))}).length&&g.results.unshift(i)),0===g.results.length&&H(f.formatNoMatches,"formatNoMatches")?(m("<li class='select2-no-results'>"+f.formatNoMatches(d.val())+"</li>"),b):(e.empty(),h.opts.populateResults.call(this,e,g.results,{term:d.val(),page:this.resultsPage,context:null}),g.more===!0&&H(f.formatLoadMore,"formatLoadMore")&&(e.append("<li class='select2-more-results'>"+h.opts.escapeMarkup(f.formatLoadMore(this.resultsPage))+"</li>"),window.setTimeout(function(){h.loadMoreIfNeeded()},10)),this.postprocessResults(g,c),l(),this.opts.element.trigger({type:"select2-loaded",items:g}),b)):(this.search.removeClass("select2-active"),b)})})}},cancel:function(){this.close()},blur:function(){this.opts.selectOnBlur&&this.selectHighlighted({noFocus:!0}),this.close(),this.container.removeClass("select2-container-active"),this.search[0]===document.activeElement&&this.search.blur(),this.clearSearch(),this.selection.find(".select2-search-choice-focus").removeClass("select2-search-choice-focus")},focusSearch:function(){w(this.search)},selectHighlighted:function(a){var b=this.highlight(),c=this.results.find(".select2-highlighted"),d=c.closest(".select2-result").data("select2-data");d?(this.highlight(b),this.onSelect(d,a)):a&&a.noFocus&&this.close()},getPlaceholder:function(){var a;return this.opts.element.attr("placeholder")||this.opts.element.attr("data-placeholder")||this.opts.element.data("placeholder")||this.opts.placeholder||((a=this.getPlaceholderOption())!==b?a.text():b)},getPlaceholderOption:function(){if(this.select){var a=this.select.children().first();if(this.opts.placeholderOption!==b)return"first"===this.opts.placeholderOption&&a||"function"==typeof this.opts.placeholderOption&&this.opts.placeholderOption(this.select);if(""===a.text()&&""===a.val())return a}},initContainerWidth:function(){function c(){var c,d,e,f,g;if("off"===this.opts.width)return null;if("element"===this.opts.width)return 0===this.opts.element.outerWidth(!1)?"auto":this.opts.element.outerWidth(!1)+"px";if("copy"===this.opts.width||"resolve"===this.opts.width){if(c=this.opts.element.attr("style"),c!==b)for(d=c.split(";"),f=0,g=d.length;g>f;f+=1)if(e=d[f].replace(/\s/g,"").match(/width:(([-+]?([0-9]*\.)?[0-9]+)(px|em|ex|%|in|cm|mm|pt|pc))/i),null!==e&&e.length>=1)return e[1];return"resolve"===this.opts.width?(c=this.opts.element.css("width"),c.indexOf("%")>0?c:0===this.opts.element.outerWidth(!1)?"auto":this.opts.element.outerWidth(!1)+"px"):null}return a.isFunction(this.opts.width)?this.opts.width():this.opts.width}var d=c.call(this);null!==d&&this.container.css("width",d)}}),e=L(d,{createContainer:function(){var b=a(document.createElement("div")).attr({"class":"select2-container"}).html(["<a href='javascript:void(0)' onclick='return false;' class='select2-choice' tabindex='-1'>"," <span class='select2-chosen'> </span><abbr class='select2-search-choice-close'></abbr>"," <span class='select2-arrow'><b></b></span>","</a>","<input class='select2-focusser select2-offscreen' type='text'/>","<div class='select2-drop select2-display-none'>"," <div class='select2-search'>"," <input type='text' autocomplete='off' autocorrect='off' autocapitalize='off' spellcheck='false' class='select2-input'/>"," </div>"," <ul class='select2-results'>"," </ul>","</div>"].join(""));return b},enableInterface:function(){this.parent.enableInterface.apply(this,arguments)&&this.focusser.prop("disabled",!this.isInterfaceEnabled())},opening:function(){var b,c,d;this.opts.minimumResultsForSearch>=0&&this.showSearch(!0),this.parent.opening.apply(this,arguments),this.showSearchInput!==!1&&this.search.val(this.focusser.val()),this.search.focus(),b=this.search.get(0),b.createTextRange?(c=b.createTextRange(),c.collapse(!1),c.select()):b.setSelectionRange&&(d=this.search.val().length,b.setSelectionRange(d,d)),this.focusser.prop("disabled",!0).val(""),this.updateResults(!0),this.opts.element.trigger(a.Event("select2-open"))},close:function(){this.opened()&&(this.parent.close.apply(this,arguments),this.focusser.removeAttr("disabled"),this.focusser.focus())},focus:function(){this.opened()?this.close():(this.focusser.removeAttr("disabled"),this.focusser.focus())},isFocused:function(){return this.container.hasClass("select2-container-active")},cancel:function(){this.parent.cancel.apply(this,arguments),this.focusser.removeAttr("disabled"),this.focusser.focus()},initContainer:function(){var d,e=this.container,f=this.dropdown;0>this.opts.minimumResultsForSearch?this.showSearch(!1):this.showSearch(!0),this.selection=d=e.find(".select2-choice"),this.focusser=e.find(".select2-focusser"),this.focusser.attr("id","s2id_autogen"+g()),a("label[for='"+this.opts.element.attr("id")+"']").attr("for",this.focusser.attr("id")),this.focusser.attr("tabindex",this.elementTabIndex),this.search.on("keydown",this.bind(function(a){if(this.isInterfaceEnabled()){if(a.which===c.PAGE_UP||a.which===c.PAGE_DOWN)return y(a),b;switch(a.which){case c.UP:case c.DOWN:return this.moveHighlight(a.which===c.UP?-1:1),y(a),b;case c.ENTER:return this.selectHighlighted(),y(a),b;case c.TAB:return this.selectHighlighted({noFocus:!0}),b;case c.ESC:return this.cancel(a),y(a),b}}})),this.search.on("blur",this.bind(function(){document.activeElement===this.body().get(0)&&window.setTimeout(this.bind(function(){this.search.focus()}),0)})),this.focusser.on("keydown",this.bind(function(a){if(this.isInterfaceEnabled()&&a.which!==c.TAB&&!c.isControl(a)&&!c.isFunctionKey(a)&&a.which!==c.ESC){if(this.opts.openOnEnter===!1&&a.which===c.ENTER)return y(a),b;if(a.which==c.DOWN||a.which==c.UP||a.which==c.ENTER&&this.opts.openOnEnter){if(a.altKey||a.ctrlKey||a.shiftKey||a.metaKey)return;return this.open(),y(a),b}return a.which==c.DELETE||a.which==c.BACKSPACE?(this.opts.allowClear&&this.clear(),y(a),b):b}})),r(this.focusser),this.focusser.on("keyup-change input",this.bind(function(a){if(this.opts.minimumResultsForSearch>=0){if(a.stopPropagation(),this.opened())return;this.open()}})),d.on("mousedown","abbr",this.bind(function(a){this.isInterfaceEnabled()&&(this.clear(),z(a),this.close(),this.selection.focus())})),d.on("mousedown",this.bind(function(b){this.container.hasClass("select2-container-active")||this.opts.element.trigger(a.Event("select2-focus")),this.opened()?this.close():this.isInterfaceEnabled()&&this.open(),y(b)})),f.on("mousedown",this.bind(function(){this.search.focus()})),d.on("focus",this.bind(function(a){y(a)})),this.focusser.on("focus",this.bind(function(){this.container.hasClass("select2-container-active")||this.opts.element.trigger(a.Event("select2-focus")),this.container.addClass("select2-container-active")})).on("blur",this.bind(function(){this.opened()||(this.container.removeClass("select2-container-active"),this.opts.element.trigger(a.Event("select2-blur")))})),this.search.on("focus",this.bind(function(){this.container.hasClass("select2-container-active")||this.opts.element.trigger(a.Event("select2-focus")),this.container.addClass("select2-container-active")})),this.initContainerWidth(),this.opts.element.addClass("select2-offscreen"),this.setPlaceholder()},clear:function(a){var b=this.selection.data("select2-data");if(b){var c=this.getPlaceholderOption();this.opts.element.val(c?c.val():""),this.selection.find(".select2-chosen").empty(),this.selection.removeData("select2-data"),this.setPlaceholder(),a!==!1&&(this.opts.element.trigger({type:"select2-removed",val:this.id(b),choice:b}),this.triggerChange({removed:b}))}},initSelection:function(){if(this.isPlaceholderOptionSelected())this.updateSelection([]),this.close(),this.setPlaceholder();else{var c=this;this.opts.initSelection.call(null,this.opts.element,function(a){a!==b&&null!==a&&(c.updateSelection(a),c.close(),c.setPlaceholder())})}},isPlaceholderOptionSelected:function(){var a;return(a=this.getPlaceholderOption())!==b&&a.is(":selected")||""===this.opts.element.val()||this.opts.element.val()===b||null===this.opts.element.val()},prepareOpts:function(){var b=this.parent.prepareOpts.apply(this,arguments),c=this;return"select"===b.element.get(0).tagName.toLowerCase()?b.initSelection=function(a,b){var d=a.find(":selected");b(c.optionToData(d))}:"data"in b&&(b.initSelection=b.initSelection||function(c,d){var e=c.val(),f=null;b.query({matcher:function(a,c,d){var g=o(e,b.id(d));return g&&(f=d),g},callback:a.isFunction(d)?function(){d(f)}:a.noop})}),b},getPlaceholder:function(){return this.select&&this.getPlaceholderOption()===b?b:this.parent.getPlaceholder.apply(this,arguments)},setPlaceholder:function(){var a=this.getPlaceholder();if(this.isPlaceholderOptionSelected()&&a!==b){if(this.select&&this.getPlaceholderOption()===b)return;this.selection.find(".select2-chosen").html(this.opts.escapeMarkup(a)),this.selection.addClass("select2-default"),this.container.removeClass("select2-allowclear")}},postprocessResults:function(a,c,d){var e=0,f=this;if(this.findHighlightableChoices().each2(function(a,c){return o(f.id(c.data("select2-data")),f.opts.element.val())?(e=a,!1):b}),d!==!1&&(c===!0&&e>=0?this.highlight(e):this.highlight(0)),c===!0){var h=this.opts.minimumResultsForSearch;h>=0&&this.showSearch(J(a.results)>=h)}},showSearch:function(b){this.showSearchInput!==b&&(this.showSearchInput=b,this.dropdown.find(".select2-search").toggleClass("select2-search-hidden",!b),this.dropdown.find(".select2-search").toggleClass("select2-offscreen",!b),a(this.dropdown,this.container).toggleClass("select2-with-searchbox",b))},onSelect:function(a,b){if(this.triggerSelect(a)){var c=this.opts.element.val(),d=this.data();this.opts.element.val(this.id(a)),this.updateSelection(a),this.opts.element.trigger({type:"select2-selected",val:this.id(a),choice:a}),this.close(),b&&b.noFocus||this.selection.focus(),o(c,this.id(a))||this.triggerChange({added:a,removed:d})}},updateSelection:function(a){var d,e,c=this.selection.find(".select2-chosen");this.selection.data("select2-data",a),c.empty(),d=this.opts.formatSelection(a,c,this.opts.escapeMarkup),d!==b&&c.append(d),e=this.opts.formatSelectionCssClass(a,c),e!==b&&c.addClass(e),this.selection.removeClass("select2-default"),this.opts.allowClear&&this.getPlaceholder()!==b&&this.container.addClass("select2-allowclear")+},val:function(){var a,c=!1,d=null,e=this,f=this.data();if(0===arguments.length)return this.opts.element.val();if(a=arguments[0],arguments.length>1&&(c=arguments[1]),this.select)this.select.val(a).find(":selected").each2(function(a,b){return d=e.optionToData(b),!1}),this.updateSelection(d),this.setPlaceholder(),c&&this.triggerChange({added:d,removed:f});else{if(!a&&0!==a)return this.clear(c),b;if(this.opts.initSelection===b)throw Error("cannot call val() if initSelection() is not defined");this.opts.element.val(a),this.opts.initSelection(this.opts.element,function(a){e.opts.element.val(a?e.id(a):""),e.updateSelection(a),e.setPlaceholder(),c&&e.triggerChange({added:a,removed:f})})}},clearSearch:function(){this.search.val(""),this.focusser.val("")},data:function(a,c){var d;return 0===arguments.length?(d=this.selection.data("select2-data"),d==b&&(d=null),d):(a&&""!==a?(d=this.data(),this.opts.element.val(a?this.id(a):""),this.updateSelection(a),c&&this.triggerChange({added:a,removed:d})):this.clear(c),b)}}),f=L(d,{createContainer:function(){var b=a(document.createElement("div")).attr({"class":"select2-container select2-container-multi"}).html(["<ul class='select2-choices'>"," <li class='select2-search-field'>"," <input type='text' autocomplete='off' autocorrect='off' autocapitilize='off' spellcheck='false' class='select2-input'>"," </li>","</ul>","<div class='select2-drop select2-drop-multi select2-display-none'>"," <ul class='select2-results'>"," </ul>","</div>"].join(""));return b},prepareOpts:function(){var b=this.parent.prepareOpts.apply(this,arguments),c=this;return"select"===b.element.get(0).tagName.toLowerCase()?b.initSelection=function(a,b){var d=[];a.find(":selected").each2(function(a,b){d.push(c.optionToData(b))}),b(d)}:"data"in b&&(b.initSelection=b.initSelection||function(c,d){var e=p(c.val(),b.separator),f=[];b.query({matcher:function(c,d,g){var h=a.grep(e,function(a){return o(a,b.id(g))}).length;return h&&f.push(g),h},callback:a.isFunction(d)?function(){for(var a=[],c=0;e.length>c;c++)for(var g=e[c],h=0;f.length>h;h++){var i=f[h];if(o(g,b.id(i))){a.push(i),f.splice(h,1);break}}d(a)}:a.noop})}),b},selectChoice:function(a){var b=this.container.find(".select2-search-choice-focus");b.length&&a&&a[0]==b[0]||(b.length&&this.opts.element.trigger("choice-deselected",b),b.removeClass("select2-search-choice-focus"),a&&a.length&&(this.close(),a.addClass("select2-search-choice-focus"),this.opts.element.trigger("choice-selected",a)))},initContainer:function(){var e,d=".select2-choices";this.searchContainer=this.container.find(".select2-search-field"),this.selection=e=this.container.find(d);var f=this;this.selection.on("mousedown",".select2-search-choice",function(){f.search[0].focus(),f.selectChoice(a(this))}),this.search.attr("id","s2id_autogen"+g()),a("label[for='"+this.opts.element.attr("id")+"']").attr("for",this.search.attr("id")),this.search.on("input paste",this.bind(function(){this.isInterfaceEnabled()&&(this.opened()||this.open())})),this.search.attr("tabindex",this.elementTabIndex),this.keydowns=0,this.search.on("keydown",this.bind(function(a){if(this.isInterfaceEnabled()){++this.keydowns;var d=e.find(".select2-search-choice-focus"),f=d.prev(".select2-search-choice:not(.select2-locked)"),g=d.next(".select2-search-choice:not(.select2-locked)"),h=x(this.search);if(d.length&&(a.which==c.LEFT||a.which==c.RIGHT||a.which==c.BACKSPACE||a.which==c.DELETE||a.which==c.ENTER)){var i=d;return a.which==c.LEFT&&f.length?i=f:a.which==c.RIGHT?i=g.length?g:null:a.which===c.BACKSPACE?(this.unselect(d.first()),this.search.width(10),i=f.length?f:g):a.which==c.DELETE?(this.unselect(d.first()),this.search.width(10),i=g.length?g:null):a.which==c.ENTER&&(i=null),this.selectChoice(i),y(a),i&&i.length||this.open(),b}if((a.which===c.BACKSPACE&&1==this.keydowns||a.which==c.LEFT)&&0==h.offset&&!h.length)return this.selectChoice(e.find(".select2-search-choice:not(.select2-locked)").last()),y(a),b;if(this.selectChoice(null),this.opened())switch(a.which){case c.UP:case c.DOWN:return this.moveHighlight(a.which===c.UP?-1:1),y(a),b;case c.ENTER:return this.selectHighlighted(),y(a),b;case c.TAB:return this.selectHighlighted({noFocus:!0}),this.close(),b;case c.ESC:return this.cancel(a),y(a),b}if(a.which!==c.TAB&&!c.isControl(a)&&!c.isFunctionKey(a)&&a.which!==c.BACKSPACE&&a.which!==c.ESC){if(a.which===c.ENTER){if(this.opts.openOnEnter===!1)return;if(a.altKey||a.ctrlKey||a.shiftKey||a.metaKey)return}this.open(),(a.which===c.PAGE_UP||a.which===c.PAGE_DOWN)&&y(a),a.which===c.ENTER&&y(a)}}})),this.search.on("keyup",this.bind(function(){this.keydowns=0,this.resizeSearch()})),this.search.on("blur",this.bind(function(b){this.container.removeClass("select2-container-active"),this.search.removeClass("select2-focused"),this.selectChoice(null),this.opened()||this.clearSearch(),b.stopImmediatePropagation(),this.opts.element.trigger(a.Event("select2-blur"))})),this.container.on("click",d,this.bind(function(b){this.isInterfaceEnabled()&&(a(b.target).closest(".select2-search-choice").length>0||(this.selectChoice(null),this.clearPlaceholder(),this.container.hasClass("select2-container-active")||this.opts.element.trigger(a.Event("select2-focus")),this.open(),this.focusSearch(),b.preventDefault()))})),this.container.on("focus",d,this.bind(function(){this.isInterfaceEnabled()&&(this.container.hasClass("select2-container-active")||this.opts.element.trigger(a.Event("select2-focus")),this.container.addClass("select2-container-active"),this.dropdown.addClass("select2-drop-active"),this.clearPlaceholder())})),this.initContainerWidth(),this.opts.element.addClass("select2-offscreen"),this.clearSearch()},enableInterface:function(){this.parent.enableInterface.apply(this,arguments)&&this.search.prop("disabled",!this.isInterfaceEnabled())},initSelection:function(){if(""===this.opts.element.val()&&""===this.opts.element.text()&&(this.updateSelection([]),this.close(),this.clearSearch()),this.select||""!==this.opts.element.val()){var c=this;this.opts.initSelection.call(null,this.opts.element,function(a){a!==b&&null!==a&&(c.updateSelection(a),c.close(),c.clearSearch())})}},clearSearch:function(){var a=this.getPlaceholder(),c=this.getMaxSearchWidth();a!==b&&0===this.getVal().length&&this.search.hasClass("select2-focused")===!1?(this.search.val(a).addClass("select2-default"),this.search.width(c>0?c:this.container.css("width"))):this.search.val("").width(10)},clearPlaceholder:function(){this.search.hasClass("select2-default")&&this.search.val("").removeClass("select2-default")},opening:function(){this.clearPlaceholder(),this.resizeSearch(),this.parent.opening.apply(this,arguments),this.focusSearch(),this.updateResults(!0),this.search.focus(),this.opts.element.trigger(a.Event("select2-open"))},close:function(){this.opened()&&this.parent.close.apply(this,arguments)},focus:function(){this.close(),this.search.focus()},isFocused:function(){return this.search.hasClass("select2-focused")},updateSelection:function(b){var c=[],d=[],e=this;a(b).each(function(){0>m(e.id(this),c)&&(c.push(e.id(this)),d.push(this))}),b=d,this.selection.find(".select2-search-choice").remove(),a(b).each(function(){e.addSelectedChoice(this)}),e.postprocessResults()},tokenize:function(){var a=this.search.val();a=this.opts.tokenizer.call(this,a,this.data(),this.bind(this.onSelect),this.opts),null!=a&&a!=b&&(this.search.val(a),a.length>0&&this.open())},onSelect:function(a,b){this.triggerSelect(a)&&(this.addSelectedChoice(a),this.opts.element.trigger({type:"selected",val:this.id(a),choice:a}),(this.select||!this.opts.closeOnSelect)&&this.postprocessResults(),this.opts.closeOnSelect?(this.close(),this.search.width(10)):this.countSelectableResults()>0?(this.search.width(10),this.resizeSearch(),this.getMaximumSelectionSize()>0&&this.val().length>=this.getMaximumSelectionSize()&&this.updateResults(!0),this.positionDropdown()):(this.close(),this.search.width(10)),this.triggerChange({added:a}),b&&b.noFocus||this.focusSearch())},cancel:function(){this.close(),this.focusSearch()},addSelectedChoice:function(c){var j,k,d=!c.locked,e=a("<li class='select2-search-choice'> <div></div> <a href='#' onclick='return false;' class='select2-search-choice-close' tabindex='-1'></a></li>"),f=a("<li class='select2-search-choice select2-locked'><div></div></li>"),g=d?e:f,h=this.id(c),i=this.getVal();j=this.opts.formatSelection(c,g.find("div"),this.opts.escapeMarkup),j!=b&&g.find("div").replaceWith("<div>"+j+"</div>"),k=this.opts.formatSelectionCssClass(c,g.find("div")),k!=b&&g.addClass(k),d&&g.find(".select2-search-choice-close").on("mousedown",y).on("click dblclick",this.bind(function(b){this.isInterfaceEnabled()&&(a(b.target).closest(".select2-search-choice").fadeOut("fast",this.bind(function(){this.unselect(a(b.target)),this.selection.find(".select2-search-choice-focus").removeClass("select2-search-choice-focus"),this.close(),this.focusSearch()})).dequeue(),y(b))})).on("focus",this.bind(function(){this.isInterfaceEnabled()&&(this.container.addClass("select2-container-active"),this.dropdown.addClass("select2-drop-active"))})),g.data("select2-data",c),g.insertBefore(this.searchContainer),i.push(h),this.setVal(i)},unselect:function(a){var c,d,b=this.getVal();if(a=a.closest(".select2-search-choice"),0===a.length)throw"Invalid argument: "+a+". Must be .select2-search-choice";c=a.data("select2-data"),c&&(d=m(this.id(c),b),d>=0&&(b.splice(d,1),this.setVal(b),this.select&&this.postprocessResults()),a.remove(),this.opts.element.trigger({type:"removed",val:this.id(c),choice:c}),this.triggerChange({removed:c}))},postprocessResults:function(a,b,c){var d=this.getVal(),e=this.results.find(".select2-result"),f=this.results.find(".select2-result-with-children"),g=this;e.each2(function(a,b){var c=g.id(b.data("select2-data"));m(c,d)>=0&&(b.addClass("select2-selected"),b.find(".select2-result-selectable").addClass("select2-selected"))}),f.each2(function(a,b){b.is(".select2-result-selectable")||0!==b.find(".select2-result-selectable:not(.select2-selected)").length||b.addClass("select2-selected")}),-1==this.highlight()&&c!==!1&&g.highlight(0),!this.opts.createSearchChoice&&!e.filter(".select2-result:not(.select2-selected)").length>0&&(!a||a&&!a.more&&0===this.results.find(".select2-no-results").length)&&H(g.opts.formatNoMatches,"formatNoMatches")&&this.results.append("<li class='select2-no-results'>"+g.opts.formatNoMatches(g.search.val())+"</li>")},getMaxSearchWidth:function(){return this.selection.width()-q(this.search)},resizeSearch:function(){var a,b,c,d,e,f=q(this.search);a=A(this.search)+10,b=this.search.offset().left,c=this.selection.width(),d=this.selection.offset().left,e=c-(b-d)-f,a>e&&(e=c-f),40>e&&(e=c-f),0>=e&&(e=a),this.search.width(e)},getVal:function(){var a;return this.select?(a=this.select.val(),null===a?[]:a):(a=this.opts.element.val(),p(a,this.opts.separator))},setVal:function(b){var c;this.select?this.select.val(b):(c=[],a(b).each(function(){0>m(this,c)&&c.push(this)}),this.opts.element.val(0===c.length?"":c.join(this.opts.separator)))},buildChangeDetails:function(a,b){for(var b=b.slice(0),a=a.slice(0),c=0;b.length>c;c++)for(var d=0;a.length>d;d++)o(this.opts.id(b[c]),this.opts.id(a[d]))&&(b.splice(c,1),c--,a.splice(d,1),d--);return{added:b,removed:a}},val:function(c,d){var e,f=this;if(0===arguments.length)return this.getVal();if(e=this.data(),e.length||(e=[]),!c&&0!==c)return this.opts.element.val(""),this.updateSelection([]),this.clearSearch(),d&&this.triggerChange({added:this.data(),removed:e}),b;if(this.setVal(c),this.select)this.opts.initSelection(this.select,this.bind(this.updateSelection)),d&&this.triggerChange(this.buildChangeDetails(e,this.data()));else{if(this.opts.initSelection===b)throw Error("val() cannot be called if initSelection() is not defined");this.opts.initSelection(this.opts.element,function(b){var c=a.map(b,f.id);f.setVal(c),f.updateSelection(b),f.clearSearch(),d&&f.triggerChange(this.buildChangeDetails(e,this.data()))})}this.clearSearch()},onSortStart:function(){if(this.select)throw Error("Sorting of elements is not supported when attached to <select>. Attach to <input type='hidden'/> instead.");this.search.width(0),this.searchContainer.hide()},onSortEnd:function(){var b=[],c=this;this.searchContainer.show(),this.searchContainer.appendTo(this.searchContainer.parent()),this.resizeSearch(),this.selection.find(".select2-search-choice").each(function(){b.push(c.opts.id(a(this).data("select2-data")))}),this.setVal(b),this.triggerChange()},data:function(c,d){var f,g,e=this;return 0===arguments.length?this.selection.find(".select2-search-choice").map(function(){return a(this).data("select2-data")}).get():(g=this.data(),c||(c=[]),f=a.map(c,function(a){return e.opts.id(a)}),this.setVal(f),this.updateSelection(c),this.clearSearch(),d&&this.triggerChange(this.buildChangeDetails(g,this.data())),b)}}),a.fn.select2=function(){var d,g,h,i,j,c=Array.prototype.slice.call(arguments,0),k=["val","destroy","opened","open","close","focus","isFocused","container","dropdown","onSortStart","onSortEnd","enable","readonly","positionDropdown","data","search"],l=["val","opened","isFocused","container","data"],n={search:"externalSearch"};return this.each(function(){if(0===c.length||"object"==typeof c[0])d=0===c.length?{}:a.extend({},c[0]),d.element=a(this),"select"===d.element.get(0).tagName.toLowerCase()?j=d.element.prop("multiple"):(j=d.multiple||!1,"tags"in d&&(d.multiple=j=!0)),g=j?new f:new e,g.init(d);else{if("string"!=typeof c[0])throw"Invalid arguments to select2 plugin: "+c;if(0>m(c[0],k))throw"Unknown method: "+c[0];if(i=b,g=a(this).data("select2"),g===b)return;if(h=c[0],"container"===h?i=g.container:"dropdown"===h?i=g.dropdown:(n[h]&&(h=n[h]),i=g[h].apply(g,c.slice(1))),m(c[0],l)>=0)return!1}}),i===b?this:i},a.fn.select2.defaults={width:"copy",loadMorePadding:0,closeOnSelect:!0,openOnEnter:!0,containerCss:{},dropdownCss:{},containerCssClass:"",dropdownCssClass:"",formatResult:function(a,b,c,d){var e=[];return C(a.text,c.term,e,d),e.join("")},formatSelection:function(a,c,d){return a?d(a.text):b},sortResults:function(a){return a},formatResultCssClass:function(){return b},formatSelectionCssClass:function(){return b},formatNoMatches:function(){return"No matches found"},formatInputTooShort:function(a,b){var c=b-a.length;return"Please enter "+c+" more character"+(1==c?"":"s")},formatInputTooLong:function(a,b){var c=a.length-b;return"Please delete "+c+" character"+(1==c?"":"s")},formatSelectionTooBig:function(a){return"You can only select "+a+" item"+(1==a?"":"s")},formatLoadMore:function(){return"Loading more results..."},formatSearching:function(){return"Searching..."},minimumResultsForSearch:0,minimumInputLength:0,maximumInputLength:null,maximumSelectionSize:0,id:function(a){return a.id},matcher:function(a,b){return(""+b).toUpperCase().indexOf((""+a).toUpperCase())>=0},separator:",",tokenSeparators:[],tokenizer:K,escapeMarkup:D,blurOnChange:!1,selectOnBlur:!1,adaptContainerCssClass:function(a){return a},adaptDropdownCssClass:function(){return null}},a.fn.select2.ajaxDefaults={transport:a.ajax,params:{type:"GET",cache:!1,dataType:"json"}},window.Select2={query:{ajax:E,local:F,tags:G},util:{debounce:t,markMatch:C,escapeMarkup:D},"class":{"abstract":d,single:e,multi:f}}}}(jQuery);
+ templates/abook.julius view
@@ -0,0 +1,44 @@+$(document).ready(function() {+ var aType = $("#addressbooktype");++ var searchAbook = function(input, page, callback) {+ $.ajax({ url: "@{AbookQueryR}"+ , dataType: 'json'+ , data: { q : input }+ })+ .done(function(data) {+ addrs = [];+ if (data) {+ $(data).each(function() {+ addrs.push({id: this, text: this});+ });+ }+ if (callback)+ callback({results: addrs, more: false});+ else+ console.log(addrs);+ })+ .fail(function(xhr, st, err) {+ console.log("Error " + err);+ });+ };++ //Set the address book type based on the dropdown. The google+ //contacts is set in a seperate change handler which is only+ //loaded depending on the settings.+ aType.change(function() {+ var v = $(this).val();+ window.localStorage.setItem("Address Book Type", v);+ if (v == 0)+ window.searchContacts = undefined;+ else if (v == 1)+ window.searchContacts = searchAbook;+ });++ // Load the old format type+ var oldformat = window.localStorage.getItem("Address Book Type");+ if (oldformat) {+ aType.val(oldformat);+ aType.change();+ }+});
templates/compose.hamlet view
@@ -1,8 +1,18 @@ $forall e <- err <div .alert> #{e}-<form .form-horizontal enctype=#{enctype} method=post action=@{ComposeR}>+<form enctype=#{enctype} method=post action=@{ComposeR}> ^{widget} <div .control-group> <div .controls> <button type="submit" .btn .btn-primary>_{MsgSend}+ <button type="button" .btn .hidden #previewbtn>_{MsgPreview}+<form #preview-form enctype=#{previewEnctype} style="display:none" method=post action=@{PreviewMessageR}>+ ^{previewWidget}+<div .modal .hide .fade #preview-modal>+ <div .modal-header>+ <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×+ <h3>_{MsgPreview}+ <div .modal-body #preview-body>+ <div .modal-footer>+ <button type="button" .btn data-dismiss="modal" aria-hidden="true">_{MsgClose}
templates/compose.julius view
@@ -1,5 +1,5 @@ $(document).ready(function() {- //copied from jquery-mobile+ //resize body field: copied from jquery-mobile var input = $("textarea#body"), extraLineHeight = 15, keyupTimeoutBuffer = 100,@@ -14,8 +14,137 @@ } }; + $(".addr-help").popover();+ input.on( "keyup change input paste", function() { clearTimeout( keyupTimeout ); keyupTimeout = setTimeout( input._keyup, keyupTimeoutBuffer );+ });++ //restore format setting+ var oldformat = window.localStorage.getItem("Compose Body Format");+ if (oldformat) {+ $("#bdyfmt").val(oldformat);+ if (oldformat == 3) { //markdown+ $("#previewbtn").removeClass("hidden");+ }+ }+ $("#bdyfmt").change(function() {+ var f = $("#bdyfmt").val();+ window.localStorage.setItem("Compose Body Format", f);+ if (f == 3) { //markdown+ $("#previewbtn").removeClass("hidden");+ } else {+ $("#previewbtn").addClass("hidden");+ }+ });++ //The preview button+ $("#previewbtn").on("click", function() {+ var frm = $("#preview-form");+ var data = frm.serializeArray();+ $(data).each(function() {+ if (this.name == "f2")+ this.value = $("#Body").val();+ });+ $.ajax({+ type: "POST",+ data: $.param(data),+ dataType: 'html',+ url: frm.attr('action')+ })+ .done(function(data) {+ $("#preview-body").html(data);+ $("#preview-modal").modal();+ })+ .fail(function(xhr, err, e) {+ alert("Error " + err + " " + e);+ });+ return false;+ });++ //parse a comma seperated list of addresses, supporting commas inside quotes in the name+ var parseAddrs = function(str) {+ var pttrn = /( *"[^"]*"[^,"]*|[^",]+)(,)?/g;+ var data = [];++ while (m = pttrn.exec(str)) {+ if (m[1]) {+ data.push({txt: m[1], comma: m[2] !== undefined});+ }+ }++ return data;+ };++ // Now the address fields+ $(".address-field").each(function() {+ $(this).select2({+ data: [],+ multiple: true,+ selectOnBlur: true,+ width: 'element',+ minimumInputLength: 3,+ createSearchChoice: function(term) {+ return {id: term, text: term};+ },+ initSelection: function(element, callback) {+ var data = []+ $(parseAddrs(element.val())).each(function() {+ data.push({id: this.txt, text: this.txt});+ });+ callback(data);+ },+ query: function(options) {+ if (window.searchContacts)+ window.searchContacts(options.term, options.page, options.callback);+ else+ options.callback({results:[], more: false});+ },+ tokenizer: function(input, selection, selectCallback, opts) {+ var data = parseAddrs(input);+ var ret = '';+ $(data).each(function() {+ if (this.comma) {+ selectCallback({id: this.txt, text: this.txt});+ } else {+ ret += this.txt;+ }+ });+ return ret;+ },+ formatSelection: function(addr, node) {+ if (addr.id != "") {+ var esc = this.escapeMarkup;+ var m = addr.id.match(/([^<]*)(<([^>]*)>)?/);+ if (m === undefined || m[1] === undefined) {+ return addr.text;+ } else {+ node.replaceWith("<div title='"+esc(addr.text)+"'>"+esc(m[1])+"</div>");+ return undefined;+ }+ }+ },+ });++ var addrbox = $(this);+ //var s2container = addrbox.prev("div.select2-container");+ var s2container = addrbox.select2("container");+ s2container.on("dblclick", "li.select2-search-choice", function() {+ var data = addrbox.select2("data");++ //Clear the clicked element+ var item = $(this).children("div").attr("title");+ var ndata = $.grep(data, function(i) {+ return i.id != item;+ });+ addrbox.select2("data", ndata);++ //Set it as the input+ s2container.find("li.select2-search-field input").val(item);+ addrbox.data("select2").resizeSearch();+ addrbox.data("select2").focusSearch();+ addrbox.select2("open");+ }); }); });
templates/compose.lucius view
@@ -1,20 +1,20 @@-textarea#body {+textarea#Body { height: 200px; }-textarea#extraheaders {+textarea#hdrs { white-space: nowrap; overflow: auto; } @media (max-width: 800px) {- textarea#body {+ textarea#Body, input#subj { width: 100%; } } @media (min-width: 801px) {- textarea#body {+ textarea#Body, input#subj { width: 650px; }- textarea#extraheaders, div.controls input {+ textarea#hdrs, div.controls input, div.controls select { width: 400px; } }
templates/default-layout-wrapper.hamlet view
@@ -9,5 +9,12 @@ <meta name="viewport" content="width=device-width,initial-scale=1"> <link rel="search" href=@{OpenSearchR} type="application/opensearchdescription+xml" title="Notmuch Web"> ^{pageHead pc}+ <noscript>+ <style>+ td.only-noscript { display: table-cell; }+ th.only-noscript { display: inline; }+ .hide-noscript { display: none; }+ .collapse { height: auto; }+ td.search-link { cursor: auto; } <body> ^{pageBody pc}
templates/default-layout.cassius view
@@ -21,6 +21,8 @@ body padding-right: 5px padding-left: 5px+.only-noscript+ display: none /* Fix for https://github.com/twitter/bootstrap/issues/7968 */ .dropdown-backdrop
templates/default-layout.hamlet view
@@ -41,8 +41,9 @@ <footer> <p>- <a href="http://notmuchmail.org/">Notmuch</a> is released under the- <a href="http://www.gnu.org/licenses/gpl.html">GPLv3+</a>.- Notmuch-web is released under the- <a href="http://www.gnu.org/licenses/agpl.html">AGPLv3+</a>(- <a href="https://bitbucket.org/wuzzeb/notmuch-web">source</a>).+ <a href="http://notmuchmail.org/">Notmuch+ is released under the <a href="http://www.gnu.org/licenses/gpl.html">GPLv3+</a>.+ <a href="https://bitbucket.org/wuzzeb/notmuch-web">Notmuch-web+ is released under the+ <a href="http://www.gnu.org/licenses/agpl.html">AGPLv3++ (<a href="#{sourceLink}">source</a>).
+ templates/google-contacts.hamlet view
@@ -0,0 +1,8 @@+<div .control-group>+ <label .control-label for="addressbooktype">_{MsgAddrBookType}+ <div .controls>+ <select name="addressbooktype" #addressbooktype>+ <option value="0">_{MsgNoAddressBook}+ <option value="1">_{MsgAbook}+ <option value="2">_{MsgGoogle}+ <button #google-auth-button .btn .hidden data-notmuch-client-id="#{clientID}">_{MsgGoogleAuth}
+ templates/google-contacts.julius view
@@ -0,0 +1,80 @@+//Some documentation+//https://developers.google.com/api-client-library/javascript/features/authentication+//https://developers.google.com/api-client-library/javascript/features/cors+//https://developers.google.com/google-apps/contacts/v3/++// loadGoogleAuth will be called once the google authentication script has+// been loaded, and we don't load the script until the document is ready so+// the body of loadGoogleAuth can assume the document is ready.+function loadGoogleAuth() {+ var btn = $("#google-auth-button");+++ var handleAuth = function(authResult) {+ if (authResult && !authResult.error) {+ btn.addClass("hidden");+ } else {+ btn.removeClass("hidden");+ }+ };++ btn.on('click', function() {+ gapi.auth.authorize({client_id: btn.data("notmuch-client-id")+ , scope: 'https://www.google.com/m8/feeds'+ , immediate: false+ }, handleAuth);++ });++ // Check auth on document load+ gapi.auth.authorize({client_id: btn.data("notmuch-client-id")+ , scope: 'https://www.google.com/m8/feeds'+ , immediate: true+ }, handleAuth);++ var searchGoogleContacts = function(input, page, callback) {+ $.ajax({ url: 'https://www.google.com/m8/feeds/contacts/default/thin'+ , dataType: 'json'+ , data: { 'access_token' : gapi.auth.getToken().access_token+ , q : input+ , 'max-results' : 20+ , 'start-index': (page-1)*20 + 1+ , 'alt' : "json"+ , 'v' : '3.0'+ }+ })+ .done(function(data) {+ var num = data.feed["openSearch$totalResults"]["$t"];+ var addrs = [];+ $(data.feed.entry).each(function() {+ var name = this.title["$t"];+ if ("gd$email" in this) {+ $(this["gd$email"]).each(function() {+ var a = name + " <" + this.address + ">"+ addrs.push({id: a, text: a});+ });+ }+ });+ if (callback)+ callback({results: addrs, more: num >= 20});+ else+ console.log(addrs);+ })+ .fail(function(xhr, st, err) {+ alert("Error " + err);+ });+ };++ // Set the address book based on the dropdown+ $("#addressbooktype").change(function() {+ if ($(this).val() == 2)+ window.searchContacts = searchGoogleContacts;+ });++ if ($("#addressbooktype").val() == 2)+ window.searchContacts = searchGoogleContacts;+}++$(document).ready(function() {+ $.getScript("https://apis.google.com/js/auth.js?onload=loadGoogleAuth");+});
+ templates/plain-contacts.hamlet view
@@ -0,0 +1,6 @@+<div .control-group>+ <label .control-label for="addressbooktype">_{MsgAddrBookType}+ <div .controls>+ <select name="addressbooktype" #addressbooktype>+ <option value="0">_{MsgNoAddressBook}+ <option value="1">_{MsgAbook}
templates/search.hamlet view
@@ -1,8 +1,9 @@ <p>- <a href=@{ThreadPagerR s}>_{MsgViewInPager}+ <a .hide-noscript href=@{ThreadPagerR s}>_{MsgViewInPager} <table .table .table-striped .table-condensed> <thead> <tr>+ <th .only-noscript> <th>_{MsgDate} <th>_{MsgSubject} <th>_{MsgAuthors}@@ -10,6 +11,8 @@ <tbody> $forall r <- search <tr>+ <td .only-noscript>+ <a href="@{ThreadR (searchThread r)}">Open <td .search-link onclick="document.location = '@{ThreadR (searchThread r)}'">#{searchDateRel r} <td .search-link onclick="document.location = '@{ThreadR (searchThread r)}'">#{searchSubject r} <td .search-link onclick="document.location = '@{ThreadR (searchThread r)}'">#{searchAuthors r}
tests/main.hs view
@@ -11,10 +11,13 @@ import Application (makeFoundation) import HomeTest+import Address main :: IO () main = do conf <- loadConfig $ (configSettings Testing) { csParseExtra = parseExtra } foundation <- makeFoundation conf- hspec $ yesodSpec foundation $- homeSpecs+ hspec $ do+ yesodSpec foundation $+ homeSpecs+ addrSpecs