Titanium MobileでiPhone上の表示名を国際化する

Titanium Mobileで文字列を国際化する方法は、AppceleratorのDocumentation Guide『Internationalizing your Application』でわかります。

iPhone上の表示名を国際化したい場合は、string.xmlとは別に「app.xml」ファイルを作ります。app.xmlには、name属性が「appname」のstring要素を書きます。このファイルを言語毎に用意すれば、iPhoneの言語環境に応じてアプリ名が切り替わることをiOSシミュレータとiPhone 3GSで確認しました。

<?xml version="1.0" encoding="UTF-8"?>
<resources>
  <string name="appname"></string>
</resources>

余談

ja.lproj内にInfoPlist.stringsファイルを作成できないかと思い、「/Library/Application Support/Titanium/mobilesdk/osx/1.6.1/common/localecompiler.py」のソースを読んでいたら53行目のlocalization_file_name_iosを見つけ、さらにその上の行ではファイル名がapp.xmlだとInfoPlist.stringsを作ってくれることがわかりました。

def isApp(self,file):
  return (os.path.basename(file) == "app.xml")

def localization_file_name_ios(self,file):
  if self.isApp(file):
    return "InfoPlist.strings"
  return "Localizable.strings"

でも、iTunesのAppで表示は英語のままなんですよね。CFBundleDisplayNameを見てるんじゃないのか。

それか、iTunes ConnectのManage Localizationsで設定するアプリ名がiTunesで表示されるのかな?

Titanium MobileアプリとMongoDBをSleepy.Mongooseを経由で接続させてみた

MongoDBSleepy.Mongoose経由で、Titanium Mobileで作ったiPhoneアプリから接続する簡単なアプリを作ってみました。

Sleepy.Mongooseを経由したのは、MongoDBのRESTインターフェースは読み取り専用で外部ツールを使うことが推奨されていたからです。*1

The mongod process includes a simple read-only REST interface for convenience. For full REST capabilities we recommend
using an external tool such as Sleepy.Mongoose.

MongoDB、Sleepy.MongooseそしてTitanium Mobileのインストールや設定などにつきましては、それぞれの公式サイトに書いてありますので、割愛させていただきます。あえて注意する事と言えば、MongoDB起動時「--rest」オプションを間違って付けないことでしょうか。

Todoアプリ

タスクの一覧(query)を表示・追加(insert)をするだけのアプリです。あくまでも勉強用なので、実用性は全くありません。また、Titanium Mobileを使ってHello World以外を作ったのもこれが初めてなので、変なコードを書いていたらどうかご勘弁を。


ソース

Titanium Developerを起動し、New ProjectでTodoプロジェクトを作成。Titanium SDKは1.5.1、iPhone SDKは4.2を選択しました。MongoDB、Sleepy.Mongoosそして本アプリは、すべてローカルで動かすことを前提にしています。


APP_ROOT/Resources/app.js

Ti.UI.setBackgroundColor('#000');

var win1 = Ti.UI.createWindow({  
  url: 'table_view.js',
  title:'List',
  backgroundColor:'#fff'
});

var tab1 = Ti.UI.createTab({
  window:win1
});  
win1.hideTabBar();

var tabGroup = Ti.UI.createTabGroup({});
tabGroup.addTab(tab1);  
tabGroup.open();


タスクを追加するウィンドウ「taskWindow」に「done」というカスタムイベントを追加しているのは、「close」イベントを追加すると、「Back」ボタンをタップしたのか「Done」ボタンをタップしたのかわからないからです。

「Done」ボタンをタップしてタスクを追加したら、MongoDBからオブジェクトIDが戻りますので、doneイベントを発動する際、引数にこのIDを渡して、一覧を更新する方がいいんですが、面倒なんで全部再取得してtableViewに詰め込んでます。


APP_ROOT/Resources/table_view.js

var win = Ti.UI.currentWindow;
var xhr = Ti.Network.createHTTPClient();

