2010-12-10

内部リンクの置き換えを実現する (MacBloggerGlass)

MacBloggerGlass を作り始めたとき(→「欲しいのはオフライン機能 - Cocoa アプリとして作り直す #0」)、機能実現のステップとして以下の 5 つを挙げた。

  1. 一覧表示
  2. Google Data API Objective-C Client Library の組み込み
  3. 記事表示
  4. ラベル検索
  5. 内部リンクの置き換え

多少、順序は入れ替わっているが、これまでの作業で 1 〜 3 が大筋で実現できている。今回は、内部リンクの置き換えを実現する。GAE 版のときにも書いたが、これができるようになると、アプリが実用的になる。以下が現在のプロトタイプ #0 のスクリーンショットだ。

MacBloggerGlass プロトタイプ #0
アプリウィンドウ
ウィンドウの上部に記事一覧、下部に一覧で選択した記事の内容をそれぞれ表示できるようになった。JavaScript を組み込んでいないものの、その他の点では Blogger Glass と同じスタイルで表示している。

Apple イベントの処理の作り込み

前回のサンプルアプリ(→ 「アプリに Apple イベントの処理を作り込む」)と同等の作り込みを行った。

独自スキームの定義

以下の内容を MacBloggerGlass-Info.plist に追加。太字の値はこちらで定義、他は Xcode のエディタではリストから選ぶだけで良い。

  • URL types
    • Item 0
      • URL identifier → MacBloggerGlass Internal Link
      • Document Role → Viewer
      • URL Schemes
        • Item 0 → bgls

独自スキームを使った URL は以下の形とした。記事の識別に必要な情報だけを詰め込んでいる。

bgls://<blog-id>/<post-id>
Apple イベントハンドラの定義

追加するのは Get URL イベントのハンドラのみ。前回のサンプルアプリと同様、公式ドキュメントで推奨されているようにアプリのデリゲートクラス (MacBloggerGlassAppDelegate) に追加している。

追加後の MacBloggerGlassAppDelegate.m を以下に示す。

#import "AppController.h"
#import "MacBloggerGlassAppDelegate.h"

@implementation MacBloggerGlassAppDelegate

@synthesize window, appController;

- (void)applicationWillFinishLaunching:(NSNotification *)notification {
    NSAppleEventManager *appleEventManager =
    [NSAppleEventManager sharedAppleEventManager];
    [appleEventManager setEventHandler:self
                           andSelector:@selector(handleGetURLEvent:withReplyEvent:)
                         forEventClass:kInternetEventClass
                            andEventID:kAEGetURL];
}

- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
 // Insert code here to initialize your application 
}

#pragma mark -
#pragma mark apple event handler
- (void)handleGetURLEvent:(NSAppleEventDescriptor *)event
           withReplyEvent:(NSAppleEventDescriptor *)replyEvent {
    NSAppleEventDescriptor *directObjectDescpritor =
    [event paramDescriptorForKeyword:keyDirectObject];
    if (! directObjectDescpritor) return;

    NSString *urlString = [directObjectDescpritor stringValue];
    if (! urlString) return;

    NSLog(@"APPLE EVET(GetURL): %@", urlString);

    // custom URL must be in the form like:
    //     bgls://<blog-id>/<post-id>
    NSURL *objectURL = [NSURL URLWithString:urlString];
    NSString *blogID = [objectURL host];
    // path will return a string with the form '/<post-id>'.
    NSString *postID = [[objectURL path] substringFromIndex:1];

    [appController openPost:postID inBlog:blogID];
}
@end

AppController.h を import しているのは、イベントハンドラの最後で、AppContoller のメソッドを呼び出しているから。アプリデリゲートでは、イベントから URL を取り出し、Blog ID と Post ID を切り出すところまでを行い、その後の処理はアプリコントローラに任せている。

また、アプリデリゲートとアプリコントローラの結びつけは、デリゲート側にコントローラ用のアウトレットを設け、Interface Builder 上で接続している。どちらのオブジェクトも MainMenu.xib 内にインスタンス化されているので、これが一番手軽な方法だと思う。

メインコントローラ (AppController) への作り込み

コントローラ側での処理を以下に示す。ここでは、カスタム URL にふくまれている Blog ID を現在表示中のものと比較している。他のブログ(自分で作ったもの)の扱いについては、ひとまず無視することにしている。どう扱うと実用的かは将来の検討課題だ。

#pragma mark -
- (void)openPost:(NSString *)aPostID inBlog:(NSString *)aBlogID
{
    NSLog(@"Open post request: %@ (%@)", aPostID, aBlogID);
    if (! [aBlogID isEqualToString:blogID]) {
        NSLog(@"The blog requested is not currently displayed. (%@)", aBlogID);
        return;
    }
    NSUInteger index = [feed findPost:aPostID];
    if (index == NSNotFound) return;

    [postTable selectRowIndexes:[NSIndexSet indexSetWithIndex:index]
           byExtendingSelection:NO];

    [self showPostWithIndex:index];
}

指定した Posd ID に対応する記事オブジェクト(EntryBlogPost)を探すメソッドを一覧オブジェクト(FeedBlogPost)に実装している。検索は単純な線形探索。これで表示が遅くなるほどの記事数にはならないと思っている。

- (NSUInteger)findPost:(NSString *)aPostID {
    EntryBlogPost *entry;
    for (NSUInteger i = 0; i < [entries count]; i++) {
        entry = [entries objectAtIndex:i];
        if ([entry.postID isEqualToString:aPostID]) {
            return i;
        }
    }
    return NSNotFound;
}

内部リンクの置き換え

