Code Walkthrough

Home Page

emails @example.com are ignored by official html standards

Todo

Need to create a default-user function refactor style for buttons

Backend


@app.route('/')
async def get_index(request):
    template = env.get_template('index.html')
    return response.html(template.render())


@app.route('/try-now', methods=['GET'])
async def try_now(request):
    try:
        username = id_generator()
        email = username + '@example.com'
        password = id_generator(size=16)
        hashf = hashlib.sha256()
        hashf.update(salt.encode('utf-8') + password.encode('utf-8'))
        has = hashf.hexdigest()
        user = User(username=username, email=email, password=has, verified=True
            )
        user.save()
        res = response.redirect('/home?trial=true')
        session = secrets.token_urlsafe(16)
        user.session = session
        user.save()
        set_session_cookie(res, session)
        db.init(get_db() + str(user.id) + '.db')
        db.create_tables([Notebook, Note])
        create_default_notebooks()
        return res
        temp = None
    except:
        traceback.print_exc()
        temp = abort(404)
    return temp


@app.route(
    '/<page:plannr-roadmap|submit-an-issue|suggest-a-feature|privacy-policy|terms-of-use>'
    )
async def get_index(request, page):
    try:
        template = env.get_template('page.html')
        return response.html(template.render({'link': page}))
        temp = None
    except:
        traceback.print_exc()
        temp = abort(404)
    return temp


Template

Template taken from tailwind

<!DOCTYPE html>
<html lang="en">
    <head>
        @[ _["Cache Client"] @]
        <title>Plannr</title>
        <script src="https://js.stripe.com/v3/"></script>
        <script src="stripe.js"></script>
    </head>
    <body>
        <section class="text-gray-600
                        body-font">
            <div class="container
                        px-5
                        py-12
                        mx-auto">
                <hstack align-x="right">
                    <a class="mr-12
                              p-4
                              border
                              border-black
                              text-lg"  href="/login">Login</a>
                </hstack>
                <div class="text-center mb-10">
                    <h1 class="sm:text-3xl
                                text-2xl
                                font-medium
                                text-center
                                title-font
                                text-gray-900
                                mb-4">
                                Plannr (beta)</h1>
                    <p class="text-base
                              leading-relaxed
                              xl:w-2/4
                              lg:w-3/4
                              mx-auto">
                              <i>Project Logbook for individuals</i></p>
                </div>
                <div class="flex
                            flex-wrap
                            lg:w-4/5
                            sm:mx-auto
                            sm:mb-2
                            -mx-2">
                    <div class="p-2
                                sm:w-1/2
                                w-full">
                        <div class="bg-gray-100
                                    rounded
                                    flex
                                    p-4
                                    h-full
                                    items-center
                                    ">
                            @[ _["Tick SVG"] @]
                            <span class="title-font font-medium">
                                Store all the notes related to a project in one place
                            </span>
                        </div>
                    </div>
                    <div class="p-2 sm:w-1/2 w-full">
                        <div class="bg-gray-100
                                    rounded
                                    flex
                                    p-4
                                    h-full
                                    items-center
                                    ">
                            @[ _["Tick SVG"] @]
                            <span class="title-font font-medium">
                                Full text search for all your bookmarks and notes
                            </span>
                        </div>
                    </div>
                    <div class="p-2
                                sm:w-1/2
                                w-full
                                ">
                        <div class="bg-gray-100
                                    rounded
                                    flex
                                    p-4
                                    h-full
                                    items-center
                                    ">
                            @[ _["Tick SVG"] @]
                            <span class="title-font font-medium">
                                Log short updates like twitter
                            </span>
                        </div>
                    </div>
                    <div class="p-2
                                sm:w-1/2
                                w-full
                                ">
                        <div class="bg-gray-100
                                    rounded
                                    flex
                                    p-4
                                    h-full
                                    items-center
                                    ">
                            @[ _["Tick SVG"] @]
                            <span class="title-font font-medium">
                            Public / Private notebooks
                            </span>
                        </div>
                    </div>
                </div>

                <vstack spacing="s" align-x="center" class="mt-20 mb-20">
                    <p class="plan__cta">
                        <a id="trial" class="mt-10
                                             text-white
                                             bg-indigo-500
                                             border-0
                                             py-2
                                             px-8
                                             focus:outline-none
                                             hover:bg-indigo-600
                                             rounded
                                             text-lg
                                             " href="/try-now">
                                             Try it with a demo account</a>
                    </p>
                </vstack>

                <vstack spacing="s" align-x="center" >
                    <div id="price3"  class="pt-5 text-center">
                        <span style="font-size: 2.5rem">
                            Basic plan - $3 / month 
                                <button class="mt-8
                                               text-white
                                               bg-indigo-500
                                               border-0
                                               py-2
                                               px-8
                                               focus:outline-none
                                               hover:bg-indigo-600
                                               rounded
                                               text-lg
                                               " id="basic-plan-btn">Sign Up</button>
                        </span>
                    </div>
                    <div id="price10"  class="pt-5 text-center">
                        <span style="font-size: 2.5rem">Pro plan - $10 / month
                        <button class="mt-8
                                       text-white
                                       bg-indigo-500
                                       border-0
                                       py-2
                                       px-8
                                       focus:outline-none
                                       hover:bg-indigo-600
                                       rounded
                                       text-lg
                                       " id="pro-plan-btn">Sign Up</button>
                        </span>
                        <div class="pt-2" style="font-size: 1.2rem">
                            Get access to more chapters, source code 
                            for private deploys and more!
                        </div>
                    </div>
                    <div class="pt-4" style="font-size: 1.2rem">
                        <i>If you can't afford this at the moment and you are a 
                        student / open source developer then feel free to drop 
                        an email for a free account</i>
                    </div>
                </vstack>

                <vstack spacing="s" align-x="center" class="mt-20 mb-20">
                    <p class="plan__cta">
                        <a class="mt-10
                                  text-white
                                  bg-indigo-500
                                  border-0
                                  py-2
                                  px-8
                                  focus:outline-none
                                  hover:bg-indigo-600
                                  rounded
                                  text-lg
                                  " href="/docs/index.html">
                                  Read the documentation + source code - 
                                  Literate programming inside!</a>
                    </p>
                </vstack>
            </div>
        </section>

        <section class="text-gray-600
                        body-font
                        relative
                        ">
            <div class="container
                        px-5
                        py-4
                        mx-auto
                        ">
                <div class="mx-auto">
                    <div class="flex flex-wrap -m-2">
                        <div class="p-2
                                    w-full
                                    pt-8
                                    mt-8
                                    border-t
                                    border-gray-200
                                    text-center
                                    ">
                            <span class="inline-flex">
                                <a class="ml-4 text-gray-500" 
                                    href="/submit-an-issue">
                                    Submit an issue
                                </a>
                                <a class="ml-4 text-gray-500" 
                                    href="https://blog.xyzzyapps.link">
                                    Blog
                                </a>
                                <a class="ml-4 text-gray-500" 
                                    href="/privacy-policy">
                                    Privacy
                                </a>
                                <a class="ml-4 text-gray-500" 
                                    href="/terms-of-use">
                                    Terms
                                </a>
                                <a class="ml-4 text-gray-500" 
                                    href="mailto:postmaster@xyzzyapps.link">
                                    Contact
                                </a>
                            </span>
                            <p class="leading-normal
                                      my-5
                                      text-gray-500
                                      ">
                                © 2021 Xyzzy Apps
                            </p>
                        </div>
                    </div>
                </div>
            </div>
        </section>
    </body>
</html>

Wordpress Wrapper

This page is a wrapper for showing iframe developed using wordpress.

<!DOCTYPE html>
<html lang="en">
    <head>
        @[ _["Cache Client"] @]
        <title>Plannr</title>
        <style>
        html, body {
            margin: 0;
            width: 100%;
            height: 100%;
            padding: 0;
        }
        iframe {
            position:fixed;
            top:0;
            left:0;
            bottom:0;
            right:0;
            width:100%;
            height:100%;
            border:none;
            margin:0;
            padding:0;
            overflow:hidden;
            z-index:999999;
        }
        </style>
    </head>
    <body>
        <iframe src="https://blog.xyzzyapps.link/{{ link }}" frameborder="0"></iframe>
    </body>
</html>

BDD Spec

./env/bin/radish radish/index.feature

Feature: home page

    Scenario: trial
        Given index page
        When try now is clicked
        Then add random user to database

Tests


from radish import given, when, then
import os, sys
sys.path.append(os.path.join(os.path.dirname(__file__)))
from tests_common import *
from selenium.webdriver.common.keys import Keys
import time

@given('index page')
def step_impl(context):
    browser = get_browser()
    browser.get(base_url)
    time.sleep(1)

@when('try now is clicked')
def step_impl(context):
    browser = get_browser()
    elem = browser.find_element_by_id("trial")
    elem.click()
    time.sleep(3)

@then('add random user to database')
def step_impl(context):
    browser = get_browser()
    elem = browser.find_element_by_id("notebooks-nav")
    notebooks = elem.find_elements_by_css_selector("a")
    assert len(notebooks) > 0

Login

Backend


@app.route('/login/<magic:.*>', methods=['GET'])
async def login_magic(request, magic):
    try:
        @[ _["Test User"] @]
        if user:
            session = secrets.token_urlsafe(16)
            user.session = session
            user.save()
            res = response.redirect('/home')
            set_session_cookie(res, session)
            return res
            temp = None
        else:
            template = env.get_template('login.html')
            return response.html(template.render({'msg':
                'Failed to login. Please try again'}))
            temp = None
        temp = temp
    except:
        traceback.print_exc()
        template = env.get_template('login.html')
        return response.html(template.render({'msg':
            'Failed to login. Please check the credentials again!'}))
        temp = None
    return temp


@app.route('/login', methods=['GET', 'POST'])
async def login(request):
    if request.method == 'GET':
        @[ _["Test User Session"] @]
    else:
        try:
            email = request.form.get('email')
            user = User.get(User.email == email)
            hashf = hashlib.sha256()
            hashf.update(salt.encode('utf-8') + secrets.token_urlsafe(16).
                encode('utf-8'))
            user.session = hashf.hexdigest()
            user.save()
            template = env.get_template('login.html')
            tasks.async_send_link.delay(user.email, hashf.hexdigest())
            return response.html(template.render({'msg':
                'Please check your email / spam folder for login link'}))
            temp = None
        except:
            traceback.print_exc()
            template = env.get_template('login.html')
            return response.html(template.render({'msg':
                'Failed to login. Please check the credentials again!'}))
            temp = None
        temp = temp
    return temp


@app.route('/logout', methods=['GET'])
async def login(request):
    try:
        session = request.cookies.get('session')
        user = User.get(User.session == session)
        user.session = None
        user.save()
        res.cookies.pop('session', None)
        res = response.redirect('/')
        return res
        temp = None
    except:
        traceback.print_exc()
        res = response.redirect('/')
        return res
        temp = None
    return temp


Sign Up Backend

Code from stripe example

Errors in getting subscription should protect against most exceptions although the error messages could be improved


stripe.api_key = config['STRIPE_SECRET_KEY']


@app.route('/setup')
async def steup(request):
    return response.json({'publishableKey': config['STRIPE_PUBLIC_KEY'],
        'basicPrice': config['STRIPE_PRICE3'], 'proPrice': config[
        'STRIPE_PRICE10']})


@app.route('/success', methods=['GET', 'POST'])
async def success(request):
    if request.method == 'GET':
        template = env.get_template('sign-up.html')
        id = request.args.get('session_id')
        checkout_session = stripe.checkout.Session.retrieve(id)
        return response.html(template.render({'msg': '', 'sessionId': id,
            'subscription': checkout_session['subscription']}))
        temp = None
    else:
        try:
            username = request.form.get('username')
            email = request.form.get('email')
            id = request.form.get('sessionId')
            checkout_session = stripe.checkout.Session.retrieve(id)
            password = id_generator(size=16)
            hashf = hashlib.sha256()
            hashf.update(salt.encode('utf-8') + password.encode('utf-8'))
            has = hashf.hexdigest()
            user = User(email=email, username=username, password=has,
                verified=True)
            user.save()
            session = secrets.token_urlsafe(16)
            user.session = session
            user.save()
            if user.metadata:
                metadata = user.metadata
                temp = None
            else:
                metadata = {}
                temp = None
            utils.set_nested_key(['last_payment'], metadata, checkout_session)
            user.metadata = metadata
            user.save()
            db.init(get_db() + str(user.id) + '.db')
            db.create_tables([Notebook, Note])
            create_default_notebooks()
            res = response.redirect('/home')
            set_session_cookie(res, session)
            return res
            temp = None
        except:
            traceback.print_exc()
            template = env.get_template('sign-up.html')
            return response.html(template.render({'msg':
                'Account already exists, please try again', 'sessionId': id,
                'subscription': checkout_session['subscription']}))
            temp = None
        temp = temp
    return temp


@app.route('/cancelled', methods=['GET'])
async def success(request):
    template = env.get_template('sign-up.html')
    return response.html(template.render({'msg':
        'Payment Failed. Would you like to try again ?', 'sessionId': ''}))


@app.route('/create-checkout-session', methods=['POST'])
async def checkout(request):
    data = request.json
    domain_url = config['HOST']
    try:
        checkout_session = stripe.checkout.Session.create(success_url=
            domain_url + '/success?session_id={CHECKOUT_SESSION_ID}',
            cancel_url=domain_url + '/cancelled', payment_method_types=[
            'card'], mode='subscription', line_items=[{'price': data[
            'priceId'], 'quantity': 1}])
        return response.json({'sessionId': checkout_session['id']})
        temp = None
    except:
        traceback.print_exc()
        return response.json({'error': {'message': str(e)}})
        temp = None
    return temp


Template

CSS centering sucks