var tableView = null;
function updateTable() {
  var find = 'http://localhost:27080/todo/tasks/_find?sort=' +
             encodeURIComponent('{"_id": -1}');
  xhr.open('GET', find);
  xhr.onload = function() {
    var todo = JSON.parse(this.responseText);
    var tasks = todo.results;
    var data = [];
    for(var i = 0; i < tasks.length; i++) {
      var row = Ti.UI.createTableViewRow();
      var label = Ti.UI.createLabel({
        text: tasks[i].text
      });
      row.add(label);
      data.push(row);
    }

    if(tableView == null) {
      tableView = Ti.UI.createTableView({
        data: data
      });
      win.add(tableView);
    } else {
      tableView.data = data;
    }
  };
  xhr.send();
}

var taskButton = Ti.UI.createButton({
  systemButton: Ti.UI.iPhone.SystemButton.ADD
});
taskButton.addEventListener(
  'click',
  function() {
    var taskWindow = Ti.UI.createWindow({
      url: 'task_window.js',
      title: 'Add Task',
      backgroundColor: '#fff'
    });
    taskWindow.addEventListener(
      'done',
      function() {
        updateTable();
      }
    );
    Ti.UI.currentTab.open(taskWindow);
  }
);
win.rightNavButton = taskButton;

var conn = 'http://localhost:27080/_connect';
xhr.open('POST', conn);
xhr.onload = function() {
  updateTable();
};
xhr.send({"data":"localhost:27017"});


APP_ROOT/Resoruces/task_window.js

var win = Ti.UI.currentWindow;

var textArea = Ti.UI.createTextArea({
  height: 180,
  width: 300,
  top: 10,
  font:{
    fontSize:20
  },
  borderWidth: 1,
  borderColor: '#bbb',
  borderRadius: 5,
  autocapitalization: Ti.UI.TEXT_AUTOCAPITALIZATION_NONE,
  suppressReturn: false
});
win.addEventListener('open', function(e) {
    textArea.focus();
});
win.add(textArea);

var doneButton = Ti.UI.createButton({
      systemButton: Ti.UI.iPhone.SystemButton.DONE
});
doneButton.addEventListener(
  'click',
  function() {
    if(textArea.value) {
      var url = 'http://localhost:27080/todo/tasks/_insert';
      var xhr = Ti.Network.createHTTPClient();
      xhr.open('POST', url);
      xhr.send({"docs": '[{"text": "' + textArea.value + '"}]'});
      xhr.onload = function() {
        win.fireEvent('done');
        win.close();
      };
    }   
  }
);
win.rightNavButton = doneButton;

動作確認

Titanium Developer経由でiPhoneシミュレータを起動し、タスクを追加。MongoDBのシェルでinsertされているか確認。試しに、日本語の文章をテキストエリアに貼付けて追加してみましたが、文字化けはしませんでした。なお、Androidのemulatorでは動きません。

$ mongo todo
MongoDB shell version: 1.6.5
connecting to: todo
> db.tasks.find().sort({id:-1})
{ "_id" : ObjectId("4d5b3b3a5a7a992baf000005"), "text" : "Go to Ginza." }
{ "_id" : ObjectId("4d5b3b4d5a7a992baf000006"), "text" : "Visit my girl friend." }
{ "_id" : ObjectId("4d5b50045a7a993380000000"), "text" : "日本語のテスト" }

余談

JavaScriptiPhone/Androidアプリが作れちゃうってすごいですね。

このアプリもそうですが、やはりプラットフォーム固有の設定・動作があり、Googleでドキュメントを検索していた時、if文で分岐させているソースを見ました。でも、例えばretainCountを気にしなくてよいとか、Objective-Cでを書いてた時と比べたら格段に楽かなぁ...

MongoDBのスケーラビリティについては、また後日、時間をとって勉強してみたいと思っています。セキュリティまわりは、MongoDBにユーザ認証がありますし、Sleepy.MongooseはSSLに対応してますから大丈夫なんじゃないかと。ただ、Titanium.Network.HTTPClientHTTPSに対応しているのかは未確認です。

コマンドラインでツイートするRubyスクリプト(つづき)

先日コマンドラインでツイートするRubyスクリプトを作ってみましたが、引数の最後がURLだった場合、Google URL Shortener APIを使って短縮するようにしてみました。

