# # # delete "www/viewmtn/about.psp" # # delete "www/viewmtn/branch.psp" # # delete "www/viewmtn/diff.psp" # # delete "www/viewmtn/enscriptlangs.py" # # delete "www/viewmtn/error.psp" # # delete "www/viewmtn/file.psp" # # delete "www/viewmtn/fileinbranch.psp" # # delete "www/viewmtn/headofbranch.psp" # # delete "www/viewmtn/help.psp" # # delete "www/viewmtn/html.py" # # delete "www/viewmtn/index.psp" # # delete "www/viewmtn/manifest.psp" # # delete "www/viewmtn/mimetypes/gnome-fs-directory.png" # # delete "www/viewmtn/mimetypes/gnome-library.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-magicpoint.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-msword.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-ogg.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-par.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-pdf.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-pgp-encrypted.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-pgp-keys.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-pgp.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-postscript.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-qif.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-rhythmbox-effect.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-rhythmbox-playlist.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-rtf.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-smil.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-vnd.lotus-1-2-3.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-vnd.ms-excel.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-vnd.ms-powerpoint.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-vnd.ms-word.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-vnd.stardivision.calc.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-vnd.stardivision.impress.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-vnd.stardivision.writer.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-vnd.sun.xml.calc.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-vnd.sun.xml.draw.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-vnd.sun.xml.impress.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-vnd.sun.xml.writer.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-vnd.sun.xml.writer.template.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-wordperfect.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-abiword.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-applix-spreadsheet.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-applix-word.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-archive.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-arj.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-bittorrent.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-bla.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-blender.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-blf.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-blv.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-bzip-compressed-tar.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-bzip.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-cd-image.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-class-file.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-compress.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-compressed-tar.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-core.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-cpio-compressed.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-cpio.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-dc-rom.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-deb.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-desktop.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-dia-diagram.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-dv.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-dvi.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-e-theme.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-executable.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-extension-nfo.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-extension-par2.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-font-afm.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-font-bdf.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-font-linux-psf.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-font-pcf.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-font-sunos-news.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-font-ttf.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-gameboy-rom.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-genesis-rom.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-glade.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-gnucash.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-gnumeric.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-gtktalog.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-gzip.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-ipod-firmware.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-jar.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-killustrator.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-kpresenter.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-kspread.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-kword.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-lha.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-lhz.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-mrproject.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-msx-rom.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-n64-rom.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-nes-rom.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-object.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-perl.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-php.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-python-bytecode.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-python.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-qw.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-rar.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-reject.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-rpm.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-ruby.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-sharedlib.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-shellscript.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-shockwave-flash.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-sms-rom.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-sql.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-stuffit.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-tar.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-tex.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-trash.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-x-x509-ca-cert.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application-zip.png" # # delete "www/viewmtn/mimetypes/gnome-mime-application.png" # # delete "www/viewmtn/mimetypes/gnome-mime-audio-ac3.png" # # delete "www/viewmtn/mimetypes/gnome-mime-audio-basic.png" # # delete "www/viewmtn/mimetypes/gnome-mime-audio-midi.png" # # delete "www/viewmtn/mimetypes/gnome-mime-audio-x-aiff.png" # # delete "www/viewmtn/mimetypes/gnome-mime-audio-x-it.png" # # delete "www/viewmtn/mimetypes/gnome-mime-audio-x-midi.png" # # delete "www/viewmtn/mimetypes/gnome-mime-audio-x-mod.png" # # delete "www/viewmtn/mimetypes/gnome-mime-audio-x-mp3.png" # # delete "www/viewmtn/mimetypes/gnome-mime-audio-x-s3m.png" # # delete "www/viewmtn/mimetypes/gnome-mime-audio-x-stm.png" # # delete "www/viewmtn/mimetypes/gnome-mime-audio-x-ulaw.png" # # delete "www/viewmtn/mimetypes/gnome-mime-audio-x-voc.png" # # delete "www/viewmtn/mimetypes/gnome-mime-audio-x-wav.png" # # delete "www/viewmtn/mimetypes/gnome-mime-audio-x-xi.png" # # delete "www/viewmtn/mimetypes/gnome-mime-audio-x-xm.png" # # delete "www/viewmtn/mimetypes/gnome-mime-audio.png" # # delete "www/viewmtn/mimetypes/gnome-mime-image-bmp.png" # # delete "www/viewmtn/mimetypes/gnome-mime-image-gif.png" # # delete "www/viewmtn/mimetypes/gnome-mime-image-jpeg.png" # # delete "www/viewmtn/mimetypes/gnome-mime-image-png.png" # # delete "www/viewmtn/mimetypes/gnome-mime-image-svg+xml.png" # # delete "www/viewmtn/mimetypes/gnome-mime-image-svg.png" # # delete "www/viewmtn/mimetypes/gnome-mime-image-tiff.png" # # delete "www/viewmtn/mimetypes/gnome-mime-image-wmf.png" # # delete "www/viewmtn/mimetypes/gnome-mime-image-x-3ds.png" # # delete "www/viewmtn/mimetypes/gnome-mime-image-x-applix-graphic.png" # # delete "www/viewmtn/mimetypes/gnome-mime-image-x-cmu-raster.png" # # delete "www/viewmtn/mimetypes/gnome-mime-image-x-lwo.png" # # delete "www/viewmtn/mimetypes/gnome-mime-image-x-lws.png" # # delete "www/viewmtn/mimetypes/gnome-mime-image-x-xcf.png" # # delete "www/viewmtn/mimetypes/gnome-mime-image.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-css.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-html.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-authors.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-c++src.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-c-header.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-c.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-chdr.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-copying.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-credits.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-csh.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-csharp.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-csrc.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-haskell.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-install.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-java.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-literate-haskell.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-lyx.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-makefile.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-objcsrc.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-patch.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-readme.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-scheme.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-sql.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-tex.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-troff-man.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-txt.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-vcalendar.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-vcard.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-x-zsh.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text-xml.png" # # delete "www/viewmtn/mimetypes/gnome-mime-text.png" # # delete "www/viewmtn/mimetypes/gnome-mime-video-mpeg.png" # # delete "www/viewmtn/mimetypes/gnome-mime-video-quicktime.png" # # delete "www/viewmtn/mimetypes/gnome-mime-video-x-ms-asf.png" # # delete "www/viewmtn/mimetypes/gnome-mime-video-x-ms-wmv.png" # # delete "www/viewmtn/mimetypes/gnome-mime-video-x-msvideo.png" # # delete "www/viewmtn/mimetypes/gnome-mime-video.png" # # delete "www/viewmtn/mimetypes/gnome-mime-x-directory-nfs-server.png" # # delete "www/viewmtn/mimetypes/gnome-mime-x-directory-smb-server.png" # # delete "www/viewmtn/mimetypes/gnome-mime-x-directory-smb-share.png" # # delete "www/viewmtn/mimetypes/gnome-mime-x-directory-smb-workgroup.png" # # delete "www/viewmtn/mimetypes/gnome-mime-x-font-afm.png" # # delete "www/viewmtn/mimetypes/gnome-package.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-database.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-drawing-template.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-drawing.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-formula.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-master-document.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-oasis-database.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-oasis-drawing-template.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-oasis-drawing.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-oasis-formula.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-oasis-master-document.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-oasis-presentation-template.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-oasis-presentation.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-oasis-spreadsheet-template.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-oasis-spreadsheet.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-oasis-text-template.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-oasis-text.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-oasis-web-template.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-presentation-template.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-presentation.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-spreadsheet-template.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-spreadsheet.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-text-template.png" # # delete "www/viewmtn/mimetypes/openofficeorg-19-text.png" # # delete "www/viewmtn/monotone.py" # # delete "www/viewmtn/revision.psp" # # delete "www/viewmtn/tags.psp" # # delete "www/viewmtn/tarofbranch.psp" # # delete "www/viewmtn/wrapper.py" # # rename "www/viewmtn/MochiKit" # to "www/viewmtn/static/MochiKit" # # rename "www/viewmtn/builtpython.sh" # to "www/viewmtn/release.sh" # # rename "www/viewmtn/rss_feed.gif" # to "www/viewmtn/static/rss_feed.gif" # # rename "www/viewmtn/version.py" # to "www/viewmtn/release.py" # # rename "www/viewmtn/viewmtn.css" # to "www/viewmtn/static/viewmtn.css" # # rename "www/viewmtn/viewmtn.js" # to "www/viewmtn/static/viewmtn.js" # # add_dir "www/viewmtn/fdo" # # add_dir "www/viewmtn/static" # # add_file "www/viewmtn/INSTALL" # content [e6b186c3fbe5e1843af38b941f284add5e3ceaa6] # # add_file "www/viewmtn/fdo/__init__.py" # content [da39a3ee5e6b4b0d3255bfef95601890afd80709] # # add_file "www/viewmtn/fdo/icontheme.py" # content [9ec270a56c060a3c63b75a4f7d00c370a831ae29] # # add_file "www/viewmtn/fdo/sharedmimeinfo.py" # content [dd993b9e526568246adc535ce4d47d360dd4eb3b] # # add_file "www/viewmtn/fdo/xdgbasedir.py" # content [864ac96a21a2fe65c401a7beaf2c98513795751d] # # add_file "www/viewmtn/genproxy.py" # content [408f46a3f5fe0d792eb62e92a8faaf5c28c67a54] # # add_file "www/viewmtn/static/highlight.css" # content [45cedf6720b40c8e1cb9283d79e1929cb4e317c8] # # add_file "www/viewmtn/syntax.py" # content [cf93e6e6a6166204daaae6ec50819ba175fa3ed1] # # add_file "www/viewmtn/templates/branch.html" # content [9b84a47baffe133624433db124e92d70a206bb6a] # # add_file "www/viewmtn/templates/branchchanges.html" # content [84dfe05fe66d988e3cdf426da5e0b339ed5d2df2] # # add_file "www/viewmtn/templates/branchchangesrss.html" # content [815fa5b5b1d06136d19fbf83b3c9f0c521d7bb05] # # add_file "www/viewmtn/templates/branchchoosehead.html" # content [67a2b7d951933ad3763747eb7bcb3fae02500c49] # # add_file "www/viewmtn/templates/revisionbrowse.html" # content [66d38381f187bc229bccd5489ad81693460b68e9] # # add_file "www/viewmtn/templates/revisiondiff.html" # content [0cfa3372c64f3e1dc8f6112afff98281feb88b07] # # add_file "www/viewmtn/templates/revisionfile.html" # content [8d316cefa1d01ec52d0e42a923a7736a46efda0d] # # add_file "www/viewmtn/templates/revisionfilebin.html" # content [ed073e34de5e16ad7ed0d14ce01bff80b0d78970] # # add_file "www/viewmtn/templates/revisionfileimg.html" # content [cd246dd75333b2ced5d8dd9216e0d50a16769dfd] # # add_file "www/viewmtn/templates/revisionfileobj.html" # content [aecf0edca364646c23f9fecfb764159a73b4d7d1] # # add_file "www/viewmtn/templates/revisionfiletxt.html" # content [c15b907d1bf28ed74a6835cb5dc775241432e958] # # patch "www/viewmtn/ChangeLog" # from [c16ac2013e466b7eafcdf54b808052056d2f3e89] # to [a90102fbf7b5d7be6c5d553f362389c465267832] # # patch "www/viewmtn/TODO" # from [6372d06a8696794b11e1f5dece588de4d1057896] # to [d57f9d2d4d2acd8412cb3d9c090f424159eecb6a] # # patch "www/viewmtn/common.py" # from [19a25a6ea138661ce01454ec58c1ea20fcd884aa] # to [b5a9eb0d651eb4f1a41e94376d35b31f5c8efe5d] # # patch "www/viewmtn/config.py.example" # from [9d8013696bf352853a5b9a9bce70386e9f8edb96] # to [343449d29b7b0e04c1e86f034c27a48bed094d5e] # # patch "www/viewmtn/mtn.py" # from [1f4c058e4f55b848c1c8195a38e6edaeb86310b6] # to [10bae73eaa4b6b891d434bfcffa8ca66968b204b] # # patch "www/viewmtn/release.py" # from [a071b2192e2092ec222ab0d59ef95efd1c9c81e6] # to [9606208d767e8ed4999ca37fa5e20d13f3fa7be5] # # patch "www/viewmtn/release.sh" # from [c1d18a362622ec209ea6818957a5778d5374de45] # to [65fe70e241d026a9e09530245362cb09287b6608] # # patch "www/viewmtn/static/viewmtn.css" # from [6efcadac0d56fb3d77a22786f477ba621f4af33d] # to [8981409323f375efedb9599c206ac0b34bdad739] # # patch "www/viewmtn/templates/about.html" # from [6b1c55564ae50e2de59d5eda418bfad86f0b596d] # to [fea245cb17ab46a7766e03293800361db0b325b2] # # patch "www/viewmtn/templates/base.html" # from [828ac669dcf846b15ebedc558c7da907079dd685] # to [96ce364d2dfd62b29e6557bebfd3e5f9a6c87b46] # # patch "www/viewmtn/templates/help.html" # from [261b26dddeecdfc25505dc8bf5ce633c947b54d6] # to [f8faec929ff6f5fd1722240f12a8b398c88fb603] # # patch "www/viewmtn/templates/index.html" # from [08145fdb8457c0f05ead0cdec4717e0f7f0de864] # to [0fb7fc02dd5179e6b4724d4fb96281b6235268ab] # # patch "www/viewmtn/templates/revisioninfo.html" # from [f78b63b0bea3ba8971396d33e0ce9e7b0477847d] # to [18daa6b4c21b0a208fe275530178c26c0c6cd824] # # patch "www/viewmtn/templates/tags.html" # from [51d40e8786b2cdfb99d956a28583be36c49cc078] # to [42d078da8e8619948a5b2ebbe93f6265ef9ab285] # # patch "www/viewmtn/viewmtn.py" # from [f64c08da43d7339e847ed1cddd44939c349bf9e7] # to [1dc5caa196fe72d70fa67baaad7ed79f3a430868] # # set "www/viewmtn/fdo/icontheme.py" # attr "mtn:execute" # value "true" # # set "www/viewmtn/fdo/sharedmimeinfo.py" # attr "mtn:execute" # value "true" # # set "www/viewmtn/genproxy.py" # attr "mtn:execute" # value "true" # # set "www/viewmtn/syntax.py" # attr "mtn:execute" # value "true" # ============================================================ --- www/viewmtn/INSTALL e6b186c3fbe5e1843af38b941f284add5e3ceaa6 +++ www/viewmtn/INSTALL e6b186c3fbe5e1843af38b941f284add5e3ceaa6 @@ -0,0 +1,101 @@ + +Installing ViewMTN +------------------ + +This document briefly describes what is necessary to install ViewMTN +and configure a working installation. + +Dependencies +------------ + +ViewMTN should run on any Unix based operating system - MacOS, +Linux, etc. It has not been ported to Windows, although that port +should be possible. + +Monotone: http://www.venge.net/monotone/ +A version which is descended from [62961c1dc..] is required. +This is post-0.30 + +Python: http://www.python.org/ +A version >= 2.4 is required. + +Cheetah templates: http://www.cheetahtemplate.org/ +Version 0.9.16-1 from Debian is known to work. + +Optional +-------- + +Highlight: http://www.andre-simon.de/ +Version 2.4 is required for highlight work. +Highlight is required if source code is to be shown highlighted. + +Shared Mime Info: http://freedesktop.org/wiki/Software/shared-mime-info +Version 0.19 is known to work, although there is a specification +so older versions should be fine. Most distributions provide this +info. Note that if you install this into a non-standard path, +please export XDG_DATA_DIRS correctly (eg. XDG_DATA_DIRS=/opt/local/share) +Without this package, ViewMTN will only perform extremely basic +MIME type auto-detection. + +Icon Theme: http://www.freedesktop.org/software/icon-theme/ +Any version should be fine. If possible, use a distributor version +of this package (Ubuntu is good!) as it will have a much richer +selection of icons. See later in this file to find out how to +configure ViewMTN to use icon themes. + +Configuring ViewMTN +------------------- + +ViewMTN can run in standalone mode, or under a web server. It is +recommended when first installing to ViewMTN to test it in standalone +mode. + +If you have not already done so, copy "config.py.example" to +"config.py". You will then need to edit "config.py" to suit your +site; there are numerous comments in the file, so this shouldn't be +too hard. + +You're then ready to run ViewMTN; + ./viewmtn.py +If you leave off the argument, ViewMTN will bind to port 8080. +You can access ViewMTN by visiting: + http://localhost:8080/ + +If everything has gone well, you should get the normal ViewMTN front +page - a list of branches in the Monotone database you specified. +If not, look at the output of viewmtn.py on the console; perhaps it +cannot read your monotone database, the path to 'mtn' is wrong, etc. + +Running ViewMTN in a web server +------------------------------- + +ViewMTN is based upon web.py (http://webpy.org/); the installation +instructions of webpy thus work for viewmtn. They are available +here: + http://webpy.infogami.com/install + +The following snippet of configuration is used to configure ViewMTN +on http://viewmtn.angrygoats.net/ (running lighttpd) and is therefore +known to work. You should be able to use it (with adjustment to +suit your site). + + fastcgi.server = ( "/viewmtn.py" => + (( "socket" => "/var/tmp/lighttpd/viewmtn.socket", + "bin-path" => "/home/grahame/mtn/viewmtn/viewmtn.py", + "max-procs" => 8, + "check-local" => "disable", + )) + ) + + ## a static document-root, for virtual-hosting take look at the + ## server.virtual-* options + $HTTP["host"] == "viewmtn.angrygoats.net" { + server.document-root = "/home/grahame/mtn/viewmtn/" + dir-listing.activate = "enable" + + url.rewrite-once = ( + "^/favicon.ico$" => "/static/favicon.ico", + "^/static/(.*)$" => "/static/$1", + "^/(.*)$" => "/viewmtn.py/$1", + ) + } ============================================================ --- www/viewmtn/fdo/__init__.py da39a3ee5e6b4b0d3255bfef95601890afd80709 +++ www/viewmtn/fdo/__init__.py da39a3ee5e6b4b0d3255bfef95601890afd80709 ============================================================ --- www/viewmtn/fdo/icontheme.py 9ec270a56c060a3c63b75a4f7d00c370a831ae29 +++ www/viewmtn/fdo/icontheme.py 9ec270a56c060a3c63b75a4f7d00c370a831ae29 @@ -0,0 +1,131 @@ +#!/usr/bin/env python2.4 + +# an implementation of: +# http://standards.freedesktop.org/icon-theme-spec/icon-theme-spec-latest.html +# (or at least, enough of it to do mime_type->icon file conversion; more could +# be added later if someone was keen..) + +from ConfigParser import ConfigParser +import xdgbasedir +import os + +class IconTheme: + def __init__(self, icon_theme): + self.icon_theme = icon_theme + # list of directories in order, in which to look for files + self.locs = [] + home = os.getenv('HOME') + if home: + self.locs.append(os.path.join(home, '.icons')) + for path in xdgbasedir.xdg_data_dirs(): + self.locs.append(os.path.join(path, 'icons')) + self.locs.append('/usr/share/pixmaps') + self.locs = filter(lambda x: os.access(x, os.R_OK), self.locs) + + # find our index.theme file + index_theme_file = None + for path in self.locs: + ini = os.path.join(path, icon_theme, 'index.theme') + if os.access(ini, os.R_OK): + index_theme_file = ini + break + + if not index_theme_file: + raise Exception("Unable to load index.theme for theme %s" % icon_theme) + + self.cp = cp = ConfigParser() + cp.read(index_theme_file) + it = "Icon Theme" + if not cp.has_section(it): + raise Exception("Theme does not have an 'Icon Theme' section.") + if not cp.has_option(it, "Directories"): + raise Exception("Theme does not specify its directories.") + else: + self.directories = cp.get(it, "Directories").split(',') + if cp.has_option(it, "Inherits"): + self.inherits = cp.get(it, "Inherits").split(',') + elif icon_theme != 'hicolor': + self.inherits = ['hicolor'] + else: + self.inherits = [] + self.inherits = map(IconTheme, self.inherits) + + # most of the time, we'll care about directories by type and + # then by size + self.dir_by_size = {} + for path in self.directories: + context = cp.get(path, 'Context', None) + if not cp.has_option(path, 'Size'): + raise Exception("Directory '%s' has no size." % (path)) + size = cp.get(path, 'Size') + self.dir_by_size.setdefault(context, {}).setdefault(size, []).append(path) + + def lookup(self, icon_name, contexts=None, size=None, accept_extensions=None): + if accept_extensions == None: + accept_extensions = ["png", "xpm", "svg"] + to_scan = [] + if contexts == None: + contexts = self.dir_by_size.keys() + + for context in contexts: + if not self.dir_by_size.has_key(context): + continue + for dir_size in self.dir_by_size[context]: + if size == None or size == dir_size: + for path in self.dir_by_size[context][dir_size]: + for loc in self.locs: + for ext in accept_extensions: + attempt = os.path.join(loc, self.icon_theme, path, icon_name+'.'+ext) + #print "attempt:", attempt + if os.access(attempt, os.R_OK): + #print "success!" + return attempt + + # if we get here, try our parent themes + for it in self.inherits: + rv = it.lookup(icon_name, contexts, size, accept_extensions) + if rv: return rv + return None + +class MimeIcon: + def __init__(self, icon_theme, size=None): + self.icon_theme = icon_theme + self.size = size + self.cache = {} + + def lookup(self, mime_type): + def __lookup(): + def gnome_mime(mime_type): + return "gnome-mime-" + mime_type.replace('/', '-') + # iff we are a inode/ type, then let's look in 'Places' instead + if mime_type.startswith('inode/'): + gnome_name = 'gnome-fs-' + mime_type.split('/')[-1] + rv = self.icon_theme.lookup(gnome_name, contexts=['Places', 'FileSystems'], size=self.size) + if rv: return rv + # stolen from gnome-ui; it's rather unfortunate but there is not a standard + # on how to name mime icons! + # http://cvs.gnome.org/viewcvs/libgnomeui/libgnomeui/gnome-icon-lookup.c + gnome_name = gnome_mime(mime_type) + rv = self.icon_theme.lookup(gnome_name, contexts=['MimeTypes'], size=self.size) + if rv: return rv + # try the x-generic stuff + generic_name = mime_type.split('/')[0] + '-x-generic' + rv = self.icon_theme.lookup(generic_name, contexts=['MimeTypes'], size=self.size) + if rv: return rv + # otherwise, this seems to work some of the time + gnome_name = gnome_mime(mime_type.split('/')[0]) + rv = self.icon_theme.lookup(gnome_name, contexts=['MimeTypes'], size=self.size) + if rv: return rv + # otherwise, otherwise, one of these should work\n + really_fallbacks = [(['Applications'], 'gnome-unknown'), (['MimeTypes'], 'unknown')] + for contexts, icon_name in really_fallbacks: + rv = self.icon_name.lookup(icon_name, contexts=contexts, size=self.size) + if rv: return rv + if not self.cache.has_key(mime_type): + self.cache[mime_type] = __lookup() + return self.cache[mime_type] + +if __name__ == '__main__': + it = IconTheme('gnome') + mi = MimeIcon(it, size="16") + print mi.lookup('inode/directory') ============================================================ --- www/viewmtn/fdo/sharedmimeinfo.py dd993b9e526568246adc535ce4d47d360dd4eb3b +++ www/viewmtn/fdo/sharedmimeinfo.py dd993b9e526568246adc535ce4d47d360dd4eb3b @@ -0,0 +1,295 @@ +#!/usr/bin/env python2.4 + +# written to: +# http://standards.freedesktop.org/shared-mime-info-spec/shared-mime-info-spec-0.13.html + +import xdgbasedir +import os, re, sys, fnmatch + +def mime(): + return [os.path.join(t, 'mime') for t in xdgbasedir.xdg_data_dirs()] + +class GlobLookup(object): + def __init__(self): + self.literal = {} + self.complex = {} + self.extensions = {} + mime_dirs = mime() + for precedence, dir in enumerate(mime_dirs): + mime_file = os.path.join(dir, 'globs') + if os.access(mime_file, os.R_OK): + self.update(precedence, mime_file) + self.complex_by_size = self.complex.keys() + self.complex_by_size.sort(lambda b, a: cmp(len(a), len(b))) + + def update(self, precedence, filename): + literal = re.compile(r'^[^\[\*\?]+$') + simple_glob = re.compile(r'^\*\.([^\[\*\?]+)$') + for line in (t.rstrip('\n') for t in open(filename)): + if line.startswith('#'): + continue + mimetype, glob = line.split(':') + if literal.match(glob): + self.add_literal(precedence, glob, mimetype) + continue + m = simple_glob.match(glob) + if m: + self.add_extension(precedence, m.groups()[0], mimetype) + else: + self.add_complex(precedence, glob, mimetype) + + def add_literal(self, precedence, glob, mimetype): + self.literal.setdefault(glob, []).append((precedence, mimetype)) + + def add_extension(self, precedence, extension, mimetype): + self.extensions.setdefault(extension, []).append((precedence, mimetype)) + + def add_complex(self, precedence, glob, mimetype): + self.complex.setdefault(glob, []).append((precedence, mimetype)) + + def __lookup(self, filename): + # increasing; lower is higher priority + precedence_sort = lambda a,b: cmp(a[0], b[0]) + def best_of(match_list): + match_list.sort(precedence_sort) + return match_list[0][1] + if self.literal.has_key(filename): + return best_of(self.literal[filename]) + for complex in self.complex_by_size: + if fnmatch.fnmatch(filename, complex): + return best_of(self.complex[complex]) + # find potential extensions in filename + def extensions(): + ext = filename + while ext: + idx = ext.find('.') + if idx == -1: + break + ext = ext[idx+1:] + yield ext + for extension in extensions(): + if self.extensions.has_key(extension): + return best_of(self.extensions[extension]) + return None + + def lookup(self, filename): + rv = self.__lookup(filename) + if rv: return rv + filename = filename.lower() + return self.__lookup(filename) + +class MagicLookup(object): + def __init__(self): + # hashed by priority, then by mime type, then a list of headers + # (multiple files could easily have conflicts otherwise) + self.headers = {} + mime_dirs = mime() + for dir in mime_dirs: + magic_file = os.path.join(dir, 'magic') + if os.access(magic_file, os.R_OK): + self.update(magic_file) + + def update(self, fname): + fd = open(fname, 'rb') + if fd.readline() != 'MIME-Magic\0\n': + raise Exception("Not a Mime Magic file: %s" % fname) + + header_re = re.compile(r'^\[([0-9]+):([^\]]+)\]$') + value_re = re.compile(r'^([0-9]*)>([0-9]+)=') + options_re = re.compile(r'^(\~[0-9]+)?(\+[0-9]+)?$') + + # read a header, followed by a number of lines + self.__buf = '' + + def skip_line(): + nl = self.__buf.find('\n') + if nl == -1: + self.__buf = '' + else: + self.__buf = self.__buf[nl+1:] + + def read_header(): + m = header_re.match(self.__buf) + if not m: + skip_line() + else: + self.__buf = self.__buf[m.end()+1:] + priority, mime_type = m.groups() + return int(priority), mime_type + + def read_line(): + # the next line will have indent, start_offset and the + # start of the value + m = value_re.match(self.__buf) + if not m: + skip_line() + return + indent, start_offset = m.groups() + try: indent = int(indent) + except: indent = 0 + try: start_offset = int(start_offset) + except: start_offset = 0 + self.__buf = self.__buf[m.end():] + # the next two bytes are the length, in big-endian format + length = (ord(self.__buf[0]) << 8) + ord(self.__buf[1]) + self.__buf = self.__buf[2:] + # read the remainder of the value + to_read = length - len(self.__buf) + if to_read > 0: + self.__buf += fd.read(to_read) + value, self.__buf = self.__buf[:length], self.__buf[length:] + # is the next thing a mask? + if len(self.__buf) == 0: + self.__buf += fd.read(1) + if self.__buf[0] == '&': + self.__buf = self.__buf[1:] + to_read = length - len(self.__buf) + if to_read > 0: + self.__buf += fd.read(to_read) + mask, self.__buf = self.__buf[:length], self.__buf[length:] + else: + mask = '\xff' * length + # anything remaining will end in a newline; so readline() + # does what we want. see whether or not we need to call it.. + if len(self.__buf) == 0 or self.__buf[-1] != '\n': + self.__buf += fd.readline() + word_size, range_length = 1, 1 + m = options_re.match(self.__buf) + if m: + for group in m.groups(): + if not group: + continue + if group[0] == '~': + word_size = int(group[1:]) + elif group[0] == '+': + range_length = int(group[1:]) + self.__buf = self.__buf[m.end()+1:] + # fix the byte order, on little-endian systems + if sys.byteorder == 'little' and word_size > 1: + if len(value) % word_size != 0: + raise Exception("value is not an integer multiple of word size!") + # make your sanity save now! + fix = lambda x: ''.join([''.join(reversed(value[t:t+word_size])) for t in xrange(0,len(x)/word_size,word_size)]) + value = fix(value) + mask = fix(mask) + return { 'indent' : indent, + 'start_offset' : start_offset, + 'value' : value, + 'mask' : mask, + 'range_length' : range_length, + 'word_size' : word_size } + + current_header = None + while True: + if self.__buf == '': + nd = fd.readline() + if not nd: + break + self.__buf += nd +# print "loop:", repr(self.__buf) + if self.__buf[0] == '[': + priority, mime_type = read_header() + current_header = [] + self.headers.setdefault(priority, []).append((mime_type, current_header)) + line_stack = [] + else: + if current_header == None: + raise Exception("non-header before header!") + line = read_line() + current_header.append(line) + + def lookup(self, data, priorities=None): + def match_line(line): + data_size = len(data) + value_size = len(line['value']) + + def match_with(data_chunk): + if line['mask'] != None: + masked = ''.join([chr(ord(t) & ord(line['mask'][i])) for i, t in enumerate(data_chunk)]) + else: + masked = data_chunk + if masked == line['value']: + return True + + for i in range(line['range_length']): + from_offset = line['start_offset'] + i + to_offset = from_offset + value_size + if to_offset > data_size: + continue + if match_with(data[from_offset:to_offset]): + return True + + def match_lines(lines): + # we need to maintain a current indent depth; we don't need to + # actually care what our parent was, as if we made it to checking + # we have necessarily succeeded + depth = -1 + length = len(lines) + for idx, line in enumerate(lines): + indent = line['indent'] + if indent > depth+1: + continue + if match_line(line): + if (idx+1 == length) or lines[idx+1]['indent'] <= indent: + # this is a match by itself + return True + depth = indent + elif indent <= depth: + depth = indent - 1 + + if not priorities: + priorities = self.priorities() + for priority in priorities: + for mime_type, lines in self.headers[priority]: + if match_lines(lines): + return mime_type + return None + + def priorities(self): + rv = self.headers.keys() + rv.sort() + rv.reverse() + return rv + +class LookupHelper: + def __init__(self): + self.glob_lookup = GlobLookup() + self.magic_lookup = MagicLookup() + nontext_chars = "\x01\x02\x03\x04\x05\x06\x0e\x0f"\ + "\x10\x11\x12\x13\x14\x15\x16\x17"\ + "\x18\x19\x1a\x1c\x1d\x1e\x1f" + self.nontext = {} + for char in nontext_chars: + self.nontext[char] = True + + def is_binary(self, str): + for char in str: + if self.nontext.has_key(char): + return True + return False + + def lookup(self, filename, data): + # spec says we try >= 80 priority magic matchers, then filename, then the other matchers + threshold = 80 + priorities = self.magic_lookup.priorities() + rv = self.magic_lookup.lookup(data, [t for t in priorities if t >= threshold]) + if rv != None: + return rv + # then try guessing from filename + rv = self.glob_lookup.lookup(filename) + if rv != None: + return rv + # then try the other magic matchers + rv = self.magic_lookup.lookup(data, [t for t in priorities if t < threshold]) + if rv != None: + return rv + # okay; fall-back behaviour is to return text/plain iff ! is_binary, + # otherwise application/octet-stream + if self.is_binary(data): + return 'application/octet-stream' + else: + return 'text/plain' + +if __name__ == '__main__': + c = LookupHelper() + print c.lookup('test.tar', '') ============================================================ --- www/viewmtn/fdo/xdgbasedir.py 864ac96a21a2fe65c401a7beaf2c98513795751d +++ www/viewmtn/fdo/xdgbasedir.py 864ac96a21a2fe65c401a7beaf2c98513795751d @@ -0,0 +1,50 @@ +#!/usr/bin/env python + +# +# An implementation of the XDG Base Directory Specification +# http://standards.freedesktop.org/basedir-spec/latest/ +# + +import os + +def xdg_data_home(): + rv = os.getenv('XDG_DATA_HOME') + if rv: + return rv + home = os.getenv('HOME') + if home: + return os.path.join(home, ".local", "share") + else: + raise Exception("Unable to determine xdg_data_home") + +def xdg_config_home(): + rv = os.getenv('XDG_CONFIG_HOME') + if rv: + return rv + home = os.getenv('HOME') + if home: + return os.path.join(home, ".config") + else: + raise Exception("Unable to determine xdg_config_home") + +def xdg_data_dirs(): + dirs = [xdg_data_home()] + for dir in os.getenv('XDG_DATA_DIRS', '/usr/local/share:/usr/share').split(':'): + dirs.append(dir) + return dirs + +def xdg_config_dirs(): + dirs = [xdg_config_home()] + for dir in os.getenv('XDG_DATA_DIRS', '/etc/xdg').split(':'): + dirs.append(dir) + return dirs + +def xdg_cache_home(): + rv = os.getenv('XDG_CACHE_HOME') + if rv: + return rv + home = os.getenv('HOME') + if home: + return os.path.join(home, ".cache") + else: + raise Exception("Unable to determine xdg_cache_home") ============================================================ --- www/viewmtn/genproxy.py 408f46a3f5fe0d792eb62e92a8faaf5c28c67a54 +++ www/viewmtn/genproxy.py 408f46a3f5fe0d792eb62e92a8faaf5c28c67a54 @@ -0,0 +1,25 @@ +#!/usr/bin/env python + +class GeneratorProxy(object): + def __init__(self, generator): + self.generator = generator + def __iter__(self): + return self + def next(self): + return self.generator.next() + +class Seedy(GeneratorProxy): + def __del__(self): + print "testing" + +def test(): + yield 2 + yield 3 + yield 4 + +if __name__ == '__main__': + a = test() + b = Seedy(test()) + for i in b: + print i + ============================================================ --- www/viewmtn/static/highlight.css 45cedf6720b40c8e1cb9283d79e1929cb4e317c8 +++ www/viewmtn/static/highlight.css 45cedf6720b40c8e1cb9283d79e1929cb4e317c8 @@ -0,0 +1,20 @@ +/* Style definition file generated by highlight 2.4.6, http://www.andre-simon.de/ */ + +/* Highlighting theme definition: */ + +body.hl { background-color:#ffffff; } +pre.hl { color:#000000; background-color:#ffffff; font-size:10pt; font-family:Courier;} +.num { color:#2928ff; } +.esc { color:#ff00ff; } +.str { color:#ff0000; } +.dstr { color:#818100; } +.slc { color:#838183; font-style:italic; } +.com { color:#838183; font-style:italic; } +.dir { color:#008200; } +.sym { color:#000000; } +.line { color:#555555; } +.kwa { color:#000000; font-weight:bold; } +.kwb { color:#830000; } +.kwc { color:#000000; font-weight:bold; } +.kwd { color:#010181; } + ============================================================ --- www/viewmtn/syntax.py cf93e6e6a6166204daaae6ec50819ba175fa3ed1 +++ www/viewmtn/syntax.py cf93e6e6a6166204daaae6ec50819ba175fa3ed1 @@ -0,0 +1,91 @@ +#!/usr/bin/env python2.4 + +from common import set_nonblocking, terminate_popen3 +from web import debug +import config +import popen2 +import select +import cgi + +mime_to_lang = { + +} + +def highlight(*args, **kwargs): + # if we don't have a highlighter, let's just return the original + # iterator + if not hasattr(config, "highlight_command"): + return __basic_highlight(*args, **kwargs) + else: + return __highlight(*args, **kwargs) + +def __basic_highlight(lines, language='py'): + for line in lines: + yield cgi.escape(line.rstrip('\n')) + +def __highlight(lines, language='py'): + """A generator which will read lines from the given input stream, and yield them back + in syntax highlighted, HTML format. """ + + # trickier than this initially looks; the syntax highlighter might not necessarily + # have any more data for us, so it's necessary to use select() and write new data + # as it wants it, and read it back (buffer and yield lines) as possible. + # + # this assumes the 'highlight' command, although shouldn't take much work to have this + # happy with other highlighters. + + fromchild_buf, tochild_buf = '', '' + to_run = [config.highlight_command, '--syntax', language, '-c', '/dev/null', '--quiet', '--xhtml', '--force', '--anchors', '--fragment'] + + process = popen2.Popen3(to_run, capturestderr=True) + map (set_nonblocking, [ process.fromchild, + process.tochild, + process.childerr ]) + while True: + r_fds = [process.fromchild] + w_fds = [] + if not process.tochild.closed: + w_fds.append(process.tochild) + r_stdin, r_stdout, r_stderr = select.select(r_fds, w_fds, [], None) + # debug ((r_stdin, r_stdout, r_stderr)) + if not r_stdin and not r_stdout and not r_stderr: + break + if process.fromchild in r_stdin: + data = process.fromchild.read() + if data == "": + break + fromchild_buf += data + + if process.tochild in r_stdout: + if tochild_buf == '': + try: + tochild_buf += lines.next() + except StopIteration: + tochild_buf = '' + if tochild_buf == '': + process.tochild.close() + else: + process.tochild.write(tochild_buf) + tochild_buf = '' + + if fromchild_buf != '': + while True: + idx = fromchild_buf.find('\n') + if idx == -1: + break + yield fromchild_buf[:idx] + fromchild_buf = fromchild_buf[idx+1:] + + # anything left over (presumably without a newline) + if fromchild_buf != '': + yield fromchild_buf + + terminate_popen3(process) + +if __name__ == '__main__': + def l(): + fd = open('syntax.py') + for line in fd: + yield line + for i in highlight(l()): + print i ============================================================ --- www/viewmtn/templates/branch.html 9b84a47baffe133624433db124e92d70a206bb6a +++ www/viewmtn/templates/branch.html 9b84a47baffe133624433db124e92d70a206bb6a @@ -0,0 +1,11 @@ +#extends base + +#def extramenu +Branch $branch.name: +Changes | +Head revision +#end def + +#def rssheaders + +#end def ============================================================ --- www/viewmtn/templates/branchchanges.html 84dfe05fe66d988e3cdf426da5e0b339ed5d2df2 +++ www/viewmtn/templates/branchchanges.html 84dfe05fe66d988e3cdf426da5e0b339ed5d2df2 @@ -0,0 +1,70 @@ +#extends branch + +#def body + +

