packages feed

darcswatch-0.4: src/HTML.hs

{-
Copyright (C) 2008 Joachim Breitner

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 2, 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; see the file COPYING.  If not, write to
the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
Boston, MA 02110-1301, USA.
-}

module HTML 
	( BundleInfo(..)
	, mainPage
	, userPage
	, repoPage
	, cgiMessagePage
	, userFile
	, repoFile
	, patchDiffFile
	, normalizeAuthor
	, bundleInfoFilter
	, maxState
	, bundleURL
	, stripIgnorethis
	) where

import Text.XHtml hiding ((!))
import qualified Text.XHtml ((!))
import Text.Printf
import qualified Data.Map as M
import qualified Data.Set as S
import Data.Map ((!))
import qualified MultiMap as MM
import MultiMap ((!!!!))
import Data.List
import Data.Ord
import Data.Char
import Data.Maybe
import Data.Time
import System.Time
import System.Locale

import qualified Data.ByteString.Char8 as B
import Data.ByteString.Char8 (ByteString)

import Darcs
import Darcs.Watch.Data


-- not in ghc6.6
infixl 0 `on`
on :: (b -> b -> c) -> (a -> b) -> a -> a -> c
(*) `on` f = \x y -> f x * f y

data BundleInfo = BundleInfo
	{ biBundleHash :: BundleHash
	, biBundle :: PatchBundle
	, biFileName :: FilePath
	, biHistory :: [BundleHistory]
	}

instance Eq BundleInfo where
	(==) = (==) `on` biBundleHash

instance Ord BundleInfo where
	compare = compare `on` (map (piDate.fst) . fst . biBundle)

mainPage date
	 nameMapping
         patchCount
	 bundleCount
	 repoData
	 authorData
	= showHtml $
   header << (
   	thetitle << "DarcsWatch overview" +++
	myHeader 
	) +++
   body << (
	h1 << "DarcsWatch overview" +++
	p << ( "Welcome to DarcsWatch. If you want to know more, please read the " +++
	       hotlink "http://darcs.nomeata.de/darcswatch/documentation.html" << "Documentation" +++
	       "."
	       ) +++
	h2 << "Statistics" +++
	p << stringToHtml (
		printf "Tracking %d repositories and %d patches in %d bundles submitted by %d users."
		(length repoData) (patchCount :: Int) (bundleCount :: Int) (length authorData)
		) +++
	h2 << "Listing by user" +++ 
	unordList (map userLine authorData) +++
	h2 << "Listing by repository" +++ 
	unordList (map repoLine repoData) +++
	footer (Just date)
	)
  where userLine (u, tracked, unapplied, obsolete, rejected) =
 		hotlink (userFile u) << M.findWithDefault u u nameMapping +++
		" " ++
		show tracked +++ 
		" tracked patches, "+++
		count Applicable "unapplied" unapplied +++
		count Obsoleted "obsolete" obsolete +++
		count Rejected "rejected" rejected
        repoLine (r, inventory, tracked, applicable, obsolete, rejected) =
 		hotlink (repoFile r) << r +++
		" " ++
		show tracked +++ 
		" patches in inventory, "+++
		count Applied "tracked" tracked +++
		count Applicable "applicable" applicable +++
		count Obsoleted "obsolete" obsolete +++
		count Rejected "rejected" rejected
	count s t 0 = noHtml
	count s t n = inColor (stateColor s) << (show n +++ " " +++ t) +++ " "

footer mDate = 
	p !!! [thestyle "font-size:80%"] << (
		"darcswatch © Joachim Breitner <" +++
		hotlink "mailto:mail@joachim-breitner.de" << "mail@joachim-breitner.de" +++
		">. Source code at " +++
		hotlink "http://darcs.nomeata.de/darcswatch/" << "http://darcs.nomeata.de/darcswatch/"+++
		"." +++ maybe noHtml (\d -> " Last update " +++ calendarTimeToString d +++ ".") mDate
		)

