본문 바로가기

vscode-with-tistory

vscode extension 개발일기8: 이미지업로드, 게시글 수정

로직

기능을 구현하기 전에 이미지 업로드, 게시글 수정은 어떤 로직을 가져야 하는지를 서술한다.

이미지 업로드

티스토리 게시글에 이미지를 첨부한다. 해당 기능을 제공하기 위한 로직은 다음과 같다.

  1. 게시글을 읽는 과정에서 이미지 옵션을 발견한다.
  2. 이미지를 업로드를 수행하고 로컬경로로 설정되어있는 경로를 업로드한 경로로 변경한다.
  3. 포스팅 수행

여기서 이미지 옵션을 찾는다고 해서 모든 파일을 업로드하지 않는다. 파일의 경로가 로컬 경로이면 업로드를 수행하고 단순 외부링크일 경우에는 별도로 티스토리에 업로드를 수행하지 않는다.

게시글 수정

게시글 수정은 새로운 게시글을 작성하는 것과 로직이 거의 동일하다. 그러나 거의 동일한 것이지 완전히 같다는 것은 아니다. 게시글 수정만 가지는 특징은 다음과 같다.

  1. postId파라미터 필요

    md파일에 작성된 블로그의 게시글의 id를 요구한다. 이를 통해 같은 명령어로 게시글 작성 및 수정이 가능하다. 이유는 postId로 새로운 게시글인지, 이미 업로드된 게시글인지를 판단하기 때문이다.

  2. published 파라미터의 선택적 적용

    이미 작성된 게시글에서 md파일에 작성되어 있던 날짜를 그대로 올릴 수 없다. 이유는 게시글을 수정하면 게시글이 수정이 반영된다. 예를들어 11월5일날 작성한 게시글에서 오타가 발견되어 11월 30일날 수정해서 올렸는데 다른 사람들은 11월 30일날 작성했다고 오해를 받을 수 있기 때문이다. 그러므로 게시글을 수정할 때는 date날짜를 무조건 수용할 수 없다. 이를 위해date의 날짜가 업로드하는 현재 시간보다 뒤에 있어야 반영되는 로직을 작성해야 한다.

1번 과정은 파싱된 내용에서 값의 유무만 체크하면 되지만 2번과정은 약간 복잡할 것으로 예상한다.


이미지 업로드 테스트 코드 작성

본 코드로 옮기기 전에 mocha에서 로직 작성하고 테스트를 수행한다. 코드 작성중 발생한 이슈가 무엇이 있었고 어떻게 처리하였는지 서술한다.

이미지 업로드를 작성하면서 발생된 이슈와 해결방식은 다음과 같다.

1. 파일 업로드 이슈

브라우저에선 FormData모듈을 통해 이미지를 업로드할 수 있다. 그러나 NodeJs에서는 해당 모듈이 기본으로 탑재되어있지 않는다.

이를 해결하기 위해 form-data라는 모듈을 설치해야한다. 해당 모듈의 라이선스는 MIT라이선스이므로 사용해도 문제 없다고 판단하였다.

form-data 모듈 페이지: https://github.com/form-data/form-data

    it("Upload Image", async () => {
        const fileStream = fs.readFileSync(
            "D:\\blog\\vscode-with-tistory-test\\[5-1]1920x1080.jpg"
        );
        const FormData = await import("form-data");
        const formData = new FormData();
        formData.append("access_token", accessToken);
        formData.append("output", "json");
        formData.append("blogName", "greenflamingo");
        formData.append("uploadedfile", fileStream, "[5-1]1920x1080.jpg");
        const {
            data,
            data: { tistory },
        } = await axios({
            method: "post",
            url: API_URI.UPLOAD_FILE,
            headers: { "Content-Type": "multipart/form-data" },
            data: formData,
            validateStatus: (status: number) => status >= 200 && status < 500,
        });
        assert.ok(data.tistory);
        assert.strictEqual(tistory.status, "200");
        console.log(tistory.url);
        console.log(tistory.replacer);
    });

결과는 다음과 같았다.

...? 왜 실패하지 생각해서 data내용을 출력시켜봤다.

failmessage;