<!DOCTYPE html>
<html lang="en">
    <head>
        @[ _["Cache Client"] @]
        <title>Plannr</title>
        <!-- CSS FILES -->
      <style>
            body, html {
                height: 100%;
                width: 100%;
            }

            .main-body, form {
                height: 100%;
                width: 100%;
            }
      </style>
    </head>
    <body>
        <section class="main-body text-gray-600 body-font">
            <form action="/login" method="POST">
                    %{ if msg == "" %}
                    <div class="v-center
                                rounded-lg
                                p-8
                                flex
                                flex-col
                                w-1/2
                                mt-10
                                md:mt-0
                                ">
                        <h2 class="text-gray-900
                                   text-lg
                                   font-medium
                                   title-font
                                   mb-5
                                   ">Enter your email</h2>
                        <div class="relative mb-4">
                            <input type="email" id="email" name="email" 
                                class="w-full
                                    bg-white
                                    rounded
                                    border
                                    border-gray-300
                                    focus:border-indigo-500
                                    focus:ring-2
                                    focus:ring-indigo-200
                                    text-base
                                    outline-none
                                    text-gray-700
                                    py-1
                                    px-3
                                    leading-8
                                    transition-colors
                                    duration-200
                                    ease-in-out
                                    ">
                        </div>
                        <button type="submit" id="submit" 
                            class="text-white
                            bg-indigo-500
                            border-0
                            py-2
                            px-8
                            focus:outline-none
                            hover:bg-indigo-600
                            rounded
                            text-lg
                            ">Get the login link</button>
                    </div>
                    %{ else %}
                    <div class="v-center
                                rounded-lg
                                p-8
                                flex
                                flex-col
                                w-1/2
                                mt-10
                                md:mt-0
                                ">
                        <p id="error-msg" class="mt-3">{{ msg }}</p>
                    </div>
                    %{ endif %}
            </form>
        </section>
      <script>
function vertical_center() {
	$(".v-center").each(function() {

		var margin = ( $(this).parent().height() - $(this).height() ) / 2;

		$(this).css("position", "absolute");
		$(this).css("display", "block");

		$(this).css("margin-top", margin);
		$(this).css("margin-bottom", margin);
                  console.log($(this).parent().width())
                  console.log($(this).width())


		var margin = ( $(this).parent().width() - $(this).width() ) / 2;
		$(this).css("left", margin);

	});
}

$(window).on('resize', function(){
          vertical_center()
});

vertical_center();

      </script>

    </body>
</html>

Sign Up Template

<!DOCTYPE html>
<html lang="en">
    <head>
        @[ _["Cache Client"] @]
        <title>Plannr</title>
        <!-- CSS FILES -->
      <style>
            body, html {
                height: 100%;
                width: 100%;
            }

            .main-body, form {
                height: 100vh;
                width: 100%;
            }
      </style>
    </head>
    <body>
        <section class="main-body text-gray-600 body-font">

        <div class="sr-payment-summary completed-view">
                <div class="v-center
                            rounded-lg
                            p-8
                            flex
                            flex-col
                            w-1/2
                            mt-10
                            md:mt-0
                            ">
                %{ if sessionId != "" %}
                <form action="/success" method="POST">
                        %{ if msg != "" %}
                            <b>{{msg}}</b><br>
                        %{ else %}
                            <h1><i>Your payment succeeded</i></h1>
                        %{ endif %}
                        Reference : {{ subscription }}
                        <h2 class="mt-5
                                   text-gray-900
                                   text-lg
                                   font-medium
                                   title-font
                                   mb-5
                                   ">Pick your username</h2>
                        <div class="relative mb-4">
                            <input type="hidden" name="sessionId" value="{{sessionId}}" />
                            <input type="text" id="username" name="username" 
                                class="w-full
                                    bg-white
                                    rounded
                                    border
                                    border-gray-300
                                    focus:border-indigo-500
                                    focus:ring-2
                                    focus:ring-indigo-200
                                    text-base
                                    outline-none
                                    text-gray-700
                                    py-1
                                    px-3
                                    leading-8
                                    transition-colors
                                    duration-200
                                    ease-in-out
                                    ">
                        </div>
                        <h2 class="text-gray-900
                                   text-lg
                                   font-medium
                                   title-font
                                   mb-5
                                   ">Enter your email</h2>
                        <div class="relative mb-4">
                            <input type="email" id="email" name="email" 
                                class="w-full
                                    bg-white
                                    rounded
                                    border
                                    border-gray-300
                                    focus:border-indigo-500
                                    focus:ring-2
                                    focus:ring-indigo-200
                                    text-base
                                    outline-none
                                    text-gray-700
                                    py-1
                                    px-3
                                    leading-8
                                    transition-colors
                                    duration-200
                                    ease-in-out
                                    ">
                        </div>
                        <button type="submit" id="submit" 
                            class="text-white
                                bg-indigo-500
                                border-0
                                py-2
                                px-8
                                focus:outline-none
                                hover:bg-indigo-600
                                rounded
                                text-lg
                                ">Get the login link</button>
                </form>
                %{ else %}
                    <h1>Your payment failed</h1>
                    Would you like to <a class="text-indigo-500" href="/">try again</a> ?
                %{ endif %}
           </div>
        </section>
      <script>
function vertical_center() {
	$(".v-center").each(function() {

		var margin = ( $(this).parent().height() - $(this).height() ) / 2;

		$(this).css("position", "absolute");
		$(this).css("display", "block");

		$(this).css("margin-top", margin);
		$(this).css("margin-bottom", margin);

		var margin = ( $(this).parent().width() - $(this).width() ) / 2;
		$(this).css("left", margin);

	});
}
vertical_center()

$(window).on('resize', function(){
          vertical_center()
});
      </script>

    </body>
</html>

BDD Spec

./env/bin/radish radish/login.feature

Feature: login features

  Scenario: user login
      Given we goto login page
      When we login
      Then see homepage

Tests


from radish import given, when, then
import os, sys
sys.path.append(os.path.join(os.path.dirname(__file__)))
from tests_common import *
from selenium.webdriver.common.keys import Keys
import time
from dotenv import dotenv_values
config = dotenv_values(".env")

@given('we goto login page')
def login_impl(context):
    browser = get_browser()
    browser.get(base_url + "login")

@when('we login')
def login_impl(context):
    browser = get_browser()
    elem = browser.find_element_by_id("email")
    elem.send_keys(config["TEST_USER"])
    elem = browser.find_element_by_id("submit")
    elem.click()

@then('see homepage')
def login_impl(context):
    # copy from email
    time.sleep(10)
    browser = get_browser()
    elem = browser.find_element_by_id("notebooks-nav")
    notebooks = elem.find_elements_by_css_selector("a")
    assert len(notebooks) > 0

Stripe JS

Todo

Could be simplified further

var handleFetchResult = function(result) {
  if (!result.ok) {
    return result.json().then(function(json) {
      if (json.error && json.error.message) {
        throw new Error(result.url + ' ' + result.status + ' ' + json.error.message);
      }
    }).catch(function(err) {
      showErrorMessage(err);
      throw err;
    });
  }
  return result.json();
};

var createCheckoutSession = function(priceId) {
  return fetch("/create-checkout-session", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      priceId: priceId
    })
  }).then(handleFetchResult);
};

var handleResult = function(result) {
  if (result.error) {
    showErrorMessage(result.error.message);
  }
};

var showErrorMessage = function(message) {
  var errorEl = document.getElementById("error-message")
  errorEl.textContent = message;
  errorEl.style.display = "block";
};

fetch("/setup")
  .then(handleFetchResult)
  .then(function(json) {
    var publishableKey = json.publishableKey;
    var basicPriceId = json.basicPrice;
    var proPriceId = json.proPrice;

    var stripe = Stripe(publishableKey);
    // Setup event handler to create a Checkout Session when button is clicked
    document
      .getElementById("basic-plan-btn")
      .addEventListener("click", function(evt) {
            createCheckoutSession(basicPriceId).then(function(data) {
            stripe
                .redirectToCheckout({
                    sessionId: data.sessionId
                })
                .then(handleResult);
            });
      });

    // Setup event handler to create a Checkout Session when button is clicked
    document
      .getElementById("pro-plan-btn")
      .addEventListener("click", function(evt) {
            createCheckoutSession(proPriceId).then(function(data) {
            stripe
                .redirectToCheckout({
                    sessionId: data.sessionId
                })
                .then(handleResult);
            });
      });

  });

Home

User Home

Backend


@app.route('/home')
async def get_home(request):
    try:
        session = request.cookies.get('session')
        if session:
            user = User.get(User.session == session)
            template = env.get_template('home.html')
            res = response.html(template.render({'session': session,
                'username': user.username}))
            return res
            temp = None
        else:
            res = response.redirect('/login')
            return res
            temp = None
        temp = temp
    except:
        res = response.redirect('/login')
        return res
        temp = None
    return temp

Template

Color scheme

  1. Black from Evernote

  2. Blue from twitter

Button shadow designed using this online tool.

Todo

Compact icons with labels for tooltips ?

<!DOCTYPE html>
<html>
    <head>
        <title>Plannr</title>
        @[ _["Cache Client"] @]
        <script>var session = "{{ session }}";
            var item = session;
            var username = "{{ username }}";
            if ((item === null) || (item === undefined) || (item === "None")) {
               window.location.replace(window.location.origin);
            }
        </script>
        <script src="/ejs.min.js"></script>
        <script src="/bower_components/underscore/underscore-min.js"></script>
        <script src="/bower_components/moment/min/moment.min.js"></script>
        <link rel="stylesheet" href="bower_components/jquery-calendar-heatmap/dist/jquery.CalendarHeatmap.min.css"/>
        <script src="bower_components/jquery-calendar-heatmap/dist/jquery.CalendarHeatmap.min.js"> </script>
        <script src="bower_components/jquery-simple-datetimepicker/jquery.simple-dtpicker.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js" crossorigin="anonymous"></script>
        <script src="//cdn.jsdelivr.net/npm/alertifyjs@1.13.1/build/alertify.min.js"></script>
        <script src="easymde.min.js"></script>

        <link rel="stylesheet" href="//cdn.jsdelivr.net/npm/alertifyjs@1.13.1/build/css/alertify.min.css"/>
        <link rel="stylesheet" href="bower_components/jquery-simple-datetimepicker/jquery.simple-dtpicker.css"/>
        <link rel="stylesheet" href="fontawesome-free/css/all.min.css">
        <link rel="stylesheet" href="easymde.min.css">
        <link rel="stylesheet" href="common.css">

        <!-- <link rel="stylesheet" href="https://grids.graaf.space/900/10/16.css"> -->

        <script type="text/template" id="simple-modal-template">
            <div class="fixed z-10 inset-0 h-full">
                <%- modal_content %>
            </div>
        </script>

        <script type="text/template" id="super-modal-template">
            <div class="fixed z-20 inset-0">
                <%- modal_content %>
            </div>
        </script>

        <script type="text/template" id="modal-template">
            <div class="fixed z-10"  
                style="top: 30vh;
                       left: 40vw;
                       box-shadow: 0px 4px 0px 0px #d4d4d4;
                       border:1px solid #dcdcdc;">
                <div class="p-4
                            inline-block
                            align-bottom
                            bg-white
                            rounded-lg
                            text-left
                            overflow-hidden
                            shadow-xl
                            transform
                            transition-all
                            w-full
                            h-3/4
                            " role="dialog" aria-modal="true" aria-labelledby="modal-headline">
                    <%- modal_content %>
                </div>
            </div>
        </script>

@[ _["Initial View JS Template"] @]
@[ _["CRUD Notebooks JS Template"] @]
@[ _["CRUD Notes JS Template"] @]
@[ _["Complex Note Operations JS Template"] @]
@[ _["Change User Data JS Template"] @]

    </head>

    <body>

        <div id="modals">
        </div>

        <div id="super-modals">
        </div>

        <hstack id="main-content">

            <div class="mr-4" id="notebooks" style="min-width: 20vw; height: 100%"></div>

            <vstack>

            <div id="dev-repl">
            <input id="dev-repl-input" type="text" 
                class="shadow
                    h-10
                    mt-2
                    px-2
                    pr-5
                    outline-none
                    rounded-lg
                    w-full
                    text-sm
                    basic-border
                    " onkeydown="repl(event, this)"/>
            </div>

            <hstack align-x="center" class="mt-2">
                <a id="search-button" class="myNavButton induce-opacity" onclick="showSearchModal()"><i class="fas fa-search" ></i>Search</a>
                <a id="clear-search-button" class="myNavButton" onclick="handleSearchClear()"><i class="fas fa-times"></i>Clear Search</a>
                <a id="add-note-button" class="myNavButton" onclick="showNoteModal(true)"><i class="fas fa-plus"></i>Note</a>
                <a id="pin-button" class="myNavButton" onclick="handlePin()"><i class="fas fa-thumbtack"></i>Pin</a>
                <a id="remove-pin-button" class="myNavButton" onclick="handleUnpin()"><i class="fas fa-thumbtack"></i>Remove Pin</a>
                <a id="delete-note-button" class="myNavButton" onclick="handleBulkDelete()"><i class="far fa-trash-alt"></i>Delete</a>
                <a class="myNavButton" onclick="handleSelect()"><i class="fas fa-caret-down"></i>Select All</a>
                <a id="merge-button" class="myNavButton" onclick="handleMerge()"><i class="fas fa-object-group"></i>Merge</a>
                <a id="rename-notebook-button" class="myNavButton induce-opacity" onclick="showRenameModal()"><i class="fas fa-exchange-alt"></i>Rename Notebook</a>
                <a id="make-public" class="myNavButton" onclick="handlePublish()"><i class="fas fa-globe"></i>Make Public</a>
                <a id="make-unpublic" class="myNavButton" onclick="handleUnpublish()"><i class="fas fa-globe"></i>Make Private</a>
                <span id="public-url" 
                    class="ml-2"
                    style="color: #0645ad;
                           text-decoration: underline;
                           font-weight: bold"></span>
            </hstack>

            <div style="height: 80vh; width: 78vw" class="overflow-y-scroll mt-8">

                <hstack align-x="center">
                    <div id="heatmap">
                    </div>
                </hstack>

                <article id="pinned">
                </article>

                <article id="notes">
                </article>

                <div class="hidden"  id="pagination">
                    <hstack class="mt-4">
                        <button class="myButton" onclick="handleNextPage()">
                            More
                        </button>
                    </hstack>
               </div>
            </div>

            </vstack>
        </hstack>
        </div>
        <script type="text/javascript" src="scripts.js"></script>
    </body>

