pukiwikiの記事を流し込む の履歴(No.5)
更新将来的に移行することを考えて?†
このサイトは 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 というファイルがあって、カンマ区切りの数値が入っているのだけれど・・・ そこまで必要な情報ではなさそう???
これだけの情報があれば取り込みは簡単にできそう。
取り込み用のプログラム†
とにかく列挙して、データベースに突っ込む。
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)}> Prev </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)}> Next </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]/, { message: 'タイトルが空あるいは数値のページは作成できません', }) .regex(/(?<!\/(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 の文法は実はかなりややこしい → 整形ルール
しかも掟破りの複数行処理プラグインを入れてたりするのでさらにややこしい。