+Changes $from_change to $to_change on this branch are displayed below, sorted in descending chronological order.
+

+ + + +#for $revision, $diffs, $ago, $author, $changelog, $shortlog, $when in $display_revs + + + + + + + + + + + + +#end for +
+#filter Filter + $ago ago: $shortlog
+ + $link($revision).html("revision info") | + $link($revision, "browse").html("browse files") + $diffs + +#end filter +
Author:$author
Changelog: +#filter Filter +$changelog +#end filter +
Date:$when
+ + + + + + + + + +
+#if $next_from and $next_to +#filter Filter +$link($branch, from_change=$next_from, to_change=$next_to).html("earlier changes") +#end filter +#else +(no earlier changes) +#end if + +#filter Filter +$link($branch).html("recent changes") +#end filter + +#if $previous_from and $previous_to +#filter Filter +$link($branch, from_change=$previous_from, to_change=$previous_to).html("later changes") +#end filter +#else +(no later changes) +#end if +
+ +#end def ============================================================ --- www/viewmtn/templates/branchchangesrss.html 815fa5b5b1d06136d19fbf83b3c9f0c521d7bb05 +++ www/viewmtn/templates/branchchangesrss.html 815fa5b5b1d06136d19fbf83b3c9f0c521d7bb05 @@ -0,0 +1,23 @@ + + + en-us + + Changes to branch $branch.name + $dynamic_join('/') + Changes to branch $branch.name + + +#for $revision, $diffs, $ago, $author, $changelog, $shortlog, $when in $display_revs + +#filter Filter + $link($revision).uri() + $shortlog + $changelog +#end filter + $author + $when +0000 + +#end for + + + ============================================================ --- www/viewmtn/templates/branchchoosehead.html 67a2b7d951933ad3763747eb7bcb3fae02500c49 +++ www/viewmtn/templates/branchchoosehead.html 67a2b7d951933ad3763747eb7bcb3fae02500c49 @@ -0,0 +1,27 @@ +#extends branch + +#def body + +