</html>

Public Notebooks

Public pages allow you to broadcast your notebooks like twitter

You can

  • Publish linklog

  • Music Playlists

  • Project Updates

Recommend an RSS Reader

  • Netnewswire

Backend



@app.route("/@<name:[A-z0-9-_:'!*]+>/<slug:[A-z0-9-_:'!*]+>")
async def public(request, name, slug):
    user = User.get(User.username == name)
    db.init(get_db() + str(user.id) + '.db')
    notebooks = Notebook.select().order_by(Notebook.name)
    for notebook in notebooks:
        if notebook.metadata:
            pslug = utils.get_nested_key(['public', 'slug'], notebook.
                metadata, '')
            if pslug == slug:
                if 'pinned' in notebook.metadata:
                    pinned = notebook.metadata['pinned']
                    temp = None
                else:
                    pinned = []
                    temp = None
                entries = []
                for pin in pinned:
                    try:
                        entry = Note.get(Note.id == int(pin))
                        temp = None
                    except:
                        continue
                        temp = None
                    entry_dict = model_to_dict(entry)
                    entry_dict.pop('notebook', None)
                    format_times(entry_dict)
                    entries.append(entry_dict)
                notes = []
                for entry in notebook.entries.order_by(Note.add_date.desc()):
                    if str(entry.id) in pinned:
                        entry_dict = model_to_dict(entry)
                        entry_dict.pop('notebook', None)
                        format_times(entry_dict)
                        temp = notes.append(entry_dict)
                    else:
                        temp = None
                template = env.get_template('public.html')
                return response.html(template.render({'user': user,
                    'notebook': notebook, 'notes': notes, 'pinned': entries,
                    'pinned_ids': pinned}))
                temp = None
            else:
                temp = None
            temp = temp
        else:
            temp = None
    return abort(404)


@app.route("/@<name:[A-z0-9-_:'!*]+>/favicon.ico")
async def feed(request, name):
    return await response.file('./public/favicon.ico')


import markdown


@app.route("/@<name:[A-z0-9-_:'!*]+>/<slug:[A-z0-9-_:'!*]+>.rss")
async def feed(request, name, slug):
    user = User.get(User.username == name)
    db.init(get_db() + str(user.id) + '.db')
    notebooks = Notebook.select().order_by(Notebook.name)
    for notebook in notebooks:
        if notebook.metadata:
            pslug = utils.get_nested_key(['public', 'slug'], notebook.
                metadata, '')
            if pslug == slug:
                notes = []
                for entry in notebook.entries.order_by(Note.add_date.desc()
                    ).paginate(0, 50):
                    entry_dict = model_to_dict(entry)
                    content = entry_dict['content']
                    entry_dict.pop('notebook', None)
                    entry_dict.pop('content', None)
                    html = markdown.markdown(content, extensions=['nl2br',
                        'md_in_html', 'fenced_code', 'tables',
                        'mdx_linkify', 'markdown_checklist.extension'])
                    entry_dict['content'] = html
                    format_email(entry_dict)
                    notes.append(entry_dict)
                template = env.get_template('template.rss')
                return response.text(template.render({'user': user,
                    'notebook': notebook, 'notes': notes}), content_type=
                    'application/rss+xml')
                temp = None
            else:
                temp = None
            temp = temp
        else:
            temp = None
    return abort(404)


def get_public(session, notebook):
    try:
        notebook = Notebook.get(Notebook.name == notebook)
        if notebook.metadata:
            metadata = notebook.metadata
            temp = None
        else:
            metadata = {}
            temp = None
        if 'public' in metadata:
            public = notebook.metadata['public']
            temp = None
        else:
            public = {}
            temp = None
        if 'slug' in public:
            slug = public['slug']
            temp = None
        else:
            slug = ''
            temp = None
        user = User.get(User.session == session)
        return {'slug': slug, 'username': user.username}
        temp = None
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp


def set_public(notebook_name, boolean, session):
    try:
        user = User.get(User.session == session)
        notebook = Notebook.get(Notebook.name == notebook_name)
        if notebook.metadata:
            metadata = notebook.metadata
            temp = None
        else:
            metadata = {}
            temp = None
        utils.set_nested_key(['public', 'slug'], metadata, slugify(
            notebook_name)) if boolean else utils.set_nested_key(['public',
            'slug'], metadata, None)
        notebook.metadata = metadata
        return notebook.save()
        return True
        temp = None
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp




UI Event Handlers

async function handlePublish(e) {
    if (username === "") {
        alert("Set the username in the user settings from the user settings menu on the top left corner")
    } else {
        send({ event: "set-public", args: [activeNotebook, true, session] });
    }
}

async function handleUnpublish(e) {
    send({ event: "set-public", args: [activeNotebook, false, session] });
}

Websocket Reply Handlers

        "set-public": function () {
            var spublic = msg["set-public"];
            if (spublic !== false) {
                send({ event: "get-public", args: [session, activeNotebook] })
                alertify.notify('Published', 'success', 5, function(){  });
            } else {
                alertify.notify('Failed', 'error', 5, function(){  });
            }
        },
        "get-public": function () {
            var notebook = msg["get-public"]
            if (notebook.slug) {
                $("#make-public").hide();
                $("#public-url").html(`<a target="_blank" 
                                        href="https://${host}/@${notebook.username}/${notebook.slug}"
                                      >public link</a>`)
                $("#public-url").show();
                $("#make-unpublic").show();
            } else {
                $("#make-public").show();
                $("#public-url").hide();
                $("#make-unpublic").hide();
            }
        },

Template

Todo

Jinja escape not working near “@”

<!DOCTYPE html>
<html>
    <head>
        <title>Plannr</title>
        <meta charset="utf-8">
        @[  _["Cache Client Without Script"] % ("") @]

        <meta name=”robots” content=”nofollow, noindex />
        <script src="/ejs.min.js"></script>
        <script src="/bower_components/underscore/underscore-min.js"></script>
        <link rel="stylesheet" href="/fontawesome-free/css/all.min.css">
        <link rel="stylesheet" href="/common.css">
        <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-dateFormat/1.0/jquery.dateFormat.min.js" integrity="sha512-YKERjYviLQ2Pog20KZaG/TXt9OO0Xm5HE1m/OkAEBaKMcIbTH1AwHB4//r58kaUDh5b1BWwOZlnIeo0vOl1SEA==" crossorigin="anonymous"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js" integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw==" crossorigin="anonymous"></script>

        <script type="text/template" id="note-template">

            <%
                var html = converter.makeHtml(content);
                var [dt, months, time] = convertDate(add_date);
            %>

            <div class="note">
                <section class="mr-2" id="today" draggable="true"   ondragstart="drag(event)" >
                    <div class="day"><%= dt.getFullYear() %></div>
                    <div class="date"> <%= dt.getDate() %> <%= months[dt.getMonth()] %></div>
                    <div class="time"><%= time %></div>
                </section>

                <div class="editor-preview">
                    <%- html %>
                </div>

            </div>
        </script>

        <script>
            var converter = new showdown.Converter({
                simplifiedAutoLink: true,
                simpleLineBreaks: true,
                tasklists: true,
                ghCodeBlocks: true,
                tables: true,
                strikethrough: true,
                openLinksInNewWindow: true
            });

            function convertDate(add_date) {
                var dt = new Date(add_date);
                var months = new Array("January",
                "February",
                "March",
                "April",
                "May",
                "June",
                "July",
                "August",
                "September",
                "October",
                "November",
                "December");
                var hours = dt.getHours();
                var minutes = dt.getMinutes();
                var suffix = "am";
                if(minutes < 10) {
                    minutes = "0" + minutes;
                }

                if (hours >= 12) {
                    suffix = "pm";
                    hours = hours - 12;
                }

                if (hours == 0) {
                    hours = 12;
                }

                var time = hours + ":" + minutes;

                return [dt, months, time]
            }

        </script>

    </head>

    <body>
        <div class="grid grid-cols-12 gap-4">
            <section class="col-span-12" >
            <hstack align-x="center">
                <article id="bio">
                    {{ user.username }}
                    <a class="mt-1 ml-5" 
                        type="application/rss+xml" 
                        href="https://plannr.xyzzyapps.link/@{{user.username}}/{{notebook.metadata["public"]["slug"] }}.rss">
                        <i class="fas fa-rss"></i>RSS
                    </a>
                    <script>
                        var content = "{{user.bio}}"
                        var html = converter.makeHtml(content);
                        document.write(html);
                    </script>
                </article>
            </hstack>

            <div class="col-span-12 mt-16">
                <vstack align-x="center">
                <article id="pinned">
                    %{ for e in pinned %}
                        <script>
                            var stuff = {{ e | tojson }};
                            var html = ejs.render($("#note-template").html(), {
                                content: stuff.content,
                                add_date: stuff.add_date
                            });
                            document.write(html);
                        </script>
                    %{ endfor %}
                </article>

                    <article id="notes">
                        %{ for e in notes %}
                            %{ if e.id not in pinned_ids %}
                                <script>
                                    var stuff = {{ e | tojson }};
                                    var html = ejs.render($("#note-template").html(), {
                                        content: stuff.content,
                                        add_date: stuff.add_date
                                    });
                                    document.write(html);
                                </script>
                            %{ endif %}
                        %{ endfor %}
                    </article>
                    </vstack>
                </div>
            </section>
        </div>
    </body>

</html>

Template RSS

<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
  <channel>
   <title>{{ user.username + "'s " + notebook.name}}</title>
   <description>Updates from {{ user.username + "'s " + notebook.name}}</description>
   <link>{{ "https://plannr.xyzzyapps.link/@" + user.username + "/" +  notebook.metadata["public"]["slug"] }}</link>
   <language>en-us</language>
   %{ for note in notes %}
    <item>
        <title>Update</title>
        <description><![CDATA[{{note.content}}]]></description>
        <pubDate>{{note.add_date}}</pubDate>
        <guid>{{note.id}}</guid>
    </item>
  %{ endfor %}
   </channel>
</rss>

BDD Spec

    @timeline_public_notebooks
    Scenario: public notebooks
        Given homepage
        When public button is clicked
        Then notebook is made public
        When unpublic button is clicked
        Then notebook is made private

Tests

/env/bin/radish radish/timeline.feature --tags 'timeline_public_notebooks'


@when('public button is clicked')
def default_timeline_impl(step):
    browser = get_browser()
    elem = browser.find_element_by_id("make-public")
    elem.click()
    time.sleep(3)

@then('notebook is made public')
def default_timeline_impl(step):
    browser = get_browser()
    elem = browser.find_element_by_id("public-url")
    assert re.search("public link", elem.text)

@when('unpublic button is clicked')
def default_timeline_impl(step):
    browser = get_browser()
    elem = browser.find_element_by_id("make-unpublic")
    elem.click()
    time.sleep(3)

@then('notebook is made private')
def default_timeline_impl(step):
    browser = get_browser()
    elem = browser.find_element_by_id("make-public")
    assert elem == browser.search("Make Public", elem.text)

Timeline View

Featrure Heatmap

Useful for seinfeld tracking, daily journal and daily process tracking

Backend


def get_notebooks():
    try:
        notebooks = Notebook.select(Notebook.name).order_by(Notebook.name)
        names = []
        for notebook in notebooks:
            names.append(notebook.name)
        return names
        temp = None
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp


def get_pinned(notebook_name):
    try:
        notebook = Notebook.get(Notebook.name == notebook_name)
        if notebook.metadata:
            metadata = notebook.metadata
            temp = None
        else:
            metadata = {}
            temp = None
        if 'pinned' in metadata:
            pinned = notebook.metadata['pinned']
            temp = None
        else:
            pinned = []
            temp = None
        entries = []
        for pin in pinned:
            try:
                entry = Note.get(Note.id == int(pin))
                temp = None
            except:
                continue
                temp = None
            entry_dict = model_to_dict(entry)
            entry_dict.pop('notebook', None)
            format_times(entry_dict)
            entries.append(entry_dict)
        return entries
        temp = None
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp


def get_notes(notebook_name):
    try:
        notebook = Notebook.get(Notebook.name == notebook_name)
        entries = []
        for entry in notebook.entries.order_by(Note.add_date.desc()).paginate(
            1, 50):
            entry_dict = model_to_dict(entry)
            entry_dict.pop('notebook', None)
            format_times(entry_dict)
            entries.append(entry_dict)
        return {'entries': entries}
        temp = None
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp


def get_more_notes(notebook_name, page_no):
    try:
        notebook = Notebook.get(Notebook.name == notebook_name)
        entries = []
        for entry in notebook.entries.order_by(Note.add_date.desc()).paginate(
            int(page_no), 50):
            entry_dict = model_to_dict(entry)
            entry_dict.pop('notebook', None)
            format_times(entry_dict)
            entries.append(entry_dict)
        return {'entries': entries}
        temp = None
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp


def get_heatmap(notebook_name):
    try:
        notebook = Notebook.get(Notebook.name == notebook_name)
        entries = {}
        for entry in notebook.entries.order_by(Note.add_date.desc()):
            change_date = entry.change_date.strftime('%Y-%m-%d')
            if change_date in entries:
                entries[change_date] = entries[change_date] + 1
                temp = None
            else:
                entries[change_date] = 1
                temp = None
        data = []
        for key in entries:
            data.append({'date': key, 'count': entries[key]})
        return data
        temp = None
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp


EJS Template

Datebox design taken from mobile patterns