myHeader   = script !!! [thetype "text/javascript", src "/javascript/jquery/jquery.js"] << noHtml
             +++
             script !!! [thetype "text/javascript"] << "\
		\$(document).ready(function () { \
                \   $('.diffshower').click(function () { \
                \       var diffid = this.id.replace(/^diffshower_/,''); \
                \       $('#diff_' + diffid).toggle(); \
		\       $('#diff_' + diffid + ' pre:visible').each(function () {\
		\           diffelem = this;\
		\           if (!($(diffelem).text())) {\
		\               $(diffelem).text('Diff is being loaded...');\
		\               jQuery.get('diff_' + diffid + '.txt',\
		\                       callback=function(text) {\
		\                              $(diffelem).text(text);\
        	\                       }\
		\                       ,type='text');\
		\           }});\
                \   })})\
		\"
             +++
	     style !!! [thetype "text/css" ] << "\
	     	\span[title] { border-bottom: dotted 1px black; } \
		\"

userPage date nameMapping u bundleInfos = showHtml $
   header << (
   	thetitle << ("DarcsWatch overview for " +++ u) +++
	myHeader
	) +++
   body << form !!! [ method "GET", action "cgi"] << (
	h1 << ("DarcsWatch overview for " +++ M.findWithDefault u u nameMapping) +++
	p << hotlink "." << "Return to main page" +++
	bundleList nameMapping "Unapplied patch bundles" (bundleInfoFilter Applicable bundleInfos) +++ 
	bundleList nameMapping "Unmatched patch bundles" (bundleInfoFilter New bundleInfos) +++ 
	bundleList nameMapping "Applied patch bundles" (bundleInfoFilter Applied bundleInfos) +++ 
	bundleList nameMapping "Obsoleted patch bundles" (bundleInfoFilter Obsoleted bundleInfos) +++ 
	bundleList nameMapping "Rejected patch bundles" (bundleInfoFilter Rejected bundleInfos) +++ 
	footer (Just date)
	)


repoPage date nameMapping r repoInfo bundleInfos = showHtml $
   header << (
   	thetitle << ("DarcsWatch overview for " +++ r) +++
	myHeader
	) +++
   body << form !!! [ method "GET", action "cgi"] << (
	h1 << ("DarcsWatch overview for " +++ r) +++
	p << hotlink "." << "Return to main page" +++
	bundleList nameMapping "Unapplied patch bundles" (bundleInfoFilter Applicable bundleInfos) +++ 
	bundleList nameMapping "Applied patch bundles" (bundleInfoFilter Applied bundleInfos) +++ 
	bundleList nameMapping "Obsoleted patch bundles" (bundleInfoFilter Obsoleted bundleInfos) +++ 
	bundleList nameMapping "Rejected patch bundles" (bundleInfoFilter Rejected bundleInfos) +++ 
	thediv << (
		"Last change in repository at " +++
		maybe (toHtml "No idea when") toHtml (lastUpdate repoInfo) +++
		", last check of repository at " +++
		maybe (toHtml "No idea when") toHtml (lastCheck repoInfo) +++ "."
	) +++
	footer (Just date)
	)

bundleList nameMapping title [] = noHtml
bundleList nameMapping title list = 
	h2 << title +++
	unordList (map (bundleView nameMapping) list)

bundleInfoFilter state = sort . filter (\bi -> state == maxState (biHistory bi))

{-
unmatchedPage d = showHtml $
   header << (
   	thetitle << ("DarcsWatch overview, unmatched patches") +++
	myHeader
	) +++
   body << form !!! [ method "GET", action "cgi"] << (
	h1 << ("DarcsWatch overview, unmatched Patches") +++
	p << hotlink "." << "Return to main page" +++
	patchList d (S.toList (unmatched d)) "Unmatched patches" False +++
	footer (Just (date d))
	)
-}

