Skip to content

Commit

Permalink
Add a GitHub saver
Browse files Browse the repository at this point in the history
Fixes #3890

I think it would be useful to have a simple tutorial for setting up saving via GitHub pages.
  • Loading branch information
Jermolene committed Apr 8, 2019
1 parent 662ae91 commit aa5eaa9
Show file tree
Hide file tree
Showing 8 changed files with 307 additions and 2 deletions.
8 changes: 8 additions & 0 deletions core/language/en-GB/ControlPanel.multids
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ Saving/DownloadSaver/Hint: These settings apply to the HTML5-compatible download
Saving/General/Caption: General
Saving/General/Hint: These settings apply to all the loaded savers
Saving/Hint: Settings used for saving the entire TiddlyWiki as a single file via a saver module
Saving/GitHub/Branch: Target branch for saving (defaults to `master`)
Saving/GitHub/Caption: ~GitHub Saver
Saving/GitHub/Description: These settings are only used when saving to ~GitHub
Saving/GitHub/Filename: Filename of target file (e.g. `index.html`)
Saving/GitHub/Password: Password, OAUTH token, or personal access token
Saving/GitHub/Path: Path to target file (e.g. `/wiki/`)
Saving/GitHub/Repo: Target repository (e.g. `Jermolene/TiddlyWiki5`)
Saving/GitHub/UserName: Username
Saving/TiddlySpot/Advanced/Heading: Advanced Settings
Saving/TiddlySpot/BackupDir: Backup Directory
Saving/TiddlySpot/Backups: Backups
Expand Down
117 changes: 117 additions & 0 deletions core/modules/savers/github.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*\
title: $:/core/modules/savers/github.js
type: application/javascript
module-type: saver
Saves wiki by pushing a commit to the GitHub v3 REST API
\*/
(function(){

/*jslint node: true, browser: true */
/*global $tw: false */
"use strict";

var base64utf8 = require("$:/core/modules/utils/base64-utf8/base64-utf8.module.js");

/*
Select the appropriate saver module and set it up
*/
var GitHubSaver = function(wiki) {
this.wiki = wiki;
};

GitHubSaver.prototype.save = function(text,method,callback) {
var self = this,
username = this.wiki.getTiddlerText("$:/GitHub/Username"),
password = $tw.utils.getPassword("github"),
repo = this.wiki.getTiddlerText("$:/GitHub/Repo"),
path = this.wiki.getTiddlerText("$:/GitHub/Path"),
filename = this.wiki.getTiddlerText("$:/GitHub/Filename"),
branch = this.wiki.getTiddlerText("$:/GitHub/Branch") || "master",
headers = {
"Accept": "application/vnd.github.v3+json",
"Content-Type": "application/json;charset=UTF-8",
"Authorization": "Basic " + window.btoa(username + ":" + password)
};
// Make sure the path start and ends with a slash
if(path.substring(0,1) !== "/") {
path = "/" + path;
}
if(path.substring(path.length - 1) !== "/") {
path = path + "/";
}
// Compose the base URI
var uri = "https://api.github.com/repos/" + repo + "/contents" + path;
// Bail if we don't have everything we need
if(!username || !password || !repo || !path || !filename) {
return false;
}
// Perform a get request to get the details (inc shas) of files in the same path as our file
$tw.utils.httpRequest({
url: uri,
type: "GET",
headers: headers,
data: {
ref: branch
},
callback: function(err,getResponseDataJson,xhr) {
if(err) {
return callback(err);
}
var getResponseData = JSON.parse(getResponseDataJson),
sha = "";
$tw.utils.each(getResponseData,function(details) {
if(details.name === filename) {
sha = details.sha;
}
});
var data = {
message: "Saved by TiddlyWiki",
content: base64utf8.base64.encode.call(base64utf8,text),
branch: branch,
sha: sha
};
// Perform a PUT request to save the file
$tw.utils.httpRequest({
url: uri + filename,
type: "PUT",
headers: headers,
data: JSON.stringify(data),
callback: function(err,putResponseDataJson,xhr) {
if(err) {
return callback(err);
}
var putResponseData = JSON.parse(putResponseDataJson);
callback(null);
}
});
}
});
return true;
};

/*
Information about this saver
*/
GitHubSaver.prototype.info = {
name: "github",
priority: 2000,
capabilities: ["save", "autosave"]
};

/*
Static method that returns true if this saver is capable of working
*/
exports.canSave = function(wiki) {
return true;
};

/*
Create an instance of this saver
*/
exports.create = function(wiki) {
return new GitHubSaver(wiki);
};

})();
124 changes: 124 additions & 0 deletions core/modules/utils/base64-utf8/base64-utf8.module.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// From https://gist.github.com/Nijikokun/5192472
//
// UTF8 Module
//
// Cleaner and modularized utf-8 encoding and decoding library for javascript.
//
// copyright: MIT
// author: Nijiko Yonskai, @nijikokun, nijikokun@gmail.com
(function (name, definition, context, dependencies) {
if (typeof context['module'] !== 'undefined' && context['module']['exports']) { if (dependencies && context['require']) { for (var i = 0; i < dependencies.length; i++) context[dependencies[i]] = context['require'](dependencies[i]); } context['module']['exports'] = definition.apply(context); }
else if (typeof context['define'] !== 'undefined' && context['define'] === 'function' && context['define']['amd']) { define(name, (dependencies || []), definition); }
else { context[name] = definition.apply(context); }
})('utf8', function () {
return {
encode: function (string) {
if (typeof string !== 'string') return string;
else string = string.replace(/\r\n/g, "\n");
var output = "", i = 0, charCode;

for (i; i < string.length; i++) {
charCode = string.charCodeAt(i);

if (charCode < 128)
output += String.fromCharCode(charCode);
else if ((charCode > 127) && (charCode < 2048))
output += String.fromCharCode((charCode >> 6) | 192),
output += String.fromCharCode((charCode & 63) | 128);
else
output += String.fromCharCode((charCode >> 12) | 224),
output += String.fromCharCode(((charCode >> 6) & 63) | 128),
output += String.fromCharCode((charCode & 63) | 128);
}

return output;
},

decode: function (string) {
if (typeof string !== 'string') return string;
var output = "", i = 0, charCode = 0;

while (i < string.length) {
charCode = string.charCodeAt(i);

if (charCode < 128)
output += String.fromCharCode(charCode),
i++;
else if ((charCode > 191) && (charCode < 224))
output += String.fromCharCode(((charCode & 31) << 6) | (string.charCodeAt(i + 1) & 63)),
i += 2;
else
output += String.fromCharCode(((charCode & 15) << 12) | ((string.charCodeAt(i + 1) & 63) << 6) | (string.charCodeAt(i + 2) & 63)),
i += 3;
}

return output;
}
};
}, this);

