pukiwikiの記事を流し込む

(144d) 更新


プログラミング/svelte

将来的に移行することを考えて?

このサイトは pukiwiki を使って作られていて、 どうやら全部で 500 ページくらいあるみたい(え?!

これをデータとして流し込んでみる。

データベースの更新

もともとは markdown で記事を書く予定だったところに pukiwiki の記事を混ぜるとわけわからなくなる。

そこで、

  • 個々のページが pukiwiki 文法なのか markdown 文法なのかを表すフラグを用意する
  • pukiwiki 文法の場合、表示の際に pukiwiki → markdown → html の変換を行う

sqlite では enum は使えないらしいので該当項目は Int で我慢する。

あと、添付ファイルに誰がアップロードしたかの情報がなかったので追加

authorId に @map をつけ忘れていたので付けた

prisma/schema.prisma

  model User {
    id       String       @id @unique
    sessions Session[]
    keys     AuthKey[]
  
    name     String       @unique
    email    String       @unique
    roles    Role[]
    articles Article[]
+   attachments Attachment[]
  }

  model Article {
    id Int @id @default(autoincrement())
    author User @relation(fields: [authorId], references: [id]) 
    authorId String @map("author_id")
    title String
    body String
    newRevisionId Int? @unique @map("new_revision_id")
    oldRevision Article? @relation("ArticleRevision", fields: [newRevisionId], references: [id])
    newRevision Article? @relation("ArticleRevision")
    createdAt DateTime @default(now()) @map("created_at")
    deletedAt DateTime? @map("deleted_at")
    attachments Attachment[]
+   syntax Int @default(0) // 0:MARKDOWN, 1:PUKIWIKI
 
    @@index([deletedAt, title, createdAt])
    @@index([newRevisionId, deletedAt, title])
    @@index([newRevisionId, deletedAt, createdAt])
  }

  model Attachment {
    id String @ id @default(uuid())
    article Article @relation(fields: [articleId], references: [id])
*   articleId Int @map("article_id")
+   author User @relation(fields: [authorId], references: [id]) 
+   authorId String @map("author_id")
    body Bytes
    createdAt DateTime @default(now()) @map("created_at")
    modifiedAt DateTime @default(now()) @map("modified_at")
  
    @@index([articleId])
  }
LANG: console
$ pnpm prisma migrate dev --name "add syntax column to Article"
$ pnpm prisma migrate dev --name "modify Attachment"

データの取り込み

記事本文

pukiwiki/wiki に E38398E383ABE38397.txt みたいな訳の分からない名前のファイルがずら~っとあって、 これらが記事の本文

ファイル名は記事タイトルを英文字も全部含めて encodeURIComponent 的な変換をして % を取り除いたもの

日付はファイルの最終編集日時から取れる

バックアップファイル

このほかに backup に E38182E38184E38195E381A4.gz みたいなファイルがたくさんあって、ここに編集履歴が入っている。

>>>>>>>>>> 1100902204

のように10個の ">" に続けてスペース、10桁の数値が続く行がセパレータになっていて、 間に昔の記事が挟まる。

https://www.yoheim.net/blog.php?q=20141002

によれば zlib を使うと .gz ファイルを node だけで解凍できる。

数値は unix timestamp なので、1000 を掛けると javascript で Date にできる
https://qiita.com/shirokurotaitsu/items/5efd855900ec6135bbab

添付ファイル

attach フォルダに 4D656E75426172_747769747465722E706E67 みたいのがたくさんある

_ の前までがページ名で後ろがファイル名

このほかに .log というファイルがあって、カンマ区切りの数値が入っているのだけれど・・・ そこまで必要な情報ではなさそう???

カウンター

counter フォルダーに 4D656E75426172.count みたいのが入っている

  • total
  • date
  • today
  • yesterday
  • ip

が1行ずつ格納されている。

これだけの情報があれば取り込みは簡単にできそう。

取り込み用のプログラム

とにかく列挙して、データベースに突っ込む。

newRevision を正しく設定しなければならないのでバックアップの処理だけちょっとややこしい。

新しい方から突っ込んで、古い方の newRevision に新しいやつの id を入れる。

id の若さと記事の古さが合わなくなるけど、まあたぶん問題にはならない・・・と思いたい?!

prismaseedPukiwikiData.ts

LANG: ts
import { db } from '../src/lib/server/db';
import * as fs from 'fs';
import * as zlib from 'zlib';

const pukiwikiFolder = 'pukiwiki/';
const wikiFolder = pukiwikiFolder + 'wiki/';
const backupFolder = pukiwikiFolder + 'backup/';
const attachFolder = pukiwikiFolder + 'attach/';

const authorId = "57e5q9g7t6ihvjt";

declare type PukiwikiPage = {title: string, encoded: string};
declare type PukiwikiAttach = {page: string, name: string, encoded: string, revision: number};

function getPages() {
  const pages = [] as PukiwikiPage[];

  const dir = fs.opendirSync(wikiFolder);
  for(let item=dir.readSync(); item; item=dir.readSync()){
    if(!item.name.match(/\.txt$/)) continue;
    const encoded = item.name.replace(/\.txt$/, '');
    const title = decodeURIComponent(encoded.replace(/../g, m=>'%'+m));
    pages.push({title, encoded});
  }
  dir.closeSync();

  return pages;
}

function getBackups() {
  const backups = [] as PukiwikiPage[];

  const dir = fs.opendirSync(backupFolder);
  for(let item=dir.readSync(); item; item=dir.readSync()){
    if(!item.name.match(/\.gz$/)) continue;
    const encoded = item.name.replace(/\.gz$/, '');
    const title = decodeURIComponent(encoded.replace(/../g, m=>'%'+m));
    backups.push({title, encoded});
  }
  dir.closeSync();

  return backups;
}

function getAttaches() {
  const attaches = [] as PukiwikiAttach[];

  const dir = fs.opendirSync(attachFolder);
  for(let item=dir.readSync(); item; item=dir.readSync()){
    if(item.name.match(/\.log$|^\.|\.html$/)) continue;

    const m = item.name.match(/\.(\d+)$/);
    const revision = m ? Number(m[1]) : 0;
    const encoded = item.name.replace(/\.\d+$/, '');

    const [page, name] = encoded.split(/_/).map(s=>
      decodeURIComponent(s.replace(/../g, m=>'%'+m)));

      attaches.push({page, name, encoded, revision});
  }
  dir.closeSync();

  return attaches;
}

async function insertPage(page: PukiwikiPage) {
  console.log(`ページを読み込み : ${page.title}`);

  const filepath = wikiFolder + page.encoded + '.txt';
  const body = fs.readFileSync(filepath).toString('utf-8');
  const date = fs.statSync(filepath).mtime;
  const record = await db.article.create({
    data: {
      authorId,
      title: page.title,
      createdAt: date,
      body,
      syntax: 1,  // pukiwiki
    }
  });
  return record.id;
}

// バックアップページを登録
// 最新ページがあればそれも登録
// 最新ページがなければ削除フラグを付ける
async function insertAllBackups(pages: PukiwikiPage[], backups: PukiwikiPage[]) {
  for(const backup of backups) {
    const filepath = backupFolder + backup.encoded + '.gz';
    const zipped = fs.readFileSync(filepath);
    const text = zlib.gunzipSync(zipped).toString('utf-8');
    const page = pages.find(page=> page.title == backup.title);
    let newer: number | null = null;
    if(page) {
      newer = await insertPage(page);
    }
    for(const block of text.split(/^>>>>>>>>>> (?=\d{10}$)/m).reverse()) {
      if(!block) continue;  // 先頭ブロックは空になる
      const dateStr = block.substring(0, 10);
      const date = new Date(Number(dateStr) * 1000);
      console.log(`  バックアップ : ${date.toLocaleString()}`);
      const body = block.substring(11);
      const record = await db.article.create({
        data: {
          authorId: authorId,
          title: backup.title,
          createdAt: date,
          body,
          syntax: 1,  // pukiwiki
          newRevisionId: newer,
          deletedAt: newer ? null : date, // 最新ページがなければ削除フラグ
        }                                 // 正しい時刻は分からない?
      });
      newer = record.id;
    }
  }
}

// バックアップを持ったないページを登録
async function insertAllPagesWithoutBackup(pages: PukiwikiPage[], backups: PukiwikiPage[]) {
  for(const page of pages) {
    if(!backups.find(backup=> page.title == backup.title)) {
      await insertPage(page); 
    }
  }
}

async function InsertAllAtaches(attaches: PukiwikiAttach[]) {
  for(const attach of attaches) {
    if(attach.revision > 0) continue;
    const article = await db.article.findFirst({where:{
      title: attach.page,
      deletedAt: null,
      newRevisionId: null,
    }});
    if(article) {
      console.log(`${attach.page} に ${attach.name} を添付`)
    } else {
      console.log(`*** ページ "${attach.page}" が存在しないため  ${attach.name} を添付できません`)
      continue;
    }
    const filepath = attachFolder + attach.encoded;
    const body = fs.readFileSync(filepath);
    const date = fs.statSync(filepath).mtime;
    const revmax = attaches.reduce((revmax, a)=> a.encoded == attach.encoded && a.revision > revmax ? a.revision : revmax, 0);
    let createdAt = date;
    if(revmax>0){
      createdAt = fs.statSync(filepath+'.'+revmax).mtime;
    }
    await db.attachment.create({
      data: {
        body,
        modifiedAt: date,
        createdAt,
        articleId: article.id, 
        authorId
      }
    });
  }
}

// pukiwiki タイプのデータを一旦すべて消す?
/*
await db.attachment.deleteMany({
  where: {
    article: {
      syntax: 1 // pukiwiki
    }
  }
});

await db.article.deleteMany({
  where:{
    syntax: 1 // pukiwiki
  }
});
*/

const backups = getBackups();
console.log(`${backups.length} 件のバックアップを確認`);

const pages = getPages();
console.log(`${pages.length} 件のページを確認`);

await insertAllBackups(pages, backups);
await insertAllPagesWithoutBackup(pages, backups);

const attaches = getAttaches();
console.log(`${pages.length} 件の添付ファイルを確認`);

await InsertAllAtaches(attaches);

これを、

LANG: console
$ pnpm ts-node prisma/seedPukiwikiData.ts

として実行。

原稿で記事の数は 500 件くらいなのだけれど、

バックアップも全部取り込んだおかげで 5,200 件くらいの Article が作られた(汗

Attachment は 1,300 件くらい

うへぇ

とはいえ sqlite だとこの件数でも数分で終わった。

dev.db のサイズは 410,345,472 ・・・ん?

400 MB あるのか。。。

LANG: console
$ git add . && git commit -m "pukiwiki 記事の取り込み"
       new file:   prisma/migrations/20231202005706_add_syntax_column_to_article/migration.sql
       new file:   prisma/migrations/20231202031246_modify_attachment/migration.sql
       modified:   prisma/schema.prisma
       new file:   prisma/seedPukiwikiData.ts

Recent ページの作成

流し込んだ記事を確認する用途にも、最近更新された記事を確認できるページが欲しい。

src/routes/articles/recent/+page.svelte

LANG: ts
<script lang="ts">
  import type { PageData } from './$types';
  import { path } from '../lib';
  export let data: PageData;
</script>

<div class="prose">
  <ul>
    {#each data.articles as article}
      <li>
        <a href={path(article)}>{article.title}</a>
        <small>({article.createdAt.toLocaleString()})</small>
      </li>
    {/each}
  </ul>
</div>

<style>
  li {
    margin-top: 0;
    margin-bottom: 0;
  }
</style>

src/routes/articles/recent/+page.svelte

最新記事の一覧

変換したページを確認するにも、記事一覧がないと話にならない。

最終編集時刻順に表示するページを作る。

で、まず必要になるのが Pagination コントロール

DaisyUI を使うと表示側は難しくなく書ける。

src/lib/components/Pagination.svelte

LANG: html
<script lang="ts">
  import { createEventDispatcher } from 'svelte';

  export let pages: number;
  export let page: number;

  let pageNums = [] as number[];
  for(let i = 0; i < pages; i++) {
    pageNums[i] = i + 1;
  }
  const dispatch = createEventDispatcher();

  function click(num: number) {
    dispatch('click', {num});
  }
</script>

<div class="join">
  <button class="join-item btn{page==1?' btn-disabled':''}" on:click={()=>click(page-1)}>&nbsp; Prev &nbsp;</button>
  {#each pageNums as num}
    {#if num == 1 || (num <= 7 && page <= 4) || Math.abs(num - page) <= 2 || (pages - 4 <= page && pages - 6 <= num)  || num == pages }
      <button class="join-item btn{page==num?' btn-active':''}" on:click={()=>click(num)}>{num}</button>
    {:else}
      {#if num == 2 || num == pages - 1}
        <button class="join-item btn btn-disabled">...</button>
      {/if}
    {/if}
  {/each}
  <button class="join-item btn{page==pages?' btn-disabled':''}" on:click={()=>click(page+1)}>&nbsp; Next &nbsp;</button>
</div>

on:click の e.detail.page に選択されたページ番号が入る。

で、最新記事一覧はこのコンポーネントを使って、

src/routes/articles/recent/+page.svelte

LANG: html
<script lang="ts">
  import Pagination from '$lib/components/Pagination.svelte';
  import type { PageData } from './$types';
  import { path } from '../lib';
  export let data: PageData;  // { article, page, pages, size }

  function click(e: CustomEvent) {
    location.search = `page=${e.detail.page}`;
  }
</script>

<div class="prose">
  <Pagination page={data.page} pages={data.pages} on:click={click} />
  <ul>
    {#each data.articles as article}
      <li>
        <a href={path(article)}>{article.title}</a>
        <small>({article.createdAt.toLocaleString()})</small>
      </li>
    {/each}
  </ul>
  <Pagination page={data.page} pages={data.pages} on:click={click} />
</div>

<style>
  li {
    margin-top: 0;
    margin-bottom: 0;
  }
</style>

サーバー側はこう。

src/routes/articles/recent/+page.server.ts

LANG: ts
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';

export const load = (async (event) => {
  let page = Number(event.url.searchParams.get('page')) || 1;
  let size = Number(event.url.searchParams.get('size')) || 50;
  if(size < 10) size = 10;
  if(page < 0) page = 0;

  const condition = {
    where: {
      newRevisionId: null,
      deletedAt: null,
    },
  };

  const count = await db.article.count(condition);

  const pages = Math.ceil(count/size);
  if(page > pages) page = pages;

  const articles = await db.article.findMany({
    ...condition,
    orderBy: {
      createdAt: 'desc',
    },
    skip: (page - 1) * size,
    take: size,
    select: {
      title: true,
      createdAt: true,
      author: true,
    },
  });
  return { articles, page, pages, size };
}) satisfies PageServerLoad;

これで表示できるようになった。

LANG: console
$ git add . && git commit -m "articles/recent ページを作成"
       new file:   src/lib/components/Pagination.svelte
       new file:   src/routes/articles/recent/+page.server.ts
       new file:   src/routes/articles/recent/+page.svelte

本当は +page.server.ts じゃなく +page.ts で fetch を使って表示できるように api を整えたほうが良いんだろうけど・・・そこらへんはまた後で調べる。

編集時に syntax も変更できるようにする

新規作成時も選べる。

src/lib/components/Select.svelte

<script lang="ts">
  import type { Writable } from 'svelte/store';

  export let name: string;
  export let label: string;
  export let labelAlt: string = '';
  export let value: string;
  export let disabled = false;
  export let errors: Writable<{}> | undefined = undefined;
  export let props: svelteHTML.IntrinsicElements['select'] | undefined = undefined;

  let key = name as keyof typeof errors;
</script>

<div class="form-control w-full">
  <label for={name} class="label">
    <span class="label-text">{label}</span>
    {#if labelAlt}<span class="label-text-alt">{labelAlt}</span>{/if}
  </label>
  <select
    {...{ name, ...props }}
    bind:value
    {disabled}
    class="w-full select select-bordered select-primary"
    class:input-error={errors && $errors && $errors[key]}
  >
    <slot />
  </select>
  {#if errors && $errors && $errors[key]}
    <label class="label" for={name}>
      <span class="label-text-alt text-error">{$errors[key]}</span>
    </label>
  {/if}
</div>

src/routes/articles/zod-schema.ts

LANG ts
  import { z } from 'zod';

  export const schema = z.object({
    title: z
      .string()
*     .regex(/^[^0-9]*$|(?<!\/(edit|rename|delete)$)/, {
*       message: 'タイトルが空あるいは数値のページ、 /edit, /rename, /delete で終わるページは作成できません',
      }),
    body: z.string(),
+   syntax: z.number().min(0).max(1),
  });

src/routes/articles/ArticleEditor.svelte

LANG: html
  import Select from '$lib/components/Select.svelte';
  ...

  export let syntax: number;

  function syntaxChange(e: Event) {
    const select = e.target as HTMLSelectElement;
    syntax=Number(select.options[select.selectedIndex].value)
  }
  ...

    <Select name="syntax" label="文法" {disabled} {errors}
      value={syntax.toString()} on:change={syntaxChange}>
      <option value="0">Markdown</option>
      <option value="1">Pukiwiki</option>
    </Select>

数値フィールドを select に結び付けるのに直接 bind:value できないので、 value と on:change で双方向バインディングを実現している。

src/routes/articles/[...titleOrId]/(login)/edit/+page.svelte

  <ArticleEditor
    formTitle="記事を編集"
    submitTitle="編集を反映"
    {...{ message: $message, enhance, disabled: $submitting, errors }}
    bind:title={$form.title}
    bind:body={$form.body}
+   bind:syntax={$form.syntax}
  />

src/lib/index.ts

LANG: ts
export function contractTo<T extends object, S extends T>(larger: S, smaller: T) {
  const result={} as T;
  Object.getOwnPropertyNames(smaller).forEach(k=> {
    if(Object.hasOwn(smaller, k)) {
      result[k as keyof T] = larger[k as keyof T];
    }
  });
  return result;
}

src/routes/articles/[...titleOrId]/(login)/edit/+page.server.ts

  ...
 
+ import { contractTo } from '$lib';
  ...

-   form.data.title = article.title;
-   form.data.body = article.body;
+   form.data = contractTo(article, form.data);
    ...

+    if (form.data.title == article.title && form.data.body == article.body) {
-    if (form.data == contractTo(article, form.data)) {
LANG: console
$ git commit -m "Syntax を編集できるようにした"
       new file:   src/lib/components/Select.svelte
       modified:   src/lib/index.ts
       modified:   src/routes/articles/(login)/new/+page.svelte
       modified:   src/routes/articles/ArticleEditor.svelte
       modified:   src/routes/articles/[...titleOrId]/(login)/edit/+page.server.ts
       modified:   src/routes/articles/[...titleOrId]/(login)/edit/+page.svelte
       modified:   src/routes/articles/zod-schema.ts

pukiwiki から markdown への変換

pukiwiki → markdown → html の変換がうまく行くようになれば、
最終的には pukiwiki → markdown を自動変換してしまえるはず。

pukiwiki の文法は実はかなりややこしい → 整形ルール

しかも掟破りの複数行処理プラグインを入れてたりするのでさらにややこしい。

さしあたり、

src/routes/articles/[...titleOrId]/+page.svelte

LANG: html
  <script lang="ts">
    import type { PageData } from './$types';
    import ArticleElement from '$lib/components/ArticleElement.svelte';
    import { path } from '../lib';
+   import { pukiwikiToMarkdown } from '$lib/pukiwikiToMarkdown';
+   import { beforeUpdate } from 'svelte';
  
    export let data: PageData;
+ 
+   let body = '';
+   beforeUpdate(() => {
+     body = data.article.syntax == 1 ? pukiwikiToMarkdown(data.article.body) : data.article.body;
+   });
  </script>
+ 
+ <svelte:head>
+   <title>{data.article.title}</title>
+ </svelte:head>

  <ArticleElement
    title={data.article.title}
*   body={body}
    author={data.article.author.name}
    date={data.article.createdAt}
    permLink={path(data.article)}
  />

としておいて、pukiwikiToMarkdown の完成度を徐々に高める方針。

テストページを作る

部分的にテストがしやすいように 入力した pukiwiki 文法を markdown 文法に直して見せる テストページを作っておく

src/routes/pukiwikiToMarkdown/+page.svelte

LANG: html
<script lang="ts">
  import TextArea from '$lib/components/TextArea.svelte';
  import { pukiwikiToMarkdown } from '$lib/pukiwikiToMarkdown';
  import type { Snapshot } from '@sveltejs/kit';

  let psrc = '';
  let msrc = '';

  export const snapshot: Snapshot<string> = {
    capture: () => psrc,
    restore: (value) => (psrc = value),
  }

  function convert(e: Event): void {
    msrc = pukiwikiToMarkdown(psrc);
  }
</script>

<div>
  <TextArea name="pukiwiki" label="Pukiwikiソース" bind:value={psrc} on:input={convert} />
  <TextArea name="markdown" label="Markdownソース" value={msrc} props={{ readonly: true }} />
</div>

<style lang="postcss">
  div {
    display: grid;
    grid-template-columns: 1fr 1fr;
    grid-template-rows: auto;
  }
  div :global(textarea) {
    font-family: monospace;
    font-size: smaller;
    min-height: 22lh;
    line-height: 1lh;
  }
</style>

変換処理


Counter: 275 (from 2010/06/03), today: 3, yesterday: 0