How to write an Auto-Save feature for content with Flask and JQuery

This post is part of a series. This list contains all of the posts:

For my Python blog, I wanted to make sure that I had a feature enabled which would auto-save my blog posts as I typed them, in the event of a Windows 10 BSOD, as per its habit, or Firefox crashing, as it's keen to do, or for any other catastrophic situation. In fact, I didn't want to start writing any posts until I had this feature.

It took me a while, but here is the solution I came up with.

Requirements

There are several use cases that need to be accounted for.

The user opens a brand new post, a draft is saved, and the browser fails before the post is officially saved.

When the user opens a new post, a draft needs to be created. After the browser fails, the user can check the drafts, open it, and save it officially.

The user opens a brand new post, a draft is saved, and the user officially saves the post.

As before, a draft needs to be created when user opens a brand new post. However, if the user successfully saves the new post, the draft should be deleted.

The user opens the draft of a brand new post that was never officially saved, and then officially saves the draft.

If the user opens the draft of a brand new post, he must be able to save the draft. The draft becomes a real post, and the reference to this post as a draft needs to be deleted.

The user opens an old post to edit, a draft is saved, and the browser fails before the post is officially saved.

If the user opens an old post to edit, a draft must be created like before, except this time the draft must be linked to the old post. If the browser fails, the user can open the draft and save.

The user opens an old post to edit, a draft is saved, and the user officially saves the post.

If the user opens and old post to edit, and successfully saves the post, the draft that was linked to this old post must be deleted.

The user opens the draft of an old post that was never officially saved, and then officially saves the draft.

If the browser crashed when the user was editing an old post, a draft was created. The user can then open the draft and save the draft officially. In this case, the draft then becomes the true post, and the old post becomes the draft.

The user opens an old post to edit, even though that old post has drafts, and officially saves the old posts.

I went back and forth on this one. The user starts editing a post and his browser fails. There is a draft available for him to open, yet he chooses to reopen the original post, edit it and officially save his old post. What should we do with the drafts? I eventually decided to delete them. I figured that if he opened the original instead of the draft, he deliberately did not care about the draft. Of course, in the previous case above, I switch the draft with the original, without deleting the original, so why wouldn't I do that in this case? I may revisit this.

Database models

I already had a post table:

class Post(BaseModel):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.Unicode, nullable=False)
    url_name = db.Column(db.String, nullable=False, 
                         unique=True)
    description = db.Column(db.String, nullable=False)  
    content = db.Column(db.Unicode)
    is_published = db.Column(db.Boolean, default=False)

For my blog, I want everything simple. URL's should look like /blog/how-to-flask/ and not /blog/python/flask/how-to-flask/, and this is why I have the unique constraint on the url_name, although this did give me some trouble with the auto saving.

I created one new table, draft:

class Draft(BaseModel):
    id = db.Column(db.Integer, primary_key=True)
    original_post_id = db.Column(db.Integer, 
        db.ForeignKey('post.id', ondelete='CASCADE'), 
        nullable=True)
    draft_post_id = db.Column(db.Integer, 
        db.ForeignKey('post.id', ondelete='CASCADE'), 
        nullable=False)

    __table_args__ = (
       (db.UniqueConstraint(
           "original_post_id", 
           "draft_post_id")
        ),
    )

See how original_post_id can be null, but draft_post_id can not? This situation arises when the user is creating a brand new post- a draft is created but doesn't have an associated original post.

Javascript

I don't use javascript much, so just to get something up and running, I created an ajax call that executed after every keystroke. This created an extreme amount of lag, such that I missed keystrokes while typing in the text area.

Then I tried sending the ajax request back every 5 seconds. I did a variation where it would only execute if the textarea's value, or its length, changed. However, I still ran into the same problem with the missing keystrokes while typing in the text area, albeit every 5 seconds instead of every other keystroke.

Finally I stumbled upon this post on auto-saving with javascript which offered a great idea: only initiate the Ajax call if the user has not typed for some length of time.

//...
// A timeout object gets stored in timeout id as the
// result from jQuery's setTimeout. This can be used
// to cancel the timeout using jQuery's clearTimeout
timeout_id = null;

