Org-mode 문서에서 링크를 긁어서 링크 섹션을 만들기

4 minute read

문장에 걸어놓은 url 링크가 많으면 찾아보기 쉽게 링크 섹션을 하나 만들어서 모아두고 싶다. 링크가 많은 프로그래밍 관련 글에서 주로 이렇게 하고 있는데, org-mode로 글을 쓰면서 왜 이걸 손으로 하고 있나 자괴감이 든다. 그래서 글에서 http https url을 싹 긁고 해당 url을 방문해서 타이틀을 가져와 링크 리스트를 만드는 emacs lisp 함수를 만들었다.

(defun my-org-extract-urls (org-elements)
  ;; link 타입 org element만 map
  (org-element-map org-elements 'link
    (lambda (link)
      (let* ((link-part (nth 1 link))
             (type (plist-get link-part :type))
             (path (url-unhex-string (plist-get link-part :raw-link))))
        ;; "https", "http"로 시작하는 link만 골라낸다
        (if (or (string= type "https") (string= type "http"))
            ;; "https://...", "http://..." 같은 전체 주소
            path)))))

org 요소(element)들을 받아 https, http로 시작하는 url만 추출한다. 정규식을 써서 link를 골라내야 하나 계획을 세우고 있었는데, 유용하게 쓸 수 있는 org 요소 API가 많다. org-element-map 함수를 사용해 org 요소에서 url을 추출한다. org mode 다른 함수를 만들 때, 사용한 함수 같다. 잘 정리해서 유저가 사용할 수 있는 API로 제공한다.

url-unhex-string 함수를 사용해 퍼센트 인코딩을 디코딩해서 저장한다. 퍼센트 인코딩된 문자열을 그냥 저장하니 제대로 저장되지 않아서 디코딩해서 저장했다.

(my-org-extract-urls (org-element-parse-buffer))

org mode 버퍼 전체에서 url을 추출하고 싶다면 org-element-parse-buffer 함수를 인자로 넘기면 된다.

(defun my-org-build-link-section ()
  (interactive)
  (let ((links (my-org-extract-urls (org-element-parse-buffer))))
    (seq-do (lambda (link) (message link)) links)))

M-x my-org-build-link-section 명령으로 실행할 수 있는 대화형 함수를 만들어보자. 위에서 만든 my-org-extract-urls 함수를 호출해서 지금 편집 중인 버퍼에 있는 url을 모두 추출해 message 버퍼에 찍게 했다. 잘 나온다.

잘 추출하는 걸 확인했으니 이제 heading을 만들고 url을 목록으로 추가해본다.

(defun my-org-build-link-section ()
  (interactive)
  (let ((links (my-org-extract-urls (org-element-parse-buffer))))
    (org-insert-heading-after-current)
    (insert "링크")
    (org-return t)
    (org-return t)
    (seq-map-indexed (lambda (elt idx)
                       ;; 첫번째 요소는 직접 정렬되지 않은 목록 아이템을 넣어준다
                       (if (= idx 0)
                           (progn
                             (insert (format "- %s" elt))
                             (org-return t))
                         ;; 두번째 요소 부터는 org-insert-item 함수를 호출해
                         ;; 이전 목록 아이템을 참고해 자동으로 넣는다
                         (progn
                           (org-insert-item)
                           (insert elt)
                           (org-return t))
                         ))
                     links)))

M-return 과 비슷하게 동작하는 org-insert-heading-after-current 함수를 호출해서 heading을 만들었다. 어디에 heading을 추가할지 고민했는데, 커서가 있는 위치 근처에 추가해야 놀라지 않을 것 같아서 이렇게 결정했다.

이제 목록을 추가할 차례다. org-list-insert-item 함수를 사용하려고 했는데, 왜 이렇게 복잡하냐? 우아하게 추가하는 건 나중으로 미룬다. 첫번째 항목을 (insert (format "- %s" elt)) 이런 식으로 - 문자를 앞에 붙여서 추가한다. org mode에서 목록으로 인식한다. 그다음 항목부터는 (org-insert-item) 함수를 호출해서 org mode에서 다음 항목을 추가할 준비를 대신하게 한다.

이제 막바지다. url을 방문해서 제목을 가져온다. 이 정보로 link를 만든다. ’org-cliplink 패키지로 title과 url을 편하게 삽입’ 글에서 추가한 org-cliplink 패키지 함수에 url을 넘기면 link를 삽입해준다. 이 패키지를 이용하자.