cgiMessagePage isError msg = showHtml $
   header << (
   	thetitle << "DarcsWatch" +++
	myHeader
	) +++
   body << (
	h1 << "DarcsWatch" +++
	p << (
		(if isError then inColor "red" << "Error: " else noHtml)
		+++
		msg
	      ) +++
	footer Nothing
	)

patchHistoryView history = unordList $
	map (\(date,source,state) ->
		date +++ ": " +++ showState state +++ " " +++ showSource source)
	    (reverse history)

showSource (ViaRepository repo) = " in repo " +++ hotlink (repoFile repo) << repo
showSource (ViaBugtracker url) = " in bug tracker ticket " +++ hotlink url << url
showSource (ViaWeb openid) = " via web interface by user " +++ hotlink openid << openid
showSource (ViaEMail from to subject mmid) = " via " +++
					     thespan !!! [ title $
					     	"From: " ++ from ++ "\n" ++
						"Subject: " ++ subject
					     ] << "e-mail" +++ " to " +++
					     hotlink ("mailto:" ++ to) << to  +++
					     maybe noHtml (\mid ->
						" " +++ hotlink ("http://mid.gmane.org/" ++ mid) 
					    	      (image !!!
						    	[ src "http://gmane.org/favicon.ico"
							, title "Search this mail on gmane.org"
							, border 0
							])
					     ) mmid

showSource ManualImport = toHtml "via a manual import"

bundleView nameMapping (BundleInfo bundleHash (ps,ctx) bundleFileName history) = 
	p << (anchor !!! [name ("bundle-" ++ bundleHash)] << noHtml +++
	     strong << "Contents:" +++
	     " (" +++
	     (if length ps == 1
	      then "1 patch"
	      else show (length ps) ++ " patches" ) +++
	     ")"
	) +++
	unordList (map (patchView nameMapping) (map fst ps)) +++
	p << (strong << "History:") +++
	patchHistoryView history +++
	actions
  where
	actions = paragraph << (
			strong << "Actions: " +++
			hotlink bundleFileName << "Download .dpatch" +++
			" "+++ 
			if maxState history <= Applicable
			then	" "+++
				select !!! [ name ("state-" ++ bundleHash), size "1"] << (
					option !!! [ value "UNCHANGED" ] << "Mark patch as..." +++
					option !!! [ value "OBSOLETE" ] << "Obsolete" +++
					option !!! [ value "REJECTED" ] << "Rejected"
					) +++
				submit "submit" "Submit"
			else noHtml
			)

patchView nameMapping p =
	piDate p +++ ": " +++ strong << (
		(if piInverted p then stringToHtml "UNDO: " else noHtml) +++
		piName p
		) +++
	" " +++
	small << (
		" by " +++
		let u = B.unpack (piAuthor p) in
		hotlink (userFile u) <<
			M.findWithDefault u u nameMapping +++
		" " +++
		diffShower
	) +++
	(if null log then noHtml else pre << B.unlines log) +++
	thediv !!! [identifier diffId, thestyle "display:none"] << pre noHtml
  where pid = patchBasename p
	diffShowerId = "diffshower_"++pid
	diffId = "diff_"++pid
	diffShower = anchor
		!!! [identifier diffShowerId, theclass "diffshower", href "javascript:"]
		<< "Show/Hide diff"
	log  = stripIgnorethis (piLog p)

maxState history = maximum $ New : map (\(_,_,s) -> s) history

{-
state d p r | p `S.member` ps                          = Applied
            | ip `S.member` ps                         = maxState history
	    | amend_obsoleted                          = Obsoleted
            | ip `S.member` subs && not (piInverted p) = Obsoleted
            | otherwise                                = maxState history
  where context = peContext (p2pe d ! p)
	history = peStateHistory (p2pe d ! p)
  	ps = r2p d ! r
        ip = inversePatch p
	subs = M.keysSet (p2pe d)
	amend_obsoleted = any (`laterThan` p) $ S.toList (u2p d ! (normalizeAuthor (piAuthor p)))

p1 `laterThan` p2 =    piAuthor p1 == piAuthor p2
                    && piName p1   == piName p2
		    && piLog p1    == piLog p2
		    && piInverted p1 == piInverted p2
		    && piDate p1   > piDate p2
-}

