Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Initial commit

  • Loading branch information...
commit d635755ca6fefd8b1bb5311b307c21f8e3ff200d 0 parents
@deefour authored
2  .gitignore
@@ -0,0 +1,2 @@
+*-config.json
+node_modules
7 .travis.yml
@@ -0,0 +1,7 @@
+language: node_js
+node_js:
+ - 0.6
+ - 0.8
+branches:
+ only:
+ - master
3  AUTHORS
@@ -0,0 +1,3 @@
+Authors ordered by first contribution
+
+Jason Daly <jason@deefour.me> (http://deefour.me)
20 LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2012 Jason Daly <jason@deefour.me>
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
166 README.md
@@ -0,0 +1,166 @@
+# SpeedDial
+
+[![Build Status](https://secure.travis-ci.org/deefour/SpeedDial.png)](http://travis-ci.org/deefour/SpeedDial)
+
+(c) 2012 Jason Daly <jason@deefour.me> (http://deefour.me)
+
+Released under the MIT License.
+
+## Description
+
+SpeedDial is a CLI bookmarking & shortcuts utility, allowing you to alias and index directory paths within 'entry groups', manage directory 'listings' *(SpeedDial will list out the children of a 'listing' and ask which you'd like to make your new current working directory)*, and swap your current working directory quickly by targeting entries or listings.
+
+## Installation & Usage
+
+### Dependencies
+
+[Node 0.6+](http://nodejs.org/) or greater is required to run SpeedDial. SpeedDial has so far only been tested on Mac OS.
+
+### Installation
+
+ 1. Install the node package by running `npm install speed-dial --global`
+ 2. Reload your terminal session to make the `speed-dial` binary available
+ 3. Run `speed-dial init`. Specify the path to a file of your choice that your terminal sources. `~/.bash_profile` or `~/.zsh_profile` are good choices for [bash](http://www.gnu.org/software/bash/) and [zsh](http://www.zsh.org/) users respectively
+ 4. Reload your terminal session once more so your terminal can source SpeedDial's `functions` file
+
+## Usage
+
+SpeedDial's `init` command sources a bash script from the package making a few commands to interact with SpeedDial available.
+
+ - `sd`: The main speed-dial interface
+ - `s`: Shortcut to `sd go [alias|id]`
+
+These `sd` and `s` commands should be your sole method for interacting with SpeedDial. Technically you can interact with SpeedDial without issue using the provided `speed-dial` binary directly, however when calling `speed-dial go [...]` no redirection will occur on exit of the script.
+
+### An Important Note
+
+**SpeedDial should not be run directly using the node package's `bin/speed-dial` binary.**
+
+There is an inherent issue trying to change the terminal's current working directory from within a child process. Changing the current working directory from within SpeedDial only changes the directory of the child process SpeedDial is running within and only for the duration of the script's execution. Once SpeedDial finishes execution, returning focus back to the main terminal's process, the current working directory will remain as it was prior to executing the SpeedDial command.
+
+SpeedDial works around this limitation by writing the target directory to a file in `/tmp` just before it's process exits. When calling SpeedDial through the available shell functions, the target directory is read from the `/tmp` file and the appropriate `cd [target directory]` command is executed directly within the terminal's process.
+
+**If anyone would like to suggest a better *(and multi-user-friendly)* alternative, please [create an issue](https://github.com/deefour/SpeedDial/issues/new) or send a pull request.**
+
+### Available Commands
+
+Like git, the SpeedDial command delegates to subcommands based on its first argument. The most common subcommands are:
+
+#### sd init
+
+`sd init`
+
+Should only be run once, immediately after installing the SpeedDial node package. A line like the following one is appended to the file you specify when prompted.
+
+```bash
+# Loads SpeedDial functions
+. /path/to/system/node_modules/speed-dial/assets/functions
+```
+
+#### sd version
+
+`sd version`
+
+Prints the currently installed version of SpeedDial.
+
+#### sd list
+
+`sd list [group] [options]`
+
+Lists the SpeeDial entries for all groups and listings if no `[group]` is specified. `--raw` can be passed to `[options]` to print a [prettyjson](https://github.com/rafeca/prettyjson) dump of the raw SpeedDial JSON storage in it's entirety.
+
+```bash
+sd list # lists all entries and listings by group
+sd list default # lists the entries for the default group
+sd list work # lists the entries for the 'work' group
+sd list listings # lists the 'listings' entries
+sd list work --raw # lists the raw SpeedDial JSON storage for the 'work' group
+```
+
+Whenever the `list` command is executed, directly by you or internally by SpeedDial, the entries are ordered by the following criteria
+
+ 1. Ascending by entry weight
+ 2. Without alias, then *with* alias
+ 3. Ascending alphabetically
+
+#### sd group
+
+`sd group [name]`
+
+Prints the name of the currently active group if `[name]` is not provided. If `[name]` *is* provided, SpeedDial's currently active group will be changed.
+
+```bash
+sd group # prints 'default'
+sd group work # changes currently active group to 'work'
+sd group # prints 'work'
+```
+
+When running a command like `sd go [alias|id]` *(or `s [alias|id] for short`)*, SpeedDial will restrict it's lookup to the currently active group. By switching the active group as you shift focus throughout the day, you can avoid the need to pass the group name to `s` when performing a lookup.
+
+#### sd add
+
+`sd add [path] [alias] [options]`
+
+```bash
+sd add ~/Sites/Deefour.me # Adds /Users/deefour/Sites/Deefour.me to the currently active group with no alias
+sd add ~/Documents docs # Adds /Users/deefour/Documents to the currently active group with alias 'docs'
+sd add ~/Work/Project1 --group work # Adds /Users/deefour/Work/Project1 to the 'work' group with no alias
+sd add ~/Work/Project2 p2 --group work # Adds /Users/deefour/Work/Project2 to the 'work' group with alias 'p2'
+sd add ~/Media/Audio/iTunes mp3s --weight 4 # Adds /Users/deefour/Media/Audio/iTunes to the currently active group with a weight of 4
+```
+
+Adds a new entry to SpeedDial. The `[alias]` is optional, as SpeedDial can lookup an entry based on it's ID within it's group by running `s [entry ID]` *(this is explained more in `sd go` below)*.
+
+#### sd addlisting
+
+`sd addlisting [path] [alias]`
+
+Adds a new listing to SpeedDial. Both `[path]` and `[alias]` are required. **Note:** Listing aliases must be unique to other listing aliases *and* from all group names.
+
+```bash
+sd addlisting ~/Sites sites # adds a new listing with alias 'sites' for path '/Users/deefour/Sites`
+```
+
+#### sd remove
+
+`sd remove`
+
+Lists all entries by group and all listings, with an incrementing ID value that is not reset for each group or the listings. Prompts for the ID value corresponding to the entry or listing to be removed. After confirmation, the entry or listing will be removed from the SpeedDial storage.
+
+*For a user-created entry group, if no entries remain in the group after the one specified was removed, the user-created entry group will be removed from the SpeedDial storage too.*
+
+#### sd go & s
+
+`sd go [group|alias|ID] [alias|ID]`
+
+```bash
+sd go # Lists all entries and listings, prompting the user to select one
+sd go work # Lists all entries for the 'work' entry group, prompting the user to select one
+sd go listings # Lists all listings, prompting the user to select one
+sd go listing sites # Lists the child directories of the path associated with the 'sites' alias listing, prompting the user to select one
+sd go me # (Assuming 'me' is an alias in the curently active group) Switches the current working directory to the path associated with the 'me' alias in the currently active group
+sd go work 1 # Switches the current working directory to the entry associated with ID '1' in the 'work' entry group
+```
+
+This command obeys the following logic
+
+ 1. For calls without an alias/ID specified and those for a specific listing, SpeedDial will list all entries/listings and prompt the user to select an ID for the entry/listing to switch to
+ 2. If a group is provided without an alias, SpeedDial will list all entries for the group and prompt the user to select an ID for the entry to switch to
+ 3. If a listing is specified, SpeedDial will list all child directories of the listing path and prompt the user to select an ID for the child directory to switch to
+ 4. If both a group/listing and alias/ID are provided, the user will not be prompted for anything; the current working directory will be changed immediately
+
+*The bash command function `s` is provided as a shortcut to `sd go`. Since SpeedDial is about minimizing keystrokes required to change a directory, it's recommended you always use `s` in favor of `sd go`.*
+
+## Notes
+
+- Group names and listing aliases must be globally unique
+- Group names, entry aliases, and listing aliases must all start with a letter and may only contain letters, numbers, underscores, and hyphens
+
+## Changelog
+
+### Version 0.1.0 - November 13 2012
+
+Initial project release
+
+- No tests available yet
+- A great deal of refactoring/cleaning to do
115 app.js
@@ -0,0 +1,115 @@
+var flatiron = require('flatiron'),
+ colors = require('colors')
+ path = require('path'),
+ util = require('utile'),
+ _ = require('lodash'),
+ appName = require('./package.json').name;
+
+
+
+var app = module.exports = flatiron.app;
+
+// Set $HOME properly for windows
+if (process.platform == "win32") {
+ process.env.HOME = process.env.USERPROFILE;
+}
+
+
+
+// Setup some basic options/usage instructions for flatiron
+// an the related cli plugin
+app.use(flatiron.plugins.cli, {
+ dir: path.join(__dirname, 'lib', 'commands'),
+ argv: {
+ usage: [
+ 'mm'
+ ],
+ colors: {
+ description: '--no-colors will disable output coloring',
+ default: true,
+ boolean: true
+ },
+ notFoundUsage: []
+ }
+});
+
+defaults = {
+ 'currentGroup': 'default',
+ 'storageFile': path.join(process.env.HOME, util.format('.%s.json', appName)),
+ 'tmpDirFile': '/tmp/speed-dial'
+};
+app.config.file({ file: path.join(__dirname, util.format('%s-config.json', appName)) });
+app.config.defaults(defaults);
+
+app.use(require('flatiron-cli-config'), {
+ store: 'file',
+ restricted: [
+ 'currentGroup',
+ 'storageFile'
+ ]
+});
+
+
+
+// Logging options
+app.options.log = {
+ console: {
+ raw: app.argv.raw
+ }
+};
+
+
+
+// Commonly used prompts (ie. confirmation prompt)
+app.prompt.properties = flatiron.common.mixin(
+ app.prompt.properties,
+ {
+ yesno: {
+ name: 'yesno',
+ message: 'Are you sure?',
+ validator: /y[es]*|n[o]?/,
+ warning: 'Must respond yes or no',
+ default: 'no'
+ }
+ }
+);
+
+app.prompt.override = app.argv;
+
+
+
+// Startup the flatiron app
+app.start = function(callback) {
+ // Allow coloring in the console output to be disabled
+ var useColors = (typeof app.argv.colors == 'undefined' || app.argv.colors);
+
+ useColors || (colors.mode = "none");
+
+ app.init(function(err) {
+ if (!useColors) {
+ app.log.get('default').stripColors = true;
+ app.log.get('default').transports.console.colorize = false;
+ }
+ });
+
+ // Set a default command if speed-dial is run without any command
+ if (_.isEmpty(app.argv._)) {
+ app.argv._ = ['list'];
+ }
+
+ return app.exec(app.argv._, callback);
+};
+
+
+// Overload implementation of flatiron's exec; attempts to handle errors within the
+// application's commands gracefully and help with routing
+app.exec = function (command, callback) {
+ app.router.dispatch('on', command.join(' '), app.log, function (err, shallow) {
+ if (err) {
+ app.log.error(err.message);
+ if (!_.isUndefined(callback)) callback(err);
+ }
+
+ if (!_.isUndefined(callback)) callback(err);
+ });
+}
16 assets/functions
@@ -0,0 +1,16 @@
+function s() {
+ sd go $*
+}
+
+function sd() {
+ node ~/Repos/Deefour/speed-dial/bin/speed-dial $*
+
+ if [ "$1" = "go" ]; then
+ file="/tmp/speed-dial"
+
+ dir=`tail -1 $file`
+ dir=`expr "$dir" : '[[:space:]]*\(.*\)[[:space:]]*$'`
+
+ cd "$dir"
+ fi
+}
4 bin/speed-dial
@@ -0,0 +1,4 @@
+#!/usr/bin/env node
+
+// Get things started
+require('../lib/cli').start();
12 lib/cli.js
@@ -0,0 +1,12 @@
+var app = module.exports = require('../app'),
+ storage = require('./storage');
+
+
+
+// Initial load, brings JSON data from the storage into memory
+storage.load(
+ app.config.get('storageFile'),
+ function(err) {
+ if (err) throw err;
+ }
+);
91 lib/commands/add.js
@@ -0,0 +1,91 @@
+var support = require('../support'),
+ storage = require('../storage'),
+ app = require('../../app'),
+ fs = require('fs'),
+ util = require('utile'),
+ _ = require('lodash'),
+ appTitle = require('../../package.json').title;
+
+
+
+/**
+ * Adds a new entry to SpeedDial. Expects a path a path.
+ * The entry will be appended to the current group if none is specified.
+ * The entry doesn't require an alias.
+ */
+var add = module.exports = function(dir, alias, callback) {
+ // Initialize
+ var groupName = app.argv.group || app.config.get('currentGroup'),
+ weight = app.argv.weight || 0,
+ entry = { weight: weight, path: support.normalizePath(dir) },
+ group, existing, listings;
+
+
+ // Validate
+ if (_.isString(alias) && !/^[a-zA-Z][a-zA-Z0-9\-\_]+$/.test(alias)) {
+ return callback(new Error(util.format('%s cannot be used as an entry alias. Aliases must start with a letter and only contain letters, numbers, underscores, and hyphens.', alias.yellow.bold)));
+ }
+
+ if (!_.isString(dir)) {
+ return callback(new Error(util.format('A directory path must be provided to add an entry to %s', appTitle.blue.bold)));
+ }
+
+ if (!fs.existsSync(entry.path)) {
+ return callback(new Error(util.format('%s does not appear to be a valid directory path', entry.path.yellow.bold)));
+ } else {
+ if (!fs.statSync(entry.path).isDirectory()) {
+ return callback(new Error(util.format('%s is a file path. %s only works with directories.', entry.path.yellow.bold, appTitle.blue.bold)));
+ }
+ }
+
+ if (_.contains(['listings'], groupName)) {
+ return callback(new Error(util.format('%s cannot be used as a group name because it is a reserved word within %s', groupName.yellow.bold, appTitle.blue.bold)));
+ }
+
+ if (/^\d/.test(groupName)) {
+ return callback(new Error(util.format('%s cannot be used as a group name because it starts with a number', groupName.yellow.bold)));
+ }
+
+
+
+ // Fetch the group data from the JSON storage
+ storage.get(['groups', groupName], function(val) {
+ group = val || []; // initialize the group if necessary
+ });
+
+
+
+ // More alias validation
+ if (_.isString(alias)) {
+ if (existing = _.find(group, function(entry){ return entry.alias === alias; })) {
+ return callback(new Error(util.format('The %s alias already exists in the %s entry group for path %s', alias.yellow.bold, groupName.yellow.bold, existing.path.yellow.bold)));
+ }
+
+ storage.get(['listings'], function(val){
+ listings = val || []; // initialize the listings if necessary
+ });
+
+ if (existing = _.find(listings, function(listing){ return listing.alias === alias; })) {
+ return callback(new Error(util.format('The %s alias already exists for the listing with path %s. Listing aliases must remain globally unique.', alias.yellow.bold, existing.path.yellow.bold)));
+ }
+
+
+ // Append the alias to the entry
+ entry.alias = alias;
+ }
+
+
+
+ // Save the entry
+ group.push(entry);
+
+ storage.set(['groups', groupName], group, function() {
+ storage.save(function(err) {
+ if (err) {
+ return callback(err);
+ }
+
+ app.log.info(util.format('The path %s has been added to the %s entry group %s', entry.path.yellow.bold, groupName.yellow.bold, (_.has(entry, 'alias')) ? util.format("with alias %s", entry.alias.yellow.bold) : util.format("with no alias")));
+ });
+ });
+};
56 lib/commands/addlisting.js
@@ -0,0 +1,56 @@
+var storage = require('../storage'),
+ app = require('../../app'),
+ util = require('utile'),
+ _ = require('lodash');
+
+
+
+/**
+ * Adds a new listing to the storage. Expects a path and an alias.
+ */
+var addlisting = module.exports = function(path, alias, callback) {
+ // Initialize
+ var weight = app.argv.weight || 0,
+ listing = { path: path, alias: alias, weight: weight },
+ listings, existing, entries;
+
+
+
+ // Fetch the listings data from the JSON storage
+ storage.get('listings', function(val) {
+ listings = val || []; // Initialize the listings if necessary
+ });
+
+
+
+ // Validate
+ if (existing = _.find(listings, function(listing){ return listing.alias === alias; })) {
+ return callback(new Error(util.format('The %s alias already exists for the listing with path %s. Listing aliases must remain globally unique.', alias.yellow.bold, existing.path.yellow.bold)));
+ }
+
+ // Fetch the groups data from the JSON storage
+ storage.get(['groups'], function(val){
+ entries = val || {};
+ });
+
+ _.each(entries, function(group, groupName) {
+ if (existing = _.find(group, function(entry){ return entry.alias === alias; })) {
+ return callback(new Error(util.format('The %s alias already exists in the %s entry group for path %s. Listing aliases must remain globally unique.', alias.yellow.bold, groupName.yellow.bold, existing.path.yellow.bold)));
+ }
+ });
+
+
+
+ // Save the listing
+ listings.push(listing);
+
+ storage.set(['listings'], listings, function() {
+ storage.save(function(err) {
+ if (err) {
+ return callback(err);
+ }
+
+ app.log.info(util.format('The path %s has been added to the %s with alias %s', listing.path.yellow.bold, 'listings'.yellow.bold, listing.alias.yellow.bold));
+ });
+ });
+};
223 lib/commands/go.js
@@ -0,0 +1,223 @@
+var commands = module.exports;
+
+var support = require('../support'),
+ app = require('../../app'),
+ util = require('utile'),
+ list = require('./list'),
+ phpjs = require('phpjs'),
+ _ = require('lodash'),
+ fs = require('fs'),
+ appTitle = require('../../package.json').title;
+
+
+/**
+ * The real purpose of SpeedDial - changes working directory to the
+ * path specified in an entry. If a listing is specified, the children
+ * of that directory will be listed and the user is prompted to make a
+ * selection
+ *
+ * **Important**
+ *
+ * This command doesn't actually do anything in terms of physically changing
+ * the terminal's working directory. This CLI runs through node in a child
+ * process of the shell, so changing the terminal process' directory is
+ * (as far as I know) impossible.
+ *
+ * Instead, this command stores the path for the new directory to a file in
+ * /tmp which the `sd` bash script that SpeedDial is intended to be run through
+ * will catch and
+ */
+var go = module.exports = function(group, alias, callback) {
+ // Initialize
+ var key = 'Entry/Listing ID',
+ entry;
+
+ // Allow for arbitrary number of arguments
+ if (alias === null) {
+ alias = group;
+ group = undefined;
+ }
+
+
+ // Help/support functions
+ /**
+ * Truncates and writes the selected new current working directory to the /tmp file
+ * for the bash script to read
+ */
+ var writeTarget = function(result, output) {
+ fs.open(app.config.get('tmpDirFile'), 'w+', 0666, function(e, id) {
+ fs.write( id, result.entry.path, null, 'utf8', function(){
+ fs.close(id, function(){
+ if (_.isUndefined(output)) {
+ output = util.format('The %s path', result.entry.path.yellow.bold);
+ output += util.format(' from the %s', (result.type === 'listing') ? 'listings'.yellow.bold : util.format('%s group', result.groupName.yellow.bold));
+ output += util.format(' with %s', result.entry.alias ? result.entry.alias.yellow.bold : 'no alias'.grey);
+ output += ' has been selected';
+ }
+
+ app.log.info(output);
+ app.log.info(util.format('The %s is now %s', 'current working directory'.yellow.bold, result.entry.path.green.bold));
+ });
+ });
+ });
+ },
+ /**
+ * Displays or retrieves child directory(ies) of a listing path
+ * - if an index is *not* provided, the child directories will be listed
+ * with an index value to be used in a prompt
+ * - if an index is provided, the child matching the index will be returned
+ * in a result object
+ */
+ listingChildren = function(dir, index) {
+ var i = 0,
+ list = fs.readdirSync(dir),
+ number, result;
+
+ _.each(list, function(filename) {
+ file = support.normalizePath(dir + '/' + filename);
+ if (fs.statSync(file).isDirectory()) {
+ if (++i === +index) {
+ result = {file: filename, path: file};
+ } else if (_.isUndefined(index)) {
+ number = phpjs.sprintf("%-26s", i.toString().yellow.bold);
+ app.log.info(phpjs.sprintf('%s%-26s', number, filename.green.bold, file));
+ }
+ }
+ });
+
+ if (result) {
+ return result;
+ }
+ },
+ /**
+ * Lists the entries for a single group and prompts the user to enter the ID
+ * or alias associated with the entry they want to change to
+ */
+ askGroup = function(group, callback){
+ list(group, {
+ 'single-listing': true,
+ 'exit-if-empty': true,
+ });
+
+ var key = 'Directory alias or ID';
+
+ app.prompt.get([{
+ name: key,
+ required: true,
+ pattern: /^(\d+|[a-zA-Z][a-zA-Z0-9\-\_]+)$/,
+ message: key.bold.grey + ' must match an alias or ID for a directory above',
+ conform: function(id){
+ return support.hasEntry(group, id);
+ }
+ }], function(err, result) {
+ if (err) return;
+
+ support.withEntry(group, result[key], function(entryResult) {
+ if (!_.isUndefined(callback)) {
+ callback(entryResult);
+ } else {
+ writeTarget(entryResult, util.format('The %s directory, an entry with %s in the %s group, has been selected', entryResult.entry.path.yellow.bold, (entryResult.entry.alias ? entryResult.entry.alias.yellow.bold : 'no alias'.grey), entryResult.groupName.yellow.bold));
+ }
+ });
+ });
+ },
+ /**
+ * Lists the entries for a single listing (@see listingChildren above) and
+ * prompts the user to enter the ID or alias associated with the entry
+ * they want to change to
+ */
+ askList = function(entryResult) {
+ app.log.info('');
+ app.log.info(util.format('Select the target directory within %s for the %s with alias %s', entryResult.entry.path.yellow.bold, 'listing'.yellow.bold, entryResult.entry.alias.yellow.bold));
+ app.log.info('');
+
+ listingChildren(entryResult.entry.path);
+ var key = 'Directory ID';
+
+ app.prompt.get([{
+ name: key,
+ required: true,
+ pattern: /^\d+$/,
+ message: key.bold.grey + ' must match an ID for a directory above',
+ conform: function(id){
+ return !_.isUndefined(listingChildren(entryResult.entry.path, id));
+ }
+ }], function(err, result) {
+ if (err) return;
+
+ var target = listingChildren(entryResult.entry.path, result[key]);
+ writeTarget({ entry: { path: target.path }}, util.format('The %s directory, a child of %s, the %s with alias %s, has been selected', target.file.yellow.bold, entryResult.entry.path.yellow.bold, 'listing'.yellow.bold, entryResult.entry.alias.yellow.bold));
+ });
+ };
+
+
+
+ // And now the branching logic for the various cases for how this command can be expected to run
+ if (!_.isString(alias)) { // no args passed; print a single listing of everything and prompt for an id
+ list({
+ 'single-listing': true,
+ 'exit-if-empty': true,
+ });
+
+ app.prompt.get([{
+ name: key,
+ required: true,
+ pattern: /\d+/,
+ message: key.bold.grey + ' must match an id for an entry or listing above',
+ conform: function(id){
+ return support.hasEntry(id);
+ }
+ }], function(err, result) {
+ if (err) return;
+
+ entryResult = support.withEntry(result[key]);
+
+ if (entryResult.type === 'listing') { // A listing was selected; list the children and prompt again
+ askList(entryResult);
+ } else {
+ writeTarget(entryResult);
+ }
+ });
+ } else if (_.isUndefined(group) && +alias > 0) { // an integer entry id was provided - go to the id within the current group
+ entryResult = support.withEntry(app.config.get('currentGroup'), +alias);
+
+ if (!entryResult) {
+ return callback(new Error(util.format('The target %s does not match any %s in the %s group', alias.yellow.bold, 'entry ID'.yellow.bold, app.config.get('currentGroup').yellow.bold)));
+ }
+
+ writeTarget(entryResult);
+ } else if (_.isString(alias) && _.isUndefined(group)) { // a string alias was provided - go to the alias within the current group
+ entryResult = support.withEntry(app.config.get('currentGroup'), alias);
+
+ if (!entryResult) { // if it didn't match an alias in the current group, check if it's a group name, an alias for a listing or if 'listings' was passed (special case)
+ if (alias === 'listings') {
+ askGroup('listings', function(entryResult){ // 'listings' was passed - list the listings, prompt the user, list the children and prompt again
+ askList(entryResult);
+ });
+ } else {
+ if (entryResult = support.withEntry('listings', alias)) { // check the listing aliases for a match, list the children, and prompt again
+ askList(entryResult);
+ } else if (entryResult = support.withEntry(alias, 1)) { // check the group names for a match, list the group entries, and prompt again
+ askGroup(alias);
+ } else {
+ return callback(new Error(util.format('The target %s does not match any %s in the %s group', alias.yellow.bold, 'alias'.yellow.bold, app.config.get('currentGroup').yellow.bold)));
+ }
+ }
+
+ // @todo Add an option here (set through the config) to look further, at aliases for entries
+ // within other unspecified groups for a match.
+ } else {
+ writeTarget(entryResult);
+ }
+ } else if (_.isString(group) && (_.isString(alias) || +alias > 0)) { // a group and alias were entered - go to the alias within the specified group
+ entryResult = support.withEntry(group, alias);
+
+ if (!entryResult) {
+ return callback(new Error(util.format('The target %s does not match any %s or id in the %s group', alias.yellow.bold, 'alias'.yellow.bold, group)));
+ }
+
+ writeTarget(entryResult);
+ } else {
+ return callback(new Error(util.format('The command format was not recognized by %s', appTitle.blue.bold)));
+ }
+};
36 lib/commands/group.js
@@ -0,0 +1,36 @@
+var storage = require('../storage'),
+ app = require('../../app'),
+ util = require('utile'),
+ _ = require('lodash');
+
+
+
+/**
+ * Displays the currently active group if no group is passed.
+ * Sets the passed group as the newly 'current' one.
+ */
+var group = module.exports = function(group, callback) {
+ // The currently active group should be printed
+ if (!_.isString(group)) {
+ return app.log.info(util.format('The %s is %s', 'active group'.yellow.bold, app.config.get('currentGroup').yellow.bold));
+ }
+
+
+ // A new group is being made active
+
+ // Validate
+ storage.get(['groups'], function(data) {
+ if (_.isUndefined(data[group])) {
+ return callback(new Error(util.format('%s is not a valid %s', group.yellow.bold, 'group name'.yellow.bold)));
+ }
+ });
+
+
+
+ // Save the newly active gropu
+ app.config.set('currentGroup', group);
+
+ app.config.save(function(){
+ app.log.info(util.format('The %s is now %s', 'active group'.yellow.bold, app.config.get('currentGroup').yellow.bold));
+ });
+};
71 lib/commands/init.js
@@ -0,0 +1,71 @@
+var support = require('../support'),
+ storage = require('../storage'),
+ app = require('../../app'),
+ path = require('path'),
+ _ = require('lodash'),
+ fs = require('fs'),
+ util = require('utile'),
+ appTitle = require('../../package.json').title,
+ appRepoUrl = require('../../package.json').repository.url;
+
+
+
+/**
+ * To be run before using SpeedDial, finishes the install by
+ * appending a bash source line to the specified config file
+ * included in a user's terminal. The user is prompted
+ * to enter the file path to append the souce line to.
+ */
+var init = module.exports = function(id, callback) {
+ // Initialize
+ var args = app.argv._,
+ key = 'File to append souce line';
+
+
+
+ // Explain
+ app.log.warn(util.format('This command will attempt to add a new %s line to the file you specify', '. (source)'.yellow.bold));
+ app.log.warn(util.format('Alternative instructions can be found in %s repo\'s %s file at %s', appTitle.blue.bold, 'README.md'.yellow.bold, appRepoUrl.yellow.bold));
+
+
+
+ // Get the filepath to append the source line to for bash shortcuts
+ app.prompt.get([{
+ name: key,
+ required: true,
+ message: util.format('%s must point to an already-existing file that %s can write to', key.bold.grey, appTitle.blue.bold),
+ conform: function(filePath){ // make sure the path exists and is a file
+ filePath = support.normalizePath(filePath);
+ return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
+ }
+ }], function(err, result) {
+ if (err) {
+ return callback(err);
+ }
+
+ // Initialize the file append
+ var filePath = support.normalizePath(result[key]),
+ sourceLine = util.format('. %s', path.join(__dirname, '..', '..', 'assets', 'functions'));
+
+ // Explain (warn)
+ app.log.warn(util.format('%s will append "%s" to the %s file', appTitle.blue.bold, sourceLine.yellow.bold, filePath.yellow.bold))
+
+ // Confirm the filepath & write action
+ app.prompt.get(['yesno'], function(err, result) {
+ if (err) throw err;
+
+ if (/^y/i.test(result.yesno)) {
+ // Perform the append
+ fs.open(filePath, 'a+', 0666, function( e, id ) {
+ fs.write( id, util.format("\n\n# Loads %s functions\n%s", appTitle, sourceLine), null, 'utf8', function(){
+ fs.close(id, function(){
+ // Explain everything went well
+ app.log.info(util.format('%s has had "%s" appended successfully', filePath.yellow.bold, sourceLine.yellow.bold));
+ app.log.info(util.format('You must reload your terminal session for the %s commands to become available', appTitle.blue.bold));
+ });
+ });
+ });
+ }
+ });
+ });
+};
144 lib/commands/list.js
@@ -0,0 +1,144 @@
+var support = require('../support'),
+ storage = require('../storage'),
+ app = require('../../app'),
+ prettyjson = require('prettyjson'),
+ _ = require('lodash'),
+ phpjs = require('phpjs'),
+ util = require('utile'),
+ appTitle = require('../../package.json').title;
+
+
+
+/**
+ * Displays the contents of the SpeedDial entries & listings
+ * in a variety of formats
+ *
+ * - Sorted and organized by group and listing
+ * - A single group if specified
+ * - prettyjson formatted raw JSON of either format
+ * - One contiguous index spanning groups/listings (used for deletion)
+ */
+var list = module.exports = function(name, options, callback) {
+ // Initialize functions
+ var _getLine = function(entry, i, internalPointer, useInternalPointer) {
+ var number;
+
+ if (useInternalPointer) {
+ number = phpjs.sprintf("%-26s", internalPointer.toString().yellow.bold);
+ } else {
+ number = phpjs.sprintf("%-17s", (i+1).toString().white);
+ }
+
+ return phpjs.sprintf('%s%-' + (entry.alias ? 35 : 26) + 's%s', number, entry.alias ? entry.alias.green.bold : '[none]'.grey, entry.path);
+ },
+ listGroup = function(entries, groupName){
+ app.log.info('');
+ app.log.info(util.format('%s%s', ('Entry Group: ' + groupName.blue.bold).underline, (app.config.get('currentGroup') === groupName) ? ' (active)'.yellow.bold : ''));
+ app.log.info('');
+
+ entries = entries.sort(support.sortEntries);
+
+ if (_.isEmpty(entries)) {
+ app.log.info('[No entries have been set]'.grey);
+ } else {
+ _.each(entries, function(entry, i) {
+ app.log.info(_getLine(entry, i, ++internalPointer, useInternalPointer));
+ });
+ }
+ },
+ listListings = function(listings){
+ app.log.info('');
+ app.log.info('Listings'.blue.bold.underline);
+ app.log.info('');
+
+ if (!_.isEmpty(listings)) {
+ listings = listings.sort(support.sortEntries);
+
+ _.each(listings, function(listing, i) {
+ app.log.info(_getLine(listing, i, ++internalPointer, useInternalPointer));
+ });
+ } else {
+ app.log.info('[No listings have been set]'.grey);
+ }
+ };
+
+
+
+ // Allow for arbitrary number of arguments; in this case where no args are passed
+ if (_.isPlainObject(name)) {
+ options = name;
+ name = undefined;
+ }
+
+ // Set defautl --... options
+ options = _.isPlainObject(options) ? options : app.argv;
+ options = _.extend({
+ 'exit-if-empty': false, // there will be no listing output if the requested list is blank/empty
+ 'empty-message': '', // if a group or listing is empty, this message will be used (ignored if --exit-with-empty is provided)
+ 'single-listing': false // causes increment of the SpeedDial ID for each entry/listing to *NOT* be reset for each group/listing
+ }, options);
+
+ // Initialize variables/defaults
+ var internalPointer = 0,
+ useInternalPointer = options['single-listing'] == true;
+
+
+
+ // Validate
+ if (options['exit-if-empty'] && !support.hasEntry()) {
+ return callback(new Error(util.format('No entries or listings have been added to %s yet', appTitle.blue.bold) + (options['empty-message'] ? '; ' + options['empty-message'] : '')));
+ }
+
+ if (_.isString(name)) {
+ if (name === 'listings') {
+ storage.get(['listings'], function(data) {
+ if (options['exit-if-empty'] && _.isEmpty(data)) {
+ return callback(new Error(util.format('No listings have been added to %s yet', appTitle.blue.bold) + (options['empty-message'] ? '; ' + options['empty-message'] : '')));
+ }
+ });
+ } else {
+ storage.get(['groups', name], function(data) {
+ if (data === null) {
+ return callback(new Error(util.format('There is currently no %s group to list entries for', name.yellow.bold)));
+ }
+
+ if (options['exit-if-empty'] && _.isEmpty(data)) {
+ return callback(new Error(util.format('No entries have been added to the %s group yet', name.yellow.bold) + (options['empty-message'] ? '; ' + options['empty-message'] : '')));
+ }
+ });
+ }
+ }
+
+
+
+ // Do the list
+ storage.get(null, function(data) {
+ if (_.has(app.argv, 'raw')) {
+ if (_.isString(name)) {
+ if (name === 'listings') {
+ console.log(prettyjson.render(data.listings));
+ } else {
+ console.log(prettyjson.render(data.groups[name]));
+ }
+ } else {
+ console.log(prettyjson.render(data));
+ }
+ } else {
+ if (_.isString(name)) {
+ if (name === 'listings') {
+ listListings(data.listings);
+ } else {
+ listGroup(data.groups[name], name);
+ }
+ } else {
+ _.each(data.groups, function(entries, groupName) {
+ listGroup(entries, groupName);
+ });
+
+ listListings(data.listings);
+ }
+
+ app.log.info('');
+ }
+ });
+};
118 lib/commands/remove.js
@@ -0,0 +1,118 @@
+var support = require('../support'),
+ storage = require('../storage'),
+ app = require('../../app'),
+ _ = require('lodash'),
+ list = require('./list'),
+ util = require('utile'),
+ changeGroup = require('./group');
+
+
+
+/**
+ * Lists all entries & listings, and prompts the user
+ * for the index/ID # of the entry or listing to delete
+ * from storage
+ */
+var remove = module.exports = function(callback) {
+ // Initialize
+ var key = 'Entry/Listing ID',
+ notifyDeletion = function(deleteObj, options) {
+ options = _.extend({ dryRun: true }, options || {});
+ var confirmation = 'You are about to delete ID ';
+
+ if (!options.dryRun) {
+ confirmation = 'ID ';
+ }
+
+ confirmation += deleteObj.id.toString().yellow.bold + ', the';
+
+ if (_.isUndefined(deleteObj.groupName)) {
+ confirmation += ' listing'.yellow.bold;
+ } else {
+ confirmation += ' group entry'.yellow.bold;
+ confirmation += ' from group ' + deleteObj.groupName.yellow.bold;
+ }
+
+ confirmation += ' with path ' + deleteObj.entry.path.yellow.bold;
+
+ if (_.isString(deleteObj.entry.alias)) {
+ confirmation += ' and alias ' + deleteObj.entry.alias.yellow.bold;
+ }
+
+ if (!options.dryRun) {
+ confirmation += ' has been';
+ confirmation += ' deleted'.red.bold;
+ confirmation += ' successfully'
+ app.log.info(confirmation);
+ } else {
+ app.log.warn(confirmation);
+ }
+ };
+
+
+
+ // List the entries/listing with single index
+ list({
+ 'exit-if-empty': true,
+ 'empty-message': 'there is nothing to delete',
+ 'single-listing': true
+ });
+
+
+
+ // Ask for the index to delete
+ app.prompt.get([{
+ name: key,
+ required: true,
+ pattern: /\d+/,
+ message: key.bold.grey + ' must match an id for an entery or listing above',
+ conform: function(id){
+ return support.hasEntry(id);
+ }
+ }], function(err, result) {
+ if (err) throw err;
+
+ var id = result[key],
+ deleteObj = support.withEntry(id),
+ confirmation = notifyDeletion(deleteObj);
+
+ // Confirm the deletion
+ app.prompt.get(['yesno'], function(err, result) {
+ if (err) {
+ return callback(err);
+ }
+
+ // Do the deletion
+ if (/^y/i.test(result.yesno)) {
+ support.withEntry(id, function(result) {
+ var groupTarget = result.dataTarget.slice(0, 2);
+ storage.del(result.dataTarget, function(){
+ storage.save(function(err) {
+ if (err) {
+ return callback(err);
+ }
+
+ // For user-created groups, if this just-deleted entry was the last one, delete the group too
+ if (!support.hasEntry(result.groupName) && result.groupName !== 'default') {
+ storage.del(groupTarget, function(){
+ storage.save(function(err) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (result.groupName === app.config.get('currentGroup')) {
+ app.log.warn(util.format('%s was the currently active group. No entries exist for this group anymore, so the group has been %s', result.groupName.yellow.bold, 'deleted'.red.bold));
+ changeGroup('default');
+ }
+ });
+ });
+ }
+
+ notifyDeletion(deleteObj, { dryRun: false });
+ });
+ });
+ });
+ }
+ });
+ });
+};
10 lib/commands/version.js
@@ -0,0 +1,10 @@
+var app = require('../../app');
+
+
+
+/**
+ * Prints the version of SpeedDial as found in the package.json file
+ */
+var version = module.exports = function() {
+ app.log.info(require('../../package.json').version)
+};
114 lib/storage.js
@@ -0,0 +1,114 @@
+var fs = require('fs'),
+ _ = require('lodash');
+
+
+
+/**
+ * Manages in memeory copy of the SpeedDial entries & listings,
+ * as well as provides functionality to load/persist the data
+ * to/from an external file.
+ *
+ * There is currently an expectation that .load will be called
+ * by the app during it's own init to preload JSON into memory.
+ */
+var storage = module.exports = function() {
+ // Initialize/defaults
+ var source,
+ data,
+ priv = {
+ defaultData: {
+ groups: {
+ default: []
+ },
+ listings: []
+ }
+ };
+
+
+ // Public api
+ var pub = {
+ /**
+ * Sets a value in the in-memory JSON data store
+ */
+ set: function(path, value, cb) {
+ if (!_.isArray(path)) path = [path];
+
+ var data = this.data,
+ key = path.pop();
+
+ _.each(path, function(key) { data = data[key] });
+
+ (data[key] = value) && cb();
+ },
+
+
+
+ /**
+ * Retrieves a value from the in-memory JSON data store
+ */
+ get: function(path, cb) {
+ var data = this.data;
+
+ if (_.isString(path) || _.isArray(path)) {
+ if (!_.isArray(path)) path = [path];
+
+ _.each(path, function(key) { data = data[key]; });
+ }
+
+ if (arguments.length === 2) {
+ return cb(data || null);
+ }
+
+ data;
+ },
+
+
+
+ /**
+ * Removes a value from the in-memory JSON data store
+ */
+ del: function(path, cb) {
+ if (!_.isArray(path)) path = [path];
+
+ var data = this.data,
+ key = path.pop();
+
+ _.each(path, function(key) {data = data[key]; });
+
+ delete data[key] && (_.isArray(data) ? data.splice(key, 1) : true) && cb();
+ },
+
+
+
+ /**
+ * Persists the in-memory JSON store to a file
+ */
+ save: function(cb) {
+ fs.writeFile(this.source, JSON.stringify(this.data), function(err) {
+ cb(err || null)
+ });
+ },
+
+
+
+ /**
+ * Loads JSON from a file into memory
+ */
+ load: function(file, cb) {
+ this.source = file;
+ this.data = _.clone(priv.defaultData);
+
+ try {
+ var content = fs.readFileSync(file, 'utf8');
+ this.data = JSON.parse(content);
+ } catch(err) {
+ if (err && err.code !== 'ENOENT') return cb(err);
+ }
+
+ cb(null);
+ }
+ };
+
+ // Expose the public api
+ return pub;
+}();
147 lib/support.js
@@ -0,0 +1,147 @@
+var storage = require('./storage'),
+ _ = require('lodash');
+
+
+
+/**
+ * Generic support/help for SpeedDial
+ */
+var support = module.exports = function(){
+
+ var pub = {
+ /**
+ * Sort function passed to Array.sort, accounting for entry
+ * weight and alias name
+ */
+ sortEntries: function(a, b) {
+ if (a.weight != b.weight) return a.weight - b.weight;
+
+ if (a.alias === b.alias) return 0;
+ if ((a.alias || '') < (b.alias || '')) return -1
+ return 1;
+ },
+
+
+
+ /**
+ * Locates a specific entry by the group/id passed and
+ * - passes it to the callback function to have something done to it
+ * - returns it
+ *
+ * This ne's a bit tricky because many times the id corresponds to an
+ * entry as though all entries and listings were printed in a single
+ * fluent lists; they need to be treated as entries in a single, sorted
+ * array
+ */
+ withEntry: function(group, id, callback) {
+ // Initialize
+ var result = false,
+ i = 0;
+
+ // Allow arbitrary # of arguments
+ if (_.isFunction(id) || _.isUndefined(id)) {
+ callback = id;
+ id = group;
+ group = undefined;
+ } else if (_.isFunction(group)) {
+ callback = group;
+ group = undefined;
+ id = undefined;
+ }
+
+ // Internal helpers
+ var iterateGroup = function(entries, groupName) {
+ entries = entries.sort(support.sortEntries);
+
+ _.each(entries, function(entry, j) {
+ if (++i === +id || id === entry.alias) {
+ result = _.clone(entry);
+
+ result = {
+ type: 'entry',
+ dataTarget: ['groups', groupName, j],
+ entry: entry,
+ groupName: groupName,
+ id: i
+ };
+ }
+ });
+ },
+ iterateListings = function(listings) {
+ _.each(listings, function(listing, j) {
+ if (++i === +id || id === listing.alias) {
+ result = _.clone(listing);
+
+ result = {
+ type: 'listing',
+ dataTarget: ['listings', j],
+ entry: listing,
+ id: i
+ };
+ }
+ });
+ };
+
+ // Find the entry
+ if (_.isUndefined(group)) {
+ storage.get(null, function(data) {
+ _.each(data.groups, function(entries, groupName){
+ iterateGroup(entries, groupName);
+ });
+
+ if (!result) {
+ iterateListings(data.listings);
+ }
+ });
+ } else {
+ var target = (group === 'listings') ? ['listings'] : ['groups', group];
+
+ storage.get(target, function(data) {
+ if (data) {
+ (group === 'listings') ? iterateListings(data) : iterateGroup(data, group);
+ }
+ });
+ }
+
+ // Pass it to the callback if there is one, then return
+ if (!_.isUndefined(callback)) {
+ callback(result);
+ }
+
+ return result;
+ },
+
+
+
+ /**
+ * Boolean check whether an entry exists. Group is optional
+ */
+ hasEntry: function(group, id){
+ if (+group > 0) {
+ id = group;
+ group = undefined;
+ }
+
+ return this.withEntry(group, id || 1) !== false;
+ },
+
+
+
+ /**
+ * *NOT*-universal translation of ~ to $HOME; attempts to
+ * get a full path when someone provides something like ~/Sites
+ */
+ normalizePath: function(filePath) {
+ if (!_.isString(filePath)) return false;
+
+ if (/^\~/.test(filePath)) {
+ filePath = filePath.replace(/^\~/, process.env.HOME);
+ }
+
+ return require('path').normalize(filePath);
+ }
+ };
+
+ // Expose the public api
+ return pub;
+}();
54 package.json
@@ -0,0 +1,54 @@
+{
+ "name": "speed-dial",
+ "title": "SpeedDial",
+ "description": "A CLI bookmarking & shortcuts utility",
+ "version": "0.1.0",
+ "homepage": "https://github.com/deefour/SpeedDial",
+ "author": {
+ "name": "Jason Daly and other contributors",
+ "url": "https://github.com/deefour/SpeedDial/blob/master/AUTHORS"
+ },
+ "maintainers": [
+ {
+ "name": "Jason Daly",
+ "email": "jason@deefour.me",
+ "url": "http://deefour.me"
+ }
+ ],
+ "repository": {
+ "type": "git",
+ "url": "git://github.com/deefour/SpeedDial.git"
+ },
+ "bugs": "https://github.com/deefour/SpeedDial/issues",
+ "licenses": [
+ {
+ "type": "MIT",
+ "url": "https://github.com/deefour/SpeedDial/blob/master/LICENSE"
+ }
+ ],
+ "private": "false",
+ "dependencies": {
+ "flatiron": "0.3.0",
+ "lodash": "0.9.2",
+ "prettyjson": "0.7.1",
+ "phpjs": "0.0.1",
+ "colors": "0.6.0-1",
+ "utile": "0.1.5",
+ "flatiron-cli-config": "0.1.3",
+ "glob": "3.1.14"
+ },
+ "devDependencies": {
+ "cli-easy": "0.1.0",
+ "vows": "0.6.1"
+ },
+ "scripts": {
+ "test": "vows --spec",
+ "start": "node app.js"
+ },
+ "bin": {
+ "speed-dial": "./bin/speed-dial"
+ },
+ "name": "speed-dial",
+ "author": "Jason Daly <jason@deefour.me> (http://deefour.me)",
+ "homepage": "https://github.com/deefour/speed-dial"
+}
Please sign in to comment.
Something went wrong with that request. Please try again.