(defun my-org-build-link-section ()
  (interactive)
  (let ((links (sort
                (delete-dups (my-org-extract-urls (org-element-parse-buffer)))
                'string<)))
    (org-insert-heading-after-current)
    (insert "링크")
    (org-return t)
    (org-return t)
    (seq-map-indexed (lambda (elt idx)
                       (message (format "processing - %s" elt))
                       (let* ((url (url-encode-url elt))
                              (title (or (org-cliplink-retrieve-title-synchronously url)
                                         "nil"))
                              (link-elt (my-org-link-transformer url title)))
                         ;; 첫번째 요소는 직접 정렬되지 않은 목록 아이템을 넣어준다
                         (if (= idx 0)
                             (progn
                               (insert (format "- %s" link-elt))
                               (org-return t))
                           ;; 두번째 요소 부터는 org-insert-item 함수를 호출해
                           ;; 이전 목록 아이템을 참고해 자동으로 넣는다
                           (progn
                             (org-insert-item)
                             (insert link-elt)
                             (org-return t)))))
                     links)))

글에 같은 링크가 여러 번 들어갈 수 있다. delete-dups 함수를 호출해 중복을 없애고 비슷한 링크가 뭉칠 수 있게 sort 함수로 정렬한다. url-encode-url 함수를 사용해 퍼센트 인코딩한다. org-cliplink-retrieve-title-synchronously 함수를 호출해서 title을 가져온다.

(defun my-org-link-transformer (url title)
  (let* ((parsed-url (url-generic-parse-url url)) ;parse the url
         (host-url (replace-regexp-in-string "^www\\." "" (url-host parsed-url)))
         (clean-title
          (cond
           ;; if the host is github.com, cleanup the title
           ((string= (url-host parsed-url) "github.com")
            (replace-regexp-in-string "^/" ""
                                      (car (url-path-and-query parsed-url))))
           ;; (replace-regexp-in-string "GitHub - .*: \\(.*\\)" "\\1" title))
           ((string= (url-host parsed-url) "www.youtube.com")
            (replace-regexp-in-string "\\(.*\\) - Youtube" "\\1" title))
           ;; otherwise keep the original title
           (t title)))
         (title-with-url (format "%s - %s" clean-title host-url)))
    ;; forward the title to the default org-cliplink transformer
    (org-cliplink-org-mode-link-transformer url title-with-url)))

my-org-link-transformer 함수에서 url과 title을 받아서 org mode에서 link로 인식할 수 있게 [[LINK][DESCRIPTION]] 문자열을 만든다. ’org-cliplink 패키지로 title과 url을 편하게 삽입’ 글에서 추가한 my-org-cliplink 함수에서 추가한 후처리 코드를 my-org-link-transformer 함수로 추출했다.

전체 코드

(defun my-org-build-link-section ()
  (interactive)
  (let ((links (sort
                (delete-dups (my-org-extract-urls (org-element-parse-buffer)))
                'string<)))
    (org-insert-heading-after-current)
    (insert "링크")
    (org-return t)
    (org-return t)
    (seq-map-indexed (lambda (elt idx)
                       (message (format "processing - %s" elt))
                       (let* ((url (url-encode-url elt))
                              (title (or (org-cliplink-retrieve-title-synchronously url)
                                         "nil"))
                              (link-elt (my-org-link-transformer url title)))
                         ;; 첫번째 요소는 직접 정렬되지 않은 목록 아이템을 넣어준다
                         (if (= idx 0)
                             (progn
                               (insert (format "- %s" link-elt))
                               (org-return t))
                           ;; 두번째 요소 부터는 org-insert-item 함수를 호출해
                           ;; 이전 목록 아이템을 참고해 자동으로 넣는다
                           (progn
                             (org-insert-item)
                             (insert link-elt)
                             (org-return t)))))
                     links)))

(defun my-org-link-transformer (url title)
  (let* ((parsed-url (url-generic-parse-url url)) ;parse the url
         (host-url (replace-regexp-in-string "^www\\." "" (url-host parsed-url)))
         (clean-title
          (cond
           ;; if the host is github.com, cleanup the title
           ((string= (url-host parsed-url) "github.com")
            (replace-regexp-in-string "^/" ""
                                      (car (url-path-and-query parsed-url))))
           ;; (replace-regexp-in-string "GitHub - .*: \\(.*\\)" "\\1" title))
           ((string= (url-host parsed-url) "www.youtube.com")
            (replace-regexp-in-string "\\(.*\\) - Youtube" "\\1" title))
           ;; otherwise keep the original title
           (t title)))
         (title-with-url (format "%s - %s" clean-title host-url)))
    ;; forward the title to the default org-cliplink transformer
    (org-cliplink-org-mode-link-transformer url title-with-url)))

(defun my-org-extract-urls (org-elements)
  ;; link 타입 org element만 map
  (org-element-map org-elements 'link
    (lambda (link)
      (let* ((link-part (nth 1 link))
             (type (plist-get link-part :type))
             (path (url-unhex-string (plist-get link-part :raw-link))))
        ;; "https", "http"로 시작하는 link만 골라낸다
        (if (or (string= type "https") (string= type "http"))
            ;; "https://...", "http://..." 같은 전체 주소
            path)))))

참고

C-x C-s C-x C-c