How to create Server File Explorer using jQuery and PHP

I’ve created plugin called jQuery File Browser that can be used to created browser for your server files. Here we will use php as server side script but it can be replaced with any other server side language like Ruby On Rails, Python or Node.js.

TL;DR you can find whole code in this gist

We will use JSON-RPC as our protocol to communicate with the server and my library that implement the protocol in php and JavaScript.

After we clone json-rpc and jQuery File Browser, we need to create our service.php file:

<?php

require('json-rpc/json-rpc.php');


class Service {
    function username() {
        return get_current_user();
    }
    function ls($path) {
        $files = array();
        $dirs = array();
        foreach (new DirectoryIterator($path) as $file) {
            $name = $file->getBasename();
            if ($name != "." && $name != "..") {
                if ($file->isFile() || $file->isLink()) {
                    $files[] = $name;
                } else {
                    $dirs[] = $name;
                }
            }
        }
        return array(
            "files" => $files,
            "dirs" => $dirs
        );
    }
}

echo handle_json_rpc(new Service());

?>

Then we need to create base html file with imported libraries including jQuery and jQuery UI (we will use file browser using jQuery UI dialog):

<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
  <meta charset="utf-8" />
  <title>File Explorer using jQuery</title>
  <!-- remove underscore from sc_ript (wordpress replace skrypt tag with a tag)-->
  <sc_ript src="https://code.jquery.com/jquery-3.1.1.min.js"></sc_ript>
  <sc_ript src="http://code.jquery.com/ui/1.12.0/jquery-ui.js"></sc_ript>
  <sc_ript src="jquery.filebrowser/js/jquery.filebrowser.min.js"></sc_ript>
  <sc_ript src="json-rpc/json-rpc.js"></sc_ript>

  <link href="http://code.jquery.com/ui/1.12.1/themes/dark-hive/jquery-ui.css" rel="stylesheet"/>
  <link href="jquery.filebrowser/css/jquery.filebrowser.min.css" rel="stylesheet"/>

Then we need to create instance of file browser inside jQuery UI dialog (we will call your service to get the username):

   $(function() {
       rpc({
           url: 'service.php',
           // this will make json-rpc library return promises instead of function that need to be called
           promisify: true
       }).then(function(service) {
           service.username().then(function(username) {
               var browser = $('<div/>').appendTo('body').dialog({
                   width: 800,
                   height: 600
               }).browse({
                   root: '/home/' + username + '/',
                   dir: function(path) {
                       return service.ls(path);
                   }
               });
           });
       });
   });

And now you should have file explorer window open with content of your server user home directory. If you don’t see the output you may want to change root to ‘/home/username’ and don’t use server to get the username, which may be the user that run http server.

The icons that are inside jQuery File Browser are taken from my Clarity icons for GNU/Linux with GTK+.

Next you can add editor that will open on double click on the icon, we will use CodeMirror for this.

First we need to include codemirror:

  <link href="codemirror-5.29.0/lib/codemirror.css" rel="stylesheet"/>
  <link href="codemirror-5.29.0/theme/base16-dark.css" rel="stylesheet"/>
  <sc_ript src="codemirror-5.29.0/lib/codemirror.js"></sc_ript>
  <sc_ript src="codemirror-5.29.0/keymap/emacs.js"></sc_ript>

We use base16-dark theme (to match dark theme of the jQuery UI) and emacs keymap, but you can pick different one.

Then we need to open the editor, we will create open function that look like this:

function open(path) {
    var fname = path.replace(/.*\//, '');
    var readonly = true;
    var mtime;
    service.is_writable(path).then(function(is_writable) {
        readonly = !is_writable;
    });
    service.filemtime(path).then(function(time) {
        mtime = time;
    });
    var ext = fname.replace(/^.*\./, '');
    var mode;
    // you can add more modes here:
    switch(ext) {
        case 'js':
            mode = 'javascript';
            break;
        case 'rb':
            mode = 'ruby';
            break;
        case 'py':
            mode = 'python';
            break;
        case 'sql':
            mode = 'mysql';
            break;
        case 'svg':
            mode = 'xml';
            break;
        case 'xml':
        case 'css':
        case 'php':
            mode = ext;
            break;
    }
    var scripts = $('script').map(function() {
        return ($(this).attr('src') || '').replace(/.*\//, '');
    }).get();
    if (scripts.indexOf(mode + '.js') == -1) {
        var name = 'codemirror-5.29.0/mode/' + mode + '/' + mode + '.js';
        service.file_exists(name).then(function(exists) {
            if (exists) {
                $('<sc' + 'ript/>').attr('src', name).appendTo('head');
            }
        });
    }
    service.file(path).then(function(content) {
        var unsaved = false;
        var div = $('<div><textarea/><div>');
        var textarea = div.find('textarea').hide();
        textarea.val(content);
        div.dialog({
            title: fname,
            width: 650,
            beforeClose: function(e, ui) {
                if (unsaved) {
                    if (confirm('Unsaved file, are you sure?')) {
                        $(this).dialog('destroy');
                    } else {
                        return false;
                    }
                } else {
                    $(this).dialog('destroy');
                }
            },
            resize: function(e, ui) {
                editor.editor.setSize(textarea.width(), textarea.height());
            }
        });
        var editor = {
            dialog: div,
            editor: CodeMirror.fromTextArea(textarea[0], {
                lineNumbers: true,
                matchBrackets: true,
                mode: mode,
                readOnly: readonly,
                theme: 'base16-dark',
                indentUnit: 4,
                keyMap: 'emacs',
                extraKeys: {
                    "Ctrl-S": function(cm) {
                        if (unsaved) {
                            function saveFile() {
                                editor.editor.save();
                                var content = textarea.val();
                                service.save(path, content).then(function() {
                                    unsaved = false;
                                    div.dialog('option', 'title', fname);
                                });
                            }
                            service.filemtime(path).then(function(time) {
                                if (mtime != time) {
                                    if (confirm('File `' + fname + '\' changed ' +
                                                'on Disk are you sure?')) {
                                        saveFile();
                                    }
                                } else {
                                    saveFile();
                                }
                            });
                        }
                    },
                    "Tab": "indentAuto"
                }
            })
        };
        editor.editor.on('change', function(editor) {
            console.log('change');
            unsaved = true;
            div.dialog('option', 'title', '*' + fname);
        });
    });
}

it require few more server side functions:

class Service {
    function file_exists($path) {
        return file_exists($path);
    }
    function username() {
        return get_current_user();
    }
    function is_writable($path) {
        return is_writable($path);
    }
    function file($path) {
        return file_get_contents($path);
    }
    function save($path, $content) {
        $file = fopen($path, 'w');
        $ret = fwrite($file, $content);
        fclose($file);
        return $ret;
    }
    function filemtime($path) {
        return filemtime($path);
    }
    function ls($path) {
        $files = array();
        $dirs = array();
        foreach (new DirectoryIterator($path) as $file) {
            $name = $file->getBasename();
            if ($name != "." && $name != "..") {
                if ($file->isFile() || $file->isLink()) {
                    $files[] = $name;
                } else {
                    $dirs[] = $name;
                }
            }
        }
        return array(
            "files" => $files,
            "dirs" => $dirs
        );
    }
}

and then we need to add this function to our file explorer, we can do that by adding open option, (we can also embed whole function instead of adding it as a value):

   $(function() {
       rpc({
           url: 'service.php',
           promisify: true
       }).then(function(service) {
           service.username().then(function(username) {
               var browser = $('<div/>').appendTo('body').dialog({
                   width: 800,
                   height: 600
               }).browse({
                   root: '/home/' + username + '/',
                   dir: function(path) {
                       return service.ls(path);
                   },
                   open: open
               });
           });
       });
   });

we also need some css tweaks to make icons take more space and to make editor resize properly.

body {
    font-size: 10px;
}
.ui-dialog .ui-dialog-content {
    padding: 0;
    overflow: hidden;
}
.browser {
    width: 800px;
    height: 600px;
}
.browser-widget .content .selection {
    border-color: #fff;
}
.browser-widget li.file,
.browser-widget li.directory {
    width: 60px;
}
.ui-dialog textarea {
    width: 100%;
    height: 100%;
    padding: 0;
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    resize: none;
}
Advertisements

, ,

Leave a comment

Create javascript interface for all methods in cordova plugin

When you create a cordova plugin you need to create execute method and check for action parameter to do different task and in each task you need to create json object and call callbackContext. It would be nice if if you could just create a class with methods and each method is map to javascript method. Here is tutorial how to do this:

You need to create this ReflectService.java file:

package com.example.package;

import android.content.Context;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.StringWriter;
import java.io.PrintWriter;

import java.util.List;
import java.util.ArrayList;

import java.lang.reflect.Method;
import java.lang.reflect.Modifier;

import org.apache.cordova.CordovaPlugin;
import org.apache.cordova.CallbackContext;
import org.apache.cordova.CordovaInterface;
import org.apache.cordova.CordovaWebView;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

public class ReflectService extends CordovaPlugin {

    protected Method getMethod(String name) {
        Class aClass = this.getClass();
        Method[] methods = aClass.getMethods();
        Method method = null;
        for (int i=0; i<methods.length; ++i) {
            if (methods[i].getName().equals(name)) {
                method = methods[i];
                break;
            }
        }
        return method;
    }

    public String[] getMethods() {
        Class aClass = this.getClass();
        Method[] methods = aClass.getDeclaredMethods();
        List<String> list = new ArrayList<String>();
        int len = methods.length;
        String[] result = new String[len];
        for (Method method : methods) {
            String name = method.getName();
            if (Modifier.isPublic(method.getModifiers()) &&
                !name.equals("initialize")) {
                list.add(name);
            }
        }
        return list.toArray(new String[list.size()]);
    }

    protected String[] jsonArrayToString(JSONArray args) {
        String[] result = {};
        try {
            int len = args.length();
            result = new String[len];
            for (int i=0; i<len; ++i) {
                result[i] = args.getString(i);
            }
        } catch (JSONException e) {}
        return result;
    }

    protected String[] getStackTrace(Exception e) {
        StringWriter sw = new StringWriter();
        PrintWriter pw = new PrintWriter(sw);
        e.printStackTrace(pw);
        return sw.toString().split("\n");
    }

    @Override
    public boolean execute(String action,
                           JSONArray args,
                           final CallbackContext callbackContext) throws JSONException {
        if (!action.equals("execute")) {
            final Method method = this.getMethod(action);
            if (method == null) {
                return false;
            }
            final Object[] arguments = this.jsonArrayToString(args);
            cordova.getThreadPool().execute(new Runnable() {
                public void run() {
                    JSONObject json;
                    Object result;
                    try {
                        result = method.invoke(ReflectService.this, arguments);
                        json = new JSONObject();
                        if (result instanceof Object[]) {
                            json.put("result", new JSONArray(result));
                        } else {
                            json.put("result", result);
                        }
                        json.put("error", null);
                        callbackContext.success(json);
                    } catch(JSONException e) {
                        callbackContext.success();
                    } catch(Exception e) {
                        try {
                            json = new JSONObject();
                            JSONObject error = new JSONObject();
                            error.put("error", "Exception");
                            error.put("code", 200);
                            error.put("message", e.getMessage());
                            String[] trace = ReflectService.this.getStackTrace(e);
                            error.put("trace", new JSONArray(trace));
                            json.put("error", error);
                            json.put("result", null);
                            callbackContext.success(json);
                        } catch(JSONException ee) {
                            callbackContext.success();
                        }
                    }
                }
            });
            return true;
        }
        return false;
    }
}

You probably don’t need to use threads but I’ve seen this in cordova-plugin-shell-exec.

and then you can write your Service like this:

package com.example.package;

import org.apache.cordova.CordovaInterface;
import org.apache.cordova.CordovaWebView;

public class Service extends ReflectService {

    public void initialize(CordovaInterface cordova, CordovaWebView webView) {
        super.initialize(cordova, webView);
        // your init code here
    }
    public String echo(String input) {
        if (input.equals("ping")) {
            return "pong";
        } else {
            return null;
        }
    }
}

You will be able to execute each method you create from execute in ReflectService class.

Then you need to create js file to create function for each method in the class. We will use getMethods action (that will execute getMethods method) to get list of methods and create function for each name.

window.Service = function Service(callback) {
    function call(method, args, callback) {
        return cordova.exec(function(response) {
            callback(null, response);
        }, function(error) {
            callback(error);
        }, "Service", method, args || []);
    }
    var service = {};
    call("getMethods", [], function(err, response) {
        if (response.result instanceof Array) {
            response.result.forEach(function(method) {
                service[method] = function() {
                    var args = [].slice.call(arguments);
                    return function(callback) {
                        return call(method, args, function(err, response) {
                            err = err || response.error;
                            if (err) {
                                callback(err);
                            } else {
                                callback(null, response.result);
                            }
                        });
                    };
                };
            });
            callback(service);
        }
    });
};

Next you need to create plugin.xml file that look like this:

<?xml version="1.0" encoding="UTF-8"?>
<plugin id="pl.jcubic.leash.service" version="1.0.0"
        xmlns="http://apache.org/cordova/ns/plugins/1.0">
  <name>Service</name>
  <description>Apache Cordova Leash shell service plugin</description>
  <license>Apache 2.0</license>
  <keywords>cordova,exec,runtime,process,shell,command</keywords>
  <js-module name="service" src="www/service.js">
    <clobbers target="service"/>
  </js-module>
  <platform name="android">
    <config-file parent="/*" target="res/xml/config.xml">
      <feature name="Service">
        <param name="android-package" value="com.example.package.Service" />
        <param name="onload" value="true" />
      </feature>
    </config-file>
    <source-file src="src/com/example/package/Service.java"
                 target-dir="src/com/example/package/Service" />
    <source-file src="src/com/example/package/ReflectService.java"
                 target-dir="src/com/example/package/Service" />
  </platform>
</plugin>

When you add this plugin you can call it in javascript like this:

window.Service(function(service) {
    service.echo('ping')(function(err, result) {
        var pre = document.createElement('pre');
        pre.innerHTML = JSON.stringify(result, null, 4);
        document.body.appendChild(pre);
    });
});

NOTE: Instead of callbacks you can use promises.

You can find whole code in this gist.

, ,

Leave a comment

How to add vignette in GIMP

Besides wrting code I’m also amateur photographer (you can see my photos on flickr most of them released under creative commons attribution share alike license) and start adding Vignette in GIMP (the only option because I’m working on GNU/Linux).

To add vignette you can do this steps:

  1. Add ellipse selection
  2. Feather it by 1000 pixels (I have 24MP camera) for your case it may be larger or smaller
  3. Inverse the Mask
  4. Add new black layer
  5. Add layer mask from selection
  6. Make it 50% opacity
  7. remove the selection

And now you have vignette on the image. It was fine but I needed to repeat those steps on each image, and since GIMP have support for scripting using script-fu (variant of scheme), I thought that I add simple script for the steps, here is result:

(define (make-vignette size opacity img)
  (let* ((width (car (gimp-image-width img)))
         (height (car (gimp-image-height img)))
         (layer (car (gimp-layer-new img width height 0 "vignette" 100 DARKEN-ONLY-MODE))))
    (gimp-selection-clear img)
    (gimp-image-select-ellipse img 0 0 0 width height)
    (gimp-selection-feather img size)
    (gimp-selection-invert img)
    (gimp-layer-set-opacity layer opacity)
    (gimp-layer-add-mask layer (car (gimp-layer-create-mask layer ADD-SELECTION-MASK)))
    (gimp-selection-clear img)
    (gimp-image-insert-layer img layer 0 -1)))

(script-fu-register
  "make-vignette"
  "Vignette"
  "Create Vignette for the image"
  "Jakub Jankiewicz"
  "Copyright (c) 2017 Jakub Jankiewicz <http://jcubic.pl/me>"
  "May 20, 2017"
  RGB
  SF-VALUE "Size" "1000"
  SF-VALUE "Opacity" "50"
  SF-IMAGE "image" 0
)
(script-fu-menu-register "make-vignette" "<Image>/Filters/Light and Shadow")

I’ve save it in ~/gimp-2.8/scripts/vignette.scm and it added menu item Vignette to menu Light and Shadow in Filters menu

Vignette Menu

and after I’ve executed the script I’ve get the same vignette layer with mask like this:

Vignette Layer

, ,

Leave a comment

Execute callback in javascript when a div change visibility with html5

There is new html5 API that can be used to trigger a callback when element change visibility. That api is called IntersectionObserver it’s supported by Chrome, Edge and Opera, its similar to MutationObserver.

The code look like this:

var element = document.querySelector('selector');
if (window.IntersectionObserver) {
  var observer = new IntersectionObserver(function(entries) {
    if (entries[0].intersectionRatio) {
      console.log('visible');
    } else {
      console.log('hidden');
    }
  }, {
    root: document.body
  });
  observer.observe(element);
}

NOTE: intersectionRatio will not work if the element have position: fixed so if you can use jQuery you can check $(element).is(':visible');.

If you don’t use jQuery you can take :visible code from jQuery which look like this:

elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length;

To remove the observer you need to call:

observer.unobserve(element);

The element will intersect with body only when it’s visible, and the observer is fired when intersection is changed, so it will fire when element will become visible or not visible. You can read about IntersectionObserver on MDN.

If you’re are using jQuery you can wrap this using special event:

if (window.IntersectionObserver) {
  $.event.special.visibility = {
    setup: function() {
      function event(visibility) {
        var e = $.Event("visibility");
        e.visible = visibility;
        return e;
      }
      var element = this;
      var $element = $(this);
      var observer = new IntersectionObserver(function(entries) {
        var e = event($element.is(':visible'));
        ($.event.dispatch || $.event.handle).call(element, e);
      }, {
        root: document.body
      });
      observer.observe(this);
      $.data(this, 'observer', observer);
    },
    teardown: function() {
      var observer = $.data(this, 'observer');
      if (observer) {
        observer.unobserve(this);
        $.removeData(this, 'observer');
      }
    }
  };
}

and call it like this:

$('div').on('visibility', function(e) {
  $('pre').html(e.visible ? 'visible' : 'hidden');
});

Here is Codepen DEMO.

The event will fire when intersection observer is added, if you don’t want that you may want to disable first check.

      var first_time = true;
      var observer = new IntersectionObserver(function(entries) {
        if (!first_time) {
          var e = event(!!entries[0].intersectionRatio);
          ($.event.dispatch || $.event.handle).call(element, e);
        }
        first_time = false;
      }, {
        root: document.body
      });

,

Leave a comment

How to detect if element is added or removed from DOM?

There is new HTML5 API called MutationObserver that allow to add events when DOM is changing including a case when element is added or removed from DOM. Mutation observer support is pretty good.

Here is example to detect if element is added/removed from DOM with little help from jQuery

var in_dom = !!element.closest('body').length;
var observer = new MutationObserver(function(mutations) {
    if (element.closest('body').length) {
        if (!in_dom) {
            console.log("element inserted");
        }
        in_dom = true;
    } else if (in_dom) {
        in_dom = false;
        console.log("element removed");
    }

});
observer.observe(document.body, {childList: true});

to stop listening to changes you can run:

observer.disconnect();

, ,

1 Comment

How to refresh the coverage badge in github README after coverage change

In my project jQuery Terminal I commited a change that make coverage was changed from 79% to 80% (the project is using jasmine, travis and coveralls service) but my badge in README still was showing 79%, even after removing cache. I’ve fixed the issue by adding md5 hash (I know it have been broken, but we don’t use anything secure here) of the spec file after url of my badge, so each time I add something to spec file I’ve get different url, so github wont cache the file.

I’m using make as my build script and I have two README files. Source with .in extension and result with .md extension so I’ve put another variable (I’m already using BRANCH and VERSION in README):

https://coveralls.io/repos/github/jcubic/jquery.terminal/badge.svg?branch=BRANCH&CHECKSUM

and in my Makefile I’ve added:

SPEC_CHECKSUM=`md5sum spec/terminalSpec.js | cut -d' ' -f 1`

README.md: README.in .$(VERSION)
    $(SED) -e "s/{{VER}}/$(VERSION)/g" -e "s/{{BRANCH}}/$(BRANCH)/g" -e "s/{{CHECKSUM}}/$(SPEC_CHECKSUM)/" < README.in > README.md

This is simplification, actually I have more code, you can see in my Makefile

if you have more then one spec file you can use this to generate checksum

SPEC_CHECKSUM=`cat spec/*.js | md5sum | cut -d' ' -f 1`

This will also work if you have different language and different build system.

,

Leave a comment

Dropdown menu in R Shiny

Shiny framework use bootstrap 3 that support drop down menus, all you have to do, to have drop down menu in Shiny, is to prepare html and the bootstrap will handle style and logic for opening and closing the menu. Here is the handy function that will create drop down menu for you:

dropdownMenu <- function(label=NULL, icon=NULL, menu=NULL) {
  ul <- lapply(names(menu), function(id) {
    if (is.character(menu[[id]])) {
      tags$li(actionLink(id, menu[[id]]))
    } else {
      args <- menu[[id]]
      args$inputId <- id
      tags$li(do.call(actionLink, args))
    }
  })
  ul$class <- "dropdown-menu"
  tags$div(
    class = "dropdown",
    tags$button(
      class = "btn btn-default dropdown-toggle",
      type = "button",
      `data-toggle` = "dropdown",
      label,
      `if`(!is.null(icon), icon, tags$span(class="caret"))
    ),
    do.call(tags$ul, ul)
  )
}

The function will create drop down menu with caret like on the demo on getbootstrap.com or if you provide icon you will get only that icon the label is optional so you can have dropdown menu with just an icon. It use actionLink to create links so you don't need to to create custom input widget

You can use this function like this, to create hamburger menu:

dropdownMenu(
  icon = icon("bars"),
  menu = list(edit = "edit item", rename = list(label = "address", icon = icon("id-card"))
)

and it will create two inputs input$edit and input$rename so you can add observeEvent to listen to on click.

, ,

Leave a comment

%d bloggers like this: