英語多読用の laravel

(2025-10-12)

英単語用の laravel を作ってボキャブラリーを 8,000 までアップしてきましたが、ここまで来ると難しい言葉ばかりが残って、なかなか新しい言葉がインプットできません。

ネット上にある語源辞典から記憶法をコピペして mnemonic というカラムにどんどん書き込んでいますが、それでも成長率はとても低くなりました。

昔からそうですが、私は行き詰まったら方法を変えるクセがあって、簡単な英語の多読をしてみようと思いました。

かなり昔ですが多読用の本はいくつか買ってあって、それを ocr を使って word などに落としたドキュメントがいくつかあります。 それを、laravel を使って効率的に閲覧するアプリを作成します。

久しぶりに laravel project を作成するので混乱しました。chatGPT は tailwind 4 に関しては何も知らないようでかなり引きずり回されました。

プロジェクト作成

プロジェクト作成と、tailwind と breeze のインストールを一気におこないます。

composer create-project --prefer-dist laravel/laravel READaLOT
cd READaLOT
npm install -D tailwindcss vite laravel-vite-plugin
composer require laravel/breeze --dev
php artisan breeze:install

breeze のオプションはすべてデフォルトで。

データベース設定

私のコンピュータは mariadb ですが、.env の設定では以下のようにします。

.env
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=readalot
DB_USERNAME=root
DB_PASSWORD=user

migration.

php artisan migrate

これで、readalot というデータベースが作成されます。

新しいテーブルを追加

私は linux mint 22.2 で word などは使わないので、libreoffice のドキュメント .doc や .rtf にドキュメントを作成しているのですが、 それらをデータベース化するために、readalot にいくつかのテーブルを追加します。

Terminal window
php artisan make:migration create_books_table --create=books
php artisan make:migration create_chapters_table --create=chapters

それぞれの migration table を編集します。

database/migrations/create_books_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('books', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('level')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('books');
}
};

create_chapters_table.php

database/migrations/create_chapters_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('chapters', function (Blueprint $table) {
$table->id();
$table->foreignId('book_id')->constrained()->onDelete('cascade'); // 外部キー制約
$table->string('title');
$table->longText('content');
$table->integer('order')->default(0);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('chapters');
}
};

migration で新しいテーブルを作成します。

php artisan migrate

model 作成

model を作成します。

php artisan make:model Book -m
php artisan make:model Chapter -m

編集します。

app/Models/Book.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Book extends Model
{
protected $fillable = ['title'];
public function chapters()
{
return $this->hasMany(Chapter::class)->orderBy('order');
}
}

Chapter.php

app/Models/Chapter.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Chapter extends Model
{
protected $fillable = ['book_id', 'title', 'content', 'order'];
public function book()
{
return $this->belongsTo(Book::class);
}
public function paragraphs()
{
return $this->hasMany(Paragraph::class)->orderBy('order');
}
}

Controller 作成

php artisan make:controller BookController

BookController.php

app/Http/Controllers/BookController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Book;
use App\Models\Chapter;
class BookController extends Controller
{
// 本の一覧表示
public function index()
{
$books = Book::orderBy('level')->get();
return view('books.index', compact('books'));
}
// 各本の詳細表示
public function show($id)
{
// 本とそのすべての章を取得
$book = Book::with('chapters')->findOrFail($id);
return view('books.show', compact('book'));
}
// 各章の内容表示
public function showChapter($id, $chapter)
{
$book = Book::findOrFail($id);
$chapter = Chapter::findOrFail($chapter);
return view('books.chapter', compact('book', 'chapter'));
}
}

blade 作成

手動で以下の 3 つの blade を作成します。

chapter.blade.php

resources/views/books/chapter.blade.php
@extends('layouts.app')
@section('content')
<div class="max-w-3xl mx-auto px-4 py-6 prose prose-lg">
<h2>{{ $chapter->title }}</h2>
{{-- 段落なし、contentを直接表示 --}}
{!! nl2br(e($chapter->content)) !!}
<div class="mt-6">
<a href="{{ url("/books/{$chapter->book_id}") }}" class="text-blue-500">← Back to book</a>
</div>
</div>
@endsection

index.blade.php