// Base64 Module
//
// Cleaner, modularized and properly scoped base64 encoding and decoding module for strings.
//
// copyright: MIT
// author: Nijiko Yonskai, @nijikokun, nijikokun@gmail.com
(function (name, definition, context, dependencies) {
if (typeof context['module'] !== 'undefined' && context['module']['exports']) { if (dependencies && context['require']) { for (var i = 0; i < dependencies.length; i++) context[dependencies[i]] = context['require'](dependencies[i]); } context['module']['exports'] = definition.apply(context); }
else if (typeof context['define'] !== 'undefined' && context['define'] === 'function' && context['define']['amd']) { define(name, (dependencies || []), definition); }
else { context[name] = definition.apply(context); }
})('base64', function (utf8) {
var $this = this;
var $utf8 = utf8 || this.utf8;
var map = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";

return {
encode: function (input) {
if (typeof $utf8 === 'undefined') throw { error: "MissingMethod", message: "UTF8 Module is missing." };
if (typeof input !== 'string') return input;
else input = $utf8.encode(input);
var output = "", a, b, c, d, e, f, g, i = 0;

while (i < input.length) {
a = input.charCodeAt(i++);
b = input.charCodeAt(i++);
c = input.charCodeAt(i++);
d = a >> 2;
e = ((a & 3) << 4) | (b >> 4);
f = ((b & 15) << 2) | (c >> 6);
g = c & 63;

if (isNaN(b)) f = g = 64;
else if (isNaN(c)) g = 64;

output += map.charAt(d) + map.charAt(e) + map.charAt(f) + map.charAt(g);
}

return output;
},

decode: function (input) {
if (typeof $utf8 === 'undefined') throw { error: "MissingMethod", message: "UTF8 Module is missing." };
if (typeof input !== 'string') return input;
else input = input.replace(/[^A-Za-z0-9\+\/\=]/g, "");
var output = "", a, b, c, d, e, f, g, i = 0;

while (i < input.length) {
d = map.indexOf(input.charAt(i++));
e = map.indexOf(input.charAt(i++));
f = map.indexOf(input.charAt(i++));
g = map.indexOf(input.charAt(i++));

a = (d << 2) | (e >> 4);
b = ((e & 15) << 4) | (f >> 2);
c = ((f & 3) << 6) | g;

output += String.fromCharCode(a);
if (f != 64) output += String.fromCharCode(b);
if (g != 64) output += String.fromCharCode(c);
}

return $utf8.decode(output);
}
}
}, this, [ "utf8" ]);
14 changes: 14 additions & 0 deletions core/modules/utils/base64-utf8/tiddlywiki.files
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"tiddlers": [
{
"file": "base64-utf8.module.js",
"fields": {
"type": "application/javascript",
"title": "$:/core/modules/utils/base64-utf8/base64-utf8.module.js",
"module-type": "library"
},
"prefix": "(function(){",
"suffix": "}).call(exports);"
}
]
}
14 changes: 14 additions & 0 deletions core/ui/ControlPanel/Saving/GitHub.tid
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
title: $:/core/ui/ControlPanel/Saving/GitHub
tags: $:/tags/ControlPanel/Saving
caption: {{$:/language/ControlPanel/Saving/GitHub/Caption}}