このままでも動きますが、GoogleAccess Keyの取得を推奨し、設定するとAnonymous状態と比べて制限*1が拡大されます。先日、公開したスクリプトを既にお使いで、Access Keyを設定したい場合は、お手数ですが「~/.twrc」ファイルに「google_api_access_key」を追記してください。

google_api_access_key: <YOUR ACCESS KEY>

ソース

#!/usr/bin/env ruby
$KCODE = 'u'

require 'fileutils'
require 'yaml'
require 'rubygems'
require 'oauth'
require 'json'

class Tw
  CONFIG_FILE   = '.twrc'

  # Twitter API
  KEY            = 'consumer_key'
  SECRET         = 'consumer_secret'
  TOKEN          = 'access_token'
  TOKEN_SECRET   = 'access_token_secret'
  MAX_LENGTH     = 140
  TWITTER_API    = 'http://api.twitter.com'
  UPDATE_URL     = 'https://api.twitter.com/1/statuses/update.xml'

  # Google URL Shortener API
  ACCESS_KEY     = 'google_api_access_key'
  GOOGLE_APIS    = 'www.googleapis.com'
  SHORTENER_PATH = '/urlshortener/v1/url'

  attr_accessor :config

  def initialize
    config_file = File.join(File.expand_path('~'), CONFIG_FILE)
    begin
      @config = YAML.load(File.read(config_file))
    rescue
      @config = {
        KEY          => prompt(KEY),
        SECRET       => prompt(SECRET),
        TOKEN        => prompt(TOKEN),
        TOKEN_SECRET => prompt(TOKEN_SECRET),
        ACCESS_KEY   => prompt(ACCESS_KEY)
      }
      File.open(config_file, 'w') {|f| f.write(@config.to_yaml)}
      FileUtils.chmod(0600, config_file)
      puts "saved to #{config_file}"
    end
  end

  def get_token
    consumer = OAuth::Consumer.new(@config[KEY],
                                   @config[SECRET],
                                   :site => TWITTER_API)
    token_hash = {
      :oauth_token        => @config[TOKEN],
      :oauth_token_secret => @config[TOKEN_SECRET]
    }
    access_token = OAuth::AccessToken.from_hash(consumer, token_hash)
  end

  def shorten_url(long_url)
    params = {'longUrl' => long_url}
    params.store('key', @config[ACCESS_KEY]) if @config[ACCESS_KEY] && !@config[ACCESS_KEY].empty?
    response = ''

    http = Net::HTTP.new(GOOGLE_APIS, 443)
    http.use_ssl = true
    http.verify_mode = OpenSSL::SSL::VERIFY_NONE
    response = http.post(SHORTENER_PATH, params.to_json, {'Content-Type' => 'application/json'})

    return Net::HTTPOK == response.class ? (JSON.parse(response.body))['id'] : ''
  end

  private

  def prompt(string)
    print string.gsub('_', ' ').capitalize + ":"
    gets.strip
  end
end