resources/views/books/index.blade.php
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Books by Level') }}
</h2>
</x-slot>
<div class="py-12 max-w-4xl mx-auto">
<div class="bg-white p-6 shadow rounded">
@php
// レベルごとにグループ化
$groupedBooks = $books->groupBy('level');
@endphp
@foreach ($groupedBooks as $level => $booksInLevel)
<div class="mb-6">
<h3 class="text-xl font-semibold text-blue-700 mb-3">
Level {{ $level }}
</h3>
<ul class="list-disc pl-6 space-y-1">
@foreach ($booksInLevel as $book)
<li>
<a href="{{ route('books.show', $book->id) }}"
class="text-blue-600 hover:underline">
{{ $book->title }}
</a>
</li>
@endforeach
</ul>
</div>
@endforeach
</div>
</div>
</x-app-layout>

show.blade.php

resources/views/books/show.blade.php
<x-app-layout>
<!-- ページトップ用アンカー -->
<div id="top"></div>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ $book->title }}
</h2>
</x-slot>
<div class="max-w-4xl mx-auto py-8 space-y-8">
@forelse($book->chapters as $chapter)
<div class="bg-white shadow-sm rounded-lg p-6">
<div class="prose prose-lg !font-times text-gray-700">
{!! $chapter->content !!}
</div>
</div>
@empty
<p class="text-gray-500 bg-white shadow-sm rounded-lg p-4">
この本には章が登録されていません。
</p>
@endforelse
</div>
<!-- サイド固定トップボタン -->
<a href="#top"
class="fixed bottom-8 right-8 bg-blue-600 text-white px-4 py-3 rounded-full shadow-lg hover:bg-blue-700 transition text-lg z-50">
↑ Top
</a>
</x-app-layout>

app.blade.php

resources/views/layouts/app.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Laravel') }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="font-sans antialiased">
<div class="min-h-screen bg-gray-100">
@include('layouts.navigation')
<!-- Page Heading -->
@isset($header)
<header class="bg-white shadow">
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{{ $header }}
</div>
</header>
@endisset
<!-- Page Content -->
<main>
{{ $slot }}
</main>
</div>
</body>
</html>

.doc, .rtf のデータをデータベースに記録

あるフォルダに .doc などを集めておきます。

.
├── level 1
│   ├── Adventures of Tom Sawyer.doc
│   ├── Alladin.doc
│   ├── Elephant man.doc
│   ├── Mutiny on Bounty.doc
│   ├── Phone rings.doc
│   └── White death.doc
├── level 2
│   ├── Dante's peak.doc
│   ├── Death in the freezer.doc
│   ├── Fly away home.doc

このドキュメントの内容をデータベースに記録します。

import subprocess
from pathlib import Path
from bs4 import BeautifulSoup
import re
import mariadb
# -------------------
# 設定
# -------------------
SOURCE_DIR = Path("/home/moheno/aaa/source") # .doc/.docx/.rtf
TXT_DIR = Path("/home/moheno/aaa/text_clean") # デバッグ用テキスト
HTML_DIR = Path("/home/moheno/aaa/html_clean") # HTML
TXT_DIR.mkdir(parents=True, exist_ok=True)
HTML_DIR.mkdir(parents=True, exist_ok=True)
DB_CONFIG = {
'host': '127.0.0.1',
'user': 'root',
'password': 'user',
'database': 'readalot',
'port': 3306
}
# -------------------
# DB接続
# -------------------
conn = mariadb.connect(**DB_CONFIG)
cursor = conn.cursor()
def insert_book(title, level=1):
cursor.execute(
"INSERT INTO books (title, level) VALUES (?, ?)",
(title, level)
)
conn.commit()
return cursor.lastrowid
def insert_chapter(book_id, title, content, order=1):
cursor.execute(
"INSERT INTO chapters (book_id, title, content, `order`) VALUES (?, ?, ?, ?)",
(book_id, title, content, order)
)
conn.commit()
# -------------------
# フォルダ階層から level を取得
# -------------------
def get_level_from_path(doc_path: Path, source_dir: Path):
try:
rel = doc_path.relative_to(source_dir)
parent_name = rel.parts[0] # 一番上のフォルダ
match = re.match(r'level\s*(\d+)', parent_name, re.IGNORECASE)
if match:
return int(match.group(1))
except Exception:
pass
return 1 # デフォルト
# -------------------
# DOC → TXT
# -------------------
def convert_doc_to_txt(doc_path: Path, out_dir: Path) -> Path:
subprocess.run([
"libreoffice", "--headless", "--convert-to", "txt:Text",
"--outdir", str(out_dir), str(doc_path)
], check=True)
txt_path = out_dir / f"{doc_path.stem}.txt"
if not txt_path.exists():
raise FileNotFoundError(f"TXT not created for {doc_path}")
return txt_path
# -------------------
# 太字判定
# -------------------
def is_bold_line(prev_line, line, next_line):
text = line.strip()
if not text:
return False
if text[-1] in ".!?”\"":
return False
prev_empty = (prev_line is None) or (not prev_line.strip()) or ('\f' in prev_line)
next_empty = (next_line is None) or (not next_line.strip()) or ('\f' in next_line)
is_chapter = re.match(r'^Chapter\s+\d+', text, re.IGNORECASE)
is_intro = text.upper() == "INTRODUCTION"
is_glossary = text.upper() == "GLOSSARY"
return (prev_empty and next_empty) or is_chapter or is_intro or is_glossary
# -------------------
# TXT → HTML
# -------------------
def txt_to_html(txt_path: Path, html_path: Path):
with open(txt_path, "r", encoding="utf-8", errors="ignore") as f:
lines = [line.rstrip("\n") for line in f.readlines()]
soup = BeautifulSoup("<html><head></head><body></body></html>", "html.parser")
for i, line in enumerate(lines):
prev_line = lines[i-1] if i > 0 else None
next_line = lines[i+1] if i < len(lines)-1 else None
text = line.strip()
if not text:
continue
if is_bold_line(prev_line, line, next_line):
tag = soup.new_tag("b")
tag.string = text
else:
tag = soup.new_tag("p")
tag.string = text
soup.body.append(tag)
style_tag = soup.new_tag("style")
style_tag.string = """
body { font-family: 'Century', serif; line-height: 1.5; margin: 0; padding: 0; }
b { display: block; margin-top: 2em; margin-bottom: 1em; font-weight: bold; }
p { margin-top: 0.5em; margin-bottom: 0.5em; }
"""
soup.head.append(style_tag)
html_path.write_text(str(soup), encoding="utf-8")
return str(soup)
# -------------------
# メイン処理
# -------------------
for doc_path in SOURCE_DIR.rglob("*"):
if doc_path.suffix.lower() in [".doc", ".docx", ".rtf"]:
try:
print(f"[PROCESS] {doc_path}")
# level判定
level = get_level_from_path(doc_path, SOURCE_DIR)
# TXT変換(デバッグ用)
txt_path = convert_doc_to_txt(doc_path, TXT_DIR)
# HTML変換
html_path = HTML_DIR / f"{doc_path.stem}.html"
html_str = txt_to_html(txt_path, html_path)
# DB書き込み
book_id = insert_book(doc_path.stem, level)
insert_chapter(book_id, None, html_str)
print(f"[DONE] {doc_path.stem}, level {level}")
except Exception as e:
print(f"[ERROR] {doc_path}: {e}")
cursor.close()
conn.close()
print("All done!")

これを実行すると、ちょっとだけ時間はかかりますが本の内容がデータベース化されます。

routing

<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\BookController;
// === 認証不要ページ ===
Route::get('/', function () {
return view('welcome');
});
// === 認証+メール認証が必要なページ ===
Route::middleware(['auth', 'verified'])->group(function () {
// ダッシュボード
Route::get('/dashboard', function () {
return view('dashboard');
})->name('dashboard');
// プロフィール編集
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
// 書籍一覧・詳細
Route::get('/books', [BookController::class, 'index'])->name('books.index');
Route::get('/books/{id}', [BookController::class, 'show'])->name('books.show');
});
// === Breeze 認証ルート ===
require __DIR__ . '/auth.php';

実行する

端末より、

#!/bin/bash
cd ~/READaLOT
# npmでTailwindCSSの開発ビルドを開始
npm run dev &
# Laravelのサーバーを起動
php artisan serve

として、「http://127.0.0.1:8000/」にアクセスすると、

ガリバーを選択。

適当にクリックすると、

そうそう、大体こんなイメージでした。

でも、本によってはきちんとコンテンツがデータベースに記録されていないものもあります。この点に関してはまだ詰める必要があります。

久しぶりに laravel project を作ってみましたが、chatGPT のおかげで割と短時間に作成できました。

でも、tailwind 4.x に関しては chatGPT はよくわからないらしく、エラーになるコードを延々と提示してきました。chatGPT とかなりの時間を共にして付き合い方は少しはわかっていたので、 ある程度は自分で調べて chatGPT を自分の思い通りの方向に向ければとても有用だと思います。

ローカルで動けば、ドメインを取得してデプロイするのは簡単です。