├── README.markdown ├── RELEASE_HELP.txt ├── client ├── Makefile ├── bash_profile ├── debian │ ├── changelog │ ├── compat │ ├── control │ ├── copyright │ └── rules ├── experimental │ └── interactive-history.py ├── setup.py ├── shellsink-client ├── test_history_file.py ├── test_inline_tag.py ├── test_shell_sink_client.py └── zshrc ├── gpl └── server ├── __init__.py ├── app.yaml ├── command.py ├── command_search.py ├── command_tag.py ├── html ├── add_tag.html ├── annotation.html ├── atom.xml ├── command.html ├── commands.html ├── commands_by_tag.html ├── preferences.html ├── shellsink.gpg ├── shellsink_base.html └── tags.html ├── images ├── annotate.png ├── button.png ├── favicon.ico ├── lt-arrow.png ├── rt-arrow.png ├── shellsink-lowres.jpg ├── shellsink-plain.jpg └── tag.png ├── index.yaml ├── js ├── prototype-1.6.0.2.js └── shellsink.js ├── paging_helper.py ├── shellsink.py ├── stylesheets └── style.css ├── sysadmin.py ├── test ├── test_command.py ├── test_command_tag.py ├── test_paging_helper.py └── test_sysadmin.py └── todo.txt /README.markdown: -------------------------------------------------------------------------------- 1 | ## shellsink 2 | 3 | Your bash/zsh history in the cloud 4 | 5 | ### What 6 | 7 | shellsink is a searchable, tagable, and awesomely web accessible version of your bash history. by installing the client all commands issued in bash are stored in the cloud ready to be accessed by you from any other command line or web browser anywhere. 8 | 9 | ### Why 10 | 11 | If you've ever wasted time trying to remember a particular command line incantation, shell_sink is for you. It stores commands and makes them easily accessible with searching and tagging. If you work on multiple computers shell_sink can aggregate your history across all machines. System administrators can even use it to aggregate commands issued across an entire organization making them fully searchable. Never lose a command again. 12 | 13 | ### Historic info 14 | The project website/blog is here http://shell-sink.blogspot.com/ Shellsink was previously hosted at launchpad. More info can still be found there: https://launchpad.net/shellsink 15 | 16 | It is worth noting that I stopped paying the hosting bill of the google app engine app in 2010, so the public free for all of bash history that was shell-sink.com is no longer there. Sorry! Since then there have been no updates to the app, but I still think it is a great idea. I'd love to collaborate with anyone who wants to give the project some love. 17 | -------------------------------------------------------------------------------- /RELEASE_HELP.txt: -------------------------------------------------------------------------------- 1 | ##Instructions for creating a new release of the client code to push to the 2 | ##shellsink PPA. Of course *I* use shellsink for this, but I thought somebody 3 | ##else might have to do this someday. 4 | 5 | ##Dependencies: 6 | -dput 7 | -debuild 8 | -dch 9 | 10 | ##Make the ~/.dput.cf file have this entry 11 | [shellsink-ppa] 12 | fqdn = ppa.launchpad.net 13 | method = ftp 14 | incoming = ~shellsink/ubuntu/ 15 | login = anonymous 16 | allow_unsigned_uploads = 0 17 | 18 | ##Of course you need the shellsink pgp key to sign packages 19 | 20 | ##Steps: 21 | bzr co lp:shellsink #get the code 22 | cd shellsink 23 | mv client shellsink #the directory we are packaging must match package name 24 | cd shellsink/debian 25 | dch -v 0.2.0 #update the changelog and version 26 | cd shellsink 27 | make buildsrc #build the source deb 28 | cd .. 29 | dput shellsink-ppa shellsink_0.2.0_source.changes #send the change to the ppa 30 | -------------------------------------------------------------------------------- /client/Makefile: -------------------------------------------------------------------------------- 1 | # $Id: Makefile,v 1.6 2008/12/26 01:01:35 josh Exp $ 2 | # 3 | PYTHON=`which python` 4 | DESTDIR=/ 5 | 6 | all: 7 | @echo "make install - Install on local system" 8 | @echo "make buildsrc - Generate a deb source package" 9 | @echo "make clean - Get rid of scratch and byte files" 10 | 11 | install: 12 | $(PYTHON) setup.py install --root $(DESTDIR) $(COMPILE) 13 | 14 | buildsrc: 15 | debuild -S 16 | 17 | clean: 18 | $(PYTHON) setup.py clean 19 | $(MAKE) -f $(CURDIR)/debian/rules clean 20 | -------------------------------------------------------------------------------- /client/bash_profile: -------------------------------------------------------------------------------- 1 | #Shell Sink 2 | #See http://shell-sink.blogspot.com/2008/12/installing-shellsink-client-application.html for installation instructions. 3 | shopt -s histappend 4 | export SHELL_SINK_COMMAND=shellsink-client 5 | export SHELL_SINK_ID=your-hex-id 6 | PROMPT_COMMAND="history -a;$SHELL_SINK_COMMAND" 7 | export SHELL_SINK_TAGS=colon:delimited:list:of:tags 8 | -------------------------------------------------------------------------------- /client/debian/changelog: -------------------------------------------------------------------------------- 1 | shellsink (0.2.1) hardy; urgency=low 2 | 3 | * Inline comments are used as tags in shellsink 4 | * Added the extra fork back since it changed terminal behavior in osx 5 | 6 | -- Josh Cronemeyer Sun, 27 Sep 2009 15:18:29 -0700 7 | 8 | shellsink (0.2.0) hardy; urgency=low 9 | 10 | * remove unnecessary fork 11 | 12 | * change rules file to use dcbs 13 | 14 | -- Josh Cronemeyer Mon, 13 Jul 2009 18:01:31 -0700 15 | 16 | shellsink (0.1.9) hardy; urgency=low 17 | 18 | * refine new logging stuff so it works right on linux systems with remote 19 | logging disabled, and just removed some weird code like the traceback 20 | stuff that I was doing. 21 | 22 | * fix bug related to history timestamp file not being created. 23 | 24 | -- Josh Cronemeyer Mon, 23 Mar 2009 22:32:28 -0700 25 | 26 | shellsink (0.1.8) hardy; urgency=low 27 | 28 | * add logging to syslog to avoid chatter on command line when there are 29 | problems 30 | * better logging for user id not found and command id not found from server 31 | 32 | -- Josh Cronemeyer Mon, 02 Mar 2009 05:33:34 -0800 33 | 34 | shellsink (0.1.5) hardy; urgency=low 35 | 36 | [ Josh Cronemeyer ] 37 | * Initial release 38 | * changed the name of the binary 39 | * hoping to fix build errors in launchpad 40 | * adding makefile to build. I think that will fix launchpad problems 41 | * shoot. also add setup.py to make launchpad work 42 | * make running with invalid args causing empty packages 43 | 44 | -- Josh Cronemeyer Fri, 26 Dec 2008 22:44:07 -0800 45 | -------------------------------------------------------------------------------- /client/debian/compat: -------------------------------------------------------------------------------- 1 | 5 2 | -------------------------------------------------------------------------------- /client/debian/control: -------------------------------------------------------------------------------- 1 | Source: shellsink 2 | Section: utils 3 | Priority: extra 4 | Maintainer: Josh Cronemeyer 5 | Build-Depends: python (>=2.4), debhelper (>= 5) 6 | Standards-Version: 3.7.3 7 | 8 | Package: shellsink 9 | Architecture: any 10 | Depends: python (>=2.4), ${shlibs:Depends}, ${misc:Depends} 11 | Description: Client for logging bash history to shellsink.com 12 | Shellsink is a client for logging bash history to shellsink.com 13 | -------------------------------------------------------------------------------- /client/debian/copyright: -------------------------------------------------------------------------------- 1 | This package was debianized by qa on 2 | Mon, 22 Dec 2008 20:37:24 -0800. 3 | 4 | It was downloaded from https://code.launchpad.net/shellsink 5 | 6 | Upstream Author(s): 7 | 8 | joshuacronemeyer@shellsink.com 9 | 10 | Copyright: 11 | 12 | 13 | 14 | License: 15 | 16 | GNU GPL v3 17 | 18 | The Debian packaging is (C) 2008, qa and 19 | is licensed under the GPL, see `/usr/share/common-licenses/GPL'. 20 | -------------------------------------------------------------------------------- /client/debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | build: 4 | #nothing to do 5 | 6 | clean: 7 | dh_testdir 8 | dh_clean 9 | 10 | install: build 11 | dh_testdir 12 | dh_testroot 13 | dh_prep 14 | dh_installdirs 15 | 16 | # Add here commands to install the package into debian/shellsink. 17 | $(MAKE) DESTDIR=$(shell pwd)/debian/shellsink COMPILE=--no-compile install 18 | 19 | binary-indep: build install 20 | # We have nothing to do by default. 21 | 22 | # Build architecture-dependent files here. 23 | binary-arch: build install 24 | dh_testdir 25 | dh_testroot 26 | dh_installchangelogs 27 | dh_installdocs 28 | dh_installexamples 29 | # dh_install 30 | # dh_installmenu 31 | # dh_installdebconf 32 | # dh_installlogrotate 33 | # dh_installemacsen 34 | # dh_installpam 35 | # dh_installmime 36 | # dh_python 37 | # dh_installinit 38 | # dh_installcron 39 | # dh_installinfo 40 | dh_installman 41 | dh_link 42 | dh_strip 43 | dh_compress 44 | dh_fixperms 45 | # dh_perl 46 | # dh_makeshlibs 47 | dh_installdeb 48 | dh_shlibdeps 49 | dh_gencontrol 50 | dh_md5sums 51 | dh_builddeb 52 | 53 | binary: binary-indep binary-arch 54 | .PHONY: clean binary-indep binary-arch binary install 55 | -------------------------------------------------------------------------------- /client/experimental/interactive-history.py: -------------------------------------------------------------------------------- 1 | #Playing with being able to have a more interactive way to browse the history... 2 | #Works different in different terminals. Don't think this will pan out. 3 | import os 4 | import sys 5 | import tty 6 | import termios 7 | 8 | def getch(): 9 | stdin_fd = sys.stdin.fileno() 10 | old_term_settings = termios.tcgetattr(stdin_fd) 11 | try: 12 | tty.setraw(sys.stdin.fileno()) 13 | term_character = sys.stdin.read(1) 14 | finally: 15 | termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_term_settings) 16 | return term_character 17 | 18 | def history(): 19 | history_file = os.environ['HOME'] + "/.bash_history" 20 | commands = [] 21 | try: 22 | file = open(history_file, "r") 23 | commands = file.readlines() 24 | finally: 25 | file.close() 26 | commands.reverse() 27 | return commands 28 | 29 | def term_width(): 30 | return int(os.system("tput cols")) 31 | 32 | commands = history() 33 | command_index = 0 34 | overwrite = 0 35 | 36 | def user_message(): 37 | return "Command to tag: %s" % commands[command_index].strip() 38 | 39 | input = " " 40 | 41 | while input != 13: 42 | message = user_message() + " "*overwrite 43 | message = message[0:term_width()] + "\t" 44 | sys.stdout.write(message) 45 | sys.stdout.flush() 46 | overwrite = len(message) 47 | 48 | input = ord(getch()) 49 | if input == 65: 50 | command_index -= 1 51 | if input == 66: 52 | command_index += 1 53 | 54 | print commands[command_index] 55 | -------------------------------------------------------------------------------- /client/setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from distutils.core import setup 4 | 5 | setup(name = "shellsink", 6 | version = "0.1", 7 | description = "Shellsink is a tool for storing your bash history on the shellsink.com server", 8 | author = "Josh Cronemeyer", 9 | author_email = "joshuacronemeyer@shellsink.com", 10 | url = "http://shellsink.com", 11 | data_files = [('/usr/bin', ['shellsink-client'])] 12 | ) 13 | -------------------------------------------------------------------------------- /client/shellsink-client: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | This file is part of Shell-Sink. 4 | Copyright Joshua Cronemeyer 2008, 2009 5 | 6 | Shell-Sink is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Shell-Sink is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License v3 for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with Shell-Sink. If not, see . 18 | """ 19 | 20 | 21 | import urllib2 22 | import urllib 23 | import socket 24 | import getopt 25 | import sys 26 | import os 27 | import re 28 | import logging, logging.handlers 29 | 30 | SOCKET_TIMEOUT=10 31 | BASE_URL="http://history.shellsink.com" 32 | 33 | class Client: 34 | def __init__(self): 35 | verify_environment() 36 | self.config = os.environ['HOME'] + "/.shellsink" 37 | self.config_file = os.environ['HOME'] + "/.shellsink/config" 38 | self.disable_slug = os.environ['HOME'] + "/.shellsink/disable_slug" 39 | self.id = os.environ['SHELL_SINK_ID'] 40 | self.tags = os.environ['SHELL_SINK_TAGS'] 41 | self.base_url = os.environ.get("SHELL_SINK_URL", BASE_URL) 42 | self.send_url = self.base_url + "/addCommand" 43 | self.send_tag_url = self.base_url + "/addTag" 44 | self.pull_url = self.base_url + "/pull" 45 | self.verbose = False 46 | self.history = HistoryFile() 47 | 48 | def url_with_send_command(self): 49 | params = {'hash' : self.id, 'command' : self.history.latest()} 50 | data = urllib.urlencode(params) 51 | return self.send_url + '?' + data 52 | 53 | def url_with_send_tag(self, tag, command_id): 54 | params = {'command' : command_id, 'tag' : tag} 55 | data = urllib.urlencode(params) 56 | return self.send_tag_url + '?' + data 57 | 58 | def inline_tags(self, command): 59 | #Regex that ignores escaped comments.. 60 | #Slight bug is that bash escapes entire chains of hash characters 61 | #So \##foo is not a comment, but my regex only calls the first hash escaped 62 | #This is here because Python lookbehinds don't match variable amounts of text 63 | #I prefer this ugly comment to a nasty loop 64 | comment = re.compile('(?') < 0: 113 | print "Is your SHELL_SINK_ID env variable correct?" 114 | exit(1) 115 | commands = commands.split('') 116 | if len(commands) < 2: 117 | print "No commands matched your query." 118 | exit(0) 119 | commands = commands[1] 120 | return commands.split('')[0].lstrip().rstrip() 121 | 122 | def spawn_process(self, func, arg): 123 | pid = os.fork() 124 | if pid > 0: 125 | sys.exit(0) 126 | os.setsid() 127 | pid = os.fork() 128 | if pid > 0: 129 | sys.exit(0) 130 | func(arg) 131 | 132 | def enable(self): 133 | if os.path.exists(self.disable_slug): 134 | os.remove(self.disable_slug) 135 | 136 | def disable(self): 137 | file = open(self.disable_slug, "w") 138 | file.close() 139 | 140 | def is_enabled(self): 141 | return not os.path.exists(self.disable_slug) 142 | 143 | def conf(self): 144 | base = ["""#shellsink-client, a client for remote archiving your shell history"""] 145 | if not os.path.exists(self.config): 146 | os.mkdir(self.config) 147 | if not os.path.exists(self.config_file): 148 | file = open(self.config_file,"w") 149 | file.writelines(base) 150 | file.close() 151 | file = open(self.config_file, "r") 152 | self.config = file.readlines() 153 | file.close() 154 | 155 | def pull(self): 156 | history_content = self.get_history() 157 | if self.verbose: 158 | print 'ShellSink Commands Pulled From Server:' 159 | print history_content 160 | print '' 161 | self.history.add(history_content) 162 | 163 | class HistoryFile: 164 | def __init__(self): 165 | self.history_file = os.environ['HOME'] + "/.bash_history" 166 | self.history_timestamp = os.environ['HOME'] + "/.bash_history_timestamp" 167 | 168 | def has_new_command(self): 169 | new_history_timestamp = self.history_file_timestamp() 170 | timestamp_if_there_is_no_last_recorded = new_history_timestamp - 1 171 | last_recorded_history_timestamp = self.last_recorded_history_timestamp() 172 | if not last_recorded_history_timestamp: 173 | last_recorded_history_timestamp = timestamp_if_there_is_no_last_recorded 174 | 175 | self.record_new_last_recorded_history_timestamp(new_history_timestamp) 176 | return new_history_timestamp > last_recorded_history_timestamp 177 | 178 | def history_file_timestamp(self): 179 | return os.path.getmtime(self.history_file) 180 | 181 | def last_recorded_history_timestamp(self): 182 | try: 183 | file = open(self.history_timestamp,"r") 184 | return float(file.readline()) 185 | except: 186 | return None 187 | 188 | def record_new_last_recorded_history_timestamp(self, timestamp): 189 | file = open(self.history_timestamp,"w") 190 | file.writelines([str(timestamp)]) 191 | file.close() 192 | 193 | def latest(self): 194 | try: 195 | file = open(self.history_file, "r") 196 | latest = file.readlines()[-1] 197 | finally: 198 | file.close() 199 | return latest 200 | 201 | def add(self, commands): 202 | try: 203 | file = open(self.history_file, "a") 204 | file.writelines(commands + "\n") 205 | finally: 206 | file.close() 207 | 208 | def get_tag(opts): 209 | for opt in opts: 210 | if opt[0] in ["-t", "--tag"]: 211 | return opt[1] 212 | return None 213 | 214 | def get_keyword(opts): 215 | for opt in opts: 216 | if opt[0] in ["-k", "--keyword"]: 217 | return opt[1] 218 | return None 219 | 220 | def verify_environment(): 221 | if not os.environ.has_key('HOME'): 222 | raise Exception, "HOME environment variable must be set" 223 | if not os.environ.has_key('SHELL_SINK_ID'): 224 | raise Exception, "SHELL_SINK_ID environment variable must be set" 225 | if not os.environ.has_key('SHELL_SINK_TAGS'): 226 | raise Exception, "SHELL_SINK_TAGS can be empty but must exist" 227 | 228 | def logger(): 229 | if sys.platform == "darwin": 230 | address = "/var/run/syslog" 231 | else: 232 | address = "/dev/log" 233 | 234 | hdlr = logging.handlers.SysLogHandler(address) 235 | logger = logging.getLogger("shellsink") 236 | formatter = logging.Formatter('%(filename)s: %(levelname)s: %(message)s') 237 | hdlr.setFormatter(formatter) 238 | logger.addHandler(hdlr) 239 | return logger 240 | 241 | def usage(): 242 | print """usage: shellsink-client [ -v|-h|-e|-d|-p [ -t TAG|-k KEYWORD ] ] | [ --verbise|--help|--enable|--disable|--pull [ --tag TAG|--keyword KEYWORD ] ] 243 | The pull option pulls the most recent commands from the server into your history. Specifying a tag or keyword along with the pull command will pull the most recent commands matching that tag or keyword into your history. You cannot combine tags and keywords in your search. The verbose option will output commands that were returned by the pull operation to standard out.""" 244 | 245 | def main(): 246 | try: 247 | opts, args = getopt.getopt(sys.argv[1:], "vhedpt:k", ["verbose", "help", "enable", "disable", "pull", "tag=", "keyword="]) 248 | except getopt.GetoptError, err: 249 | # print help information and exit: 250 | print str(err) # will print something like "option -a not recognized" 251 | usage() 252 | sys.exit(2) 253 | 254 | client = Client() 255 | client.conf() 256 | for o, a in opts: 257 | if o in ("-v", "--verbose"): 258 | client.verbose = True 259 | elif o in ("-h", "--help"): 260 | usage() 261 | sys.exit() 262 | elif o in ("-e", "--enable"): 263 | client.enable() 264 | sys.exit(0) 265 | elif o in ("-d", "--disable"): 266 | client.disable() 267 | sys.exit(0) 268 | elif o in ("-p", "--pull"): 269 | client.tag = get_tag(opts) 270 | client.keyword = get_keyword(opts) 271 | client.pull() 272 | print "History file updated. Execute 'history -r' to add the commands to your current bash session." 273 | sys.exit(0) 274 | else: 275 | assert False, "unhandled option" 276 | 277 | socket.setdefaulttimeout(SOCKET_TIMEOUT) 278 | if client.is_enabled(): 279 | client.send_command() 280 | 281 | if __name__== '__main__': 282 | logger = logger() 283 | try: 284 | main() 285 | except SystemExit: 286 | pass 287 | except Exception, e: 288 | logger.error(e) 289 | -------------------------------------------------------------------------------- /client/test_history_file.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of Shell-Sink. 3 | Copyright Joshua Cronemeyer 2008, 2009 4 | 5 | Shell-Sink is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | Shell-Sink is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License v3 for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with Shell-Sink. If not, see . 17 | """ 18 | import unittest 19 | from shellsink_client import * 20 | from mock import Mock 21 | import os 22 | 23 | class TestHistoryFile(unittest.TestCase): 24 | def test_new_command_is_detected_when_timestamp_is_newer(self): 25 | history= StubHistory() 26 | history.new_time = 2 27 | history.old_time = 1 28 | self.assertEqual(True, history.has_new_command()) 29 | 30 | def test_new_command_is_not_detected_when_timestamp_is_older(self): 31 | history= StubHistory() 32 | history.new_time = 1 33 | history.old_time = 2 34 | self.assertEqual(False, history.has_new_command()) 35 | 36 | def test_new_command_is_detected_when_no_previous_timestamp_existed(self): 37 | history= StubHistory() 38 | history.new_time = 1 39 | history.old_time = None 40 | self.assertEqual(True, history.has_new_command()) 41 | 42 | class StubHistory(HistoryFile): 43 | 44 | def __init__(self): 45 | pass 46 | 47 | def latest(self): 48 | return "the latest command" 49 | 50 | def history_file_timestamp(self): 51 | return self.new_time 52 | 53 | def last_recorded_history_timestamp(self): 54 | return self.old_time 55 | 56 | def record_new_last_recorded_history_timestamp(self, timestamp): 57 | pass 58 | 59 | if __name__ == '__main__': 60 | unittest.main() 61 | 62 | -------------------------------------------------------------------------------- /client/test_inline_tag.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of Shell-Sink. 3 | Copyright Joshua Cronemeyer 2008, 2009 4 | 5 | Shell-Sink is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | Shell-Sink is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License v3 for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with Shell-Sink. If not, see . 17 | """ 18 | import unittest 19 | from shellsink_client import * 20 | from mock import Mock 21 | import os 22 | 23 | class TestInlineTag(unittest.TestCase): 24 | def test_one_inline_tag(self): 25 | client = StubClient() 26 | self.assertEqual(['tag'], client.inline_tags("echo #tag")) 27 | 28 | def test_zero_inline_tags(self): 29 | client = StubClient() 30 | self.assertEqual(None, client.inline_tags("echo")) 31 | 32 | def test_two_inline_tags(self): 33 | client = StubClient() 34 | self.assertEqual(["tag1", "tag2"], client.inline_tags("echo #tag1:tag2")) 35 | 36 | def test_one_escaped_comment_delimiter(self): 37 | client = StubClient() 38 | self.assertEqual(None, client.inline_tags("echo \#tag1:tag2")) 39 | 40 | def test_one_escaped_comment_delimiter_and_one_unescaped(self): 41 | client = StubClient() 42 | self.assertEqual(["tag2"], client.inline_tags("echo \#tag1 #tag2")) 43 | 44 | def test_two_escaped_comment_delimiters_and_two_unescaped(self): 45 | client = StubClient() 46 | self.assertEqual(["tag1", "tag2"], client.inline_tags("echo \#tag1 \#tag2 #tag1:tag2")) 47 | 48 | #These document a known issue. 49 | # def test_strange_adjoining_comments_are_escaped_behavior(self): 50 | # client = StubClient() 51 | # self.assertEqual(None, client.inline_tags("echo \##tag")) 52 | 53 | # def test_strange_adjoining_comments_are_escaped_behavior_can_have_tag_later(self): 54 | # client = StubClient() 55 | # self.assertEqual(["taglater"], client.inline_tags("echo \##tag #taglater")) 56 | 57 | 58 | class StubClient(Client): 59 | 60 | def __init__(self): 61 | pass 62 | 63 | if __name__ == '__main__': 64 | unittest.main() 65 | 66 | -------------------------------------------------------------------------------- /client/test_shell_sink_client.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of Shell-Sink. 3 | Copyright Joshua Cronemeyer 2008, 2009 4 | 5 | Shell-Sink is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | Shell-Sink is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License v3 for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with Shell-Sink. If not, see . 17 | """ 18 | import unittest 19 | from shellsink_client import * 20 | from mock import Mock 21 | import os 22 | 23 | class TestShellSinkClient(unittest.TestCase): 24 | 25 | def test_home_env_variable_required(self): 26 | os.environ = {'SHELL_SINK_ID' : None} 27 | self.assertRaises(Exception, verify_environment) 28 | 29 | def test_id_env_variable_required(self): 30 | os.environ = {'HOME' : None} 31 | self.assertRaises(Exception, verify_environment) 32 | 33 | def test_url_of_send_command_is_correct(self): 34 | client = StubClient() 35 | url_hash = {'id': "1234", 'url': "http://history.shellsink.com/addCommand?"} 36 | client.id, client.URL = url_hash['id'], url_hash['url'] 37 | correct_url = "%(url)scommand=the+latest+command&hash=%(id)s" % url_hash 38 | self.assertEqual(client.url_with_send_command(), correct_url) 39 | 40 | def test_url_of_send_tag_is_correct(self): 41 | client = StubClient() 42 | correct_url = "http://history.shellsink.com/addTag?tag=abc&command=1234" 43 | self.assertEqual(client.url_with_send_tag('abc', '1234'), correct_url) 44 | 45 | def test_nothing_happens_if_no_new_command(self): 46 | client = StubClient() 47 | mock = Mock() 48 | def has_new_command(): 49 | return False 50 | mock.has_new_command = has_new_command 51 | client.history = mock 52 | client.send_command() 53 | self.assertEquals(False, client.spawned) 54 | 55 | def test_http_process_spawned_if_new_command(self): 56 | client = StubClient() 57 | mock = Mock() 58 | def has_new_command(): 59 | return True 60 | mock.has_new_command = has_new_command 61 | client.history = mock 62 | client.send_command() 63 | self.assertEquals(True, client.spawned) 64 | 65 | def test_get_tag_returns_none_when_there_is_no_tag(self): 66 | opts = [("-p",None)] 67 | self.assertEquals(None, get_tag(opts)) 68 | 69 | def test_get_tag_returns_tag_when_there_is_a_tag(self): 70 | opts = [("-p",None),("-t","mytag")] 71 | self.assertEquals("mytag", get_tag(opts)) 72 | opts = [("-p",None),("--tag","mytag")] 73 | self.assertEquals("mytag", get_tag(opts)) 74 | 75 | def test_get_keyword_returns_none_when_there_is_no_keyword(self): 76 | opts = [("-p",None)] 77 | self.assertEquals(None, get_keyword(opts)) 78 | 79 | def test_get_tag_returns_keyword_when_there_is_a_keyword(self): 80 | opts = [("-p",None),("-k","mykeyword")] 81 | self.assertEquals("mykeyword", get_keyword(opts)) 82 | opts = [("-p",None),("--keyword","mykeyword")] 83 | self.assertEquals("mykeyword", get_keyword(opts)) 84 | 85 | class StubClient(Client): 86 | #This is a big stink bomb in here. 87 | #Stubbing a piece of what I am testing. 88 | #Major code smell. Could use dependency injection or somesuch. 89 | def __init__(self): 90 | self.spawned = False 91 | self.tags = [] 92 | mock = Mock() 93 | def latest(): 94 | return "the latest command" 95 | mock.latest = latest 96 | self.history = mock 97 | self.send_url = "http://history.shellsink.com/addCommand" 98 | self.send_tag_url = "http://history.shellsink.com/addTag" 99 | pass 100 | 101 | def spawn_process(self, func, arg): 102 | self.spawned = True 103 | 104 | def async_sending_of_command_and_tags(self): 105 | pass 106 | 107 | 108 | if __name__ == '__main__': 109 | unittest.main() 110 | -------------------------------------------------------------------------------- /client/zshrc: -------------------------------------------------------------------------------- 1 | #Shellsink 2 | #See http://shell-sink.blogspot.com/2008/12/installing-shellsink-client-application.html for installation instructions. 3 | 4 | # number of lines kept in history 5 | export HISTSIZE=1000 6 | 7 | # number of lines saved in the history after logout 8 | export SAVEHIST=1000 9 | 10 | # location of history 11 | export HISTFILE=~/.zhistory 12 | 13 | # append command to history file once executed 14 | setopt inc_append_history 15 | 16 | # allow interactive comments 17 | setopt interactivecomments 18 | 19 | # execute shellsink before a new command is run 20 | function precmd { 21 | /path/to/shellsink-client; 22 | } 23 | -------------------------------------------------------------------------------- /gpl: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of Shell-Sink. 3 | Copyright Joshua Cronemeyer 2008, 2009 4 | 5 | Shell-Sink is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | Shell-Sink is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License v3 for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with Shell-Sink. If not, see . 17 | """ 18 | -------------------------------------------------------------------------------- /server/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python2.5 2 | # 3 | # Copyright 2008 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | -------------------------------------------------------------------------------- /server/app.yaml: -------------------------------------------------------------------------------- 1 | application: shell-sink 2 | version: 3 3 | runtime: python 4 | api_version: 1 5 | 6 | handlers: 7 | - url: /images 8 | static_dir: images 9 | 10 | - url: /stylesheets 11 | static_dir: stylesheets 12 | 13 | - url: /js 14 | static_dir: js 15 | 16 | - url: /favicon.ico 17 | static_files: images/favicon.ico 18 | upload: images/favicon.ico 19 | 20 | - url: /addCommand* 21 | script: shellsink.py 22 | 23 | - url: /addTag* 24 | script: shellsink.py 25 | 26 | - url: /atom* 27 | script: shellsink.py 28 | 29 | - url: /pull* 30 | script: shellsink.py 31 | 32 | - url: /shellsink.gpg 33 | static_files: html/shellsink.gpg 34 | upload: html/shellsink.gpg 35 | 36 | - url: /.* 37 | login: required 38 | script: shellsink.py 39 | -------------------------------------------------------------------------------- /server/command.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of Shell-Sink. 3 | Copyright Joshua Cronemeyer 2008, 2009 4 | 5 | Shell-Sink is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | Shell-Sink is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License v3 for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with Shell-Sink. If not, see . 17 | """ 18 | import sys 19 | import string 20 | from sysadmin import Sysadmin 21 | import command_search 22 | from google.appengine.ext import db 23 | from google.appengine.ext import search 24 | 25 | class Command(command_search.SearchableModel): 26 | command = db.StringProperty(multiline=True) 27 | date = db.DateTimeProperty(auto_now_add=True) 28 | user = db.ReferenceProperty(Sysadmin) 29 | tags = db.StringListProperty() 30 | annotation = db.StringProperty(multiline=True) 31 | COMMANDS_PER_PAGE = 20 32 | MAX_KEYWORDS = 2 33 | 34 | def find_command_by_db_key(db_key): 35 | return db.get(db_key) 36 | 37 | def add_command(sysadmin, command_string): 38 | command = Command(command = command_string, user = sysadmin) 39 | command.put() 40 | return command 41 | 42 | def fetch_commands(sysadmin, page): 43 | query = Command.all().filter('user =', sysadmin).order('-date') 44 | return query.fetch(Command.COMMANDS_PER_PAGE, (page - 1) * Command.COMMANDS_PER_PAGE) 45 | 46 | def full_text_search(sysadmin, query, page): 47 | if (query == None): 48 | return None 49 | 50 | number_of_keywords = len(query.split()) 51 | if (number_of_keywords > Command.MAX_KEYWORDS): 52 | query = string.join(query.split(None, Command.MAX_KEYWORDS)[0:-1], ' ') 53 | 54 | query = Command.all().filter('user =', sysadmin).order('-date').search(query) 55 | return query.fetch(Command.COMMANDS_PER_PAGE, (page - 1) * Command.COMMANDS_PER_PAGE) 56 | -------------------------------------------------------------------------------- /server/command_search.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright 2007 Google Inc. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | 18 | """Full text indexing and search, implemented in pure python. 19 | 20 | Defines a SearchableModel subclass of db.Model that supports full text 21 | indexing and search, based on the datastore's existing indexes. 22 | 23 | Don't expect too much. First, there's no ranking, which is a killer drawback. 24 | There's also no exact phrase match, substring match, boolean operators, 25 | stemming, or other common full text search features. Finally, support for stop 26 | words (common words that are not indexed) is currently limited to English. 27 | 28 | To be indexed, entities must be created and saved as SearchableModel 29 | instances, e.g.: 30 | 31 | class Article(search.SearchableModel): 32 | text = db.TextProperty() 33 | ... 34 | 35 | article = Article(text=...) 36 | article.save() 37 | 38 | To search the full text index, use the SearchableModel.all() method to get an 39 | instance of SearchableModel.Query, which subclasses db.Query. Use its search() 40 | method to provide a search query, in addition to any other filters or sort 41 | orders, e.g.: 42 | 43 | query = article.all().search('a search query').filter(...).order(...) 44 | for result in query: 45 | ... 46 | 47 | The full text index is stored in a property named __searchable_text_index. 48 | 49 | Specifying multiple indexes and properties to index 50 | --------------------------------------------------- 51 | 52 | By default, one index is created with all string properties. You can define 53 | multiple indexes and specify which properties should be indexed for each by 54 | overriding SearchableProperties() method of model.SearchableModel, for example: 55 | 56 | class Article(search.SearchableModel): 57 | @classmethod 58 | def SearchableProperties(cls): 59 | return [['book', 'author'], ['book']] 60 | 61 | In this example, two indexes will be maintained - one that includes 'book' and 62 | 'author' properties, and another one for 'book' property only. They will be 63 | stored in properties named __searchable_text_index_book_author and 64 | __searchable_text_index_book respectively. Note that the index that includes 65 | all properties will not be created unless added explicitly like this: 66 | 67 | @classmethod 68 | def SearchableProperties(cls): 69 | return [['book', 'author'], ['book'], search.ALL_PROPERTIES] 70 | 71 | The default return value of SearchableProperties() is [search.ALL_PROPERTIES] 72 | (one index, all properties). 73 | 74 | To search using a custom-defined index, pass its definition 75 | in 'properties' parameter of 'search': 76 | 77 | Article.all().search('Lem', properties=['book', 'author']) 78 | 79 | Note that the order of properties in the list matters. 80 | 81 | Adding indexes to index.yaml 82 | ----------------------------- 83 | 84 | In general, if you just want to provide full text search, you *don't* need to 85 | add any extra indexes to your index.yaml. However, if you want to use search() 86 | in a query *in addition to* an ancestor, filter, or sort order, you'll need to 87 | create an index in index.yaml with the __searchable_text_index property. For 88 | example: 89 | 90 | - kind: Article 91 | properties: 92 | - name: __searchable_text_index 93 | - name: date 94 | direction: desc 95 | ... 96 | 97 | Similarly, if you created a custom index (see above), use the name of the 98 | property it's stored in, e.g. __searchable_text_index_book_author. 99 | 100 | Note that using SearchableModel will noticeable increase the latency of save() 101 | operations, since it writes an index row for each indexable word. This also 102 | means that the latency of save() will increase roughly with the size of the 103 | properties in a given entity. Caveat hacker! 104 | """ 105 | 106 | 107 | 108 | 109 | import re 110 | import string 111 | import sys 112 | 113 | from google.appengine.api import datastore 114 | from google.appengine.api import datastore_errors 115 | from google.appengine.api import datastore_types 116 | from google.appengine.ext import db 117 | from google.appengine.datastore import datastore_pb 118 | 119 | ALL_PROPERTIES = [] 120 | 121 | class SearchableEntity(datastore.Entity): 122 | """A subclass of datastore.Entity that supports full text indexing. 123 | 124 | Automatically indexes all string and Text properties, using the datastore's 125 | built-in per-property indices. To search, use the SearchableQuery class and 126 | its Search() method. 127 | """ 128 | _FULL_TEXT_INDEX_PROPERTY = '__searchable_text_index' 129 | 130 | _FULL_TEXT_MIN_LENGTH = 1 131 | 132 | _FULL_TEXT_STOP_WORDS = frozenset([]) 133 | 134 | _word_delimiter_regex = re.compile('[' + re.escape(string.punctuation) + ']') 135 | 136 | _searchable_properties = [ALL_PROPERTIES] 137 | 138 | def __init__(self, kind_or_entity, word_delimiter_regex=None, *args, 139 | **kwargs): 140 | """Constructor. May be called as a copy constructor. 141 | 142 | If kind_or_entity is a datastore.Entity, copies it into this Entity. 143 | datastore.Get() and Query() returns instances of datastore.Entity, so this 144 | is useful for converting them back to SearchableEntity so that they'll be 145 | indexed when they're stored back in the datastore. 146 | 147 | Otherwise, passes through the positional and keyword args to the 148 | datastore.Entity constructor. 149 | 150 | Args: 151 | kind_or_entity: string or datastore.Entity 152 | word_delimiter_regex: a regex matching characters that delimit words 153 | """ 154 | self._word_delimiter_regex = word_delimiter_regex 155 | if isinstance(kind_or_entity, datastore.Entity): 156 | self._Entity__key = kind_or_entity._Entity__key 157 | self._Entity__unindexed_properties = frozenset(kind_or_entity.unindexed_properties()) 158 | if isinstance(kind_or_entity, SearchableEntity): 159 | if getattr(kind_or_entity, '_searchable_properties', None) is not None: 160 | self._searchable_properties = kind_or_entity._searchable_properties 161 | self.update(kind_or_entity) 162 | else: 163 | super(SearchableEntity, self).__init__(kind_or_entity, *args, **kwargs) 164 | 165 | def _ToPb(self): 166 | """Rebuilds the full text index, then delegates to the superclass. 167 | 168 | Returns: 169 | entity_pb.Entity 170 | """ 171 | for properties_to_index in self._searchable_properties: 172 | index_property_name = SearchableEntity.IndexPropertyName(properties_to_index) 173 | if index_property_name in self: 174 | del self[index_property_name] 175 | 176 | 177 | 178 | if not properties_to_index: 179 | properties_to_index = self.keys() 180 | 181 | index = set() 182 | for name in properties_to_index: 183 | if not self.has_key(name): 184 | continue 185 | 186 | values = self[name] 187 | if not isinstance(values, list): 188 | values = [values] 189 | 190 | if (isinstance(values[0], basestring) and 191 | not isinstance(values[0], datastore_types.Blob)): 192 | for value in values: 193 | index.update(SearchableEntity._FullTextIndex( 194 | value, self._word_delimiter_regex)) 195 | 196 | index_list = list(index) 197 | if index_list: 198 | self[index_property_name] = index_list 199 | 200 | return super(SearchableEntity, self)._ToPb() 201 | 202 | @classmethod 203 | def _FullTextIndex(cls, text, word_delimiter_regex=None): 204 | """Returns a set of keywords appropriate for full text indexing. 205 | 206 | See SearchableQuery.Search() for details. 207 | 208 | Args: 209 | text: string 210 | 211 | Returns: 212 | set of strings 213 | """ 214 | 215 | if word_delimiter_regex is None: 216 | word_delimiter_regex = cls._word_delimiter_regex 217 | 218 | if text: 219 | datastore_types.ValidateString(text, 'text', max_len=sys.maxint) 220 | text = word_delimiter_regex.sub(' ', text) 221 | words = text.lower().split() 222 | 223 | words = set(unicode(w) for w in words) 224 | 225 | words -= cls._FULL_TEXT_STOP_WORDS 226 | for word in list(words): 227 | if len(word) < cls._FULL_TEXT_MIN_LENGTH: 228 | words.remove(word) 229 | 230 | else: 231 | words = set() 232 | 233 | return words 234 | 235 | @classmethod 236 | def IndexPropertyName(cls, properties): 237 | """Given index definition, returns the name of the property to put it in.""" 238 | name = SearchableEntity._FULL_TEXT_INDEX_PROPERTY 239 | 240 | if properties: 241 | name += '_' + '_'.join(properties) 242 | 243 | return name 244 | 245 | 246 | class SearchableQuery(datastore.Query): 247 | """A subclass of datastore.Query that supports full text search. 248 | 249 | Only searches over entities that were created and stored using the 250 | SearchableEntity or SearchableModel classes. 251 | """ 252 | 253 | def Search(self, search_query, word_delimiter_regex=None, 254 | properties=ALL_PROPERTIES): 255 | """Add a search query. This may be combined with filters. 256 | 257 | Note that keywords in the search query will be silently dropped if they 258 | are stop words or too short, ie if they wouldn't be indexed. 259 | 260 | Args: 261 | search_query: string 262 | 263 | Returns: 264 | # this query 265 | SearchableQuery 266 | """ 267 | datastore_types.ValidateString(search_query, 'search query') 268 | self._search_query = search_query 269 | self._word_delimiter_regex = word_delimiter_regex 270 | self._properties = properties 271 | return self 272 | 273 | def _ToPb(self, *args, **kwds): 274 | """Adds filters for the search query, then delegates to the superclass. 275 | 276 | Mimics Query._ToPb()'s signature. Raises BadFilterError if a filter on the 277 | index property already exists. 278 | 279 | Returns: 280 | datastore_pb.Query 281 | """ 282 | 283 | properties = getattr(self, "_properties", ALL_PROPERTIES) 284 | 285 | index_property_name = SearchableEntity.IndexPropertyName(properties) 286 | if index_property_name in self: 287 | raise datastore_errors.BadFilterError( 288 | '%s is a reserved name.' % index_property_name) 289 | 290 | pb = super(SearchableQuery, self)._ToPb(*args, **kwds) 291 | 292 | if hasattr(self, '_search_query'): 293 | keywords = SearchableEntity._FullTextIndex( 294 | self._search_query, self._word_delimiter_regex) 295 | for keyword in keywords: 296 | filter = pb.add_filter() 297 | filter.set_op(datastore_pb.Query_Filter.EQUAL) 298 | prop = filter.add_property() 299 | prop.set_name(index_property_name) 300 | prop.set_multiple(len(keywords) > 1) 301 | prop.mutable_value().set_stringvalue(unicode(keyword).encode('utf-8')) 302 | 303 | return pb 304 | 305 | 306 | class SearchableMultiQuery(datastore.MultiQuery): 307 | """A multiquery that supports Search() by searching subqueries.""" 308 | 309 | def Search(self, *args, **kwargs): 310 | """Add a search query, by trying to add it to all subqueries. 311 | 312 | Args: 313 | args: Passed to Search on each subquery. 314 | kwargs: Passed to Search on each subquery. 315 | 316 | Returns: 317 | self for consistency with SearchableQuery. 318 | """ 319 | for q in self: 320 | q.Search(*args, **kwargs) 321 | return self 322 | 323 | 324 | class SearchableModel(db.Model): 325 | """A subclass of db.Model that supports full text search and indexing. 326 | 327 | Automatically indexes all string-based properties. To search, use the all() 328 | method to get a SearchableModel.Query, then use its search() method. 329 | 330 | Override SearchableProperties() to define properties to index and/or multiple 331 | indexes (see the file's comment). 332 | """ 333 | 334 | @classmethod 335 | def SearchableProperties(cls): 336 | return [ALL_PROPERTIES] 337 | 338 | class Query(db.Query): 339 | """A subclass of db.Query that supports full text search.""" 340 | _search_query = None 341 | _properties = None 342 | 343 | def search(self, search_query, properties=ALL_PROPERTIES): 344 | """Adds a full text search to this query. 345 | 346 | Args: 347 | search_query, a string containing the full text search query. 348 | 349 | Returns: 350 | self 351 | """ 352 | self._search_query = search_query 353 | self._properties = properties 354 | 355 | if self._properties not in getattr(self, '_searchable_properties', [ALL_PROPERTIES]): 356 | raise datastore_errors.BadFilterError( 357 | '%s does not have a corresponding index. Please add it to' 358 | 'the SEARCHABLE_PROPERTIES list' % self._properties) 359 | 360 | return self 361 | 362 | def _get_query(self): 363 | """Wraps db.Query._get_query() and injects SearchableQuery.""" 364 | query = db.Query._get_query(self, 365 | _query_class=SearchableQuery, 366 | _multi_query_class=SearchableMultiQuery) 367 | if self._search_query: 368 | query.Search(self._search_query, properties=self._properties) 369 | return query 370 | 371 | def _populate_internal_entity(self): 372 | """Wraps db.Model._populate_internal_entity() and injects 373 | SearchableEntity.""" 374 | entity = db.Model._populate_internal_entity(self, 375 | _entity_class=SearchableEntity) 376 | entity._searchable_properties = self.SearchableProperties() 377 | return entity 378 | 379 | 380 | @classmethod 381 | def from_entity(cls, entity): 382 | """Wraps db.Model.from_entity() and injects SearchableEntity.""" 383 | if not isinstance(entity, SearchableEntity): 384 | entity = SearchableEntity(entity) 385 | return super(SearchableModel, cls).from_entity(entity) 386 | 387 | @classmethod 388 | def all(cls): 389 | """Returns a SearchableModel.Query for this kind.""" 390 | query = SearchableModel.Query(cls) 391 | query._searchable_properties = cls.SearchableProperties() 392 | return query 393 | -------------------------------------------------------------------------------- /server/command_tag.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file is part of Shell-Sink. 3 | Copyright Joshua Cronemeyer 2008, 2009 4 | 5 | Shell-Sink is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | Shell-Sink is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License v3 for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with Shell-Sink. If not, see . 17 | """ 18 | from command import Command 19 | from sysadmin import Sysadmin 20 | from google.appengine.ext import db 21 | 22 | class Tag(db.Model): 23 | name = db.CategoryProperty() 24 | 25 | class CommandTag(db.Model): 26 | command = db.ReferenceProperty(Command) 27 | tag = db.ReferenceProperty(Tag) 28 | user = db.ReferenceProperty(Sysadmin) 29 | tag_creation_date = db.DateTimeProperty(auto_now_add=True) 30 | command_creation_date = db.DateTimeProperty() 31 | MAX_TAGS_YOU_CAN_ADD_AT_ONE_TIME = 4 32 | 33 | def limit_tag_count(tags): 34 | number_of_tags = len(tags) 35 | if (number_of_tags > CommandTag.MAX_TAGS_YOU_CAN_ADD_AT_ONE_TIME): 36 | tags = tags[0:CommandTag.MAX_TAGS_YOU_CAN_ADD_AT_ONE_TIME] 37 | return tags 38 | 39 | def find_tag_by_name(name): 40 | tag = Tag.all().filter('name =', name).fetch(1) 41 | if len(tag) > 0: 42 | return tag[0] 43 | return None 44 | 45 | def create_tag(name): 46 | tag = find_tag_by_name(name) 47 | if not tag: 48 | tag = Tag(name = name) 49 | tag.put() 50 | return tag 51 | 52 | def create_command_tags(sysadmin, command, tag_names): 53 | for tag_name in tag_names: 54 | tag = create_tag(tag_name) 55 | command_tag = CommandTag(user = sysadmin, tag = tag, command = command, command_creation_date = command.date) 56 | command_tag.put() 57 | command.tags.extend(tag_names) 58 | command.put() 59 | 60 | def find_command_tags_by_tag(sysadmin, tag_name, page): 61 | tag = find_tag_by_name(tag_name) 62 | command_tag_query = db.GqlQuery("SELECT * FROM CommandTag WHERE user = :user AND tag = :tag ORDER BY command_creation_date desc", user = sysadmin, tag = tag) 63 | return command_tag_query.fetch(Command.COMMANDS_PER_PAGE, (page - 1) * Command.COMMANDS_PER_PAGE) 64 | 65 | def find_commands_by_filter_tag_for_atom(sysadmin): 66 | command_tags = find_command_tags_by_tag(sysadmin, sysadmin.filter(), 1) 67 | commands = [] 68 | for command_tag in command_tags: 69 | commands.append(command_tag.command) 70 | return commands 71 | -------------------------------------------------------------------------------- /server/html/add_tag.html: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 |
21 | 22 | 23 | 24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /server/html/annotation.html: -------------------------------------------------------------------------------- 1 | 18 | 19 |
{{ command.annotation|escape }}
20 |