From f51d4ed0b8d3422558bc0e97d35060746d5049b2 Mon Sep 17 00:00:00 2001 From: Pawel Solyga Date: Sat, 30 May 2009 00:36:52 +0200 Subject: [PATCH] Add taggable-mixin to Melange repository. --- app/taggable-mixin/COPYING | 202 + app/taggable-mixin/taggable.html | 9732 ++++++++++++++++++++++++++++++++++ app/taggable-mixin/taggable.py | 201 + app/taggable-mixin/taggable_tests.py | 243 + 4 files changed, 10378 insertions(+) create mode 100644 app/taggable-mixin/COPYING create mode 100644 app/taggable-mixin/taggable.html create mode 100644 app/taggable-mixin/taggable.py create mode 100644 app/taggable-mixin/taggable_tests.py diff --git a/app/taggable-mixin/COPYING b/app/taggable-mixin/COPYING new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/app/taggable-mixin/COPYING @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/app/taggable-mixin/taggable.html b/app/taggable-mixin/taggable.html new file mode 100644 index 00000000..d0935011 --- /dev/null +++ b/app/taggable-mixin/taggable.html @@ -0,0 +1,9732 @@ + + + + + + + + + + + + Taggable - a portable mixin class for adding Tags to Google AppEngine Models + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+
+
+
<!--{{{-->
+<link rel='alternate' type='application/rss+xml' title='RSS' href='index.xml'/>
+<!--}}}-->
+
+
+
Background: #fff
+Foreground: #000
+PrimaryPale: #8cf
+PrimaryLight: #18f
+PrimaryMid: #04b
+PrimaryDark: #014
+SecondaryPale: #ffc
+SecondaryLight: #fe8
+SecondaryMid: #db4
+SecondaryDark: #841
+TertiaryPale: #eee
+TertiaryLight: #ccc
+TertiaryMid: #999
+TertiaryDark: #666
+Error: #f88
+
+
+
/*{{{*/
+body {background:[[ColorPalette::Background]]; color:[[ColorPalette::Foreground]];}
+
+a {color:[[ColorPalette::PrimaryMid]];}
+a:hover {background-color:[[ColorPalette::PrimaryMid]]; color:[[ColorPalette::Background]];}
+a img {border:0;}
+
+h1,h2,h3,h4,h5,h6 {color:[[ColorPalette::SecondaryDark]]; background:transparent;}
+h1 {border-bottom:2px solid [[ColorPalette::TertiaryLight]];}
+h2,h3 {border-bottom:1px solid [[ColorPalette::TertiaryLight]];}
+
+.button {color:[[ColorPalette::PrimaryDark]]; border:1px solid [[ColorPalette::Background]];}
+.button:hover {color:[[ColorPalette::PrimaryDark]]; background:[[ColorPalette::SecondaryLight]]; border-color:[[ColorPalette::SecondaryMid]];}
+.button:active {color:[[ColorPalette::Background]]; background:[[ColorPalette::SecondaryMid]]; border:1px solid [[ColorPalette::SecondaryDark]];}
+
+.header {background:[[ColorPalette::PrimaryMid]];}
+.headerShadow {color:[[ColorPalette::Foreground]];}
+.headerShadow a {font-weight:normal; color:[[ColorPalette::Foreground]];}
+.headerForeground {color:[[ColorPalette::Background]];}
+.headerForeground a {font-weight:normal; color:[[ColorPalette::PrimaryPale]];}
+
+.tabSelected{color:[[ColorPalette::PrimaryDark]];
+	background:[[ColorPalette::TertiaryPale]];
+	border-left:1px solid [[ColorPalette::TertiaryLight]];
+	border-top:1px solid [[ColorPalette::TertiaryLight]];
+	border-right:1px solid [[ColorPalette::TertiaryLight]];
+}
+.tabUnselected {color:[[ColorPalette::Background]]; background:[[ColorPalette::TertiaryMid]];}
+.tabContents {color:[[ColorPalette::PrimaryDark]]; background:[[ColorPalette::TertiaryPale]]; border:1px solid [[ColorPalette::TertiaryLight]];}
+.tabContents .button {border:0;}
+
+#sidebar {}
+#sidebarOptions input {border:1px solid [[ColorPalette::PrimaryMid]];}
+#sidebarOptions .sliderPanel {background:[[ColorPalette::PrimaryPale]];}
+#sidebarOptions .sliderPanel a {border:none;color:[[ColorPalette::PrimaryMid]];}
+#sidebarOptions .sliderPanel a:hover {color:[[ColorPalette::Background]]; background:[[ColorPalette::PrimaryMid]];}
+#sidebarOptions .sliderPanel a:active {color:[[ColorPalette::PrimaryMid]]; background:[[ColorPalette::Background]];}
+
+.wizard {background:[[ColorPalette::PrimaryPale]]; border:1px solid [[ColorPalette::PrimaryMid]];}
+.wizard h1 {color:[[ColorPalette::PrimaryDark]]; border:none;}
+.wizard h2 {color:[[ColorPalette::Foreground]]; border:none;}
+.wizardStep {background:[[ColorPalette::Background]]; color:[[ColorPalette::Foreground]];
+	border:1px solid [[ColorPalette::PrimaryMid]];}
+.wizardStep.wizardStepDone {background:[[ColorPalette::TertiaryLight]];}
+.wizardFooter {background:[[ColorPalette::PrimaryPale]];}
+.wizardFooter .status {background:[[ColorPalette::PrimaryDark]]; color:[[ColorPalette::Background]];}
+.wizard .button {color:[[ColorPalette::Foreground]]; background:[[ColorPalette::SecondaryLight]]; border: 1px solid;
+	border-color:[[ColorPalette::SecondaryPale]] [[ColorPalette::SecondaryDark]] [[ColorPalette::SecondaryDark]] [[ColorPalette::SecondaryPale]];}
+.wizard .button:hover {color:[[ColorPalette::Foreground]]; background:[[ColorPalette::Background]];}
+.wizard .button:active {color:[[ColorPalette::Background]]; background:[[ColorPalette::Foreground]]; border: 1px solid;
+	border-color:[[ColorPalette::PrimaryDark]] [[ColorPalette::PrimaryPale]] [[ColorPalette::PrimaryPale]] [[ColorPalette::PrimaryDark]];}
+
+#messageArea {border:1px solid [[ColorPalette::SecondaryMid]]; background:[[ColorPalette::SecondaryLight]]; color:[[ColorPalette::Foreground]];}
+#messageArea .button {color:[[ColorPalette::PrimaryMid]]; background:[[ColorPalette::SecondaryPale]]; border:none;}
+
+.popupTiddler {background:[[ColorPalette::TertiaryPale]]; border:2px solid [[ColorPalette::TertiaryMid]];}
+
+.popup {background:[[ColorPalette::TertiaryPale]]; color:[[ColorPalette::TertiaryDark]]; border-left:1px solid [[ColorPalette::TertiaryMid]]; border-top:1px solid [[ColorPalette::TertiaryMid]]; border-right:2px solid [[ColorPalette::TertiaryDark]]; border-bottom:2px solid [[ColorPalette::TertiaryDark]];}
+.popup hr {color:[[ColorPalette::PrimaryDark]]; background:[[ColorPalette::PrimaryDark]]; border-bottom:1px;}
+.popup li.disabled {color:[[ColorPalette::TertiaryMid]];}
+.popup li a, .popup li a:visited {color:[[ColorPalette::Foreground]]; border: none;}
+.popup li a:hover {background:[[ColorPalette::SecondaryLight]]; color:[[ColorPalette::Foreground]]; border: none;}
+.popup li a:active {background:[[ColorPalette::SecondaryPale]]; color:[[ColorPalette::Foreground]]; border: none;}
+.popupHighlight {background:[[ColorPalette::Background]]; color:[[ColorPalette::Foreground]];}
+.listBreak div {border-bottom:1px solid [[ColorPalette::TertiaryDark]];}
+
+.tiddler .defaultCommand {font-weight:bold;}
+
+.shadow .title {color:[[ColorPalette::TertiaryDark]];}
+
+.title {color:[[ColorPalette::SecondaryDark]];}
+.subtitle {color:[[ColorPalette::TertiaryDark]];}
+
+.toolbar {color:[[ColorPalette::PrimaryMid]];}
+.toolbar a {color:[[ColorPalette::TertiaryLight]];}
+.selected .toolbar a {color:[[ColorPalette::TertiaryMid]];}
+.selected .toolbar a:hover {color:[[ColorPalette::Foreground]];}
+
+.tagging, .tagged {border:1px solid [[ColorPalette::TertiaryPale]]; background-color:[[ColorPalette::TertiaryPale]];}
+.selected .tagging, .selected .tagged {background-color:[[ColorPalette::TertiaryLight]]; border:1px solid [[ColorPalette::TertiaryMid]];}
+.tagging .listTitle, .tagged .listTitle {color:[[ColorPalette::PrimaryDark]];}
+.tagging .button, .tagged .button {border:none;}
+
+.footer {color:[[ColorPalette::TertiaryLight]];}
+.selected .footer {color:[[ColorPalette::TertiaryMid]];}
+
+.sparkline {background:[[ColorPalette::PrimaryPale]]; border:0;}
+.sparktick {background:[[ColorPalette::PrimaryDark]];}
+
+.error, .errorButton {color:[[ColorPalette::Foreground]]; background:[[ColorPalette::Error]];}
+.warning {color:[[ColorPalette::Foreground]]; background:[[ColorPalette::SecondaryPale]];}
+.lowlight {background:[[ColorPalette::TertiaryLight]];}
+
+.zoomer {background:none; color:[[ColorPalette::TertiaryMid]]; border:3px solid [[ColorPalette::TertiaryMid]];}
+
+.imageLink, #displayArea .imageLink {background:transparent;}
+
+.annotation {background:[[ColorPalette::SecondaryLight]]; color:[[ColorPalette::Foreground]]; border:2px solid [[ColorPalette::SecondaryMid]];}
+
+.viewer .listTitle {list-style-type:none; margin-left:-2em;}
+.viewer .button {border:1px solid [[ColorPalette::SecondaryMid]];}
+.viewer blockquote {border-left:3px solid [[ColorPalette::TertiaryDark]];}
+
+.viewer table, table.twtable {border:2px solid [[ColorPalette::TertiaryDark]];}
+.viewer th, .viewer thead td, .twtable th, .twtable thead td {background:[[ColorPalette::SecondaryMid]]; border:1px solid [[ColorPalette::TertiaryDark]]; color:[[ColorPalette::Background]];}
+.viewer td, .viewer tr, .twtable td, .twtable tr {border:1px solid [[ColorPalette::TertiaryDark]];}
+
+.viewer pre {border:1px solid [[ColorPalette::SecondaryLight]]; background:[[ColorPalette::SecondaryPale]];}
+.viewer code {color:[[ColorPalette::SecondaryDark]];}
+.viewer hr {border:0; border-top:dashed 1px [[ColorPalette::TertiaryDark]]; color:[[ColorPalette::TertiaryDark]];}
+
+.highlight, .marked {background:[[ColorPalette::SecondaryLight]];}
+
+.editor input {border:1px solid [[ColorPalette::PrimaryMid]];}
+.editor textarea {border:1px solid [[ColorPalette::PrimaryMid]]; width:100%;}
+.editorFooter {color:[[ColorPalette::TertiaryMid]];}
+
+#backstageArea {background:[[ColorPalette::Foreground]]; color:[[ColorPalette::TertiaryMid]];}
+#backstageArea a {background:[[ColorPalette::Foreground]]; color:[[ColorPalette::Background]]; border:none;}
+#backstageArea a:hover {background:[[ColorPalette::SecondaryLight]]; color:[[ColorPalette::Foreground]]; }
+#backstageArea a.backstageSelTab {background:[[ColorPalette::Background]]; color:[[ColorPalette::Foreground]];}
+#backstageButton a {background:none; color:[[ColorPalette::Background]]; border:none;}
+#backstageButton a:hover {background:[[ColorPalette::Foreground]]; color:[[ColorPalette::Background]]; border:none;}
+#backstagePanel {background:[[ColorPalette::Background]]; border-color: [[ColorPalette::Background]] [[ColorPalette::TertiaryDark]] [[ColorPalette::TertiaryDark]] [[ColorPalette::TertiaryDark]];}
+.backstagePanelFooter .button {border:none; color:[[ColorPalette::Background]];}
+.backstagePanelFooter .button:hover {color:[[ColorPalette::Foreground]];}
+#backstageCloak {background:[[ColorPalette::Foreground]]; opacity:0.6; filter:'alpha(opacity:60)';}
+/*}}}*/
+
+
+
/*{{{*/
+* html .tiddler {height:1%;}
+
+body {font-size:.75em; font-family:arial,helvetica; margin:0; padding:0;}
+
+h1,h2,h3,h4,h5,h6 {font-weight:bold; text-decoration:none;}
+h1,h2,h3 {padding-bottom:1px; margin-top:1.2em;margin-bottom:0.3em;}
+h4,h5,h6 {margin-top:1em;}
+h1 {font-size:1.35em;}
+h2 {font-size:1.25em;}
+h3 {font-size:1.1em;}
+h4 {font-size:1em;}
+h5 {font-size:.9em;}
+
+hr {height:1px;}
+
+a {text-decoration:none;}
+
+dt {font-weight:bold;}
+
+ol {list-style-type:decimal;}
+ol ol {list-style-type:lower-alpha;}
+ol ol ol {list-style-type:lower-roman;}
+ol ol ol ol {list-style-type:decimal;}
+ol ol ol ol ol {list-style-type:lower-alpha;}
+ol ol ol ol ol ol {list-style-type:lower-roman;}
+ol ol ol ol ol ol ol {list-style-type:decimal;}
+
+.txtOptionInput {width:11em;}
+
+#contentWrapper .chkOptionInput {border:0;}
+
+.externalLink {text-decoration:underline;}
+
+.indent {margin-left:3em;}
+.outdent {margin-left:3em; text-indent:-3em;}
+code.escaped {white-space:nowrap;}
+
+.tiddlyLinkExisting {font-weight:bold;}
+.tiddlyLinkNonExisting {font-style:italic;}
+
+/* the 'a' is required for IE, otherwise it renders the whole tiddler in bold */
+a.tiddlyLinkNonExisting.shadow {font-weight:bold;}
+
+#mainMenu .tiddlyLinkExisting,
+	#mainMenu .tiddlyLinkNonExisting,
+	#sidebarTabs .tiddlyLinkNonExisting {font-weight:normal; font-style:normal;}
+#sidebarTabs .tiddlyLinkExisting {font-weight:bold; font-style:normal;}
+
+.header {position:relative;}
+.header a:hover {background:transparent;}
+.headerShadow {position:relative; padding:4.5em 0em 1em 1em; left:-1px; top:-1px;}
+.headerForeground {position:absolute; padding:4.5em 0em 1em 1em; left:0px; top:0px;}
+
+.siteTitle {font-size:3em;}
+.siteSubtitle {font-size:1.2em;}
+
+#mainMenu {position:absolute; left:0; width:10em; text-align:right; line-height:1.6em; padding:1.5em 0.5em 0.5em 0.5em; font-size:1.1em;}
+
+#sidebar {position:absolute; right:3px; width:16em; font-size:.9em;}
+#sidebarOptions {padding-top:0.3em;}
+#sidebarOptions a {margin:0em 0.2em; padding:0.2em 0.3em; display:block;}
+#sidebarOptions input {margin:0.4em 0.5em;}
+#sidebarOptions .sliderPanel {margin-left:1em; padding:0.5em; font-size:.85em;}
+#sidebarOptions .sliderPanel a {font-weight:bold; display:inline; padding:0;}
+#sidebarOptions .sliderPanel input {margin:0 0 .3em 0;}
+#sidebarTabs .tabContents {width:15em; overflow:hidden;}
+
+.wizard {padding:0.1em 1em 0em 2em;}
+.wizard h1 {font-size:2em; font-weight:bold; background:none; padding:0em 0em 0em 0em; margin:0.4em 0em 0.2em 0em;}
+.wizard h2 {font-size:1.2em; font-weight:bold; background:none; padding:0em 0em 0em 0em; margin:0.4em 0em 0.2em 0em;}
+.wizardStep {padding:1em 1em 1em 1em;}
+.wizard .button {margin:0.5em 0em 0em 0em; font-size:1.2em;}
+.wizardFooter {padding:0.8em 0.4em 0.8em 0em;}
+.wizardFooter .status {padding:0em 0.4em 0em 0.4em; margin-left:1em;}
+.wizard .button {padding:0.1em 0.2em 0.1em 0.2em;}
+
+#messageArea {position:fixed; top:2em; right:0em; margin:0.5em; padding:0.5em; z-index:2000; _position:absolute;}
+.messageToolbar {display:block; text-align:right; padding:0.2em 0.2em 0.2em 0.2em;}
+#messageArea a {text-decoration:underline;}
+
+.tiddlerPopupButton {padding:0.2em 0.2em 0.2em 0.2em;}
+.popupTiddler {position: absolute; z-index:300; padding:1em 1em 1em 1em; margin:0;}
+
+.popup {position:absolute; z-index:300; font-size:.9em; padding:0; list-style:none; margin:0;}
+.popup .popupMessage {padding:0.4em;}
+.popup hr {display:block; height:1px; width:auto; padding:0; margin:0.2em 0em;}
+.popup li.disabled {padding:0.4em;}
+.popup li a {display:block; padding:0.4em; font-weight:normal; cursor:pointer;}
+.listBreak {font-size:1px; line-height:1px;}
+.listBreak div {margin:2px 0;}
+
+.tabset {padding:1em 0em 0em 0.5em;}
+.tab {margin:0em 0em 0em 0.25em; padding:2px;}
+.tabContents {padding:0.5em;}
+.tabContents ul, .tabContents ol {margin:0; padding:0;}
+.txtMainTab .tabContents li {list-style:none;}
+.tabContents li.listLink { margin-left:.75em;}
+
+#contentWrapper {display:block;}
+#splashScreen {display:none;}
+
+#displayArea {margin:1em 17em 0em 14em;}
+
+.toolbar {text-align:right; font-size:.9em;}
+
+.tiddler {padding:1em 1em 0em 1em;}
+
+.missing .viewer,.missing .title {font-style:italic;}
+
+.title {font-size:1.6em; font-weight:bold;}
+
+.missing .subtitle {display:none;}
+.subtitle {font-size:1.1em;}
+
+.tiddler .button {padding:0.2em 0.4em;}
+
+.tagging {margin:0.5em 0.5em 0.5em 0; float:left; display:none;}
+.isTag .tagging {display:block;}
+.tagged {margin:0.5em; float:right;}
+.tagging, .tagged {font-size:0.9em; padding:0.25em;}
+.tagging ul, .tagged ul {list-style:none; margin:0.25em; padding:0;}
+.tagClear {clear:both;}
+
+.footer {font-size:.9em;}
+.footer li {display:inline;}
+
+.annotation {padding:0.5em; margin:0.5em;}
+
+* html .viewer pre {width:99%; padding:0 0 1em 0;}
+.viewer {line-height:1.4em; padding-top:0.5em;}
+.viewer .button {margin:0em 0.25em; padding:0em 0.25em;}
+.viewer blockquote {line-height:1.5em; padding-left:0.8em;margin-left:2.5em;}
+.viewer ul, .viewer ol {margin-left:0.5em; padding-left:1.5em;}
+
+.viewer table, table.twtable {border-collapse:collapse; margin:0.8em 1.0em;}
+.viewer th, .viewer td, .viewer tr,.viewer caption,.twtable th, .twtable td, .twtable tr,.twtable caption {padding:3px;}
+table.listView {font-size:0.85em; margin:0.8em 1.0em;}
+table.listView th, table.listView td, table.listView tr {padding:0px 3px 0px 3px;}
+
+.viewer pre {padding:0.5em; margin-left:0.5em; font-size:1.2em; line-height:1.4em; overflow:auto;}
+.viewer code {font-size:1.2em; line-height:1.4em;}
+
+.editor {font-size:1.1em;}
+.editor input, .editor textarea {display:block; width:100%; font:inherit;}
+.editorFooter {padding:0.25em 0em; font-size:.9em;}
+.editorFooter .button {padding-top:0px; padding-bottom:0px;}
+
+.fieldsetFix {border:0; padding:0; margin:1px 0px 1px 0px;}
+
+.sparkline {line-height:1em;}
+.sparktick {outline:0;}
+
+.zoomer {font-size:1.1em; position:absolute; overflow:hidden;}
+.zoomer div {padding:1em;}
+
+* html #backstage {width:99%;}
+* html #backstageArea {width:99%;}
+#backstageArea {display:none; position:relative; overflow: hidden; z-index:150; padding:0.3em 0.5em 0.3em 0.5em;}
+#backstageToolbar {position:relative;}
+#backstageArea a {font-weight:bold; margin-left:0.5em; padding:0.3em 0.5em 0.3em 0.5em;}
+#backstageButton {display:none; position:absolute; z-index:175; top:0em; right:0em;}
+#backstageButton a {padding:0.1em 0.4em 0.1em 0.4em; margin:0.1em 0.1em 0.1em 0.1em;}
+#backstage {position:relative; width:100%; z-index:50;}
+#backstagePanel {display:none; z-index:100; position:absolute; margin:0em 3em 0em 3em; padding:1em 1em 1em 1em;}
+.backstagePanelFooter {padding-top:0.2em; float:right;}
+.backstagePanelFooter a {padding:0.2em 0.4em 0.2em 0.4em;}
+#backstageCloak {display:none; z-index:20; position:absolute; width:100%; height:100px;}
+
+.whenBackstage {display:none;}
+.backstageVisible .whenBackstage {display:block;}
+/*}}}*/
+
+
+
/***
+StyleSheet for use when a translation requires any css style changes.
+This StyleSheet can be used directly by languages such as Chinese, Japanese and Korean which need larger font sizes.
+***/
+/*{{{*/
+body {font-size:0.8em;}
+#sidebarOptions {font-size:1.05em;}
+#sidebarOptions a {font-style:normal;}
+#sidebarOptions .sliderPanel {font-size:0.95em;}
+.subtitle {font-size:0.8em;}
+.viewer table.listView {font-size:0.95em;}
+/*}}}*/
+
+
+
/*{{{*/
+@media print {
+#mainMenu, #sidebar, #messageArea, .toolbar, #backstageButton, #backstageArea {display: none ! important;}
+#displayArea {margin: 1em 1em 0em 1em;}
+/* Fixes a feature in Firefox 1.5.0.2 where print preview displays the noscript content */
+noscript {display:none;}
+}
+/*}}}*/
+
+
+
<!--{{{-->
+<div class='header' macro='gradient vert [[ColorPalette::PrimaryLight]] [[ColorPalette::PrimaryMid]]'>
+<div class='headerShadow'>
+<span class='siteTitle' refresh='content' tiddler='SiteTitle'></span>&nbsp;
+<span class='siteSubtitle' refresh='content' tiddler='SiteSubtitle'></span>
+</div>
+<div class='headerForeground'>
+<span class='siteTitle' refresh='content' tiddler='SiteTitle'></span>&nbsp;
+<span class='siteSubtitle' refresh='content' tiddler='SiteSubtitle'></span>
+</div>
+</div>
+<div id='mainMenu' refresh='content' tiddler='MainMenu'></div>
+<div id='sidebar'>
+<div id='sidebarOptions' refresh='content' tiddler='SideBarOptions'></div>
+<div id='sidebarTabs' refresh='content' force='true' tiddler='SideBarTabs'></div>
+</div>
+<div id='displayArea'>
+<div id='messageArea'></div>
+<div id='tiddlerDisplay'></div>
+</div>
+<!--}}}-->
+
+
+
<!--{{{-->
+<div class='toolbar' macro='toolbar [[ToolbarCommands::ViewToolbar]]'></div>
+<div class='title' macro='view title'></div>
+<div class='subtitle'><span macro='view modifier link'></span>, <span macro='view modified date'></span> (<span macro='message views.wikified.createdPrompt'></span> <span macro='view created date'></span>)</div>
+<div class='tagging' macro='tagging'></div>
+<div class='tagged' macro='tags'></div>
+<div class='viewer' macro='view text wikified'></div>
+<div class='tagClear'></div>
+<!--}}}-->
+
+
+
<!--{{{-->
+<div class='toolbar' macro='toolbar [[ToolbarCommands::EditToolbar]]'></div>
+<div class='title' macro='view title'></div>
+<div class='editor' macro='edit title'></div>
+<div macro='annotations'></div>
+<div class='editor' macro='edit text'></div>
+<div class='editor' macro='edit tags'></div><div class='editorFooter'><span macro='message views.editor.tagPrompt'></span><span macro='tagChooser'></span></div>
+<!--}}}-->
+
+
+
To get started with this blank TiddlyWiki, you'll need to modify the following tiddlers:
+* SiteTitle & SiteSubtitle: The title and subtitle of the site, as shown above (after saving, they will also appear in the browser title bar)
+* MainMenu: The menu (usually on the left)
+* DefaultTiddlers: Contains the names of the tiddlers that you want to appear when the TiddlyWiki is opened
+You'll also need to enter your username for signing your edits: <<option txtUserName>>
+
+
+
These InterfaceOptions for customising TiddlyWiki are saved in your browser
+
+Your username for signing your edits. Write it as a WikiWord (eg JoeBloggs)
+
+<<option txtUserName>>
+<<option chkSaveBackups>> SaveBackups
+<<option chkAutoSave>> AutoSave
+<<option chkRegExpSearch>> RegExpSearch
+<<option chkCaseSensitiveSearch>> CaseSensitiveSearch
+<<option chkAnimate>> EnableAnimations
+
+----
+Also see AdvancedOptions
+
+
+
<<importTiddlers>>
+
+
+ +
+
+
Adam is the author of ''Taggable.''  To find out more about him and other software that he has created, visit http://www.adamcrossland.net.  You can also visit his blog, http://blog.adamcrossland.net, to see the code in action; he created ''Taggable'' as part of the blogging software project the he wrote as a vehicle to learn about [[Google AppEngine]].
+
+
+
{{{
+Copyright 2008 Adam A. Crossland
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+}}}
+
+
+
''1.0''
+Initial release
+----
+''2.0''
+This release has major changes to both [[Tag|Tag Class]] and [[Taggable|Taggable Class]], reflecting significant and important lessons that [[the author|AdamCrossland]] learned about [[Google AppEngine]].  Specifically, this release's changes should substantially improve the performance of any code that uses tags.
+*[[Tag|Tag Class]] entities are now stored with a custom key_name that allows them to be much-more-quickly retrieved from the data store.
+*The [[Tag|Tag Class]] has been rewritten to breakdown Tag/Taggable interactions in to a series of simple, atomic transactions.
+*[[Tag|Tag Class]] has a new property, [[tagged_count|Tag tagged_count Property]], that records the number of Taggable entities in its [[tagged|Tag tagged Property]] property.
+*Two new methods, [[get_tags_by_frequency|Tag get_tags_by_frequency Method]] and [[popular_tags|Tag popular_tags Method]], provide access to lists of [[Tags|Tag Class]] based on the number of [[Taggable|Taggable Class]] entities to which they refer.
+*A new method, [[get_tags_by_name|Tag get_tags_by_name Method]], provides lists of tags in alphabetical order.
+*The [[Taggable|Taggable Class]] class has been greatly simplified.  Multiple methods for getting and setting tags have been replaced with a single property, [[tags|Taggable tags Property]], that handles both getting and setting operations.
+*A full suite of automated unit tests for both classes has been created.
+
+
+
If you have any questions or suggestions about ''Taggable'', please feel free to contact the author, AdamCrossland, at adam@adamcrossland.net.  Alternatively, you could post the question in <html><a href="http://groups.google.com/group/google-appengine/topics">the Google AppEngine Group.</a></html>
+
+
+
If you are using ''Taggable'' and you have some changes, improvements or bug fixes that you'd like to contribute, please contact AdamCrossland at adam@adamcrossland.net.
+
+
+
Because the Tag records are stored and queried slightly differently (yet much-more efficiently) in //Taggable-mixin 2.0//, an existing application will have to go through a process of converting all of its existing tags.
+
+For AdamCBlog, the application from which //Taggable-mixin// is extracted, I added a new RequestHandler called UpdateTag:
+{{{
+class UpdateTag(RequestHandler):
+    def get(self):
+        from taggable import Tag
+        tag_name = self.request.get('tag')
+        tag = db.Query(Tag).filter('tag =', tag_name).fetch(1)[0]
+        if tag is not None:
+            new_tag = Tag.get_or_create(tag_name)
+            for each_tagged in tag.tagged:
+                new_tag.add_tagged(each_tagged)
+        tag.delete()
+    
+        self.redirect('/')
+}}}
+
+Then, I made the request /~UpdateTag?tag=//inserttaghere// for each Tag in the datastore.  Yes, it is laborious.
+
+Why not just create a RequestHandler that cycled through all of the tags automatically, converting them as it went?  I tried that, but there is too much processing involved; the request uses up its time quota and is killed before it can finish.
+
+
+
A [[Google AppEngine]] datastore property that holds a Python //datetime//.
+
+http://code.google.com/appengine/docs/datastore/typesandpropertyclasses.html#DateTimeProperty
+
+
+
[[Introduction]]
+
+
+
Google ~AppEngine is Google's platform for developing web applications that run inside Google's computing cloud.
+
+Find out more about it at <html><a href="http://code.google.com/appengine/">The Google AppEngine website</a></html>.
+
+
+
+
+
A [[Google AppEngine]] datastore property that holds a Python //long//.
+
+http://code.google.com/appengine/docs/datastore/typesandpropertyclasses.html#IntegerProperty
+
+
+
''Taggable'' is a <html><a href="http://www.linuxjournal.com/node/4540/print">mixin</a></html> class that AdamCrossland created in order to add [[tags|Tag Definition]] to his personal blog (http://blog.adamcrossland.net) which is built on [[Google AppEngine]].  The design seemed clean and portable, so he decided to share it with the greater [[AppEngine|Google AppEngine]] community.  It is available under [[Apache 2.0 Open Source license]].
+
+Users of taggable-mixin 1.0 should note that taggable-mixin 2.0 is not directly compatible with 1.0.  While it is possible to upgrade, existing Tag entities will have to be [[converted|Converting from 1.0 to 2.0]].
+
+
+
A [[Google AppEngine]] class that represents a unique key for a datastore entity.
+
+http://code.google.com/appengine/docs/datastore/keyclass.html
+
+
+
A [[Google AppEngine]] datastore property that represents a Python //list//.
+
+http://code.google.com/appengine/docs/datastore/typesandpropertyclasses.html#ListProperty
+
+
+
[[Introduction|Introduction]]
+ChangeLog
+StepByStep
+<<tag api>>
+----
+[[License|Apache 2.0 Open Source license]]
+[[Contribute]]
+[[Contact]]
+
+
+
A //~RequestHandler// is a Python class that inherits from //webapp.~RequestHandler//.  It is used to answer HTTP requests that are received by your [[Google AppEngine]] application.  For more information on this subject, please consult the <html><a href="http://code.google.com/appengine/docs/webapp/requesthandlers.html">documentation.</a></html>
+
+
+
+
+
<<search>><<closeAll>><<permaview>><<newTiddler>><<saveChanges>><<slider chkSliderOptionsPanel OptionsPanel "options ยป" "Change TiddlyWiki advanced options">>
+
+
+
a portable mixin class for adding Tags to Google ~AppEngine Models
+
+
+
Taggable
+
+
+
Here's a step-by-step guide to adding ''Taggable'' to your [[Google AppEngine]] application.  This presents the simplest, most straightforward path to integrating ''Taggable'' as as such, it does not cover all of the options that are available.  
+
+All of the code examples are modified extracts from the blogging software for which I originally created ''Taggable.''  //''These examples do not represent what the author considers to be complete and secure code; please make sure that you are familiar with best practices for building secure web applications before creating a web application.  The author of this document and the accompanying code bears no responsibility whatsoever for any losses or damages that you incur as a result of failing to take the appropriate steps to create a stable, secure, well-written web application.''//
+
+*Copy [[taggable.py]] to your application directory.
+*Import [[taggable.py]] into the python file that defines the Model that you want to make taggable:
+{{{
+from taggable import Taggable
+}}}
+*Add //Taggable// to the list of classes from which your Model class inherits.  Taggable -- and any other mixin classes -- should come before db.Model:
+{{{
+class Post(Taggable, db.Model):
+}}}
+*Add code to your Model's //init// method to call Taggable's //init// method.  If your Model class does not already override //init//, it will have to, and you will have to explicitly call the //init// method of any other superclass -- such as db.Model:
+{{{
+    def __init__(self, parent=None, key_name=None, app=None, **entity_values):
+        db.Model.__init__(self, parent, key_name, app, **entity_values)
+        Taggable.__init__(self)
+}}}
+*Add code to your template to express the tagging information:
+{{{
+{% if post.tags %}
+    <div class="posttags">tags:&nbsp;
+        {% for each_tag in post.tags %}
+            <a href="/searchbytag?tag={{ each_tag.tag|escape }}">{{ each_tag.tag }}</a>{% if forloop.last %}{% else %}, {% endif %}
+        {% endfor %}
+    </div>
+{% endif %}
+}}}
+or
+{{{
+<tr>
+    <td>Tags:</td>
+    <td>
+        <input type="TEXT" name="tags" size="106" value="{% if post %}{{ post.tags_string }}{% endif %}" />
+    </td>
+</tr>
+}}}
+*In any RequestHandler method that updates a Model object that has tags associated with it,  assign any new value to the ''Taggable'' object's [[tags|TaggableTagsProperty]]:
+{{{
+class EditPost(SmartHandler.SmartHandler):
+    def post(self):
+        postid = self.request.get('id')
+        post = Post.get(postid)
+        post.title = self.request.get('title')
+        post.body = self.request.get('body')
+        post.edited = datetime.datetime.now()
+        post.tags = self.request.get('tags')
+        post.put()
+        .
+        . do whatever else you need to do in your handler...
+        .
+}}}
+
+
+
A [[Google AppEngine]] datastore property that holds a Python //string// of 500 characters or less.
+
+http://code.google.com/appengine/docs/datastore/typesandpropertyclasses.html#StringProperty
+
+
+
The //Tag// class is the Model class that holds the data for an individual [[tag|Tag Definition]].
+
+It has four properties:
+*[[tag|Tag tag Property]]
+*[[added|Tag tag_added Property]]
+*[[tagged|Tag tagged Property]]
+*[[tagged_count|Tag tagged_count Property]]
+
+and ten methods:
+*[[remove_tagged|Tag remove_tagged Method]]
+*[[add_tagged|Tag add_tagged Method]]
+*[[clear_tagged|Tag clear_tagged Method]]
+*[[get_by_name|Tag get_by_name Method]]
+*[[get_tags_for_key|Tag get_tags_for_key Method]]
+*[[get_or_create|Tag get_or_create Method]]
+*[[get_tags_by_frequency|Tag get_tags_by_frequency Method]]
+*[[get_tags_by_name|Tag get_tags_by_name Method]]
+*[[popular_tags|Tag popular_tags Method]]
+*[[expire_cached_tags|Tag expire_cached_tags Method]]
+
+
+
A //Tag// is a word or short phrase that acts as metadata; it describes and increases the searchability and findability of data.
+
+http://en.wikipedia.org/wiki/Tag_(metadata)
+
+
+
//tag_instance.add_tagged(key)//
+* key - a [[Key|Key Class]] for a //Taggable// Model to mark with the [[Tag|Tag Class]]
+Returns: nothing
+
+The //add_tagged// method adds the [[Key|Key Class]] passed in to the Tag's [[tagged|Tag tagged Property]] collection and increments the [[tagged_count|Tag tagged_count Property]].  The operations are performed inside a transaction to ensure data intergity.
+
+Under most circumstances, the programmer will not need to call //add_tagged// directly; it is meant to be used internally by the [[Tagged Class]].  However, it is available for use by those who wish to customize the behavior of Taggable.
+
+
+
//tag_instance.clear_tagged(key)//
+* key - a [[Key|Key Class]] for a //Taggable// object.
+Returns: nothing
+
+The //clear_tagged// method empties the Tag's [[tagged|Tag tagged Property]] collection and sets the [[tagged_count|Tag tagged_count Property]] to zero.  The operations are performed inside a transaction to ensure data intergity.
+
+Under most circumstances, the programmer will not need to call //clear_tagged// directly; it is meant to be used internally by the [[Tagged Class]].  However, it is available for use by those who wish to customize the behavior of Taggable.
+
+
+
//Tag.expire_cached_tags()//
+Returns: nothing
+
+Comments:
+This method removes from memcache any objects that have been cached by other [[Tag|Tag Class]] methods.  Usually, the programmer will not call this method directly, as it is used behind-the-scenes by code that affects the validity of the cached items.  It is available, however, in case the programmer wishes to customize existing behavior.
+
+
+
//Tag.get_by_name(tag_name)//
+*tag_name - the tag, in text form
+Returns: a [[Tag|Tag Class]] or //None//
+
+Example:
+{{{
+class SearchByTag(RequestHandler):
+    def get(self):
+        from post import Post
+        from taggable import Tag
+        
+        requested_tag = self.request.get('tag')
+        if requested_tag is not None and len(requested_tag) > 0:
+            tag = Tag.get_by_name(requested_tag)
+            posts = None
+            if tag is not None:
+                posts = Post.get(tag.tagged)
+
+            self.template_values['posts'] = posts
+}}}
+
+
+
+
//Tag.get_or_create(tag_name)//
+*tag_name - the name of the tag that is to be retrieved from or created in the datastore
+Returns: a [[Tag|Tag Class]]
+
+Example:
+{{{
+for each_tag in tags:
+    each_tag = string.strip(each_tag)
+    if len(each_tag) > 0 and each_tag not in self.__tags:
+        # A tag that was not previously assigned to this entity
+        # is present in the list that is being assigned, so we
+        # associate this entity with the tag.
+        tag = Tag.get_or_create(each_tag)
+        tag.add_tagged(self.key())
+        self.__tags.append(tag)
+}}}
+
+
+
//Tag.get_tags_by_frequency(limit=1000)//
+*limit - the number of records to return; the maximum is 1000
+Returns: a list of [[Tag|Tag Class]] objects, ordered by the number of //Taggable// objects assigned to it.
+
+Example:
+{{{
+@classmethod
+def popular_tags(cls, limit=5):
+    from google.appengine.api import memcache
+        
+    tags = memcache.get('popular_tags')
+    if tags is None:
+        tags = Tag.get_tags_by_frequency(limit)
+        memcache.add('popular_tags', tags, 3600)
+        
+    return tags
+}}}
+
+
+
//Tag.get_tags_by_name(tag_name)//
+*tag_name - the string value of a [[Tag|Tag Class]] to be retrieved from the datastore
+Returns: a [[Tag|Tag Class]] object or None if the given //tag_name// does not exist
+
+Example:
+{{{
+requested_tag = self.request.get('tag')
+if requested_tag is not None and len(requested_tag) > 0:
+    tag = Tag.get_by_name(requested_tag)
+    posts = None
+    if tag is not None:
+        posts = Post.get(tag.tagged)
+
+    self.template_values['posts'] = posts
+}}}
+
+
+
//Tag.get_tags_for_key(key)//
+*key - a [[Key|Key Class]] for a //Taggable// object.
+Returns: a //list// of [[Tag|Tag Class]] objects
+
+Example:
+{{{
+def __get_tags(self):
+    "Get a List of Tag objects for all Tags that apply to this object."
+    if self.__tags is None or len(self.__tags) == 0:
+        self.__tags = Tag.get_tags_for_key(self.key())
+    return self.__tags
+}}}
+
+
+
//Tag.popular_tags(limit=5)//
+*limit - the number of records to return; the default is 5 and the maximum is 1000
+Returns: a list of [[Tag|Tag Class]] objects, ordered by the number of [[Taggable|Taggable Class]] objects assigned to it.
+
+Comments:
+This method is a thin wrapper around [[get_tags_by_frequency|Tag get_tags_by_frequency]] that caches the returned values.
+
+Example:
+{{{
+self.template_values['popular_tags'] = Tag.popular_tags()
+}}}
+
+
+
//tag_instance.remove_tagged(key)//
+* key - a [[Key|Key Class]] for a //Taggable// object.
+Returns: nothing
+
+The //remove_tagged// method removes the [[Key|Key Class]] passed in from the Tag's [[tagged|Tag tagged Property]] collection and decrements the [[tagged_count|Tag tagged_count Property]].  The operations are performed inside a transaction to ensure data intergity.
+
+Under most circumstances, the programmer will not need to call //remove_tagged// directly; it is meant to be used internally by the [[Tagged Class]].  However, it is available for use by those who wish to customize the behavior of Taggable.
+
+
+
A StringProperty that holds the name or value of the tag.
+
+
+
The DateTimeProperty that records that date and time that the [[Tag|TagClass]] was first added.
+
+
+
A ListProperty that holds the Keys of all of the entities that have the given [[Tag|TagClass]] assigned to them.
+
+
+
An IntegerProperty that holds the number of items that have the Tag assigned to them.
+
+
+
The //Taggable// class can be mixed-in to any other Python class that inherits from //db.Model// in order to associate [[Tags|Tag Definition]] with that model.  For instance, if you were creating blogging software, one of your fundamental objects would be a //Post// that would comprise all of the information about an individual entry in your blog.  Usually, a Post can have [[Tags|Tag Definition]] added to it in order to provide easily-digested information to the reader about the content.
+
+It has two properties:
+*[[tags|Taggable tags Property]]
+*[[tag_separator|Taggable tag_separator Property]]
+
+and one method:
+*[[tags_string|Taggable tags_string Method]]
+
+Example:
+{{{
+class Post(Taggable, db.Model):
+    index = db.IntegerProperty(required=True, default=0)
+    body = db.TextProperty(required = True)
+    title = db.StringProperty()
+    added = db.DateTimeProperty(auto_now_add=True)
+    added_month = db.IntegerProperty()
+    added_year = db.IntegerProperty()
+    edited = db.DateTimeProperty()
+        
+    def __init__(self, parent=None, key_name=None, app=None, **entity_values):
+        db.Model.__init__(self, parent, key_name, app, **entity_values)
+        Taggable.__init__(self)
+}}}
+Notes:
+*Notice that [[Taggable|Taggable Class]] is placed before db.Model in the inheritance list.  While this is not strictly required for //Taggable-mixin// to function correctly, it //is// required for other mixin classes, and it is generally a good practice.
+*It //is// required that the [[Taggable|Taggable Class]] //init// method is explicitly called, so it should be placed in the inherting class's //init// method.  If the class would not otherwise have one, it should be created, and the db.Model //init// must be called as well, as shown.
+
+
+
The //tag_separator// property is the string that is used to separate individual tags in a string representation of a list of tags.  It is used by the [[tags property|TaggableTagsProperty]] to parse a string that may contain one or more tags to be applied to a [[Taggable|TaggableClass]] object.  It is also used by the [[tags_string|TaggableTagsStringMethod]] method to construct a string representation of the tags for a [[Taggable|TaggableClass]] object.
+
+*By default, it is set to a comma (','), but it can be programatically-set to whatever value the developer desires
+*It is probably best to avoid using whitespace characters, as that would prevent users from entering multi-word tags
+*Custom separator values can be set at different levels.
+**To set one value for all [[Taggable|TaggableClass]] objects, modify the //init// method in the //Taggable// class in the taggable.py file:
+{{{
+class Taggable:
+     def __init__(self):
+        self.tag_separator = ";" # Made it semi-colon instead of comma
+}}}
+**To set one value for all instances of a particular [[Taggable|TaggableClass]] class, modify the //init// method of that class:
+{{{
+class Post(Taggable, db.Model):
+    body = db.TextProperty(required = True)
+    title = db.StringProperty()
+    added = db.DateTimeProperty(auto_now_add=True)
+    edited = db.DateTimeProperty()
+            
+    def __init__(self, parent=None, key_name=None, app=None, **entity_values):
+        db.Model.__init__(self, parent, key_name, app, **entity_values)
+        Taggable.__init__(self)
+        self.tag_separator = ";" # Made it semi-colon instead of comma        
+}}}
+**To set a value for one particular instance of a [[Taggable|TaggableClass]] class, set the value after creating the instance:
+{{{
+newpost = Post(title = self.request.get('title'), body = self.request.get('body'))
+newpost.tag_separator = ";" # Made it semi-colon instead of comma
+newpost.set_tags_from_string("foo; bar; bletch; this is a tag; tags rule"     
+}}}
+
+
+
The a [[Taggable|Taggable Class]] class's //tags// property is used to assign [[Tags|Tag Class]] to the [[Taggable|Taggable Class]] instance or retrieve a //list// of those already assigned.
+
+Assignment Example:
+{{{
+class Post(Taggable, db.Model):
+    index = db.IntegerProperty(required=True, default=0)
+    body = db.TextProperty(required = True)
+    title = db.StringProperty()
+    added = db.DateTimeProperty(auto_now_add=True)
+    added_month = db.IntegerProperty()
+    added_year = db.IntegerProperty()
+    edited = db.DateTimeProperty()
+        
+    def __init__(self, parent=None, key_name=None, app=None, **entity_values):
+        db.Model.__init__(self, parent, key_name, app, **entity_values)
+        Taggable.__init__(self)
+
+    @classmethod
+    def new_post(cls, new_title=None, new_body=None, new_tags=[]):
+        if new_title is not None and new_body is not None:
+            
+            new_post = Post(title = new_title, body = new_body)
+            new_post.put()
+        
+            new_post.tags = new_tags
+            new_post.save()
+        else:
+            raise Exception("Must supply both new_title and new_body when creating a new Post.")
+}}}
+
+Retrieval Example:
+{{{
+{% if post.tags %}
+    <div class="posttags">tags:&nbsp;
+        {% for each_tag in post.tags %}
+            <a href="/searchbytag?tag={{ each_tag.tag|escape }}">{{ each_tag.tag }}</a>{% if forloop.last %}{% else %}, {% endif %}
+        {% endfor %}
+    </div>
+{% endif %}
+}}}
+
+
+
//taggable_instance.tags_string()//
+Returns: a string representation of the tags assigned to the [[Taggable|Taggable Class]] instance.
+
+This method simply joins the string versions of each [[Tag|Tag Class]] in the [[Taggable|Taggable Class]] class's //list//, placing the value stored in [[tag_separator|Taggable tag_separator Property]] inbetween each element.
+
+
+
<<tagging api>>
+
+
+
The //get_tags_as_string// method creates a string representation of all of the tags that apply to a ''Taggable'' object.
+
+
+
The //set_tags// method sets the tags for a ''Taggable'' object from a list of strings:
+{{{
+tag_list = ["foo", "bar", "bletch","this is a tag", "tags rule"]
+my_taggable_object.set_tags(tag_list)
+}}}
+
+
+
The //set_tags_from_string method// sets the tags for a Taggable object from a string that contains one or more tags separated by the character or characters in //self.tag_seperator//:
+{{{
+tag_list = "foo, bar, bletch, this is a tag, tags rule"
+my_taggable_object.set_tags_from_string(tag_list)
+}}}
+
+By default, [[tag_separator|tag_separator]] is set to a comma (','), but it can be anything that the programmer wants.
+
+
+
All of the code for the ''Taggable'' mixin class lives in the file taggable.py.  Simply copy this file in to your Google ~AppEngine main directory, and it should be available to your code.
+
+
+ + + + + + + + + + diff --git a/app/taggable-mixin/taggable.py b/app/taggable-mixin/taggable.py new file mode 100644 index 00000000..76712a7d --- /dev/null +++ b/app/taggable-mixin/taggable.py @@ -0,0 +1,201 @@ +from google.appengine.ext import db +import string + +class Tag(db.Model): + "Google AppEngine model for store of tags." + + tag = db.StringProperty(required=True) + "The actual string value of the tag." + + added = db.DateTimeProperty(auto_now_add=True) + "The date and time that the tag was first added to the datastore." + + tagged = db.ListProperty(db.Key) + "A List of db.Key values for the datastore objects that have been tagged with this tag value." + + tagged_count = db.IntegerProperty(default=0) + "The number of entities in tagged." + + @staticmethod + def __key_name(tag_name): + return "tag_%s" % tag_name + + def remove_tagged(self, key): + def remove_tagged_txn(): + if key in self.tagged: + self.tagged.remove(key) + self.tagged_count -= 1 + self.put() + db.run_in_transaction(remove_tagged_txn) + Tag.expire_cached_tags() + + def add_tagged(self, key): + def add_tagged_txn(): + if key not in self.tagged: + self.tagged.append(key) + self.tagged_count += 1 + self.put() + db.run_in_transaction(add_tagged_txn) + Tag.expire_cached_tags() + + def clear_tagged(self): + def clear_tagged_txn(): + self.tagged = [] + self.tagged_count = 0 + self.put() + db.run_in_transaction(clear_tagged_txn) + Tag.expire_cached_tags() + + @classmethod + def get_by_name(cls, tag_name): + return Tag.get_by_key_name(Tag.__key_name(tag_name)) + + @classmethod + def get_tags_for_key(cls, key): + "Set the tags for the datastore object represented by key." + tags = db.Query(Tag).filter('tagged =', key).fetch(1000) + return tags + + @classmethod + def get_or_create(cls, tag_name): + "Get the Tag object that has the tag value given by tag_value." + tag_key_name = Tag.__key_name(tag_name) + existing_tag = Tag.get_by_key_name(tag_key_name) + if existing_tag is None: + # The tag does not yet exist, so create it. + def create_tag_txn(): + new_tag = Tag(key_name=tag_key_name, tag = tag_name) + new_tag.put() + return new_tag + existing_tag = db.run_in_transaction(create_tag_txn) + return existing_tag + + @classmethod + def get_tags_by_frequency(cls, limit=1000): + """Return a list of Tags sorted by the number of objects to which they have been applied, + most frequently-used first. If limit is given, return only that many tags; otherwise, + return all.""" + tag_list = db.Query(Tag).filter('tagged_count >', 0).order("-tagged_count").fetch(limit) + + return tag_list + + @classmethod + def get_tags_by_name(cls, limit=1000, ascending=True): + """Return a list of Tags sorted alphabetically by the name of the tag. + If a limit is given, return only that many tags; otherwise, return all. + If ascending is True, sort from a-z; otherwise, sort from z-a.""" + + from google.appengine.api import memcache + + cache_name = 'tags_by_name' + if ascending: + cache_name += '_asc' + else: + cache_name += '_desc' + + tags = memcache.get(cache_name) + if tags is None or len(tags) < limit: + order_by = "tag" + if not ascending: + order_by = "-tag" + + tags = db.Query(Tag).order(order_by).fetch(limit) + memcache.add(cache_name, tags, 3600) + else: + if len(tags) > limit: + # Return only as many as requested. + tags = tags[:limit] + + return tags + + + @classmethod + def popular_tags(cls, limit=5): + from google.appengine.api import memcache + + tags = memcache.get('popular_tags') + if tags is None: + tags = Tag.get_tags_by_frequency(limit) + memcache.add('popular_tags', tags, 3600) + + return tags + + @classmethod + def expire_cached_tags(cls): + from google.appengine.api import memcache + + memcache.delete('popular_tags') + memcache.delete('tags_by_name_asc') + memcache.delete('tags_by_name_desc') + +class Taggable: + """A mixin class that is used for making Google AppEnigne Model classes taggable. + Usage: + class Post(db.Model, taggable.Taggable): + body = db.TextProperty(required = True) + title = db.StringProperty() + added = db.DateTimeProperty(auto_now_add=True) + edited = db.DateTimeProperty() + + def __init__(self, parent=None, key_name=None, app=None, **entity_values): + db.Model.__init__(self, parent, key_name, app, **entity_values) + taggable.Taggable.__init__(self) + """ + + def __init__(self): + self.__tags = None + self.tag_separator = "," + """The string that is used to separate individual tags in a string + representation of a list of tags. Used by tags_string() to join the tags + into a string representation and tags setter to split a string into + individual tags.""" + + def __get_tags(self): + "Get a List of Tag objects for all Tags that apply to this object." + if self.__tags is None or len(self.__tags) == 0: + self.__tags = Tag.get_tags_for_key(self.key()) + return self.__tags + + def __set_tags(self, tags): + import types + if type(tags) is types.UnicodeType: + # Convert unicode to a plain string + tags = str(tags) + if type(tags) is types.StringType: + # Tags is a string, split it on tag_seperator into a list + tags = string.split(tags, self.tag_separator) + if type(tags) is types.ListType: + self.__get_tags() + # Firstly, we will check to see if any tags have been removed. + # Iterate over a copy of __tags, as we may need to modify __tags + for each_tag in self.__tags[:]: + if each_tag not in tags: + # A tag that was previously assigned to this entity is + # missing in the list that is being assigned, so we + # disassocaite this entity and the tag. + each_tag.remove_tagged(self.key()) + self.__tags.remove(each_tag) + # Secondly, we will check to see if any tags have been added. + for each_tag in tags: + each_tag = string.strip(each_tag) + if len(each_tag) > 0 and each_tag not in self.__tags: + # A tag that was not previously assigned to this entity + # is present in the list that is being assigned, so we + # associate this entity with the tag. + tag = Tag.get_or_create(each_tag) + tag.add_tagged(self.key()) + self.__tags.append(tag) + else: + raise Exception, "tags must be either a unicode, a string or a list" + + tags = property(__get_tags, __set_tags, None, None) + + def tags_string(self): + "Create a formatted string version of this entity's tags" + to_str = "" + for each_tag in self.tags: + to_str += each_tag.tag + if each_tag != self.tags[-1]: + to_str += self.tag_separator + return to_str + diff --git a/app/taggable-mixin/taggable_tests.py b/app/taggable-mixin/taggable_tests.py new file mode 100644 index 00000000..56efa5ab --- /dev/null +++ b/app/taggable-mixin/taggable_tests.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python + +#Copyright 2008 Adam A. Crossland +# +#Licensed under the Apache License, Version 2.0 (the "License"); +#you may not use this file except in compliance with the License. +#You may obtain a copy of the License at +# +#http://www.apache.org/licenses/LICENSE-2.0 +# +#Unless required by applicable law or agreed to in writing, software +#distributed under the License is distributed on an "AS IS" BASIS, +#WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#See the License for the specific language governing permissions and +#limitations under the License. + +import sys +import os.path + +APPENGINE_PATH = '../../../../google/google_appengine' + +# Add app-engine related libraries to your path +paths = [ + APPENGINE_PATH, + os.path.join(APPENGINE_PATH, 'lib', 'django'), + os.path.join(APPENGINE_PATH, 'lib', 'webob'), + os.path.join(APPENGINE_PATH, 'lib', 'yaml', 'lib') +] +for path in paths: + if not os.path.exists(path): + raise 'Path does not exist: %s' % path +sys.path = paths + sys.path + +import unittest +from google.appengine.api import apiproxy_stub_map +from google.appengine.api import datastore_file_stub +from google.appengine.api import mail_stub +from google.appengine.api import user_service_stub +from google.appengine.ext import webapp +from google.appengine.ext import db +from google.appengine.api.memcache import memcache_stub +from taggable import * + +APP_ID = u'taggable' +AUTH_DOMAIN = 'gmail.com' +LOGGED_IN_USER = 'me@example.com' # set to '' for no logged in user + +BLOG_NAME='test_blog' + +class BlogIndex(db.Model): + "A global counter used to provide the index of the next blog post." + index = db.IntegerProperty(required=True, default=0) + "The next available index for a Post." + +class Post(Taggable, db.Model): + index = db.IntegerProperty(required=True, default=0) + body = db.TextProperty(required = True) + title = db.StringProperty() + added = db.DateTimeProperty(auto_now_add=True) + added_month = db.IntegerProperty() + added_year = db.IntegerProperty() + edited = db.DateTimeProperty() + + def __init__(self, parent=None, key_name=None, app=None, **entity_values): + db.Model.__init__(self, parent, key_name, app, **entity_values) + Taggable.__init__(self) + + def get_all_posts(): + return db.GqlQuery("SELECT * from Post ORDER BY added DESC") + Get_All_Posts = staticmethod(get_all_posts) + + @classmethod + def get_posts(cls, start_index=0, count=10): + start_index = int(start_index) # Just make sure that we have an int + posts = Post.gql('WHERE index <= :1 ORDER BY index DESC', start_index).fetch(count + 1) + if len(posts) > count: + posts = posts[:count] + + return posts + + @classmethod + def new_post(cls, new_title=None, new_body=None, new_tags=[]): + new_post = None + if new_title is not None and new_body is not None: + def txn(): + blog_index = BlogIndex.get_by_key_name(BLOG_NAME) + if blog_index is None: + blog_index = BlogIndex(key_name=BLOG_NAME) + new_index = blog_index.index + blog_index.index += 1 + blog_index.put() + + new_post_key_name = BLOG_NAME + str(new_index) + new_post = Post(key_name=new_post_key_name, parent=blog_index, + index = new_index, title = new_title, + body = new_body) + new_post.put() + + return new_post + new_post = db.run_in_transaction(txn) + + new_post.tags = new_tags + new_post.put() + else: + raise Exception("Must supply both new_title and new_body when creating a new Post.") + + return new_post + + def delete(self): + # Perform any actions that are required to maintain data integrity + # when this Post is delete. + # Disassociate this Post from any Tag + self.set_tags([]) + + # Finally, call the real delete + db.Model.delete(self) + +class MyTest(unittest.TestCase): + + def setUp(self): + # Start with a fresh api proxy. + apiproxy_stub_map.apiproxy = apiproxy_stub_map.APIProxyStubMap() + + # Use a fresh stub datastore. + stub = datastore_file_stub.DatastoreFileStub(APP_ID, '/dev/null', '/dev/null') + apiproxy_stub_map.apiproxy.RegisterStub('datastore_v3', stub) + + # Use a fresh memcache stub. + apiproxy_stub_map.apiproxy.RegisterStub('memcache', memcache_stub.MemcacheServiceStub()) + + # Use a fresh stub UserService. + apiproxy_stub_map.apiproxy.RegisterStub( + 'user', user_service_stub.UserServiceStub()) + os.environ['AUTH_DOMAIN'] = AUTH_DOMAIN + os.environ['USER_EMAIL'] = LOGGED_IN_USER + os.environ['APPLICATION_ID'] = APP_ID + + def testSimpleTagAdding(self): + new_post = Post.new_post(new_title='test post 1', new_body='This is a test post. Please ignore.') + assert new_post is not None + + new_post.tags = "test, testing, tests" + self.assertEqual(len(new_post.tags), 3) + + def testComplexTagAdding(self): + new_post = Post.new_post(new_title='test post 1', new_body='This is a test post. Please ignore.') + assert new_post is not None + + new_post.tags = " test, testing, tests,,,tag with spaces" + self.assertEqual(len(new_post.tags), 4) + + tag = new_post.tags[3] + assert tag is not None + self.assertEqual(tag.tag, 'tag with spaces') + self.assertEqual(tag.tagged_count, 1) + + tag2 = Tag.get_by_name('tag with spaces') + assert tag2 is not None + self.assertEqual(tag.tag, 'tag with spaces') + self.assertEqual(tag.tagged_count, 1) + + def testTagDeletion(self): + new_post = Post.new_post(new_title='test post 2', new_body='This is a test post. Please continue to ignore.') + assert new_post is not None + + new_post.tags = "test, testing, tests" + self.assertEqual(len(new_post.tags), 3) + + new_post.tags = "test" + self.assertEqual(len(new_post.tags), 1) + + def testTagCounts(self): + new_post3 = Post.new_post(new_title='test post 3', new_body='This is a test post. Please continue to ignore.') + assert new_post3 is not None + new_post3.tags = "foo, bar, baz" + new_post4 = Post.new_post(new_title='test post 4', new_body='This is a test post. Please continue to ignore.') + assert new_post4 is not None + new_post4.tags = "bar, baz, bletch" + new_post5 = Post.new_post(new_title='test post 5', new_body='This is a test post. Please continue to ignore.') + assert new_post5 is not None + new_post5.tags = "baz, bletch, quux" + + foo_tag = Tag.get_by_name('foo') + assert foo_tag is not None + self.assertEqual(foo_tag.tagged_count, 1) + + bar_tag = Tag.get_by_name('bar') + assert bar_tag is not None + self.assertEqual(bar_tag.tagged_count, 2) + + baz_tag = Tag.get_by_name('baz') + assert baz_tag is not None + self.assertEqual(baz_tag.tagged_count, 3) + + bletch_tag = Tag.get_by_name('bletch') + assert bletch_tag is not None + self.assertEqual(bletch_tag.tagged_count, 2) + + quux_tag = Tag.get_by_name('quux') + assert quux_tag is not None + self.assertEqual(quux_tag.tagged_count, 1) + + new_post3.tags = 'bar, baz' + foo_tag = Tag.get_by_name('foo') + assert foo_tag is not None + self.assertEqual(len(new_post3.tags), 2) + self.assertEqual(foo_tag.tagged_count, 0) + + def testTagGetTagsForKey(self): + new_post = Post.new_post(new_title='test post 6', new_body='This is a test post. Please continue to ignore.', new_tags='foo,bar,bletch,quux') + assert new_post is not None + + tags = Tag.get_tags_for_key(new_post.key()) + assert tags is not None + self.assertEqual(type(tags), type([])) + self.assertEqual(len(tags), 4) + + def testTagGetByName(self): + new_post = Post.new_post(new_title='test post 6', new_body='This is a test post. Please continue to ignore.', new_tags='foo,bar,bletch,quux') + assert new_post is not None + + quux_tag = Tag.get_by_name('quux') + assert quux_tag is not None + + zizzle_tag = Tag.get_by_name('zizzle') + assert zizzle_tag is None + + def testTagsString(self): + new_post = Post.new_post(new_title='test post 6', new_body='This is a test post. Please continue to ignore.', new_tags=' pal,poll ,,pip,pony') + assert new_post is not None + self.assertEqual(new_post.tags_string(), "pal,poll,pip,pony") + new_post.tag_separator = "|" + self.assertEqual(new_post.tags_string(), "pal|poll|pip|pony") + new_post.tag_separator = " , " + self.assertEqual(new_post.tags_string(), "pal , poll , pip , pony") + + new_post.tag_separator = ", " + new_post.tags = "pal, pill, pip" + self.assertEqual(len(new_post.tags), 3) + self.assertEqual(new_post.tags_string(), "pal, pill, pip") + +if __name__ == '__main__': + unittest.main() -- 2.11.4.GIT