xml형식으로 반환해주는 것을 보니 파라미터를 아예 인식을 안하는 것을 판단하였다.

이를 해결하기 위해 formData의 getHeaders함수를 사용하였다.

getHeaders함수는 formData의 내장 함수로서 formData의 내용을 읽어서 알맞은 content-type header을 설정해준다.

getHeaders()함수의 반환 내용을 보면 content-type뿐만 아니라 boundary내용도 추가된다.

getHeaders

코드를 다음과 같이 고쳐서 성공하였다.

    it("Upload Image", async () => {
        const buffer = fs.readFileSync(
            "D:\\blog\\vscode-with-tistory-test\\[5-1]1920x1080.jpg"
        );
        const FormData = await import("form-data");
        const formData = new FormData();
        formData.append("access_token", accessToken);
        formData.append("output", "json");
        formData.append("blogName", "greenflamingo");
        formData.append("uploadedfile", buffer, "[5-1]1920x1080.jpg");
        const {
            data,
            data: { tistory },
        } = await axios({
            method: "post",
            url: API_URI.UPLOAD_FILE,
            headers: formData.getHeaders(),
            data: formData,
            validateStatus: (status: number) => status >= 200 && status < 500,
        });
        assert.ok(data.tistory);
        assert.strictEqual(tistory.status, "200");
    });

2. 이미지 경로 변경

1번과정에서 이미지를 업로드 하고 업로드된 url을 게시글에 적용시켜야 한다. 이를 위해 작성된 내용에서 이미지 링크를 티스토리에 업로드된 이미지 링크로 변경해야 한다. 즉 게시글의 내용에 수정을 가해야 한다. 이를 구현할 수 있는 방법은 2가지이다.

  1. 파일을 업로드를 수행한 후 원본 마크다운 파일의 이미지 경로를 변경
  2. 게시글을 업로드할 때만 이미지 경로를 임시로 변경(원본 마크다운 파일엔 변화를 주지 않는다.)

필자는 2번과정으로 구현하기로 하였다. 이유는 다음과 같다.

왜 이런 로직을 썼냐

  1. 게시글의 글 내용을 바꾸는 것은 지양해야 한다.

    사용자의 게시글은 매우 다양한 형태로 존재한다. 즉 게시글을 변경하는 작업은 매우 많은 변수들이 존재한다. 특히 이미지의 경우 게시글의 이미지가 많을수록 변경되야 하는 요소도 점점 많아진다. 이들을 관리하기 위한 코드를 작성하고 여러 케이스에서 정상동작함을 확인하기 위해 테스트를 수행하는 과정이 매우 오래 걸릴 것이다.

  2. 외부에서 글을 삭제할 때 이미지도 같이 삭제되는 여부를 알 수 없다.

    티스토리 사용자가 굳이 vscode로만으로 제어한다고 판단할 수 없다. 웹페이지로 게시글을 삭제 및 변경을 수행할 경우 이미지의 url이 변경될 수 있고 vscode extension은 이를 파악할 수 없다.

  3. 게시글을 수정할 때 이미지가 변경될 수 있다

    로컬에서 게시글을 수정해서 적용할 경우 같은 이름으로 이미지를 저장해서 push를 할 수 있다. 이처럼 외부의 프로그램으로 인해 이미지가 변경되어도 extension은 이를 인지할 수 없다.

어느 부분에서 변경할까

변경할 수 있는 부분은 다음과 같다.

  1. 글을 읽은 후 마크다운 문법으로만 이루어진 텍스트
  2. 마크다운글을 html로 포팅된 텍스트

2번 부분을 선택해서 구현을 수행할려다가 markdown-it을 조사를 해보니 좀 더 쉬운 방법이 찾아서 해당 방법으로 해보기로 하였다.

markdown-it token

markdown-it 데모 사이트를 들어가보면 신기한 부분이 하나가 있다. 바로 디버그창이다.

마크다운 데모 사이트: https://markdown-it.github.io/

랜더링된 결과창에서 디버그 모드로 보면 json형태로 html로 변경될 내용이 저장되어 있다.

