読者です 読者をやめる 読者になる 読者になる

はてなブログに自作VIMスクリプトで記事を書く

概要

VIMでメモをよく取る、このノリでブログを書きたい。 調べてみると、すでにはてなブログへ投稿するプラグインは存在する。

しかし、この仕組みは自作できるようになっていれば、他の開発でも役に立つはずと考え 自作してみることにした。ちなみに初vimscript。いちおうgithubで公開中hajimemat/hateblo.vim

行った調査は以下。

  1. vimとはてなブログはどうつながるか
    1. 通信仕様 AtomPub
    2. 認証仕様 WSSE
  2. vimでどう実現するか
    1. 通信ツール math/webapi-vim
  3. 参考にしたスクリプト
    1. 既存のツール moznion/hateblo.vim

わかったこと

webapi-vimを使うとAtomPubのAPIと通信ができる。 HTTPリクエストを行い、XMLレスポンスをパースし、vimscript内で配列として受け取れる。

こんなスクリプトを書いて確認

api_url = "エントリポイント"
user = "はてなID"
api_key = "はてなAPIKEY
echo webapi#atom#getFeed(api_url, user,api_key)

作るもの

これから作るものの仕様を考えてみた

  • インターフェイスはuniteを使う
  • 通常のVIMの編集作業と変わるようなコマンド、ショートカットは使わない
  • VIMの保存 = サーバへの送信としたい
  • 削除機能は実装しない
  • 編集書式は常にMarkdownとする

コマンド

モード CMD -
ex :Unite hateblo-list 記事一覧を表示する+新規作成ができる
ex :w サーバにデータを保存する

書式

1行目にタグ、タイトルを記述する。 未公開の場合はタイトルの前にD:が表示される。 公開させるにはD:を削除して保存する。

# [:tag1,:tag2] タイトル
# [:tag1,:tag2] D:タイトル

セッティング

let g:hateblo_settings = {
\ 'user': 'はてなID',
\ 'blog': 'はてなブログID',
\ 'api_key': 'APIキー',
\ }

新規作成

Uniteで起動したリストからNewを選択。 プロンプトでタイトルとカテゴリが聞かれる

以下のように入力

TITLE: タイトル
CATEGORIES: cat,cat2
  • 追記[2017/01/01] ~~ 保存で送信はされるが、送信されたデータのentry_urlが取れなかったので、 一度保存したら、Uniteから再び開いて編集モードで送信しないと、 新規記事が何個もできてしまう。。。~~

webapi-vimのソースを読んだら、戻り値でentry_urlを返却していたので、 新規で開く保存 => 作成。 もう一度保存 => 上書き。 になるように処理フローを変更

function! webapi#atom#createEntry(uri, user, pass, entry, ...) abort
  let headdata = a:0 > 0 ? a:000[0] : {}
  let headdata["Content-Type"] = "application/x.atom+xml"
  let headdata["X-WSSE"] = s:createWsse(a:user, a:pass)
  let headdata["WWW-Authenticate"] = "WSSE profile=\"UsernameToken\""
  let res = webapi#http#post(a:uri, s:createXml(a:entry), headdata, "POST")
  let location = filter(res.header, 'v:val =~ "^Location:"')
  if len(location)
    return split(location[0], '\s*:\s\+')[1]
  endif
  return ''
endfunction

ソースコード一式

plugin/plugin.vim

let g:hateblo_draft_marker = "D:"
" :wでサーバに送信する仕組み
augroup hateble_env
  autocmd!
  autocmd BufWriteCmd hateblo:* call hateblo#editor#save()
augroup END

autoload/hateblo/webapi.vim

" WEBAPI用の設定などを取得する
" エントリポイントを取得する
function! hateblo#webapi#getEndPoint()
  return g:hateblo['entry_point']
    \ .'/'
    \ .g:hateblo['user']
    \ .'/'
    \ .g:hateblo['blog']
endfunction

" 記事用のエントリポイントを取得する
function! hateblo#webapi#getEntryEndPoint()
  return hateblo#webapi#getEndPoint()
    \ .'/atom/entry'
endfunction

autoload/hateblo/util.vim

" 文字列操作などのユーティリティを格納する
function! hateblo#util#stripWhitespace(str)
  let l:str = substitute(a:str, '^\s\+', '', '')
  return substitute(l:str,'\s\+$', '', '')
endfunction

autoload/hateblo/entry.vim

" エントリの処理

