diff --git a/.circleci/config.yml b/.circleci/config.yml index 8446fc92625..3b9064369b0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -30,9 +30,9 @@ jobs: command: | bundle lock --add-platform ruby - ruby/install-deps - - ruby/rubocop-check: - format: progress - label: Inspecting with Rubocop + - run: + name: Inspecting with Rubocop + command: bundle exec rubocop --format progress - run: name: Slim Lint command: bundle exec slim-lint app/views -c config/slim_lint.yml @@ -111,7 +111,15 @@ jobs: psql -h localhost -U postgres -d ci_test -c "CREATE EXTENSION IF NOT EXISTS vector;" - run: name: Database setup - command: 'bundle exec rails db:prepare' + command: | + # pg_bigm未インストール環境でschema.rbのenable_extension/gin_bigm_opsが失敗するため除外 + echo "=== Before sed ===" + grep -n "pg_bigm\|gin_bigm_ops" db/schema.rb || echo "No pg_bigm lines found" + sed -i '/pg_bigm/d' db/schema.rb + sed -i '/gin_bigm_ops/d' db/schema.rb + echo "=== After sed ===" + grep -n "pg_bigm\|gin_bigm_ops" db/schema.rb || echo "No pg_bigm lines found" + bundle exec rails db:migrate - run: name: Assets precompile command: 'bundle exec rails assets:clean assets:precompile' diff --git a/.cloudbuild/cloudbuild-review.yaml b/.cloudbuild/cloudbuild-review.yaml index 70385bb0fdf..7878996f1df 100644 --- a/.cloudbuild/cloudbuild-review.yaml +++ b/.cloudbuild/cloudbuild-review.yaml @@ -194,6 +194,7 @@ steps: - '--set-env-vars=RAILS_LOG_TO_STDOUT=true' - '--set-env-vars=RAILS_ENV=production' - '--set-env-vars=RACK_ENV=production' + - '--set-env-vars=REVIEW_APP=1' - '--set-env-vars=APP_HOST_NAME=$_APP_HOST_NAME' - '--set-env-vars=CLOUD_RUN_HOST_NAME=$_CLOUD_RUN_HOST_NAME' - '--set-env-vars=RAILS_MASTER_KEY=$_RAILS_MASTER_KEY' diff --git a/.rubocop.yml b/.rubocop.yml index 46ad6a92763..a5fc89dc758 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -25,6 +25,7 @@ AllCops: - db/data_schema.rb - db/migrate/* - db/schema.rb + - db/seeds/**/* - storage/**/* - tmp/**/* - bin/**/* diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index cca9379c689..2691e5fe466 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -57,6 +57,7 @@ @import "./application/blocks/page-content/_page-content-header.css"; @import "./application/blocks/page-content/_page-content-members.css"; @import "./application/blocks/page-content/_page-content-prev-next.css"; +@import "./application/blocks/textbook/_textbook-list.css"; @import "./application/blocks/pair-work/_pair-work-info.css"; @import "./application/blocks/pair-work/_pair-work-schedule-dates.css"; @import "./application/blocks/practice/_categories.css"; diff --git a/app/assets/stylesheets/application/blocks/textbook/_textbook-list.css b/app/assets/stylesheets/application/blocks/textbook/_textbook-list.css new file mode 100644 index 00000000000..957588ce41f --- /dev/null +++ b/app/assets/stylesheets/application/blocks/textbook/_textbook-list.css @@ -0,0 +1,40 @@ +/* 教科書共通 */ + +.textbook-summary { + margin-top: 0.75rem; +} + +.textbook-summary__stats { + font-size: 0.8125rem; + color: var(--muted-text); + margin-bottom: 0.375rem; +} + +.textbook-summary__action { + margin-top: 0.75rem; +} + +.a-completed-check { + color: var(--success); + margin-right: 0.375rem; +} + +.a-pending-check { + color: var(--disabled); + margin-right: 0.375rem; + font-size: 0.875em; +} + +.textbook-breadcrumb { + font-size: 0.8125rem; + color: var(--muted-text); + margin-bottom: 1rem; +} + +.textbook-breadcrumb a { + color: var(--link-text); +} + +.textbook-breadcrumb a:hover { + text-decoration: underline; +} diff --git a/app/assets/stylesheets/common-imports.css b/app/assets/stylesheets/common-imports.css index cb7bca44dc8..d0927afdf50 100644 --- a/app/assets/stylesheets/common-imports.css +++ b/app/assets/stylesheets/common-imports.css @@ -140,3 +140,4 @@ @import "./shared/blocks/form/_form-textarea.css"; @import "./shared/blocks/form/_form-table.css"; @import "./shared/blocks/_o-empty-message.css"; +@import "./shared/blocks/_piyo-companion.css"; diff --git a/app/assets/stylesheets/shared/blocks/_piyo-companion.css b/app/assets/stylesheets/shared/blocks/_piyo-companion.css new file mode 100644 index 00000000000..026480c4d55 --- /dev/null +++ b/app/assets/stylesheets/shared/blocks/_piyo-companion.css @@ -0,0 +1,78 @@ +.piyo-companion { + position: fixed; + bottom: 1rem; + right: 1rem; + z-index: 1000; +} + +.piyo-companion__button { + background: none; + border: none; + cursor: pointer; + padding: 0; + position: relative; + transition: transform 0.15s; +} + +.piyo-companion__button:hover { + transform: scale(1.1); +} + +.piyo-companion__image { + width: 56px; + height: 56px; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15)); +} + +.piyo-companion__bubble { + position: absolute; + bottom: 64px; + right: 0; + background: var(--base, #fff); + border: 1px solid var(--border, #e0e0e0); + border-radius: 12px; + padding: 0.625rem 0.875rem; + max-width: 220px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); + animation: piyo-bubble-in 0.2s ease-out; +} + +.piyo-companion__bubble::after { + content: ''; + position: absolute; + bottom: -7px; + right: 20px; + width: 0; + height: 0; + border-left: 7px solid transparent; + border-right: 7px solid transparent; + border-top: 7px solid var(--base, #fff); +} + +.piyo-companion__message { + margin: 0; + font-size: 0.8125rem; + line-height: 1.5; +} + +.piyo-companion__badge { + position: absolute; + top: -3px; + right: -3px; + width: 10px; + height: 10px; + background: var(--danger, #ef4444); + border-radius: 50%; + border: 2px solid var(--base, #fff); +} + +@keyframes piyo-bubble-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/app/controllers/api/textbooks/reading_progresses_controller.rb b/app/controllers/api/textbooks/reading_progresses_controller.rb new file mode 100644 index 00000000000..6cf978fb0fd --- /dev/null +++ b/app/controllers/api/textbooks/reading_progresses_controller.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class API::Textbooks::ReadingProgressesController < API::BaseController + include TextbookFeatureGuard + before_action :require_textbook_enabled + + def create + @reading_progress = current_user.reading_progresses.find_or_initialize_by( + textbook_section_id: reading_progress_params[:textbook_section_id] + ) + @reading_progress.assign_attributes(reading_progress_params) + if @reading_progress.save + head(@reading_progress.previously_new_record? ? :created : :ok) + else + head :unprocessable_entity + end + rescue ActiveRecord::RecordNotUnique + retry + end + + def update + @reading_progress = current_user.reading_progresses.find(params[:id]) + if @reading_progress.update(reading_progress_params) + head :ok + else + head :unprocessable_entity + end + end + + private + + def reading_progress_params + params.require(:reading_progress).permit(:textbook_section_id, :read_ratio, :completed, :last_block_index, :last_read_at) + end +end diff --git a/app/controllers/api/textbooks/term_explanations_controller.rb b/app/controllers/api/textbooks/term_explanations_controller.rb new file mode 100644 index 00000000000..08453a8986e --- /dev/null +++ b/app/controllers/api/textbooks/term_explanations_controller.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class API::Textbooks::TermExplanationsController < API::BaseController + include TextbookFeatureGuard + before_action :require_textbook_enabled + + def show + @term_explanation = TermExplanation.find(params[:id]) + end +end diff --git a/app/controllers/concerns/textbook_feature_guard.rb b/app/controllers/concerns/textbook_feature_guard.rb new file mode 100644 index 00000000000..331a757e25c --- /dev/null +++ b/app/controllers/concerns/textbook_feature_guard.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# textbook機能のfeature flag制御 +module TextbookFeatureGuard + extend ActiveSupport::Concern + + private + + def require_textbook_enabled + return if Switchlet.enabled?(:textbook) || Rails.env.local? || staging_or_review? + + head :not_found + end + + def staging_or_review? + ENV['REVIEW_APP'].present? || ENV['STAGING'].present? + end +end diff --git a/app/controllers/mentor/textbooks/chapters_controller.rb b/app/controllers/mentor/textbooks/chapters_controller.rb new file mode 100644 index 00000000000..8a8ba474042 --- /dev/null +++ b/app/controllers/mentor/textbooks/chapters_controller.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class Mentor::Textbooks::ChaptersController < ApplicationController + include TextbookFeatureGuard + before_action :require_admin_or_mentor_login + before_action :require_textbook_enabled + before_action :set_textbook + before_action :set_chapter, only: %i[edit update destroy] + + def new + @chapter = @textbook.chapters.build + end + + def create + @chapter = @textbook.chapters.build(chapter_params) + if @chapter.save + redirect_to mentor_textbook_path(@textbook), notice: '章を作成しました。' + else + render :new, status: :unprocessable_entity + end + end + + def edit; end + + def update + if @chapter.update(chapter_params) + redirect_to mentor_textbook_path(@textbook), notice: '章を更新しました。' + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @chapter.destroy! + redirect_to mentor_textbook_path(@textbook), notice: '章を削除しました。' + end + + private + + def set_textbook + @textbook = Textbook.find(params[:textbook_id]) + end + + def set_chapter + @chapter = @textbook.chapters.find(params[:id]) + end + + def chapter_params + params.require(:textbook_chapter).permit(:title, :position) + end +end diff --git a/app/controllers/mentor/textbooks/sections_controller.rb b/app/controllers/mentor/textbooks/sections_controller.rb new file mode 100644 index 00000000000..54ca4695646 --- /dev/null +++ b/app/controllers/mentor/textbooks/sections_controller.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class Mentor::Textbooks::SectionsController < ApplicationController + include TextbookFeatureGuard + before_action :require_admin_or_mentor_login + before_action :require_textbook_enabled + before_action :set_textbook + before_action :set_chapter + before_action :set_section, only: %i[edit update destroy] + + def new + @section = @chapter.sections.build + end + + def create + @section = @chapter.sections.build(section_params) + if @section.save + redirect_to mentor_textbook_path(@textbook), notice: '節を作成しました。' + else + render :new, status: :unprocessable_entity + end + end + + def edit; end + + def update + if @section.update(section_params) + redirect_to mentor_textbook_path(@textbook), notice: '節を更新しました。' + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @section.destroy! + redirect_to mentor_textbook_path(@textbook), notice: '節を削除しました。' + end + + private + + def set_textbook + @textbook = Textbook.find(params[:textbook_id]) + end + + def set_chapter + @chapter = @textbook.chapters.find(params[:chapter_id]) + end + + def set_section + @section = @chapter.sections.find(params[:id]) + end + + def section_params + params.require(:textbook_section).permit(:title, :body, :estimated_minutes, :position, goals: [], key_terms: []) + end +end diff --git a/app/controllers/mentor/textbooks_controller.rb b/app/controllers/mentor/textbooks_controller.rb new file mode 100644 index 00000000000..4d7d3d789c0 --- /dev/null +++ b/app/controllers/mentor/textbooks_controller.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class Mentor::TextbooksController < ApplicationController + include TextbookFeatureGuard + before_action :require_admin_or_mentor_login + before_action :require_textbook_enabled + before_action :set_textbook, only: %i[edit update destroy] + + def index + @textbooks = Textbook.order(created_at: :desc) + end + + def new + @textbook = Textbook.new + end + + def create + @textbook = Textbook.new(textbook_params) + if @textbook.save + redirect_to mentor_textbooks_path, notice: '教科書を作成しました。' + else + render :new, status: :unprocessable_entity + end + end + + def edit; end + + def update + if @textbook.update(textbook_params) + redirect_to mentor_textbooks_path, notice: '教科書を更新しました。' + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @textbook.destroy! + redirect_to mentor_textbooks_path, notice: '教科書を削除しました。' + end + + private + + def set_textbook + @textbook = Textbook.includes(chapters: :sections).find(params[:id]) + end + + def textbook_params + params.require(:textbook).permit(:title, :description, :published, :practice_id) + end +end diff --git a/app/controllers/textbooks/chapters_controller.rb b/app/controllers/textbooks/chapters_controller.rb new file mode 100644 index 00000000000..b704dc9caea --- /dev/null +++ b/app/controllers/textbooks/chapters_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Textbooks::ChaptersController < ApplicationController + include TextbookFeatureGuard + before_action :require_textbook_enabled + + def show + @textbook = Textbook.published.find(params[:textbook_id]) + @chapter = @textbook.chapters.find(params[:id]) + first_section = @chapter.sections.first + redirect_to textbook_chapter_section_path(@textbook, @chapter, first_section) if first_section + end +end diff --git a/app/controllers/textbooks/sections_controller.rb b/app/controllers/textbooks/sections_controller.rb new file mode 100644 index 00000000000..5096f43aac8 --- /dev/null +++ b/app/controllers/textbooks/sections_controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class Textbooks::SectionsController < ApplicationController + include TextbookFeatureGuard + before_action :require_textbook_enabled + + def show + @textbook = Textbook.published.find(params[:textbook_id]) + @chapter = @textbook.chapters.find(params[:chapter_id]) + @section = @chapter.sections.find(params[:id]) + end +end diff --git a/app/controllers/textbooks_controller.rb b/app/controllers/textbooks_controller.rb new file mode 100644 index 00000000000..060bf6f28bc --- /dev/null +++ b/app/controllers/textbooks_controller.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class TextbooksController < ApplicationController + include TextbookFeatureGuard + before_action :require_textbook_enabled + + def index + @textbooks = Textbook.published.order(created_at: :asc) + end + + def show + @textbook = Textbook.published.find(params[:id]) + end +end diff --git a/app/helpers/textbook_helper.rb b/app/helpers/textbook_helper.rb new file mode 100644 index 00000000000..2b5b082730a --- /dev/null +++ b/app/helpers/textbook_helper.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module TextbookHelper + include MarkdownHelper + + def textbook_section_body(section) + html = md2html(section.body) + html = add_block_indexes(html) + html = wrap_key_terms(html, section) + raw(html) # rubocop:disable Rails/OutputSafety + end + + def add_block_indexes(html) + doc = Nokogiri::HTML::DocumentFragment.parse(html) + block_tags = %w[p pre h1 h2 h3 h4 h5 h6 ul ol blockquote table] + index = 0 + + doc.children.each do |node| + next unless node.element? && block_tags.include?(node.name) + + node['data-block-index'] = index.to_s + index += 1 + end + + doc.to_html + end + + def wrap_key_terms(html, section) + terms = section.key_terms + return html if terms.blank? + + doc = Nokogiri::HTML::DocumentFragment.parse(html) + apply_term_highlighting(doc, terms, section.id) + doc.to_html + end + + private + + def apply_term_highlighting(doc, terms, section_id) + terms.sort_by(&:length).reverse_each do |term| + next if term.blank? + + pattern = Regexp.new(Regexp.escape(term), Regexp::IGNORECASE) + highlight_term_in_doc(doc, pattern, section_id) + end + end + + def highlight_term_in_doc(doc, pattern, section_id) + doc.css('p, li, td, th, dd, dt').each do |node| + process_text_nodes(node, pattern, section_id) + end + end + + def process_text_nodes(node, pattern, section_id) + node.xpath('.//text()').each do |child| + next unless child.text? + + replaced = replace_with_tooltip(child.text, pattern, section_id) + next if replaced == child.text + + child.replace(Nokogiri::HTML::DocumentFragment.parse(replaced)) + end + end + + def replace_with_tooltip(text, pattern, section_id) + text.gsub(pattern) do |match| + build_tooltip_span(match, section_id) + end + end + + def build_tooltip_span(match, section_id) + 'term-tooltip#toggle\">#{ERB::Util.html_escape(match)}" + end +end diff --git a/app/javascript/controllers/piyo_companion_controller.js b/app/javascript/controllers/piyo_companion_controller.js new file mode 100644 index 00000000000..e877a28d28c --- /dev/null +++ b/app/javascript/controllers/piyo_companion_controller.js @@ -0,0 +1,88 @@ +import { Controller } from 'stimulus' + +const CONGRATULATION_MESSAGES = [ + 'このセクション完了!よくがんばったね!🎉', + 'すごい!また一歩前進だね!✨', + 'おつかれさま!着実に進んでるよ!💪', + 'やったね!次のセクションも一緒にがんばろう!🌟', + 'クリアおめでとう!その調子!🎊' +] + +const WELCOME_BACK_MESSAGES = [ + 'おかえり!また一緒に学ぼう!📚', + 'ひさしぶり!待ってたよ!😊', + '戻ってきてくれてうれしいピヨ!🐣' +] + +const ABSENCE_THRESHOLD_MS = 3 * 24 * 60 * 60 * 1000 // 3 days +const AUTO_DISMISS_MS = 5000 + +export default class extends Controller { + static targets = ['bubble', 'message'] + + connect() { + this.dismissTimer = null + this.boundHandleCompletion = this.handleCompletion.bind(this) + this.checkWelcomeBack() + + document.addEventListener( + 'reading-progress:completed', + this.boundHandleCompletion + ) + } + + disconnect() { + if (this.dismissTimer) { + clearTimeout(this.dismissTimer) + } + document.removeEventListener( + 'reading-progress:completed', + this.boundHandleCompletion + ) + } + + checkWelcomeBack() { + const lastVisit = localStorage.getItem('textbook_last_visit') + const now = Date.now() + localStorage.setItem('textbook_last_visit', now.toString()) + + if (lastVisit) { + const elapsed = now - parseInt(lastVisit, 10) + if (elapsed > ABSENCE_THRESHOLD_MS) { + setTimeout(() => { + this.showMessage(this.randomFrom(WELCOME_BACK_MESSAGES)) + }, 1000) + } + } + } + + handleCompletion() { + this.showMessage(this.randomFrom(CONGRATULATION_MESSAGES)) + } + + showMessage(text) { + if (!this.hasBubbleTarget || !this.hasMessageTarget) return + + this.messageTarget.textContent = text + this.bubbleTarget.classList.remove('is-hidden') + + if (this.dismissTimer) { + clearTimeout(this.dismissTimer) + } + this.dismissTimer = setTimeout(() => this.dismiss(), AUTO_DISMISS_MS) + } + + dismiss() { + if (this.hasBubbleTarget) { + this.bubbleTarget.classList.add('is-hidden') + } + if (this.dismissTimer) { + clearTimeout(this.dismissTimer) + this.dismissTimer = null + } + } + + randomFrom(array) { + return array[Math.floor(Math.random() * array.length)] + } +} diff --git a/app/javascript/controllers/reading_progress_controller.js b/app/javascript/controllers/reading_progress_controller.js new file mode 100644 index 00000000000..0032abd8906 --- /dev/null +++ b/app/javascript/controllers/reading_progress_controller.js @@ -0,0 +1,87 @@ +import { Controller } from 'stimulus' +import { post } from '@rails/request.js' + +export default class extends Controller { + static values = { + sectionId: Number, + totalBlocks: Number + } + + connect() { + this.maxBlockSeen = -1 + this.completed = false + this.debounceTimer = null + + this.blocks = this.element.querySelectorAll('[data-block-index]') + this.totalBlocksValue = this.blocks.length + + if (this.blocks.length === 0) return + + this.observer = new IntersectionObserver( + (entries) => this.handleIntersection(entries), + { threshold: 0.5 } + ) + + this.blocks.forEach((block) => this.observer.observe(block)) + } + + disconnect() { + if (this.observer) { + this.observer.disconnect() + } + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + } + } + + handleIntersection(entries) { + entries.forEach((entry) => { + const index = parseInt(entry.target.dataset.blockIndex, 10) + if (entry.isIntersecting) { + if (index > this.maxBlockSeen) { + this.maxBlockSeen = index + } + } + }) + + this.debouncedUpdate() + } + + debouncedUpdate() { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer) + } + this.debounceTimer = setTimeout(() => this.updateProgress(), 2000) + } + + async updateProgress() { + if (this.totalBlocksValue === 0) return + if (this.maxBlockSeen < 0) return + + const readRatio = (this.maxBlockSeen + 1) / this.totalBlocksValue + const isCompleted = + this.maxBlockSeen >= this.totalBlocksValue - 1 && !this.completed + + try { + const response = await post('/api/textbooks/reading_progresses', { + body: { + reading_progress: { + textbook_section_id: this.sectionIdValue, + last_block_index: this.maxBlockSeen, + read_ratio: Math.min(readRatio, 1.0), + completed: isCompleted || this.completed + } + } + }) + + if (isCompleted && response.ok) { + this.completed = true + this.dispatch('completed', { + detail: { sectionId: this.sectionIdValue } + }) + } + } catch (error) { + console.error('進捗の保存に失敗しました:', error) + } + } +} diff --git a/app/javascript/controllers/term_tooltip_controller.js b/app/javascript/controllers/term_tooltip_controller.js new file mode 100644 index 00000000000..6c0898b0c7e --- /dev/null +++ b/app/javascript/controllers/term_tooltip_controller.js @@ -0,0 +1,118 @@ +import { Controller } from 'stimulus' +import { get } from '@rails/request.js' + +export default class extends Controller { + static values = { + term: String, + sectionId: Number + } + + connect() { + this.cache = {} + this.tooltip = null + this.requestId = 0 + } + + disconnect() { + this.removeTooltip() + } + + toggle(event) { + event.preventDefault() + + if (this.tooltip) { + this.removeTooltip() + return + } + + const term = this.termValue + const cacheKey = `${this.sectionIdValue}:${term}` + + if (this.cache[cacheKey]) { + this.showTooltip(this.cache[cacheKey], event.currentTarget) + return + } + + this.showLoading(event.currentTarget) + this.fetchExplanation(term, cacheKey, event.currentTarget) + } + + async fetchExplanation(term, cacheKey, anchor) { + // リクエストIDを更新して競合状態を防ぐ + const currentRequestId = ++this.requestId + + try { + const response = await get( + `/api/textbooks/term_explanations/${encodeURIComponent( + this.sectionIdValue + )}?term=${encodeURIComponent(term)}`, + { responseKind: 'json' } + ) + + // 最新のリクエストでない場合は無視 + if (currentRequestId !== this.requestId) { + return + } + + if (!response.ok) { + this.showTooltip('説明が見つかりませんでした。', anchor) + return + } + + const data = await response.json + const explanation = data.explanation || '説明がありません。' + this.cache[cacheKey] = explanation + this.removeTooltip() + this.showTooltip(explanation, anchor) + } catch { + // 最新のリクエストでない場合は無視 + if (currentRequestId !== this.requestId) { + return + } + this.removeTooltip() + this.showTooltip('読み込みに失敗しました。', anchor) + } + } + + showLoading(anchor) { + this.showTooltip('読み込み中...', anchor) + } + + showTooltip(text, anchor) { + this.removeTooltip() + + this.tooltip = document.createElement('div') + this.tooltip.className = 'term-tooltip' + this.tooltip.textContent = text + + const rect = anchor.getBoundingClientRect() + this.tooltip.style.position = 'absolute' + this.tooltip.style.left = `${rect.left + window.scrollX}px` + this.tooltip.style.top = `${rect.bottom + window.scrollY + 4}px` + this.tooltip.style.zIndex = '1000' + + document.body.appendChild(this.tooltip) + + document.addEventListener('click', this.handleOutsideClick) + } + + handleOutsideClick = (event) => { + if ( + this.tooltip && + !this.tooltip.contains(event.target) && + !this.element.contains(event.target) + ) { + this.removeTooltip() + } + } + + removeTooltip() { + // リクエストをキャンセル(リクエストIDをリセット) + this.requestId = 0 + document.removeEventListener('click', this.handleOutsideClick) + if (this.tooltip) { + this.tooltip.remove() + this.tooltip = null + } + } +} diff --git a/app/jobs/pjord_respond_job.rb b/app/jobs/pjord_respond_job.rb index b674305590a..21e41fa6a37 100644 --- a/app/jobs/pjord_respond_job.rb +++ b/app/jobs/pjord_respond_job.rb @@ -2,19 +2,14 @@ class PjordRespondJob < ApplicationJob queue_as :default - retry_on StandardError, wait: :polynomially_longer, attempts: 3 - discard_on ActiveJob::DeserializationError def perform(mentionable_type:, mentionable_id:) - mentionable_class = mentionable_type.safe_constantize - return if mentionable_class.nil? - - mentionable = mentionable_class.find_by(id: mentionable_id) + mentionable = mentionable_type.constantize.find_by(id: mentionable_id) return if mentionable.nil? pjord = Pjord.user return if pjord.nil? - return unless mentions_pjord?(mentionable) + return unless mentionable.body&.include?("@#{pjord.login_name}") context = build_context(mentionable) response = Pjord.respond(message: mentionable.body, context: context) @@ -30,35 +25,24 @@ def reply(mentionable, pjord, text) case mentionable when Comment - reply_as_comment(mentionable.commentable, pjord, body) + reply_as_comment(pjord, mentionable.commentable, body) when Answer - reply_as_answer(mentionable.question, pjord, body) + Answer.create!(user: pjord, question: mentionable.question, description: body) when Question - reply_as_answer(mentionable, pjord, body) - when Report, Product, PairWork, MicroReport - reply_as_comment(mentionable, pjord, body) - else - Rails.logger.warn("[Pjord] Unsupported mentionable type: #{mentionable.class.name}") + Answer.create!(user: pjord, question: mentionable, description: body) + when Report, Product + reply_as_comment(pjord, mentionable, body) end end - def reply_as_comment(commentable, pjord, body) + def reply_as_comment(pjord, commentable, body) Comment.create!(user: pjord, commentable: commentable, description: body) end - def reply_as_answer(question, pjord, body) - Answer.create!(user: pjord, question: question, description: body) - end - - def mentions_pjord?(mentionable) - mentionable.body&.match?(/(? { where(published: true) } +end diff --git a/app/models/textbook/chapter.rb b/app/models/textbook/chapter.rb new file mode 100644 index 00000000000..4aeb512a321 --- /dev/null +++ b/app/models/textbook/chapter.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class Textbook::Chapter < ApplicationRecord + self.table_name = 'textbook_chapters' + + belongs_to :textbook + has_many :sections, class_name: 'Textbook::Section', foreign_key: 'textbook_chapter_id', dependent: :destroy, inverse_of: :chapter + + validates :title, presence: true + validates :position, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, + uniqueness: { scope: :textbook_id } + + default_scope { order(:position) } +end diff --git a/app/models/textbook/section.rb b/app/models/textbook/section.rb new file mode 100644 index 00000000000..d1dddd6110c --- /dev/null +++ b/app/models/textbook/section.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Textbook::Section < ApplicationRecord + self.table_name = 'textbook_sections' + + belongs_to :chapter, class_name: 'Textbook::Chapter', foreign_key: 'textbook_chapter_id', inverse_of: :sections + has_many :reading_progresses, foreign_key: 'textbook_section_id', dependent: :destroy, inverse_of: :section + has_many :term_explanations, foreign_key: 'textbook_section_id', dependent: :destroy, inverse_of: :section + + validates :title, presence: true + validates :body, presence: true + validates :position, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + + # Sections must always be ordered by position for consistent display in chapters. + default_scope { order(:position) } +end diff --git a/app/models/user.rb b/app/models/user.rb index 9e328862e35..9683f3274e3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -93,6 +93,7 @@ class User < ApplicationRecord # rubocop:todo Metrics/ClassLength belongs_to :company, optional: true belongs_to :course has_many :learnings, dependent: :destroy + has_many :reading_progresses, dependent: :destroy has_many :pages, dependent: :destroy has_many :comments, dependent: :destroy has_many :reports, dependent: :destroy diff --git a/app/views/mentor/textbooks/_form.html.slim b/app/views/mentor/textbooks/_form.html.slim new file mode 100644 index 00000000000..67557503a02 --- /dev/null +++ b/app/views/mentor/textbooks/_form.html.slim @@ -0,0 +1,34 @@ += form_with model: [:mentor, @textbook], local: true, class: 'form', html: { name: 'textbook' } do |f| + = render 'errors', object: @textbook + .form-item + .row + .col-md-6.col-xs-12 + = f.label :title, class: 'a-form-label is-required' + = f.text_field :title, class: 'a-text-input js-warning-form' + + .form-item + .row + .col-md-6.col-xs-12 + = f.label :description, class: 'a-form-label' + = f.text_area :description, class: 'a-text-input js-warning-form', rows: 4 + + .form-item + .row + .col-md-6.col-xs-12 + = f.label :published, '教科書を公開する', class: 'a-form-label' + label.a-on-off-checkbox.is-md + = f.check_box :published + span + + .form-item + .row + .col-md-6.col-xs-12 + = f.label :practice_id, 'プラクティス', class: 'a-form-label' + = f.collection_select :practice_id, Practice.all, :id, :title, { include_blank: '選択なし' }, class: 'a-text-input' + + .form-actions + ul.form-actions__items + li.form-actions__item.is-main + = f.submit '内容を保存', class: 'a-button is-lg is-primary is-block' + li.form-actions__item.is-sub + = link_to 'キャンセル', :back, class: 'a-button is-sm is-secondary is-block' diff --git a/app/views/mentor/textbooks/chapters/_form.html.slim b/app/views/mentor/textbooks/chapters/_form.html.slim new file mode 100644 index 00000000000..1219688b6f5 --- /dev/null +++ b/app/views/mentor/textbooks/chapters/_form.html.slim @@ -0,0 +1,20 @@ += form_with model: [:mentor, @textbook, @chapter], local: true, class: 'form', html: { name: 'chapter' } do |f| + = render 'errors', object: @chapter + .form-item + .row + .col-md-6.col-xs-12 + = f.label :title, class: 'a-form-label is-required' + = f.text_field :title, class: 'a-text-input js-warning-form' + + .form-item + .row + .col-md-6.col-xs-12 + = f.label :position, '表示順', class: 'a-form-label is-required' + = f.number_field :position, class: 'a-text-input js-warning-form', min: 0 + + .form-actions + ul.form-actions__items + li.form-actions__item.is-main + = f.submit '内容を保存', class: 'a-button is-lg is-primary is-block' + li.form-actions__item.is-sub + = link_to 'キャンセル', :back, class: 'a-button is-sm is-secondary is-block' diff --git a/app/views/mentor/textbooks/chapters/edit.html.slim b/app/views/mentor/textbooks/chapters/edit.html.slim new file mode 100644 index 00000000000..ba4ee205d60 --- /dev/null +++ b/app/views/mentor/textbooks/chapters/edit.html.slim @@ -0,0 +1,28 @@ +- title '章の編集' + +header.page-header + .container + .page-header__inner + .page-header__start + .page-header__title + | メンターページ + += render 'mentor/mentor_page_tabs' + +main.page-main + header.page-main-header + .container + .page-main-header__inner + .page-main-header__start + h1.page-main-header__title + = title + .page-main-header__end + .page-main-header-actions + .page-main-header-actions__items + .page-main-header-actions__item + = link_to edit_mentor_textbook_path(@textbook), class: 'a-button is-md is-secondary is-block is-back' do + | 教科書に戻る + hr.a-border + .page-body + .container.is-xl + = render 'form', chapter: @chapter diff --git a/app/views/mentor/textbooks/chapters/new.html.slim b/app/views/mentor/textbooks/chapters/new.html.slim new file mode 100644 index 00000000000..3607813e7c6 --- /dev/null +++ b/app/views/mentor/textbooks/chapters/new.html.slim @@ -0,0 +1,28 @@ +- title '章の作成' + +header.page-header + .container + .page-header__inner + .page-header__start + .page-header__title + | メンターページ + += render 'mentor/mentor_page_tabs' + +main.page-main + header.page-main-header + .container + .page-main-header__inner + .page-main-header__start + h1.page-main-header__title + = title + .page-main-header__end + .page-main-header-actions + .page-main-header-actions__items + .page-main-header-actions__item + = link_to edit_mentor_textbook_path(@textbook), class: 'a-button is-md is-secondary is-block is-back' do + | 教科書に戻る + hr.a-border + .page-body + .container.is-xl + = render 'form', chapter: @chapter diff --git a/app/views/mentor/textbooks/edit.html.slim b/app/views/mentor/textbooks/edit.html.slim new file mode 100644 index 00000000000..074763086f7 --- /dev/null +++ b/app/views/mentor/textbooks/edit.html.slim @@ -0,0 +1,82 @@ +- title '教科書編集' + +header.page-header + .container + .page-header__inner + .page-header__start + .page-header__title + | メンターページ + += render 'mentor/mentor_page_tabs' + +main.page-main + header.page-main-header + .container + .page-main-header__inner + .page-main-header__start + h1.page-main-header__title + = title + .page-main-header__end + .page-main-header-actions + .page-main-header-actions__items + .page-main-header-actions__item + = link_to mentor_textbooks_path, class: 'a-button is-md is-secondary is-block is-back' do + | 教科書一覧 + hr.a-border + .page-body + .container.is-xl + = render 'form', textbook: @textbook + + - if @textbook.persisted? + hr.a-border.mt-2 + .mt-2 + h2.page-main-header__title + | 章の管理 + .mt-2 + = link_to new_mentor_textbook_chapter_path(@textbook), class: 'a-button is-md is-secondary is-block' do + i.fa-regular.fa-plus + | 章を追加 + + - @textbook.chapters.each do |chapter| + .a-card.mt-2 + header.card-header + h3.card-header__title + = "第#{chapter.position}章 #{chapter.title}" + hr.a-border-tint + .card-body + - if chapter.sections.any? + ul.card-body__items + - chapter.sections.each do |section| + li.card-body__item + .flex.items-center.gap-2 + span + = section.title + - if section.estimated_minutes.present? + span.text-muted + = "(#{section.estimated_minutes}分)" + .ml-auto + ul.is-inline-buttons + li + = link_to edit_mentor_textbook_chapter_section_path(@textbook, chapter, section), class: 'a-button is-sm is-secondary is-icon is-block' do + i.fa-solid.fa-pen + li + = link_to mentor_textbook_chapter_section_path(@textbook, chapter, section), data: { confirm: '本当に削除しますか?' }, method: :delete, class: 'a-button is-sm is-danger is-icon is-block' do + i.fa-solid.fa-trash + - else + p.text-muted + | セクションはまだありません。 + hr.a-border-tint + footer.card-footer + .card-main-actions + ul.card-main-actions__items + li.card-main-actions__item + = link_to new_mentor_textbook_chapter_section_path(@textbook, chapter), class: 'card-main-actions__action a-button is-sm is-secondary is-block' do + i.fa-regular.fa-plus + | セクション追加 + li.card-main-actions__item + = link_to edit_mentor_textbook_chapter_path(@textbook, chapter), class: 'card-main-actions__action a-button is-sm is-secondary is-block' do + i.fa-solid.fa-pen + | 章を編集 + li.card-main-actions__item.is-sub + = link_to mentor_textbook_chapter_path(@textbook, chapter), data: { confirm: '本当に削除しますか?' }, method: :delete, class: 'card-main-actions__muted-action' do + | 章を削除 diff --git a/app/views/mentor/textbooks/index.html.slim b/app/views/mentor/textbooks/index.html.slim new file mode 100644 index 00000000000..c1576df89bf --- /dev/null +++ b/app/views/mentor/textbooks/index.html.slim @@ -0,0 +1,63 @@ +- title 'メンターページ' +header.page-header + .container + .page-header__inner + .page-header__start + .page-header__title + = title + += render 'mentor/mentor_page_tabs' + +.page-main + header.page-main-header + .container + .page-main-header__inner + .page-main-header__start + h1.page-main-header__title + | 教科書 + .page-main-header__end + .page-main-header-actions + .page-main-header-actions__items + .page-main-header-actions__item + = link_to new_mentor_textbook_path, class: 'a-button is-md is-secondary is-block' do + i.fa-regular.fa-plus + span + | 教科書作成 + hr.a-border + .page-body + .container.is-lg + .admin-table + table.admin-table__table + thead.admin-table__header + tr.admin-table__labels + td.admin-table__label + | タイトル + td.admin-table__label + | 公開 + td.admin-table__label + | 章数 + td.admin-table__label + | 操作 + tbody.admin-table__items + - @textbooks.each do |textbook| + tr.admin-table__item + td.admin-table__item-value + - if textbook.published? + = link_to textbook.title, textbook_path(textbook) + - else + = link_to textbook.title, edit_mentor_textbook_path(textbook) + td.admin-table__item-value.is-text-align-center + - if textbook.published? + i.fa-solid.fa-check.text-green + - else + | - + td.admin-table__item-value.is-text-align-center + = textbook.chapters.size + td.admin-table__item-value.is-text-align-center + ul.is-inline-buttons + li + = link_to edit_mentor_textbook_path(textbook), class: 'a-button is-sm is-secondary is-icon is-block', aria: { label: "#{textbook.title}を編集" } do + i.fa.fa-solid.fa-pen + li + = link_to mentor_textbook_path(textbook), data: { confirm: '本当に削除しますか?' }, method: :delete, class: 'a-button is-sm is-danger is-icon is-block', aria: { label: "#{textbook.title}を削除" } do + i.fa-solid.fa-trash diff --git a/app/views/mentor/textbooks/new.html.slim b/app/views/mentor/textbooks/new.html.slim new file mode 100644 index 00000000000..4764ee30609 --- /dev/null +++ b/app/views/mentor/textbooks/new.html.slim @@ -0,0 +1,28 @@ +- title '教科書作成' + +header.page-header + .container + .page-header__inner + .page-header__start + .page-header__title + | メンターページ + += render 'mentor/mentor_page_tabs' + +main.page-main + header.page-main-header + .container + .page-main-header__inner + .page-main-header__start + h1.page-main-header__title + = title + .page-main-header__end + .page-main-header-actions + .page-main-header-actions__items + .page-main-header-actions__item + = link_to mentor_textbooks_path, class: 'a-button is-md is-secondary is-block is-back' do + | 教科書一覧 + hr.a-border + .page-body + .container.is-xl + = render 'form', textbook: @textbook diff --git a/app/views/mentor/textbooks/sections/_form.html.slim b/app/views/mentor/textbooks/sections/_form.html.slim new file mode 100644 index 00000000000..8f1165e5260 --- /dev/null +++ b/app/views/mentor/textbooks/sections/_form.html.slim @@ -0,0 +1,52 @@ += form_with model: [:mentor, @textbook, @chapter, @section], local: true, class: 'form', html: { name: 'section' } do |f| + = render 'errors', object: @section + .form-item + .row + .col-md-6.col-xs-12 + = f.label :title, class: 'a-form-label is-required' + = f.text_field :title, class: 'a-text-input js-warning-form' + + .form-item + .row + .col-md-6.col-xs-12 + = f.label :position, '表示順', class: 'a-form-label is-required' + = f.number_field :position, class: 'a-text-input js-warning-form', min: 0 + + .form-item + .row + .col-md-6.col-xs-12 + = f.label :estimated_minutes, '想定学習時間(分)', class: 'a-form-label' + = f.number_field :estimated_minutes, class: 'a-text-input js-warning-form', min: 0 + + .form-item + = f.label :body, '本文(Markdown)', class: 'a-form-label is-required' + .row.js-markdown-parent.mt-2 + .col-md-6.col-xs-12 + .a-form-label + | 入力 + = f.text_area :body, class: 'a-text-input js-warning-form markdown-form__text-area js-markdown', rows: 20, data: { 'preview': '#body-preview' } + .col-md-6.col-xs-12 + .a-form-label + | プレビュー + .js-preview.a-long-text.is-md.markdown-form__preview#body-preview + + .form-item + = f.label :goals, '学習目標(1行ずつ入力)', class: 'a-form-label' + .a-form-help + p + | 改行で区切って入力してください。 + = f.text_area :goals, value: @section.goals&.join("\n"), class: 'a-text-input js-warning-form', rows: 4 + + .form-item + = f.label :key_terms, 'キーワード(1行ずつ入力)', class: 'a-form-label' + .a-form-help + p + | 本文中でツールチップ表示するキーワードです。改行で区切って入力してください。 + = f.text_area :key_terms, value: @section.key_terms&.join("\n"), class: 'a-text-input js-warning-form', rows: 4 + + .form-actions + ul.form-actions__items + li.form-actions__item.is-main + = f.submit '内容を保存', class: 'a-button is-lg is-primary is-block' + li.form-actions__item.is-sub + = link_to 'キャンセル', :back, class: 'a-button is-sm is-secondary is-block' diff --git a/app/views/mentor/textbooks/sections/edit.html.slim b/app/views/mentor/textbooks/sections/edit.html.slim new file mode 100644 index 00000000000..2ea426bdd63 --- /dev/null +++ b/app/views/mentor/textbooks/sections/edit.html.slim @@ -0,0 +1,28 @@ +- title 'セクション編集' + +header.page-header + .container + .page-header__inner + .page-header__start + .page-header__title + | メンターページ + += render 'mentor/mentor_page_tabs' + +main.page-main + header.page-main-header + .container + .page-main-header__inner + .page-main-header__start + h1.page-main-header__title + = title + .page-main-header__end + .page-main-header-actions + .page-main-header-actions__items + .page-main-header-actions__item + = link_to edit_mentor_textbook_path(@textbook), class: 'a-button is-md is-secondary is-block is-back' do + | 教科書に戻る + hr.a-border + .page-body + .container.is-xl + = render 'form', section: @section diff --git a/app/views/mentor/textbooks/sections/new.html.slim b/app/views/mentor/textbooks/sections/new.html.slim new file mode 100644 index 00000000000..49ba493c204 --- /dev/null +++ b/app/views/mentor/textbooks/sections/new.html.slim @@ -0,0 +1,28 @@ +- title 'セクション作成' + +header.page-header + .container + .page-header__inner + .page-header__start + .page-header__title + | メンターページ + += render 'mentor/mentor_page_tabs' + +main.page-main + header.page-main-header + .container + .page-main-header__inner + .page-main-header__start + h1.page-main-header__title + = title + .page-main-header__end + .page-main-header-actions + .page-main-header-actions__items + .page-main-header-actions__item + = link_to edit_mentor_textbook_path(@textbook), class: 'a-button is-md is-secondary is-block is-back' do + | 教科書に戻る + hr.a-border + .page-body + .container.is-xl + = render 'form', section: @section diff --git a/app/views/shared/_piyo_companion.html.slim b/app/views/shared/_piyo_companion.html.slim new file mode 100644 index 00000000000..235e0d4c728 --- /dev/null +++ b/app/views/shared/_piyo_companion.html.slim @@ -0,0 +1,10 @@ +- section_id = local_assigns[:section_id] + +.piyo-companion data-controller='piyo-companion' + /! 吹き出し + .piyo-companion__bubble.is-hidden data-piyo-companion-target='bubble' + p.piyo-companion__message data-piyo-companion-target='message' + + /! ピヨルドボタン + button.piyo-companion__button type='button' data-action='click->piyo-companion#dismiss' + = image_tag 'shared/piyo.svg', alt: 'ピヨルド', class: 'piyo-companion__image' diff --git a/app/views/textbooks/chapters/show.html.slim b/app/views/textbooks/chapters/show.html.slim new file mode 100644 index 00000000000..2bc22998a18 --- /dev/null +++ b/app/views/textbooks/chapters/show.html.slim @@ -0,0 +1,22 @@ +- title "#{@textbook.title} - #{@chapter.title}" + + main.page-main + header.page-main-header + .container + .page-main-header__inner + .page-main-header__start + h1.page-main-header__title + = @chapter.title + .page-main-header__end + .page-header-actions + ul.page-header-actions__items + li.page-header-actions__item + = link_to textbook_path(@textbook), class: 'a-button is-md is-secondary is-block is-back' do + | 目次に戻る + hr.a-border + .page-body + .container.is-md + .a-card + .card-body + p + | この章にはまだセクションがありません。 diff --git a/app/views/textbooks/index.html.slim b/app/views/textbooks/index.html.slim new file mode 100644 index 00000000000..b1feb662c15 --- /dev/null +++ b/app/views/textbooks/index.html.slim @@ -0,0 +1,42 @@ +- title '教科書' + +main.page-main + header.page-main-header + .container + .page-main-header__inner + .page-main-header__start + h1.page-main-header__title + | 教科書 + hr.a-border + .page-body + .container.is-md + - if @textbooks.empty? + .o-empty-message + .o-empty-message__text + | 教科書はまだありません。 + - else + - @textbooks.each do |textbook| + - total = textbook.chapters.flat_map(&:sections).size + - completed = current_user.present? ? textbook.chapters.flat_map(&:sections).count { |s| s.reading_progresses.exists?(user: current_user, completed: true) } : 0 + - progress_percent = total.positive? ? (completed.to_f / total * 100).round : 0 + .a-card + .card-body + = link_to textbook_path(textbook), class: 'card-list__item-link' do + .card-list__item-body + .card-list__item-title + = textbook.title + - if textbook.description.present? + .card-list__item-meta + = truncate(textbook.description, length: 140) + .textbook-summary + .textbook-summary__stats + = "#{textbook.chapters.size}章 · #{total}セクション" + - if current_user.present? && total.positive? + .textbook-summary__progress + .completed-practices-progress + .completed-practices-progress__bar-container + .completed-practices-progress__bar + .completed-practices-progress__percentage-bar style="width: #{progress_percent}%" + .completed-practices-progress__counts + span.completed-practices-progress__percentage + = "#{progress_percent}%(#{completed}/#{total})" diff --git a/app/views/textbooks/sections/show.html.slim b/app/views/textbooks/sections/show.html.slim new file mode 100644 index 00000000000..735e94a9f39 --- /dev/null +++ b/app/views/textbooks/sections/show.html.slim @@ -0,0 +1,88 @@ +- title "#{@textbook.title} - #{@section.title}" + +ruby: + sections = @chapter.sections.to_a + current_index = sections.index(@section) + progress_percent = ((current_index + 1).to_f / sections.size * 100).round + all_sections = @textbook.chapters.flat_map(&:sections) + global_index = all_sections.index(@section) + prev_section = global_index.positive? ? all_sections[global_index - 1] : nil + next_section = global_index < all_sections.size - 1 ? all_sections[global_index + 1] : nil + +main.page-main + header.page-main-header + .container + .page-main-header__inner + .page-main-header__start + h1.page-main-header__title + = @section.title + .page-main-header__end + .page-header-actions + ul.page-header-actions__items + li.page-header-actions__item + = link_to textbook_path(@textbook), class: 'a-button is-md is-secondary is-block is-back' do + | 目次に戻る + hr.a-border + .page-body + .page-body__inner.has-side-nav + .container.is-md + /! パンくず + nav.textbook-breadcrumb + = link_to @textbook.title, textbook_path(@textbook) + | › + = " 第#{@chapter.position}章 #{@chapter.title}" + | › + span = " #{current_index + 1}/#{sections.size}" + + /! 学習目標 + - if @section.goals.present? && @section.goals.any?(&:present?) + .a-card.is-sm + header.card-header.is-sm + h2.card-header__title + i.fa-solid.fa-bullseye + | このセクションの目標 + .card-body + ul.card-body__items + - @section.goals.each do |goal| + - next if goal.blank? + li.card-body__item + = goal + + /! 本文 + .a-card data-controller='reading-progress' data-reading-progress-section-id-value="#{@section.id}" data-reading-progress-total-blocks-value='0' + .card-body + .card-body__description + .a-long-text.is-md.js-markdown-view + = textbook_section_body(@section) + + /! 前後ナビ + .page-content-prev-next + .page-content-prev-next__item + - if prev_section + = link_to textbook_chapter_section_path(@textbook, prev_section.chapter, prev_section), class: 'page-content-prev-next__item-link is-prev' do + i.fa-solid.fa-angle-left + | 前へ + .page-content-prev-next__item + - if next_section + = link_to textbook_chapter_section_path(@textbook, next_section.chapter, next_section), class: 'page-content-prev-next__item-link is-next' do + | 次へ + i.fa-solid.fa-angle-right + + /! サイドナビ(章のセクション一覧) + nav.a-side-nav + .a-side-nav__inner + header.a-side-nav__header + h2.a-side-nav__title + = link_to textbook_path(@textbook), class: 'a-side-nav__title-link' do + = "第#{@chapter.position}章 #{@chapter.title}" + hr.a-border + .a-side-nav__body + ul.a-side-nav__items + - sections.each do |section| + li.a-side-nav__item class=("#{'is-current' if section == @section}") + = link_to textbook_chapter_section_path(@textbook, @chapter, section), class: 'a-side-nav__item-link' do + span.a-side-nav__item-link-inner + = section.title + + /! ピヨルド + = render 'shared/piyo_companion', section_id: @section.id diff --git a/app/views/textbooks/show.html.slim b/app/views/textbooks/show.html.slim new file mode 100644 index 00000000000..507ee36c971 --- /dev/null +++ b/app/views/textbooks/show.html.slim @@ -0,0 +1,83 @@ +- title @textbook.title + +main.page-main + header.page-main-header + .container + .page-main-header__inner + .page-main-header__start + h1.page-main-header__title + = @textbook.title + .page-main-header__end + .page-header-actions + ul.page-header-actions__items + li.page-header-actions__item + = link_to textbooks_path, class: 'a-button is-md is-secondary is-block is-back' do + | 教科書一覧 + hr.a-border + .page-body + .container.is-md + ruby: + all_sections = @textbook.chapters.flat_map(&:sections) + total = all_sections.size + if current_user.present? + completed_ids = current_user.reading_progresses.where(textbook_section_id: all_sections.map(&:id), completed: true).pluck(:textbook_section_id) + completed_count = completed_ids.size + else + completed_ids = [] + completed_count = 0 + end + progress_percent = total.positive? ? (completed_count.to_f / total * 100).round : 0 + + /! 概要カード + - if @textbook.description.present? || (current_user.present? && total.positive?) + .a-card + .card-body + - if @textbook.description.present? + .card-body__description + .a-long-text.is-md + = @textbook.description + - if current_user.present? && total.positive? + .textbook-summary + .completed-practices-progress + .completed-practices-progress__bar-container + .completed-practices-progress__bar + .completed-practices-progress__percentage-bar style="width: #{progress_percent}%" + .completed-practices-progress__counts + span.completed-practices-progress__percentage + = "#{progress_percent}%(#{completed_count}/#{total}セクション完了)" + - next_section = all_sections.find { |s| !completed_ids.include?(s.id) } + - if next_section + .textbook-summary__action + = link_to textbook_chapter_section_path(@textbook, next_section.chapter, next_section), class: 'a-button is-md is-primary is-block' do + | 続きを読む + + /! 章・セクション一覧 + - @textbook.chapters.each do |chapter| + - chapter_sections = chapter.sections.to_a + - chapter_completed = current_user.present? ? chapter_sections.count { |s| completed_ids.include?(s.id) } : 0 + .a-card + header.card-header + h2.card-header__title + = "第#{chapter.position}章 #{chapter.title}" + - if current_user.present? && chapter_sections.size.positive? + .card-header__metas + span.a-meta + = "#{chapter_completed}/#{chapter_sections.size}" + hr.a-border-tint + .card-list + .card-list__items + - chapter.sections.each do |section| + .card-list__item + = link_to textbook_chapter_section_path(@textbook, chapter, section), class: 'card-list__item-link' do + .card-list__item-body + .card-list__item-title + - if current_user.present? && completed_ids.include?(section.id) + i.fa-solid.fa-circle-check.a-completed-check + - else + i.fa-regular.fa-circle.a-pending-check + = section.title + .card-list__item-meta + - if section.estimated_minutes.present? + span + i.fa-regular.fa-clock + = " 約#{section.estimated_minutes}分" diff --git a/config/initializers/ruby_llm.rb b/config/initializers/ruby_llm.rb index a03e9903cd4..5a73a687134 100644 --- a/config/initializers/ruby_llm.rb +++ b/config/initializers/ruby_llm.rb @@ -2,4 +2,5 @@ RubyLLM.configure do |config| config.openai_api_key = ENV['OPEN_AI_ACCESS_TOKEN'] + config.anthropic_api_key = ENV['ANTHROPIC_API_KEY'] end diff --git a/config/routes.rb b/config/routes.rb index 37bb9370732..f5d8007b55f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -34,6 +34,7 @@ draw :connection draw :products draw :reports + draw :textbook resources :announcements resource :training_completion, only: %i(show new create), controller: "training_completion" resource :retirement, only: %i(show new create), controller: "retirement" diff --git a/config/routes/mentor.rb b/config/routes/mentor.rb index bea4ad5e395..d2541d279b9 100644 --- a/config/routes/mentor.rb +++ b/config/routes/mentor.rb @@ -2,23 +2,28 @@ Rails.application.routes.draw do namespace :mentor do - root to: "home#index", as: :root + root to: 'home#index', as: :root resources :categories do - resources :practices, only: %i(index), controller: "categories/practices" + resources :practices, only: %i[index], controller: 'categories/practices' end - resources :practices, only: %i(index new edit create update destroy) do - resources :coding_tests, only: %i(index), controller: "practices/coding_tests" - resource :submission_answer, only: %i(new edit create update), controller: "practices/submission_answer" + resources :practices, only: %i[index new edit create update destroy] do + resources :coding_tests, only: %i[index], controller: 'practices/coding_tests' + resource :submission_answer, only: %i[new edit create update], controller: 'practices/submission_answer' end - resources :coding_tests, only: %i(index new edit create update destroy) - resources :courses, only: %i(index new edit create update) do - resources :categories, only: %i(index), controller: "courses/categories" + resources :coding_tests, only: %i[index new edit create update destroy] + resources :courses, only: %i[index new edit create update] do + resources :categories, only: %i[index], controller: 'courses/categories' + end + resources :survey_questions, except: %i[show destroy] + resources :textbooks, only: %i[index new create edit update destroy] do + resources :chapters, only: %i[new create edit update destroy], controller: 'textbooks/chapters' do + resources :sections, only: %i[new create edit update destroy], controller: 'textbooks/sections' + end + end + resources :surveys do + resources :survey_questions, only: %i[index], controller: 'surveys/survey_question_listings' + resources :survey_answers, only: %i[index show], controller: 'surveys/survey_answers' + resource :survey_result, only: %i[show], controller: 'surveys/survey_result' end - resources :survey_questions, except: %i(show destroy) - resources :surveys do - resources :survey_questions, only: %i(index), controller: "surveys/survey_question_listings" - resources :survey_answers, only: %i(index show), controller: "surveys/survey_answers" - resource :survey_result, only: %i(show), controller: "surveys/survey_result" - end end end diff --git a/config/routes/textbook.rb b/config/routes/textbook.rb new file mode 100644 index 00000000000..78753561764 --- /dev/null +++ b/config/routes/textbook.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do + resources :textbooks, only: %i[index show] do + resources :chapters, only: %i[show], controller: 'textbooks/chapters' do + resources :sections, only: %i[show], controller: 'textbooks/sections' + end + end + + namespace :api do + namespace :textbooks do + resources :reading_progresses, only: %i[create update] + resources :term_explanations, only: %i[show] + end + end +end diff --git a/db/migrate/20260322000000_enable_pg_bigm_extension.rb b/db/migrate/20260322000000_enable_pg_bigm_extension.rb index ac77229c2b4..297a571b2ab 100644 --- a/db/migrate/20260322000000_enable_pg_bigm_extension.rb +++ b/db/migrate/20260322000000_enable_pg_bigm_extension.rb @@ -2,24 +2,15 @@ class EnablePgBigmExtension < ActiveRecord::Migration[8.1] def up - return unless pg_bigm_installable? - enable_extension 'pg_bigm' + rescue StandardError => e + # pg_bigmがインストールされていない環境(CI等)ではスキップ + say "pg_bigm extension not available, skipping: #{e.message.lines.first.strip}" end def down disable_extension 'pg_bigm' rescue ActiveRecord::StatementInvalid - # pg_bigmが存在しない環境では無視 nil end - - private - - def pg_bigm_installable? - result = execute("SELECT COUNT(*) FROM pg_available_extensions WHERE name = 'pg_bigm'") - result.first['count'].to_i.positive? - rescue ActiveRecord::StatementInvalid - false - end end diff --git a/db/migrate/20260326000000_create_textbook_tables.rb b/db/migrate/20260326000000_create_textbook_tables.rb new file mode 100644 index 00000000000..b3ec4a53292 --- /dev/null +++ b/db/migrate/20260326000000_create_textbook_tables.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class CreateTextbookTables < ActiveRecord::Migration[8.1] + def change + create_table :textbooks do |t| + t.string :title, null: false + t.text :description + t.boolean :published, default: false, null: false + t.references :practice, foreign_key: true, null: true + t.timestamps + end + + create_table :textbook_chapters do |t| + t.references :textbook, null: false, foreign_key: true + t.string :title, null: false + t.integer :position, null: false + t.timestamps + end + + create_table :textbook_sections do |t| + t.references :textbook_chapter, null: false, foreign_key: true + t.string :title, null: false + t.text :body, null: false + t.integer :estimated_minutes + t.text :goals, array: true, default: [] + t.text :key_terms, array: true, default: [] + t.integer :position, null: false + t.timestamps + end + + create_table :reading_progresses do |t| + t.references :user, null: false, foreign_key: true + t.references :textbook_section, null: false, foreign_key: true + t.float :read_ratio, default: 0.0, null: false + t.boolean :completed, default: false, null: false + t.integer :last_block_index, default: 0 + t.datetime :last_read_at + t.timestamps + t.index %i[user_id textbook_section_id], unique: true + end + + create_table :term_explanations do |t| + t.references :textbook_section, null: false, foreign_key: true + t.string :term, null: false + t.text :explanation, null: false + t.timestamps + t.index %i[textbook_section_id term], unique: true + end + end +end diff --git a/db/migrate/20260328000000_add_unique_index_to_textbook_chapters_on_textbook_id_and_position.rb b/db/migrate/20260328000000_add_unique_index_to_textbook_chapters_on_textbook_id_and_position.rb new file mode 100644 index 00000000000..37913c71d4e --- /dev/null +++ b/db/migrate/20260328000000_add_unique_index_to_textbook_chapters_on_textbook_id_and_position.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddUniqueIndexToTextbookChaptersOnTextbookIdAndPosition < ActiveRecord::Migration[8.1] + def change + add_index :textbook_chapters, %i[textbook_id position], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index c37c421810b..6d551bafc2a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,8 +10,9 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_03_06_064800) do +ActiveRecord::Schema[8.1].define(version: 2026_03_28_000000) do # These are extensions that must be enabled in order to support this database + enable_extension "pg_bigm" enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -52,6 +53,8 @@ t.datetime "updated_at", precision: nil, null: false t.bigint "user_id" t.boolean "wip", default: false, null: false + t.index ["description"], name: "index_announcements_on_description_bigm", opclass: :gin_bigm_ops, using: :gin + t.index ["title"], name: "index_announcements_on_title_bigm", opclass: :gin_bigm_ops, using: :gin t.index ["user_id"], name: "index_announcements_on_user_id" end @@ -62,6 +65,7 @@ t.string "type" t.datetime "updated_at", precision: nil t.integer "user_id" + t.index ["description"], name: "index_answers_on_description_bigm", opclass: :gin_bigm_ops, using: :gin t.index ["question_id", "type"], name: "index_answers_on_question_id_and_type", unique: true t.index ["question_id"], name: "index_answers_on_question_id" t.index ["user_id"], name: "index_answers_on_user_id" @@ -216,6 +220,7 @@ t.datetime "updated_at", precision: nil t.integer "user_id" t.index ["commentable_id"], name: "index_comments_on_commentable_id" + t.index ["description"], name: "index_comments_on_description_bigm", opclass: :gin_bigm_ops, using: :gin t.index ["user_id"], name: "index_comments_on_user_id" end @@ -289,6 +294,8 @@ t.datetime "updated_at", null: false t.bigint "user_id" t.boolean "wip", default: false, null: false + t.index ["description"], name: "index_events_on_description_bigm", opclass: :gin_bigm_ops, using: :gin + t.index ["title"], name: "index_events_on_title_bigm", opclass: :gin_bigm_ops, using: :gin t.index ["user_id"], name: "index_events_on_user_id" end @@ -547,8 +554,10 @@ t.datetime "updated_at", precision: nil, null: false t.bigint "user_id" t.boolean "wip", default: false, null: false + t.index ["body"], name: "index_pages_on_body_bigm", opclass: :gin_bigm_ops, using: :gin t.index ["practice_id"], name: "index_pages_on_practice_id" t.index ["slug"], name: "index_pages_on_slug", unique: true + t.index ["title"], name: "index_pages_on_title_bigm", opclass: :gin_bigm_ops, using: :gin t.index ["updated_at"], name: "index_pages_on_updated_at" t.index ["user_id"], name: "index_pages_on_user_id" end @@ -575,8 +584,10 @@ t.bigint "user_id", null: false t.boolean "wip", default: false, null: false t.index ["buddy_id"], name: "index_pair_works_on_buddy_id" + t.index ["description"], name: "index_pair_works_on_description_bigm", opclass: :gin_bigm_ops, using: :gin t.index ["practice_id"], name: "index_pair_works_on_practice_id" t.index ["published_at"], name: "index_pair_works_on_published_at" + t.index ["title"], name: "index_pair_works_on_title_bigm", opclass: :gin_bigm_ops, using: :gin t.index ["user_id"], name: "index_pair_works_on_user_id" end @@ -606,7 +617,10 @@ t.string "title", limit: 255, null: false t.datetime "updated_at", precision: nil t.index ["category_id"], name: "index_practices_on_category_id" + t.index ["description"], name: "index_practices_on_description_bigm", opclass: :gin_bigm_ops, using: :gin + t.index ["goal"], name: "index_practices_on_goal_bigm", opclass: :gin_bigm_ops, using: :gin t.index ["source_id"], name: "index_practices_on_source_id" + t.index ["title"], name: "index_practices_on_title_bigm", opclass: :gin_bigm_ops, using: :gin end create_table "practices_books", force: :cascade do |t| @@ -647,6 +661,7 @@ t.datetime "updated_at", precision: nil, null: false t.bigint "user_id" t.boolean "wip", default: false, null: false + t.index ["body"], name: "index_products_on_body_bigm", opclass: :gin_bigm_ops, using: :gin t.index ["commented_at"], name: "index_products_on_commented_at" t.index ["practice_id"], name: "index_products_on_practice_id" t.index ["user_id", "practice_id"], name: "index_products_on_user_id_and_practice_id", unique: true @@ -663,7 +678,9 @@ t.datetime "updated_at", precision: nil t.integer "user_id" t.boolean "wip", default: false, null: false + t.index ["description"], name: "index_questions_on_description_bigm", opclass: :gin_bigm_ops, using: :gin t.index ["practice_id"], name: "index_questions_on_practice_id" + t.index ["title"], name: "index_questions_on_title_bigm", opclass: :gin_bigm_ops, using: :gin t.index ["user_id"], name: "index_questions_on_user_id" end @@ -697,6 +714,20 @@ t.index ["user_id"], name: "index_reactions_on_user_id" end + create_table "reading_progresses", force: :cascade do |t| + t.boolean "completed", default: false, null: false + t.datetime "created_at", null: false + t.integer "last_block_index", default: 0 + t.datetime "last_read_at" + t.float "read_ratio", default: 0.0, null: false + t.bigint "textbook_section_id", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["textbook_section_id"], name: "index_reading_progresses_on_textbook_section_id" + t.index ["user_id", "textbook_section_id"], name: "index_reading_progresses_on_user_id_and_textbook_section_id", unique: true + t.index ["user_id"], name: "index_reading_progresses_on_user_id" + end + create_table "regular_event_participations", force: :cascade do |t| t.datetime "created_at", null: false t.bigint "regular_event_id", null: false @@ -730,6 +761,8 @@ t.datetime "updated_at", null: false t.bigint "user_id", null: false t.boolean "wip", default: false, null: false + t.index ["description"], name: "index_regular_events_on_description_bigm", opclass: :gin_bigm_ops, using: :gin + t.index ["title"], name: "index_regular_events_on_title_bigm", opclass: :gin_bigm_ops, using: :gin t.index ["user_id"], name: "index_regular_events_on_user_id" end @@ -752,6 +785,8 @@ t.integer "user_id", null: false t.boolean "wip", default: false, null: false t.index ["created_at"], name: "index_reports_on_created_at" + t.index ["description"], name: "index_reports_on_description_bigm", opclass: :gin_bigm_ops, using: :gin + t.index ["title"], name: "index_reports_on_title_bigm", opclass: :gin_bigm_ops, using: :gin t.index ["user_id", "reported_on"], name: "index_reports_on_user_id_and_reported_on", unique: true t.index ["user_id", "title"], name: "index_reports_on_user_id_and_title", unique: true t.index ["user_id"], name: "reports_user_id" @@ -1011,6 +1046,49 @@ t.index ["user_id"], name: "index_talks_on_user_id" end + create_table "term_explanations", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "explanation", null: false + t.string "term", null: false + t.bigint "textbook_section_id", null: false + t.datetime "updated_at", null: false + t.index ["textbook_section_id", "term"], name: "index_term_explanations_on_textbook_section_id_and_term", unique: true + t.index ["textbook_section_id"], name: "index_term_explanations_on_textbook_section_id" + end + + create_table "textbook_chapters", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "position", null: false + t.bigint "textbook_id", null: false + t.string "title", null: false + t.datetime "updated_at", null: false + t.index ["textbook_id", "position"], name: "index_textbook_chapters_on_textbook_id_and_position", unique: true + t.index ["textbook_id"], name: "index_textbook_chapters_on_textbook_id" + end + + create_table "textbook_sections", force: :cascade do |t| + t.text "body", null: false + t.datetime "created_at", null: false + t.integer "estimated_minutes" + t.text "goals", default: [], array: true + t.text "key_terms", default: [], array: true + t.integer "position", null: false + t.bigint "textbook_chapter_id", null: false + t.string "title", null: false + t.datetime "updated_at", null: false + t.index ["textbook_chapter_id"], name: "index_textbook_sections_on_textbook_chapter_id" + end + + create_table "textbooks", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "description" + t.bigint "practice_id" + t.boolean "published", default: false, null: false + t.string "title", null: false + t.datetime "updated_at", null: false + t.index ["practice_id"], name: "index_textbooks_on_practice_id" + end + create_table "users", id: :serial, force: :cascade do |t| t.datetime "accessed_at", precision: nil t.boolean "admin", default: false, null: false @@ -1081,9 +1159,13 @@ t.string "unsubscribe_email_token" t.datetime "updated_at", precision: nil t.index ["course_id"], name: "index_users_on_course_id" + t.index ["description"], name: "index_users_on_description_bigm", opclass: :gin_bigm_ops, using: :gin t.index ["email"], name: "index_users_on_email", unique: true t.index ["github_id"], name: "index_users_on_github_id", unique: true t.index ["login_name"], name: "index_users_on_login_name", unique: true + t.index ["login_name"], name: "index_users_on_login_name_bigm", opclass: :gin_bigm_ops, using: :gin + t.index ["name"], name: "index_users_on_name_bigm", opclass: :gin_bigm_ops, using: :gin + t.index ["name_kana"], name: "index_users_on_name_kana_bigm", opclass: :gin_bigm_ops, using: :gin t.index ["remember_me_token"], name: "index_users_on_remember_me_token" t.index ["reset_password_token"], name: "index_users_on_reset_password_token" end @@ -1166,6 +1248,8 @@ add_foreign_key "radio_button_choices", "radio_buttons" add_foreign_key "radio_buttons", "survey_questions" add_foreign_key "reactions", "users" + add_foreign_key "reading_progresses", "textbook_sections" + add_foreign_key "reading_progresses", "users" add_foreign_key "regular_event_participations", "regular_events" add_foreign_key "regular_event_participations", "users" add_foreign_key "regular_event_repeat_rules", "regular_events" @@ -1189,5 +1273,9 @@ add_foreign_key "survey_questions", "users" add_foreign_key "surveys", "users" add_foreign_key "talks", "users" + add_foreign_key "term_explanations", "textbook_sections" + add_foreign_key "textbook_chapters", "textbooks" + add_foreign_key "textbook_sections", "textbook_chapters" + add_foreign_key "textbooks", "practices" add_foreign_key "works", "users" end diff --git a/db/seeds.rb b/db/seeds.rb index e0ea89c47f4..abeb82750e5 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -79,3 +79,6 @@ ] ActiveRecord::FixtureSet.create_fixtures 'db/fixtures', tables + +# textbook seed データ +load Rails.root.join('db/seeds/textbook_linux.rb') diff --git a/db/seeds/textbook_linux.rb b/db/seeds/textbook_linux.rb new file mode 100644 index 00000000000..e5599013bd3 --- /dev/null +++ b/db/seeds/textbook_linux.rb @@ -0,0 +1,561 @@ +# frozen_string_literal: true + +# Linuxの教科書 seed データ + +textbook = Textbook.find_or_initialize_by(title: 'Linux') +textbook.description = 'Linuxの基本操作からシェルスクリプトまで、実践的に学べる教科書です。' +textbook.published = true +textbook.save! + +# チャプターとセクションの定義 +chapters_data = [ + { + title: 'Linuxを使ってみよう', + sections: [ + { title: 'Linuxとは何か', body: <<~MD, estimated_minutes: 5, goals: ['Linuxの概要を説明できる', 'Linuxが使われている場面を挙げられる'], key_terms: ['Linux', 'カーネル', 'ディストリビューション'] }, + ## Linuxとは + + Linuxは、1991年にリーナス・トーバルズによって開発が始まったオペレーティングシステム(OS)です。 + + 正確には、Linux自体は「カーネル」と呼ばれるOSの中核部分を指します。このカーネルに様々なソフトウェアを組み合わせたものを「Linuxディストリビューション」と呼びます。 + + ## Linuxが使われている場面 + + Linuxは私たちの身の回りで広く使われています。 + + - **Webサーバー** — インターネット上のサーバーの大部分がLinuxで動いています + - **スマートフォン** — AndroidはLinuxカーネルをベースにしています + - **クラウドサービス** — AWS、GCPなどのクラウド基盤はLinuxが主流です + - **組み込み機器** — ルーターやIoTデバイスにも使われています + + ## なぜLinuxを学ぶのか + + Webエンジニアにとって、Linuxの知識は必須です。開発したアプリケーションが動く環境の多くがLinuxだからです。Linuxの基本操作を身につけることで、開発・運用の現場で自信を持って作業できるようになります。 + MD + { title: 'Linuxディストリビューション', body: <<~MD, estimated_minutes: 5, goals: ['主要なディストリビューションの違いを説明できる', '自分の用途に合ったディストリビューションを選べる'], key_terms: ['Ubuntu', 'CentOS', 'Debian', 'ディストリビューション'] }, + ## ディストリビューションとは + + Linuxカーネル単体ではOSとして使えません。カーネルにシェル、コマンド、ライブラリ、パッケージ管理システムなどを組み合わせて配布されるものを**ディストリビューション**(通称ディストロ)と呼びます。 + + ## 主要なディストリビューション + + ### Debian系 + - **Ubuntu** — デスクトップ用途で最も人気。初心者に優しい + - **Debian** — 安定性重視。サーバー用途に強い + + ### Red Hat系 + - **CentOS / AlmaLinux / Rocky Linux** — 企業サーバーで広く使われている + - **Fedora** — 最新技術をいち早く取り入れる + + ### その他 + - **Arch Linux** — 上級者向け。自分で一から構築する + - **Alpine Linux** — 軽量。Dockerコンテナでよく使われる + + ## この教科書の環境 + + この教科書では**Ubuntu**を前提に解説します。他のディストリビューションでもコマンドの基本は同じですが、パッケージ管理などで違いがある場合は補足します。 + MD + { title: 'ターミナルを開いてみよう', body: <<~MD, estimated_minutes: 4, goals: ['ターミナルを起動できる', '簡単なコマンドを実行できる'], key_terms: ['ターミナル', 'コマンドライン', 'プロンプト'] }, + ## ターミナルとは + + ターミナル(端末)は、キーボードからコマンドを入力してコンピュータを操作するためのアプリケーションです。 + + GUIでマウスをクリックして操作するのとは異なり、テキストベースで操作します。最初は戸惑うかもしれませんが、慣れるととても効率的に作業できるようになります。 + + ## ターミナルを起動する + + Ubuntuでは、以下の方法でターミナルを起動できます。 + + - `Ctrl + Alt + T` キーを押す + - アプリケーションメニューから「端末」を選ぶ + + ## プロンプト + + ターミナルを開くと、以下のような表示が出ます。 + + ``` + komagata@ubuntu:~$ + ``` + + これを**プロンプト**と呼びます。「コマンドを入力してください」という合図です。 + + - `komagata` — ユーザー名 + - `ubuntu` — ホスト名 + - `~` — 現在のディレクトリ(ホームディレクトリ) + - `$` — 一般ユーザーであることを示す(rootユーザーの場合は `#`) + + ## 最初のコマンド + + 試しに `date` と入力してEnterを押してみましょう。 + + ```bash + $ date + 2026年 3月 26日 木曜日 20:00:00 JST + ``` + + 現在の日時が表示されます。これがLinuxでのコマンド実行の基本です。 + MD + ] + }, + { + title: 'シェルって何だろう?', + sections: [ + { title: 'シェルの役割', body: <<~MD, estimated_minutes: 5, goals: ['シェルの役割を説明できる', 'カーネルとシェルの関係を理解する'], key_terms: ['シェル', 'カーネル', 'インタプリタ'] }, + ## シェルとは + + シェル(shell)は、ユーザーとカーネルの間を取り持つプログラムです。「殻」という意味で、カーネル(核)を包む殻のような存在です。 + + ユーザーがターミナルに入力したコマンドを受け取り、それをカーネルに伝え、結果をユーザーに返します。 + + ## シェルの動作の流れ + + 1. ユーザーがコマンドを入力する + 2. シェルがコマンドを解釈する + 3. シェルがカーネルにリクエストを送る + 4. カーネルが処理を実行する + 5. 結果がシェルを通じてユーザーに表示される + + ## なぜシェルが必要なのか + + カーネルは直接人間の言葉を理解できません。シェルが「通訳」の役割を果たすことで、私たちは人間に近い形でコンピュータに指示を出せます。 + + シェルは単なる通訳ではなく、変数、条件分岐、ループなどのプログラミング機能も持っています。これを活用したものが「シェルスクリプト」です。 + MD + { title: 'シェルの種類', body: <<~MD, estimated_minutes: 4, goals: ['主要なシェルの種類を挙げられる', '自分が使っているシェルを確認できる'], key_terms: ['bash', 'zsh', 'sh', 'fish'] }, + ## 主なシェル + + Linuxには複数のシェルがあります。 + + | シェル | 正式名 | 特徴 | + |--------|--------|------| + | sh | Bourne Shell | 最も古いシェル。POSIX標準 | + | bash | Bourne Again Shell | shの後継。Linux標準 | + | zsh | Z Shell | bash互換で高機能。macOSの標準 | + | fish | Friendly Interactive Shell | 初心者に優しい。補完が強力 | + + ## この教科書で使うシェル + + この教科書では**bash**を使います。Linuxで最も広く使われており、シェルスクリプトの標準的な実行環境です。 + + ## 自分のシェルを確認する + + 現在使っているシェルは以下のコマンドで確認できます。 + + ```bash + $ echo $SHELL + /bin/bash + ``` + + `/bin/bash` と表示されればbashを使っています。 + MD + { title: 'コマンドの基本構文', body: <<~MD, estimated_minutes: 5, goals: ['コマンド・オプション・引数の関係を理解する', 'マニュアルを参照できる'], key_terms: ['コマンド', 'オプション', '引数', 'man'] }, + ## コマンドの書き方 + + Linuxのコマンドは基本的に以下の形式です。 + + ``` + コマンド [オプション] [引数] + ``` + + - **コマンド** — 実行したい命令(例: `ls`, `cp`, `mkdir`) + - **オプション** — コマンドの動作を変更する(例: `-l`, `--all`) + - **引数** — コマンドの対象(例: ファイル名、ディレクトリ名) + + ## 例 + + ```bash + $ ls -l /home + ``` + + - `ls` — ファイル一覧を表示するコマンド + - `-l` — 詳細表示するオプション + - `/home` — 表示対象のディレクトリ(引数) + + ## オプションの書き方 + + オプションには2つの書き方があります。 + + - **短いオプション** — `-` + 1文字(例: `-l`, `-a`) + - **長いオプション** — `--` + 単語(例: `--all`, `--help`) + + 短いオプションは複数まとめて書けます。 + + ```bash + $ ls -la # ls -l -a と同じ + ``` + + ## マニュアルを見る + + コマンドの使い方がわからないときは `man` コマンドを使います。 + + ```bash + $ man ls + ``` + + `q` キーで終了できます。 + MD + ] + }, + { + title: 'シェルの便利な機能', + sections: [ + { title: 'コマンド履歴', body: <<~MD, estimated_minutes: 4, goals: ['コマンド履歴を活用できる', 'historyコマンドを使える'], key_terms: ['history', '↑キー', 'Ctrl+R'] }, + ## コマンド履歴とは + + bashは過去に実行したコマンドを記録しています。これを**コマンド履歴**と呼びます。同じコマンドを何度も入力する手間を省けます。 + + ## 履歴の使い方 + + ### ↑↓キーで呼び出す + - `↑` キー — 1つ前のコマンドを表示 + - `↓` キー — 1つ後のコマンドを表示 + + ### historyコマンド + ```bash + $ history + 1 ls + 2 cd /home + 3 cat file.txt + ``` + + ### Ctrl+R で検索 + `Ctrl+R` を押すと、履歴を検索できます。文字を入力すると、一致するコマンドが表示されます。 + + ``` + (reverse-i-search)`cat': cat file.txt + ``` + + Enterで実行、`Ctrl+C` でキャンセルできます。 + + ## 履歴ファイル + + コマンド履歴は `~/.bash_history` ファイルに保存されています。ターミナルを閉じても履歴は残ります。 + MD + { title: 'タブ補完', body: <<~MD, estimated_minutes: 3, goals: ['タブ補完を使いこなせる'], key_terms: ['タブ補完', 'Tab'] }, + ## タブ補完とは + + コマンドやファイル名を途中まで入力して `Tab` キーを押すと、残りを自動的に補完してくれる機能です。 + + ## 使い方 + + 例えば、`/etc/hostname` というファイルを表示したい場合: + + ```bash + $ cat /etc/hostn[Tab] + ``` + + `Tab` を押すと、自動的に以下のように補完されます。 + + ```bash + $ cat /etc/hostname + ``` + + ## 候補が複数ある場合 + + 候補が複数ある場合は、`Tab` を2回押すと候補一覧が表示されます。 + + ```bash + $ cat /etc/host[Tab][Tab] + host.conf hostname hosts + ``` + + さらに文字を入力して絞り込み、再度 `Tab` を押せば補完されます。 + + ## タブ補完を習慣にしよう + + タブ補完は**タイプミスを防ぎ、入力を速くする**最も基本的なテクニックです。Linuxを使うときは常に `Tab` キーを意識しましょう。 + MD + { title: 'ワイルドカード', body: <<~MD, estimated_minutes: 4, goals: ['*と?の違いを説明できる', 'ワイルドカードを使ってファイルを指定できる'], key_terms: ['ワイルドカード', '*', '?', 'グロブ'] }, + ## ワイルドカードとは + + ワイルドカードは、ファイル名のパターンを指定するための特殊文字です。複数のファイルをまとめて指定したいときに使います。 + + ## 主なワイルドカード + + ### `*` — 任意の文字列(0文字以上) + + ```bash + $ ls *.txt # .txtで終わるファイルすべて + $ ls test* # testで始まるファイルすべて + $ ls *.rb # .rbで終わるファイルすべて + ``` + + ### `?` — 任意の1文字 + + ```bash + $ ls file?.txt # file1.txt, fileA.txt など + $ ls ???.rb # 3文字.rb にマッチ + ``` + + ### `[ ]` — 指定した文字のいずれか + + ```bash + $ ls file[123].txt # file1.txt, file2.txt, file3.txt + $ ls file[a-z].txt # filea.txt〜filez.txt + ``` + + ## 注意点 + + ワイルドカードを展開するのはシェルです。コマンドに渡される前に、シェルがワイルドカードを実際のファイル名に置き換えます。 + + これを**グロブ展開**と呼びます。 + MD + ] + }, + { + title: 'ファイルとディレクトリ', + sections: [ + { title: 'ファイルシステムの構造', body: <<~MD, estimated_minutes: 5, goals: ['Linuxのディレクトリ構造を説明できる', '主要なディレクトリの役割を知る'], key_terms: ['ルートディレクトリ', 'ディレクトリツリー', 'FHS'] }, + ## Linuxのディレクトリ構造 + + Linuxのファイルシステムは1つの**ルートディレクトリ** `/` から始まる木構造(ツリー構造)になっています。Windowsのように `C:` `D:` といったドライブの概念はありません。 + + ## 主要なディレクトリ + + ``` + / + ├── bin/ ← 基本コマンド + ├── etc/ ← 設定ファイル + ├── home/ ← ユーザーのホームディレクトリ + ├── tmp/ ← 一時ファイル + ├── usr/ ← ユーザー用プログラム + ├── var/ ← 可変データ(ログなど) + └── root/ ← rootユーザーのホーム + ``` + + | ディレクトリ | 役割 | + |-------------|------| + | `/bin` | 基本的なコマンド(ls, cpなど) | + | `/etc` | システムの設定ファイル | + | `/home` | 各ユーザーのホームディレクトリ | + | `/tmp` | 一時ファイル(再起動で消える) | + | `/usr` | ユーザー向けプログラムやライブラリ | + | `/var` | ログ、メール、データベースなど | + + ## FHS + + このディレクトリ構造は **FHS**(Filesystem Hierarchy Standard)という標準で定められています。ほとんどのLinuxディストリビューションがこの規格に従っています。 + MD + { title: '絶対パスと相対パス', body: <<~MD, estimated_minutes: 4, goals: ['絶対パスと相対パスの違いを説明できる', '適切なパスの書き方を選べる'], key_terms: ['絶対パス', '相対パス', 'カレントディレクトリ', '.', '..'] }, + ## パスとは + + ファイルやディレクトリの場所を示す文字列を**パス**と呼びます。パスには2種類あります。 + + ## 絶対パス + + ルートディレクトリ `/` から始まるパスです。どのディレクトリにいても同じファイルを指します。 + + ```bash + /home/komagata/documents/memo.txt + ``` + + ## 相対パス + + 現在のディレクトリ(カレントディレクトリ)を基準にしたパスです。 + + ```bash + documents/memo.txt + ``` + + ### 特殊な表記 + + - `.` — カレントディレクトリ自身 + - `..` — 1つ上のディレクトリ(親ディレクトリ) + - `~` — ホームディレクトリ + + ```bash + $ cd .. # 1つ上に移動 + $ cat ./memo.txt # カレントディレクトリのmemo.txt + $ cd ~/documents # ホームのdocumentsに移動 + ``` + + ## どちらを使うか + + - **スクリプトや設定ファイル** → 絶対パスが安全 + - **日常の操作** → 相対パスが便利 + MD + { title: 'ホームディレクトリとカレントディレクトリ', body: <<~MD, estimated_minutes: 3, goals: ['pwdでカレントディレクトリを確認できる', 'cdで移動できる'], key_terms: ['ホームディレクトリ', 'カレントディレクトリ', 'pwd', 'cd'] }, + ## ホームディレクトリ + + ログインしたときに最初にいるディレクトリが**ホームディレクトリ**です。通常 `/home/ユーザー名` にあります。 + + `~`(チルダ)はホームディレクトリを表す省略記号です。 + + ```bash + $ echo ~ + /home/komagata + ``` + + ## カレントディレクトリ + + 今いるディレクトリを**カレントディレクトリ**(作業ディレクトリ)と呼びます。 + + ### 確認する: pwd + + ```bash + $ pwd + /home/komagata/documents + ``` + + ### 移動する: cd + + ```bash + $ cd /tmp # /tmpに移動 + $ cd documents # documentsに移動(相対パス) + $ cd # ホームに戻る + $ cd - # 直前のディレクトリに戻る + ``` + + `cd` を引数なしで実行するとホームディレクトリに戻ります。迷ったら `cd` だけ打てば安全な場所に戻れます。 + MD + ] + }, + { + title: 'ファイル操作の基本', + sections: [ + { title: 'ファイルの一覧を見る', body: "lsコマンドの基本的な使い方を学びます。`ls`、`ls -l`、`ls -la`の違いを理解し、ファイルの詳細情報を読めるようになります。", estimated_minutes: 5, goals: ['lsコマンドのオプションを使い分けられる'], key_terms: ['ls', '-l', '-a'] }, + { title: 'ファイルを作成・削除する', body: "touchでファイルを作成し、rmで削除する方法を学びます。`rm -r`によるディレクトリの再帰的削除や、`rm -i`による確認付き削除も扱います。", estimated_minutes: 5, goals: ['ファイルの作成と削除ができる'], key_terms: ['touch', 'rm', 'rm -r'] }, + { title: 'ディレクトリを作成・削除する', body: "mkdirでディレクトリを作成し、rmdirで削除する方法を学びます。`mkdir -p`で深い階層を一度に作る方法も扱います。", estimated_minutes: 4, goals: ['ディレクトリの作成と削除ができる'], key_terms: ['mkdir', 'rmdir', 'mkdir -p'] }, + { title: 'ファイルをコピー・移動する', body: "cpでコピー、mvで移動(リネーム)する方法を学びます。ディレクトリのコピーには`cp -r`が必要なことも理解します。", estimated_minutes: 5, goals: ['ファイルのコピーと移動ができる'], key_terms: ['cp', 'mv', 'cp -r'] }, + { title: 'ファイルの中身を見る', body: "cat、less、headコマンドでファイルの内容を表示する方法を学びます。大きなファイルにはlessが便利なことを理解します。", estimated_minutes: 4, goals: ['ファイルの内容を確認できる'], key_terms: ['cat', 'less', 'head', 'tail'] }, + ] + }, + { + title: '探す、調べる', + sections: [ + { title: 'findでファイルを探す', body: "findコマンドでファイル名、種類、更新日時などの条件でファイルを検索する方法を学びます。", estimated_minutes: 5, goals: ['findコマンドで条件を指定してファイルを探せる'], key_terms: ['find', '-name', '-type'] }, + { title: 'grepでファイルの中身を検索する', body: "grepコマンドでファイル内のテキストを検索する方法を学びます。`-r`で再帰検索、`-i`で大文字小文字の無視も扱います。", estimated_minutes: 5, goals: ['grepでテキスト検索ができる'], key_terms: ['grep', '-r', '-i', '-n'] }, + { title: 'whichとwhereisでコマンドの場所を調べる', body: "which、whereisコマンドでコマンドの実体ファイルがどこにあるかを調べる方法を学びます。", estimated_minutes: 3, goals: ['コマンドのパスを調べられる'], key_terms: ['which', 'whereis', 'PATH'] }, + ] + }, + { + title: 'テキストエディタ', + sections: [ + { title: 'Vimの基本操作', body: "Vimの起動・終了、ノーマルモードとインサートモードの切り替え、保存と終了の方法を学びます。", estimated_minutes: 7, goals: ['Vimでファイルを開いて編集・保存・終了できる'], key_terms: ['Vim', 'ノーマルモード', 'インサートモード', ':wq'] }, + { title: 'Vimでの移動と編集', body: "hjklでのカーソル移動、dd(行削除)、yy(コピー)、p(ペースト)、u(アンドゥ)などの基本操作を学びます。", estimated_minutes: 5, goals: ['Vimの基本的な編集操作ができる'], key_terms: ['hjkl', 'dd', 'yy', 'p', 'u'] }, + { title: 'Vimの検索と置換', body: "/で検索、:s/old/new/gで置換する方法を学びます。nとNで検索結果を移動する方法も扱います。", estimated_minutes: 4, goals: ['Vimで検索と置換ができる'], key_terms: ['/', ':s', 'n', 'N'] }, + ] + }, + { + title: 'bashの設定', + sections: [ + { title: '環境変数', body: "環境変数の概念、export、echo $変数名での確認方法を学びます。PATHやHOMEなど重要な環境変数も扱います。", estimated_minutes: 5, goals: ['環境変数を設定・確認できる'], key_terms: ['環境変数', 'export', 'PATH', 'HOME'] }, + { title: '.bashrcと.bash_profile', body: "bashの設定ファイル(.bashrc、.bash_profile)の違いと役割を学びます。エイリアスやプロンプトのカスタマイズも扱います。", estimated_minutes: 5, goals: ['bashの設定ファイルを編集できる'], key_terms: ['.bashrc', '.bash_profile', 'alias', 'source'] }, + { title: 'PATHを通す', body: "PATHの仕組みを理解し、自分のスクリプトやツールにパスを通す方法を学びます。", estimated_minutes: 4, goals: ['PATHの仕組みを理解し、パスを通せる'], key_terms: ['PATH', 'export PATH'] }, + ] + }, + { + title: 'ファイルパーミッション、スーパーユーザ', + sections: [ + { title: 'パーミッションの読み方', body: "ls -lの出力にあるrwxの意味を学びます。所有者・グループ・その他の3つの権限区分を理解します。", estimated_minutes: 5, goals: ['パーミッション表記を読める'], key_terms: ['パーミッション', 'rwx', '所有者', 'グループ'] }, + { title: 'chmodでパーミッションを変更する', body: "chmodコマンドで数値表記(755など)と記号表記(u+xなど)でパーミッションを変更する方法を学びます。", estimated_minutes: 5, goals: ['chmodでパーミッションを変更できる'], key_terms: ['chmod', '755', '644', 'u+x'] }, + { title: 'スーパーユーザとsudo', body: "rootユーザーの概念と、sudoコマンドで一時的に管理者権限を使う方法を学びます。", estimated_minutes: 4, goals: ['sudoの使い方と注意点を理解する'], key_terms: ['root', 'sudo', 'su'] }, + ] + }, + { + title: 'プロセスとジョブ', + sections: [ + { title: 'プロセスとは', body: "プロセスの概念、psコマンドでのプロセス一覧表示、PIDの意味を学びます。", estimated_minutes: 5, goals: ['プロセスの概念を理解し、一覧を表示できる'], key_terms: ['プロセス', 'ps', 'PID', 'ps aux'] }, + { title: 'ジョブとフォアグラウンド・バックグラウンド', body: "ジョブの概念、&でのバックグラウンド実行、fg/bgでの切り替え、Ctrl+Zでの一時停止を学びます。", estimated_minutes: 5, goals: ['ジョブをフォアグラウンド・バックグラウンドで制御できる'], key_terms: ['ジョブ', 'fg', 'bg', '&', 'Ctrl+Z'] }, + { title: 'プロセスを終了する', body: "killコマンドでプロセスを終了する方法、シグナルの種類(SIGTERM、SIGKILL)を学びます。", estimated_minutes: 4, goals: ['killコマンドでプロセスを終了できる'], key_terms: ['kill', 'SIGTERM', 'SIGKILL', 'kill -9'] }, + ] + }, + { + title: '標準入出力とパイプライン', + sections: [ + { title: '標準入力・標準出力・標準エラー出力', body: "3つのストリーム(stdin, stdout, stderr)の概念を学びます。コマンドのデータの流れを理解します。", estimated_minutes: 5, goals: ['3つの標準ストリームを説明できる'], key_terms: ['標準入力', '標準出力', '標準エラー出力', 'stdin', 'stdout', 'stderr'] }, + { title: 'リダイレクト', body: ">, >>, <, 2>によるリダイレクトを学びます。コマンドの出力をファイルに保存したり、エラーを分離する方法を理解します。", estimated_minutes: 5, goals: ['リダイレクトでファイルに出力を保存できる'], key_terms: ['リダイレクト', '>', '>>', '2>'] }, + { title: 'パイプライン', body: "|(パイプ)でコマンドをつなげる方法を学びます。複数のコマンドを組み合わせてデータを加工する考え方を理解します。", estimated_minutes: 5, goals: ['パイプでコマンドを組み合わせられる'], key_terms: ['パイプ', '|', 'パイプライン'] }, + ] + }, + { + title: 'テキスト処理', + sections: [ + { title: 'sortとuniq', body: "sortで行をソートし、uniqで重複を除去する方法を学びます。パイプと組み合わせたデータ集計の基本を扱います。", estimated_minutes: 4, goals: ['sortとuniqでデータを整理できる'], key_terms: ['sort', 'uniq', 'sort -n', 'sort -r'] }, + { title: 'cutとpaste', body: "cutで列を抽出し、pasteでファイルを横に結合する方法を学びます。CSVやTSVデータの処理を扱います。", estimated_minutes: 4, goals: ['cutとpasteでデータを加工できる'], key_terms: ['cut', 'paste', '-d', '-f'] }, + { title: 'wcとtr', body: "wcで行数・単語数・文字数を数え、trで文字を変換・削除する方法を学びます。", estimated_minutes: 3, goals: ['wcとtrを使えるようになる'], key_terms: ['wc', 'tr', 'wc -l'] }, + ] + }, + { + title: '正規表現', + sections: [ + { title: '正規表現の基本', body: "正規表現とは何か、なぜ必要かを学びます。メタ文字(., *, ^, $, [ ])の基本的な使い方を理解します。", estimated_minutes: 6, goals: ['基本的なメタ文字を使った正規表現を書ける'], key_terms: ['正規表現', 'メタ文字', '.', '*', '^', '$'] }, + { title: '拡張正規表現', body: "+、?、|、( )など拡張正規表現のメタ文字を学びます。grep -Eやegrepでの使い方を扱います。", estimated_minutes: 5, goals: ['拡張正規表現を使える'], key_terms: ['拡張正規表現', '+', '?', '|', 'grep -E'] }, + { title: '正規表現の実践', body: "IPアドレス、メールアドレス、日付などの実践的なパターンマッチを学びます。grepと組み合わせた活用例を扱います。", estimated_minutes: 5, goals: ['実用的な正規表現を書ける'], key_terms: ['文字クラス', '量指定子', 'バックリファレンス'] }, + ] + }, + { + title: '高度なテキスト処理', + sections: [ + { title: 'sedの基本', body: "sedコマンドでテキストの置換・削除・挿入を行う方法を学びます。ファイルを直接編集する-iオプションも扱います。", estimated_minutes: 6, goals: ['sedで基本的なテキスト処理ができる'], key_terms: ['sed', 's/old/new/', '-i', 'd'] }, + { title: 'awkの基本', body: "awkで列指定の処理、パターンマッチ、簡単な計算を行う方法を学びます。ログ解析の基本を扱います。", estimated_minutes: 6, goals: ['awkで列を指定した処理ができる'], key_terms: ['awk', '$1', 'NR', 'NF', 'BEGIN', 'END'] }, + ] + }, + { + title: 'シェルスクリプトを書こう', + sections: [ + { title: '最初のシェルスクリプト', body: "シェルスクリプトの作成方法、シバン(#!/bin/bash)、実行権限の付与、実行方法を学びます。", estimated_minutes: 5, goals: ['シェルスクリプトを作成して実行できる'], key_terms: ['シェルスクリプト', 'シバン', '#!/bin/bash', 'chmod +x'] }, + { title: '変数と引数', body: "シェルスクリプトでの変数の使い方、コマンドライン引数($1, $2, $@, $#)の受け取り方を学びます。", estimated_minutes: 5, goals: ['変数と引数を扱える'], key_terms: ['変数', '$1', '$@', '$#', '$?'] }, + { title: 'echoとread', body: "echoで出力し、readでユーザー入力を受け取る方法を学びます。対話的なスクリプトの作り方を扱います。", estimated_minutes: 3, goals: ['入出力を扱えるスクリプトが書ける'], key_terms: ['echo', 'read', '-p'] }, + ] + }, + { + title: 'シェルスクリプトの基礎知識', + sections: [ + { title: '条件分岐(if文)', body: "if文の書き方、testコマンド([ ])、文字列比較、数値比較、ファイルテストを学びます。", estimated_minutes: 6, goals: ['if文で条件分岐が書ける'], key_terms: ['if', 'then', 'fi', 'test', '[ ]'] }, + { title: 'ループ(for, while)', body: "forループ、whileループの書き方を学びます。ファイル一覧の処理や、条件付きの繰り返し処理を扱います。", estimated_minutes: 5, goals: ['forとwhileでループ処理が書ける'], key_terms: ['for', 'while', 'do', 'done'] }, + { title: '関数', body: "シェルスクリプトでの関数の定義方法、引数の受け渡し、戻り値(return, echo)を学びます。", estimated_minutes: 4, goals: ['関数を定義して使える'], key_terms: ['function', 'return', 'local'] }, + ] + }, + { + title: 'シェルスクリプトを活用しよう', + sections: [ + { title: 'バックアップスクリプト', body: "日付付きのバックアップを自動作成するスクリプトの作り方を学びます。dateコマンドとの組み合わせを扱います。", estimated_minutes: 5, goals: ['実用的なバックアップスクリプトが書ける'], key_terms: ['date', 'cp', 'バックアップ'] }, + { title: 'ログ解析スクリプト', body: "アクセスログからIPアドレスや頻度を集計するスクリプトの作り方を学びます。awk, sort, uniqの組み合わせを扱います。", estimated_minutes: 5, goals: ['ログを集計するスクリプトが書ける'], key_terms: ['アクセスログ', '集計', 'awk'] }, + { title: 'cronで定期実行する', body: "crontabの書き方、スクリプトの定期実行設定を学びます。ログのローテーションや定期バックアップを扱います。", estimated_minutes: 4, goals: ['cronで定期実行を設定できる'], key_terms: ['cron', 'crontab', 'crontab -e'] }, + ] + }, + { + title: 'アーカイブと圧縮', + sections: [ + { title: 'tarコマンド', body: "tarでファイルをまとめる(アーカイブ)方法、展開する方法を学びます。-c, -x, -f, -vオプションを扱います。", estimated_minutes: 4, goals: ['tarでアーカイブの作成と展開ができる'], key_terms: ['tar', 'アーカイブ', '-c', '-x', '-f'] }, + { title: 'gzipとbzip2', body: "gzip、bzip2による圧縮・展開の方法を学びます。tar.gzの作り方(tar czf)も扱います。", estimated_minutes: 4, goals: ['圧縮・展開ができる'], key_terms: ['gzip', 'bzip2', 'tar.gz', 'czf'] }, + { title: 'zipとunzip', body: "zipコマンドでの圧縮、unzipでの展開を学びます。WindowsやmacOSとのファイルやり取りで使う場面を扱います。", estimated_minutes: 3, goals: ['zip形式の圧縮・展開ができる'], key_terms: ['zip', 'unzip'] }, + ] + }, + { + title: 'バージョン管理システム', + sections: [ + { title: 'バージョン管理とは', body: "バージョン管理の概念、なぜ必要か、Gitが生まれた背景を学びます。", estimated_minutes: 4, goals: ['バージョン管理の必要性を説明できる'], key_terms: ['バージョン管理', 'Git', 'リポジトリ'] }, + { title: 'Gitの基本操作', body: "git init, add, commit, status, logの基本的なワークフローを学びます。", estimated_minutes: 6, goals: ['Gitの基本操作ができる'], key_terms: ['git init', 'git add', 'git commit', 'git log'] }, + { title: 'ブランチとマージ', body: "ブランチの概念、作成・切り替え・マージの方法を学びます。コンフリクトの解決方法も扱います。", estimated_minutes: 6, goals: ['ブランチを使った開発ができる'], key_terms: ['branch', 'checkout', 'merge', 'コンフリクト'] }, + ] + }, + { + title: 'ソフトウェアパッケージ', + sections: [ + { title: 'パッケージ管理とは', body: "パッケージ管理の概念、なぜ手動インストールではなくパッケージマネージャを使うべきかを学びます。", estimated_minutes: 4, goals: ['パッケージ管理の利点を説明できる'], key_terms: ['パッケージ', 'パッケージマネージャ', '依存関係'] }, + { title: 'apt(Debian系)', body: "apt update, apt install, apt remove, apt searchなどの基本的なパッケージ操作を学びます。", estimated_minutes: 5, goals: ['aptでパッケージの管理ができる'], key_terms: ['apt', 'apt update', 'apt install', 'apt remove'] }, + { title: 'yum/dnf(Red Hat系)', body: "yum(dnf)でのパッケージ管理を学びます。aptとの対応関係を理解します。", estimated_minutes: 4, goals: ['yum/dnfの基本操作を理解する'], key_terms: ['yum', 'dnf', 'rpm'] }, + ] + }, +] + +# チャプターとセクションの作成 +chapters_data.each_with_index do |chapter_data, chapter_index| + chapter = textbook.chapters.find_or_initialize_by(title: chapter_data[:title]) + chapter.position = chapter_index + chapter.save! + + chapter_data[:sections].each_with_index do |section_data, section_index| + section = chapter.sections.find_or_initialize_by(title: section_data[:title]) + section.body = section_data[:body] + section.estimated_minutes = section_data[:estimated_minutes] + section.goals = section_data[:goals] + section.key_terms = section_data[:key_terms] + section.position = section_index + section.save! + end +end + +puts "Linuxの教科書を作成しました: #{textbook.chapters.count}章 #{Textbook::Section.joins(:chapter).where(textbook_chapters: { textbook_id: textbook.id }).count}セクション" diff --git a/test/fixtures/reading_progresses.yml b/test/fixtures/reading_progresses.yml new file mode 100644 index 00000000000..2260c680e25 --- /dev/null +++ b/test/fixtures/reading_progresses.yml @@ -0,0 +1,19 @@ +reading_progress_komagata_variables: + user_id: <%= ActiveRecord::FixtureSet.identify(:komagata) %> + textbook_section_id: <%= ActiveRecord::FixtureSet.identify(:section_variables) %> + read_ratio: 0.5 + completed: false + last_block_index: 3 + last_read_at: <%= 1.hour.ago.to_fs(:db) %> + created_at: <%= 1.day.ago.to_fs(:db) %> + updated_at: <%= 1.hour.ago.to_fs(:db) %> + +reading_progress_komagata_types: + user_id: <%= ActiveRecord::FixtureSet.identify(:komagata) %> + textbook_section_id: <%= ActiveRecord::FixtureSet.identify(:section_types) %> + read_ratio: 1.0 + completed: true + last_block_index: 10 + last_read_at: <%= 1.day.ago.to_fs(:db) %> + created_at: <%= 2.days.ago.to_fs(:db) %> + updated_at: <%= 1.day.ago.to_fs(:db) %> \ No newline at end of file diff --git a/test/fixtures/term_explanations.yml b/test/fixtures/term_explanations.yml new file mode 100644 index 00000000000..5fe3bfa3be1 --- /dev/null +++ b/test/fixtures/term_explanations.yml @@ -0,0 +1,13 @@ +term_variable: + textbook_section_id: <%= ActiveRecord::FixtureSet.identify(:section_variables) %> + term: "変数" + explanation: "変数とは、プログラムの中でデータを一時的に保存するための名前付きの入れ物です。" + created_at: <%= 1.week.ago.to_fs(:db) %> + updated_at: <%= 1.week.ago.to_fs(:db) %> + +term_method: + textbook_section_id: <%= ActiveRecord::FixtureSet.identify(:section_methods) %> + term: "メソッド" + explanation: "メソッドとは、一連の処理をまとめて名前をつけたもので、何度でも呼び出すことができます。" + created_at: <%= 1.week.ago.to_fs(:db) %> + updated_at: <%= 1.week.ago.to_fs(:db) %> \ No newline at end of file diff --git a/test/fixtures/textbook_chapters.yml b/test/fixtures/textbook_chapters.yml new file mode 100644 index 00000000000..b07e94ed472 --- /dev/null +++ b/test/fixtures/textbook_chapters.yml @@ -0,0 +1,20 @@ +chapter_ruby_basics: + textbook_id: <%= ActiveRecord::FixtureSet.identify(:textbook_ruby) %> + title: "Rubyの基本" + position: 0 + created_at: <%= 1.week.ago.to_fs(:db) %> + updated_at: <%= 1.week.ago.to_fs(:db) %> + +chapter_ruby_methods: + textbook_id: <%= ActiveRecord::FixtureSet.identify(:textbook_ruby) %> + title: "メソッドと引数" + position: 1 + created_at: <%= 1.week.ago.to_fs(:db) %> + updated_at: <%= 1.week.ago.to_fs(:db) %> + +chapter_git_basics: + textbook_id: <%= ActiveRecord::FixtureSet.identify(:textbook_git) %> + title: "Gitの基本" + position: 0 + created_at: <%= 1.week.ago.to_fs(:db) %> + updated_at: <%= 1.week.ago.to_fs(:db) %> \ No newline at end of file diff --git a/test/fixtures/textbook_sections.yml b/test/fixtures/textbook_sections.yml new file mode 100644 index 00000000000..209d3395dd9 --- /dev/null +++ b/test/fixtures/textbook_sections.yml @@ -0,0 +1,55 @@ +section_variables: + textbook_chapter_id: <%= ActiveRecord::FixtureSet.identify(:chapter_ruby_basics) %> + title: "変数とは何か" + body: "変数とは、データを一時的に保存しておくための名前付きの箱です。" + estimated_minutes: 10 + goals: + - "変数の概念を理解する" + - "変数の宣言方法を覚える" + key_terms: + - "変数" + - "代入" + position: 0 + created_at: <%= 1.week.ago.to_fs(:db) %> + updated_at: <%= 1.week.ago.to_fs(:db) %> + +section_types: + textbook_chapter_id: <%= ActiveRecord::FixtureSet.identify(:chapter_ruby_basics) %> + title: "データ型" + body: "Rubyには文字列、整数、浮動小数点数などのデータ型があります。" + estimated_minutes: 15 + goals: + - "主要なデータ型を理解する" + key_terms: + - "文字列" + - "整数" + position: 1 + created_at: <%= 1.week.ago.to_fs(:db) %> + updated_at: <%= 1.week.ago.to_fs(:db) %> + +section_methods: + textbook_chapter_id: <%= ActiveRecord::FixtureSet.identify(:chapter_ruby_methods) %> + title: "メソッドとは何か" + body: "メソッドとは、処理をまとめて名前をつけたものです。" + estimated_minutes: 20 + goals: + - "メソッドの概念を理解する" + key_terms: + - "メソッド" + - "引数" + position: 0 + created_at: <%= 1.week.ago.to_fs(:db) %> + updated_at: <%= 1.week.ago.to_fs(:db) %> + +section_git_init: + textbook_chapter_id: <%= ActiveRecord::FixtureSet.identify(:chapter_git_basics) %> + title: "リポジトリの作成" + body: "git initコマンドでリポジトリを作成します。" + estimated_minutes: 5 + goals: + - "リポジトリを作成できる" + key_terms: + - "リポジトリ" + position: 0 + created_at: <%= 1.week.ago.to_fs(:db) %> + updated_at: <%= 1.week.ago.to_fs(:db) %> \ No newline at end of file diff --git a/test/fixtures/textbooks.yml b/test/fixtures/textbooks.yml new file mode 100644 index 00000000000..801f0eef2fd --- /dev/null +++ b/test/fixtures/textbooks.yml @@ -0,0 +1,15 @@ +textbook_ruby: + title: "Ruby入門" + description: "Rubyの基本を学ぶ教科書" + published: true + practice: practice1 + +textbook_git: + title: "Git入門" + description: "Gitの基本を学ぶ教科書" + published: true + +textbook_draft: + title: "下書きの教科書" + description: "まだ公開されていない教科書" + published: false diff --git a/test/models/reading_progress_test.rb b/test/models/reading_progress_test.rb new file mode 100644 index 00000000000..416acf4f3c2 --- /dev/null +++ b/test/models/reading_progress_test.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ReadingProgressTest < ActiveSupport::TestCase + def setup + @user = users(:komagata) + @textbook = Textbook.create!(title: 'Test Textbook', description: 'Test Description') + @chapter = @textbook.chapters.create!(title: 'Test Chapter', position: 0) + @section = @chapter.sections.create!(title: 'Test Section', body: 'Test content', position: 0, estimated_minutes: 10) + end + + test 'user and section combination must be unique' do + # Create existing record + existing = ReadingProgress.create!( + user: @user, + section: @section, + read_ratio: 0.5 + ) + + duplicate = ReadingProgress.new( + user: existing.user, + section: existing.section, + read_ratio: 0.0 + ) + + assert_not duplicate.valid? + assert_includes duplicate.errors[:user_id], 'はすでに存在します' + end + + test 'read_ratio must be between 0.0 and 1.0' do + progress = ReadingProgress.new(user: @user, section: @section, read_ratio: 0.5) + + progress.read_ratio = -0.1 + assert_not progress.valid? + + progress.read_ratio = 1.1 + assert_not progress.valid? + + progress.read_ratio = 0.5 + assert progress.valid? + end + + test '#complete! sets completed and read_ratio' do + progress = ReadingProgress.create!(user: @user, section: @section, read_ratio: 0.3) + progress.complete! + progress.reload + + assert progress.completed? + assert_equal 1.0, progress.read_ratio + assert_not_nil progress.last_read_at + end +end diff --git a/test/models/term_explanation_test.rb b/test/models/term_explanation_test.rb new file mode 100644 index 00000000000..088aca877b9 --- /dev/null +++ b/test/models/term_explanation_test.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'test_helper' + +class TermExplanationTest < ActiveSupport::TestCase + def setup + @textbook = Textbook.create!(title: 'Test Textbook', description: 'Test Description') + @chapter = @textbook.chapters.create!(title: 'Test Chapter', position: 0) + @section = @chapter.sections.create!(title: 'Test Section', body: 'Test content', position: 0, estimated_minutes: 10) + end + + test 'term is required' do + explanation = TermExplanation.new( + section: @section, + term: nil, + explanation: 'test' + ) + assert_not explanation.valid? + assert_includes explanation.errors[:term], 'を入力してください' + end + + test 'explanation is required' do + explanation = TermExplanation.new( + section: @section, + term: 'test_term', + explanation: nil + ) + assert_not explanation.valid? + assert_includes explanation.errors[:explanation], 'を入力してください' + end + + test 'term must be unique per section' do + existing = TermExplanation.create!( + section: @section, + term: 'test_term', + explanation: 'original explanation' + ) + + duplicate = TermExplanation.new( + section: existing.section, + term: existing.term, + explanation: 'different explanation' + ) + assert_not duplicate.valid? + assert_includes duplicate.errors[:term], 'はすでに存在します' + end + + test 'same term can exist in different sections' do + other_section = @chapter.sections.create!( + title: 'Other Section', + body: 'Other content', + position: 1, + estimated_minutes: 5 + ) + + TermExplanation.create!( + section: @section, + term: 'shared_term', + explanation: 'explanation in first section' + ) + + explanation = TermExplanation.new( + section: other_section, + term: 'shared_term', + explanation: 'explanation in second section' + ) + assert explanation.valid? + end +end diff --git a/test/models/textbook/chapter_test.rb b/test/models/textbook/chapter_test.rb new file mode 100644 index 00000000000..91f3f69a32e --- /dev/null +++ b/test/models/textbook/chapter_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Textbook::ChapterTest < ActiveSupport::TestCase + fixtures :textbooks, :textbook_chapters, :textbook_sections + test 'title is required' do + chapter = Textbook::Chapter.new(title: nil, textbook: textbooks(:textbook_ruby), position: 0) + assert_not chapter.valid? + assert_includes chapter.errors[:title], 'を入力してください' + end + + test 'position is required' do + chapter = Textbook::Chapter.new(title: 'Test', textbook: textbooks(:textbook_ruby), position: nil) + assert_not chapter.valid? + assert_includes chapter.errors[:position], 'を入力してください' + end + + test '#sections returns associated sections' do + chapter = textbook_chapters(:chapter_ruby_basics) + assert_equal 2, chapter.sections.size + end + + test 'default scope orders by position' do + textbook = textbooks(:textbook_ruby) + chapters = textbook.chapters + assert_equal chapters.map(&:position), chapters.map(&:position).sort + end + + test 'destroying chapter destroys sections' do + chapter = textbook_chapters(:chapter_ruby_basics) + assert_difference 'Textbook::Section.count', -chapter.sections.count do + chapter.destroy! + end + end +end diff --git a/test/models/textbook/section_test.rb b/test/models/textbook/section_test.rb new file mode 100644 index 00000000000..408c02ea3f6 --- /dev/null +++ b/test/models/textbook/section_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Textbook::SectionTest < ActiveSupport::TestCase + fixtures :textbooks, :textbook_chapters, :textbook_sections, :reading_progresses, :term_explanations, :users + test 'title is required' do + section = Textbook::Section.new( + title: nil, + body: 'content', + chapter: textbook_chapters(:chapter_ruby_basics), + position: 0 + ) + assert_not section.valid? + assert_includes section.errors[:title], 'を入力してください' + end + + test 'body is required' do + section = Textbook::Section.new( + title: 'Test', + body: nil, + chapter: textbook_chapters(:chapter_ruby_basics), + position: 0 + ) + assert_not section.valid? + assert_includes section.errors[:body], 'を入力してください' + end + + test 'position is required' do + section = Textbook::Section.new( + title: 'Test', + body: 'content', + chapter: textbook_chapters(:chapter_ruby_basics), + position: nil + ) + assert_not section.valid? + assert_includes section.errors[:position], 'を入力してください' + end + + test 'default scope orders by position' do + chapter = textbook_chapters(:chapter_ruby_basics) + Textbook::Section.create!(title: 'Z Last', body: 'content', chapter: chapter, position: 99) + Textbook::Section.create!(title: 'A First', body: 'content', chapter: chapter, position: 0) + sections = chapter.sections.reload + positions = sections.map(&:position) + assert_equal positions.sort, positions + end + + test '#reading_progresses returns associated progresses' do + section = textbook_sections(:section_variables) + assert_includes section.reading_progresses, reading_progresses(:reading_progress_komagata_variables) + end + + test '#term_explanations returns associated explanations' do + section = textbook_sections(:section_variables) + assert_includes section.term_explanations, term_explanations(:term_variable) + end + + test 'goals and key_terms are arrays' do + section = textbook_sections(:section_variables) + assert_kind_of Array, section.goals + assert_kind_of Array, section.key_terms + end +end diff --git a/test/models/textbook_test.rb b/test/models/textbook_test.rb new file mode 100644 index 00000000000..7a5d1f8d963 --- /dev/null +++ b/test/models/textbook_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'test_helper' + +class TextbookTest < ActiveSupport::TestCase + test '#published scope returns only published textbooks' do + published = Textbook.published + assert published.all?(&:published?) + assert_not_includes published, textbooks(:textbook_draft) + end + + test 'title is required' do + textbook = Textbook.new(title: nil) + assert_not textbook.valid? + assert_includes textbook.errors[:title], 'を入力してください' + end + + test '#chapters returns associated chapters' do + textbook = textbooks(:textbook_ruby) + assert_equal 2, textbook.chapters.size + end + + test '#practice is optional' do + textbook = textbooks(:textbook_git) + assert_nil textbook.practice + assert textbook.valid? + end + + test 'destroying textbook destroys chapters' do + textbook = textbooks(:textbook_ruby) + assert_difference 'Textbook::Chapter.count', -textbook.chapters.count do + textbook.destroy! + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 552cc29b97b..d7edb1ea815 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -37,6 +37,10 @@ class ActiveSupport::TestCase # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all + set_fixture_class textbook_chapters: Textbook::Chapter, + textbook_sections: Textbook::Section, + reading_progresses: ReadingProgress, + term_explanations: TermExplanation setup do ActiveStorage::Current.url_options = { protocol: 'http', host: 'localhost', port: '3000' }