├── spec ├── fixtures │ └── data │ │ └── pages │ │ ├── empty-page │ │ ├── simple-page │ │ ├── duplicate-paragraphs │ │ └── multiple-paragraphs ├── favicon.png ├── fast ├── ReadMe.md ├── favicon_spec.rb ├── slug_reference.rb ├── slug_reference.coffee ├── spec_helper.rb ├── stores │ └── couch_spec.rb ├── server_helpers_spec.rb ├── page_spec.rb ├── js │ └── jquery.simulate.drag-sortable.js ├── server_spec.rb └── integration_spec.rb ├── Procfile ├── server ├── sinatra │ ├── config.ru │ ├── views │ │ ├── view.haml │ │ ├── oops.haml │ │ ├── page.haml │ │ ├── static.html │ │ └── layout.haml │ ├── random_id.rb │ ├── stores │ │ ├── all.rb │ │ ├── ReadMe.md │ │ ├── store.rb │ │ ├── file.rb │ │ └── couch.rb │ ├── favicon.rb │ ├── server_helpers.rb │ ├── page.rb │ ├── ReadMe.md │ └── server.rb └── Wikiduino │ ├── ReadMe.md │ └── Wikiduino.ino ├── client ├── client.coffee ├── crosses.png ├── images │ ├── oops.jpg │ ├── noise.png │ └── external-link-ltr-icon.png ├── theme │ ├── stoneSeamless.jpg │ └── granite.css ├── twitter-maintainance.jpg ├── js │ ├── images │ │ ├── ui-icons_222222_256x240.png │ │ └── ui-bg_glass_65_ffffff_1x400.png │ ├── jquery.ui.touch-punch.min.js │ ├── d3 │ │ ├── d3.csv.js │ │ └── d3.behavior.js │ ├── jquery-migrate-1.1.1.min.js │ ├── underscore-min.js │ └── jquery.ie.cors.js ├── build.bat ├── testclient.coffee ├── build-test.bat ├── runtests.html ├── package.json ├── builder.pl ├── Gruntfile.js ├── mkplugin.sh ├── ReadMe.md ├── test │ └── mocha.css └── style.css ├── default-data ├── status │ ├── favicon.png │ └── local-identity └── pages │ └── welcome-visitors ├── Rakefile.rb ├── browser-extensions ├── Chrome │ └── Wiki │ │ ├── wiki_16.png │ │ ├── wiki_19.png │ │ ├── wiki_128.png │ │ ├── background.html │ │ ├── options.js │ │ ├── manifest.json │ │ ├── options.html │ │ ├── activity.js │ │ ├── runtime.js │ │ └── main.js └── README ├── .gitignore ├── Dockerfile ├── Gemfile ├── .htaccess ├── mit-license.txt ├── Windows.md ├── ReadMe.md └── Gemfile.lock /spec/fixtures/data/pages/empty-page: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec thin start -R server/sinatra/config.ru -p $PORT -------------------------------------------------------------------------------- /server/sinatra/config.ru: -------------------------------------------------------------------------------- 1 | require File.expand_path('../server', __FILE__) 2 | run Controller 3 | -------------------------------------------------------------------------------- /spec/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WardCunningham/Smallest-Federated-Wiki/HEAD/spec/favicon.png -------------------------------------------------------------------------------- /client/client.coffee: -------------------------------------------------------------------------------- 1 | window.wiki = require('wiki-client/lib/wiki') 2 | require('wiki-client/lib/legacy') 3 | 4 | -------------------------------------------------------------------------------- /client/crosses.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WardCunningham/Smallest-Federated-Wiki/HEAD/client/crosses.png -------------------------------------------------------------------------------- /client/images/oops.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WardCunningham/Smallest-Federated-Wiki/HEAD/client/images/oops.jpg -------------------------------------------------------------------------------- /client/images/noise.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WardCunningham/Smallest-Federated-Wiki/HEAD/client/images/noise.png -------------------------------------------------------------------------------- /server/sinatra/views/view.haml: -------------------------------------------------------------------------------- 1 | - pages.each do |page_tuple| 2 | .page{:id => page_tuple[:id], 'data-site' => page_tuple[:site]} -------------------------------------------------------------------------------- /client/theme/stoneSeamless.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WardCunningham/Smallest-Federated-Wiki/HEAD/client/theme/stoneSeamless.jpg -------------------------------------------------------------------------------- /client/twitter-maintainance.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WardCunningham/Smallest-Federated-Wiki/HEAD/client/twitter-maintainance.jpg -------------------------------------------------------------------------------- /default-data/status/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WardCunningham/Smallest-Federated-Wiki/HEAD/default-data/status/favicon.png -------------------------------------------------------------------------------- /server/sinatra/random_id.rb: -------------------------------------------------------------------------------- 1 | module RandomId 2 | def self.generate 3 | (0..15).collect{(rand*16).to_i.to_s(16)}.join 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /Rakefile.rb: -------------------------------------------------------------------------------- 1 | require 'rspec/core/rake_task' 2 | 3 | desc "Run all RSpec tests" 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /browser-extensions/Chrome/Wiki/wiki_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WardCunningham/Smallest-Federated-Wiki/HEAD/browser-extensions/Chrome/Wiki/wiki_16.png -------------------------------------------------------------------------------- /browser-extensions/Chrome/Wiki/wiki_19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WardCunningham/Smallest-Federated-Wiki/HEAD/browser-extensions/Chrome/Wiki/wiki_19.png -------------------------------------------------------------------------------- /client/images/external-link-ltr-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WardCunningham/Smallest-Federated-Wiki/HEAD/client/images/external-link-ltr-icon.png -------------------------------------------------------------------------------- /browser-extensions/Chrome/Wiki/wiki_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WardCunningham/Smallest-Federated-Wiki/HEAD/browser-extensions/Chrome/Wiki/wiki_128.png -------------------------------------------------------------------------------- /client/js/images/ui-icons_222222_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WardCunningham/Smallest-Federated-Wiki/HEAD/client/js/images/ui-icons_222222_256x240.png -------------------------------------------------------------------------------- /client/js/images/ui-bg_glass_65_ffffff_1x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WardCunningham/Smallest-Federated-Wiki/HEAD/client/js/images/ui-bg_glass_65_ffffff_1x400.png -------------------------------------------------------------------------------- /spec/fast: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | fast_tests = Dir[ "spec/**/*_spec.rb" ] - %w[ spec/integration_spec.rb ] 3 | system "bundle exec rspec --color --format nested #{fast_tests.join(' ')}" 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.swp 3 | *.iml 4 | .idea/ 5 | .sass-cache 6 | .rvmrc 7 | /data 8 | /spec/data 9 | /client/plugins/ 10 | /client/chart/ 11 | /client/garden/ 12 | node_modules 13 | npm-debug.log -------------------------------------------------------------------------------- /server/sinatra/stores/all.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('store', File.dirname(__FILE__)) 2 | require File.expand_path('file', File.dirname(__FILE__)) 3 | require File.expand_path('couch', File.dirname(__FILE__)) 4 | -------------------------------------------------------------------------------- /client/build.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | :: 3 | :: Used on Windows to build client.js as npm start and test don't work! 4 | :: 5 | 6 | echo "Building client.js" 7 | 8 | .\node_modules\.bin\browserify.cmd client.coffee -o client.js 9 | -------------------------------------------------------------------------------- /server/sinatra/views/oops.haml: -------------------------------------------------------------------------------- 1 | %body{:style => 'font-family: "Helvetica Neue", helvetica, Verdana, Arial, Sans;'} 2 | %center 3 | %img{:src => '/images/oops.jpg'} 4 | %h1{:style => 'width:400; color:#f10'} 5 | = message -------------------------------------------------------------------------------- /default-data/status/local-identity: -------------------------------------------------------------------------------- 1 | { 2 | "site": "fw.example.com", 3 | "title": "New Simplest Federated Wiki Install", 4 | "root": "welcome-visitors", 5 | "owner": "example.com", 6 | "launch": "1309112042" 7 | } 8 | -------------------------------------------------------------------------------- /spec/ReadMe.md: -------------------------------------------------------------------------------- 1 | Automated Tests 2 | =============== 3 | 4 | We're using RSpec to test the ruby code. The Gemfile is updated to include everything needed. 5 | 6 | Run the specs with the following where -fn and -c are optional: 7 | 8 | bundle exec rspec -fn -c spec 9 | -------------------------------------------------------------------------------- /browser-extensions/Chrome/Wiki/background.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |   9 | 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | from ooyala/quantal64-ruby1.9.3 2 | maintainer Peter Stuifzand "peter@stuifzand.eu" 3 | run gem install bundler 4 | run apt-get install -y ruby-dev 5 | run apt-get install -y libxml2-dev libxslt-dev build-essential git 6 | run gem install nokogiri -v '1.5.6' 7 | add . /wiki 8 | expose 1111 9 | volume /wiki/data 10 | run cd /wiki && bundle install --without development test 11 | cmd cd /wiki/server/sinatra && bundle exec rackup -s thin -p 1111 12 | -------------------------------------------------------------------------------- /spec/fixtures/data/pages/simple-page: -------------------------------------------------------------------------------- 1 | { 2 | "title": "simple-page", 3 | "story": [ 4 | { 5 | "type": "paragraph", 6 | "id": "a03218244dc51a2b", 7 | "text": "simple paragraph 1" 8 | } 9 | ], 10 | "journal": [ 11 | { 12 | "type": "edit", 13 | "id": "a03218244dc51a2b", 14 | "item": { 15 | "type": "paragraph", 16 | "id": "a03218244dc51a2b", 17 | "text": "simple paragraph 1" 18 | } 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /client/testclient.coffee: -------------------------------------------------------------------------------- 1 | mocha.setup('bdd') 2 | 3 | window.wiki = require('wiki-client/lib/wiki') 4 | 5 | require('wiki-client/test/util') 6 | require('wiki-client/test/active') 7 | require('wiki-client/test/pageHandler') 8 | require('wiki-client/test/refresh') 9 | require('wiki-client/test/page') 10 | require('wiki-client/test/plugin') 11 | require('wiki-client/test/revision') 12 | require('wiki-client/test/neighborhood') 13 | require('wiki-client/test/search') 14 | 15 | $ -> 16 | mocha.run() 17 | 18 | -------------------------------------------------------------------------------- /client/build-test.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | :: 3 | :: Used on Windows to build testclient.js as npm start and test don't work! 4 | :: 5 | 6 | :: Build testclient.js - need to expand .\plugins\*\test.coffee as wildcard does not work on Windows 7 | 8 | echo "Building test\testclient.js" 9 | 10 | .\node_modules\.bin\browserify.cmd testclient.coffee .\plugins\calendar\test.coffee .\plugins\changes\test.coffee .\plugins\efficiency\test.coffee .\plugins\report\test.coffee .\plugins\txtzyme\test.coffee -o test\testclient.js -------------------------------------------------------------------------------- /spec/fixtures/data/pages/duplicate-paragraphs: -------------------------------------------------------------------------------- 1 | { 2 | "title": "duplicate-paragraphs", 3 | "story": [ 4 | { 5 | "type": "paragraph", 6 | "id": "a03218244dc51a2b", 7 | "text": "paragraph 1" 8 | }, 9 | { 10 | "type": "paragraph", 11 | "id": "a03218244dc51a2b", 12 | "text": "copy of paragraph 1" 13 | } 14 | 15 | ], 16 | "journal": [ 17 | { 18 | "type": "edit", 19 | "id": "a03218244dc51a2b", 20 | "item": { 21 | "type": "paragraph", 22 | "id": "a03218244dc51a2b", 23 | "text": "paragraph 1" 24 | } 25 | } 26 | ] 27 | } 28 | 29 | -------------------------------------------------------------------------------- /browser-extensions/Chrome/Wiki/options.js: -------------------------------------------------------------------------------- 1 | 2 | if (window.top === window) { 3 | 4 | window["_options"] = new (function(){ 5 | var defaultWikiUrl = "http://localhost:1111/"; 6 | var wikiUrl = "wikiUrl"; 7 | this["_targetedProtocol"] = "http:"; 8 | this[wikiUrl] = function() { 9 | var r = this.load({wikiUrl:wikiUrl}); 10 | return ( r && ( r=r[wikiUrl] ) ) ? r: defaultWikiUrl; }; 11 | this["load"] = function(hash) { 12 | var v; for(var p in hash) { 13 | hash[p] = (v=localStorage[p])?JSON.parse(v):defaultWikiUrl; } 14 | return hash; } 15 | this["save"] = function(hash) { 16 | for(var p in hash) { 17 | localStorage[p] = JSON.stringify(hash[p]); }; 18 | return hash; }; 19 | return this; 20 | })(); 21 | } -------------------------------------------------------------------------------- /spec/favicon_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/spec_helper' 2 | require 'png/reader' 3 | require 'pp' 4 | 5 | describe "Favicon" do 6 | before(:all) do 7 | root = File.expand_path(File.join(File.dirname(__FILE__), "..")) 8 | @test_data_dir = File.join(root, 'spec/data') 9 | end 10 | 11 | before(:each) do 12 | FileUtils.rm_rf @test_data_dir 13 | FileUtils.mkdir @test_data_dir 14 | end 15 | 16 | describe "create" do 17 | it "creates a favicon.png image" do 18 | favicon = Favicon.create_blob 19 | favicon_path = File.join(@test_data_dir, 'favicon-test.png') 20 | File.open(favicon_path, 'wb') { |file| file.write(favicon) } 21 | file = PNG.load_file(favicon_path) 22 | file.should be_a(PNG::Canvas) 23 | file.width.should == 32 24 | file.height.should == 32 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /browser-extensions/Chrome/Wiki/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Federated Wiki", 3 | "description": "Federated Wiki", 4 | "icons": { 5 | "128": "wiki_128.png", 6 | "16" : "wiki_16.png" }, 7 | "browser_action": { 8 | "default_icon": "wiki_19.png", 9 | "default_title": "Add to my Wiki" 10 | }, 11 | "background_page": "background.html", 12 | "options_page": "options.html", 13 | "omnibox": { "keyword" : "wiki" }, 14 | "permissions": [ 15 | "tabs", 16 | "http://*/*" 17 | ], 18 | "content_scripts": [ { 19 | "matches": ["http://*/*","https://*/*"], 20 | "js": ["runtime.js", "activity.js"] } ], 21 | "homepage_url": "https://github.com/WardCunningham/Smallest-Federated-Wiki", 22 | // "update_url": "unknown", 23 | "minimum_chrome_version": "8", 24 | "version": "0.1" 25 | } -------------------------------------------------------------------------------- /server/sinatra/views/page.haml: -------------------------------------------------------------------------------- 1 | .page{:id => page_name, "data-server-generated" => "true"} 2 | %h1 3 | %a{:href => '/', :style => "text-decoration: none"} 4 | %img{:src => '/favicon.png', :height => '32px'} 5 | = page['title'] 6 | .story 7 | - page['story'].each do |item| 8 | %div{:class => ['item', type=item['type']], :id => item['id'], "data-static-item" => item.to_json} 9 | - case type 10 | - when 'paragraph' 11 | %p= resolve_links(item['text']) 12 | - when 'image' 13 | %img{:src => item['url'], :class => 'thumbnail'} 14 | %p= resolve_links(item['text'] || item['caption'] || 'uploaded image') 15 | - else 16 | %p.error= type 17 | .footer 18 | %a{:id => "license", :href => "http://creativecommons.org/licenses/by-sa/3.0/"} CC BY-SA 3.0 19 | ='.' 20 | %a{:class => "show-page-source", :href => "/#{page_name}.json"} JSON 21 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem "rake" 4 | 5 | gem "sinatra", '1.2.6' 6 | gem "haml" 7 | gem "sass" 8 | gem "json" 9 | gem "thin" 10 | gem "RubyInline" 11 | gem "ZenTest", '<= 4.6.0' # dependency of RubyInline, newer versions break Heroku deploy 12 | gem "png" 13 | gem "rest-client" 14 | gem "ruby-openid" 15 | gem "couchrest" 16 | gem "memcache-client", :require => 'memcache' 17 | 18 | group :development do 19 | gem 'ruby-debug', :require => 'ruby-debug', :platform => :mri_18 20 | gem 'ruby-debug19', :require => 'ruby-debug19', :platform => :mri_19 21 | end 22 | 23 | group :test do 24 | gem 'rack-test' , '0.5.6' , :require => 'rack/test' 25 | gem 'rspec' , '2.4.0' 26 | gem 'rspec-core' , '2.4.0' 27 | gem 'rspec-expectations' , '2.4.0' 28 | gem 'rspec-mocks' , '2.4.0' 29 | gem 'capybara' 30 | gem 'launchy' 31 | gem 'selenium-webdriver', '2.22.2' 32 | end 33 | -------------------------------------------------------------------------------- /client/runtests.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SFW Mocha Tests 6 | 7 | 8 | 9 | 10 | 11 | 17 | 18 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /server/sinatra/favicon.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'png' 3 | 4 | class Favicon 5 | class << self 6 | def create_blob 7 | canvas = PNG::Canvas.new 32, 32 8 | light = PNG::Color.from_hsv(256*rand,200,255).rgb() 9 | dark = PNG::Color.from_hsv(256*rand,200,125).rgb() 10 | angle = 2 * (rand()-0.5) 11 | sin = Math.sin angle 12 | cos = Math.cos angle 13 | scale = sin.abs + cos.abs 14 | for x in (0..31) 15 | for y in (0..31) 16 | p = (sin >= 0 ? sin*x+cos*y : -sin*(31-x)+cos*y) / 31 / scale 17 | canvas[x,y] = PNG::Color.new( 18 | light[0]*p + dark[0]*(1-p), 19 | light[1]*p + dark[1]*(1-p), 20 | light[2]*p + dark[2]*(1-p)) 21 | end 22 | end 23 | PNG.new(canvas).to_blob 24 | end 25 | 26 | def get_or_create(path) 27 | Store.get_blob(path) || Store.put_blob(path, Favicon.create_blob) 28 | end 29 | 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /server/sinatra/stores/ReadMe.md: -------------------------------------------------------------------------------- 1 | We support several wiki page persistence mechanisms called Stores. 2 | Currently the server includes all versions and selects one with 3 | an environment variable. 4 | 5 | File Store 6 | ========== 7 | 8 | Pages are stored in flat files under `data` in the subdirectory 9 | `pages`. File names are the slugs with no suffix. 10 | A second subdirectory, `status`, contains additional metadata 11 | such as the site's favicon.png. 12 | 13 | When the server is operated as a wiki site farm, 14 | data and status subdirectories are pushed several levels deeper 15 | in the file hierarchy under `data/farm/*` where * is replaced 16 | with the virtual host domain name. 17 | The existence of the farm subdirectory configures the server 18 | into farm mode. 19 | 20 | Couch Store 21 | =========== 22 | 23 | Pages are stored as Couch documents with fully qualified 24 | names following the conventions established in the File Store. 25 | An environment variable indicates that the server should 26 | be in farm mode. 27 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sfw", 3 | "version": "0.0.2", 4 | "main": "client.coffee", 5 | "private": true, 6 | "dependencies": { 7 | "coffee-script": "*", 8 | "browserify": ">=2.13.2", 9 | "underscore": "*", 10 | "coffeeify": "*", 11 | "wiki-client": ">=0.0.2" 12 | }, 13 | "scripts": { 14 | "test": "browserify -t coffeeify testclient.coffee ./plugins/*/test.coffee --debug > test/testclient.js", 15 | "start": "browserify -t coffeeify client.coffee --debug > client.js" 16 | }, 17 | "devDependencies": { 18 | "mocha": "*", 19 | "sinon": "1.7.1", 20 | "expect.js": "*", 21 | "grunt": "~0.4.1", 22 | "grunt-browserify": "~1.1.1", 23 | "grunt-contrib-coffee": "~0.7.0", 24 | "grunt-contrib-watch": "~0.4.4" 25 | }, 26 | "testling": { 27 | "harness": "mocha" 28 | }, 29 | "engines": { 30 | "node": ">=0.8.0" 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "https://github.com/WardCunningham/Smallest-Federated-Wiki" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /server/sinatra/stores/store.rb: -------------------------------------------------------------------------------- 1 | class Store 2 | class << self 3 | 4 | attr_writer :app_root 5 | 6 | def set(store_classname, app_root) 7 | # @store_class is literally the class FileStore by default, or if a class name is passed in, another subclass of Store 8 | @store_class = store_classname ? Kernel.const_get(store_classname) : FileStore 9 | @store_class.app_root = app_root 10 | @store_class 11 | end 12 | 13 | def method_missing(*args) 14 | # For any method not implemented in *this* class, pass the method call through to the designated Store subclass 15 | @store_class.send(*args) 16 | end 17 | 18 | ### GET 19 | 20 | def get_hash(path) 21 | json = get_text path 22 | JSON.parse json if json 23 | end 24 | 25 | alias_method :get_page, :get_hash 26 | 27 | ### PUT 28 | 29 | def put_hash(path, ruby_data, metadata={}) 30 | json = JSON.pretty_generate(ruby_data) 31 | put_text path, json, metadata 32 | ruby_data 33 | end 34 | 35 | alias_method :put_page, :put_hash 36 | 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /.htaccess: -------------------------------------------------------------------------------- 1 | 2 | RewriteEngine On 3 | 4 | RewriteRule ^$ /server/views/static.html 5 | RewriteRule ^style.css$ /server/views/style.css 6 | 7 | #jquery stuff - css, images, js 8 | RewriteCond %{REQUEST_URI} !^/client/ 9 | RewriteRule ^(js/.*)$ /client/$1 10 | 11 | #client.js 12 | RewriteCond %{REQUEST_URI} !^/client/ 13 | RewriteRule ^client.js$ /client/client.js 14 | 15 | RewriteCond %{REQUEST_URI} !^/data/ 16 | RewriteRule ^(.*)\.json$ /data/pages/$1 17 | 18 | #work around the issue where in local edit mode we still ask the server, even if the server can tell us nothing 19 | #should really use the 404 for 'topic does not exist' and then have the client create it locally 20 | RewriteCond %{REQUEST_URI} ^/data/ 21 | RewriteCond %{REQUEST_URI} !^/data/pages/missing-page 22 | RewriteCond %{REQUEST_FILENAME} !-f 23 | RewriteRule .* /data/pages/missing-page 24 | 25 | RewriteCond %{REQUEST_URI} ^/data/ 26 | RewriteRule .* - [T=application/json] 27 | 28 | #SvenDowideit@fosiki.com's gravatar - you'll want to change this :) 29 | RewriteRule ^favicon.png$ http://en.gravatar.com/userimage/3255925/c3addcadf86caced332408a2e0b4d68b.jpeg 30 | 31 | -------------------------------------------------------------------------------- /mit-license.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011, Ward Cunningham 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /spec/slug_reference.rb: -------------------------------------------------------------------------------- 1 | def asSlug (name) 2 | name.gsub(/\s/, '-').gsub(/[^A-Za-z0-9-]/, '').downcase() 3 | end 4 | 5 | def section (comment) 6 | puts "\n\t#{comment}\n" 7 | end 8 | 9 | def test (given, expected) 10 | actual = asSlug given 11 | puts actual == expected ? "OK\t#{given}" : "YIKES\t#{given} => #{actual}, not #{expected} as expected" 12 | end 13 | 14 | # the following test cases presume to be implementation language agnostic 15 | # perhaps they should be included from a common file 16 | 17 | # 'WORKING' 18 | section 'case and hyphen insensitive' 19 | test 'Welcome Visitors', 'welcome-visitors' 20 | test 'welcome visitors', 'welcome-visitors' 21 | test 'Welcome-visitors', 'welcome-visitors' 22 | 23 | 24 | section 'numbers and punctuation' 25 | test '2012 Report', '2012-report' 26 | test 'Ward\'s Wiki', 'wards-wiki' 27 | 28 | # 'PROBLEMATIC' 29 | section 'white space insenstive' 30 | test 'Welcome Visitors', 'welcome-visitors' 31 | test ' Welcome Visitors', 'welcome-visitors' 32 | test 'Welcome Visitors ', 'welcome-visitors' 33 | 34 | section 'foreign language' 35 | test 'Les Misérables', 'les-misérables' 36 | test 'Les Misérables', 'les-miserables' 37 | -------------------------------------------------------------------------------- /client/theme/granite.css: -------------------------------------------------------------------------------- 1 | a { 2 | color: #c14615; } 3 | 4 | a:hover { 5 | color: #ffba00; 6 | text-shadow: 0 0 1px #d9a513; } 7 | 8 | h1 { 9 | color: #566a6c; 10 | font-weight: 400; } 11 | 12 | body { 13 | background: url("/theme/stoneSeamless.jpg"); } 14 | 15 | .journal { 16 | border-radius: 5px; } 17 | 18 | .action { 19 | font-size: 1.3em; 20 | -webkit-border-radius: 5px; 21 | -moz-border-radius: 5px; 22 | border-radius: 5px; 23 | padding: 0.1em; 24 | margin: 4px; 25 | float: left; 26 | width: 26px; } 27 | 28 | .control-buttons { 29 | right: 3px; 30 | } 31 | 32 | .button { 33 | left: 0; 34 | color: #c14615; 35 | font-size: 21px; 36 | padding: 0.1em; 37 | margin: 1px; 38 | width: 26px; } 39 | 40 | .action:hover { 41 | color: #2c3f39; 42 | text-shadow: 0 0 1px #2c3f39; } 43 | 44 | .target { 45 | background-color: #c9e1d4 !important; } 46 | 47 | .page { 48 | padding: 0 16px; 49 | border-radius: 5px; 50 | border-style:solid; 51 | border-width:1px; 52 | border-color: #878984; 53 | box-shadow: inset 0px 0px 7px rgba(0, 0, 0, 0.5); } 54 | .page.active { 55 | border-color: #515d75; } 56 | 57 | .ghost { 58 | opacity: 0.8; 59 | border-color: #eef2fe; } 60 | -------------------------------------------------------------------------------- /client/js/jquery.ui.touch-punch.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * jQuery UI Touch Punch 0.1.0 3 | * 4 | * Copyright 2010, Dave Furfero 5 | * Dual licensed under the MIT or GPL Version 2 licenses. 6 | * 7 | * Depends: 8 | * jquery.ui.widget.js 9 | * jquery.ui.mouse.js 10 | */ 11 | (function(c){c.support.touch=typeof Touch==="object";if(!c.support.touch){return;}var f=c.ui.mouse.prototype,g=f._mouseInit,a=f._mouseDown,e=f._mouseUp,b={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup"};function d(h){var i=h.originalEvent.changedTouches[0];return c.extend(h,{type:b[h.type],which:1,pageX:i.pageX,pageY:i.pageY,screenX:i.screenX,screenY:i.screenY,clientX:i.clientX,clientY:i.clientY});}f._mouseInit=function(){var h=this;h.element.bind("touchstart."+h.widgetName,function(i){return h._mouseDown(d(i));});g.call(h);};f._mouseDown=function(j){var h=this,i=a.call(h,j);h._touchMoveDelegate=function(k){return h._mouseMove(d(k));};h._touchEndDelegate=function(k){return h._mouseUp(d(k));};c(document).bind("touchmove."+h.widgetName,h._touchMoveDelegate).bind("touchend."+h.widgetName,h._touchEndDelegate);return i;};f._mouseUp=function(i){var h=this;c(document).unbind("touchmove."+h.widgetName,h._touchMoveDelegate).unbind("touchend."+h.widgetName,h._touchEndDelegate);return e.call(h,i);};})(jQuery); -------------------------------------------------------------------------------- /client/builder.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | #this script exists because the browserify -w option does not appear to work? 4 | 5 | my ($old, $new); 6 | my $OSXsay = ($^O eq 'darwin'); 7 | 8 | sub say { 9 | my $msg = shift; 10 | if ($OSXsay) { 11 | `say $msg&`; 12 | } 13 | print $msg."\n"; 14 | } 15 | sub run { 16 | $trouble = `($_[0] || echo 'failed to run') 2>&1`; 17 | return unless $trouble; 18 | if ($trouble =~ /( on line \d+)/) { 19 | say("having trouble $1."); 20 | print "\n$_[0]\n$trouble"; 21 | }elsif ($trouble =~ /failed to run/) { 22 | say('failed to run'); 23 | print("\n$_[0]\n$trouble"); 24 | } 25 | 26 | } 27 | 28 | while (sleep 1) { 29 | $new = `ls -lt *.coffee lib/*.coffee test/*.coffee plugins/*/*.coffee`; 30 | next if $old eq $new; 31 | $old = $new; 32 | print `clear; date`; 33 | say('client.'); 34 | run('./node_modules/.bin/browserify -t coffeeify client.coffee --debug > client.js'); 35 | say('test.'); 36 | run('./node_modules/.bin/browserify -t coffeeify testclient.coffee ./plugins/*/test.coffee --debug > test/testclient.js'); 37 | say('plugins.'); 38 | run('./node_modules/.bin/coffee -c ./plugins/*.coffee'); 39 | run('./node_modules/.bin/coffee -c ./plugins/*/*.coffee'); 40 | say('done.'); 41 | } 42 | -------------------------------------------------------------------------------- /spec/slug_reference.coffee: -------------------------------------------------------------------------------- 1 | asSlug = (name) -> 2 | name.replace(/\s/g, '-').replace(/[^A-Za-z0-9-]/g, '').toLowerCase() 3 | # name.replace(/\s+/g, '-').replace(/[^A-Za-z0-9-]|^\-+|\-+$/g, '').toLowerCase() 4 | 5 | section = (comment) -> 6 | console.log "\n\t#{comment}\n" 7 | 8 | test = (given, expected) -> 9 | actual = asSlug given 10 | console.log if actual == expected then "OK\t#{given}" else "YIKES\t#{given} => #{actual}, not #{expected} as expected" 11 | 12 | # the following test cases presume to be implementation language agnostic 13 | # perhaps they should be included from a common file 14 | 15 | # 'WORKING' 16 | section 'case and hyphen insensitive' 17 | test 'Welcome Visitors', 'welcome-visitors' 18 | test 'welcome visitors', 'welcome-visitors' 19 | test 'Welcome-visitors', 'welcome-visitors' 20 | 21 | 22 | section 'numbers and punctuation' 23 | test '2012 Report', '2012-report' 24 | test 'Ward\'s Wiki', 'wards-wiki' 25 | 26 | # 'PROBLEMATIC' 27 | section 'white space insenstive' 28 | test 'Welcome Visitors', 'welcome-visitors' 29 | test ' Welcome Visitors', 'welcome-visitors' 30 | test 'Welcome Visitors ', 'welcome-visitors' 31 | 32 | section 'foreign language' 33 | test 'Les Misérables', 'les-misérables' 34 | test 'Les Misérables', 'les-miserables' 35 | test 'Är du där?', 'ar-du-dar' # Swedish 36 | -------------------------------------------------------------------------------- /server/sinatra/stores/file.rb: -------------------------------------------------------------------------------- 1 | class FileStore < Store 2 | class << self 3 | 4 | ### GET 5 | 6 | def get_text(path) 7 | File.read path if File.exist? path 8 | end 9 | 10 | def get_blob(path) 11 | File.binread path if File.exist? path 12 | end 13 | 14 | ### PUT 15 | 16 | def put_text(path, text, metadata=nil) 17 | # Note: metadata is ignored for filesystem storage 18 | File.open(path, 'w'){ |file| file.write text } 19 | text 20 | end 21 | 22 | def put_blob(path, blob) 23 | File.open(path, 'wb'){ |file| file.write blob } 24 | blob 25 | end 26 | 27 | ### COLLECTIONS 28 | 29 | def annotated_pages(pages_dir) 30 | Dir.foreach(pages_dir).reject{|name|name =~ /^\./}.collect do |name| 31 | page = get_page(File.join pages_dir, name) 32 | page.merge!({ 33 | 'name' => name, 34 | 'updated_at' => File.new("#{pages_dir}/#{name}").mtime 35 | }) 36 | end 37 | end 38 | 39 | ### UTILITY 40 | 41 | def farm?(data_root) 42 | ENV['FARM_MODE'] || File.exists?(File.join data_root, "farm") 43 | end 44 | 45 | def mkdir(directory) 46 | FileUtils.mkdir_p directory 47 | end 48 | 49 | def exists?(path) 50 | File.exists?(path) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /client/Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | grunt.loadNpmTasks('grunt-browserify'); 3 | grunt.loadNpmTasks('grunt-contrib-coffee'); 4 | grunt.loadNpmTasks('grunt-contrib-watch'); 5 | 6 | grunt.initConfig({ 7 | browserify: { 8 | client: { 9 | src: ['client.coffee'], 10 | dest: 'client.js', 11 | options: { 12 | transform: ['coffeeify'], 13 | debug: true 14 | } 15 | }, 16 | testClient: { 17 | src: ['testclient.coffee', 'plugins/*/test.coffee'], 18 | dest: 'test/testclient.js', 19 | options: { 20 | transform: ['coffeeify'], 21 | debug: true 22 | } 23 | } 24 | }, 25 | 26 | coffee: { 27 | plugins: { 28 | expand: true, 29 | src: ['plugins/**/*.coffee'], 30 | ext: '.js' 31 | } 32 | }, 33 | 34 | watch: { 35 | all: { 36 | files: [ 37 | '<%= browserify.testClient.src %>', 38 | '<%= browserify.client.src %>', 39 | '<%= coffee.plugins.src %>', 40 | 'lib/**/*.coffee' 41 | ], 42 | tasks: ['coffee', 'browserify'] 43 | } 44 | } 45 | }); 46 | 47 | grunt.registerTask('build', ['coffee', 'browserify']); 48 | grunt.registerTask('default', ['build']); 49 | 50 | }; 51 | -------------------------------------------------------------------------------- /server/sinatra/views/static.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Smallest Federated Wiki 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 |
20 |
21 |
22 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /spec/fixtures/data/pages/multiple-paragraphs: -------------------------------------------------------------------------------- 1 | { 2 | "title": "multiple-paragraphs", 3 | "story": [ 4 | { 5 | "type": "paragraph", 6 | "id": "a03218244dc51a2b", 7 | "text": "paragraph 1" 8 | }, 9 | { 10 | "type": "paragraph", 11 | "id": "b121c247944cda6e", 12 | "text": "paragraph 2" 13 | }, 14 | { 15 | "type": "paragraph", 16 | "id": "c12c3f8acf7410cb", 17 | "text": "paragraph 3" 18 | } 19 | ], 20 | "journal": [ 21 | { 22 | "type": "edit", 23 | "id": "a03218244dc51a2b", 24 | "item": { 25 | "type": "paragraph", 26 | "id": "a03218244dc51a2b", 27 | "text": "paragraph 1" 28 | } 29 | }, 30 | { 31 | "item": { 32 | "type": "factory", 33 | "id": "b121c247944cda6e" 34 | }, 35 | "id": "b121c247944cda6e", 36 | "type": "add", 37 | "after": "a03218244dc51a2b" 38 | }, 39 | { 40 | "type": "edit", 41 | "id": "b121c247944cda6e", 42 | "item": { 43 | "type": "paragraph", 44 | "id": "b121c247944cda6e", 45 | "text": "paragraph 2" 46 | } 47 | }, 48 | { 49 | "item": { 50 | "type": "factory", 51 | "id": "c12c3f8acf7410cb" 52 | }, 53 | "id": "c12c3f8acf7410cb", 54 | "type": "add", 55 | "after": "b121c247944cda6e" 56 | }, 57 | { 58 | "type": "edit", 59 | "id": "c12c3f8acf7410cb", 60 | "item": { 61 | "type": "paragraph", 62 | "id": "c12c3f8acf7410cb", 63 | "text": "paragraph 3" 64 | } 65 | } 66 | ] 67 | } -------------------------------------------------------------------------------- /browser-extensions/README: -------------------------------------------------------------------------------- 1 | To give the extension a test run: 2 | 3 | 1. Make sure the Smallest-Federated-Wiki local server is running on port 1111. 4 | 2. Start Chrome 5 | 3. Give http://localhost:1111/ a try 6 | 4. Open a new tab and navigate to chrome://extensions 7 | 5. On the new page, make sure Developer mode is checked off 8 | 6. Now you should see the "Load unpacked extension..." button. Click and point to the Wiki project directory, where the manifest.json file is to be found. Click OK. 9 | 7. The extension is now running. When viewing http(s) page a "+" badge appears. Select portion of the page and click the Wiki extension button. The selected text will transfer to the local Federated Wiki. 10 | 8. Alternativelly, you can collect the entire page. The text is injected in the already created Wiki containers, or new containers are created on the right-most Wiki page. 11 | 12 | 13 | Cople of notes: 14 | 15 | 1. This is the initial code! It is know to be working with the master branch as of May 31st @4pm PDT. 16 | 2. Wanted an extension that does not require adjusting any of the WardCunningham / Smallest-Federated-Wiki client or server code. This made the extension far from perfect. It automates clicks on the "+" button, dbllicks, textarea blurs. There is a need for communication channel between the Wiki page and the extension script. If the extension seems interesting enougn, will wire a comm channel. 17 | 3. In various pleases there is TODO notes. In one instance the note asks for temporary storage. In case the Wiki page is not yet up, there is a need to temporary preserve the text to be injected into the Wiki. When the page is up and running, it will ask for its new content. 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'capybara/rspec' 2 | require 'capybara/dsl' 3 | require 'rack/test' 4 | 5 | USE_NODE = ENV['TEST_NODE'] == "true" 6 | 7 | Bundler.require :test 8 | 9 | module TestDirs 10 | ROOT = File.expand_path(File.join(File.dirname(__FILE__), "..")) 11 | APP_DATA_DIR = File.join(ROOT, "data") 12 | TEST_DATA_DIR = File.join(ROOT, 'spec/data') 13 | FIXTURE_DATA_DIR = File.join(ROOT, 'spec/fixtures/data') 14 | JS_DIR = File.join(ROOT, "spec/js") 15 | end 16 | 17 | if USE_NODE 18 | Capybara.app_host = "http://localhost:33333" 19 | Capybara.server_port = 33333 20 | else 21 | require File.expand_path(File.join(File.dirname(__FILE__), "../server/sinatra/server")) 22 | 23 | raise 'Forget it.' if ENV['RACK_ENV'] == 'production' 24 | 25 | class TestApp < Controller 26 | def self.data_root 27 | TestDirs::TEST_DATA_DIR 28 | end 29 | end 30 | 31 | Capybara.server do |app, port| 32 | Thin::Logging.silent = true 33 | server = Thin::Server.new '0.0.0.0', port, app 34 | server.threaded = true 35 | server.start 36 | server 37 | end 38 | ENV['RACK_ENV'] = 'test' 39 | 40 | Capybara.app = TestApp 41 | Capybara.server_port = 31337 42 | 43 | end 44 | 45 | 46 | Capybara.register_driver :selenium do |app| 47 | Capybara::Selenium::Driver.new(app, :resynchronize => true) 48 | end 49 | 50 | RSpec.configure do |config| 51 | config.include Capybara::DSL 52 | 53 | config.before(:each) do 54 | `rm -rf #{TestDirs::TEST_DATA_DIR}` 55 | FileUtils.mkdir_p TestDirs::TEST_DATA_DIR 56 | Capybara.current_driver = :selenium 57 | end 58 | 59 | module RackTestOurApp 60 | include Rack::Test::Methods 61 | def app; TestApp; end 62 | end 63 | config.include(RackTestOurApp) 64 | end 65 | 66 | -------------------------------------------------------------------------------- /Windows.md: -------------------------------------------------------------------------------- 1 | Windows Notes 2 | ============= 3 | 4 | These notes will help with installing and running the Smallest Federated Wiki on Windows. 5 | 6 | * [Install and Launch (Sinatra)](Windows.md#install-and-launch-sinatra) 7 | 8 | 9 | 10 | Install and Launch (Sinatra) 11 | ============================ 12 | 13 | The Sinstra server requires Ruby, this can be installed from [RubyInstaller for Windows](http://rubyinstaller.org/). 14 | As well as the Ruby Installer, you will also need the Development Kit to compile some of the Gem files. These notes 15 | have been written/tested with Ruby version 1.9.3. 16 | 17 | > **N.B.** See [Development Kit](https://github.com/oneclick/rubyinstaller/wiki/Development-Kit) for installation 18 | > instructions. 19 | 20 | Open a command window - ensure that Ruby, the Development Kit, and Git have all been added to the environment (there 21 | are scripts in the ruby and development kit directories if necessary). 22 | 23 | The server is a ruby bundle. Get the bundler gem and then use it to get everything else: 24 | 25 | gem install bundler 26 | bundle update 27 | 28 | > It is probably best to use ```bundle update``` rather than ```bundle install``` so that the latest version of the 29 | > gems are installed. There are know problems with eventmachine (0.12.10) not installing, and with in-line code in the PNG (1.2.0) gem 30 | > this will be seen as an error when the server is started - if the update to the PNG gem has not been release, see [Fixed a misordered block of C code](https://github.com/bensomers/png/commit/eff179b3e5849b287251d0c33435852e8842597e) 31 | > for the changes needed to the PNG gem code. 32 | 33 | 34 | Launch the server with this bundle command: 35 | 36 | cd server\sinatra 37 | bundle exec rackup -s thin -p 1111 38 | 39 | Now go to your browser and browse your new wiki: 40 | 41 | http://localhost:1111 42 | 43 | -------------------------------------------------------------------------------- /server/sinatra/server_helpers.rb: -------------------------------------------------------------------------------- 1 | module ServerHelpers 2 | 3 | def cross_origin 4 | headers 'Access-Control-Allow-Origin' => "*" if request.env['HTTP_ORIGIN'] 5 | end 6 | 7 | def resolve_links string 8 | string. 9 | gsub(/\[\[([^\]]+)\]\]/i) { 10 | |name| 11 | name.gsub!(/^\[\[(.*)\]\]/, '\1') 12 | 13 | slug = name.gsub(/\s/, '-') 14 | slug = slug.gsub(/[^A-Za-z0-9-]/, '').downcase 15 | ''+name+'' 16 | }. 17 | gsub(/\[(http.*?) (.*?)\]/i, '\2') 18 | end 19 | 20 | def openid_consumer 21 | @openid_consumer ||= OpenID::Consumer.new(session, OpenID::Store::Filesystem.new("#{farm_status}/tmp/openid")) 22 | end 23 | 24 | def authenticated? 25 | session[:authenticated] == true 26 | end 27 | 28 | def identified? 29 | Store.exists? "#{farm_status}/open_id.identifier" 30 | end 31 | 32 | def claimed? 33 | Store.exists? "#{farm_status}/open_id.identity" 34 | end 35 | 36 | def authenticate! 37 | session[:authenticated] = true 38 | redirect "/" 39 | end 40 | 41 | def oops status, message 42 | haml :oops, :layout => false, :locals => {:status => status, :message => message} 43 | end 44 | 45 | def serve_resources_locally?(site) 46 | !!ENV['FARM_DOMAINS'] && ENV['FARM_DOMAINS'].split(',').any?{|domain| site.end_with?(domain)} 47 | end 48 | 49 | def serve_page(name, site=request.host) 50 | cross_origin 51 | halt 404 unless farm_page(site).exists?(name) 52 | JSON.pretty_generate farm_page(site).get(name) 53 | end 54 | 55 | def synopsis page 56 | text = page['synopsis'] 57 | p1 = page['story'] && page['story'][0] 58 | p2 = page['story'] && page['story'][1] 59 | text ||= p1 && p1['text'] if p1 && p1['type'] == 'paragraph' 60 | text ||= p2 && p2['text'] if p2 && p2['type'] == 'paragraph' 61 | text ||= p1 && p1['text'] || p2 && p2['text'] || page['story'] && "A page with #{page['story'].length} paragraphs." || "A page with no story." 62 | return text 63 | end 64 | 65 | end 66 | 67 | -------------------------------------------------------------------------------- /client/mkplugin.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Script to create a stub plugin with stub documentation 4 | 5 | if [ $# -eq 0 ] 6 | then 7 | echo "Usage: ./mkplugin.sh " 8 | echo "e.g. ./mkplugin.sh CoolThing" 9 | exit 0 10 | fi 11 | 12 | if [ ! -d plugins ] 13 | then 14 | echo "can't find plugins directory (running from client directory?)" 15 | exit 1 16 | fi 17 | 18 | name=`echo $1 | tr '[A-Z]' '[a-z]'` 19 | date=`date -u +%s` 20 | msec=000 21 | 22 | if [ "$1" == "$name" ] 23 | then 24 | echo "Expected capitalized name" 25 | echo "e.g. CoolThing" 26 | exit 2 27 | fi 28 | 29 | if [ -e plugins/$name ] 30 | then 31 | echo "plugin directory already exists: $name" 32 | exit 3 33 | fi 34 | 35 | mkdir plugins/$name 36 | cat < plugins/$name/$name.coffee 37 | emit = (\$item, item) -> 38 | \$item.append """ 39 |