" エントリ一覧を取得する
function! hateblo#entry#getEntries()
  return hateblo#entry#getEntriesWithURL(hateblo#webapi#getEntryEndPoint())
endfunction

function! hateblo#entry#getEntriesWithURL(api_url)
  let l:feed = webapi#atom#getFeed(a:api_url, g:hateblo['user'],g:hateblo['api_key'])
  let b:hateblo_entries = l:feed['entry']
  let b:hateblo_next_link = ''
  for l:link in l:feed['link']
    if l:link['rel'] == 'next'
      let b:hateblo_next_link = l:link['href']
    endif
  endfor
  return b:hateblo_entries
endfunction


function! hateblo#entry#getList()
  if !exists('b:hateblo_entries')
    call hateblo#entry#getEntries()
  endif

  let l:entries = b:hateblo_entries
  let l:list = []

  for l:entry in l:entries
    if l:entry['app:control']['app:draft'] == 'yes'
      let l:word = '[draft] '.l:entry['title']
    else
      let l:word = l:entry['title']
    endif

    call add(l:list, {
      \ 'word': l:word,
      \ 'source': 'hateblo-list',
      \ 'kind': 'file',
      \ 'action__action': 'edit_entry',
      \ 'action__entry_url': l:entry['link'][0]['href'],
      \ 'draft': l:entry['app:control']['app:draft']
      \})
  endfor

  if b:hateblo_next_link != ''
    call add(l:list, {
      \ 'word': '### NEXT PAGE ###',
      \ 'source': 'hateblo-list',
      \ 'kind': 'file',
      \ 'action__action': 'next_page',
      \ 'action__url': b:hateblo_next_link
      \})
  endif

  call add(l:list, {
    \ 'word': '### NEW ###',
    \ 'source': 'hateblo-list',
    \ 'kind': 'file',
    \ 'action__action': 'new',
    \})

  call add(l:list, {
    \ 'word': '### Reflesh ###',
    \ 'source': 'hateblo-list',
    \ 'kind': 'file',
    \ 'action__action': 'reflesh',
    \})
  return l:list
endfunction


function! hateblo#entry#getCategories(entry)
  let l:categories = []
  for l:category in a:entry['category']
    call add(l:categories, l:category['term'])
  endfor
  return l:categories
endfunction

autoload/hateblo/editor.vim

" 編集
function! hateblo#editor#edit(entry_url)
  let l:entry = webapi#atom#getEntry(a:entry_url, g:hateblo_vim['user'],g:hateblo_vim['api_key'])
  let l:type = 'html'
  execute 'edit hateblo:'.fnameescape(l:entry['title'])
  execute ":%d"
  let b:entry_url = a:entry_url
  let b:entry_is_new = 0
  if l:entry['app:control']['app:draft'] == 'yes'
    let b:entry_is_draft = 1
  else
    let b:entry_is_draft = 0
  endif
  call append(0, hateblo#editor#buildFirstLine(l:entry['title'],hateblo#entry#getCategories(l:entry)))
  call append(2, split(l:entry['content'], '\n'))
  execute ":2"
  if l:entry['content.type']  ==# 'text/x-markdown'
    let l:type = 'markdown'
  elseif l:entry['content.type']  ==# 'text/x-hatena-syntax'
    let l:type = 'hatena'
  endif
  execute 'setlocal filetype='.l:type.'.hateblo'
endfunction

" ファーストラインからタイトルとタグを取得する
function! hateblo#editor#parseFirstLine(line)
  let l:matched = matchlist(a:line, '#\s\+\[\(.\+\)\]\s\+\(.\+\)')
  if len(l:matched) < 1
    return []
  endif
  let l:categories = []
  for l:category in split(l:matched[1],',')
    call add(l:categories, hateblo#util#stripWhitespace(substitute(l:category, '^\s*:', '', '')))
  endfor
  let l:title = l:matched[2]
  return {
    \ 'categories': l:categories,
    \ 'title': l:title
    \}
endfunction

&quot; ファーストラインを作成する
function! hateblo#editor#buildFirstLine(title,categories)
  let l:categories = []
  for l:category in a:categories
    call add(l:categories, &quot;:&quot;.l:category)
  endfor
  if b:entry_is_draft == 1
    return &quot;# [&quot;.join(l:categories, ',').&quot;] &quot;.g:hateblo_draft_marker.a:title
  else
    return &quot;# [&quot;.join(l:categories, ',').&quot;] &quot;.a:title
  endif
endfunction

&quot; 保存
function! hateblo#editor#save()
  let l:data = hateblo#editor#parseFirstLine(getline(1))

  let l:categories = l:data['categories']
  let l:title = l:data['title']
  let l:contents = join(getline(3,'$'), &quot;\n&quot;)

  if l:title[0:len(g:hateblo_draft_marker)-1] ==# g:hateblo_draft_marker
    let l:title = l:title[len(g:hateblo_draft_marker):]
    let l:is_draft = 'yes'
  else
    let l:is_draft = 'no'
  endif

  if b:entry_is_new == 1
    call webapi#atom#createEntry(
      \ hateblo#webapi#getEntryEndPoint(),
      \ g:hateblo_vim['user'],
      \ g:hateblo_vim['api_key'],
      \ {
      \   'title': l:title,
      \   'content': l:contents,
      \   'content.type': 'text/plain',
      \   'content.mode': '',
      \   'app:control': {
      \     'app:draft': l:is_draft,
      \   },
      \   'category': l:categories
      \ })
    echom &quot;Created&quot;
    execute(&quot;:q!&quot;)
    call hateblo#entry#getEntries()
    Unite hateblo-list
  else
    call webapi#atom#updateEntry(
      \ b:entry_url,
      \ g:hateblo_vim['user'],
      \ g:hateblo_vim['api_key'],
      \ {
      \   'title': l:title,
      \   'content': l:contents,
      \   'content.type': 'text/plain',
      \   'content.mode': '',
      \   'app:control': {
      \     'app:draft': l:is_draft,
      \   },
      \   'category': l:categories
      \ })
    echom &quot;Saved&quot;
  endif
  
