├── .gitignore ├── .travis.yml ├── LICENSE ├── README.rst ├── bin └── twtxt.js ├── config.example ├── lib ├── cli.js ├── config.js ├── file.js ├── helper.js └── parser.js ├── package.json ├── test ├── cli.js ├── config.js ├── file.js ├── helper.js └── parser.js └── twtxt.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .coverage 3 | *.pyc 4 | *.egg-info 5 | *.pex 6 | .cache 7 | .idea 8 | build 9 | dist 10 | venv -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.12 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 buckket 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | 2 | twtxt 3 | ~~~~~ 4 | |gitter| |license| 5 | 6 | **twtxt** is a decentralised, minimalist microblogging service for hackers. 7 | 8 | So you want to get some thoughts out on the internet in a convenient and slick way while also following the gibberish of others? Instead of signing up at a closed and/or regulated microblogging platform, getting your status updates out with twtxt is as easy as putting them in a publicly accessible text file. The URL pointing to this file is your identity, your account. twtxt then tracks these text files, like a feedreader, and builds your unique timeline out of them, depending on which files you track. The format is simple, human readable, and integrates well with UNIX command line utilities. 9 | 10 | 11 | |demo| 12 | 13 | **tl;dr**: twtxt is a CLI tool, as well as a format specification for self-hosted flat file based microblogging. 14 | 15 | ----- 16 | 17 | .. contents:: 18 | :local: 19 | :depth: 1 20 | :backlinks: none 21 | 22 | ----- 23 | 24 | Features 25 | -------- 26 | - A beautiful command-line interface thanks to click. 27 | - Asynchronous HTTP requests 28 | - Integrates well with existing tools (scp, cut, echo, date, etc.) and your shell. 29 | - Don’t like the official client? Tweet using ``echo -e "`date -Im`\tHello world!" >> twtxt.txt``! 30 | 31 | Installation 32 | ------------ 33 | 34 | Release version: 35 | ================ 36 | 1) Make sure that you have the latest npm and node 37 | 38 | 2) Install this package using npm: 39 | 40 | .. code:: 41 | 42 | $ npm install -g twtxt 43 | 44 | 3) Run ``twtxt quickstart``. :) 45 | 46 | Usage 47 | ----- 48 | twtxt features an excellent command-line interface thanks to `click `_. Don’t hesitate to append ``--help`` or call commands without arguments to get information about all available commands, options and arguments. 49 | 50 | Here are a few of the most common operations you may encounter when using twtxt: 51 | 52 | Follow a source: 53 | ================ 54 | 55 | .. code:: 56 | 57 | $ twtxt follow bob http://bobsplace.xyz/twtxt 58 | ✓ You’re now following bob. 59 | 60 | List all sources you’re following: 61 | ================================== 62 | 63 | .. code:: 64 | 65 | $ twtxt following 66 | ➤ alice @ https://example.org/alice.txt 67 | ➤ bob @ http://bobsplace.xyz/twtxt 68 | 69 | Unfollow a source: 70 | ================== 71 | 72 | .. code:: 73 | 74 | $ twtxt unfollow bob 75 | ✓ You’ve unfollowed bob. 76 | 77 | Post a status update: 78 | ===================== 79 | 80 | .. code:: 81 | 82 | $ twtxt tweet "Hello, this is twtxt!" 83 | 84 | View your timeline: 85 | =================== 86 | 87 | .. code:: 88 | 89 | $ twtxt timeline 90 | 91 | ➤ bob (5 minutes ago): 92 | This is my first "tweet". :) 93 | 94 | ➤ alice (2 hours ago): 95 | I wonder if this is a thing? 96 | 97 | Configuration 98 | ------------- 99 | twtxt uses a simple INI-like configuration file. It’s recommended to use ``twtxt quickstart`` to create it. On Linux twtxt checks ``~/.config/twtxt/config`` for it’s configuration. Consult `get_app_dir `_ to find out the config directory for other operating systems. 100 | 101 | Note: The npm version uses json instead of an ini. 102 | 103 | Here’s an example ``conf`` file, showing every currently supported option: 104 | 105 | .. code:: 106 | 107 | { 108 | "user": "melvincarvalho", 109 | "twtfile": "/home/user/twtxt.txt", 110 | "limit_timeline": "20", 111 | "following": [ 112 | { 113 | "user": "twtxt", 114 | "uri": "https://buckket.org/twtxt_news.txt" 115 | } 116 | ] 117 | } 118 | 119 | 120 | [twtxt] section: 121 | ================ 122 | +-------------------+-------+------------+---------------------------------------------------+ 123 | | Option: | Type: | Default: | Help: | 124 | +===================+=======+============+===================================================+ 125 | | nick | TEXT | | your nick, will be displayed in your timeline | 126 | +-------------------+-------+------------+---------------------------------------------------+ 127 | | twtfile | PATH | | path to your local twtxt file | 128 | +-------------------+-------+------------+---------------------------------------------------+ 129 | | limit_timeline | INT | 20 | limit amount of tweets shown in your timeline | 130 | +-------------------+-------+------------+---------------------------------------------------+ 131 | | post_tweet_hook | TEXT | | command to be executed after tweeting | 132 | +-------------------+-------+------------+---------------------------------------------------+ 133 | 134 | ``post_tweet_hook`` is very useful if you want to push your twtxt file to a remote (web) server. Check the example above tho see how it’s used with ``scp``. 135 | 136 | [followings] section: 137 | ===================== 138 | This section holds all your followings as nick, URL pairs. You can edit this section manually or use the ``follow``/``unfollow`` commands of twtxt for greater comfort. 139 | 140 | Format specification 141 | -------------------- 142 | The central component of sharing information, i.e. status updates, with twtxt is a simple text file containing all the status updates of a single user. One status per line, each of which is equipped with an ISO 8601 date/time string followed by a TAB character (\\t) to separate it from the actual text. A specific ordering of the statuses is not mandatory. 143 | 144 | The file must be encoded with UTF-8 and must use LF (\\n) as line separators. 145 | 146 | A status should consist of up to 140 characters, longer status updates are technically possible but discouraged. twtxt will warn the user if a newly composed status update exceeds this limit, and it will also shorten incoming status updates by default. Also note that a status may not contain any control characters. 147 | 148 | Take a look at this example file: 149 | 150 | .. code:: 151 | 152 | 2016-02-04T13:30+01 You can really go crazy here! ┐(゚∀゚)┌ 153 | 2016-02-01T11:00+01 This is just another example. 154 | 2015-12-12T12:00+01 Fiat lux! 155 | 156 | Contributions 157 | ------------- 158 | - A web-based directory of twtxt users by `reednj `_: http://twtxt.reednj.com/ 159 | - A web-based directory of twtxt users by `xena `_: https://twtxtlist.cf 160 | - A web-based twtxt feed hoster for the masses by `plomlompom `_: https://github.com/plomlompom/htwtxt 161 | - A twitter-to-twtxt converter in node.js by `DracoBlue `_: https://gist.github.com/DracoBlue/488466eaabbb674c636f 162 | 163 | License 164 | ------- 165 | twtxt is released under the MIT License. See the bundled LICENSE file for details. 166 | 167 | 168 | .. |gitter| image:: https://img.shields.io/gitter/room/buckket/twtxt.svg?style=flat 169 | :target: https://gitter.im/buckket/twtxt 170 | :alt: Chat on gitter 171 | 172 | .. |license| image:: https://img.shields.io/badge/license-MIT-blue.svg?style=flat 173 | :target: https://raw.githubusercontent.com/buckket/twtxt/master/LICENSE 174 | :alt: Package license 175 | 176 | .. |demo| image:: https://asciinema.org/a/1w2q3suhgrzh2hgltddvk9ot4.png 177 | :target: https://asciinema.org/a/1w2q3suhgrzh2hgltddvk9ot4 178 | :alt: Demo 179 | -------------------------------------------------------------------------------- /bin/twtxt.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // requires 4 | var fs = require('fs'); 5 | var program = require('commander'); 6 | var request = require('request'); 7 | var debug = require('debug')('twtxt'); 8 | var childProcess = require('child_process'); 9 | var readline = require('readline'); 10 | var moment = require('moment'); 11 | 12 | var tweet = require("../lib/cli").tweet; 13 | var follow = require("../lib/cli").follow; 14 | var unfollow = require("../lib/cli").unfollow; 15 | var following = require("../lib/cli").following; 16 | var quickstart = require("../lib/cli").quickstart; 17 | var timeline = require("../lib/cli").timeline; 18 | 19 | 20 | var error = debug('app:error'); 21 | 22 | // defaults 23 | var default_limit_timeline = 20; 24 | 25 | /** 26 | * run as bin 27 | */ 28 | function bin() { 29 | var MAXCHARS = 140; 30 | var user; 31 | var uri; 32 | 33 | program 34 | .arguments(' [arg] [uri]') 35 | .option('-c, --config ', 'Specify a custom config file location.') 36 | .option('-v, --verbose', 'Enable verbose output for deubgging purposes') 37 | .option('--version', 'Shows the version and exit.') 38 | .action(function (cmd, arg, uri) { 39 | cmdValue = cmd; 40 | argValue = arg; 41 | uriValue = uri; 42 | }); 43 | 44 | program.parse(process.argv); 45 | 46 | if (typeof cmdValue === 'undefined') { 47 | console.error('no command given!'); 48 | process.exit(1); 49 | } 50 | if (program.verbose) { 51 | process.env.DEBUG = 'twtxt'; 52 | debug = console.log; 53 | } 54 | debug('command:', cmdValue); 55 | debug('arg: ', argValue || "no arg given"); 56 | debug('uri: ', uriValue || "no uri given"); 57 | debug('verbose: ' + program.verbose); 58 | 59 | if (cmdValue === 'tweet') { 60 | var description = argValue; 61 | 62 | if (!description) { 63 | console.error('Usage: twtxt tweet '); 64 | process.exit(-1); 65 | } 66 | 67 | if (description && description.length > MAXCHARS) { 68 | console.error('Maximum tweet length : ' + MAXCHARS); 69 | process.exit(-1); 70 | } 71 | 72 | tweet(description); 73 | } else if (cmdValue === 'follow') { 74 | user = argValue; 75 | uri = uriValue; 76 | 77 | if (!user) { 78 | console.error('Usage: twtxt follow '); 79 | process.exit(-1); 80 | } 81 | 82 | if (!uri) { 83 | console.error('Usage: twtxt follow '); 84 | process.exit(-1); 85 | } 86 | 87 | follow(user, uri); 88 | 89 | } else if (cmdValue === 'unfollow') { 90 | user = argValue; 91 | 92 | if (!user) { 93 | console.error('Usage: twtxt unfollow '); 94 | process.exit(-1); 95 | } 96 | 97 | unfollow(user); 98 | 99 | } else if (cmdValue === 'following') { 100 | following(); 101 | 102 | } else if (cmdValue === 'timeline') { 103 | timeline(); 104 | 105 | } else if (cmdValue === 'quickstart') { 106 | quickstart(); 107 | 108 | } else { 109 | console.error(cmdValue + ' : not recognized'); 110 | } 111 | 112 | } 113 | 114 | 115 | // If one import this file, this is a module, otherwise a library 116 | if (require.main === module) { 117 | bin(process.argv); 118 | } 119 | -------------------------------------------------------------------------------- /config.example: -------------------------------------------------------------------------------- 1 | { 2 | "user": "melvincarvalho", 3 | "twtfile": "/home/user/twtxt.txt", 4 | "limit_timeline": "20", 5 | "following": [ 6 | { 7 | "user": "twtxt", 8 | "uri": "https://buckket.org/twtxt_news.txt" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /lib/cli.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tweet: tweet, 3 | follow: follow, 4 | unfollow: unfollow, 5 | following: following, 6 | quickstart: quickstart, 7 | timeline: timeline 8 | }; 9 | 10 | // requires 11 | var fs = require('fs'); 12 | var program = require('commander'); 13 | var request = require('request'); 14 | var debug = require('debug')('twtxt'); 15 | var childProcess = require('child_process'); 16 | var readline = require('readline'); 17 | var moment = require('moment'); 18 | 19 | var writeTweet = require("../lib/file").writeTweet; 20 | var writeConfig = require("../lib/file").writeConfig; 21 | 22 | var hook = require("../lib/helper").hook; 23 | var getUserHome = require("../lib/helper").getUserHome; 24 | var getUserName = require("../lib/helper").getUserName; 25 | 26 | var getConfigFile = require("../lib/config").getConfigFile; 27 | var getConfig = require("../lib/config").getConfig; 28 | var getTimelineFile = require("../lib/config").getTimelineFile; 29 | 30 | var parseTimeline = require("../lib/parser").parseTimeline; 31 | var displayPosts = require("../lib/parser").displayPosts; 32 | var serializeTweet = require("../lib/parser").serializeTweet; 33 | 34 | var error = debug('app:error'); 35 | 36 | // defaults 37 | var default_limit_timeline = 20; 38 | 39 | 40 | 41 | /** 42 | * Append a new tweet to your twtxt file. 43 | * @return {[type]} [description] 44 | */ 45 | function tweet(description) { 46 | // init 47 | var config = getConfig(); 48 | var twtfile = getTimelineFile(); 49 | output = serializeTweet(description); 50 | 51 | writeTweet(twtfile, output); 52 | if (config.post_tweet_hook) { 53 | hook(config.post_tweet_hook); 54 | } 55 | } 56 | 57 | /** 58 | * follow a user 59 | * @param {String} user the user to follow 60 | */ 61 | function follow(user, uri) { 62 | var config = getConfig(); 63 | config.following = config.following || []; 64 | config.following.push( {"user" : user, "uri": uri} ); 65 | writeConfig(config); 66 | debug(config); 67 | console.log("✓ You’re now following "+user+"."); 68 | } 69 | 70 | /** 71 | * list following 72 | */ 73 | function following() { 74 | var config = getConfig(); 75 | config.following = config.following || []; 76 | for (var i = 0; i < config.following.length; i++) { 77 | console.log('➤ ' + config.following[i].user + ' @ ' + config.following[i].uri); 78 | } 79 | } 80 | 81 | /** 82 | * unfollow a user 83 | * @param {String} user the user to follow 84 | */ 85 | function unfollow(user) { 86 | var config = getConfig(); 87 | config.following = config.following || []; 88 | for (var i = 0; i < config.following.length; i++) { 89 | if (config.following[i] && config.following[i].user === user ) { 90 | config.following.splice(i,1); 91 | } 92 | } 93 | writeConfig(config); 94 | debug(config); 95 | console.log("✓ You’ve unfollowed "+user+"."); 96 | } 97 | 98 | /** 99 | * list following 100 | */ 101 | function timeline() { 102 | var config = getConfig(); 103 | var nick = config.user || 'you'; 104 | var following = config.following || []; 105 | var fetched = 0; 106 | var sources = 1 + following.length; 107 | var posts = []; 108 | 109 | function callback(err, val) { 110 | fetched++; 111 | if (err) { 112 | console.error(err); 113 | } else { 114 | posts = posts.concat(val); 115 | if (fetched === sources) { 116 | displayPosts(posts); 117 | } 118 | } 119 | } 120 | 121 | // local 122 | var twtfile = getTimelineFile(); 123 | parseTimeline(twtfile, nick, callback); 124 | 125 | // remote 126 | for (var i = 0; i < following.length; i++) { 127 | 128 | var uri = following[i].uri; 129 | parseTimeline(uri, following[i].user, callback); 130 | 131 | } 132 | 133 | } 134 | 135 | /** 136 | * quick start 137 | */ 138 | function quickstart() { 139 | 140 | var rl = readline.createInterface({ 141 | input: process.stdin, 142 | output: process.stdout 143 | }); 144 | 145 | var help = "This wizard will generate a basic configuration file for twtxt with all mandatory options set. Have a look at the README.rst to get information about the other available options and their meaning."; 146 | 147 | console.log(help); 148 | var defaultNick = getUserName(); 149 | var defaultPath = getTimelineFile(); 150 | var defaultFollow = true; 151 | 152 | rl.question('➤ Please enter your desired nick: ( '+ defaultNick +' ) ', function(nick) { 153 | nick = nick || defaultNick; 154 | 155 | rl.question('➤ Please enter the desired location for your twtxt file: ( '+ defaultPath +' ) ', function(path) { 156 | path = path || defaultPath; 157 | 158 | rl.question('➤ Do you want to follow the twtxt news feed?: ( '+ (defaultFollow?'y':'n') + ' ) ', function(follow) { 159 | follow = follow || defaultFollow; 160 | debug('Thank you: ', nick); 161 | debug('Path: ', path); 162 | debug('Follow: ', follow); 163 | 164 | config = {}; 165 | config.user = nick; 166 | config.twtfile = path; 167 | config.limit_timeline = "20"; 168 | if (follow) { 169 | config.following = [ {"user" : "twtxt", "uri": "https://buckket.org/twtxt_news.txt" }]; 170 | rl.close(); 171 | debug(config); 172 | fs.writeFile(getConfigFile(), JSON.stringify(config)); 173 | console.log('✓ Created config file at ' + getConfigFile()); 174 | } 175 | 176 | }); 177 | 178 | }); 179 | 180 | }); 181 | 182 | } 183 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | getConfigFile: getConfigFile, 3 | getConfig: getConfig, 4 | getTimelineFile: getTimelineFile 5 | }; 6 | 7 | var debug = require('debug')('twtxt'); 8 | var getUserHome = require("../lib/helper").getUserHome; 9 | var program = require('commander'); 10 | 11 | 12 | /** 13 | * gets the default config file 14 | * @return {String} returns location of config file 15 | */ 16 | function getConfigFile() { 17 | var defaultConfigFile = 'twtxt.json'; 18 | var ret = getUserHome() + '/.config/' + defaultConfigFile; 19 | debug('default : ' + ret); 20 | return program.config || ret; 21 | } 22 | 23 | /** 24 | * gets a config 25 | * @return {String} A config 26 | */ 27 | function getConfig() { 28 | var configFile = getConfigFile(); 29 | debug('getting config from : ' + configFile); 30 | try { 31 | var config = require(configFile); 32 | debug('config : '); 33 | debug(config); 34 | return (config); 35 | } catch (e) { 36 | console.error(e); 37 | process.exit(-1); 38 | } 39 | } 40 | 41 | /** 42 | * returns location of timeline 43 | * @return {[String]} location of timeline 44 | */ 45 | function getTimelineFile() { 46 | var config = getConfig(); 47 | 48 | var HOME = getUserHome(); 49 | var timelineFile = HOME + '/' + filename; 50 | 51 | var filename = config.twtfile || timelineFile; 52 | 53 | return filename; 54 | } 55 | -------------------------------------------------------------------------------- /lib/file.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | writeConfig: writeConfig, 3 | writeTweet: writeTweet 4 | }; 5 | 6 | var debug = require('debug')('twtxt'); 7 | var fs = require('fs'); 8 | 9 | 10 | var getUserHome = require("../lib/helper").getUserHome; 11 | 12 | var getConfigFile = require("../lib/config").getConfigFile; 13 | var configFile = require("../lib/config").configFile; 14 | var getConfig = require("../lib/config").getConfig; 15 | var parsePost = require("../lib/parser").parsePost; 16 | var parseTimeline = require("../lib/parser").parseTimeline; 17 | 18 | /** 19 | * write a config file 20 | * @param {Object} config the config 21 | */ 22 | function writeConfig(config) { 23 | var configFile = getConfigFile(); 24 | debug('writing : '); 25 | debug(config); 26 | fs.writeFile(configFile, JSON.stringify(config), function(err) { 27 | if (err) { 28 | console.error(err); 29 | } 30 | }); 31 | } 32 | 33 | /** 34 | * writes a tweet 35 | * @param {String} twtfile file to write to 36 | * @param {[type]} output tweet 37 | */ 38 | function writeTweet(twtfile, output) { 39 | var config = getConfig(); 40 | parseTimeline(twtfile, config.user, function(err, posts) { 41 | str = ''; 42 | posts = posts.sort(function(a,b){ 43 | return new Date(a.time) > new Date(b.time); 44 | }); 45 | posts = posts.concat(parsePost(output, config.user)); 46 | var limit_timeline = config.limit_timeline || default_limit_timeline; 47 | if (posts.length > limit_timeline) { 48 | posts.splice(0, posts.length - limit_timeline); 49 | } 50 | 51 | for (var i = 0; i < posts.length; i++) { 52 | str += posts[i].time + '\t' + posts[i].description + '\n'; 53 | } 54 | 55 | debug('writing ' + twtfile + ' with ' + str); 56 | fs.writeFile(twtfile, str, function (err) { 57 | if (err) { 58 | console.error(err); 59 | } 60 | }); 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /lib/helper.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | hook: hook, 3 | getUserHome: getUserHome, 4 | getUserName: getUserName 5 | }; 6 | 7 | var childProcess = require('child_process'); 8 | var debug = require('debug')('twtxt'); 9 | 10 | /** 11 | * hook 12 | * @param {String} hook run a hook 13 | */ 14 | function hook(command) { 15 | var proc = childProcess.exec(command, function (error, stdout, stderr) { 16 | if (error) { 17 | console.error(error.stack); 18 | console.error('Error code: '+error.code); 19 | console.error('Signal received: '+error.signal); 20 | } 21 | debug('Child Process STDOUT: '+stdout); 22 | console.error('Child Process STDERR: '+stderr); 23 | }); 24 | 25 | proc.on('exit', function (code) { 26 | debug('Child process exited with exit code '+code); 27 | }); 28 | } 29 | 30 | /** 31 | * getUserHome 32 | * @return {String} home directory 33 | */ 34 | function getUserHome() { 35 | return process.env[(process.platform == 'win32') ? 'USERPROFILE' : 'HOME']; 36 | } 37 | 38 | /** 39 | * getUserName 40 | * @return {String} user name 41 | */ 42 | function getUserName() { 43 | return process.env.USER; 44 | } 45 | -------------------------------------------------------------------------------- /lib/parser.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | serializeTweet: serializeTweet, 3 | parseTimeline: parseTimeline, 4 | parsePost: parsePost, 5 | displayPosts: displayPosts 6 | }; 7 | 8 | var debug = require('debug')('twtxt'); 9 | var getUserHome = require("../lib/helper").getUserHome; 10 | var fs = require('fs'); 11 | var moment = require('moment'); 12 | var request = require('request'); 13 | 14 | 15 | /** 16 | * serializes a tweet 17 | * @param {String} description The desscription 18 | * @param {[type]} date Date 19 | * @return {String} formatted entry 20 | */ 21 | function serializeTweet(description, date) { 22 | var now = date || new Date().toISOString(); 23 | var output = now + "\t" + description + '\n'; 24 | debug('blogging : ' + output); 25 | return output; 26 | } 27 | 28 | /** 29 | * parses a post 30 | * @param {String} post A post 31 | * @param {String} nick A nick 32 | * @return {Object} A time, description and nick 33 | */ 34 | function parsePost(post, nick) { 35 | var vals = post.split('\t'); 36 | return { "time" : vals[0], "description" : vals[1], "nick" : nick }; 37 | } 38 | 39 | /** 40 | * displays the posts 41 | * @param {Array} posts the posts to display 42 | */ 43 | function displayPosts(posts) { 44 | posts = posts.sort(function(a,b) { 45 | var datea = new Date(a.time).getTime(); 46 | var dateb = new Date(b.time).getTime(); 47 | return datea > dateb ? 1 : -1; 48 | }); 49 | for (var i = 0; i < posts.length; i++) { 50 | console.log("➤ " + posts[i].nick + ' (' + moment(posts[i].time).fromNow() + ')'); 51 | console.log(posts[i].description); 52 | } 53 | } 54 | 55 | 56 | 57 | /** 58 | * parseTimeline 59 | * @param {String} uri which uri 60 | * @param {String} nick which nick 61 | * @param {Function} callback The callback 62 | */ 63 | function parseTimeline(uri, nick, callback) { 64 | if (uri && uri.indexOf('http') === 0) { 65 | request(uri, function (error, response, body) { 66 | if (!error && response.statusCode == 200) { 67 | var ret = []; 68 | var arr = body.split('\n'); 69 | for (var i = 0; i < arr.length; i++) { 70 | if (arr[i]) { 71 | ret.push(parsePost(arr[i], nick)); 72 | } 73 | } 74 | callback(null, (ret)); 75 | } else { 76 | callback(error); 77 | } 78 | }); 79 | } else { 80 | fs.readFile(uri, "utf-8", function(err, val) { 81 | if (err) { 82 | callback(err); 83 | } else { 84 | var ret = []; 85 | var arr = val.split('\n'); 86 | for (var i = 0; i < arr.length; i++) { 87 | if (arr[i]) { 88 | ret.push(parsePost(arr[i], nick)); 89 | } 90 | } 91 | callback(null, (ret)); 92 | } 93 | }); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twtxt", 3 | "version": "0.1.13", 4 | "description": "a decentralised, minimalist microblogging service for hackers", 5 | "main": "twtxt.js", 6 | "directories": { 7 | "test": "tests" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/webize/twtxt.git" 15 | }, 16 | "keywords": [ 17 | "twtxt", 18 | "solid" 19 | ], 20 | "author": "Melvin Carvalho ", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/webize/twtxt/issues" 24 | }, 25 | "homepage": "https://github.com/webize/twtxt#readme", 26 | "dependencies": { 27 | "commander": "^2.9.0", 28 | "debug": "^2.2.0", 29 | "moment": "^2.11.2", 30 | "request": "^2.69.0" 31 | }, 32 | "scripts": { 33 | "test": "mocha" 34 | }, 35 | "bin": { 36 | "twtxt": "bin/twtxt.js" 37 | }, 38 | "devDependencies": { 39 | "chai": "^3.5.0", 40 | "mocha": "^2.4.5" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/cli.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | var cli = require("../lib/cli"); 4 | 5 | /* test/my_test.js */ 6 | var expect = require('chai').expect; 7 | 8 | describe('Cli Functions', function () { 9 | 10 | describe('tweet', function() { 11 | it('tweet is a function', function () { 12 | expect( (cli.tweet)).to.be.a('function'); 13 | }); 14 | }); 15 | 16 | describe('follow', function() { 17 | it('follow is a function', function () { 18 | expect( (cli.follow)).to.be.a('function'); 19 | }); 20 | }); 21 | 22 | describe('unfollow', function() { 23 | it('unfollow is a function', function () { 24 | expect( (cli.unfollow)).to.be.a('function'); 25 | }); 26 | }); 27 | 28 | describe('following', function() { 29 | it('following is a function', function () { 30 | expect( (cli.following)).to.be.a('function'); 31 | }); 32 | }); 33 | 34 | describe('quickstart', function() { 35 | it('quickstart is a function', function () { 36 | expect( (cli.quickstart)).to.be.a('function'); 37 | }); 38 | }); 39 | 40 | describe('timeline', function() { 41 | it('timeline is a function', function () { 42 | expect( (cli.timeline)).to.be.a('function'); 43 | }); 44 | }); 45 | 46 | 47 | }); 48 | -------------------------------------------------------------------------------- /test/config.js: -------------------------------------------------------------------------------- 1 | var config = require("../lib/config"); 2 | 3 | /* test/my_test.js */ 4 | var expect = require('chai').expect; 5 | 6 | describe('Config Functions', function () { 7 | 8 | describe('getConfigFile', function() { 9 | it('getConfigFile is a function', function () { 10 | expect( (config.getConfigFile)).to.be.a('function'); 11 | }); 12 | }); 13 | 14 | describe('getConfig', function() { 15 | it('getConfig is a function', function () { 16 | expect( (config.getConfig)).to.be.a('function'); 17 | }); 18 | }); 19 | 20 | describe('getTimelineFile', function() { 21 | it('getTimelineFile is a function', function () { 22 | expect( (config.getTimelineFile)).to.be.a('function'); 23 | }); 24 | }); 25 | 26 | }); 27 | -------------------------------------------------------------------------------- /test/file.js: -------------------------------------------------------------------------------- 1 | var file = require("../lib/file"); 2 | 3 | /* test/my_test.js */ 4 | var expect = require('chai').expect; 5 | 6 | describe('File Functions', function () { 7 | 8 | describe('writeConfig', function() { 9 | it('writeConfig is a function', function () { 10 | expect( (file.writeConfig)).to.be.a('function'); 11 | }); 12 | }); 13 | 14 | describe('writeTweet', function() { 15 | it('writeTweet is a function', function () { 16 | expect( (file.writeTweet)).to.be.a('function'); 17 | }); 18 | }); 19 | 20 | }); 21 | -------------------------------------------------------------------------------- /test/helper.js: -------------------------------------------------------------------------------- 1 | var helper = require("../lib/helper"); 2 | 3 | /* test/my_test.js */ 4 | var expect = require('chai').expect; 5 | 6 | describe('Helper Functions', function () { 7 | 8 | describe('hook', function() { 9 | it('hook is a function', function () { 10 | expect( (helper.hook)).to.be.a('function'); 11 | }); 12 | }); 13 | 14 | describe('getUserHome', function() { 15 | it('getUserHome is a function', function () { 16 | expect( (helper.getUserHome)).to.be.a('function'); 17 | }); 18 | }); 19 | 20 | describe('getUserName', function() { 21 | it('getUserName is a function', function () { 22 | expect( (helper.getUserName)).to.be.a('function'); 23 | }); 24 | }); 25 | 26 | }); 27 | -------------------------------------------------------------------------------- /test/parser.js: -------------------------------------------------------------------------------- 1 | var parser = require("../lib/parser"); 2 | 3 | /* test/my_test.js */ 4 | var expect = require('chai').expect; 5 | 6 | describe('Parser Functions', function () { 7 | 8 | describe('serializeTweet', function() { 9 | it('serializeTweet is a function', function () { 10 | expect( (parser.serializeTweet)).to.be.a('function'); 11 | }); 12 | it('serializeTweet gives the right output', function () { 13 | expect(parser.serializeTweet('test', '2016-02-07T19:55:58.254Z')).to.equal('2016-02-07T19:55:58.254Z\ttest\n'); 14 | }); 15 | }); 16 | 17 | describe('parseTimeline', function() { 18 | it('parseTimeline is a function', function () { 19 | expect( (parser.parseTimeline)).to.be.a('function'); 20 | }); 21 | }); 22 | 23 | describe('parsePost', function() { 24 | it('parsePost is a function', function () { 25 | expect( (parser.parsePost)).to.be.a('function'); 26 | }); 27 | }); 28 | 29 | describe('displayPosts', function() { 30 | it('displayPosts is a function', function () { 31 | expect( (parser.displayPosts)).to.be.a('function'); 32 | }); 33 | }); 34 | 35 | }); 36 | -------------------------------------------------------------------------------- /twtxt.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // requires 4 | var fs = require('fs'); 5 | var program = require('commander'); 6 | var request = require('request'); 7 | var debug = require('debug')('twtxt'); 8 | var childProcess = require('child_process'); 9 | var readline = require('readline'); 10 | var moment = require('moment'); 11 | 12 | var error = debug('app:error'); 13 | 14 | // defaults 15 | var default_limit_timeline = 20; 16 | 17 | 18 | // helper functions 19 | 20 | /** 21 | * hook 22 | * @param {String} hook run a hook 23 | */ 24 | function hook(command) { 25 | var proc = childProcess.exec(command, function (error, stdout, stderr) { 26 | if (error) { 27 | console.error(error.stack); 28 | console.error('Error code: '+error.code); 29 | console.error('Signal received: '+error.signal); 30 | } 31 | debug('Child Process STDOUT: '+stdout); 32 | console.error('Child Process STDERR: '+stderr); 33 | }); 34 | 35 | proc.on('exit', function (code) { 36 | debug('Child process exited with exit code '+code); 37 | }); 38 | } 39 | 40 | 41 | 42 | // config functions 43 | 44 | /** 45 | * getUserHome 46 | * @return {String} home directory 47 | */ 48 | function getUserHome() { 49 | return process.env[(process.platform == 'win32') ? 'USERPROFILE' : 'HOME']; 50 | } 51 | 52 | /** 53 | * getUserName 54 | * @return {String} user name 55 | */ 56 | function getUserName() { 57 | return process.env.USER; 58 | } 59 | 60 | /** 61 | * gets the default config file 62 | * @return {String} returns location of config file 63 | */ 64 | function getConfigFile() { 65 | var defaultConfigFile = 'twtxt.json'; 66 | var ret = getUserHome() + '/.config/' + defaultConfigFile; 67 | debug('default : ' + ret); 68 | return program.config || ret; 69 | } 70 | 71 | /** 72 | * gets a config 73 | * @return {String} A config 74 | */ 75 | function getConfig() { 76 | var configFile = getConfigFile(); 77 | debug('getting config from : ' + configFile); 78 | try { 79 | var config = require(configFile); 80 | debug('config : '); 81 | debug(config); 82 | return (config); 83 | } catch (e) { 84 | console.error(e); 85 | process.exit(-1); 86 | } 87 | } 88 | 89 | /** 90 | * write a config file 91 | * @param {Object} config the config 92 | */ 93 | function writeConfig(config) { 94 | var configFile = getConfigFile(); 95 | debug('writing : '); 96 | debug(config); 97 | fs.writeFile(configFile, JSON.stringify(config), function(err) { 98 | if (err) { 99 | console.error(err); 100 | } 101 | }); 102 | } 103 | 104 | // twtxt functions 105 | 106 | /** 107 | * returns location of timeline 108 | * @return {[String]} location of timeline 109 | */ 110 | function getTimelineFile() { 111 | var config = getConfig(); 112 | 113 | var HOME = getUserHome(); 114 | var timelineFile = HOME + '/' + filename; 115 | 116 | var filename = config.twtfile || timelineFile; 117 | 118 | return filename; 119 | } 120 | 121 | /** 122 | * serializes a tweet 123 | * @param {String} description The desscription 124 | * @param {[type]} date Date 125 | * @return {String} formatted entry 126 | */ 127 | function serializeTweet(description, date) { 128 | var now = date || new Date().toISOString(); 129 | var output = now + "\t" + description + '\n'; 130 | debug('blogging : ' + output); 131 | return output; 132 | } 133 | 134 | /** 135 | * parseTimeline 136 | * @param {String} uri which uri 137 | * @param {String} nick which nick 138 | * @param {Function} callback The callback 139 | */ 140 | function parseTimeline(uri, nick, callback) { 141 | if (uri && uri.indexOf('http') === 0) { 142 | request(uri, function (error, response, body) { 143 | if (!error && response.statusCode == 200) { 144 | var ret = []; 145 | var arr = body.split('\n'); 146 | for (var i = 0; i < arr.length; i++) { 147 | if (arr[i]) { 148 | ret.push(parsePost(arr[i], nick)); 149 | } 150 | } 151 | callback(null, (ret)); 152 | } else { 153 | callback(error); 154 | } 155 | }); 156 | } else { 157 | fs.readFile(uri, "utf-8", function(err, val) { 158 | if (err) { 159 | callback(err); 160 | } else { 161 | var ret = []; 162 | var arr = val.split('\n'); 163 | for (var i = 0; i < arr.length; i++) { 164 | if (arr[i]) { 165 | ret.push(parsePost(arr[i], nick)); 166 | } 167 | } 168 | callback(null, (ret)); 169 | } 170 | }); 171 | } 172 | } 173 | 174 | /** 175 | * parses a post 176 | * @param {String} post A post 177 | * @param {String} nick A nick 178 | * @return {Object} A time, description and nick 179 | */ 180 | function parsePost(post, nick) { 181 | var vals = post.split('\t'); 182 | return { "time" : vals[0], "description" : vals[1], "nick" : nick }; 183 | } 184 | 185 | /** 186 | * writes a tweet 187 | * @param {String} twtfile file to write to 188 | * @param {[type]} output tweet 189 | */ 190 | function writeTweet(twtfile, output) { 191 | var config = getConfig(); 192 | parseTimeline(twtfile, config.user, function(err, posts) { 193 | str = ''; 194 | posts = posts.sort(function(a,b){ 195 | return new Date(a.time) > new Date(b.time); 196 | }); 197 | posts = posts.concat(parsePost(output, config.user)); 198 | var limit_timeline = config.limit_timeline || default_limit_timeline; 199 | if (posts.length > limit_timeline) { 200 | posts.splice(0, posts.length - limit_timeline); 201 | } 202 | 203 | for (var i = 0; i < posts.length; i++) { 204 | str += posts[i].time + '\t' + posts[i].description + '\n'; 205 | } 206 | 207 | debug('writing ' + twtfile + ' with ' + str); 208 | fs.writeFile(twtfile, str, function (err) { 209 | if (err) { 210 | console.error(err); 211 | } 212 | }); 213 | }); 214 | } 215 | 216 | /** 217 | * Append a new tweet to your twtxt file. 218 | * @return {[type]} [description] 219 | */ 220 | function tweet(description) { 221 | // init 222 | var config = getConfig(); 223 | var twtfile = getTimelineFile(); 224 | output = serializeTweet(description); 225 | 226 | writeTweet(twtfile, output); 227 | if (config.post_tweet_hook) { 228 | hook(config.post_tweet_hook); 229 | } 230 | } 231 | 232 | /** 233 | * displays the posts 234 | * @param {Array} posts the posts to display 235 | */ 236 | function displayPosts(posts) { 237 | posts = posts.sort(function(a,b){ 238 | return new Date(a.time) > new Date(b.time); 239 | }); 240 | for (var i = 0; i < posts.length; i++) { 241 | console.log("➤ " + posts[i].nick + ' (' + moment(posts[i].time).fromNow() + ')'); 242 | console.log(posts[i].description); 243 | } 244 | } 245 | 246 | /** 247 | * follow a user 248 | * @param {String} user the user to follow 249 | */ 250 | function follow(user, uri) { 251 | var config = getConfig(); 252 | config.following = config.following || []; 253 | config.following.push( {"user" : user, "uri": uri} ); 254 | writeConfig(config); 255 | debug(config); 256 | console.log("✓ You’re now following "+user+"."); 257 | } 258 | 259 | /** 260 | * list following 261 | */ 262 | function following() { 263 | var config = getConfig(); 264 | config.following = config.following || []; 265 | for (var i = 0; i < config.following.length; i++) { 266 | console.log('➤ ' + config.following[i].user + ' @ ' + config.following[i].uri); 267 | } 268 | } 269 | 270 | /** 271 | * unfollow a user 272 | * @param {String} user the user to follow 273 | */ 274 | function unfollow(user) { 275 | var config = getConfig(); 276 | config.following = config.following || []; 277 | for (var i = 0; i < config.following.length; i++) { 278 | if (config.following[i] && config.following[i].user === user ) { 279 | config.following.splice(i,1); 280 | } 281 | } 282 | writeConfig(config); 283 | debug(config); 284 | console.log("✓ You’ve unfollowed "+user+"."); 285 | } 286 | 287 | /** 288 | * list following 289 | */ 290 | function timeline() { 291 | var config = getConfig(); 292 | var nick = config.user || 'you'; 293 | var following = config.following || []; 294 | var fetched = 0; 295 | var sources = 1 + following.length; 296 | var posts = []; 297 | 298 | function callback(err, val) { 299 | fetched++; 300 | if (err) { 301 | console.error(err); 302 | } else { 303 | posts = posts.concat(val); 304 | if (fetched === sources) { 305 | displayPosts(posts); 306 | } 307 | } 308 | } 309 | 310 | // local 311 | var twtfile = getTimelineFile(); 312 | parseTimeline(twtfile, nick, callback); 313 | 314 | // remote 315 | for (var i = 0; i < following.length; i++) { 316 | 317 | var uri = following[i].uri; 318 | parseTimeline(uri, following[i].user, callback); 319 | 320 | } 321 | 322 | } 323 | 324 | /** 325 | * quick start 326 | */ 327 | function quickstart() { 328 | 329 | var rl = readline.createInterface({ 330 | input: process.stdin, 331 | output: process.stdout 332 | }); 333 | 334 | var help = "This wizard will generate a basic configuration file for twtxt with all mandatory options set. Have a look at the README.rst to get information about the other available options and their meaning."; 335 | 336 | console.log(help); 337 | var defaultNick = getUserName(); 338 | var defaultPath = getTimelineFile(); 339 | var defaultFollow = true; 340 | 341 | rl.question('➤ Please enter your desired nick: ( '+ defaultNick +' ) ', function(nick) { 342 | nick = nick || defaultNick; 343 | 344 | rl.question('➤ Please enter the desired location for your twtxt file: ( '+ defaultPath +' ) ', function(path) { 345 | path = path || defaultPath; 346 | 347 | rl.question('➤ Do you want to follow the twtxt news feed?: ( '+ (defaultFollow?'y':'n') + ' ) ', function(follow) { 348 | follow = follow || defaultFollow; 349 | debug('Thank you: ', nick); 350 | debug('Path: ', path); 351 | debug('Follow: ', follow); 352 | 353 | config = {}; 354 | config.user = nick; 355 | config.twtfile = path; 356 | config.limit_timeline = "20"; 357 | if (follow) { 358 | config.following = [ {"user" : "twtxt", "uri": "https://buckket.org/twtxt_news.txt" }]; 359 | rl.close(); 360 | debug(config); 361 | fs.writeFile(getConfigFile(), JSON.stringify(config)); 362 | console.log('✓ Created config file at ' + getConfigFile()); 363 | } 364 | 365 | }); 366 | 367 | }); 368 | 369 | }); 370 | 371 | } 372 | 373 | // cli functions 374 | /** 375 | * run as bin 376 | */ 377 | function bin() { 378 | var MAXCHARS = 140; 379 | var user; 380 | var uri; 381 | 382 | program 383 | .arguments(' [arg] [uri]') 384 | .option('-c, --config ', 'Specify a custom config file location.') 385 | .option('-v, --verbose', 'Enable verbose output for deubgging purposes') 386 | .option('--version', 'Shows the version and exit.') 387 | .action(function (cmd, arg, uri) { 388 | cmdValue = cmd; 389 | argValue = arg; 390 | uriValue = uri; 391 | }); 392 | 393 | program.parse(process.argv); 394 | 395 | if (typeof cmdValue === 'undefined') { 396 | console.error('no command given!'); 397 | process.exit(1); 398 | } 399 | if (program.verbose) { 400 | process.env.DEBUG = 'twtxt'; 401 | debug = console.log; 402 | } 403 | debug('command:', cmdValue); 404 | debug('arg: ', argValue || "no arg given"); 405 | debug('uri: ', uriValue || "no uri given"); 406 | debug('verbose: ' + program.verbose); 407 | 408 | if (cmdValue === 'tweet') { 409 | var description = argValue; 410 | 411 | if (!description) { 412 | console.error('Usage: twtxt tweet '); 413 | process.exit(-1); 414 | } 415 | 416 | if (description && description.length > MAXCHARS) { 417 | console.error('Maximum tweet length : ' + MAXCHARS); 418 | process.exit(-1); 419 | } 420 | 421 | tweet(description); 422 | } else if (cmdValue === 'follow') { 423 | user = argValue; 424 | uri = uriValue; 425 | 426 | if (!user) { 427 | console.error('Usage: twtxt follow '); 428 | process.exit(-1); 429 | } 430 | 431 | if (!uri) { 432 | console.error('Usage: twtxt follow '); 433 | process.exit(-1); 434 | } 435 | 436 | follow(user, uri); 437 | 438 | } else if (cmdValue === 'unfollow') { 439 | user = argValue; 440 | 441 | if (!user) { 442 | console.error('Usage: twtxt unfollow '); 443 | process.exit(-1); 444 | } 445 | 446 | unfollow(user); 447 | 448 | } else if (cmdValue === 'following') { 449 | following(); 450 | 451 | } else if (cmdValue === 'timeline') { 452 | timeline(); 453 | 454 | } else if (cmdValue === 'quickstart') { 455 | quickstart(); 456 | 457 | } else { 458 | console.error(cmdValue + ' : not recognized'); 459 | } 460 | 461 | } 462 | 463 | 464 | // If one import this file, this is a module, otherwise a library 465 | if (require.main === module) { 466 | bin(process.argv); 467 | } 468 | 469 | module.exports = { 470 | tweet: tweet, 471 | follow: follow, 472 | unfollow: unfollow, 473 | following: following, 474 | timeline: timeline 475 | }; 476 | --------------------------------------------------------------------------------