Commits

Adam Gomaa  committed 76bb7d8

Initial code import

  • Participants

Comments (0)

Files changed (8)

+This is a web-based note viewer. I use this in conjuction with a
+script bound to an F-key that starts an emacsclient with a timestamped
+text file in a note directory. This allows me to quickly make one-off
+notes by pressing a single key, then close the emacsclient window and
+continue with what I was doing before. However, since I deliberately
+don't include filenames or the like - to make note-taking as quick and
+low-investment as possible - viewing the notes in a text editor isn't
+very nice. So this is a Django application that displays them on a
+simple web interface.
+
+
+Feature Creep
+=============
+
+I've already added automatic linking of URLs, editing in the browser,
+and am working on including non-text files. For example, I'd like to
+have a similar keybinding to take a screenshot and stick it in the
+same directory.
+
+
+Configuration
+=============
+
+I wrote this for myself, in a single-user web environment. Some things
+- path to my note directory, allowed username, etc were originally
+hardcoded. There's a notedir.Notedir() you should call, which will
+give you a view with your options, which you can then attach to your
+urlconf. Here's what I use in my urls.py::
+
+
+    from notedir import Notedir
+    def _notes_pre_callback(request, path):
+        from django.http import HttpResponseNotAllowed as http403
+        if not request.user.is_authenticated():
+            return http403("Go away!")
+        if request.user.username != 'akg':
+            return http403("Go away!")
+
+    notes = Notedir(
+        directory="/home/akg/var/notes", pre_callback=_notes_pre_callback)
+
+    urlpatterns = patterns(
+        # ...
+        url(r'^notes/(.*)$', notes, {}),
+    )
+

File notedir/__init__.py

+from functools import partial
+
+
+def Notedir(directory, pre_callback):
+    """directory - the directory containing your note files
+
+    pre_callback - runs before any actual processing. Unless it
+    returns None, the notedir view will return immediately with the
+    value given by pre_callback. pre_callback will be passed the
+    request and path.
+
+    """
+    from .views import notes
+    return partial(notes, directory=directory, pre_callback=pre_callback)
+
+
+

File notedir/note.py

+
+
+class Note(object):
+    "A file in my notes directory"
+
+    def __init__(self, fname, directory):
+        self.fname = fname
+        self.directory = directory
+
+    def model_data(self):
+        "The data for this Note that should be supplied to the Backbone model"
+        return {
+            "id": self.fname,
+            "timestamp": self.timestamp(),
+            "text": self.text(),
+            "html": self.html(),
+            }
+
+    def timestamp(self):
+        from os.path import splitext
+        return splitext(self.fname)[0]
+
+    def text(self):
+        return self.fd().read()
+
+    def html(self):
+        from django.utils.html import urlize
+        # urlize to make http:// clickable
+        return urlize(self.text())
+
+    def fd(self, mode='r'):
+        from os.path import join
+        return open(join(self.directory, self.fname), mode=mode)
+
+    def save_text(self, text):
+        self.fd('w').write(text)
+

File notedir/notes.css

+.notes-container
+{
+    margin: auto;
+    width: 95%;
+}
+
+.note
+{
+    border: solid #eee 1px;
+    margin-bottom: 20px;
+    padding: 10px;
+    background-color: #eee;
+    position: relative;
+}
+
+.note h3
+{
+    font-family: DejaVu Sans Mono;
+    margin: 0 auto 20px;
+    text-align: center;
+    font-weight: normal;
+    font-size: 25px;
+}
+.note .edit
+{
+    cursor:pointer;
+    text-decoration: underline;
+    color: #00f;
+    position: absolute;
+    top: 0;
+    right: 0;
+}
+
+.note-text
+{
+
+    white-space: pre-wrap;
+    font-family: DejaVu Sans Mono;
+}
+
+.notes-pagination
+{
+    margin: 20px 0px;
+    font-family: DejaVu Sans Mono;
+    text-align: center;
+    font-size: 20px;
+}

File notedir/notes.html

+<!DOCTYPE html>
+<html>
+  <head>
+    <title>Notedir</title>
+
+    <script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/jquery/1.7/jquery.min.js"></script>
+    <script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/underscore.js/1.3.1/underscore-min.js"></script>
+    <script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/backbone.js/0.9.0/backbone-min.js"></script>
+    <script type="text/javascript" src="js"></script>
+    <link rel="stylesheet" type="text/css" href="css">
+  </head>
+  <body>
+    <div id="note-template" style="display: none;">
+      <div class="note" data-note-timestamp="<%- timestamp %>" >
+        <a class="edit">edit</a>
+        <h3><%- timestamp %></h3>
+        <div class="note-text"><%= html %></div>
+      </div>
+    </div>
+    <div id="note-edit-template" style="display: none;">
+      <div class="note" data-note-timestamp="<%- timestamp %>" >
+        <h3><%- timestamp %></h3>
+        <textarea style="width: 100%;" name="text"><%= text %></textarea>
+      </div>
+    </div>
+    <div id="notes-view">
+      <div class="notes-pagination"></div>
+      <div class="notes-container"></div>
+      <div class="notes-pagination"></div>
+    </div>
+    <div id="pagination-template" style="display:none;">
+      <% if(pages > 1) { %>
+        <% if(prev) { %>
+          <a href="#" class="prev">previous</a>
+        <% } else { %>
+          <span>previous</span>
+        <% } %>
+
+        <%= range[0] %>..<%= range[1] %> of <%= total %>
+
+        <% if(next) { %>
+          <a href="#" class="next">next</a>
+        <% } else { %>
+          <span>next</span>
+        <% } %>
+      <% } %>
+    </div>
+  </body>
+</html>

File notedir/notes.js

+$(function(){
+
+    var get_template = function(selector){
+        return _.template($(selector).html().replace(/&lt;/g, "<").replace(/&gt;/g, ">"));
+    }
+
+// https://gist.github.com/838460
+var PaginatedCollection = Backbone.Collection.extend({
+  initialize: function() {
+    _.bindAll(this, 'parse', 'url', 'pageInfo', 'nextPage', 'previousPage');
+    typeof(options) != 'undefined' || (options = {});
+    this.page = 1;
+    typeof(this.perPage) != 'undefined' || (this.perPage = 10);
+  },
+  fetch: function(options) {
+    typeof(options) != 'undefined' || (options = {});
+    this.trigger("fetching");
+    var self = this;
+    var success = options.success;
+    options.success = function(resp) {
+      self.trigger("fetched");
+      if(success) { success(self, resp); }
+    };
+    return Backbone.Collection.prototype.fetch.call(this, options);
+  },
+  parse: function(resp) {
+    this.page = resp.page;
+    this.perPage = resp.perPage;
+    this.total = resp.total;
+    return resp.models;
+  },
+  url: function() {
+      return this.baseUrl + '?' + $.param({col: "", page: this.page, perPage: this.perPage});
+  },
+  pageInfo: function() {
+    var info = {
+      total: this.total,
+      page: this.page,
+      perPage: this.perPage,
+      pages: Math.ceil(this.total / this.perPage),
+      prev: false,
+      next: false
+    };
+
+    var max = Math.min(this.total, this.page * this.perPage);
+
+    if (this.total == this.pages * this.perPage) {
+      max = this.total;
+    }
+
+    info.range = [(this.page - 1) * this.perPage + 1, max];
+
+    if (this.page > 1) {
+      info.prev = this.page - 1;
+    }
+
+    if (this.page < info.pages) {
+      info.next = this.page + 1;
+    }
+
+    return info;
+  },
+  nextPage: function() {
+    if (!this.pageInfo().next) {
+      return false;
+    }
+    this.page = this.page + 1;
+    return this.fetch();
+  },
+  previousPage: function() {
+    if (!this.pageInfo().prev) {
+      return false;
+    }
+    this.page = this.page - 1;
+    return this.fetch();
+  }
+
+});
+
+
+var Note = Backbone.Model.extend({
+    timestamp_display: function(){
+        var ts = this.get("timestamp");
+        return ts.substr(0, 10);
+    },
+    url: function(){
+        return "/notes/note/" + this.id;
+    }
+});
+
+var NoteCol = PaginatedCollection.extend({model: Note, baseUrl: '/notes/'});
+
+PaginatedView = Backbone.View.extend({
+  initialize: function() {
+    _.bindAll(this, 'previous', 'next', 'render');
+      this.collection.bind('reset', this.render, this);
+  },
+  events: {
+    'click a.prev': 'previous',
+    'click a.next': 'next'
+  },
+  render: function() {
+      this.$el.find(".notes-pagination").html(get_template("#pagination-template")(this.collection.pageInfo()));
+  },
+
+  previous: function() {
+    this.collection.previousPage();
+    return false;
+  },
+  next: function() {
+    this.collection.nextPage();
+    return false;
+  }
+});
+
+// http://stackoverflow.com/a/499158/16361
+
+function setSelectionRange(input, selectionStart, selectionEnd) {
+  if (input.setSelectionRange) {
+    input.focus();
+    input.setSelectionRange(selectionStart, selectionEnd);
+  }
+  else if (input.createTextRange) {
+    var range = input.createTextRange();
+    range.collapse(true);
+    range.moveEnd('character', selectionEnd);
+    range.moveStart('character', selectionStart);
+    range.select();
+  }
+}
+
+function setCaretToPos (input, pos) {
+  setSelectionRange(input, pos, pos);
+}
+
+var NoteView = Backbone.View.extend({
+    tagName: "div",
+    template: get_template("#note-template"),
+    events: {
+        "click .edit": "startEdit",
+    },
+    initialize: function(){
+        _.bindAll(this, "startEdit");
+        this.edit_view = null;
+        this.model.bind("sync", function(){
+            this.render();
+        }, this);
+    },
+    render: function(){
+        $(this.el).html(this.template(this.template_context()));
+        return this;
+    },
+    template_context: function(){
+        return this.model.attributes;
+    },
+    startEdit: function(){
+        if(!this.edit_view){
+            this.edit_view = new EditableNoteView({model: this.model, el: this.el, view: this});
+        }
+        this.edit_view.render();
+    }
+});
+
+    var EditableNoteView = Backbone.View.extend({
+        template: get_template("#note-edit-template"),
+        events: {
+            "blur textarea": "stopEdit"
+        },
+        initialize: function(){
+            _.bindAll(this, "stopEdit");
+        },
+        render: function(){
+            var width = this.$(".note-text").width();
+            var height = this.$(".note-text").height();
+            $(this.el).html(this.template(this.model.attributes));
+            this.$("textarea").width(width).height(height).focus();
+            setCaretToPos(this.$("textarea")[0], this.$("textarea").val().length);
+        },
+        stopEdit: function(){
+            var val = this.$("textarea").val()
+            this.model.set({"text": val});
+            this.model.save();
+            this.options.view.render().$(".note-text").css({"color": "#888"});
+        }
+    });
+
+var AppView = PaginatedView.extend({
+    el: $("#notes-view"),
+    initialize: function(options){
+        this.collection = new NoteCol();
+        this.constructor.__super__.initialize.apply(this, [options])
+        //this.notes.bind("add", this.addOne, this)
+        this.collection.bind("reset", this.addAll, this)
+        this.collection.fetch();
+    },
+    addOne: function(note){
+        var view = new NoteView({model: note});
+        this.$(".notes-container").append(view.render().el);
+    },
+    addAll: function(){
+        this.$(".notes-container").empty();
+        this.collection.each(this.addOne)
+    }
+})
+
+    window.app = new AppView();
+});

File notedir/views.py

+from functools import partial
+import json
+
+from django.http import HttpResponse
+from django.http import Http404
+from django.http import HttpResponseRedirect
+
+from .note import Note
+
+def fs_file(name, *args, **kwargs):
+    "Get the source of a file in this directory."
+    # Interestingly, I don't think I ever have to mess with
+    # server-side templating. So, no django/jinja template decisions
+    # to make. Nice.
+    from os.path import join as j, dirname as d
+    return HttpResponse(open(j(d(__file__), name)).read(), *args, **kwargs)
+
+
+def notes(request, path, pre_callback, directory):
+    "Render notes template"
+    _Note = partial(Note, directory=directory)
+    pre_val = pre_callback(request, path)
+    # early exit of pre-cb returning anything
+    if pre_val is not None:
+        return pre_val
+
+    if request.method == "PUT":
+        if '/' in path:
+            first, rest = path.split("/", 1)
+        else:
+            first, rest = path, ''
+        try:
+            _view = getattr(Put, first)
+        except AttributeError:
+            raise Http404
+        return _view(request, *rest.split("/"), _Note=_Note)
+
+    def js_source():
+        "Get static JS"
+        from .util import get_jinja2_env
+        env = get_jinja2_env()
+        source, fname, uptodate = env.loader.get_source(None, "notes.js")
+        return HttpResponse(source, content_type="text/javascript")
+
+    def css_source():
+        "Get static CSS"
+        from .util import get_jinja2_env
+        env = get_jinja2_env()
+        source, fname, uptodate = env.loader.get_source(None, "notes.css")
+        return HttpResponse(source, content_type="text/css")
+
+    def notes_data():
+        "Get JSON data for backbone models"
+        from os import listdir
+        from os.path import join, splitext
+
+        notes = []
+        for fname in sorted(listdir(directory), reverse=True):
+            if fname.startswith("."): continue
+            notes.append(_Note(fname))
+
+        page = int(request.GET.get("page"))
+        per_page = int(request.GET.get("perPage"))
+        slice_start = per_page * (page - 1)
+        slice_end = slice_start + per_page
+        # data that'll be serialized & passed to Collection.reset()
+        resp_data = {
+            "page": page, "perPage": per_page, "total": len(notes),
+            "models": [n.model_data() for n in notes[slice_start:slice_end]]}
+        return HttpResponse(json.dumps(resp_data), content_type="text/javascript")
+
+
+    if path == "js":
+        return fs_file("notes.js", content_type="text/javascript")
+    if path == "css":
+        return fs_file("notes.css", content_type="text/css")
+    if "col" in request.GET:
+        return notes_data()
+
+    return fs_file("notes.html")
+
+
+# Oh why the hell not, Python. You should have given me
+# namespaces. Just a nice little syntax for grouping code, that's
+# all. Functions of a type, but not really related in the sense of
+# being on the same class.
+class Put:
+    @staticmethod
+    def note(request, fname, _Note):
+        posted_text = json.loads(request.raw_post_data)['text']
+        n = _Note(fname)
+        n.save_text(posted_text)
+        return HttpResponse(json.dumps(n.model_data()), content_type="text/javascript")
+
+from setuptools import setup
+
+setup(
+    name='notedir',
+    packages=['notedir'],
+    install_requires=['Jinja2'],
+    include_package_data=True,
+    version="0.0.1",
+    description="A web front-end for viewing text notes.",
+    )
+