endfunction

function! hateblo#editor#create()
  let l:data = {}
  let l:data['title'] = input('TITLE: ')
  if len(l:data['title']) < 1
    echom 'Canceled'
    return 0
  endif
  let l:data['categories'] = split(input('CATEGORIES: '),',')
  execute 'tabe hateblo:'.fnameescape(l:data['title'])
  let b:entry_is_draft = 1
  call append(0, hateblo#editor#buildFirstLine(l:data['title'], l:data['categories']))
  &quot; let l:data = hateblo#editor#parseFirstLine(getline(1))
  &quot; if len(l:data) < 1
  &quot;   let l:data = {}
  &quot;   let l:data['title'] = input('TITLE: ')
  &quot;   if len(l:data['title']) < 1
  &quot;     echom 'Canceled'
  &quot;     return 0
  &quot;   endif
  &quot;   let l:data['categories'] = split(input('CATEGORIES: '),',')
  &quot;   execute 'tabe hateblo:'.fnameescape(l:data['title'])
  &quot;   call append(0, hateblo#editor#buildFirstLine(l:data['title'], l:data['categories']))
  &quot; else
  &quot;   execute 'tabe hateblo:'.fnameescape(l:data['title'])
  &quot;   call append(0, getline(0,'$'))
  &quot; endif

  let b:entry_is_new=1
  execute 'setlocal filetype=markdowm.hateblo'
endfunction

autoload/unite/sources/hateblo_list.vim

let s:save_cpo=&cpo
set cpo&vim
&quot; Unite用

let s:source = {
      \ 'name': 'hateblo-list',
      \ 'description': 'Entry list of hateblo',
      \ 'action_table': {
      \  'on_choose': {
      \   }
      \ },
      \ 'default_action': 'on_choose'
      \ }

function! s:source.gather_candidates(args,context)
  return hateblo#entry#getList()
endfunction

function! s:unite_action_on_choose(candidate)
  &quot;echo a:candidate.action__action
  if a:candidate.action__action == 'edit_entry'
    call hateblo#editor#edit(a:candidate['action__entry_url'])
  elseif a:candidate.action__action == 'next_page'
    &quot;echo a:candidate
    call hateblo#entry#getEntriesWithURL(a:candidate.action__url)
    Unite hateblo-list
  elseif a:candidate.action__action == 'reflesh'
    &quot;echo a:candidate
    call hateblo#entry#getEntries()
    Unite hateblo-list
  elseif a:candidate.action__action == 'new'
    call hateblo#editor#create()
  else
    echo 'not impl'
  endif
endfunction

function! s:source.action_table.on_choose.func(candidate)
    call s:unite_action_on_choose(a:candidate)
endfunction

function! unite#sources#hateblo_list#define()
  return s:source
endfunction

let &cpo = s:save_cpo
unlet s:save_cpo