URL_REGEXP = /(^(http|https):\/\/[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(([0-9]{1,5})?\/.*)?$)/ix

tw = Tw.new

if URL_REGEXP =~ (ARGV.length > 0 ? ARGV[ARGV.length - 1] : '')
  status = (ARGV[0, ARGV.length - 1].join(' ') + ' ' + tw.shorten_url($1)).strip
else
  status = ARGV.join(' ')
end

unless status.empty? && status.split('').length > Tw::MAX_LENGTH
  response = tw.get_token.post(Tw::UPDATE_URL, {:status => status})
  exit(Net::HTTPOK == response)
else
  exit(false)
end

設定

アプリをTwitter Applicationsで登録してください。アプリケーションの種類は、「クライアントアプリケーション」です。

Access TokenとAccess Token Secretは、アプリを登録した後、右サイドメニューにある「My Access Token」をクリックすると取得できます。

Access Keyは、Google APIs Consoleで取得できます。

引数なしで起動し、Consumer Key、Consumer Secret、Access Token、Access Token Secret、Google Api Access Keyを入力してください。Access Keyは省略する場合、単にエンターキーでスキップしてください。

使い方

Twitterに投稿したいメッセージを引数として、起動してください。複数の引数は、半角の空白で連結します。ただし、半角括弧が文中にある場合は、文章全体をダブルクォーテーションで囲ってください。

注意点

当たり前のことですが、「AS IS」*2です。

バレンタインチョコ欲しい!*3

欲しいプレゼントは…

Apple iPod touch 64GB MC547J/A

Apple iPod touch 64GB MC547J/A

*1:queries/day。一日に何回、問い合わせ(短縮化)をGoogleが受け付けるか。

*2:The MIT License

*3:はてなキーワードのキャンペーンに応募してみました。

Cucumberをはじめてみた(つづき)

先日に引き続き、『はじめる! Cucumber』(諸橋恭介著、達人出版界、2010年、v0.9.1)の第5章以降をRuby on Rails 3でやってみました。

redirect_to :backで悩む

p.60およびp.63のウォッチを作成・削除した後、一覧に戻るため「redirect_to :back」をしていますが、なぜかこれでテストが通りません。もちろん、ブラウザで実行した場合は、きちんと戻ってくれました。

expected: "/users/bob/messages"
     got: "/watchings" (using ==) (RSpec::Expectations::ExpectationNotMetError)
./features/step_definitions/web_steps.rb:260:in `/^(?:|I )should be on (.+)$/'
features/watching_users.feature:18:in `ならば"bobのメッセージ一覧"ページを表示していること'

そこで、リダイレクト先のURLを生成してテストを通過させました。
APP_ROOT/app/controllers/watchings_controller.rb

class WatchingsController < ApplicationController
  before_filter :authenticate

  def create
    watching = current_user.watchings.create!(params[:watching])
    redirect_to user_messages_url(watching.watchee.login)
    #redirect_to :back
  end 

  def destroy
    watching = current_user.watchings.find(params[:id]).destroy
    redirect_to user_messages_url(watching.watchee.login)
    #redirect_to :back
  end 
end

config/route.rbの書き換え

Rails 3からconfig/route.rbの書き方が変わりましたので、/users/:login名/messagesなどは以下のようにしました。

  root :to => 'messages#index'

  resources :users do
    resources :messages
  end

  resources :messages do
    collection do
      get :home
    end
  end

  resources :sessions
  resources :watchings

細かい所*1

  • APP_ROOT/public/index.htmlは削除しておくこと。
  • 「named_scope」は、「scope」に変更。
  • 「<% form_tag」だとDEPRECATION WARNINGが表示されるので、「<%= form_tag」に変更。
  • p.42 リスト5.1中の「でき」は、「前提」。
  • p.52 リスト5.15の前段落、「features/support/trasform.rb」は、「features/support/transform.rb」。
  • 2011年2月10日9時現在、p.87 用意されたCSSのURL「http://goo.gl/YmQM」にアクセスすると404エラーが発生。本書の最初に書いてあるGitHubのURL*2からapplication.cssを取得した。
  • p.90の「lib/tasks/cucumber_profile.rake」は、「lib/tasks/cucumber.rake」。

余談

PDFをA4に小冊子印刷して、半分に折り、輪ゴムで止めて勉強してました。

Railsを理解した上でなら、集中してやれば2日でやれる分量で、Cucumberのとっかかりには最適な本だと思いました。ただし、RSpecによるテストの解説は無いので、Michael Hartl氏のRuby on Rails 3 Tutorial: Learn Rails by Exampleなどが別途必要になるでしょう。

The RSpec Bookのp.10に、BDDサイクルの図が掲載されていますが、いきなり第1章でこの図を見せられてもイマイチ、私の頭には入りませんでした。

しかし、『はじめる! Cucumber』の演習部分を一通りやった後、第9章のp.92にあるBDDサイクルについて読むと(以下、抜粋)、「あ、自分ならこうテストを書いて実装するな」というイメージがわきました。

1. 外側からの受け入れ条件をCucumberで記述する
2. RSpecを使いながら内部処理を実装していく
3. Cucumberで定義している受け入れ条件を満たしているか確認する

*1:第4章以前の部分につきましては、先日書いた『Cucumberをはじめてみた』をご覧下さい。

*2:https://github.com/moro/begin-cucumber-sample/raw/master/public/stylesheets/application.css

Cucumberをはじめてみた

昨日、『はじめる! Cucumber』(諸橋恭介著、達人出版界、2010年、v0.9.1)を買い、本のタイトルどおり、Cucumberを勉強しはじめました。とりあえず、第4章までやってみたので、Ruby on Rails 3での注意点など書きたいと思います。第5章以降は、また後日

プロジェクトの作成

プロジェクト作成時、「-T」を付けてTest::Unitファイルの作成をスキップ。

$ rails new cuke-handson -T

Gemfile

gemコマンドでインストールするcucumberなどを、Gemfileに追加し、「bundle install」。

group :development do
  gem 'rspec-rails'
  gem 'cucumber-rails'
  gem 'i18n_generators'
end

group :test do
  gem 'rspec'
  gem 'cucumber'
  gem 'webrat'
  gem 'launchy'
end

読み替え

ruby script/generateは、rails generateに。例えば:

$ ruby script/generate cucumber ja --webrat --rspec

これを、以下のように読み替えて実行しました。

$ rails generate cucumber ja --webrat --rspec

Webratのバグ修正*1

Webratがインストールされているディレクト*2にある、lib/webrat/core/session.rbを修正。

def current_host
  URI.parse(current_url).host || @custom_headers["Host"] || default_current_host
end

def default_current_host
  adapter.class==Webrat::RackAdapter ? "example.org" : "www.example.com"
end 

Cucumberのバグ修正*3

APP_ROOT/features/support/env.rb

require 'webrat'
require 'webrat/core/matchers'

Webrat.configure do |config|
  config.mode = :rack
  config.open_error_files = false # Set to true if you want error pages to pop up in the browser
end

World(Webrat::Methods)
World(Webrat::Matchers)

細かい所

  • 本書はRSpecによるテストは無いのですが、「bundle install」した後、「rails generate rspec:install」を実行。
  • p.7の「RAILS_ENV=cucumber rake gems:install」はやらなかった。
  • p.21でi18nジェネレータを実行すると、config/application.rbを上書きしようとするので「Y」をする。
  • p.22でapp/views/messages/new.html.erbを編集しているけど、同ディレクトリの_form.html.erbを編集する。
  • HTTP_ACCEPT_LANGUAGEヘッダを参照するはずなのに、p.24のlocalized_steps.rbで「HTTP_」が抜けてる。
  • p.31のi18n_scaffoldが動かない。本書とは関係ありませんが、i18nジェネレータのlib/generatorsにi18n_scaffoldがないんですが...なんでだろう?
  • p.32-33のヘルパーをApplicationHelperに書き、ApplicationControlerでincludeした。
  • 「rake db:migrate」した後は、「rake db:test:prepare」した。本書には書いてないんですが、これって必要なかったのかなぁ...*4

余談

RSpecとCucumberの棲み分けというか、使い方の違いが段々と実感できるようになってきました。これはあくまでも私の主観で、間違っているかもしれませんが、Selenium IDEでやることをCucumberでやってる感じかなぁ...

*1:Webrat and Rails: Using assert_contain after click_button gives me “You are being redirected”

*2:「$ bundle show webrat」でわかります。

*3:undefined method `visit' for cucumber

*4:(2/10追記)APP_ROOT/lib/tasks/cucumber.rakeで、db:test:prepareしているので必要なかった。

コマンドラインでツイートするRubyスクリプト

komatagaさんのブログ記事、『非同期コミュニケーションツール』を読んで、「vimでプログラミング中でも、外部コマンド実行でツイートできたらなぁ...」と思い、作ってみました。

[追記:2/11 1:54]
Google URL Shortener APIでURLを短縮化するバージョンを作ってみました。-> 『コマンドラインでツイートするRubyスクリプト(つづき)

ソース

#!/usr/bin/env ruby
$KCODE = 'u'

require 'fileutils'
require 'yaml'
require 'rubygems'
require 'oauth'

class Tw
  KEY          = 'consumer_key'
  SECRET       = 'consumer_secret'
  TOKEN        = 'access_token'
  TOKEN_SECRET = 'access_token_secret'

  MAX_LENGTH   = 140
  UPDATE_URL   = 'https://api.twitter.com/1/statuses/update.xml'

  attr_accessor :config

  def initialize
    config_file = File.join(File.expand_path('~'), '.twrc')
    begin
      @config = YAML.load(File.read(config_file))
    rescue
      @config = {
        KEY          => prompt(KEY),
        SECRET       => prompt(SECRET),
        TOKEN        => prompt(TOKEN),
        TOKEN_SECRET => prompt(TOKEN_SECRET)
      }
      File.open(config_file, 'w') {|f| f.write(@config.to_yaml)}
      FileUtils.chmod(0600, config_file)
      puts "saved to #{config_file}"
    end
  end

  def get_token
    consumer = OAuth::Consumer.new(@config[KEY],
                                   @config[SECRET],
                                   :site => 'http://api.twitter.com')
    token_hash = {
      :oauth_token        => @config[TOKEN],
      :oauth_token_secret => @config[TOKEN_SECRET]
    }
    access_token = OAuth::AccessToken.from_hash(consumer, token_hash)
  end

  private

  def prompt(string)
    print string.gsub('_', ' ').capitalize + ":"
    gets.strip
  end
end

tw = Tw.new
unless (status = ARGV.join(' ')).empty? && status.split('').length > Tw::MAX_LENGTH
  response = tw.get_token.post(Tw::UPDATE_URL, {:status => status})
  exit(Net::HTTPOK == response)
else
  exit(false)
end

設定

アプリをTwitter Applicationsで登録してください。アプリケーションの種類は、「クライアントアプリケーション」です。

引数なしで起動し、Consumer Key、Consumer Secret、Access Token、Access Token Secretを入力してください。

Access TokenとAccess Token Secretは、アプリを登録した後、右サイドメニューにある「My Access Token」をクリックすると取得できます。

使い方

Twitterに投稿したいメッセージを引数として、起動してください。複数の引数は、半角の空白で連結します。ただし、半角括弧が文中にある場合は、文章全体をダブルクォーテーションで囲ってください。

注意点

当たり前のことですが、「AS IS」*1です。

なんでLokkaのプラグインを作っているのか

これまでLokkaプラグインを...15個も作ってた...そもそも、私がLokkaのプラグインを作っている理由は:

素のLokkaは...

  • カレンダーがない。→ lokka-calendar
  • 画像ファイルをを投稿できない。→ URLを書けばできるけど、面倒。→ lokka-picasa_filesなど
  • 管理画面の左サイドバーが長い。→ 表示中のページだけメニュー項目を表示する。→ lokka-elastic_admin_menu

この他、mixiチェック(lokka-mixi_check)とかGravatar画像(lokka-gravatar_image)などを作りましたが、これらはSinatraの勉強がてら作ったものです。

また、バク修正パッチなどをGitHubでPull Requestしてたら、Collaboratorとして私を登録していただき...それがモチベーションアップになってプラグイン作成に拍車がかかり...

気がついたら16個目を作ってました。

正規表現で指定したURLパスでアクセスがあった時、スラッグ(Slug)を指定してあるURLへリダイレクトするプラグイン「lokka-redirectors」を作ってみました。

これも、私の会社HPをLokkaにするのに必要なので作りました。会社の定款に「当社の公告は、電子公告によって行う。」と書いてあり、届け出たURLパスが「/company/statement」でした。Apacheなら.htaccessファイルでリダイレクトさせますが、Herokuで動かす場合はそれができません。したがって、「/company/statement」を「/company」にリダイレクトしたいなと。

インストール

データベースに「redirects」テーブルを作成する為、rake db:migrateをお忘れずに。

$ cd APP_ROOT/public/plugin
$ git clone git://github.com/nkmrshn/lokka-redirectors.git
$ cd lokka-redirectors
$ bundle exec rake db:migrate

設定

プラグインをインストールしLokkaを(再)起動すると、Lokka管理画面の左サイドメニューに、「URLリダイレクト」という項目が表示されます。

左サイドメニューの[一覧]をクリックすると、登録してあるURLリダイレクトの一覧が表示されます。

URLリダイレクトの追加は、左サイドメニューの[登録]をクリックしてください。


余談

後は、メールフォームか...これが一番、面倒なんだよなぁ...マッシュアップで解決しようかなぁ...