$('#post-content').keypress(function() {
    if (timeout_id) {
        // User has hit a key within 5 seconds
        // Break the timeout and do not ajax
        timeout_id = clearTimeout(timeout_id);
    }

    // Initiate the timeout and wait for 5 seconds
    timeout_id = setTimeout(function() {
        var content = {
            "content": $('#post-content').val(),
            "draft_id": $('#draft-id').val(),
            "post_id": $('#post-id').val()
        };

        // This is my ajax call to deliver draft payload
        save_draft(content);
    }
});

// The API will create a draft post in the database
// the first time this is called. It returns the 
// post_id for the draft and the javascript will
// update the draft-id form entry with this.
save_draft = function(content) {
    $.ajax({
        ...
        success: function(data) {
            $('#draft-id').val(draft_id);
        }
    });
}

So, 5 seconds after the last keystroke pressed, an ajax call will be launched containing the post content, the draft_id, and the post_id.

The post_id is null if this is a brand new post. It is not null under three conditions:

  1. I am editing an old post
  2. I am editing a draft of a new post that wasn't saved
  3. I am editing a draft of an old post

When the page loads, the draft_id is null if this is a brand new page or if we are editing an old post. It is not null at page load only when we are editing a draft of either a new post that wasn't saved, or the draft of an old post; in this case, the draft id is equal to the post id..

If the draft_id is null when the page loads, the very first ajax call to the draft API will create a new post, and the returned id becomes the draft_id. This information will be important to have when I officially save my post.

Flask Draft API for auto save

This is the code used to create the API that implements the autosave feature, which the javascript will call via ajax.

import uuid

@app.route('/api/save-draft/', methods=['POST'])
@login_required
def save_draft():
    data = request.json
    content = data['content']
    title = str(uuid.uuid4())
    url_name = title
    text_to_number = lambda x: None if x == '' else x
    draft_id = text_to_number(data['draft_id'])
    post_id = text_to_number(data['post_id'])

    if draft_id is None:
        """
        Draft ID is None only when user is creating a 
        brand new post or is editing a previous post, 
        AND this is the very first time this API has 
        been called for this post
        """
        post = Post(title=title, url_name=url_name,
                    content=content, is_published=False)
        db.session.add(post)
        db.session.flush()

        # post_id is None if this is a brand new post
        draft = Draft(original_post_id=post_id,
                      draft_post_id=post.id)
        db.session.add(draft)
    else:
        """
        draft_id is not None under the following OR
        conditions:
        * User created new post/edit old post, and this 
         is the 2nd through Nth time this api has 
         been called
        * User opens a draft. draft_id == post_id 
          - dont make a new draft
        """
        post = db.session.query(Post) \
               .filter_by(id=draft_id).one()
        post.content = content
        db.session.add(post)

     db.session.commit()

     draft_id = post.id
     return jsonify({"draft_id': draft_id}), 200

Flask view for officially saving a post

Remember the logic for draft_id andpost_id`:

When is post_id null?

post_id is null only if the user is saving a brand new post, or is saving the draft of a brand new post.

When is draft_id null?

draft_id is rarely null. It can only be null if the user typed constantly without stopping for longer than 5 seconds and hit save.

When is draft_id equal to post_id?

draft_id is equal to post_id only when the user is saving a draft, of either a brand new post or an old post. In theory, draft_id and post_id could both be null, so exclude this.

@app.route('/admin/post//', methods=['POST'])
@login_required
def save_post(post_id=None):
    post_id = request.form['post_id']
    draft_id = request.form['draft_id']
    title = request.form['post_title']
    submit = request.form['submit']
    # ...
    content = request.form['post_content']

    if post_id == '':
        # This is a brand new post, just delete draft
        post = Post()
        if draft_id:
            db.session.query(Post).filter_by(
                id=draft_id
            ).delete()
    else:
        post = db.session.query(Post).filter_by(
            id=post_id
        ).one()
        # We are either saving a draft of a new post,
        # saving a draft of an old post,
        # or saving on old post who may or may not 
        #   have drafts
        try:
            draft = db.session.query(Draft).filter_by(
                draft_post_id=post_id
            ).one()
        except NoResultFound:
            # We are saving old post, not a draft
            drafts = db.session.query(Draft) \
              .filter_by(original_post_id=post_id) \
              .all()
            if drafts:
                # This old post has drafts-delete them
                delete_drafts(drafts)
         else:
             # We are saving a draft of some post
             if draft.original_post_id:
                 # We are saving the draft of an old post
                 # Turn old post into the draft
                 # Turn the draft into the real post
                 if submit == 'publish':
                     update_post_to_draft(
                         draft.original_post_id
                     )
                     temp = draft.draft_post_id
                     draft.draft_post_id = draft.original_post_id
                     draft.original_post_id = temp

             else:
                 # We are saving a draft of a new post
                 # who was never saved. Just delete
                 # the draft
                 db.session.query(Draft) \
                    .filter_by(
                       draft_post_id=post_id
                    ).delete()

I've simplified this code a bit for the purpose of this blog. Check out the real thing at my Github.

If you would like to learn more, see the post describing my Python blog in detail.

This post is part of a series. This list contains all of the posts:


Comments

Add Comment

Name

Email

Comment

Are you human? * seven = 56