# HG changeset patch # User cmlenz # Date 1151952807 0 # Node ID 93b4dcbafd7b09b5f53bf57ef1c4998541b2a889 # Parent ee669cb9cccc1241658be630e5703af5f2a01901 Copy Trac to main branch. diff --git a/examples/trac/AUTHORS b/examples/trac/AUTHORS new file mode 100644 --- /dev/null +++ b/examples/trac/AUTHORS @@ -0,0 +1,12 @@ + * Jonas Borgstrm + * Daniel Lundin + * Rocky Burt + * Christopher Lenz + * Francois Harvey + * Mark Rowe + * Matthew Good + * Christian Boos + * Emmanual Blot + * Alec Thomas + +See also THANKS for people who have contributed to the project. diff --git a/examples/trac/COPYING b/examples/trac/COPYING new file mode 100644 --- /dev/null +++ b/examples/trac/COPYING @@ -0,0 +1,28 @@ +Copyright (C) 2003-2006 Edgewall Software +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + 3. The name of the author may not be used to endorse or promote + products derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER +IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/examples/trac/ChangeLog b/examples/trac/ChangeLog new file mode 100644 --- /dev/null +++ b/examples/trac/ChangeLog @@ -0,0 +1,297 @@ +Trac 0.9.5 (Apr 18, 2006) +http://svn.edgewall.com/repos/trac/tags/trac-0.9.5 + + * Fixed wiki macro XSS vulnerability found by Mr. Kazuhiro Nishiyama: + http://jvn.jp/jp/JVN%2384091359/index.html + * Smaller memory usage when accessing subversion history. + * Fixed issue with incorrectly generated urls when installed behind a web + proxy (#2531). + * Fixed bugs: #2531, #2777, #3020. + +Trac 0.9.4 (Feb 15, 2006) +http://svn.edgewall.com/repos/trac/tags/trac-0.9.4 + + * Deletion of reports has been fixed. + * Various encoding issues with the timeline RSS feed have been fixed. + * Fixed a memory leak when syncing with the repository. + * Milestones in the roadmap are now ordered more intelligently. + * Fixed bugs: #1064, #1150, #2006, #2253, #2324, #2330, #2408, #2430, + #2431, #2459, #2544, #2459, #2481, #2485, #2536, #2544, #2553, + #2580, #2583, #2606, #2613, #2621, #2664, #2666, #2680, #2706, + #2707, #2735 + +Trac 0.9.3 (Jan 8, 2006) +http://svn.edgewall.com/repos/trac/tags/trac-0.9.3 + + * Fixed XSS vulnerabilities. + * Timeline RSS feed validity issue resolved. + * "trac-admin initenv" now handles empty repositories. + * Textile unicode support. + * Fixed bugs: #1158, #2290, #2337, #2416, #2440, #2468, #2473, #2484, + #2490, #2493, #2512, #2517, #2519, #2527, #2548, #2558, #2558 + +Trac 0.9.2 (Dec 5, 2005) +http://svn.edgewall.com/repos/trac/tags/trac-0.9.2 + + * Fixed SQL injection vulnerability in ticket search module. + * Fixed broken ticket email notifications. + +Trac 0.9.1 (Dec 1, 2005) +http://svn.edgewall.com/repos/trac/tags/trac-0.9.1 + + * Fixed SQL injection vulnerability in ticket query module. + * Fixed bugs: #1633, #2167, #2283, #2284, #2285, #2291, #2292, #2300, + #2318, #2329, #2366, #2369, #2373, #2383, #2416, #2457 + + +Trac 0.9 (Oct 31, 2005) +http://svn.edgewall.com/repos/trac/tags/trac-0.9 + + * Support for a global trac.ini configuration file. + * Changed logic for enabling plugins/components. + * Improved support for handling repository subsets. + * Fixes problems with Trac links when using multiple environments in the + same Python interpreter. + * Improvements to email notification layout and encoding. + * Fixes for database locking with SQLite, in particular in a multi-threaded + environment. + * PostgreSQL compatibility fixes. + * Fixed bugs: #804, #861, #927, #1044, #1051, #1123, #1153, #1169, + #1239, #1344, #1463, #1562, #1881, #1886, #1895, #1909, #1921, #1930, + #1983, #1988, #2019, #2051, #2061, #2229, #2106, #2107, #2116, #2120, + #2124, #2129, #2135, #2136, #2138, #2140, #2144, #2164, #2166, #2170, + #2172, #2191, #2192, #2196, #2201, #2202, #2203, #2208, #2215, #2218, + #2223, #2230, #2232, #2239, #2240, #2241, #2243, #2251, + + +Trac 0.9-beta2 (Sept 25, 2005) +http://svn.edgewall.com/repos/trac/tags/trac-0.9b2 + + * Support for setuptools 0.6. + * Allow insertion of a custom HTML snippet above the new ticket form + to explain site-specific policies and/or guidelines. + * Much improved Bugzilla import script. + * Fixed a bug where deleting a wiki page version would sometimes delete + the entire page. + * Fixes for the rendering of diffs and patches. + * Fixes for the Subversion authz support. + * Fixed bugs: #2008, #2032, #2034, #1801, #1893, #1040, #2040, + #1036, #1944, #1081, #1863, #2052, #2066, #2016, #2090, #1985, + #2012, #2089, #2079, #1999, #2029, #2079, #1960, #2080, #2021, + #2042, #2088, #1345, #2011, #2100, #2103, #2113, #2116, #2109 + + +Trac 0.9-beta1 (Sept 5, 2005) +http://svn.edgewall.com/repos/trac/tags/trac-0.9b1 + + Trac 0.9 contains a great number of new features, improvements and + bug fixes. The following list contains only a few highlights: + + * License changed from GPL to modified BSD (See the file COPYING). + * Improved modularity and extendibility (plugin support). + * Support for both pysqlite 1.x and pysqlite 2.x. + * Postgresql database support (with psycopg or pyPgSQL). + * Repository subsets. Multiple Trac environments can share a single + repository. + * Version control abstraction layer making it possible to support + other version control systems besides subversion in the future. + * FastCGI frontend support. + * Python version >= 2.3 is now required. + + The complete list of closed tickets can be found here: + http://projects.edgewall.com/trac/query?status=closed&milestone=0.9 + + +Trac 0.8.3 (Jun 15, 2005) +http://svn.edgewall.com/repos/trac/tags/trac-0.8.3 + + * Fix compatibility of 'trac-admin resync' with Subversion >= 1.2. + * Settings page now works correctly when Trac is deployed at the + root of a host. + * Windows packaging issues resolved. + * Fixed bugs: #1282, #1500, #1648 + + +Trac 0.8.2 (Jun 1, 2005) +http://svn.edgewall.com/repos/trac/tags/trac-0.8.2 + + * Compatibility with Subversion >= 1.2 fixed. + * Compatibility with Docutils >= 0.3.7 fixed. + * Fixed bugs: #1020, #1302, #1500, #1182, #1339, #1518 + #1525, #1618 + + +Trac 0.8.1 (Feb 28, 2005) +http://svn.edgewall.com/repos/trac/tags/trac-0.8.1 + + * Improved Python 2.1 compatibility. + * Layout of navigation bar in Opera fixed. + * Execution of Javascript through event handler attributes + in HTML code is now forbidden. + * Fixed bugs: #157, #371, #556, #683, #970, #971, #972, + #974, #979, #983, #1001, #1003, #1007, #1008, #1011, + #1020, #1026, #1030, #1045, #1054, #1070, #1072, #1074, + #1076, #1087, #1090, #1103, #1108, #1111, #1136, #1159, + #1164, #1190, #1195, #1220 + + +Trac 0.8 'Qualia' (Nov 15, 2004) +http://svn.edgewall.com/repos/trac/tags/trac-0.8 + + * Roadmap module. + * Support for custom ticket properties. + * Wiki administration features. + * Advanced ticket queries. + * Improved diff display. + * User preferences. + * Wiki editing (near-wysiwyg) aids a la wikipedia. + * Improved email notification. + * Fixed bugs: #13, #63, #99, #100, #158, #164, #203, #210, + #225, #264, #304, #306, #326, #346, #347, #351, + #352, #364, #373, #375, #405, #411, #416, #431, + #433, #434, #436, #438, #443, #445, #446, #447, + #450, #452, #453, #455, #458, #460, #465, #468, + #471, #472, #473, #474, #477, #478, #479, #480, + #482, #483, #486, #487, #489, #491, #492, #494, + #496, #501, #503, #506, #510, #512, #513, #514, + #516, #522, #524, #526, #527, #528, #530, #532, + #536, #537, #538, #539, #542, #543, #545, #546, + #550, #551, #552, #553, #555, #556, #557, #558, + #559, #560, #565, #567, #568, #570, #572, #574, + #577, #578, #580, #581, #583, #587, #589, #591, + #593, #594, #597, #598, #599, #600, #601, #602, + #606, #609, #610, #612, #613, #616, #618, #619, + #620, #622, #623, #626, #627, #628, #630, #631, + #634, #644, #647, #648, #651, #652, #657, #658, + #660, #664, #668, #669, #670, #671, #674, #675, + #676, #677, #678, #680, #690, #692, #696, #698, + #699, #703, #705, #706, #708, #709, #713, #714, + #715, #716, #718, #720, #721, #722, #726, #727, + #730, #732, #734, #735, #736, #737, #738, #741, + #742, #743, #744, #745, #748, #749, #750, #751, + #752, #759, #762, #764, #768, #769, #770, #771, + #774, #775, #776, #778, #779, #780, #785, #789, + #793, #798, #800, #806, #807, #815, #816, #817, + #818, #829, #830, #831, #833, #836, #844, #846, + #848, #850, #851, #852, #872, #873, #877, #878, + #885, #888, #889, #892, #901, #903, #907, #912, + #916, #923, #929, #931, #932, #935 + + +Trac 0.7.1 'Argento' (Jun, 2004) +http://svn.edgewall.com/repos/trac/tags/trac-0.7.1 + + * Bugfixes for 0.7 + * Fixes security hole in auth.py + * Experimental support for mod_python + * Improved MIME-types + * Fixed bugs: #93, #202, #307, #312, #342, #345, #350, #353, #355, #391, + #393, #401, #404, #406, #415, #417, #419, #420, #421, #422, #424, + #425, #428, #429, #432, #435, #437, #441, #442, #448, #451, #452, + #456, #457, #461, #463, #466, #467, #470, #497, #498, #502, #504 + + +Trac 0.7 'Fulci' (May 18, 2004) +http://svn.edgewall.com/repos/trac/tags/trac-0.7 + + * Revised database format (requires manual upgrade). + * Trac standalone daemon, tracd (Experimental). + * Greatly improved browser. + * Many usability improvements. + * Clean-up of CSS and templates. + * UTF-8 character encoding support. + * Wiki page attachments. + * Syntax coloring supporting >35 languages, using SilverCity or GNU Enscript. + * Better support for ReStructuredText. + * Logging support, including syslog and windows eventlog. + * Ticket attachments. + * Import tickets from Bugzilla (contributed by Mark Rowe). + * Import tickets from SourceForge (contributed by Dmitry Yusupov). + * New ticket field: keywords + * Ticket email notification. + * Localized date and time display. + * Viewable SQL for reports. + * Improved search facilities. + * Windows installer package. + * More documentation. + * Fixed bugs: #14, #19, #27, #62, #87, #96, #106, #111, #115, #127, #146, + #161, #166, #171, #180, #182, #183, #188, #190, #191, #192, #193, + #195, #196, #197, #201, #205, #207, #211, #212, #213, #220, #224, + #227, #228, #231, #233, #235, #236, #240, #241, #243, #244, #246, + #247, #248, #249, #251, #252, #253, #254, #255, #258, #259, #261, + #262, #263, #265, #270, #271, #273, #275, #277, #278, #281, #284, + #285, #88, #289, #292, #293, #294, #296, #300, #302, #310, #313, + #314, #315, #316, #320, #322, #328, #332, #333, #337, #338, #339, + #340, #341, #344, #348, #349, #358, #361, #362, #363, #368, #370, + #371, #372, #376, #377, #378, #381, #384, #385, #386, #387, #388, + #392, #394, #396, #397, #398, #399, #402, #403, #410 + + +Trac 0.6.1 '245 Trioxin (April 12, 2004) +http://svn.edgewall.com/repos/trac/tags/trac-0.6.1 + + * RSS now escapes entities in summary. + * Search results won't highlight dates anymore. + * RPM for SuSE Linux. + * Preliminary Windows Installer. + * More documentation. + * Fixed bugs: #163, #165, #189, #198, #200, #206, #209, #214, #223 + + +Trac 0.6 'Solanum' (March 23, 2004) +http://svn.edgewall.com/repos/trac/tags/trac-0.6 + + * View diffs between wiki page edits. + * Improved Search module. + * Support for tables in the wiki. (Thanks to Stephen Hansen) + * Colored reports. Use colors to show priority, etc. + * Support for custom wiki processor macros. + * ReStructuredText markup support (through a processor macro) + * HTML markup support (through a processor macro) + * Report groups. Group results by a column. + * Multi-line report rows. + * Download report in CSV (Comma Separated Value) and tab-separated format + * RSS 2.0 content syndication support in Timeline, Reports and Log/Browser + * Better, locale-based date and time formatting. + * Wiki RecentChanges support. + * Overall usability, consistency and cosmetic improvements. + * More documentation. + * Fixed bugs: #16, #68, #81, #88, #98, #101, #102, #103, #104, #105, + #110, #112, #113, #114, #117, #119, #120, #131, #132, #134, + #135, #136, #138, #142, #145, #147, #151, #155, #170, #173, + #174, #175, #177, #179 + + +Trac 0.5.2 'Nameless' (March 2, 2004) +http://svn.edgewall.com/repos/trac/tags/trac-0.5.2 + + * Performance improvements. + * Better unicode support in commit-messages. + * TRAC_ADMIN is now a real "meta-permission" containing all other permissions. + * Wiki-links of the svn:/path format can now also link to directories. + * Handle subversion changesets without any "author" specified. + * "view" checkboxes in the timeline view now reflect the current state. + * The subversion repository is now indexed by "svnadmin initdb" instead of + trac.cgi at first execution. + * trac-admin now has a 'wiki dump' and 'wiki load' commands to + export/import all pages to/from a directory. + * Most of the inline css is removed. + * IE6 navbar problem fixed. + * Fixed bugs: #69, #73, #77, #78, #79, #80, #84, #85, #86, #89, #90, + #91, #93, #97 + + +Trac 0.5.1 'Unnamed' (February 25, 2004) +http://svn.edgewall.com/repos/trac/tags/trac-0.5.1 + + * Navbar now works properly on error pages. + * Cleaned up source code. Removed ugly tabs. + * Added missing COPYING, AUTHORS etc. Cleaned up package. + * trac-admin now works with python 2.1. + * Fixed bugs: #74, #75, #76, #77 + + +Trac 0.5 'Incognito' (February 23, 2004) +http://svn.edgewall.com/repos/trac/tags/trac-0.5 + + * First release. diff --git a/examples/trac/INSTALL b/examples/trac/INSTALL new file mode 100644 --- /dev/null +++ b/examples/trac/INSTALL @@ -0,0 +1,114 @@ +Trac Installation Guide +======================= +Trac is a lightweight project management tool that is implemented as a +web-based application. Trac is written in the Python programming language and +can use SQLite or PostgreSQL as database. For HTML rendering, Trac uses the +Clearsilver templating system. + + +Requirements +------------ +To install Trac, the following software packages must be installed: + + * Python, version >= 2.3. + + Please keep in mind, that for RPM-based systems you will also need + python-devel and python-xml packages. + * Subversion, version >= 1.0. (>= 1.1.x recommended) + * Subversion SWIG Python bindings (not PySVN). + * PySQLite, version >= 0.5 (1.1.6 recommended) + * Clearsilver, version >= 0.9.3 (0.9.14 recommended) + * A web server capable of executing CGI/FastCGI scripts, or Apache HTTPD with + mod_python. (Trac also comes with a standalone server, but its use is not + recommended for use in a production environment.) + + +Installing Trac +--------------- +The command: + + $ python ./setup.py install + +will byte-compile the python source code and install it in the +site-packages directory of your python installation. The directories cgi-bin, +templates, htdocs and wiki-default are all copied to ``$prefix/share/trac/``. + +The script will also install the trac-admin command-line tool, used to create +and maintain project environments. Trac-admin is the command center of Trac. + +Note: you'll need root permissions or equivalent for this step. + +To install Trac in a different location, or use other advanced installation +options, run: + + $ python ./setup.py --help + + +Installing Trac on Windows +-------------------------- +If you downloaded the Trac installer (the .exe file), installing is simply a +matter of running the installer. After running the installer, configuration +and installation is the same as for other platforms. + + +Creating a Project Environment +------------------------------ +A Trac environment is the backend storage format where Trac stores information +like wiki pages, tickets, reports, settings, etc. A Trac environment consists +of the environment configuration file (trac.ini), custom templates, log files, +and more. + +A new Trac environment is created with trac-admin: + + $ trac-admin /path/to/projectenv initenv + +Note: The user account under which the web server is run needs write permission +to the environment directory and all the files inside. + +trac-admin will prompt you for the name of the project, where your subversion +repository is located, what database you want to use, etc. + + +Running the Standalone Server +----------------------------- +After having created a Trac environment, you can easily try the web interface +by running the standalone server tracd: + + $ tracd --port 8000 /path/to/projectenv + +Then, fire up a browser and visit http://localhost:8000/. You should get a +simple listing of all environments that tracd knows about. Follow the link +to the environment you just created, and you should see Trac in action. + + +Running Trac on a Web Server +---------------------------- +Trac provides three options for connecting to a "real" web server: CGI, FastCGI +and mod_python. For decent performance, it is recommended that you use either +FastCGI or mod_python. + +Please refer to the TracInstall page for details on these setups. You can find +it either in the wiki of the Trac project you just created, or on the main Trac +site. + + +Using Trac +---------- +Once you have your Trac site up and running, you should be able to browse your +subversion repository, create tickets, view the timeline, etc. + +Keep in mind that anonymous (not logged in) users can by default access most +but not all of the features. You will need to configure authentication and +grant additional permissions to authenticated users to see the full set of +features. + +For further documentation, see the TracGuide wiki page. + +Enjoy! + +/The Trac Team + +Please also consider joining the mailing list at +. + + +Visit the Trac open source project at diff --git a/examples/trac/MANIFEST.in b/examples/trac/MANIFEST.in new file mode 100644 --- /dev/null +++ b/examples/trac/MANIFEST.in @@ -0,0 +1,35 @@ +include AUTHORS ChangeLog COPYING INSTALL MANIFEST.in README README.tracd RELEASE setup.cfg THANKS UPGRADE +include scripts/trac-admin +include scripts/tracd +include scripts/tracdb2env +include scripts/trac-postinstall.py +include scripts/*.1 +include scripts/rpm-install.sh +include cgi-bin/trac.cgi +include cgi-bin/trac.fcgi +include trac/tests/*.py +include trac/web/tests/*.py +include trac/wiki/tests/*.py +include trac/wiki/tests/wiki-tests.txt +include trac/ticket/tests/*.py +include trac/scripts/tests/*.py +include trac/scripts/tests/admin-tests.txt +include trac/versioncontrol/tests/*.py +include trac/versioncontrol/tests/svnrepos.dump +recursive-include htdocs *.png *.gif *.ico *.js *README +recursive-include htdocs/css *.css +recursive-include htdocs/js *.js +recursive-include doc *trac_logo.* *README +recursive-include wiki-default * +recursive-include wiki-macros *.py +recursive-include templates *.cs *README +recursive-include contrib * +global-exclude *~ +global-exclude *.tmp +global-exclude *.bak +global-exclude *.old +global-exclude .svn +global-exclude .svn/* +global-exclude .svn/*/* +global-exclude .svn/*/*/* +exclude wiki-default/checkwiki.py* diff --git a/examples/trac/README b/examples/trac/README new file mode 100644 --- /dev/null +++ b/examples/trac/README @@ -0,0 +1,24 @@ +About Trac +========== + +Trac is a minimalistic web-based software project management and bug/issue +tracking system. It provides an interface to the Subversion revision control +systems, an integrated wiki, flexible issue tracking and convenient report +facilities. + +Trac is distributed using the modified BSD License. + + * For installation instructions, please see the INSTALL. * + * If you are upgrading from a previous Trac version, please read UPGRADE. * + +You might also want to take a look at the RELEASE and ChangeLog files for more +information. + +Otherwise, the primary source of information is the main Trac web site: + + + +We hope you enjoy it, + +/The Trac Team + diff --git a/examples/trac/README.tracd b/examples/trac/README.tracd new file mode 100644 --- /dev/null +++ b/examples/trac/README.tracd @@ -0,0 +1,50 @@ +Trac in stand-alone mode +======================== + +Trac 0.7 introduced among many other important features, the capability to run +Trac as a stand-alone server (daemon), without a web server. + +Tracd supports all features of the CGI version (trac.cgi), +and can serve multiple projects from a single server instance. + +Running tracd +------------- + + tracd [options] [database] ... + + Options: + +-a, --auth Per-project authentication information +-p, --port Port number to use (default: 80) +-b, --hostname IP to bind to (default: '') + + +Example 1: Single Project (non-authenticated) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + $ tracd -p 9090 /var/trac/myproject + +Example 2: Multiple Projects (authenticated) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + $ tracd -p 9090 \ + -a projectA,/var/trac/htdigest.ALPHA,ALPHA \ + -a projectB,/var/trac/htdigest.ALPHA,ALPHA \ + /var/trac/projectA \ + /var/trac/projectB + +The file ``htdigest.ALPHA`` can be generated using the Apache2 tool ``htdigest`` +(be carefull *NOT* to use ``htpasswd`` here). + +Feedback and bug reports +------------------------ + +Please provide feedback on tracd using the issue tracker or the mailing list. + + Submit a bug report: http://projects.edgewall.com/trac/newticket?component=tracd + Mailing list info: http://projects.edgewall.com/trac/wiki/MailingList + + +Thanks, + +/The Trac Team (http://trac.edgewall.com/) \ No newline at end of file diff --git a/examples/trac/RELEASE b/examples/trac/RELEASE new file mode 100644 --- /dev/null +++ b/examples/trac/RELEASE @@ -0,0 +1,66 @@ +Trac 0.9.5 Release Notes +============================ +April 18, 2006 + +We're proud to present our latest release - Trac 0.9.5. + +Trac is an enhanced wiki and issue tracking system, integrated with +Subversion, for software development projects. Trac uses a minimalistic +approach to web-based software project management. Our mission is simple; to +help developers write great software while staying out of the way. Trac should +impose as little as possible on a team's established development process and +policies. + +Trac allows wiki markup in issue descriptions and commit messages, creating +links and useful structure between bugs, tasks, changesets, files and wiki +pages. A timeline view presents all project events in chronological order, +making tracking progress or getting an overview of a project easy. + +For more information, please visit the main Trac web site: + + + +The software, published under the modified BSD License, is +available at: + + + +Please report problems and provide feedback in the project issue tracker: + + + +For questions, comments and user discussions, please use the Trac mailing list. +List information, subscription and archive available at: + + + + +What's New +---------- +A brief summary of major changes for version 0.9.5: + + * Fixed wiki macro XSS vulnerability. + * Smaller memory usage when accessing subversion history. + * Fixed issue with incorrectly generated urls when installed behind a web + proxy. + +For a more complete list of improvements, see the ChangeLog at: + + + + +Acknowledgements +---------------- +Many thanks to the growing number of people who have, and continue to, +support the project. Also our thanks to all people providing feedback and bug +reports that helps us make Trac better, easier to use and more effective. + +Without your invaluable help, Trac would not evolve. Thank you all. + +Finally, we offer hope that Trac will prove itself useful to like-minded +programmers around the world, and that this release will prove an improvement +over the last version. + +Please let us know. :-) + +/The Trac Team diff --git a/examples/trac/THANKS b/examples/trac/THANKS new file mode 100644 --- /dev/null +++ b/examples/trac/THANKS @@ -0,0 +1,68 @@ + * Brad Anderson brad@dsource.org + * Christopher Armstrong radix + * Jani Averbach jaa@jaa.iki.fi + * Juanma Barranquero lektu@terra.es + * Christian Boos cboos@bct-technology.com + * Rocky Burt rocky.burt@myrealbox.com + * Toni Brkic toni.brkic@switchcore.com + * Felix Colins felix@keyghost.com + * Wesley Crucius wcrucius@sandc.com + * dju' + * Daragh Fitzpatrick Daragh@i2i-Tech.com + * Markus Fuchs + * Eric Gillespie epg@netbsd.org + * Matthew Good trac@matt-good.net + * Shun-ichi Goto gotoh@taiyo.co.jp + * Chris Green cmgreen@uab.edu + * Mikael Hallendal micke@imendio.com + * Stephen Hansen shansen@advpubtech.com + * Laurie Harper zodiac@holoweb.net + * Francois Harvey fharvey@securiweb.net + * Tim Hatch trac@timhatch.com + * Michael Hope michael.hope@hamjet.co.nz + * Richard Hult richard@imendio.com + * Nuutti Kotivuori naked@iki.fi + * Ian Leader ian.leader@line.co.uk + * Christopher Lenz cmlenz@gmx.de + * Ivo Looser ivo.looser@gmail.com + * Rui Lopes rgl ruilopes com + * Angel Marin anmar@gmx.net + * Keir Mierle keir@cs.utoronto.ca + * James Moger jamesm@transonic.com + * Tim Moloney moloney@mrsl.com + * Jennifer Murtell jen@jmurtell.com + * Jacob Norda jacobnorda@gmail.com + * Jeroen Ruigrok van der Werven asmodai@in-nomine.org + * Juracy Filho juracy@gmail.com + * Cap Petschulat cap@cdres.com + * Nicholas Riley sabi + * Manuzhai manuzhai@gmail.com + * Mark Rowe mark.rowe@bdash.net.nz + * Olliver Rutherfurd ollie + * pkou pkou@ua.fm + * Andres Salomon dilinger@athenacr.com + * Michael Scherer misc@mandrake.org + * Andreas Schrattenecker vittorio + * Emmeran Seehuber rototor@rototor.de + * Noah Slater nslater@gmail.com + * Bill Soudan bill@soudan.net + * Ludvig Strigeus + * Alec Thomas alec@swapoff.org + * Jani Tiainen redetin@luukku.com + * Zilvinas Valinskas zilvinas@gemtek.lt + * Jason Vasquez jason@mugfu.com + * Jeff Weiss trac@jeffweiss.org + * Dmitry Yusupov dmitry_yus@yahoo.com + +The ever so elusive Anonymous. + +`Diggs and Lula`_ (official pawprint contributors) + +And everyone who keeps sending feedback, helping us improve Trac. + +---- + +Our apologies to everyone we forgot to mention, but without whose invaluable +help, Trac would not continue to rapidly evolve. + +.. _Diggs and Lula: http://people.edgewall.com/~daniel/lula_diggs.jpg diff --git a/examples/trac/UPGRADE b/examples/trac/UPGRADE new file mode 100644 --- /dev/null +++ b/examples/trac/UPGRADE @@ -0,0 +1,168 @@ +Upgrade Instructions +==================== + +A Trac environment sometimes needs to be upgraded before it can be used with +a new version of Trac. This document describes the steps necessary to upgrade +an environment. + +Note that Environment upgrades are not necessary for minor version releases +unless otherwise noted. For example, there's no need to upgrade a Trac +environment created with (or upgraded) 0.8.0 when installing 0.8.4 (or any +other 0.8.x release). + +General Instructions +-------------------- +Typically, there are four steps involved in upgrading to a newer version of +Trac: + +1. Update the Trac Code + +Get the new version of Trac, either by downloading an offical release package +or by checking it out from the Subversion repository. + +If you have a source distribution, you need to run + + python setup.py install + +to install the new version. If you've downloaded the Windows installer, you +execute it, and so on. + +In any case, if you're doing a major version upgrade (such as from 0.8 to +0.9), it is highly recommended that you first remove the existing Trac code. +To do this, you need to delete the `trac` directory from the Python +`lib/site-packages` directory. You may also want to remove the Trac `cgi-bin`, +`htdocs` and `templates` directories that are commonly found in a directory +called `share/trac` (the exact location depends on your platform). + +2. Upgrade the Trac Environment + +Unless noted otherwise, upgrading between major versions (such as 0.8 and +0.9) involves changes to the database schema, and possibly the layout of the +environment. Fortunately, Trac provides automated upgrade scripts to ease the +pain. These scripts are run via `trac-admin`: + + trac-admin /path/to/projenv upgrade + +This command will do nothing if the environment is already up-to-date. + +3. Update the Trac Documentation + +Every Trac environment includes a copy of the Trac documentation for the +installed version. As you probably want to keep the included documentation in +sync with the installed version of Trac, `trac-admin` provides a command to +upgrade the documentation: + + trac-admin /path/to/projenv wiki upgrade + +Note that this procedure will of course leave your `WikiStart` page intact. + +4. Restart the Web Server + +In order to reload the new Trac code you will need to restart your web +server (note this is not necessary for CGI). + + +The following sections discuss any extra actions that may need to be taken +to upgrade to specific versions of Trac. + + +From 0.9-beta to 0.9 +-------------------- + +If inclusion of the static resources (style sheets, javascript, images) is not +working, check the value of the `htdocs_location` in trac.ini. For mod_python, +Tracd and FastCGI, you can simply remove the option altogether. For CGI, you +should fix it to point to the URL you mapped the Trac `htdocs` directory to. + +If you've been using plugins with a beta release of Trac 0.9, or have +disabled some of the built-in components, you might have to update the rules +for disabling/enabling components in trac.ini. In particular, globally +installed plugins now need to be enabled explicitly. See the TracPlugins and +TracIni wiki pages for more information. + +If you want to enable the display of all ticket changes in the timeline (the +Ticket Details option), you now have to explicitly enable that in trac.ini, +too: + + [timeline] + ticket_show_details = true + + +From 0.8.x to 0.9 +----------------- + +mod_python users will need to change the name of the mod_python handler in +the Apache HTTPD configuration: + + from: PythonHandler trac.ModPythonHandler + to: PythonHandler trac.web.modpython_frontend + +If you have PySQLite 2.x installed, Trac will now try to open your SQLite +database using the SQLite 3.x file format. The database formats used by +SQLite 2.8.x and SQLite 3.x are incompatible. If you get an error like "file +is encrypted or is not a database" after upgrading, then you must convert +your database file. + +To do this, you need to have both SQLite 2.8.x and SQLite 3.x installed (they +have different filenames so can coexist on the same system). Then use the +following commands: + + mv trac.db trac2.db + sqlite trac2.db .dump | sqlite3 trac.db + +After testing that the conversion was successful, the `trac2.db` file can be +deleted. For more information on the SQLite upgrade see +http://www.sqlite.org/version3.html. + + +From 0.7.x to 0.8 +----------------- + +0.8 adds a new roadmap feature which requires additional permissions. While a +fresh installation will by default grant `ROADMAP_VIEW` and `MILESTONE_VIEW` +permissions to anonymous, these permissions have to be granted manually when +upgrading: + + trac-admin /path/to/projectenv permission add anonymous MILESTONE_VIEW + trac-admin /path/to/projectenv permission add anonymous ROADMAP_VIEW + + +From 0.6.x to 0.7 +----------------- +Trac 0.7 introduced a new database format, requiring manual upgrade. + +Previous versions of Trac stored wiki pages, ticket, reports, settings, +etc. in a single SQLite database file. Trac 0.7 replaces this file +with a new backend storage format; the 'Trac Environment', which is a +directory containing an SQLite database, a human-readable configuration file, +log-files and attachments. + +Fear not though, old-style Trac databases can easily be converted to +Environments using the included `tracdb2env` program as follows: + + tracdb2env /path/to/old/project.db /path/to/new/projectenv + +`tracdb2env` will create a new environment and copy the information from the +old database to the new environment. The existing database will not be +modified. + +You also need to update your apache configuration: + +Change the line: + + SetEnv TRAC_DB "/path/to/old/project.db" + +to: + + SetEnv TRAC_ENV "/path/to/new/projectenv" + + +---- + +If you have trouble upgrading Trac, please ask questions on the mailing list: + + + +Or for other support options, see: + + diff --git a/examples/trac/cgi-bin/trac.cgi b/examples/trac/cgi-bin/trac.cgi new file mode 100755 --- /dev/null +++ b/examples/trac/cgi-bin/trac.cgi @@ -0,0 +1,36 @@ +#!/usr/bin/python +# -*- coding: iso8859-1 -*- +# +# Copyright (C) 2003-2004 Edgewall Software +# Copyright (C) 2003-2004 Jonas Borgstrm +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Jonas Borgstrm + +try: + from trac.web import cgi_frontend + cgi_frontend.run() + +except Exception, e: + import sys + import traceback + + print>>sys.stderr, e + traceback.print_exc(file=sys.stderr) + + print 'Status: 500 Internal Server Error' + print 'Content-Type: text/plain' + print + print 'Oops...' + print + print 'Trac detected an internal error:', e + print + traceback.print_exc(file=sys.stdout) diff --git a/examples/trac/cgi-bin/trac.fcgi b/examples/trac/cgi-bin/trac.fcgi new file mode 100755 --- /dev/null +++ b/examples/trac/cgi-bin/trac.fcgi @@ -0,0 +1,33 @@ +#!/usr/bin/python +# -*- coding: iso8859-1 -*- +# +# Copyright (C) 2003-2004 Edgewall Software +# Copyright (C) 2003-2004 Jonas Borgstrm +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Jonas Borgstrm + +try: + from trac.web import fcgi_frontend + fcgi_frontend.run() +except Exception, e: + print 'Content-Type: text/plain\r\n\r\n', + print 'Oops...' + print + print 'Trac detected an internal error:' + print + print e + print + import traceback + import StringIO + tb = StringIO.StringIO() + traceback.print_exc(file=tb) + print tb.getvalue() diff --git a/examples/trac/contrib/README b/examples/trac/contrib/README new file mode 100644 --- /dev/null +++ b/examples/trac/contrib/README @@ -0,0 +1,7 @@ +This directory contains useful contributed scripts and programs for Trac. + +Please note that these scripts are provided AS-IS are NOT tested to the same +extent as the main sources. They are usually maintained by the original +author, typically listed at the top of the source file. + +See ../THANKS for a list of contributors. \ No newline at end of file diff --git a/examples/trac/contrib/bugzilla2trac.py b/examples/trac/contrib/bugzilla2trac.py new file mode 100644 --- /dev/null +++ b/examples/trac/contrib/bugzilla2trac.py @@ -0,0 +1,883 @@ +#!/usr/bin/env python + +""" +Import a Bugzilla items into a Trac database. + +Requires: Trac 0.9b1 from http://trac.edgewall.com/ + Python 2.3 from http://www.python.org/ + MySQL >= 3.23 from http://www.mysql.org/ + +Thanks: Mark Rowe + for original TracDatabase class + +Copyright 2004, Dmitry Yusupov + +Many enhancements, Bill Soudan +Other enhancements, Florent Guillaume +Reworked, Jeroen Ruigrok van der Werven + +$Id$ +""" + +import re + +### +### Conversion Settings -- edit these before running if desired +### + +# Bugzilla version. You can find this in Bugzilla's globals.pl file. +# +# Currently, the following bugzilla versions are known to work: +# 2.11 (2110), 2.16.5 (2165), 2.18.3 (2183), 2.19.1 (2191) +# +# If you run this script on a version not listed here and it is successful, +# please report it to the Trac mailing list and drop a note to +# asmodai@tendra.org so we can update the list. +BZ_VERSION = 2180 + +# MySQL connection parameters for the Bugzilla database. These can also +# be specified on the command line. +BZ_DB = "" +BZ_HOST = "" +BZ_USER = "" +BZ_PASSWORD = "" + +# Path to the Trac environment. +TRAC_ENV = "/usr/local/trac" + +# If true, all existing Trac tickets and attachments will be removed +# prior to import. +TRAC_CLEAN = True + +# Enclose imported ticket description and comments in a {{{ }}} +# preformat block? This formats the text in a fixed-point font. +PREFORMAT_COMMENTS = False + +# Replace bug numbers in comments with #xyz +REPLACE_BUG_NO = False + +# Severities +SEVERITIES = [ + ("blocker", "1"), + ("critical", "2"), + ("major", "3"), + ("normal", "4"), + ("minor", "5"), + ("trivial", "6") +] + +# Priorities +# If using the default Bugzilla priorities of P1 - P5, do not change anything +# here. +# If you have other priorities defined please change the P1 - P5 mapping to +# the order you want. You can also collapse multiple priorities on bugzilla's +# side into the same priority on Trac's side, simply adjust PRIORITIES_MAP. +PRIORITIES = [ + ("highest", "1"), + ("high", "2"), + ("normal", "3"), + ("low", "4"), + ("lowest", "5") +] + +# Bugzilla: Trac +# NOTE: Use lowercase. +PRIORITIES_MAP = { + "p1": "highest", + "p2": "high", + "p3": "normal", + "p4": "low", + "p5": "lowest" +} + +# By default, all bugs are imported from Bugzilla. If you add a list +# of products here, only bugs from those products will be imported. +PRODUCTS = [] +# These Bugzilla products will be ignored during import. +IGNORE_PRODUCTS = [] + +# These milestones are ignored +IGNORE_MILESTONES = ["---"] + +# These logins are converted to these user ids +LOGIN_MAP = { + #'some.user@example.com': 'someuser', +} + +# These emails are removed from CC list +IGNORE_CC = [ + #'loser@example.com', +] + +# The 'component' field in Trac can come either from the Product or +# or from the Component field of Bugzilla. COMPONENTS_FROM_PRODUCTS +# switches the behavior. +# If COMPONENTS_FROM_PRODUCTS is True: +# - Bugzilla Product -> Trac Component +# - Bugzilla Component -> Trac Keyword +# IF COMPONENTS_FROM_PRODUCTS is False: +# - Bugzilla Product -> Trac Keyword +# - Bugzilla Component -> Trac Component +COMPONENTS_FROM_PRODUCTS = False + +# If COMPONENTS_FROM_PRODUCTS is True, the default owner for each +# Trac component is inferred from a default Bugzilla component. +DEFAULT_COMPONENTS = ["default", "misc", "main"] + +# This mapping can assign keywords in the ticket entry to represent +# products or components (depending on COMPONENTS_FROM_PRODUCTS). +# The keyword will be ignored if empty. +KEYWORDS_MAPPING = { + #'Bugzilla_product_or_component': 'Keyword', + "default": "", + "misc": "", + } + +# If this is True, products or components are all set as keywords +# even if not mentionned in KEYWORDS_MAPPING. +MAP_ALL_KEYWORDS = True + + +# Bug comments that should not be imported. Each entry in list should +# be a regular expression. +IGNORE_COMMENTS = [ + "^Created an attachment \(id=" +] + +########################################################################### +### You probably don't need to change any configuration past this line. ### +########################################################################### + +# Bugzilla status to Trac status translation map. +# +# NOTE: bug activity is translated as well, which may cause bug +# activity to be deleted (e.g. resolved -> closed in Bugzilla +# would translate into closed -> closed in Trac, so we just ignore the +# change). +# +# There is some special magic for open in the code: if there is no +# Bugzilla owner, open is mapped to 'new' instead. +STATUS_TRANSLATE = { + "unconfirmed": "new", + "open": "assigned", + "resolved": "closed", + "verified": "closed", + "released": "closed" +} + +# Translate Bugzilla statuses into Trac keywords. This provides a way +# to retain the Bugzilla statuses in Trac. e.g. when a bug is marked +# 'verified' in Bugzilla it will be assigned a VERIFIED keyword. +STATUS_KEYWORDS = { + "verified": "VERIFIED", + "released": "RELEASED" +} + +# Some fields in Bugzilla do not have equivalents in Trac. Changes in +# fields listed here will not be imported into the ticket change history, +# otherwise you'd see changes for fields that don't exist in Trac. +IGNORED_ACTIVITY_FIELDS = ["everconfirmed"] + +# Regular expression and its replacement +BUG_NO_RE = re.compile(r"\b(bug #?)([0-9])") +BUG_NO_REPL = r"#\2" + +### +### Script begins here +### + +import os +import sys +import string +import StringIO + +import MySQLdb +import MySQLdb.cursors +try: + from trac.env import Environment +except: + from trac.Environment import Environment +from trac.attachment import Attachment + +if not hasattr(sys, 'setdefaultencoding'): + reload(sys) + +sys.setdefaultencoding('latin1') + +# simulated Attachment class for trac.add +#class Attachment: +# def __init__(self, name, data): +# self.filename = name +# self.file = StringIO.StringIO(data.tostring()) + +# simple field translation mapping. if string not in +# mapping, just return string, otherwise return value +class FieldTranslator(dict): + def __getitem__(self, item): + if not dict.has_key(self, item): + return item + + return dict.__getitem__(self, item) + +statusXlator = FieldTranslator(STATUS_TRANSLATE) + +class TracDatabase(object): + def __init__(self, path): + self.env = Environment(path) + self._db = self.env.get_db_cnx() + self._db.autocommit = False + self.loginNameCache = {} + self.fieldNameCache = {} + + def db(self): + return self._db + + def hasTickets(self): + c = self.db().cursor() + c.execute("SELECT count(*) FROM Ticket") + return int(c.fetchall()[0][0]) > 0 + + def assertNoTickets(self): + if self.hasTickets(): + raise Exception("Will not modify database with existing tickets!") + + def setSeverityList(self, s): + """Remove all severities, set them to `s`""" + self.assertNoTickets() + + c = self.db().cursor() + c.execute("DELETE FROM enum WHERE type='severity'") + for value, i in s: + print " inserting severity '%s' - '%s'" % (value, i) + c.execute("""INSERT INTO enum (type, name, value) + VALUES (%s, %s, %s)""", + ("severity", value.encode('utf-8'), i)) + self.db().commit() + + def setPriorityList(self, s): + """Remove all priorities, set them to `s`""" + self.assertNoTickets() + + c = self.db().cursor() + c.execute("DELETE FROM enum WHERE type='priority'") + for value, i in s: + print " inserting priority '%s' - '%s'" % (value, i) + c.execute("""INSERT INTO enum (type, name, value) + VALUES (%s, %s, %s)""", + ("priority", value.encode('utf-8'), i)) + self.db().commit() + + + def setComponentList(self, l, key): + """Remove all components, set them to `l`""" + self.assertNoTickets() + + c = self.db().cursor() + c.execute("DELETE FROM component") + for comp in l: + print " inserting component '%s', owner '%s'" % \ + (comp[key], comp['owner']) + c.execute("INSERT INTO component (name, owner) VALUES (%s, %s)", + (comp[key].encode('utf-8'), + comp['owner'].encode('utf-8'))) + self.db().commit() + + def setVersionList(self, v, key): + """Remove all versions, set them to `v`""" + self.assertNoTickets() + + c = self.db().cursor() + c.execute("DELETE FROM version") + for vers in v: + print " inserting version '%s'" % (vers[key]) + c.execute("INSERT INTO version (name) VALUES (%s)", + (vers[key].encode('utf-8'),)) + self.db().commit() + + def setMilestoneList(self, m, key): + """Remove all milestones, set them to `m`""" + self.assertNoTickets() + + c = self.db().cursor() + c.execute("DELETE FROM milestone") + for ms in m: + milestone = ms[key] + print " inserting milestone '%s'" % (milestone) + c.execute("INSERT INTO milestone (name) VALUES (%s)", + (milestone.encode('utf-8'),)) + self.db().commit() + + def addTicket(self, id, time, changetime, component, severity, priority, + owner, reporter, cc, version, milestone, status, resolution, + summary, description, keywords): + c = self.db().cursor() + + desc = description.encode('utf-8') + type = "defect" + + if severity.lower() == "enhancement": + severity = "minor" + type = "enhancement" + + if PREFORMAT_COMMENTS: + desc = '{{{\n%s\n}}}' % desc + + if REPLACE_BUG_NO: + if BUG_NO_RE.search(desc): + desc = re.sub(BUG_NO_RE, BUG_NO_REPL, desc) + + if PRIORITIES_MAP.has_key(priority): + priority = PRIORITIES_MAP[priority] + + print " inserting ticket %s -- %s" % (id, summary) + + c.execute("""INSERT INTO ticket (id, type, time, changetime, component, + severity, priority, owner, reporter, + cc, version, milestone, status, + resolution, summary, description, + keywords) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, %s, %s, %s)""", + (id, type.encode('utf-8'), time.strftime('%s'), + changetime.strftime('%s'), component.encode('utf-8'), + severity.encode('utf-8'), priority.encode('utf-8'), owner, + reporter, cc, version, milestone.encode('utf-8'), + status.lower(), resolution, summary.encode('utf-8'), desc, + keywords)) + + self.db().commit() + return self.db().get_last_id(c, 'ticket') + + def addTicketComment(self, ticket, time, author, value): + comment = value.encode('utf-8') + + if PREFORMAT_COMMENTS: + comment = '{{{\n%s\n}}}' % comment + + if REPLACE_BUG_NO: + if BUG_NO_RE.search(comment): + comment = re.sub(BUG_NO_RE, BUG_NO_REPL, comment) + + c = self.db().cursor() + c.execute("""INSERT INTO ticket_change (ticket, time, author, field, + oldvalue, newvalue) + VALUES (%s, %s, %s, %s, %s, %s)""", + (ticket, time.strftime('%s'), author, 'comment', '', comment)) + self.db().commit() + + def addTicketChange(self, ticket, time, author, field, oldvalue, newvalue): + c = self.db().cursor() + + if field == "owner": + if LOGIN_MAP.has_key(oldvalue): + oldvalue = LOGIN_MAP[oldvalue] + if LOGIN_MAP.has_key(newvalue): + newvalue = LOGIN_MAP[newvalue] + + if field == "priority": + if PRIORITIES_MAP.has_key(oldvalue.lower()): + oldvalue = PRIORITIES_MAP[oldvalue.lower()] + if PRIORITIES_MAP.has_key(newvalue.lower()): + newvalue = PRIORITIES_MAP[newvalue.lower()] + + # Doesn't make sense if we go from highest -> highest, for example. + if oldvalue == newvalue: + return + + c.execute("""INSERT INTO ticket_change (ticket, time, author, field, + oldvalue, newvalue) + VALUES (%s, %s, %s, %s, %s, %s)""", + (ticket, time.strftime('%s'), author, field, + oldvalue.encode('utf-8'), newvalue.encode('utf-8'))) + self.db().commit() + + def addAttachment(self, author, a): + description = a['description'].encode('utf-8') + id = a['bug_id'] + filename = a['filename'].encode('utf-8') + filedata = StringIO.StringIO(a['thedata'].tostring()) + filesize = len(filedata.getvalue()) + time = a['creation_ts'] + print " ->inserting attachment '%s' for ticket %s -- %s" % \ + (filename, id, description) + + attachment = Attachment(self.env, 'ticket', id) + attachment.author = author + attachment.description = description + attachment.insert(filename, filedata, filesize, time.strftime('%s')) + del attachment + + def getLoginName(self, cursor, userid): + if userid not in self.loginNameCache: + cursor.execute("SELECT * FROM profiles WHERE userid = %s", (userid)) + loginName = cursor.fetchall() + + if loginName: + loginName = loginName[0]['login_name'] + else: + print """WARNING: unknown bugzilla userid %d, recording as + anonymous""" % (userid) + loginName = "anonymous" + + loginName = LOGIN_MAP.get(loginName, loginName) + + self.loginNameCache[userid] = loginName + + return self.loginNameCache[userid] + + def getFieldName(self, cursor, fieldid): + if fieldid not in self.fieldNameCache: + cursor.execute("SELECT * FROM fielddefs WHERE fieldid = %s", + (fieldid)) + fieldName = cursor.fetchall() + + if fieldName: + fieldName = fieldName[0]['name'].lower() + else: + print "WARNING: unknown bugzilla fieldid %d, \ + recording as unknown" % (userid) + fieldName = "unknown" + + self.fieldNameCache[fieldid] = fieldName + + return self.fieldNameCache[fieldid] + +def makeWhereClause(fieldName, values, negative=False): + if not values: + return '' + if negative: + connector, op = ' AND ', '!=' + else: + connector, op = ' OR ', '=' + clause = connector.join(["%s %s '%s'" % (fieldName, op, value) for value in values]) + return ' ' + clause + +def convert(_db, _host, _user, _password, _env, _force): + activityFields = FieldTranslator() + + # account for older versions of bugzilla + print "Using Bugzilla v%s schema." % BZ_VERSION + if BZ_VERSION == 2110: + activityFields['removed'] = "oldvalue" + activityFields['added'] = "newvalue" + + # init Bugzilla environment + print "Bugzilla MySQL('%s':'%s':'%s':'%s'): connecting..." % \ + (_db, _host, _user, ("*" * len(_password))) + mysql_con = MySQLdb.connect(host=_host, + user=_user, passwd=_password, db=_db, compress=1, + cursorclass=MySQLdb.cursors.DictCursor) + mysql_cur = mysql_con.cursor() + + # init Trac environment + print "Trac SQLite('%s'): connecting..." % (_env) + trac = TracDatabase(_env) + + # force mode... + if _force == 1: + print "\nCleaning all tickets..." + c = trac.db().cursor() + c.execute("DELETE FROM ticket_change") + trac.db().commit() + + c.execute("DELETE FROM ticket") + trac.db().commit() + + c.execute("DELETE FROM attachment") + attachments_dir = os.path.join(os.path.normpath(trac.env.path), + "attachments") + # Straight from the Python documentation. + for root, dirs, files in os.walk(attachments_dir, topdown=False): + for name in files: + os.remove(os.path.join(root, name)) + for name in dirs: + os.rmdir(os.path.join(root, name)) + if not os.stat(attachments_dir): + os.mkdir(attachments_dir) + trac.db().commit() + print "All tickets cleaned..." + + + print "\n0. Filtering products..." + mysql_cur.execute("SELECT name FROM products") + products = [] + for line in mysql_cur.fetchall(): + product = line['name'] + if PRODUCTS and product not in PRODUCTS: + continue + if product in IGNORE_PRODUCTS: + continue + products.append(product) + PRODUCTS[:] = products + print " Using products", " ".join(PRODUCTS) + + print "\n1. Import severities..." + trac.setSeverityList(SEVERITIES) + + print "\n2. Import components..." + if not COMPONENTS_FROM_PRODUCTS: + if BZ_VERSION >= 2180: + sql = """SELECT DISTINCT c.name AS name, c.initialowner AS owner + FROM components AS c, products AS p + WHERE c.product_id = p.id AND""" + sql += makeWhereClause('p.name', PRODUCTS) + else: + sql = "SELECT value AS name, initialowner AS owner FROM components" + sql += " WHERE" + makeWhereClause('program', PRODUCTS) + mysql_cur.execute(sql) + components = mysql_cur.fetchall() + for component in components: + component['owner'] = trac.getLoginName(mysql_cur, + component['owner']) + trac.setComponentList(components, 'name') + else: + sql = """SELECT program AS product, value AS comp, initialowner AS owner + FROM components""" + sql += " WHERE" + makeWhereClause('program', PRODUCTS) + mysql_cur.execute(sql) + lines = mysql_cur.fetchall() + all_components = {} # product -> components + all_owners = {} # product, component -> owner + for line in lines: + product = line['product'] + comp = line['comp'] + owner = line['owner'] + all_components.setdefault(product, []).append(comp) + all_owners[(product, comp)] = owner + component_list = [] + for product, components in all_components.items(): + # find best default owner + default = None + for comp in DEFAULT_COMPONENTS: + if comp in components: + default = comp + break + if default is None: + default = components[0] + owner = all_owners[(product, default)] + owner_name = trac.getLoginName(mysql_cur, owner) + component_list.append({'product': product, 'owner': owner_name}) + trac.setComponentList(component_list, 'product') + + print "\n3. Import priorities..." + trac.setPriorityList(PRIORITIES) + + print "\n4. Import versions..." + if BZ_VERSION >= 2180: + sql = """SELECT DISTINCTROW versions.value AS value + FROM products, versions""" + sql += " WHERE" + makeWhereClause('products.name', PRODUCTS) + else: + sql = "SELECT DISTINCTROW value FROM versions" + sql += " WHERE" + makeWhereClause('program', PRODUCTS) + mysql_cur.execute(sql) + versions = mysql_cur.fetchall() + trac.setVersionList(versions, 'value') + + print "\n5. Import milestones..." + sql = "SELECT DISTINCT value FROM milestones" + sql += " WHERE" + makeWhereClause('value', IGNORE_MILESTONES, negative=True) + mysql_cur.execute(sql) + milestones = mysql_cur.fetchall() + trac.setMilestoneList(milestones, 'value') + + print "\n6. Retrieving bugs..." + sql = """SELECT DISTINCT b.*, c.name AS component, p.name AS product + FROM bugs AS b, components AS c, products AS p """ + sql += " WHERE (" + makeWhereClause('p.name', PRODUCTS) + sql += ") AND b.product_id = p.id" + sql += " AND b.component_id = c.id" + sql += " ORDER BY b.bug_id" + mysql_cur.execute(sql) + bugs = mysql_cur.fetchall() + + + print "\n7. Import bugs and bug activity..." + for bug in bugs: + bugid = bug['bug_id'] + + ticket = {} + keywords = [] + ticket['id'] = bugid + ticket['time'] = bug['creation_ts'] + ticket['changetime'] = bug['delta_ts'] + if COMPONENTS_FROM_PRODUCTS: + ticket['component'] = bug['product'] + else: + ticket['component'] = bug['component'] + ticket['severity'] = bug['bug_severity'] + ticket['priority'] = bug['priority'].lower() + + ticket['owner'] = trac.getLoginName(mysql_cur, bug['assigned_to']) + ticket['reporter'] = trac.getLoginName(mysql_cur, bug['reporter']) + + mysql_cur.execute("SELECT * FROM cc WHERE bug_id = %s", bugid) + cc_records = mysql_cur.fetchall() + cc_list = [] + for cc in cc_records: + cc_list.append(trac.getLoginName(mysql_cur, cc['who'])) + cc_list = [cc for cc in cc_list if '@' in cc and cc not in IGNORE_CC] + ticket['cc'] = string.join(cc_list, ', ') + + ticket['version'] = bug['version'] + + target_milestone = bug['target_milestone'] + if target_milestone in IGNORE_MILESTONES: + target_milestone = '' + ticket['milestone'] = target_milestone + + bug_status = bug['bug_status'].lower() + ticket['status'] = statusXlator[bug_status] + ticket['resolution'] = bug['resolution'].lower() + + # a bit of extra work to do open tickets + if bug_status == 'open': + if owner != '': + ticket['status'] = 'assigned' + else: + ticket['status'] = 'new' + + ticket['summary'] = bug['short_desc'] + + mysql_cur.execute("SELECT * FROM longdescs WHERE bug_id = %s" % bugid) + longdescs = list(mysql_cur.fetchall()) + + # check for empty 'longdescs[0]' field... + if len(longdescs) == 0: + ticket['description'] = '' + else: + ticket['description'] = longdescs[0]['thetext'] + del longdescs[0] + + for desc in longdescs: + ignore = False + for comment in IGNORE_COMMENTS: + if re.match(comment, desc['thetext']): + ignore = True + + if ignore: + continue + + trac.addTicketComment(ticket=bugid, + time = desc['bug_when'], + author=trac.getLoginName(mysql_cur, desc['who']), + value = desc['thetext']) + + mysql_cur.execute("""SELECT * FROM bugs_activity WHERE bug_id = %s + ORDER BY bug_when""" % bugid) + bugs_activity = mysql_cur.fetchall() + resolution = '' + ticketChanges = [] + keywords = [] + for activity in bugs_activity: + field_name = trac.getFieldName(mysql_cur, activity['fieldid']).lower() + + removed = activity[activityFields['removed']] + added = activity[activityFields['added']] + + # statuses and resolutions are in lowercase in trac + if field_name == "resolution" or field_name == "bug_status": + removed = removed.lower() + added = added.lower() + + # remember most recent resolution, we need this later + if field_name == "resolution": + resolution = added.lower() + + add_keywords = [] + remove_keywords = [] + + # convert bugzilla field names... + if field_name == "bug_severity": + field_name = "severity" + elif field_name == "assigned_to": + field_name = "owner" + elif field_name == "bug_status": + field_name = "status" + if removed in STATUS_KEYWORDS: + remove_keywords.append(STATUS_KEYWORDS[removed]) + if added in STATUS_KEYWORDS: + add_keywords.append(STATUS_KEYWORDS[added]) + added = statusXlator[added] + removed = statusXlator[removed] + elif field_name == "short_desc": + field_name = "summary" + elif field_name == "product" and COMPONENTS_FROM_PRODUCTS: + field_name = "component" + elif ((field_name == "product" and not COMPONENTS_FROM_PRODUCTS) or + (field_name == "component" and COMPONENTS_FROM_PRODUCTS)): + if MAP_ALL_KEYWORDS or removed in KEYWORDS_MAPPING: + kw = KEYWORDS_MAPPING.get(removed, removed) + if kw: + remove_keywords.append(kw) + if MAP_ALL_KEYWORDS or added in KEYWORDS_MAPPING: + kw = KEYWORDS_MAPPING.get(added, added) + if kw: + add_keywords.append(kw) + if field_name == "component": + # just keep the keyword change + added = removed = "" + elif field_name == "target_milestone": + field_name = "milestone" + if added in IGNORE_MILESTONES: + added = "" + if removed in IGNORE_MILESTONES: + removed = "" + + ticketChange = {} + ticketChange['ticket'] = bugid + ticketChange['time'] = activity['bug_when'] + ticketChange['author'] = trac.getLoginName(mysql_cur, + activity['who']) + ticketChange['field'] = field_name + ticketChange['oldvalue'] = removed + ticketChange['newvalue'] = added + + if add_keywords or remove_keywords: + # ensure removed ones are in old + old_keywords = keywords + [kw for kw in remove_keywords if kw + not in keywords] + # remove from new + keywords = [kw for kw in keywords if kw not in remove_keywords] + # add to new + keywords += [kw for kw in add_keywords if kw not in keywords] + if old_keywords != keywords: + ticketChangeKw = ticketChange.copy() + ticketChangeKw['field'] = "keywords" + ticketChangeKw['oldvalue'] = ' '.join(old_keywords) + ticketChangeKw['newvalue'] = ' '.join(keywords) + ticketChanges.append(ticketChangeKw) + + if field_name in IGNORED_ACTIVITY_FIELDS: + continue + + # Skip changes that have no effect (think translation!). + if added == removed: + continue + + # Bugzilla splits large summary changes into two records. + for oldChange in ticketChanges: + if (field_name == "summary" + and oldChange['field'] == ticketChange['field'] + and oldChange['time'] == ticketChange['time'] + and oldChange['author'] == ticketChange['author']): + oldChange['oldvalue'] += " " + ticketChange['oldvalue'] + oldChange['newvalue'] += " " + ticketChange['newvalue'] + break + # cc sometime appear in different activities with same time + if (field_name == "cc" \ + and oldChange['time'] == ticketChange['time']): + oldChange['newvalue'] += ", " + ticketChange['newvalue'] + break + else: + ticketChanges.append (ticketChange) + + for ticketChange in ticketChanges: + trac.addTicketChange (**ticketChange) + + # For some reason, bugzilla v2.11 seems to clear the resolution + # when you mark a bug as closed. Let's remember it and restore + # it if the ticket is closed but there's no resolution. + if not ticket['resolution'] and ticket['status'] == "closed": + ticket['resolution'] = resolution + + bug_status = bug['bug_status'] + if bug_status in STATUS_KEYWORDS: + kw = STATUS_KEYWORDS[bug_status] + if kw not in keywords: + keywords.append(kw) + + product = bug['product'] + if product in KEYWORDS_MAPPING and not COMPONENTS_FROM_PRODUCTS: + kw = KEYWORDS_MAPPING.get(product, product) + if kw and kw not in keywords: + keywords.append(kw) + + component = bug['component'] + if (COMPONENTS_FROM_PRODUCTS and \ + (MAP_ALL_KEYWORDS or component in KEYWORDS_MAPPING)): + kw = KEYWORDS_MAPPING.get(component, component) + if kw and kw not in keywords: + keywords.append(kw) + + ticket['keywords'] = string.join(keywords) + ticketid = trac.addTicket(**ticket) + + mysql_cur.execute("SELECT * FROM attachments WHERE bug_id = %s" % bugid) + attachments = mysql_cur.fetchall() + for a in attachments: + author = trac.getLoginName(mysql_cur, a['submitter_id']) + trac.addAttachment(author, a) + + print "\n8. Importing users and passwords..." + if BZ_VERSION >= 2180: + mysql_cur.execute("SELECT login_name, cryptpassword FROM profiles") + users = mysql_cur.fetchall() + htpasswd = file("htpasswd", 'w') + for user in users: + if LOGIN_MAP.has_key(user['login_name']): + login = LOGIN_MAP[user['login_name']] + else: + login = user['login_name'] + htpasswd.write(login + ":" + user['cryptpassword'] + "\n") + + htpasswd.close() + print " Bugzilla users converted to htpasswd format, see 'htpasswd'." + + print "\nAll tickets converted." + +def log(msg): + print "DEBUG: %s" % (msg) + +def usage(): + print """bugzilla2trac - Imports a bug database from Bugzilla into Trac. + +Usage: bugzilla2trac.py [options] + +Available Options: + --db - Bugzilla's database name + --tracenv /path/to/trac/env - Full path to Trac db environment + -h | --host - Bugzilla's DNS host name + -u | --user - Effective Bugzilla's database user + -p | --passwd - Bugzilla's user password + -c | --clean - Remove current Trac tickets before + importing + --help | help - This help info + +Additional configuration options can be defined directly in the script. +""" + sys.exit(0) + +def main(): + global BZ_DB, BZ_HOST, BZ_USER, BZ_PASSWORD, TRAC_ENV, TRAC_CLEAN + if len (sys.argv) > 1: + if sys.argv[1] in ['--help','help'] or len(sys.argv) < 4: + usage() + iter = 1 + while iter < len(sys.argv): + if sys.argv[iter] in ['--db'] and iter+1 < len(sys.argv): + BZ_DB = sys.argv[iter+1] + iter = iter + 1 + elif sys.argv[iter] in ['-h', '--host'] and iter+1 < len(sys.argv): + BZ_HOST = sys.argv[iter+1] + iter = iter + 1 + elif sys.argv[iter] in ['-u', '--user'] and iter+1 < len(sys.argv): + BZ_USER = sys.argv[iter+1] + iter = iter + 1 + elif sys.argv[iter] in ['-p', '--passwd'] and iter+1 < len(sys.argv): + BZ_PASSWORD = sys.argv[iter+1] + iter = iter + 1 + elif sys.argv[iter] in ['--tracenv'] and iter+1 < len(sys.argv): + TRAC_ENV = sys.argv[iter+1] + iter = iter + 1 + elif sys.argv[iter] in ['-c', '--clean']: + TRAC_CLEAN = 1 + else: + print "Error: unknown parameter: " + sys.argv[iter] + sys.exit(0) + iter = iter + 1 + + convert(BZ_DB, BZ_HOST, BZ_USER, BZ_PASSWORD, TRAC_ENV, TRAC_CLEAN) + +if __name__ == '__main__': + main() diff --git a/examples/trac/contrib/emailfilter.py b/examples/trac/contrib/emailfilter.py new file mode 100644 --- /dev/null +++ b/examples/trac/contrib/emailfilter.py @@ -0,0 +1,64 @@ +#!/usr/bin/python +""" +emailfilter.py -- Email tickets to Trac. + +A simple MTA filter to create Trac tickets from inbound emails. + +Copyright 2005, Daniel Lundin +Copyright 2005, Edgewall Software + +The scripts reads emails from stdin and inserts directly into a Trac database. +MIME headers are mapped as follows: + + * From: => Reporter + * Subject: => Summary + * Body => Description + +How to use +---------- + * Set TRAC_ENV_PATH to the path of your project's Trac environment + * Configure script as a mail (pipe) filter with your MTA + typically, this involves adding a line like this to /etc/aliases: + somename: |/path/to/email2trac.py + Check your MTA's documentation for specifics. + +Todo +---- + * Configure target database through env variable? + * Handle/discard HTML parts + * Attachment support +""" + +TRAC_ENV_PATH = '/var/trac/test' + +import email +import sys + +from trac.env import Environment +from trac.ticket import Ticket + + +class TicketEmailParser(object): + + env = None + + def __init__(self, env): + self.env = env + + def parse(self, fp): + msg = email.message_from_file(fp) + tkt = Ticket(self.env) + tkt['status'] = 'new' + tkt['reporter'] = msg['from'] + tkt['summary'] = msg['subject'] + for part in msg.walk(): + if part.get_content_type() == 'text/plain': + tkt['description'] = part.get_payload(decode=1).strip() + + if tkt.values.get('description'): + tkt.insert() + +if __name__ == '__main__': + env = Environment(TRAC_ENV_PATH, create=0) + tktparser = TicketEmailParser(env) + tktparser.parse(sys.stdin) diff --git a/examples/trac/contrib/migrateticketmodel.py b/examples/trac/contrib/migrateticketmodel.py new file mode 100644 --- /dev/null +++ b/examples/trac/contrib/migrateticketmodel.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +# +# This script completely migrates a <= 0.8.x Trac environment to use the new +# default ticket model introduced in Trac 0.9. +# +# In particular, this means that the severity field is removed (or rather +# disabled by removing all possible values), and the priority values are +# changed to the more meaningful new defaults. +# +# Make sure to make a backup of the Trac environment before running this! + +import os +import sys + +from trac.env import open_environment +from trac.ticket.model import Priority, Severity + +priority_mapping = { + 'highest': 'blocker', + 'high': 'critical', + 'normal': 'major', + 'low': 'minor', + 'lowest': 'trivial' +} + +def main(): + if len(sys.argv) < 2: + print >> sys.stderr, 'usage: %s /path/to/projenv' \ + % os.path.basename(sys.argv[0]) + sys.exit(2) + + env = open_environment(sys.argv[1]) + db = env.get_db_cnx() + + for oldprio, newprio in priority_mapping.items(): + priority = Priority(env, oldprio, db) + priority.name = newprio + priority.update(db) + + for severity in list(Severity.select(env, db)): + severity.delete(db) + + db.commit() + +if __name__ == '__main__': + main() diff --git a/examples/trac/contrib/sourceforge2trac.py b/examples/trac/contrib/sourceforge2trac.py new file mode 100644 --- /dev/null +++ b/examples/trac/contrib/sourceforge2trac.py @@ -0,0 +1,305 @@ +""" +Import a Sourceforge project's tracker items into a Trac database. + +Requires: Development version of Trac 0.7-pre from http://trac.edgewall.com/ + ElementTree from effbot.org/zone/element.htm + Python 2.3 from http://www.python.org/ + +The Sourceforge tracker items can be exported from the 'Backup' page of the +project admin section. + +Copyright 2004, Mark Rowe +""" + +from elementtree.ElementTree import ElementTree +from datetime import datetime +import trac.env + +class FieldParser(object): + def __init__(self, e): + for field in e: + if field.get('name').endswith('date'): + setattr(self, field.get('name'), datetime.fromtimestamp(int(field.text))) + else: + setattr(self, field.get('name'), field.text) + +class ArtifactHistoryItem(FieldParser): + def __repr__(self): + return '' % ( + self.field_name, self.old_value, self.entrydate, self.mod_by) + +class ArtifactMessage(FieldParser): + def __repr__(self): + return '' % (self.adddate, self.user_name, self.body) + +class Artifact(object): + def __init__(self, e): + self._history = [] + self._messages = [] + + for field in e: + if field.get('name') == 'artifact_history': + for h in field: + self._history.append(ArtifactHistoryItem(h)) + elif field.get('name') == 'artifact_messages': + for m in field: + self._messages.append(ArtifactMessage(m)) + else: + setattr(self, field.get('name'), field.text) + + def history(self): + """Returns the history items in reverse chronological order so that the "new value" + can easily be calculated based on the final value of the field, and the old value + of the items occuring before it. + """ + history = [(h.entrydate, h) for h in self._history] + history.sort() + return [h[1] for h in history][::-1] + + def messages(self): + return self._messages[:] + + def __repr__(self): + return '' % (self.summary, self.artifact_type, self.category, self.status) + +class ExportedProjectData(object): + def __init__(self, f): + self._artifacts = [] + + root = ElementTree().parse(f) + + for artifact in root.find('artifacts'): + self._artifacts.append(Artifact(artifact)) + + def artifacts(self): + """Returns the artifacts in chronological order, so that they will be assigned numbers in sequence.""" + artifacts = [(a.open_date, a) for a in self._artifacts] + artifacts.sort() + return [a[1] for a in artifacts] + + def featureRequests(self): + return [a for a in self._artifacts if a.artifact_type == 'Feature Requests'] + + def bugs(self): + return [a for a in self._artifacts if a.artifact_type == 'Bugs'] + + def categories(self): + """Returns all the category names that are used, in alphabetical order.""" + c = {} + for a in self._artifacts: + c[a.category] = 1 + + categories = c.keys() + categories.sort() + return categories + + def groups(self): + """Returns all the group names that are used, in alphabetical order.""" + g = {} + for a in self._artifacts: + g[a.artifact_group_id] = 1 + del g['None'] + + groups = g.keys() + groups.sort() + return groups + + def artifactTypes(self): + """Returns all the artifact types that are used, in alphabetical order.""" + t = {} + for a in self._artifacts: + t[a.artifact_type] = 1 + types = t.keys() + types.sort() + return types + +class TracDatabase(object): + def __init__(self, path): + self.env = trac.env.Environment(path) + self._db = self.env.get_db_cnx() + self._db.autocommit = False + + def db(self): + return self._db + + def hasTickets(self): + c = self.db().cursor() + c.execute('''SELECT count(*) FROM Ticket''') + return int(c.fetchall()[0][0]) > 0 + + def setTypeList(self, s): + """Remove all types, set them to `s`""" + if self.hasTickets(): + raise Exception("Will not modify database with existing tickets!") + + c = self.db().cursor() + c.execute("""DELETE FROM enum WHERE kind='ticket_type'""") + for i, value in enumerate(s): + c.execute("""INSERT INTO enum (kind, name, value) VALUES (%s, %s, %s)""", + "ticket_type", + value, + i) + self.db().commit() + + def setPriorityList(self, s): + """Remove all priorities, set them to `s`""" + if self.hasTickets(): + raise Exception("Will not modify database with existing tickets!") + + c = self.db().cursor() + c.execute("""DELETE FROM enum WHERE kind='priority'""") + for i, value in enumerate(s): + c.execute("""INSERT INTO enum (kind, name, value) VALUES (%s, %s, %s)""", + "priority", + value, + i) + self.db().commit() + + + def setComponentList(self, l): + """Remove all components, set them to `l`""" + if self.hasTickets(): + raise Exception("Will not modify database with existing tickets!") + + c = self.db().cursor() + c.execute("""DELETE FROM component""") + for value in l: + c.execute("""INSERT INTO component (name) VALUES (%s)""", + value) + self.db().commit() + + def setVersionList(self, v): + """Remove all versions, set them to `v`""" + if self.hasTickets(): + raise Exception("Will not modify database with existing tickets!") + + c = self.db().cursor() + c.execute("""DELETE FROM version""") + for value in v: + c.execute("""INSERT INTO version (name) VALUES (%s)""", + value) + self.db().commit() + + def setMilestoneList(self, m): + """Remove all milestones, set them to `m`""" + if self.hasTickets(): + raise Exception("Will not modify database with existing tickets!") + + c = self.db().cursor() + c.execute("""DELETE FROM milestone""") + for value in m: + c.execute("""INSERT INTO milestone (name) VALUES (%s)""", + value) + self.db().commit() + + def addTicket(self, type, time, changetime, component, + priority, owner, reporter, cc, + version, milestone, status, resolution, + summary, description, keywords): + c = self.db().cursor() + if status.lower() == 'open': + if owner != '': + status = 'assigned' + else: + status = 'new' + + c.execute("""INSERT INTO ticket (type, time, changetime, component, + priority, owner, reporter, cc, + version, milestone, status, resolution, + summary, description, keywords) + VALUES (%s, %s, %s, + %s, %s, %s, %s, %s, + %s, %s, %s, %s, + %s, %s, %s)""", + type, time, changetime, component, + priority, owner, reporter, cc, + version, milestone, status.lower(), resolution, + summary, '{{{\n%s\n}}}' % (description, ), keywords) + self.db().commit() + return self.db().db.sqlite_last_insert_rowid() + + def addTicketComment(self, ticket, time, author, value): + c = self.db().cursor() + c.execute("""INSERT INTO ticket_change (ticket, time, author, field, oldvalue, newvalue) + VALUES (%s, %s, %s, %s, %s, %s)""", + ticket, time.strftime('%s'), author, 'comment', '', '{{{\n%s\n}}}' % (value, )) + self.db().commit() + + def addTicketChange(self, ticket, time, author, field, oldvalue, newvalue): + c = self.db().cursor() + c.execute("""INSERT INTO ticket_change (ticket, time, author, field, oldvalue, newvalue) + VALUES (%s, %s, %s, %s, %s, %s)""", + ticket, time.strftime('%s'), author, field, oldvalue, newvalue) + self.db().commit() + + +def main(): + import optparse + p = optparse.OptionParser('usage: %prog xml_export.xml /path/to/trac/environment') + opt, args = p.parse_args() + if len(args) != 2: + p.error("Incorrect number of arguments") + + try: + importData(open(args[0]), args[1]) + except Exception, e: + print 'Error:', e + +def importData(f, env): + project = ExportedProjectData(f) + + db = TracDatabase(env) + db.setTypeList(project.artifactTypes()) + db.setComponentList(project.categories()) + db.setPriorityList(range(1, 11)) + db.setVersionList(project.groups()) + db.setMilestoneList([]) + + for a in project.artifacts(): + i = db.addTicket(type=a.artifact_type, + time=a.open_date, + changetime='', + component=a.category, + priority=a.priority, + owner=a.assigned_to, + reporter=a.submitted_by, + cc='', + version=a.artifact_group_id, + milestone='', + status=a.status, + resolution=a.resolution, + summary=a.summary, + description=a.details, + keywords='') + print 'Imported %s as #%d' % (a.artifact_id, i) + for msg in a.messages(): + db.addTicketComment(ticket=i, + time=msg.adddate, + author=msg.user_name, + value=msg.body) + if a.messages(): + print ' imported %d messages for #%d' % (len(a.messages()), i) + + values = a.__dict__.copy() + field_map = {'summary': 'summary'} + for h in a.history(): + if h.field_name == 'close_date' and values.get(h.field_name, '') == '': + f = 'status' + oldvalue = 'assigned' + newvalue = 'closed' + else: + f = field_map.get(h.field_name, None) + oldvalue = h.old_value + newvalue = values.get(h.field_name, '') + + if f: + db.addTicketChange(ticket=i, + time=h.entrydate, + author=h.mod_by, + field=f, + oldvalue=oldvalue, + newvalue=newvalue) + values[h.field_name] = h.old_value + +if __name__ == '__main__': + main() diff --git a/examples/trac/contrib/trac-post-commit-hook b/examples/trac/contrib/trac-post-commit-hook new file mode 100755 --- /dev/null +++ b/examples/trac/contrib/trac-post-commit-hook @@ -0,0 +1,196 @@ +#!/usr/bin/env python + +# trac-post-commit-hook +# ---------------------------------------------------------------------------- +# Copyright (c) 2004 Stephen Hansen +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. +# ---------------------------------------------------------------------------- + +# This Subversion post-commit hook script is meant to interface to the +# Trac (http://www.edgewall.com/products/trac/) issue tracking/wiki/etc +# system. +# +# It should be called from the 'post-commit' script in Subversion, such as +# via: +# +# REPOS="$1" +# REV="$2" +# LOG=`/usr/bin/svnlook log -r $REV $REPOS` +# AUTHOR=`/usr/bin/svnlook author -r $REV $REPOS` +# TRAC_ENV='/somewhere/trac/project/' +# TRAC_URL='http://trac.mysite.com/project/' +# +# /usr/bin/python /usr/local/src/trac/contrib/trac-post-commit-hook \ +# -p "$TRAC_ENV" \ +# -r "$REV" \ +# -u "$AUTHOR" \ +# -m "$LOG" \ +# -s "$TRAC_URL" +# +# It searches commit messages for text in the form of: +# command #1 +# command #1, #2 +# command #1 & #2 +# command #1 and #2 +# +# You can have more then one command in a message. The following commands +# are supported. There is more then one spelling for each command, to make +# this as user-friendly as possible. +# +# closes, fixes +# The specified issue numbers are closed with the contents of this +# commit message being added to it. +# references, refs, addresses, re +# The specified issue numbers are left in their current status, but +# the contents of this commit message are added to their notes. +# +# A fairly complicated example of what you can do is with a commit message +# of: +# +# Changed blah and foo to do this or that. Fixes #10 and #12, and refs #12. +# +# This will close #10 and #12, and add a note to #12. + +import re +import os +import sys +import time + +from trac.env import open_environment +from trac.ticket.notification import TicketNotifyEmail +from trac.ticket import Ticket +from trac.ticket.web_ui import TicketModule +# TODO: move grouped_changelog_entries to model.py +from trac.web.href import Href + +try: + from optparse import OptionParser +except ImportError: + try: + from optik import OptionParser + except ImportError: + raise ImportError, 'Requires Python 2.3 or the Optik option parsing library.' + +parser = OptionParser() +parser.add_option('-e', '--require-envelope', dest='env', default='', + help='Require commands to be enclosed in an envelope. If -e[], ' + 'then commands must be in the form of [closes #4]. Must ' + 'be two characters.') +parser.add_option('-p', '--project', dest='project', + help='Path to the Trac project.') +parser.add_option('-r', '--revision', dest='rev', + help='Repository revision number.') +parser.add_option('-u', '--user', dest='user', + help='The user who is responsible for this action') +parser.add_option('-m', '--msg', dest='msg', + help='The log message to search.') +parser.add_option('-s', '--siteurl', dest='url', + help='The base URL to the project\'s trac website (to which ' + '/ticket/## is appended). If this is not specified, ' + 'the project URL from trac.ini will be used.') + +(options, args) = parser.parse_args(sys.argv[1:]) + +if options.env: + leftEnv = '\\' + options.env[0] + rghtEnv = '\\' + options.env[1] +else: + leftEnv = '' + rghtEnv = '' + +commandPattern = re.compile(leftEnv + r'(?P[A-Za-z]*).?(?P#[0-9]+(?:(?:[, &]*|[ ]?and[ ]?)#[0-9]+)*)' + rghtEnv) +ticketPattern = re.compile(r'#([0-9]*)') + +class CommitHook: + _supported_cmds = {'close': '_cmdClose', + 'closed': '_cmdClose', + 'closes': '_cmdClose', + 'fix': '_cmdClose', + 'fixed': '_cmdClose', + 'fixes': '_cmdClose', + 'addresses': '_cmdRefs', + 're': '_cmdRefs', + 'references': '_cmdRefs', + 'refs': '_cmdRefs', + 'see': '_cmdRefs'} + + def __init__(self, project=options.project, author=options.user, + rev=options.rev, msg=options.msg, url=options.url): + self.author = author + self.rev = rev + self.msg = "(In [%s]) %s" % (rev, msg) + self.now = int(time.time()) + self.env = open_environment(project) + if url is None: + url = self.env.config.get('project', 'url') + class Request(object): + def __init__(self): + self.href = self.abs_href = Href(url) + + cmdGroups = commandPattern.findall(msg) + + tickets = {} + for cmd, tkts in cmdGroups: + funcname = CommitHook._supported_cmds.get(cmd.lower(), '') + if funcname: + for tkt_id in ticketPattern.findall(tkts): + func = getattr(self, funcname) + tickets.setdefault(tkt_id, []).append(func) + + for tkt_id, cmds in tickets.iteritems(): + try: + db = self.env.get_db_cnx() + + ticket = Ticket(self.env, int(tkt_id), db) + for cmd in cmds: + cmd(ticket) + + # determine sequence number... + cnum = 0 + tm = TicketModule(self.env) + for change in tm.grouped_changelog_entries(ticket, db): + if change['permanent']: + cnum += 1 + + ticket.save_changes(self.author, self.msg, self.now, db, cnum+1) + db.commit() + + tn = TicketNotifyEmail(self.env) + tn.notify(ticket, Request(), newticket=0, modtime=self.now) + except Exception, e: + # import traceback + # traceback.print_exc(file=sys.stderr) + print>>sys.stderr, 'Unexpected error while processing ticket ' \ + 'ID %s: %s' % (tkt_id, e) + + + def _cmdClose(self, ticket): + ticket['status'] = 'closed' + ticket['resolution'] = 'fixed' + + def _cmdRefs(self, ticket): + pass + + +if __name__ == "__main__": + if len(sys.argv) < 5: + print "For usage: %s --help" % (sys.argv[0]) + else: + CommitHook() diff --git a/examples/trac/contrib/trac-pre-commit-hook b/examples/trac/contrib/trac-pre-commit-hook new file mode 100644 --- /dev/null +++ b/examples/trac/contrib/trac-pre-commit-hook @@ -0,0 +1,63 @@ +#!/usr/bin/env python +# -*- coding: iso8859-1 -*- +# +# Author: Jonas Borgstrm +# +# This script will enforce the following policy: +# +# "A checkin must reference an open ticket." +# +# This script should be invoked from the subversion pre-commit hook like this: +# +# REPOS="$1" +# TXN="$2" +# TRAC_ENV="/somewhere/trac/project/" +# LOG=`/usr/bin/svnlook log -t "$TXN" "$REPOS"` +# /usr/bin/python /some/path/trac-pre-commit-hook "$TRAC_ENV" "$LOG" || exit 1 +# +import os +import re +import sys + +from trac.env import open_environment + +def main(): + if len(sys.argv) != 3: + print >> sys.stderr, 'Usage: %s ' % sys.argv[0] + sys.exit(1) + + env_path = sys.argv[1] + log = sys.argv[2] + + tickets = [] + for tmp in re.findall('(?:closes|fixes|addresses|references|refs|re)' + '.?(#[0-9]+(?:(?:[, &]+| *and *)#[0-9]+)*)', log): + tickets += re.findall('#([0-9]+)', tmp) + + # At least one ticket has to be mentioned in the log message + if tickets == []: + print >> sys.stderr, 'At least one open ticket must be mentioned ' \ + 'in the log message.' + sys.exit(1) + + env = open_environment(env_path) + db = env.get_db_cnx() + + cursor = db.cursor() + cursor.execute("SELECT COUNT(id) FROM ticket WHERE " + "status <> 'closed' AND id IN (%s)" % ','.join(tickets)) + row = cursor.fetchone() + # At least one of the tickets mentioned in the log messages has to + # be open + if not row or row[0] < 1: + print >> sys.stderr, 'At least one open ticket must be mentioned ' \ + 'in the log message.' + sys.exit(1) + else: + sys.exit(0) + +if __name__ == '__main__': + main() + + + diff --git a/examples/trac/doc/README b/examples/trac/doc/README new file mode 100644 --- /dev/null +++ b/examples/trac/doc/README @@ -0,0 +1,2 @@ +Sorry, for now the only documentation we have is what's on the project website: +http://projects.edgewall.com/trac/ diff --git a/examples/trac/doc/trac_icon_16x16.png b/examples/trac/doc/trac_icon_16x16.png new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..d946c78dd213258ecb13a38e5fffcdc2b9f02222 GIT binary patch literal 453 zc%17D@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbKJbFq_W2nPqp?T7vkfZXr^pAgp{ z@85rY^5oOqyC1G!e{=ra>ysy69Y6kT@7~8-w?5jm>E4>vcb6``IeYfCNt3Sh_FQOb zIaghMvY_B-X2y}k#Dh^$`vL>^czf?~a@t~HvB}72orcC5Ma5MzG7rsH-2z%6TN30K z%A~-*-q&%@GmBBYLxhOw3H6=4q!7Z~WwLHHlyI8?k&)|Kg;Rm4N zUQZXtkcv66{z zUvce?=cPn}!Wpq~tHjH5ROUZBwXx7GL`hb4_jL(FAJx4DE9>iI?=G)qJh6mBT#a`{ QCeUFFp00i_>zopr0P^O@tpET3 diff --git a/examples/trac/doc/trac_icon_32x32.png b/examples/trac/doc/trac_icon_32x32.png new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..c63d4127e0ae88af44c864765f57aad54dffb3be GIT binary patch literal 683 zc%17D@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dy%*9TgAsieWw;%dH0CIBzd_r9R zeE{TzlP6ysJ^F0#-p50os9-oU`!-rhT$oVHq8ZZb67 zps&A9Lt~Ai;wmYrA2aM-f%dAD1o;Is{3i^)E{Q7vs^={5h%9DcaF+mK#^R=XJAr~? zC9V-A&PAz-C7Jno3L%-fsSJh&`bK8@1}0q_^`n6*6hSJ2^V3So6N^$Ad=rz4@^e#D zG7}ZtGK*5n^NX^J6^!)^-e($qU|?Vj_jGX#shCq6oGaa=z%wU9a_{1)shoWO{@3T^ zO!Kn4D_&rox4n^pbCt=21xIezAG-MXm;S;Rf%+|*uSPuHQ*v&{#~E=!E1bW3TKAuS z)4t*5?8Nlu?+ar;lx6Xqaq&OE>-vm&rAEx=gt746`eqE-1%B*>g3e(lBB`SwYSg$;q zm-1aAM=$P!LW{S|;)5$>d$T7q&zV^-c<5AA*3O*j`%mZ3;ywS`qGqLU+{7JPZo$_* zUL2o(kyYkNLbRl*&zGNPr)$_UJ(+7V`C`{@{f7spZ8PXOxL;(&FT0sj1SYU0FO8jU zqpiy_>%N0R*G$7*D{}UiADz)DV%G9ufi<7BG>F}p16DlG(uKlAAb=PEA}k#MkU9n#`*ndRd)`Kbiy>~XGM?JoAoHOlvU-E#_BhA}fZT6! zu|C0ZdmaQEN%M$k{GH?W0SNk`RUF~Cy$DkG$Bggq??;g3p&e&ddlO`S=b6>s1Yu=X z_2>H!r0$Ozb;y73?K_ZdfzH}7NApOsXC*R353Gddp=8g32;}i?i$nNwzZrX$0$H{} zhIR|GI`jF6?BoFXsmb4eI+d3L61M!*B{=&e5|Blz&siHJz)BYK`56Z!o;NqNlDBv8 z_6VQJS#2krmfUFu%1XvL+j%Pj5XceF)58&SV}}=sqqiE2HZqs?^ zSLE5wLfatQ-gm#%02$gTNDNNbI0xiAx}BFq>+_y%9|Zk)j|_mD4G`OJQMp(X5PZi$ z2jr2jovm*+2Tz@pqsV@LAOhfq3u48|jq^JIdFk|7^K)?BgkI4c9Et#Bi!|+JfijYL zAv}oT=u6IEfN-BR?jrhc5&=k-v~ zwnT#nw!DVJf-DfPzRUvQlmLNny^#b&Yr9C>i0OYfs0^kV$U_38$_46Hg>hmuk`#q7 z2`0!=ND!Xru<3tem!B$-du6;YKEnVhv=5T?MsgO&{g5E!o5%k5tX)$zGNh4sLQMB` ze&!P2PCWFa@kAuwplpN&c|0{W)$o~9c$-FREEVEA=G^#4I$*N3oh&})+fN$Z+{)_j zCIzxh*$69*XM#+~EIBHqOk)j&G9m>T^Efd_*RuXJm_)5qdcKra&xf zZ+NlbNfH{QN<}<+Je!}IaQ$z|mw81GGUg5KV|I>zEj)-XWm=i^4xrL*5|(^(AQ!xW zWXZ9ivCtrtk?Z5MJ4>T}SIf{FGIq;NZFsz|fi;aTa|eTX+c8FoDI9EfZAR z0;_%+2$Cim)tKpTN~Ojo(SeXgavo8g-}E@Joq(_g9d8U|bJ!a=?4)Gg-Cy*?%9bD} zn`Oz?CAFx%F9ul@t}_yM+i!j8fqWhcWWb;A(jb*L0WxoPkjSpA&{1#f*1aco0P>Mc`6s+jwIT)?_9nb`f4{Y3;MQ(Ih*98iGw}8K zj6EJLh{rd%{jJj;ZHFLkV*gj`fFGnYQe(H3f01blWV*6LkRq)>eZGLmDbax}J3Xvt ziS)`pow##j7wLqG0aA!Cjx<8uHhU;FFEn+($v_Za9>V*R#@^^aaG0=lW;@GT$~f=I zgu{avzF^@Bk&oKiIm;_KuiE(j>NVf;nie4OAP}9-Sqv9MjM_-dTnkTCJquptFwdTb zb6#pJ2t=bnG#A9JZXb&_X@ux^J*W*;PDPaEI;I>9;;Ve6h_Yl%i4w$Uwq9eIJ;U1w z+aCphsI-lUE=bKc%MjsEH)wId2D_jlQtvcaB5^vkrO)PTvpF4eig6pwo{4V$YMMxl7G2fuQS64H5(Og;^ichY6xG)BAQowrRKIorzm! zuI8w=1Q7#Pl`MY;Vre>Qw0$O}H(qtvGQ3%qb`~`AR01Obq=l9t*9*LCj}#5Z1VZXz~_?q33D+$KSaSO|R}J`uXj<6>e_ zk+1;D=k&%DqT0BNamyU(0B&fIR|*ScGo(NqkTw6DlbRwyM0kvbN9i|{$mqh$B1Awj zK~TT{wi>P#)dpo$`aCR%$OK`T2s4W>7IbAnK@X0~X7yR0TScp=fZ)(~iKp-1qay3wzI zYN@H0y+-FMt|qKQ$4b~vzL1plJO)`ml(Rt&q4&BKQO+opv=LX6NV=_+@-as{t z`V`4j92r2$IV}ZU5&^{M%UK40yd;+vu=&)wkWs)OuM(&~hq{5Yl7@@o=zZHp%Cku$ zh4rsAswUu$xG^*k#qPs2R`L{!vt`6L(v)*^iG6gHq_ zEoH@#YTWw2XDwIMFo-y)Xdsz#3Gz$ChL?gqQARl}Z$9iviw!wlak$7$3rVb!_ z;>CapF{OM0%(@@`1gTciYn`V!?A&aSKd7VNqsO_E7RVj07akPk*4#r_{k`o zF8?;4#}Fn+sUTGfl2SeL!UCx#UiOv;FvxU%&H;(%^Ga)w)<5M$NCZFnoCWee95+=9 zp@u{%Sn-gNFp^0OvOWOLMrnXxi41^5&ROBc0+EYF1uM>)1tQ6n-ZiXRxX%s7$h&8a0Z2s)gGe{fJ)>p~3>Y$Kx}6L1uIcLSvS z6VT1mG1b2`I|7I0jKl4oImNPV~uGM{N+e&H<5GAm$pOFuy-% zgQ#(M=z?s-CU^{z$@2xhOV+v;aws;_2OxjB%R#v8nE|Z=i9t7w*sM4xyCBl7_plls znw>+^i8&pALS0m#+oeMBN~YHYDP75=5kJYxYJ8#KfJj&J_Rb%fzf@EI_Ub(p9>mC; zMB}j>dUzs(HR)9h%mdQHuOUu9=)@W}fs3VDeqdhkQ;sbCa#vgkNAjaeA rHm=sf%(*pv!mr@hW&aQI%WVGxW}Zj=s%Q(000000NkvXXu0mjf1l&<; diff --git a/examples/trac/doc/trac_logo.svg b/examples/trac/doc/trac_logo.svg new file mode 100644 --- /dev/null +++ b/examples/trac/doc/trac_logo.svg @@ -0,0 +1,105 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/examples/trac/htdocs/README b/examples/trac/htdocs/README new file mode 100644 --- /dev/null +++ b/examples/trac/htdocs/README @@ -0,0 +1,5 @@ +This directory contains files used by Trac's default clearsilver templates. + +Local modifications to these files might be lost during the installation of +a new Trac version. This can be avoided by making a copy of this entire +directory before beginning modifications. diff --git a/examples/trac/htdocs/asc.png b/examples/trac/htdocs/asc.png new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..486b7315902f8bd4d7c953f4b6e65e56cb171c50 GIT binary patch literal 222 zc%17D@N?(olHy`uVBq!ia0vp^96-#@!VDyjmkZ_sDdu7)&kzm{j@u9Y9{{=h0X`wF zuV25udiCm=GiUbi-@kI@%BvF}%mYfXmIV0)GyDevnG0$lvp5SpB8wRqxITa|qthCb z89+hB64!{5;QX|b^2DN42H(WwqWs*{l*~j0x6Go{^8BLgVg+M8gZG(+KwS(Po-U3d z6?2jk5`q#E5(Elagw)RQ@bu{EG4Y(y(_`b&($n+f@p52bFt%pi>BpeA4ycF0)78&q Iol`;+0Q%iTa{vGU diff --git a/examples/trac/htdocs/attachment.png b/examples/trac/htdocs/attachment.png new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..a4d212439158aa08cbf451a557a5f987c8f9f32a GIT binary patch literal 280 zc%17D@N?(olHy`uVBq!ia0vp^JRr=$3?vg*uel1OSkfJR9T^zbpD<_bdI{u9mbgZg z1m~xflqVLYGB~E>C#5QQ<|d}62BjvZR2H60wE-$J2=EDU_3`lu3JQvdh=`4i&B@6r zD=X{j>Y6ZN!tB|zckbMI^5n^j7cbtrb?e!)XYb#?2Wt9c9la7r36uo+1^-6{3=foJ zC4q85o-U3d6}NoOy%us1~z``8;h%n3^2IkhduF<%B(nCd+l_ z8BIL1SLy72C-HC1@v1R1mYqp&3=!6>U2O2HTqI!GSKc-TW`-;FZ2S>7J>~#SXYh3O Kb6Mw<&;$Szcyo3D diff --git a/examples/trac/htdocs/changeset.png b/examples/trac/htdocs/changeset.png new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..31c0356a3eaf6530f6b0a619de10c0435f063fff GIT binary patch literal 294 zc%17D@N?(olHy`uVBq!ia0vp^JRr=%3?!FCJ6-`&%*9TgAsieWw;%dH0CIH$d_r9R z|NsB}`}fbEKfiwc`u_dpmT^vIy<|HRHFrJf4m=xS3?cqB|%jzk^ z1a*xS#}6F1s8w;Y#FDvvflhm;_yqMUZ+3BMEYEqw7-E^a`nBK!uRJq-xeav-co;tP W@~-*5I?@JcCWEJ|pUXO@geCwMCUaH* diff --git a/examples/trac/htdocs/closedticket.png b/examples/trac/htdocs/closedticket.png new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..43f7a844739b0875c3ea281d8c4720585d6ead4f GIT binary patch literal 297 zc%17D@N?(olHy`uVBq!ia0vp^JRr=%3?!FCJ6-`&%*9TgAsieWw;%dH0CFV)d_r6q z(ilEGtvtQJ`Tmiv|NsAMPBma?VEFm9RrsysT%a0`k|4iep!k0VAl7XOQ3Z-|7I;J! zGca%qfiUBxyLEqnf{G=s5hcO-X(i=}MX3zFiOEIzxv43ci3)C+MXBZaMcKs)CVB?1 z)YMCXip4;Ror_WvOEUBG6hbm{QyB~m^$pGS4NNZIvWx<%aP@R?45^rtT)@c8mTZt< zpwHeU#8#}jRH-!4g5QPJc*bhBmDAM>W=vO`(r(B+m1Tum!;?cdZuBh($ diff --git a/examples/trac/htdocs/css/about.css b/examples/trac/htdocs/css/about.css new file mode 100644 --- /dev/null +++ b/examples/trac/htdocs/css/about.css @@ -0,0 +1,42 @@ +/* About config */ +#content.about_config table { + border-collapse: collapse; + margin: 2em 0; +} +#content.about_config th { + background: #f7f7f0; + font-weight: bold; + text-align: left; + vertical-align: top; +} +#content.about_config th.section { + text-align: right; +} +#content.about_config th, #content.about_config td { + border: 1px solid #ddd; + padding: 3px; +} +#content.about_config td.name { background:#f9f9f0; } +#content.about_config td.value { background:#f9f9f0; font-weight: bold; } +#content.about_config td.defaultvalue { font-family: monospace; background:#f9f9f0; } +#content.about_config td.doc { padding: 3px 1em 3px 1em; } + +/* About plugins */ +#content.about_plugins h2 { + background: #f7f7f7; + border-bottom: 1px solid #d7d7d7; + margin: 2em 0 0; +} +#content.about_plugins table { + border-collapse: collapse; + margin: 1em 0; + table-layout: fixed; + width: 100%; +} +#content.about_plugins th, #content.about_plugins td { border: 1px solid #ddd; padding: 3px } +#content.about_plugins th { background: #f7f7f0; font-weight: bold; text-align: right; vertical-align: top; width: 12em } +#content.about_plugins td.module { font-family: monospace; } +#content.about_plugins td.module .path { color: #999; font-size: 90%; } + +#content.about_plugins td.xtnpts { margin-top: 1em; } +#content.about_plugins td.xtnpts ul { list-style: square; margin: 0; padding: 0 0 0 2em; } diff --git a/examples/trac/htdocs/css/browser.css b/examples/trac/htdocs/css/browser.css new file mode 100644 --- /dev/null +++ b/examples/trac/htdocs/css/browser.css @@ -0,0 +1,138 @@ +#prefs { margin-top: -1.6em } +* html #prefs { width: 34em } /* Set width only for IE */ +#prefs fieldset label { display: block } +#prefs .buttons { margin-top: -1.6em } +#prefs .choice { margin-top: -0.6em } + +#legend { clear: right; } + +/* Browser */ +h1 { margin: 0; padding: 0 0 .5em } +h1 :link, h1 :visited, h1 .filename { border: none; padding: 0 .2em } +h1 :link, h1 :visited { color: #b00 } +h1 .first:link, h1 .first:visited { color: #998 } +h1 .sep { color: #666; padding: 0 .1em } + +#jumprev { float: right; font-size: 10px; margin: 0 0 0 } +#jumprev form { margin: 0 } +#jumprev input { font-size: 10px; margin-right: 0 } + +/* Styles for the directory entries table + (extends the styles for "table.listing") */ +#dirlist { margin-top: 0 } +#dirlist td.rev, #dirlist td.age, #dirlist td.change { + color: #888; + white-space: nowrap; + vertical-align: baseline; +} +#dirlist td.rev { + font-family: monospace; + letter-spacing: -0.08em; + font-size: 90%; + text-align: right; +} +#dirlist td.size { + color: #888; + white-space: nowrap; + text-align: right; + vertical-align: middle; + font-size: 70%; +} +#dirlist td.name { width: 100% } +#dirlist td.name a, #dirlist td.name span { + background-position: 0% 50%; + background-repeat: no-repeat; + padding-left: 20px; +} +#dirlist td.name a.parent { background-image: url(../parent.png) } +#dirlist td.name a.dir { background-image: url(../folder.png) } +#dirlist td.name span.dir { background-image: url(../folderdeny.png) } +#dirlist td.name a.file { background-image: url(../file.png) } +#dirlist td.name span.file { background-image: url(../filedeny.png) } +#dirlist td.name a, #dirlist td.rev a { border-bottom: none; display: block } +#dirlist td.rev { text-align: right } +#dirlist td.change { font-size: 85%; vertical-align: middle; white-space: nowrap } + +/* Style for the ''View Changes'' button */ +#anydiff { + margin: 0 0 1em; + float: left; +} +#anydiff form, #anydiff div, #anydiff h2 { + display: inline; +} +#anydiff input { + vertical-align: baseline; + margin: 0 -0.5em 0 1em; +} +@media print { + #anydiff form { display: none } +} + + +/* Log */ +tr.diff input { + padding: 0 1em 0 1em; + margin: 0; +} + +div.buttons { + clear: left; +} +@media print { + th.diff, td.diff { display: none } +} +/* Styles for the revision log table + (extends the styles for "table.listing") */ +#chglist { margin-top: 0 } +#chglist td.change span { + border: 1px solid #999; + display: block; + margin: .2em .5em 0 0; + width: .8em; height: .8em; +} +#chglist td.diff { white-space: nowrap } +#chglist td.change .comment { display: none } +#chglist td.old_path { font-style: italic } +#chglist td.date { font-size: 85%; vertical-align: top; padding-top: 0.55em; white-space: nowrap } +#chglist td.author { font-size: 85%; vertical-align: top; padding-top: 0.55em } +#chglist td.rev, #chglist td.chgset { + font-family: monospace; + letter-spacing: -0.08em; + font-size: 90%; + text-align: right; +} +#chglist td.rev a, #chglist td.chgset a { border-bottom: none } +#chglist td.summary { + width: 100%; + font-size: 85%; + vertical-align: middle; + white-space: nowrap +} +#chglist .verbose td.summary { + border: none; + color: #333; + padding: .5em 1em 1em 2em; + font-size: 90%; + white-space: normal +} + +#chglist td.summary * { margin-top: 0; margin-bottom: 0 } + +#paging { margin: 1em 0 } + +/* Styles for the revision info in the file view (see also trac.css) */ +#info { margin: 0; } +#info .props { + color: #666; + list-style: square; + margin: 0 0 .4em 1.6em; + padding: 0; +} +#info .props li { padding: 0 } + +/* Styles for the HTML preview */ +#preview { background: #fff; clear: both; margin: 0 } +#preview .code-block { border-top: 1px solid #999; margin: 0 } +#preview .image-file { overflow: hidden } +#preview .image-file img { max-width: 100% } diff --git a/examples/trac/htdocs/css/changeset.css b/examples/trac/htdocs/css/changeset.css new file mode 100644 --- /dev/null +++ b/examples/trac/htdocs/css/changeset.css @@ -0,0 +1,44 @@ +/* Changeset overview */ +#overview .files { padding-top: 1em } +#overview .files ul { margin: 0; padding: 0 } +#overview .files li { list-style-type: none } +#overview .files li .comment { display: none } +#overview .files li div { + border: 1px solid #999; + float: left; + margin: .2em .5em 0 0; + overflow: hidden; + width: .8em; height: .8em; +} +#overview div.add div, #overview div.cp div, #overview div.mv div { + border: 0; + margin: 0; + float: right; + width: .35em; +} + +#overview .changeset { padding: 0 0 1px } +#overview dd.changeset p { + margin-bottom: 0; + margin-top: 0; +} +#overview .files { padding: 1px 0 } + +.diff ul.props { font-size: 90%; list-style: disc; margin: .5em 0 0; padding: 0 .5em 1em 2em } +.diff ul.props li { margin: 0; padding: 0 } + + +#title dl { + display: inline; + font-size: 110% +} +#title dt { + font-size: 110%; + font-weight: bold; + display: inline; + margin-left: 3em; +} +#title dd { + display: inline; + margin-left: 0.4em; +} diff --git a/examples/trac/htdocs/css/code.css b/examples/trac/htdocs/css/code.css new file mode 100644 --- /dev/null +++ b/examples/trac/htdocs/css/code.css @@ -0,0 +1,161 @@ +div.code { + background: #f7f7f7; + border: 1px solid #d7d7d7; + margin: 1em 1.75em; + padding: .25em; + overflow: auto +} + +div.code pre { margin: 0; } + +table.code { + border: 1px solid #ddd; + border-spacing: 0; + border-top: 0; + empty-cells: show; + font-size: 12px; + line-height: 130%; + padding: 0; + margin: 0 auto; + table-layout: fixed; + width: 100%; +} +table.code th { + border-right: 1px solid #d7d7d7; + border-bottom: 1px solid #998; + font-size: 11px; +} +table.code th.lineno { width: 4em } +table.code thead th { + background: #eee; + border-top: 1px solid #d7d7d7; + color: #999; + padding: 0 .25em; + text-align: center; + white-space: nowrap; +} +table.code tbody th { + background: #eed; + color: #886; + font-weight: normal; + padding: 0 .5em; + text-align: right; + vertical-align: top; +} +table.code tbody th :link, table.code tbody th :visited { + border: none; + color: #886; + text-decoration: none; +} +table.code tbody th :link:hover, table.code tbody th :visited:hover { + color: #000; +} +table.code tbody td { + background: #fff; + font: normal 11px monospace; + overflow: hidden; + padding: 1px 2px; + vertical-align: top; +} + +.image-file { background: #eee; padding: .3em } +.image-file img { background: url(../imggrid.png) } + +/* Default */ +.code-block span { + font-family: monospace; +} + +/* Comments */ +.code-comment, .css_comment, .c_comment, .c_commentdoc, .c_commentline, +.c_commentlinedoc, .h_comment,.pl_commentline, .p_commentblock, +.p_commentline, .hphp_comment, .hphp_commentblock, .hphp_commentline, +.yaml_comment { + color: #998; + font-style: italic; +} + +/* Language keyword */ +.code-keyword, .pl_word { color: #789; font-weight: bold } + +/* Type */ +.code-type, .c_word, .c_word2, .p_classname, .hphp_classname{ + color: #468; + font-weight: bold; +} + +/* Function */ +.code-func, .p_defname { + color: #900; + font-weight: bold; + border-bottom: none; +} + +/* Pre-processor */ +.code-prep, .c_preprocessor, .pl_preprocessor, .yaml_identifier { + color: #999; + font-weight: bold; +} + +/* Language construct */ +.code-lang, .p_word { color: #000; font-weight: bold } + +/* String */ +.code-string, .c_string, .c_stringeol, .css_doublestring, .css_singlestring, +.h_singlestring, .h_doublestring, .pl_string, .pl_string_q, .pl_string_qq, +.pl_string_qr, .pl_string_qw, .pl_string_qx, .pl_backticks, .pl_character, +.p_string, .p_stringeol, .hphp_string, .hphp_stringeol, .hphp_triple, +.hphp_tripledouble, .p_character, .p_triple, .p_tripledouble { + color: #b84; + font-weight: normal; +} + +/* Variable name */ +.code-var { color: #f9f } + +/* SilverCity-specific styles */ +.css_id, .css_class, .css_pseudoclass, .css_tag { color: #900000 } +.css_directive { color: #009000; font-weight: bold } +.css_important { color: blue } +.css_operator { color: #000090; font-weight: bold } +.css_tag { font-weight: bold } +.css_unknown_identifier, .css_unknown_pseudoclass { color: red } +.css_value { color: navy } +.c_commentdockeyword { color: navy; font-weight: bold } +.c_commentdockeyworderror { color: red; font-weight: bold } +.c_character, .c_regex, .c_uuid, .c_verbatim { color: olive } +.c_number { color: #099 } +.h_asp { color: #ff0 } +.h_aspat { color: #ffdf00 } +.h_attribute { color: teal } +.h_attributeunknown { color: red } +.h_cdata { color: #373 } +.h_entity { color: purple } +.h_number { color: #099 } +.h_other { color: purple } +.h_script, .h_tag, .h_tagend { color: navy } +.h_tagunknown { color: red } +.h_xmlend, .h_xmlstart { color: blue } +.pl_datasection { color: olive } +.pl_error { color: red; font-weight: bold } +.pl_hash { color: #000 } +.pl_here_delim, .pl_here_q, .pl_here_qq, .pl_here_qx, .pl_longquote { color: olive } +.pl_number { color: #099 } +.pl_pod { font-style: italic } +.pl_regex, .pl_regsubst { color: olive } +.p_number { color: #099 } +.hphp_character { color: olive } +.hphp_defname { color: #099; font-weight: bold } +.hphp_number { color: #099 } +.hphp_word { color: navy; font-weight: bold } +.yaml_document { color: gray; font-style: italic } +.yaml_keyword { color: #808 } +.yaml_number { color: #800 } +.yaml_reference { color: #088 } +.v_comment { color: gray; font-style: italic } +.v_commentline, .v_commentlinebang { color: red; font-style: italic } +.v_number, .v_preprocessor { color: #099 } +.v_string, .v_stringeol { color: olive } +.v_user{ color: blue; font-weight: bold } +.v_word, .v_word3 { color: navy; font-weight: bold } +.v_word2 { color: green; font-weight: bold } diff --git a/examples/trac/htdocs/css/diff.css b/examples/trac/htdocs/css/diff.css new file mode 100644 --- /dev/null +++ b/examples/trac/htdocs/css/diff.css @@ -0,0 +1,163 @@ +/* Diff preferences */ +#prefs fieldset { margin: 1em .5em .5em; padding: .5em 1em 0 } + +/* Diff/change overview */ +#overview { + line-height: 130%; + margin-top: 1em; + padding: .5em; +} +#overview dt.property { + font-weight: bold; + padding-right: .25em; + position: absolute; + left: 0; + text-align: right; + width: 7.75em; +} +#overview dd { margin-left: 8em } + +#overview .message { padding: 1em 0 1px } +#overview dd.message p, #overview dd.message ul, #overview dd.message ol, +#overview dd.message pre { + margin-bottom: 1em; + margin-top: 0; +} + +/* Colors for change types */ +#chglist .edit, #overview .mod, .diff #legend .mod { background: #fd8 } +#chglist .delete, #overview .rem, .diff #legend .rem { background: #f88 } +#chglist .add, #overview .add, .diff #legend .add { background: #bfb } +#chglist .copy, #overview .cp, .diff #legend .cp { background: #88f } +#chglist .move, #overview .mv, .diff #legend .mv { background: #ccc } +#chglist .unknown { background: #fff } + +/* Legend for diff colors */ +.diff #legend { + float: left; + font-size: 9px; + line-height: 1em; + margin: 1em 0; + padding: .5em; +} +.diff #legend h3 { display: none; } +.diff #legend dt { + background: #fff; + border: 1px solid #999; + float: left; + margin: .1em .5em .1em 2em; + overflow: hidden; + width: .8em; height: .8em; +} +.diff #legend dl, .diff #legend dd { + display: inline; + float: left; + padding: 0; + margin: 0; + margin-right: .5em; +} + +/* Styles for the list of diffs */ +.diff ul.entries { clear: both; margin: 0; padding: 0 } +.diff li.entry { + background: #f7f7f7; + border: 1px solid #d7d7d7; + list-style-type: none; + margin: 0 0 2em; + padding: 2px; + position: relative; + width: 100%; +} +.diff h2 { + color: #333; + font-size: 14px; + letter-spacing: normal; + margin: 0 auto; + padding: .1em 0 .25em .5em; +} + +/* Styles for the actual diff tables (side-by-side and inline) */ +.diff table { + border: 1px solid #ddd; + border-spacing: 0; + border-top: 0; + empty-cells: show; + font-size: 12px; + line-height: 130%; + padding: 0; + margin: 0 auto; + table-layout: fixed; + width: 100%; +} +.diff table col.lineno { width: 4em } +.diff table th { + border-right: 1px solid #d7d7d7; + border-bottom: 1px solid #998; + font-size: 11px; +} +.diff table thead th { + background: #eee; + border-top: 1px solid #d7d7d7; + color: #999; + padding: 0 .25em; + text-align: center; + white-space: nowrap; +} +.diff table tbody th { + background: #eed; + color: #886; + font-weight: normal; + padding: 0 .5em; + text-align: right; + vertical-align: top; +} +.diff table tbody td { + background: #fff; + font: normal 11px monospace; + overflow: hidden; + padding: 1px 2px; + vertical-align: top; +} +.diff table tbody.skipped td { + background: #f7f7f7; + border: 1px solid #d7d7d7; +} +.diff table td del, .diff table td ins { text-decoration: none } +.diff table td del { color: #600 } +.diff table td ins { color: #060 } + +/* Styles for the inline diff */ +.diff table.inline tbody.mod td.l, .diff table.inline tbody.rem td.l { + background: #fdd; + border-color: #c00; + border-style: solid; + border-width: 0 1px 0 1px; +} +.diff table.inline tbody.mod td.r, .diff table.inline tbody.add td.r { + background: #dfd; + border-color: #0a0; + border-style: solid; + border-width: 0 1px 0 1px; +} +.diff table.inline tbody.mod tr.first td.l, +.diff table.inline tbody.rem tr.first td.l { border-top-width: 1px } +.diff table.inline tbody.mod tr.last td.l, +.diff table.inline tbody.rem tr.last td.l { border-bottom-width: 1px } +.diff table.inline tbody.mod tr.first td.r, +.diff table.inline tbody.add tr.first td.r { border-top-width: 1px } +.diff table.inline tbody.mod tr.last td.r, +.diff table.inline tbody.add tr.last td.r { border-bottom-width: 1px } +.diff table.inline tbody.mod td del { background: #e99; color: #000 } +.diff table.inline tbody.mod td ins { background: #9e9; color: #000 } + +/* Styles for the side-by-side diff */ +.diff table.sidebyside colgroup.content { width: 50% } +.diff table.sidebyside tbody.mod td.l { background: #fe9 } +.diff table.sidebyside tbody.mod td.r { background: #fd8 } +.diff table.sidebyside tbody.add td.l { background: #dfd } +.diff table.sidebyside tbody.add td.r { background: #cfc } +.diff table.sidebyside tbody.rem td.l { background: #f88 } +.diff table.sidebyside tbody.rem td.r { background: #faa } +.diff table.sidebyside tbody.mod del, .diff table.sidebyside tbody.mod ins { + background: #fc0; +} diff --git a/examples/trac/htdocs/css/report.css b/examples/trac/htdocs/css/report.css new file mode 100644 --- /dev/null +++ b/examples/trac/htdocs/css/report.css @@ -0,0 +1,90 @@ +@import url(code.css); + +h1 .numrows, h2 .numrows { + margin-left: 1em; + color: #999; + font-size: 65%; + font-weight: normal; +} +h2 { + background: #f7f7f7; + border-bottom: 1px solid #d7d7d7; + margin: 2em 0 0; + padding: 0 .33em; +} +#report-descr { margin: 0 2em; font-size: 90% } +#report-notfound { margin: 2em; font-size: 110% } + +#query { clear: right } +#query fieldset, #query fieldset input, #query fieldset select { font-size: 11px } +#query fieldset { margin-top: 1em } +#query .option, #query .option input, #query .option select { font-size: 11px } +#query .option { float: left; line-height: 2em; margin: .9em 2.5em 0 .5em; padding: 0 0 .1em } +#query .buttons { float: right; margin-top: .5em } +#query .buttons input { margin: .5em } +#query hr { clear: both; margin: 0; visibility: hidden } + +#filters table { width: 100% } +#filters tr { height: 2em } +#filters th, #filters td { padding: 0 .2em; vertical-align: middle } +#filters th { font-size: 11px; text-align: right; white-space: nowrap; } +#filters td label { font-size: 11px } +#filters td.mode { text-align: right } +#filters td.filter { width: 100% } +#filters td.filter label { padding-right: 1em } +#filters td.actions { text-align: right; white-space: nowrap } + +/* Styles for the report list and the report results table + (extends the styles for "table.listing") */ +.reports td.title { width: 100% } +.reports tbody td :link, .reports tbody td :visited, +.tickets tbody td :link, .tickets tbody td :visited { display: block } +.tickets { border-bottom: none } +.tickets thead th { text-transform: capitalize; white-space: nowrap; } +.tickets tbody td, .reports tbody td { padding: .1em .5em !important } +.tickets tbody td a, .reports tbody td a { border-bottom: none } +.tickets tbody td.id :link, .tickets tbody td.id :visited { + font-weight: bold; +} +.tickets tbody tr:hover { background: #eed; color: #000 } +.tickets tr.color1-odd { background: #fdc; border-color: #e88; color: #a22 } +.tickets tr.color1-even { background: #fed; border-color: #e99; color: #a22 } +.tickets tr.color2-odd { background: #ffb; border-color: #eea; color: #880 } +.tickets tr.color2-even { background: #ffd; border-color: #dd8; color: #880 } +.tickets tr.color3-odd { background: #fbfbfb; border-color: #ddd; color: #444 } +.tickets tr.color3-even { background: #f6f6f6; border-color: #ccc; color: #333 } +.tickets tr.color4-odd { background: #e7ffff; border-color: #cee; color: #099 } +.tickets tr.color4-even { background: #dff; border-color: #bee; color: #099 } +.tickets tr.color5-odd { background: #e7eeff; border-color: #cde; color: #469 } +.tickets tr.color5-even { background: #dde7ff; border-color: #cde; color: #469 } +.tickets tr.color6-odd { background: #f0f0f0; border-color: #ddd; color: #888 } +.tickets tr.color6-even { background: #f7f7f7; border-color: #ddd; color: #888 } +.tickets tr.color6-odd a, .color6-even a { color: #b66 } +.tickets tbody tr.fullrow td, .tickets tbody td.fullrow { + border: none; + color: #333; + background: transparent; + padding: 0 1em 2em 2em !important; + font-size: 85%; +} +.tickets tbody tr.fullrow:hover { background: transparent !important } +.tickets .fullrow :link, .tickets .fullrow :visited { display: inline } +.tickets .fullrow .meta { color: #999; margin-bottom: -.5em; margin-left: -1em } +.tickets .fullrow hr { display: none } + +/* Query results table */ +table.tickets tbody tr.added td { font-weight: bold } +table.tickets tbody tr.changed td { font-style: italic } +table.tickets tbody tr.removed td { color: #999 } +table.tickets tbody tr.prio1 { background: #fdc; border-color: #e88 } +table.tickets tbody tr.even.prio1 { background: #fed; border-color: #e99 } +table.tickets tbody tr.prio2 { background: #ffb; border-color: #eea } +table.tickets tbody tr.even.prio2 { background: #ffd; border-color: #dd8 } +table.tickets tbody tr.prio3 { background: #fbfbfb; border-color: #ddd } +table.tickets tbody tr.even.prio3 { background: #f6f6f6; border-color: #ccc } +table.tickets tbody tr.prio4 { background: #e7ffff; border-color: #cee } +table.tickets tbody tr.even.prio4 { background: #dff; border-color: #bee } +table.tickets tbody tr.prio5 { background: #e7eeff; border-color: #cde } +table.tickets tbody tr.even.prio5 { background: #dde7ff } +table.tickets tbody tr.prio6 { background: #f0f0f0; border-color: #ddd } +table.tickets tbody tr.even.prio6 { background: #f7f7f7 } diff --git a/examples/trac/htdocs/css/roadmap.css b/examples/trac/htdocs/css/roadmap.css new file mode 100644 --- /dev/null +++ b/examples/trac/htdocs/css/roadmap.css @@ -0,0 +1,83 @@ +/* General styles for the progress bars */ +table.progress { + border: 1px solid #d7d7d7; + border-collapse: collapse; + border-spacing: 0; + float: left; + margin: 0; + padding: 0; + empty-cells: show; +} +table.progress a, table.progress :link, table.progress :visited, +table.progress :link:hover, table.progress :visited:hover { + border: none; + display: block; + width: 100%; + height: 1.2em; + padding: 0; + margin: 0; + text-decoration: none +} +table.progress td { background: #fff; padding: 0 } +table.progress td.closed { background: #bae0ba } +table.progress td :hover { background: none } +p.percent { font-size: 10px; line-height: 2.4em; margin: 0.9em 0 0 } + +/* Styles for the roadmap view */ +ul.milestones { margin: 2em 0 0; padding: 0 } +li.milestone { list-style: none; margin-bottom: 4em } +.milestone .info { white-space: nowrap } +.milestone .info h2 { + background: #f7f7f7; + border-bottom: 1px solid #d7d7d7; + margin: 0; +} +.milestone .info h2 :link, .milestone .info h2 :visited { + color: #000; + display: block; + border-bottom: none; +} +.milestone .info h2 :link:hover, .milestone .info h2 :visited:hover { + color: #000; +} +.milestone .info h2 em { color: #b00; font-style: normal } +.milestone .info .date { + color: #888; + font-size: 11px; + font-style: italic; + margin: 0; +} +.milestone .info .progress { margin: 1em 1em 0; width: 40em; max-width: 70% } +.milestone .info dl { + font-size: 10px; + font-style: italic; + margin: 0 1em 2em; + white-space: nowrap; +} +.milestone .info dt { display: inline; margin-left: .5em } +.milestone .info dd { display: inline; margin: 0 1em 0 .5em } +.milestone .description { margin-left: 1em } + +/* Styles for the milestone view */ +.milestone .date { color: #888; font-style: italic; margin: 0 } +.milestone .description { margin: 1em 0 2em } + +/* Styles for the milestone statistics table */ +#stats { float: right; margin: 0 0 2em 2em; width: 400px; max-width: 40% } +#stats legend { white-space: nowrap } +#stats table { border-collapse: collapse; width: 100% } +#stats th, #stats td { font-size: 10px; padding: 0; white-space: nowrap } +#stats th { text-align: right; text-transform: capitalize } +#stats th :link, #stats th :visited { border: none } +#stats td { padding-left: 0.5em; width: 100% } +#stats td table.progress { margin: 3px 4px 3px 0 } +#stats td table.progress td { padding: 0 } +#stats td p.percent { line-height: 1.2em; margin-top: 3px } + +/* Styles for the milestone edit form */ +#edit fieldset { margin: 1em 0 } +#edit em { color: #888; font-size: smaller } +#edit .disabled em { color: #d7d7d7 } +#edit .field { margin-top: 1.3em } +#edit label { padding-left: .2em } +#edit textarea#description { width: 97% } diff --git a/examples/trac/htdocs/css/search.css b/examples/trac/htdocs/css/search.css new file mode 100644 --- /dev/null +++ b/examples/trac/htdocs/css/search.css @@ -0,0 +1,14 @@ +#content form { margin: 1em 0 0 } +#content form p { margin: .5em 0 } +#content hr { clear: left; margin-bottom: 0 } +#notfound { margin: 2em; font-size: 110% } + +#results { margin-right: 3em } +#results dt { margin: 1.5em 0 0 } +#results dt a { color: #33c } +#results dd { font-size: 80%; margin: 0; padding: 0 } +#results .author, #results .date, #results .keywords { color: #090 } + +#quickjump { font-style: italic; font-weight: bold; } + +#paging { margin: 0 0 2em; text-align: center } diff --git a/examples/trac/htdocs/css/ticket.css b/examples/trac/htdocs/css/ticket.css new file mode 100644 --- /dev/null +++ b/examples/trac/htdocs/css/ticket.css @@ -0,0 +1,92 @@ +@import url(code.css); + +#content { width: 700px; max-width: 100% } + +#newticket #description { width: 100% } +#newticket #properties { width: 100% } + +#ticket { + background: #ffd; + border: 1px outset #996; + margin-top: 1em; + padding: .5em 1em; + position: relative; +} +h1 .status { color: #444; text-transform: lowercase; } +#ticket h2.summary { margin: 0 0 .8em 0 } +#ticket .date { color: #996; float: right; font-size: 85%; position: relative } +#ticket .date p { margin: 0 } + +#ticket table.properties { + border-bottom: 1px solid #dd9; + border-top: 1px solid #dd9; + border-collapse: collapse; + table-layout: fixed; + width: 100%; +} +#ticket table.properties tr { border-bottom: 1px dotted #eed } +#ticket table.properties td, #ticket table.properties th { + font-size: 80%; + padding: .5em 1em; + vertical-align: top; +} +#ticket table.properties th { + color: #663; + font-weight: normal; + text-align: left; + width: 20%; +} +#ticket table.properties td { width: 30% } +#ticket table.properties .description { border-top: 1px solid #dd9 } + +#ticket .description form { + float: right; + position: relative; + bottom: 1.8em; +} + +#changelog { border: 1px outset #996; padding: 1em } +#changelog h3 { + border-bottom: 1px solid #d7d7d7; + color: #999; + font-size: 100%; + font-weight: normal; +} +#changelog .threading { + float: right; + position: relative; + bottom: 0.3em; +} +#changelog h3 .threading form { + display: inline +} + +#changelog .changes { list-style: square; margin-left: 2em; padding: 0 } +#changelog .comment { margin-left: 2em } + +form .field { margin-top: .75em; width: 100% } +form #comment { width: 100% } + +#properties { white-space: nowrap; line-height: 160%; padding: .5em } +#properties table { border-spacing: 0; width: 100%; } +#properties table th { + padding: .4em; + text-align: right; + width: 20%; + vertical-align: top; +} +#properties table th.col2 { border-left: 1px dotted #d7d7d7 } +#properties table td { vertical-align: middle; width: 30% } +#properties table td.fullrow { vertical-align: middle; width: 80% } + +#action { line-height: 2em } + +fieldset.radio { border: none; margin: 0; padding: 0 } +fieldset.radio legend { + color: #000; + float: left; + font-size: 100%; + font-weight: normal; + padding: 0 1em 0 0; +} +fieldset.radio label { padding-right: 1em } diff --git a/examples/trac/htdocs/css/timeline.css b/examples/trac/htdocs/css/timeline.css new file mode 100644 --- /dev/null +++ b/examples/trac/htdocs/css/timeline.css @@ -0,0 +1,70 @@ +/* Timeline */ + +* html #prefs { width: 34em } /* Set width only for IE */ +#prefs fieldset label { display: block } +#prefs .buttons { margin-top: -1.6em } + +h2 { + background: #f7f7f7; + border-bottom: 1px solid #d7d7d7; + font-size: 105%; + margin: 2em 0 .5em; +} +dl { line-height: 1.3em; margin-left: 1em } +dt { background: 3px 4px no-repeat; padding: 0 } +dt :link, dt :visited { + background: 3px 3px no-repeat; + border: none; + color: #000; + padding: 0 4px 2px 22px; +} +dt>:link, dt>:visited { + /* Hide from IE/Win */ + background-position: 3px 4px; + display: block; +} +dt :link:hover, dt :visited:hover { background-color: #eed; color: #000 } +dt em { + border-bottom: 1px dotted #bbb; + color: #b00; + font-style: normal; + text-decoration: none; +} +dt .time { color: #999; font-size: 80%; } +dd { + font-size: 80%; + margin: 0 0 .75em 5.5em; + padding: 0; + color: #776; +} + +/* Apply icon background-image twice to avoid hover-flicker in IE/Win */ +dt.changeset, dt.changeset a { background-image: url(../changeset.png) !important } +dt.newticket, dt.newticket a { background-image: url(../newticket.png) !important } +dt.editedticket, dt.editedticket a { background-image: url(../editedticket.png) !important } +dt.closedticket, dt.closedticket a { background-image: url(../closedticket.png) !important } +dt.wiki, dt.wiki a { background-image: url(../wiki.png) !important } +dt.milestone, dt.milestone a { background-image: url(../milestone.png) !important } +dt.attachment, dt.attachment a { background-image: url(../attachment.png) !important } + +/* styles for the 'changeset_long_messages' option */ +dd.changeset p { margin: 0; padding: 0 } +dd.changeset ul { padding-left: 15px; } + +/* Styles for the 'changeset_show_files' option */ +dd.changeset ul.changes { + padding-left: 0; + list-style-type: none; +} +dd.changeset .changes li div { + border: 1px solid #999; + float: left; + margin: .4em .5em 0 0; + overflow: hidden; + width: .8em; height: .8em; +} +dd.changeset .changes .add { background: #bfb } +dd.changeset .changes .delete { background: #f88 } +dd.changeset .changes .edit { background: #fd8 } +dd.changeset .changes .copy { background: #88f } +dd.changeset .changes .move { background: #ccc } diff --git a/examples/trac/htdocs/css/trac.css b/examples/trac/htdocs/css/trac.css new file mode 100644 --- /dev/null +++ b/examples/trac/htdocs/css/trac.css @@ -0,0 +1,450 @@ +/* Trac CSS */ +body { + background: #fff; + color: #000; + margin: 10px; + padding: 0; +} +body, th, td { + font: normal 13px verdana,arial,'Bitstream Vera Sans',helvetica,sans-serif; +} +h1, h2, h3, h4 { + font-family: arial,verdana,'Bitstream Vera Sans',helvetica,sans-serif; + font-weight: bold; + letter-spacing: -0.018em; +} +h1 { font-size: 19px; margin: .15em 1em 0 0 } +h2 { font-size: 16px } +h3 { font-size: 14px } +hr { border: none; border-top: 1px solid #ccb; margin: 2em 0 } +address { font-style: normal } +img { border: none } + +.underline { text-decoration: underline } +ol.loweralpha { list-style-type: lower-alpha } +ol.upperalpha { list-style-type: upper-alpha } +ol.lowerroman { list-style-type: lower-roman } +ol.upperroman { list-style-type: upper-roman } +ol.arabic { list-style-type: decimal } + +/* Link styles */ +:link, :visited { + text-decoration: none; + color: #b00; + border-bottom: 1px dotted #bbb; +} +:link:hover, :visited:hover { + background-color: #eee; + color: #555; +} +h1 :link, h1 :visited ,h2 :link, h2 :visited, h3 :link, h3 :visited, +h4 :link, h4 :visited, h5 :link, h5 :visited, h6 :link, h6 :visited { + color: inherit; +} + +/* Heading anchors */ +.anchor:link, .anchor:visited { + border: none; + color: #d7d7d7; + font-size: .8em; + vertical-align: text-top; +} +* > .anchor:link, * > .anchor:visited { + visibility: hidden; +} +h1:hover .anchor, h2:hover .anchor, h3:hover .anchor, +h4:hover .anchor, h5:hover .anchor, h6:hover .anchor { + visibility: visible; +} + +@media screen { + a.ext-link .icon { + background: url(../extlink.gif) left center no-repeat; + padding-left: 16px; + } + * html a.ext-link .icon { display: inline-block; } +} + +/* Forms */ +input, textarea, select { margin: 2px } +input, select { vertical-align: middle } +input[type=button], input[type=submit], input[type=reset] { + background: #eee; + color: #222; + border: 1px outset #ccc; + padding: .1em .5em; +} +input[type=button]:hover, input[type=submit]:hover, input[type=reset]:hover { + background: #ccb; +} +input[type=button][disabled], input[type=submit][disabled], +input[type=reset][disabled] { + background: #f6f6f6; + border-style: solid; + color: #999; +} +input[type=text], input.textwidget, textarea { border: 1px solid #d7d7d7 } +input[type=text], input.textwidget { padding: .25em .5em } +input[type=text]:focus, input.textwidget:focus, textarea:focus { + border: 1px solid #886; +} +option { border-bottom: 1px dotted #d7d7d7 } +fieldset { border: 1px solid #d7d7d7; padding: .5em; margin: 0 } +fieldset.iefix { background: transparent; border: none; padding: 0; margin: 0 } +* html fieldset.iefix { width: 98% } +fieldset.iefix p { margin: 0 } +legend { color: #999; padding: 0 .25em; font-size: 90%; font-weight: bold } +label.disabled { color: #d7d7d7 } +.buttons { margin: .5em .5em .5em 0 } +.buttons form, .buttons form div { display: inline } +.buttons input { margin: 1em .5em .1em 0 } +.inlinebuttons input { + font-size: 70%; + border-width: 1px; + border-style: dotted; + margin: 0; + padding: 0.1em; + background: none; +} + +/* Header */ +#header hr { display: none } +#header h1 { margin: 1.5em 0 -1.5em; } +#header img { border: none; margin: 0 0 -3em } +#header :link, #header :visited, #header :link:hover, #header :visited:hover { + background: transparent; + color: #555; + margin-bottom: 2px; + border: none; +} +#header h1 :link:hover, #header h1 :visited:hover { color: #000 } + +/* Quick search */ +#search { + clear: both; + font-size: 10px; + height: 2.2em; + margin: 0 0 1em; + text-align: right; +} +#search input { font-size: 10px } +#search label { display: none } + +/* Navigation */ +.nav h2, .nav hr { display: none } +.nav ul { font-size: 10px; list-style: none; margin: 0; text-align: right } +.nav li { + border-right: 1px solid #d7d7d7; + display: inline; + padding: 0 .75em; + white-space: nowrap; +} +.nav li.last { border-right: none } + +/* Main navigation bar */ +#mainnav { + background: #f7f7f7 url(../topbar_gradient.png) 0 0; + border: 1px solid #000; + font: normal 10px verdana,'Bitstream Vera Sans',helvetica,arial,sans-serif; + margin: .66em 0 .33em; + padding: .2em 0; +} +#mainnav li { border-right: none; padding: .25em 0 } +#mainnav :link, #mainnav :visited { + background: url(../dots.gif) 0 0 no-repeat; + border-right: 1px solid #fff; + border-bottom: none; + border-left: 1px solid #555; + color: #000; + padding: .2em 20px; +} +* html #mainnav :link, * html #mainnav :visited { background-position: 1px 0 } +#mainnav :link:hover, #mainnav :visited:hover { + background-color: #ccc; + border-right: 1px solid #ddd; +} +#mainnav .active :link, #mainnav .active :visited { + background: #333 url(../topbar_gradient2.png) 0 0 repeat-x; + border-top: none; + border-right: 1px solid #000; + color: #eee; + font-weight: bold; +} +#mainnav .active :link:hover, #mainnav .active :visited:hover { + border-right: 1px solid #000; +} + +/* Context-dependent navigation links */ +#ctxtnav { height: 1em } +#ctxtnav li ul { + background: #f7f7f7; + color: #ccc; + border: 1px solid; + padding: 0; + display: inline; + margin: 0; +} +#ctxtnav li li { padding: 0; } +#ctxtnav li li :link, #ctxtnav li li :visited { padding: 0 1em } +#ctxtnav li li :link:hover, #ctxtnav li li :visited:hover { + background: #bba; + color: #fff; +} + +/* Alternate links */ +#altlinks { clear: both; text-align: center } +#altlinks h3 { font-size: 12px; letter-spacing: normal; margin: 0 } +#altlinks ul { list-style: none; margin: 0; padding: 0 0 1em } +#altlinks li { + border-right: 1px solid #d7d7d7; + display: inline; + font-size: 11px; + line-height: 16px; + padding: 0 1em; + white-space: nowrap; +} +#altlinks li.last { border-right: none } +#altlinks li :link, #altlinks li :visited { + background-position: 0 -1px; + background-repeat: no-repeat; + border: none; +} +#altlinks li a.ics { background-image: url(../ics.png); padding-left: 22px } +#altlinks li a.rss { background-image: url(../xml.png); padding-left: 42px } + +/* Footer */ +#footer { + clear: both; + color: #bbb; + font-size: 10px; + border-top: 1px solid; + height: 31px; + padding: .25em 0; +} +#footer :link, #footer :visited { color: #bbb; } +#footer hr { display: none } +#footer #tracpowered { border: 0; float: left } +#footer #tracpowered:hover { background: transparent } +#footer p { margin: 0 } +#footer p.left { + float: left; + margin-left: 1em; + padding: 0 1em; + border-left: 1px solid #d7d7d7; + border-right: 1px solid #d7d7d7; +} +#footer p.right { + float: right; + text-align: right; +} + +#content { padding-bottom: 2em; position: relative } + +#help { + clear: both; + color: #999; + font-size: 90%; + margin: 1em; + text-align: right; +} +#help :link, #help :visited { cursor: help } +#help hr { display: none } + +/* Page preferences form */ +#prefs { + background: #f7f7f0; + border: 1px outset #998; + float: right; + font-size: 9px; + padding: .8em; + position: relative; + margin: 0 1em 1em; +} +* html #prefs { width: 26em } /* Set width only for IE */ +#prefs input, #prefs select { font-size: 9px; vertical-align: middle } +#prefs fieldset { + background: transparent; + border: none; + margin: .5em; + padding: 0; +} +#prefs fieldset legend { + background: transparent; + color: #000; + font-size: 9px; + font-weight: normal; + margin: 0 0 0 -1.5em; + padding: 0; +} +#prefs .buttons { text-align: right } + +/* Version information (browser, wiki, attachments) */ +#info { + margin: 1em 0 0 0; + background: #f7f7f0; + border: 1px solid #d7d7d7; + border-collapse: collapse; + border-spacing: 0; + clear: both; + width: 100%; +} +#info th, #info td { font-size: 85%; padding: 2px .5em; vertical-align: top } +#info th { font-weight: bold; text-align: left; white-space: nowrap } +#info td.message { width: 100% } +#info .message ul { padding: 0; margin: 0 2em } +#info .message p { margin: 0; padding: 0 } + +/* Wiki */ +.wikipage { padding-left: 18px } +.wikipage h1, .wikipage h2, .wikipage h3 { margin-left: -18px } + +a.missing:link, a.missing:visited, span.missing { color: #998 } +a.missing:link, a.missing:visited { background: #fafaf0 } +a.missing:hover { color: #000 } +a.closed:link, a.closed:visited { text-decoration: line-through } + +dl.wiki dt { font-weight: bold } +dl.compact dt { float: left; padding-right: .5em } +dl.compact dd { margin: 0; padding: 0 } + +pre.wiki, pre.literal-block { + background: #f7f7f7; + border: 1px solid #d7d7d7; + margin: 1em 1.75em; + padding: .25em; + overflow: auto; +} + +blockquote.citation { + margin: -0.6em 0; + border-style: solid; + border-width: 0 0 0 2px; + padding-left: .5em; + border-color: #b44; +} +.citation blockquote.citation { border-color: #4b4; } +.citation .citation blockquote.citation { border-color: #44b; } +.citation .citation .citation blockquote.citation { border-color: #c55; } + +table.wiki { + border: 2px solid #ccc; + border-collapse: collapse; + border-spacing: 0; +} +table.wiki td { border: 1px solid #ccc; padding: .1em .25em; } + +.wikitoolbar { + border: solid #d7d7d7; + border-width: 1px 1px 1px 0; + height: 18px; + width: 208px; +} +.wikitoolbar :link, .wikitoolbar :visited { + background: transparent url(../edit_toolbar.png) no-repeat; + border: 1px solid #fff; + border-left-color: #d7d7d7; + cursor: default; + display: block; + float: left; + width: 24px; + height: 16px; +} +.wikitoolbar :link:hover, .wikitoolbar :visited:hover { + background-color: transparent; + border: 1px solid #fb2; +} +.wikitoolbar a#em { background-position: 0 0 } +.wikitoolbar a#strong { background-position: 0 -16px } +.wikitoolbar a#heading { background-position: 0 -32px } +.wikitoolbar a#link { background-position: 0 -48px } +.wikitoolbar a#code { background-position: 0 -64px } +.wikitoolbar a#hr { background-position: 0 -80px } +.wikitoolbar a#np { background-position: 0 -96px } +.wikitoolbar a#br { background-position: 0 -112px } + +/* Styles for the form for adding attachments. */ +#attachment .field { margin-top: 1.3em } +#attachment label { padding-left: .2em } +#attachment fieldset { margin-top: 2em } +#attachment fieldset .field { float: left; margin: 0 1em .5em 0 } +#attachment .options { float: left; padding: 0 0 1em 1em } +#attachment br { clear: left } +.attachment #preview { margin-top: 1em } + +/* Styles for the list of attachments. */ +#attachments { border: 1px outset #996; padding: 1em } +#attachments .attachments { margin-left: 2em; padding: 0 } +#attachments dt { display: list-item; list-style: square; } +#attachments dd { font-style: italic; margin-left: 0; padding-left: 0; } + + +/* Styles for tabular listings such as those used for displaying directory + contents and report results. */ +table.listing { + clear: both; + border-bottom: 1px solid #d7d7d7; + border-collapse: collapse; + border-spacing: 0; + margin-top: 1em; + width: 100%; +} +table.listing th { text-align: left; padding: 0 1em .1em 0; font-size: 12px } +table.listing thead { background: #f7f7f0 } +table.listing thead th { + border: 1px solid #d7d7d7; + border-bottom-color: #999; + font-size: 11px; + font-weight: bold; + padding: 2px .5em; + vertical-align: bottom; +} +table.listing thead th :link:hover, table.listing thead th :visited:hover { + background-color: transparent; +} +table.listing thead th a { border: none; padding-right: 12px } +table.listing th.asc a, table.listing th.desc a { font-weight: bold } +table.listing th.asc a, table.listing th.desc a { + background-position: 100% 50%; + background-repeat: no-repeat; +} +table.listing th.asc a { background-image: url(../asc.png) } +table.listing th.desc a { background-image: url(../desc.png) } +table.listing tbody td, table.listing tbody th { + border: 1px dotted #ddd; + padding: .33em .5em; + vertical-align: top; +} +table.listing tbody td a:hover, table.listing tbody th a:hover { + background-color: transparent; +} +table.listing tbody tr { border-top: 1px solid #ddd } +table.listing tbody tr.even { background-color: #fcfcfc } +table.listing tbody tr.odd { background-color: #f7f7f7 } +table.listing tbody tr:hover { background: #eed !important } + +/* Styles for the error page (and rst errors) */ +#content.error .message, div.system-message { + background: #fdc; + border: 2px solid #d00; + color: #500; + padding: .5em; + margin: 1em 0; +} +#content.error pre, div.system-message pre { margin-left: 1em; overflow: auto } +div.system-message p { margin: 0; } +div.system-message p.system-message-title { font-weight: bold; } + +/* Styles for search word highlighting */ +@media screen { + .searchword0 { background: #ff9 } + .searchword1 { background: #cfc } + .searchword2 { background: #cff } + .searchword3 { background: #ccf } + .searchword4 { background: #fcf } +} + +@media print { + #header, #altlinks, #footer, #help { display: none } + .nav, form, .buttons form, form .buttons { display: none } + form.printableform { display: block } +} diff --git a/examples/trac/htdocs/css/wiki.css b/examples/trac/htdocs/css/wiki.css new file mode 100644 --- /dev/null +++ b/examples/trac/htdocs/css/wiki.css @@ -0,0 +1,53 @@ +@import url(code.css); + +/* Styles for the page editing form */ +#edit #rows { float: right; font-size: 80% } +#edit #rows select { font-size: 90% } +#edit #text { clear: both; width: 100% } +#edit .wikitoolbar { float: left; } +#changeinfo { padding: .5em } +#changeinfo .field { float: left; margin: 0 1em .5em 0 } +#changeinfo br { clear: left } +#changeinfo .options { padding: 0 0 1em 1em } +#changeinfo .options, #changeinfo .buttons { clear: left } +#delete { margin-left: 6em } +#preview { + background: #f4f4f4 url(../draft.png); + margin: 1em 0 2em; + overflow: auto; +} + +/* Diff view */ +#overview .multi { color: #999 } +#overview .ipnr { color: #999; font-size: 80% } +#overview .comment { padding: 1em 0 0 } + +/* Styles for the page history table + (extends the styles for "table.listing") */ +#wikihist td { padding: 0 .5em } +#wikihist td.date, #wikihist td.diff, #wikihist td.version, +#wikihist td.author { + white-space: nowrap; +} +#wikihist td.version { text-align: center } +#wikihist td.comment { width: 100% } + +@media print { + th.diff, td.diff { display: none } +} + +/* Styles for the TracGuideToc wikimacro */ +.wiki-toc { + padding: .5em 1em; + margin: 0 0 2em 1em; + float: right; + border: 1px outset #ddc; + background: #ffd; + font-size: 85%; + position: relative; +} +.wiki-toc h4 { font-size: 12px; margin: 0 } +.wiki-toc ul, .wiki-toc ol { list-style: none; padding: 0; margin: 0 } +.wiki-toc ul ul, .wiki-toc ol ol { padding-left: 1.2em } +.wiki-toc li { margin: 0; padding: 0 } +.wiki-toc .active { background: #ff9; position: relative; } diff --git a/examples/trac/htdocs/desc.png b/examples/trac/htdocs/desc.png new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..207018551f623aaa16ad5e52477f37e7c84c5590 GIT binary patch literal 222 zc%17D@N?(olHy`uVBq!ia0vp^96-#@!VDyjmkZ_sDdu7)&kzm{j@u9Y9{{=h0X`wF zuV25udiCm=GiUbi-@kI@%BvF}%mYfXmIV0)GyDevnG0$lvp5SpB8wRqxITa|qthCb z89+hB64!{5;QX|b^2DN42H(WwqWs*{l*~j0x6Go{^8BLgVg+M8gZG(+KwS(Po-U3d z6?2jUn3&i^#l_Xx(!|A?+1$j%8QIu`)7jYA1l<)5E;+!!;Cq^Rr|-_5g+M(Fp00i_ I>zopr0F_rm$N&HU diff --git a/examples/trac/htdocs/dots.gif b/examples/trac/htdocs/dots.gif new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..c6ae052feca96879d63b3046e7c6416029a4104f GIT binary patch literal 50 yc${Ew&^?NHAbP zrHHojh^<9bQ0#|&czHzS)fy0a2}-C^6!nJITC29+eGcEff8Mq3KR0W!$eBI!+q364 zGkZ?7@o`ZU82UcP+!?Af!iyTj(GsbB6txIZyDF*bH@YFjymxtrC~i^KlSdTtOEuvSJ{pdbpme$KW(l1A%U(B+&R|j+ds} zj$4S4F+&8xgD{pr>w;|9xp)#>SbTs^rgyZ@Z-SktltIA*m(0$> zS*4+`3rpf8Fk%sj)J4ct8v6;+(NCMWENjFnM8m^LJ})`5MLPr!S|K{F!fK_Eia`Ke zg-ImT?-_xx3_YgVO_&6sqL%_;K6(_39lI1c3RA?VC9uRuSQsizC#p`T3y&jFp`77W z8XXrR1is5M2I~@lv5D8aJJ1%uvAm?lm_A@qYo?3HE?I$OMCc$TG!3N4nJDIJ5b7#4 zoLyXvLJ=%a&c=h?vq@~%PiyY(MO0E%;wW`SECzAUS3t1iJh4U?A%6}Jn!nn#g8_Uj zUPpV(%N<;X#QT-Hf{MTJohM+u;J2D`%!StgB~aa^aayithU{7dwhdHtLE}*oHr%zW zG8_hMNBcS5N3Jw>NKT>Wt4pQTSyu+{xp@pB9I~d1;1-@~fqrk~N!v2WMxVs!(_AH8 zOzwQO@f{L46P5jfKmCa9r{{L;#>}s%AOJ)UH$a;D`fx=&JfGW4OJ$S5i6ho%OgPex z3!FN>3X@;35(ljHcI@N)#P-zgs2G?uJ$amr=!I#flt!aR`GtD6i^@ z_I_cxb^=MNS`T{>xwQ7}jYi4K`DLe{?j%*`o*b(lqa7 z%972rW877G%_JQaF3~hwRbnKk!V>UW`61*4Y8A8&&e4AxQFTIl`p{KGbX)}m=&Qvq zeCrQ#q;q}<@87_k5Z5hnjZe(Y=UGTnY!;~9#LfK?t)F{#kG4}0Y#)sYFVVCrI(mg4 z(;g#%jkjgD@~64aAn&!gnvsGuOdP~6ymvO6ROkHj#I zR!69WB+K10{HS{qj}DrUTB>6TyOmt=;VvVDCS|2S4RPbBt~CpJ`4gPsJ4S8!U<#eU3hK9cA;A9_@)tB z29Q}zQe$020W2tfd-8Dm-(;&N-&?_j#NEuCqF*(~nOYJzFH={dEjo4b^}kWb6E3wO zY+tWLoK}PZs-K*x%BP{d=XFbkfQF?0t6QVQwFsY(?k+VCaVehpyZbCsq#k}B1-2u# z7_ozzn}+tW?$nosM8y6?5FPJ?*zX)3CZ{20rcDh&*{HVM^*4LCHD zm_So9Fm~@P?&U7~#0C*;pebiIIeAmKjq5X!+JSA^X)Q)$WCEP<*-dvuV+&1L>bWWI zH@ud3gpKTEv7#s3V0gY65L+Rc!7S2L%e1P$VybArbe`l;!){xBJvNhc}?bg0mK&uDTUn$*5+%6-nFWxazdk9HhQ{mm+Wm>y@i92VY&ZxYaGF=yeVq%WE=lHe6 zC;9jdcxV(@oV@+khi9u5niW;NaqL?K#NG-FU-^BO>_MGA;`yQ@mAH4?^ldHd2rkva z;)+o2pFCUI11kT_4q5tFKL!8*ewVnCzWJMVxpTDbK9YfZCxMgA`LUR)Pg3$Vx3l#f zsk=x{zM4Yr$r)*LT-u%`^{Ws(w{vLcTA7+3w^^ms-A z+_$Xcy1#a+Cd-O^7LM2j)#$4wS$TK-(C4W)>~QAi{j>qgSD9!~;Mq%l(y_SMT663O z%9@~g_^5S&2R0%u-?TuGiq~1AA59^~q6TRx8~gB7yRSM}C@0>-nfn{I`{PM$(T1$_ zRg*<1%aKgK=Gtw^5QR<5ij97~KvemUv~wg9T?kv(zf@Smh(DU)@E_?Q=>@u zx|+yFrBw$n`-?3_HT^y=G4)MCT?K#dM|?ZBzg_%UHt1vHn|hZTg%1k?70r}$5VNvh zSFP;e)d~!@G=A&l#XBq~tlq74?9Pv>EnIYqOqMfS#1t{w6U#a^M@|{<{Y)c~MtdUH zuV9G(H9*E7kZQUer)OiQw#&YfCaRLLOz04?SxrA5T%PQIeK((0w5MrsHWywh`VzbQ zBq(4?Cv?w>JbGy?0$b3w;&es&0Ntg%*oBWtc<^Dy>R&WIWFwc`uU@*WHzXT%Ey<4a z_Pva2J?q4Hq}G)e1%|G^7`aYely5pr2SsHH(pdIQ8$(A|LdaNWTBT9!RFFKm+b(uE zh%i{;Brnk`_>A&T{}6GwjFlw?o#;heR9=_ng!8qQ?%o?A!JQCXD35pZvx&BtPw!dm zA=9Z3Q${__l~coJrc1*X0J3Hy@4Qm7*e!1B>N)L0i!<)WjzZGM!b{ztae+dw-DVU1 zEZ*FGu$~b5@wrCps2#{ebgsHb193&TNb-{C%<7!%q#V#9_&>sIFa0Z$6IcA=!j%_z zM05jYv-QyH&D6hNP79FBKv^RY^7O&AUn`Gf@6WcZOY4(#7Og;8RyGQR-gv5p8p8$bQTvhh0BZDVk|*r{rag&rvfyx znvORC;Bqg}c1L1Uoxp0orB)eKV}3g;G_v zp)u#r_fdVW5+_@}MEpAjhf}!WQbQN?`1$_AOn!>>mW6HhsGtyiigxn!&ARu4u?W1k zMvs$-Gc_fE_6k?gX+lYQost{-Vx`kR0v1B`L8jKeMM`%OH$62L(;vVTzzutqE=4>q0g^<|n#|b*V+GP%=Zm$cn~Gvg5a#v0yFNWtY;xQ~R@Mpir)|^#;By zJ!NZXAEjw^ZL&+2}$v;@DmOBpy+Bf((RVY+JVmY?C9G0`yPS``;$K=v(? zWaHT>s-bRVln9dSZAjT{K$wGqA%Wg$pJ3RE~Z~$_9m?{+faYkZ0+Z>YFs}972UoywIN?q77t6m^isKrBNSN# zP|=nlxt!t-aN}zILXvKcf&*dqoxL5G2)9h_%ds14*^HPz@MSO86Z}4D)XcRMKd%l0 zxl5CW0#Z_v1D8?$S}3sa3%dcAefp9KFv^2FR0fY?J(lWuH&@r5iY(4ur~&g@)>5T9 zCmFhJ`nOWO^SW@jrS@pf?{dw^Bv?ZJkA+7qBjED-nEzQN&Bia)7fZK+_szUe+~lS1 zTamNn!JW!Lau?{bw12J86yDB~)duSGicxHmeUs5@ke3w!jyv;;o~|@@hrC4^a(Bv( z!>jbKO17hs2s!_U1*kQ>K7hqRb3Z^ePk_pD>!poUrHiQQf>LeBZ3LC&TV@IP2AZP z;)GaUuU@iNzmxcv*5f!97W&gW0|pJCLG!dU%>-kfNiVFl)VQCQNyU z=0SZ_KsI3zLgz8|r$+1DaG4riV2Exs69zR43+WGO9KA?{#zxX`UakxbM<-0NvrNS8)+ii=Y`{{y1MB7kZ zt&|&zL&q91nPal4sBl1`Kg>7WG_DBhL^owBqOczS`%f_cA4`Q;%%0`b*`(6W5e)c9 MBIAS?!_!p%1T7ytkO diff --git a/examples/trac/htdocs/edgewall.png b/examples/trac/htdocs/edgewall.png new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..9a4b65c99417a91f4102a281abe2aeabdab05ead GIT binary patch literal 2231 zc$@*Z2uSydP)&nq4?eTsS;9}zw zrYCo)#VlIQ;EC1Qs-yiYHx}T=l9n{^d-O=lpW}s2sNa;uR>}h zJxhT#rrh*W(?cfxlc)}9<`Q5{XA}Wt0Ukcx{Qgf;{PRibcq5tSVB6+d(TI_*mzt@Jh2ws5cNE^lnBk zwPQzGFM}(uG69W+R|wXJ1hLYe&!;-a`$aNdqTqFH8>_YPiyZ=MoL_rHSX~mHqK7zk zq1@GI0$Pv#rFkV5j+Hl#hjM-6^%E8z_UaL8njuXpDR7(kS$Pe|IvWUE^iV0ZgqK)7 zITiHY9Ex>yfMKOSQ>ni8yKz@>f^m{cjbUAot4T$e;Y2+x0sPAxV>Xaj7K$~~{WHT# ze=4V|5B*+I-f}h=rwmjK#A;Eo@Ai9i^~*}$ zM6DEg|2*@h0p`9EmZhm!Ot@bNGdDB1kD==G{Ge$M=1v`DZOvg=Zt}mh$7Kdd)Gs`3 zF%zs3o{@67*80zL$4`y1!}Ek&KB)@lxkmCrGg+>*H&|*ABw_olyXMj=m(wQCou{oS z&-s~8bM3T?8*8bo@Db7&__Htk53OYw4n6jWz{E0f`b!?BVOVcC>3|EP%x+Rl(#}Sp z*(X9zC~}P5DMIJKI8rShKf8BQ6=vw2&>@mwix4FzAN5m27}g<@mbi4MMxhdEi#f<0 zSeWCEiJYoMS1LEgw|1Rfweuv&Q%|BH>Ae#m(PxT`ar$?-E!mL$nng^wv4q zh?#J0Rlqe$f>|_9GwBSQkj< zs4;Dr`Tsv+0H-(>teGo;RV{7z{Jj>>*jObzZ*_P^?Fu3>*qAXd$JEha@gwSWOk@%up&HdaqF!2Zp0VbwE%xe=@dipB3G~~%oi?nrcQ?+kvK1AJW~Q|kd(k0;3Knj zUwdT~$i&IbeEypD9ADG42EKAfUgExnUl}Jo53RdQZ zAJ^r_qs*e8{eAS+x-%cE?uG{<%gzL0Vcaw<7_WrOg;>kX8y*PNba!vx3-S}(&3OSn zw4P@P32WfS2bwM1b2tay<7RmAgRsewzJqe}!J=5CZGgE~fc4}8<~LR7kUMhk@D=3P zY}o9E4jz`@67?7VTwqt2b23(JA!^U7s|`4-=4mE5Q8=XFt44U z4Wsyj7s&{#Q=x*d7{hfmOL$n31vzf82=e#R7TpsLKeoh&D0Q+Ig=+W(VsB_wr#w{nHoi3S9 z0)urDFT=~L(CI!hU8f!x?!vH(U4 z|Ni**`TO_x`S$ht{qpqi?(ppC?CR#|<=x-h)z{V0)6dMz%*M#Yz{0+|yt%l# zx3#ylvaqhLudJ!7sHCZ^rKh8xp`MwUnU0c`jFgOtjEaPYgMNU0fPI01e0zF-fqHy- z2P*ro00001VoOIv0Eh)0NB{r;32;bRa{vGf6951U69E94oEQKA00(qQO+^RR1``%9 z9yRBJj{pDy>`6pHR7l6wRtvYAAPfe1)LN=@UcJKFhT{MKk4x}TpSyS4+1Zx%2YULtfTuQQSoVRoa zVw$x|P*rIy@K0%1gdj&nZWPh4dYox-0A^E-h8#d>*Zd5{9To$!O~;@agVt6m8Ct>p zd_yGLDlk^8y<{j!4WJ%~oR}9tJ0$ii*e8#KpYiIEQ$Hh~dQNVX8-v1P}coCE#6y1>~BA zu|IkufUXH4hK?H31@rqOo@-M*R)zYx`{SP@*r6W-!+jpdA81d1boNqYdW>Td$qjy3jw+xnje z+84mZ;<_IJ`s|t5=XTyR@(5UcY8(aH{;?E%2N^t45Z1?3ApigX07*qoM6N<$f@+)O ARsaA1 diff --git a/examples/trac/htdocs/editedticket.png b/examples/trac/htdocs/editedticket.png new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..9d12a9107496c42238d5e88b28cc31e515a9f060 GIT binary patch literal 241 zc%17D@N?(olHy`uVBq!ia0vp^JRr=%3?!FCJ6-`&VkNE-CC){ui6xo&c?uz!xv30> zhWdu)`UWPKZ&^kGRj`+M`ns}TWn~rPG?=%NClV;cS>O>_%)r3)0fZTy)|kuy3bLd- z`Z_W&Z0zU$lgJ9>iw5|Fxc>kD|NfD#pI=*n0w11MGNdt_Uf}G@|9&fw&sY-V7tG-B z>_!@pW8mrH7*cU7IiZ2U(I7z}h>3}nZQ&M^tt##o{4R`@3l}D{J@*EQ)2w a5@RSV=9tQw!!85V%i!ti=d#Wzp$PzX`$GZ% diff --git a/examples/trac/htdocs/extlink.gif b/examples/trac/htdocs/extlink.gif new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..d47e14ca58d68e5b3f66f6a21b3ea7211636c289 GIT binary patch literal 90 zc${sIv-Z;U{r{1G;!hSv1_l-e9R?r(sbyf6w-C~C iW^qtb;}P&^T6}1Cj=OonVA_UBqU7O zTmAjh(=-KV^Dpn7?iS;6U<-KiZY_g?vhLeW-abqbPu|7OWZB>mwTLIgMc|Cbd0*`- zjNLIlI!wlwxKDTIH1N1OI;ticEZ(zc&kgny0YPSkg&!Gb$?W`fW~T9S83_pq-^-^@ bJBct9m+PL3-Ctt}bOeK^tDnm{r-UW|XWVVB diff --git a/examples/trac/htdocs/filedeny.png b/examples/trac/htdocs/filedeny.png new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..f35fc99b3e6281ebab6b8fc8bea5f21a91f830b3 GIT binary patch literal 285 zc%17D@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPHF3h)VWWnlRK{rmU#@83Ur_Uzuh zd)KdDKXc~Hkt0VA95^s@=FFCsmWqms;^N}}|NqNXN9zD}F_r}R1v5B2yO9Rua29w( z7Bet#eE?xbr!^)sfP(BLp1!W^S6GFa_?ecfYKa1cDm`5sLn>}1Cj=OonVA_UBqU7O zTmAjh(=-KV^Dpn7?iS;6U<-KiZY_g?vhLeW-abqbPu|7OWZB>mwTLIgMc|Cbd0*`- zjNLIlI!wlwxKDTIH1N1OI;ticEZ(zc&kgny0YPSkg&!Gb$?W`fW~T9S83_pq-^-^@ bJBct9m+PL3-Ctt}bOeK^tDnm{r-UW|XWVVB diff --git a/examples/trac/htdocs/folder.png b/examples/trac/htdocs/folder.png new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..d26c06cfc3d84b08274b7971b850a60256c83998 GIT binary patch literal 357 zc$@)c0h<1aP)fgTS<;d>k%H`+e>g&|)>Duw_-tzJ2@$>NW_44=j?fm}xOh+Io(CHZf0004W zQchCfgTS<;d>k%H`+e>g&|)>Duw_-tzJ2@$>NW_44=j?fm}xOh+Io(CHZf0004W zQchC + function createLabel(text, htmlFor) { + var label = document.createElement("label"); + if (text) label.appendChild(document.createTextNode(text)); + if (htmlFor) label.htmlFor = htmlFor; + return label; + } + + // Convenience function for creating an + function createCheckbox(name, value, id) { + var input = document.createElement("input"); + input.type = "checkbox"; + if (name) input.name = name; + if (value) input.value = value; + if (id) input.id = id; + return input; + } + + // Convenience function for creating an + function createRadio(name, value, id) { + var input = document.createElement("input"); + input.type = "radio"; + if (name) input.name = name; + if (value) input.value = value; + if (id) input.id = id; + return input; + } + + // Convenience function for creating a + + + + +
+ : + +
+ +

Attachments

+
+
() - added by on .
+
+
+ + +
s diff --git a/examples/trac/templates/milestone.cs b/examples/trac/templates/milestone.cs new file mode 100644 --- /dev/null +++ b/examples/trac/templates/milestone.cs @@ -0,0 +1,244 @@ + + + + + +
+ +

New Milestone

+ +

Edit Milestone

+ +

Delete Milestone

+ +

Milestone

+ + + + +
+ + +
+ +
+
+ Schedule + +
+ + +
+ + + +
+
+
+
+ +

+
+
+
+ + +
+ +
+ +
+ + +

Are you sure you want to delete this milestone?

+ + + +
+ + +
+
+ + +
+

+ Completed ago () + late + Due in () + No date set +

#0 ?> + + + + +
+ + +
+

%

+
+
Closed tickets:
+
+
Active tickets:
+
+
+
+
+
+ + + + + + + + + + +
+ + + + + +
+ +
+

/

+
+
+
+
+
+
+ + + +
+
+ + + +
+
+ +
+ Note: See TracRoadmap for help on using the roadmap. +
+ +
+ diff --git a/examples/trac/templates/newticket.cs b/examples/trac/templates/newticket.cs new file mode 100644 --- /dev/null +++ b/examples/trac/templates/newticket.cs @@ -0,0 +1,125 @@ + + + + + + +
+

Create New Ticket

+ +
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+ +
+ Description Preview + +
+
+ +
+ Ticket Properties + + + + + colspan="3"> checked="checked" /> +
+
+ + + +

+ +

+ +
+   + +
+
+ +
+ Note: See TracTickets for help on using tickets. +
+
+ + diff --git a/examples/trac/templates/query.cs b/examples/trac/templates/query.cs new file mode 100644 --- /dev/null +++ b/examples/trac/templates/query.cs @@ -0,0 +1,243 @@ + + + + + +(No matches) + +
+

+ +
+
+ Filters + checked="checked" + + + + + + + + + + + + + + + + + + + +
+ + colspan="2"> + +
+ /> + + checked="checked" /> + + checked="checked" /> + + +
+
+   + + +
+
+

+ + + checked="checked" /> + + +

+

+ checked="checked" /> + +

+
+ + + +
+
+
+ + + + + + + + + + + + +
+

:

+ + + + + + + +
+

Reported by , + :

+

+
+ +
+ Note: See TracQuery + for help on using queries. +
+ +
+ diff --git a/examples/trac/templates/query_rss.cs b/examples/trac/templates/query_rss.cs new file mode 100644 --- /dev/null +++ b/examples/trac/templates/query_rss.cs @@ -0,0 +1,46 @@ + + + + <?cs var:project.name_encoded ?>: Ticket Query + Ticket Query + + + en-us + + <?cs var:project.name_encoded ?> + + + + + + Trac v + + + + <?cs var:'#' + result.id + ': ' + result.summary ?> + + + + Tickets + #changelog + + + diff --git a/examples/trac/templates/report.cs b/examples/trac/templates/report.cs new file mode 100644 --- /dev/null +++ b/examples/trac/templates/report.cs @@ -0,0 +1,244 @@ + + + + + +
+ + + + 0 ?> + + + +

+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

( matches)

+
+ + +
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #'+$cell+'') ?> + + '+$cell+'') ?> + + {'+$cell+'}') ?> + + + + + + + + + + + '+$cell+'') ?> + + + + + + + +
+
+
colspan="100" class="">
+
+ + +
+
+
No matches found.
+ + + +

+
+ + +

Are you sure you want to delete this report?

+
+ + +
+
+ + + +

+
+
+ +
+
+
+
+
+ (You may use WikiFormatting here) +
+ +
+
+
+ +
+
+ + +
+
+ +
+ + +
+ Note: See TracReports for help on using and + creating reports. +
+ +
+ diff --git a/examples/trac/templates/report_rss.cs b/examples/trac/templates/report_rss.cs new file mode 100644 --- /dev/null +++ b/examples/trac/templates/report_rss.cs @@ -0,0 +1,42 @@ + + + + <?cs var:project.name_encoded ?>: <?cs var:report.title ?> + <?cs var:title ?> + / + Trac Report - + en-us + Trac v + + + + <?cs var:'#' + id + ': ' + title ?> + + + Report + + diff --git a/examples/trac/templates/roadmap.cs b/examples/trac/templates/roadmap.cs new file mode 100644 --- /dev/null +++ b/examples/trac/templates/roadmap.cs @@ -0,0 +1,86 @@ + + + + + +
+

Roadmap

+ +
+
+ checked="checked" /> + +
+
+ +
+
+ +
    +
  • +
    +

    Milestone:

    +

    title=""> + Completed ago title=""> + late + Due in ()> + No date set +

    #0 ?> + + + + + +
    +

    %

    +
    +
    Closed tickets:
    +
    +
    Active tickets:
    +
    +
    +
    +
    +
+
+
+ + +
+
+ +
+ Note: See TracRoadmap for help on using the roadmap. +
+ +
+ diff --git a/examples/trac/templates/search.cs b/examples/trac/templates/search.cs new file mode 100644 --- /dev/null +++ b/examples/trac/templates/search.cs @@ -0,0 +1,102 @@ + + + + + + diff --git a/examples/trac/templates/settings.cs b/examples/trac/templates/settings.cs new file mode 100644 --- /dev/null +++ b/examples/trac/templates/settings.cs @@ -0,0 +1,66 @@ + + + + + +
+ +

Settings and Session Management

+ +

User Settings

+

+ This page lets you customize and personalize your Trac settings. Session + settings are stored on the server and identified using a 'Session Key' + stored in a browser cookie. The cookie lets Trac restore your settings. +

+
+
+

Personal Information

+
+ + + +
+
+ + +
+

Session

+
+ + +

The session key is used to identify stored custom settings and session + data on the server. Automatically generated by default, you may change it + to something easier to remember at any time if you wish to use your settings + in a different web browser.

+
+
+
+ +
+
+
+
+

Load Session

+

You may load a previously created session by entering the corresponding + session key below and clicking 'Recover'. This lets you share settings between + multiple computers and/or web browsers.

+
+
+ + + + +
+
+ +
+ diff --git a/examples/trac/templates/ticket.cs b/examples/trac/templates/ticket.cs new file mode 100644 --- /dev/null +++ b/examples/trac/templates/ticket.cs @@ -0,0 +1,327 @@ + + + + + +
+ +

Ticket # ( : )

+ +
+
+
+

Opened ago

+

Last modified ago

+ +
+

+ + + + + + + + + colspan="3" headers="h_"> +
Reported by:Assigned to: (accepted)
:
+
+ +
+ +
+
+
+
+ + + + + + + + + +

Change History

+
+
+

id="comment:"> + (in reply to: ; : )
+ + +
+
changed by   +

+
    +
  • addedchanged from to set to deletedchanged.
  • + +
+
+
+
+ + +
+
+

Add/Change # ()

+ +
+
+
+
+ +
+
+
+

+
+
+ Comment Preview + +
+
+ +
+ Change Properties + + + + + + + + + + + + + + + + + colspan="3"> checked="checked" /> +
+
+ +
+
+ + +
+ Action + checked="checked" /> + +
+
+
+ + +
+ + + +
+ + + + +
+ + + +   + +
+
+ + +
+
+ diff --git a/examples/trac/templates/ticket_notify_email.cs b/examples/trac/templates/ticket_notify_email.cs new file mode 100644 --- /dev/null +++ b/examples/trac/templates/ticket_notify_email.cs @@ -0,0 +1,23 @@ + + + + +Changes (by ): + + +Comment (by ): + + + +-- +Ticket URL: <> + <> + \ No newline at end of file diff --git a/examples/trac/templates/ticket_rss.cs b/examples/trac/templates/ticket_rss.cs new file mode 100644 --- /dev/null +++ b/examples/trac/templates/ticket_rss.cs @@ -0,0 +1,44 @@ + + + + + <?cs var:project.name_encoded ?>: Ticket <?cs var:title ?> + Ticket <?cs var:title ?> + + + en-us + Trac v + + + <?cs var:change.title ?> + #comment: + + + <ul> + <li><strong></strong> set to <em></em>changed from <em></em> to <em></em>.deleted</li> + </ul> + + + + Ticket + + + diff --git a/examples/trac/templates/timeline.cs b/examples/trac/templates/timeline.cs new file mode 100644 --- /dev/null +++ b/examples/trac/templates/timeline.cs @@ -0,0 +1,51 @@ + + + + +
+

Timeline

+ +
+
+ and + . +
+
+ +
+
+ +
+
+

:

+ +
+
+ Note: See TracTimeline + for information about the timeline view. +
+ +
+ diff --git a/examples/trac/templates/timeline_rss.cs b/examples/trac/templates/timeline_rss.cs new file mode 100644 --- /dev/null +++ b/examples/trac/templates/timeline_rss.cs @@ -0,0 +1,33 @@ + + + + <?cs var:project.name_encoded ?>: <?cs var:title ?> + <?cs var:title ?> + + Trac Timeline + en-us + Trac v + + <?cs var:project.name_encoded ?> + + + + + <?cs var:event.title ?> + + + + + + + diff --git a/examples/trac/templates/wiki.cs b/examples/trac/templates/wiki.cs new file mode 100644 --- /dev/null +++ b/examples/trac/templates/wiki.cs @@ -0,0 +1,390 @@ + + + + + +
+ + 1 ?> +

Delete

+
+ +

Are you sure you want to completely delete this page?
+ This is the only version the page, so the page will be removed + completely! + + + This is an irreversible operation.

+
+ + +
+
+ + +

Changes between + Version and from + Version of +

+
+
+ + + +
+ Show + +
+
+ Ignore: +
+ checked="checked" /> + +
+
+ checked="checked" /> + +
+
+ checked="checked" /> + +
+
+
+ +
+
+
+
+
Author:
+
1 ?>(multiple changes) (IP: )
+
Timestamp:
+
1 ?>(multiple changes) ( ago)--
+
Comment:
+
1 ?>(multiple changes)
+
+
+
+

Legend:

+
+
Unmodified
+
Added
+
Removed
+
Modified
+
+
+
    +
  • +

    + + + + + + + +
    Version Version
    + + + + + + + +
    vv 
    +
  • +
+
+ + + + +
+
+ + +

Change History of

+
+ +
+ +
+ + + + + + + + + + + + + + + + + +
VersionDateAuthorComment
checked="checked" /> checked="checked" />
#10 ?> +
+ +
+
+ + + +

Editing ""

+ + + + +
+ Preview of future version (modified by ) +
+
+ Preview (skip) +
+
+
+ Sorry, this page has been modified by somebody else since you started + editing. Your changes cannot be saved. +
+
+
+ + + +
+ + +
+

+ +
+
+ Note: See WikiFormatting and TracWiki for help on editing wiki content. +
+
+ Change information + +
+ +
+ +
+ +

+ +
+ +
+ +
+
+   +   + +   +   + + +
+ +
+ + + + + + + +
+ Version (modified by , ago) +
+ +
+
+
+ +

Attachments

+
  • () -, added by on .
  • +
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+ +
+ + + + +
+ + diff --git a/examples/trac/trac/About.py b/examples/trac/trac/About.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/About.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2004-2006 Edgewall Software +# Copyright (C) 2004-2005 Jonas Borgström +# Copyright (C) 2004-2005 Daniel Lundin +# Copyright (C) 2005-2006 Christopher Lenz +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Jonas Borgström +# Christopher Lenz + +import re + +from trac.core import * +from trac.perm import IPermissionRequestor +from trac.web import IRequestHandler +from trac.util.markup import html +from trac.web.chrome import add_stylesheet, INavigationContributor + + +class AboutModule(Component): + """Provides various about pages.""" + + implements(INavigationContributor, IPermissionRequestor, IRequestHandler) + + # INavigationContributor methods + + def get_active_navigation_item(self, req): + return 'about' + + def get_navigation_items(self, req): + yield ('metanav', 'about', + html.a('About Trac', href=req.href.about())) + + # IPermissionRequestor methods + + def get_permission_actions(self): + return ['CONFIG_VIEW'] + + # IRequestHandler methods + + def match_request(self, req): + match = re.match(r'/about(?:_trac)?(?:/(.*))?$', req.path_info) + if match: + if match.group(1): + req.args['page'] = match.group(1) + return True + + def process_request(self, req): + page = req.args.get('page', 'default') + req.hdf['title'] = 'About Trac' + if req.perm.has_permission('CONFIG_VIEW'): + req.hdf['about.config_href'] = req.href.about('config') + req.hdf['about.plugins_href'] = req.href.about('plugins') + if page == 'config': + self._render_config(req) + elif page == 'plugins': + self._render_plugins(req) + + add_stylesheet(req, 'common/css/about.css') + return 'about.cs', None + + # Internal methods + + def _render_config(self, req): + req.perm.assert_permission('CONFIG_VIEW') + req.hdf['about.page'] = 'config' + + # Export the config table to hdf + sections = [] + for section in self.config.sections(): + options = [] + default_options = self.config.defaults().get(section) + for name,value in self.config.options(section): + default = default_options and default_options.get(name) or '' + options.append({ + 'name': name, 'value': value, + 'valueclass': (unicode(value) == unicode(default) + and 'defaultvalue' or 'value')}) + options.sort(lambda x,y: cmp(x['name'], y['name'])) + sections.append({'name': section, 'options': options}) + sections.sort(lambda x,y: cmp(x['name'], y['name'])) + req.hdf['about.config'] = sections + # TODO: + # We should probably export more info here like: + # permissions, components... + + def _render_plugins(self, req): + try: + from trac.wiki.formatter import wiki_to_html + import inspect + def getdoc(obj): + return wiki_to_html(inspect.getdoc(obj), self.env, req) + except: + def getdoc(obj): + return obj.__doc__ + req.perm.assert_permission('CONFIG_VIEW') + import sys + req.hdf['about.page'] = 'plugins' + from trac.core import ComponentMeta + plugins = [] + for component in ComponentMeta._components: + if not self.env.is_component_enabled(component): + continue + plugin = {'name': component.__name__} + if component.__doc__: + plugin['description'] = getdoc(component) + + module = sys.modules[component.__module__] + plugin['module'] = module.__name__ + if hasattr(module, '__file__'): + plugin['path'] = module.__file__ + + xtnpts = [] + for name, xtnpt in [(attr, getattr(component, attr)) for attr + in dir(component)]: + if not isinstance(xtnpt, ExtensionPoint): + continue + xtnpts.append({'name': name, + 'interface': xtnpt.interface.__name__, + 'module': xtnpt.interface.__module__}) + if xtnpt.interface.__doc__: + xtnpts[-1]['description'] = getdoc(xtnpt.interface) + extensions = [] + for extension in ComponentMeta._registry.get(xtnpt.interface, []): + if self.env.is_component_enabled(extension): + extensions.append({'name': extension.__name__, + 'module': extension.__module__}) + xtnpts[-1]['extensions'] = extensions + xtnpts.sort(lambda x,y: cmp(x['name'], y['name'])) + plugin['extension_points'] = xtnpts + + plugins.append(plugin) + + def plugincmp(x, y): + c = cmp(len(x['module'].split('.')), len(y['module'].split('.'))) + if c == 0: + c = cmp(x['module'].lower(), y['module'].lower()) + if c == 0: + c = cmp(x['name'].lower(), y['name'].lower()) + return c + plugins.sort(plugincmp) + + req.hdf['about.plugins'] = plugins diff --git a/examples/trac/trac/Search.py b/examples/trac/trac/Search.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/Search.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2003-2004 Edgewall Software +# Copyright (C) 2003-2004 Jonas Borgström +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Jonas Borgström + +import re +import time + +from trac.config import IntOption +from trac.core import * +from trac.perm import IPermissionRequestor +from trac.util.datefmt import format_datetime +from trac.util.markup import escape, html, Element +from trac.web import IRequestHandler +from trac.web.chrome import add_link, add_stylesheet, INavigationContributor +from trac.wiki import IWikiSyntaxProvider, wiki_to_link + + +class ISearchSource(Interface): + """ + Extension point interface for adding search sources to the Trac + Search system. + """ + + def get_search_filters(self, req): + """ + Return a list of filters that this search source supports. Each + filter must be a (name, label[, default]) tuple, where `name` is the + internal name, `label` is a human-readable name for display and + `default` is an optional boolean for determining whether this filter + is searchable by default. + """ + + def get_search_results(self, req, terms, filters): + """ + Return a list of search results matching each search term in `terms`. + The `filters` parameters is a list of the enabled + filters, each item being the name of the tuples returned by + `get_search_events`. + + The events returned by this function must be tuples of the form + (href, title, date, author, excerpt). + """ + + +def search_terms(q): + """ + Break apart a search query into its various search terms. Terms are + grouped implicitly by word boundary, or explicitly by (single or double) + quotes. + """ + results = [] + for term in re.split('(".*?")|(\'.*?\')|(\s+)', q): + if term != None and term.strip() != '': + if term[0] == term[-1] == "'" or term[0] == term[-1] == '"': + term = term[1:-1] + results.append(term) + return results + +def search_to_sql(db, columns, terms): + """ + Convert a search query into a SQL condition string and corresponding + parameters. The result is returned as a (string, params) tuple. + """ + if len(columns) < 1 or len(terms) < 1: + raise TracError('Empty search attempt, this should really not happen.') + + likes = ['%s %s' % (i, db.like()) for i in columns] + c = ' OR '.join(likes) + sql = '(' + ') AND ('.join([c] * len(terms)) + ')' + args = [] + for t in terms: + args.extend(['%'+db.like_escape(t)+'%'] * len(columns)) + return sql, tuple(args) + +def shorten_result(text='', keywords=[], maxlen=240, fuzz=60): + if not text: text = '' + text_low = text.lower() + beg = -1 + for k in keywords: + i = text_low.find(k.lower()) + if (i > -1 and i < beg) or beg == -1: + beg = i + excerpt_beg = 0 + if beg > fuzz: + for sep in ('.', ':', ';', '='): + eb = text.find(sep, beg - fuzz, beg - 1) + if eb > -1: + eb += 1 + break + else: + eb = beg - fuzz + excerpt_beg = eb + if excerpt_beg < 0: excerpt_beg = 0 + msg = text[excerpt_beg:beg+maxlen] + if beg > fuzz: + msg = '... ' + msg + if beg < len(text)-maxlen: + msg = msg + ' ...' + return msg + + +class SearchModule(Component): + + implements(INavigationContributor, IPermissionRequestor, IRequestHandler, + IWikiSyntaxProvider) + + search_sources = ExtensionPoint(ISearchSource) + + RESULTS_PER_PAGE = 10 + + min_query_length = IntOption('search', 'min_query_length', 3, + """Minimum length of query string allowed when performing a search.""") + + # INavigationContributor methods + + def get_active_navigation_item(self, req): + return 'search' + + def get_navigation_items(self, req): + if not req.perm.has_permission('SEARCH_VIEW'): + return + yield ('mainnav', 'search', + html.A('Search', href=req.href.search(), accesskey=4)) + + # IPermissionRequestor methods + + def get_permission_actions(self): + return ['SEARCH_VIEW'] + + # IRequestHandler methods + + def match_request(self, req): + return re.match(r'/search/?', req.path_info) is not None + + def process_request(self, req): + req.perm.assert_permission('SEARCH_VIEW') + + available_filters = [] + for source in self.search_sources: + available_filters += source.get_search_filters(req) + + filters = [f[0] for f in available_filters if req.args.has_key(f[0])] + if not filters: + filters = [f[0] for f in available_filters + if len(f) < 3 or len(f) > 2 and f[2]] + + req.hdf['search.filters'] = [ + { 'name': filter[0], + 'label': filter[1], + 'active': filter[0] in filters + } for filter in available_filters] + + req.hdf['title'] = 'Search' + + query = req.args.get('q') + if query: + page = int(req.args.get('page', '1')) + noquickjump = int(req.args.get('noquickjump', '0')) + link_elt = self.quickjump(req, query) + if link_elt is not None: + quickjump_href = link_elt.attr['href'] + if noquickjump: + req.hdf['search.quickjump'] = { + 'href': quickjump_href, + 'name': html.EM(link_elt.children), + 'description': link_elt.attr.get('title', '') + } + else: + req.redirect(quickjump_href) + elif query.startswith('!'): + query = query[1:] + terms = search_terms(query) + # Refuse queries that obviously would result in a huge result set + if len(terms) == 1 and len(terms[0]) < self.min_query_length: + raise TracError('Search query too short. ' + 'Query must be at least %d characters long.' % \ + self.min_query_length, 'Search Error') + results = [] + for source in self.search_sources: + results += list(source.get_search_results(req, terms, filters)) + results.sort(lambda x,y: cmp(y[2], x[2])) + page_size = self.RESULTS_PER_PAGE + n = len(results) + n_pages = (n-1) / page_size + 1 + results = results[(page-1) * page_size: page * page_size] + + req.hdf['title'] = 'Search Results' + req.hdf['search.q'] = req.args.get('q') + req.hdf['search.page'] = page + req.hdf['search.n_hits'] = n + req.hdf['search.n_pages'] = n_pages + req.hdf['search.page_size'] = page_size + if page < n_pages: + next_href = req.href.search(zip(filters, ['on'] * len(filters)), + q=req.args.get('q'), page=page + 1) + add_link(req, 'next', next_href, 'Next Page') + if page > 1: + prev_href = req.href.search(zip(filters, ['on'] * len(filters)), + q=req.args.get('q'), page=page - 1) + add_link(req, 'prev', prev_href, 'Previous Page') + req.hdf['search.page_href'] = req.href.search(zip(filters, ['on'] * len(filters)), + q=req.args.get('q')) + req.hdf['search.result'] = [ + { 'href': result[0], + 'title': result[1], + 'date': format_datetime(result[2]), + 'author': result[3], + 'excerpt': result[4] + } for result in results] + + add_stylesheet(req, 'common/css/search.css') + return 'search.cs', None + + def quickjump(self, req, kwd): + # Source quickjump + if kwd[0] == '/': + return req.href.browser(kwd) + link = wiki_to_link(kwd, self.env, req) + if isinstance(link, Element): + return link + + # IWikiSyntaxProvider methods + + def get_wiki_syntax(self): + return [] + + def get_link_resolvers(self): + yield ('search', self._format_link) + + def _format_link(self, formatter, ns, target, label): + path, query, fragment = formatter.split_link(target) + if query: + href = formatter.href.search() + query.replace(' ', '+') + else: + href = formatter.href.search(q=path) + return html.A(label, class_='search', href=href) diff --git a/examples/trac/trac/Settings.py b/examples/trac/trac/Settings.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/Settings.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2004-2005 Edgewall Software +# Copyright (C) 2004-2005 Daniel Lundin +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Daniel Lundin + +from trac.core import * +from trac.util.markup import html +from trac.web import IRequestHandler +from trac.web.chrome import INavigationContributor + + +class SettingsModule(Component): + + implements(INavigationContributor, IRequestHandler) + + _form_fields = ['newsid','name', 'email'] + + # INavigationContributor methods + + def get_active_navigation_item(self, req): + return 'settings' + + def get_navigation_items(self, req): + yield ('metanav', 'settings', + html.A('Settings', href=req.href.settings())) + + # IRequestHandler methods + + def match_request(self, req): + return req.path_info == '/settings' + + def process_request(self, req): + action = req.args.get('action') + + if req.method == 'POST': + if action == 'save': + self._do_save(req) + elif action == 'load': + self._do_load(req) + + req.hdf['title'] = 'Settings' + req.hdf['settings'] = req.session + if req.authname == 'anonymous': + req.hdf['settings.session_id'] = req.session.sid + + return 'settings.cs', None + + # Internal methods + + def _do_save(self, req): + for field in self._form_fields: + val = req.args.get(field) + if val: + if field == 'newsid' and val: + req.session.change_sid(val) + else: + req.session[field] = val + req.redirect(req.href.settings()) + + def _do_load(self, req): + if req.authname == 'anonymous': + oldsid = req.args.get('loadsid') + req.session.get_session(oldsid) + req.redirect(req.href.settings()) diff --git a/examples/trac/trac/Timeline.py b/examples/trac/trac/Timeline.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/Timeline.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2003-2006 Edgewall Software +# Copyright (C) 2003-2005 Jonas Borgström +# Copyright (C) 2004-2005 Christopher Lenz +# Copyright (C) 2005-2006 Christian Boos +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Jonas Borgström +# Christopher Lenz + +import re +import time + +from trac.config import IntOption +from trac.core import * +from trac.perm import IPermissionRequestor +from trac.util.datefmt import format_date, format_time, http_date +from trac.util.text import to_unicode +from trac.util.markup import html, Markup +from trac.web import IRequestHandler +from trac.web.chrome import add_link, add_stylesheet, INavigationContributor + + +class ITimelineEventProvider(Interface): + """Extension point interface for adding sources for timed events to the + timeline. + """ + + def get_timeline_filters(self, req): + """Return a list of filters that this event provider supports. + + Each filter must be a (name, label) tuple, where `name` is the internal + name, and `label` is a human-readable name for display. + + Optionally, the tuple can contain a third element, `checked`. + If `checked` is omitted or True, the filter is active by default, + otherwise it will be inactive. + """ + + def get_timeline_events(self, req, start, stop, filters): + """Return a list of events in the time range given by the `start` and + `stop` parameters. + + The `filters` parameters is a list of the enabled filters, each item + being the name of the tuples returned by `get_timeline_filters`. + + The events returned by this function must be tuples of the form + (kind, href, title, date, author, message). + """ + + +class TimelineModule(Component): + + implements(INavigationContributor, IPermissionRequestor, IRequestHandler) + + event_providers = ExtensionPoint(ITimelineEventProvider) + + default_daysback = IntOption('timeline', 'default_daysback', 30, + """Default number of days displayed in the Timeline, in days. + (''since 0.9.'')""") + + # INavigationContributor methods + + def get_active_navigation_item(self, req): + return 'timeline' + + def get_navigation_items(self, req): + if not req.perm.has_permission('TIMELINE_VIEW'): + return + yield ('mainnav', 'timeline', + html.A('Timeline', href=req.href.timeline(), accesskey=2)) + + # IPermissionRequestor methods + + def get_permission_actions(self): + return ['TIMELINE_VIEW'] + + # IRequestHandler methods + + def match_request(self, req): + return re.match(r'/timeline/?', req.path_info) is not None + + def process_request(self, req): + req.perm.assert_permission('TIMELINE_VIEW') + + format = req.args.get('format') + maxrows = int(req.args.get('max', 0)) + + # Parse the from date and adjust the timestamp to the last second of + # the day + t = time.localtime() + if req.args.has_key('from'): + try: + t = time.strptime(req.args.get('from'), '%x') + except: + pass + + fromdate = time.mktime((t[0], t[1], t[2], 23, 59, 59, t[6], t[7], t[8])) + try: + daysback = max(0, int(req.args.get('daysback', ''))) + except ValueError: + daysback = self.default_daysback + req.hdf['timeline.from'] = format_date(fromdate) + req.hdf['timeline.daysback'] = daysback + + available_filters = [] + for event_provider in self.event_providers: + available_filters += event_provider.get_timeline_filters(req) + + filters = [] + # check the request or session for enabled filters, or use default + for test in (lambda f: req.args.has_key(f[0]), + lambda f: req.session.get('timeline.filter.%s' % f[0], '')\ + == '1', + lambda f: len(f) == 2 or f[2]): + if filters: + break + filters = [f[0] for f in available_filters if test(f)] + + # save the results of submitting the timeline form to the session + if req.args.has_key('update'): + for filter in available_filters: + key = 'timeline.filter.%s' % filter[0] + if req.args.has_key(filter[0]): + req.session[key] = '1' + elif req.session.has_key(key): + del req.session[key] + + stop = fromdate + start = stop - (daysback + 1) * 86400 + + events = [] + for event_provider in self.event_providers: + try: + events += event_provider.get_timeline_events(req, start, stop, + filters) + except Exception, e: # cope with a failure of that provider + self._provider_failure(e, req, event_provider, filters, + [f[0] for f in available_filters]) + + events.sort(lambda x,y: cmp(y[3], x[3])) + if maxrows and len(events) > maxrows: + del events[maxrows:] + + req.hdf['title'] = 'Timeline' + + # Get the email addresses of all known users + email_map = {} + for username, name, email in self.env.get_known_users(): + if email: + email_map[username] = email + + idx = 0 + for kind, href, title, date, author, message in events: + event = {'kind': kind, 'title': title, 'href': href, + 'author': author or 'anonymous', + 'date': format_date(date), + 'time': format_time(date, '%H:%M'), + 'message': message} + + if format == 'rss': + # Strip/escape HTML markup + if isinstance(title, Markup): + title = title.plaintext(keeplinebreaks=False) + event['title'] = title + event['message'] = to_unicode(message) + + if author: + # For RSS, author must be an email address + if author.find('@') != -1: + event['author.email'] = author + elif email_map.has_key(author): + event['author.email'] = email_map[author] + event['date'] = http_date(date) + + req.hdf['timeline.events.%s' % idx] = event + idx += 1 + + if format == 'rss': + return 'timeline_rss.cs', 'application/rss+xml' + + add_stylesheet(req, 'common/css/timeline.css') + rss_href = req.href.timeline([(f, 'on') for f in filters], + daysback=90, max=50, format='rss') + add_link(req, 'alternate', rss_href, 'RSS Feed', 'application/rss+xml', + 'rss') + for idx,fltr in enumerate(available_filters): + req.hdf['timeline.filters.%d' % idx] = {'name': fltr[0], + 'label': fltr[1], 'enabled': int(fltr[0] in filters)} + + return 'timeline.cs', None + + def _provider_failure(self, exc, req, ep, current_filters, all_filters): + """Raise a TracError exception explaining the failure of a provider. + + At the same time, the message will contain a link to the timeline + without the filters corresponding to the guilty event provider `ep`. + """ + ep_name, exc_name = [i.__class__.__name__ for i in (ep, exc)] + guilty_filters = [f[0] for f in ep.get_timeline_filters(req)] + guilty_kinds = [f[1] for f in ep.get_timeline_filters(req)] + other_filters = [f for f in current_filters if not f in guilty_filters] + if not other_filters: + other_filters = [f for f in all_filters if not f in guilty_filters] + args = [(a, req.args.get(a)) for a in ('from', 'format', 'max', + 'daysback')] + href = req.href.timeline(args+[(f, 'on') for f in other_filters]) + raise TracError(Markup( + '%s event provider (%s) failed:

' + '%s: %s' + '

You may want to see the other kind of events from the ' + 'Timeline

', + ", ".join(guilty_kinds), ep_name, exc_name, to_unicode(exc), href)) diff --git a/examples/trac/trac/__init__.py b/examples/trac/trac/__init__.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/__init__.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- + +""" +Trac +Edgewall Software + +U{http://trac.edgewall.com/} + +@author: Jonas Borgström +@author: Daniel Lundin +""" +__docformat__ = 'epytext en' + +__version__ = '0.10dev' +__url__ = 'http://trac.edgewall.com/' +__copyright__ = '(C) 2003-2006 Edgewall Software' +__license__ = 'BSD' +__license_long__ = """ + Copyright (C) 2003-2006 Edgewall Software + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + 3. The name of the author may not be used to endorse or promote + products derived from this software without specific prior + written permission. + + THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS + OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY + DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE + GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN + IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.""" + diff --git a/examples/trac/trac/attachment.py b/examples/trac/trac/attachment.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/attachment.py @@ -0,0 +1,605 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2003-2005 Edgewall Software +# Copyright (C) 2003-2005 Jonas Borgström +# Copyright (C) 2005 Christopher Lenz +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Jonas Borgström +# Christopher Lenz + +import os +import re +import shutil +import time +import unicodedata + +from trac import perm, util +from trac.config import BoolOption, IntOption +from trac.core import * +from trac.env import IEnvironmentSetupParticipant +from trac.mimeview import * +from trac.util import get_reporter_id, create_unique_file +from trac.util.datefmt import format_datetime, pretty_timedelta +from trac.util.markup import Markup, html +from trac.util.text import unicode_quote, unicode_unquote, pretty_size +from trac.web import HTTPBadRequest, IRequestHandler +from trac.web.chrome import add_link, add_stylesheet, INavigationContributor +from trac.wiki.api import IWikiSyntaxProvider +from trac.wiki.formatter import wiki_to_html, wiki_to_oneliner + + +class InvalidAttachment(TracError): + """Exception raised when attachment validation fails.""" + + +class IAttachmentChangeListener(Interface): + """Extension point interface for components that require notification when + attachments are created or deleted.""" + def attachment_added(attachment): + """Called when an attachment is added.""" + + def attachment_deleted(attachment): + """Called when an attachment is deleted.""" + + +class IAttachmentManipulator(Interface): + """Extension point interface for components that need to manipulate + attachments. + + Unlike change listeners, a manipulator can reject changes being committed + to the database.""" + def prepare_attachment(req, attachment, fields): + """Not currently called, but should be provided for future + compatibility.""" + + def validate_attachment(req, attachment): + """Validate an attachment after upload but before being stored in Trac + environment. + + Must return a list of `(field, message)` tuples, one for each problem + detected. `field` can be any of `description`, `username`, `filename`, + `content`, or `None` to indicate an overall problem with the + attachment. Therefore, a return value of `[]` means everything is + OK.""" + + +class Attachment(object): + + def __init__(self, env, parent_type, parent_id, filename=None, db=None): + self.env = env + self.parent_type = parent_type + self.parent_id = unicode(parent_id) + if filename: + self._fetch(filename, db) + else: + self.filename = None + self.description = None + self.size = None + self.time = None + self.author = None + self.ipnr = None + + def _fetch(self, filename, db=None): + if not db: + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("SELECT filename,description,size,time,author,ipnr " + "FROM attachment WHERE type=%s AND id=%s " + "AND filename=%s ORDER BY time", + (self.parent_type, unicode(self.parent_id), filename)) + row = cursor.fetchone() + cursor.close() + if not row: + self.filename = filename + raise TracError('Attachment %s does not exist.' % (self.title), + 'Invalid Attachment') + self.filename = row[0] + self.description = row[1] + self.size = row[2] and int(row[2]) or 0 + self.time = row[3] and int(row[3]) or 0 + self.author = row[4] + self.ipnr = row[5] + + def _get_path(self): + path = os.path.join(self.env.path, 'attachments', self.parent_type, + unicode_quote(self.parent_id)) + if self.filename: + path = os.path.join(path, unicode_quote(self.filename)) + return os.path.normpath(path) + path = property(_get_path) + + def href(self, req, *args, **dict): + return req.href.attachment(self.parent_type, self.parent_id, + self.filename, *args, **dict) + + def parent_href(self, req): + return req.href(self.parent_type, self.parent_id) + + def _get_title(self): + return '%s%s: %s' % (self.parent_type == 'ticket' and '#' or '', + self.parent_id, self.filename) + title = property(_get_title) + + def delete(self, db=None): + assert self.filename, 'Cannot delete non-existent attachment' + if not db: + db = self.env.get_db_cnx() + handle_ta = True + else: + handle_ta = False + + cursor = db.cursor() + cursor.execute("DELETE FROM attachment WHERE type=%s AND id=%s " + "AND filename=%s", (self.parent_type, self.parent_id, + self.filename)) + if os.path.isfile(self.path): + try: + os.unlink(self.path) + except OSError: + self.env.log.error('Failed to delete attachment file %s', + self.path, exc_info=True) + if handle_ta: + db.rollback() + raise TracError, 'Could not delete attachment' + + self.env.log.info('Attachment removed: %s' % self.title) + if handle_ta: + db.commit() + + for listener in AttachmentModule(self.env).change_listeners: + listener.attachment_deleted(self) + + + def insert(self, filename, fileobj, size, t=None, db=None): + if not db: + db = self.env.get_db_cnx() + handle_ta = True + else: + handle_ta = False + + self.size = size + self.time = t or time.time() + + # Make sure the path to the attachment is inside the environment + # attachments directory + attachments_dir = os.path.join(os.path.normpath(self.env.path), + 'attachments') + commonprefix = os.path.commonprefix([attachments_dir, self.path]) + assert commonprefix == attachments_dir + + if not os.access(self.path, os.F_OK): + os.makedirs(self.path) + filename = unicode_quote(filename) + path, targetfile = create_unique_file(os.path.join(self.path, + filename)) + try: + # Note: `path` is an unicode string because `self.path` was one. + # As it contains only quoted chars and numbers, we can use `ascii` + basename = os.path.basename(path).encode('ascii') + filename = unicode_unquote(basename) + + cursor = db.cursor() + cursor.execute("INSERT INTO attachment " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s)", + (self.parent_type, self.parent_id, filename, + self.size, self.time, self.description, self.author, + self.ipnr)) + shutil.copyfileobj(fileobj, targetfile) + self.filename = filename + + self.env.log.info('New attachment: %s by %s', self.title, + self.author) + + if handle_ta: + db.commit() + + for listener in AttachmentModule(self.env).change_listeners: + listener.attachment_added(self) + + finally: + targetfile.close() + + def select(cls, env, parent_type, parent_id, db=None): + if not db: + db = env.get_db_cnx() + cursor = db.cursor() + cursor.execute("SELECT filename,description,size,time,author,ipnr " + "FROM attachment WHERE type=%s AND id=%s ORDER BY time", + (parent_type, unicode(parent_id))) + for filename,description,size,time,author,ipnr in cursor: + attachment = Attachment(env, parent_type, parent_id) + attachment.filename = filename + attachment.description = description + attachment.size = size + attachment.time = time + attachment.author = author + attachment.ipnr = ipnr + yield attachment + + select = classmethod(select) + + def open(self): + self.env.log.debug('Trying to open attachment at %s', self.path) + try: + fd = open(self.path, 'rb') + except IOError: + raise TracError('Attachment %s not found' % self.filename) + return fd + + +# Templating utilities + +def attachments_to_hdf(env, req, db, parent_type, parent_id): + return [attachment_to_hdf(env, req, db, attachment) for attachment + in Attachment.select(env, parent_type, parent_id, db)] + +def attachment_to_hdf(env, req, db, attachment): + if not db: + db = env.get_db_cnx() + hdf = { + 'filename': attachment.filename, + 'description': wiki_to_oneliner(attachment.description, env, db), + 'author': attachment.author, + 'ipnr': attachment.ipnr, + 'size': pretty_size(attachment.size), + 'time': format_datetime(attachment.time), + 'age': pretty_timedelta(attachment.time), + 'href': attachment.href(req) + } + return hdf + + +class AttachmentModule(Component): + + implements(IEnvironmentSetupParticipant, IRequestHandler, + INavigationContributor, IWikiSyntaxProvider) + + change_listeners = ExtensionPoint(IAttachmentChangeListener) + manipulators = ExtensionPoint(IAttachmentManipulator) + + CHUNK_SIZE = 4096 + + max_size = IntOption('attachment', 'max_size', 262144, + """Maximum allowed file size for ticket and wiki attachments.""") + + render_unsafe_content = BoolOption('attachment', 'render_unsafe_content', + 'false', + """Whether non-binary attachments should be rendered in the browser, or + only made downloadable. + + Pretty much any text file may be interpreted as HTML by the browser, + which allows a malicious user to attach a file containing cross-site + scripting attacks. + + For public sites where anonymous users can create attachments, it is + recommended to leave this option disabled (which is the default).""") + + # IEnvironmentSetupParticipant methods + + def environment_created(self): + """Create the attachments directory.""" + if self.env.path: + os.mkdir(os.path.join(self.env.path, 'attachments')) + + def environment_needs_upgrade(self, db): + return False + + def upgrade_environment(self, db): + pass + + # INavigationContributor methods + + def get_active_navigation_item(self, req): + return req.args.get('type') + + def get_navigation_items(self, req): + return [] + + # IRequestHandler methods + + def match_request(self, req): + match = re.match(r'^/attachment/(ticket|wiki)(?:[/:](.*))?$', + req.path_info) + if match: + req.args['type'] = match.group(1) + req.args['path'] = match.group(2).replace(':', '/') + return True + + def process_request(self, req): + parent_type = req.args.get('type') + path = req.args.get('path') + if not parent_type or not path: + raise HTTPBadRequest('Bad request') + if not parent_type in ['ticket', 'wiki']: + raise HTTPBadRequest('Unknown attachment type') + + action = req.args.get('action', 'view') + if action == 'new': + attachment = Attachment(self.env, parent_type, path) + else: + segments = path.split('/') + parent_id = '/'.join(segments[:-1]) + last_segment = segments[-1] + if len(segments) == 1: + self._render_list(req, parent_type, last_segment) + return 'attachment.cs', None + if not last_segment: + raise HTTPBadRequest('Bad request') + attachment = Attachment(self.env, parent_type, parent_id, + last_segment) + parent_link, parent_text = self._parent_to_hdf( + req, attachment.parent_type, attachment.parent_id) + if req.method == 'POST': + if action == 'new': + self._do_save(req, attachment) + elif action == 'delete': + self._do_delete(req, attachment) + elif action == 'delete': + self._render_confirm(req, attachment) + elif action == 'new': + self._render_form(req, attachment) + else: + add_link(req, 'up', parent_link, parent_text) + self._render_view(req, attachment) + + add_stylesheet(req, 'common/css/code.css') + return 'attachment.cs', None + + def _parent_to_hdf(self, req, parent_type, parent_id): + # Populate attachment.parent: + parent_link = req.href(parent_type, parent_id) + if parent_type == 'ticket': + parent_text = 'Ticket #' + parent_id + else: # 'wiki' + parent_text = parent_id + req.hdf['attachment.parent'] = { + 'type': parent_type, 'id': parent_id, + 'name': parent_text, 'href': parent_link + } + return parent_link, parent_text + + # IWikiSyntaxProvider methods + + def get_wiki_syntax(self): + return [] + + def get_link_resolvers(self): + yield ('attachment', self._format_link) + + # Public methods + + def get_history(self, start, stop, type): + """Return an iterable of tuples describing changes to attachments on + a particular object type. + + The tuples are in the form (change, type, id, filename, time, + description, author). `change` can currently only be `created`.""" + # Traverse attachment directory + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("SELECT type, id, filename, time, description, author " + " FROM attachment " + " WHERE time > %s AND time < %s " + " AND type = %s", (start, stop, type)) + for type, id, filename, time, description, author in cursor: + yield ('created', type, id, filename, time, description, author) + + def get_timeline_events(self, req, db, type, format, start, stop, display): + """Return an iterable of events suitable for ITimelineEventProvider. + + `display` is a callback for formatting the attachment's parent + """ + for change, type, id, filename, time, descr, author in \ + self.get_history(start, stop, type): + title = html.EM(os.path.basename(filename)) + \ + ' attached to ' + display(id) + if format == 'rss': + descr = wiki_to_html(descr or '--', self.env, req, db, + absurls=True) + href = req.abs_href + else: + descr = wiki_to_oneliner(descr, self.env, db, shorten=True) + title += Markup(' by %s', author) + href = req.href + yield('attachment', href.attachment(type, id, filename), title, + time, author, descr) + + # Internal methods + + def _do_save(self, req, attachment): + perm_map = {'ticket': 'TICKET_APPEND', 'wiki': 'WIKI_MODIFY'} + req.perm.assert_permission(perm_map[attachment.parent_type]) + + if req.args.has_key('cancel'): + req.redirect(attachment.parent_href(req)) + + upload = req.args['attachment'] + if not hasattr(upload, 'filename') or not upload.filename: + raise TracError('No file uploaded') + if hasattr(upload.file, 'fileno'): + size = os.fstat(upload.file.fileno())[6] + else: + size = upload.file.len + if size == 0: + raise TracError("Can't upload empty file") + + # Maximum attachment size (in bytes) + max_size = self.max_size + if max_size >= 0 and size > max_size: + raise TracError('Maximum attachment size: %d bytes' % max_size, + 'Upload failed') + + # We try to normalize the filename to unicode NFC if we can. + # Files uploaded from OS X might be in NFD. + filename = unicodedata.normalize('NFC', unicode(upload.filename, + 'utf-8')) + filename = filename.replace('\\', '/').replace(':', '/') + filename = os.path.basename(filename) + if not filename: + raise TracError('No file uploaded') + + attachment.description = req.args.get('description', '') + attachment.author = get_reporter_id(req, 'author') + attachment.ipnr = req.remote_addr + + # Validate attachment + for manipulator in self.manipulators: + for field, message in manipulator.validate_attachment(req, attachment): + if field: + raise InvalidAttachment('Attachment field %s is invalid: %s' + % (field, message)) + else: + raise InvalidAttachment('Invalid attachment: %s' % message) + + if req.args.get('replace'): + try: + old_attachment = Attachment(self.env, attachment.parent_type, + attachment.parent_id, filename) + if not (old_attachment.author and req.authname \ + and old_attachment.author == req.authname): + perm_map = {'ticket': 'TICKET_ADMIN', 'wiki': 'WIKI_DELETE'} + req.perm.assert_permission(perm_map[old_attachment.parent_type]) + old_attachment.delete() + except TracError: + pass # don't worry if there's nothing to replace + attachment.filename = None + attachment.insert(filename, upload.file, size) + + # Redirect the user to the newly created attachment + req.redirect(attachment.href(req)) + + def _do_delete(self, req, attachment): + perm_map = {'ticket': 'TICKET_ADMIN', 'wiki': 'WIKI_DELETE'} + req.perm.assert_permission(perm_map[attachment.parent_type]) + + if req.args.has_key('cancel'): + req.redirect(attachment.href(req)) + + attachment.delete() + + # Redirect the user to the attachment parent page + req.redirect(attachment.parent_href(req)) + + def _render_confirm(self, req, attachment): + perm_map = {'ticket': 'TICKET_ADMIN', 'wiki': 'WIKI_DELETE'} + req.perm.assert_permission(perm_map[attachment.parent_type]) + + req.hdf['title'] = '%s (delete)' % attachment.title + req.hdf['attachment'] = {'filename': attachment.filename, + 'mode': 'delete'} + + def _render_form(self, req, attachment): + perm_map = {'ticket': 'TICKET_APPEND', 'wiki': 'WIKI_MODIFY'} + req.perm.assert_permission(perm_map[attachment.parent_type]) + + req.hdf['attachment'] = {'mode': 'new', + 'author': get_reporter_id(req)} + + def _render_view(self, req, attachment): + perm_map = {'ticket': 'TICKET_VIEW', 'wiki': 'WIKI_VIEW'} + req.perm.assert_permission(perm_map[attachment.parent_type]) + + req.check_modified(attachment.time) + + # Render HTML view + req.hdf['title'] = attachment.title + req.hdf['attachment'] = attachment_to_hdf(self.env, req, None, + attachment) + # Override the 'oneliner' + req.hdf['attachment.description'] = wiki_to_html(attachment.description, + self.env, req) + + perm_map = {'ticket': 'TICKET_ADMIN', 'wiki': 'WIKI_DELETE'} + if req.perm.has_permission(perm_map[attachment.parent_type]): + req.hdf['attachment.can_delete'] = 1 + + fd = attachment.open() + try: + mimeview = Mimeview(self.env) + + # MIME type detection + str_data = fd.read(1000) + fd.seek(0) + + binary = is_binary(str_data) + mime_type = mimeview.get_mimetype(attachment.filename, str_data) + + # Eventually send the file directly + format = req.args.get('format') + if format in ('raw', 'txt'): + if not self.render_unsafe_content and not binary: + # Force browser to download HTML/SVG/etc pages that may + # contain malicious code enabling XSS attacks + req.send_header('Content-Disposition', 'attachment;' + + 'filename=' + attachment.filename) + if not mime_type or (self.render_unsafe_content and \ + not binary and format == 'txt'): + mime_type = 'text/plain' + if 'charset=' not in mime_type: + charset = mimeview.get_charset(str_data, mime_type) + mime_type = mime_type + '; charset=' + charset + req.send_file(attachment.path, mime_type) + + # add ''Plain Text'' alternate link if needed + if self.render_unsafe_content and not binary and \ + mime_type and not mime_type.startswith('text/plain'): + plaintext_href = attachment.href(req, format='txt') + add_link(req, 'alternate', plaintext_href, 'Plain Text', + mime_type) + + # add ''Original Format'' alternate link (always) + raw_href = attachment.href(req, format='raw') + add_link(req, 'alternate', raw_href, 'Original Format', mime_type) + + self.log.debug("Rendering preview of file %s with mime-type %s" + % (attachment.filename, mime_type)) + + req.hdf['attachment'] = mimeview.preview_to_hdf( + req, fd, os.fstat(fd.fileno()).st_size, mime_type, + attachment.filename, raw_href, annotations=['lineno']) + finally: + fd.close() + + def _render_list(self, req, p_type, p_id): + self._parent_to_hdf(req, p_type, p_id) + req.hdf['attachment'] = { + 'mode': 'list', + 'list': attachments_to_hdf(self.env, req, None, p_type, p_id), + 'attach_href': req.href.attachment(p_type, p_id) + } + + def _format_link(self, formatter, ns, target, label): + link, params, fragment = formatter.split_link(target) + ids = link.split(':', 2) + if len(ids) == 3: + parent_type, parent_id, filename = ids + else: + # FIXME: the formatter should know which object the text being + # formatter belongs to + parent_type, parent_id = 'wiki', 'WikiStart' + if formatter.req: + path_info = formatter.req.path_info.split('/', 2) + if len(path_info) > 1: + parent_type = path_info[1] + if len(path_info) > 2: + parent_id = path_info[2] + filename = link + href = formatter.href() + try: + attachment = Attachment(self.env, parent_type, parent_id, filename) + if formatter.req: + href = attachment.href(formatter.req) + params + return html.A(label, class_='attachment', href=href, + title='Attachment %s' % attachment.title) + except TracError: + return html.A(label, class_='missing attachment', rel='nofollow', + href=formatter.href()) diff --git a/examples/trac/trac/config.py b/examples/trac/trac/config.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/config.py @@ -0,0 +1,415 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2006 Edgewall Software +# Copyright (C) 2005-2006 Christopher Lenz +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Christopher Lenz + +from ConfigParser import ConfigParser +import os +try: + set +except NameError: + from sets import Set as set +import sys + +from trac.core import ExtensionPoint, TracError +from trac.util import sorted, to_unicode + +__all__ = ['Configuration', 'Option', 'BoolOption', 'IntOption', 'ListOption', + 'ExtensionOption', 'OrderedExtensionsOption', 'ConfigurationError', + 'default_dir'] + +_TRUE_VALUES = ('yes', 'true', 'on', 'aye', '1', 1, True) + + +class ConfigurationError(TracError): + """Exception raised when a value in the configuration file is not valid.""" + + +class Configuration(object): + """Thin layer over `ConfigParser` from the Python standard library. + + In addition to providing some convenience methods, the class remembers + the last modification time of the configuration file, and reparses it + when the file has changed. + """ + def __init__(self, filename): + self._sections = {} + self.filename = filename + self.parser = ConfigParser() + self._lastmtime = 0 + self.site_filename = os.path.join(default_dir('conf'), 'trac.ini') + self.site_parser = ConfigParser() + self._lastsitemtime = 0 + self.parse_if_needed() + + def __contains__(self, name): + """Return whether the configuration contains a section of the given + name. + """ + return self.parser.has_section(name) + + def __getitem__(self, name): + """Return the configuration section with the specified name.""" + if name not in self._sections: + self._sections[name] = Section(self, name) + return self._sections[name] + + def get(self, section, name, default=None): + """Return the value of the specified option.""" + return self[section].get(name, default) + + def getbool(self, section, name, default=None): + """Return the specified option as boolean value. + + If the value of the option is one of "yes", "true", "on", or "1", this + method wll return `True`, otherwise `False`. + + (since Trac 0.9.3) + """ + return self[section].getbool(name, default) + + def getint(self, section, name, default=None): + """Return the value of the specified option as integer. + + If the specified option can not be converted to an integer, a + `ConfigurationError` exception is raised. + + (since Trac 0.10) + """ + return self[section].getint(name, default) + + def getlist(self, section, name, default=None, sep=',', keep_empty=False): + """Return a list of values that have been specified as a single + comma-separated option. + + A different separator can be specified using the `sep` parameter. If + the `keep_empty` parameter is set to `True`, empty elements are + included in the list. + + (since Trac 0.10) + """ + return self[section].getlist(name, default, sep, keep_empty) + + def set(self, section, name, value): + """Change a configuration value. + + These changes are not persistent unless saved with `save()`. + """ + self[section].set(name, value) + + def defaults(self): + """Returns a dictionary of the default configuration values. + + (since Trac 0.10) + """ + defaults = {} + for (section, name), option in Option.registry.items(): + defaults.setdefault(section, {})[name] = option.default + return defaults + + def options(self, section): + """Return a list of `(name, value)` tuples for every option in the + specified section. + + This includes options that have default values that haven't been + overridden. + """ + return self[section].options() + + def remove(self, section, name): + """Remove the specified option.""" + if self.parser.has_section(section): + self.parser.remove_option(section, name) + + def sections(self): + """Return a list of section names.""" + return sorted(set(self.site_parser.sections() + self.parser.sections())) + + def save(self): + """Write the configuration options to the primary file.""" + if not self.filename: + return + + # Only save options that differ from the defaults + config = ConfigParser() + for section in self.sections(): + for option in self[section]: + default = self.site_parser.has_option(section, option) and \ + self.site_parser.get(section, option) + current = self.parser.has_option(section, option) and \ + self.parser.get(section, option) + if current is not False and current != default: + if not config.has_section(section): + config.add_section(section) + config.set(section, option, current or '') + + fileobj = file(self.filename, 'w') + try: + config.write(fileobj) + finally: + fileobj.close() + + def parse_if_needed(self): + # Load global configuration + if os.path.isfile(self.site_filename): + modtime = os.path.getmtime(self.site_filename) + if modtime > self._lastsitemtime: + self.site_parser.read(self.site_filename) + self._lastsitemtime = modtime + + if not self.filename or not os.path.isfile(self.filename): + return + modtime = os.path.getmtime(self.filename) + if modtime > self._lastmtime: + self.parser.read(self.filename) + self._lastmtime = modtime + + +class Section(object): + """Proxy for a specific configuration section. + + Objects of this class should not be instantiated directly. + """ + __slots__ = ['config', 'name'] + + def __init__(self, config, name): + self.config = config + self.name = name + + def __contains__(self, name): + return self.config.parser.has_option(self.name, name) or \ + self.config.site_parser.has_option(self.name, name) + + def __iter__(self): + options = [] + if self.config.parser.has_section(self.name): + for option in self.config.parser.options(self.name): + options.append(option.lower()) + yield option + if self.config.site_parser.has_section(self.name): + for option in self.config.site_parser.options(self.name): + if option.lower() not in options: + yield option + + def __repr__(self): + return '
' % (self.name) + + def get(self, name, default=None): + """Return the value of the specified option.""" + if self.config.parser.has_option(self.name, name): + value = self.config.parser.get(self.name, name) + elif self.config.site_parser.has_option(self.name, name): + value = self.config.site_parser.get(self.name, name) + else: + option = Option.registry.get((self.name, name)) + if option: + value = option.default or default + else: + value = default + if value is None: + return '' + return to_unicode(value) + + def getbool(self, name, default=None): + """Return the value of the specified option as boolean. + + This method returns `True` if the option value is one of "yes", "true", + "on", or "1", ignoring case. Otherwise `False` is returned. + """ + value = self.get(name, default) + if isinstance(value, basestring): + value = value.lower() in _TRUE_VALUES + return bool(value) + + def getint(self, name, default=None): + """Return the value of the specified option as integer. + + If the specified option can not be converted to an integer, a + `ConfigurationError` exception is raised. + """ + value = self.get(name, default) + try: + return int(value) + except ValueError: + raise ConfigurationError('expected integer, got %s' % repr(value)) + + def getlist(self, name, default=None, sep=',', keep_empty=True): + """Return a list of values that have been specified as a single + comma-separated option. + + A different separator can be specified using the `sep` parameter. If + the `skip_empty` parameter is set to `True`, empty elements are omitted + from the list. + """ + value = self.get(name, default) + if isinstance(value, basestring): + items = [item.strip() for item in value.split(sep)] + else: + items = list(value) + if not keep_empty: + items = filter(None, items) + return items + + def options(self): + """Return `(name, value)` tuples for every option in the section.""" + for name in self: + yield name, self.get(name) + + def set(self, name, value): + """Change a configuration value. + + These changes are not persistent unless saved with `save()`. + """ + if not self.config.parser.has_section(self.name): + self.config.parser.add_section(self.name) + return self.config.parser.set(self.name, name, + to_unicode(value).encode('utf-8')) + + +class Option(object): + """Descriptor for configuration options on `Configurable` subclasses.""" + + registry = {} + accessor = Section.get + + def __init__(self, section, name, default=None, doc=''): + """Create the extension point. + + @param section: the name of the configuration section this option + belongs to + @param name: the name of the option + @param default: the default value for the option + @param doc: documentation of the option + """ + self.section = section + self.name = name + self.default = default + self.registry[(self.section, self.name)] = self + self.__doc__ = doc + + def __get__(self, instance, owner): + if instance is None: + return self + config = getattr(instance, 'config', None) + if config and isinstance(config, Configuration): + section = config[self.section] + value = self.accessor(section, self.name, self.default) + return value + return None + + def __set__(self, instance, value): + raise AttributeError, 'can\'t set attribute' + + def __repr__(self): + return '<%s [%s] "%s">' % (self.__class__.__name__, self.section, + self.name) + + +class BoolOption(Option): + """Descriptor for boolean configuration options.""" + accessor = Section.getbool + + +class IntOption(Option): + """Descriptor for integer configuration options.""" + accessor = Section.getint + + +class ListOption(Option): + """Descriptor for configuration options that contain multiple values + separated by a specific character.""" + + def __init__(self, section, name, default=None, sep=',', keep_empty=False, + doc=''): + Option.__init__(self, section, name, default, doc) + self.sep = sep + self.keep_empty = keep_empty + + def accessor(self, section, name, default): + return section.getlist(name, default, self.sep, self.keep_empty) + + +class ExtensionOption(Option): + + def __init__(self, section, name, interface, default=None, doc=''): + Option.__init__(self, section, name, default, doc) + self.xtnpt = ExtensionPoint(interface) + + def __get__(self, instance, owner): + if instance is None: + return self + value = Option.__get__(self, instance, owner) + for impl in self.xtnpt.extensions(instance): + if impl.__class__.__name__ == value: + return impl + raise AttributeError('Cannot find an implementation of the "%s" ' + 'interface named "%s". Please update the option ' + '%s.%s in trac.ini.' + % (self.xtnpt.interface.__name__, value, + self.section, self.name)) + + +class OrderedExtensionsOption(ListOption): + """A comma separated, ordered, list of components implementing `interface`. + Can be empty. + + If `include_missing` is true (the default) all components implementing the + interface are returned, with those specified by the option ordered first.""" + + def __init__(self, section, name, interface, default=None, + include_missing=True, doc=''): + ListOption.__init__(self, section, name, default, doc=doc) + self.xtnpt = ExtensionPoint(interface) + self.include_missing = include_missing + + def __get__(self, instance, owner): + if instance is None: + return self + order = ListOption.__get__(self, instance, owner) + components = [] + for impl in self.xtnpt.extensions(instance): + if self.include_missing or impl.__class__.__name__ in order: + components.append(impl) + + def compare(x, y): + x, y = x.__class__.__name__, y.__class__.__name__ + if x not in order: + return int(y in order) + if y not in order: + return -int(x in order) + return cmp(order.index(x), order.index(y)) + components.sort(compare) + return components + + +def default_dir(name): + try: + from trac import siteconfig + return getattr(siteconfig, '__default_%s_dir__' % name) + except ImportError: + # This is not a regular install with a generated siteconfig.py file, + # so try to figure out the directory based on common setups + special_dirs = {'wiki': 'wiki-default', 'macros': 'wiki-macros'} + dirname = special_dirs.get(name, name) + + # First assume we're being executing directly form the source directory + import trac + path = os.path.join(os.path.split(os.path.dirname(trac.__file__))[0], + dirname) + if not os.path.isdir(path): + # Not being executed from the source directory, so assume the + # default installation prefix + path = os.path.join(sys.prefix, 'share', 'trac', dirname) + + return path diff --git a/examples/trac/trac/core.py b/examples/trac/trac/core.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/core.py @@ -0,0 +1,204 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2003-2005 Edgewall Software +# Copyright (C) 2003-2004 Jonas Borgström +# Copyright (C) 2004-2005 Christopher Lenz +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Jonas Borgström +# Christopher Lenz + +__all__ = ['Component', 'ExtensionPoint', 'implements', 'Interface', + 'TracError'] + + +class TracError(Exception): + """Exception base class for errors in Trac.""" + + def __init__(self, message, title=None, show_traceback=False): + Exception.__init__(self, message) + self.message = message + self.title = title + self.show_traceback = show_traceback + + +class Interface(object): + """Marker base class for extension point interfaces.""" + + +class ExtensionPoint(property): + """Marker class for extension points in components.""" + + def __init__(self, interface): + """Create the extension point. + + @param interface: the `Interface` subclass that defines the protocol + for the extension point + """ + property.__init__(self, self.extensions) + self.interface = interface + self.__doc__ = 'List of components that implement `%s`' % \ + self.interface.__name__ + + def extensions(self, component): + """Return a list of components that declare to implement the extension + point interface.""" + extensions = ComponentMeta._registry.get(self.interface, []) + return filter(None, [component.compmgr[cls] for cls in extensions]) + + def __repr__(self): + """Return a textual representation of the extension point.""" + return '' % self.interface.__name__ + + +class ComponentMeta(type): + """Meta class for components. + + Takes care of component and extension point registration. + """ + _components = [] + _registry = {} + + def __new__(cls, name, bases, d): + """Create the component class.""" + + new_class = type.__new__(cls, name, bases, d) + if name == 'Component': + # Don't put the Component base class in the registry + return new_class + + # Only override __init__ for Components not inheriting ComponentManager + if True not in [issubclass(x, ComponentManager) for x in bases]: + # Allow components to have a no-argument initializer so that + # they don't need to worry about accepting the component manager + # as argument and invoking the super-class initializer + init = d.get('__init__') + if not init: + # Because we're replacing the initializer, we need to make sure + # that any inherited initializers are also called. + for init in [b.__init__._original for b in new_class.mro() + if issubclass(b, Component) + and '__init__' in b.__dict__]: + break + def maybe_init(self, compmgr, init=init, cls=new_class): + if cls not in compmgr.components: + compmgr.components[cls] = self + if init: + init(self) + maybe_init._original = init + new_class.__init__ = maybe_init + + if d.get('abstract'): + # Don't put abstract component classes in the registry + return new_class + + ComponentMeta._components.append(new_class) + for interface in d.get('_implements', []): + ComponentMeta._registry.setdefault(interface, []).append(new_class) + for base in [base for base in bases if hasattr(base, '_implements')]: + for interface in base._implements: + ComponentMeta._registry.setdefault(interface, []).append(new_class) + + return new_class + + +def implements(*interfaces): + """ + Can be used in the class definiton of `Component` subclasses to declare + the extension points that are extended. + """ + import sys + + frame = sys._getframe(1) + locals = frame.f_locals + + # Some sanity checks + assert locals is not frame.f_globals and '__module__' in frame.f_locals, \ + 'implements() can only be used in a class definition' + assert not '_implements' in locals, \ + 'implements() can only be used once in a class definition' + + locals['_implements'] = interfaces + + +class Component(object): + """Base class for components. + + Every component can declare what extension points it provides, as well as + what extension points of other components it extends. + """ + __metaclass__ = ComponentMeta + + def __new__(cls, *args, **kwargs): + """Return an existing instance of the component if it has already been + activated, otherwise create a new instance. + """ + # If this component is also the component manager, just invoke that + if issubclass(cls, ComponentManager): + self = super(Component, cls).__new__(cls) + self.compmgr = self + return self + + # The normal case where the component is not also the component manager + compmgr = args[0] + self = compmgr.components.get(cls) + if self is None: + self = super(Component, cls).__new__(cls) + self.compmgr = compmgr + compmgr.component_activated(self) + return self + + +class ComponentManager(object): + """The component manager keeps a pool of active components.""" + + def __init__(self): + """Initialize the component manager.""" + self.components = {} + self.enabled = {} + if isinstance(self, Component): + self.components[self.__class__] = self + + def __contains__(self, cls): + """Return wether the given class is in the list of active components.""" + return cls in self.components + + def __getitem__(self, cls): + """Activate the component instance for the given class, or return the + existing the instance if the component has already been activated.""" + if cls not in self.enabled: + self.enabled[cls] = self.is_component_enabled(cls) + if not self.enabled[cls]: + return None + component = self.components.get(cls) + if not component: + if cls not in ComponentMeta._components: + raise TracError, 'Component "%s" not registered' % cls.__name__ + try: + component = cls(self) + except TypeError, e: + raise TracError, 'Unable to instantiate component %r (%s)' \ + % (cls, e) + return component + + def component_activated(self, component): + """Can be overridden by sub-classes so that special initialization for + components can be provided. + """ + + def is_component_enabled(self, cls): + """Can be overridden by sub-classes to veto the activation of a + component. + + If this method returns False, the component with the given class will + not be available. + """ + return True diff --git a/examples/trac/trac/db/__init__.py b/examples/trac/trac/db/__init__.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/db/__init__.py @@ -0,0 +1,2 @@ +from trac.db.api import * +from trac.db.schema import * diff --git a/examples/trac/trac/db/api.py b/examples/trac/trac/db/api.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/db/api.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005 Edgewall Software +# Copyright (C) 2005 Christopher Lenz +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Christopher Lenz + +import os +import urllib + +from trac.config import Option +from trac.core import * +from trac.db.pool import ConnectionPool + + +def get_column_names(cursor): + return cursor.description and \ + [(isinstance(d[0], str) and [unicode(d[0], 'utf-8')] or [d[0]])[0] + for d in cursor.description] or [] + + +class IDatabaseConnector(Interface): + """Extension point interface for components that support the connection to + relational databases.""" + + def get_supported_schemes(): + """Return the connection URL schemes supported by the connector, and + their relative priorities as an iterable of `(scheme, priority)` tuples. + """ + + def get_connection(**kwargs): + """Create a new connection to the database.""" + + def init_db(**kwargs): + """Initialize the database.""" + + def to_sql(table): + """Return the DDL statements necessary to create the specified table, + including indices.""" + + +class DatabaseManager(Component): + + connectors = ExtensionPoint(IDatabaseConnector) + + connection_uri = Option('trac', 'database', 'sqlite:db/trac.db', + """Database connection + [wiki:TracEnvironment#DatabaseConnectionStrings string] for this + project""") + + def __init__(self): + self._cnx_pool = None + + def init_db(self): + connector, args = self._get_connector() + connector.init_db(**args) + + def get_connection(self): + if not self._cnx_pool: + connector, args = self._get_connector() + self._cnx_pool = ConnectionPool(5, connector, **args) + return self._cnx_pool.get_cnx() + + def shutdown(self): + if self._cnx_pool: + self._cnx_pool.shutdown() + self._cnx_pool = None + + def _get_connector(self): ### FIXME: Make it public? + scheme, args = _parse_db_str(self.connection_uri) + candidates = {} + connector = None + for connector in self.connectors: + for scheme_, priority in connector.get_supported_schemes(): + if scheme_ != scheme: + continue + highest = candidates.get(scheme_, (None, 0))[1] + if priority > highest: + candidates[scheme] = (connector, priority) + connector = candidates.get(scheme, [None])[0] + if not connector: + raise TracError, 'Unsupported database type "%s"' % scheme + + if scheme == 'sqlite': + # Special case for SQLite to support a path relative to the + # environment directory + if args['path'] != ':memory:' and \ + not args['path'].startswith('/'): + args['path'] = os.path.join(self.env.path, + args['path'].lstrip('/')) + + return connector, args + + +def _parse_db_str(db_str): + scheme, rest = db_str.split(':', 1) + + if not rest.startswith('/'): + if scheme == 'sqlite': + # Support for relative and in-memory SQLite connection strings + host = None + path = rest + else: + raise TracError, 'Database connection string %s must start with ' \ + 'scheme:/' % db_str + else: + if rest.startswith('/') and not rest.startswith('//'): + host = None + rest = rest[1:] + elif rest.startswith('///'): + host = None + rest = rest[3:] + else: + rest = rest[2:] + if rest.find('/') == -1: + host = rest + rest = '' + else: + host, rest = rest.split('/', 1) + path = None + + if host and host.find('@') != -1: + user, host = host.split('@', 1) + if user.find(':') != -1: + user, password = user.split(':', 1) + else: + password = None + else: + user = password = None + if host and host.find(':') != -1: + host, port = host.split(':') + port = int(port) + else: + port = None + + if not path: + path = '/' + rest + if os.name == 'nt': + # Support local paths containing drive letters on Win32 + if len(rest) > 1 and rest[1] == '|': + path = "%s:%s" % (rest[0], rest[2:]) + + params = {} + if path.find('?') != -1: + path, qs = path.split('?', 1) + qs = qs.split('&') + for param in qs: + name, value = param.split('=', 1) + value = urllib.unquote(value) + params[name] = value + + args = zip(('user', 'password', 'host', 'port', 'path', 'params'), + (user, password, host, port, path, params)) + return scheme, dict([(key, value) for key, value in args if value]) diff --git a/examples/trac/trac/db/mysql_backend.py b/examples/trac/trac/db/mysql_backend.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/db/mysql_backend.py @@ -0,0 +1,157 @@ +# -*- coding: iso8859-1 -*- +# +# Copyright (C) 2005-2006 Edgewall Software +# Copyright (C) 2005-2006 Christopher Lenz +# Copyright (C) 2005 Jeff Weiss +# Copyright (C) 2006 Andres Salomon +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. + +import re + +from trac.core import * +from trac.db.api import IDatabaseConnector +from trac.db.util import ConnectionWrapper + +_like_escape_re = re.compile(r'([/_%])') + + +class MySQLConnector(Component): + """MySQL database support for version 4.1 and greater. + + Database urls should be of the form: + mysql://user[:password]@host[:port]/database + """ + + implements(IDatabaseConnector) + + def get_supported_schemes(self): + return [('mysql', 1)] + + def get_connection(self, path, user=None, password=None, host=None, + port=None, params={}): + return MySQLConnection(path, user, password, host, port, params) + + def init_db(self, path, user=None, password=None, host=None, port=None, + params={}): + cnx = self.get_connection(path, user, password, host, port, params) + cursor = cnx.cursor() + from trac.db_default import schema + for table in schema: + for stmt in self.to_sql(table): + self.env.log.debug(stmt) + cursor.execute(stmt) + cnx.commit() + + def _collist(self, table, columns): + """Take a list of columns and impose limits on each so that indexing + works properly. + + Some Versions of MySQL limit each index prefix to 500 bytes total, with + a max of 255 bytes per column. + """ + cols = [] + limit = 500 / len(columns) + if limit > 255: + limit = 255 + for c in columns: + name = '`%s`' % c + table_col = filter((lambda x: x.name == c), table.columns) + if len(table_col) == 1 and table_col[0].type.lower() == 'text': + name += '(%s)' % limit + # For non-text columns, we simply throw away the extra bytes. + # That could certainly be optimized better, but for now let's KISS. + cols.append(name) + return ','.join(cols) + + def to_sql(self, table): + sql = ['CREATE TABLE %s (' % table.name] + coldefs = [] + for column in table.columns: + ctype = column.type + if column.auto_increment: + ctype = 'INT UNSIGNED NOT NULL AUTO_INCREMENT' + # Override the column type, as a text field cannot + # use auto_increment. + column.type = 'int' + coldefs.append(' `%s` %s' % (column.name, ctype)) + if len(table.key) > 0: + coldefs.append(' PRIMARY KEY (%s)' % + self._collist(table, table.key)) + sql.append(',\n'.join(coldefs) + '\n)') + yield '\n'.join(sql) + + for index in table.indices: + yield 'CREATE INDEX %s_%s_idx ON %s (%s);' % (table.name, + '_'.join(index.columns), table.name, + self._collist(table, index.columns)) + + +class MySQLConnection(ConnectionWrapper): + """Connection wrapper for MySQL.""" + + poolable = True + + def _mysqldb_gt_or_eq(self, v): + """This function checks whether the version of python-mysqldb + is greater than or equal to the version that's passed to it. + Note that the tuple only checks the major, minor, and sub versions; + the sub-sub version is weird, so we only check for 'final' versions. + """ + from MySQLdb import version_info as ver + if ver[0] < v[0] or ver[1] < v[1] or ver[2] < v[2]: + return False + if ver[3] != 'final': + return False + return True + + def _set_character_set(self, cnx, charset): + vers = tuple([ int(n) for n in cnx.get_server_info().split('.')[:2] ]) + if vers < (4, 1): + raise TracError, 'MySQL servers older than 4.1 are not supported!' + cnx.query('SET NAMES %s' % charset) + cnx.store_result() + cnx.charset = charset + + def __init__(self, path, user=None, password=None, host=None, + port=None, params={}): + import MySQLdb + + if path.startswith('/'): + path = path[1:] + if password == None: + password = '' + if port == None: + port = 3306 + + # python-mysqldb 1.2.1 added a 'charset' arg that is required for + # unicode stuff. We hack around that here for older versions; at + # some point, this hack should be removed, and a strict requirement + # on 1.2.1 made. -dilinger + if (self._mysqldb_gt_or_eq((1, 2, 1))): + cnx = MySQLdb.connect(db=path, user=user, passwd=password, + host=host, port=port, charset='utf8') + else: + cnx = MySQLdb.connect(db=path, user=user, passwd=password, + host=host, port=port, use_unicode=True) + self._set_character_set(cnx, 'utf8') + ConnectionWrapper.__init__(self, cnx) + + def cast(self, column, type): + return 'CAST(%s AS %s)' % (column, type) + + def like(self): + return "LIKE %s ESCAPE '/'" + + def like_escape(self, text): + return _like_escape_re.sub(r'/\1', text) + + def get_last_id(self, cursor, table, column='id'): + return self.cnx.insert_id() diff --git a/examples/trac/trac/db/pool.py b/examples/trac/trac/db/pool.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/db/pool.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005 Edgewall Software +# Copyright (C) 2005 Christopher Lenz +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Christopher Lenz + +try: + import threading +except ImportError: + import dummy_threading as threading + threading._get_ident = lambda: 0 +import time + +from trac.db.util import ConnectionWrapper + + +class TimeoutError(Exception): + """Exception raised by the connection pool when no connection has become + available after a given timeout.""" + + +class PooledConnection(ConnectionWrapper): + """A database connection that can be pooled. When closed, it gets returned + to the pool. + """ + + def __init__(self, pool, cnx): + ConnectionWrapper.__init__(self, cnx) + self._pool = pool + + def close(self): + if self.cnx: + self._pool._return_cnx(self.cnx) + self.cnx = None + + def __del__(self): + self.close() + + +class ConnectionPool(object): + """A very simple connection pool implementation.""" + + def __init__(self, maxsize, connector, **kwargs): + self._dormant = [] # inactive connections in pool + self._active = {} # active connections by thread ID + self._available = threading.Condition(threading.Lock()) + self._maxsize = maxsize # maximum pool size + self._cursize = 0 # current pool size, includes active connections + self._connector = connector + self._kwargs = kwargs + + def get_cnx(self, timeout=None): + start = time.time() + self._available.acquire() + try: + tid = threading._get_ident() + if tid in self._active: + self._active[tid][0] += 1 + return PooledConnection(self, self._active[tid][1]) + while True: + if self._dormant: + cnx = self._dormant.pop() + break + elif self._maxsize and self._cursize < self._maxsize: + cnx = self._connector.get_connection(**self._kwargs) + self._cursize += 1 + break + else: + if timeout: + self._available.wait(timeout) + if (time.time() - start) >= timeout: + raise TimeoutError, 'Unable to get database ' \ + 'connection within %d seconds' \ + % timeout + else: + self._available.wait() + self._active[tid] = [1, cnx] + return PooledConnection(self, cnx) + finally: + self._available.release() + + def _return_cnx(self, cnx): + self._available.acquire() + try: + tid = threading._get_ident() + if tid in self._active: + num, cnx_ = self._active.get(tid) + assert cnx is cnx_ + if num > 1: + self._active[tid][0] = num - 1 + else: + del self._active[tid] + if cnx not in self._dormant: + cnx.rollback() + if cnx.poolable: + self._dormant.append(cnx) + else: + self._cursize -= 1 + self._available.notify() + finally: + self._available.release() + + def shutdown(self): + self._available.acquire() + try: + for cnx in self._dormant: + cnx.cnx.close() + finally: + self._available.release() diff --git a/examples/trac/trac/db/postgres_backend.py b/examples/trac/trac/db/postgres_backend.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/db/postgres_backend.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005 Edgewall Software +# Copyright (C) 2005 Christopher Lenz +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Christopher Lenz + +import re + +from trac.core import * +from trac.db.api import IDatabaseConnector +from trac.db.util import ConnectionWrapper + +psycopg = None +PgSQL = None +PGSchemaError = None + +_like_escape_re = re.compile(r'([/_%])') + + +class PostgreSQLConnector(Component): + """PostgreSQL database support.""" + + implements(IDatabaseConnector) + + def get_supported_schemes(self): + return [('postgres', 1)] + + def get_connection(self, path, user=None, password=None, host=None, + port=None, params={}): + return PostgreSQLConnection(path, user, password, host, port, params) + + def init_db(self, path, user=None, password=None, host=None, port=None, + params={}): + cnx = self.get_connection(path, user, password, host, port, params) + cursor = cnx.cursor() + if cnx.schema: + cursor.execute('CREATE SCHEMA %s' % cnx.schema) + cursor.execute('SET search_path TO %s, public', (cnx.schema,)) + from trac.db_default import schema + for table in schema: + for stmt in self.to_sql(table): + cursor.execute(stmt) + cnx.commit() + + def to_sql(self, table): + sql = ["CREATE TABLE %s (" % table.name] + coldefs = [] + for column in table.columns: + ctype = column.type + if column.auto_increment: + ctype = "SERIAL" + if len(table.key) == 1 and column.name in table.key: + ctype += " PRIMARY KEY" + coldefs.append(" %s %s" % (column.name, ctype)) + if len(table.key) > 1: + coldefs.append(" CONSTRAINT %s_pk PRIMARY KEY (%s)" + % (table.name, ','.join(table.key))) + sql.append(',\n'.join(coldefs) + '\n)') + yield '\n'.join(sql) + for index in table.indices: + yield "CREATE INDEX %s_%s_idx ON %s (%s)" % (table.name, + '_'.join(index.columns), table.name, ','.join(index.columns)) + + +class PostgreSQLConnection(ConnectionWrapper): + """Connection wrapper for PostgreSQL.""" + + poolable = True + + def __init__(self, path, user=None, password=None, host=None, port=None, + params={}): + if path.startswith('/'): + path = path[1:] + # We support both psycopg and PgSQL but prefer psycopg + global psycopg + global PgSQL + global PGSchemaError + + if not psycopg and not PgSQL: + try: + import psycopg2 as psycopg + import psycopg2.extensions + from psycopg2 import ProgrammingError as PGSchemaError + psycopg2.extensions.register_type(psycopg2.extensions.UNICODE) + except ImportError: + from pyPgSQL import PgSQL + from pyPgSQL.libpq import OperationalError as PGSchemaError + if psycopg: + dsn = [] + if path: + dsn.append('dbname=' + path) + if user: + dsn.append('user=' + user) + if password: + dsn.append('password=' + password) + if host: + dsn.append('host=' + host) + if port: + dsn.append('port=' + str(port)) + cnx = psycopg.connect(' '.join(dsn)) + cnx.set_client_encoding('UNICODE') + else: + cnx = PgSQL.connect('', user, password, host, path, port, + client_encoding='utf-8', unicode_results=True) + try: + self.schema = None + if 'schema' in params: + self.schema = params['schema'] + cnx.cursor().execute('SET search_path TO %s, public', + (self.schema,)) + except PGSchemaError: + cnx.rollback() + ConnectionWrapper.__init__(self, cnx) + + def cast(self, column, type): + # Temporary hack needed for the union of selects in the search module + return 'CAST(%s AS %s)' % (column, type) + + def like(self): + # Temporary hack needed for the case-insensitive string matching in the + # search module + return "ILIKE %s ESCAPE '/'" + + def like_escape(self, text): + return _like_escape_re.sub(r'/\1', text) + + def get_last_id(self, cursor, table, column='id'): + cursor.execute("SELECT CURRVAL('%s_%s_seq')" % (table, column)) + return cursor.fetchone()[0] + + def rollback(self): + self.cnx.rollback() + if self.schema: + try: + self.cnx.cursor().execute("SET search_path TO %s, public", + (self.schema,)) + except PGSchemaError: + self.cnx.rollback() diff --git a/examples/trac/trac/db/schema.py b/examples/trac/trac/db/schema.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/db/schema.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005 Edgewall Software +# Copyright (C) 2005 Christopher Lenz +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Christopher Lenz + + +class Table(object): + """Declare a table in a database schema.""" + + def __init__(self, name, key=[]): + self.name = name + self.columns = [] + self.indices = [] + self.key = key + if isinstance(key, basestring): + self.key = [key] + + def __getitem__(self, objs): + self.columns = [o for o in objs if isinstance(o, Column)] + self.indices = [o for o in objs if isinstance(o, Index)] + return self + + +class Column(object): + """Declare a table column in a database schema.""" + + def __init__(self, name, type='text', size=None, unique=False, + auto_increment=False): + self.name = name + self.type = type + self.size = size + self.auto_increment = auto_increment + + +class Index(object): + """Declare an index for a database schema.""" + + def __init__(self, columns): + self.columns = columns diff --git a/examples/trac/trac/db/sqlite_backend.py b/examples/trac/trac/db/sqlite_backend.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/db/sqlite_backend.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005 Edgewall Software +# Copyright (C) 2005 Christopher Lenz +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Christopher Lenz + +import os +import re +import weakref + +from trac.core import * +from trac.db.api import IDatabaseConnector +from trac.db.util import ConnectionWrapper + +_like_escape_re = re.compile(r'([/_%])') + +try: + import pysqlite2.dbapi2 as sqlite + have_pysqlite = 2 + _ver = sqlite.sqlite_version_info + sqlite_version = _ver[0] * 10000 + _ver[1] * 100 + int(_ver[2]) + + class PyFormatCursor(sqlite.Cursor): + def _rollback_on_error(self, function, *args, **kwargs): + try: + return function(self, *args, **kwargs) + except sqlite.OperationalError, e: + self.cnx.rollback() + raise + def execute(self, sql, args=None): + if args: + sql = sql % (('?',) * len(args)) + return self._rollback_on_error(sqlite.Cursor.execute, sql, + args or []) + def executemany(self, sql, args=None): + if args: + sql = sql % (('?',) * len(args[0])) + return self._rollback_on_error(sqlite.Cursor.executemany, sql, + args or []) + +except ImportError: + try: + import sqlite + have_pysqlite = 1 + _ver = sqlite._sqlite.sqlite_version_info() + sqlite_version = _ver[0] * 10000 + _ver[1] * 100 + _ver[2] + + class SQLiteUnicodeCursor(sqlite.Cursor): + def _convert_row(self, row): + return tuple([(isinstance(v, str) and [v.decode('utf-8')] or [v])[0] + for v in row]) + def fetchone(self): + row = sqlite.Cursor.fetchone(self) + return row and self._convert_row(row) or None + def fetchmany(self, num): + rows = sqlite.Cursor.fetchmany(self, num) + return rows != None and [self._convert_row(row) + for row in rows] or [] + def fetchall(self): + rows = sqlite.Cursor.fetchall(self) + return rows != None and [self._convert_row(row) + for row in rows] or [] + except ImportError: + have_pysqlite = 0 + +def _to_sql(table): + sql = ["CREATE TABLE %s (" % table.name] + coldefs = [] + for column in table.columns: + ctype = column.type.lower() + if column.auto_increment: + ctype = "integer PRIMARY KEY" + elif len(table.key) == 1 and column.name in table.key: + ctype += " PRIMARY KEY" + elif ctype == "int": + ctype = "integer" + coldefs.append(" %s %s" % (column.name, ctype)) + if len(table.key) > 1: + coldefs.append(" UNIQUE (%s)" % ','.join(table.key)) + sql.append(',\n'.join(coldefs) + '\n);') + yield '\n'.join(sql) + for index in table.indices: + yield "CREATE INDEX %s_%s_idx ON %s (%s);" % (table.name, + '_'.join(index.columns), table.name, ','.join(index.columns)) + + + + +class SQLiteConnector(Component): + """SQLite database support.""" + implements(IDatabaseConnector) + + def get_supported_schemes(self): + return [('sqlite', 1)] + + def get_connection(self, path, params={}): + return SQLiteConnection(path, params) + + def init_db(cls, path, params={}): + if path != ':memory:': + # make the directory to hold the database + if os.path.exists(path): + raise TracError, 'Database already exists at %s' % path + os.makedirs(os.path.split(path)[0]) + cnx = sqlite.connect(path, timeout=int(params.get('timeout', 10000))) + cursor = cnx.cursor() + from trac.db_default import schema + for table in schema: + for stmt in cls.to_sql(table): + cursor.execute(stmt) + cnx.commit() + + def to_sql(cls, table): + return _to_sql(table) + + +class SQLiteConnection(ConnectionWrapper): + """Connection wrapper for SQLite.""" + + __slots__ = ['_active_cursors'] + poolable = False + + def __init__(self, path, params={}): + assert have_pysqlite > 0 + self.cnx = None + if path != ':memory:': + if not os.access(path, os.F_OK): + raise TracError, 'Database "%s" not found.' % path + + dbdir = os.path.dirname(path) + if not os.access(path, os.R_OK + os.W_OK) or \ + not os.access(dbdir, os.R_OK + os.W_OK): + from getpass import getuser + raise TracError, 'The user %s requires read _and_ write ' \ + 'permission to the database file %s and the ' \ + 'directory it is located in.' \ + % (getuser(), path) + + if have_pysqlite == 2: + self._active_cursors = weakref.WeakKeyDictionary() + timeout = int(params.get('timeout', 10.0)) + cnx = sqlite.connect(path, detect_types=sqlite.PARSE_DECLTYPES, + timeout=timeout) + else: + timeout = int(params.get('timeout', 10000)) + cnx = sqlite.connect(path, timeout=timeout, encoding='utf-8') + + ConnectionWrapper.__init__(self, cnx) + + if have_pysqlite == 2: + def cursor(self): + cursor = self.cnx.cursor(PyFormatCursor) + self._active_cursors[cursor] = True + cursor.cnx = self + return cursor + + def rollback(self): + for cursor in self._active_cursors.keys(): + cursor.close() + self.cnx.rollback() + + else: + def cursor(self): + self.cnx._checkNotClosed("cursor") + return SQLiteUnicodeCursor(self.cnx, self.cnx.rowclass) + + def cast(self, column, type): + return column + + def like(self): + if sqlite_version >= 30100: + return "LIKE %s ESCAPE '/'" + else: + return 'LIKE %s' + + def like_escape(self, text): + if sqlite_version >= 30100: + return _like_escape_re.sub(r'/\1', text) + else: + return text + + if have_pysqlite == 2: + def get_last_id(self, cursor, table, column='id'): + return cursor.lastrowid + else: + def get_last_id(self, cursor, table, column='id'): + return self.cnx.db.sqlite_last_insert_rowid() diff --git a/examples/trac/trac/db/tests/__init__.py b/examples/trac/trac/db/tests/__init__.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/db/tests/__init__.py @@ -0,0 +1,13 @@ +import unittest + +from trac.db.tests import api + +def suite(): + + suite = unittest.TestSuite() + suite.addTest(api.suite()) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') + diff --git a/examples/trac/trac/db/tests/api.py b/examples/trac/trac/db/tests/api.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/db/tests/api.py @@ -0,0 +1,57 @@ +from trac.db.api import _parse_db_str + +import unittest + + +class ParseConnectionStringTestCase(unittest.TestCase): + + def test_sqlite_relative(self): + # Default syntax for specifying DB path relative to the environment + # directory + self.assertEqual(('sqlite', {'path': 'db/trac.db'}), + _parse_db_str('sqlite:db/trac.db')) + + def test_sqlite_absolute(self): + # Standard syntax + self.assertEqual(('sqlite', {'path': '/var/db/trac.db'}), + _parse_db_str('sqlite:///var/db/trac.db')) + # Legacy syntax + self.assertEqual(('sqlite', {'path': '/var/db/trac.db'}), + _parse_db_str('sqlite:/var/db/trac.db')) + + def test_sqlite_with_timeout_param(self): + # In-memory database + self.assertEqual(('sqlite', {'path': 'db/trac.db', + 'params': {'timeout': '10000'}}), + _parse_db_str('sqlite:db/trac.db?timeout=10000')) + + def test_postgres_simple(self): + self.assertEqual(('postgres', {'host': 'localhost', 'path': '/trac'}), + _parse_db_str('postgres://localhost/trac')) + + def test_postgres_with_port(self): + self.assertEqual(('postgres', {'host': 'localhost', 'port': 9431, + 'path': '/trac'}), + _parse_db_str('postgres://localhost:9431/trac')) + + def test_postgres_with_creds(self): + self.assertEqual(('postgres', {'user': 'john', 'password': 'letmein', + 'host': 'localhost', 'port': 9431, + 'path': '/trac'}), + _parse_db_str('postgres://john:letmein@localhost:9431/trac')) + + def test_mysql_simple(self): + self.assertEqual(('mysql', {'host': 'localhost', 'path': '/trac'}), + _parse_db_str('mysql://localhost/trac')) + + def test_mysql_with_creds(self): + self.assertEqual(('mysql', {'user': 'john', 'password': 'letmein', + 'host': 'localhost', 'port': 3306, + 'path': '/trac'}), + _parse_db_str('mysql://john:letmein@localhost:3306/trac')) + +def suite(): + return unittest.makeSuite(ParseConnectionStringTestCase,'test') + +if __name__ == '__main__': + unittest.main() diff --git a/examples/trac/trac/db/util.py b/examples/trac/trac/db/util.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/db/util.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2006 Edgewall Software +# Copyright (C) 2005 Christopher Lenz +# Copyright (C) 2006 Matthew Good +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Christopher Lenz + +def sql_escape_percent(sql): + import re + return re.sub("'((?:[^']|(?:''))*)'", lambda m: m.group(0).replace('%', '%%'), sql) + + +class IterableCursor(object): + """Wrapper for DB-API cursor objects that makes the cursor iterable + and escapes all "%"s used inside literal strings with parameterized + queries. + + Iteration will generate the rows of a SELECT query one by one. + """ + __slots__ = ['cursor'] + + def __init__(self, cursor): + self.cursor = cursor + + def __getattr__(self, name): + return getattr(self.cursor, name) + + def __iter__(self): + while True: + row = self.cursor.fetchone() + if not row: + return + yield row + + def execute(self, sql, args=None): + if args: + return self.cursor.execute(sql_escape_percent(sql), args) + return self.cursor.execute(sql) + + def executemany(self, sql, args=None): + if args: + return self.cursor.executemany(sql_escape_percent(sql), args) + return self.cursor.executemany(sql) + + +class ConnectionWrapper(object): + """Generic wrapper around connection objects. + + This wrapper makes cursors produced by the connection iterable using + `IterableCursor`. + """ + __slots__ = ['cnx'] + + def __init__(self, cnx): + self.cnx = cnx + + def __getattr__(self, name): + if hasattr(self, 'cnx'): + return getattr(self.cnx, name) + return object.__getattr__(self, name) + + def cursor(self): + return IterableCursor(self.cnx.cursor()) diff --git a/examples/trac/trac/db_default.py b/examples/trac/trac/db_default.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/db_default.py @@ -0,0 +1,406 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2003-2006 Edgewall Software +# Copyright (C) 2003-2005 Daniel Lundin +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Daniel Lundin + +from trac.db import Table, Column, Index + +# Database version identifier. Used for automatic upgrades. +db_version = 19 + +def __mkreports(reports): + """Utility function used to create report data in same syntax as the + default data. This extra step is done to simplify editing the default + reports.""" + result = [] + for report in reports: + result.append((None, report[0], report[2], report[1])) + return result + + +## +## Database schema +## + +schema = [ + # Common + Table('system', key='name')[ + Column('name'), + Column('value')], + Table('permission', key=('username', 'action'))[ + Column('username'), + Column('action')], + Table('auth_cookie', key=('cookie', 'ipnr', 'name'))[ + Column('cookie'), + Column('name'), + Column('ipnr'), + Column('time', type='int')], + Table('session', key=('sid', 'authenticated'))[ + Column('sid'), + Column('authenticated', type='int'), + Column('last_visit', type='int'), + Index(['last_visit']), + Index(['authenticated'])], + Table('session_attribute', key=('sid', 'authenticated', 'name'))[ + Column('sid'), + Column('authenticated', type='int'), + Column('name'), + Column('value')], + + # Attachments + Table('attachment', key=('type', 'id', 'filename'))[ + Column('type'), + Column('id'), + Column('filename'), + Column('size', type='int'), + Column('time', type='int'), + Column('description'), + Column('author'), + Column('ipnr')], + + # Wiki system + Table('wiki', key=('name', 'version'))[ + Column('name'), + Column('version', type='int'), + Column('time', type='int'), + Column('author'), + Column('ipnr'), + Column('text'), + Column('comment'), + Column('readonly', type='int'), + Index(['time'])], + + # Version control cache + Table('revision', key='rev')[ + Column('rev'), + Column('time', type='int'), + Column('author'), + Column('message'), + Index(['time'])], + Table('node_change', key=('rev', 'path', 'change_type'))[ + Column('rev'), + Column('path'), + Column('node_type', size=1), + Column('change_type', size=1), + Column('base_path'), + Column('base_rev'), + Index(['rev'])], + + # Ticket system + Table('ticket', key='id')[ + Column('id', auto_increment=True), + Column('type'), + Column('time', type='int'), + Column('changetime', type='int'), + Column('component'), + Column('severity'), + Column('priority'), + Column('owner'), + Column('reporter'), + Column('cc'), + Column('version'), + Column('milestone'), + Column('status'), + Column('resolution'), + Column('summary'), + Column('description'), + Column('keywords'), + Index(['time']), + Index(['status'])], + Table('ticket_change', key=('ticket', 'time', 'field'))[ + Column('ticket', type='int'), + Column('time', type='int'), + Column('author'), + Column('field'), + Column('oldvalue'), + Column('newvalue'), + Index(['ticket']), + Index(['time'])], + Table('ticket_custom', key=('ticket', 'name'))[ + Column('ticket', type='int'), + Column('name'), + Column('value')], + Table('enum', key=('type', 'name'))[ + Column('type'), + Column('name'), + Column('value')], + Table('component', key='name')[ + Column('name'), + Column('owner'), + Column('description')], + Table('milestone', key='name')[ + Column('name'), + Column('due', type='int'), + Column('completed', type='int'), + Column('description')], + Table('version', key='name')[ + Column('name'), + Column('time', type='int'), + Column('description')], + + # Report system + Table('report', key='id')[ + Column('id', auto_increment=True), + Column('author'), + Column('title'), + Column('query'), + Column('description')], +] + + +## +## Default Reports +## + +reports = ( +('Active Tickets', +""" + * List all active tickets by priority. + * Color each row based on priority. + * If a ticket has been accepted, a '*' is appended after the owner's name +""", +""" +SELECT p.value AS __color__, + id AS ticket, summary, component, version, milestone, t.type AS type, + (CASE status WHEN 'assigned' THEN owner||' *' ELSE owner END) AS owner, + time AS created, + changetime AS _changetime, description AS _description, + reporter AS _reporter + FROM ticket t, enum p + WHERE status IN ('new', 'assigned', 'reopened') +AND p.name = t.priority AND p.type = 'priority' + ORDER BY p.value, milestone, t.type, time +"""), +#---------------------------------------------------------------------------- + ('Active Tickets by Version', +""" +This report shows how to color results by priority, +while grouping results by version. + +Last modification time, description and reporter are included as hidden fields +for useful RSS export. +""", +""" +SELECT p.value AS __color__, + version AS __group__, + id AS ticket, summary, component, version, t.type AS type, + (CASE status WHEN 'assigned' THEN owner||' *' ELSE owner END) AS owner, + time AS created, + changetime AS _changetime, description AS _description, + reporter AS _reporter + FROM ticket t, enum p + WHERE status IN ('new', 'assigned', 'reopened') +AND p.name = t.priority AND p.type = 'priority' + ORDER BY (version IS NULL),version, p.value, t.type, time +"""), +#---------------------------------------------------------------------------- +('All Tickets by Milestone', +""" +This report shows how to color results by priority, +while grouping results by milestone. + +Last modification time, description and reporter are included as hidden fields +for useful RSS export. +""", +""" +SELECT p.value AS __color__, + milestone||' Release' AS __group__, + id AS ticket, summary, component, version, t.type AS type, + (CASE status WHEN 'assigned' THEN owner||' *' ELSE owner END) AS owner, + time AS created, + changetime AS _changetime, description AS _description, + reporter AS _reporter + FROM ticket t, enum p + WHERE status IN ('new', 'assigned', 'reopened') +AND p.name = t.priority AND p.type = 'priority' + ORDER BY (milestone IS NULL),milestone, p.value, t.type, time +"""), +#---------------------------------------------------------------------------- +('Assigned, Active Tickets by Owner', +""" +List assigned tickets, group by ticket owner, sorted by priority. +""", +""" + +SELECT p.value AS __color__, + owner AS __group__, + id AS ticket, summary, component, milestone, t.type AS type, time AS created, + changetime AS _changetime, description AS _description, + reporter AS _reporter + FROM ticket t,enum p + WHERE status = 'assigned' +AND p.name=t.priority AND p.type='priority' + ORDER BY owner, p.value, t.type, time +"""), +#---------------------------------------------------------------------------- +('Assigned, Active Tickets by Owner (Full Description)', +""" +List tickets assigned, group by ticket owner. +This report demonstrates the use of full-row display. +""", +""" +SELECT p.value AS __color__, + owner AS __group__, + id AS ticket, summary, component, milestone, t.type AS type, time AS created, + description AS _description_, + changetime AS _changetime, reporter AS _reporter + FROM ticket t, enum p + WHERE status = 'assigned' +AND p.name = t.priority AND p.type = 'priority' + ORDER BY owner, p.value, t.type, time +"""), +#---------------------------------------------------------------------------- +('All Tickets By Milestone (Including closed)', +""" +A more complex example to show how to make advanced reports. +""", +""" +SELECT p.value AS __color__, + t.milestone AS __group__, + (CASE status + WHEN 'closed' THEN 'color: #777; background: #ddd; border-color: #ccc;' + ELSE + (CASE owner WHEN '$USER' THEN 'font-weight: bold' END) + END) AS __style__, + id AS ticket, summary, component, status, + resolution,version, t.type AS type, priority, owner, + changetime AS modified, + time AS _time,reporter AS _reporter + FROM ticket t,enum p + WHERE p.name=t.priority AND p.type='priority' + ORDER BY (milestone IS NULL), milestone DESC, (status = 'closed'), + (CASE status WHEN 'closed' THEN modified ELSE (-1)*p.value END) DESC +"""), +#---------------------------------------------------------------------------- +('My Tickets', +""" +This report demonstrates the use of the automatically set +USER dynamic variable, replaced with the username of the +logged in user when executed. +""", +""" +SELECT p.value AS __color__, + (CASE status WHEN 'assigned' THEN 'Assigned' ELSE 'Owned' END) AS __group__, + id AS ticket, summary, component, version, milestone, + t.type AS type, priority, time AS created, + changetime AS _changetime, description AS _description, + reporter AS _reporter + FROM ticket t, enum p + WHERE t.status IN ('new', 'assigned', 'reopened') +AND p.name = t.priority AND p.type = 'priority' AND owner = '$USER' + ORDER BY (status = 'assigned') DESC, p.value, milestone, t.type, time +"""), +#---------------------------------------------------------------------------- +('Active Tickets, Mine first', +""" + * List all active tickets by priority. + * Show all tickets owned by the logged in user in a group first. +""", +""" +SELECT p.value AS __color__, + (CASE owner + WHEN '$USER' THEN 'My Tickets' + ELSE 'Active Tickets' + END) AS __group__, + id AS ticket, summary, component, version, milestone, t.type AS type, + (CASE status WHEN 'assigned' THEN owner||' *' ELSE owner END) AS owner, + time AS created, + changetime AS _changetime, description AS _description, + reporter AS _reporter + FROM ticket t, enum p + WHERE status IN ('new', 'assigned', 'reopened') +AND p.name = t.priority AND p.type = 'priority' + ORDER BY (owner = '$USER') DESC, p.value, milestone, t.type, time +""")) + + +## +## Default database values +## + +# (table, (column1, column2), ((row1col1, row1col2), (row2col1, row2col2))) +data = (('component', + ('name', 'owner'), + (('component1', 'somebody'), + ('component2', 'somebody'))), + ('milestone', + ('name', 'due', 'completed'), + (('milestone1', 0, 0), + ('milestone2', 0, 0), + ('milestone3', 0, 0), + ('milestone4', 0, 0))), + ('version', + ('name', 'time'), + (('1.0', 0), + ('2.0', 0))), + ('enum', + ('type', 'name', 'value'), + (('status', 'new', 1), + ('status', 'assigned', 2), + ('status', 'reopened', 3), + ('status', 'closed', 4), + ('resolution', 'fixed', 1), + ('resolution', 'invalid', 2), + ('resolution', 'wontfix', 3), + ('resolution', 'duplicate', 4), + ('resolution', 'worksforme', 5), + ('priority', 'blocker', 1), + ('priority', 'critical', 2), + ('priority', 'major', 3), + ('priority', 'minor', 4), + ('priority', 'trivial', 5), + ('ticket_type', 'defect', 1), + ('ticket_type', 'enhancement', 2), + ('ticket_type', 'task', 3))), + ('permission', + ('username', 'action'), + (('anonymous', 'LOG_VIEW'), + ('anonymous', 'FILE_VIEW'), + ('anonymous', 'WIKI_VIEW'), + ('anonymous', 'WIKI_CREATE'), + ('anonymous', 'WIKI_MODIFY'), + ('anonymous', 'SEARCH_VIEW'), + ('anonymous', 'REPORT_VIEW'), + ('anonymous', 'REPORT_SQL_VIEW'), + ('anonymous', 'TICKET_VIEW'), + ('anonymous', 'TICKET_CREATE'), + ('anonymous', 'TICKET_MODIFY'), + ('anonymous', 'BROWSER_VIEW'), + ('anonymous', 'TIMELINE_VIEW'), + ('anonymous', 'CHANGESET_VIEW'), + ('anonymous', 'ROADMAP_VIEW'), + ('anonymous', 'MILESTONE_VIEW'))), + ('system', + ('name', 'value'), + (('database_version', str(db_version)),)), + ('report', + ('author', 'title', 'query', 'description'), + __mkreports(reports))) + + +default_components = ('trac.About', 'trac.attachment', + 'trac.db.mysql_backend', 'trac.db.postgres_backend', + 'trac.db.sqlite_backend', + 'trac.mimeview.enscript', 'trac.mimeview.patch', + 'trac.mimeview.php', 'trac.mimeview.rst', + 'trac.mimeview.silvercity', 'trac.mimeview.txtl', + 'trac.scripts.admin', + 'trac.Search', 'trac.Settings', + 'trac.ticket.query', 'trac.ticket.report', + 'trac.ticket.roadmap', 'trac.ticket.web_ui', + 'trac.Timeline', + 'trac.versioncontrol.web_ui', + 'trac.versioncontrol.svn_fs', + 'trac.wiki.macros', 'trac.wiki.web_ui', + 'trac.web.auth') diff --git a/examples/trac/trac/env.py b/examples/trac/trac/env.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/env.py @@ -0,0 +1,435 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2003-2005 Edgewall Software +# Copyright (C) 2003-2005 Jonas Borgström +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Jonas Borgström + +import os + +from trac import db_default, util +from trac.config import * +from trac.core import Component, ComponentManager, implements, Interface, \ + ExtensionPoint, TracError +from trac.db import DatabaseManager +from trac.versioncontrol import RepositoryManager + +__all__ = ['Environment', 'IEnvironmentSetupParticipant', 'open_environment'] + + +class IEnvironmentSetupParticipant(Interface): + """Extension point interface for components that need to participate in the + creation and upgrading of Trac environments, for example to create + additional database tables.""" + + def environment_created(): + """Called when a new Trac environment is created.""" + + def environment_needs_upgrade(db): + """Called when Trac checks whether the environment needs to be upgraded. + + Should return `True` if this participant needs an upgrade to be + performed, `False` otherwise. + """ + + def upgrade_environment(db): + """Actually perform an environment upgrade. + + Implementations of this method should not commit any database + transactions. This is done implicitly after all participants have + performed the upgrades they need without an error being raised. + """ + + +class Environment(Component, ComponentManager): + """Trac stores project information in a Trac environment. + + A Trac environment consists of a directory structure containing among other + things: + * a configuration file. + * an SQLite database (stores tickets, wiki pages...) + * Project specific templates and wiki macros. + * wiki and ticket attachments. + """ + setup_participants = ExtensionPoint(IEnvironmentSetupParticipant) + + base_url = Option('trac', 'base_url', '', + """Base URL of the Trac deployment. + + In most configurations, Trac will automatically reconstruct the URL + that is used to access it automatically. However, in more complex + setups, usually involving running Trac behind a HTTP proxy, you may + need to use this option to force Trac to use the correct URL.""") + + project_name = Option('project', 'name', 'My Project', + """Name of the project.""") + + project_description = Option('project', 'descr', 'My example project', + """Short description of the project.""") + + project_url = Option('project', 'url', 'http://example.org/', + """URL of the main project web site.""") + + project_footer = Option('project', 'footer', + 'Visit the Trac open source project at
' + '' + 'http://trac.edgewall.com/', + """Page footer text (right-aligned).""") + + project_icon = Option('project', 'icon', 'common/trac.ico', + """URL of the icon of the project.""") + + log_type = Option('logging', 'log_type', 'none', + """Logging facility to use. + + Should be one of (`none`, `file`, `stderr`, `syslog`, `winlog`).""") + + log_file = Option('logging', 'log_file', 'trac.log', + """If `log_type` is `file`, this should be a path to the log-file.""") + + log_level = Option('logging', 'log_level', 'DEBUG', + """Level of verbosity in log. + + Should be one of (`CRITICAL`, `ERROR`, `WARN`, `INFO`, `DEBUG`).""") + + def __init__(self, path, create=False, options=[]): + """Initialize the Trac environment. + + @param path: the absolute path to the Trac environment + @param create: if `True`, the environment is created and populated with + default data; otherwise, the environment is expected to + already exist. + @param options: A list of `(section, name, value)` tuples that define + configuration options + """ + ComponentManager.__init__(self) + + self.path = path + self.setup_config(load_defaults=create) + self.setup_log() + + from trac.loader import load_components + load_components(self) + + if create: + self.create(options) + else: + self.verify() + + if create: + for setup_participant in self.setup_participants: + setup_participant.environment_created() + + def component_activated(self, component): + """Initialize additional member variables for components. + + Every component activated through the `Environment` object gets three + member variables: `env` (the environment object), `config` (the + environment configuration) and `log` (a logger object).""" + component.env = self + component.config = self.config + component.log = self.log + + def is_component_enabled(self, cls): + """Implemented to only allow activation of components that are not + disabled in the configuration. + + This is called by the `ComponentManager` base class when a component is + about to be activated. If this method returns false, the component does + not get activated.""" + if not isinstance(cls, basestring): + component_name = (cls.__module__ + '.' + cls.__name__).lower() + else: + component_name = cls.lower() + + rules = [(name.lower(), value.lower() in ('enabled', 'on')) + for name, value in self.config.options('components')] + rules.sort(lambda a, b: -cmp(len(a[0]), len(b[0]))) + + for pattern, enabled in rules: + if component_name == pattern or pattern.endswith('*') \ + and component_name.startswith(pattern[:-1]): + return enabled + + # versioncontrol components are enabled if the repository is configured + # FIXME: this shouldn't be hardcoded like this + if component_name.startswith('trac.versioncontrol.'): + return self.config.get('trac', 'repository_dir') != '' + + # By default, all components in the trac package are enabled + return component_name.startswith('trac.') + + def verify(self): + """Verify that the provided path points to a valid Trac environment + directory.""" + fd = open(os.path.join(self.path, 'VERSION'), 'r') + try: + assert fd.read(26) == 'Trac Environment Version 1' + finally: + fd.close() + + def get_db_cnx(self): + """Return a database connection from the connection pool.""" + return DatabaseManager(self).get_connection() + + def shutdown(self): + """Close the environment.""" + DatabaseManager(self).shutdown() + + def get_repository(self, authname=None): + """Return the version control repository configured for this + environment. + + @param authname: user name for authorization + """ + return RepositoryManager(self).get_repository(authname) + + def create(self, options=[]): + """Create the basic directory structure of the environment, initialize + the database and populate the configuration file with default values.""" + def _create_file(fname, data=None): + fd = open(fname, 'w') + if data: fd.write(data) + fd.close() + + # Create the directory structure + if not os.path.exists(self.path): + os.mkdir(self.path) + os.mkdir(self.get_log_dir()) + os.mkdir(self.get_htdocs_dir()) + os.mkdir(os.path.join(self.path, 'plugins')) + os.mkdir(os.path.join(self.path, 'wiki-macros')) + + # Create a few files + _create_file(os.path.join(self.path, 'VERSION'), + 'Trac Environment Version 1\n') + _create_file(os.path.join(self.path, 'README'), + 'This directory contains a Trac environment.\n' + 'Visit http://trac.edgewall.com/ for more information.\n') + + # Setup the default configuration + os.mkdir(os.path.join(self.path, 'conf')) + _create_file(os.path.join(self.path, 'conf', 'trac.ini')) + self.setup_config(load_defaults=True) + for section, name, value in options: + self.config.set(section, name, value) + self.config.save() + + # Create the database + DatabaseManager(self).init_db() + + def get_version(self, db=None): + """Return the current version of the database.""" + if not db: + db = self.get_db_cnx() + cursor = db.cursor() + cursor.execute("SELECT value FROM system WHERE name='database_version'") + row = cursor.fetchone() + return row and int(row[0]) + + def setup_config(self, load_defaults=False): + """Load the configuration file.""" + self.config = Configuration(os.path.join(self.path, 'conf', 'trac.ini')) + if load_defaults: + for section, default_options in self.config.defaults().iteritems(): + for name, value in default_options.iteritems(): + self.config.set(section, name, value) + + def get_templates_dir(self): + """Return absolute path to the templates directory.""" + return os.path.join(self.path, 'templates') + + def get_htdocs_dir(self): + """Return absolute path to the htdocs directory.""" + return os.path.join(self.path, 'htdocs') + + def get_log_dir(self): + """Return absolute path to the log directory.""" + return os.path.join(self.path, 'log') + + def setup_log(self): + """Initialize the logging sub-system.""" + from trac.log import logger_factory + logtype = self.log_type + logfile = self.log_file + if logtype == 'file' and not os.path.isabs(logfile): + logfile = os.path.join(self.get_log_dir(), logfile) + self.log = logger_factory(logtype, logfile, self.log_level, self.path) + + def get_known_users(self, cnx=None): + """Generator that yields information about all known users, i.e. users + that have logged in to this Trac environment and possibly set their name + and email. + + This function generates one tuple for every user, of the form + (username, name, email) ordered alpha-numerically by username. + + @param cnx: the database connection; if ommitted, a new connection is + retrieved + """ + if not cnx: + cnx = self.get_db_cnx() + cursor = cnx.cursor() + cursor.execute("SELECT DISTINCT s.sid, n.value, e.value " + "FROM session AS s " + " LEFT JOIN session_attribute AS n ON (n.sid=s.sid " + " and n.authenticated=1 AND n.name = 'name') " + " LEFT JOIN session_attribute AS e ON (e.sid=s.sid " + " AND e.authenticated=1 AND e.name = 'email') " + "WHERE s.authenticated=1 ORDER BY s.sid") + for username,name,email in cursor: + yield username, name, email + + def backup(self, dest=None): + """Simple SQLite-specific backup of the database. + + @param dest: Destination file; if not specified, the backup is stored in + a file called db_name.trac_version.bak + """ + import shutil + + db_str = self.config.get('trac', 'database') + if not db_str.startswith('sqlite:'): + raise EnvironmentError, 'Can only backup sqlite databases' + db_name = os.path.join(self.path, db_str[7:]) + if not dest: + dest = '%s.%i.bak' % (db_name, self.get_version()) + shutil.copy (db_name, dest) + + def needs_upgrade(self): + """Return whether the environment needs to be upgraded.""" + db = self.get_db_cnx() + for participant in self.setup_participants: + if participant.environment_needs_upgrade(db): + self.log.warning('Component %s requires environment upgrade', + participant) + return True + return False + + def upgrade(self, backup=False, backup_dest=None): + """Upgrade database. + + Each db version should have its own upgrade module, names + upgrades/dbN.py, where 'N' is the version number (int). + + @param backup: whether or not to backup before upgrading + @param backup_dest: name of the backup file + @return: whether the upgrade was performed + """ + db = self.get_db_cnx() + + upgraders = [] + for participant in self.setup_participants: + if participant.environment_needs_upgrade(db): + upgraders.append(participant) + if not upgraders: + return False + + if backup: + self.backup(backup_dest) + for participant in upgraders: + participant.upgrade_environment(db) + db.commit() + + # Database schema may have changed, so close all connections + self.shutdown() + + return True + + +class EnvironmentSetup(Component): + implements(IEnvironmentSetupParticipant) + + # IEnvironmentSetupParticipant methods + + def environment_created(self): + """Insert default data into the database.""" + db = self.env.get_db_cnx() + cursor = db.cursor() + for table, cols, vals in db_default.data: + cursor.executemany("INSERT INTO %s (%s) VALUES (%s)" % (table, + ','.join(cols), ','.join(['%s' for c in cols])), + vals) + db.commit() + self._update_sample_config() + + def environment_needs_upgrade(self, db): + dbver = self.env.get_version(db) + if dbver == db_default.db_version: + return False + elif dbver > db_default.db_version: + raise TracError, 'Database newer than Trac version' + return True + + def upgrade_environment(self, db): + cursor = db.cursor() + dbver = self.env.get_version() + for i in range(dbver + 1, db_default.db_version + 1): + name = 'db%i' % i + try: + upgrades = __import__('upgrades', globals(), locals(), [name]) + script = getattr(upgrades, name) + except AttributeError: + err = 'No upgrade module for version %i (%s.py)' % (i, name) + raise TracError, err + script.do_upgrade(self.env, i, cursor) + cursor.execute("UPDATE system SET value=%s WHERE " + "name='database_version'", (db_default.db_version,)) + self.log.info('Upgraded database version from %d to %d', + dbver, db_default.db_version) + self._update_sample_config() + + # Internal methods + + def _update_sample_config(self): + from ConfigParser import ConfigParser + config = ConfigParser() + for section, options in self.config.defaults().items(): + config.add_section(section) + for name, value in options.items(): + config.set(section, name, value) + filename = os.path.join(self.env.path, 'conf', 'trac.ini.sample') + try: + fileobj = file(filename, 'w') + try: + config.write(fileobj) + fileobj.close() + finally: + fileobj.close() + self.log.info('Wrote sample configuration file with the new ' + 'settings and their default values: %s', + filename) + except IOError, e: + self.log.warn('Couldn\'t write sample configuration file (%s)', e, + exc_info=True) + + +def open_environment(env_path=None): + """Open an existing environment object, and verify that the database is up + to date. + + @param: env_path absolute path to the environment directory; if ommitted, + the value of the `TRAC_ENV` environment variable is used + @return: the `Environment` object + """ + if not env_path: + env_path = os.getenv('TRAC_ENV') + if not env_path: + raise TracError, 'Missing environment variable "TRAC_ENV". Trac ' \ + 'requires this variable to point to a valid Trac ' \ + 'environment.' + + env = Environment(env_path) + if env.needs_upgrade(): + raise TracError, 'The Trac Environment needs to be upgraded. Run ' \ + 'trac-admin %s upgrade"' % env_path + return env diff --git a/examples/trac/trac/loader.py b/examples/trac/trac/loader.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/loader.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005-2006 Edgewall Software +# Copyright (C) 2005-2006 Christopher Lenz +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Christopher Lenz + +from glob import glob +import imp +import os +import sys +from trac.config import default_dir +try: + set +except NameError: + from sets import Set as set + +try: + import pkg_resources +except ImportError: + pkg_resources = None + +__all__ = ['load_components'] + +def load_components(env): + loaded_components = [] + plugins_dirs = [os.path.normcase(os.path.realpath(os.path.join(env.path, + 'plugins'))), + default_dir('plugins')] + + # First look for Python source files in the plugins directories, which + # simply get imported, thereby registering them with the component manager + # if they define any components. + for plugins_dir in plugins_dirs: + auto_enable = plugins_dir != default_dir('plugins') + plugin_files = glob(os.path.join(plugins_dir, '*.py')) + for plugin_file in plugin_files: + try: + plugin_name = os.path.basename(plugin_file[:-3]) + if plugin_name not in loaded_components: + env.log.debug('Loading file plugin %s from %s' % (plugin_name, + plugin_file)) + module = imp.load_source(plugin_name, plugin_file) + loaded_components.append(plugin_name) + if auto_enable and plugin_name + '.*' \ + not in env.config['components']: + env.config['components'].set(plugin_name + '.*', 'enabled') + except Exception, e: + env.log.error('Failed to load plugin from %s', plugin_file, + exc_info=True) + + # If setuptools is installed try to load any eggs from the plugins + # directory, and also plugins available on sys.path + if pkg_resources is not None: + ws = pkg_resources.working_set + for plugins_dir in plugins_dirs: + ws.add_entry(plugins_dir) + pkg_env = pkg_resources.Environment(plugins_dirs + sys.path) + + memo = set() + def flatten(dists): + for dist in dists: + if dist in memo: + continue + memo.add(dist) + try: + predecessors = ws.resolve([dist.as_requirement()]) + for predecessor in flatten(predecessors): + yield predecessor + yield dist + except pkg_resources.DistributionNotFound, e: + env.log.error('Skipping "%s" ("%s" not found)', dist, e) + except pkg_resources.VersionConflict, e: + env.log.error('Skipping "%s" (version conflict: "%s")', + dist, e) + + for egg in flatten([pkg_env[name][0] for name in pkg_env]): + modules = [] + + for name in egg.get_entry_map('trac.plugins'): + # Load plugins declared via the `trac.plugins` entry point. + # This is the only supported option going forward, the + # others will be dropped at some point in the future. + env.log.debug('Loading egg plugin %s from %s', name, + egg.location) + egg.activate() + try: + entry_point = egg.get_entry_info('trac.plugins', name) + if entry_point.module_name not in loaded_components: + try: + entry_point.load() + except pkg_resources.DistributionNotFound, e: + env.log.warning('Cannot load plugin %s because it ' + 'requires "%s"', name, e) + modules.append(entry_point.module_name) + loaded_components.append(entry_point.module_name) + except ImportError, e: + env.log.error('Failed to load plugin %s from %s', name, + egg.location, exc_info=True) + + else: + # Support for pre-entry-point plugins + if egg.has_metadata('trac_plugin.txt'): + env.log.debug('Loading plugin %s from %s', name, + egg.location) + egg.activate() + for modname in egg.get_metadata_lines('trac_plugin.txt'): + module = None + if modname not in loaded_components: + try: + module = __import__(modname) + loaded_components.append(modname) + modules.append(modname) + except ImportError, e: + env.log.error('Component module %s not found', + modname, exc_info=True) + + if modules: + # Automatically enable any components provided by plugins + # loaded from the environment plugins directory. + if os.path.dirname(egg.location) == plugins_dirs[0]: + for module in modules: + if module + '.*' not in env.config['components']: + env.config['components'].set(module + '.*', + 'enabled') + + # Load default components + from trac.db_default import default_components + for module in default_components: + if not module in loaded_components: + __import__(module) diff --git a/examples/trac/trac/log.py b/examples/trac/trac/log.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/log.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2003-2006 Edgewall Software +# Copyright (C) 2003-2005 Daniel Lundin +# Copyright (C) 2006 Christian Boos +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Daniel Lundin + +import logging +import logging.handlers +import sys + +def logger_factory(logtype='syslog', logfile=None, level='WARNING', + logid='Trac'): + logger = logging.getLogger(logid) + logtype = logtype.lower() + if logtype == 'file': + hdlr = logging.FileHandler(logfile) + elif logtype in ['winlog', 'eventlog', 'nteventlog']: + # Requires win32 extensions + hdlr = logging.handlers.NTEventLogHandler(logid, + logtype='Application') + elif logtype in ['syslog', 'unix']: + hdlr = logging.handlers.SysLogHandler('/dev/log') + elif logtype in ['stderr']: + hdlr = logging.StreamHandler(sys.stderr) + else: + hdlr = logging.handlers.BufferingHandler(0) + # Note: this _really_ throws away log events, as a `MemoryHandler` + # would keep _all_ records in case there's no target handler (a bug?) + + format = 'Trac[%(module)s] %(levelname)s: %(message)s' + if logtype in ['file', 'stderr']: + format = '%(asctime)s ' + format + datefmt = '' + if logtype == 'stderr': + datefmt = '%X' + level = level.upper() + if level in ['DEBUG', 'ALL']: + logger.setLevel(logging.DEBUG) + elif level == 'INFO': + logger.setLevel(logging.INFO) + elif level == 'ERROR': + logger.setLevel(logging.ERROR) + elif level == 'CRITICAL': + logger.setLevel(logging.CRITICAL) + else: + logger.setLevel(logging.WARNING) + formatter = logging.Formatter(format,datefmt) + hdlr.setFormatter(formatter) + logger.addHandler(hdlr) + + return logger diff --git a/examples/trac/trac/mimeview/__init__.py b/examples/trac/trac/mimeview/__init__.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/mimeview/__init__.py @@ -0,0 +1,1 @@ +from trac.mimeview.api import * diff --git a/examples/trac/trac/mimeview/api.py b/examples/trac/trac/mimeview/api.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/mimeview/api.py @@ -0,0 +1,668 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2004-2006 Edgewall Software +# Copyright (C) 2004 Daniel Lundin +# Copyright (C) 2005-2006 Christopher Lenz +# Copyright (C) 2006 Christian Boos +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Daniel Lundin +# Christopher Lenz +# Christian Boos + +""" +The `trac.mimeview` module centralize the intelligence related to +file metadata, principally concerning the `type` (MIME type) of the content +and, if relevant, concerning the text encoding (charset) used by the content. + +There are primarily two approaches for getting the MIME type of a given file: + * taking advantage of existing conventions for the file name + * examining the file content and applying various heuristics + +The module also knows how to convert the file content from one type +to another type. + +In some cases, only the `url` pointing to the file's content is actually +needed, that's why we avoid to read the file's content when it's not needed. + +The actual `content` to be converted might be a `unicode` object, +but it can also be the raw byte string (`str`) object, or simply +an object that can be `read()`. +""" + +import re +from StringIO import StringIO + +from trac.config import IntOption, ListOption, Option +from trac.core import * +from trac.util import sorted +from trac.util.text import to_utf8, to_unicode +from trac.util.markup import escape, Markup, Fragment, html + + +__all__ = ['get_mimetype', 'is_binary', 'detect_unicode', 'Mimeview', + 'content_to_unicode'] + + +# Some common MIME types and their associated keywords and/or file extensions + +KNOWN_MIME_TYPES = { + 'application/pdf': ['pdf'], + 'application/postscript': ['ps'], + 'application/rtf': ['rtf'], + 'application/x-sh': ['sh'], + 'application/x-csh': ['csh'], + 'application/x-troff': ['nroff', 'roff', 'troff'], + + 'image/x-icon': ['ico'], + 'image/svg+xml': ['svg'], + + 'model/vrml': ['vrml', 'wrl'], + + 'text/css': ['css'], + 'text/html': ['html'], + 'text/plain': ['txt', 'TXT', 'text', 'README', 'INSTALL', + 'AUTHORS', 'COPYING', 'ChangeLog', 'RELEASE'], + 'text/xml': ['xml'], + 'text/xsl': ['xsl'], + 'text/x-csrc': ['c', 'xs'], + 'text/x-chdr': ['h'], + 'text/x-c++src': ['cc', 'CC', 'cpp', 'C'], + 'text/x-c++hdr': ['hh', 'HH', 'hpp', 'H'], + 'text/x-diff': ['diff', 'patch'], + 'text/x-eiffel': ['e'], + 'text/x-elisp': ['el'], + 'text/x-fortran': ['f'], + 'text/x-haskell': ['hs'], + 'text/x-javascript': ['js'], + 'text/x-objc': ['m', 'mm'], + 'text/x-makefile': ['make', 'mk', + 'Makefile', 'makefile', 'GNUMakefile'], + 'text/x-pascal': ['pas'], + 'text/x-perl': ['pl', 'pm', 'PL', 'perl'], + 'text/x-php': ['php', 'php3', 'php4'], + 'text/x-python': ['py', 'python'], + 'text/x-pyrex': ['pyx'], + 'text/x-ruby': ['rb', 'ruby'], + 'text/x-scheme': ['scm'], + 'text/x-textile': ['txtl', 'textile'], + 'text/x-vba': ['vb', 'vba', 'bas'], + 'text/x-verilog': ['v', 'verilog'], + 'text/x-vhdl': ['vhd'], +} + +# extend the above with simple (text/x-: ) mappings + +for x in ['ada', 'asm', 'asp', 'awk', 'idl', 'inf', 'java', 'ksh', 'lua', + 'm4', 'mail', 'psp', 'rfc', 'rst', 'sql', 'tcl', 'tex', 'zsh']: + KNOWN_MIME_TYPES.setdefault('text/x-%s' % x, []).append(x) + + +# Default mapping from keywords/extensions to known MIME types: + +MIME_MAP = {} +for t, exts in KNOWN_MIME_TYPES.items(): + MIME_MAP[t] = t + for e in exts: + MIME_MAP[e] = t + +# Simple builtin autodetection from the content using a regexp +MODE_RE = re.compile( + r"#!(?:[/\w.-_]+/)?(\w+)|" # look for shebang + r"-\*-\s*(?:mode:\s*)?([\w+-]+)\s*-\*-|" # look for Emacs' -*- mode -*- + r"vim:.*?syntax=(\w+)" # look for VIM's syntax= + ) + +def get_mimetype(filename, content=None, mime_map=MIME_MAP): + """Guess the most probable MIME type of a file with the given name. + + `filename` is either a filename (the lookup will then use the suffix) + or some arbitrary keyword. + + `content` is either a `str` or an `unicode` string. + """ + suffix = filename.split('.')[-1] + if suffix in mime_map: + # 1) mimetype from the suffix, using the `mime_map` + return mime_map[suffix] + else: + mimetype = None + try: + import mimetypes + # 2) mimetype from the suffix, using the `mimetypes` module + mimetype = mimetypes.guess_type(filename)[0] + except: + pass + if not mimetype and content: + match = re.search(MODE_RE, content[:1000]) + if match: + mode = match.group(1) or match.group(3) or \ + match.group(2).lower() + if mode in mime_map: + # 3) mimetype from the content, using the `MODE_RE` + return mime_map[mode] + else: + if is_binary(content): + # 4) mimetype from the content, using`is_binary` + return 'application/octet-stream' + return mimetype + +def is_binary(data): + """Detect binary content by checking the first thousand bytes for zeroes. + + Operate on either `str` or `unicode` strings. + """ + if isinstance(data, str) and detect_unicode(data): + return False + return '\0' in data[:1000] + +def detect_unicode(data): + """Detect different unicode charsets by looking for BOMs (Byte Order Marks). + + Operate obviously only on `str` objects. + """ + if data.startswith('\xff\xfe'): + return 'utf-16-le' + elif data.startswith('\xfe\xff'): + return 'utf-16-be' + elif data.startswith('\xef\xbb\xbf'): + return 'utf-8' + else: + return None + +def content_to_unicode(env, content, mimetype): + """Retrieve an `unicode` object from a `content` to be previewed""" + mimeview = Mimeview(env) + if hasattr(content, 'read'): + content = content.read(mimeview.max_preview_size) + return mimeview.to_unicode(content, mimetype) + + +class IHTMLPreviewRenderer(Interface): + """Extension point interface for components that add HTML renderers of + specific content types to the `Mimeview` component. + + (Deprecated) + """ + + # implementing classes should set this property to True if they + # support text content where Trac should expand tabs into spaces + expand_tabs = False + + def get_quality_ratio(mimetype): + """Return the level of support this renderer provides for the `content` + of the specified MIME type. The return value must be a number between + 0 and 9, where 0 means no support and 9 means "perfect" support. + """ + + def render(req, mimetype, content, filename=None, url=None): + """Render an XHTML preview of the raw `content`. + + The `content` might be: + * a `str` object + * an `unicode` string + * any object with a `read` method, returning one of the above + + It is assumed that the content will correspond to the given `mimetype`. + + Besides the `content` value, the same content may eventually + be available through the `filename` or `url` parameters. + This is useful for renderers that embed objects, using or + instead of including the content inline. + + Can return the generated XHTML text as a single string or as an + iterable that yields strings. In the latter case, the list will + be considered to correspond to lines of text in the original content. + """ + +class IHTMLPreviewAnnotator(Interface): + """Extension point interface for components that can annotate an XHTML + representation of file contents with additional information.""" + + def get_annotation_type(): + """Return a (type, label, description) tuple that defines the type of + annotation and provides human readable names. The `type` element should + be unique to the annotator. The `label` element is used as column + heading for the table, while `description` is used as a display name to + let the user toggle the appearance of the annotation type. + """ + + def annotate_line(number, content): + """Return the XHTML markup for the table cell that contains the + annotation data.""" + + +class IContentConverter(Interface): + """An extension point interface for generic MIME based content + conversion.""" + + def get_supported_conversions(): + """Return an iterable of tuples in the form (key, name, extension, + in_mimetype, out_mimetype, quality) representing the MIME conversions + supported and + the quality ratio of the conversion in the range 0 to 9, where 0 means + no support and 9 means "perfect" support. eg. ('latex', 'LaTeX', 'tex', + 'text/x-trac-wiki', 'text/plain', 8)""" + + def convert_content(req, mimetype, content, key): + """Convert the given content from mimetype to the output MIME type + represented by key. Returns a tuple in the form (content, + output_mime_type) or None if conversion is not possible.""" + + +class Mimeview(Component): + """A generic class to prettify data, typically source code.""" + + renderers = ExtensionPoint(IHTMLPreviewRenderer) + annotators = ExtensionPoint(IHTMLPreviewAnnotator) + converters = ExtensionPoint(IContentConverter) + + default_charset = Option('trac', 'default_charset', 'iso-8859-15', + """Charset to be used when in doubt.""") + + tab_width = IntOption('mimeviewer', 'tab_width', 8, + """Displayed tab width in file preview (''since 0.9'').""") + + max_preview_size = IntOption('mimeviewer', 'max_preview_size', 262144, + """Maximum file size for HTML preview. (''since 0.9'').""") + + mime_map = ListOption('mimeviewer', 'mime_map', + 'text/x-dylan:dylan,text/x-idl:ice,text/x-ada:ads:adb', + """List of additional MIME types and keyword mappings. + Mappings are comma-separated, and for each MIME type, + there's a colon (":") separated list of associated keywords + or file extensions. (''since 0.10'').""") + + def __init__(self): + self._mime_map = None + + # Public API + + def get_supported_conversions(self, mimetype): + """Return a list of target MIME types in same form as + `IContentConverter.get_supported_conversions()`, but with the converter + component appended. Output is ordered from best to worst quality.""" + converters = [] + for converter in self.converters: + for k, n, e, im, om, q in converter.get_supported_conversions(): + if im == mimetype and q > 0: + converters.append((k, n, e, im, om, q, converter)) + converters = sorted(converters, key=lambda i: i[-1], reverse=True) + return converters + + def convert_content(self, req, mimetype, content, key, filename=None, + url=None): + """Convert the given content to the target MIME type represented by + `key`, which can be either a MIME type or a key. Returns a tuple of + (content, output_mime_type, extension).""" + if not content: + return ('', 'text/plain;charset=utf-8') + + # Ensure we have a MIME type for this content + full_mimetype = mimetype + if not full_mimetype: + if hasattr(content, 'read'): + content = content.read(self.max_preview_size) + full_mimetype = self.get_mimetype(filename, content) + if full_mimetype: + mimetype = full_mimetype.split(';')[0].strip() # split off charset + else: + mimetype = full_mimetype = 'text/plain' # fallback if not binary + + # Choose best converter + candidates = list(self.get_supported_conversions(mimetype)) + candidates = [c for c in candidates if key in (c[0], c[4])] + if not candidates: + raise TracError('No available MIME conversions from %s to %s' % + (mimetype, key)) + + # First successful conversion wins + for ck, name, ext, input_mimettype, output_mimetype, quality, \ + converter in candidates: + output = converter.convert_content(req, mimetype, content, ck) + if not output: + continue + return (output[0], output[1], ext) + raise TracError('No available MIME conversions from %s to %s' % + (mimetype, key)) + + def get_annotation_types(self): + """Generator that returns all available annotation types.""" + for annotator in self.annotators: + yield annotator.get_annotation_type() + + def render(self, req, mimetype, content, filename=None, url=None, + annotations=None): + """Render an XHTML preview of the given `content`. + + `content` is the same as an `IHTMLPreviewRenderer.render`'s + `content` argument. + + The specified `mimetype` will be used to select the most appropriate + `IHTMLPreviewRenderer` implementation available for this MIME type. + If not given, the MIME type will be infered from the filename or the + content. + + Return a string containing the XHTML text. + """ + if not content: + return '' + + # Ensure we have a MIME type for this content + full_mimetype = mimetype + if not full_mimetype: + if hasattr(content, 'read'): + content = content.read(self.max_preview_size) + full_mimetype = self.get_mimetype(filename, content) + if full_mimetype: + mimetype = full_mimetype.split(';')[0].strip() # split off charset + else: + mimetype = full_mimetype = 'text/plain' # fallback if not binary + + # Determine candidate `IHTMLPreviewRenderer`s + candidates = [] + for renderer in self.renderers: + qr = renderer.get_quality_ratio(mimetype) + if qr > 0: + candidates.append((qr, renderer)) + candidates.sort(lambda x,y: cmp(y[0], x[0])) + + # First candidate which renders successfully wins. + # Also, we don't want to expand tabs more than once. + expanded_content = None + for qr, renderer in candidates: + try: + self.log.debug('Trying to render HTML preview using %s' + % renderer.__class__.__name__) + # check if we need to perform a tab expansion + rendered_content = content + if getattr(renderer, 'expand_tabs', False): + if expanded_content is None: + content = content_to_unicode(self.env, content, + full_mimetype) + expanded_content = content.expandtabs(self.tab_width) + rendered_content = expanded_content + result = renderer.render(req, full_mimetype, rendered_content, + filename, url) + if not result: + continue + elif isinstance(result, Fragment): + return result + elif isinstance(result, basestring): + return Markup(to_unicode(result)) + elif annotations: + return Markup(self._annotate(result, annotations)) + else: + buf = StringIO() + buf.write('
')
+                    for line in result:
+                        buf.write(line + '\n')
+                    buf.write('
') + return Markup(buf.getvalue()) + except Exception, e: + self.log.warning('HTML preview using %s failed (%s)' + % (renderer, e), exc_info=True) + + def _annotate(self, lines, annotations): + buf = StringIO() + buf.write('') + annotators = [] + for annotator in self.annotators: + atype, alabel, adesc = annotator.get_annotation_type() + if atype in annotations: + buf.write('' % (atype, alabel)) + annotators.append(annotator) + buf.write('') + buf.write('') + + space_re = re.compile('(?P (?: +))|' + '^(?P<\w+.*?>)?( )') + def htmlify(match): + m = match.group('spaces') + if m: + div, mod = divmod(len(m), 2) + return div * '  ' + mod * ' ' + return (match.group('tag') or '') + ' ' + + num = -1 + for num, line in enumerate(_html_splitlines(lines)): + cells = [] + for annotator in annotators: + cells.append(annotator.annotate_line(num + 1, line)) + cells.append('\n' % space_re.sub(htmlify, line)) + buf.write('' + '\n'.join(cells) + '') + else: + if num < 0: + return '' + buf.write('
%s 
%s
') + return buf.getvalue() + + def get_max_preview_size(self): + """Deprecated: use `max_preview_size` attribute directly.""" + return self.max_preview_size + + def get_charset(self, content='', mimetype=None): + """Infer the character encoding from the `content` or the `mimetype`. + + `content` is either a `str` or an `unicode` object. + + The charset will be determined using this order: + * from the charset information present in the `mimetype` argument + * auto-detection of the charset from the `content` + * the configured `default_charset` + """ + if mimetype: + ctpos = mimetype.find('charset=') + if ctpos >= 0: + return mimetype[ctpos + 8:].strip() + if isinstance(content, str): + utf = detect_unicode(content) + if utf is not None: + return utf + return self.default_charset + + def get_mimetype(self, filename, content=None): + """Infer the MIME type from the `filename` or the `content`. + + `content` is either a `str` or an `unicode` object. + + Return the detected MIME type, augmented by the + charset information (i.e. "; charset=..."), + or `None` if detection failed. + """ + # Extend default extension to MIME type mappings with configured ones + if not self._mime_map: + self._mime_map = MIME_MAP + for mapping in self.config['mimeviewer'].getlist('mime_map'): + if ':' in mapping: + assocations = mapping.split(':') + for keyword in assocations: # Note: [0] kept on purpose + self._mime_map[keyword] = assocations[0] + + mimetype = get_mimetype(filename, content, self._mime_map) + charset = None + if mimetype: + charset = self.get_charset(content, mimetype) + if mimetype and charset and not 'charset' in mimetype: + mimetype += '; charset=' + charset + return mimetype + + def to_utf8(self, content, mimetype=None): + """Convert an encoded `content` to utf-8. + + ''Deprecated in 0.10. You should use `unicode` strings only.'' + """ + return to_utf8(content, self.get_charset(content, mimetype)) + + def to_unicode(self, content, mimetype=None, charset=None): + """Convert `content` (an encoded `str` object) to an `unicode` object. + + This calls `trac.util.to_unicode` with the `charset` provided, + or the one obtained by `Mimeview.get_charset()`. + """ + if not charset: + charset = self.get_charset(content, mimetype) + return to_unicode(content, charset) + + def configured_modes_mapping(self, renderer): + """Return a MIME type to `(mode,quality)` mapping for given `option`""" + types, option = {}, '%s_modes' % renderer + for mapping in self.config['mimeviewer'].getlist(option): + if not mapping: + continue + try: + mimetype, mode, quality = mapping.split(':') + types[mimetype] = (mode, int(quality)) + except (TypeError, ValueError): + self.log.warning("Invalid mapping '%s' specified in '%s' " + "option." % (mapping, option)) + return types + + def preview_to_hdf(self, req, content, length, mimetype, filename, + url=None, annotations=None): + """Prepares a rendered preview of the given `content`. + + Note: `content` will usually be an object with a `read` method. + """ + if length >= self.max_preview_size: + return {'max_file_size_reached': True, + 'max_file_size': self.max_preview_size, + 'raw_href': url} + else: + return {'preview': self.render(req, mimetype, content, filename, + url, annotations), + 'raw_href': url} + + def send_converted(self, req, in_type, content, selector, filename='file'): + """Helper method for converting `content` and sending it directly. + + `selector` can be either a key or a MIME Type.""" + from trac.web import RequestDone + content, output_type, ext = self.convert_content(req, in_type, + content, selector) + req.send_response(200) + req.send_header('Content-Type', output_type) + req.send_header('Content-Disposition', 'filename=%s.%s' % (filename, + ext)) + req.end_headers() + req.write(content) + raise RequestDone + + +def _html_splitlines(lines): + """Tracks open and close tags in lines of HTML text and yields lines that + have no tags spanning more than one line.""" + open_tag_re = re.compile(r'<(\w+)(\s.*?)?[^/]?>') + close_tag_re = re.compile(r'') + open_tags = [] + for line in lines: + # Reopen tags still open from the previous line + for tag in open_tags: + line = tag.group(0) + line + open_tags = [] + + # Find all tags opened on this line + for tag in open_tag_re.finditer(line): + open_tags.append(tag) + + open_tags.reverse() + + # Find all tags closed on this line + for ctag in close_tag_re.finditer(line): + for otag in open_tags: + if otag.group(1) == ctag.group(1): + open_tags.remove(otag) + break + + # Close all tags still open at the end of line, they'll get reopened at + # the beginning of the next line + for tag in open_tags: + line += '' % tag.group(1) + + yield line + + +# -- Default annotators + +class LineNumberAnnotator(Component): + """Text annotator that adds a column with line numbers.""" + implements(IHTMLPreviewAnnotator) + + # ITextAnnotator methods + + def get_annotation_type(self): + return 'lineno', 'Line', 'Line numbers' + + def annotate_line(self, number, content): + return '%s' % (number, number, + number) + + +# -- Default renderers + +class PlainTextRenderer(Component): + """HTML preview renderer for plain text, and fallback for any kind of text + for which no more specific renderer is available. + """ + implements(IHTMLPreviewRenderer) + + expand_tabs = True + + TREAT_AS_BINARY = [ + 'application/pdf', + 'application/postscript', + 'application/rtf' + ] + + def get_quality_ratio(self, mimetype): + if mimetype in self.TREAT_AS_BINARY: + return 0 + return 1 + + def render(self, req, mimetype, content, filename=None, url=None): + if is_binary(content): + self.env.log.debug("Binary data; no preview available") + return + + self.env.log.debug("Using default plain text mimeviewer") + content = content_to_unicode(self.env, content, mimetype) + for line in content.splitlines(): + yield escape(line) + + +class ImageRenderer(Component): + """Inline image display. Here we don't need the `content` at all.""" + implements(IHTMLPreviewRenderer) + + def get_quality_ratio(self, mimetype): + if mimetype.startswith('image/'): + return 8 + return 0 + + def render(self, req, mimetype, content, filename=None, url=None): + if url: + return html.DIV(html.IMG(src=url,alt=filename), + class_="image-file") + + +class WikiTextRenderer(Component): + """Render files containing Trac's own Wiki formatting markup.""" + implements(IHTMLPreviewRenderer) + + def get_quality_ratio(self, mimetype): + if mimetype in ('text/x-trac-wiki', 'application/x-trac-wiki'): + return 8 + return 0 + + def render(self, req, mimetype, content, filename=None, url=None): + from trac.wiki import wiki_to_html + return wiki_to_html(content_to_unicode(self.env, content, mimetype), + self.env, req) diff --git a/examples/trac/trac/mimeview/enscript.py b/examples/trac/trac/mimeview/enscript.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/mimeview/enscript.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2004-2005 Edgewall Software +# Copyright (C) 2004 Daniel Lundin +# Copyright (C) 2005 Christopher Lenz +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Daniel Lundin + +from trac.config import Option, ListOption +from trac.core import * +from trac.mimeview.api import IHTMLPreviewRenderer, Mimeview +from trac.util import NaivePopen +from trac.util.markup import escape, Deuglifier + +__all__ = ['EnscriptRenderer'] + +types = { + 'application/xhtml+xml': ('html', 2), + 'application/postscript': ('postscript', 2), + 'application/x-csh': ('csh', 2), + 'application/x-javascript': ('javascript', 2), + 'application/x-troff': ('nroff', 2), + 'text/html': ('html', 2), + 'text/x-ada': ('ada', 2), + 'text/x-asm': ('asm', 2), + 'text/x-awk': ('awk', 2), + 'text/x-c++src': ('cpp', 2), + 'text/x-c++hdr': ('cpp', 2), + 'text/x-chdr': ('c', 2), + 'text/x-csh': ('csh', 2), + 'text/x-csrc': ('c', 2), + 'text/x-diff': ('diffu', 2), # Assume unified diff (works otherwise) + 'text/x-eiffel': ('eiffel', 2), + 'text/x-elisp': ('elisp', 2), + 'text/x-fortran': ('fortran', 2), + 'text/x-haskell': ('haskell', 2), + 'text/x-idl': ('idl', 2), + 'text/x-inf': ('inf', 2), + 'text/x-java': ('java', 2), + 'text/x-javascript': ('javascript', 2), + 'text/x-ksh': ('ksh', 2), + 'text/x-lua': ('lua', 2), + 'text/x-m4': ('m4', 2), + 'text/x-makefile': ('makefile', 2), + 'text/x-mail': ('mail', 2), + 'text/x-matlab': ('matlab', 2), + 'text/x-objc': ('objc', 2), + 'text/x-pascal': ('pascal', 2), + 'text/x-perl': ('perl', 2), + 'text/x-pyrex': ('pyrex', 2), + 'text/x-python': ('python', 2), + 'text/x-rfc': ('rfc', 2), + 'text/x-ruby': ('ruby', 2), + 'text/x-sh': ('sh', 2), + 'text/x-scheme': ('scheme', 2), + 'text/x-sql': ('sql', 2), + 'text/x-tcl': ('tcl', 2), + 'text/x-tex': ('tex', 2), + 'text/x-vba': ('vba', 2), + 'text/x-verilog': ('verilog', 2), + 'text/x-vhdl': ('vhdl', 2), + 'model/vrml': ('vrml', 2), + 'application/x-sh': ('sh', 2), + 'text/x-zsh': ('zsh', 2), + 'text/vnd.wap.wmlscript': ('wmlscript', 2), +} + + +class EnscriptDeuglifier(Deuglifier): + def rules(cls): + return [ + r'(?P)', + r'(?P)', + r'(?P)', + r'(?P)', + r'(?P)', + r'(?P)', + r'(?P)', + r'(?P)', + r'(?P)', + r'(?P)' + ] + rules = classmethod(rules) + + +class EnscriptRenderer(Component): + """Syntax highlighting using GNU Enscript.""" + + implements(IHTMLPreviewRenderer) + + expand_tabs = True + + path = Option('mimeviewer', 'enscript_path', 'enscript', + """Path to the Enscript executable.""") + + enscript_modes = ListOption('mimeviewer', 'enscript_modes', + 'text/x-dylan:dylan:4', + """List of additional MIME types known by Enscript. + For each, a tuple `mimetype:mode:quality` has to be + specified, where `mimetype` is the MIME type, + `mode` is the corresponding Enscript mode to be used + for the conversion and `quality` is the quality ratio + associated to this conversion. + That can also be used to override the default + quality ratio used by the Enscript render, which is 2 + (''since 0.10'').""") + + def __init__(self): + self._types = None + + # IHTMLPreviewRenderer methods + + def get_quality_ratio(self, mimetype): + # Extend default MIME type to mode mappings with configured ones + if not self._types: + self._types = {} + self._types.update(types) + self._types.update( + Mimeview(self.env).configured_modes_mapping('enscript')) + return self._types.get(mimetype, (None, 0))[1] + + def render(self, req, mimetype, content, filename=None, rev=None): + cmdline = self.path + mimetype = mimetype.split(';', 1)[0] # strip off charset + mode = self._types[mimetype][0] + cmdline += ' --color -h -q --language=html -p - -E%s' % mode + self.env.log.debug("Enscript command line: %s" % cmdline) + + np = NaivePopen(cmdline, content.encode('utf-8'), capturestderr=1) + if np.errorlevel or np.err: + err = 'Running (%s) failed: %s, %s.' % (cmdline, np.errorlevel, + np.err) + raise Exception, err + odata = np.out + + # Strip header and footer + i = odata.find('
')
+        beg = i > 0 and i + 6
+        i = odata.rfind('
') + end = i > 0 and i or len(odata) + + odata = EnscriptDeuglifier().format(odata[beg:end].decode('utf-8')) + return odata.splitlines() diff --git a/examples/trac/trac/mimeview/patch.py b/examples/trac/trac/mimeview/patch.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/mimeview/patch.py @@ -0,0 +1,204 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005 Edgewall Software +# Copyright (C) 2005 Christopher Lenz +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Christopher Lenz +# Ludvig Strigeus + +from trac.core import * +from trac.mimeview.api import content_to_unicode, IHTMLPreviewRenderer, Mimeview +from trac.util.markup import escape, Markup +from trac.web.chrome import add_stylesheet + +__all__ = ['PatchRenderer'] + + +class PatchRenderer(Component): + """Structured display of patches in unified diff format, similar to the + layout provided by the changeset view. + """ + + implements(IHTMLPreviewRenderer) + + diff_cs = """ + +
  • +

    + + + + + + + + + + +
     
     
    +
  • +
+""" # diff_cs + + # IHTMLPreviewRenderer methods + + def get_quality_ratio(self, mimetype): + if mimetype == 'text/x-diff': + return 8 + return 0 + + def render(self, req, mimetype, content, filename=None, rev=None): + from trac.web.clearsilver import HDFWrapper + + content = content_to_unicode(self.env, content, mimetype) + d = self._diff_to_hdf(content.splitlines(), + Mimeview(self.env).tab_width) + if not d: + raise TracError, 'Invalid unified diff content' + hdf = HDFWrapper(loadpaths=[self.env.get_templates_dir(), + self.config.get('trac', 'templates_dir')]) + hdf['diff.files'] = d + + add_stylesheet(req, 'common/css/diff.css') + return hdf.render(hdf.parse(self.diff_cs)) + + # Internal methods + + # FIXME: This function should probably share more code with the + # trac.versioncontrol.diff module + def _diff_to_hdf(self, difflines, tabwidth): + """ + Translate a diff file into something suitable for inclusion in HDF. + The result is [(filename, revname_old, revname_new, changes)], + where changes has the same format as the result of + `trac.versioncontrol.diff.hdf_diff`. + + If the diff cannot be parsed, this method returns None. + """ + def _markup_intraline_change(fromlines, tolines): + from trac.versioncontrol.diff import _get_change_extent + for i in xrange(len(fromlines)): + fr, to = fromlines[i], tolines[i] + (start, end) = _get_change_extent(fr, to) + if start != 0 and end != 0: + fromlines[i] = fr[:start] + '\0' + fr[start:end+len(fr)] + \ + '\1' + fr[end:] + tolines[i] = to[:start] + '\0' + to[start:end+len(to)] + \ + '\1' + to[end:] + + import re + space_re = re.compile(' ( +)|^ ') + def htmlify(match): + div, mod = divmod(len(match.group(0)), 2) + return div * '  ' + mod * ' ' + + output = [] + filename, groups = None, None + lines = iter(difflines) + try: + line = lines.next() + while True: + if not line.startswith('--- '): + line = lines.next() + continue + + # Base filename/version + words = line.split(None, 2) + filename, fromrev = words[1], 'old' + groups, blocks = None, None + + # Changed filename/version + line = lines.next() + if not line.startswith('+++ '): + return None + + words = line.split(None, 2) + if len(words[1]) < len(filename): + # Always use the shortest filename for display + filename = words[1] + groups = [] + output.append({'filename' : filename, 'oldrev' : fromrev, + 'newrev' : 'new', 'diff' : groups}) + + for line in lines: + # @@ -333,10 +329,8 @@ + r = re.match(r'@@ -(\d+),(\d+) \+(\d+),(\d+) @@', line) + if not r: + break + blocks = [] + groups.append(blocks) + fromline,fromend,toline,toend = map(int, r.groups()) + last_type = None + + fromend += fromline + toend += toline + + while fromline < fromend or toline < toend: + line = lines.next() + + # First character is the command + command, line = line[0], line[1:] + # Make a new block? + if (command == ' ') != last_type: + last_type = command == ' ' + blocks.append({'type': last_type and 'unmod' or 'mod', + 'base.offset': fromline - 1, + 'base.lines': [], + 'changed.offset': toline - 1, + 'changed.lines': []}) + if command == ' ': + blocks[-1]['changed.lines'].append(line) + blocks[-1]['base.lines'].append(line) + fromline += 1 + toline += 1 + elif command == '+': + blocks[-1]['changed.lines'].append(line) + toline += 1 + elif command == '-': + blocks[-1]['base.lines'].append(line) + fromline += 1 + else: + return None + line = lines.next() + except StopIteration: + pass + + # Go through all groups/blocks and mark up intraline changes, and + # convert to html + for o in output: + for group in o['diff']: + for b in group: + f, t = b['base.lines'], b['changed.lines'] + if b['type'] == 'mod': + if len(f) == 0: + b['type'] = 'add' + elif len(t) == 0: + b['type'] = 'rem' + elif len(f) == len(t): + _markup_intraline_change(f, t) + for i in xrange(len(f)): + line = f[i].expandtabs(tabwidth) + line = escape(line).replace('\0', '') \ + .replace('\1', '') + f[i] = Markup(space_re.sub(htmlify, line)) + for i in xrange(len(t)): + line = t[i].expandtabs(tabwidth) + line = escape(line).replace('\0', '') \ + .replace('\1', '') + t[i] = Markup(space_re.sub(htmlify, line)) + return output diff --git a/examples/trac/trac/mimeview/php.py b/examples/trac/trac/mimeview/php.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/mimeview/php.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2005 Edgewall Software +# Copyright (C) 2005 Christian Boos +# Copyright (C) 2005 Christopher Lenz +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Christian Boos +# Christopher Lenz + +import re + +from trac.core import * +from trac.config import Option +from trac.mimeview.api import IHTMLPreviewRenderer, content_to_unicode +from trac.util import NaivePopen +from trac.util.markup import Deuglifier + +__all__ = ['PHPRenderer'] + +php_types = ('text/x-php', 'application/x-httpd-php', + 'application/x-httpd-php4', 'application/x-httpd-php1') + + +class PhpDeuglifier(Deuglifier): + + def format(self, indata): + # The PHP highlighter produces the end-span tags on the next line + # instead of the line they actually apply to, which causes + # Trac to produce lots of (useless) open-and-immediately-close + # spans beginning each line. This tries to curtail by bubbling + # the first span after a set of 1+ "
" to before them. + r_fixeol = re.compile(r"((?:
)+)()") + indata = r_fixeol.sub(lambda m: m.group(2) + m.group(1), indata) + + # Now call superclass implementation that handles the dirty work + # of applying css classes. + return Deuglifier.format(self, indata) + + def rules(cls): + colors = dict(comment='FF8000', lang='0000BB', keyword='007700', + string='DD0000') + # rules check for for PHP 4 or for PHP 5 + color_rules = [ + r'(?P<%s><(?:font color="|span style="color: )#%s">)' % c + for c in colors.items() + ] + return color_rules + [ r'(?P)', r'(?P)' ] + rules = classmethod(rules) + +class PHPRenderer(Component): + """ + Syntax highlighting using the PHP executable if available. + """ + + implements(IHTMLPreviewRenderer) + + path = Option('mimeviewer', 'php_path', 'php', + """Path to the PHP executable (''since 0.9'').""") + + # IHTMLPreviewRenderer methods + + def get_quality_ratio(self, mimetype): + if mimetype in php_types: + return 4 + return 0 + + def render(self, req, mimetype, content, filename=None, rev=None): + cmdline = self.config.get('mimeviewer', 'php_path') + # -n to ignore php.ini so we're using default colors + cmdline += ' -sn' + self.env.log.debug("PHP command line: %s" % cmdline) + + content = content_to_unicode(self.env, content, mimetype) + content = content.encode('utf-8') + np = NaivePopen(cmdline, content, capturestderr=1) + if np.errorlevel or np.err: + err = 'Running (%s) failed: %s, %s.' % (cmdline, np.errorlevel, + np.err) + raise Exception, err + odata = ''.join(np.out.splitlines()[1:-2]) + if odata.startswith('X-Powered-By'): + raise TracError, 'You appear to be using the PHP CGI binary. ' \ + 'Trac requires the CLI version for syntax ' \ + 'highlighting.' + + html = PhpDeuglifier().format(odata.decode('utf-8')) + for line in html.split('
'): + # PHP generates _way_ too many non-breaking spaces... + # We don't need them anyway, so replace them by normal spaces + yield line.replace(' ', ' ') diff --git a/examples/trac/trac/mimeview/rst.py b/examples/trac/trac/mimeview/rst.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/mimeview/rst.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2004-2005 Edgewall Software +# Copyright (C) 2004 Oliver Rutherfurd +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Daniel Lundin +# Oliver Rutherfurd (initial implementation) +# Nuutti Kotivuori (role support) +# +# Trac support for reStructured Text, including a custom 'trac' directive +# +# 'trac' directive code by Oliver Rutherfurd. +# +# Inserts `reference` nodes for TracLinks into the document tree. + +__docformat__ = 'reStructuredText' + +from distutils.version import StrictVersion +import re + +from trac.core import * +from trac.mimeview.api import IHTMLPreviewRenderer, content_to_unicode +from trac.web.href import Href +from trac.wiki.formatter import WikiProcessor +from trac.wiki import WikiSystem + +WIKI_LINK = re.compile(r'(?:wiki:)?(.+)') +TICKET_LINK = re.compile(r'(?:#(\d+))|(?:ticket:(\d+))') +REPORT_LINK = re.compile(r'(?:{(\d+)})|(?:report:(\d+))') +CHANGESET_LINK = re.compile(r'(?:\[(\d+)\])|(?:changeset:(\d+))') +FILE_LINK = re.compile(r'(?:browser|repos|source):([^#]+)#?(.*)') + +def _wikipage(href, args): + return href.wiki(args[0]) + +def _ticket(href, args): + return href.ticket(args[0]) + +def _report(href, args): + return href.report(args[0]) + +def _changeset(href, args): + return href.changeset(int(args[0])) + +def _browser(href, args): + path = args[0] + rev = len(args) == 2 and args[1] or '' + return href.browser(path, rev=rev) + +# TracLink REs and callback functions +LINKS = [(TICKET_LINK, _ticket), + (REPORT_LINK, _report), + (CHANGESET_LINK, _changeset), + (FILE_LINK, _browser), + (WIKI_LINK, _wikipage)] + +class ReStructuredTextRenderer(Component): + """ + Renders plain text in reStructuredText format as HTML. + """ + implements(IHTMLPreviewRenderer) + + def get_quality_ratio(self, mimetype): + if mimetype == 'text/x-rst': + return 8 + return 0 + + def render(self, req, mimetype, content, filename=None, rev=None): + try: + from docutils import nodes + from docutils.core import publish_string + from docutils.parsers import rst + from docutils import __version__ + except ImportError: + raise TracError, 'Docutils not found' + if StrictVersion(__version__) < StrictVersion('0.3.3'): + raise TracError, 'Docutils version >= %s required, %s found' \ + % ('0.3.3', __version__) + + def trac_get_reference(rawtext, link, text): + for (pattern, function) in LINKS: + m = pattern.match(link) + if m: + g = filter(None, m.groups()) + missing = 0 + if not text: + text = g[0] + if pattern == WIKI_LINK: + pagename = re.search(r'^[^\#]+',g[0]) + if not WikiSystem(self.env).has_page(pagename.group()): + missing = 1 + text = text + "?" + uri = function(req.href, g) + reference = nodes.reference(rawtext, text) + reference['refuri']= uri + if missing: + reference.set_class('missing') + return reference + return None + + def trac(name, arguments, options, content, lineno, + content_offset, block_text, state, state_machine): + """Inserts a `reference` node into the document + for a given `TracLink`_, based on the content + of the arguments. + + Usage:: + + .. trac:: target [text] + + ``target`` may be one of the following: + + * For wiki: ``WikiName`` or ``wiki:WikiName`` + * For tickets: ``#1`` or ``ticket:1`` + * For reports: ``{1}`` or ``report:1`` + * For changesets: ``[1]`` or ``changeset:1`` + * For files: ``source:trunk/COPYING`` + + ``[text]`` is optional. If not given, ``target`` is + used as the reference text. + + .. _TracLink: http://projects.edgewall.com/trac/wiki/TracLinks + """ + link = arguments[0] + if len(arguments) == 2: + text = arguments[1] + else: + text = None + reference = trac_get_reference(block_text, link, text) + if reference: + p = nodes.paragraph() + p += reference + return p + # didn't find a match (invalid TracLink), + # report a warning + warning = state_machine.reporter.warning( + '%s is not a valid TracLink' % (arguments[0]), + nodes.literal_block(block_text, block_text), + line=lineno) + return [warning] + + def trac_role(name, rawtext, text, lineno, inliner, options={}, + content=[]): + args = text.split(" ",1) + link = args[0] + if len(args)==2: + text = args[1] + else: + text = None + reference = trac_get_reference(rawtext, link, text) + if reference: + return [reference], [] + warning = nodes.warning(None, nodes.literal_block(text, + 'WARNING: %s is not a valid TracLink' % rawtext)) + return warning, [] + + # 1 required arg, 1 optional arg, spaces allowed in last arg + trac.arguments = (1,1,1) + trac.options = None + trac.content = None + rst.directives.register_directive('trac', trac) + rst.roles.register_local_role('trac', trac_role) + + # The code_block could is taken from the leo plugin rst2 + def code_formatter(language, text): + processor = WikiProcessor(self.env, language) + html = processor.process(req, text) + raw = nodes.raw('', html, format='html') + return raw + + def code_role(name, rawtext, text, lineno, inliner, options={}, + content=[]): + language = options.get('language') + if not language: + args = text.split(':', 1) + language = args[0] + if len(args) == 2: + text = args[1] + else: + text = '' + reference = code_formatter(language, text) + return [reference], [] + + def code_block(name, arguments, options, content, lineno, + content_offset, block_text, state, state_machine): + """ + Create a code-block directive for docutils. + + Usage: .. code-block:: language + + If the language can be syntax highlighted it will be. + """ + language = arguments[0] + text = '\n'.join(content) + reference = code_formatter(language, text) + return [reference] + + # These are documented + # at http://docutils.sourceforge.net/spec/howto/rst-directives.html. + code_block.arguments = ( + 1, # Number of required arguments. + 0, # Number of optional arguments. + 0) # True if final argument may contain whitespace. + + # A mapping from option name to conversion function. + code_role.options = code_block.options = { + 'language' : + rst.directives.unchanged # Return the text argument, unchanged + } + code_block.content = 1 # True if content is allowed. + # Register the directive with docutils. + rst.directives.register_directive('code-block', code_block) + rst.roles.register_local_role('code-block', code_role) + + _inliner = rst.states.Inliner() + _parser = rst.Parser(inliner=_inliner) + content = content_to_unicode(self.env, content, mimetype) + content = content.encode('utf-8') + html = publish_string(content, writer_name='html', parser=_parser, + settings_overrides={'halt_level': 6}) + html = html.decode('utf-8') + return html[html.find('') + 6:html.find('')].strip() diff --git a/examples/trac/trac/mimeview/silvercity.py b/examples/trac/trac/mimeview/silvercity.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/mimeview/silvercity.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2004-2006 Edgewall Software +# Copyright (C) 2004 Daniel Lundin +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Daniel Lundin + +"""Syntax highlighting module, based on the SilverCity module. + +Get it at: http://silvercity.sourceforge.net/ +""" + +import re +from StringIO import StringIO + +from trac.core import * +from trac.config import ListOption +from trac.mimeview.api import IHTMLPreviewRenderer, Mimeview + +__all__ = ['SilverCityRenderer'] + +types = { + 'text/css': ('CSS', 3), + 'text/html': ('HyperText', 3, {'asp.default.language':1}), + 'application/xml': ('XML', 3), + 'application/xhtml+xml': ('HyperText', 3, {'asp.default.language':1}), + 'application/x-javascript': ('CPP', 3), # Kludgy. + 'text/x-asp': ('HyperText', 3, {'asp.default.language':2}), + 'text/x-c++hdr': ('CPP', 3), + 'text/x-c++src': ('CPP', 3), + 'text/x-chdr': ('CPP', 3), + 'text/x-csrc': ('CPP', 3), + 'text/x-perl': ('Perl', 3), + 'text/x-php': ('HyperText', 3, {'asp.default.language':4}), + 'application/x-httpd-php': ('HyperText', 3, {'asp.default.language':4}), + 'application/x-httpd-php4': ('HyperText', 3, {'asp.default.language':4}), + 'application/x-httpd-php3': ('HyperText', 3, {'asp.default.language':4}), + 'text/x-javascript': ('CPP', 3), # Kludgy. + 'text/x-psp': ('HyperText', 3, {'asp.default.language':3}), + 'text/x-python': ('Python', 3), + 'text/x-ruby': ('Ruby', 3), + 'text/x-sql': ('SQL', 3), + 'text/xml': ('XML', 3), + 'text/xslt': ('XSLT', 3), + 'image/svg+xml': ('XML', 3) +} + +CRLF_RE = re.compile('\r$', re.MULTILINE) + + +class SilverCityRenderer(Component): + """Syntax highlighting based on SilverCity.""" + + implements(IHTMLPreviewRenderer) + + enscript_modes = ListOption('mimeviewer', 'silvercity_modes', + '', + """List of additional MIME types known by SilverCity. + For each, a tuple `mimetype:mode:quality` has to be + specified, where `mimetype` is the MIME type, + `mode` is the corresponding SilverCity mode to be used + for the conversion and `quality` is the quality ratio + associated to this conversion. + That can also be used to override the default + quality ratio used by the SilverCity render, which is 3 + (''since 0.10'').""") + + expand_tabs = True + + def __init__(self): + self._types = None + + def get_quality_ratio(self, mimetype): + # Extend default MIME type to mode mappings with configured ones + if not self._types: + self._types = {} + self._types.update(types) + self._types.update( + Mimeview(self.env).configured_modes_mapping('silvercity')) + return self._types.get(mimetype, (None, 0))[1] + + def render(self, req, mimetype, content, filename=None, rev=None): + import SilverCity + try: + mimetype = mimetype.split(';', 1)[0] + typelang = self._types[mimetype] + lang = typelang[0] + module = getattr(SilverCity, lang) + generator = getattr(module, lang + "HTMLGenerator") + try: + allprops = typelang[2] + propset = SilverCity.PropertySet() + for p in allprops.keys(): + propset[p] = allprops[p] + except IndexError: + pass + except (KeyError, AttributeError): + err = "No SilverCity lexer found for mime-type '%s'." % mimetype + raise Exception, err + + # SilverCity does not like unicode strings + content = content.encode('utf-8') + + # SilverCity generates extra empty line against some types of + # the line such as comment or #include with CRLF. So we + # standardize to LF end-of-line style before call. + content = CRLF_RE.sub('', content) + + buf = StringIO() + generator().generate_html(buf, content) + + br_re = re.compile(r'$', re.MULTILINE) + span_default_re = re.compile(r'(.*?)', + re.DOTALL) + html = span_default_re.sub(r'\1', br_re.sub('', buf.getvalue())) + + # Convert the output back to a unicode string + html = html.decode('utf-8') + + # SilverCity generates _way_ too many non-breaking spaces... + # We don't need them anyway, so replace them by normal spaces + return html.replace(' ', ' ').splitlines() diff --git a/examples/trac/trac/mimeview/tests/__init__.py b/examples/trac/trac/mimeview/tests/__init__.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/mimeview/tests/__init__.py @@ -0,0 +1,11 @@ +from trac.mimeview.tests import api + +import unittest + +def suite(): + suite = unittest.TestSuite() + suite.addTest(api.suite()) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/examples/trac/trac/mimeview/tests/api.py b/examples/trac/trac/mimeview/tests/api.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/mimeview/tests/api.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2006 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. + +import unittest + +from trac.mimeview.api import get_mimetype, _html_splitlines + +class GetMimeTypeTestCase(unittest.TestCase): + + def test_from_suffix_using_MIME_MAP(self): + self.assertEqual('text/plain', get_mimetype('README', None)) + self.assertEqual('text/plain', get_mimetype('README.txt', None)) + + def test_from_suffix_using_mimetypes(self): + self.assertEqual('application/x-python-code', + get_mimetype('test.pyc', None)) + + def test_from_content_using_CONTENT_RE(self): + self.assertEqual('text/x-python', + get_mimetype('xxx', """ +#!/usr/bin/python +# This is a python script +""")) + self.assertEqual('text/x-ksh', + get_mimetype('xxx', """ +#!/bin/ksh +# This is a shell script +""")) + self.assertEqual('text/x-python', + get_mimetype('xxx', """ +# -*- Python -*- +# This is a python script +""")) + self.assertEqual('text/x-ruby', + get_mimetype('xxx', """ +# -*- mode: ruby -*- +# This is a ruby script +""")) + + def test_from_content_using_is_binary(self): + self.assertEqual('application/octet-stream', + get_mimetype('xxx', "abc\0xyz")) + + +class MimeviewTestCase(unittest.TestCase): + + def test_html_splitlines_without_markup(self): + lines = ['line 1', 'line 2'] + self.assertEqual(lines, list(_html_splitlines(lines))) + + def test_html_splitlines_with_markup(self): + lines = ['

Hi', 'How are you

'] + result = list(_html_splitlines(lines)) + self.assertEqual('

Hi

', result[0]) + self.assertEqual('

How are you

', result[1]) + + def test_html_splitlines_with_multiline(self): + """ + Regression test for http://projects.edgewall.com/trac/ticket/2655 + """ + lines = ['"""', + 'a http://google.com/', + 'Test', 'Test', '"""'] + result = list(_html_splitlines(lines)) + self.assertEqual('"""', result[0]) + self.assertEqual('a ' + 'http://google.com/' + '', result[1]) + self.assertEqual('Test', result[2]) + self.assertEqual('Test', result[3]) + self.assertEqual('"""', result[4]) + + +def suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(GetMimeTypeTestCase, 'test')) + suite.addTest(unittest.makeSuite(MimeviewTestCase, 'test')) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/examples/trac/trac/mimeview/txtl.py b/examples/trac/trac/mimeview/txtl.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/mimeview/txtl.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2004-2006 Edgewall Software +# Copyright (C) 2004 Daniel Lundin +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Daniel Lundin + +"""Trac support for Textile +See also: http://dealmeida.net/projects/textile/ +""" + +from trac.core import * +from trac.mimeview.api import IHTMLPreviewRenderer + + +class TextileRenderer(Component): + """Renders plain text in Textile format as HTML.""" + implements(IHTMLPreviewRenderer) + + def get_quality_ratio(self, mimetype): + if mimetype == 'text/x-textile': + return 8 + return 0 + + def render(self, req, mimetype, content, filename=None, rev=None): + import textile + return textile.textile(content.encode('utf-8'), encoding='utf-8') diff --git a/examples/trac/trac/notification.py b/examples/trac/trac/notification.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/notification.py @@ -0,0 +1,372 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2003-2006 Edgewall Software +# Copyright (C) 2003-2005 Daniel Lundin +# Copyright (C) 2005-2006 Emmanuel Blot +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# + +import time +import smtplib +import re + +from trac import __version__ +from trac.config import BoolOption, IntOption, Option +from trac.core import * +from trac.util.text import CRLF, wrap +from trac.web.chrome import Chrome +from trac.web.clearsilver import HDFWrapper +from trac.web.main import populate_hdf + +MAXHEADERLEN = 76 + + +class NotificationSystem(Component): + + smtp_enabled = BoolOption('notification', 'smtp_enabled', 'false', + """Enable SMTP (email) notification.""") + + smtp_server = Option('notification', 'smtp_server', 'localhost', + """SMTP server hostname to use for email notifications.""") + + smtp_port = IntOption('notification', 'smtp_port', 25, + """SMTP server port to use for email notification.""") + + smtp_user = Option('notification', 'smtp_user', '', + """Username for SMTP server. (''since 0.9'').""") + + smtp_password = Option('notification', 'smtp_password', '', + """Password for SMTP server. (''since 0.9'').""") + + smtp_from = Option('notification', 'smtp_from', 'trac@localhost', + """Sender address to use in notification emails.""") + + smtp_replyto = Option('notification', 'smtp_replyto', 'trac@localhost', + """Reply-To address to use in notification emails.""") + + smtp_always_cc = Option('notification', 'smtp_always_cc', '', + """Email address(es) to always send notifications to, + addresses can be see by all recipients (Cc:).""") + + smtp_always_bcc = Option('notification', 'smtp_always_bcc', '', + """Email address(es) to always send notifications to, + addresses do not appear publicly (Bcc:). (''since 0.10'').""") + + smtp_default_domain = Option('notification', 'smtp_default_domain', '', + """Default host/domain to append to address that do not specify one""") + + mime_encoding = Option('notification', 'mime_encoding', 'base64', + """Specifies the MIME encoding scheme for emails. + + Valid options are 'base64' for Base64 encoding, 'qp' for + Quoted-Printable, and 'none' for no encoding. Note that the no encoding + means that non-ASCII characters in text are going to cause problems + with notifications (''since 0.10'').""") + + use_public_cc = BoolOption('notification', 'use_public_cc', 'false', + """Recipients can see email addresses of other CC'ed recipients. + + If this option is disabled (the default), recipients are put on BCC + (''since 0.10'').""") + + use_short_addr = BoolOption('notification', 'use_short_addr', 'false', + """Permit email address without a host/domain (i.e. username only) + + The SMTP server should accept those addresses, and either append + a FQDN or use local delivery (''since 0.10'').""") + + use_tls = BoolOption('notification', 'use_tls', 'false', + """Use SSL/TLS to send notifications (''since 0.10'').""") + + +class Notify(object): + """Generic notification class for Trac. + + Subclass this to implement different methods. + """ + + def __init__(self, env): + self.env = env + self.config = env.config + self.db = env.get_db_cnx() + + loadpaths = Chrome(self.env).get_all_templates_dirs() + self.hdf = HDFWrapper(loadpaths) + populate_hdf(self.hdf, env) + + def notify(self, resid): + (torcpts, ccrcpts) = self.get_recipients(resid) + self.begin_send() + self.send(torcpts, ccrcpts) + self.finish_send() + + def get_recipients(self, resid): + """Return a pair of list of subscribers to the resource 'resid'. + + First list represents the direct recipients (To:), second list + represents the recipients in carbon copy (Cc:). + """ + raise NotImplementedError + + def begin_send(self): + """Prepare to send messages. + + Called before sending begins. + """ + + def send(self, torcpts, ccrcpts): + """Send message to recipients.""" + raise NotImplementedError + + def finish_send(self): + """Clean up after sending all messages. + + Called after sending all messages. + """ + + +class NotifyEmail(Notify): + """Baseclass for notification by email.""" + + smtp_server = 'localhost' + smtp_port = 25 + from_email = 'trac+tickets@localhost' + subject = '' + server = None + email_map = None + template_name = None + addrfmt = r"[\w\d_\.\-\+=]+\@(([\w\d\-])+\.)+([\w\d]{2,4})+" + shortaddr_re = re.compile(addrfmt) + longaddr_re = re.compile(r"^\s*(.*)\s+<(" + addrfmt + ")>\s*$"); + nodomaddr_re = re.compile(r"[\w\d_\.\-]+") + addrsep_re = re.compile(r"[;\s,]+") + + def __init__(self, env): + Notify.__init__(self, env) + + self._use_tls = self.env.config.getbool('notification', 'use_tls') + self._init_pref_encoding() + # Get the email addresses of all known users + self.email_map = {} + for username, name, email in self.env.get_known_users(self.db): + if email: + self.email_map[username] = email + + def _init_pref_encoding(self): + from email.Charset import Charset, QP, BASE64 + self._charset = Charset() + self._charset.input_charset = 'utf-8' + pref = self.env.config.get('notification', 'mime_encoding').lower() + if pref == 'base64': + self._charset.header_encoding = BASE64 + self._charset.body_encoding = BASE64 + self._charset.output_charset = 'utf-8' + self._charset.input_codec = 'utf-8' + self._charset.output_codec = 'utf-8' + elif pref in ['qp', 'quoted-printable']: + self._charset.header_encoding = QP + self._charset.body_encoding = QP + self._charset.output_charset = 'utf-8' + self._charset.input_codec = 'utf-8' + self._charset.output_codec = 'utf-8' + elif pref == 'none': + self._charset.header_encoding = None + self._charset.body_encoding = None + self._charset.input_codec = None + self._charset.output_charset = 'ascii' + else: + raise TracError, 'Invalid email encoding setting: %s' % pref + + def notify(self, resid, subject): + self.subject = subject + + if not self.config.getbool('notification', 'smtp_enabled'): + return + self.smtp_server = self.config['notification'].get('smtp_server') + self.smtp_port = self.config['notification'].getint('smtp_port') + self.from_email = self.config['notification'].get('smtp_from') + self.replyto_email = self.config['notification'].get('smtp_replyto') + self.from_email = self.from_email or self.replyto_email + if not self.from_email and not self.replyto_email: + raise TracError(Markup('Unable to send email due to identity ' + 'crisis.

Neither notification.from ' + 'nor notification.reply_to are ' + 'specified in the configuration.

'), + 'SMTP Notification Error') + + # Authentication info (optional) + self.user_name = self.config['notification'].get('smtp_user') + self.password = self.config['notification'].get('smtp_password') + + Notify.notify(self, resid) + + def format_header(self, key, name, email=None): + from email.Header import Header + maxlength = MAXHEADERLEN-(len(key)+2) + # Do not sent ridiculous short headers + if maxlength < 10: + raise TracError, "Header length is too short" + try: + tmp = name.encode('ascii') + header = Header(tmp, 'ascii', maxlinelen=maxlength) + except UnicodeEncodeError: + header = Header(name, self._charset, maxlinelen=maxlength) + if not email: + return header + else: + return "\"%s\" <%s>" % (header, email) + + def add_headers(self, msg, headers): + for h in headers: + msg[h] = self.encode_header(h, headers[h]) + + def get_smtp_address(self, address): + if not address: + return None + if address.find('@') == -1: + if address == 'anonymous': + return None + if self.email_map.has_key(address): + address = self.email_map[address] + elif NotifyEmail.nodomaddr_re.match(address): + if self.config.getbool('notification', 'use_short_addr'): + return address + domain = self.config.get('notification', 'smtp_default_domain') + if domain: + address = "%s@%s" % (address, domain) + else: + self.env.log.info("Email address w/o domain: %s" % address) + return None + mo = NotifyEmail.shortaddr_re.search(address) + if mo: + return mo.group(0) + mo = NotifyEmail.longaddr_re.search(address) + if mo: + return mo.group(2) + self.env.log.info("Invalid email address: %s" % address) + return None + + def encode_header(self, key, value): + if isinstance(value, tuple): + return self.format_header(key, value[0], value[1]) + if isinstance(value, list): + items = [] + for v in value: + items.append(self.encode_header(v)) + return ',\n\t'.join(items) + mo = NotifyEmail.longaddr_re.match(value) + if mo: + return self.format_header(key, mo.group(1), mo.group(2)) + return self.format_header(key, value) + + def begin_send(self): + self.server = smtplib.SMTP(self.smtp_server, self.smtp_port) + # self.server.set_debuglevel(True) + if self._use_tls: + self.server.ehlo() + if not self.server.esmtp_features.has_key('starttls'): + raise TracError, "TLS enabled but server does not support TLS" + self.server.starttls() + self.server.ehlo() + if self.user_name: + self.server.login(self.user_name, self.password) + + def send(self, torcpts, ccrcpts, mime_headers={}): + from email.MIMEText import MIMEText + from email.Utils import formatdate, formataddr + body = self.hdf.render(self.template_name) + projname = self.config.get('project', 'name') + public_cc = self.config.getbool('notification', 'use_public_cc') + headers = {} + headers['X-Mailer'] = 'Trac %s, by Edgewall Software' % __version__ + headers['X-Trac-Version'] = __version__ + headers['X-Trac-Project'] = projname + headers['X-URL'] = self.config.get('project', 'url') + headers['Subject'] = self.subject + headers['From'] = (projname, self.from_email) + headers['Sender'] = self.from_email + headers['Reply-To'] = self.replyto_email + + def build_addresses(rcpts): + """Format and remove invalid addresses""" + return filter(lambda x: x, \ + [self.get_smtp_address(addr) for addr in rcpts]) + + def remove_dup(rcpts, all): + """Remove duplicates""" + tmp = [] + for rcpt in rcpts: + if not rcpt in all: + tmp.append(rcpt) + all.append(rcpt) + return (tmp, all) + + toaddrs = build_addresses(torcpts) + ccaddrs = build_addresses(ccrcpts) + accparam = self.config.get('notification', 'smtp_always_cc') + accaddrs = accparam and \ + build_addresses(accparam.replace(',', ' ').split()) or [] + bccparam = self.config.get('notification', 'smtp_always_bcc') + bccaddrs = bccparam and \ + build_addresses(bccparam.replace(',', ' ').split()) or [] + + recipients = [] + (toaddrs, recipients) = remove_dup(toaddrs, recipients) + (ccaddrs, recipients) = remove_dup(ccaddrs, recipients) + (accaddrs, recipients) = remove_dup(accaddrs, recipients) + (bccaddrs, recipients) = remove_dup(bccaddrs, recipients) + + # if there is not valid recipient, leave immediately + if len(recipients) < 1: + return + + pcc = accaddrs + if public_cc: + pcc += ccaddrs + if toaddrs: + headers['To'] = ', '.join(toaddrs) + if pcc: + headers['Cc'] = ', '.join(pcc) + headers['Date'] = formatdate() + # sanity check + if not self._charset.body_encoding: + try: + dummy = body.encode('ascii') + except UnicodeDecodeError: + raise TracError, "Ticket contains non-Ascii chars. " \ + "Please change encoding setting" + msg = MIMEText(body, 'plain') + # Message class computes the wrong type from MIMEText constructor, + # which does not take a Charset object as initializer. Reset the + # encoding type to force a new, valid evaluation + del msg['Content-Transfer-Encoding'] + msg.set_charset(self._charset) + self.add_headers(msg, headers); + self.add_headers(msg, mime_headers); + self.env.log.debug("Sending SMTP notification to %s on port %d to %s" + % (self.smtp_server, self.smtp_port, recipients)) + msgtext = msg.as_string() + # Ensure the message complies with RFC2822: use CRLF line endings + recrlf = re.compile("\r?\n") + msgtext = "\r\n".join(recrlf.split(msgtext)) + self.server.sendmail(msg['From'], recipients, msgtext) + + def finish_send(self): + if self._use_tls: + # avoid false failure detection when the server closes + # the SMTP connection with TLS enabled + import socket + try: + self.server.quit() + except socket.sslerror: + pass + else: + self.server.quit() diff --git a/examples/trac/trac/perm.py b/examples/trac/trac/perm.py new file mode 100644 --- /dev/null +++ b/examples/trac/trac/perm.py @@ -0,0 +1,290 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2003-2005 Edgewall Software +# Copyright (C) 2003-2004 Jonas Borgström +# Copyright (C) 2005 Christopher Lenz +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# +# Author: Jonas Borgström +# Christopher Lenz + +"""Management of permissions.""" + +from trac.config import ExtensionOption +from trac.core import * + +__all__ = ['IPermissionRequestor', 'IPermissionStore', + 'IPermissionGroupProvider', 'PermissionError', 'PermissionSystem'] + +class PermissionError(StandardError): + """Insufficient permissions to complete the operation""" + + def __init__ (self, action): + StandardError.__init__(self) + self.action = action + + def __str__ (self): + return '%s privileges are required to perform this operation' % self.action + + +class IPermissionRequestor(Interface): + """Extension point interface for components that define actions.""" + + def get_permission_actions(): + """Return a list of actions defined by this component. + + The items in the list may either be simple strings, or + `(string, sequence)` tuples. The latter are considered to be "meta + permissions" that group several simple actions under one name for + convenience. + """ + + +class IPermissionStore(Interface): + """Extension point interface for components that provide storage and + management of permissions.""" + + def get_user_permissions(username): + """Return all permissions for the user with the specified name. + + The permissions are returned as a dictionary where the key is the name + of the permission, and the value is either `True` for granted + permissions or `False` for explicitly denied permissions.""" + + def get_all_permissions(): + """Return all permissions for all users. + + The permissions are returned as a list of (subject, action) + formatted tuples.""" + + def grant_permission(username, action): + """Grant a user permission to perform an action.""" + + def revoke_permission(username, action): + """Revokes the permission of the given user to perform an action.""" + + +class IPermissionGroupProvider(Interface): + """Extension point interface for components that provide information about + user groups. + """ + + def get_permission_groups(username): + """Return a list of names of the groups that the user with the specified + name is a member of.""" + + +class DefaultPermissionStore(Component): + """Default implementation of permission storage and simple group management. + + This component uses the `PERMISSION` table in the database to store both + permissions and groups. + """ + implements(IPermissionStore) + + group_providers = ExtensionPoint(IPermissionGroupProvider) + + def get_user_permissions(self, username): + """Retrieve the permissions for the given user and return them in a + dictionary. + + The permissions are stored in the database as (username, action) + records. There's simple support for groups by using lowercase names for + the action column: such a record represents a group and not an actual + permission, and declares that the user is part of that group. + """ + subjects = [username] + for provider in self.group_providers: + subjects += list(provider.get_permission_groups(username)) + + actions = [] + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("SELECT username,action FROM permission") + rows = cursor.fetchall() + while True: + num_users = len(subjects) + num_actions = len(actions) + for user, action in rows: + if user in subjects: + if not action.islower() and action not in actions: + actions.append(action) + if action.islower() and action not in subjects: + # action is actually the name of the permission group + # here + subjects.append(action) + if num_users == len(subjects) and num_actions == len(actions): + break + return [action for action in actions if not action.islower()] + + def get_all_permissions(self): + """Return all permissions for all users. + + The permissions are returned as a list of (subject, action) + formatted tuples.""" + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("SELECT username,action FROM permission") + return [(row[0], row[1]) for row in cursor] + + def grant_permission(self, username, action): + """Grants a user the permission to perform the specified action.""" + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("INSERT INTO permission VALUES (%s, %s)", + (username, action)) + self.log.info('Granted permission for %s to %s' % (action, username)) + db.commit() + + def revoke_permission(self, username, action): + """Revokes a users' permission to perform the specified action.""" + db = self.env.get_db_cnx() + cursor = db.cursor() + cursor.execute("DELETE FROM permission WHERE username=%s AND action=%s", + (username, action)) + self.log.info('Revoked permission for %s to %s' % (action, username)) + db.commit() + + +class DefaultPermissionGroupProvider(Component): + """Provides the basic builtin permission groups 'anonymous' and + 'authenticated'.""" + + implements(IPermissionGroupProvider) + + def get_permission_groups(self, username): + groups = ['anonymous'] + if username and username != 'anonymous': + groups.append('authenticated') + return groups + + +class PermissionSystem(Component): + """Sub-system that manages user permissions.""" + + implements(IPermissionRequestor) + + requestors = ExtensionPoint(IPermissionRequestor) + + store = ExtensionOption('trac', 'permission_store', IPermissionStore, + 'DefaultPermissionStore', + """Name of the component implementing `IPermissionStore`, which is used + for managing user and group permissions.""") + + # Public API + + def grant_permission(self, username, action): + """Grant the user with the given name permission to perform to specified + action.""" + if action.isupper() and action not in self.get_actions(): + raise TracError, '%s is not a valid action.' % action + + self.store.grant_permission(username, action) + + def revoke_permission(self, username, action): + """Revokes the permission of the specified user to perform an action.""" + # TODO: Validate that this permission does in fact exist + if action.isupper() and action not in self.get_actions(): + raise TracError, '%s is not a valid action.' % action + + self.store.revoke_permission(username, action) + + def get_actions(self): + actions = [] + for requestor in self.requestors: + for action in requestor.get_permission_actions(): + if isinstance(action, tuple): + actions.append(action[0]) + else: + actions.append(action) + return actions + + def get_user_permissions(self, username=None): + """Return the permissions of the specified user. + + The return value is a dictionary containing all the actions as keys, and + a boolean value. `True` means that the permission is granted, `False` + means the permission is denied.""" + actions = [] + for requestor in self.requestors: + actions += list(requestor.get_permission_actions()) + permissions = {} + if username: + # Return all permissions that the given user has + meta = {} + for action in actions: + if isinstance(action, tuple): + name, value = action + meta[name] = value + def _expand_meta(action): + permissions[action] = True + if meta.has_key(action): + [_expand_meta(perm) for perm in meta[action]] + for perm in self.store.get_user_permissions(username): + _expand_meta(perm) + else: + # Return all permissions available in the system + for action in actions: + if isinstance(action, tuple): + permissions[action[0]] = True + else: + permissions[action] = True + return permissions + + def get_all_permissions(self): + """Return all permissions for all users. + + The permissions are returned as a list of (subject, action) + formatted tuples.""" + return self.store.get_all_permissions() + + # IPermissionRequestor methods + + def get_permission_actions(self): + """Implement the global `TRAC_ADMIN` meta permission.""" + actions = [] + for requestor in [r for r in self.requestors if r is not self]: + for action in requestor.get_permission_actions(): + if isinstance(action, tuple): + actions.append(action[0]) + else: + actions.append(action) + return [('TRAC_ADMIN', actions)] + + +class PermissionCache(object): + """Cache that maintains the permissions of a single user.""" + + def __init__(self, env, username): + self.perms = PermissionSystem(env).get_user_permissions(username) + + def has_permission(self, action): + return self.perms.has_key(action) + + def assert_permission(self, action): + if not self.perms.has_key(action): + raise PermissionError(action) + + def permissions(self): + return self.perms.keys() + + +class NoPermissionCache(object): + """Permission cache for ''anonymous requests''.""" + + def has_permission(self, action): + return False + + def assert_permission(self, action): + raise PermissionError(action) + + def permissions(self): + return [] diff --git a/examples/trac/trac/scripts/__init__.py b/examples/trac/trac/scripts/__init__.py new file mode 100644 diff --git a/examples/trac/trac/scripts/admin.py b/examples/trac/trac/scripts/admin.py new file mode 100755 --- /dev/null +++ b/examples/trac/trac/scripts/admin.py @@ -0,0 +1,1206 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2003-2006 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://trac.edgewall.com/license.html. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://projects.edgewall.com/trac/. +# + +__copyright__ = 'Copyright (c) 2003-2006 Edgewall Software' + +import cmd +import getpass +import os +import shlex +import shutil +import StringIO +import sys +import time +import traceback +import urllib +import locale + +import trac +from trac import perm, util, db_default +from trac.config import default_dir +from trac.core import TracError +from trac.env import Environment +from trac.perm import PermissionSystem +from trac.ticket.model import * +from trac.util.markup import html +from trac.util.text import to_unicode, wrap +from trac.wiki import WikiPage +from trac.wiki.macros import WikiMacroBase + +def copytree(src, dst, symlinks=False, skip=[]): + """Recursively copy a directory tree using copy2() (from shutil.copytree.) + + Added a `skip` parameter consisting of absolute paths + which we don't want to copy. + """ + names = os.listdir(src) + os.mkdir(dst) + errors = [] + for name in names: + srcname = os.path.join(src, name) + if srcname in skip: + continue + dstname = os.path.join(dst, name) + try: + if symlinks and os.path.islink(srcname): + linkto = os.readlink(srcname) + os.symlink(linkto, dstname) + elif os.path.isdir(srcname): + copytree(srcname, dstname, symlinks, skip) + else: + shutil.copy2(srcname, dstname) + # XXX What about devices, sockets etc.? + except (IOError, os.error), why: + errors.append((srcname, dstname, why)) + if errors: + raise shutil.Error, errors + + +class TracAdmin(cmd.Cmd): + intro = '' + license = trac.__license_long__ + doc_header = 'Trac Admin Console %(ver)s\n' \ + 'Available Commands:\n' \ + % {'ver':trac.__version__ } + ruler = '' + prompt = "Trac> " + __env = None + _date_format = '%Y-%m-%d' + _datetime_format = '%Y-%m-%d %H:%M:%S' + _date_format_hint = 'YYYY-MM-DD' + + def __init__(self, envdir=None): + cmd.Cmd.__init__(self) + self.interactive = False + if envdir: + self.env_set(os.path.abspath(envdir)) + self._permsys = None + + def emptyline(self): + pass + + def onecmd(self, line): + """`line` may be a `str` or an `unicode` object""" + try: + if isinstance(line, str): + line = to_unicode(line, sys.stdin.encoding) + rv = cmd.Cmd.onecmd(self, line) or 0 + except SystemExit: + raise + except Exception, e: + print>>sys.stderr, 'Command failed: %s' % e + rv = 2 + if not self.interactive: + return rv + + def run(self): + self.interactive = True + print 'Welcome to trac-admin %(ver)s\n' \ + 'Interactive Trac administration console.\n' \ + '%(copy)s\n\n' \ + "Type: '?' or 'help' for help on commands.\n" % \ + {'ver':trac.__version__,'copy':__copyright__} + self.cmdloop() + + ## + ## Environment methods + ## + + def env_set(self, envname, env=None): + self.envname = envname + self.prompt = "Trac [%s]> " % self.envname + if env is not None: + self.__env = env + + def env_check(self): + try: + self.__env = Environment(self.envname) + except: + return 0 + return 1 + + def env_open(self): + try: + if not self.__env: + self.__env = Environment(self.envname) + return self.__env + except Exception, e: + print 'Failed to open environment.', e + traceback.print_exc() + sys.exit(1) + + def db_open(self): + return self.env_open().get_db_cnx() + + def db_query(self, sql, cursor=None, params=None): + if not cursor: + cnx = self.db_open() + cursor = cnx.cursor() + if params: + cursor.execute(sql, params) + else: + cursor.execute(sql) + for row in cursor: + yield row + + def db_update(self, sql, cursor=None, params=None): + if not cursor: + cnx = self.db_open() + cursor = cnx.cursor() + else: + cnx = None + if params: + cursor.execute(sql, params) + else: + cursor.execute(sql) + if cnx: + cnx.commit() + + ## + ## Utility methods + ## + + def arg_tokenize (self, argstr): + """`argstr` is an `unicode` string + + ... but shlex is not unicode friendly. + """ + return [unicode(token, 'utf-8') + for token in shlex.split(argstr.encode('utf-8'))] or [''] + + def word_complete (self, text, words): + return [a for a in words if a.startswith (text)] + + def print_listing(self, headers, data, sep=' ', decor=True): + cons_charset = sys.stdout.encoding + ldata = list(data) + if decor: + ldata.insert(0, headers) + print + colw = [] + ncols = len(ldata[0]) # assumes all rows are of equal length + for cnum in xrange(0, ncols): + mw = 0 + for cell in [unicode(d[cnum]) or '' for d in ldata]: + if len(cell) > mw: + mw = len(cell) + colw.append(mw) + for rnum in xrange(len(ldata)): + for cnum in xrange(ncols): + if decor and rnum == 0: + sp = ('%%%ds' % len(sep)) % ' ' # No separator in header + else: + sp = sep + if cnum + 1 == ncols: + sp = '' # No separator after last column + pdata = ((u'%%-%ds%s' % (colw[cnum], sp)) + % (ldata[rnum][cnum] or '')) + if cons_charset and isinstance(pdata, unicode): + pdata = pdata.encode(cons_charset, 'replace') + print pdata, + print + if rnum == 0 and decor: + print ''.join(['-' for x in + xrange(0, (1 + len(sep)) * cnum + sum(colw))]) + print + + def print_doc(cls, docs, stream=None): + if stream is None: + stream = sys.stdout + if not docs: return + for cmd, doc in docs: + print>>stream, cmd + print>>stream, '\t-- %s\n' % doc + print_doc = classmethod(print_doc) + + def get_component_list(self): + rows = self.db_query("SELECT name FROM component") + return [row[0] for row in rows] + + def get_user_list(self): + rows = self.db_query("SELECT DISTINCT username FROM permission") + return [row[0] for row in rows] + + def get_wiki_list(self): + rows = self.db_query('SELECT DISTINCT name FROM wiki') + return [row[0] for row in rows] + + def get_dir_list(self, pathstr, justdirs=False): + dname = os.path.dirname(pathstr) + d = os.path.join(os.getcwd(), dname) + dlist = os.listdir(d) + if justdirs: + result = [] + for entry in dlist: + try: + if os.path.isdir(entry): + result.append(entry) + except: + pass + else: + result = dlist + return result + + def get_enum_list(self, type): + rows = self.db_query("SELECT name FROM enum WHERE type=%s", + params=[type]) + return [row[0] for row in rows] + + def get_milestone_list(self): + rows = self.db_query("SELECT name FROM milestone") + return [row[0] for row in rows] + + def get_version_list(self): + rows = self.db_query("SELECT name FROM version") + return [row[0] for row in rows] + + def _parse_date(self, t): + seconds = None + t = t.strip() + if t == 'now': + seconds = int(time.time()) + else: + for format in [self._date_format, '%x %X', '%x, %X', '%X %x', + '%X, %x', '%x', '%c', '%b %d, %Y']: + try: + pt = time.strptime(t, format) + seconds = int(time.mktime(pt)) + except ValueError: + continue + break + if seconds == None: + try: + seconds = int(t) + except ValueError: + pass + if seconds == None: + print>>sys.stderr, 'Unknown time format %s' % t + return seconds + + def _format_date(self, s): + return time.strftime(self._date_format, time.localtime(s)) + + def _format_datetime(self, s): + return time.strftime(self._datetime_format, time.localtime(s)) + + + ## + ## Available Commands + ## + + ## Help + _help_help = [('help', 'Show documentation')] + + def all_docs(cls): + return (cls._help_about + cls._help_help + + cls._help_initenv + cls._help_hotcopy + + cls._help_resync + cls._help_upgrade + + cls._help_wiki + +# cls._help_config + cls._help_wiki + + cls._help_permission + cls._help_component + + cls._help_ticket + + cls._help_ticket_type + cls._help_priority + + cls._help_severity + cls._help_version + + cls._help_milestone) + all_docs = classmethod(all_docs) + + def do_help(self, line=None): + arg = self.arg_tokenize(line) + if arg[0]: + try: + doc = getattr(self, "_help_" + arg[0]) + self.print_doc(doc) + except AttributeError: + print "No documentation found for '%s'" % arg[0] + else: + print 'trac-admin - The Trac Administration Console %s' \ + % trac.__version__ + if not self.interactive: + print + print "Usage: trac-admin [command [subcommand] [option ...]]\n" + print "Invoking trac-admin without command starts "\ + "interactive mode." + self.print_doc(self.all_docs()) + + + ## About / Version + _help_about = [('about', 'Shows information about trac-admin')] + + def do_about(self, line): + print + print 'Trac Admin Console %s' % trac.__version__ + print '=================================================================' + print self.license + + + ## Quit / EOF + _help_quit = [['quit', 'Exit the program']] + _help_exit = _help_quit + _help_EOF = _help_quit + + def do_quit(self, line): + print + sys.exit() + + do_exit = do_quit # Alias + do_EOF = do_quit # Alias + + + # Component + _help_component = [('component list', 'Show available components'), + ('component add ', 'Add a new component'), + ('component rename ', + 'Rename a component'), + ('component remove ', + 'Remove/uninstall component'), + ('component chown ', + 'Change component ownership')] + + def complete_component(self, text, line, begidx, endidx): + if begidx in (16, 17): + comp = self.get_component_list() + elif begidx > 15 and line.startswith('component chown '): + comp = self.get_user_list() + else: + comp = ['list', 'add', 'rename', 'remove', 'chown'] + return self.word_complete(text, comp) + + def do_component(self, line): + arg = self.arg_tokenize(line) + if arg[0] == 'list': + self._do_component_list() + elif arg[0] == 'add' and len(arg)==3: + name = arg[1] + owner = arg[2] + self._do_component_add(name, owner) + elif arg[0] == 'rename' and len(arg)==3: + name = arg[1] + newname = arg[2] + self._do_component_rename(name, newname) + elif arg[0] == 'remove' and len(arg)==2: + name = arg[1] + self._do_component_remove(name) + elif arg[0] == 'chown' and len(arg)==3: + name = arg[1] + owner = arg[2] + self._do_component_set_owner(name, owner) + else: + self.do_help ('component') + + def _do_component_list(self): + data = [] + for c in Component.select(self.env_open()): + data.append((c.name, c.owner)) + self.print_listing(['Name', 'Owner'], data) + + def _do_component_add(self, name, owner): + component = Component(self.env_open()) + component.name = name + component.owner = owner + component.insert() + + def _do_component_rename(self, name, newname): + component = Component(self.env_open(), name) + component.name = newname + component.update() + + def _do_component_remove(self, name): + component = Component(self.env_open(), name) + component.delete() + + def _do_component_set_owner(self, name, owner): + component = Component(self.env_open(), name) + component.owner = owner + component.update() + + + ## Permission + _help_permission = [('permission list [user]', 'List permission rules'), + ('permission add [action] [...]', + 'Add a new permission rule'), + ('permission remove [action] [...]', + 'Remove permission rule')] + + def complete_permission(self, text, line, begidx, endidx): + argv = self.arg_tokenize(line) + argc = len(argv) + if line[-1] == ' ': # Space starts new argument + argc += 1 + if argc == 2: + comp = ['list', 'add', 'remove'] + elif argc >= 4: + comp = perm.permissions + perm.meta_permissions.keys() + comp.sort() + return self.word_complete(text, comp) + + def do_permission(self, line): + arg = self.arg_tokenize(line) + if arg[0] == 'list': + user = None + if len(arg) > 1: + user = arg[1] + self._do_permission_list(user) + elif arg[0] == 'add' and len(arg) >= 3: + user = arg[1] + for action in arg[2:]: + self._do_permission_add(user, action) + elif arg[0] == 'remove' and len(arg) >= 3: + user = arg[1] + for action in arg[2:]: + self._do_permission_remove(user, action) + else: + self.do_help('permission') + + def _do_permission_list(self, user=None): + if not self._permsys: + self._permsys = PermissionSystem(self.env_open()) + if user: + rows = [] + perms = self._permsys.get_user_permissions(user) + for action in perms: + if perms[action]: + rows.append((user, action)) + else: + rows = self._permsys.get_all_permissions() + rows.sort() + self.print_listing(['User', 'Action'], rows) + print + print 'Available actions:' + actions = self._permsys.get_actions() + actions.sort() + text = ', '.join(actions) + print wrap(text, initial_indent=' ', subsequent_indent=' ', + linesep='\n') + print + + def _do_permission_add(self, user, action): + if not self._permsys: + self._permsys = PermissionSystem(self.env_open()) + if not action.islower() and not action.isupper(): + print 'Group names must be in lower case and actions in upper case' + return + self._permsys.grant_permission(user, action) + + def _do_permission_remove(self, user, action): + if not self._permsys: + self._permsys = PermissionSystem(self.env_open()) + rows = self._permsys.get_all_permissions() + if action == '*': + for row in rows: + if user != '*' and user != row[0]: + continue + self._permsys.revoke_permission(row[0], row[1]) + else: + for row in rows: + if action != row[1]: + continue + if user != '*' and user != row[0]: + continue + self._permsys.revoke_permission(row[0], row[1]) + + ## Initenv + _help_initenv = [('initenv', + 'Create and initialize a new environment interactively'), + ('initenv ', + 'Create and initialize a new environment from arguments')] + + def do_initdb(self, line): + self.do_initenv(line) + + def get_initenv_args(self): + returnvals = [] + print 'Creating a new Trac environment at %s' % self.envname + print + print 'Trac will first ask a few questions about your environment ' + print 'in order to initalize and prepare the project database.' + print + print " Please enter the name of your project." + print " This name will be used in page titles and descriptions." + print + dp = 'My Project' + returnvals.append(raw_input('Project Name [%s]> ' % dp).strip() or dp) + print + print ' Please specify the connection string for the database to use.' + print ' By default, a local SQLite database is created in the environment ' + print ' directory. It is also possible to use an already existing ' + print ' PostgreSQL database (check the Trac documentation for the exact ' + print ' connection string syntax).' + print + ddb = 'sqlite:db/trac.db' + prompt = 'Database connection string [%s]> ' % ddb + returnvals.append(raw_input(prompt).strip() or ddb) + print + print ' Please specify the type of version control system,' + print ' By default, it will be svn.' + print + print ' If you don\'t want to use Trac with version control integration, ' + print ' choose the default here and don\'t specify a repository directory. ' + print ' in the next question.' + print + drpt = 'svn' + prompt = 'Repository type [%s]> ' % drpt + returnvals.append(raw_input(prompt).strip() or drpt) + print + print ' Please specify the absolute path to the version control ' + print ' repository, or leave it blank to use Trac without a repository.' + print ' You can also set the repository location later.' + print + prompt = 'Path to repository [/path/to/repos]> ' + returnvals.append(raw_input(prompt).strip()) + print + print ' Please enter location of Trac page templates.' + print ' Default is the location of the site-wide templates installed with Trac.' + print + dt = default_dir('templates') + prompt = 'Templates directory [%s]> ' % dt + returnvals.append(raw_input(prompt).strip() or dt) + print + return returnvals + + def do_initenv(self, line): + if self.env_check(): + print "Initenv for '%s' failed." % self.envname + print "Does an environment already exist?" + return 2 + + if os.path.exists(self.envname) and os.listdir(self.envname): + print "Initenv for '%s' failed." % self.envname + print "Directory exists and is not empty." + return 2 + + arg = self.arg_tokenize(line) + project_name = None + db_str = None + repository_dir = None + templates_dir = None + if len(arg) == 1 and not arg[0]: + returnvals = self.get_initenv_args() + project_name, db_str, repository_type, repository_dir, \ + templates_dir = returnvals + elif len(arg) != 5: + print 'Wrong number of arguments to initenv: %d' % len(arg) + return 2 + else: + project_name, db_str, repository_type, repository_dir, \ + templates_dir = arg[:5] + + if not os.access(os.path.join(templates_dir, 'header.cs'), os.F_OK): + print templates_dir, "doesn't look like a Trac templates directory" + return 2 + + try: + print 'Creating and Initializing Project' + options = [ + ('trac', 'database', db_str), + ('trac', 'repository_type', repository_type), + ('trac', 'repository_dir', repository_dir), + ('trac', 'templates_dir', templates_dir), + ('project', 'name', project_name), + ] + try: + self.__env = Environment(self.envname, create=True, + options=options) + except Exception, e: + print 'Failed to create environment.', e + traceback.print_exc() + sys.exit(1) + + # Add a few default wiki pages + print ' Installing default wiki pages' + cnx = self.__env.get_db_cnx() + cursor = cnx.cursor() + self._do_wiki_load(default_dir('wiki'), cursor) + cnx.commit() + + if repository_dir: + try: + repos = self.__env.get_repository() + if repos: + print ' Indexing repository' + repos.sync() + except TracError, e: + print>>sys.stderr, "\nWarning:\n" + if repository_type == "svn": + print>>sys.stderr, "You should install the SVN bindings" + else: + print>>sys.stderr, "Repository type %s not supported" \ + % repository_type + except Exception, e: + print 'Failed to initialize environment.', e + traceback.print_exc() + return 2 + + print """ +--------------------------------------------------------------------- +Project environment for '%(project_name)s' created. + +You may now configure the environment by editing the file: + + %(config_path)s + +If you'd like to take this new project environment for a test drive, +try running the Trac standalone web server `tracd`: + + tracd --port 8000 %(project_path)s + +Then point your browser to http://localhost:8000/%(project_dir)s. +There you can also browse the documentation for your installed +version of Trac, including information on further setup (such as +deploying Trac to a real web server). + +The latest documentation can also always be found on the project +website: + + http://projects.edgewall.com/trac/ + +Congratulations! +""" % dict(project_name=project_name, project_path=self.envname, + project_dir=os.path.basename(self.envname), + config_path=os.path.join(self.envname, 'conf', 'trac.ini')) + + _help_resync = [('resync', 'Re-synchronize trac with the repository')] + + ## Resync + def do_resync(self, line): + print 'Resyncing repository history...' + cnx = self.db_open() + cursor = cnx.cursor() + cursor.execute("DELETE FROM revision") + cursor.execute("DELETE FROM node_change") + repos = self.__env.get_repository() + cursor.execute("DELETE FROM system WHERE name='repository_dir'") + cursor.execute("INSERT INTO system (name,value) " + "VALUES ('repository_dir',%s)", (repos.name,)) + repos.sync() + print 'Done.' + + ## Wiki + _help_wiki = [('wiki list', 'List wiki pages'), + ('wiki remove ', 'Remove wiki page'), + ('wiki export [file]', + 'Export wiki page to file or stdout'), + ('wiki import [file]', + 'Import wiki page from file or stdin'), + ('wiki dump ', + 'Export all wiki pages to files named by title'), + ('wiki load ', + 'Import all wiki pages from directory'), + ('wiki upgrade', + 'Upgrade default wiki pages to current version')] + + def complete_wiki(self, text, line, begidx, endidx): + argv = self.arg_tokenize(line) + argc = len(argv) + if line[-1] == ' ': # Space starts new argument + argc += 1 + if argc == 2: + comp = ['list', 'remove', 'import', 'export', 'dump', 'load', + 'upgrade'] + else: + if argv[1] in ('dump', 'load'): + comp = self.get_dir_list(argv[-1], 1) + elif argv[1] == 'remove': + comp = self.get_wiki_list() + elif argv[1] in ('export', 'import'): + if argc == 3: + comp = self.get_wiki_list() + elif argc == 4: + comp = self.get_dir_list(argv[-1]) + return self.word_complete(text, comp) + + def do_wiki(self, line): + arg = self.arg_tokenize(line) + if arg[0] == 'list': + self._do_wiki_list() + elif arg[0] == 'remove' and len(arg)==2: + name = arg[1] + self._do_wiki_remove(name) + elif arg[0] == 'import' and len(arg) == 3: + title = arg[1] + file = arg[2] + self._do_wiki_import(file, title) + elif arg[0] == 'export' and len(arg) in [2,3]: + page = arg[1] + file = (len(arg) == 3 and arg[2]) or None + self._do_wiki_export(page, file) + elif arg[0] == 'dump' and len(arg) in [1,2]: + dir = (len(arg) == 2 and arg[1]) or '' + self._do_wiki_dump(dir) + elif arg[0] == 'load' and len(arg) in [1,2]: + dir = (len(arg) == 2 and arg[1]) or '' + self._do_wiki_load(dir) + elif arg[0] == 'upgrade' and len(arg) == 1: + self._do_wiki_load(default_dir('wiki'), + ignore=['WikiStart', 'checkwiki.py'], + create_only=['InterMapTxt']) + else: + self.do_help ('wiki') + + def _do_wiki_list(self): + rows = self.db_query("SELECT name, max(version), max(time) " + "FROM wiki GROUP BY name ORDER BY name") + self.print_listing(['Title', 'Edits', 'Modified'], + [(r[0], r[1], self._format_datetime(r[2])) for r in rows]) + + def _do_wiki_remove(self, name): + page = WikiPage(self.env_open(), name) + page.delete() + + def _do_wiki_import(self, filename, title, cursor=None, + create_only=[]): + if not os.path.isfile(filename): + raise Exception, '%s is not a file' % filename + + f = open(filename,'r') + data = to_unicode(f.read(), 'utf-8') + + # Make sure we don't insert the exact same page twice + rows = self.db_query("SELECT text FROM wiki WHERE name=%s " + "ORDER BY version DESC LIMIT 1", cursor, + params=(title,)) + old = list(rows) + if old and title in create_only: + print ' %s already exists.' % title + return + if old and data == old[0][0]: + print ' %s already up to date.' % title + return + f.close() + + self.db_update("INSERT INTO wiki(version,name,time,author,ipnr,text) " + " SELECT 1+COALESCE(max(version),0),%s,%s," + " 'trac','127.0.0.1',%s FROM wiki " + " WHERE name=%s", + cursor, (title, int(time.time()), data, title)) + + def _do_wiki_export(self, page, filename=''): + data = self.db_query("SELECT text FROM wiki WHERE name=%s " + "ORDER BY version DESC LIMIT 1", params=[page]) + text = data.next()[0] + if not filename: + print text + else: + if os.path.isfile(filename): + raise Exception("File '%s' exists" % filename) + f = open(filename,'w') + f.write(text.encode('utf-8')) + f.close() + + def _do_wiki_dump(self, dir): + pages = self.get_wiki_list() + for p in pages: + dst = os.path.join(dir, urllib.quote(p, '')) + print " %s => %s" % (p, dst) + self._do_wiki_export(p, dst) + + def _do_wiki_load(self, dir, cursor=None, ignore=[], create_only=[]): + for page in os.listdir(dir): + if page in ignore: + continue + filename = os.path.join(dir, page) + page = urllib.unquote(page) + if os.path.isfile(filename): + print " %s => %s" % (filename, page) + self._do_wiki_import(filename, page, cursor, create_only) + + ## Ticket + _help_ticket = [('ticket remove ', 'Remove ticket')] + + def complete_ticket(self, text, line, begidx, endidx): + argv = self.arg_tokenize(line) + argc = len(argv) + if line[-1] == ' ': # Space starts new argument + argc += 1 + comp = [] + if argc == 2: + comp = ['remove'] + return self.word_complete(text, comp) + + def do_ticket(self, line): + arg = self.arg_tokenize(line) + if arg[0] == 'remove' and len(arg)==2: + try: + number = int(arg[1]) + except ValueError: + print>>sys.stderr, " must be a number" + return + self._do_ticket_remove(number) + else: + self.do_help ('ticket') + + def _do_ticket_remove(self, number): + ticket = Ticket(self.env_open(), number) + ticket.delete() + print "Ticket %d and all associated data removed." % number + + + ## (Ticket) Type + _help_ticket_type = [('ticket_type list', 'Show possible ticket types'), + ('ticket_type add ', 'Add a ticket type'), + ('ticket_type change ', + 'Change a ticket type'), + ('ticket_type remove ', 'Remove a ticket type'), + ('ticket_type order up|down', + 'Move a ticket type up or down in the list')] + + def complete_ticket_type (self, text, line, begidx, endidx): + if begidx == 16: + comp = self.get_enum_list ('ticket_type') + elif begidx < 15: + comp = ['list', 'add', 'change', 'remove', 'order'] + return self.word_complete(text, comp) + + def do_ticket_type(self, line): + self._do_enum('ticket_type', line) + + ## (Ticket) Priority + _help_priority = [('priority list', 'Show possible ticket priorities'), + ('priority add ', 'Add a priority value option'), + ('priority change ', + 'Change a priority value'), + ('priority remove ', 'Remove priority value'), + ('priority order up|down', + 'Move a priority value up or down in the list')] + + def complete_priority (self, text, line, begidx, endidx): + if begidx == 16: + comp = self.get_enum_list ('priority') + elif begidx < 15: + comp = ['list', 'add', 'change', 'remove', 'order'] + return self.word_complete(text, comp) + + def do_priority(self, line): + self._do_enum('priority', line) + + ## (Ticket) Severity + _help_severity = [('severity list', 'Show possible ticket severities'), + ('severity add ', 'Add a severity value option'), + ('severity change ', + 'Change a severity value'), + ('severity remove ', 'Remove severity value'), + ('severity order up|down', + 'Move a severity value up or down in the list')] + + def complete_severity (self, text, line, begidx, endidx): + if begidx == 16: + comp = self.get_enum_list ('severity') + elif begidx < 15: + comp = ['list', 'add', 'change', 'remove', 'order'] + return self.word_complete(text, comp) + + def do_severity(self, line): + self._do_enum('severity', line) + + # Type, priority, severity share the same datastructure and methods: + + _enum_map = {'ticket_type': Type, 'priority': Priority, + 'severity': Severity} + + def _do_enum(self, type, line): + arg = self.arg_tokenize(line) + if arg[0] == 'list': + self._do_enum_list(type) + elif arg[0] == 'add' and len(arg) == 2: + name = arg[1] + self._do_enum_add(type, name) + elif arg[0] == 'change' and len(arg) == 3: + name = arg[1] + newname = arg[2] + self._do_enum_change(type, name, newname) + elif arg[0] == 'remove' and len(arg) == 2: + name = arg[1] + self._do_enum_remove(type, name) + elif arg[0] == 'order' and len(arg) == 3 and arg[2] in ('up', 'down'): + name = arg[1] + if arg[2] == 'up': + direction = -1 + else: + direction = 1 + self._do_enum_order(type, name, direction) + else: + self.do_help(type) + + def _do_enum_list(self, type): + enum_cls = self._enum_map[type] + self.print_listing(['Possible Values'], + [(e.name,) for e in enum_cls.select(self.env_open())]) + + def _do_enum_add(self, type, name): + cnx = self.db_open() + sql = ("INSERT INTO enum(value,type,name) " + " SELECT 1+COALESCE(max(%(cast)s),0),'%(type)s','%(name)s'" + " FROM enum WHERE type='%(type)s'" + % {'type':type, 'name':name, 'cast': cnx.cast('value', 'int')}) + cursor = cnx.cursor() + self.db_update(sql, cursor) + cnx.commit() + + def _do_enum_change(self, type, name, newname): + enum_cls = self._enum_map[type] + enum = enum_cls(self.env_open(), name) + enum.name = newname + enum.update() + + def _do_enum_remove(self, type, name): + enum_cls = self._enum_map[type] + enum = enum_cls(self.env_open(), name) + enum.delete() + + def _do_enum_order(self, type, name, direction): + env = self.env_open() + enum_cls = self._enum_map[type] + enum1 = enum_cls(env, name) + enum1.value = int(float(enum1.value) + direction) + for enum2 in enum_cls.select(env): + if int(float(enum2.value)) == enum1.value: + enum2.value = int(float(enum2.value) - direction) + break + else: + return + enum1.update() + enum2.update() + + ## Milestone + + _help_milestone = [('milestone list', 'Show milestones'), + ('milestone add [due]', 'Add milestone'), + ('milestone rename ', + 'Rename milestone'), + ('milestone due ', + 'Set milestone due date (Format: "%s" or "now")' + % _date_format_hint), + ('milestone completed ', + 'Set milestone completed date (Format: "%s" or "now")' + % _date_format_hint), + ('milestone remove ', 'Remove milestone')] + + def complete_milestone (self, text, line, begidx, endidx): + if begidx in (15, 17): + comp = self.get_milestone_list() + elif begidx < 15: + comp = ['list', 'add', 'rename', 'time', 'remove'] + return self.word_complete(text, comp) + + def do_milestone(self, line): + arg = self.arg_tokenize(line) + if arg[0] == 'list': + self._do_milestone_list() + elif arg[0] == 'add' and len(arg) in [2,3]: + self._do_milestone_add(arg[1]) + if len(arg) == 3: + self._do_milestone_set_due(arg[1], arg[2]) + elif arg[0] == 'rename' and len(arg) == 3: + self._do_milestone_rename(arg[1], arg[2]) + elif arg[0] == 'remove' and len(arg) == 2: + self._do_milestone_remove(arg[1]) + elif arg[0] == 'due' and len(arg) == 3: + self._do_milestone_set_due(arg[1], arg[2]) + elif arg[0] == 'completed' and len(arg) == 3: + self._do_milestone_set_completed(arg[1], arg[2]) + else: + self.do_help('milestone') + + def _do_milestone_list(self): + data = [] + for m in Milestone.select(self.env_open()): + data.append((m.name, m.due and self._format_date(m.due), + m.completed and self._format_datetime(m.completed))) + + self.print_listing(['Name', 'Due', 'Completed'], data) + + def _do_milestone_rename(self, name, newname): + milestone = Milestone(self.env_open(), name) + milestone.name = newname + milestone.update() + + def _do_milestone_add(self, name): + milestone = Milestone(self.env_open()) + milestone.name = name + milestone.insert() + + def _do_milestone_remove(self, name): + milestone = Milestone(self.env_open(), name) + milestone.delete(author=getpass.getuser()) + + def _do_milestone_set_due(self, name, t): + milestone = Milestone(self.env_open(), name) + milestone.due = self._parse_date(t) + milestone.update() + + def _do_milestone_set_completed(self, name, t): + milestone = Milestone(self.env_open(), name) + milestone.completed = self._parse_date(t) + milestone.update() + + ## Version + _help_version = [('version list', 'Show versions'), + ('version add [time]', 'Add version'), + ('version rename ', + 'Rename version'), + ('version time