{
"type": "inline",
    "tag": "",
    "attrs": null,
    "map": [
      152,
      153
    ],
    "nesting": 0,
    "level": 1,
    "children": [
      {
        "type": "image",
        "tag": "img",
        "attrs": [
          [
            "src",
            "https://octodex.github.com/images/dojocat.jpg"
          ],
          [
            "alt",
            ""
          ],
          [
            "title",
            "The Dojocat"
          ]
        ],
        "map": null,
        "nesting": 0,
        "level": 0,
        "children": [
          {
            "type": "text",
            "tag": "",
            "attrs": null,
            "map": null,
            "nesting": 0,
            "level": 0,
            "children": null,
            "content": "Alt text",
            "markup": "",
            "info": "",
            "meta": null,
            "block": false,
            "hidden": false
          }
        ],
        "content": "Alt text",
        "markup": "",
        "info": "",
        "meta": null,
        "block": false,
        "hidden": false
      }
    ],
    "content": "![Alt text][id]",
    "markup": "",
    "info": "",
    "meta": null,
    "block": true,
    "hidden": false
},

마크다운을 변경한 html의 정보가 객체 형태로 담겨져 있으며 children을 통해 태그 내에 존재하는 태그 정보도 표현한다. 위의 json객체를 markdown-it에선 token으로 지칭한다. 마크다운을 html로 랜더링하는 과정에서 프록시를 통해 이미지 태그만 잡아내서 이미지 url을 티스토리에 업로드한 url로 바꿔버리면 통과될 것이라고 기대한다.

해당 부분을 테스트를 구현하기 위해 다음과 같은 과정을 수행하였다.

  1. 마크다운 내용을 html형식으로 token화한다.
  2. token을 순회하면서 이미지 태그를 발견하면 src의 경로의 이미지로 파일 업로드 수행 후 값 변경(프록시)
  3. token을 html로 변경

token을 순회하는 경우에는 token내에 children이라는 현재태그 내에 속한 또 다른 태그 정보가 들어있다. 즉 부모-자식관계를 가진 토큰이 존재하며 모든 태그를 탐색하기 위해 children이 null값이 될 때까지 탐색해야한다. 즉 범위를 알 수 없는 트리에서 type객체 값이 image이고 tag가 img인 트리 탐색 문제로 변경되었다.

다행히 나같은 사람을 위한 markdown-it플러그인이 존재했다. markdown-it-for-inline플러그인을 사용하면 된다. MIT라이선스를 가지며 속도는 약간 느려지지만 특정 태그의 값들을 특정룰로 수정할 수 있다는 것이 이점을 가진다. 트리탐색을 수행해야하는 구조여서 그런것으로 조심스럽게 예상해본다. 복잡하게 위의 로직대로 모두 작성할 필요 없이 반복해서 탐색할 패턴만 규칙대로 삽입해주면 된다.

코드는 다음과 같다.

테스트 코드

    it("Convert imagePath", async () => {
        const iterator = require("markdown-it-for-inline");
        md.use(
            iterator,
            "uploadImage",
            "image",
            (tokens: Token[], idx: number) => {
                const token = tokens[idx];
                token.attrSet("src", `img/${token.attrGet("src")}`);
            }
        );
        const result = md.render("![test](./test.jpg)");
        assert.match(result, /img\/\.\/test.jpg/);
        console.log(result);
    });

uploadImage라는 rule-name을 가지고 이미지 타입의 token에서 src 속성의 값을 변경하는 테스트 코드이다.

테스트를 수행해보면 조건을 통과하고 이미지 태그의 src이외의 다른 값들은 변함이 없다는 것을 판단한다.

convertImage


테스트 코드 이식

이미지 업로드하는 로직과 마크다운 문법에서 img 태그의 src속성을 바꾸는 로직을 작성하였음으로 이 둘을 합쳐본다.

예상할 수 없는 오류가 발생될 수 있다고 생각하여 mocha에서 합쳐본다. 합쳐보니 다음과 같은 문제점이 발견되었다.

1. 상대경로 이미지 파일 읽기

상대경로의 파일을 nodejs의 fs모듈로 읽을려니 경로를 찾지 못하였다. 이미지 로직을 작성할 때 절대경로로 테스트를 수행하였는데 상대경로를 파악하지 못했다.

상대경로가 안되는 이유를 찾아보았는데 nodejs가 실행되는 경로가 vscode가 설치된 경로로 잡히는 것을 확인하였다. 이는 블로그 포스팅에서도 발생할 수 있을 가능성이 있었다. 그러나 블로그 포스팅은 게시글에선 발생하지 않는다.

그렇다고 블로그 포스팅과 동일한 방법을 사용할 수 없다. 이유는 이미지 경로가 항상 상대경로라는 보장이 없고 로컬 파일을 접근할 것이라고 보장할 수 없다.

이를 해결하기 위해 nodejs의 path모듈을 사용하기로 하였다.

path모듈에는 resolve라는 join과 달리 상대경로를 인식하여 경로를 만들어준다. 간단하게 console에서 테스트를 수행해보았다.

path_resolve

활성화된 창을 기준으로 시작 위치를 잡은 것을 가정하였다.

첫번째의 경우는 이미지가 상대경로일 경우를 가정한 방법이다. 첫번째 인자가 마크다운 파일임에도 불구하고 두번째 경로가 상대경로임을 인지하여 해당 경로에 맞게 바꿔주어서 절대경로 형식으로 반환해주었다.

두번재의 경우는 이미지가 절대경로일 경우를 가정한 방법이다. 첫번째와 마찬가지로 마크다운 파일 경로임에도 불구하고 절대경로를 인지하고 앞에 작성된 절대경로가 무시되어서 반환된다.

이를 통해 다음과 같은 해결 방법을 얻었다.

  1. vscode에서 활성화된 창(포스팅할려는 블로그 게시글)을 기준으로 탐색을 시작
  2. path.resolve함수를 통해 경로를 수정.
    • 경로에는 블로그 게시글의 이름이 포함되어 있으므로 ../ 를 통해 블로그 게시글의 디렉토리 경로만 남도록 한다.
  3. 수정된 경로의 파일을 읽는다.

해당 로직은 파일이 절대경로든 상대경로든 상관없이 사용할 수 있는 방법이라 매우 쓰기 좋은 방법이라고 생각했다. 테스트 코드는 다음과 같다.

    const uploadImage = async (imagePath: string): Promise<string> => {
        const {
            window: { activeTextEditor },
        } = vscode;

        const imageAbsolutePath = path.resolve(
            activeTextEditor!.document?.uri.fsPath,
            "../",
            imagePath
        );
        const buffer = fs.readFileSync(imageAbsolutePath);
        const FormData = await import("form-data");
        const formData = new FormData();
        formData.append("access_token", accessToken);
        formData.append("output", "json");
        formData.append("blogName", selectedBlog.name);
        formData.append("uploadedfile", buffer, "coffee.jpg");
        const {
            data,
            data: { tistory },
        } = await axios({
            method: "post",
            url: API_URI.UPLOAD_FILE,
            headers: formData.getHeaders(),
            data: formData,
            validateStatus: (status: number) => status >= 200 && status < 500,
        });
        assert.ok(data.tistory);
        assert.strictEqual(tistory.status, "200");
        return tistory.url;
    };
    it("Upload Absolute Path Image", async () => {
        const url = await uploadImage(
            "D:\\blog\\vscode-with-tistory-test\\coffee.jpg"
        );
        console.log("absolute image", url);
    });
    it("Upload Relative Path Image", async () => {
        const url = await uploadImage("./coffee.jpg");
        console.log("relative image", url);
    });

동일한 파일을 상대경로, 절대경로로 업로드를 시도했다.

결과는 다음과 같다.

upload_image_test

같은 파일임에도 불구하고 다른 url을 가지므로 모두 이미지 업로드가 성공되므로 절대경로 이미지, 상대경로 이미지를 모두 업로드를 성공하여 구현이 완료되었다.

2. 외부 이미지

외부 이미지에 대한 처리가 진행되어있지 않는다. 외부이미지와 로컬에 저장된 이미지를 분류하지 않고 모두 업로드를 수행한다. 여기서 외부 이미지란 외부 서버에 저장되어 있는 이미지를 지칭한다. 가장 많이 사용되는 방식으로는 http, https를 통해 다른 서버에 저장되어 있는 페이지를 불러오는 방법이다.