40 | #{item.text} 41 |

42 | """ 43 | 44 | bind = (\$item, item) -> 45 | \$item.dblclick -> wiki.textEditor \$item, item 46 | 47 | window.plugins.$name = {emit, bind} 48 | EOF 49 | 50 | mkdir plugins/$name/pages 51 | title='"About '"$1"' Plugin"' 52 | id1=`cat /dev/urandom | env LC_CTYPE=C tr -cd 'a-f0-9' | head -c 16` 53 | id2=`cat /dev/urandom | env LC_CTYPE=C tr -cd 'a-f0-9' | head -c 16` 54 | 55 | read -r -d '' story < plugins/$name/pages/about-$name-plugin 85 | { 86 | "title": $title, 87 | "story": $story, 88 | "journal": $journal 89 | } 90 | EOF 91 | 92 | echo Plugin and documentation pages created. 93 | echo Build with client/builder.sh or grunt build 94 | echo View localhost:1111/about-$name-plugin.html 95 | echo Edit client/plugins/$name/$name.coffee 96 | 97 | echo 98 | -------------------------------------------------------------------------------- /ReadMe.md: -------------------------------------------------------------------------------- 1 | ``` 2 | This repository exists as both a historical document and 3 | a community of interested parties. This is not where you 4 | want to find the current source for Federated Wiki. 5 | ``` 6 | 7 | 8 | Smallest Federated Wiki Goals 9 | ============================= 10 | 11 | The original wiki was written in a week and cloned within a week after that. 12 | The concept was shown to be fruitful while leaving other implementors room to innovate. 13 | When we ask for simple, we are looking for the same kind of simplicity: nothing to distract from our innovation in federation. 14 | 15 | We imagined two components: 16 | 17 | 1. a server component managing page storage and collaboration between peer servers, and, 18 | 2. a client component presenting and modifying the server state in server specific ways. 19 | 20 | The project is judged successful to the degree that it can: 21 | 22 | * Demonstrate that wiki would have been better had it been effectively federated from the beginning. 23 | * Explore federation policies necessary to sustain an open creative community. 24 | 25 | This project has been founded within the community assembled in Portland at the Indie Web Camp: 26 | 27 | * http://IndieWebCamp.com 28 | 29 | Software development continues elsewhere within github: 30 | 31 | * https://github.com/fedwiki 32 | 33 | 34 | Install and Launch 35 | ================== 36 | 37 | The preferred implementation is distributed as [npm module wiki](https://npmjs.org/package/wiki), 38 | and a corresponding [github repository](https://github.com/fedwiki/wiki-node). 39 | 40 | With node/npm installed, install wiki with this command: 41 | 42 | npm install -g wiki 43 | 44 | Launch the wiki server with this command: 45 | 46 | wiki -p 3000 47 | 48 | Your wiki will now be available as localhost:3000. 49 | 50 | If you have a public facing site with a wildcard domain name then you can launch wiki as a virtual hosting site 51 | we call a wiki farm. We'll use the more conventional port 80 assuming you also have root access. 52 | 53 | wiki -p 80 -f 54 | 55 | Heavy wiki users will want a farm of their own. 56 | 57 | License 58 | ======= 59 | 60 | You may use the Smallest Federated Wiki under either the 61 | [MIT License](https://github.com/WardCunningham/Smallest-Federated-Wiki/blob/master/mit-license.txt) or the 62 | [GNU General Public License](https://github.com/WardCunningham/Smallest-Federated-Wiki/blob/master/gpl-license.txt) (GPL) Version 2. 63 | 64 | -------------------------------------------------------------------------------- /spec/stores/couch_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../spec_helper' 2 | require File.dirname(__FILE__) + '/../../server/sinatra/stores/all' 3 | 4 | describe CouchStore do 5 | before :each do 6 | CouchStore.app_root = '' 7 | end 8 | 9 | before :each do 10 | @db = CouchStore.db = double() 11 | @couch_doc = double(:save => nil, :merge! => nil, :[]= => nil) 12 | end 13 | 14 | describe 'put_text' do 15 | it 'should store a string to Couch' do 16 | @db.should_receive(:save_doc) do |hash| 17 | hash['_id'].should == 'some/path/segments' 18 | hash['data'].should == 'value -- any sting data' 19 | end 20 | 21 | CouchStore.put_text('some/path/segments', 'value -- any sting data') 22 | end 23 | 24 | it 'should convert full paths to relative paths' do 25 | CouchStore.app_root = '/home/joe/sfw/' 26 | @db.should_receive(:save_doc) do |hash| 27 | hash['_id'].should == 'data/pages/joes-place' 28 | hash['data'].should == '

Joe\'s Place

' 29 | hash['directory'].should == 'data/pages/' 30 | hash['any_param'].should == '/home/jennifer/sfw/is/not/affected' 31 | end 32 | 33 | CouchStore.put_text '/home/joe/sfw/data/pages/joes-place', '

Joe\'s Place

', { 34 | 'directory' => '/home/joe/sfw/data/pages/', 35 | 'any_param' => '/home/jennifer/sfw/is/not/affected', 36 | } 37 | 38 | end 39 | 40 | it 'should not blow up even when Couch initially raises a "conflict" exception' do 41 | @db.should_receive(:save_doc).and_raise(RestClient::Conflict) 42 | @db.should_receive(:get).and_return(@couch_doc) # .with('same/key/a/second/time') 43 | 44 | CouchStore.put_text('same/key/a/second/time', 'value') 45 | end 46 | 47 | it 'should return the data' do 48 | CouchStore.db = double(:save_doc => nil) 49 | CouchStore.put_text('key', 'value').should == 'value' 50 | end 51 | end 52 | 53 | describe 'get_text' do 54 | it 'retrieve a string from Couch' do 55 | @db.should_receive(:get).with('some/path/segments').and_return('data' => 'some string value') 56 | 57 | CouchStore.get_text('some/path/segments').should == 'some string value' 58 | end 59 | 60 | it 'should not blow up even when Couch raises a "not found" exception' do 61 | @db.should_receive(:get).and_raise(RestClient::ResourceNotFound) 62 | 63 | CouchStore.get_text('not/found/key').should be_nil 64 | end 65 | 66 | it 'should return the data' do 67 | CouchStore.db = double(:get => {'data' => 'value'}) 68 | 69 | CouchStore.get_text('key').should == 'value' 70 | end 71 | end 72 | 73 | end 74 | 75 | -------------------------------------------------------------------------------- /browser-extensions/Chrome/Wiki/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Federation Wiki Options 4 | 52 | 53 | 54 | 55 | 56 |   57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /server/sinatra/page.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require File.expand_path("../random_id", __FILE__) 3 | require File.expand_path("../stores/all", __FILE__) 4 | 5 | class PageError < StandardError; end; 6 | 7 | # Page Class 8 | # Handles writing and reading JSON data to and from files. 9 | class Page 10 | 11 | # Directory where pages are to be stored. 12 | attr_accessor :directory 13 | # Directory where default (pre-existing) pages are stored. 14 | attr_accessor :default_directory 15 | # Directory where plugins that may have pages are stored. 16 | attr_accessor :plugins_directory 17 | 18 | def plugin_page_path name 19 | Dir.glob(File.join(plugins_directory, '*/pages')) do |dir| 20 | probe = "#{dir}/#{name}" 21 | return probe if File.exists? probe 22 | end 23 | return nil 24 | end 25 | 26 | 27 | # Get a page 28 | # 29 | # @param [String] name - The name of the file to retrieve, relative to Page.directory. 30 | # @return [Hash] The contents of the retrieved page (parsed JSON). 31 | def get(name) 32 | assert_attributes_set 33 | path = File.join(directory, name) 34 | default_path = File.join(default_directory, name) 35 | page = Store.get_page(path) 36 | if page 37 | page 38 | elsif File.exist?(default_path) 39 | FileStore.get_page(default_path) 40 | elsif (path = plugin_page_path name) 41 | page = FileStore.get_page(path) 42 | page['plugin'] = path.match(/plugins\/(.*?)\/pages/)[1] 43 | page 44 | else 45 | halt 404 46 | end 47 | end 48 | 49 | def exists?(name) 50 | Store.exists?(File.join(directory, name)) or 51 | File.exist?(File.join(default_directory, name)) or 52 | !plugin_page_path(name).nil? 53 | end 54 | 55 | # Create or update a page 56 | # 57 | # @param [String] name - The name of the file to create/update, relative to Page.directory. 58 | # @param [Hash] page - The page data to be written to the file (it will be converted to JSON). 59 | # @return [Hash] The contents of the retrieved page (parsed JSON). 60 | def put(name, page) 61 | assert_attributes_set 62 | path = File.join directory, name 63 | page.delete 'plugin' 64 | Store.put_page(path, page, :name => name, :directory => directory) 65 | end 66 | 67 | private 68 | 69 | def assert_attributes_set 70 | raise PageError.new('Page.directory must be set') unless directory 71 | raise PageError.new('Page.default_directory must be set') unless default_directory 72 | raise PageError.new('Page.plugins_directory must be set') unless plugins_directory 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /client/js/d3/d3.csv.js: -------------------------------------------------------------------------------- 1 | (function(){d3.csv = function(url, callback) { 2 | d3.text(url, "text/csv", function(text) { 3 | callback(text && d3.csv.parse(text)); 4 | }); 5 | }; 6 | d3.csv.parse = function(text) { 7 | var header; 8 | return d3.csv.parseRows(text, function(row, i) { 9 | if (i) { 10 | var o = {}, j = -1, m = header.length; 11 | while (++j < m) o[header[j]] = row[j]; 12 | return o; 13 | } else { 14 | header = row; 15 | return null; 16 | } 17 | }); 18 | }; 19 | 20 | d3.csv.parseRows = function(text, f) { 21 | var EOL = {}, // sentinel value for end-of-line 22 | EOF = {}, // sentinel value for end-of-file 23 | rows = [], // output rows 24 | re = /\r\n|[,\r\n]/g, // field separator regex 25 | n = 0, // the current line number 26 | t, // the current token 27 | eol; // is the current token followed by EOL? 28 | 29 | re.lastIndex = 0; // work-around bug in FF 3.6 30 | 31 | /** @private Returns the next token. */ 32 | function token() { 33 | if (re.lastIndex >= text.length) return EOF; // special case: end of file 34 | if (eol) { eol = false; return EOL; } // special case: end of line 35 | 36 | // special case: quotes 37 | var j = re.lastIndex; 38 | if (text.charCodeAt(j) === 34) { 39 | var i = j; 40 | while (i++ < text.length) { 41 | if (text.charCodeAt(i) === 34) { 42 | if (text.charCodeAt(i + 1) !== 34) break; 43 | i++; 44 | } 45 | } 46 | re.lastIndex = i + 2; 47 | var c = text.charCodeAt(i + 1); 48 | if (c === 13) { 49 | eol = true; 50 | if (text.charCodeAt(i + 2) === 10) re.lastIndex++; 51 | } else if (c === 10) { 52 | eol = true; 53 | } 54 | return text.substring(j + 1, i).replace(/""/g, "\""); 55 | } 56 | 57 | // common case 58 | var m = re.exec(text); 59 | if (m) { 60 | eol = m[0].charCodeAt(0) !== 44; 61 | return text.substring(j, m.index); 62 | } 63 | re.lastIndex = text.length; 64 | return text.substring(j); 65 | } 66 | 67 | while ((t = token()) !== EOF) { 68 | var a = []; 69 | while ((t !== EOL) && (t !== EOF)) { 70 | a.push(t); 71 | t = token(); 72 | } 73 | if (f && !(a = f(a, n++))) continue; 74 | rows.push(a); 75 | } 76 | 77 | return rows; 78 | }; 79 | d3.csv.format = function(rows) { 80 | return rows.map(d3_csv_formatRow).join("\n"); 81 | }; 82 | 83 | function d3_csv_formatRow(row) { 84 | return row.map(d3_csv_formatValue).join(","); 85 | } 86 | 87 | function d3_csv_formatValue(text) { 88 | return /[",\n]/.test(text) 89 | ? "\"" + text.replace(/\"/g, "\"\"") + "\"" 90 | : text; 91 | } 92 | })(); 93 | -------------------------------------------------------------------------------- /server/sinatra/views/layout.haml: -------------------------------------------------------------------------------- 1 | !!! 5 2 | %html( class="no-js" ) 3 | %head 4 | %title Smallest Federated Wiki 5 | / 6 | = settings.versions 7 | 8 | %meta(http-equiv="Content-Type" content="text/html; charset=UTF-8") 9 | %meta(name="viewport" content="width=device-width, height=device-height, initial-scale=1.0, user-scalable=no") 10 | %link(type="image/png" href="/favicon.png" rel="icon") 11 | %link(type="text/css" href="/style.css" rel="stylesheet") 12 | %script(type="text/javascript" src="/js/jquery-1.9.1.min.js") 13 | %script(type="text/javascript" src="/js/jquery-migrate-1.1.1.min.js") 14 | %script(type="text/javascript" src="/js/jquery-ui-1.10.1.custom.min.js") 15 | %link(type="text/css" href="/js/jquery-ui-1.10.1.custom.min.css" rel="stylesheet") 16 | %script(type="text/javascript" src="/js/modernizr.custom.63710.js") 17 | %script(type="text/javascript" src="/js/underscore-min.js") 18 | %script(type="text/javascript" src="/client.js") 19 | :javascript 20 | Modernizr.load([ 21 | { 22 | test: Modernizr.cors, 23 | nope: '/js/jquery.ie.cors.js'} 24 | ]); 25 | %meta(name="apple-mobile-web-app-capable" content="yes") 26 | %meta(name="apple-mobile-web-app-status-bar-style" content="black") 27 | %meta(name="apple-mobile-web-app-title" content="SF Wiki") 28 | 29 | %link(rel="apple-touch-icon" href="/favicon.png") 30 | %link(rel="shortcut icon" href="/favicon.png") 31 | %body 32 | %section.main 33 | =yield 34 | %footer{:class => authenticated? ? "logout" : identified? ? "login" : claimed? ? "login" : "claim"} 35 | - if authenticated? 36 | %form{:method => 'POST', :action => "/logout"} 37 | %input{:type => 'submit', :value => "Logout"} 38 | - else 39 | - if identified? 40 | %form{:method => 'POST', :action => "/login"} 41 | %input{:type => 'submit', :value => "Login"} 42 | - else 43 | %form{:method => 'POST', :action => "/login"} 44 | OpenID: 45 | %input{:type => 'text', :name => 'identifier'} 46 | %input{:type => 'submit', :value => claimed? ? "Login" : "Claim"} 47 | or use: 48 | %span{:class => 'provider'} 49 | %input{:type => 'button', :title => 'google', :value => 'G', 'data-provider' => 'https://www.google.com/accounts/o8/id'} 50 | %input{:type => 'button', :title => 'yahoo', :value => 'Y', 'data-provider' => 'https://me.yahoo.com'} 51 | %input{:type => 'button', :title => 'aol', :value => 'A', 'data-provider' => 'https://www.aol.com'} 52 | %input{:type => 'button', :title => 'livejournal', :value => 'L', 'data-provider' => 'http://www.livejournal.com/openid/server.bml'} 53 | %span.searchbox 54 | Search: 55 | %input.search(name='search' type='text') 56 | %span.pages 57 | %span.neighborhood 58 | -------------------------------------------------------------------------------- /client/ReadMe.md: -------------------------------------------------------------------------------- 1 | Client Goals 2 | ============ 3 | 4 | A server offers direct restful read/write access to pages it owns and proxy access to pages held elsewhere in federated space. 5 | A page is owned if it was created with the server or has been cloned and edited such that it is believed to be the most authoritative copy of a page previously owned elsewhere. 6 | A server operates as a proxy to the rest of the federated wiki. 7 | In this role it reformats data and metadata providing a unified experience. 8 | It is welcome to collect behavioral statistics in order to improve this experience by anticipating permitted peer-to-peer server operations. 9 | 10 | In summary, the server's client side exists to: 11 | 12 | * Offer to a user a browsing experience that is independent of any specific server. 13 | * Support writing, editing and curating of one server in a way that offers suitable influence over others. 14 | 15 | Working with Browserify 16 | ======================= 17 | 18 | The client side is written in CoffeeScript, and built with Browserify. 19 | If you are not checking in changes you need not concern yourself with this. 20 | We've checked in the generated Javascript for the client application. 21 | 22 | If you do want to check in changes, install node v0.6.x 23 | 24 | * On Linux download the source from [GitHub](https://github.com/joyent/node) 25 | * On Windows get the installer from the [main node.js site](http://nodejs.org). 26 | * On Mac you should be able to choose either. 27 | 28 | Once node is installed come back to this directory and run: 29 | 30 | * `npm install` To install CoffeeScript, Browserify, and all their dependencies. 31 | 32 | You can now use: 33 | 34 | * `npm start` To build the main client. 35 | * `npm test` To build the test client. 36 | 37 | These commands build client.js and test/testclient.js from client.coffee and 38 | testclient.coffee respectively. They use their entry files to require the 39 | rest of the coffee script they need from the source CS files in /lib. 40 | 41 | We also have a cool automated talking (Mac only) Perl build script that uses 42 | a globally installed browserify via `npm install -g browserify`, it watches 43 | for changes, builds the clients automatically, and gives a verbal report 44 | when you have syntax errors. 45 | 46 | Testing 47 | ======= 48 | 49 | All the client tests can be run by visiting /runtests.html on your server 50 | or by running the full ruby test suite. Information about the libraries we 51 | are using for testing can be found at: 52 | 53 | * http://visionmedia.github.com/mocha/ 54 | * https://github.com/LearnBoost/expect.js 55 | * http://sinonjs.org/ 56 | 57 | CoffeeScript hints 58 | ================== 59 | 60 | We recommend taking time to learn the CoffeeScript syntax and the rationale for the Javascript idioms it employs. Start here: 61 | 62 | http://jashkenas.github.com/coffee-script/ 63 | 64 | We used a Javascript to Coffeescript converter to create the first draft of client.coffee. You may find this converter useful for importing sample codes. 65 | 66 | http://ricostacruz.com/js2coffee/ 67 | 68 | -------------------------------------------------------------------------------- /spec/server_helpers_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | require File.dirname(__FILE__) + '/spec_helper' 4 | 5 | class TestHelpers 6 | include ServerHelpers 7 | end 8 | 9 | describe 'Server helpers' do 10 | let(:helpers) { TestHelpers.new } 11 | 12 | describe "#resolve_links" do 13 | it "should leave strings without links alone" do 14 | helpers.resolve_links('foo').should == 'foo' 15 | end 16 | 17 | it "should convert wikilinks to slug links" do 18 | helpers.resolve_links('[[My Page]]').should == 'My Page' 19 | end 20 | 21 | it "should leave dashes in the name as dashes in the slug" do 22 | helpers.resolve_links('[[My-Page]]').should == 'My-Page' 23 | end 24 | 25 | it "should create a dash for each whitespace character" do 26 | helpers.resolve_links('[[ My Page ]]').should == ' My Page ' 27 | end 28 | 29 | it "should remove non-slug characters" do 30 | helpers.resolve_links('[[My Page Røøøx!!!]]').should == 'My Page Røøøx!!!' 31 | end 32 | end 33 | 34 | describe "#serve_resources_locally?" do 35 | it "should be true when FARM_DOMAINS is set to the current domain" do 36 | ENV['FARM_DOMAINS'] = 'me.com' 37 | helpers.serve_resources_locally?('me.com').should == true 38 | end 39 | 40 | it "should be true when FARM_DOMAINS is set to the *root domain* of the current subdomain" do 41 | ENV['FARM_DOMAINS'] = 'forkthis.net' 42 | helpers.serve_resources_locally?('jack.forkthis.net').should == true 43 | helpers.serve_resources_locally?('jacks-thoughts.jack.forkthis.net').should == true 44 | end 45 | 46 | it "should be false when the environment variable FARM_DOMAINS is not set" do 47 | helpers.serve_resources_locally?('anything.com').should == false 48 | end 49 | 50 | it "should be false when FARM_DOMAINS does not includes the current domain" do 51 | ENV['FARM_DOMAINS'] = 'you.com' 52 | helpers.serve_resources_locally?('hoodoo.com').should == false 53 | end 54 | 55 | describe "FARM_DOMAINS includes multiple domains" do 56 | it "should be true when FARM_DOMAINS includes the current domain" do 57 | ENV['FARM_DOMAINS'] = 'we.com,me.com,you.com' 58 | helpers.serve_resources_locally?('me.com').should == true 59 | end 60 | 61 | it "should be false when FARM_DOMAINS does not includes the current domain" do 62 | ENV['FARM_DOMAINS'] = 'we.com,me.com,you.com' 63 | helpers.serve_resources_locally?('hoodoo.com').should == false 64 | end 65 | 66 | it "should be true when FARM_DOMAINS is set to the *root domain* of the current subdomain" do 67 | ENV['FARM_DOMAINS'] = 'bar.com,forkthis.net,foo.com' 68 | helpers.serve_resources_locally?('jack.forkthis.net').should == true 69 | helpers.serve_resources_locally?('jacks-thoughts.jack.forkthis.net').should == true 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/page_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/spec_helper' 2 | 3 | describe "Page" do 4 | before(:all) do 5 | Store.set 'FileStore', nil 6 | @page = Page.new 7 | @page.directory = nil 8 | @page.default_directory = nil 9 | end 10 | 11 | context "when @page.directory has not been set" do 12 | it "raises PageError" do 13 | expect { 14 | @page.get('anything') 15 | }.to raise_error(PageError, /Page\.directory/) 16 | end 17 | end 18 | 19 | context "when @page.default_directory has not been set" do 20 | it "raises PageError" do 21 | @page.directory = 'tmp' 22 | expect { 23 | @page.get('anything') 24 | }.to raise_error(PageError, /Page\.default_directory/) 25 | end 26 | end 27 | 28 | context "when Page directories have been set" do 29 | before(:all) do 30 | @root = File.expand_path(File.join(File.dirname(__FILE__), "..")) 31 | @test_data_dir = File.join(@root, 'spec/data') 32 | @page.directory = @test_data_dir 33 | @page.default_directory = File.join(@test_data_dir, 'defaults') 34 | @page.plugins_directory = File.join(@root, 'client', 'plugins') 35 | end 36 | 37 | before(:each) do 38 | FileUtils.rm_rf @page.directory 39 | FileUtils.mkdir @page.directory 40 | FileUtils.mkdir @page.default_directory 41 | @page_data = {'foo' => 'bar'} 42 | end 43 | 44 | describe "put" do 45 | context "when page doesn't exist yet" do 46 | it "creates new page" do 47 | File.exist?(File.join(@test_data_dir, 'foo')).should be_false 48 | @page.put('foo', @page_data) 49 | File.exist?(File.join(@test_data_dir, 'foo')).should be_true 50 | end 51 | 52 | it "returns the page" do 53 | @page.put('foo', @page_data).should == @page_data 54 | end 55 | end 56 | 57 | context "when page already exists" do 58 | it "updates the page" do 59 | @page.put('foo', @page_data).should == @page_data 60 | new_data = {'buzz' => 'fuzz'} 61 | @page.put('foo', new_data) 62 | @page.get('foo').should == new_data 63 | end 64 | end 65 | end 66 | 67 | describe "get" do 68 | context "when page exists" do 69 | it "returns the page" do 70 | @page.put('foo', @page_data).should == @page_data 71 | @page.get('foo').should == @page_data 72 | end 73 | end 74 | 75 | context "when page does not exist" do 76 | it "creates a factory page" do 77 | RandomId.stub(:generate).and_return('fake-id') 78 | foo_data = @page.get('foo') 79 | foo_data['title'].should == 'foo' 80 | foo_data['story'].first['id'].should == 'fake-id' 81 | foo_data['story'].first['type'].should == 'factory' 82 | end 83 | end 84 | 85 | context "when page does not exist, but default with same name exists" do 86 | it "copies default page to new page path and returns it" do 87 | default_data = {'default' => 'data'} 88 | @page.put('defaults/foo', default_data) 89 | @page.get('foo').should == default_data 90 | end 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /server/sinatra/stores/couch.rb: -------------------------------------------------------------------------------- 1 | require 'time' # for Time#iso8601 2 | 3 | class CouchStore < Store 4 | class << self 5 | 6 | attr_writer :db # used by specs 7 | 8 | def db 9 | unless @db 10 | couchdb_server = ENV['COUCHDB_URL'] || raise('please set ENV["COUCHDB_URL"]') 11 | @db = CouchRest.database!("#{couchdb_server}/sfw") 12 | begin 13 | @db.save_doc "_id" => "_design/recent-changes", :views => {} 14 | rescue RestClient::Conflict 15 | # design document already exists, do nothing 16 | end 17 | end 18 | @db 19 | end 20 | 21 | ### GET 22 | 23 | def get_text(path) 24 | path = relative_path(path) 25 | begin 26 | db.get(path)['data'] 27 | rescue RestClient::ResourceNotFound 28 | nil 29 | end 30 | end 31 | 32 | def get_blob(path) 33 | blob = get_text path 34 | Base64.decode64 blob if blob 35 | end 36 | 37 | ### PUT 38 | 39 | def put_text(path, text, metadata={}) 40 | path = relative_path(path) 41 | metadata = metadata.each{ |k,v| metadata[k] = relative_path(v) } 42 | attrs = { 43 | 'data' => text, 44 | 'updated_at' => Time.now.utc.iso8601 45 | }.merge! metadata 46 | 47 | begin 48 | db.save_doc attrs.merge('_id' => path) 49 | rescue RestClient::Conflict 50 | doc = db.get path 51 | doc.merge! attrs 52 | doc.save 53 | end 54 | text 55 | end 56 | 57 | def put_blob(path, blob) 58 | put_text path, Base64.strict_encode64(blob) 59 | blob 60 | end 61 | 62 | ### COLLECTIONS 63 | 64 | def annotated_pages(pages_dir) 65 | changes = pages pages_dir 66 | changes.map do |change| 67 | page = JSON.parse change['value']['data'] 68 | page.merge! 'updated_at' => Time.parse(change['value']['updated_at']) 69 | page.merge! 'name' => change['value']['name'] 70 | page 71 | end 72 | end 73 | 74 | ### UTILITY 75 | 76 | def pages(pages_dir) 77 | pages_dir = relative_path pages_dir 78 | pages_dir_safe = CGI.escape pages_dir 79 | begin 80 | db.view("recent-changes/#{pages_dir_safe}")['rows'] 81 | rescue RestClient::ResourceNotFound 82 | create_view 'recent-changes', pages_dir 83 | db.view("recent-changes/#{pages_dir_safe}")['rows'] 84 | end 85 | end 86 | 87 | def create_view(design_name, view_name) 88 | design = db.get "_design/#{design_name}" 89 | design['views'][view_name] = { 90 | :map => " 91 | function(doc) { 92 | if (doc.directory == '#{view_name}') 93 | emit(doc._id, doc) 94 | } 95 | " 96 | } 97 | design.save 98 | end 99 | 100 | def farm?(_) 101 | !!ENV['FARM_MODE'] 102 | end 103 | 104 | def mkdir(_) 105 | # do nothing 106 | end 107 | 108 | def exists?(path) 109 | !(get_text path).nil? 110 | end 111 | 112 | def relative_path(path) 113 | raise "Please set @app_root" unless @app_root 114 | path.match(%r[^#{Regexp.escape @app_root}/?(.+?)$]) ? $1 : path 115 | end 116 | 117 | end 118 | 119 | end 120 | 121 | 122 | -------------------------------------------------------------------------------- /server/sinatra/ReadMe.md: -------------------------------------------------------------------------------- 1 | Server Goals 2 | ============ 3 | 4 | The server participates in a peer-to-peer exchange of page content and page metadata. 5 | It is expected to be mostly-on so that it can support the needs of peers and anticipate the needs of ux clients of this server. 6 | In summary, the server's peer-to-peer side exists to: 7 | 8 | * Encourage the deployment of independently owned content stores. 9 | * Support community among owners through systematic sharing of content. 10 | 11 | 12 | Customizing your Server 13 | ======================= 14 | 15 | The distribution contains default files. They will be copied the first time 16 | they're requested, if you don't install your own. These are: 17 | 18 | default-data/pages/welcome-visitors 19 | default-data/status/favicon.png (unused, see below) 20 | 21 | The first is the usual welcome page offered as the server's home page. 22 | The second is a 32x32 png gradient that is used to identify your server 23 | in bookmarks, browser tabs, page headings and journal entries. 24 | 25 | You can revise the welcome page by editing your copy here: 26 | 27 | data/pages/welcome-visitors 28 | 29 | A suitable random gradient will be generated for you. 30 | You can remove or replace it here: 31 | 32 | data/status/favicon.png 33 | 34 | 35 | Launching the Server 36 | ==================== 37 | 38 | We're now using Ruby 1.9.2 which we manage with rvm. Launch the server with the following bundler commands: 39 | 40 | rvm 1.9.2 41 | bundle exec rackup -s thin -p 1111 42 | 43 | Hosting a Server Farm 44 | ===================== 45 | 46 | The server can host separate pages and status directories for a number of virtual hosts. Enable this by creating the subdirectory: 47 | 48 | data/farm 49 | 50 | or by setting the environment variable 51 | 52 | FARM_MODE=true 53 | 54 | The server will create subdirectories with farm for each virtual host name and locate pages and status directories within that. 55 | 56 | Recursive Server Calls 57 | ====================== 58 | 59 | Federated sites hosted in the same farm can cause recursive web requests. 60 | This is an issue for certain rack servers, notably thin, which is widely used in production rack setups. 61 | If you have a standard server configuration, in which all traffic coming to *.my-sfw-farm.org will be handled by 62 | a single server, you can serve page json and favicons in the context of the current request 63 | (instead of generating an additional HTTP request) 64 | by setting the environment variable: 65 | 66 | FARM_DOMAINS=my-sfw-farm.org 67 | 68 | Your server can handle multiple domains by comma-separating them: 69 | 70 | FARM_DOMAINS=my-sfw-farm.org,fedwiki.jacksmith.com 71 | 72 | With this setup, pages and favicons will be served more efficiently, as well as being friendly to single-threaded servers like thin. 73 | 74 | Alternately, you can use webrick, which handles recursive calls out of the box. Launch it with this command: 75 | 76 | bundle exec rackup -s webrick -p 1111 77 | 78 | CouchDB 79 | ======= 80 | 81 | By default, all pages, favicons, and server claims are stored in the server's local filesystem. 82 | If you prefer to use CouchDB for storage, set two environment variables: 83 | 84 | STORE_TYPE=CouchStore 85 | COUCHDB_URL=https://username:password@some-couchdb-host.com 86 | 87 | If you want to run a farm with CouchDB, be sure to set the environment variable 88 | 89 | FARM_MODE=true 90 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | RubyInline (3.12.2) 5 | ZenTest (~> 4.3) 6 | ZenTest (4.6.0) 7 | addressable (2.3.2) 8 | archive-tar-minitar (0.5.2) 9 | capybara (2.0.2) 10 | mime-types (>= 1.16) 11 | nokogiri (>= 1.3.3) 12 | rack (>= 1.0.0) 13 | rack-test (>= 0.5.4) 14 | selenium-webdriver (~> 2.0) 15 | xpath (~> 1.0.0) 16 | childprocess (0.3.8) 17 | ffi (~> 1.0, >= 1.0.11) 18 | columnize (0.3.6) 19 | couchrest (1.1.3) 20 | mime-types (~> 1.15) 21 | multi_json (~> 1.0) 22 | rest-client (~> 1.6.1) 23 | daemons (1.1.9) 24 | diff-lcs (1.1.3) 25 | eventmachine (1.0.0) 26 | eventmachine (1.0.0-java) 27 | eventmachine (1.0.0-x86-mingw32) 28 | ffi (1.4.0) 29 | ffi (1.4.0-java) 30 | haml (4.0.0) 31 | tilt 32 | json (1.7.7) 33 | json (1.7.7-java) 34 | launchy (2.2.0) 35 | addressable (~> 2.3) 36 | launchy (2.2.0-java) 37 | addressable (~> 2.3) 38 | spoon (~> 0.0.1) 39 | libwebsocket (0.1.7.1) 40 | addressable 41 | websocket 42 | linecache (0.46) 43 | rbx-require-relative (> 0.0.4) 44 | linecache19 (0.5.12) 45 | ruby_core_source (>= 0.1.4) 46 | memcache-client (1.8.5) 47 | mime-types (1.21) 48 | multi_json (1.6.1) 49 | nokogiri (1.5.6) 50 | nokogiri (1.5.6-java) 51 | nokogiri (1.5.6-x86-mingw32) 52 | png (1.2.0) 53 | rack (1.5.2) 54 | rack-test (0.5.6) 55 | rack (>= 1.0) 56 | rake (10.0.3) 57 | rbx-require-relative (0.0.9) 58 | rest-client (1.6.7) 59 | mime-types (>= 1.16) 60 | rspec (2.4.0) 61 | rspec-core (~> 2.4.0) 62 | rspec-expectations (~> 2.4.0) 63 | rspec-mocks (~> 2.4.0) 64 | rspec-core (2.4.0) 65 | rspec-expectations (2.4.0) 66 | diff-lcs (~> 1.1.2) 67 | rspec-mocks (2.4.0) 68 | ruby-debug (0.10.4) 69 | columnize (>= 0.1) 70 | ruby-debug-base (~> 0.10.4.0) 71 | ruby-debug-base (0.10.4) 72 | linecache (>= 0.3) 73 | ruby-debug-base19 (0.11.25) 74 | columnize (>= 0.3.1) 75 | linecache19 (>= 0.5.11) 76 | ruby_core_source (>= 0.1.4) 77 | ruby-debug19 (0.11.6) 78 | columnize (>= 0.3.1) 79 | linecache19 (>= 0.5.11) 80 | ruby-debug-base19 (>= 0.11.19) 81 | ruby-openid (2.2.3) 82 | ruby_core_source (0.1.5) 83 | archive-tar-minitar (>= 0.5.2) 84 | rubyzip (0.9.9) 85 | sass (3.2.5) 86 | selenium-webdriver (2.22.2) 87 | childprocess (>= 0.2.5) 88 | ffi (~> 1.0) 89 | libwebsocket (~> 0.1.3) 90 | multi_json (~> 1.0) 91 | rubyzip 92 | sinatra (1.2.6) 93 | rack (~> 1.1) 94 | tilt (>= 1.2.2, < 2.0) 95 | spoon (0.0.1) 96 | thin (1.5.0) 97 | daemons (>= 1.0.9) 98 | eventmachine (>= 0.12.6) 99 | rack (>= 1.0.0) 100 | tilt (1.3.3) 101 | websocket (1.0.7) 102 | xpath (1.0.0) 103 | nokogiri (~> 1.3) 104 | 105 | PLATFORMS 106 | java 107 | ruby 108 | x86-mingw32 109 | 110 | DEPENDENCIES 111 | RubyInline 112 | ZenTest (<= 4.6.0) 113 | capybara 114 | couchrest 115 | haml 116 | json 117 | launchy 118 | memcache-client 119 | png 120 | rack-test (= 0.5.6) 121 | rake 122 | rest-client 123 | rspec (= 2.4.0) 124 | rspec-core (= 2.4.0) 125 | rspec-expectations (= 2.4.0) 126 | rspec-mocks (= 2.4.0) 127 | ruby-debug 128 | ruby-debug19 129 | ruby-openid 130 | sass 131 | selenium-webdriver (= 2.22.2) 132 | sinatra (= 1.2.6) 133 | thin 134 | -------------------------------------------------------------------------------- /browser-extensions/Chrome/Wiki/activity.js: -------------------------------------------------------------------------------- 1 | // ignore the inner frames 2 | if (window.top === window) { 3 | 4 | (function(){ 5 | // NOTE: Decided to keep this script small, so no jquery 6 | var $__ = function(cls,idx) { 7 | var c 8 | if (!(cls&&(c=document.getElementsByClassName(cls))&&(c.length>0))) { 9 | return; } 10 | if (idx >= 0) { idx = Math.min(idx, c.length-1); } 11 | return c[(idx<0)?(c.length-1):idx]; } 12 | var $$ = function(cls) { return $__(cls,-1); } 13 | var $_ = function(cls) { return $__(cls, 0); } 14 | var getSelection = function() { 15 | var el = document; 16 | var r = "", s = el.getSelection(), i, h; 17 | if ((s.type==="Range") && (s.rangeCount)) { 18 | for(var i = 0; i < s.rangeCount; i++) { 19 | if ((s=s.getRangeAt(i))&&(s=s.cloneContents())&&(s=s.childNodes)) { 20 | for( i = 0; i < s.length; i ++ ) { 21 | if (r.length) { r+= "\r\n"; } 22 | r += (h=s[i]["innerText"])?h:""; } } } } 23 | else { 24 | r = el.body["innerText"]; } 25 | return r; 26 | }; 27 | 28 | var dblClick = function(e) { 29 | var v = document.createEvent("MouseEvents"); 30 | v.initMouseEvent("dblclick", true, true, window, 31 | 0, 0, 0, 0, 0, false, false, false, false, 0, null); 32 | e.dispatchEvent(v); }; 33 | var persistContent = function(c) { 34 | if (!c) { return; } 35 | var p = c.replace(/(?:\s{0,}(?:\r\n|\r|\n)\s{0,}){1,}/gi, "\n").split("\n"); 36 | var t, injectParagraph = function() { 37 | if ( !p.length ) { 38 | if (t) { 39 | window.clearInterval(t); t = null }; 40 | return; } 41 | var c = p.shift().trim(); if (!c.length) { return; } 42 | e = $_("factory"); 43 | if (!e) { 44 | if (e = $$("add-factory")) { 45 | e.click(); } 46 | if (! (e = $_("factory") ) ) { 47 | return; } }; 48 | dblClick(e); 49 | if ((e=e.getElementsByTagName("TEXTAREA")) && (e.length>0) && (e=e[0])) { 50 | e.value=c; e.blur(); } }; 51 | t = window.setInterval( injectParagraph, 100 ); 52 | }; 53 | 54 | var hook = function() { 55 | if (document.readyState != "complete") { return; } 56 | if (interval) { window.clearInterval(interval); interval = null; } 57 | chrome.extension.onRequest.addListener( 58 | function(p,sender,reply) { 59 | if (p && p.name && reply) { 60 | // handle main window requests here 61 | switch(p.name) { 62 | case "clip": 63 | reply({"content":getSelection()}); 64 | break; 65 | case "persist": 66 | try { persistContent( p.content ); } catch(e) {;} 67 | reply({}); 68 | break; 69 | } 70 | } 71 | }); 72 | // give the page a half a sec, before injecting text 73 | // NOTE: we won't be doing this if the page script could 74 | // notify us when the page is fully loaded and interactive 75 | var t = window.setTimeout( function() { 76 | if (t) { window.clearTimeout(t); } 77 | chrome.extension.sendRequest(undefined, {name:"fetch"}, function(r){ 78 | if ((r)&&(r=r.content)) { 79 | persistContent(r); } } ); }, 500 ); 80 | }; 81 | var interval = window.setInterval(hook, 500); 82 | })(); 83 | } 84 | -------------------------------------------------------------------------------- /server/Wikiduino/ReadMe.md: -------------------------------------------------------------------------------- 1 | Wikiduino: Federated Wiki for Arduino 2 | ===================================== 3 | 4 | The Arduino is an open-source hardware and software platform designed for 5 | artists and experimenters. Arduino boards employ one of several Atmel AVR 8-bit microcontrollers 6 | along with an FDDI USB to serial converter for device programming and software debugging 7 | from a custom made for purpose development environment. The Wikiduino configuration 8 | adds to this an Ethernet adapter daughter card (called a shield) and a variety of sensors. 9 | 10 | The source code for Wikiduino exists in a single file which can be copied and pasted into the Arduino IDE or cloned directly from GitHub: 11 | 12 | * Wikiduino.ino for use with release 1.0 or later 13 | 14 | System Operation 15 | ================ 16 | 17 | The Arduino operating loop calls two service routines at high frequency: 18 | 19 | * sample() -- Acquire and store new sensor data every second or so 20 | * serve() -- Report saved data in response to server requests 21 | 22 | Adding new sensors requires modifying the sample() routine to acquire new data and modifying the serve() routine to report newly sampled sensor data when requested via web service. 23 | 24 | Installing Wikiduino in a new network environment requires modifying network addresses 25 | and recompiling the program as is routine in the Arduino environment. 26 | 27 | Device Limitations 28 | ================== 29 | 30 | Wikiduino was built as much as a proof of concept as anything. 31 | We struggled with Arduino's weak string manipulation and found that the device simply failed when its modest memory was exhausted. 32 | Our solution was to chop literal strings into words that could be reassembled from shared parts. 33 | We understand that [pjrc.com](http://pjrc.com/teensy/td_libs.html) distributes an Arduino compatible String Library which might relieve these problems. 34 | 35 | Initial Deployment 36 | ================== 37 | 38 | Our first deployment has been in a remote soil temperature sensing application. 39 | We use Maxium DS18B20 digital thermometers on 20 to 50 foot runs of CAT-3 cable to reach the soil plots of interest. 40 | We also sense environmental conditions in the garden shed including air temperature, battery voltage and ambient light. 41 | 42 | Our directional WiFi transceiver draws several times the current as the Arduino/Ethernet setup. 43 | We've adopted the policy of powering down the radio after five minutes of activity each hour. 44 | The server software continues to run during this power-save period and could be accumulating data to be reported later. 45 | 46 | We've described this installation in two DorkbotPDX blog posts: 47 | 48 | * http://dorkbotpdx.org/blog/wardcunningham/remote_sensing_with_federated_wiki 49 | * http://dorkbotpdx.org/blog/wardcunningham/wikiduino_deployed 50 | 51 | A few more pointers to background work can be found in [issue #94](https://github.com/WardCunningham/Smallest-Federated-Wiki/issues/94) discussion. 52 | 53 | Retrospective 54 | ============= 55 | 56 | We've struggled to make Wikiduino serve content to the web from its placement in a remote environment. 57 | Another approach would be to make the Arduino a client of some cloud-based server which would be the world's point of contact. The first approach met our goal of making an Arduino implementation of a Smallest Federated Wiki server. We acknowledge some pluses and minuses of our configuration: 58 | 59 | * Plus: Several of us can retrieve information from the site without undue coordination. 60 | * Plus: Additional sensor channels can be added with modification only at the server. 61 | * Minus: Our power-saving strategy requires continually resetting the server clock to avoid drift. 62 | * Minus: Direct internet access to the server requires our maintaining a tunnel through a firewall. 63 | 64 | We also find it ironic that our WiFi transceiver has within it a full linux implementation capabable of remote administration during the periods that the 8-bit Arduino allows it to operate. 65 | -------------------------------------------------------------------------------- /client/test/mocha.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | body { 4 | font: 20px/1.5 "Helvetica Neue", Helvetica, Arial, sans-serif; 5 | padding: 60px 50px; 6 | } 7 | 8 | #mocha ul, #mocha li { 9 | margin: 0; 10 | padding: 0; 11 | } 12 | 13 | #mocha ul { 14 | list-style: none; 15 | } 16 | 17 | #mocha h1, #mocha h2 { 18 | margin: 0; 19 | } 20 | 21 | #mocha h1 { 22 | margin-top: 15px; 23 | font-size: 1em; 24 | font-weight: 200; 25 | } 26 | 27 | #mocha h1 a { 28 | text-decoration: none; 29 | color: inherit; 30 | } 31 | 32 | #mocha h1 a:hover { 33 | text-decoration: underline; 34 | } 35 | 36 | #mocha .suite .suite h1 { 37 | margin-top: 0; 38 | font-size: .8em; 39 | } 40 | 41 | .hidden { 42 | display: none; 43 | } 44 | 45 | #mocha h2 { 46 | font-size: 12px; 47 | font-weight: normal; 48 | cursor: pointer; 49 | } 50 | 51 | #mocha .suite { 52 | margin-left: 15px; 53 | } 54 | 55 | #mocha .test { 56 | margin-left: 15px; 57 | overflow: hidden; 58 | } 59 | 60 | #mocha .test.pending:hover h2::after { 61 | content: '(pending)'; 62 | font-family: arial; 63 | } 64 | 65 | #mocha .test.pass.medium .duration { 66 | background: #C09853; 67 | } 68 | 69 | #mocha .test.pass.slow .duration { 70 | background: #B94A48; 71 | } 72 | 73 | #mocha .test.pass::before { 74 | content: '✓'; 75 | font-size: 12px; 76 | display: block; 77 | float: left; 78 | margin-right: 5px; 79 | color: #00d6b2; 80 | } 81 | 82 | #mocha .test.pass .duration { 83 | font-size: 9px; 84 | margin-left: 5px; 85 | padding: 2px 5px; 86 | color: white; 87 | -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.2); 88 | -moz-box-shadow: inset 0 1px 1px rgba(0,0,0,.2); 89 | box-shadow: inset 0 1px 1px rgba(0,0,0,.2); 90 | -webkit-border-radius: 5px; 91 | -moz-border-radius: 5px; 92 | -ms-border-radius: 5px; 93 | -o-border-radius: 5px; 94 | border-radius: 5px; 95 | } 96 | 97 | #mocha .test.pass.fast .duration { 98 | display: none; 99 | } 100 | 101 | #mocha .test.pending { 102 | color: #0b97c4; 103 | } 104 | 105 | #mocha .test.pending::before { 106 | content: '◦'; 107 | color: #0b97c4; 108 | } 109 | 110 | #mocha .test.fail { 111 | color: #c00; 112 | } 113 | 114 | #mocha .test.fail pre { 115 | color: black; 116 | } 117 | 118 | #mocha .test.fail::before { 119 | content: '✖'; 120 | font-size: 12px; 121 | display: block; 122 | float: left; 123 | margin-right: 5px; 124 | color: #c00; 125 | } 126 | 127 | #mocha .test pre.error { 128 | color: #c00; 129 | max-height: 300px; 130 | overflow: auto; 131 | } 132 | 133 | #mocha .test pre { 134 | display: block; 135 | float: left; 136 | clear: left; 137 | font: 12px/1.5 monaco, monospace; 138 | margin: 5px; 139 | padding: 15px; 140 | border: 1px solid #eee; 141 | border-bottom-color: #ddd; 142 | -webkit-border-radius: 3px; 143 | -webkit-box-shadow: 0 1px 3px #eee; 144 | -moz-border-radius: 3px; 145 | -moz-box-shadow: 0 1px 3px #eee; 146 | } 147 | 148 | #mocha .test h2 { 149 | position: relative; 150 | } 151 | 152 | #mocha .test a.replay { 153 | position: absolute; 154 | top: 3px; 155 | right: 0; 156 | text-decoration: none; 157 | vertical-align: middle; 158 | display: block; 159 | width: 15px; 160 | height: 15px; 161 | line-height: 15px; 162 | text-align: center; 163 | background: #eee; 164 | font-size: 15px; 165 | -moz-border-radius: 15px; 166 | border-radius: 15px; 167 | -webkit-transition: opacity 200ms; 168 | -moz-transition: opacity 200ms; 169 | transition: opacity 200ms; 170 | opacity: 0.3; 171 | color: #888; 172 | } 173 | 174 | #mocha .test:hover a.replay { 175 | opacity: 1; 176 | } 177 | 178 | #mocha-report.pass .test.fail { 179 | display: none; 180 | } 181 | 182 | #mocha-report.fail .test.pass { 183 | display: none; 184 | } 185 | 186 | #mocha-error { 187 | color: #c00; 188 | font-size: 1.5 em; 189 | font-weight: 100; 190 | letter-spacing: 1px; 191 | } 192 | 193 | #mocha-stats { 194 | position: fixed; 195 | top: 15px; 196 | right: 10px; 197 | font-size: 12px; 198 | margin: 0; 199 | color: #888; 200 | } 201 | 202 | #mocha-stats .progress { 203 | float: right; 204 | padding-top: 0; 205 | } 206 | 207 | #mocha-stats em { 208 | color: black; 209 | } 210 | 211 | #mocha-stats a { 212 | text-decoration: none; 213 | color: inherit; 214 | } 215 | 216 | #mocha-stats a:hover { 217 | border-bottom: 1px solid #eee; 218 | } 219 | 220 | #mocha-stats li { 221 | display: inline-block; 222 | margin: 0 5px; 223 | list-style: none; 224 | padding-top: 11px; 225 | } 226 | 227 | code .comment { color: #ddd } 228 | code .init { color: #2F6FAD } 229 | code .string { color: #5890AD } 230 | code .keyword { color: #8A6343 } 231 | code .number { color: #2F6FAD } 232 | -------------------------------------------------------------------------------- /browser-extensions/Chrome/Wiki/runtime.js: -------------------------------------------------------------------------------- 1 | 2 | // ignore the inner frames 3 | if (window.top === window) { 4 | 5 | var undef; 6 | 7 | // adds few helper methods to a string 8 | (function enhanceString() { 9 | var sp = String.prototype; 10 | if (!sp.isWhitespace) { 11 | sp.isWhitespace = function() { 12 | if (!this) { return true; } 13 | return (!this.replace(/\s+/gi, "")); 14 | }; 15 | }; 16 | if (!sp.isEmpty) { 17 | sp.isEmpty = function() { 18 | return (!this.length); 19 | }; 20 | }; 21 | if (!sp.ltrim) { 22 | sp.ltrim = function() { 23 | return this.replace(/^\s+/gi, ""); 24 | }; 25 | }; 26 | if (!sp.rtrim) { 27 | sp.rtrim = function() { 28 | return this.replace(/\s+$/gi, ""); 29 | }; 30 | }; 31 | if (!sp.trim) { 32 | sp.trim = function() { 33 | return this.ltrim().rtrim(); 34 | }; 35 | }; 36 | if (!sp.startsWith) { 37 | sp.startsWith = function(arg, caseSensitive) { 38 | if (!arg) return false; 39 | arg = arg.toString(); 40 | if (arg.length <= this.length) { 41 | var s = this; 42 | if (!caseSensitive) { arg = arg.toLowerCase(); s = s.toLowerCase(); } 43 | return (s.substr(0, arg.length) == arg); 44 | } 45 | else { 46 | return false; 47 | }; 48 | }; 49 | }; 50 | if (!sp.endsWith) { 51 | sp.endsWith = function(arg, caseSensitive) { 52 | if (!arg) return false; 53 | arg = arg.toString(); 54 | if (arg.length <= this.length) { 55 | var s = this; 56 | if (!caseSensitive) { arg = arg.toLowerCase(); s = s.toLowerCase(); } 57 | return (s.substr(s.length - arg.length) == arg); 58 | } 59 | else { 60 | return false; 61 | }; 62 | }; 63 | }; 64 | if (!sp.paragraph) { 65 | sp.paragraph = function(index, sizeLimit) { 66 | if ((!index) || isNaN(index)) { index = 1; } 67 | if (!sizeLimit || isNaN(sizeLimit)) { sizeLimit = -1; } 68 | var m = this.match(new RegExp("^(?:\\s{0,}([^\\n]{1,})\\n){" + index + "," + index + "}")); 69 | if (m = ((m.length > 1) ? m[m.length - 1] : undef)) { 70 | if ((sizeLimit > 0) && (sizeLimit < m.length)) { m = m.substr(0, sizeLimit) + "..."; }; 71 | }; 72 | return m; 73 | }; 74 | }; 75 | if (!sp.Split) { // VB style split 76 | sp.Split = function(substr, limit) { 77 | if (!limit) { return this.split(substr); } 78 | if (isNaN(limit) || (limit < 1)) { throw "Numerical positive limit expected."; } 79 | var r = [], s = this, i; 80 | while (true) { 81 | if ((r.length + 1 < limit) && ((i = s.indexOf(substr)) > -1)) { 82 | r[r.length] = (i) ? s.substr(0, i) : ""; 83 | s = s.substr(i + 1); 84 | } 85 | else { 86 | r[r.length] = s; 87 | break; 88 | } 89 | } 90 | return r; 91 | } 92 | } 93 | })(); 94 | 95 | // object serialization 96 | (function defineDumpObject() { 97 | if (typeof (dumpObject) == "undefined") { 98 | var trimData = function(d, l) { 99 | if ((!l) || isNaN(l) || l < 4) { return d; } 100 | return (d && d.length > l) ? (d.substr(0, l - 3) + "...") : d; } 101 | window.dumpObject = function(o, deep, limit) { 102 | var retVal = ""; 103 | for (var p in o) { 104 | retVal += p; retVal += ": "; 105 | retVal += (deep && (typeof (o[p]) == "object")) ? dumpObject(o[p], deep, limit) : trimData(o[p],limit); 106 | retVal += "\r\n"; 107 | } 108 | return retVal; 109 | }; 110 | } 111 | })(); 112 | 113 | (function addDebug() { 114 | if (!window["debug"]) { 115 | window["debug"] = { 116 | level: 1, // 0 - no output, 1 - errors, 2 - warnings, 4 - info, flag combinations 117 | print: function(t) { 118 | if (!this.level) { return; } 119 | t = (t) ? t.toString() : ""; 120 | var e = t.startsWith("ERROR:", true); 121 | var w = t.startsWith("WARNING:", true); 122 | var i = ((!e) && (!w)); 123 | if (i && (this.level & 4)) { console.log(t); } 124 | if (w && (this.level & 2)) { console.log(t); } 125 | if (e && (this.level & 1)) { console.log(t); } 126 | } 127 | }; 128 | } 129 | })(); 130 | 131 | // TODO: set to 1 or zero before releasing 132 | debug.level = 1; 133 | 134 | }; 135 | -------------------------------------------------------------------------------- /client/js/jquery-migrate-1.1.1.min.js: -------------------------------------------------------------------------------- 1 | /*! jQuery Migrate v1.1.1 | (c) 2005, 2013 jQuery Foundation, Inc. and other contributors | jquery.org/license */ 2 | jQuery.migrateMute===void 0&&(jQuery.migrateMute=!0),function(e,t,n){function r(n){o[n]||(o[n]=!0,e.migrateWarnings.push(n),t.console&&console.warn&&!e.migrateMute&&(console.warn("JQMIGRATE: "+n),e.migrateTrace&&console.trace&&console.trace()))}function a(t,a,o,i){if(Object.defineProperty)try{return Object.defineProperty(t,a,{configurable:!0,enumerable:!0,get:function(){return r(i),o},set:function(e){r(i),o=e}}),n}catch(s){}e._definePropertyBroken=!0,t[a]=o}var o={};e.migrateWarnings=[],!e.migrateMute&&t.console&&console.log&&console.log("JQMIGRATE: Logging is active"),e.migrateTrace===n&&(e.migrateTrace=!0),e.migrateReset=function(){o={},e.migrateWarnings.length=0},"BackCompat"===document.compatMode&&r("jQuery is not compatible with Quirks Mode");var i=e("",{size:1}).attr("size")&&e.attrFn,s=e.attr,u=e.attrHooks.value&&e.attrHooks.value.get||function(){return null},c=e.attrHooks.value&&e.attrHooks.value.set||function(){return n},l=/^(?:input|button)$/i,d=/^[238]$/,p=/^(?:autofocus|autoplay|async|checked|controls|defer|disabled|hidden|loop|multiple|open|readonly|required|scoped|selected)$/i,f=/^(?:checked|selected)$/i;a(e,"attrFn",i||{},"jQuery.attrFn is deprecated"),e.attr=function(t,a,o,u){var c=a.toLowerCase(),g=t&&t.nodeType;return u&&(4>s.length&&r("jQuery.fn.attr( props, pass ) is deprecated"),t&&!d.test(g)&&(i?a in i:e.isFunction(e.fn[a])))?e(t)[a](o):("type"===a&&o!==n&&l.test(t.nodeName)&&t.parentNode&&r("Can't change the 'type' of an input or button in IE 6/7/8"),!e.attrHooks[c]&&p.test(c)&&(e.attrHooks[c]={get:function(t,r){var a,o=e.prop(t,r);return o===!0||"boolean"!=typeof o&&(a=t.getAttributeNode(r))&&a.nodeValue!==!1?r.toLowerCase():n},set:function(t,n,r){var a;return n===!1?e.removeAttr(t,r):(a=e.propFix[r]||r,a in t&&(t[a]=!0),t.setAttribute(r,r.toLowerCase())),r}},f.test(c)&&r("jQuery.fn.attr('"+c+"') may use property instead of attribute")),s.call(e,t,a,o))},e.attrHooks.value={get:function(e,t){var n=(e.nodeName||"").toLowerCase();return"button"===n?u.apply(this,arguments):("input"!==n&&"option"!==n&&r("jQuery.fn.attr('value') no longer gets properties"),t in e?e.value:null)},set:function(e,t){var a=(e.nodeName||"").toLowerCase();return"button"===a?c.apply(this,arguments):("input"!==a&&"option"!==a&&r("jQuery.fn.attr('value', val) no longer sets properties"),e.value=t,n)}};var g,h,v=e.fn.init,m=e.parseJSON,y=/^(?:[^<]*(<[\w\W]+>)[^>]*|#([\w\-]*))$/;e.fn.init=function(t,n,a){var o;return t&&"string"==typeof t&&!e.isPlainObject(n)&&(o=y.exec(t))&&o[1]&&("<"!==t.charAt(0)&&r("$(html) HTML strings must start with '<' character"),n&&n.context&&(n=n.context),e.parseHTML)?v.call(this,e.parseHTML(e.trim(t),n,!0),n,a):v.apply(this,arguments)},e.fn.init.prototype=e.fn,e.parseJSON=function(e){return e||null===e?m.apply(this,arguments):(r("jQuery.parseJSON requires a valid JSON string"),null)},e.uaMatch=function(e){e=e.toLowerCase();var t=/(chrome)[ \/]([\w.]+)/.exec(e)||/(webkit)[ \/]([\w.]+)/.exec(e)||/(opera)(?:.*version|)[ \/]([\w.]+)/.exec(e)||/(msie) ([\w.]+)/.exec(e)||0>e.indexOf("compatible")&&/(mozilla)(?:.*? rv:([\w.]+)|)/.exec(e)||[];return{browser:t[1]||"",version:t[2]||"0"}},e.browser||(g=e.uaMatch(navigator.userAgent),h={},g.browser&&(h[g.browser]=!0,h.version=g.version),h.chrome?h.webkit=!0:h.webkit&&(h.safari=!0),e.browser=h),a(e,"browser",e.browser,"jQuery.browser is deprecated"),e.sub=function(){function t(e,n){return new t.fn.init(e,n)}e.extend(!0,t,this),t.superclass=this,t.fn=t.prototype=this(),t.fn.constructor=t,t.sub=this.sub,t.fn.init=function(r,a){return a&&a instanceof e&&!(a instanceof t)&&(a=t(a)),e.fn.init.call(this,r,a,n)},t.fn.init.prototype=t.fn;var n=t(document);return r("jQuery.sub() is deprecated"),t},e.ajaxSetup({converters:{"text json":e.parseJSON}});var b=e.fn.data;e.fn.data=function(t){var a,o,i=this[0];return!i||"events"!==t||1!==arguments.length||(a=e.data(i,t),o=e._data(i,t),a!==n&&a!==o||o===n)?b.apply(this,arguments):(r("Use of jQuery.fn.data('events') is deprecated"),o)};var j=/\/(java|ecma)script/i,w=e.fn.andSelf||e.fn.addBack;e.fn.andSelf=function(){return r("jQuery.fn.andSelf() replaced by jQuery.fn.addBack()"),w.apply(this,arguments)},e.clean||(e.clean=function(t,a,o,i){a=a||document,a=!a.nodeType&&a[0]||a,a=a.ownerDocument||a,r("jQuery.clean() is deprecated");var s,u,c,l,d=[];if(e.merge(d,e.buildFragment(t,a).childNodes),o)for(c=function(e){return!e.type||j.test(e.type)?i?i.push(e.parentNode?e.parentNode.removeChild(e):e):o.appendChild(e):n},s=0;null!=(u=d[s]);s++)e.nodeName(u,"script")&&c(u)||(o.appendChild(u),u.getElementsByTagName!==n&&(l=e.grep(e.merge([],u.getElementsByTagName("script")),c),d.splice.apply(d,[s+1,0].concat(l)),s+=l.length));return d});var Q=e.event.add,x=e.event.remove,k=e.event.trigger,N=e.fn.toggle,C=e.fn.live,S=e.fn.die,T="ajaxStart|ajaxStop|ajaxSend|ajaxComplete|ajaxError|ajaxSuccess",M=RegExp("\\b(?:"+T+")\\b"),H=/(?:^|\s)hover(\.\S+|)\b/,A=function(t){return"string"!=typeof t||e.event.special.hover?t:(H.test(t)&&r("'hover' pseudo-event is deprecated, use 'mouseenter mouseleave'"),t&&t.replace(H,"mouseenter$1 mouseleave$1"))};e.event.props&&"attrChange"!==e.event.props[0]&&e.event.props.unshift("attrChange","attrName","relatedNode","srcElement"),e.event.dispatch&&a(e.event,"handle",e.event.dispatch,"jQuery.event.handle is undocumented and deprecated"),e.event.add=function(e,t,n,a,o){e!==document&&M.test(t)&&r("AJAX events should be attached to document: "+t),Q.call(this,e,A(t||""),n,a,o)},e.event.remove=function(e,t,n,r,a){x.call(this,e,A(t)||"",n,r,a)},e.fn.error=function(){var e=Array.prototype.slice.call(arguments,0);return r("jQuery.fn.error() is deprecated"),e.splice(0,0,"error"),arguments.length?this.bind.apply(this,e):(this.triggerHandler.apply(this,e),this)},e.fn.toggle=function(t,n){if(!e.isFunction(t)||!e.isFunction(n))return N.apply(this,arguments);r("jQuery.fn.toggle(handler, handler...) is deprecated");var a=arguments,o=t.guid||e.guid++,i=0,s=function(n){var r=(e._data(this,"lastToggle"+t.guid)||0)%i;return e._data(this,"lastToggle"+t.guid,r+1),n.preventDefault(),a[r].apply(this,arguments)||!1};for(s.guid=o;a.length>i;)a[i++].guid=o;return this.click(s)},e.fn.live=function(t,n,a){return r("jQuery.fn.live() is deprecated"),C?C.apply(this,arguments):(e(this.context).on(t,this.selector,n,a),this)},e.fn.die=function(t,n){return r("jQuery.fn.die() is deprecated"),S?S.apply(this,arguments):(e(this.context).off(t,this.selector||"**",n),this)},e.event.trigger=function(e,t,n,a){return n||M.test(e)||r("Global events are undocumented and deprecated"),k.call(this,e,t,n||document,a)},e.each(T.split("|"),function(t,n){e.event.special[n]={setup:function(){var t=this;return t!==document&&(e.event.add(document,n+"."+e.guid,function(){e.event.trigger(n,null,t,!0)}),e._data(this,n,e.guid++)),!1},teardown:function(){return this!==document&&e.event.remove(document,n+"."+e._data(this,n)),!1}}})}(jQuery,window); 3 | //@ sourceMappingURL=dist/jquery-migrate.min.map -------------------------------------------------------------------------------- /spec/js/jquery.simulate.drag-sortable.js: -------------------------------------------------------------------------------- 1 | (function($) { 2 | /* 3 | * Simulate drag of a JQuery UI sortable list 4 | * Repository: https://github.com/mattheworiordan/jquery.simulate.drag-sortable.js 5 | * Author: http://mattheworiordan.com 6 | * 7 | * options are: 8 | * - move: move item up (positive) or down (negative) by Integer amount 9 | * - handle: selector for the draggable handle element (optional) 10 | * - listItem: selector to limit which sibling items can be used for reordering 11 | * - placeHolder: if a placeholder is used during dragging, we need to consider it's height 12 | * 13 | */ 14 | $.fn.simulateDragSortable = function(options) { 15 | // build main options before element iteration 16 | var opts = $.extend({}, $.fn.simulateDragSortable.defaults, options); 17 | 18 | // iterate and move each matched element 19 | return this.each(function() { 20 | // allow for a drag handle if item is not draggable 21 | var that = this, 22 | handle = opts.handle ? $(this).find(opts.handle)[0] : $(this)[0], 23 | listItem = opts.listItem, 24 | placeHolder = opts.placeHolder, 25 | sibling = $(this), 26 | moveCounter = Math.floor(opts.move), 27 | direction = moveCounter > 0 ? 'down' : 'up', 28 | moveVerticalAmount = 0, 29 | dragPastBy = 0; 30 | 31 | if (moveCounter === 0) { return; } 32 | 33 | while (moveCounter !== 0) { 34 | if (direction === 'down') { 35 | if (sibling.next(listItem).length) { 36 | sibling = sibling.next(listItem); 37 | moveVerticalAmount += sibling.outerHeight(); 38 | } 39 | moveCounter -= 1; 40 | } else { 41 | if (sibling.prev(listItem).length) { 42 | sibling = sibling.prev(listItem); 43 | moveVerticalAmount -= sibling.outerHeight(); 44 | } 45 | moveCounter += 1; 46 | } 47 | } 48 | 49 | var center = findCenter(handle); 50 | var x = Math.floor(center.x), y = Math.floor(center.y); 51 | dispatchEvent(handle, 'mousedown', createEvent('mousedown', handle, { clientX: x, clientY: y })); 52 | // simulate drag start 53 | dispatchEvent(document, 'mousemove', createEvent('mousemove', document, { clientX: x+1, clientY: y+1 })); 54 | 55 | // Sortable is using a fixed height placeholder meaning items jump up and down as you drag variable height items into fixed height placeholder 56 | placeHolder = placeHolder && $(this).parent().find(placeHolder); 57 | if (placeHolder && placeHolder.length) { 58 | // we're going to move past it, and back again 59 | moveVerticalAmount += (direction === 'down' ? -1 : 1) * Math.min($(this).outerHeight() / 2, 5); 60 | // Sortable UI bug when dragging down and place holder exists. You need to drag past by the total height of this 61 | // and then drag back to the right point 62 | dragPastBy = (direction === 'down' ? 1 : -1) * $(this).outerHeight() / 2; 63 | } else { 64 | // no place holder 65 | if (direction === 'down') { 66 | // need to move at least as far as this item and or the last sibling 67 | if ($(this).outerHeight() > $(sibling).outerHeight()) { 68 | moveVerticalAmount += $(this).outerHeight() - $(sibling).outerHeight(); 69 | } 70 | moveVerticalAmount += $(sibling).outerHeight() / 2; 71 | } else { 72 | // move a little extra to ensure item clips into next position 73 | moveVerticalAmount -= Math.min($(this).outerHeight() / 2, 5); 74 | } 75 | } 76 | 77 | if (sibling[0] !== $(this)[0]) { 78 | // step through so that the UI controller can determine when to show the placeHolder 79 | var targetOffset = moveVerticalAmount + dragPastBy; 80 | for (var offset = 0; Math.abs(offset) < Math.abs(targetOffset); offset += (direction === 'down' ? 10 : -10)) { 81 | // drag move 82 | dispatchEvent(document, 'mousemove', createEvent('mousemove', document, { clientX: x, clientY: y + offset })); 83 | } 84 | dispatchEvent(document, 'mousemove', createEvent('mousemove', document, { clientX: x, clientY: y + targetOffset })); 85 | } else { 86 | if (window.console) { 87 | console.log('Could not move as at top or bottom already'); 88 | } 89 | } 90 | 91 | setTimeout(function() { 92 | dispatchEvent(document, 'mousemove', createEvent('mousemove', document, { clientX: x, clientY: y + moveVerticalAmount })); 93 | }, 5); 94 | setTimeout(function() { 95 | dispatchEvent(handle, 'mouseup', createEvent('mouseup', handle, { clientX: x, clientY: y + moveVerticalAmount })); 96 | }, 10); 97 | }); 98 | }; 99 | 100 | function createEvent(type, target, options) { 101 | var evt; 102 | var e = $.extend({ 103 | target: target, 104 | preventDefault: function() { }, 105 | stopImmediatePropagation: function() { }, 106 | stopPropagation: function() { }, 107 | isPropagationStopped: function() { return true; }, 108 | isImmediatePropagationStopped: function() { return true; }, 109 | isDefaultPrevented: function() { return true; }, 110 | bubbles: true, 111 | cancelable: (type != "mousemove"), 112 | view: window, 113 | detail: 0, 114 | screenX: 0, 115 | screenY: 0, 116 | clientX: 0, 117 | clientY: 0, 118 | ctrlKey: false, 119 | altKey: false, 120 | shiftKey: false, 121 | metaKey: false, 122 | button: 0, 123 | relatedTarget: undefined 124 | }, options || {}); 125 | 126 | if ($.isFunction(document.createEvent)) { 127 | evt = document.createEvent("MouseEvents"); 128 | evt.initMouseEvent(type, e.bubbles, e.cancelable, e.view, e.detail, 129 | e.screenX, e.screenY, e.clientX, e.clientY, 130 | e.ctrlKey, e.altKey, e.shiftKey, e.metaKey, 131 | e.button, e.relatedTarget || document.body.parentNode); 132 | } else if (document.createEventObject) { 133 | evt = document.createEventObject(); 134 | $.extend(evt, e); 135 | evt.button = { 0:1, 1:4, 2:2 }[evt.button] || evt.button; 136 | } 137 | return evt; 138 | } 139 | 140 | function dispatchEvent(el, type, evt) { 141 | if (el.dispatchEvent) { 142 | el.dispatchEvent(evt); 143 | } else if (el.fireEvent) { 144 | el.fireEvent('on' + type, evt); 145 | } 146 | return evt; 147 | } 148 | 149 | function findCenter(el) { 150 | var el = $(el), o = el.offset(); 151 | return { 152 | x: o.left + el.outerWidth() / 2, 153 | y: o.top + el.outerHeight() / 2 154 | }; 155 | } 156 | 157 | // 158 | // plugin defaults 159 | // 160 | $.fn.simulateDragSortable.defaults = { 161 | move: 0 162 | }; 163 | })(jQuery); -------------------------------------------------------------------------------- /client/js/d3/d3.behavior.js: -------------------------------------------------------------------------------- 1 | (function(){d3.behavior = {}; 2 | // TODO unbind zoom behavior? 3 | // TODO unbind listener? 4 | d3.behavior.zoom = function() { 5 | var xyz = [0, 0, 0], 6 | event = d3.dispatch("zoom"); 7 | 8 | function zoom() { 9 | this 10 | .on("mousedown.zoom", mousedown) 11 | .on("mousewheel.zoom", mousewheel) 12 | .on("DOMMouseScroll.zoom", dblclick) 13 | .on("dblclick.zoom", dblclick) 14 | .on("touchstart.zoom", touchstart); 15 | 16 | d3.select(window) 17 | .on("mousemove.zoom", d3_behavior_zoomMousemove) 18 | .on("mouseup.zoom", d3_behavior_zoomMouseup) 19 | .on("touchmove.zoom", d3_behavior_zoomTouchmove) 20 | .on("touchend.zoom", d3_behavior_zoomTouchup); 21 | } 22 | 23 | // snapshot the local context for subsequent dispatch 24 | function start() { 25 | d3_behavior_zoomXyz = xyz; 26 | d3_behavior_zoomDispatch = event.zoom.dispatch; 27 | d3_behavior_zoomTarget = this; 28 | d3_behavior_zoomArguments = arguments; 29 | } 30 | 31 | function mousedown() { 32 | start.apply(this, arguments); 33 | d3_behavior_zoomPanning = d3_behavior_zoomLocation(d3.svg.mouse(d3_behavior_zoomTarget)); 34 | d3.event.preventDefault(); 35 | window.focus(); 36 | } 37 | 38 | // store starting mouse location 39 | function mousewheel() { 40 | start.apply(this, arguments); 41 | if (!d3_behavior_zoomZooming) d3_behavior_zoomZooming = d3_behavior_zoomLocation(d3.svg.mouse(d3_behavior_zoomTarget)); 42 | d3_behavior_zoomTo(d3_behavior_zoomDelta() + xyz[2], d3.svg.mouse(d3_behavior_zoomTarget), d3_behavior_zoomZooming); 43 | } 44 | 45 | function dblclick() { 46 | start.apply(this, arguments); 47 | var mouse = d3.svg.mouse(d3_behavior_zoomTarget); 48 | d3_behavior_zoomTo(d3.event.shiftKey ? Math.ceil(xyz[2] - 1) : Math.floor(xyz[2] + 1), mouse, d3_behavior_zoomLocation(mouse)); 49 | } 50 | 51 | // doubletap detection 52 | function touchstart() { 53 | start.apply(this, arguments); 54 | var touches = d3_behavior_zoomTouchup(), 55 | touch, 56 | now = Date.now(); 57 | if ((touches.length === 1) && (now - d3_behavior_zoomLast < 300)) { 58 | d3_behavior_zoomTo(1 + Math.floor(xyz[2]), touch = touches[0], d3_behavior_zoomLocations[touch.identifier]); 59 | } 60 | d3_behavior_zoomLast = now; 61 | } 62 | 63 | zoom.on = function(type, listener) { 64 | event[type].add(listener); 65 | return zoom; 66 | }; 67 | 68 | return zoom; 69 | }; 70 | 71 | var d3_behavior_zoomDiv, 72 | d3_behavior_zoomPanning, 73 | d3_behavior_zoomZooming, 74 | d3_behavior_zoomLocations = {}, // identifier -> location 75 | d3_behavior_zoomLast = 0, 76 | d3_behavior_zoomXyz, 77 | d3_behavior_zoomDispatch, 78 | d3_behavior_zoomTarget, 79 | d3_behavior_zoomArguments; 80 | 81 | function d3_behavior_zoomLocation(point) { 82 | return [ 83 | point[0] - d3_behavior_zoomXyz[0], 84 | point[1] - d3_behavior_zoomXyz[1], 85 | d3_behavior_zoomXyz[2] 86 | ]; 87 | } 88 | 89 | // detect the pixels that would be scrolled by this wheel event 90 | function d3_behavior_zoomDelta() { 91 | 92 | // mousewheel events are totally broken! 93 | // https://bugs.webkit.org/show_bug.cgi?id=40441 94 | // not only that, but Chrome and Safari differ in re. to acceleration! 95 | if (!d3_behavior_zoomDiv) { 96 | d3_behavior_zoomDiv = d3.select("body").append("div") 97 | .style("visibility", "hidden") 98 | .style("top", 0) 99 | .style("height", 0) 100 | .style("width", 0) 101 | .style("overflow-y", "scroll") 102 | .append("div") 103 | .style("height", "2000px") 104 | .node().parentNode; 105 | } 106 | 107 | var e = d3.event, delta; 108 | try { 109 | d3_behavior_zoomDiv.scrollTop = 1000; 110 | d3_behavior_zoomDiv.dispatchEvent(e); 111 | delta = 1000 - d3_behavior_zoomDiv.scrollTop; 112 | } catch (error) { 113 | delta = e.wheelDelta || -e.detail; 114 | } 115 | 116 | return delta * .005; 117 | } 118 | 119 | // Note: Since we don't rotate, it's possible for the touches to become 120 | // slightly detached from their original positions. Thus, we recompute the 121 | // touch points on touchend as well as touchstart! 122 | function d3_behavior_zoomTouchup() { 123 | var touches = d3.svg.touches(d3_behavior_zoomTarget), 124 | i = -1, 125 | n = touches.length, 126 | touch; 127 | while (++i < n) d3_behavior_zoomLocations[(touch = touches[i]).identifier] = d3_behavior_zoomLocation(touch); 128 | return touches; 129 | } 130 | 131 | function d3_behavior_zoomTouchmove() { 132 | var touches = d3.svg.touches(d3_behavior_zoomTarget); 133 | switch (touches.length) { 134 | 135 | // single-touch pan 136 | case 1: { 137 | var touch = touches[0]; 138 | d3_behavior_zoomTo(d3_behavior_zoomXyz[2], touch, d3_behavior_zoomLocations[touch.identifier]); 139 | break; 140 | } 141 | 142 | // double-touch pan + zoom 143 | case 2: { 144 | var p0 = touches[0], 145 | p1 = touches[1], 146 | p2 = [(p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2], 147 | l0 = d3_behavior_zoomLocations[p0.identifier], 148 | l1 = d3_behavior_zoomLocations[p1.identifier], 149 | l2 = [(l0[0] + l1[0]) / 2, (l0[1] + l1[1]) / 2, l0[2]]; 150 | d3_behavior_zoomTo(Math.log(d3.event.scale) / Math.LN2 + l0[2], p2, l2); 151 | break; 152 | } 153 | } 154 | } 155 | 156 | function d3_behavior_zoomMousemove() { 157 | d3_behavior_zoomZooming = null; 158 | if (d3_behavior_zoomPanning) d3_behavior_zoomTo(d3_behavior_zoomXyz[2], d3.svg.mouse(d3_behavior_zoomTarget), d3_behavior_zoomPanning); 159 | } 160 | 161 | function d3_behavior_zoomMouseup() { 162 | if (d3_behavior_zoomPanning) { 163 | d3_behavior_zoomMousemove(); 164 | d3_behavior_zoomPanning = null; 165 | } 166 | } 167 | 168 | function d3_behavior_zoomTo(z, x0, x1) { 169 | var K = Math.pow(2, (d3_behavior_zoomXyz[2] = z) - x1[2]), 170 | x = d3_behavior_zoomXyz[0] = x0[0] - K * x1[0], 171 | y = d3_behavior_zoomXyz[1] = x0[1] - K * x1[1], 172 | o = d3.event, // Events can be reentrant (e.g., focus). 173 | k = Math.pow(2, z); 174 | 175 | d3.event = { 176 | scale: k, 177 | translate: [x, y], 178 | transform: function(sx, sy) { 179 | if (sx) transform(sx, x); 180 | if (sy) transform(sy, y); 181 | } 182 | }; 183 | 184 | function transform(scale, o) { 185 | var domain = scale.__domain || (scale.__domain = scale.domain()), 186 | range = scale.range().map(function(v) { return (v - o) / k; }); 187 | scale.domain(domain).domain(range.map(scale.invert)); 188 | } 189 | 190 | try { 191 | d3_behavior_zoomDispatch.apply(d3_behavior_zoomTarget, d3_behavior_zoomArguments); 192 | } finally { 193 | d3.event = o; 194 | } 195 | 196 | o.preventDefault(); 197 | } 198 | })(); 199 | -------------------------------------------------------------------------------- /spec/server_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/spec_helper' 2 | require 'json' 3 | 4 | # TODO: now that there is a Page spec and an integration spec, we should 5 | # mock and stub the page writing in these tests. 6 | shared_examples_for "Welcome as HTML" do 7 | let!(:versions) { 8 | log = `git log -10 --oneline` 9 | log.split("\n").map{ |e| e.split(' ').first } 10 | } 11 | it "renders the page" do 12 | last_response.status.should == 200 13 | end 14 | 15 | it "has a section with class 'main'" do 16 | @body.should match(/
/) 17 | end 18 | 19 | it "has a div with class 'page' and id 'welcome-visitors'" do 20 | @body.should match(/
/) 21 | end 22 | 23 | it "has the latest commit in the head" do 24 | versions.each do |version| 25 | @body.should match(version) 26 | end 27 | end 28 | end 29 | 30 | describe "GET /" do 31 | before(:all) do 32 | get "/" 33 | @response = last_response 34 | @body = last_response.body 35 | end 36 | 37 | it_behaves_like 'Welcome as HTML' 38 | end 39 | 40 | describe "GET /welcome-visitors.html" do 41 | before(:all) do 42 | get "/welcome-visitors.html" 43 | @response = last_response 44 | @body = last_response.body 45 | end 46 | 47 | it_behaves_like 'Welcome as HTML' 48 | end 49 | 50 | describe "GET /view/welcome-visitors" do 51 | before(:all) do 52 | get "/view/welcome-visitors" 53 | @response = last_response 54 | @body = last_response.body 55 | end 56 | 57 | it_behaves_like 'Welcome as HTML' 58 | end 59 | 60 | describe "GET /view/welcome-visitors/view/indie-web-camp" do 61 | before(:all) do 62 | get "/view/welcome-visitors/view/indie-web-camp" 63 | @response = last_response 64 | @body = last_response.body 65 | end 66 | 67 | it_behaves_like 'Welcome as HTML' 68 | 69 | it "has a div with class 'page' and id 'indie-web-camp'" do 70 | @body.should match(/
/) 71 | end 72 | end 73 | 74 | shared_examples_for "GET to JSON resource" do 75 | it "returns 200" do 76 | @response.status.should == 200 77 | end 78 | 79 | it "returns Content-Type application/json" do 80 | last_response.header["Content-Type"].should == "application/json" 81 | end 82 | 83 | it "returns valid JSON" do 84 | expect { 85 | JSON.parse(@body) 86 | }.should_not raise_error 87 | end 88 | end 89 | 90 | describe "GET /welcome-visitors.json" do 91 | before(:all) do 92 | get "/welcome-visitors.json" 93 | @response = last_response 94 | @body = last_response.body 95 | end 96 | 97 | it_behaves_like "GET to JSON resource" 98 | 99 | context "JSON from GET /welcome-visitors.json" do 100 | before(:all) do 101 | @json = JSON.parse(@body) 102 | end 103 | 104 | it "has a title string" do 105 | @json['title'].class.should == String 106 | end 107 | 108 | it "has a story array" do 109 | @json['story'].class.should == Array 110 | end 111 | 112 | it "has paragraph as first item in story" do 113 | @json['story'].first['type'].should == 'paragraph' 114 | end 115 | 116 | it "has paragraph with text string" do 117 | @json['story'].first['text'].class.should == String 118 | end 119 | end 120 | end 121 | 122 | describe "GET /recent-changes.json" do 123 | def create_sample_pages 124 | page = { 125 | "title" => "A Page", 126 | "story" => [ { "type" => "paragraph", "text" => "Hello test" } ], 127 | "journal" => [ { "type" => "add", "date" => Time.now - 10000 } ] 128 | } 129 | 130 | page_without_journal = { 131 | "title" => "No Journal Here", 132 | "story" => [ { "type" => "paragraph", "text" => "Hello test" } ], 133 | } 134 | 135 | page_without_date_in_journal = { 136 | "title" => "Old journal", 137 | "story" => [ { "type" => "paragraph", "text" => "Hello test" } ], 138 | "journal" => [ {"type" => "add"} ] 139 | } 140 | 141 | pages = { 142 | "a-page" => page, 143 | "page-without-journal" => page_without_journal, 144 | "page-without-date-in-journal" => page_without_date_in_journal 145 | } 146 | 147 | # ==== 148 | 149 | pages_path = File.join TestDirs::TEST_DATA_DIR, 'pages' 150 | FileUtils.rm_f pages_path 151 | FileUtils.mkdir_p pages_path 152 | 153 | pages.each do |name, content| 154 | page_path = File.join(pages_path, name) 155 | File.open(page_path, 'w'){|file| file.write(content.to_json)} 156 | end 157 | 158 | end 159 | 160 | before(:all) do 161 | create_sample_pages 162 | get "/recent-changes.json" 163 | @response = last_response 164 | @body = last_response.body 165 | @json = JSON.parse(@body) 166 | end 167 | 168 | it_behaves_like "GET to JSON resource" 169 | 170 | context "the JSON" do 171 | it "has a title string" do 172 | @json['title'].class.should == String 173 | end 174 | 175 | it "has a story array" do 176 | @json['story'].class.should == Array 177 | end 178 | 179 | it "has the heading 'Within a Minute'" do 180 | @json['story'].first['text'].should == "

Within a Minute

" 181 | @json['story'].first['type'].should == 'paragraph' 182 | end 183 | 184 | it "has a listing of the single recent change" do 185 | @json['story'][1]['slug'].should == "a-page" 186 | @json['story'][1]['title'].should == "A Page" 187 | @json['story'][1]['type'].should == 'reference' 188 | end 189 | 190 | it "does not show page without journal" do 191 | @json['story'].map {|s| s['slug'] }.should_not include("page-without-journal") 192 | end 193 | 194 | it "does not show page with journal but without date" do 195 | pending 196 | @json['story'].map {|s| s['slug'] }.should_not include("page-without-date-in-journal") 197 | end 198 | end 199 | end 200 | 201 | describe "GET /non-existent-test-page" do 202 | before(:all) do 203 | @non_existent_page = "#{TestDirs::TEST_DATA_DIR}/pages/non-existent-test-page" 204 | `rm -f #{@non_existent_page}` 205 | end 206 | 207 | it "should return 404" do 208 | get "/non-existent-test-page.json" 209 | last_response.status.should == 404 210 | end 211 | 212 | end 213 | 214 | describe "PUT /non-existent-test-page" do 215 | before(:all) do 216 | @non_existent_page = "#{TestDirs::TEST_DATA_DIR}/pages/non-existent-test-page" 217 | `rm -f #{@non_existent_page}` 218 | end 219 | 220 | it "should create page" do 221 | action = {'type' => 'create', 'id' => "123foobar", 'item' => {'title' => 'non-existent-test-page'}} 222 | put "/page/non-existent-test-page/action", :action => action.to_json 223 | last_response.status.should == 200 224 | File.exist?(@non_existent_page).should == true 225 | end 226 | end 227 | 228 | describe "PUT /welcome-visitors" do 229 | 230 | it "should respond with 409" do 231 | action = {'type' => 'create', 'id' => "123foobar", 'item' => {'title' => 'welcome-visitors'}} 232 | put "/page/welcome-visitors/action", :action => action.to_json 233 | last_response.status.should == 409 234 | end 235 | 236 | end 237 | 238 | describe "PUT /foo twice" do 239 | it "should return a 409 when recreating existing page" do 240 | page_file = "#{TestDirs::TEST_DATA_DIR}/pages/foo" 241 | File.exist?(page_file).should == false 242 | 243 | action = {'type' => 'create', 'id' => "123foobar", 'item' => {'title' => 'foo'}} 244 | put "/page/foo/action", :action => action.to_json 245 | 246 | last_response.status.should == 200 247 | File.exist?(page_file).should == true 248 | page_file_contents = File.read(page_file) 249 | 250 | action = {'type' => 'create', 'id' => "123foobar", 'item' => {'title' => 'spam'}} 251 | put "/page/foo/action", :action => action.to_json 252 | last_response.status.should == 409 253 | File.read(page_file).should == page_file_contents 254 | end 255 | end 256 | -------------------------------------------------------------------------------- /client/style.css: -------------------------------------------------------------------------------- 1 | .error { 2 | color: #bb0000; } 3 | 4 | a { 5 | text-decoration: none; } 6 | 7 | a img { 8 | border: 0; } 9 | 10 | body { 11 | top: 0; 12 | left: 0; 13 | right: 0; 14 | bottom: 0; 15 | position: absolute; 16 | background: #eeeeee url("/crosses.png"); 17 | overflow: hidden; 18 | padding: 0; 19 | margin: 0; 20 | font-family: "Helvetica Neue", helvetica, Verdana, Arial, Sans; 21 | line-height: 1.3; 22 | color: #333333; } 23 | 24 | .main { 25 | top: 0; 26 | left: 0; 27 | right: 0; 28 | bottom: 0; 29 | position: absolute; 30 | bottom: 60px; 31 | margin: 0; 32 | width: 10000%; } 33 | 34 | footer { 35 | border-top: 1px solid #3d3c43; 36 | box-shadow: inset 0px 0px 7px rgba(0, 0, 0, 0.8); 37 | background: #eeeeee url("/images/noise.png"); 38 | position: fixed; 39 | left: 0; 40 | right: 0; 41 | height: 20px; 42 | padding: 10px; 43 | font-size: 80%; 44 | z-index: 1000; 45 | color: #ccdcd2; } 46 | 47 | footer form { 48 | display: inline; 49 | } 50 | 51 | .neighbor { 52 | float: right; 53 | padding-left: 8px; 54 | width: 16px; 55 | } 56 | 57 | img.remote, 58 | .neighbor img { 59 | width: 16px; 60 | height: 16px; 61 | background-color: #cccccc; 62 | } 63 | 64 | .neighbor .wait { 65 | -webkit-animation: rotatecw 2s linear infinite; 66 | -moz-animation: rotatecw 2s linear infinite; 67 | animation: rotatecw 2s linear infinite; 68 | } 69 | .fetch { 70 | -webkit-animation: rotatecw .5s linear infinite; 71 | -moz-animation: rotatecw .5s linear infinite; 72 | animation: rotatecw .5s linear infinite; 73 | } 74 | .fail { 75 | -webkit-transform: rotate(15deg); 76 | -moz-transform: rotate(15deg); 77 | transform: rotate(15deg); 78 | } 79 | @-webkit-keyframes rotatecw { from { -webkit-transform: rotate(0deg); } to { -webkit-transform: rotate(360deg); } } 80 | @-moz-keyframes rotatecw { from { -webkit-transform: rotate(0deg); } to { -webkit-transform: rotate(360deg); } } 81 | @keyframes rotatecw { from { -webkit-transform: rotate(0deg); } to { -webkit-transform: rotate(360deg); } } 82 | 83 | 84 | header { 85 | top: 0; } 86 | 87 | footer { 88 | bottom: 0; } 89 | 90 | .twins, 91 | .journal, 92 | .footer { 93 | min-height: 1em; 94 | opacity: 1; 95 | } 96 | .twins:hover, 97 | .journal:hover, 98 | .footer:hover { 99 | opacity: 1; 100 | } 101 | 102 | .story { 103 | padding-bottom: 5px; } 104 | 105 | .data, 106 | .chart, 107 | .image { 108 | float: right; 109 | margin-left: 0.4em; 110 | margin-bottom: 0.4em; 111 | background: #eeeeee; 112 | padding: 0.8em; 113 | width: 42%; 114 | } 115 | 116 | .image .thumbnail { 117 | width: 100%; } 118 | 119 | .journal { 120 | width: 420px; 121 | overflow-x: hidden; 122 | margin-top: 2px; 123 | clear: both; 124 | background-color: #eeeeee; 125 | overflow: auto; 126 | padding: 3px; } 127 | 128 | .action.fork { 129 | color: black; } 130 | 131 | .action { 132 | font-size: 0.9em; 133 | background-color: #cccccc; 134 | color: #666666; 135 | text-align: center; 136 | text-decoration: none; 137 | padding: 0.2em; 138 | margin: 3px; 139 | float: left; 140 | width: 18px; } 141 | 142 | .action.separator { 143 | background-color: #eeeeee; 144 | } 145 | 146 | .control-buttons { 147 | float: right; 148 | position: relative; 149 | right: 5px; 150 | overflow-x: visible; 151 | white-space: nowrap; } 152 | 153 | .button { 154 | /*from bootstrap*/ 155 | background-color: #f5f5f5; 156 | *background-color: #e6e6e6; 157 | background-image: -ms-linear-gradient(top, #ffffff, #e6e6e6); 158 | background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), to(#e6e6e6)); 159 | background-image: -webkit-linear-gradient(top, #ffffff, #e6e6e6); 160 | background-image: -o-linear-gradient(top, #ffffff, #e6e6e6); 161 | background-image: linear-gradient(top, #ffffff, #e6e6e6); 162 | background-image: -moz-linear-gradient(top, #ffffff, #e6e6e6); 163 | background-repeat: repeat-x; 164 | border: 1px solid #cccccc; 165 | *border: 0; 166 | border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); 167 | border-color: #e6e6e6 #e6e6e6 #bfbfbf; 168 | border-bottom-color: #b3b3b3; 169 | -webkit-border-radius: 4px; 170 | -moz-border-radius: 4px; 171 | border-radius: 4px; 172 | -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); 173 | -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); 174 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); 175 | 176 | /*custom*/ 177 | display: inline-block; 178 | white-space: normal; 179 | position: relative; 180 | top: 2px; 181 | left: 3px; 182 | font-size: 0.9em; 183 | text-align: center; 184 | text-decoration: none; 185 | padding: 0.2em; 186 | margin-bottom: 2px; 187 | color: #2c3f39; 188 | width: 18px; } 189 | 190 | .button:hover { 191 | color: #d9a513; 192 | text-shadow: 0 0 1px #2c3f39; 193 | box-shadow: 0 0 7px #3d3c43; } 194 | 195 | .button:active { 196 | box-shadow: inset 0 0 7px #3d3c43; } 197 | 198 | .target { 199 | background-color: #ffffcc !important; } 200 | 201 | .report p, 202 | .factory p, 203 | .data p, 204 | .chart p, 205 | .footer a, 206 | .image p, 207 | p.caption { 208 | text-align: center; 209 | margin-bottom: 0; 210 | color: gray; 211 | font-size: 70%; } 212 | 213 | .twins p { 214 | color: gray; 215 | } 216 | 217 | image.remote { 218 | width:16px; 219 | height:16px; 220 | } 221 | 222 | p.readout { 223 | text-align: center; 224 | font-size: 300%; 225 | color: black; 226 | font-weight: bold; 227 | margin: 0; } 228 | 229 | .footer { 230 | clear: both; 231 | margin-bottom: 1em; } 232 | 233 | .page { 234 | float: left; 235 | margin: 8px; 236 | padding: 0 31px; 237 | width: 430px; 238 | background-color: white; 239 | height: 100%; 240 | overflow: auto; 241 | box-shadow: 2px 1px 4px rgba(0, 0, 0, 0.2); } 242 | .page.active { 243 | box-shadow: 2px 1px 24px rgba(0, 0, 0, 0.4); 244 | z-index: 10; } 245 | 246 | .page.plugin { 247 | box-shadow: inset 0px 0px 40px 0px rgba(0,220,0,.5); 248 | } 249 | 250 | .page.local { 251 | box-shadow: inset 0px 0px 40px 0px rgba(220,180,0,.7); 252 | } 253 | 254 | .page.remote { 255 | box-shadow: inset 0px 0px 40px 0px rgba(0,180,220,.5); 256 | } 257 | 258 | .factory, 259 | textarea { 260 | font-size: inherit; 261 | width: 100%; 262 | height: 150px; } 263 | 264 | .clickable:hover { 265 | cursor: pointer; } 266 | 267 | @media all and (max-width: 400px) { 268 | .page { 269 | width: 0.9%; 270 | margin: 0.01%; 271 | padding: 0.04%; } } 272 | ::-webkit-scrollbar { 273 | width: 10px; 274 | height: 10px; 275 | margin-right: 10px; } 276 | 277 | ::-webkit-scrollbar-button:start:decrement, 278 | ::-webkit-scrollbar-button:end:increment { 279 | height: 30px; 280 | display: block; 281 | background-color: transparent; } 282 | 283 | ::-webkit-scrollbar-track-piece { 284 | background-color: #eeeeee; 285 | -webkit-border-radius: 6px; } 286 | 287 | ::-webkit-scrollbar-thumb:vertical { 288 | height: 50px; 289 | background-color: #cccccc; 290 | border: 1px solid #eeeeee; 291 | -webkit-border-radius: 6px; } 292 | 293 | .factory { 294 | clear: both; 295 | margin-top: 5px; 296 | margin-bottom: 5px; 297 | background-color: #eeeeee; } 298 | .factory p { 299 | padding: 10px; } 300 | 301 | .bytebeat .play { 302 | display: inline-block; 303 | height: 14px; 304 | width: 11px; 305 | padding-left: 3px; 306 | font-size: 70%; 307 | color: #999999; 308 | border: 1px solid #999999; 309 | border-radius: 8px; } 310 | .bytebeat .symbol { 311 | color: #990000; } 312 | 313 | .revision { 314 | position: relative; 315 | font-size: 24px; 316 | color: rgba(128,32,16,0.7); #7b2106 317 | font-weight: bold; } 318 | 319 | .revision span { 320 | background: rgba(255,255,255,0.8); 321 | padding: 10px; 322 | position: absolute; 323 | display: block; 324 | text-align: center; 325 | top: -40px; 326 | right: 10px; 327 | -webkit-transform: rotate(-15deg); 328 | -moz-transform: rotate(-15deg); 329 | 330 | } 331 | 332 | .favicon { 333 | position: relative; 334 | margin-bottom: -6px; } 335 | 336 | .ghost { 337 | opacity: 0.6; 338 | border-color: #eef2fe; 339 | } 340 | -------------------------------------------------------------------------------- /browser-extensions/Chrome/Wiki/main.js: -------------------------------------------------------------------------------- 1 | // 2 | // main background script module. 3 | 4 | // ignore the inner frames 5 | if (window.top === window) { 6 | 7 | var _tabs = { 8 | getKey: function(tabId) { 9 | return "_" + String(tabId); }, 10 | cleanup: function(){ 11 | var s = this, m, now = (new Date()).valueOf(); 12 | for(var p in s.messages) { 13 | if ((m=s.messages[p])&&(m["expire"]wiki: Add to your Wiki' }); } 44 | chrome.omnibox.onInputStarted.addListener(function() { 45 | resetDefaultSuggestion(); }); 46 | chrome.omnibox.onInputCancelled.addListener(function() { 47 | resetDefaultSuggestion(); }); 48 | chrome.omnibox.onInputChanged.addListener(function() { 49 | resetDefaultSuggestion(); }); 50 | chrome.omnibox.onInputEntered.addListener(function(text) { 51 | try { chrome.tabs.getSelected(null, execWikiAction(text)); } catch (e) { ; } }); 52 | context.resetDefaultSuggestion(); 53 | } 54 | return context; 55 | })(); 56 | 57 | var isSupportedUrl = function(u) { 58 | return ( u && ( u.startsWith( "http://" ) || u.startsWith( "https://" ) ) && 59 | ( !u.startsWith( _options["wikiUrl"]() ) ) ); }; 60 | 61 | var getWikiTab = function(tabs) { 62 | var wikiUrl = _options["wikiUrl"](); 63 | for (var i = 0; i < tabs.length; i++) { 64 | with (tabs[i]) { 65 | if( url.startsWith(wikiUrl)) { 66 | return tabs[i]; } } } 67 | return null; }; 68 | 69 | var requestStoreToWiki = function(tabId, arg) { 70 | chrome.tabs.sendRequest( 71 | tabId, arg, 72 | function(m){ 73 | // As it turns, if no one listens to our 74 | // background page request, the callback is not 75 | // getting called at all. Hence he deferred parameter 76 | // and the local message cache. 77 | if (!m) { return; } 78 | } ); }; 79 | 80 | var storeToWiki = function(srcTab,dstTab,deferred) { 81 | if (!( srcTab && dstTab ) ) { return; } 82 | var s = srcTab, d = dstTab; 83 | chrome.tabs.sendRequest( s.id, { name: "clip" }, 84 | function(m) { 85 | if (!m) { return; } 86 | var arg = { name: "persist", content: m.content }; 87 | if (deferred) { 88 | _tabs.putMessage( d.id, arg, function(m1,m2){ 89 | return ( m1.content ? String(m1.content) + "\r\n": "" ) + 90 | ( m2.content ? String(m2.content) : "" ); } ); } 91 | else { 92 | requestStoreToWiki( d.id, arg ); } } ); 93 | }; 94 | 95 | var execWikiAction = function(action) { 96 | return function(argTab) { 97 | // TODO: make sure the activity script is loaded. for now just check the 98 | // protocol and assume the script is available 99 | var u; if ( !isSupportedUrl( argTab.url ) ) { 100 | return; } 101 | // locate the wiki container 102 | chrome.windows.getAll( { populate: true }, function(windows){ 103 | var tab; 104 | windows.forEach( function(win) { 105 | if (tab) { return; } 106 | tab = getWikiTab(win.tabs); 107 | }); 108 | if (tab) { // just select 109 | chrome.tabs.update(tab.id, { "selected": true }); 110 | storeToWiki(argTab, tab); } 111 | else { // or navigate to a new one, but be careful with the context 112 | (function(src){ 113 | var s = src; 114 | chrome.tabs.create({ "url": _options["wikiUrl"]() }, 115 | function(t){ storeToWiki( s, t, true ); } ); } )( argTab ) }; 116 | } ); 117 | }; 118 | }; 119 | 120 | var hasStatus = function(tab, changeInfo, status ) { 121 | return ((tab && (tab.status == status)) || 122 | (changeInfo && (changeInfo.status == status))); 123 | }; 124 | 125 | var getUrl = function(tab, changeInfo ) { 126 | return ( ( tab && tab.url ) ? tab.url : 127 | ( ( changeInfo && changeInfo.url ) ? changeInfo.url: "" ) ); 128 | }; 129 | 130 | var hookActivity = function(id) { 131 | chrome.tabs.executeScript(id, { file: "runtime.js" }); 132 | chrome.tabs.executeScript(id, { file: "activity.js" }); 133 | }; 134 | 135 | var setBadge = function(enabled) { 136 | chrome.browserAction.setBadgeText({ "text": enabled ? "+": "" }); 137 | }; 138 | 139 | var handleWindowActivate = function(id) { 140 | if (id===chrome.windows.WINDOW_ID_NONE) { 141 | setBadge( false ); } 142 | else { 143 | chrome.tabs.getSelected( null, function(t){ handleNewTab( null, null, t ) } ); }; 144 | }; 145 | 146 | var handleNewTab = function(tabId, changeInfo, tab) { 147 | if (!tab) { tab = tabId; if (!tab) { return; } } 148 | if (tab.incognito) { return; } 149 | 150 | debug.print("tab " + tab.id.toString() + " changed: " + dumpObject(changeInfo)); 151 | 152 | var u; if (hasStatus(tab, changeInfo, "complete")) { 153 | u = tab.url; 154 | // TODO: ensure content script is loaded. while doind so, silence any errors that may occur 155 | } 156 | // the badge and button state ties to the active window/tab 157 | if (tab.selected) { 158 | setBadge( isSupportedUrl(u) ); } 159 | _tabs.cleanup(); 160 | }; 161 | 162 | var handleTabClose = function(tabId) { 163 | _tabs.getMessage( tabId ); _tabs.cleanup(); 164 | return; }; 165 | 166 | // processes requests from the browser tabs 167 | var handleRequest = function(request, sender, response) { 168 | switch( request.name ) { 169 | case "fetch": 170 | var t, m; if ((t=sender)&&(t=t.tab)&&(t=t.id)) { 171 | if (m=_tabs.getMessage(t)) { 172 | response( m ); } } 173 | break; } 174 | _tabs.cleanup(); 175 | return; }; 176 | 177 | // init the extension 178 | (function() { 179 | with ( chrome.browserAction ) { 180 | onClicked.addListener( execWikiAction("add") ); 181 | setBadgeBackgroundColor({ "color": [255, 0, 0, 255] }); 182 | }; 183 | with (chrome.tabs) { 184 | onRemoved.addListener( handleTabClose ); 185 | onUpdated.addListener( handleNewTab ); 186 | onCreated.addListener( handleNewTab ); 187 | onSelectionChanged.addListener( 188 | function(tabId) { 189 | get(tabId, 190 | function(tab) { 191 | handleNewTab(tab); 192 | }); 193 | }); 194 | }; 195 | with (chrome.windows) { 196 | onFocusChanged.addListener( handleWindowActivate ); 197 | }; 198 | with (chrome.extension) { 199 | onRequest.addListener(handleRequest); 200 | }; 201 | 202 | })(); 203 | 204 | } -------------------------------------------------------------------------------- /server/sinatra/server.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler' 3 | require 'pathname' 4 | require 'pp' 5 | Bundler.require 6 | 7 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 8 | SINATRA_ROOT = File.expand_path(File.dirname(__FILE__)) 9 | APP_ROOT = File.expand_path(File.join(SINATRA_ROOT, "..", "..")) 10 | 11 | Encoding.default_external = Encoding::UTF_8 12 | 13 | require 'server_helpers' 14 | require 'stores/all' 15 | require 'random_id' 16 | require 'page' 17 | require 'favicon' 18 | 19 | require 'openid' 20 | require 'openid/store/filesystem' 21 | 22 | class Controller < Sinatra::Base 23 | set :port, 1111 24 | set :public, File.join(APP_ROOT, "client") 25 | set :views , File.join(SINATRA_ROOT, "views") 26 | set :haml, :format => :html5 27 | set :versions, `git log -10 --oneline` || "no git log" 28 | if ENV.include?('SESSION_STORE') 29 | use ENV['SESSION_STORE'].split('::').inject(Object) { |mod, const| mod.const_get(const) } 30 | else 31 | enable :sessions 32 | end 33 | helpers ServerHelpers 34 | 35 | Store.set ENV['STORE_TYPE'], APP_ROOT 36 | 37 | class << self # overridden in test 38 | def data_root 39 | File.join APP_ROOT, "data" 40 | end 41 | end 42 | 43 | def farm_page(site=request.host) 44 | page = Page.new 45 | page.directory = File.join data_dir(site), "pages" 46 | page.default_directory = File.join APP_ROOT, "default-data", "pages" 47 | page.plugins_directory = File.join APP_ROOT, "client", "plugins" 48 | Store.mkdir page.directory 49 | page 50 | end 51 | 52 | def farm_status(site=request.host) 53 | status = File.join data_dir(site), "status" 54 | Store.mkdir status 55 | status 56 | end 57 | 58 | def data_dir(site) 59 | Store.farm?(self.class.data_root) ? File.join(self.class.data_root, "farm", site) : self.class.data_root 60 | end 61 | 62 | def identity 63 | default_path = File.join APP_ROOT, "default-data", "status", "local-identity" 64 | real_path = File.join farm_status, "local-identity" 65 | id_data = Store.get_hash real_path 66 | id_data ||= Store.put_hash(real_path, FileStore.get_hash(default_path)) 67 | end 68 | 69 | post "/logout" do 70 | session.delete :authenticated 71 | redirect "/" 72 | end 73 | 74 | post '/login' do 75 | begin 76 | root_url = request.url.match(/(^.*\/{2}[^\/]*)/)[1] 77 | identifier_file = File.join farm_status, "open_id.identifier" 78 | identifier = Store.get_text(identifier_file) 79 | unless identifier 80 | identifier = params[:identifier] 81 | end 82 | open_id_request = openid_consumer.begin(identifier) 83 | 84 | redirect open_id_request.redirect_url(root_url, root_url + "/login/openid/complete") 85 | rescue 86 | oops 400, "Trouble starting OpenID
Did you enter a proper endpoint?" 87 | end 88 | end 89 | 90 | get '/login/openid/complete' do 91 | begin 92 | response = openid_consumer.complete(params, request.url) 93 | case response.status 94 | when OpenID::Consumer::FAILURE 95 | oops 401, "Login failure" 96 | when OpenID::Consumer::SETUP_NEEDED 97 | oops 400, "Setup needed" 98 | when OpenID::Consumer::CANCEL 99 | oops 400, "Login cancelled" 100 | when OpenID::Consumer::SUCCESS 101 | id = params['openid.identity'] 102 | id_file = File.join farm_status, "open_id.identity" 103 | stored_id = Store.get_text(id_file) 104 | if stored_id 105 | if stored_id == id 106 | # login successful 107 | authenticate! 108 | else 109 | oops 403, "This is not your wiki" 110 | end 111 | else 112 | Store.put_text id_file, id 113 | # claim successful 114 | authenticate! 115 | end 116 | else 117 | oops 400, "Trouble with OpenID" 118 | end 119 | rescue 120 | oops 400, "Trouble running OpenID
Did you enter a proper endpoint?" 121 | end 122 | end 123 | 124 | get '/system/slugs.json' do 125 | content_type 'application/json' 126 | cross_origin 127 | JSON.pretty_generate(Dir.entries(farm_page.directory).reject{|e|e[0] == '.'}) 128 | end 129 | 130 | get '/favicon.png' do 131 | content_type 'image/png' 132 | headers 'Cache-Control' => "max-age=3600" 133 | cross_origin 134 | Favicon.get_or_create(File.join farm_status, 'favicon.png') 135 | end 136 | 137 | get '/random.png' do 138 | unless authenticated? or (!identified? and !claimed?) 139 | halt 403 140 | return 141 | end 142 | 143 | content_type 'image/png' 144 | path = File.join farm_status, 'favicon.png' 145 | Store.put_blob path, Favicon.create_blob 146 | end 147 | 148 | get '/' do 149 | redirect "/#{identity['root']}.html" 150 | end 151 | 152 | get %r{^/data/([\w -]+)$} do |search| 153 | content_type 'application/json' 154 | cross_origin 155 | pages = Store.annotated_pages farm_page.directory 156 | candidates = pages.select do |page| 157 | datasets = page['story'].select do |item| 158 | item['type']=='data' && item['text'] && item['text'].index(search) 159 | end 160 | datasets.length > 0 161 | end 162 | halt 404 unless candidates.length > 0 163 | JSON.pretty_generate(candidates.first) 164 | end 165 | 166 | get %r{^/([a-z0-9-]+)\.html$} do |name| 167 | halt 404 unless farm_page.exists?(name) 168 | haml :page, :locals => { :page => farm_page.get(name), :page_name => name } 169 | end 170 | 171 | get %r{^((/[a-zA-Z0-9:.-]+/[a-z0-9-]+(_rev\d+)?)+)$} do 172 | elements = params[:captures].first.split('/') 173 | pages = [] 174 | elements.shift 175 | while (site = elements.shift) && (id = elements.shift) 176 | if site == 'view' 177 | pages << {:id => id} 178 | else 179 | pages << {:id => id, :site => site} 180 | end 181 | end 182 | haml :view, :locals => {:pages => pages} 183 | end 184 | 185 | get '/system/plugins.json' do 186 | content_type 'application/json' 187 | cross_origin 188 | plugins = [] 189 | path = File.join(APP_ROOT, "client/plugins") 190 | pathname = Pathname.new path 191 | Dir.glob("#{path}/*/") {|filename| plugins << Pathname.new(filename).relative_path_from(pathname)} 192 | JSON.pretty_generate plugins 193 | end 194 | 195 | get '/system/sitemap.json' do 196 | content_type 'application/json' 197 | cross_origin 198 | pages = Store.annotated_pages farm_page.directory 199 | sitemap = pages.collect {|p| {"slug" => p['name'], "title" => p['title'], "date" => p['updated_at'].to_i*1000, "synopsis" => synopsis(p)}} 200 | JSON.pretty_generate sitemap 201 | end 202 | 203 | get '/system/factories.json' do 204 | content_type 'application/json' 205 | cross_origin 206 | # return "[]" 207 | factories = Dir.glob(File.join(APP_ROOT, "client/plugins/*/factory.json")).collect do |info| 208 | begin 209 | JSON.parse(File.read(info)) 210 | rescue 211 | end 212 | end.reject {|info| info.nil?} 213 | JSON.pretty_generate factories 214 | end 215 | 216 | get %r{^/([a-z0-9-]+)\.json$} do |name| 217 | content_type 'application/json' 218 | serve_page name 219 | end 220 | 221 | error 403 do 222 | 'Access forbidden' 223 | end 224 | 225 | put %r{^/page/([a-z0-9-]+)/action$} do |name| 226 | unless authenticated? or (!identified? and !claimed?) 227 | halt 403 228 | return 229 | end 230 | 231 | action = JSON.parse params['action'] 232 | if site = action['fork'] 233 | # this fork is bundled with some other action 234 | page = JSON.parse RestClient.get("#{site}/#{name}.json") 235 | ( page['journal'] ||= [] ) << { 'type' => 'fork', 'site' => site } 236 | farm_page.put name, page 237 | action.delete 'fork' 238 | elsif action['type'] == 'create' 239 | return halt 409 if farm_page.exists?(name) 240 | page = action['item'].clone 241 | elsif action['type'] == 'fork' 242 | if action['item'] 243 | page = action['item'].clone 244 | action.delete 'item' 245 | else 246 | page = JSON.parse RestClient.get("#{action['site']}/#{name}.json") 247 | end 248 | else 249 | page = farm_page.get(name) 250 | end 251 | 252 | case action['type'] 253 | when 'move' 254 | page['story'] = action['order'].collect{ |id| page['story'].detect{ |item| item['id'] == id } || raise('Ignoring move. Try reload.') } 255 | when 'add' 256 | before = action['after'] ? 1+page['story'].index{|item| item['id'] == action['after']} : 0 257 | page['story'].insert before, action['item'] 258 | when 'remove' 259 | page['story'].delete_at page['story'].index{ |item| item['id'] == action['id'] } 260 | when 'edit' 261 | page['story'][page['story'].index{ |item| item['id'] == action['id'] }] = action['item'] 262 | when 'create', 'fork' 263 | page['story'] ||= [] 264 | else 265 | puts "unfamiliar action: #{action.inspect}" 266 | status 501 267 | return "unfamiliar action" 268 | end 269 | ( page['journal'] ||= [] ) << action 270 | farm_page.put name, page 271 | "ok" 272 | end 273 | 274 | get %r{^/remote/([a-zA-Z0-9:\.-]+)/([a-z0-9-]+)\.json$} do |site, name| 275 | content_type 'application/json' 276 | host = site.split(':').first 277 | if serve_resources_locally?(host) 278 | serve_page(name, host) 279 | else 280 | RestClient.get "#{site}/#{name}.json" do |response, request, result, &block| 281 | case response.code 282 | when 200 283 | response 284 | when 404 285 | halt 404 286 | else 287 | response.return!(request, result, &block) 288 | end 289 | end 290 | end 291 | end 292 | 293 | get %r{^/remote/([a-zA-Z0-9:\.-]+)/favicon.png$} do |site| 294 | content_type 'image/png' 295 | host = site.split(':').first 296 | if serve_resources_locally?(host) 297 | Favicon.get_or_create(File.join farm_status(host), 'favicon.png') 298 | else 299 | RestClient.get "#{site}/favicon.png" 300 | end 301 | end 302 | 303 | not_found do 304 | oops 404, "Page not found" 305 | end 306 | 307 | put '/submit' do 308 | content_type 'application/json' 309 | bundle = JSON.parse params['bundle'] 310 | spawn = "#{(rand*1000000).to_i}.#{request.host}" 311 | site = request.port == 80 ? spawn : "#{spawn}:#{request.port}" 312 | bundle.each do |slug, page| 313 | farm_page(spawn).put slug, page 314 | end 315 | citation = { 316 | "type"=> "reference", 317 | "id"=> RandomId.generate, 318 | "site"=> site, 319 | "slug"=> "recent-changes", 320 | "title"=> "Recent Changes", 321 | "text"=> bundle.collect{|slug, page| "
  • [[#{page['title']||slug}]]"}.join("\n") 322 | } 323 | action = { 324 | "type"=> "add", 325 | "id"=> citation['id'], 326 | "date"=> Time.new.to_i*1000, 327 | "item"=> citation 328 | } 329 | slug = 'recent-submissions' 330 | page = farm_page.get slug 331 | (page['story']||=[]) << citation 332 | (page['journal']||=[]) << action 333 | farm_page.put slug, page 334 | JSON.pretty_generate citation 335 | end 336 | 337 | end 338 | -------------------------------------------------------------------------------- /spec/integration_spec.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/spec_helper' 2 | 3 | require 'pathname' 4 | require 'digest/sha1' 5 | require 'net/http' 6 | 7 | 8 | 9 | describe "loading a page" do 10 | 11 | it "should load the welcome page" do 12 | visit("/") 13 | body.should include("Welcome Visitors") 14 | end 15 | 16 | it "should copy welcome-visitors from the default-data to data" do 17 | File.exist?(File.join(TestDirs::TEST_DATA_DIR, "pages/welcome-visitors")).should == false 18 | visit("/") 19 | body.should include("Welcome Visitors") 20 | File.exist?(File.join(TestDirs::TEST_DATA_DIR, "pages/welcome-visitors")).should == true 21 | end 22 | 23 | it "should load multiple pages at once" do 24 | visit("/view/welcome-visitors/view/multiple-paragraphs") 25 | body.should include("Welcome to the") 26 | end 27 | 28 | it "should load remote page" do 29 | remote = "localhost:#{Capybara.server_port}" 30 | visit("/#{remote}/welcome-visitors") 31 | body.should include("Welcome to the") 32 | end 33 | 34 | it "should load a page from plugins" do 35 | visit("/view/air-temperature") 36 | body.should include("Air Temperature") 37 | end 38 | 39 | end 40 | 41 | class Capybara::Node::Element 42 | def double_click 43 | driver.browser.mouse.double_click(native) 44 | end 45 | 46 | TRIGGER_JS = "$(arguments[0]).trigger(arguments[1]);" 47 | def trigger(event) 48 | driver.browser.execute_script(TRIGGER_JS, native, event) 49 | end 50 | 51 | def drag_down(number) 52 | driver.resynchronize do 53 | driver.browser.execute_script "$(arguments[0]).simulateDragSortable({move: arguments[1]});", native, number 54 | end 55 | end 56 | 57 | def roll_over 58 | trigger "mouseover" 59 | end 60 | 61 | def roll_out 62 | trigger "mouseout" 63 | end 64 | end 65 | 66 | class Capybara::Session 67 | def back 68 | execute_script("window.history.back()") 69 | end 70 | 71 | def load_test_library! 72 | Dir["#{TestDirs::JS_DIR}/*.js"].each do |file| 73 | driver.browser.execute_script File.read(file) 74 | end 75 | end 76 | 77 | AJAX_TIMEOUT_LIMIT = 5 78 | def wait_for_ajax_to_complete! 79 | start = Time.now 80 | while evaluate_script("window.jQuery.active") != 0 do 81 | raise Timeout::Error.new("AJAX request timed out") if Time.now - start > AJAX_TIMEOUT_LIMIT 82 | end 83 | end 84 | 85 | def visit_with_wait_for_ajax(*args) 86 | visit_without_wait_for_ajax(*args) 87 | wait_for_ajax_to_complete! 88 | end 89 | alias_method :visit_without_wait_for_ajax, :visit 90 | alias_method :visit, :visit_with_wait_for_ajax 91 | end 92 | 93 | def pause 94 | STDIN.read(1) 95 | end 96 | 97 | module IntegrationHelpers 98 | def journal 99 | page.find(".journal").all(".action") 100 | end 101 | 102 | def first_paragraph 103 | page.find(".paragraph:first") 104 | end 105 | 106 | end 107 | 108 | describe "edit paragraph in place" do 109 | before do 110 | visit("/") 111 | end 112 | include IntegrationHelpers 113 | 114 | 115 | def double_click_paragraph 116 | first_paragraph.double_click 117 | end 118 | 119 | def text_area 120 | first_paragraph.find("textarea") 121 | end 122 | 123 | def replace_and_save(value) 124 | text_area.set value 125 | text_area.trigger "focusout" 126 | end 127 | 128 | it "should turn into a text area, showing wikitext when double-clicking" do 129 | double_click_paragraph 130 | text_area.value.should include("Welcome to the [[Smallest Federated Wiki]]") 131 | end 132 | 133 | it "should save changes to wiki text when unfocused" do 134 | double_click_paragraph 135 | replace_and_save("The [[quick brown]] fox.") 136 | first_paragraph.text.should include("The quick brown fox") 137 | end 138 | 139 | it "should record edit in the journal" do 140 | j = journal.length 141 | double_click_paragraph 142 | replace_and_save("The [[quick brown]] fox.") 143 | journal.length.should == j+1 144 | end 145 | end 146 | 147 | def use_fixture_pages(*pages) 148 | `rm -rf #{TestDirs::TEST_DATA_DIR}` 149 | pages.each do |page| 150 | FileUtils.mkdir_p "#{TestDirs::TEST_DATA_DIR}/pages/" 151 | FileUtils.cp "#{TestDirs::FIXTURE_DATA_DIR}/pages/#{page}", "#{TestDirs::TEST_DATA_DIR}/pages/#{page}" 152 | end 153 | end 154 | 155 | describe "completely empty (but valid json) page" do 156 | before do 157 | use_fixture_pages("empty-page") 158 | visit("/view/empty-page") 159 | end 160 | 161 | it "should have a title of empty" do 162 | body.should include(" empty") 163 | end 164 | 165 | it "should have an empty story" do 166 | body.should include("
    ") 167 | end 168 | 169 | it "should have an empty journal" do 170 | body.should include("
    ") 171 | page.all(".journal .action").length.should == 0 172 | end 173 | end 174 | 175 | 176 | describe "moving paragraphs" do 177 | before do 178 | use_fixture_pages("multiple-paragraphs") 179 | end 180 | 181 | include IntegrationHelpers 182 | 183 | def move_paragraph 184 | page.load_test_library! 185 | first_paragraph.drag_down(2) 186 | end 187 | 188 | def journal_items 189 | page.all(".journal .action") 190 | end 191 | 192 | before do 193 | visit "/view/multiple-paragraphs" 194 | end 195 | 196 | it "should move paragraph 1 past paragraph 2" do 197 | move_paragraph 198 | page.all(".paragraph").map(&:text).should == ["paragraph 2", "paragraph 1", "paragraph 3"] 199 | end 200 | 201 | it "should add a move to the journal" do 202 | original_journal_length = journal_items.length 203 | move_paragraph 204 | journal_items.length.should == original_journal_length + 1 205 | journal_items.last[:class].should == "action move" 206 | end 207 | 208 | 209 | end 210 | 211 | describe "moving paragraphs between pages on different servers" do 212 | before do 213 | use_fixture_pages "simple-page", "multiple-paragraphs" 214 | remote = "localhost:#{Capybara.server_port}" 215 | visit "/view/simple-page/#{remote}/multiple-paragraphs" 216 | end 217 | 218 | def drag_item_to(item, destination) 219 | page.driver.browser.execute_script "(function(p, d) { 220 | var paragraph = $(p); 221 | var destination = $(d); 222 | 223 | var source = paragraph.parents('.story'); 224 | 225 | paragraph.appendTo(destination); 226 | 227 | var ui = {item: paragraph}; 228 | destination.trigger('sortupdate', [ui]); 229 | source.trigger('sortupdate', [ui]); 230 | }).apply(this, arguments);", item.native, destination.find(".story").native 231 | end 232 | 233 | def journal_for(page) 234 | JSON.parse(Net::HTTP.get(URI.parse("http://localhost:#{Capybara.server_port}/#{page}.json")))['journal'] 235 | end 236 | 237 | it "should move the paragraph and add provenance to the journal" do 238 | pending 239 | local_page, remote_page = page.all(".page") 240 | paragraph_to_copy = remote_page.find(".item") 241 | 242 | drag_item_to paragraph_to_copy, local_page 243 | 244 | journal_entry = journal_for("simple-page").last 245 | 246 | journal_entry['type'].should == "add" 247 | journal_entry['item']['text'] == paragraph_to_copy.text 248 | journal_entry['origin'].should == { 249 | 'site' => "localhost:#{Capybara.server_port}", 250 | 'slug' => 'multiple-paragraphs' 251 | } 252 | end 253 | 254 | it "should move the paragraph from one to another" do 255 | pending 256 | local_page, remote_page = page.all(".page") 257 | paragraph_to_copy = remote_page.find(".item") 258 | 259 | drag_item_to paragraph_to_copy, local_page 260 | 261 | journal_for("multiple-paragraphs").each {|j| p j } 262 | journal_for("multiple-paragraphs").last['type'].should == 'remove' 263 | end 264 | 265 | end 266 | 267 | describe "navigating between pages" do 268 | before do 269 | visit("/") 270 | end 271 | 272 | def link_titled(text) 273 | page.all("a").select {|l| l.text == text}.first 274 | end 275 | 276 | it "should open internal links by adding a new wiki page to the web page" do 277 | link_titled("Local Editing").click 278 | page.all(".page").length.should == 2 279 | end 280 | 281 | it "should remove added pages when the browser's back button is pressed" do 282 | link_titled("Local Editing").click 283 | page.back 284 | page.all(".page").length.should == 1 285 | end 286 | end 287 | 288 | # This should probably be moved somewhere else. 289 | describe "should retrieve favicon" do 290 | 291 | def default_favicon 292 | File.join(APP_ROOT, "default-data/status/favicon.png") 293 | end 294 | 295 | def local_favicon 296 | File.join(TestDirs::TEST_DATA_DIR, "status/favicon.png") 297 | end 298 | 299 | def favicon_response 300 | Net::HTTP.get_response URI.parse(page.driver.rack_server.url("/favicon.png")) 301 | end 302 | 303 | def sha(text) 304 | Digest::SHA1.hexdigest(text) 305 | end 306 | 307 | it "should create an image when no other image is present" do 308 | File.exist?(local_favicon).should == false 309 | sha(favicon_response.body).should == sha(File.read(local_favicon)) 310 | favicon_response['Content-Type'].should == 'image/png' 311 | end 312 | 313 | it "should return the local image when it exists" do 314 | FileUtils.mkdir_p File.dirname(local_favicon) 315 | FileUtils.cp "#{TestDirs::ROOT}/spec/favicon.png", local_favicon 316 | sha(favicon_response.body).should == sha(File.read(local_favicon)) 317 | favicon_response['Content-Type'].should == 'image/png' 318 | end 319 | 320 | end 321 | 322 | describe "viewing journal" do 323 | before do 324 | use_fixture_pages("multiple-paragraphs", "duplicate-paragraphs") 325 | end 326 | include IntegrationHelpers 327 | 328 | RSpec::Matchers.define :be_highlighted do 329 | match do |actual| 330 | actual['class'].include?("target") 331 | end 332 | end 333 | 334 | it "should highlight a paragraph when hovering over journal entry" do 335 | visit "/view/multiple-paragraphs" 336 | paragraphs = page.all(".paragraph") 337 | first_paragraph = paragraphs.first 338 | other_paragraphs = paragraphs - [first_paragraph] 339 | 340 | paragraphs.each {|p| p.should_not be_highlighted } 341 | 342 | journal.first.roll_over 343 | first_paragraph.should be_highlighted 344 | other_paragraphs.each {|p| p.should_not be_highlighted } 345 | 346 | journal.first.roll_out 347 | paragraphs.each {|p| p.should_not be_highlighted } 348 | end 349 | 350 | it "should highlight all paragraphs with all the same JSON id" do 351 | visit "/view/duplicate-paragraphs" 352 | first_paragraph, second_paragraph = page.all(".paragraph") 353 | 354 | journal.first.roll_over 355 | first_paragraph.should be_highlighted 356 | second_paragraph.should be_highlighted 357 | end 358 | end 359 | 360 | # describe "testing javascript with mocha" do 361 | 362 | # it "should run with no failures" do 363 | # visit "/runtests.html" 364 | # failures = page.all(".failures em").first.text 365 | # trouble = page.all(".fail h2").collect{|e|e.text}.inspect 366 | # if failures.to_i > 0 367 | # puts "Paused to review #{failures} Mocha errors. RETURN to continue." 368 | # STDIN.readline 369 | # end 370 | # failures.should be('0'), trouble 371 | # end 372 | # end 373 | -------------------------------------------------------------------------------- /client/js/underscore-min.js: -------------------------------------------------------------------------------- 1 | // Underscore.js 1.2.3 2 | // (c) 2009-2011 Jeremy Ashkenas, DocumentCloud Inc. 3 | // Underscore is freely distributable under the MIT license. 4 | // Portions of Underscore are inspired or borrowed from Prototype, 5 | // Oliver Steele's Functional, and John Resig's Micro-Templating. 6 | // For all details and documentation: 7 | // http://documentcloud.github.com/underscore 8 | (function(){function r(a,c,d){if(a===c)return a!==0||1/a==1/c;if(a==null||c==null)return a===c;if(a._chain)a=a._wrapped;if(c._chain)c=c._wrapped;if(a.isEqual&&b.isFunction(a.isEqual))return a.isEqual(c);if(c.isEqual&&b.isFunction(c.isEqual))return c.isEqual(a);var e=l.call(a);if(e!=l.call(c))return false;switch(e){case "[object String]":return a==String(c);case "[object Number]":return a!=+a?c!=+c:a==0?1/a==1/c:a==+c;case "[object Date]":case "[object Boolean]":return+a==+c;case "[object RegExp]":return a.source== 9 | c.source&&a.global==c.global&&a.multiline==c.multiline&&a.ignoreCase==c.ignoreCase}if(typeof a!="object"||typeof c!="object")return false;for(var f=d.length;f--;)if(d[f]==a)return true;d.push(a);var f=0,g=true;if(e=="[object Array]"){if(f=a.length,g=f==c.length)for(;f--;)if(!(g=f in a==f in c&&r(a[f],c[f],d)))break}else{if("constructor"in a!="constructor"in c||a.constructor!=c.constructor)return false;for(var h in a)if(m.call(a,h)&&(f++,!(g=m.call(c,h)&&r(a[h],c[h],d))))break;if(g){for(h in c)if(m.call(c, 10 | h)&&!f--)break;g=!f}}d.pop();return g}var s=this,F=s._,o={},k=Array.prototype,p=Object.prototype,i=k.slice,G=k.concat,H=k.unshift,l=p.toString,m=p.hasOwnProperty,v=k.forEach,w=k.map,x=k.reduce,y=k.reduceRight,z=k.filter,A=k.every,B=k.some,q=k.indexOf,C=k.lastIndexOf,p=Array.isArray,I=Object.keys,t=Function.prototype.bind,b=function(a){return new n(a)};if(typeof exports!=="undefined"){if(typeof module!=="undefined"&&module.exports)exports=module.exports=b;exports._=b}else typeof define==="function"&& 11 | define.amd?define("underscore",function(){return b}):s._=b;b.VERSION="1.2.3";var j=b.each=b.forEach=function(a,c,b){if(a!=null)if(v&&a.forEach===v)a.forEach(c,b);else if(a.length===+a.length)for(var e=0,f=a.length;e2;a==null&&(a=[]);if(x&&a.reduce===x)return e&&(c=b.bind(c,e)),f?a.reduce(c,d):a.reduce(c);j(a,function(a,b,i){f?d=c.call(e,d,a,b,i):(d=a,f=true)});if(!f)throw new TypeError("Reduce of empty array with no initial value");return d};b.reduceRight=b.foldr=function(a,c,d,e){var f=arguments.length>2;a==null&&(a=[]);if(y&&a.reduceRight===y)return e&&(c=b.bind(c,e)),f?a.reduceRight(c,d):a.reduceRight(c);var g=b.toArray(a).reverse();e&&!f&&(c=b.bind(c,e));return f?b.reduce(g, 13 | c,d,e):b.reduce(g,c)};b.find=b.detect=function(a,c,b){var e;D(a,function(a,g,h){if(c.call(b,a,g,h))return e=a,true});return e};b.filter=b.select=function(a,c,b){var e=[];if(a==null)return e;if(z&&a.filter===z)return a.filter(c,b);j(a,function(a,g,h){c.call(b,a,g,h)&&(e[e.length]=a)});return e};b.reject=function(a,c,b){var e=[];if(a==null)return e;j(a,function(a,g,h){c.call(b,a,g,h)||(e[e.length]=a)});return e};b.every=b.all=function(a,c,b){var e=true;if(a==null)return e;if(A&&a.every===A)return a.every(c, 14 | b);j(a,function(a,g,h){if(!(e=e&&c.call(b,a,g,h)))return o});return e};var D=b.some=b.any=function(a,c,d){c||(c=b.identity);var e=false;if(a==null)return e;if(B&&a.some===B)return a.some(c,d);j(a,function(a,b,h){if(e||(e=c.call(d,a,b,h)))return o});return!!e};b.include=b.contains=function(a,c){var b=false;if(a==null)return b;return q&&a.indexOf===q?a.indexOf(c)!=-1:b=D(a,function(a){return a===c})};b.invoke=function(a,c){var d=i.call(arguments,2);return b.map(a,function(a){return(c.call?c||a:a[c]).apply(a, 15 | d)})};b.pluck=function(a,c){return b.map(a,function(a){return a[c]})};b.max=function(a,c,d){if(!c&&b.isArray(a))return Math.max.apply(Math,a);if(!c&&b.isEmpty(a))return-Infinity;var e={computed:-Infinity};j(a,function(a,b,h){b=c?c.call(d,a,b,h):a;b>=e.computed&&(e={value:a,computed:b})});return e.value};b.min=function(a,c,d){if(!c&&b.isArray(a))return Math.min.apply(Math,a);if(!c&&b.isEmpty(a))return Infinity;var e={computed:Infinity};j(a,function(a,b,h){b=c?c.call(d,a,b,h):a;bd?1:0}),"value")};b.groupBy=function(a,c){var d={},e=b.isFunction(c)?c:function(a){return a[c]};j(a,function(a,b){var c=e(a,b);(d[c]||(d[c]=[])).push(a)});return d};b.sortedIndex= 17 | function(a,c,d){d||(d=b.identity);for(var e=0,f=a.length;e>1;d(a[g])=0})})};b.difference=function(a){var c=b.flatten(i.call(arguments,1));return b.filter(a,function(a){return!b.include(c,a)})};b.zip=function(){for(var a=i.call(arguments),c=b.max(b.pluck(a,"length")),d=Array(c),e=0;e=0;d--)b=[a[d].apply(this,b)];return b[0]}};b.after= 24 | function(a,b){return a<=0?b():function(){if(--a<1)return b.apply(this,arguments)}};b.keys=I||function(a){if(a!==Object(a))throw new TypeError("Invalid object");var b=[],d;for(d in a)m.call(a,d)&&(b[b.length]=d);return b};b.values=function(a){return b.map(a,b.identity)};b.functions=b.methods=function(a){var c=[],d;for(d in a)b.isFunction(a[d])&&c.push(d);return c.sort()};b.extend=function(a){j(i.call(arguments,1),function(b){for(var d in b)b[d]!==void 0&&(a[d]=b[d])});return a};b.defaults=function(a){j(i.call(arguments, 25 | 1),function(b){for(var d in b)a[d]==null&&(a[d]=b[d])});return a};b.clone=function(a){return!b.isObject(a)?a:b.isArray(a)?a.slice():b.extend({},a)};b.tap=function(a,b){b(a);return a};b.isEqual=function(a,b){return r(a,b,[])};b.isEmpty=function(a){if(b.isArray(a)||b.isString(a))return a.length===0;for(var c in a)if(m.call(a,c))return false;return true};b.isElement=function(a){return!!(a&&a.nodeType==1)};b.isArray=p||function(a){return l.call(a)=="[object Array]"};b.isObject=function(a){return a=== 26 | Object(a)};b.isArguments=function(a){return l.call(a)=="[object Arguments]"};if(!b.isArguments(arguments))b.isArguments=function(a){return!(!a||!m.call(a,"callee"))};b.isFunction=function(a){return l.call(a)=="[object Function]"};b.isString=function(a){return l.call(a)=="[object String]"};b.isNumber=function(a){return l.call(a)=="[object Number]"};b.isNaN=function(a){return a!==a};b.isBoolean=function(a){return a===true||a===false||l.call(a)=="[object Boolean]"};b.isDate=function(a){return l.call(a)== 27 | "[object Date]"};b.isRegExp=function(a){return l.call(a)=="[object RegExp]"};b.isNull=function(a){return a===null};b.isUndefined=function(a){return a===void 0};b.noConflict=function(){s._=F;return this};b.identity=function(a){return a};b.times=function(a,b,d){for(var e=0;e/g,">").replace(/"/g,""").replace(/'/g,"'").replace(/\//g,"/")};b.mixin=function(a){j(b.functions(a),function(c){J(c, 28 | b[c]=a[c])})};var K=0;b.uniqueId=function(a){var b=K++;return a?a+b:b};b.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};b.template=function(a,c){var d=b.templateSettings,d="var __p=[],print=function(){__p.push.apply(__p,arguments);};with(obj||{}){__p.push('"+a.replace(/\\/g,"\\\\").replace(/'/g,"\\'").replace(d.escape,function(a,b){return"',_.escape("+b.replace(/\\'/g,"'")+"),'"}).replace(d.interpolate,function(a,b){return"',"+b.replace(/\\'/g, 29 | "'")+",'"}).replace(d.evaluate||null,function(a,b){return"');"+b.replace(/\\'/g,"'").replace(/[\r\n\t]/g," ")+";__p.push('"}).replace(/\r/g,"\\r").replace(/\n/g,"\\n").replace(/\t/g,"\\t")+"');}return __p.join('');",e=new Function("obj","_",d);return c?e(c,b):function(a){return e.call(this,a,b)}};var n=function(a){this._wrapped=a};b.prototype=n.prototype;var u=function(a,c){return c?b(a).chain():a},J=function(a,c){n.prototype[a]=function(){var a=i.call(arguments);H.call(a,this._wrapped);return u(c.apply(b, 30 | a),this._chain)}};b.mixin(b);j("pop,push,reverse,shift,sort,splice,unshift".split(","),function(a){var b=k[a];n.prototype[a]=function(){b.apply(this._wrapped,arguments);return u(this._wrapped,this._chain)}});j(["concat","join","slice"],function(a){var b=k[a];n.prototype[a]=function(){return u(b.apply(this._wrapped,arguments),this._chain)}});n.prototype.chain=function(){this._chain=true;return this};n.prototype.value=function(){return this._wrapped}}).call(this); 31 | -------------------------------------------------------------------------------- /default-data/pages/welcome-visitors: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Welcome Visitors", 3 | "story": [ 4 | { 5 | "text": "Welcome to the [[Smallest Federated Wiki]]. From this page you can find who we are and what we do. New sites provide this information and then claim the site as their own. You will need your own site to participate.", 6 | "id": "7b56f22a4b9ee974", 7 | "type": "paragraph" 8 | }, 9 | { 10 | "type": "paragraph", 11 | "id": "821827c99b90cfd1", 12 | "text": "Pages about us." 13 | }, 14 | { 15 | "type": "factory", 16 | "id": "63ad2e58eecdd9e5", 17 | "prompt": "Create a page about yourself. Start by making a link to that page right here. Double-click the gray box below. That opens an editor. Type your name enclosed in double square brackets. Then press Command/ALT-S to save." 18 | }, 19 | { 20 | "type": "paragraph", 21 | "id": "2bbd646ff3f44b51", 22 | "text": "Pages where we do and share." 23 | }, 24 | { 25 | "type": "factory", 26 | "id": "05e2fa92643677ca", 27 | "prompt": "Create a page about things you do on this wiki. Double-click the gray box below. Type a descriptive name of something you will be writing about. Enclose it in square brackets. Then press Command/ALT-S to save." 28 | }, 29 | { 30 | "type": "paragraph", 31 | "id": "0cbb4ef5f5d7e472", 32 | "text": "Look for the claim button below this page. If you have your own OpenID you can use it to claim these pages so that only you can edit them. If you have a Google account you can use that too. Press the (G) button. Press the (Y) button to use your Yahoo account. You get the idea." 33 | }, 34 | { 35 | "type": "paragraph", 36 | "id": "ee416d431ebf4fb4", 37 | "text": "You can edit your copy of these pages. Press [+] to add more writing spaces. Read [[How to Wiki]] for more ideas. Follow [[Recent Changes]] here and nearby." 38 | } 39 | ], 40 | "journal": [ 41 | { 42 | "type": "create", 43 | "id": "7b56f22a30118509", 44 | "date": 1309114800000, 45 | "item": { 46 | "title": "Welcome Visitors" 47 | } 48 | }, 49 | { 50 | "id": "7b56f22a4b9ee974", 51 | "type": "edit", 52 | "date": 1309114800000, 53 | "item": { 54 | "text": "Welcome to the [[Smallest Federated Wiki]]. This page was first drafted Sunday, June 26th, 2011, at [[Indie Web Camp]]. You are welcome to copy this page to any server you own and revise its welcoming message as you see fit. You can assume this has happened many times already.", 55 | "id": "7b56f22a4b9ee974", 56 | "type": "paragraph" 57 | } 58 | }, 59 | { 60 | "type": "edit", 61 | "id": "7b56f22a4b9ee974", 62 | "item": { 63 | "text": "Welcome to the [[Smallest Federated Wiki]]. You may be seeing this page because you have just entered a wiki of your own. If so, you have three things to do before you go on.", 64 | "id": "7b56f22a4b9ee974", 65 | "type": "paragraph" 66 | }, 67 | "date": 1344306124590 68 | }, 69 | { 70 | "type": "add", 71 | "item": { 72 | "type": "paragraph", 73 | "id": "821827c99b90cfd1", 74 | "text": "One: Create a page about yourself. Start by making a link to that page right here. Double-click the gray box below. That opens an editor. Type your name enclosed in double square brackets. Then press Command/ALT-S to save." 75 | }, 76 | "after": "7b56f22a4b9ee974", 77 | "id": "821827c99b90cfd1", 78 | "date": 1344306133455 79 | }, 80 | { 81 | "type": "add", 82 | "item": { 83 | "type": "factory", 84 | "id": "63ad2e58eecdd9e5" 85 | }, 86 | "after": "821827c99b90cfd1", 87 | "id": "63ad2e58eecdd9e5", 88 | "date": 1344306138935 89 | }, 90 | { 91 | "type": "add", 92 | "item": { 93 | "type": "paragraph", 94 | "id": "2bbd646ff3f44b51", 95 | "text": "Two: Create a page about things you do on this wiki. Double-click the gray box below. Type a descriptive name of something you will be writing about. Enclose it in square brackets. Then press Command/ALT-S to save." 96 | }, 97 | "after": "63ad2e58eecdd9e5", 98 | "id": "2bbd646ff3f44b51", 99 | "date": 1344306143742 100 | }, 101 | { 102 | "type": "add", 103 | "item": { 104 | "type": "factory", 105 | "id": "05e2fa92643677ca" 106 | }, 107 | "after": "2bbd646ff3f44b51", 108 | "id": "05e2fa92643677ca", 109 | "date": 1344306148580 110 | }, 111 | { 112 | "type": "add", 113 | "item": { 114 | "type": "paragraph", 115 | "id": "0cbb4ef5f5d7e472", 116 | "text": "Three: Look for the claim button below this page. If you have your own OpenID you can use it to claim these pages so that only you can edit them. If you have a Google account you can use that too. Press the (G) button. Press the (Y) button to use your Yahoo account. You get the idea." 117 | }, 118 | "after": "05e2fa92643677ca", 119 | "id": "0cbb4ef5f5d7e472", 120 | "date": 1344306151782 121 | }, 122 | { 123 | "type": "add", 124 | "item": { 125 | "type": "paragraph", 126 | "id": "ee416d431ebf4fb4", 127 | "text": "Start writing. Click either link. Press [+] to add more writing spaces. Read [[How to Wiki]] for more ideas. Track changes with [[Recent Changes]] and [[Local Editing]]." 128 | }, 129 | "after": "0cbb4ef5f5d7e472", 130 | "id": "ee416d431ebf4fb4", 131 | "date": 1344306168918 132 | }, 133 | { 134 | "type": "edit", 135 | "id": "821827c99b90cfd1", 136 | "item": { 137 | "type": "paragraph", 138 | "id": "821827c99b90cfd1", 139 | "text": "One: A page about myself." 140 | }, 141 | "date": 1361751371185 142 | }, 143 | { 144 | "type": "edit", 145 | "id": "2bbd646ff3f44b51", 146 | "item": { 147 | "type": "paragraph", 148 | "id": "2bbd646ff3f44b51", 149 | "text": "Two: Pages about what we do here." 150 | }, 151 | "date": 1361751496155 152 | }, 153 | { 154 | "type": "edit", 155 | "id": "821827c99b90cfd1", 156 | "item": { 157 | "type": "paragraph", 158 | "id": "821827c99b90cfd1", 159 | "text": "One: Pages about us." 160 | }, 161 | "date": 1361751512280 162 | }, 163 | { 164 | "type": "edit", 165 | "id": "7b56f22a4b9ee974", 166 | "item": { 167 | "text": "Welcome to the [[Smallest Federated Wiki]]. From this page you can find who we are and what we do.", 168 | "id": "7b56f22a4b9ee974", 169 | "type": "paragraph" 170 | }, 171 | "date": 1361751649467 172 | }, 173 | { 174 | "type": "edit", 175 | "id": "7b56f22a4b9ee974", 176 | "item": { 177 | "text": "Welcome to the [[Smallest Federated Wiki]]. From this introductory page you can find who we are and what we do.", 178 | "id": "7b56f22a4b9ee974", 179 | "type": "paragraph" 180 | }, 181 | "date": 1361751711554 182 | }, 183 | { 184 | "type": "edit", 185 | "id": "2bbd646ff3f44b51", 186 | "item": { 187 | "type": "paragraph", 188 | "id": "2bbd646ff3f44b51", 189 | "text": "Two: Pages where we do and share." 190 | }, 191 | "date": 1361751811972 192 | }, 193 | { 194 | "type": "edit", 195 | "id": "7b56f22a4b9ee974", 196 | "item": { 197 | "text": "Welcome to the [[Smallest Federated Wiki]]. From this page you can find who we are and what we do. New sites provide this information and then claim the site as their own. You will need your own site to participate.", 198 | "id": "7b56f22a4b9ee974", 199 | "type": "paragraph" 200 | }, 201 | "date": 1361751965824 202 | }, 203 | { 204 | "type": "edit", 205 | "id": "821827c99b90cfd1", 206 | "item": { 207 | "type": "paragraph", 208 | "id": "821827c99b90cfd1", 209 | "text": "Pages about us." 210 | }, 211 | "date": 1361752195146 212 | }, 213 | { 214 | "type": "edit", 215 | "id": "2bbd646ff3f44b51", 216 | "item": { 217 | "type": "paragraph", 218 | "id": "2bbd646ff3f44b51", 219 | "text": "Pages where we do and share." 220 | }, 221 | "date": 1361752202873 222 | }, 223 | { 224 | "type": "edit", 225 | "id": "0cbb4ef5f5d7e472", 226 | "item": { 227 | "type": "paragraph", 228 | "id": "0cbb4ef5f5d7e472", 229 | "text": "Look for the claim button below this page. If you have your own OpenID you can use it to claim these pages so that only you can edit them. If you have a Google account you can use that too. Press the (G) button. Press the (Y) button to use your Yahoo account. You get the idea." 230 | }, 231 | "date": 1361752223856 232 | }, 233 | { 234 | "type": "edit", 235 | "id": "ee416d431ebf4fb4", 236 | "item": { 237 | "type": "paragraph", 238 | "id": "ee416d431ebf4fb4", 239 | "text": "Click either link. Press [+] to add more writing spaces. Read [[How to Wiki]] for more ideas. Track changes with [[Recent Changes]] and [[Local Editing]]." 240 | }, 241 | "date": 1361752251367 242 | }, 243 | { 244 | "type": "edit", 245 | "id": "ee416d431ebf4fb4", 246 | "item": { 247 | "type": "paragraph", 248 | "id": "ee416d431ebf4fb4", 249 | "text": "You can your copy of these pages. Press [+] to add more writing spaces. Read [[How to Wiki]] for more ideas. Discover [[Recent Changes]] here and nearby." 250 | }, 251 | "date": 1361752421212 252 | }, 253 | { 254 | "type": "edit", 255 | "id": "ee416d431ebf4fb4", 256 | "item": { 257 | "type": "paragraph", 258 | "id": "ee416d431ebf4fb4", 259 | "text": "You can your copy of these pages. Press [+] to add more writing spaces." 260 | }, 261 | "date": 1361752436556 262 | }, 263 | { 264 | "item": { 265 | "type": "paragraph", 266 | "id": "78a8278db93c6ed2", 267 | "text": "Read [[How to Wiki]] for more ideas. Discover [[Recent Changes]] here and nearby." 268 | }, 269 | "id": "78a8278db93c6ed2", 270 | "type": "add", 271 | "after": "ee416d431ebf4fb4", 272 | "date": 1361752437061 273 | }, 274 | { 275 | "type": "edit", 276 | "id": "78a8278db93c6ed2", 277 | "item": { 278 | "type": "paragraph", 279 | "id": "78a8278db93c6ed2", 280 | "text": "Read [[How to Wiki]] for more ideas." 281 | }, 282 | "date": 1361752441155 283 | }, 284 | { 285 | "item": { 286 | "type": "paragraph", 287 | "id": "67a126ec849b55ed", 288 | "text": "Discover [[Recent Changes]] here and nearby." 289 | }, 290 | "id": "67a126ec849b55ed", 291 | "type": "add", 292 | "after": "78a8278db93c6ed2", 293 | "date": 1361752441668 294 | }, 295 | { 296 | "type": "remove", 297 | "id": "78a8278db93c6ed2", 298 | "date": 1361752452012 299 | }, 300 | { 301 | "type": "edit", 302 | "id": "ee416d431ebf4fb4", 303 | "item": { 304 | "type": "paragraph", 305 | "id": "ee416d431ebf4fb4", 306 | "text": "You can your copy of these pages. Press [+] to add more writing spaces. Read [[How to Wiki]] for more ideas." 307 | }, 308 | "date": 1361752459193 309 | }, 310 | { 311 | "type": "remove", 312 | "id": "67a126ec849b55ed", 313 | "date": 1361752461793 314 | }, 315 | { 316 | "type": "edit", 317 | "id": "ee416d431ebf4fb4", 318 | "item": { 319 | "type": "paragraph", 320 | "id": "ee416d431ebf4fb4", 321 | "text": "You can your copy of these pages. Press [+] to add more writing spaces. Read [[How to Wiki]] for more ideas. Follow [[Recent Changes]] here and nearby." 322 | }, 323 | "date": 1361752483992 324 | }, 325 | { 326 | "type": "edit", 327 | "id": "ee416d431ebf4fb4", 328 | "item": { 329 | "type": "paragraph", 330 | "id": "ee416d431ebf4fb4", 331 | "text": "You can edit your copy of these pages. Press [+] to add more writing spaces. Read [[How to Wiki]] for more ideas. Follow [[Recent Changes]] here and nearby." 332 | }, 333 | "date": 1361752498310 334 | } 335 | ] 336 | } -------------------------------------------------------------------------------- /client/js/jquery.ie.cors.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 Ovea 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | /** 17 | * https://gist.github.com/1114981 18 | * 19 | * By default, support transferring session cookie with XDomainRequest for IE. The cookie value is by default 'jsessionid' 20 | * 21 | * You can change the session cookie value like this, before including this script: 22 | * 23 | * window.XDR_SESSION_COOKIE_NAME = 'ID'; 24 | * 25 | * Or if you want to disable cookie session support: 26 | * 27 | * window.XDR_SESSION_COOKIE_NAME = null; 28 | * 29 | * If you need to convert other cookies as headers: 30 | * 31 | * window.XDR_COOKIE_HEADERS = ['PHP_SESSION']; 32 | * 33 | * To DEBUG: 34 | * 35 | * window.XDR_DEBUG = true; 36 | * 37 | * To pass some headers: 38 | * 39 | * window.XDR_HEADERS = ['Content-Type', 'Accept'] 40 | * 41 | */ 42 | (function ($) { 43 | 44 | if (!('__jquery_xdomain__' in $) 45 | && $.browser.msie // must be IE 46 | && 'XDomainRequest' in window // and support XDomainRequest (IE8+) 47 | && !(window.XMLHttpRequest && 'withCredentials' in new XMLHttpRequest()) // and must not support CORS (IE10+) 48 | && document.location.href.indexOf("file:///") == -1) { // and must not be local 49 | 50 | $['__jquery_xdomain__'] = $.support.cors = true; 51 | 52 | var urlMatcher = /^(((([^:\/#\?]+:)?(?:\/\/((?:(([^:@\/#\?]+)(?:\:([^:@\/#\?]+))?)@)?(([^:\/#\?]+)(?:\:([0-9]+))?))?)?)?((\/?(?:[^\/\?#]+\/+)*)([^\?#]*)))?(\?[^#]+)?)(#.*)?/, 53 | oldxhr = $.ajaxSettings.xhr, 54 | sessionCookie = 'XDR_SESSION_COOKIE_NAME' in window ? window['XDR_SESSION_COOKIE_NAME'] : "jsessionid", 55 | cookies = 'XDR_COOKIE_HEADERS' in window ? window['XDR_COOKIE_HEADERS'] : [], 56 | headers = 'XDR_HEADERS' in window ? window['XDR_HEADERS'] : ['Content-Type'], 57 | ReadyState = {UNSENT:0, OPENED:1, LOADING:3, DONE:4}, 58 | debug = window['XDR_DEBUG'] && 'console' in window, 59 | XDomainRequestAdapter, 60 | domain, 61 | reqId = 0; 62 | 63 | function forEachCookie(names, fn) { 64 | if (typeof names == 'string') { 65 | names = [names]; 66 | } 67 | var i, cookie; 68 | for (i = 0; i < names.length; i++) { 69 | cookie = new RegExp('(?:^|; )' + names[i] + '=([^;]*)', 'i').exec(document.cookie); 70 | cookie = cookie && cookie[1]; 71 | if (cookie) { 72 | fn.call(null, names[i], cookie); 73 | } 74 | } 75 | } 76 | 77 | function parseResponse(str) { 78 | // str === [data][header]~status~hlen~ 79 | // min: ~0~0~ 80 | if (str.length >= 5) { 81 | // return[0] = status 82 | // return[1] = data 83 | // return[2] = header 84 | var sub = str.substring(str.length <= 20 ? 0 : str.length - 20), 85 | i = sub.length - 1, 86 | end, hl, st; 87 | if (sub.charAt(i) === '~') { 88 | for (end = i--; i >= 0 && sub.charAt(i) !== '~'; i--); 89 | hl = parseInt(sub.substring(i + 1, end)); 90 | if (!isNaN(hl) && hl >= 0 && i >= 2 && sub.charAt(i) === '~') { 91 | for (end = i--; i >= 0 && sub.charAt(i) !== '~'; i--); 92 | st = parseInt(sub.substring(i + 1, end)); 93 | if (!isNaN(st) && i >= 0 && sub.charAt(i) === '~') { 94 | end = str.length - hl - sub.length + i; 95 | return [st, str.substring(0, end), str.substr(end, hl)]; 96 | } 97 | } 98 | } 99 | } 100 | return [200, str, '']; 101 | } 102 | 103 | function parseUrl(url) { 104 | if (typeof(url) === "object") { 105 | return url; 106 | } 107 | var matches = urlMatcher.exec(url); 108 | return matches ? { 109 | href:matches[0] || "", 110 | hrefNoHash:matches[1] || "", 111 | hrefNoSearch:matches[2] || "", 112 | domain:matches[3] || "", 113 | protocol:matches[4] || "", 114 | authority:matches[5] || "", 115 | username:matches[7] || "", 116 | password:matches[8] || "", 117 | host:matches[9] || "", 118 | hostname:matches[10] || "", 119 | port:matches[11] || "", 120 | pathname:matches[12] || "", 121 | directory:matches[13] || "", 122 | filename:matches[14] || "", 123 | search:matches[15] || "", 124 | hash:matches[16] || "" 125 | } : {}; 126 | } 127 | 128 | function parseCookies(header) { 129 | if (header.length == 0) { 130 | return []; 131 | } 132 | var cooks = [], i = 0, start = 0, end, dom; 133 | do { 134 | end = header.indexOf(',', start); 135 | cooks[i] = (cooks[i] || '') + header.substring(start, end == -1 ? header.length : end); 136 | start = end + 1; 137 | if (cooks[i].indexOf('Expires=') == -1 || cooks[i].indexOf(',') != -1) { 138 | i++; 139 | } else { 140 | cooks[i] += ','; 141 | } 142 | } while (end > 0); 143 | for (i = 0; i < cooks.length; i++) { 144 | dom = cooks[i].indexOf('Domain='); 145 | if (dom != -1) { 146 | cooks[i] = cooks[i].substring(0, dom) + cooks[i].substring(cooks[i].indexOf(';', dom) + 1); 147 | } 148 | } 149 | return cooks; 150 | } 151 | 152 | domain = parseUrl(document.location.href).domain; 153 | XDomainRequestAdapter = function () { 154 | var self = this, 155 | _xdr = new XDomainRequest(), 156 | _mime, 157 | _reqHeaders = [], 158 | _method, 159 | _url, 160 | _id = reqId++, 161 | _setState = function (state) { 162 | self.readyState = state; 163 | if (typeof self.onreadystatechange === 'function') { 164 | self.onreadystatechange.call(self); 165 | } 166 | }, 167 | _done = function (state, code) { 168 | if (!self.responseText) { 169 | self.responseText = ''; 170 | } 171 | if (debug) { 172 | console.log('[XDR-' + _id + '] request end with state ' + state + ' and code ' + code + ' and data length ' + self.responseText.length); 173 | } 174 | self.status = code; 175 | if (!self.responseType) { 176 | _mime = _mime || _xdr.contentType; 177 | if (_mime.match(/\/json/)) { 178 | self.responseType = 'json'; 179 | self.response = self.responseText; 180 | } else if (_mime.match(/\/xml/)) { 181 | self.responseType = 'document'; 182 | var $error, dom = new ActiveXObject('Microsoft.XMLDOM'); 183 | dom.async = false; 184 | dom.loadXML(self.responseText); 185 | self.responseXML = self.response = dom; 186 | if ($(dom).children('error').length != 0) { 187 | $error = $(dom).find('error'); 188 | self.status = parseInt($error.attr('response_code')); 189 | } 190 | } else { 191 | self.responseType = 'text'; 192 | self.response = self.responseText; 193 | } 194 | } 195 | _setState(state); 196 | // clean memory 197 | _xdr = null; 198 | _reqHeaders = null; 199 | _url = null; 200 | }; 201 | _xdr.onprogress = function () { 202 | _setState(ReadyState.LOADING); 203 | }; 204 | _xdr.ontimeout = function () { 205 | _done(ReadyState.DONE, 408); 206 | }; 207 | _xdr.onerror = function () { 208 | _done(ReadyState.DONE, 500); 209 | }; 210 | _xdr.onload = function () { 211 | // check if we are using a filter which modify the response 212 | var cooks, i, resp = parseResponse(_xdr.responseText || ''); 213 | if (debug) { 214 | console.log('[XDR-' + reqId + '] parsing cookies for header ' + resp[2]); 215 | } 216 | cooks = parseCookies(resp[2]); 217 | self.responseText = resp[1] || ''; 218 | if (debug) { 219 | console.log('[XDR-' + _id + '] raw data:\n' + _xdr.responseText + '\n parsed response: status=' + resp[0] + ', header=' + resp[2] + ', data=\n' + resp[1]); 220 | } 221 | for (i = 0; i < cooks.length; i++) { 222 | if (debug) { 223 | console.log('[XDR-' + _id + '] installing cookie ' + cooks[i]); 224 | } 225 | document.cookie = cooks[i] + ";Domain=" + document.domain; 226 | } 227 | _done(ReadyState.DONE, resp[0]); 228 | resp = null; 229 | }; 230 | this.readyState = ReadyState.UNSENT; 231 | this.status = 0; 232 | this.statusText = ''; 233 | this.responseType = ''; 234 | this.timeout = 0; 235 | this.withCredentials = false; 236 | this.overrideMimeType = function (mime) { 237 | _mime = mime; 238 | }; 239 | this.abort = function () { 240 | _xdr.abort(); 241 | }; 242 | this.setRequestHeader = function (k, v) { 243 | if ($.inArray(k, headers) >= 0) { 244 | _reqHeaders.push({k:k, v:v}); 245 | } 246 | }; 247 | this.open = function (m, u) { 248 | _url = u; 249 | _method = m; 250 | _setState(ReadyState.OPENED); 251 | }; 252 | this.send = function (data) { 253 | _xdr.timeout = this.timeout; 254 | if (sessionCookie || cookies || _reqHeaders.length) { 255 | var h, addParam = function (name, value) { 256 | var q = _url.indexOf('?'); 257 | _url += (q == -1 ? '?' : '&') + name + '=' + encodeURIComponent(value); 258 | if (debug) { 259 | console.log('[XDR-' + _id + '] added parameter ' + name + "=" + value + " => " + _url); 260 | } 261 | }; 262 | for (h = 0; h < _reqHeaders.length; h++) { 263 | addParam(_reqHeaders[h].k, _reqHeaders[h].v); 264 | } 265 | forEachCookie(sessionCookie, function (name, value) { 266 | var q = _url.indexOf('?'); 267 | if (q == -1) { 268 | _url += ';' + name + '=' + value; 269 | } else { 270 | _url = _url.substring(0, q) + ';' + name + '=' + value + _url.substring(q); 271 | } 272 | if (debug) { 273 | console.log('[XDR-' + _id + '] added cookie ' + _url); 274 | } 275 | }); 276 | forEachCookie(cookies, addParam); 277 | addParam('_xdr', '' + _id); 278 | } 279 | if (debug) { 280 | console.log('[XDR-' + _id + '] opening ' + _url); 281 | } 282 | _xdr.open(_method, _url); 283 | if (debug) { 284 | console.log('[XDR-' + _id + '] send, timeout=' + _xdr.timeout); 285 | } 286 | _xdr.send(data); 287 | }; 288 | this.getAllResponseHeaders = function () { 289 | return ''; 290 | }; 291 | this.getResponseHeader = function () { 292 | return null; 293 | } 294 | }; 295 | 296 | $.ajaxSettings.xhr = function () { 297 | var target = parseUrl(this.url).domain; 298 | if (target === "" || target === domain) { 299 | return oldxhr.call($.ajaxSettings); 300 | } else { 301 | try { 302 | return new XDomainRequestAdapter(); 303 | } catch (e) { 304 | } 305 | } 306 | }; 307 | 308 | } 309 | }) 310 | (jQuery); -------------------------------------------------------------------------------- /server/Wikiduino/Wikiduino.ino: -------------------------------------------------------------------------------- 1 | 2 | // Copyright (c) 2011, Ward Cunningham 3 | // Released under MIT and GPLv2 4 | 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #define num(array) (sizeof(array)/sizeof(array[0])) 11 | 12 | // pin assignments 13 | byte radioPowerPin = 2; 14 | 15 | // Ethernet Configuration 16 | 17 | byte mac[] = { 0xEE, 0xAD, 0xBE, 0xEF, 0xFE, 0xED }; 18 | IPAddress ip(10, 94, 54, 2); 19 | IPAddress gateway(10, 94, 54, 1); 20 | 21 | //IPAddress ip(10, 0, 3, 201 ); 22 | //IPAddress gateway( 10, 0, 3, 1 ); 23 | 24 | //IPAddress ip(192, 168, 0, 201 ); 25 | //IPAddress gateway( 192, 168, 0, 1 ); 26 | 27 | IPAddress subnet( 255, 255, 255, 0 ); 28 | 29 | EthernetServer server(1111); 30 | EthernetClient client(255); 31 | 32 | unsigned long requests = 0; 33 | unsigned long lastRequest = 0; // records the request number at time of the most recent radio powerup 34 | byte radioPowerMode = 1; // indicates which power management algorith to use 35 | 36 | // Sensor Configuration 37 | 38 | OneWire ds(8); 39 | 40 | int analog[3]; 41 | struct Temp { 42 | unsigned int code; 43 | int data; 44 | } temp[4] = {{0,0}}; 45 | 46 | unsigned int last = 100; 47 | unsigned int powersave = 0; 48 | unsigned long lastSample = 100; 49 | unsigned long lastRadioOn = 0; // records time the radio was last powered on 50 | unsigned long totalRadioOn = 0; // records total time the radio has been on 51 | unsigned long now = 0; 52 | unsigned long rollOvers = 0; 53 | boolean topOfHourFlag = false; 54 | unsigned long topOfHour = 0; 55 | boolean radioOn = false; // status of radio power 56 | unsigned long crc_errs = 0; 57 | 58 | // Arduino Setup and Loop 59 | 60 | void setup() { 61 | Serial.begin(115200L); 62 | Ethernet.begin(mac, ip, gateway, subnet); 63 | server.begin(); 64 | // configure radio power control pin 65 | pinMode(radioPowerPin,OUTPUT); 66 | powerRadio(true); 67 | } 68 | 69 | void loop() { 70 | sample(); // every second or so 71 | pinMode(13,OUTPUT); 72 | digitalWrite(13,HIGH); 73 | serve(); // whenever web requests come in 74 | digitalWrite(13,LOW); 75 | } 76 | 77 | // Sample and Hold Analog and One-Wire Temperature Data 78 | 79 | void sample() { 80 | now = millis(); 81 | if ((now-lastSample) >= 1000) { 82 | if(now < lastSample) { 83 | rollOvers++; 84 | } 85 | lastSample = now; 86 | manageRadioPower(); 87 | analogSample(); 88 | tempSample(); 89 | } 90 | } 91 | 92 | unsigned long modeOneOnTime = (58*60+30) * 1000UL; 93 | unsigned long modeOneOffTime = (4*60+15) * 1000UL; 94 | unsigned long modeTwoOnTime = (2*60+30) * 1000UL; 95 | unsigned long modeTwoOffTime = (4*60+5) * 1000UL; 96 | unsigned long longestOnTimeWithoutRequest = 3600*1000UL; 97 | 98 | void manageRadioPower() { 99 | if(radioOn && (lastRequest == requests) && ((now-lastRadioOn) >= longestOnTimeWithoutRequest)) { 100 | // radio has been on for a while, but received no requests, may be wedged, try rebooting 101 | printTime(now,0); Serial.println(" Resetting radio"); 102 | powerRadio(false); 103 | delay(2000); 104 | now = millis(); 105 | powerRadio(true); 106 | } else { 107 | if(radioPowerMode == 0 || !topOfHourFlag) { // stay on 108 | if(!radioOn) { 109 | powerRadio(true); 110 | } 111 | } else { 112 | // remove integer hours from time, just interested in phase ... not needed if we get a sync often enough relative to wrapping 113 | while((now-topOfHour) > (3600*1000UL)) { 114 | topOfHour += 3600*1000UL; 115 | } 116 | unsigned long timeAfterHour = (now-topOfHour) % (3600*1000UL); 117 | if(radioPowerMode == 1) { // on at 58m30s, off at 4m15s after hour 118 | boolean duringOffTime = (timeAfterHour > modeOneOffTime) && (timeAfterHour < modeOneOnTime); 119 | if(radioOn && duringOffTime) { 120 | powerRadio(false); 121 | } else if (!radioOn && !duringOffTime) { 122 | powerRadio(true); 123 | } 124 | } else if(radioPowerMode == 2) { // minimal radio uptime on at 2m30s, off at 4m5s after hour 125 | boolean duringOnTime = (timeAfterHour > modeTwoOnTime) && (timeAfterHour < modeTwoOffTime); 126 | if(radioOn && !duringOnTime) { 127 | powerRadio(false); 128 | } else if (!radioOn && duringOnTime) { 129 | powerRadio(true); 130 | } 131 | } 132 | } 133 | } 134 | } 135 | 136 | void printTime(unsigned long t,unsigned long ref) { 137 | unsigned long hour; 138 | unsigned long minute; 139 | unsigned long second; 140 | 141 | t -= ref; 142 | hour = t / (3600 * 1000UL); 143 | minute = t % (3600 * 1000UL); 144 | second = minute % (60 * 1000UL); 145 | minute -= second; 146 | if(topOfHourFlag) { 147 | Serial.print("Sync'd: "); 148 | } 149 | Serial.print(hour); Serial.print(":"); 150 | Serial.print(minute/60000UL); Serial.print(":"); 151 | Serial.print(second/1000.0,3); 152 | } 153 | 154 | float uptime() { // returns uptime as a floating point hour 155 | return (4294967296.0 * rollOvers + now) / (3600.0 * 1000); 156 | } 157 | 158 | float radioOnTime() { // returns time radio has been on in hours 159 | return (float) (totalRadioOn + (radioOn ? (now-lastRadioOn) : 0)) / (3600.0 * 1000); 160 | } 161 | 162 | void powerRadio(boolean power) { 163 | digitalWrite(radioPowerPin,power); 164 | radioOn = power; 165 | if(power) { 166 | lastRadioOn = now; 167 | lastRequest = requests; 168 | } else { 169 | totalRadioOn += (now-lastRadioOn); 170 | lastRadioOn = 0; 171 | } 172 | printTime(now,0); Serial.print(" "); printTime(now,topOfHour); Serial.print(" "); Serial.println(radioOn); 173 | } 174 | 175 | void analogSample() { 176 | for (int i = 0; i < num(analog); i++) { 177 | analog[i] = analogRead(i); 178 | } 179 | } 180 | 181 | byte data[12]; 182 | unsigned int id; 183 | int ch = -1; 184 | 185 | void tempSample() { 186 | finishTempSample(); 187 | startTempSample(); 188 | } 189 | 190 | void startTempSample() { 191 | if (ch < 0) { 192 | ds.reset_search(); 193 | } 194 | if (!ds.search(data)) { 195 | ch = -1; 196 | } 197 | else { 198 | if (OneWire::crc8(data, 7) == data[7] && 0x28 == data[0]) { 199 | id = data[2]*256u+data[1]; 200 | ch = channel (id); 201 | ds.reset(); 202 | ds.select(data); 203 | ds.write(0x44,1); // start conversion, with parasite power on at the end 204 | } else { 205 | crc_errs++; 206 | Serial.print(id); 207 | Serial.println(F(" a-err")); 208 | } 209 | } 210 | } 211 | 212 | void finishTempSample() { 213 | if (ch >= 0) { // if we've discovered a devise and started a conversion 214 | ds.reset(); 215 | ds.select(data); 216 | ds.write(0xBE); // Read Scratchpad 217 | for (int i = 0; i < 9; i++) { 218 | data[i] = ds.read(); 219 | } 220 | if (OneWire::crc8(data, 8) == data[8]) { 221 | temp[ch].data = data[1]*256+data[0]; 222 | temp[ch].code = id; // don't set this too early or we could report bad data 223 | } else { 224 | crc_errs++; 225 | Serial.print(id); 226 | Serial.println(F(" d-err")); 227 | } 228 | } 229 | } 230 | 231 | int channel(int id) { 232 | for (int ch=0; ch")); } 309 | void scpt(__FlashStringHelper* s) { p(F("")); } 310 | void stag(__FlashStringHelper* s) { p('<'); p(s); p('>'); } 311 | void etag(__FlashStringHelper* s) { p('<'); p('/'); p(s); p('>'); } 312 | 313 | void htmlReport () { 314 | code(F("200 OK")); 315 | mime(F("text/html")); 316 | stag(F("html")); 317 | stag(F("head")); 318 | link(F("style.css")); 319 | scpt(F("js/jquery.min.js")); 320 | scpt(F("js/jquery-ui.custom.min.js")); 321 | scpt(F("client.js")); 322 | etag(F("head")); 323 | stag(F("body")); 324 | p(F("
    ")); 325 | p(F("
    ")); 326 | etag(F("div")); 327 | etag(F("div")); 328 | etag(F("body")); 329 | etag(F("html")); 330 | } 331 | 332 | boolean more; 333 | 334 | void sh () { if (more) { p(','); } p('{'); more = false; } 335 | void sa () { if (more) { p(','); } p('['); more = false; } 336 | void eh () { p('}'); more = true; } 337 | void ea () { p(']'); more = true; } 338 | void k (__FlashStringHelper* s) { if (more) { p(','); } p('"'); p(s); p('"'); p(':'); more = false; } 339 | void v (__FlashStringHelper* s) { if (more) { p(','); } p('"'); p(s); p('"'); more = true; } 340 | void v (long s) { if (more) { p(','); } client.print(s); more = true; } 341 | void v (int s) { if (more) { p(','); } client.print(s); more = true; } 342 | void v (float s) { if (more) { p(','); } client.print(s); more = true; } 343 | 344 | void jsonReport () { 345 | more = false; 346 | long id = 472647400L; 347 | 348 | code(F("200 OK")); 349 | mime(F("application/json")); 350 | sh(); 351 | k(F("title")); v(F("garden-report")); 352 | k(F("logo")); 353 | sh(); 354 | k(F("nw")); sa(); v(127); v(255); v(127); ea(); 355 | k(F("se")); sa(); v(63); v(63); v(16); ea(); 356 | eh(); 357 | k(F("story")); 358 | sa(); 359 | sh(); 360 | k(F("type")); v(F("paragraph")); 361 | k(F("id")); v(id++); 362 | k(F("text")); v(F("Experimental data from Nike's Community Garden. This content is being served on the open-source hardware Arduino platform running the [[smallest-federated-wiki]] server application.")); 363 | eh(); 364 | for (int ch=0; chUpdated in Seconds")); 370 | k(F("data")); 371 | sa(); 372 | sa(); v(1314306006L); v(temp[ch].data * (9.0F/5/16) + 32); ea(); 373 | ea(); 374 | eh(); 375 | } 376 | for (int ch=0; chVolts")) : ch == 2 ? v(F("Solar Panel
    Volts")) : v(F("Daylight
    Percent"))); 381 | k(F("data")); 382 | sa(); 383 | sa(); v(1314306006L); v(analog[ch] * (ch>=1 ? (1347.0F/89.45F/1024) : (100.0F/1024))); ea(); 384 | ea(); 385 | eh(); 386 | } 387 | sh(); 388 | k(F("type")); v(F("chart")); 389 | k(F("id")); v(id++); 390 | k(F("caption")); v(F("Wiki Server
    Requests")); 391 | k(F("data")); 392 | sa(); 393 | sa(); v(1314306006L); v((long)requests); ea(); 394 | ea(); 395 | eh(); 396 | sh(); 397 | k(F("type")); v(F("chart")); 398 | k(F("id")); v(id++); 399 | k(F("caption")); v(F("Arduino Uptime
    Hours")); 400 | k(F("data")); 401 | sa(); 402 | sa(); v(1314306006L); v(uptime()); ea(); 403 | ea(); 404 | eh(); 405 | sh(); 406 | k(F("type")); v(F("chart")); 407 | k(F("id")); v(id++); 408 | k(F("caption")); v(F("Radio Uptime
    Hours")); 409 | k(F("data")); 410 | sa(); 411 | sa(); v(1314306006L); v(radioOnTime()); ea(); 412 | ea(); 413 | eh(); 414 | sh(); 415 | k(F("type")); v(F("chart")); 416 | k(F("id")); v(id++); 417 | k(F("caption")); v(F("Minutes After Hour")); 418 | k(F("data")); 419 | sa(); 420 | sa(); v(1314306006L); v((float) (topOfHourFlag ? ((now-topOfHour) % (3600*1000UL))/60000.0 : 0.0)); ea(); 421 | ea(); 422 | eh(); 423 | sh(); 424 | k(F("type")); v(F("chart")); 425 | k(F("id")); v(id++); 426 | k(F("caption")); v(F("Radio Power Mode")); 427 | k(F("data")); 428 | sa(); 429 | sa(); v(1314306006L); v(radioPowerMode); ea(); 430 | ea(); 431 | eh(); 432 | ea(); 433 | k(F("journal")); 434 | sa(); 435 | ea(); 436 | eh(); 437 | n(F("")); 438 | } 439 | 440 | void errorReport () { 441 | code(F("404 Not Found")); 442 | mime(F("text/html")); 443 | n(F("404 Not Found")); 444 | } 445 | 446 | void faviconReport () { 447 | code(F("200 OK")); 448 | mime(F("image/png")); 449 | client.print(F("\0211\0120\0116\0107\015\012\032\012\0\0\0\015\0111\0110\0104\0122\0\0\0\05\0\0\0\010\010\02\0\0\0\0276\0223\0242\0154\0\0\0\025\0111\0104\0101\0124\010\0231\0143\0374\0377\0277\0203\01\011\0260\074\0370\0372\0236\0236\0174\0\0366\0225\026\0\0105\030\0216\0134\0\0\0\0\0111\0105\0116\0104\0256\0102\0140\0202")); 450 | } 451 | --------------------------------------------------------------------------------