内部リンクの置き換えのために、GAE 版でも作ったように(→「内部リンクを置き換える #2」)、permalink と Post ID のひもづけを保持する仕組みが必要になる。

PairOfLinks は辞書で実現

蓄積と探索の処理を単純にするため、Cocoa 標準の辞書オブジェクト(NSMutableDictionary)を使った。ひもづけを保持するのは FeedBlogPost オブジェクトにした。これはこのオブジェクトが記事オブジェクト(EntryBlogPost)の配列を保持しているから、データを抽出するのに相応しいと考えたからだ。以下に FeedBlogPost.m 内の抽出処理を示す。

- (void)extractPairOfLinks {
    pairOfLinks =
    [[NSMutableDictionary alloc] initWithCapacity:[entries count]];
    EntryBlogPost *post;
    for (post in entries) {
        [pairOfLinks setObject:post.postID forKey:post.permalink];
    }
}
置換処理の実際

記事データ中のリンク文字列の置換は、WebView に HTML データをわたす直前に行っている。href="<permalink>" と href='<permalink>' をそれぞれ置換するという手抜きコーディングにしてある。

- (void)renderHTML:(NSString *)content
             title:(NSString *)title
       withBaseURL:(NSURL *)baseURL {
    // replace internal links into those have custom URL.
    NSString *replacedContent = content;
    NSString *permalink;
    for (permalink in [feed.pairOfLinks allKeys]) {
        NSString *aPostID = [feed.pairOfLinks objectForKey:permalink];
        NSString *customLink =
        [NSString stringWithFormat:@"href='bgls://%@/%@'", blogID, aPostID];

        // replace href attribute values wrapped with double quotation
        NSString *targetLink = [NSString stringWithFormat:@"href=\"%@\"", permalink];
        replacedContent =
        [replacedContent stringByReplacingOccurrencesOfString:targetLink
                                                   withString:customLink];

        // replace href attribute values wrapped with single quotation
        targetLink = [NSString stringWithFormat:@"href='%@'", permalink];
        replacedContent =
        [replacedContent stringByReplacingOccurrencesOfString:targetLink
                                                   withString:customLink];
    }
    
    NSString *htmlData =
    [NSString stringWithFormat:templateData, title, replacedContent];

    NSLog(@"Base URL: %@", baseURL);
    [[postView mainFrame] loadHTMLString:htmlData baseURL:baseURL];
}

このメソッドはアプリウィンドウで記事の一覧が選択されたときに呼ばれる。その際、baseURL としてアプリバンドル(MacBloggerGlass.app)のトップが URL としてわたってくる。その部分を以下に示す。

- (void)showPostWithIndex:(NSUInteger)index {
    EntryBlogPost *entry = [feed objectInEntriesAtIndex:index];
    NSLog(@"Post ID: %@", entry.postID);
    [self renderHTML:[entry content]
               title:[entry title]
         withBaseURL:[[NSBundle mainBundle] bundleURL]];
}

表示機能の作り込み

最後に、記事表示機能の作り込みとして、記事データを流し込む HTML のテンプレートと CSS ファイルの組み込みについて説明する。

HTML のテンプレートと CSS ファイル

テンプレートも CSS ファイルも、アプリバンドル (MacBloggerGlass.app) の中に置くことにした。

以下はテンプレートをロードする処理で、アプリコントローラ (AppController) で定義している。NSBundle を使ってテンプレートファイルのパスを特定し、NSString に読み込んでいる。templateData という変数は AppContoller のメンバ変数だ。

- (void)loadHTMLTemplate:(NSString *)templateName {
    NSBundle *mainBundle = [NSBundle mainBundle];
    NSString *pathForTempalte =
    [mainBundle pathForResource:templateName ofType:@"html"];

    templateData = [[NSString alloc] initWithContentsOfFile:pathForTempalte
                                                   encoding:NSUTF8StringEncoding
                                                      error:NULL];
}

読み込まれるテンプレートは以下のもの。注意すべきは 6 行目のスタイルシートへのリンクだ。このテンプレートと CSS ファイルはアプリのリソースの一部として、アプリバンドル(MacBloggerGlass.app)の中にコピーされる。先に示したように、WebView にわたすベース URL としてアプリバンドルのトップを指定している。このため、CSS ファイルの相対パスは 6 行目のようになるわけだ。ただ、これはベース URL の指定を変えて、Contents/Resources がベースになるようにすべきかもしれない。後で、NSBundle のリファレンスをもう少し調べてみよう。

<!DOCTYPE HTML>
<html lang='ja'>
<head>
<meta content='text/html; charset=UTF-8' http-equiv='Content-Type'>
<title>MacBloggerGlass</title>
<link type='text/css' rel='stylesheet' href='Contents/Resources/default.css'>
</head>
<body>
<article>

<div id='view-header'>
  <h3>%@</h3>
</div>

<div id='content'>
%@
</div>

</article>
</body>
</html>

CSS は Blogger Glass で使っているものを手直しして流用。手直しは、テンプレートの構造の変更に対応するためのもの。冒頭のスクリーンショットは、現在の MacBloggerGlass のものだが、GAE 版の Blogger Glass と同じスタイルが使われていることが見てとれる。

次の一手

記事表示の機能については、まだ細部の詰めが残っている。たとえば、ソースコード表示用の JavaScript を組み込むことなど。

記事表示が GAE 版と同等になったら、その次はラベル検索に取り組むか。機能的には GAE 版と同じく GData を使って検索するもので良いが、問題はユーザ体験だ。まずは、そこからになるな。

いや、それより先に「オフライン機能」すなわちデータの保存について考えるべきだろう。そもそもそのための Cocoa 版なのだから。

関連リンク

関連記事

0 件のコメント:

コメントを投稿