\define lingo-base() $:/language/ControlPanel/Saving/GitHub/

<<lingo Description>>

|<<lingo UserName>> |<$edit-text tiddler="$:/GitHub/Username" default="" tag="input"/> |
|<<lingo Password>> |<$password name="github"/> |
|<<lingo Repo>> |<$edit-text tiddler="$:/GitHub/Repo" default="" tag="input"/> |
|<<lingo Branch>> |<$edit-text tiddler="$:/GitHub/Branch" default="" tag="input"/> |
|<<lingo Path>> |<$edit-text tiddler="$:/GitHub/Path" default="" tag="input"/> |
|<<lingo Filename>> |<$edit-text tiddler="$:/GitHub/Filename" default="" tag="input"/> |
2 changes: 2 additions & 0 deletions editions/prerelease/tiddlers/Release 5.1.20.tid
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Added serveral new string operators: [[length|length Operator]], [[uppercase|upp

Added several new palettes. See the [[palette manager|$:/core/ui/ControlPanel/Palette]].

Added new [[GitHub saver|Saving to GitHub]].

!! Plugin Improvements

New and improved plugins:
Expand Down
6 changes: 4 additions & 2 deletions editions/tw5.com/tiddlers/definitions/GitHub.tid
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
created: 20140910212609354
modified: 20140910212725412
modified: 20190408173002622
tags: Definitions
title: GitHub
type: text/vnd.tiddlywiki
Expand All @@ -8,4 +8,6 @@ GitHub is a hosting service for distributed projects that use git as their versi

The code and documentation of TiddlyWiki is hosted on GitHub at:

https://github.com/Jermolene/TiddlyWiki5
https://github.com/Jermolene/TiddlyWiki5

GitHub also offer a free web hosting service called [[GitHub Pages|https://pages.github.com/]] that can be used directly from the single file configuration. See [[Saving to GitHub]].
24 changes: 24 additions & 0 deletions editions/tw5.com/tiddlers/saving/Saving to GitHub.tid
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
created: 20190408173002622
modified: 20190408173002622
tags: Saving Android Chrome Firefox InternetExplorer iOS Linux Mac Opera Safari Windows
title: Saving to GitHub
type: text/vnd.tiddlywiki
delivery: Service
method: save
caption: GitHub Saver
description: Save changes directly to a GitHub repository

TiddlyWiki can save changes directly to a GitHub repository in the single file configuration.

Saving to GitHub is configured in the [[$:/ControlPanel]] in the ''~GitHub Saver'' tab under the ''Saving'' tab. The following settings are supported:

* ''Username'' - (mandatory) the username for the GitHub account used for saving changes
* ''Password'' - (mandatory) the password, OAUTH token or personal access token for the specified account. Note that GitHub permits [[several different mechanisms|https://developer.github.com/v3/#authentication]] for authentication
* ''Repository'' - (mandatory) the name of the GitHub repository. Both the owner name and the repository name must be specified. For example `Jermolene/TiddlyWiki5`
* ''Branch'' - (optional) the name of the branch to be used within the GitHub repository. Defaults to `master`
* ''Path'' - (optional) the path to the target file. Defaults to `/`
* ''Filename'' - (mandatory) the filename of the target file

Notes

* The GitHub password is stored persistently in browser local storage. Be sure to clear the password if using a shared machine. Using a [[personal access token|]] for authentication offers an extra layer of security: if the access token is accidentally exposed it can be revoked without needing to reset the account password

2 comments on commit aa5eaa9

@pesho-ivanov
Copy link

@pesho-ivanov pesho-ivanov commented on aa5eaa9 Aug 3, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EDIT: I cannot reliably reproduce the following issue. One guess is that it happens when the save button is clicked twice.

I copied the pre-release build in order to test the GitHub saver by this commit. All seems to function correctly with one annoyance:

After every successful saving to my gh repo (should be enough to reproduce the error), the following floating box appears: Error while saving: XMLHttpRequest error code: 409

Error:

{
  "message": "Not Found",
  "documentation_url": "https://developer.github.com/v3"
}

GitHub saver config:

Username: pesho-ivanov
Personal access token: xxxxxxxxxxxx (works)
Target repository: pesho-ivanov/pesho-ivanov.github.io
Target branch for saving: master
Path to target file: /
Filename of target file: index.html
Server API URL: https://api.github.com

Request URL:https://api.github.com/repos/pesho-ivanov/pesho-ivanov.github.io/contents/index.html
Request method:PUT
Remote address:140.82.118.5:443
Status code:
409
Version:HTTP/1.1
Referrer Policy:no-referrer-when-downgrade

Response headers:

HTTP/1.1 409 Conflict
Date: Sat, 03 Aug 2019 21:27:40 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 184
Server: GitHub.com
Status: 409 Conflict
X-RateLimit-Limit: 5000
X-RateLimit-Remaining: 4913
X-RateLimit-Reset: 1564870160
X-OAuth-Scopes: admin:repo_hook, repo
X-Accepted-OAuth-Scopes: 
X-GitHub-Media-Type: github.v3; format=json
Access-Control-Expose-Headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type
Access-Control-Allow-Origin: *
Strict-Transport-Security: max-age=31536000; includeSubdomains; preload
X-Frame-Options: deny
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Referrer-Policy: origin-when-cross-origin, strict-origin-when-cross-origin
Content-Security-Policy: default-src 'none'
X-GitHub-Request-Id: C712:284F0:4CC0EFB:6007152:5D45FC4A

Request headers

Host: api.github.com
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0
Accept: application/vnd.github.v3+json
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: https://pesho-ivanov.github.io/
Content-Type: application/json;charset=UTF-8
Authorization: Basic cGVzaG8taXZhbm92OjEwZDM4MTk3YWUzZjg0YjhlOTRiYTVmZGJmODU3ODFkMjBiOTA5ZTg=
X-Requested-With: TiddlyWiki
Content-Length: 3127289
Origin: https://pesho-ivanov.github.io
Connection: keep-alive

@Jermolene
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @pesho-ivanov that does indeed sound like a timing issue. This stuff is quite new so your help with testing is much appreciated.

Please sign in to comment.