+There are multiple head revisions of the branch +#filter Filter +$link($branch).html() +#end filter +. You can access the method +'$proxy_to' on each of these revisions by clicking on the links provided below. If you are +attempting to access this method in a script, perhaps consider using this +#filter Filter +$anyhead +#end filter +which will always go directly to one of the head revisions. +

+ + + +#end def ============================================================ --- www/viewmtn/templates/revisionbrowse.html 66d38381f187bc229bccd5489ad81693460b68e9 +++ www/viewmtn/templates/revisionbrowse.html 66d38381f187bc229bccd5489ad81693460b68e9 @@ -0,0 +1,57 @@ +#extends revision + +#def body + +#filter Filter +

+Current directory: +#for $l in $path_links +$link($l).html() +#end for +

+ +These files are in a revision of +#if len($branches) == 1 +branch +#end if +#if len($branches) > 1 +branches +#end if +#if $branches +$branch_links +#end if +

+#end filter + + + +#for $stanza_type, $this_path, $author, $ago, $content_mark, $shortlog, $mime_type in $entries + + + + + + + +#end for +
NameAgeAuthorLast log entry
+ $mime_type + + #filter Filter + $link($this_path).html() + #end filter + + #if $content_mark + #filter Filter + $link($content_mark).html($ago, True) + #end filter + #end if + + #filter Filter + $link($author).html() + #end filter + + $shortlog +
+ +#end def ============================================================ --- www/viewmtn/templates/revisiondiff.html 0cfa3372c64f3e1dc8f6112afff98281feb88b07 +++ www/viewmtn/templates/revisiondiff.html 0cfa3372c64f3e1dc8f6112afff98281feb88b07 @@ -0,0 +1,29 @@ +#extends revision + +#def body + + +#filter Filter +

+The unified diff between revisions $link($revision_from).html() and $link($revision_to).html() is displayed below. +

+#end filter + +#if $files +

+This diff has been restricted to the following files: + #for $fname in $files + '$fname' + #end for +

+#end if + +
+#filter Filter
+#for $line in $diff
+$line
+#end for
+#end filter
+
+ +#end def ============================================================ --- www/viewmtn/templates/revisionfile.html 8d316cefa1d01ec52d0e42a923a7736a46efda0d +++ www/viewmtn/templates/revisionfile.html 8d316cefa1d01ec52d0e42a923a7736a46efda0d @@ -0,0 +1,17 @@ +#extends revision + +#def body + +

+Below is the file '$filename.name' from this revision. You can also +#filter Filter +$link($filename, for_download=True).html(override_description="download the file"). +#end filter +

+ +#block filecontents + +#end block + +#end def + ============================================================ --- www/viewmtn/templates/revisionfilebin.html ed073e34de5e16ad7ed0d14ce01bff80b0d78970 +++ www/viewmtn/templates/revisionfilebin.html ed073e34de5e16ad7ed0d14ce01bff80b0d78970 @@ -0,0 +1,10 @@ +#extends revisionfile + +#def filecontents +

+Unfortunately, this ViewMTN has determined that this file (with +MIME type $mimetype) is not suitable for display inside the web +browser. If you feel this file could have been better displayed +please inform the author. +

+#end def ============================================================ --- www/viewmtn/templates/revisionfileimg.html cd246dd75333b2ced5d8dd9216e0d50a16769dfd +++ www/viewmtn/templates/revisionfileimg.html cd246dd75333b2ced5d8dd9216e0d50a16769dfd @@ -0,0 +1,5 @@ +#extends revisionfile + +#def filecontents + +#end def ============================================================ --- www/viewmtn/templates/revisionfileobj.html aecf0edca364646c23f9fecfb764159a73b4d7d1 +++ www/viewmtn/templates/revisionfileobj.html aecf0edca364646c23f9fecfb764159a73b4d7d1 @@ -0,0 +1,11 @@ +#extends revisionfile + +#def filecontents + +Your browser doesn't support viewing content of type $mimetype. Use the download +link from above instead. + +#end def ============================================================ --- www/viewmtn/templates/revisionfiletxt.html c15b907d1bf28ed74a6835cb5dc775241432e958 +++ www/viewmtn/templates/revisionfiletxt.html c15b907d1bf28ed74a6835cb5dc775241432e958 @@ -0,0 +1,12 @@ +#extends revisionfile + +#def filecontents +
+#filter Filter
+#for $line in $contents
+$line
+#end for
+#end filter
+
+#end def + ============================================================ --- www/viewmtn/ChangeLog c16ac2013e466b7eafcdf54b808052056d2f3e89 +++ www/viewmtn/ChangeLog a90102fbf7b5d7be6c5d553f362389c465267832 @@ -1,3 +1,9 @@ +2006-10-26 Grahame Bowland + + * release 0.06 + * almost total rewrite + * see INSTALL for more details + 2005-11-21 Grahame Bowland * release 0.05 ============================================================ --- www/viewmtn/TODO 6372d06a8696794b11e1f5dece588de4d1057896 +++ www/viewmtn/TODO d57f9d2d4d2acd8412cb3d9c090f424159eecb6a @@ -1,7 +1,21 @@ +NEW VERSION + + * Fix the RSS date fields + * Highlight -> content_type mapping (works at the moment, somehow) + * JSON + * Unit tests + * Read-only WebDAV support + * Put copyright notices on the files, mostly so I can keep track + of where they end up. + * remove the leading path from subdirectories in file browser + * page showing the tags for a given branch + * client side reorderable tables using JS + BUGS: - * \n in title of the ancestry graph + * HEAD just does GET; we should really do HEAD properly. Also, we + should start issuing eTags TODO: @@ -20,16 +34,6 @@ TODO: which updates with the commands that have been run - useful for debugging, also useful for beginners to see how to do stuff. - * Use monotone automate graph to do the ancestry graphing, and show some - future information. Perhaps cache based on the mtime of the db viewmtn - is looking at? Might be good enough. - - Show dotted lines for propogates (with that information on the arc) - - Show lines leading off from the boxes at either end of the graph (if - applicable) to give context that the image being shown is part of a - large overall graph. - * Show information when mousing over long hex strings. * Magically make http:// ftp:// etc links inside files clickable @@ -39,20 +43,10 @@ TODO: explain what's happened. For bonus points, index into the diff so you can click on a file that changed and jump to that section in the diff. - * Improve the file listing; use data from the new rosters branch - to get a better idea of when a given file was last touched. - * When viewing a file, give a list of revisions in which that file was recently changed. Include in the list a link to diff with each revision. - * generally clean up table formatting code; things like - list_of_branches() - list_of_tags() - revision_certs() - revision_details() - use a generic method to output the tables - * from [mrb] support for multiple databases (perhaps some sort of dropdown to say which database you want to look in) - perhaps also make the branches page show the @@ -80,10 +74,3 @@ AJAX ideas: * we need some concept of selecting two points revisions for diffs or other comparison. This is the main strength ViewCVS seems to have over us. -LONG TERM: - - * provide some option for people without apache2 / mod_python to run the - thing; even if it's running the program from a standalone python webserver. - Would settle for a solution that required a cgi capable webserver rather than - specifically mod_python (and thus apache2) while still supporting mod_python - acceleration if present. ============================================================ --- www/viewmtn/common.py 19a25a6ea138661ce01454ec58c1ea20fcd884aa +++ www/viewmtn/common.py b5a9eb0d651eb4f1a41e94376d35b31f5c8efe5d @@ -1,239 +1,48 @@ import datetime import datetime -import urllib -import pydoc import time +import fcntl +import os +import signal +from web import debug +import traceback -escape_function = pydoc.HTMLRepr().escape - -def type_wrapper(e, x): - if x == None: - return "" - elif type(x) == type([]): - return '
'.join(map(e, x)) - else: - return e(x) - def parse_timecert(value): return apply(datetime.datetime, time.strptime(value, "%Y-%m-%dT%H:%M:%S")[:6]) -def get_branch_links(mt, branches): - if len(branches) > 1: - branch_links = "branches " - else: - branch_links = "branch " - links = [] - for branch in branches: - links.append(link(mt, "branch", branch)) - return branch_links + ', '.join(links) +def set_nonblocking(fd): + fl = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NDELAY) -def extract_cert_from_certs(certs, certname, as_list=False): - rv = [] - for cert in certs: - name, value = None, None - for k, v in cert: - if k == "name": name = v - elif k == "value": value = v - if name == None or value == None: continue - if name == certname: - if not as_list: - return value - else: - rv.append(value) - return rv +def terminate_popen3(process): + debug("[%s] stopping process: %s" % (os.getpid(), process.pid)) + try: + process.tochild.close() + process.fromchild.close() + process.childerr.close() + if process.poll() == -1: + # the process is still running, so kill it. + os.kill(process.pid, signal.SIGKILL) + process.wait() + except: + debug("%s failed_to_stop %s (%s)" % (os.getpid(), process.pid, traceback.format_exc())) -def determine_date(certs): - dateval = extract_cert_from_certs(certs, "date") - if dateval == None: - return None - else: - return parse_timecert(dateval) - -def quicklog(value): - hq = html_escape() - rv = hq(value.strip().split('\n')[0]) - if rv.startswith('*'): - rv = rv[1:].strip() - return rv - -def ago_string(event, now): +def ago(event): def plural(v, singular, plural): - if v == 1: - return "%d %s" % (v, singular) - else: - return "%d %s" % (v, plural) + if v == 1: + return "%d %s" % (v, singular) + else: + return "%d %s" % (v, plural) now = datetime.datetime.utcnow() ago = now - event if ago.days > 0: - rv = "%s, %s" % (plural(ago.days, "day", "days"), - plural(ago.seconds / 3600, "hour", "hours")) + rv = "%s" % (plural(ago.days, "day", "days")) elif ago.seconds > 3600: hours = ago.seconds / 3600 minutes = (ago.seconds - (hours * 3600)) / 60 - rv = "%s, %s" % (plural(hours, "hour", "hours"), - plural(minutes, "minute", "minutes")) + rv = "%s" % (plural(hours, "hour", "hours")) else: minutes = ago.seconds / 60 seconds = (ago.seconds - (minutes * 60)) - rv = "%s, %s" % (plural(minutes, "minute", "minutes"), - plural(seconds, "second", "seconds")) + rv = "%s" % (plural(minutes, "minute", "minutes")) return rv - -def link(mt, link_type, link_to, description = None, no_quote = False): - hq = html_escape() - if not no_quote and description != None: description = hq(description) - if link_type == "revision": - rv = '' % (urllib.quote(link_to)) - if description != None: rv += description - else: rv += hq(link_to[:8]) + ".." - rv += '' - if description == None: rv = '[' + rv + ']' - elif link_type == "diff" or link_type == "download_diff": - link_to = map(urllib.quote, filter(lambda x: x != None, link_to)) - if link_type == "diff": - handler = "diff.psp" - else: - handler = "getdiff.py" - uri = '%s?id1=%s&id2=%s' % (handler, link_to[0], link_to[1]) - if len(link_to) == 3: - uri += '&fname=%s' % (link_to[2]) - rv = '' - if description != None: rv += description - else: rv += "diff" - rv += '' - elif link_type == "download": - if type(link_to) == type([]): - rv = '' % (urllib.quote(link_to[0]), - urllib.quote(link_to[1])) - link_id = link_to[0] - else: - rv = '' % (urllib.quote(link_to)) - link_id = link_to - if description != None: rv += description + "" - else: rv = "[" + rv + hq(link_id[:8]) + ".." + "]" - elif link_type == "file": - revision_id, path = link_to - rv = '' % (urllib.quote(revision_id), - urllib.quote(path)) - if description != None: rv += description + "" - else: rv = "[" + rv + hq(path + '@' + revision_id[:8]) + ".." + "]" - elif link_type == "fileinbranch": - branch, path = link_to - rv = '' % (urllib.quote(branch), - urllib.quote(path)) - if description != None: rv += description + "" - else: rv = "[" + rv + hq(path + '@' + branch) + "]" - elif link_type == "branch": - rv = '' % (urllib.quote(link_to)) - if description != None: rv += description - else: rv += hq(link_to) - rv += '' - elif link_type == "tar": - rv = '' % (urllib.quote(link_to)) - if description != None: rv += description - else: rv = "tar of [" + rv + hq(link_to[:8]) + "..]" + "]" - rv += '' - elif link_type == "headofbranch": - rv = '' % (urllib.quote(link_to)) - if description != None: rv += description - else: rv += "head of " + hq(link_to) - rv += '' - elif link_type == "manifest": - if type(link_to) == type([]): - link_to, path = link_to - rv = '' % (urllib.quote(link_to), urllib.quote(path)) - else: - rv = '' % (urllib.quote(link_to)) - if description != None: rv += description - else: rv += hq(link_to[:8]) + ".." - rv += '' - if description == None: rv = '[' + rv + ']' - else: - rv = 'Unknown link type: %s' % (hq(link_type)) - return '%s' % (hq(link_type+'Link'), rv) - -def html_escape(): - "returns a function stolen from pydoc that can be used to escape HTML" - return lambda x: type_wrapper(escape_function, x) - -from enscriptlangs import enscript_langs -from utility import run_command -import mimetypes -import config -import pipes - -# is it binary? -def is_binary(str): - nontext_chars = "\x01\x02\x03\x04\x05\x06\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1c\x1d\x1e\x1f" - check = {} - for char in nontext_chars: - check[char] = True - for i in str: - if check.has_key(i): return True - return False - -# hm, later on might make this be some javascript that does an call back to the server. -# then could have a pull down to let people choose which enscript encoding to use, and -# just update the DOM with the new data. -def colourise_code(req, hq, path, contents, filter=None): - mime_type = mimetypes.guess_type(path)[0] - if mime_type == None: mime_type = 'text/plain' - if mime_type == 'image/png' or mime_type == 'image/jpeg' or mime_type == 'image/gif': - display_as_image = True - else: display_as_image = False - - # okay; can we guess a valid enscript filter to run this through? - tsp = mime_type.split('/', 1) - if filter == None and tsp[0] == 'text': - candidate = tsp[1] - if candidate.startswith('x-'): candidate = candidate[2:] - if candidate.endswith('src'): candidate = candidate[:-3] - if candidate.endswith('hdr'): candidate = candidate[:-3] - if candidate == 'c++': candidate = 'cpp' # ugly - if candidate in enscript_langs: filter = candidate - if filter == None: - # heh, will at least work for lua files - last_dot = path.rfind('.') - if last_dot == -1: last_dot = 0 - candidate = path[last_dot:] - if candidate in enscript_langs: filter = candidate - - # if no filter then let's check if it's binary or not; if not binary - # we'll just treat it as text; otherwise display a warning and a download - # link - if filter == None and not is_binary(contents): - filter = 'text' - - req.write('''
''') - if display_as_image: - req.write('''') - def stop_code(): - req.write('') - def text(): - start_code() - req.write(hq(contents)) - stop_code() - def enscript(): - command = config.enscript_path + ' -o - --color --language=html' - command += ' --highlight=%s' % (pipes.quote(filter)) - result = run_command(command, to_child=contents) - if result['exitcode'] != 0: - raise Exception('Error running enscript (%s) : "%s".' % (hq(command), hq(result['childerr']))) - in_contents = False - for line in result['fromchild'].split('\n'): - if line.startswith('
'):
-		    in_contents = True
-		    start_code()
-		elif line.startswith('
'): - in_contents = False - stop_code() - elif in_contents: - req.write(line + '\r\n') - if filter == "text": text() - else: enscript() - else: - req.write('''

This file seems to binary and not suitable for display in the browser. You must %s the file and use a suitable viewer.

''' % (link("download", [matching_file_id, path], "download"))) - req.write('''
''') ============================================================ --- www/viewmtn/config.py.example 9d8013696bf352853a5b9a9bce70386e9f8edb96 +++ www/viewmtn/config.py.example 343449d29b7b0e04c1e86f034c27a48bed094d5e @@ -9,11 +9,6 @@ # If config changes are not picked up, reloading # the web server should solve the issue. # -# If you want to run multiple viewmtn installs from -# a single apache server, you might want to look at -# giving them seperate python interpreter instances, -# ie set PythonInterpreter viewmtn1, viewmtn2 etc -# in .htaccess. import sys import os @@ -42,13 +37,13 @@ for s in file_tokens(conffile): lastkey = s config_data[lastkey] = [] - -# the base URL of this install 'http://%s%s/' % (req.hostname, os.path.dirname(req.uri))# -def base_url(uri): +def dynamic_uri_path(uri): d = os.path.dirname(os.path.dirname(uri)) - return (config_data['base_url'][0] + '/projects/%s/viewmtn/') % os.path.basename(d) + return config_data['base_url'][0] + ('/projects/%s/viewmtn/' % os.path.basename(d)) +def static_uri_path(uri): + return config_data['base_url'][0] + '/viewmtn/static/' -# the path to the 'monotone' binary +# the path to the 'mtn' binary monotone = config_data['monotone'][0] # the monotone database to be shared out @@ -61,37 +56,52 @@ gnome_mimetype_icon_path = config_data[' # where to find GNOME icons (used in manifest listing) gnome_mimetype_icon_path = config_data['www_dir'][0] + '/viewmtn/mimetypes/' #'/usr/share/icons/gnome/' +gnome_mimetype_icon_path = '/Users/grahame/mtn/viewmtn/mimetypes/' # and where they are on the web gnome_mimetype_uri = config_data['base_url'][0] + '/viewmtn/mimetypes/' -# where to find GNU enscript -enscript_path = '/usr/bin/enscript' +# highlight from http://andre-simon.de/ +# if you don't have this available, just comment +# the "highlight_command" line out +highlight_command = '/opt/local/bin/highlight' graphopts = { - # a directory (must be writable by the web user) - # in which viewmtn can output graph files - # (you should set up a cronjob to delete old ones - # periodically) + # a directory (must be writable by the web user) + # in which viewmtn can output graph files + # (you should set up a cronjob to delete old ones + # periodically) 'directory' : config_data['graph_dir'][0], - # a URL, relative or absolute, at which the files - # in the 'graphdir' directory can be found. Should - # end in a '/' character - 'uri' : 'graph/', + # a URL, relative or absolute, at which the files + # in the 'graphdir' directory can be found. Should + # end in a '/' character + 'uri' : 'graph/', - # the path to the 'dot' program - 'dot' : '/usr/bin/dot', + # the path to the 'dot' program + 'dot' : '/opt/local/bin/dot', - # options to use for nodes in the dot input file - # we generate. - 'nodeopts' : { 'fontname' : 'Windsor', - 'fontsize' : '8', - 'shape' : 'box', - 'height' : '0.3', - 'spline' : 'true', - 'style' : 'filled', - 'fillcolor' : '#dddddd' } + # options to use for nodes in the dot input file + # we generate. + 'nodeopts' : { 'fontname' : 'Monaco', + 'fontsize' : '8', + 'shape' : 'box', + 'height' : '0.3', + 'spline' : 'true', + 'style' : 'filled', + 'fillcolor' : '#dddddd' } } +# Icon Theme to use for icons; 'gnome' is a safe value, +# as is 'hicolor'. Note that icon_size must be a string, +# not an integer +icon_theme = 'gnome' +icon_size = '16' +# for tests/ +# don't worry about these unless you are going to run +# the tests +test_branch = "net.angrygoats.viewmtn" +#test_revision = "53b7a6866f0f7268a8eb721e8d74688de8567fb8" +test_revision = "ea14ea3aadb3a02ffe5041e0a98db15306cbcd81" + ============================================================ --- www/viewmtn/mtn.py 1f4c058e4f55b848c1c8195a38e6edaeb86310b6 +++ www/viewmtn/mtn.py 10bae73eaa4b6b891d434bfcffa8ca66968b204b @@ -6,19 +6,20 @@ import popen2 import select import threading import popen2 +from common import set_nonblocking, terminate_popen3 +from traceback import format_exc +import genproxy -debugging = True - -debug = None - import web -debug = web.debug +from web import debug # regular expressions that are of general use when # validating monotone output def group_compile(r): return re.compile('('+r+')') +hex_re = r'[A-Fa-f0-9]*' +hex_re_c = group_compile(hex_re) revision_re = r'[A-Fa-f0-9]{40}' revision_re_c = group_compile(revision_re) name_re = r'^[\S]+' @@ -29,32 +30,22 @@ class Revision(str): class Revision(str): def __init__(self, v): - str.__init__(v) - if not revision_re_c.match(self): - raise MonotoneException("Not a valid revision ID") + # special case that must be handled: empty (initial) revision ID '' + str.__init__(v) + self.obj_type = "revision" + if v != '' and not revision_re_c.match(self): + raise MonotoneException("Not a valid revision ID: %s" % (v)) def abbrev(self): - return '[' + self[:8] + '..]' + return '[' + self[:8] + '..]' -def set_nonblocking(fd): - fl = fcntl.fcntl(fd, fcntl.F_GETFL) - fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NDELAY) - -def terminate_popen3(process): - debug("%s stopping %s" % (os.getpid(), process.pid)) - try: - process.tochild.close() - process.fromchild.close() - process.childerr.close() - if process.poll() == -1: - # the process is still running, so kill it. - os.kill(process.pid, signal.SIGKILL) - process.wait() - except: - debug("%s failed_to_stop %s" % (os.getpid(), self.process.pid)) - +class Author(str): + def __init__(self, v): + str.__init__(v) + self.obj_type = "author" + class Runner: def __init__(self, monotone, database): - self.base_command = [monotone, "--db=%s" % pipes.quote(database)] + self.base_command = [monotone, "--db=%s" % pipes.quote(database)] packet_header_re = re.compile(r'^(\d+):(\d+):([lm]):(\d+):') import web @@ -62,126 +53,191 @@ class Automate(Runner): class Automate(Runner): """Runs commands via a particular monotone process. This process is started the first time run() is called, and - stopped when this class instance is deleted. + stopped when this class instance is deleted or the stop() + method is called. If an error occurs, the monotone process may need to be stopped and a new one created. """ def __init__(self, *args, **kwargs): - Runner.__init__(*[self] + list(args), **kwargs) - self.lock = threading.Lock() - self.process = None + Runner.__init__(*[self] + list(args), **kwargs) + self.lock = threading.Lock() + self.process = None def stop(self): if not self.process: - return - terminate_popen3(self.process) + return + terminate_popen3(self.process) + self.process = None + + def __process_required(self): + if self.process != None: + return + to_run = self.base_command + ['automate', 'stdio'] + self.process = popen2.Popen3(to_run, capturestderr=True) + map (set_nonblocking, [ self.process.fromchild, + self.process.tochild, + self.process.childerr ]) - def __process_required(self): - if self.process != None: - return - to_run = self.base_command + ['automate', 'stdio'] - self.process = popen2.Popen3(to_run, capturestderr=True) - map (set_nonblocking, [ self.process.fromchild, - self.process.tochild, - self.process.childerr ]) + def run(self, *args, **kwargs): +# debug(("automate is running:", args, kwargs)) - def run(self, command, args): - if not self.lock.acquire(False): - raise MonotoneException("Automate process can't be called: it is already locked.") - self.__process_required() + lock = self.lock + stop = self.stop + class CleanRequest(genproxy.GeneratorProxy): + def __init__(self, *args, **kwargs): + genproxy.GeneratorProxy.__init__(self, *args, **kwargs) - debug(command, args) + # nb; this used to be False, but True seems to behave more sensibly. + # in particular, if someone holds down Refresh sometimes the code + # gets here before __del__ is called on the previous iterator, + # causing a pointless error to occur + if not lock.acquire(True): + # I've checked; this exception does _not_ cause __del__ to run, so + # we don't accidentally unlock a lock below + raise MonotoneException("Automate request cannot be called: it is already locked! This indicates a logic error in ViewMTN; please report.") + + def __del__(self): + def read_any_unread_output(): + try: + # this'll raise StopIteration if we're done + self.next() + # okay, we're not done.. + debug("warning: Automate output not completely read; reading manually.") + for stanza in self: + pass + except StopIteration: + pass + + try: + read_any_unread_output() + lock.release() + except: + debug("exception cleaning up after Automation; calling stop()!") + stop() + + return CleanRequest(self.__run(*args, **kwargs)) + + def __run(self, command, args): enc = "l%d:%s" % (len(command), command) - enc += ''.join(map(lambda x: "%d:%s" % (len(x), x), args)) + 'e' - debug("wrote", enc) - self.process.tochild.write(enc) - self.process.tochild.flush() + enc += ''.join(["%d:%s" % (len(x), x) for x in args]) + 'e' - import sys - def read_result_packets(): - buffer = "" - while True: - r_stdin, r_stdout, r_stderr = select.select([self.process.fromchild], [], [], None) - if not r_stdin and not r_stdout and not r_stderr: - break + # number of tries to get a working mtn going.. + for i in xrange(2): + self.__process_required() + try: + self.process.tochild.write(enc) + self.process.tochild.flush() + break + except: + # mtn has died underneath the automate; restart it + debug("exception writing to child process; attempting restart: %s" % format_exc()) + self.stop() - if self.process.fromchild in r_stdin: - data = self.process.fromchild.read() - debug("data", data) - if data == "": - break - buffer += data + import sys + def read_result_packets(): + buffer = "" + while True: + r_stdin, r_stdout, r_stderr = select.select([self.process.fromchild], [], [], None) + if not r_stdin and not r_stdout and not r_stderr: + break - # loop, trying to get complete packets out of our buffer - complete, in_packet = False, False - while not complete and buffer != '': - if not in_packet: - m = packet_header_re.match(buffer) - if not m: - break - in_packet = True - cmdnum, errnum, pstate, length = m.groups() - errnum = int(errnum) - length = int(length) - header_length = m.end(m.lastindex) + 1 # the '1' is the colon + if self.process.fromchild in r_stdin: + data = self.process.fromchild.read() + if data == "": + break + buffer += data - if len(buffer) < length + header_length: - # not enough data read from client yet; go round - break - else: - result = buffer[header_length:header_length+length] - buffer = buffer[header_length+length:] - complete = pstate == 'l' - in_packet = False - yield errnum, complete, result + # loop, trying to get complete packets out of our buffer + complete, in_packet = False, False + while not complete and buffer != '': + if not in_packet: + m = packet_header_re.match(buffer) + if not m: + break + in_packet = True + cmdnum, errnum, pstate, length = m.groups() + errnum = int(errnum) + length = int(length) + header_length = m.end(m.lastindex) + 1 # the '1' is the colon - if complete: - break - - # get our response, and yield() it back one line at a time - code_max = -1 - for code, is_last, data in read_result_packets(): - if code and code > code_max: - code_max = code - for line in data.split('\n'): - yield line + '\n' - if code_max > 0: - raise MonotoneException("error code %d in automate packet." % (code_max)) - self.lock.release() + if len(buffer) < length + header_length: + # not enough data read from client yet; go round + break + else: + result = buffer[header_length:header_length+length] + buffer = buffer[header_length+length:] + complete = pstate == 'l' + in_packet = False + yield errnum, complete, result + if complete: + break + + # get our response, and yield() it back one line at a time + code_max = -1 + data_buf = '' + for code, is_last, data in read_result_packets(): + if code and code > code_max: + code_max = code + data_buf += data + while True: + nl_idx = data_buf.find('\n') + if nl_idx == -1: + break + yield data_buf[:nl_idx+1] + data_buf = data_buf[nl_idx+1:] + # left over data? + if data_buf: + yield data_buf + if code_max > 0: + raise MonotoneException("error code %d in automate packet." % (code_max)) + class Standalone(Runner): """Runs commands by running monotone. One monotone process per command""" def run(self, command, args): - # as we pass popen3 as sequence, it executes monotone with these - # arguments - and does not pass them through the shell according - # to help(os.popen3) - to_run = self.base_command + [command] + args - process = popen2.Popen3(to_run, capturestderr=True) - for line in process.fromchild: - yield line - stderr_data = process.childerr.read() - if len(stderr_data) > 0: - raise MonotoneException("data on stderr for command '%s': %s" % (command, - stderr_data)) - terminate_popen3(process) + # as we pass popen3 as sequence, it executes monotone with these + # arguments - and does not pass them through the shell according + # to help(os.popen3) +# debug(("standalone is running:", command, args)) + to_run = self.base_command + [command] + args + process = popen2.Popen3(to_run, capturestderr=True) + for line in process.fromchild: + yield line + stderr_data = process.childerr.read() + if len(stderr_data) > 0: + raise MonotoneException("data on stderr for command '%s': %s" % (command, + stderr_data)) + terminate_popen3(process) class MtnObject: def __init__(self, obj_type): - self.obj_type = obj_type + self.obj_type = obj_type class Tag(MtnObject): - def __init__(self, name, revision, author): - MtnObject.__init__(self, "tag") - self.name, self.revision, self.author = name, Revision(revision), author + def __init__(self, name, revision, author, branches): + MtnObject.__init__(self, "tag") + self.name, self.revision, self.author, self.branches = name, Revision(revision), author, branches class Branch(MtnObject): def __init__(self, name): - MtnObject.__init__(self, "branch") - self.name = name + MtnObject.__init__(self, "branch") + self.name = name +class File(MtnObject): + def __init__(self, name, in_revision): + MtnObject.__init__(self, "file") + self.name = name + self.in_revision = in_revision + +class Dir(MtnObject): + def __init__(self, name, in_revision): + MtnObject.__init__(self, "dir") + self.name = name + self.in_revision = in_revision + basic_io_name_tok = re.compile(r'^(\S+)') def basic_io_from_stream(gen): @@ -190,116 +246,162 @@ def basic_io_from_stream(gen): # new value of line (eg. with consumed tokens removed) def hex_consume(line): - m = revision_re_c.match(line[1:]) - if line[0] != '[' or not m: - raise MonotoneException("This is not a hex token: %s" % line) - end_of_match = m.end(m.lastindex) - if line[end_of_match+1] != ']': - raise MonotoneException("Hex token ends in character other than ']': %s" % line) - return Revision(m.groups()[0]), choose_consume, line[end_of_match+2:] + m = hex_re_c.match(line[1:]) + if line[0] != '[' or not m: + raise MonotoneException("This is not a hex token: %s" % line) + end_of_match = m.end(m.lastindex) + if line[end_of_match+1] != ']': + raise MonotoneException("Hex token ends in character other than ']': %s" % line) + return Revision(m.groups()[0]), choose_consume, line[end_of_match+2:] def name_consume(line): - m = name_re_c.match(line) - if not m: - raise MonotoneException("Not a name: %s" % line) - end_of_match = m.end(m.lastindex) - return m.groups()[0], choose_consume, line[end_of_match:] + m = name_re_c.match(line) + if not m: + raise MonotoneException("Not a name: %s" % line) + end_of_match = m.end(m.lastindex) + return m.groups()[0], choose_consume, line[end_of_match:] def choose_consume(line): - line = line.lstrip() - if line == '': - consumer = choose_consume - elif line[0] == '[': - consumer = hex_consume - elif line[0] == '"': - consumer = string_consume - else: - consumer = name_consume - return None, consumer, line + line = line.lstrip() + if line == '': + consumer = choose_consume + elif line[0] == '[': + consumer = hex_consume + elif line[0] == '"': + consumer = string_consume + else: + consumer = name_consume + return None, consumer, line class StringState: - def __init__(self): - self.in_escape = False - self.has_started = False - self.has_ended = False - self.value = '' + def __init__(self): + self.in_escape = False + self.has_started = False + self.has_ended = False + self.value = '' def string_consume(line, state=None): - if not state: - state = StringState() + if not state: + state = StringState() - if not state.has_started: - if line[0] != '"': - raise MonotoneException("Not a string: %s" % line) - line = line[1:] - state.has_started = True + if not state.has_started: + if line[0] != '"': + raise MonotoneException("Not a string: %s" % line) + line = line[1:] + state.has_started = True - for idx, c in enumerate(line): - if state.in_escape: - if c != '\\' or c != '\"': - raise MonotoneException("Invalid escape code: %s\n" % line) - state.value += c - state.in_escape = False - else: - if c == '\\': - state.in_escape = True - elif c == '"': - state.has_ended = True - break - else: - state.value += c + idx = 0 + for idx, c in enumerate(line): + if state.in_escape: + if c != '\\' and c != '"': + raise MonotoneException("Invalid escape code: %s in %s\n" % (c, line)) + state.value += c + state.in_escape = False + else: + if c == '\\': + state.in_escape = True + elif c == '"': + state.has_ended = True + break + else: + state.value += c - if state.has_ended: - return state.value, choose_consume, line[idx+1:] - else: - return (None, - lambda s: string_consume(s, state), - line[idx+1:]) + if state.has_ended: + return state.value, choose_consume, line[idx+1:] + else: + return (None, + lambda s: string_consume(s, state), + line[idx+1:]) consumer = choose_consume current_stanza = [] for line in gen: - # if we're not in an actual consumer (which we shouldn't be, unless - # we're parsing some sort of multi-line token) and we have a blank - # line, it indicates the end of any current stanza - if (consumer == choose_consume) and (line == '' or line == '\n') and current_stanza: - yield current_stanza - current_stanza = [] - continue + # if we're not in an actual consumer (which we shouldn't be, unless + # we're parsing some sort of multi-line token) and we have a blank + # line, it indicates the end of any current stanza + if (consumer == choose_consume) and (line == '' or line == '\n') and current_stanza: + yield current_stanza + current_stanza = [] + continue - while line != '' and line != '\n': - new_token, consumer, line = consumer(line) - if new_token != None: - current_stanza.append(new_token) - + while line != '' and line != '\n': + new_token, consumer, line = consumer(line) + if new_token != None: + current_stanza.append(new_token) + if current_stanza: + yield current_stanza + class Operations: def __init__(self, runner_args): - self.standalone = apply(Standalone, runner_args) - self.automate = apply(Automate, runner_args) + self.standalone = apply(Standalone, runner_args) + self.automate = apply(Automate, runner_args) - def __del__(self): - debug("deleting Operations instance.") - def tags(self): - for line in (t.strip() for t in self.standalone.run('ls', ['tags'])): - if not line: - continue - yield apply(Tag, line.split(' ', 2)) + for stanza in basic_io_from_stream(self.automate.run('tags', [])): + if stanza[0] == 'tag': + branches = [] + for branch in stanza[7:]: + branches.append(Branch(branch)) + yield Tag(stanza[1], stanza[3], stanza[5], branches) def branches(self): - for line in (t.strip() for t in self.standalone.run('ls', ['branches'])): - if not line: - continue - yield apply(Branch, (line,)) + for line in (t.strip() for t in self.automate.run('branches', [])): + if not line: + continue + yield apply(Branch, (line,)) def graph(self): - for line in self.automate.run('graph', []): - yield line + for line in self.automate.run('graph', []): + yield line + def parents(self, revision): + if revision != "": + for line in (t.strip() for t in self.automate.run('parents', [revision])): + if not line: + continue + yield apply(Revision, (line,)) + + def children(self, revision): + if revision != "": + for line in (t.strip() for t in self.automate.run('children', [revision])): + if not line: + continue + yield apply(Revision, (line,)) + + def toposort(self, revisions): + for line in (t.strip() for t in self.automate.run('toposort', revisions)): + if not line: + continue + yield apply(Revision, (line,)) + + def heads(self, branch): + for line in (t.strip() for t in self.automate.run('heads', [branch])): + if not line: + continue + yield apply(Revision, (line,)) + + def get_content_changed(self, revision, path): + for stanza in basic_io_from_stream(self.automate.run('get_content_changed', [revision, path])): + yield stanza + def get_revision(self, revision): - for stanza in basic_io_from_stream(self.automate.run('get_revision', [revision])): - yield stanza + for stanza in basic_io_from_stream(self.automate.run('get_revision', [revision])): + yield stanza + def get_manifest_of(self, revision): + for stanza in basic_io_from_stream(self.automate.run('get_manifest_of', [revision])): + yield stanza + + def get_file(self, fileid): + for stanza in self.automate.run('get_file', [fileid]): + yield stanza + def certs(self, revision): + for stanza in basic_io_from_stream(self.automate.run('certs', [revision])): + yield stanza + + def diff(self, revision_from, revision_to, files=[]): + args = ['-r', revision_from, '-r', revision_to] + files + for line in self.standalone.run('diff', args): + yield line + - for stanza in basic_io_from_stream(self.automate.run('certs', [revision])): - yield stanza ============================================================ --- www/viewmtn/version.py a071b2192e2092ec222ab0d59ef95efd1c9c81e6 +++ www/viewmtn/release.py 9606208d767e8ed4999ca37fa5e20d13f3fa7be5 @@ -1,3 +1,12 @@ +version='0.06beta' +authors='''Authors: +Grahame Bowland +Contributors: +Matt Johnston +Nathaniel Smith +Bruce Stephens +Lapo Luchini +David Reiss + +''' -# the latest release; make sure to update this (note for Grahame) -release = "0.05" ============================================================ --- www/viewmtn/builtpython.sh c1d18a362622ec209ea6818957a5778d5374de45 +++ www/viewmtn/release.sh 65fe70e241d026a9e09530245362cb09287b6608 @@ -1,16 +1,11 @@ #!/bin/sh -# generate the list of enscript formatting options -LANGS=enscriptlangs.py -echo -n 'enscript_langs = [' > "$LANGS" -for i in `enscript --help-highlight | grep Name | awk {'print $2'}`; do - echo -n "'$i', " >> "$LANGS" -done; echo ']' >> "$LANGS" - # generate the help file data -AUTHORS=authors.py -echo -n "authors='''" > "$AUTHORS" -cat AUTHORS >> "$AUTHORS" -echo "'''" >> "$AUTHORS" +OUT="release.py" +RELEASE="0.06beta" +echo -n > "$OUT" +echo "version='$RELEASE'" > "$OUT" +echo -n "authors='''" >> "$OUT" +cat AUTHORS >> "$OUT" +echo "'''" >> "$OUT" - ============================================================ --- www/viewmtn/viewmtn.css 6efcadac0d56fb3d77a22786f477ba621f4af33d +++ www/viewmtn/static/viewmtn.css 8981409323f375efedb9599c206ac0b34bdad739 @@ -47,11 +47,11 @@ TABLE.pretty TH { padding-right: 0.5em; } -TR.odd { +TR.even { background-color: #eeeeee; } -TR.even { +TR.odd { background-color: #ffffff; } @@ -91,3 +91,10 @@ DIV#popupBox { padding: 2px; z-index: 10; } + +PRE.code { + border-left-style: solid; + border-left-width: 3px; + border-left-color: #A0A0A0; + padding-left: 3px; +} ============================================================ --- www/viewmtn/templates/about.html 6b1c55564ae50e2de59d5eda418bfad86f0b596d +++ www/viewmtn/templates/about.html fea245cb17ab46a7766e03293800361db0b325b2 @@ -5,18 +5,18 @@

Authors and Contributors

    -
  • Grahame Bowland - address@hidden
  • -
  • Matt Johnston - address@hidden
  • -
  • Nathaniel Smith - address@hidden
  • -
  • Bruce Stephens - address@hidden
  • -
  • Lapo Luchini - address@hidden
  • -
  • David Reiss - address@hidden
  • +
  • Grahame Bowland (grahame at angrygoats.net)
  • +
  • Matt Johnston (matt at ucc.asn.au)
  • +
  • Nathaniel Smith (njs at pobox.com)
  • +
  • Bruce Stephens (monotone at cenderis.demon.co.uk)
  • +
  • Lapo Luchini (lapo at lapo.it)
  • +
  • David Reiss (davidn at gmail.com)

Licensing

-Copyright (C) 2005 Grahame Bowland +Copyright (C) 2005-2006 Grahame Bowland

@@ -42,26 +42,12 @@ Foundation, Inc., 59 Temple Place, Suite

Dependencies

-ViewMTN is written in Python and -runs under mod_python. +ViewMTN is written in Python and runs under web.py. Code highlighting via Highlight. +Graphing via GraphViz. Graph colour generation algorithm +from monotone-viz with modifications from Matt Johnston. AJAX funtionality uses the MochiKit Javascript library.

-

-Code highlighting via -GNU Enscript. -

-

-Graphing via GraphViz. -

- -

-Graph colour generation algorithm from monotone-viz with modifications from Matt Johnston. -

- -

-AJAX funtionality uses the MochiKit Javascript library. -

- - #end def ============================================================ --- www/viewmtn/templates/base.html 828ac669dcf846b15ebedc558c7da907079dd685 +++ www/viewmtn/templates/base.html 96ce364d2dfd62b29e6557bebfd3e5f9a6c87b46 @@ -1,13 +1,16 @@ ViewMTN: $(page_title) + #block extraheaders #end block +#block rssheaders +#end block @@ -31,7 +34,7 @@ installCallbacks(); ============================================================ --- www/viewmtn/templates/help.html 261b26dddeecdfc25505dc8bf5ce633c947b54d6 +++ www/viewmtn/templates/help.html f8faec929ff6f5fd1722240f12a8b398c88fb603 @@ -19,4 +19,5 @@ software page and follow the contact to the ViewMTN software page and follow the contact instructions there.

+ #end def ============================================================ --- www/viewmtn/templates/index.html 08145fdb8457c0f05ead0cdec4717e0f7f0de864 +++ www/viewmtn/templates/index.html 0fb7fc02dd5179e6b4724d4fb96281b6235268ab @@ -20,7 +20,7 @@ might be useful. #filter Filter $link($branch).html() - #filter WebSafe + #end filter #end for ============================================================ --- www/viewmtn/templates/revisioninfo.html f78b63b0bea3ba8971396d33e0ce9e7b0477847d +++ www/viewmtn/templates/revisioninfo.html 18daa6b4c21b0a208fe275530178c26c0c6cd824 @@ -1,7 +1,8 @@ #extends revision #def body +

Certificates

@@ -10,12 +11,12 @@ #end for @@ -24,20 +25,30 @@

Revision Details

#filter Filter $cert['name'] - #filter WebSafe + #end filter #filter Filter $cert['value'] - #filter WebSafe + #end filter
-#for stanza_type, descr, value in $revisions - +#for grouping, stanzagroup in $revisions + #end for
#filter Filter - $descr - #filter WebSafe + $grouping + #end filter #filter Filter - $value - #filter WebSafe - + #for value in $stanzagroup + $value
+ #end for + #end filter +
+
+#filter Filter +$imagemap +#end filter +
+Ancestry of $revision +
+ #end def ============================================================ --- www/viewmtn/templates/tags.html 51d40e8786b2cdfb99d956a28583be36c49cc078 +++ www/viewmtn/templates/tags.html 42d078da8e8619948a5b2ebbe93f6265ef9ab285 @@ -8,20 +8,27 @@ To view a particular tag, select it from

- + #for tag in $tags - - + #end for
TagSigned byWhen
TagSigned byBranchesAge
+ #filter Filter $link($tag).html() - #filter WebSafe + #end filter + $tag.author - + #filter Filter + #for branch in $tag.branches + $link($branch).html()
+ #end for + #end filter
+ $revision_ago($tag.revision) +
============================================================ --- www/viewmtn/viewmtn.py f64c08da43d7339e847ed1cddd44939c349bf9e7 +++ www/viewmtn/viewmtn.py 1dc5caa196fe72d70fa67baaad7ed79f3a430868 @@ -1,13 +1,31 @@ -#!/usr/bin/env python +#!/usr/bin/env python2.4 +import os import cgi import mtn +import sha +import sys import web +import struct +import string +import rfc822 import config +import common import urllib import urlparse +import syntax +import tarfile +import tempfile +import datetime +import cStringIO +from colorsys import hls_to_rgb +from fdo import sharedmimeinfo, icontheme +import release hq = cgi.escape +import web +debug = web.debug + # /about.psp -> /about # /branch.psp -> /branch/{branch}/ @@ -30,211 +48,980 @@ hq = cgi.escape # /getjson.py -> /json[...] (private) +dynamic_join = lambda path: urlparse.urljoin(config.dynamic_uri_path, path) +static_join = lambda path: urlparse.urljoin(config.static_uri_path, path) + +def quicklog(changelog, max_size=None): + interesting_line = None + for line in changelog: + line = line.strip() + if line: + interesting_line = line + break + if not interesting_line: + return "" + if interesting_line.startswith('*'): + interesting_line = interesting_line[1:].strip() + if max_size and len(interesting_line) > max_size: + interesting_line = interesting_line[:max_size] + r_wspc = interesting_line.rfind(' ') + if r_wspc <> -1: + interesting_line = interesting_line[:r_wspc] + interesting_line += '..' + return interesting_line + +def timecert(certs): + revdate = None + for cert in certs: + if cert[4] == 'name' and cert[5] == 'date': + revdate = common.parse_timecert(cert[7]) + return revdate + +def nbhq(s): + return ' '.join([hq(t) for t in s.split(' ')]) + +def normalise_changelog(changelog): + changelog = map(hq, changelog.split('\n')) + if changelog and changelog[-1] == '': + changelog = changelog[:-1] + return changelog + class Link: - def __init__(self, description=None): - self.relative_uri = None - self.description = description - def html(self): - return '%s' % (self.relative_uri, - self.description) + def __init__(self, description=None, link_type=None, **kwargs): + self.absolute_uri = None + self.relative_uri = None + self.description = description + def uri(self): + return dynamic_join(self.relative_uri) + def html(self, override_description=None, force_nbsp=False): + if override_description: + if force_nbsp: + d = nbhq(override_description) + else: + d = hq(override_description) + else: + d = self.description + if self.relative_uri: + uri = dynamic_join(self.relative_uri) + elif self.absolute_uri: + uri = self.absolute_uri + else: + return self.description + return '%s' % (uri, d) +class AuthorLink(Link): + def __init__(self, author, **kwargs): + Link.__init__(*(self, ), **kwargs) + name, email = rfc822.parseaddr(author) + self.description = author + if email: + self.absolute_uri = "mailto:%s" % urllib.quote(email) + if name: + self.description = hq(name) + class RevisionLink(Link): def __init__(self, revision, **kwargs): - Link.__init__(*(self, ), **kwargs) - self.relative_uri = 'revision/info/%s' % (revision) - self.description = revision.abbrev() + link_type = kwargs.get("link_type") + if link_type == "browse": + subpage = "browse" + else: + subpage = "info" + Link.__init__(*(self, ), **kwargs) + self.relative_uri = 'revision/%s/%s' % (subpage, revision) + self.description = revision.abbrev() class TagLink(Link): def __init__(self, tag, **kwargs): - Link.__init__(*(self, ), **kwargs) - self.relative_uri = 'revision/info/%s' % (tag.revision) - self.description = tag.name + Link.__init__(*(self, ), **kwargs) + self.relative_uri = 'revision/info/%s' % (tag.revision) + self.description = tag.name class BranchLink(Link): def __init__(self, branch, **kwargs): - Link.__init__(*(self, ), **kwargs) - self.relative_uri = 'branch/changes/' + urllib.quote(branch.name) - self.description = hq(branch.name) + Link.__init__(*(self, ), **kwargs) + from_change, to_change = kwargs.get('from_change'), kwargs.get('to_change') + if from_change and to_change: + self.relative_uri = 'branch/changes/%s/from/%d/to/%d' % (urllib.quote(branch.name), from_change, to_change) + else: + self.relative_uri = 'branch/changes/' + urllib.quote(branch.name) + self.description = hq(branch.name) class DiffLink(Link): def __init__(self, diff, **kwargs): - Link.__init__(*(self, ), **kwargs) - self.relative_uri = 'revision/diff/' + diff.from_rev + '/with/' + diff.to_rev - self.description = "diff" + Link.__init__(*(self, ), **kwargs) + self.relative_uri = 'revision/diff/' + diff.from_rev + '/with/' + diff.to_rev + if diff.fname: + self.relative_uri += '/'+urllib.quote(diff.fname) + self.description = "diff" +class DirLink(Link): + def __init__(self, file, **kwargs): + Link.__init__(*(self, ), **kwargs) + # handle the root directory + if file.name == '/': + fn = '' + else: + fn = file.name + self.relative_uri = 'revision/browse/' + file.in_revision + '/' + urllib.quote(fn) + self.description = hq(file.name) + +class FileLink(Link): + def __init__(self, file, **kwargs): + Link.__init__(*(self, ), **kwargs) + if kwargs.has_key('for_download'): + access_method = 'downloadfile' + else: + access_method = 'file' + self.relative_uri = 'revision/' + access_method + '/' + file.in_revision + '/' + urllib.quote(file.name) + self.description = hq(file.name) + class Diff: - def __init__(self, from_rev, to_rev, file): - self.obj_type = 'diff' - self.file = file - self.from_rev = from_rev - self.to_rev = to_rev + def __init__(self, from_rev, to_rev, fname=None): + self.obj_type = 'diff' + self.fname = fname + self.from_rev = from_rev + self.to_rev = to_rev def prettify(s): - return ' '.join( - map(lambda x: hq(x[0].upper() + x[1:]), - s.replace("_", " ").split(" "))) + return ' '.join([hq(x[0].upper() + x[1:]) for x in s.replace("_", "").split(" ")]) def certs_for_template(cert_gen): for cert in cert_gen: - if cert[0] == 'key' and len(cert) != 10: - raise Exception("Not a correcly formatted certificate: %s" % cert) - if cert[3] != 'ok': - raise Exception("Certificate failed check.") + if cert[0] == 'key' and len(cert) != 10: + raise Exception("Not a correctly formatted certificate: %s" % cert) + if cert[3] != 'ok': + raise Exception("Certificate failed check.") - key = cert[1] - name = cert[5] - value = cert[7] - if name == "branch": - value = link(mtn.Branch(value)).html() - else: - value = '
'.join(map(hq, value.split('\n'))) + key = cert[1] + name = cert[5] + value = cert[7] + if name == "branch": + value = link(mtn.Branch(value)).html() + else: + value = '
'.join(map(hq, value.split('\n'))) - yield { 'key' : key, - 'name' : prettify(name), - 'value' : value } + yield { 'key' : key, + 'name' : prettify(name), + 'value' : value } -def revisions_for_template(rev_gen): +def revisions_for_template(revision, rev_gen): + old_revisions = [] + stanzas = [] + grouping = None for stanza in rev_gen: - stanza_type = stanza[0] - description, value = prettify(stanza_type), None + stanza_type = stanza[0] + description, value = prettify(stanza_type), None - if stanza_type == "format_version" or \ - stanza_type == "new_manifest": - continue - elif stanza_type == "patch": + if grouping == None: + grouping = description + if description != grouping: + if len(stanzas) > 0: + yield grouping, stanzas + grouping, stanzas = description, [] + + if stanza_type == "format_version" or \ + stanza_type == "new_manifest": + continue + elif stanza_type == "patch": fname, from_id, to_id = stanza[1], stanza[3], stanza[5] - # if from_id is null, this is a new file - # since we're showing that information under "Add", so - # skip it here - if not from_id: - continue - value = "Patch file %s (%s)" % ("not yet", - link(Diff(from_id, to_id, fname)).html()) - else: - value = "(this stanza type is not explicitly rendered; please report this.)\n%s" % hq(str(stanza)) + # if from_id is null, this is a new file + # since we're showing that information under "Add", so + # skip it here + if not from_id: + continue + diff_links = ','.join([link(Diff(old_revision, revision, fname)).html() for old_revision in old_revisions]) + value = "Patch file %s (%s)" % (link(mtn.File(fname, revision)).html(), diff_links) + elif stanza_type == "old_revision": + old_revision = mtn.Revision(stanza[1]) + old_revisions.append(old_revision) + value = "Old revision is: %s (%s)" % (link(old_revision).html(), link(Diff(old_revision, revision)).html()) + elif stanza_type == "add_file": + fname = stanza[1] + value = "Add file: %s" % (link(mtn.File(fname, revision)).html()) + elif stanza_type == "add_dir": + dname = stanza[1] + value = "Add directory: %s" % (hq(dname)) + elif stanza_type == "delete": + fname = stanza[1] + value = "Delete: %s" % (hq(fname)) + elif stanza_type == "set": + fname, attr, value = stanza[1], stanza[3], stanza[5] + value = "Set attribute '%s' to '%s' upon %s" % (hq(attr), hq(value), link(mtn.File(fname, revision)).html()) + elif stanza_type == "rename": + oldname, newname = stanza[1], stanza[3] + value = "Rename %s to %s" % (hq(oldname), link(mtn.File(newname, revision)).html()) + else: + value = "(this stanza type is not explicitly rendered; please report this.)\n%s" % hq(str(stanza)) - if description != None: - yield stanza_type, description, value + if description != None: + stanzas.append(value) + if len(stanzas) > 0: + yield grouping, stanzas + type_to_link_class = { - 'tag' : TagLink, + 'author' : AuthorLink, 'branch' : BranchLink, 'diff' : DiffLink, + 'dir' : DirLink, + 'file' : FileLink, + 'revision' : RevisionLink, + 'tag' : TagLink, } -def link(obj): +def link(obj, link_type=None, **kwargs): link_class = type_to_link_class.get(obj.obj_type) if not link_class: - raise LinkException("Unable to link to objects of type: '%s'" % (obj.obj_type)) - return link_class(obj) + raise LinkException("Unable to link to objects of type: '%s'" % (obj.obj_type)) + # ugh + if link_type: + kwargs['link_type'] = link_type + return link_class(obj, **kwargs) class Renderer: def __init__(self): - # any templates that can be inherited from, should be added to the list here - self.templates = [ ('base.html', 'base'), - ('revision.html', 'revision'), ] - self._templates_loaded = False + # any templates that can be inherited from, should be added to the list here + self.templates = [ ('base.html', 'base'), + ('revision.html', 'revision'), + ('branch.html', 'branch'), + ('revisionfile.html', 'revisionfile') ] + self._templates_loaded = False - # these variables will be available to any template - self.terms = { - 'context' : web.context, # fugly - 'dynamic_uri_path' : config.dynamic_uri_path, - 'dynamic_join' : lambda path: urlparse.urljoin(config.dynamic_uri_path, path), - 'link' : link, - 'static_uri_path' : config.static_uri_path, - 'static_join' : lambda path: urlparse.urljoin(config.static_uri_path, path), - } + # these variables will be available to any template + self.terms = { + 'context' : web.context, # fugly + 'dynamic_uri_path' : config.dynamic_uri_path, + 'dynamic_join' : dynamic_join, + 'static_uri_path' : config.static_uri_path, + 'static_join' : static_join, + 'link' : link, + 'version' : release.version, + } def load_templates(self): - if self._templates_loaded: return - for template, mod_name in self.templates: - web.render(template, None, True, mod_name) - self._templates_loaded = True + if self._templates_loaded: return + for template, mod_name in self.templates: + web.render(template, None, True, mod_name) + self._templates_loaded = True def render(self, template, **kwargs): - self.load_templates() - terms = self.terms.copy() - terms.update(kwargs) - web.render(template, terms) + self.load_templates() + terms = self.terms.copy() + terms.update(kwargs) + web.render(template, terms) renderer = Renderer() ops = mtn.Operations([config.monotone, config.dbfile]) +mimehelp = sharedmimeinfo.LookupHelper() +mimeicon = icontheme.MimeIcon(icontheme.IconTheme(config.icon_theme), config.icon_size) class Index: def GET(self): - renderer.render('index.html', page_title="Branches", branches=ops.branches()) + renderer.render('index.html', page_title="Branches", branches=ops.branches()) class About: def GET(self): - renderer.render('about.html', page_title="About") + renderer.render('about.html', page_title="About") class Tags: def GET(self): - renderer.render('tags.html', page_title="Tags", tags=ops.tags()) + # otherwise we couldn't use automate again.. + tags = map(None, ops.tags()) + tags.sort(lambda t1, t2: cmp(t1.name, t2.name)) + def revision_ago(rev): + rv = "" + for cert in ops.certs(rev): + if cert[4] == 'name' and cert[5] == 'date': + revdate = common.parse_timecert(cert[7]) + rv = common.ago(revdate) + return rv + renderer.render('tags.html', page_title="Tags", tags=tags, revision_ago=revision_ago) class Help: def GET(self): - renderer.render('help.html', page_title="Help") + renderer.render('help.html', page_title="Help") -class RevisionInfo: +class BranchChanges: + def get_last_changes(self, branch, heads, from_change, to_change): + revs = heads + if len(revs) == 0: + raise Exception("get_last_changes() unable to find somewhere to start - probably a non-existent branch?") + to_parent = revs+[] # copy + count = to_change + + def on_our_branch(r): + rv = False + for cert in ops.certs(r): + if cert[4] == 'name' and cert[5] == 'branch': + if cert[7] == branch.name: + rv = True + return rv + + while len(revs) < count: + new_to_parent = [] + for rev in to_parent: + # we must be cautious; we only want to look at parents on our branch! + parents = map(None, ops.parents(rev)) + parents = filter(on_our_branch, parents) + new_to_parent += parents + if len(new_to_parent) == 0: + # out of revisions... + break + to_parent = new_to_parent + revs += new_to_parent +# toposort seems pretty darn slow; let's avoid this one.. +# revs = map(None, ops.toposort(revs))[:count] + certs_for_revs = [] + for rev in revs: + certs_for_revs.append((rev, map(None, ops.certs(rev)))) + def cd(certs): + for cert in certs: + if cert[4] == 'name' and cert[5] == 'date': + return common.parse_timecert(cert[7]) + return None + certs_for_revs.sort(lambda b, a: cmp(cd(a[1]), cd(b[1]))) + return certs_for_revs[from_change:to_change], new_to_parent + + def GET(self, branch, from_change, to_change, template_name): + def for_template(revs): + rv = [] + for rev, certs in revs: + rev_branch = "" + revision, diffs, ago, author, changelog, shortlog, when = mtn.Revision(rev), [], "", mtn.Author(""), "", "", "" + for cert in certs: + if cert[4] != 'name': + continue + if cert[5] == "branch": + rev_branch = cert[7] + elif cert[5] == 'date': + when = cert[7] + revdate = common.parse_timecert(when) + ago = common.ago(revdate) + elif cert[5] == 'author': + author = mtn.Author(cert[7]) + elif cert[5] == 'changelog': + changelog = normalise_changelog(cert[7]) # NB: this HTML escapes! + shortlog = quicklog(changelog) # so this is also HTML escaped. + if rev_branch != branch.name: + # yikes, fallen down a well + continue + for stanza in ops.get_revision(rev): + if stanza and stanza[0] == "old_revision": + old_revision = stanza[1] + diffs.append(Diff(mtn.Revision(old_revision), revision)) + if diffs: + diffs = '| ' + ', '.join([link(d).html('diff') for d in diffs]) + else: + diffs = '' + rv.append((revision, diffs, ago, mtn.Author(author), '
\n'.join(changelog), shortlog, when)) + return rv + + branch = mtn.Branch(branch) + heads = [t for t in ops.heads(branch.name)] + if not heads: + return web.notfound() + per_page = 10 + if from_change: + from_change = int(from_change) + else: + from_change = 0 + if to_change: + to_change = int(to_change) + else: + to_change = per_page + changed, new_starting_point = self.get_last_changes(branch, heads, from_change, to_change) + # next and previous 'from' and 'to' indexes + if len(changed) == to_change - from_change: + next_from, next_to = to_change, to_change + per_page + else: + next_from, next_to = None, None + if from_change > 0: + previous_from = from_change - per_page + if previous_from < 0: previous_from = 0 + previous_to = previous_from + per_page + else: + previous_from, previous_to = None, None + + renderer.render(template_name, + page_title="Branch %s" % branch.name, + branch=branch, + from_change=from_change, + to_change=to_change, + previous_from=previous_from, + previous_to=previous_to, + next_from=next_from, + next_to=next_to, + display_revs=for_template(changed)) + +class HTMLBranchChanges(BranchChanges): + def GET(self, branch, from_change, to_change): + BranchChanges.GET(self, branch, from_change, to_change, "branchchanges.html") + +class RSSBranchChanges(BranchChanges): + def GET(self, branch, from_change, to_change): + BranchChanges.GET(self, branch, from_change, to_change, "branchchangesrss.html") + +class RevisionPage(object): + def get_fileid(self, revision, filename): + rv = None + for stanza in ops.get_manifest_of(revision): + if stanza[0] != 'file': + continue + if stanza[1] == filename: + rv = stanza[3] + return rv + def exists(self, revision): + try: + certs = [t for t in ops.certs(revision)] + return True + except mtn.MonotoneException: + return False + def branches_for_rev(self, revisions_val): + rv = [] + for stanza in ops.certs(revisions_val): + if stanza[4] == 'name' and stanza[5] == 'branch': + rv.append(stanza[7]) + return rv + +class RevisionInfo(RevisionPage): def GET(self, revision): - revision = mtn.Revision(revision) - certs = ops.certs(revision) - revisions = ops.get_revision(revision) - renderer.render('revisioninfo.html', - page_title="Revision %s" % revision.abbrev(), - revision=revision, - certs=certs_for_template(certs), - revisions=revisions_for_template(revisions)) + revision = mtn.Revision(revision) + if not self.exists(revision): + return web.notfound() + certs = ops.certs(revision) + revisions = ops.get_revision(revision) + output_png, output_imagemap = ancestry_graph(revision) + if os.access(output_imagemap, os.R_OK): + imagemap = open(output_imagemap).read().replace('\\n', ' by ') + imageuri = dynamic_join('/revision/graph/' + revision) + else: + imagemap = imageuri = None + renderer.render('revisioninfo.html', + page_title="Revision %s" % revision.abbrev(), + revision=revision, + certs=certs_for_template(certs), + imagemap=imagemap, + imageuri=imageuri, + revisions=revisions_for_template(revision, revisions)) -class RevisionDiff: - def GET(self, revision_from, revision_to): - revision_from = mtn.Revision(revision_from) - revision_to = mtn.Revision(revision_to) - renderer.render('revisiondiff.html', - page_title="Diff from %s to %s" % (revision_from.abbrev(), revision_to.abbrev()), - revision_from=revision_from, - revision_to=revision_to) +class RevisionDiff(RevisionPage): + def GET(self, revision_from, revision_to, filename=None): + revision_from = mtn.Revision(revision_from) + revision_to = mtn.Revision(revision_to) + if not self.exists(revision_from): + return web.notfound() + if not self.exists(revision_to): + return web.notfound() + if filename != None: + files = [filename] + else: + files = [] + diff = ops.diff(revision_from, revision_to, files) + renderer.render('revisiondiff.html', + page_title="Diff from %s to %s" % (revision_from.abbrev(), revision_to.abbrev()), + revision=revision_from, + revision_from=revision_from, + revision_to=revision_to, + diff=syntax.highlight(diff, 'diff'), + files=files) -class RevisionFile: - def GET(self, revision, file): - revision = mtn.Revision(revision) - print "file %s from revision %s" % (file, revision) +class RevisionFile(RevisionPage): + def GET(self, revision, filename): + revision = mtn.Revision(revision) + if not self.exists(revision): + return web.notfound() + language = filename.rsplit('.', 1)[-1] + fileid = RevisionPage.get_fileid(self, revision, filename) + if not fileid: + return web.notfound() + contents = ops.get_file(fileid) + mimetype = mimehelp.lookup(filename, '') + mime_to_template = { + 'image/jpeg' : 'revisionfileimg.html', + 'image/png' : 'revisionfileimg.html', + 'image/gif' : 'revisionfileimg.html', + 'image/svg+xml' : 'revisionfileobj.html', + 'application/pdf' : 'revisionfileobj.html', + 'application/x-python' : 'revisionfiletxt.html', + 'application/x-perl' : 'revisionfiletxt.html', + } + template = mime_to_template.get(mimetype, None) + if not template: + if mimetype.startswith('text/'): + template = 'revisionfiletxt.html' + else: + template = 'revisionfilebin.html' + renderer.render(template, + filename=mtn.File(filename, revision), + page_title="File %s in revision %s" % (filename, revision.abbrev()), + revision=revision, + mimetype=mimetype, + contents=syntax.highlight(contents, language)) -class RevisionBrowse: +class RevisionDownloadFile(RevisionPage): + def GET(self, revision, filename): + web.header('Content-Disposition', 'attachment; filename=%s' % filename) + revision = mtn.Revision(revision) + if not self.exists(revision): + return web.notfound() + fileid = RevisionPage.get_fileid(self, revision, filename) + if not fileid: + return web.notfound() + for idx, data in enumerate(ops.get_file(fileid)): + if idx == 0: + mimetype = mimehelp.lookup(filename, data) + web.header('Content-Type', mimetype) + sys.stdout.write(data) + sys.stdout.flush() + +class RevisionTar(RevisionPage): + def GET(self, revision): + # we'll output in the USTAR tar format; documentation taken from: + # http://en.wikipedia.org/wiki/Tar_%28file_format%29 + revision = mtn.Revision(revision) + if not self.exists(revision): + return web.notfound() + web.header('Content-Disposition', 'attachment; filename=%s.tar' % revision) + web.header('Content-Type', 'application/x-tar') + manifest = [stanza for stanza in ops.get_manifest_of(revision)] + # for now; we might want to come up with something more interesting; + # maybe the branch name (but there might be multiple branches?) + basedirname = revision + tarobj = tarfile.open(mode="w", fileobj=sys.stdout) + dir_mode, file_mode = "0700", "0600" + certs = {} + for stanza in manifest: + stanza_type = stanza[0] + if stanza_type != 'file': + continue + filename, fileid = stanza[1], stanza[3] + filecontents = cStringIO.StringIO() + filesize = 0 + for data in ops.get_file(fileid): + filesize += len(data) + filecontents.write(data) + ti = tarfile.TarInfo() + ti.name = os.path.join(revision, filename) + ti.mode, ti.type = 00600, tarfile.REGTYPE + ti.uid = ti.gid = 0 + # determine the most recent of the content marks + content_marks = [t[1] for t in ops.get_content_changed(revision, filename)] + if len(content_marks) > 0: + # just pick one to make this faster + content_mark = content_marks[0] + since_epoch = timecert(ops.certs(content_mark)) - datetime.datetime.fromtimestamp(0) + ti.mtime = since_epoch.days * 24 * 60 * 60 + since_epoch.seconds + else: + ti.mtime = 0 + ti.size = filesize + filecontents.seek(0) + tarobj.addfile(ti, filecontents) + +class RevisionBrowse(RevisionPage): def GET(self, revision, path): - revision = mtn.Revision(revision) - renderer.render('revisionpath.html', - page_title=revision, - path=path) + revision = mtn.Revision(revision) + if not self.exists(revision): + return web.notfound() + branches = RevisionPage.branches_for_rev(self, revision) + revisions = ops.get_revision(revision) -class RevisionTar: + def components(path): + # NB: mtn internally uses '/' for paths, so we shouldn't use os.path.join() + # we should do things manually; otherwise we'll break on other platforms + # when we accidentally use \ or : or whatever. + # + # also, let's handle the case of spurious extra / characters + # whatever we return should make sense as '/'.join(rv) + rv = [] + while path: + path = path.lstrip('/') + pc = path.split('/', 1) + if len(pc) == 2: + rv.append(pc[0]) + path = pc[1] + else: + rv.append(pc[0]) + path = '' + return rv + + path = path or "" + path_components = components(path) + normalised_path = '/'.join(path_components) + # TODO: detect whether or not this exists and skip the following if it doesn't. + page_title = "Browsing revision %s: dir %s/" % (revision.abbrev(), normalised_path or '') + + if len(branches) > 0: + if len(branches) == 1: + branch_plural = 'branch' + else: + branch_plural = 'branches' + page_title += " of %s %s" % (branch_plural, ', '.join(branches)) + + def cut_manifest_to_subdir(): + manifest = map(None, ops.get_manifest_of(revision)) + in_the_dir = False + for stanza in manifest: + stanza_type = stanza[0] + if stanza_type != "file" and stanza_type != "dir": + continue + this_path = stanza[1] + + if not in_the_dir: + if stanza_type == "dir" and this_path == normalised_path: + in_the_dir = True + continue + + this_path_components = components(this_path) + # debug(["inthedir", stanza_type, this_path, len(this_path_components), len(path_components)]) + if stanza_type == "dir": + # are we still in our directory? + if len(this_path_components) > len(path_components) and \ + this_path_components[:len(path_components)] == path_components: + # is this an immediate subdirectory of our directory? + if len(this_path_components) == len(path_components) + 1: + yield (stanza_type, this_path) + else: + in_the_dir = False + # and we've come out of the dir ne'er to re-enter, so.. + break + elif stanza_type == "file" and len(this_path_components) == len(path_components) + 1: + yield (stanza_type, this_path) + + def info_for_manifest(entry_iter): + # should probably limit memory usage (worst case is this gets huge) + # but for now, this is really a needed optimisation, as most of the + # time a single cert will be seen *many* times + certs = {} + certinfo = {} + + def get_cert(revision): + if not certs.has_key(revision): + # subtle bug slipped in here; ops.cert() is a generator + # so we can't just store it in a cache! + certs[revision] = map(None, ops.certs(revision)) + return certs[revision] + + def _get_certinfo(revision): + author, ago, shortlog = None, None, None + for cert in get_cert(revision): + if cert[4] != 'name': + continue + name, value = cert[5], cert[7] + if name == "author": + author = mtn.Author(value) + elif name == "date": + revdate = common.parse_timecert(value) + ago = common.ago(revdate) + elif name == "changelog": + shortlog = quicklog(normalise_changelog(value), 40) + to_return = (author, ago, shortlog) + return [t or "" for t in to_return] + + def get_certinfo(revision): + if not certinfo.has_key(revision): + certinfo[revision] = _get_certinfo(revision) + return certinfo[revision] + + for stanza_type, this_path in entry_iter: + # determine the most recent of the content marks + content_marks = [t[1] for t in ops.get_content_changed(revision, this_path)] + for mark in content_marks: + get_cert(mark) + if len(content_marks): + content_marks.sort(lambda b, a: cmp(timecert(certs[a]), timecert(certs[b]))) + content_mark = mtn.Revision(content_marks[0]) + author, ago, shortlog = get_certinfo(content_mark) + else: + author, ago, shortlog, content_mark = mtn.Author(""), "", "", None + if stanza_type == "file": + file_obj = mtn.File(this_path, revision) + mime_type = mimehelp.lookup(this_path, "") + else: + file_obj = mtn.Dir(this_path, revision) + mime_type = 'inode/directory' + yield (stanza_type, file_obj, author, ago, content_mark, shortlog, mime_type) + + def path_links(components): + # we always want a link to '/' + yield mtn.Dir('/', revision) + running_path = "" + for component in components: + running_path += component + "/" + yield mtn.Dir(running_path, revision) + + def row_class(): + while True: + yield "odd" + yield "even" + + def mime_icon(mime_type): + return dynamic_join('/mimeicon/' + mime_type) + + renderer.render('revisionbrowse.html', + branches=branches, + branch_links=', '.join([link(mtn.Branch(b)).html() for b in branches]), + path=path, + page_title=page_title, + revision=revision, + path_links=path_links(path_components), + row_class=row_class(), + mime_icon=mime_icon, + entries=info_for_manifest(cut_manifest_to_subdir())) + +def ancestry_dot(revision): + def dot_escape(s): + # kinda paranoid, should probably revise later + permitted=string.digits + string.letters + ' -<>-:,address@hidden&.+_~?/' + return ''.join([t for t in s if t in permitted]) + revision = mtn.Revision(revision) + original_branches = [] + for cert in ops.certs(revision): + if cert[4] == 'name' and cert[5] == 'branch': + original_branches.append(cert[7]) + + # strategy: we want to show information about this revision's place + # in the overall graph, both forward and back, for revision_count + # revisions in both directions (if possible) + # + # we will show propogates as dashed arcs + # otherwise, a full arc + # + # we'll show the arcs leading away from the revisions at either end, + # to make it clear that this is one part of a larger picture + # + # it'd be neat if someone wrote a google-maps style browser; I have + # some ideas as to how to approach this problem. + + # revision graph is prone to change; someone could commit anywhere + # any time. so we'll have to generate this dotty file each time; + # let's write it into a temporary file (save some memory, no point + # keeping it about on disk) and sha1 hash the contents. + # we'll then see if ..png exists; if not, we'll + # generate it from the dot file + + # let's be general, it's fairly symmetrical in either direction anyway + # I think we want to show a consistent view over a depth vertically; at the + # very least we should always show the dangling arcs + arcs = set() + nodes = set() + visited = set() + + def visit_node(revision): + for node in ops.children(revision): + arcs.add((revision, node)) + nodes.add(node) + for node in ops.parents(revision): + arcs.add((node, revision)) + nodes.add(node) + visited.add(revision) + + def graph_build_iter(): + for node in (nodes - visited): + visit_node(node) + + # stolen from monotone-viz + def colour_from_string(str): + def f(off): + return ord(hashval[off]) / 256.0 + hashval = sha.new(str).digest() + hue = f(5) + li = f(1) * 0.15 + 0.55 + sat = f(2) * 0.5 + .5 + return ''.join(["%.2x" % int(x * 256) for x in hls_to_rgb(hue, li, sat)]) + + # for now, let's do three passes; seems to work fairly well + nodes.add(revision) + for i in xrange(3): + graph_build_iter() + + graph = '''\ +digraph ancestry { + ratio=compress + nodesep=0.1 + ranksep=0.2 + edge [dir=forward]; +''' + + # for each node, let's figure out it's colour, whether or not it's in our branch, + # and the label we'd give it; we need to look at all the nodes, as we need to know + # if off-screen nodes are propogates + + node_colour = {} + node_label = {} + node_in_branch = {} + + for node in nodes: + author, date = '', '' + branches = [] + for cert in ops.certs(node): + if cert[4] == 'name' and cert[5] == 'date': + date = cert[7] + elif cert[4] == 'name' and cert[5] == 'author': + author = cert[7] + elif cert[4] == 'name' and cert[5] == 'branch': + branches.append(cert[7]) + name, email = rfc822.parseaddr(author) + if name: + brief_name = name + else: + brief_name = author + node_label[node] = '%s on %s\\n%s' % (node.abbrev(), + dot_escape(date), + dot_escape(brief_name)) + node_colour[node] = colour_from_string(author) + for branch in original_branches: + if branch in branches: + node_in_branch[node] = True + break + + # draw visited nodes; other nodes are not actually shown + for node in visited: + line = ' "%s" ' % (node) + options = [] + nodeopts = config.graphopts['nodeopts'] + for option in nodeopts: + if option == 'fillcolor' and node_colour.has_key(node): + value = '#'+node_colour[node] + elif option == 'shape' and node == revision: + value = 'hexagon' + else: + value = nodeopts[option] + options.append('%s="%s"' % (option, value)) + options.append('label="%s"' % (node_label[node])) + options.append('href="%s"' % link(node).uri()) + line += '[' + ','.join(options) + ']' + graph += line + '\n' + + for node in (nodes - visited): + graph += ' "%s" [style="invis",label=""]\n' % (node) + + for (from_node, to_node) in arcs: + if node_in_branch.has_key(from_node) and node_in_branch.has_key(to_node): + style = "solid" + else: + style = "dashed" + graph += ' "%s"->"%s" [style="%s"]\n' % (from_node, to_node, style) + graph += '}' + return graph + +def ancestry_graph(revision): + dot_data = ancestry_dot(revision) + # okay, let's output the graph + graph_sha = sha.new(dot_data).hexdigest() + output_directory = os.path.join(config.graphopts['directory'], revision) + if not os.access(output_directory, os.R_OK): + os.mkdir(output_directory) + dot_file = os.path.join(output_directory, graph_sha+'.dot') + output_png = os.path.join(output_directory, 'graph.png') + output_imagemap = os.path.join(output_directory, 'imagemap.txt') + must_exist = (output_png, output_imagemap, dot_file) + if filter(lambda fname: not os.access(fname, os.R_OK), must_exist): + open(dot_file, 'w').write(dot_data) + command = "%s -Tcmapx -o %s -Tpng -o %s %s" % (config.graphopts['dot'], + output_imagemap, + output_png, + dot_file) + os.system(command) + return output_png, output_imagemap + +class RevisionGraph: def GET(self, revision): - revision = mtn.Revision(revision) - print "not implemented" + output_png, output_imagemap = ancestry_graph(revision) + if os.access(output_png, os.R_OK): + web.header('Content-Type', 'image/png') + sys.stdout.write(open(output_png).read()) + else: + return web.notfound() class Json: def GET(self, method, data): - print "Bah." + print "Bah." +class BranchHead: + def GET(self, head_method, proxy_to, branch, extra_path): + branch = mtn.Branch(branch) + valid = ('browse', 'file', 'downloadfile', 'info', 'tar', 'graph') + if not proxy_to in valid: + return web.notfound() + heads = [head for head in ops.heads(branch.name)] + if len(heads) == 0: + return web.notfound() + def proxyurl(revision): + return dynamic_join('/revision/' + proxy_to + '/' + revision + urllib.quote(extra_path)) + if len(heads) == 1 or head_method == 'anyhead': + web.redirect(proxyurl(heads[0])) + else: + # present an option to the user to choose the head + anyhead = 'link' % (dynamic_join('/branch/anyhead/' + proxy_to + '/' + branch.name)) + head_links = [] + for revision in heads: + author, date = '', '' + for cert in ops.certs(revision): + if cert[4] == 'name' and cert[5] == 'date': + date = cert[7] + elif cert[4] == 'name' and cert[5] == 'author': + author = mtn.Author(cert[7]) + head_links.append('%s %s at %s' % (proxyurl(revision), + revision.abbrev(), + link(author).html(), + hq(date))) + renderer.render('branchchoosehead.html', + page_title="Branch %s" % branch.name, + branch=branch, + proxy_to=proxy_to, + anyhead=anyhead, + head_links=head_links) + +class MimeIcon: + def GET(self, type, sub_type): + mime_type = type+'/'+sub_type + icon_file = mimeicon.lookup(mime_type) + if icon_file: + web.header('Content-Type', 'image/png') + sys.stdout.write(open(icon_file).read()) + else: + return web.notfound() + +class RobotsTxt: + def GET(self): + web.header('Content-Type', 'text/plain') + print "User-agent: *" + for revision_page in ['tar', 'downloadfile', 'graph']: + for access_method in ['/revision/', '/branch/head/', '/branch/anyhead/']: + print "Disallow:", access_method + revision_page + branch_re = r'' urls = ( - '/', 'Index', - '/about', 'About', - '/tags', 'Tags', - '/help', 'Help', - '/json/(A-Za-z)/(.*)', 'Json', + r'/', 'Index', #done + r'/about', 'About', #done + r'/tags', 'Tags', #done + r'/help', 'Help', #done + r'/json/(A-Za-z)/(.*)', 'Json', - '/revision/browse/('+mtn.revision_re+')/(.*)', 'RevisionBrowse', - '/revision/diff/('+mtn.revision_re+')/with/('+mtn.revision_re+')', 'RevisionDiff', - '/revision/file/('+mtn.revision_re+')/(.*)', 'RevisionFile', - '/revision/info/('+mtn.revision_re+')', 'RevisionInfo', - '/revision/tar/('+mtn.revision_re+')', 'RevisionTar', + r'/revision/browse/('+mtn.revision_re+')/(.*)', 'RevisionBrowse', + r'/revision/browse/('+mtn.revision_re+')()', 'RevisionBrowse', + r'/revision/diff/('+mtn.revision_re+')/with/('+mtn.revision_re+')', 'RevisionDiff', + r'/revision/diff/('+mtn.revision_re+')/with/('+mtn.revision_re+')'+'/(.*)', 'RevisionDiff', + r'/revision/file/('+mtn.revision_re+')/(.*)', 'RevisionFile', + r'/revision/downloadfile/('+mtn.revision_re+')/(.*)', 'RevisionDownloadFile', + r'/revision/info/('+mtn.revision_re+')', 'RevisionInfo', + r'/revision/tar/('+mtn.revision_re+')', 'RevisionTar', + r'/revision/graph/('+mtn.revision_re+')', 'RevisionGraph', - '/branch/changes/(.*)', 'BranchChanges', - '/branch/head/(.*)', 'BranchHead', - '/branch/tar/(.*)', 'BranchTar' + r'/branch/changes/(.*)/from/(\d+)/to/(\d+)', 'HTMLBranchChanges', + r'/branch/changes/([^/]+)()()', 'HTMLBranchChanges', + r'/branch/changes/(.*)/from/(\d+)/to/(\d+)/rss', 'RSSBranchChanges', + r'/branch/changes/([^/]+)()()/rss', 'RSSBranchChanges', + # let's make it possible to access any function on the head revision + # through this proxy method; it'll return a redirect to the head revision + # with the specified function + r'/branch/(head)/([A-Za-z]+)/([^/]+)(.*)', 'BranchHead', + r'/branch/(anyhead)/([A-Za-z]+)/([^/]+)(.*)', 'BranchHead', + + r'/static/(.*)', 'Static', + r'/robots.txt', 'RobotsTxt', + r'/mimeicon/([A-Za-z0-9][a-z0-9\-\+\.]*)/([A-Za-z0-9][a-z0-9\-\+\.]*)', 'MimeIcon', ) if __name__ == '__main__':