<script type="text/template" id="notes-template">

    <% notes.forEach(function(note) {
        var html = converter.makeHtml(note.content);
        var [dt, months, time] = convertDate(note.add_date);
    %>
        <div class="note" data-note-id="<%= note.id %>">
            <section id="today" draggable="true"   ondragstart="drag(event)" 
                     data-note-id="<%= note.id %>">
                <div class="day"><%= dt.getFullYear() %></div>
                <div class="date"> <%= dt.getDate() %> <%= months[dt.getMonth()] %></div>
                <div class="time"><%= time %></div>
            </section>

            <div class="note-edit-controls">
                <input onclick="cboxClick(event)" type="checkbox" 
                       data-notebook="<%= activeNotebook %>"
                       data-id="<%= note.id %>" />
                <br/>
                <span onclick="handleEdit(event)" 
                      data-id="<%= note.id %>">
                      <i class="fa fa-edit font-hairline" ></i>
                </span>
            </div>

            <div class="editor-preview">
                <%- html %>
            </div>

        </div>
    <% }); %>

</script>

<script type="text/template" id="notebooks-template">
    <div class="main-sidebar">
    <div class="flex
                text-white
                text-lg
                p-2
                ">
        <img src="favicon-32x32.png" 
             style="width: 8%; height: 8% !important" 
             class="mr-2 mt-1"/>
        <h3 class="font-bold">Notebooks</h3>
        <span class="flex-grow"></span>
        <span><a id="create-notebook-button"
                 class="induce-opacity
                        cursor-pointer
                        " onclick="showNotebookModal(true)">
                    <i class="fas fa-plus"></i></a>
        <span id="user-settings" class="ml-2
                  induce-opacity
                  cursor-pointer
                  " onclick="handleUserSettings()">
            <i class="fas fa-cog"></i>
        </span>
        <span class="ml-4
                     mr-2
                     cursor-pointer
                     " >
            <a href="/logout"><i class="fas fa-sign-out-alt"></i></a>
        </span>
    </div>
    <div class="flex
                text-white
                p-2
                " style="font-size: 1rem;">
        <vstack>
            <a target="_blank" href="/submit-an-issue">
                <i class="fas fa-comment-medical mr-2"></i>
                Submit an Issue
            </a>
            <a target="_blank" href="/suggest-a-feature">
                <i class="fas fa-comments-dollar mr-2"></i>
                Suggest a feature
            </a>
        </vstack>
    </div>
    <nav id="notebooks-nav" >
        <% var as = "";
        if (activeNotebook === "__pinned") {
            var as = "notebook-active";
        }
        %>
        <a class="<%= as %>" onclick="getAllPins(event)" >
            <p>
            <i class="fas fa-thumbtack"></i>
            Pinned
            </p>
            <span class="flex-grow"></span>
        </a>
        <% notebooks.forEach(function(notebook) { %>
            <% if (activeNotebook === notebook) { %>
                <a class="notebook-active">
                    <p>
                    <i class="fas fa-book-open"></i>
                    <%= notebook %>
                    </p>
                    <span class="flex-grow"></span>
                    <span class="mr-6
                                 notebook-delete-element
                                 " onclick="handleNotebookDelete()">
                        <i class="far fa-trash-alt"></i>
                    </span>
                </a>
            <% } else {%>
                    <a onclick="changeNotebook(event)">
                        <p style="width: 100%"
                            ondrop="drop(event)" 
                            ondragenter="allowDrop(event)"
                            ondragover="dragOver(event)"
                            ondragleave="removeDrop(event)">
                            <i class="fas fa-book-open"></i><%= notebook %>
                        </p>
                    </a>
            <% } %>
        <% }); %>
    </nav>
    </div>
</script>

UI Event Handlers

function handleNextPage() {
    ++page;
    send({ event: "get-more-notes", args: [activeNotebook, page] })
}

Websocket Reply Handlers

        "get-pinned": function () {
            var notes = msg["get-pinned"];
            notes.map(function (note) {
                $('[data-note-id="' + note.id + '"]').remove();
            })
            displayTemplate("#notes-template", { 
                    notes: notes,
                    notebook: activeNotebook,
                    pinned: true
                },
                "#pinned");
            if (notes.length > 0) {
                $("#notes").addClass("pinned-border");
            }
            send({ event: "get-heatmap", args: [activeNotebook] })
        },
        "get-heatmap": function () {
            var chartData = msg["get-heatmap"];
            log(chartData)
            $("#heatmap").CalendarHeatmap(chartData);
            $("#heatmap").CalendarHeatmap( "updateDates", chartData );
        },
        "get-more-notes": function () {
            $("#clear-search-button").hide();
            var html = ejs.render($("#notes-template").html(), { 
                notes: msg["get-more-notes"]["entries"],
                notebook: activeNotebook,
                pinned: false
            });
            $("#notes").append(html);
            if (msg["get-more-notes"]["entries"].length >= 50) {
                $("#pagination").show();
            } else {
                $("#pagination").hide();
            }
        },
        "get-notes": function () {
            $("#clear-search-button").hide();
            displayTemplate("#notes-template", { 
                notes: msg["get-notes"]["entries"],
                notebook: activeNotebook,
                pinned: false 
            }, "#notes");
            send({ event: "get-pinned", args: [activeNotebook] })
            send({ event: "get-public", args: [session, activeNotebook] })
            if (msg["get-notes"]["entries"].length >= 50) {
                $("#pagination").show();
            } else {
                $("#pagination").hide();
            }
        },

Basic Notebook operations

  1. Create

  2. Rename

  3. Delete

Only empty notebooks can be deleted.

Backend



def create_notebook(name):
    try:
        notebook = Notebook.get(Notebook.name == name)
        temp = None
    except peewee.DoesNotExist:
        notebook = Notebook(name=name)
        return notebook.save()
        temp = None
    except:
        return False
        temp = None
    return True


def remove_notebook(name):
    try:
        notebook = Notebook.get(Notebook.name == name)
        if len(notebook.entries) == 0:
            return notebook.delete_instance()
            temp = None
        else:
            temp = None
        temp = temp
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp


def rename_notebook(notebook, new_name):
    try:
        if re.match("^[A-z0-9-_:'\!\*]+$", new_name):
            notebook = Notebook.get(Notebook.name == notebook)
            notebook.name = new_name
            notebook.save()
            return True
            temp = None
        else:
            return False
            temp = None
        temp = temp
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp


def edit_notebook(name, new_name):
    try:
        notebook = Notebook.get(Notebook.name == name)
        notebook.name = new_name
        return notebook.save()
        if True:
            return notebook.delete_instance()
            temp = None
        else:
            temp = None
        temp = temp
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp


EJS Template

<script type="text/template" id="new-notebook-modal">
    <div class="bg-white
                px-4
                pt-5
                pb-4
                ">
        <div>
            <div class="w-full
                        mt-3
                        text-center
                        ">
                <input id="notebook" 
                       placeholder="New Noteboook" 
                       class="w-full myTextInput"
                       type="text" value="">
            </div>
        </div>
    </div>
    <div class="px-4
                py-3
                px-6
                flex
                flex-row-reverse
                ">
            <button  id="cancel-notebook-button" type="button" class="ml-4 myButton" 
                onclick="closeModal()">
                Cancel
            </button>
            <button id="add-notebook-button" type="button" class="myButton"
                onclick="addNotebook(document.getElementById('notebook').value)">
                Add
        </button>
    </div>
</script>

<script type="text/template" id="rename-notebook-modal">
    <div class="bg-white
                px-4
                pt-5
                pb-4
                ">
        <div>
            <div class="w-full
                        mt-3
                        text-center
                        ">
                <input id="notebook-rename" 
                        placeholder="New Notebook Name"
                        class="w-full myTextInput"
                        type="text" value="">
            </div>
        </div>
        <div class="px-4
                    py-3
                    px-6
                    flex
                    flex-row-reverse
                    ">
            <button type="button"
                class="ml-4 myButton"
                onclick="closeModal()">
                Cancel
            </button>
            <button id="notebook-rename-submit"
                    type="button"
                    class="myButton"
                    onclick="renameNotebook(document.getElementById('notebook-rename').value)">
                Add
            </button>
    </div>
</script>

UI Event Handlers

Todo

Explain scrolling logic

async function changeNotebook(event) {
    if (event.target.innerText.trim()) {
        var scroll = $(".main-sidebar").scrollTop();
        activeNotebook = event.target.innerText.trim();

        displayTemplate("#notebooks-template", { 
            notebooks: notebooks, 
            activeNotebook: activeNotebook
        }, "#notebooks");
        $(".main-sidebar").scrollTop(scroll);

        page = 1;
        send({ event: "get-notes", args: [activeNotebook] })
    }
}

function showNotebookModal(arg) {
    var modal_content = ejs.render($("#new-notebook-modal").html(), {});
    displayTemplate("#modal-template", {"modal_content": modal_content }, "#modals");
}

function showRenameModal(arg) {
    var modal_content = ejs.render($("#rename-notebook-modal").html(), {});
    displayTemplate("#modal-template", {"modal_content": modal_content }, "#modals");
}

function addNotebook(notebook) {
    send({ event: "create-notebook", args: [notebook] })
    $("#modals").html("")
}

function renameNotebook(notebook) {
    send({ event: "rename-notebook", args: [activeNotebook, notebook] })
    $("#modals").html("")
}

async function handleNotebookDelete() {
    send({ event: "remove-notebook", args: [activeNotebook] })
}

Websocket Reply Handlers

        "create-notebook": function () {
            send({ event: "get-notebooks", args: [] })
            closeModal(true);
        },
        "remove-notebook": function () {
            activeNotebook = null;
            send({ event: "get-notebooks", args: [] })
        },
        "get-notebooks": function () {
            var scroll = $(".main-sidebar").scrollTop();

            if (!activeNotebook) {
                if (msg["get-notebooks"].length > 0) {
                    activeNotebook = msg["get-notebooks"][0];
                    page = 1;
                    send({ event: "get-notes", args: [activeNotebook] })
                }
            }
            notebooks = msg["get-notebooks"];
            displayTemplate("#notebooks-template", { 
                notebooks: notebooks, 
                activeNotebook: activeNotebook
            } , "#notebooks");
            $(".main-sidebar").scrollTop(scroll);
        },
        "rename-notebook": function () {
            send({ event: "get-notebooks", args: [] })
            closeModal(true);
        },

BDD Spec

    @timeline_crud_notebooks
    Scenario: crud notebooks
        Given homepage
        When create notebook button is clicked
        Then new notebook is created
        When rename notebook button is clicked
        Then notebook is renamed
        When delete notebook button is clicked
        Then notebook is deleted if it is empty

Tests

./env/bin/radish radish/timeline.feature --tags 'timeline_crud_notebooks'


@when('create notebook button is clicked')
def default_timeline_impl(step):
    browser = get_browser()
    elem = browser.find_element_by_id("create-notebook-button")
    elem.click()
    time.sleep(1)
    elem = browser.find_element_by_id("notebook")
    elem.send_keys("test")
    time.sleep(1)
    elem = browser.find_element_by_id("add-notebook-button")
    elem.click()


@then('new notebook is created')
def default_timeline_impl(step):
    browser = get_browser()
    elem = browser.find_element_by_id("notebooks-nav")
    notebooks = elem.find_elements_by_css_selector("a")
    final_element = notebooks[len(notebooks) -1]
    assert final_element.text == "test"

@when('rename notebook button is clicked')
def default_timeline_impl(step):
    browser = get_browser()
    elem = browser.find_element_by_id("notebooks-nav")
    notebooks = elem.find_elements_by_css_selector("a")
    final_element = notebooks[len(notebooks) -1]
    final_element.click()
    time.sleep(1)
    elem = browser.find_element_by_id("rename-notebook-button")
    elem.click()
    time.sleep(1)
    elem = browser.find_element_by_id("notebook-rename")
    elem.clear()
    elem.send_keys('test4')
    elem = browser.find_element_by_id("notebook-rename-submit")
    elem.click()

@then('notebook is renamed')
def default_timeline_impl(step):
    browser = get_browser()
    elem = browser.find_element_by_id("notebooks-nav")
    notebooks = elem.find_elements_by_css_selector("a")
    final_element = notebooks[len(notebooks) -1]
    final_element.text == "test5"

@when('delete notebook button is clicked')
def default_timeline_impl(step):
    browser = get_browser()
    elem = browser.find_element_by_id("notebooks-nav")
    notebooks = elem.find_elements_by_css_selector("a")
    final_element = notebooks[len(notebooks) -1]
    final_element.click()
    time.sleep(1)
    elem = browser.find_element_by_id("notebooks-nav")
    notebooks = elem.find_elements_by_css_selector("a")
    final_element = notebooks[len(notebooks) -1]
    del_span = final_element.find_elements_by_css_selector(".notebook-delete-element")
    del_span[0].click()
    time.sleep(3)

@then('notebook is deleted if it is empty')
def default_timeline_impl(step):
    browser = get_browser()
    elem = browser.find_element_by_id("notebooks-nav")
    notebooks = elem.find_elements_by_css_selector("a")
    final_element = notebooks[len(notebooks) -1]
    final_element.text != "test"
    time.sleep(2)

Basic Note Operations

  1. Create

  2. Edit

  3. Delete

EasyMDE is used for the markdown editor.

Todo

Pinned notes should also be deleted. Cleanup Time formatting dates. Select all text does not work in Easy MDE in Firefox. Syntax Highlighting for notes

Backend


def get_note(id):
    try:
        note = Note.get(Note.id == id)
        return format_times(model_to_dict(note))
        temp = None
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp


def add_note(notebook, text, date):
    try:
        notebook = Notebook.get(Notebook.name == notebook)
        note = Note(notebook=notebook, content=text)
        if date != '':
            add_date = datetime.datetime.strptime(date, '%Y-%m-%dT%H:%M:%SZ')
            note.add_date = add_date
            temp = None
        else:
            temp = None
        note.save()
        return note.id
        temp = None
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp


def edit_note(id, text, date):
    try:
        note = Note.get(Note.id == id)
        note.content = text
        if date != '':
            add_date = datetime.datetime.strptime(date, '%Y-%m-%dT%H:%M:%SZ')
            note.add_date = add_date
            temp = None
        else:
            temp = None
        note.save()
        return note.id
        temp = None
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp


def rm_note(id, notebook_name):
    try:
        notebook = Notebook.get(Notebook.name == notebook_name)
        note = Note.get(Note.id == id)
        if notebook.metadata:
            metadata = notebook.metadata
            temp = None
        else:
            metadata = {}
            temp = None
        if 'pinned' in metadata:
            pinned = notebook.metadata['pinned']
            temp = None
        else:
            pinned = []
            temp = None
        entries = []
        for pin in pinned:
            entries.append(pin) if note.id != int(pin) else None
        metadata['pinned'] = entries
        notebook.metadata = metadata
        notebook.save()
        note.delete_instance()
        return id
        temp = None
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp



EJS Template

<script  type="text/template" id="update-date-modal">

    <vstack class="w-1/4
                   m-auto
                   bg-white
                   p-4
                   mt-8
                   " 
            style="border: 1px solid #f6f6f6;">
        <input type="text" class="mb-2" id="date-card"></input>

        <button type="button" class="w-full myButton" onclick="closeSuperModal()">
            Cancel
        </button>

        <button id="date-update-button"
                class="w-full
                       mt-2
                       myButton
                       "
                onclick="handleUpdateDate()">
            Update
        </button>
    </vstack>

</script>

<script type="text/template" id="add-note-modal">
    <div class="bg-white
                h-full
                p-4
                ">
        <textarea id="editor-container"></textarea>
        <div class="flex
                    flex-col
                    mt-4
                    text-black
                    ">
            <div class="px-4
                        py-3
                        px-6
                        flex
                        flex-row-reverse
                        ">
                    <button type="button" class="ml-4 myButton" onclick="closeModal()">
                        Cancel
                    </button>
                    <button id="change-date-button" class="ml-4 myButton" onclick="showChangeDateModal()">
                        Change Date
                    </button>
                    <button id="note-submit-button" class="myButton" onclick="handleAddNote()">
                        Update
                    </button>
            </div>
        </div>
    </div>
</script>

<script type="text/template" id="editor-script">
    var el = document.querySelector('#editor-container');
        var easyMDE = new EasyMDE({element: el,
            spellChecker: false,
            maxHeight: "70vh",
            showIcons: ['strikethrough',
                'code', 'table',
                'redo', 'heading',
                'undo', 'heading-bigger',
                'heading-smaller',
                'heading-1',
                'heading-2',
                'heading-3',
                'clean-block',
                'horizontal-rule'],
            renderingConfig: { 
                sanitizerFunction: function (md) { 
                return converter.makeHtml(md)
                }
            }
        });
        if (currentNote) {
            easyMDE.value(currentNote)
        }
</script>

<script type="text/template" id="timepicker-script">
    $('#date-card').appendDtpicker({
        "inline": true,
    });
</script>

UI Event Handlers

function showNoteModal(arg) {
    var modal_content = ejs.render($("#add-note-modal").html(), {edit: false});
    displayTemplateWithScript("#simple-modal-template", "#editor-script",
        {"modal_content": modal_content },
    "#modals");
}

function showChangeDateModal() {
    var modal_content = ejs.render($("#update-date-modal").html(), {edit: false});
    displayTemplateWithScript("#super-modal-template", "#timepicker-script", 
    {"modal_content": modal_content },
    "#super-modals");
}

async function handleAddNote(event) {
    var text = easyMDE.value()
    if (currentNoteId) {
        send({ event: "edit-note", args: [currentNoteId, text, currentNoteDate] })
    } else {
        send({ event: "add-note", args: [activeNotebook, text, currentNoteDate] })
    }
}

async function handleUpdateDate(e) {
    currentNoteDate = moment($("#date-card").val()).utc().format();
    closeSuperModal();
}

async function handleEdit (e) {
    selectedId = e.target.parentElement.getAttribute('data-id');
    send({ event: "get-note", args: [selectedId] })
}

function handleBulkDelete() {
    checkboxTemplate(async function (logId, activeNotebook) {
        send({ event: "rm-note", args: [logId, activeNotebook] })
    }, "ignore")
}

Websocket Reply Handlers

Todo

Add scroll logic after get-notes and use get-more-notes to reset position. Right now batch all operation in 1 go so as to avoid reloading.

        "rm-note": function () {
            var data = msg["rm-note"];
            $('[data-note-id="' + data + '"]').remove();
        },
        "get-note": function () {
            var note = msg["get-note"];
            currentNoteId = note.id;
            currentNote = note.content;
            showNoteModal(true);
        },
        "add-note": function () {
            page = 1;
            send({ event: "get-notes", args: [activeNotebook] })
            closeModal(true);
        },
        "edit-note": function () {
            if (activeNotebook == "__pinned") {
                send({ event: "get-all-pins", args: [] })
            } else if (activeNotebook == "__searched") {
                send({"event": "search-notes", args: [searchTerm]});
            } else {
                page = 1;
                send({ event: "get-notes", args: [activeNotebook] })
            }
            closeModal(true);
        },

BDD Spec

    @timeline_crud_notes
    Scenario: crud notes
        Given homepage
        When add note is clicked
        Then note is added
        When delete note is clicked
        Then note is deleted
        When edit note is clicked and time is changed
        Then note is edited

Tests

./env/bin/radish radish/timeline.feature --tags 'timeline_crud_notes'


@when('add note is clicked')
def default_timeline_impl(step):
    browser = get_browser()
    elem = browser.find_element_by_id("add-note-button")
    elem.click()
    time.sleep(1)
    codeMirror = browser.find_element_by_class_name("CodeMirror")
    codeLine = codeMirror.find_element_by_class_name("CodeMirror-line")
    codeLine.click()
    txtbx = codeMirror.find_element_by_css_selector("textarea")
    txtbx.send_keys("**Hello World**")
    elem = browser.find_element_by_id("note-submit-button")
    elem.click()

@then('note is added')
def default_timeline_impl(step):
    browser = get_browser()
    elem = browser.find_element_by_css_selector("#notes > div")
    assert re.search("Hello World", elem.text)

@when('delete note is clicked')
def default_timeline_impl(step):
    browser = get_browser()
    elem = browser.find_element_by_css_selector("#notes .note-edit-controls > input")
    elem.click()
    elem = browser.find_element_by_id("delete-note-button")
    elem.click()

@then('note is deleted')
def default_timeline_impl(step):
    browser = get_browser()
    elem = browser.find_element_by_css_selector("#notes > div")
    assert re.search("Hello World", elem.text) == None

@when('edit note is clicked and time is changed')
def default_timeline_impl(step):
    browser = get_browser()
    elems = browser.find_elements_by_css_selector("#notes > div")
    for elem in elems:
            e = elem.find_element_by_css_selector(".note-edit-controls > span")
            e.click()
            break
    time.sleep(1)

    codeMirror = browser.find_element_by_class_name("CodeMirror")
    codeLine = codeMirror.find_element_by_class_name("CodeMirror-line")
    codeLine.click()
    txtbx = codeMirror.find_element_by_css_selector("textarea")
    txtbx.send_keys("**Hello World**")

    elem = browser.find_element_by_id("change-date-button")
    elem.click()
    elem = browser.find_element_by_id("date-card")
    elem.clear()
    elem.send_keys("2021-03-01 01:00")
    time.sleep(2)

    elem = browser.find_element_by_id("date-update-button")
    elem.click()
    time.sleep(2)

    elem = browser.find_element_by_id("note-submit-button")
    elem.click()
    time.sleep(2)

@then('note is edited')
def default_timeline_impl(step):
    pass

Merge and Move Notes

You can drag the notes by the Date and move it to any notebook.

Merging notes is useful if you want to combine multiple smaller notes into a bigger one. All notes are merged into the oldest note.

Todo

Dates are not preserved

Backend

def merge_notes(ids):
    try:
        notes = []
        for id in ids:
            note = Note.get(Note.id == int(id))
            notes.append(note)
        if len(notes) >= 2:
            notes.sort(key=lambda e: e.id)
            oldest_element = notes[0]
            text = ''
            tags = []
            for note in notes:
                if note.metadata:
                    metadata = note.metadata
                    temp = None
                else:
                    metadata = {}
                    temp = None
                if 'tags' in metadata:
                    tags = note.metadata['tags']
                    temp = None
                else:
                    tags = []
                    temp = None
                for t in tags:
                    tags.append(t)
                text += note.content
                text += '\n'
                note.delete_instance(
                    ) if note.id != oldest_element.id else None
            oldest_element.content = text
            oldest_element.change_date = datetime.datetime.now()
            oldest_element.save()
            if oldest_element.metadata:
                metadata = oldest_element.metadata
                temp = None
            else:
                metadata = {}
                temp = None
            metadata['tags'] = list(set(tags))
            oldest_element.metadata = metadata
            oldest_element.save()
            return [ids, str(oldest_element.id), oldest_element.content]
            temp = None
        else:
            return False
            temp = None
        temp = temp
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp


def move_note(id, notebook):
    try:
        notebook = Notebook.get(Notebook.name == notebook)
        if notebook:
            note = Note.get(Note.id == id)
            note.notebook = notebook
            note.save()
            return id
            temp = None
        else:
            return False
            temp = None
        temp = temp
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp



def search_notes(query, notebook, every_book):
    try:
        results = []
        for term in query.split(' '):
            notes = Note.select().where(Note.content.contains(term))
            for entry in notes:
                if every_book:
                    entry_dict = model_to_dict(entry)
                    entry_dict['notebook_name'] = entry.notebook.name
                    entry_dict.pop('notebook', None)
                    format_times(entry_dict)
                    temp = results.append(entry_dict)
                else:
                    if entry.notebook.name == notebook:
                        entry_dict = model_to_dict(entry)
                        entry_dict['notebook_name'] = entry.notebook.name
                        entry_dict.pop('notebook', None)
                        format_times(entry_dict)
                        temp = results.append(entry_dict)
                    else:
                        continue
                        temp = None
                    temp = temp
        results.sort(key=lambda o: o['notebook_name'])
        return results
        temp = None
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp

EJS Templates

<script type="text/template" id="new-search-modal">

    <hstack align-x="center">
        <input type="text" id="searchBox" name="search" placeholder="Search" class="w-1/2 myTextInput" />
        <input id="search-all" class="ml-2" type="checkbox" /><span class="ml-2">All Notebooks</span>
    </hstack>

    <div class="p-4
                flex
                flex-row-reverse
                ">
        <button id="cancel-notebook-button" type="button" 
                class="ml-4 myButton" onclick="closeModal()">
            Cancel
        </button>

        <button id="cancel-notebook-button" type="button" class="ml-4 myButton" onclick="handleSearch()">
            <i class="fas fa-search ml-2 cursor-pointer" ></i><span class="ml-1">Search</span>
        </button>
    </div>
</script>

<script type="text/template" id="search-template">

    <% notes.forEach(function(note) {
        var isNew = false;
        if (searchTemplateCurrentNotebook != note.notebook_name) {
            isNew = true;
            searchTemplateCurrentNotebook = note.notebook_name;
        }
        var html = converter.makeHtml(note.content);
        var [dt, months, time] = convertDate(note.add_date);
    %>
    <% if (isNew) { %>
        <div class="text-3xl underline"><%= searchTemplateCurrentNotebook %></div>
    <%} %>
        <div class="note" data-note-id="<%= note.id %>">
            <section id="today" draggable="true"   ondragstart="drag(event)"  data-note-id="<%= note.id %>">
                <div class="day"><%= dt.getFullYear() %></div>
                <div class="date"> <%= dt.getDate() %> <%= months[dt.getMonth()] %></div>
                <div class="time"><%= time %></div>
            </section>

            <div class="note-edit-controls">
                <input onclick="cboxClick(event)" type="checkbox"
                        data-notebook="<%= activeNotebook %>" data-id="<%= note.id %>" />
                <br/>
                <span onclick="handleEdit(event)" data-id="<%= note.id %>">
                    <i class="fa fa-edit font-hairline" ></i>
                </span>
            </div>

            <div class="editor-preview">
                <%- html %>
            </div>

        </div>
    <% });
    searchTemplateCurrentNotebook = "";
    %>
</script>

UI Event Handlers

function showSearchModal(arg) {
    var modal_content = ejs.render($("#new-search-modal").html(), {});
    displayTemplate("#modal-template", {"modal_content": modal_content }, "#modals");
}

async function handleMerge() {

    var checkboxes = window.document.querySelectorAll('input[type=checkbox]:checked')

    var ids = [];

    for (var i = 0; i < checkboxes.length; i++) {
        if (checkboxes[i].parentElement.classList[0] !== "task-list-item") {
            var id = checkboxes[i].dataset.id;
            checkboxes[i].checked = false;
            if (id) {
                ids.push(id);
            }
        }
    }

    send({ event: "merge-notes", args: [ids] })

}

function handleSearch() {

    searchTerm = document.getElementById('searchBox').value;
    searchBool = $('#search-all:checked').val();
    if (searchBool) {
        searchBool = true;
    } else {
        searchBool = false;
    }
    lastActiveNotebook = activeNotebook;
    send({"event": "search-notes", args: [searchTerm, activeNotebook, searchBool]});
}

function handleSearchClear(event) {
    searchTerm = "";
    activeNotebook = lastActiveNotebook;
    page = 1;
    send({ event: "get-notes", args: [activeNotebook] });
    $("#clear-search-button").hide();
}

function dragOver(event) {
    event.preventDefault();
}

function allowDrop(ev) {
    $(ev.target).css({"border": "1px dotted white"})
}

function removeDrop(ev) {
    $(ev.target).css({"border": "1px none white"})
}

function drag(ev) {
    ev.dataTransfer.setData("text", ev.target.dataset["noteId"]);
}

function drop(e) {
    e.stopPropagation();
    e.preventDefault();
    var checkboxes = getSelectedCheckboxes();
    var note_id = e.dataTransfer.getData("text");
    var notebook = e.target.innerText;
    if (checkboxes.length > 0) {
        checkboxTemplate(async function (note_id) {
            send({ event: "move-note", args: [note_id, notebook] })
        }, "ignore")
    } else {
        send({ event: "move-note", args: [note_id, notebook] })
    }
    $(e.target).css({"border": "1px none white"})
    return false;
}