showState New = "Seen bundle"
showState Applicable = "Not yet fully applied bundle"
showState Rejected = "Bundle marked rejected"
showState Obsoleted = "Bundle marked obsolete"
showState Applied = "Bundle fully applied"

stateColor New = "black"
stateColor Applicable = "red"
stateColor Obsoleted = "gray"
stateColor Rejected = "brown"
stateColor Applied = "green"

inColor c = thespan !!! [thestyle ("color:"++c)]

(!!!) :: ADDATTRS a => a -> [HtmlAttr] -> a
(!!!) = (Text.XHtml.!)	    

{-
userStats u d = " " +++
	show (length ps) +++ 
	" tracked patches, "+++
	count Applicable "unapplied" +++
	count Obsoleted "obsolete" +++
	count Rejected "rejected"
  where (ps, sps) = userData u d
  	count s t = case sps !!!! s of
	              [] -> noHtml
	              _  -> inColor (stateColor s) << (show (length ps) +++ " " +++ t) +++ " "
	  where ps = (sps !!!! s)

repoStats r d = " " +++
	show (S.size (r2p d !!!! r)) +++ 
	" patches in inventory "+++
	count Applied "tracked" +++
	count Applicable "applicable" +++
	count Obsoleted "obsolete" +++
	count Rejected "rejected"
  where (ps, sps) = repoData r d
  	count s t = case sps !!!! s of
	              [] -> noHtml
	              _  -> inColor (stateColor s) << (show (length ps) +++ " " +++ t) +++ " "
	  where ps = (sps !!!! s)

repoData r d = (ps, sorted) 
  where ps = s2List $ r2mp d !!!! r
	sorted = MM.fromList $ map (\p -> (state d p r, p)) ps

userData u d = (ps, sorted)
  where ps = patchSort $ S.toList $ u2p d !!!! u
	sorted = MM.fromList $ map (\p -> (state' p, p)) ps
	state' p | S.null repos = New
	         | otherwise    = S.findMax (S.map (state d p) repos)
	  where repos = p2pr d !!!! p
-}

userFile u = "user_" ++ normalizeAuthor u ++ ".html"
repoFile r = "repo_" ++ safeName r ++ ".html"
patchDiffFile p = "diff_" ++ patchBasename p ++ ".txt"

normalizeAuthor name | not (null r') && valid = email
                     | otherwise              = safeName name
  where r' = dropWhile (/='<') name
        (email,r'') = span (/='>') (tail r')
	valid = not (null email) && not (null r'') && all (isSafeFileChar) email

safeName n = map s n
  where s c = if isSafeFileChar c then c else '_'

isSafeFileChar c = isAlpha c || isDigit c || c `elem` "-_.@:"


s2List = patchSort . S.toList
patchSort = sortBy (flip (compare `on` piDate))

bundleURL :: DarcsWatchConfig -> RepositoryURL -> BundleHash -> String
bundleURL config repo hash = cDarcsWatchURL config ++ repoFile repo ++ "#bundle-" ++ hash

stripIgnorethis [] = []
stripIgnorethis (x:xs) | B.pack "Ignore-this:" `B.isPrefixOf` x = stripIgnorethis xs
		       | B.null x                               = stripIgnorethis xs
		       | otherwise                              = x:xs

instance HTML ByteString where
	toHtml = toHtml . B.unpack

instance HTML UTCTime where
	toHtml = toHtml . formatTime defaultTimeLocale "%c" 

instance HTML ZonedTime where
	toHtml = toHtml . formatTime defaultTimeLocale "%c"