英単語用の laravel を作ってボキャブラリーを 8,000 までアップしてきましたが、ここまで来ると難しい言葉ばかりが残って、なかなか新しい言葉がインプットできません。
ネット上にある語源辞典から記憶法をコピペして mnemonic というカラムにどんどん書き込んでいますが、それでも成長率はとても低くなりました。
昔からそうですが、私は行き詰まったら方法を変えるクセがあって、簡単な英語の多読をしてみようと思いました。
かなり昔ですが多読用の本はいくつか買ってあって、それを ocr を使って word などに落としたドキュメントがいくつかあります。 それを、laravel を使って効率的に閲覧するアプリを作成します。
久しぶりに laravel project を作成するので混乱しました。chatGPT は tailwind 4 に関しては何も知らないようでかなり引きずり回されました。
プロジェクト作成と、tailwind と breeze のインストールを一気におこないます。
composer create-project --prefer-dist laravel/laravel READaLOTcd READaLOT
npm install -D tailwindcss vite laravel-vite-plugin
composer require laravel/breeze --devphp artisan breeze:installbreeze のオプションはすべてデフォルトで。
私のコンピュータは mariadb ですが、.env の設定では以下のようにします。
DB_CONNECTION=mysqlDB_HOST=127.0.0.1DB_PORT=3306DB_DATABASE=readalotDB_USERNAME=rootDB_PASSWORD=usermigration.
php artisan migrateこれで、readalot というデータベースが作成されます。
私は linux mint 22.2 で word などは使わないので、libreoffice のドキュメント .doc や .rtf にドキュメントを作成しているのですが、 それらをデータベース化するために、readalot にいくつかのテーブルを追加します。
php artisan make:migration create_books_table --create=booksphp artisan make:migration create_chapters_table --create=chaptersそれぞれの migration table を編集します。
<?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
<?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 migratemodel を作成します。
php artisan make:model Book -mphp artisan make:model Chapter -m編集します。
<?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
<?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'); }}php artisan make:controller BookControllerBookController.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')); }}手動で以下の 3 つの blade を作成します。
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>@endsectionindex.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
<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
<!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 などを集めておきます。
.├── 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 subprocessfrom pathlib import Pathfrom bs4 import BeautifulSoupimport reimport mariadb
# -------------------# 設定# -------------------SOURCE_DIR = Path("/home/moheno/aaa/source") # .doc/.docx/.rtfTXT_DIR = Path("/home/moheno/aaa/text_clean") # デバッグ用テキストHTML_DIR = Path("/home/moheno/aaa/html_clean") # HTMLTXT_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!")これを実行すると、ちょっとだけ時間はかかりますが本の内容がデータベース化されます。
<?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 を自分の思い通りの方向に向ければとても有用だと思います。
ローカルで動けば、ドメインを取得してデプロイするのは簡単です。