Websocket Reply Handlers

        "move-note": function () {
            var data = msg["move-note"];
            $('[data-note-id="' + data + '"]').remove();
        },
        "search-notes": function () {
            closeModal();
            $("#clear-search-button").show()
            var notes = msg["search-notes"];
            activeNotebook = "__searched"
            displayTemplate("#search-template", { 
                notes: notes,
                notebook: "",
                pinned: true
            }, "#notes");
            $("#pinned").html("");
            $("#pagination").hide();
        },
        "merge-notes": function () {
            var data = msg["merge-notes"];
            for (var i = 0; i < data[0].length; i++) {
                var id = data[0][i];
                if (id !== data[1]) {
                    $('[data-note-id="' + id + '"]').remove();
                }
            }
            $('[data-note-id="' + data[1] + '"]').find(".editor-preview").html(converter.makeHtml(data[2]));
        },

BDD Spec

    @timeline_complex_note
    Scenario: complex note

        When notes are selected and merge note is clicked
        Then notes are merged
        When search button is clicked
        Then notes are searched
        When move note is clicked
        Then notes are moved to new notebook

Tests

/env/bin/radish radish/timeline.feature --tags 'timeline_complex_note'


@when('notes are selected and merge note is clicked')
def default_timeline_impl(step):
    browser = get_browser()
    elems = browser.find_elements_by_css_selector("#notes > div")
    for elem in elems:
            e = elem.find_element_by_css_selector(".note-edit-controls > input")
            e.click()
    elem = browser.find_element_by_id("merge-button")
    elem.click()

@then('notes are merged')
def default_timeline_impl(step):
    pass

@when('search button is clicked')
def default_timeline_impl(step):
    pass

@then('notes are searched')
def default_timeline_impl(step):
    pass

@when('move note is clicked')
def default_timeline_impl(step):
    pass

@then('notes are moved to new notebook')
def default_timeline_impl(step):
    pass

Note Pinning

Pin notes at the top. View all pinned notes together - this is useful for tracking checklists across multiple projects.

Backend


def get_pins(notebook):
    try:
        notebook = notebook.get(notebook.name == notebook)
        return notebook.metadata['pinned']
        temp = None
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp


def get_all_pins():
    try:
        notebooks = Notebook.select().order_by(Notebook.name)
        results = []
        for notebook in notebooks:
            if notebook.metadata:
                metadata = notebook.metadata
                temp = None
            else:
                metadata = {}
                temp = None
            if 'pinned' in metadata:
                pinned = notebook.metadata['pinned']
                temp = None
            else:
                pinned = []
                temp = None
            for pin in pinned:
                entry = Note.get(Note.id == int(pin))
                entry_dict = model_to_dict(entry)
                entry_dict['notebook_name'] = entry.notebook.name
                entry_dict.pop('notebook', None)
                format_times(entry_dict)
                results.append(entry_dict)
        return results
        temp = None
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp


def pin_note(notebook, id):
    try:
        notebook = Notebook.get(Notebook.name == notebook)
        if notebook.metadata:
            metadata = notebook.metadata
            temp = None
        else:
            metadata = {}
            temp = None
        if 'pinned' in metadata:
            pinned = notebook.metadata['pinned']
            temp = None
        else:
            pinned = []
            temp = None
        pinned.append(id) if not id in pinned else None
        metadata['pinned'] = pinned
        notebook.metadata = metadata
        return notebook.save()
        temp = None
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp


def unpin_note(notebook, id):
    try:
        notebook = Notebook.get(Notebook.name == notebook)
        if notebook.metadata:
            metadata = notebook.metadata
            temp = None
        else:
            metadata = {}
            temp = None
        if 'pinned' in metadata:
            pinned = notebook.metadata['pinned']
            temp = None
        else:
            pinned = []
            temp = None
        pinned.remove(id)
        metadata['pinned'] = pinned
        notebook.metadata = metadata
        return notebook.save()
        temp = None
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp


UI Event Handlers

async function getAllPins(e) {
    e.preventDefault();
    activeNotebook = "__pinned";
    send({ event: "get-all-pins", args: [] })
}

async function handlePin() {
    checkboxTemplate(async function (logId, activeNotebook) {
        send({ event: "pin-note", args: [activeNotebook, logId] })
    })
}

async function handleUnpin() {
    checkboxTemplate(async function (logId, activeNotebook) {
        send({ event: "unpin-note", args: [activeNotebook, logId] })
    })
}

Websocket Reply Handlers

        "pin-note": function () {
            send({ event: "get-notes", args: [activeNotebook] })
        },
        "get-all-pins": function () {
            var notes = msg["get-all-pins"];
            notes.map(function (note) {
                $('[data-note-id="' + note.id + '"]').remove();
            })
            displayTemplate("#search-template", { notes: notes, notebook: "", pinned: true}, "#notes");
            $("#pinned").html("");
            page = 1;
            send({ event: "get-notebooks", args: [] })
        },

BDD Spec

    @timeline_pinning
    Scenario: pinning
        When pin button is clicked
        Then note is pinned
        When unpin button is clicked
        Then note is unpinned

Tests

/env/bin/radish radish/timeline.feature --tags 'timeline_pinning'


@when('pin button is clicked')
def default_timeline_impl(step):
    browser = get_browser()
    elems = browser.find_elements_by_css_selector("#notes > div")
    for elem in elems:
            e = elem.find_element_by_css_selector(".note-edit-controls > input")
            e.click()
            break
    elem = browser.find_element_by_id("pin-button")
    elem.click()
    time.sleep(2)

@then('note is pinned')
def default_timeline_impl(step):
    pass


@when('unpin button is clicked')
def default_timeline_impl(step):
    browser = get_browser()
    elems = browser.find_elements_by_css_selector("#pinned > div")
    for elem in elems:
            e = elem.find_element_by_css_selector(".note-edit-controls > input")
            e.click()
            break
    elem = browser.find_element_by_id("remove-pin-button")
    elem.click()
    time.sleep(2)

@then('note is unpinned')
def default_timeline_impl(step):
    pass

Change Email

Backend


def get_user_details(session):
    try:
        user = User.get(User.session == session)
        details = {'username': user.username or '', 'email': user.email,
            'bio': user.bio or ''}
        return details
        temp = None
    except:
        traceback.print_exc()
        return False
        temp = None
    return temp


def set_user_details(session, username, email, bio):
    try:
        user = User.get(User.session == session)
        if not re.match('^.+?example.com+$', user.username):
            if re.match("^[A-z0-9-_:'\!\*]+$", username):
                user.username = username
                user.email = email
                user.bio = bio
                user.save()
                return True
                temp = None
            else:
                return False
                temp = None
            temp = temp
        else:
            return False
            temp = None
        temp = temp
    except:
        traceback.print_exc()
        temp = abort(404)
    return temp



EJS Template

<script  type="text/template" id="user-settings-modal">
    <div class="bg-white p-4 h-full">

    <vstack align-x="center">
        <label for="username" class="leading-7">Username</label>
        <input id="username" type="text" class="myTextInput" value="<%= username %>"/>
        <label for="email" class="leading-7 pt-2">Email</label>
        <input id="email" type="text" class="myTextInput" value="<%= email %>" />
        <label for="bio-container" class="leading-7 pt-2">Bio in markdown</label>
        <textarea class="myTextInput w-1/2" rows="5" id="bio-container"  ><%= bio %></textarea>
    </vstack>

    <hstack align-x="center">
        <div class="px-4
                    py-3
                    px-6
                    flex
                    flex-row-reverse
                    ">

            <button id="user-settings-cancel"
                    type="button"
                    class="ml-4 myButton" onclick="closeModal()">
                Close
            </button>

            <button id="update-user" 
                    type="button"
                    class="myButton"
                    onclick="handleUpdateUser()">
                Update
            </button>

        </div>
    </hstack>

    </div>
</script>

UI Event Handlers

async function handleUserSettings (e) {
    send({ event: "get-user-details", args: [session] });
}

async function handleUpdateUser(e) {
    var username = $("#username").val();
    var email = $("#email").val();
    var bio = $("#bio-container").val();
    send({ event: "set-user-details", args: [session, username, email, bio] });
}

async function handleUpdatePassword(e) {
    var password = $("#password").val();
    var cpassword = $("#confirm-password").val();
    if (password !== cpassword) {
        alert("Passwords are not equal");
    }
    if (password === "") {
        alert("Passwords is empty");
    }
    send({ event: "set-user-password", args: [session, password] });
}

Websocket Reply Handlers

        "get-user-details": function () {
            var user_details = msg["get-user-details"];
            var modal_content = ejs.render($("#user-settings-modal").html(), user_details);
            displayTemplate("#simple-modal-template", {"modal_content": modal_content }, "#modals");
        },
        "set-user-details": function () {
            var user_details = msg["set-user-details"];
            if (user_details === true) {
                alertify.notify('Updated', 'success', 5, function(){  });
            } else {
                alertify.notify('Username / Email already in use', 'error', 5, function(){  });
            }
        },
        "set-user-password": function () {
            var user_details = msg["set-user-password"];
            if (user_details === true) {
                alertify.notify('Updated', 'success', 5, function(){  });
            } else {
                alertify.notify('Failed', 'error', 5, function(){  });
            }
        },

BDD Spec

    @timeline_user_data
    Scenario: user data
        When settings button is clicked
        Then see settings modal
        When username is changed
        Then see change
        When duplicate username is changed
        Then see duplicate alert
        When invalid username is changed
        Then see invalid alert
        When duplicate email is changed
        Then see duplicate email alert

Tests

/env/bin/radish radish/timeline.feature --tags 'timeline_user_data'


@when('settings button is clicked')
def default_timeline_impl(step):
    browser = get_browser()
    elem = browser.find_element_by_id("user-settings")
    elem.click()
    time.sleep(1)

@then('see settings modal')
def default_timeline_impl(step):
    browser = get_browser()
    assert re.search("Username", browser.find_element_by_id("modals").text)

@when('username is changed')
def default_timeline_impl(step):
    browser = get_browser()
    elem = browser.find_element_by_id("username")
    elem.clear()
    elem.send_keys('meh2')
    elem = browser.find_element_by_id("update-user")
    elem.click()

@then('see change')
def default_timeline_impl(step):
    browser = get_browser()
    elem = browser.find_element_by_id("user-settings-cancel")
    elem.click()
    elem = browser.find_element_by_id("user-settings")
    elem.click()
    time.sleep(1)
    elem = browser.find_element_by_id("username")
    assert elem.get_attribute('value') == "meh2"
    elem = browser.find_element_by_id("user-settings-cancel")
    elem.click()

@when('duplicate username is changed')
def default_timeline_impl(step):
    browser = get_browser()
    elem = browser.find_element_by_id("user-settings")
    elem.click()
    elem = browser.find_element_by_id("username")
    elem.clear()
    elem = browser.find_element_by_id("update-user")
    elem.click()

@then('see duplicate alert')
def default_timeline_impl(step):
    browser = get_browser()
    assert  re.search("Could not update", browser.find_element_by_class_name("alertify").text)
    elem = browser.find_element_by_class_name("ajs-ok")
    elem.click()
    time.sleep(1)
    elem = browser.find_element_by_id("user-settings-cancel")
    elem.click()


@when('invalid username is changed')
def default_timeline_impl(step):
    browser = get_browser()
    elem = browser.find_element_by_id("user-settings")
    elem.click()
    elem = browser.find_element_by_id("username")
    elem.clear()
    elem.send_keys('meh@')
    elem = browser.find_element_by_id("update-user")
    elem.click()
    time.sleep(1)

@then('see invalid alert')
def default_timeline_impl(step):
    browser = get_browser()
    assert  re.search("Could not update", browser.find_element_by_class_name("alertify").text)
    elem = browser.find_element_by_class_name("ajs-ok")
    elem.click()
    time.sleep(1)
    elem = browser.find_element_by_id("user-settings-cancel")
    elem.click()

@when('duplicate email is changed')
def default_timeline_impl(step):
    browser = get_browser()
    elem = browser.find_element_by_id("user-settings")
    elem.click()
    elem = browser.find_element_by_id("email")
    elem.clear()
    elem = browser.find_element_by_id("update-user")
    elem.click()

@then('see duplicate email')
def default_timeline_impl(step):
    browser = get_browser()
    assert  re.search("Could not update", browser.find_element_by_class_name("alertify").text)
    elem = browser.find_element_by_class_name("ajs-ok")
    elem.click()
    time.sleep(1)
    elem = browser.find_element_by_id("user-settings-cancel")
    elem.click()

Source Code Listing

Utils

https://stackoverflow.com/questions/5938890/setting-different-reply-to-message-in-python-email-smtplib Without ehlo Maddy doesn’t send mail.

import pytz
from dotenv import dotenv_values
import smtplib, ssl
from email.mime.text import MIMEText

config = dotenv_values(".env")

salt = config["SALT"]
smtp_user = config["SMTP_USER"]
smtp_password = config["SMTP_PASSWORD"]
smtp_host = config["SMTP_HOST"]
smtp_reply_to = config["SMTP_REPLY_TO"]
host = config["HOST"]

def send_link(to_mail, digest):
    context = ssl.create_default_context()

    message = """<html>
  <head></head>
  <body>
  <p>
  Hi!<br>
  </p>
  <p>Use this <a href="%s/login/%s">link</a> to login.</p>
  </body>
</html>
    """

    message = message % (host, digest)

    msg = MIMEText(message, 'html')
    msg['Subject'] = "Login"
    msg['From'] = "Plannr <" + smtp_reply_to + ">"
    msg['To'] = to_mail

    with smtplib.SMTP_SSL(smtp_host, 465, context=context) as server:
        server.ehlo()
        server.login(smtp_user, smtp_password)
        server.send_message(msg)

def send_mail(from_mail, to, subhect, text):
    mailer = Mailgun('apikey', 'example.mailgun.org')

    mailer.send_message(
        from_mail,
        [to],
        subject=subject,
        text=text
    )

def set_nested_key(array, d, value):
    for e in array:
        last_index = e
        last_d = d
        if e in d:
            d = d[e]
        else:
            d[e] = {}
            d = d[e]

    last_d[last_index] = value

def get_nested_key(array, d, default):
    if not d:
        d = {}
    for e in array:
        last_index = e
        last_d = d
        if e in d:
            d = d[e]
        else:
            d[e] = {}
            d = d[e]

    return last_d[last_index] or default

App

The main application. The code is left here for comparison with r-expressions.

from sanic import Sanic, response
from sanic.response import json, text
from sanic.exceptions import NotFound, abort
from jinja2 import Environment, FileSystemLoader
import hashlib
from slugify import slugify
import secrets
import re
import ipdb
import sys
import traceback
from sanic.websocket import WebSocketProtocol
import json
import peewee
from models import Notebook, Note, User, Invoice, db, id_generator
from playhouse.shortcuts import model_to_dict
import datetime
from email.utils import format_datetime
from utils import salt, config
import utils
from collections import defaultdict
import stripe
import pytz
import tasks
file_loader = FileSystemLoader('templates')
env = Environment(loader=file_loader, block_start_string='%{',
    block_end_string='%}')

def get_db():
    path = "@[ _["DB PATH"] @]"
    return path


app = Sanic('Plannr')
app.static('/', './public')

def set_session_cookie(res, session):
    res.cookies['session'] = session
    res.cookies['session']['httponly'] = True
    res.cookies['session']['path'] = '/home'
    res.cookies['session']['secure'] = True


@app.exception(NotFound)
async def ignore_404s(request, exception):
    return text('Yep, I totally found the page ' + request.url)


def create_default_notebooks():
    try:
        notebook = Notebook(name='!Daily')
        notebook.save()
        notebook = Notebook(name='!To Do')
        notebook.save()
        notebook = Notebook(name='!Checklists')
        notebook.save()
        note = Note(notebook=notebook, content="""### Hello !

**Basic** *formatting*  you can ~~use~~ try

\\`\\`\\`
(console.log "Hello World")!
\\`\\`\\`

Feel free to include html `<script>` tag if you are feeling adventourous.

> Basic operations are pinning / merging / deleting notes.
> You can move a note across notebooks by dragging the date section to the left of this note.
> Edit a note by clicking the pencil icon to the left of this note.

Go ahead and make the notebook public. RSS Feeds are generated for public notebooks.

1. User information can be edited in the top left corner.
2.  - [] Checklists
    - [] Are
    - [] Supported
    - [] with Github syntax

3. Tables

| Column 1 | Column 2 | Column 3 |
| -------- | -------- | -------- |
| Text     | Text     | Text     |

4. Images

<blockquote class="imgur-embed-pub" lang="en" data-id="6L5Ru7k"  ><a href="//imgur.com/6L5Ru7k">grumpy cat meows</a></blockquote><script async src="//s.imgur.com/min/embed.js" charset="utf-8"></script>

5. Listen to some Lo/Fi

<iframe width="560" height="315" src="https://www.youtube.com/embed/5qap5aO4i9A" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
"""
            )
        note.save()
        notebook = Notebook(name='Default Logbook')
        notebook.save()
        notebook = Notebook(name='Journal')
        notebook.save()
        notebook = Notebook(name='Mood')
        notebook.save()
        notebook = Notebook(name='Ideas')
        notebook.save()
        notebook = Notebook(name='Random')
        temp = notebook.save()
    except:
        temp = traceback.print_exc()
    return temp


def format_email(entry_dict):
    entry_dict.pop('notebook', None)
    entry_dict['add_date'] = format_datetime(entry_dict['add_date']).replace(
        '-0000', 'GMT')
    entry_dict['change_date'] = format_datetime(entry_dict['change_date']
        ).replace('-0000', 'GMT')
    return entry_dict


def format_times(entry_dict):
    entry_dict.pop('notebook', None)
    entry_dict['add_date'] = str(entry_dict['add_date'])
    entry_dict['change_date'] = str(entry_dict['change_date'])
    return entry_dict

@[ _["Sign Up"] @]
@[ _["Index Page Backend"] @]
@[ _["Public Notebooks Backend"] @]
@[ _["Login Backend"] @]
@[ _["Initial Home Render Backend"] @]
@[ _["Home Initial View Backend"] @]
@[ _["CRUD Notebooks Backend"] @]
@[ _["CRUD Notes Backend"] @]
@[ _["Complex Note Operations Backend"] @]
@[ _["Pinning Backend"] @]
@[ _["Change User Data Backend"] @]

@app.websocket('/timeline')
async def home(request, ws):
    while True:
        msg = await ws.recv()
        data = json.loads(msg)
        session = data['session']
        user = User.get(User.session == session)
        db.init(get_db() + str(user.id) + '.db')
        result = {}
        underscore = data['event'].replace('-', '_')
        res = globals()[underscore](*data['args'])
        result[data['event']] = res
        await ws.send(json.dumps(result))


if __name__ == '__main__':
    import sys
    app.run(host='0.0.0.0', port=8000)(*sys.argv)
    

Test Utils


from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.firefox.firefox_profile import FirefoxProfile
from selenium.webdriver.firefox.options import Options

browser = None
base_url = "http://localhost:8000/"

def get_browser():
    global browser
    if browser != None:
        return browser
    else:
        profile = FirefoxProfile()
        profile.set_preference("network.cookie.cookieBehavior", 1);
        options = Options()
        options.profile = profile
        browser = webdriver.Firefox(options=options)
        return browser

Snippets

Cache Client

https://kaspars.net/blog/change-viewport-meta-window-size

        <meta name="viewport" content="width=device-width, initial-scale=1" id="viewport-meta">
        <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
        <meta http-equiv="Pragma" content="no-cache" />
        <meta http-equiv="Expires" content="0" />
        <meta name="keywords" content="microblog, logbook, journal, rss, feed, indieweb, twitter alternative, evernote alternative, notion, makerlog, indiehacker">
        <link rel="icon" href="favicon.ico">
        <link rel="stylesheet" href="pylon.css"/>
        <meta charset="utf-8">
        <script src="https://code.jquery.com/jquery-3.6.0.min.js" crossorigin="anonymous"></script>

<script type="text/javascript">
var _paq = window._paq = window._paq || [];
_paq.push(['trackPageView']);_paq.push(['enableLinkTracking']);_paq.push(['alwaysUseSendBeacon']);_paq.push(['setTrackerUrl', "\/\/blog.xyzzyapps.link\/owoamims\/matomo\/app\/matomo.php"]);_paq.push(['setSiteId', '1']);var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.type='text/javascript'; g.async=true; g.src="\/\/blog.xyzzyapps.link\/oozotche\/matomo\/matomo.js"; s.parentNode.insertBefore(g,s);
</script>


    <script type="text/javascript">
        $(function () {
        // Store the meta element
        var viewport_meta = document.getElementById('viewport-meta');

        // Define our viewport meta values
        var viewports = {
            default: viewport_meta.getAttribute('content'),
            landscape: 'width=1280'
        };

        // Change the viewport value based on screen.width
        var viewport_set = function() {
            if ( window.innerWidth > 1280 ) {
                viewport_meta.setAttribute( 'content', viewports.default );
                document.body.style.zoom = "100%";
            }

            else {

                viewport_meta.setAttribute( 'content', viewports.landscape );
                let zoomLevel = Math.round((window.innerWidth / 1280) * 100);
                document.body.style.zoom = zoomLevel + "%";

                }
            }

            // Set the correct viewport value on page load
            viewport_set();

            // Set the correct viewport after device orientation change or resize
            window.onresize = function() { 
                viewport_set(); 
            }
        })
        </script>

Javascript

Web Sockets

  • No Ajax, everything is sent via a websocket

  • Dev Repl for backend testing

  • Reconnect automatically

The Dev Repl is currently left open for users who would like to experiment, if you would like to use it … copy any send command from this document and paste it there. It just evals the code.

Uses a dispatch table to dispatch from the message on the first entry from the backend. msg will be available for all the reply handlers in the above code.

Todo

Multiple websocket requests at the same time

var reconnecting = false;
var host = window.location.host;
var connection = connect();

function connect() {
    if (debug === true) {
        return new WebSocket('ws://' + window.location.host + '/timeline');
    } else {
        return new WebSocket('wss://' + window.location.host + '/timeline');
    }
}


function send(data) {
    if (debug) log(session);
    data["session"] = session;
    connection.send(JSON.stringify(data));
}

function repl(e, el) {
    if (e.key === 'Enter' || e.keyCode === 13) {
        var text = el.value;
        eval(text);
    }
}

function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

connection.onopen = onOpen;
connection.onmessage = onMessage;
connection.onclose = onClose;

function onOpen(event) {
    if (reconnecting) {
        reconnecting = false;
        alertify.notify('Connected', 'success', 5, function(){  });

        alertify.confirm('<b>New features</b>', 'It looks like the server has some new code! Would you like to load the latest changes ? <br><br> Unsaved changes will be lost. Select cancel if you want to save your changes and reload later ...', function() { 
            window.location.reload();
        }, function() {
        });

    } else {
        send({ event: "get-notebooks", args: [] })
    }
    connection.onmessage = onMessage;
    connection.onclose = onClose;
};

async function onClose(event) {

    reconnecting = true;

    while (reconnecting) {
        try {
            alertify.notify('Reconnecting', 'error', 5, function(){  });
            connection = connect();
            if (connection) {
                connection.onopen = onOpen;
            }
        } catch (err) {
        }
        await sleep(3000);
    }
};

function onMessage (event) {
    var msg = JSON.parse(event.data);
    if (debug) log(msg);
    var dispatch = {
@[ _["Public Notebooks PostEvent Handlers"] @]
@[ _["CRUD Notebooks JS Event After Handlers"] @]
@[ _["CRUD Notes JS After Events"] @]
@[ _["Complex Note Operations JS After Event Handlers"] @]
@[ _["Pinning JS After Event Handlers"] @]
@[ _["Change User Data JS After Event Handlers"] @]
@[ _["Initial View JS After Event Handlers"] @]
    }
    var key = Object.keys(msg)[0];
    if (debug) log(key);
    dispatch[key]();
}

Main Javascript

Merges all the event handlers defined in the above section here. Merges the websocket code here.

More code

/*
@[ _["CC1 License"]  @]
*/

var debug = @[ _["DEBUG"] @];
var activeNotebook;
var notebooks;
var searchTerm;
var page;
var currentNote = "";
var currentNoteId;
let hideSidebar = false;
let selectedId = false;
let lastInput;
var currentNoteDate = "";
var lastActiveNotebook = "";
var searchTemplateCurrentNotebook = "";
var renderedScripts = [];
var converter = new showdown.Converter({
    simplifiedAutoLink: true,
    simpleLineBreaks: true,
    tasklists: true,
    ghCodeBlocks: true,
    tables: true,
    strikethrough: true,
    openLinksInNewWindow: true
    });

@[ _["Web Socket Helpers"] @]
@[ _["CRUD Notebooks JS Event Handlers"] @]
@[ _["Public Notebooks Event Handlers"] @]
@[ _["CRUD Notes JS Event Handlers"] @]
@[ _["Complex Note Operations JS Event Handlers"] @]
@[ _["Pinning JS Event Handlers"] @]
@[ _["Change User Data JS Event Handlers"] @]
@[ _["Initial View JS Event Handlers"] @]

$(function () {
    var params = new URLSearchParams(window.location.search);
    if (params.get("trial")) {
        alert("Welcome to the trial! You are free to use this account for 14 days.");
    }

    $("#clear-search-button").hide();

    $( document ).on( "click", ".induce-opacity", function() {
        $("#main-content").css({"opacity": 0.4})
    })

});

$("#searchBox").on('keyup', function (e) {
    if (e.key === 'Enter' || e.keyCode === 13) {
        handleSearch();
    }
});

async function toggleSidebar() {
    if (hideSidebar === false) {
        hideSidebar = true;
    } else {
        hideSidebar = false;
    }
}

function getSelectedCheckboxes() {
    var checkboxes = window.document.querySelectorAll('input[type=checkbox]:checked')
    var filtered = []

    for (var i = 0; i < checkboxes.length; i++) {
        if (checkboxes[i].parentElement.classList[0] !== "task-list-item") {
            filtered.push(checkboxes[i])
        }
    }

    return filtered;
}

async function checkboxTemplate(code, postCode) {
    var checkboxes = window.document.querySelectorAll('input[type=checkbox]:checked')

    for (var i = 0; i < checkboxes.length; i++) {
        if (checkboxes[i].parentElement.classList[0] !== "task-list-item") {
            var id = checkboxes[i].dataset.id;
            var notebook =  checkboxes[i].dataset.notebook;
            checkboxes[i].checked = false;
            await code(id, notebook);
        }
    }

    (async function () {
        if (postCode == "ignore") {
        }
        else if (postCode) {
            await postCode();
        } else {
            send({ event: "get-notes", args: [activeNotebook] })
        }

    })();

}

function handleSelect() {
    var checkboxes = window.document.querySelectorAll('input[type=checkbox]')

    for (var i = 0; i < checkboxes.length; i++) {
        if (checkboxes[i].parentElement.classList[0] !== "task-list-item") {
            checkboxes[i].checked = ! checkboxes[i].checked;
        }
    }

}

function setAllBetween(parent, start, end) {
    var checked = start.checked;
    var inputs  = document.getElementsByClassName(parent)
    var inRange = false;
    var el;

    for (var i=0, len=inputs.length; i < len; i++) {
        el = inputs[i];

        if (el == start || el == end) {
            inRange    = !inRange;
            el.checked = checked;
        } else if (inRange) {
            el.checked = checked;
        }
    }
}

function cboxClick(event) {

    var input = event.target || event.srcElement;
    var checked;

    if (lastInput && event.shiftKey) {
        setAllBetween("cbox", lastInput, input);
    }

    lastInput = input;
}




function applyMarkdownToTable(tableId) {
    $(tableId + " td").each(function (e) {
        $(this).html( converter.makeHtml( $(this).text() ) );
    })
}