이는 간단하게 처리할 수 있다. 바로 vscode.Uri를 사용하면 된다.

Uri는 파일의 경로를 식별해주는 vscode의 객체이다. 유사한 기능을 수행하는 Nodejs의 URL도 있으나 URL를 사용하지 않기로 했다. 이유는 다음과 같다.

  1. vscode의 이점을 최대한 누리자 vscode extension을 개발하면서 vscode의 기능을 사용하지 않으면 모든 vscode에서 동일하게 수행된다는 보장이 없다.
  2. parse함수의 지원 여부 nodejs와 vscode에는 parse라는 string형식의 파일 경로를 Uri객체로 반환해주는 함수가 존재한다. 둘이 비슷한 기능을 수행하지만 다음의 차이점이 존재한다.
    • nodejs에서는 parse함수가 레거시이다.(https://nodejs.org/api/url.html#urlparseurlstring-parsequerystring-slashesdenotehost)
    • vscode에서는 parse함수가 레거시가 아니다.(https://code.visualstudio.com/api/references/vscode-api#Uri)

테스트 코드는 다음과 같다.

    it("Check External Image", async () => {
        const name1 =
            "https://cdn.pixabay.com/photo/2017/11/12/09/05/black-2941843_960_720.jpg";
        const name2 = "./coffee.jpg";
        const name3 = "D:\\blog\\vscode-with-tistory-test\\coffee.jpg";
        assert.strictEqual(vscode.Uri.parse(name1).scheme, "https");
        assert.strictEqual(vscode.Uri.parse(name2).scheme, "file");
        assert.strictEqual(vscode.Uri.parse(name3).scheme, "file");
    });

결과는 다음과 같다.

imageTest

절대경로 부분에서 scheme를 file이 아닌 D로 잡는다. 아마 scheme가 file://, https:// 형식으로 잡히므로 D를 scheme로 인식하는 것 같다. 어차피 외부 경로를 적어놓은 name1은 통과하므로 외부 경로의 파일인지 로컬파일인지를 판별하는 기능을 수행하는 것에는 문제가 없을 것이라고 생각하여 넘어가기로 하였다.

3. 업로드된 이미지 미반영

포스팅이 이미지 업로드보다 먼저 처리되는 기이한 문제가 발견되었다. 포스트 내용을 업로드 된 이미지가 안보인다. 테스트 코드는 다음과 같다.

    it("Post Blog3: Upload Markdown && Image", async () => {
        const document = vscode.window.activeTextEditor?.document;
        assert.strictEqual(document?.languageId, "markdown");
        const blogName = selectedBlog.name;
        const [options, markdownContent] = await readFile(
            document.uri,
            blogName
        );
        const md = new MarkdownIt();
        md.use(require("markdown-it-emoji"));
        md.use(
            require("markdown-it-for-inline"),
            "uploadImage",
            "image",
            async (tokens: Token[], idx: number) => {
                const token = tokens[idx];
                const imageAbsolutePath = path.resolve(
                    document.uri.fsPath,
                    "../",
                    `${token.attrGet("src")}`
                );
                const uri = vscode.Uri.parse(`${token.attrGet("src")}`);
                if (uri.scheme === "https" || uri.scheme === "http") {
                    return;
                } else {
                    const FormData = require("form-data");
                    const formData = new FormData();
                    const buffer = fs.readFileSync(imageAbsolutePath);
                    const splitPath = uri.path.split("/");
                    const filename = splitPath.pop();
                    formData.append("access_token", accessToken);
                    formData.append("output", "json");
                    formData.append("blogName", selectedBlog.name);
                    formData.append("uploadedfile", buffer, filename);
                    const {
                        data: { tistory },
                    } = await axios({
                        method: "post",
                        url: API_URI.UPLOAD_FILE,
                        headers: formData.getHeaders(),
                        data: formData,
                        validateStatus: (status: number) =>
                            status >= 200 && status < 500,
                    });
                    assert.ok(tistory);
                    assert.strictEqual(tistory.status, "200");
                    assert.strictEqual(tistory.error_message, undefined);
                    token.attrSet("src", tistory.url);
                }
            }
        );
        const content = md.render(markdownContent);
        assert.ok(accessToken);
        const {
            data: { tistory },
        } = await axios({
            method: "post",
            url: API_URI.PUSH_POST,
            data: {
                access_token: accessToken,
                output: "json",
                blogName: selectedBlog.name,
                title: options.title,
                visibility: options.post,
                published: options.date,
                password: options.password,
                tag: options.tag,
                category: options.category,
                slogan: options.url,
                acceptComment: options.comments,
                content,
            },
            validateStatus: (status: number) => status >= 200 && status < 500,
        });
        assert.ok(tistory);
        assert.strictEqual(tistory.status, "200");
        console.log(tistory.url);
    });

결과는 다음과 같다.

imageTest

파일을 업로드하는 동안 기다리질 않아서 반환되는 구나 싶어서 파일 업로드를 promise로 변경하여 대기하도록 유도했는데 대기하지 않고 바로 반환된다.

markdown-it-for-inline함수가 비동기 내용을 아예 처리하지 못하는 것 같다. 그러나 promise를 사용하면 비동기 내용을 동기적으로 처리할 수 있는데 promise도 거부되는 걸 보면 내가 모르는 뭔가가 있는 것 같다.

구글링을 해서 문제를 해결하고 싶지만 해당 모듈은 다른 플러그인에 비해 인기가 없어 유의미한 내용이 나오진 않는다.

어쩔 수 없이 해당 모듈을 제거하고 알고리즘을 작성한다는 결론을 내렸다.

알고리즘 자체는 어렵진 않지만 귀찮다. 위에서 말했듯이 이 알고리즘은 token들을 탐색 알고리즘을 통해 이미지 태그를 찾고 태그의 내용을 바꿔주면 된다. BFS나 DFS중에 1개 선택해서 작성하면 된다.

DFS는 call stack에러가 날 것 같아 BFS로 작성하였다. 로직은 다음과 같다.

    it("Post Blog3: Upload Markdown && Image", async () => {
        const document = vscode.window.activeTextEditor?.document;
        assert.strictEqual(document?.languageId, "markdown");
        const blogName = selectedBlog.name;
        const [options, markdownContent] = await readFile(
            document.uri,
            blogName
        );
        //md2html
        const md = new MarkdownIt();
        md.use(require("markdown-it-emoji"));
        const tokens = md.parse(markdownContent, {});
        //uploading image use BFS
        const queue: Array<Token> = [];
        queue.push(...tokens);
        while (queue.length > 0) {
            const token = queue.shift();
            if (token?.children) {
                token.children.forEach((child) => queue.push(child));
            }
            const inputImageSrc = token!.attrGet("src");
            if (
                token?.type === "image" &&
                token.tag === "img" &&
                inputImageSrc
            ) {
                const imageAbsolutePath = path.resolve(
                    document.uri.fsPath,
                    "../",
                    inputImageSrc
                );
                const uri = vscode.Uri.parse(inputImageSrc);
                if (uri.scheme === "https" || uri.scheme === "http") {
                    break;
                } else {
                    const FormData = require("form-data");
                    const formData = new FormData();
                    const buffer = fs.readFileSync(imageAbsolutePath);
                    const splitPath = uri.path.split("/");
                    const filename = splitPath.pop();
                    formData.append("access_token", accessToken);
                    formData.append("output", "json");
                    formData.append("blogName", selectedBlog.name);
                    formData.append("uploadedfile", buffer, filename);
                    const {
                        data: { tistory },
                    } = await axios({
                        method: "post",
                        url: API_URI.UPLOAD_FILE,
                        headers: formData.getHeaders(),
                        data: formData,
                        validateStatus: (status: number) =>
                            status >= 200 && status < 500,
                    });
                    assert.ok(tistory);
                    assert.strictEqual(tistory.status, "200");
                    token.attrSet("src", tistory.url);
                }
            }
        }
        // Markdown Token to HTML
        const content = md.renderer.render(tokens, {}, {});
        // push new post
        assert.ok(accessToken);
        const {
            data: { tistory },
        } = await axios({
            method: "post",
            url: API_URI.PUSH_POST,
            data: {
                access_token: accessToken,
                output: "json",
                blogName: selectedBlog.name,
                title: options.title,
                visibility: options.post,
                published: options.date,
                password: options.password,
                tag: options.tag,
                category: options.category,
                slogan: options.url,
                acceptComment: options.comments,
                content,
            },
            validateStatus: (status: number) => status >= 200 && status < 500,
        });
        assert.ok(tistory);
        assert.strictEqual(tistory.status, "200");
        console.log(tistory.url);
    });

content내용도 잘 변경되고 업로드도 성공되었다. 이제 이 로직을 실제 코드에 넣으면 된다. 넣는 부분은 블로그 수정 로직이 짜여진 후에 수행한다. 이유는 블로그가 잘 업로드가 되면 업로드된 블로그의 postId를 파일에 직접 적어줘야 하기 때문이다.(위에 있는 코드들은 깃의 커밋로그에 남아있다.)

4. 게시글 id남기기

게시글 수정을 위해 새 글을 업로드하고 업로드된 블로그의 게시글id를 파일에 작성시켜야 한다. 원래 새로운 게시글을 작성할 때 삽입할 내용이었는데 까먹었다. 게시글 아이디는 게시글 상단의 포스팅할 게시글의 옵션을 넣는 곳의 최하단에 넣고자 한다 즉 다음과 같은 작성되어야 한다.

---
title: typescript
date: 2021-10-25
post: private
tag: 
- typescrit
- 타입스크립트
comments: true
category: typescript
postId: 10
---

그러나 해당 문제를 작성하기엔 다음과 같은 문제점에 직면한다.

  1. 특정 위치에서 부터 파일을 읽기 위해 파일을 처음부터 다시 읽어야 한다.
  2. 특정 위치부터 파일을 읽는 기눙이 존재하는가

1번의 경우에는 파일을 읽는 과정에서 문자열의 길이를 count하는 방식으로 수정 위치를 찾을 수 있다. 2번 과정이 좀 어렵다. 왜냐하면 nodejs fs에서 지원해주지 않는다. vscode fs에서도 마찬가지로 지원해주지 않는다. 그러나 vscode의 다른 모듈로 해결할 수 있다.

TextEditor

https://code.visualstudio.com/api/references/vscode-api#TextEditor

vscode의 TextDocument의 내용을 수정할 수 있는 객체다. 해당 객체에는 edit이란 메소드가 존재하는데 콜백 함수로 TextEditorEdit객체를 받는다.

TextEditorEdit객체에는 insert라는 특정 위치에 내용을 추가로 작성할 수 있는 함수가 존재한다. 특정 위치는 Position이란 객체를 읽으며 Position에는 글의 line과 line내에서 수정될 위치를 인자로 받는다. 즉 위치를 행렬처럼 받는다.

내용조차 string으로 입력받으므로 버퍼로 변환해주는 처리조차 필요가 없다.

https://code.visualstudio.com/api/references/vscode-api#TextEditorEdit

테스트 코드

테스트를 위해 다음과 같은 코드를 수정 혹은 추가하였다.

  1. postId를 작성할 위치 찾기
export const readFile = async (
    uri: vscode.Uri,
    blogName: string
): Promise<[ParsedOptions, string, number]> => {
    const { createReadStream } = await import("fs");
    const { createInterface } = await import("readline");

    const categoryList = await getCategories(blogName);
    const options = new ParsedOptions(categoryList);
    let lineNumber = 0;
    let endParsing = false;
    let parsingTag = false;
    let buffer = [""];
    let postIdLocationLine = 0;
    const rl = createInterface({
        input: createReadStream(uri.fsPath),
        crlfDelay: Infinity,
    });
    for await (const line of rl) {
        if (!endParsing) {
            // parsingOptions
            if (lineNumber === 0 && line !== "---") {
                throw new Error(ERROR_MESSAGES.FailParsing);
            } else if (lineNumber === 0 && line === "---") {
                lineNumber++;
                continue;
            } else if (lineNumber > 0 && line !== "---") {
                const parsedArray = parsingOption(line);
                if (parsedArray.length > 0) {
                    const isParsingSuccess = options.setOption(
                        parsedArray,
                        parsingTag
                    );
                    if (parsingTag) {
                        options.tag = parsingTagOption(line);
                    }
                    if (isParsingSuccess) {
                        parsingTag = false;
                    } else if (!isParsingSuccess) {
                        parsingTag = true;
                    }
                }
            } else {
                postIdLocationLine = lineNumber;
                endParsing = true;
            }
        } else {
            // get context
            buffer.push(line + "\n");
        }
        lineNumber++;
    }
    return [options, buffer.join(""), postIdLocationLine];
};

옵션은 ---문자로 끝나므로 해당 문자가 존재하는 위치를 찾는다.

  1. 파일 수정

전체 코드중 추가한 내용은 다음과 같다.

        const activeTextEditor = vscode.window.activeTextEditor;
        activeTextEditor?.selection;
        activeTextEditor!.edit((editorBuilder) => {
            const position = new vscode.Position(
                postIdLocation,
                "".charCodeAt(0)
            );
            editorBuilder.insert(position, `postId: ${tistory.postId}\n`);
        });
    });