function generateTableHead(table, data) {
    let thead = table.createTHead();
    let row = thead.insertRow();
    for (let key of data) {
        let th = document.createElement("th");
        let text = document.createTextNode(key);
        th.appendChild(text);
        row.appendChild(th);
    }
}

function generateTable(table, data) {
    for (let element of data) {
        let row = table.insertRow();
        for (key in element) {
            let cell = row.insertCell();
            let text = document.createTextNode(element[key]);
            cell.appendChild(text);
        }
    }
}

function clearModal() {
    $("#modals").html("")
    $("#super-modals").html("");
    renderedScripts.map(function (s) {
        $(s).remove();
    })
    currentNoteId = null;
    currentNote = "";
    currentNoteDate = "";
    $("#main-content").css({"opacity": 1})
}

function displayTemplate(templateId, data, container) {
    var html = ejs.render($(templateId).html(), data);
    $(container).html(html);
}

function displayTemplateWithScript(templateId, scriptId, data, container) {
    var html = ejs.render($(templateId).html(), data);
    $(container).html(html);
    $(scriptId + "-instance").remove();
    var s = document.createElement("script");
    s.type = "text/javascript";
    s.id = scriptId + "-instance";
    s.innerHTML = $(scriptId).html();
    $("head").append(s);
    renderedScripts.push(scriptId + "-instance");
}

function closeModal(arg) {
    $("#modals").html("")
    $("#editor-script-instance").remove();
    currentNoteId = null;
    currentNote = "";
    currentNoteDate = "";
    $("#main-content").css({"opacity": 1})
}

function closeSuperModal(arg) {
    $("#super-modals").html("")
    $("#timepicker-script-instance").remove();
}

function clear() {
    arguments.map(function () {
        $(container).html("");
    })
}

function log(msg) {
    if (debug === true) {
        console.log(msg);
    }
}

function alert(msg) {
    alertify
        .alert("", msg, function() {
        });
}

function convertDate(add_date) {
    var dt = new Date(add_date);
    var months = new Array("January","February","March","April","May","June","July","August","September","October","November","December");
    var hours = dt.getHours();
    var minutes = dt.getMinutes();
    var suffix = "am";
    if(minutes < 10) {
        minutes = "0" + minutes;
    }

    if (hours >= 12) {
        suffix = "pm";
        hours = hours - 12;
    }

    if (hours == 0) {
        hours = 12;
    }

    var time = hours + ":" + minutes;

    return [dt, months, time]
}

String.prototype.times = function(count) {
    return count < 1 ? '' : new Array(count + 1).join(this);
}
 
var tracer = {
    nativeCodeEx: /\[native code\]/,
    indentCount: -4,
    tracing: [],
 
    traceMe: function(func, methodName) {
        var traceOn = function() {
                var startTime = +new Date;
                var indentString = " ".times(tracer.indentCount += 4);
                console.info(indentString + methodName + '(' + Array.prototype.slice.call(arguments).join(', ') + ')');
                var result = func.apply(this, arguments);
                console.info(indentString + methodName, '-> ', result, "(", new Date - startTime, 'ms', ")");
                tracer.indentCount -= 4;
                return result;
        }
        traceOn.traceOff = func;
        for (var prop in func) {
            traceOn[prop] = func[prop];
        }
        console.log("tracing " + methodName);
        return traceOn;
    },
 
    traceAll: function(root, recurse) {
        if ((root == window) || !((typeof root == 'object') || (typeof root == 'function'))) {return;}
        for (var key in root) {
            if ((root.hasOwnProperty(key)) && (root[key] != root)) {
                var thisObj = root[key];
                if (typeof thisObj == 'function') {
                    if ((this != root) && !thisObj.traceOff && !this.nativeCodeEx.test(thisObj)) {
                        root[key] = this.traceMe(root[key], key);
                        this.tracing.push({obj:root,methodName:key});
                    }
                }
                recurse && this.traceAll(thisObj, true);
             }
        }
    },
 
    untraceAll: function() {
        for (var i=0; i<this.tracing.length; ++i) {
            var thisTracing = this.tracing[i];
            thisTracing.obj[thisTracing.methodName] =
                thisTracing.obj[thisTracing.methodName].traceOff;
        }
        console.log("tracing disabled");
        tracer.tracing = [];
    }
}

SVG

<svg fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" class="text-indigo-500 w-6 h-6 flex-shrink-0 mr-4" viewBox="0 0 24 24">
    <path d="M22 11.08V12a10 10 0 11-5.93-9.14"></path>
    <path d="M22 4L12 14.01l-3-3"></path>
</svg>

CSS

Nested CSS

/*
@[ _["CC0 License"]  @]
*/



@tailwind base;
@tailwind components;
@tailwind utilities;

button, button:focus {outline:0 !important; }

html, body {
    height: 100%;
    width: 100%;
}

@font-face {
    font-family: SanFrancisco;
    src: url("./SanFranciscoFont/SanFranciscoDisplay-Regular.otf") format("opentype");
}

#editor-container {
    display: none;
}

.default-font {
    font-family: 'SanFrancisco' !important;
    font-size: 14px !important;
}

:root {
    --main-bg-color: #33bdef;
    --nav-bg-color: #2B3137;
}

.main-bg {
    background-color:  var(--main-bg-color);
}

.main-fg {
    color: var(--main-bg-color);
}

.main-border {
    border-color: var(--main-bg-color);
}

.basic-border {
    @apply border border-solid main-border border-opacity-100;
}

.small-icon {
    width: 12px !important;
    height: 12px !important;
}

/* Templates */

.notebook-active {
    background-color: var(--main-bg-color);
}

.pinned-border {
    @apply border-gray-300;
    border-top-width: 2px;
    margin-top: 24px;
    border-style: dashed;
}

.main-sidebar {
     > h3 {
        @apply text-white;
    }

    > nav > a {
         @apply  flex items-center  cursor-pointer default-font;
    }

    > nav > a > p {
        padding: 4px;
    }

    > nav > a > p > i {
        @apply p-2;
    }

    > nav > a > p > .notebook-delete-element {
        @apply p-2 cursor-pointer;
    }

    @apply default-font;

    min-width: 250px;
    overflow: auto;
    background-color: var(--nav-bg-color);
    color: white;
    height: 100vh;
}

.day {
    width: 100%;
    height: 2rem;
    font-size: 1rem;
    font-weight: 600;
    line-height: 2rem;
    border-radius: 5px 5px 0 0;
    background: #ebebeb;
    color: #4e4e4e;
}

.date {
    font-size: 0.8rem;
    padding-top: 0.1rem;
    padding-bottom: 0.5rem;
    font-weight: 500;
}

.time {
    font-size: 0.8rem;
    font-weight: 500;
}

.note {
    > section {
        @apply cursor-move mt-2;
        display: inline-block;
        width: 5rem;
        max-height: 96px;
        background: #fff;
        border: 1px solid #f6f6f6;
        text-align: center;
        color: #333;
        vertical-align: top;
        border-radius: 5px;
    }
    > .note-edit-controls {
        @apply flex-col;
        > input {
            @apply ml-2 w-6 mt-3;
        }
        > span {
            @apply ml-3 cursor-pointer text-gray-500;
        }
    }

    > .editor-preview {
        @apply mt-2 overflow-auto;
        width: 88%;
    }

    min-height: 140px;
    @apply flex flex-row mt-2;
}

.myButton {
    box-shadow: 0px 4px 0px 0px #06a0cf;
	background-color:#33bdef;
	border-radius:4px;
	display:inline-block;
    outline: none; 
	cursor:pointer;
	color:#ffffff;
	font-family:Arial;
	font-size:14px;
	font-weight:bold;
	padding:8px 32px;
	text-decoration:none;
}
.myButton:hover {
	background-color:#33bdef;
    outline: none;
}
.myButton:active {
	position:relative;
    outline: none;
	top:1px;
    box-shadow: 0px 2px 0px 0px #06a0cf;
}

.myNavButton {
    box-shadow: 0px 4px 0px 0px #d4d4d4;
    background-color: #ffffff;
	border-radius: 4px;
    border:1px solid #dcdcdc;
	display:inline-block;
	cursor:pointer;
	color:#666666;
	font-family:Arial;
	font-size:14px;
	padding:8px;
    margin-left: 8px;
	text-decoration:none;
}

.myNavButton:hover {
    background-color: #ffffff;
}
.myNavButton:active {
	position:relative;
    box-shadow: 0px 2px 0px 0px #d4d4d4;
    top: 1px;
}

.myTextInput {
    @apply outline-none p-2;
    border: 2px solid #dcdcdc;
    border-radius: 4px;
    min-width: 300px;
}

.EasyMDEContainer {
    height: 90%;
}

label {
    font-weight: bold;
}

/* Markdown */

.editor-preview {
    font-size: 14px;
    -webkit-text-size-adjust: 100%;
    -ms-text-size-adjust: 100%;
    color: #444;
    line-height: 1.5em;
    box-shadow: 0px 4px 0px 0px #f6f6f6;
    background-color: #ffffff;
    border: 1px solid #f6f6f6;
    padding: 4px;
}

.editor-preview a {
    color: #0645ad;
    text-decoration: none;
}

.editor-preview a:visited {
    color: #0b0080;
}

.editor-preview a:hover {
    color: #06e;
}

.editor-preview a:active {
    color: #faa700;
}

.editor-preview a:focus {
    outline: thin dotted;
}

.editor-preview a:hover, a:active {
    outline: 0;
}

.editor-preview ::selection {
    background: rgba(255, 255, 0, 0.3);
    color: #000;
}

.editor-preview a::selection {
    background: rgba(255, 255, 0, 0.3);
    color: #0645ad;
}

.editor-preview p {
    margin-left: 0.8em;
    margin-top: 0.8em;
}

.editor-preview img {
    max-width: 100%;
}

.editor-preview h1,
.editor-preview h2,
.editor-preview h3,
.editor-preview h4,
.editor-preview h5,
.editor-preview h6 {
    font-weight: normal;
    color: #111;
    line-height: 1em;
    margin-left: 0.6rem;
    margin-top: 0.8rem;
    margin-bottom: 0.8rem;
    font-size: 1.25em;
}

.editor-preview h4, .editor-preview h5, .editor-preview h6 {
    font-weight: bold;
}

.editor-preview h5 {
    font-size: 1em;
}

.editor-preview h6 {
    font-size: 0.9em;
}

.editor-preview blockquote {
    color: #666666;
    margin-left: 0.8em;
    border-left: 0.2em #eee solid;
}

.editor-preview hr {
    display: block;
    border: 0;
    border-top: 1px solid #aaa;
    border-bottom: 1px solid #eee;
    margin: 1em 0;
    padding: 0;
}

.editor-preview pre, code, kbd, samp {
    color: #000;
    font-family: monospace, monospace;
    _font-family: 'courier new', monospace;
    font-size: 0.98em;
    margin-left: 0.8em;
}

.editor-preview pre {
    white-space: pre;
    white-space: pre-wrap;
    word-wrap: break-word;
}

.editor-preview b, strong {
    font-weight: bold;
}

.editor-preview dfn {
    font-style: italic;
}

.editor-preview ins {
    background: #ff9;
    color: #000;
    text-decoration: none;
}

.editor-preview mark {
    background: #ff0;
    color: #000;
    font-style: italic;
    font-weight: bold;
}

.editor-preview sub, sup {
    font-size: 75%;
    line-height: 0;
    position: relative;
    vertical-align: baseline;
}

.editor-preview sup {
    top: -0.5em;
}

.editor-preview sub {
    bottom: -0.25em;
}

.editor-preview ul {
    list-style: disc;
    margin-left: 0.8em;
    padding: 0 0 0 1.5em;
}

.editor-preview ol {
    list-style: decimal;
    margin-left: 0.8em;
    padding: 0 0 0 1.5em;
}

.editor-preview li p:last-child {
    margin: 0;
}

.editor-preview dd {
    margin: 0 0 0 2em;
}

.editor-preview img {
    border: 0;
    -ms-interpolation-mode: bicubic;
    vertical-align: middle;
    margin-left: 0.8em;
}

.editor-preview table {
    border-collapse: collapse;
    border-spacing: 0;
    margin: 0.8em;
    width: 90%;
}

.editor-preview td {
    vertical-align: top;
}

table {
    width: 100%;
    border-collapse: collapse;
}

table,
th,
td {
    border: 1px solid black;
}
thead {
    color: #000000;
}
th {
    text-align: center;
    height: 50px;
}

tbody tr:nth-child(odd) {
    background: #ffffff;
}
tbody tr:nth-child(even) {
    background: #f4f4f4;
}

.tab-border {
    border-color:var(--main-bg-color);
}

img, video {
    max-width: 90%    !important;
    height: auto   !important;
}

Timeline Spec

Feature: timeline
    @[ _["Public Notebooks Spec"] @]
    @[ _["CRUD Notebooks Spec"] @]
    @[ _["CRUD Notes Spec"] @]
    @[ _["Complex Note Operations Spec"] @]
    @[ _["Pinning Spec"] @]
    @[ _["Change User Data Spec"] @]

All Tests


from radish import given, when, then
import os, sys
sys.path.append(os.path.join(os.path.dirname(__file__)))
from tests_common import *
from selenium.webdriver.common.keys import Keys
import time
import re

@given('homepage')
def default_timeline_impl(step):
    browser = get_browser()
    browser.get(base_url + "home")
    time.sleep(1)

@[ _["Public Notebooks Tests"] @]
@[ _["CRUD Notebooks Tests"] @]
@[ _["CRUD Notes Tests"] @]
@[ _["Complex Note Operations Tests"] @]
@[ _["Pinning Tests"] @]
@[ _["Change User Data Tests"] @]

Celery Tasks


from celery import Celery
from utils import send_mail, send_link

app = Celery('tasks', broker='redis://localhost')

@app.task
def test(x, y):
    return x + y

@app.task
def async_send_mail(from_mail, to, subject, text):
    send_mail(from_mail, to, subject, text)

@app.task
def async_send_link(to, digest):
    send_link(to, digest)