포스팅을 수행한 활성화된 창에서 옵션이 끝나는 위치인 --- 문자 앞에 postId를 작성한다. 이때 개행을 추가하여 ---을 다음 라인으로 넘겨서 해당 줄에는 postId만 남도록 한다. 테스트 결과는 다음과 같다.

testresult


게시글 수정 테스트 코드 작성

게시글 수정 로직을 작성하면서 발생한 문제와 해결방법을 작성하였다.

문제1: 내부 서버 오류(500)

게시글을 수정하는 과정에서 서버에서 500에러를 반환하였다.

internal_error

반환 내용도 없어서 어떤 파라미터에서 문제가 발생했는지 알 수 없었다.

에러가 발생된 원인은 아마 잘못된 파라미터를 전달해줘서 문제가 발생하였다고 생각하여 파라미터를 보기로 하였다. 그래서 테스트 코드를 작성해서 어디에서 문제가 발생하였는지 찾기 시작했다.

노가다를 하다가 찾았다. published파라미터 문제에서 발생되었다.

실제로 해당 부분을 주석처리하고 동작하면 정상수행된다.

not_published

published파라미터의 값을 주는 date값은 게시글에 적혀있는 날짜를 실제로 적용시키지 않고 현재시간보다 미래시간이어야 적용시킨다. 코드는 다음과 같다.

        const currentTime = new Date().getTime();
        if (parseInt(options.date) > currentTime) {
            updatePostInfo = Object.assign(updatePostInfo, {
                published: options.date,
            });
        }

생각보다 원인은 다른곳에 있었다. javascript의 timestamp는 일반적인 timestamp와 다르다. 일반적인 timestamp인 UNIX Timestamp는 단위가 초(s) 지만 javascript의 timestamp는 밀리초(ms)단위로 나온다. 티스토리는 UNIX Timestamp를 사용하는데 javascript는 밀리초 단위로 반환을 해주니 잘못된 시간이라고 판단하고 500에러를 준다.

새글을 작성할 때는 406에러로 반환해주고 timestamp가 1년 이상이라는 오류를 반환해주지만 게시글 수정은 이를 반영해주지 않는다.(아니, 이런건 좀 써주라고). 실제로 javascript timestamp를 unix timestamp로 바꾸면 5521년으로 나온다.(티스토리 3500년 운영 기원)

그래서 getter을 밀리초가 아닌 초단위의 timestamp로 반환되도록 변경하였다.

public get date(): string {
    if (this._date) {
        return Math.floor(this._date.getTime() / 1000).toString();
    } else {
        return "";
    }
}

정상적으로 반영된다.

success_modify

문제2 comments의 잘못된 반환

500에러를 찾다가 발견된 문제이다. comment작성 허용 여부를 작성하는 acceptComment는 0혹은 1의 값을 주어야 하나 boolean으로 주고 있었다. 아마 기본값이 1이므로 이전에 발견되지 않았던 문제인것 같다. 그래서 getter을 수정하였다.

public get comments(): string {
    if (this._comments || !this?._comments) {
        return "1";
    } else {
        return "0";
    }
}